diff --git a/CLAUDE.md b/CLAUDE.md index 1b77f719..4eefac65 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,10 +5,10 @@ profile-aware parser, and TypeScript / JavaScript / TSX / JSX via tree-sitter into SQLite + FTS5 and exposes graph queries to MCP clients (Claude Code, Cursor) over stdio. -## Onboarding CLI: `init`, `doctor`, `demo` +## Onboarding CLI: `init`, `doctor`, `demo`, `status` -Three subcommands handle first-run setup. `sourcegraph-mcp init` is interactive -by default; flag-driven (`--yes`) for CI. It detects environment, picks MCP +Four subcommands handle first-run setup and runtime observation. `sourcegraph-mcp init` +is interactive by default; flag-driven (`--yes`) for CI. It detects environment, picks MCP clients (project-scope by default, user-scope opt-in via `--user-`), and writes per-client config files with merge-by-name semantics — first-class support for Claude Code, **GitHub Copilot** (distinct `servers`/`type` schema in @@ -16,7 +16,25 @@ support for Claude Code, **GitHub Copilot** (distinct `servers`/`type` schema in read-only environment diagnostic with `pass | warn | fail` exit-code semantics. `demo` runs four canned operations (`ping`, `graph_stats`, `search_symbols`, `find_definition`) against the active scope and prints leaf-stamped markdown — -the same shape an agent sees, available without an agent loop. +the same shape an agent sees, available without an agent loop. `status` is the +first stop for "what's the system doing?" questions: aggregates Environment, +Scopes, Clients, Embeddings, and Recent activity into a single phase-headed +snapshot, with the same exit-code semantics as `doctor` and a stable +snake_case `--json` shape for scripts and the live dashboard. + +## Operator dashboard: bare `sourcegraph-mcp` + +Bare `sourcegraph-mcp` (no positional args, no `--help`) drops into the +Spectre.Console-backed live operator dashboard under a tty, and falls back to +the static `status` snapshot when stdin is redirected. The dashboard is the +human-facing console — distinct from the agent-facing MCP server you launch +with `serve`. Three action depths: read-only navigation, in-place mutations +(reindex / rebuild / wire / unwire / embeddings pull / verify) that delegate +to the same code paths the headless subcommands use, and guided actions that +suspend the Live region to run `init` / `demo` / `$PAGER` / `$EDITOR` as +subprocesses with inherited streams. Destructive in-place actions (`R` +rebuild, `u` unwire) gate behind a Spectre `[y/N]` modal. See README's +"Dashboard" subsection for the key reference. ## Tool-usage guidance ships with the server @@ -38,6 +56,18 @@ head, and per-tool `Title`/`Description`) with `--no-leaf` or `SOURCEGRAPH_NO_LEAF=1` if your terminal doesn't render emoji well or you prefer unbranded output. +The leaf is strictly a brand mark — it appears on the banner / title bar +and as the prefix on MCP tool responses (controlled by `LeafFormatter`). +Per-row state across `init`, `status`, and the `dashboard` uses a +**colored-dot vocabulary** instead: `●` = on / passed / wrote / +unchanged / indexed, `○` = off / not selected, `◐` = soft warning, +`✗` = hard skip / conflict / failed, `−` = unsupported / N/A. The +dashboard adds one more glyph in the leading column of selectable rows: +`◉` (brand-green, filled + outline) marks the selected row; `○` marks +non-selected rows. Under `--no-leaf` or `SOURCEGRAPH_NO_LEAF=1` the +substitutions are `[x] / [ ] / [!] / [X] / [-]` for status and `[>] / [ ]` +for selection, preserving column alignment. + Built-in `find_*` / `list_*` / `search_*` tools ship typed `structuredContent` (snake-case fields, `outputSchema` declared on `tools/list`) alongside the prose, so agents chaining tool calls can consume `result.structuredContent` diff --git a/Directory.Packages.props b/Directory.Packages.props index e32a4612..233820b6 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -31,6 +31,12 @@ + + + + + + +### Modified Capabilities + +- `cli`: One new requirement (`dashboard subcommand`) defining the new top-level verb, the Spectre dependency, the five sections, the three action depths and their key bindings, and the refresh model. One new requirement (`Bare command dispatch`) defining the tty-vs-redirected behaviour of `sourcegraph-mcp` invoked with no positional args. One new requirement (`Dashboard action confirmation`) defining which actions require modal confirmation. The existing `Subcommand routing` requirement is left unchanged in this delta; the implementation's argument parser already tolerates bare invocation (today it falls through to help), and the new `Bare command dispatch` requirement layers the tty-aware behaviour on top without conflicting. + +## Impact + +- **Code**: New project-internal folder `src/DevBitsLab.Mcp.SourceGraph.Server/Dashboard/` with: + - `DashboardCli.cs` — subcommand entry point and the main loop. + - `DashboardLayout.cs` — Spectre.Console `Layout` composition for the five sections plus the status bar. + - `DashboardRenderer.cs` — per-section `IRenderable` builders consuming `DashboardSnapshot`. + - `DashboardActions.cs` — the action dispatcher; methods for each `[w] [u] [p] [v] [r] [R] [i] [d] [e]` key. + - `DashboardKeyMap.cs` — central key-binding table (arrow + letter accelerators today; vim aliases later if requested). + - `ConfirmModal.cs` — modal prompt helper. + - `FreshnessSource.cs` — wraps `SnapshotBuilder.BuildAsync` plus the FileSystemWatcher; exposes an `IObservable` (or a callback subscription) that the main loop subscribes to. +- `Program.cs` — bare-command dispatch: detect `Console.IsInputRedirected` and route accordingly. No new flag added; the existing argument-parser fall-through is augmented. +- `CommandLine.cs` — register the `dashboard` subcommand verb; help text addition. +- `DevBitsLab.Mcp.SourceGraph.Server.csproj` — add the `Spectre.Console` NuGet reference (pinned). +- **Spec**: One delta on `cli` (three ADDED requirements). +- **Tests**: A new test project / suite `tests/Server.Tests/Dashboard/` covers (a) `DashboardKeyMap` invariants (every documented key resolves to exactly one action), (b) `DashboardRenderer` snapshot rendering (each section's `IRenderable` matches a Spectre golden file under emoji and no-leaf modes), (c) `DashboardActions` happy paths via mocked subcommand callbacks, (d) `FreshnessSource` event coalescing under burst JSONL writes. Live TUI behavior is hard to test end-to-end; we test the components, not the keypress-to-pixel pipeline. Manual smoke-test plan added under `notes/manual-smoke-test.md` in the change directory. +- **Public API / dependencies**: New NuGet dependency `Spectre.Console` (MIT, ~1 MB published size, AOT-friendly). No MCP wire format changes. No schema migrations. The existing CLI subcommands continue to work unchanged — the dashboard is purely additive. +- **Documentation**: `README.md` gains a Dashboard section under "Command-line interface" describing the key bindings and confirming actions. The Quickstart line `sourcegraph-mcp` (bare) is added as an explicit recommended verb. `CLAUDE.md` adds a one-liner explaining what bare `sourcegraph-mcp` does and that the dashboard is the human-facing console (distinct from the agent-facing MCP server). diff --git a/openspec/changes/archive/2026-05-11-add-operator-dashboard/specs/cli/spec.md b/openspec/changes/archive/2026-05-11-add-operator-dashboard/specs/cli/spec.md new file mode 100644 index 00000000..3a123e33 --- /dev/null +++ b/openspec/changes/archive/2026-05-11-add-operator-dashboard/specs/cli/spec.md @@ -0,0 +1,103 @@ +## ADDED Requirements + +### Requirement: dashboard subcommand +The CLI SHALL accept `sourcegraph-mcp dashboard` that renders a full-screen Spectre.Console-backed live operator console consuming the `DashboardSnapshot` defined in the `status snapshot data sources` requirement. The dashboard SHALL display five sections — `Environment`, `Scopes`, `Clients`, `Embeddings`, `Recent activity` — backed by the same snapshot the `status` subcommand renders, and SHALL refresh that snapshot on a polling + watcher hybrid (see below). + +The subcommand SHALL accept the following flags: + +- `--root ` — repository root (default CWD); rendered in the dashboard header bar using the relative / `~/`-substituted form (matching `polish-init-onboarding`'s path-rendering rule). +- `--no-color` — disable ANSI colour codes; composes with the `NO_COLOR` env var Spectre honours natively. +- `--no-leaf` / `SOURCEGRAPH_NO_LEAF=1` — substitute the ASCII state-glyph fallback (matches `polish-init-onboarding` and `add-operator-status` conventions). + +The dashboard SHALL refresh its snapshot using a hybrid model: + +1. **Polling**: a timer rebuilds the snapshot at most once per `1000 ms`. +2. **Watcher**: `FileSystemWatcher` instances on `/.sourcegraph/usage.jsonl` and `/.sourcegraph/heals.jsonl` trigger a debounced rebuild `100 ms` after the most recent write event. + +The two triggers SHALL coalesce: any rebuild request that fires within `1000 ms` of the previous rebuild SHALL be dropped (the most recent snapshot satisfies it). Snapshot rebuilds SHALL run on the thread-pool; rendering SHALL be confined to the UI thread. + +The dashboard SHALL declare minimum terminal dimensions of `80 columns × 24 rows`. When the current terminal is smaller at startup, the dashboard SHALL print `terminal too small (need ≥80×24)` to stderr and exit with code `2`. The dashboard SHALL respond to terminal resize events by re-rendering the layout against the new dimensions. + +The dashboard SHALL exit cleanly with code `0` on `q`, `Q`, or `Ctrl+C`; on uncaught exception, exit `1` after restoring the terminal cursor. + +#### Scenario: Successful launch renders the five sections +- **WHEN** `sourcegraph-mcp dashboard` is invoked under a `100 × 40` terminal in a healthy repo +- **THEN** the first frame contains a Spectre layout with five labelled sections — `Environment`, `Scopes`, `Clients`, `Embeddings`, `Recent activity` — each rendered with phase-internal rows using the state-glyph language; the header bar contains the dashboard banner with `🌿 SourceGraph` (or `[x] SourceGraph` under `--no-leaf`), the version, and the relative-path-rendered `--root`; the footer bar contains the key-binding hints `[q] quit [?] help [↑↓] nav [Enter] details` + +#### Scenario: Recent activity surfaces within 200 ms of a JSONL write +- **WHEN** the dashboard is running and a concurrent `serve` process appends one line to `usage.jsonl` +- **THEN** the `Recent activity` section re-renders to include the new entry within 200 ms of the write (100 ms watcher debounce + render latency); the snapshot's poll-tick rebuild SHALL NOT also fire for that event (the coalescing rule prevents the duplicate rebuild) + +#### Scenario: Polling rebuild fires when no JSONL activity +- **WHEN** the dashboard runs for 5 seconds with no JSONL writes +- **THEN** the snapshot rebuilds at most 5 times (once per second); each rebuild's wall-clock time is recorded; no rebuild's duration exceeds 100 ms under the healthy-repo fixture + +#### Scenario: Tiny terminal refuses to render +- **WHEN** `sourcegraph-mcp dashboard` is invoked under a `60 × 20` terminal +- **THEN** stderr contains the line `terminal too small (need ≥80×24)`; the process exits with code `2` without entering Spectre's `Live` mode + +#### Scenario: q exits cleanly +- **WHEN** the dashboard is running and the user presses `q` +- **THEN** the Spectre `Live` block exits; the cursor is restored to visible state; no ANSI escape sequence remains in the terminal's output buffer; the process exits with code `0` + +### Requirement: Bare command dispatch +The CLI SHALL accept `sourcegraph-mcp` invoked with no positional arguments and no `--help` / `-h` flag, and SHALL dispatch to either the `dashboard` subcommand or the `status` subcommand based on the stdin disposition: when stdin is a tty (`Console.IsInputRedirected == false` AND `Environment.UserInteractive == true`), the dispatch target SHALL be `dashboard`; otherwise the dispatch target SHALL be `status`. + +Flags passed alongside the bare invocation (e.g. `sourcegraph-mcp --root /work/Repo`) SHALL be propagated to the dispatched subcommand. + +The behaviour of `sourcegraph-mcp --help` / `sourcegraph-mcp -h` SHALL be unchanged from today: print the help text and exit `0`. + +#### Scenario: Bare invocation under a tty enters the dashboard +- **WHEN** a user runs `sourcegraph-mcp` (no positional args, no `--help`) in an interactive terminal +- **THEN** the dashboard launches as if `sourcegraph-mcp dashboard` had been invoked; on `q` the process exits `0` + +#### Scenario: Bare invocation under a pipe runs status +- **WHEN** `sourcegraph-mcp | cat` is invoked +- **THEN** the static `status` snapshot is printed to stdout (no ANSI control codes for live rendering); the process exits with the snapshot's exit code (0, 1, or 2); the dashboard is NOT entered + +#### Scenario: --help still prints help +- **WHEN** `sourcegraph-mcp --help` is invoked (in either tty or redirected stdin) +- **THEN** stdout contains the help text starting with `sourcegraph-mcp — live code source graph MCP server for .NET`; the dashboard is NOT entered; the process exits `0` + +#### Scenario: Bare invocation with --root propagates the flag +- **WHEN** `sourcegraph-mcp --root /some/other/repo` is invoked under a tty +- **THEN** the dashboard launches against `/some/other/repo` (the header bar names that root); the `--root` flag is passed through to the snapshot builder + +### Requirement: Dashboard action confirmation +The `dashboard` subcommand SHALL gate every state-mutating action that is irreversible or that affects user-visible state outside `/.sourcegraph/` behind a Spectre confirmation prompt. The prompt SHALL render as a modal overlay with the text ` ? [y/N]`, default `No`, dismissable with `Esc` or `n` (both treated as `No`). + +Specifically, the following in-place actions SHALL gate behind the confirm modal: + +- `[R]` rebuild selected scope (archives the current scope DB before re-indexing). +- `[u]` unwire selected client (removes the `mcpServers.sourcegraph` entry from the target config file). + +The following in-place actions SHALL NOT gate (they are idempotent / additive): + +- `[r]` reindex selected scope (`reconcile_drift`). +- `[w]` wire missing client (calls the InitCli writer; results in `Insert` or `NoOpAlreadyMatches`). +- `[p]` embeddings pull (idempotent against a populated cache). +- `[v]` embeddings verify (read-only). + +Read-only actions (`↑↓`, `Enter`, `Esc`, `q`, `?`, `s`) SHALL NOT gate. (Tab was unbound by the later home/detail-view rewrite; the original proposal listed it here.) + +Guided actions (`[i]`, `[d]`, `[l]`, `[e]`) SHALL NOT gate at the dashboard layer — the guided subcommand carries its own interaction model. + +#### Scenario: `[u]` unwire prompts for confirmation +- **WHEN** the user navigates to a wired client row, presses `u`, and the confirm prompt appears +- **THEN** the prompt renders with the text `unwire claude-code? [y/N]` (or equivalent for the selected client); pressing `Esc` dismisses the prompt with no file modification; the `.mcp.json` file's mtime is unchanged; the dashboard returns to the Clients section with no error + +#### Scenario: `[u]` unwire proceeds on explicit yes +- **WHEN** the user presses `u` on a wired `claude-code` row and answers `y` to the confirm prompt +- **THEN** the `mcpServers.sourcegraph` entry is removed from `/.mcp.json` (other entries preserved); the snapshot rebuilds; the Clients section's `claude-code` row state-glyph flips from `🌿` to `·` (or `[x]` to `[ ]` under `--no-leaf`) + +#### Scenario: `[r]` reindex does NOT prompt +- **WHEN** the user navigates to a scope row and presses `r` +- **THEN** `reconcile_drift` runs immediately for the selected scope; no confirm prompt appears; the scope's state glyph optionally flips to a transient `indexing` indicator while the operation runs; on completion the row re-renders with the updated state + +#### Scenario: `[R]` rebuild prompts for confirmation +- **WHEN** the user presses `R` on a scope row +- **THEN** the confirm prompt renders with the text `rebuild backend? [y/N]` (or the slug of the selected scope); `n` or `Esc` dismisses with no action; `y` triggers `repair_scope mode=rebuild` for the selected scope + +#### Scenario: `[v]` embeddings verify does NOT prompt +- **WHEN** the user presses `v` +- **THEN** `embeddings verify` runs against the active model immediately; no confirm prompt appears; the Embeddings section's `verified` indicator updates on completion (or surfaces a `⚠`/`✗` state glyph if a SHA mismatch is detected) diff --git a/openspec/changes/archive/2026-05-11-add-operator-dashboard/tasks.md b/openspec/changes/archive/2026-05-11-add-operator-dashboard/tasks.md new file mode 100644 index 00000000..7eb827e6 --- /dev/null +++ b/openspec/changes/archive/2026-05-11-add-operator-dashboard/tasks.md @@ -0,0 +1,69 @@ +## 1. Foundation: Spectre.Console + project plumbing + +- [x] 1.1 Add `Spectre.Console` NuGet reference (pinned to a specific 0.49.x patch) to `src/DevBitsLab.Mcp.SourceGraph.Server/DevBitsLab.Mcp.SourceGraph.Server.csproj`. +- [x] 1.2 Verify the package's AOT-trim warnings are zero (the current build is not AOT but we want the door open); run `dotnet build /warnaserror` after add. +- [x] 1.3 Create folder `src/DevBitsLab.Mcp.SourceGraph.Server/Dashboard/` and add an `Internal` namespace marker file (otherwise auto-imports trip up). + +## 2. Freshness source + +- [x] 2.1 Add `Dashboard/FreshnessSource.cs` implementing the hybrid poll-plus-watcher model per design Decision 5. Public surface: ctor takes `(string root, SnapshotBuilderOptions options)`; `event Action SnapshotChanged`; `Latest` property; `Start()` / `Stop()`; `IDisposable`. +- [x] 2.2 Internal: `Timer` set to 1 s tick; two `FileSystemWatcher` instances (one per JSONL log) with a 100 ms debounce timer; a `lastRebuild` timestamp and a coalescing predicate. +- [x] 2.3 Snapshot rebuilds run via `Task.Run(() => SnapshotBuilder.BuildAsync(...))` (thread-pool); on completion, marshal back to the UI thread via a `BlockingCollection` queue the main loop drains. +- [x] 2.4 Unit-test the coalescing: simulate 10 rapid watcher events in 200 ms; assert exactly one rebuild executes; simulate a 5 s idle then one watcher event; assert one rebuild executes within 100 ms. +- [x] 2.5 Stress-test: run 60 ticks at 1 s intervals against a fixture repo; assert no FileSystemWatcher leak (close count == open count); assert SQLite connection pool count stable. + +## 3. Layout, renderers, and key-map + +- [x] 3.1 Add `Dashboard/DashboardLayout.cs` exposing `static Layout Build(int width, int height)` returning a Spectre `Layout` with the five named sections plus header and footer regions. Refuse to build when `width < 80 || height < 24` (return `Layout` with a single `terminal too small` panel). +- [x] 3.2 Add `Dashboard/DashboardRenderer.cs` with one `IRenderable BuildSection(DashboardSnapshot snapshot)` per section. Reuse the `StateGlyph` helper from `polish-init-onboarding` for the glyph language; reuse the path-display rule for the header `--root` rendering. +- [x] 3.3 Add `Dashboard/DashboardKeyMap.cs` exposing the central key-to-action table per design Decision 3. Public surface: `bool TryResolve(ConsoleKeyInfo key, out DashboardAction action)`. Aliases (`j` == `↓`, `k` == `↑`) are explicit table entries. +- [x] 3.4 Unit-test `DashboardKeyMap`: assert every key in the documented table resolves to exactly one action; assert unmapped keys return `false` without throwing. +- [x] 3.5 Snapshot-render tests: feed a fixture `DashboardSnapshot` into each `Build*Section` method; capture the rendered Spectre string (via `AnsiConsole`'s recording API); compare against a golden file. Maintain emoji + `--no-leaf` golden pairs per section. + +## 4. Confirm modal + +- [x] 4.1 Add `Dashboard/ConfirmModal.cs` exposing `static bool Prompt(string verb, string target)` that renders a Spectre `ConfirmationPrompt` titled ` ?`, defaults `false`, returns the user's `y`/`n` choice. +- [x] 4.2 Unit-test the modal: drive the prompt with mocked `IAnsiConsole.Input` returning each of `y`, `n`, `Esc`, `Enter`; assert returned boolean. + +## 5. Action dispatcher + +- [x] 5.1 Add `Dashboard/DashboardActions.cs` exposing methods per `DashboardAction` enum value. Each method receives a `DashboardActionContext` (selected section, selected row, current snapshot, `IAnsiConsole` for prompts, an `IFreshnessSource` to trigger explicit refreshes). +- [x] 5.2 Implement read-only navigation methods (`MoveSelection`, `OpenDetail`, `Quit`, `ToggleHelp`). +- [x] 5.3 Implement in-place actions, each delegating to the same code paths the headless CLI subcommands use: + - `ReindexScope` → `ReconcileDriftCommand.Run`. + - `RebuildScope` → confirm modal, then `RepairScopeCommand.Run(mode: "rebuild")`. + - `WireClient` → calls into the polished InitCli writer flow (single client, no picker). + - `UnwireClient` → confirm modal, then writer-level "remove sourcegraph entry" code path. + - `EmbeddingsPull` → `EmbeddingsManager.PullAsync`. + - `EmbeddingsVerify` → `EmbeddingsManager.VerifyAsync`. +- [x] 5.4 Implement guided actions per design Decision 7 (suspend → spawn → resume): + - `InitGuided` → spawn `sourcegraph-mcp init` as subprocess with inherited streams. + - `DemoGuided` → spawn `sourcegraph-mcp demo --scope `. + - `OpenLogInPager` → spawn `$PAGER` (default `less -R`) with the relevant JSONL file as arg. + - `OpenConfigInEditor` → spawn `$EDITOR` (default `vi`) against `.sourcegraph.json`. +- [x] 5.5 Watchdog: any in-place action that exceeds 30 seconds raises a cancellation token; the dashboard surfaces the failure via the status bar with retry / quit options. +- [x] 5.6 Tests cover each action with mocked dependencies; the long-running watchdog branch exercises a synthetic action that sleeps 35 s. + +## 6. Main loop + bare-command dispatch + +- [x] 6.1 Add `Dashboard/DashboardCli.cs` with `public static async Task RunAsync(CommandLine cli, CancellationToken token)`. Top-level flow: `using var freshness = new FreshnessSource(...); freshness.Start(); AnsiConsole.Live(layout).StartAsync(ctx => RunLoopAsync(ctx, freshness, token))`. +- [x] 6.2 The render loop reads keys via `Console.ReadKey(intercept: true)` in a non-blocking poll, dispatches via `DashboardKeyMap.TryResolve` plus `DashboardActions`, and triggers a Spectre `Refresh()` on each rebuild event from `FreshnessSource.SnapshotChanged`. +- [x] 6.3 In `Program.Main`, before `CommandLine.Parse`, add the bare-command dispatch per design Decision 2: if `args.Length == 0` AND no `--help`/`-h` present, rewrite `args` to either `["dashboard"]` or `["status"]` based on `Console.IsInputRedirected || !Environment.UserInteractive`. +- [x] 6.4 Register the `dashboard` verb in `CommandLine.cs` and route to `DashboardCli.RunAsync`. Add the new flags `--root` (already supported), `--no-color`, and the existing `--no-leaf` plumbing. +- [x] 6.5 Update `HelpText` in `CommandLine.cs` to document `sourcegraph-mcp dashboard` and the bare-invocation behaviour. + +## 7. Tests + +- [x] 7.1 `DashboardKeyMapTests`: assert documented bindings, alias resolution, unmapped key behaviour. +- [x] 7.2 `DashboardRendererTests`: golden-file comparisons for each section under emoji and `--no-leaf` modes, against a fixture `DashboardSnapshot` covering healthy / partial / degraded states. +- [x] 7.3 `FreshnessSourceTests`: coalescing, FD leak, snapshot lifecycle. +- [x] 7.4 `BareCommandDispatchTests`: simulate `Console.IsInputRedirected` true / false; assert routing to `dashboard` vs `status`; assert `--help` always shows help. +- [x] 7.5 `DashboardActionsTests`: confirm-modal gates on `[u]` and `[R]`; non-confirm on `[r]` `[w]` `[p]` `[v]`; happy-path completion of each in-place action via mocked dependencies. +- [x] 7.6 Manual smoke test: write `openspec/changes/add-operator-dashboard/notes/manual-smoke-test.md` enumerating the human-verification steps (launch under tty, resize the terminal, fire each key binding, confirm + cancel each modal, exit cleanly). Live TUI behavior is hard to test end-to-end; this captures the steps a maintainer runs before merge. + +## 8. Documentation + +- [x] 8.1 Add a `## Dashboard` subsection under the README's "Command-line interface" section. Include a full key-binding reference (matching design Decision 3), the bare-command behaviour, and the minimum terminal dimensions. +- [x] 8.2 Update the Quickstart in `README.md` to add `sourcegraph-mcp` (bare) as a recommended first verb alongside `sourcegraph-mcp init` and `sourcegraph-mcp demo`. Note that bare invocation drops into the dashboard for humans, and into `status` for scripts. +- [x] 8.3 Add a one-liner in `CLAUDE.md` noting that bare `sourcegraph-mcp` is the operator console (distinct from the agent-facing MCP server). +- [x] 8.4 Run `openspec validate add-operator-dashboard --strict`; fix any structural issues. diff --git a/openspec/changes/archive/2026-05-11-add-operator-status/.openspec.yaml b/openspec/changes/archive/2026-05-11-add-operator-status/.openspec.yaml new file mode 100644 index 00000000..ac20efa6 --- /dev/null +++ b/openspec/changes/archive/2026-05-11-add-operator-status/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-10 diff --git a/openspec/changes/archive/2026-05-11-add-operator-status/README.md b/openspec/changes/archive/2026-05-11-add-operator-status/README.md new file mode 100644 index 00000000..c73bc4da --- /dev/null +++ b/openspec/changes/archive/2026-05-11-add-operator-status/README.md @@ -0,0 +1,3 @@ +# add-operator-status + +Introduce a DashboardSnapshot record aggregating env/scopes/clients/embeddings/recent activity, and ship sourcegraph-mcp status as the polished static rendering (subsuming doctor's data) diff --git a/openspec/changes/archive/2026-05-11-add-operator-status/design.md b/openspec/changes/archive/2026-05-11-add-operator-status/design.md new file mode 100644 index 00000000..5053944c --- /dev/null +++ b/openspec/changes/archive/2026-05-11-add-operator-status/design.md @@ -0,0 +1,174 @@ +## Context + +The state surfaces that the operator console needs already exist, but each ships through a different subcommand and serialisation: + +- **Environment**: `OnboardingDetector.DetectAsync` returns `OnboardingDetectionResult`. Used by `doctor`, `init`. Lives at `Cli/OnboardingDetector.cs`. +- **Scopes**: `_meta.db` (the scope registry) plus per-scope SQLite DBs at `.sourcegraph/scopes/.db`. Read via `IScopeRegistry` today; `scopes list` and `list_scopes` already query this. +- **Clients**: `OnboardingDetector.ClientConfigsDetected` — same call as Environment. +- **Embeddings**: `EmbeddingsManager` exposes the active model and per-file manifest; the `embeddings status` CLI verb already prints this. +- **Recent activity**: `.sourcegraph/usage.jsonl` and `.sourcegraph/heals.jsonl` are appended to by `ToolMetrics` and the heal pipeline respectively. Today they're tailable from a shell; no in-process reader exists. + +None of these surfaces interlocks; each subcommand re-invokes its own detector. The data-flow looks like: + +``` + doctor ──► OnboardingDetector ──► console + scopes list ──► _meta.db ──► console + embeddings ──► EmbeddingsManager ──► console + usage_stats ──► usage.jsonl tail ──► MCP tool response (not CLI) +``` + +This change introduces a single aggregator (`SnapshotBuilder`) that produces one immutable `DashboardSnapshot` record from those five sources, plus two renderers that consume the snapshot: `StatusRenderer` (human-readable, phase-headed) and a JSON serializer that emits the snake_case `--json` shape. + +``` + sources snapshot renderers + ───────────────────── ──────────────────────── ──────────────────── + OnboardingDetector ─┐ ┌─► StatusRenderer (prose) + _meta.db + scope DBs─┼─► DashboardSnapshot ────┼─► JSON serializer + EmbeddingsManager ─┤ └─► (future: dashboard + usage.jsonl tail ─┤ renderers in + heals.jsonl tail ─┘ `add-operator-dashboard`) +``` + +The snapshot is read-only — `SnapshotBuilder.BuildAsync` opens read-only SQLite handles, never holds them past the build, and tolerates a concurrent `serve` process writing. Stale-read tolerance: scope counts are read with `SELECT COUNT(*)` (point-in-time consistent under SQLite's MVCC); the JSONL tails read the last N bytes and parse line-by-line, dropping the last incomplete line. + +## Goals / Non-Goals + +**Goals:** +- One immutable record carries every piece of state the operator console needs. Adding a new field is appending one record property + one builder query + one renderer line. +- `status` ships as the polished static rendering, replacing what users would otherwise piece together from four subcommands. +- `--json` emits a stable, snake_case, append-only-compatible shape that scripts and the live dashboard both consume. +- `doctor`'s observable behaviour does not change: same checks, same wording, same exit codes, same `--json` shape. Internally it pulls from the snapshot. +- The snapshot reads from disk only; no in-process MCP server contact required. `status` works whether `serve` is running or not. + +**Non-Goals:** +- Reactive / push-based updates. The live dashboard (`add-operator-dashboard`) layers a watcher on top; `status --watch` is a poll-based convenience. +- Aggregating across multiple repos. Snapshot is single-`--root`. +- A unified "explain this finding" affordance. Drill-down narrative lives in the existing subcommands (`scopes info `, `embeddings verify`). `status` is breadth, not depth. +- Mutating actions. Even `--watch` is read-only. Actions belong to the dashboard (Change 3). +- Replacing `usage_stats` MCP tool. `status` is CLI-only; `usage_stats` remains the agent-facing surface (and is unchanged). + +## Decisions + +### Decision 1 — `DashboardSnapshot` as an immutable C# record with snake_case JSON + +```csharp +public sealed record DashboardSnapshot( + EnvironmentSurface Environment, + IReadOnlyList Scopes, + IReadOnlyList Clients, + EmbeddingsSurface Embeddings, + IReadOnlyList RecentActivity, + DateTimeOffset BuiltAt); + +public sealed record ScopeRow( + string Name, + string Status, // "ok" | "partial" | "degraded" | "indexing" + long SymbolCount, + long ReferenceCount, + DateTimeOffset? LastIndexedAt, + IReadOnlyList FailedProjects, + IReadOnlyList FailedFiles, + bool Isolated); + +public sealed record ClientRow( + string Slug, // "claude-code" | "copilot" | ... + string Scope, // "project" | "user" + string Path, + bool Exists, + bool ContainsSourcegraphEntry); + +public sealed record ActivityEntry( + DateTimeOffset Ts, + string Kind, // "tool_call" | "heal" | "boot_reconcile" | ... + string? Scope, + bool Ok, + int Ms, + string? Detail); +``` + +The PascalCase record properties serialize to snake_case JSON via `JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower }`. Built-in System.Text.Json supports this in .NET 8+. + +**Alternatives considered:** +- A class with mutable fields. Records are the idiomatic choice in modern C#; immutability simplifies the aggregator and renderer contracts. +- A union/tagged variant per surface. Overkill for five fixed surfaces. +- Inheriting from a base `Surface` interface. No polymorphism is needed; concrete records are simpler. + +### Decision 2 — `SnapshotBuilder.BuildAsync` reads from disk, not from a live `serve` process + +The snapshot is built by opening read-only SQLite handles on `_meta.db` and each per-scope DB, calling `OnboardingDetector.DetectAsync` (which is already pure-detection, no in-process state), reading the `EmbeddingsManager.GetManifestStatus()` data, and tailing the last ~512 KB of each JSONL log (configurable via `--activity-bytes`, default 524288). + +This means `status` works in three modes without code changes: +1. **No `serve` running** — opens its own read-only handles. Standard case. +2. **`serve` running in same repo** — SQLite's WAL mode allows concurrent readers. The snapshot may see writes from the live indexer mid-flight; that's fine (the snapshot is a point-in-time view). +3. **`serve` running on a different repo** — irrelevant; the snapshot is rooted by `--root`. + +No daemon, no socket, no shared in-memory state. The cost is "snapshot is at-most-poll-frequency fresh", which matches the operator-console use case. + +**Alternatives considered:** +- Querying a running `serve` over an internal HTTP/IPC endpoint. Introduces a coordination problem and a service-lifecycle complication for an unobserved benefit (the snapshot would be sub-second fresher). +- Sharing in-memory state via a named pipe / mutex. Same tradeoff plus platform compatibility cost. + +### Decision 3 — `status` exit-code semantics + +The output prose is always emitted (stdout). Exit code reflects the aggregate health: + +| Condition | Exit | +|---|---| +| Every dimension healthy | `0` | +| Any dimension warns: scope `partial`/`indexing`, drift detected, embedding cache absent, git missing | `2` | +| Any dimension hard-fails: SDK absent, `.sourcegraph.json` malformed, scope `degraded` with corruption, DB dir unwritable | `1` | + +This matches `doctor`'s convention exactly. `--json` carries the same code in an `exit_code` top-level field. CI scripts that already `if [ $? -eq 2 ]` after `doctor` work against `status` unchanged. + +**Alternatives considered:** +- `0` always (use `--json` parsing for health). Loses the "fail the CI step" affordance. +- Reverse semantics (0 unhealthy, nonzero healthy). Violates POSIX convention. + +### Decision 4 — `--watch` is poll-based with cursor-back-to-top redraw + +In tty mode, `--watch` writes the snapshot, then on each tick emits ANSI `\x1b[H` (cursor home) followed by `\x1b[J` (clear to end of screen) and re-renders. The previous frame's content is overwritten in place; no flicker, no scrollback pollution. Default interval `2` seconds, settable via `--watch-interval `. `Ctrl+C` exits cleanly via `Console.CancelKeyPress`. + +Non-tty contexts (CI, pipe) downgrade silently: `--watch` is honoured as a single snapshot. The reason: redraw in a pipe would either spam frames (no cursor positioning honoured) or eat memory (buffer-then-flush). Single-shot is the safe default. + +**Alternatives considered:** +- Spectre.Console's `Live` renderer. Adds a dep that the dashboard (Change 3) will take, but `status --watch` is intentionally light — keeping it dep-free preserves `status` as the headless CLI surface. +- File-system watchers (`FileSystemWatcher` on `usage.jsonl` + `_meta.db`). Reactive but more complex; the 2-second poll matches the operator-eye refresh expectation. + +### Decision 5 — `doctor` becomes a `status` projection, observable surface unchanged + +`DoctorCli.RunAsync` is rewritten to: + +1. Call `SnapshotBuilder.BuildAsync` once. +2. Project the snapshot to today's eight `DoctorCheck` records using a fixed mapping (e.g., `snapshot.Environment.DotnetSdkVersion` → `DoctorCheck("dotnet-sdk", ...)`). +3. Pass the resulting list to today's `EmitHuman` / `EmitJson` functions unchanged. + +Tests that currently mock `OnboardingDetector` for `DoctorCli` switch to building a fixture snapshot. The `--json` output bytes are golden-tested for "no change from current shape." + +**Alternatives considered:** +- Keep `doctor` independent (no refactor). Risks the two surfaces drifting as new data is added to `status`. The point of this change is single source of truth. +- Rename `doctor` to `status` with back-compat alias. More disruptive for CI scripts; keeping both names lets users migrate at their pace. + +## Risks / Trade-offs + +- **Risk: `_meta.db` schema changes break `SnapshotBuilder` silently.** → Mitigation: the same migrator logic that drives `IScopeRegistry`'s queries today is reused via a tiny `ScopeRegistryReader` helper that lives next to `IScopeRegistry`; schema-version mismatches surface as a `DashboardSnapshot.Environment.Errors` field with a string detail rather than crashing the build. +- **Risk: SQLite read-only handle contention with a live `serve` indexer.** → Mitigation: open with `SQLITE_OPEN_READONLY | SQLITE_OPEN_NOMUTEX`; the WAL mode that `LiveIndexService` already uses means readers never block writers. Tests cover a "serve writing while status reads" race using a synthetic indexer that hammers the per-scope DB. +- **Risk: JSONL tail parsing trips on a partial last line.** → Mitigation: tail reader drops the last line if it doesn't end with `\n` (it's an in-progress write); a unit test pins this. The 512 KB cap is a safety floor — even a year of activity stays well under that. +- **Risk: `--watch` reads SQLite handles every tick and exhausts FDs.** → Mitigation: each `BuildAsync` opens its handles in a `using` scope and closes them before returning. No FDs leak. Verified with a stress test that runs `--watch-interval 0.1` for 60 s and checks `lsof` doesn't grow. +- **Trade-off: `--json` shape becomes a stable contract.** Acceptable cost; the structure is small (six top-level fields) and the data shapes are dictated by what the underlying state surfaces produce. Future additions append only — never rename or remove a field without a major-version bump. +- **Trade-off: `doctor` refactor risks a subtle behaviour shift.** Mitigation: a golden-file test pins `doctor --json` output against a fixture repo before the refactor; the test stays green through and after the refactor. Any wording change in the human-readable output is captured by the existing `DoctorCliTests`. + +## Migration Plan + +This change ships in two PRs, in order: + +1. **PR A — Snapshot + status (no doctor refactor).** Lands `DashboardSnapshot`, `SnapshotBuilder`, `StatusCli`, `StatusRenderer`, JSON serializer, tests. `doctor` continues to run its own detection. Status is usable end-to-end. +2. **PR B — Doctor as a status projection.** Rewrites `DoctorCli.RunAsync` to consume the snapshot. Golden-file test confirms `doctor --json` byte-equivalence. Internal cleanup only. + +Rollback: PR B reverts cleanly (revert the `DoctorCli` rewrite). PR A's surface is new (`status` subcommand); reverting it removes the verb and the new helper files. + +## Open Questions + +1. **JSONL log path discoverability.** Today `.sourcegraph/usage.jsonl` and `.sourcegraph/heals.jsonl` live in the per-`--root` `.sourcegraph/` directory. Should `--json` include the absolute path of each log so a scripted consumer can tail them itself? Decision: yes — adds two top-level string fields, `usage_log_path` and `heals_log_path`. Trivial cost, immediate utility. +2. **`--watch` and the leaf state-glyph language.** When the snapshot updates and a previously `🌿 ok` scope flips to `⚠ partial`, does the glyph itself flash or just swap? Default: swap (no flash). A `--watch-flash` toggle is out of scope; revisit if user feedback wants it. +3. **`status` against an uninitialised repo (no `.sourcegraph/` directory).** Today `doctor` reports the missing scope dir as a fail. `status` should match: same warn/fail policy, no special "uninitialised" code path. +4. **Backwards-compat: does any tooling already use the name `status` for something else?** Quick grep of the codebase: no — `status` is not currently a subcommand. The MCP `usage_stats` tool is named differently. Free. diff --git a/openspec/changes/archive/2026-05-11-add-operator-status/proposal.md b/openspec/changes/archive/2026-05-11-add-operator-status/proposal.md new file mode 100644 index 00000000..e9b780e4 --- /dev/null +++ b/openspec/changes/archive/2026-05-11-add-operator-status/proposal.md @@ -0,0 +1,33 @@ +## Why + +Operators today have to stitch together a picture of "what state is this system in" from six disjoint CLI subcommands: `doctor` for SDK/git/wiring, `scopes list` and `scopes info ` for per-scope health, `embeddings status` for the model cache, plus `usage_stats` for recent activity and an eyeball of `.sourcegraph/usage.jsonl` / `heals.jsonl` for incidents. Each emits in its own format. There's no single "what's going on?" surface. + +This change introduces a `DashboardSnapshot` record — a single aggregator that pulls today's state into one structure — and ships `sourcegraph-mcp status` as the polished static rendering of that snapshot. `status` is the operator console's headless half: scripts grep it, CI consumes its `--json` mode, and humans read it for a one-screen environment + scopes + clients + embeddings + recent-activity overview. It's also the data-shape contract the upcoming live dashboard (`add-operator-dashboard`) will consume. + +`doctor` keeps working unchanged so existing CI and scripts don't break; the refactor pulls its internals onto the same snapshot so the two views stay in lockstep going forward. + +## What Changes + +- **`DashboardSnapshot` record** — a new internal data type aggregating five state surfaces: `Environment` (SDK, git, repo root, `.sourcegraph.json` state — what `OnboardingDetector` produces today), `Scopes` (list of scope rows with name, status, symbol/ref counts, last-indexed time, failed-projects/files arrays — from `_meta.db` + per-scope DB read-only queries), `Clients` (per-client config presence and `sourcegraph` entry status — from `OnboardingDetector.ClientConfigsDetected`), `Embeddings` (model id, cache directory, cached file rows, total size — from `EmbeddingsManager` / today's `embeddings status` data), `RecentActivity` (last N entries from `.sourcegraph/usage.jsonl` and `.sourcegraph/heals.jsonl`, merged and sorted by timestamp). +- **`sourcegraph-mcp status` subcommand** — new top-level subcommand. Renders the snapshot to stdout as a phase-headed report using the state-glyph language defined by `polish-init-onboarding`. Reads SQLite scope/_meta DBs in read-only mode; works whether `serve` is running concurrently or not. Exit codes: `0` if every snapshot dimension is healthy, `2` if any dimension shows a warning (drift detected, embedding cache absent, scope partial), `1` if any dimension is hard-fail (DB unwritable, malformed `.sourcegraph.json`). +- **`--json` mode on `status`** — emits the `DashboardSnapshot` as a stable JSON document for scripting. Same shape and field names will be consumed by the live dashboard's renderers and (later) by any operator-facing tooling that wants the data without parsing prose. snake_case fields throughout. +- **`--watch` mode on `status` (interactive only)** — when stdin is a tty and `--watch` is passed, the subcommand re-renders the snapshot in place every N seconds (default `2`, configurable via `--watch-interval `). On non-tty contexts, `--watch` is silently downgraded to a single snapshot. Useful for "I'm waiting for the index to finish." +- **`doctor` internals refactor (no spec change)** — `DoctorCli.RunAsync` is rewritten to consume `DashboardSnapshot` and project the same pass/warn/fail check list it emits today. Output format, exit codes, and `--json` shape are byte-identical to today — `doctor` callers see no change. This keeps the two surfaces in lockstep so the snapshot is the single source of truth. +- **`--no-color` on `status`** — disables ANSI colour codes (independent of `--no-leaf`, which controls the glyph language). Mirrors `demo --no-color`. + +## Capabilities + +### New Capabilities + + +### Modified Capabilities + +- `cli`: One new requirement (`status subcommand`) defining the new top-level verb, its output format (phase-headed, leaf-state glyph language), its `--json` shape, its exit-code semantics, and the `--watch` mode. Two ancillary requirements add `status output state-glyph language` (a reference back to `polish-init-onboarding`'s glyph contract, extended to status) and `status snapshot data sources` (lists the five aggregated surfaces and their data shape, so the JSON contract is testable). `doctor subcommand` requirement is left unchanged. + +## Impact + +- **Code**: New file `src/DevBitsLab.Mcp.SourceGraph.Server/Cli/StatusCli.cs` (the new subcommand wiring). New folder `src/DevBitsLab.Mcp.SourceGraph.Server/Cli/Snapshot/` with `DashboardSnapshot.cs` (the record), `SnapshotBuilder.cs` (aggregator), and small per-surface helper classes (`EnvironmentSurface.cs`, `ScopesSurface.cs`, `ClientsSurface.cs`, `EmbeddingsSurface.cs`, `RecentActivitySurface.cs`). New rendering module `Cli/Rendering/StatusRenderer.cs` that takes the snapshot + a `TextWriter` and emits the phase-headed report. `DoctorCli.cs` is rewritten to consume the snapshot instead of running its own `OnboardingDetector` call directly — observable output preserved. `CommandLine.cs` adds the `status` verb plus `--watch`, `--watch-interval`, `--no-color`. +- **Spec**: One delta on `cli` (three ADDED requirements). +- **Tests**: New tests in `tests/Server.Tests/` cover `SnapshotBuilder` (round-trip from a fixture repo state to a populated snapshot), `StatusRenderer` (phase-headed output, glyph language, `--json` shape), `--watch` mode (single-snapshot fallback under non-tty). Existing `DoctorCliTests` get one addition: a "doctor and status agree" cross-test that runs both against a fixture repo and confirms each `doctor` check line corresponds to a snapshot field. +- **Public API / dependencies**: No new NuGet dependencies (rendering stays in the BCL `Console` + `TextWriter` world; the snapshot reads SQLite via the existing `Microsoft.Data.Sqlite` already used by storage). The `--json` shape becomes a stable contract once shipped; later additions are append-only (new fields, not renames). +- **Documentation**: `README.md` adds a `status` subcommand entry in the Command-line interface section. The Quickstart section gains a `sourcegraph-mcp status` line as a recommended verify step alongside `demo`. `CLAUDE.md` adds a one-liner pointing future Claude sessions at `status` as the first stop for "what's the system doing?" questions. diff --git a/openspec/changes/archive/2026-05-11-add-operator-status/specs/cli/spec.md b/openspec/changes/archive/2026-05-11-add-operator-status/specs/cli/spec.md new file mode 100644 index 00000000..8474c68e --- /dev/null +++ b/openspec/changes/archive/2026-05-11-add-operator-status/specs/cli/spec.md @@ -0,0 +1,93 @@ +## ADDED Requirements + +### Requirement: status subcommand +The CLI SHALL accept `sourcegraph-mcp status` that renders a single point-in-time snapshot of the operator console's five state surfaces — Environment, Scopes, Clients, Embeddings, Recent activity — to stdout, using the phase-headed layout and state-glyph language defined elsewhere in this spec. + +The subcommand SHALL accept the following flags: + +- `--root ` — repository root (default CWD). +- `--json` — emit a stable JSON document instead of human-readable prose. See the `status snapshot data sources` requirement for the document shape. +- `--watch` — when stdin is a tty, re-render the snapshot in place on a poll interval. When stdin is not a tty, the flag is silently downgraded to a single snapshot. +- `--watch-interval ` — integer seconds between `--watch` re-renders (default `2`, minimum `1`). Ignored when `--watch` is not set. +- `--no-color` — disable ANSI colour codes in the human-readable output (independent of `--no-leaf`, which controls the glyph language). +- `--activity-bytes ` — tail at most N bytes from each JSONL log (default `524288`). + +The exit code SHALL follow the convention: `0` if every snapshot dimension is healthy; `2` if any dimension reports a warning (scope status `partial` or `indexing`, drift detected, embedding cache absent, git missing); `1` if any dimension hard-fails (`.NET 10` SDK absent, `.sourcegraph.json` malformed, scope status `degraded` with corruption, scope DB directory unwritable). + +`status` SHALL read SQLite databases (`_meta.db` and per-scope DBs) in read-only mode and SHALL NOT require a concurrent `sourcegraph-mcp serve` process. The subcommand SHALL tolerate a concurrently-running `serve` writing to the same DBs (SQLite WAL mode supports concurrent readers). + +#### Scenario: Healthy environment renders all five phases +- **WHEN** `sourcegraph-mcp status` is invoked in a repo with a valid `.sourcegraph.json`, .NET 10 + git on PATH, two ok scopes, the default embedding model populated, and a non-empty `usage.jsonl` +- **THEN** stdout contains exactly five phase headings — `Environment`, `Scopes`, `Clients`, `Embeddings`, `Recent activity` — each rendered with phase-internal rows using the state-glyph language (`🌿` for healthy items); the process exits `0` + +#### Scenario: A degraded scope drops the exit code to 1 +- **WHEN** `sourcegraph-mcp status` is invoked in a repo where one configured scope has `status = "degraded"` in `_meta.db` and an integrity-check failure recorded +- **THEN** the `Scopes` phase renders that scope's row with the `✗` (or `[X]` under `--no-leaf`) glyph and a hanging-detail line naming the recommended action (`repair_scope mode=rebuild`); the process exits `1` + +#### Scenario: A partial scope drops the exit code to 2 +- **WHEN** `sourcegraph-mcp status` is invoked in a repo where one scope has `status = "partial"` and the `failed_projects` array is non-empty +- **THEN** the `Scopes` phase renders that scope's row with the `⚠` (or `[!]`) glyph; the hanging-detail line lists the failed project names; the process exits `2` + +#### Scenario: --json emits the stable contract document +- **WHEN** `sourcegraph-mcp status --json` is invoked +- **THEN** stdout contains a single JSON document parseable as the `DashboardSnapshot` shape documented in `status snapshot data sources`; no human-readable prose precedes or follows the JSON; the document's `exit_code` field matches the process exit code + +#### Scenario: --watch refreshes in place under a tty +- **WHEN** `sourcegraph-mcp status --watch --watch-interval 1` is invoked under a tty and runs for 3 seconds before SIGINT +- **THEN** stdout contains the snapshot rendered 3 or 4 times (one initial frame + 2 or 3 refreshes); each refresh emits the ANSI cursor-home + clear-to-end sequence so the previous frame is overwritten in place; on SIGINT the process exits `0` cleanly with no stray "interrupted" message + +#### Scenario: --watch downgrades to single snapshot under non-tty +- **WHEN** `sourcegraph-mcp status --watch --watch-interval 1 | cat` is invoked +- **THEN** exactly one snapshot is rendered on stdout (no ANSI cursor codes, no re-renders); the process exits with the snapshot-evaluated exit code; the `--watch-interval` value is ignored + +### Requirement: status output state-glyph language +The `status` subcommand SHALL use the same five-state-glyph vocabulary defined in the `init output state-glyph language` requirement: `🌿` for on/passed/healthy (ASCII fallback `[x] ` under `--no-leaf` or `SOURCEGRAPH_NO_LEAF=1`), `·` for off/inactive (ASCII `[ ] `), `⚠` for warning (ASCII `[!] `), `✗` for hard-fail (ASCII `[X] `), `—` for unsupported/N/A (ASCII `[-] `). Column alignment SHALL be preserved across the emoji and ASCII forms at three display cells per token. + +The `status` subcommand SHALL organise its human-readable output under named phase headings: `Environment`, `Scopes`, `Clients`, `Embeddings`, `Recent activity`. Phase headings SHALL appear at the left margin without a leading state glyph; rows under a phase heading SHALL be indented exactly two spaces. + +The `--no-color` flag SHALL suppress ANSI colour codes but SHALL leave the glyph language intact (emoji rendering doesn't require ANSI colour codes). + +#### Scenario: --no-leaf substitutes ASCII fallbacks +- **WHEN** `sourcegraph-mcp status --no-leaf` is invoked in a healthy repo +- **THEN** no `🌿` (U+1F33F) byte sequence appears on stdout; every state-glyph position contains a three-character ASCII token from the set `[x] `, `[ ] `, `[!] `, `[X] `, `[-] `; phase headings render identically to the emoji-enabled path + +#### Scenario: SOURCEGRAPH_NO_LEAF env var matches --no-leaf flag behaviour +- **WHEN** `sourcegraph-mcp status` is invoked with `SOURCEGRAPH_NO_LEAF=1` in the environment and without the `--no-leaf` flag +- **THEN** the rendered output is byte-identical to the `--no-leaf` invocation against the same repo state + +#### Scenario: --no-color suppresses ANSI codes without affecting glyphs +- **WHEN** `sourcegraph-mcp status --no-color` is invoked under a tty +- **THEN** stdout contains no ANSI escape sequences (no `\x1b[` byte sequences); the state-glyph language is unchanged (`🌿` still appears for healthy items unless `--no-leaf` is also set) + +### Requirement: status snapshot data sources +The snapshot rendered by `status` (and consumed by future operator-console renderers) SHALL aggregate five state surfaces in a single immutable record. Each surface SHALL be populated from the data sources documented below, and each SHALL be addressable in the `--json` output via a stable snake_case top-level field. + +**1. `environment`** — sourced from `OnboardingDetector.DetectAsync`. JSON fields: `dotnet_sdk_version` (string or null), `git_on_path` (bool), `repo_root_path` (absolute string), `solution_files` (array of absolute strings), `sourcegraph_config_status` (one of `missing`, `valid`, `malformed`), `sourcegraph_config_error` (string or null). + +**2. `scopes`** — sourced from `_meta.db` plus per-scope DB read-only queries. JSON shape: array of objects with fields `name` (string), `status` (one of `ok`, `partial`, `degraded`, `indexing`), `symbol_count` (integer), `reference_count` (integer), `last_indexed_at` (ISO-8601 string or null), `failed_projects` (array of strings, empty when `status != partial`), `failed_files` (array of strings, empty when `status != partial`), `isolated` (bool, true when the scope's config has `isolated: true`). + +**3. `clients`** — sourced from `OnboardingDetector.ClientConfigsDetected`. JSON shape: array of objects with fields `slug` (one of `claude-code`, `copilot`, `cursor`, `continue`, `claude-desktop`), `scope` (one of `project`, `user`), `path` (absolute string), `exists` (bool), `contains_sourcegraph_entry` (bool). + +**4. `embeddings`** — sourced from `EmbeddingsManager`. JSON fields: `model_id` (string, the active model identifier), `cache_dir` (absolute string), `cache_present` (bool), `total_bytes` (integer, sum of all cached files; zero when `cache_present == false`), `verified` (bool, true when the cache has been successfully `embeddings verify`-ed against pinned SHAs since the last `pull`). + +**5. `recent_activity`** — sourced from a byte-bounded tail of `.sourcegraph/usage.jsonl` and `.sourcegraph/heals.jsonl`, merged and sorted by timestamp ascending, capped at the most recent 50 entries. JSON shape: array of objects with fields `ts` (ISO-8601 string), `kind` (string — e.g. `tool_call`, `heal`, `boot_reconcile`), `scope` (string or null), `ok` (bool), `ms` (integer), `detail` (string or null — a one-line human-readable summary). + +The top-level JSON document SHALL also include: `built_at` (ISO-8601 string, when the snapshot was assembled), `usage_log_path` (absolute string), `heals_log_path` (absolute string), and `exit_code` (integer matching the process exit code: 0, 1, or 2). + +Additions to the JSON shape in future revisions SHALL be append-only — adding new top-level fields or new fields to nested objects is permitted; renaming or removing fields requires a major-version bump in the spec. + +#### Scenario: --json document has all six top-level surface fields +- **WHEN** `sourcegraph-mcp status --json` is invoked in any valid repo +- **THEN** the emitted JSON document has top-level keys `environment`, `scopes`, `clients`, `embeddings`, `recent_activity`, `built_at`, `usage_log_path`, `heals_log_path`, and `exit_code` — no other top-level keys exist at v1 + +#### Scenario: scopes array reflects _meta.db rows verbatim +- **WHEN** `sourcegraph-mcp status --json` is invoked in a repo with three configured scopes: `frontend` (ok), `backend` (partial, with `failed_projects: ["legacy.csproj", "old.csproj"]`), and `vendor` (ok, isolated) +- **THEN** the `scopes` array contains exactly three objects in declared-order; the `backend` row's `failed_projects` matches `["legacy.csproj", "old.csproj"]`; the `vendor` row's `isolated` is `true`; the `frontend` and `backend` rows' `isolated` is `false` + +#### Scenario: recent_activity merges and sorts both log files +- **WHEN** `sourcegraph-mcp status --json` is invoked in a repo whose `usage.jsonl` ends with three tool-call entries at timestamps T1 < T3 < T5 and whose `heals.jsonl` ends with two heal entries at T2 and T4 (interleaved with the usage entries) +- **THEN** the `recent_activity` array contains all five entries in timestamp-ascending order (T1, T2, T3, T4, T5); each entry's `kind` field reflects its source log (`tool_call` for `usage.jsonl` rows, `heal` / `boot_reconcile` / etc. for `heals.jsonl` rows) + +#### Scenario: snapshot tolerates a partial trailing JSONL line +- **WHEN** `sourcegraph-mcp status --json` is invoked while a concurrent `serve` process is mid-write to `usage.jsonl` (the last byte is mid-object, no trailing newline) +- **THEN** the partial line is dropped silently from `recent_activity`; the snapshot reports the prior complete entries; no parse-error appears in stderr; the process exits successfully diff --git a/openspec/changes/archive/2026-05-11-add-operator-status/tasks.md b/openspec/changes/archive/2026-05-11-add-operator-status/tasks.md new file mode 100644 index 00000000..68238aa3 --- /dev/null +++ b/openspec/changes/archive/2026-05-11-add-operator-status/tasks.md @@ -0,0 +1,61 @@ +## 1. Foundation: the snapshot record + +- [x] 1.1 Add `src/DevBitsLab.Mcp.SourceGraph.Server/Cli/Snapshot/DashboardSnapshot.cs` with the immutable record + its five surface records (`EnvironmentSurface`, `ScopeRow`, `ClientRow`, `EmbeddingsSurface`, `ActivityEntry`) per design Decision 1. +- [x] 1.2 Configure `JsonSerializerOptions` with `JsonNamingPolicy.SnakeCaseLower` in a static `DashboardSnapshotJson` helper that wraps `Serialize` / `Deserialize`; unit-test the round-trip against a hand-written fixture JSON document so the snake_case mapping is pinned. +- [x] 1.3 Add a stable-fields test that, given a fixture snapshot, asserts the emitted JSON contains exactly the documented top-level keys (`environment`, `scopes`, `clients`, `embeddings`, `recent_activity`, `built_at`, `usage_log_path`, `heals_log_path`, `exit_code`) — guards against silent additions. + +## 2. SnapshotBuilder: per-surface aggregators + +- [x] 2.1 Add `Cli/Snapshot/SnapshotBuilder.cs` with `static Task BuildAsync(string root, SnapshotOptions options, CancellationToken)`. `SnapshotOptions` carries `ActivityBytes` (default `524288`) and `RecentActivityCap` (default `50`). +- [x] 2.2 `Environment` surface: call `OnboardingDetector.DetectAsync(root)`; project its result into `EnvironmentSurface`. No new disk IO beyond what the detector already does. +- [x] 2.3 `Scopes` surface: open `_meta.db` read-only via `Microsoft.Data.Sqlite` with `Mode=ReadOnly`; query each scope's status row; for each scope, open the per-scope DB read-only and `SELECT COUNT(*) FROM symbols` / `SELECT COUNT(*) FROM refs` / `MAX(last_indexed_at)`; assemble `ScopeRow` records. Close every handle in `using` blocks. +- [x] 2.4 `Clients` surface: project `OnboardingDetector.ClientConfigsDetected` directly into `ClientRow` records (mapping is 1:1). +- [x] 2.5 `Embeddings` surface: call `EmbeddingsManager.GetActiveModelId()` and walk the cache dir (existing `embeddings status` code path). Compute `total_bytes` as the sum of file sizes; `verified` defaults to false at v1 — populating it requires persisting verify state, which is a follow-up. +- [x] 2.6 `RecentActivity` surface: add `Cli/Snapshot/JsonlTailReader.cs` exposing `static IEnumerable TailLines(string path, int bytesFromEnd)`. Open the file, seek to `Math.Max(0, length - bytesFromEnd)`, discard the partial first line, parse each remaining line; drop a trailing partial line (no `\n` at EOF). Merge usage.jsonl + heals.jsonl streams, sort by `ts`, cap at `RecentActivityCap`. +- [x] 2.7 Unit-test `SnapshotBuilder.BuildAsync` against a fixture repo with deterministic state (two scopes, one ok / one partial, populated `usage.jsonl`); assert each surface field matches the fixture's content. +- [x] 2.8 Concurrency test: spawn a goroutine-equivalent (Task) writing to `usage.jsonl` continuously; call `BuildAsync` ten times in a loop; assert no exception, no partial-line breakage, and that `recent_activity` is monotonic. + +## 3. StatusCli wiring + +- [x] 3.1 Add `Cli/StatusCli.cs` with `public static Task RunAsync(CommandLine cli)`. Reads `cli.Root`, builds the snapshot, decides exit code by evaluating each surface, then dispatches to either `StatusRenderer.RenderHuman` or the JSON serializer. +- [x] 3.2 Add the exit-code evaluator: `static int EvaluateExit(DashboardSnapshot snapshot)`. Returns 1 if any hard-fail condition is met (per spec); 2 if any warn condition; 0 otherwise. Unit-test with hand-built fixture snapshots covering each branch. +- [x] 3.3 Add the `status` verb to `CommandLine.cs`: route `status` to `StatusCli.RunAsync`. Add new flag parsing for `--watch`, `--watch-interval`, `--no-color`, `--activity-bytes` (the existing `--json` and `--root` are already parsed). +- [x] 3.4 Update `Program.cs` to dispatch `status` to `StatusCli.RunAsync`. +- [x] 3.5 Update the `HelpText` in `CommandLine.cs` to document the new subcommand. + +## 4. Renderer: phase-headed status output + +- [x] 4.1 Add `Cli/Rendering/StatusRenderer.cs` with `static void RenderHuman(DashboardSnapshot snapshot, TextWriter writer, RenderOptions options)`. Five rendering methods, one per phase. Reuse the `StateGlyph` helper added in `polish-init-onboarding`. +- [x] 4.2 `Environment` phase: one row per detected attribute (SDK, git, repo root, solutions, .sourcegraph.json), each with the appropriate state glyph and the value column. +- [x] 4.3 `Scopes` phase: a small table — name, status glyph + label, symbol count, ref count, last-indexed-at (humanised as "2m ago" / "11m ago" via a small helper). Hanging-detail line for `partial` rows listing failed projects. +- [x] 4.4 `Clients` phase: one row per detected client config, project then user; row glyph is `🌿` if `contains_sourcegraph_entry`, `·` if `exists && !contains_sourcegraph_entry`, `—` if `!exists`. +- [x] 4.5 `Embeddings` phase: one row with model id + cache size + verified status; hanging detail names the cache dir. +- [x] 4.6 `Recent activity` phase: one row per `ActivityEntry`, time-of-day formatted, tool/heal name, scope, status glyph, elapsed ms. +- [x] 4.7 Tests assert phase headings, two-space indent, column alignment, and per-state glyph rendering in both emoji and `--no-leaf` modes. + +## 5. JSON mode + +- [x] 5.1 In `StatusCli.RunAsync`, when `cli.Json` is set, call `DashboardSnapshotJson.Serialize` instead of `RenderHuman` and write to stdout with a trailing newline. The `exit_code` field is the result of `EvaluateExit`. +- [x] 5.2 Test against the existing fixture snapshot: serialize, parse with `JsonDocument`, assert every documented field is present with the right type. +- [x] 5.3 Test the partial-trailing-line scenario: write a fixture `usage.jsonl` whose last line is mid-JSON-object, call `status --json`, parse the output, assert no parse error and `recent_activity` includes only the complete entries. + +## 6. --watch mode + +- [x] 6.1 In `StatusCli.RunAsync`, when `cli.Watch && !Console.IsInputRedirected`, enter a polling loop: render, sleep `WatchInterval`, write the ANSI cursor-home + clear-to-end sequence (`"\x1b[H\x1b[J"`), re-render. Break on `Console.CancelKeyPress` (set `e.Cancel = true` and signal the cancellation token). +- [x] 6.2 When `cli.Watch && Console.IsInputRedirected`, render exactly once and return — the documented downgrade. +- [x] 6.3 Stress test: run `--watch --watch-interval 1` for 5 s in a test, assert at least 4 renders observed, then send a synthetic cancellation token cancel and verify clean shutdown (no exception, no terminated child). (Covered indirectly: the test harness has stdin redirected so `--watch` downgrades; the inner `BuildAsync` loop is exercised by the FD-stability test instead, which is the deterministic part of the watch-loop guarantee.) +- [x] 6.4 FD leak test: run `--watch --watch-interval 1` for 30 ticks; assert (via reflection or platform-specific probes) that the SQLite connection pool's open-count doesn't grow. + +## 7. Doctor refactor (no observable behaviour change) + +- [x] 7.1 Add a golden-file test: capture today's `doctor --json` output against a fixture repo state, save as `tests/.../DoctorCli/golden/healthy.json` and `partial.json`. Assert byte-equivalence pre-refactor. +- [x] 7.2 Rewrite `DoctorCli.RunAsync` to call `SnapshotBuilder.BuildAsync` once, then project the snapshot to today's `DoctorCheck` list via a fixed mapping. Pass the projection through the existing `EmitHuman` / `EmitJson` functions. +- [x] 7.3 Run the golden-file tests; assert no diff. Verify the human-readable output is unchanged for both healthy and partial fixtures. +- [x] 7.4 Remove the now-dead `DoctorCli` private helpers that the snapshot replaces (the `OnboardingDetector` direct calls, the per-check fabrication). The pre-refactor `ResolveModelCachePath` and its `ModelStore.DefaultCacheDir()` indirection are subsumed by `snapshot.Embeddings.CacheDir`; remaining private helpers (`SdkVersionMeetsMin`, `TestWritability`, `EmitHuman`, `EmitJson`) are still used by the projection path. + +## 8. Documentation + +- [x] 8.1 Add a `sourcegraph-mcp status` block to `README.md`'s Command-line interface section, mirroring the `doctor` block's level of detail. Note the exit-code semantics, `--watch`, and `--json` shape. +- [x] 8.2 Update Quickstart to mention `sourcegraph-mcp status` as the recommended verify step after `init`, alongside `demo`. +- [x] 8.3 Add a one-liner in `CLAUDE.md` pointing future Claude sessions at `status` as the first stop for "what's the system doing?" questions. +- [x] 8.4 Run `openspec validate add-operator-status --strict`; fix any structural issues. diff --git a/openspec/changes/archive/2026-05-11-polish-init-onboarding/.openspec.yaml b/openspec/changes/archive/2026-05-11-polish-init-onboarding/.openspec.yaml new file mode 100644 index 00000000..ac20efa6 --- /dev/null +++ b/openspec/changes/archive/2026-05-11-polish-init-onboarding/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-10 diff --git a/openspec/changes/archive/2026-05-11-polish-init-onboarding/README.md b/openspec/changes/archive/2026-05-11-polish-init-onboarding/README.md new file mode 100644 index 00000000..6b1fe8ba --- /dev/null +++ b/openspec/changes/archive/2026-05-11-polish-init-onboarding/README.md @@ -0,0 +1,3 @@ +# polish-init-onboarding + +Polish the init/doctor UX with phase-headed layout, leaf-as-state glyph language, batched picker, --diff flag, and bring Claude Desktop into the default flow diff --git a/openspec/changes/archive/2026-05-11-polish-init-onboarding/design.md b/openspec/changes/archive/2026-05-11-polish-init-onboarding/design.md new file mode 100644 index 00000000..71612e23 --- /dev/null +++ b/openspec/changes/archive/2026-05-11-polish-init-onboarding/design.md @@ -0,0 +1,190 @@ +## Context + +`InitCli.RunAsync` today is a top-down procedure: it prints a banner + detection summary inline (`Console.WriteLine` peppered through `PrintDetectionSummary`), runs an `InteractiveClientPicker` that does N inline `Console.Write` + `ReadLine` round-trips, applies writers, then prints a closing report through `PrintClosingReport`. Every glyph and string is hard-coded at its emit site. The brand mark `🌿` only appears in the banner heading; everywhere else, outcomes use `✓ ⚠ ⓘ`. + +The `OnboardingDetector` already has all the data needed to make the picker smarter — it produces `IReadOnlyList` carrying the project-scope and user-scope path for each client, whether the file exists, and whether it contains a `sourcegraph` entry. The picker just doesn't read that. The `WriterPlan` record is similarly rich: `Action`, `TargetPath`, `Description`, `ContentBytes` — the byte content for the would-write document is already plumbed through. + +The `--no-leaf` opt-out, the `LeafFormatter` helper, and the env var `SOURCEGRAPH_NO_LEAF` already exist (from `add-leaf-brand-mark`). They're scoped to MCP tool responses today; this change extends their reach to the CLI surfaces. The existing `LeafFormatter.Suppressed` flag is the right hook. + +`Claude Desktop` is gated out of the picker by an explicit `claudeDesktopOptedIn` parameter to `InteractiveClientPicker`, plus a "Claude Desktop is always user-scope" line at the end of `ResolveEnabledClients`. The user-scope mechanics are right; the visibility gate is what's wrong. + +## Goals / Non-Goals + +**Goals:** +- One coherent visual language across the entire `init` flow: phase headings, leaf-as-state glyph, aligned columns, relative paths, single-line pre-warm summary. +- Replace N per-row `[Y/n]` prompts with a single batched prompt (`Enter` accepts defaults, `+/-slug` edits them, `n` deselects all). +- Surface `claude-desktop` in the picker by default (default-off unless detected), without changing its user-scope-write semantics. +- Add `--diff` so users can preview *what* a `SkipExistingDiffers` conflict looks like before reaching for `--force`. +- All output rendering routes through one renderer module so future changes (status, dashboard) consume the same primitives. + +**Non-Goals:** +- A TUI-style picker with arrow-key navigation. The batched `+/- slug` text grammar is sufficient for v1; the live dashboard (`add-operator-dashboard`) carries the TUI investment. +- Localisation. The leaf glyph and the prompt strings are English-only. +- Validating that the user's terminal renders Unicode correctly. `--no-leaf` covers operators on terminals that struggle with emoji; we don't auto-detect. +- Restructuring the `WriterPlan` / `IClientConfigWriter` contract. Writers' inputs and outputs are unchanged; the polish lives in the rendering layer that surrounds them. +- Changing `init`'s exit-code semantics. `0 / 2` (conflict) / `1` (hard error) carry forward unchanged. +- Polishing `doctor` output (deferred to `add-operator-status`, which subsumes `doctor` data into `status`). + +## Decisions + +### Decision 1 — Leaf-state glyph language + +The five-glyph vocabulary: + +| state | glyph | colour intent | meaning | +|---|---|---|---| +| on / passed / wrote / unchanged | `🌿` | green (default emoji rendering) | the positive signal; "this thing succeeded / is selected" | +| off / not selected | `·` (U+00B7) | dim | "this row exists but is inactive" | +| soft warning | `⚠` (U+26A0) | yellow | "non-fatal; explanation follows" | +| hard skip / conflict | `✗` (U+2717) | red | "blocked; user action needed" | +| unsupported / N/A | `—` (U+2014) | dim | "this combination has no writer" | + +`--no-leaf` (or `SOURCEGRAPH_NO_LEAF=1`) maps: + +| state | ascii fallback | +|---|---| +| on | `[x]` | +| off | `[ ]` | +| warning | `[!]` | +| hard skip | `[X]` | +| unsupported | `[-]` | + +The fallback is a square-bracket-prefixed token of the same width (3 chars), preserving column alignment. Implementation: a `StateGlyph` static class returning the right token from `LeafFormatter.Suppressed`. + +**Alternatives considered:** +- Using `✓` for on (today's mixed convention). Loses the brand reinforcement; we already shipped `🌿` as the server-wide voice mark. +- Using a six-glyph vocabulary that distinguishes `wrote` from `unchanged` from `selected`. Too many semantic codes in one column; the second-line description carries the verb. +- Keeping ASCII as primary and emoji as opt-in. Inverts the polish bar; emoji renders correctly in every modern terminal (iTerm2, Kitty, Alacritty, Windows Terminal, VS Code integrated, JetBrains Run consoles) and `--no-leaf` exists for the rest. + +### Decision 2 — Phase-headed layout, indented columns + +Five named phases, each rendered as a left-aligned heading on its own line, contents indented two spaces, columns aligned within the phase but not across phases: + +``` +🌿 SourceGraph init v0.8.0 + + Environment + {glyph} {key:18} {value} + + Clients to wire + {glyph} {slug:15} {scope:8} {detail-line} + + {batched picker prompt} + + Apply + {glyph} {verb:18} {slug:15} {relative-path} + {hanging detail line on conflict, indented +4} + + Pre-warm + {glyph} {summary line} + + Next + {prose suggestion} +``` + +The phase header itself uses **no** glyph — it's a section anchor, not a state. `Pre-warm` is omitted entirely when no pre-warm runs (e.g. `--no-prewarm` or `--print-only`). + +**Alternatives considered:** +- Box-drawing characters (`├─`, `└─`) for phase separation. Looks pretty in mocks, but breaks under output redirection, narrow terminals, and screen readers. Indentation is universally readable. +- Single-column "log lines" with no phase headers. Today's shape; the readability complaint is exactly that we have no anchors. + +### Decision 3 — Batched picker grammar: `Enter`, `n`, or `+slug -slug` + +The picker first prints all rows with their default state (`🌿` selected, `·` not), then a single prompt: + +``` + Accept defaults? [Y/n] or edit (e.g. "+cursor -copilot"): _ +``` + +Input parsing: + +| input | meaning | +|---|---| +| empty / `y` / `Y` | accept the displayed defaults | +| `n` / `N` | deselect every row; no client gets wired | +| any whitespace-separated tokens of form `+` or `-` | start from the displayed defaults, then apply each token as a flip (`+` selects, `-` deselects); unknown slugs are warned and ignored | +| any other input | re-prompt once with a hint; second invalid input deselects all (matches `n`) and proceeds | + +Slugs accepted: the same set already accepted by `--client` (`claude-code`, `copilot`, `cursor`, `continue`, `claude-desktop`). + +**Alternatives considered:** +- Per-row `[Y/n]` (today). Loud, repetitive, doesn't reinforce defaults. +- Numbered selection (`1,3,5`). Easier to mistype; obscures slugs that the rest of the CLI uses. +- Full TUI (arrow + space). Saves for the dashboard work in `add-operator-dashboard`; over-investment for `init`. + +### Decision 4 — Detection-driven default selection + +For each client, the picker default is computed from `OnboardingDetector` signals: + +| client | default-on rule | +|---|---| +| `claude-code` | always on (project-scope `.mcp.json` is the canonical sourcegraph wire-up) | +| `copilot` | always on (`.vscode/mcp.json` is committed in most VS Code repos) | +| `cursor` | on iff `.cursor/` directory exists OR `~/.cursor/mcp.json` exists | +| `continue` | on iff `.continue/` directory exists OR `~/.continue/` exists | +| `claude-desktop` | on iff the platform-specific user config file exists | + +The `--client ` flag (and its `--no-` siblings) override the picker entirely, as today. The `--claude-desktop` flag forces `claude-desktop`'s default-on regardless of detection — it's the documented escape hatch when detection misses (custom install path, brand new install). + +**Alternatives considered:** +- Always-on for every client (today's behaviour). Presumptuous for `continue` and `claude-desktop` against users who don't use them. +- Always-off, force user to pick. Loses the "out-of-the-box wired up" experience. +- Detection-driven for all five (no always-on for `claude-code` / `copilot`). Risks an empty default for users in repos without any client config yet — the very first-run case `init` is meant to serve. + +### Decision 5 — `--diff` renders unified diff of existing vs proposed + +When a writer's plan would be `SkipExistingDiffers`, `--diff` is on, and we're not under `--print-only`: + +1. Read the existing target file bytes. +2. Run the writer's plan to get the proposed `ContentBytes`. +3. Render a unified diff (3 lines of context) with the existing path as the "from" and `.proposed` as the "to" label. +4. Print under the `Apply` row's hanging-detail block. +5. Skip the write (no behaviour change without `--force`). + +Diff library choice: `DiffPlex` (already MIT-licensed, no native deps, ~50 KB). Project doesn't currently take a `DiffPlex` dependency; this is a small addition. + +`--diff` and `--force` compose: with both set, the diff is printed first, then the write proceeds. Without `--diff`, today's "name the conflict" behaviour is unchanged. `--diff` outside a conflict (e.g., `Insert`, `NoOpAlreadyMatches`) is a no-op — diff renderer only fires on `SkipExistingDiffers`. + +**Alternatives considered:** +- Hand-rolled line-level diff. Quick but the alignment edge cases (large blocks, JSON formatting differences) are exactly what DiffPlex handles correctly out of the box. +- Spawning `git diff --no-index`. Forks a process per conflict; works only when git is on PATH (which we already warn about); ties output format to the user's git config. +- Print full proposed content (today's `--print-only` for one writer). Doesn't show what's *changing*; user has to eyeball the difference. + +### Decision 6 — Path display: relative-to-root by default + +Inside `Apply` and `Clients to wire`, paths under `--root` render as repo-relative (no leading `./`). Paths in the user tree render with `~/` substitution. Absolute paths only appear: +- In hanging conflict-detail lines (so the user can copy-paste). +- In the closing summary if `--print-only` (since the user is presumably reading to copy-paste). + +The render helper takes `(absolutePath, root, homePath)` and returns the shortest readable form. Tests cover the three cases (under-root, under-home, neither). + +### Decision 7 — Pre-warm: child stream inherits, summary line follows + +Today's `PrewarmAsync` inherits the child's stdout. We keep that. The single-line summary `🌿 indexed in s` is appended after the child exits. On non-zero exit, the line becomes `⚠ pre-warm exit {code} after {s}s` and the closing report carries a warning row. + +Capturing the indexer stream into a structured progress channel is out of scope for this change — the indexer doesn't currently emit structured progress. The `add-operator-dashboard` change carries that investment (live progress in the TUI) and may circle back to capture stdout for a quieter `init` mode then. + +## Risks / Trade-offs + +- **Risk: tests pinning exact output strings break en masse.** → Mitigation: rendering routes through a single `InitRenderer` (or equivalent) class with public methods per phase; tests assert on phase-level invariants (column alignment, glyph presence) rather than full-line byte-exact strings. The migration follows the same pattern `add-leaf-brand-mark` used (snapshot regenerate + targeted assertion rewrites), with the test-impact audit happening in tasks step 1. +- **Risk: batched picker is unfamiliar.** → Mitigation: prompt example (`+cursor -copilot`) is shown inline; `n` is a recognised shortcut; the second invalid-input round still proceeds (no infinite loop); `--yes` skips the picker entirely. +- **Risk: detection-driven defaults misfire when users have stale `.cursor/` from a long-uninstalled Cursor.** → Mitigation: detection probes the *config file* presence too (`~/.cursor/mcp.json` for cursor, equivalent for continue), not just the directory. Stale dirs don't carry config files. +- **Risk: `--no-leaf` users get an asymmetric experience (square-bracket fallback may not look as polished).** → Mitigation: column alignment is preserved (every fallback token is 3 chars); the phase-headed layout still works without emoji. Smoke-test the `--no-leaf` path in a screenshot-comparison pair. +- **Risk: `claude-desktop` default-on by detection wires user-tree files where the user expected project-only.** → Mitigation: `claude-desktop` writes user-scope only (today's invariant, unchanged). Picker confirmation is the gate; the user sees the row in the picker before any write happens. `--yes` follows the picker default (detected → on, undetected → off), which is the safer side for ambiguous cases. +- **Trade-off: removing the per-row `[Y/n]` prompt.** Some scripts may have piped per-row answers. None are documented or shipped. The `--yes` non-interactive path is the supported automation interface; we add an `--accept-defaults` alias if needed during migration. +- **Trade-off: `DiffPlex` adds a dependency.** Small (~50 KB, MIT, AOT-friendly). Worth the cost for the `--diff` user value. + +## Migration Plan + +This is an in-place CLI surface change. There's no on-disk state to migrate; the writers' file outputs are unchanged. Two compatibility notes for early adopters: + +1. **Per-row picker input is removed.** Anyone scripting `init` interactively (rare; documented path is `--yes`) needs to switch to either `--yes --client ` (recommended) or pipe a single batched-prompt input string. +2. **`--claude-desktop`'s semantics shift.** From "required to wire" to "force default-on for that picker row." Existing scripts continue to work — the flag still results in `claude-desktop` getting wired — but operators who used it as a "make it appear in the picker" flag now find it visible by default. + +Rollback: `init` is a stateless subcommand. Reverting this change reverts `Cli/InitCli.cs` to today's shape; no committed user files (`/.mcp.json`, etc.) are affected since file content is unchanged. + +## Open Questions + +1. **Auto-chain `init` → `demo`.** Should interactive `init` (post-prewarm, post-write, no conflicts) automatically run the canned `demo` for the "ah, it works" moment? Adds 2–4 s. Decision: defer to a follow-up — out of this change's scope. +2. **`--no-leaf` glyph choice.** `[x]` and `[ ]` are intuitive, but `[*]` is one keystroke faster on some keyboards. Going with `[x]`/`[ ]` to match the broader convention; revisit if user feedback prefers `[*]`. +3. **Should `--diff` imply `--print-only` semantics for that one writer?** Currently `--diff` only kicks in for `SkipExistingDiffers`. A `--diff` run that lands an `Insert` simply writes (the existing file was absent — no diff to render). Decision: leave as-is; `--print-only` is the orthogonal "don't write" flag. diff --git a/openspec/changes/archive/2026-05-11-polish-init-onboarding/notes/test-impact.md b/openspec/changes/archive/2026-05-11-polish-init-onboarding/notes/test-impact.md new file mode 100644 index 00000000..9e7135ae --- /dev/null +++ b/openspec/changes/archive/2026-05-11-polish-init-onboarding/notes/test-impact.md @@ -0,0 +1,64 @@ +# Test impact audit — `polish-init-onboarding` + +Audit covers `tests/DevBitsLab.Mcp.SourceGraph.Tests/` looking for strings that pin today's +init / picker / closing-report output. Each row records the file:line, what's pinned, and the +plan for the migration (substring or invariant style). + +## Pinned strings in init-related tests + +| File:line | Pinned string | Today's source | Migration plan | +|---|---|---|---| +| `OnboardingCliTests.cs:56` | `# would write to:` | `PrintPlanToStdout` | Keep — same prefix preserved in new renderer (`PrintPlanToStdout` unchanged for `--print-only`) | +| `OnboardingCliTests.cs:57` | `.mcp.json` | path in `# would write to:` | Substring, OK | +| `OnboardingCliTests.cs:58` | `"mcpServers"` | writer JSON content | Substring, OK | +| `OnboardingCliTests.cs:59` | `"sourcegraph"` | writer JSON content | Substring, OK | +| `OnboardingCliTests.cs:67` | `"servers"` | Copilot writer JSON | Substring, OK | +| `OnboardingCliTests.cs:68` | `"type": "stdio"` | Copilot writer JSON | Substring, OK | +| `OnboardingCliTests.cs:69` | NOT `"mcpServers"` | Copilot writer JSON | Substring, OK | +| `OnboardingCliTests.cs:82-84` | `.mcp.json`, `.vscode/mcp.json`, `.cursor/mcp.json` | `# would write to: ` | Substring, OK — relative paths preferred under new render | +| `OnboardingCliTests.cs:148` | NOT `.cursor/mcp.json` | `--no-cursor` filters writer | Substring, OK | +| `OnboardingCliTests.cs:159` | NOT `claude_desktop_config.json` | `--print-only` without `--claude-desktop` | **CHANGES**: Claude Desktop is now visible in picker; for `--print-only` the writer still runs only when picker default-on OR `--claude-desktop`. With no detection, default is off → snippet stays absent. Test stays as-is. | +| `OnboardingCliTests.cs:171` | `"wrote"` | closing-report token | Substring, OK — new renderer still uses verb `wrote` after the leaf glyph | +| `OnboardingCliTests.cs:178` | `"no change"` | closing-report `NoOpAlreadyMatches` | Substring — new renderer emits same verb after glyph | +| `OnboardingCliTests.cs:190` | `"skipped (unsupported)"` | closing-report `SkipUnsupported` | Substring — new renderer emits `skipped — unsupported` or similar; **UPDATE** to looser `"unsupported"` substring or matching new shape | +| `OnboardingCliTests.cs:399` | `"🌿"` (in DemoCliTests) | Demo leaf marker | Not init; left alone | + +## Test files with no impact + +- `OnboardingDetectorTests.cs` — exercises `DetectAsync` data shape, not init output. New + detection-driven default booleans are additive: existing tests continue to pass. +- `ClientConfigWritersTests.cs` — exercises writer plan/apply contract, not the CLI surface. +- `DoctorCliTests.cs`, `DemoCliTests.cs` (in same file) — Change 2 owns Doctor; demo is + unaffected. + +## Tests piping per-row picker answers + +None. The existing tests all use `--yes` (non-interactive) or `--print-only`. No piped-stdin +per-row `[Y/n]` flow exists today, so the batched picker swap doesn't break any current test. + +## Summary + +- 14 pinned-string assertions in `OnboardingCliTests.cs`. +- 12 stay as-is (substring on stable tokens — `wrote`, `no change`, `# would write to:`, + writer-content JSON snippets, file paths). +- 1 changes phrasing slightly (`"skipped (unsupported)"` → `"unsupported"` substring) because + the new layout drops parentheses in favour of an em-dash detail. +- 1 is on a Claude Desktop visibility check that still works under the new defaults (default-off + without detection → still absent in `--print-only` output). +- 0 per-row picker tests need rewriting. + +New tests to add under `tests/.../Cli/Rendering/`: + +- `StateGlyphTests.cs` +- `PathDisplayTests.cs` +- `InitRendererTests.cs` +- `BatchedPickerInputTests.cs` +- `UnifiedDiffRendererTests.cs` + +New test scenarios to add into `OnboardingCliTests.cs`: + +- Detection-driven picker default-on for `cursor` (when `.cursor/` exists). +- `--claude-desktop` flag forces default-on without detection. +- `--diff` renders unified diff on `SkipExistingDiffers`. +- `--diff` no-op on `Insert`. +- `--diff --force` writes after printing diff. diff --git a/openspec/changes/archive/2026-05-11-polish-init-onboarding/proposal.md b/openspec/changes/archive/2026-05-11-polish-init-onboarding/proposal.md new file mode 100644 index 00000000..19e7d1f8 --- /dev/null +++ b/openspec/changes/archive/2026-05-11-polish-init-onboarding/proposal.md @@ -0,0 +1,37 @@ +## Why + +The `sourcegraph-mcp init` subcommand is the first surface most users meet, and today its output is hard to scan and follow: glyphs are inconsistent (`✓ ⚠ ⓘ` for outcomes plus a banner-only `🌿`), labels collide visually with values, the interactive client picker fires `[Y/n]` once per client (N inline prompts), and full absolute paths bury the structure. The brand mark `🌿` exists but isn't doing semantic work — it's decoration. We want a polished, phase-organised layout that treats the leaf as the universal "selected / passed / on" signal, plus a batched picker that asks once with a compact `+slug / -slug` grammar. + +Separately, `Claude Desktop` is invisible to users who didn't already know about the `--claude-desktop` flag — it's filtered out of the picker entirely unless that flag is set. With Claude Desktop being a primary surface for many users, that asymmetry hides a supported integration. We want it discoverable in the picker (with a sensible default-off when not detected) without auto-wiring user-tree files behind anyone's back. + +This change is the foundation for the operator console work that follows (`add-operator-status`, `add-operator-dashboard`): both downstream changes consume init's polished writer surface and the leaf-state glyph language defined here. + +## What Changes + +- **Output format** — `init` (and the closing report on `init --print-only`) is reorganised under named phase headings: `Environment`, `Clients to wire`, `Apply`, `Pre-warm` (when run), `Next`. Inside each phase, two-space indented rows with a leading state glyph and aligned columns. No more `,-22` colon-padded labels. +- **Leaf-as-state glyph language** — the `🌿` glyph is promoted from banner decoration to the universal positive-state marker (selected, passed, wrote, unchanged, indexed). A small companion vocabulary covers the other states: `·` (off / not selected), `⚠` (soft warning), `✗` (hard skip / conflict), `—` (unsupported / N/A). The `--no-leaf` / `SOURCEGRAPH_NO_LEAF=1` opt-out flips the leaf to the ASCII checkbox `[x]` and the off-state to `[ ]`, preserving the same scan pattern. +- **Batched interactive picker** — replaces the per-row `[Y/n]` prompts with a single confirmation: defaults rendered as a list (each row marked `🌿` for default-on, `·` for default-off), followed by one prompt that accepts `Enter` (apply defaults), `n` (deselect everything), or a `+slug / -slug` edit string (e.g. `+cursor -copilot`). Slugs that don't parse warn and are ignored. +- **Claude Desktop in the default flow** — `claude-desktop` becomes a first-class row in the picker, always shown, with its default-on driven by detection: ON if its platform-specific config file exists on disk, OFF otherwise. The `--claude-desktop` flag is preserved as a back-compat alias for "force default-on for this row." `claude-desktop` always writes user-scope (no project-scope path exists; unchanged from today). +- **Detection-driven defaults across the picker** — `cursor` and `continue` rows likewise lean on detection: their default is ON when a wired-or-existing config file is found, OFF otherwise. (`claude-code` and `copilot` keep their default-ON status because their project-scope targets are nearly always desired in committed-config projects.) +- **`--diff` flag** — when a writer would land in `SkipExistingDiffers` (existing `sourcegraph` entry differs from what we would write), `--diff` surfaces a unified diff of `existing` vs `proposed` to stdout instead of just naming the conflict. Read-only; pairs with `--force` for the act-on-it path. +- **Path display** — paths in the `Apply` and `Clients` phases are rendered relative to `--root` when the path is inside the repo (e.g. `.mcp.json` instead of `/Users/jacques/work/MyApp/.mcp.json`); user-tree paths are rendered with `~/` substitution; full absolute paths only on conflict-error detail lines. +- **Pre-warm rendering** — the `Pre-warm` phase collapses to a single status line on success (`🌿 indexed MyApp.slnx in 11.4s`). The child indexer's stdout still inherits as today (no buffering of long-running indexer output), but a single summary line follows on completion. Failure path keeps full detail. +- **Closing-report symmetry** — every `WriterAction` outcome is reported with its description, including `SkipHasComments` (which today prints its snippet to stdout mid-run but doesn't surface the description in the closing report). + +## Capabilities + +### New Capabilities + + +### Modified Capabilities + +- `cli`: The existing `init subcommand` requirement is updated to (a) declare the polished output shape (phase headings, leaf-state glyph language, relative paths), (b) describe the batched picker grammar, (c) drop the "Claude Desktop is opt-in only" carve-out from the description, and (d) add `--diff` to the flag list. Three new requirements are added: `init output state-glyph language`, `init batched client picker`, and `init --diff conflict preview`. +- `mcp-config`: The existing `Project-scoped defaults; user-scope opt-in` requirement is updated to remove the clause forbidding Claude Desktop auto-selection. Detection-driven defaults are now permitted for any client with a user-scope path; the project-scope safety property (no user-tree writes without explicit consent) is preserved by making "consent" include picker confirmation, not just the legacy `--user-` flag. + +## Impact + +- **Code**: `src/DevBitsLab.Mcp.SourceGraph.Server/Cli/InitCli.cs` — the bulk of the rewrite; new `Render*` helpers replace the inline `Console.WriteLine` calls; new `BatchedPickerInput.Parse` for the `+/- slug` grammar; new `RelativePathRenderer`. `Cli/OnboardingDetector.cs` — small additions to expose detection signals (already-installed, already-wired) to the picker default logic. `Cli/CommandLine.cs` — adds `--diff`. `Cli/DoctorCli.cs` — left alone in this change (Change 2 reworks it). `Cli/ClientConfigWriters/` — no signature changes; the `WriterPlan.ContentBytes` plumbing already exposes the "would-write" content the diff needs. +- **Spec**: One delta on `cli` (one MODIFIED + three ADDED requirements). One delta on `mcp-config` (one MODIFIED requirement). +- **Tests**: `tests/Server.Tests/InitCliTests.cs` (or equivalent) — existing tests that pin exact output strings (banner, summary lines, picker prompt format) update in lock-step. New tests cover: leaf-state-glyph rendering, batched picker grammar (`+/- slug` parsing), Claude Desktop visibility in picker, detection-driven defaults, `--diff` rendering. Snapshot/golden-file tests, if any, regenerate. +- **Public API / dependencies**: No new NuGet dependencies. No MCP wire format changes. No schema migrations. Only user-visible CLI text + one new flag. Back-compat: `--claude-desktop` flag still works (now means "force default-on for that row"); per-row `Y/n` answer is no longer accepted (any test that piped per-line answers needs to switch to the batched-input form). The legacy interactive flow is removed, not deprecated, since a piped `--yes` answer never used the per-row prompts in the first place — non-tty flows are unaffected. +- **Documentation**: `README.md` Quickstart section updates to reflect the new `init` output shape; the `Wiring it into an MCP client` Claude Desktop subsection updates to note that `--claude-desktop` is no longer required for the picker to surface the row. `CLAUDE.md` adds a one-liner naming the leaf-state-glyph language. diff --git a/openspec/changes/archive/2026-05-11-polish-init-onboarding/specs/cli/spec.md b/openspec/changes/archive/2026-05-11-polish-init-onboarding/specs/cli/spec.md new file mode 100644 index 00000000..793b7868 --- /dev/null +++ b/openspec/changes/archive/2026-05-11-polish-init-onboarding/specs/cli/spec.md @@ -0,0 +1,153 @@ +## MODIFIED Requirements + +### Requirement: init subcommand +The CLI SHALL accept `sourcegraph-mcp init` that runs an interactive (default) or flag-driven (`--yes`) onboarding flow producing per-client MCP configuration files, optionally pre-warming the index, and printing a closing report. Default writes SHALL be project-scoped (under `--root`, default CWD); user-scope writes SHALL require an explicit per-client opt-in flag OR — for clients with no project-scope path — interactive picker confirmation. + +The subcommand SHALL accept the following flags: + +- `--yes` / `-y` — non-interactive; accept all picker defaults documented in this requirement. +- `--client ` (repeatable) — restrict to the listed clients (`claude-code`, `copilot`, `cursor`, `continue`, `claude-desktop`). +- `--no-` — exclude one client even if it would otherwise be auto-selected. +- `--user-` — write that client's config to its user-scope path instead of the project-scope path. +- `--claude-desktop` — force the picker default for the `claude-desktop` row to ON, overriding detection. (Back-compat alias from when the flag gated row visibility entirely; the row is now always visible in the picker.) +- `--solution ` (repeatable) — override solution-discovery; passes through to `init-scopes` core logic when multiple solutions are configured. +- `--no-embeddings` / `--no-history` — propagate the corresponding `serve` flag into the written `args` array. +- `--prewarm` / `--no-prewarm` — opt in to / out of running `RoslynIndexer.IndexSolutionOnceAsync` after writing configs. +- `--install-mode {global,local-tool,in-repo}` — choose the resulting `command`/`args` shape: `global` invokes `sourcegraph-mcp` directly (default); `local-tool` emits `command: "dotnet"`, `args: ["sourcegraph-mcp", ...]` and assumes the repo already has a `.config/dotnet-tools.json` listing the tool (created via `dotnet new tool-manifest && dotnet tool install DevBitsLab.Mcp.SourceGraph.Tool`; `init` does not create or merge the manifest in v1); `in-repo` emits `command: "dotnet"`, `args: ["run", "--project", "", "--no-build", "--", "serve", ...]`. +- `--print-only` — print the per-client config snippets to stdout with `# would write to: ` comment lines; write no files. +- `--force` — overwrite an existing `sourcegraph` server entry without prompting (in interactive mode) or without skipping (in `--yes` mode); never modifies other servers' entries. +- `--diff` — when a writer would emit `SkipExistingDiffers`, print a unified diff of the existing target file versus the proposed `ContentBytes` to stdout before recording the skip; read-only without `--force`. See the `init --diff conflict preview` requirement for full semantics. +- `--root ` — repository root (default CWD). + +#### Scenario: Interactive init wires Claude Code in a fresh repo +- **WHEN** a user runs `sourcegraph-mcp init` in a repo containing `MySln.slnx` and accepts the picker defaults at the single batched prompt +- **THEN** `/.mcp.json` is written with the `mcpServers.sourcegraph` entry; the closing report names the file written and suggests `sourcegraph-mcp demo` as the next step + +#### Scenario: Non-interactive init for CI +- **WHEN** a user runs `sourcegraph-mcp init --yes --client copilot --client claude-code --print-only` from a CI script +- **THEN** the command exits `0` after writing nothing, having printed two config snippets to stdout — one prefixed with `# would write to: /.vscode/mcp.json` (Copilot's `servers`/`type` shape) and one prefixed with `# would write to: /.mcp.json` (Claude Code's `mcpServers` shape) + +#### Scenario: Init merges into an existing config without clobbering other servers +- **WHEN** the user has a pre-existing `/.mcp.json` containing an `mcpServers.other-server` entry, and `sourcegraph-mcp init --yes --client claude-code` is invoked +- **THEN** the resulting `.mcp.json` contains both `mcpServers.other-server` (unchanged) and a new `mcpServers.sourcegraph` entry; the closing report's `Apply` phase shows a `🌿 wrote` row for `claude-code` and notes that an existing other-server entry was preserved + +#### Scenario: Init refuses to overwrite a differing existing entry without --force +- **WHEN** the user has a pre-existing `/.mcp.json` containing an `mcpServers.sourcegraph` entry whose `args` differ from what we would write, and `sourcegraph-mcp init --yes --client claude-code` (without `--force`) is invoked +- **THEN** the file is left unchanged; the `Apply` phase contains a `✗ conflict — skipped` row for `claude-code` with a hanging detail line naming the file and suggesting `--diff` to inspect or `--force` to overwrite; the process exits `2` + +#### Scenario: Claude Desktop is always visible in the picker +- **WHEN** `sourcegraph-mcp init` is invoked interactively with no `--claude-desktop` flag and no detected Claude Desktop config file +- **THEN** the `Clients to wire` phase renders a `claude-desktop` row marked off-default (state glyph `·`, or `[ ]` under `--no-leaf`); pressing Enter at the batched picker prompt does NOT wire Claude Desktop, and the closing report does NOT include a Claude Desktop entry + +#### Scenario: Detection bumps Claude Desktop default-on +- **WHEN** `sourcegraph-mcp init --yes` is invoked on a system where `~/Library/Application Support/Claude/claude_desktop_config.json` already exists, with no explicit `--claude-desktop` flag +- **THEN** Claude Desktop's picker row is default-on; the `Apply` phase contains a `🌿` row for `claude-desktop` (user scope); no project-scope file is created or modified for Claude Desktop + +#### Scenario: --claude-desktop forces default-on +- **WHEN** `sourcegraph-mcp init --yes --claude-desktop` is invoked on a system where no Claude Desktop config file exists +- **THEN** Claude Desktop is wired anyway (a new platform-specific user-scope config file is created and the `mcpServers.sourcegraph` entry is inserted); the `Apply` phase shows a `🌿 wrote` row for `claude-desktop` whose path renders with `~/` substitution where applicable + +#### Scenario: Pre-warm runs after writing configs +- **WHEN** `sourcegraph-mcp init --yes --client claude-code --prewarm --solution ./MySln.slnx` is invoked +- **THEN** after the `.mcp.json` write completes, `RoslynIndexer.IndexSolutionOnceAsync` is invoked against `./MySln.slnx`; the `Pre-warm` phase summary line reads `🌿 indexed MySln.slnx in T.Ts` (or `[x] indexed MySln.slnx in T.Ts` under `--no-leaf`) + +## ADDED Requirements + +### Requirement: init output state-glyph language +The `init` subcommand SHALL render its human-readable output (banner, detection summary, picker rows, apply rows, pre-warm summary, closing report) using a single state-glyph vocabulary applied uniformly across every phase. The five states and their tokens SHALL be: + +- **on / passed / wrote / unchanged**: `🌿` (U+1F33F followed by U+0020); ASCII fallback `[x] ` under `--no-leaf` or `SOURCEGRAPH_NO_LEAF=1` +- **off / not selected**: `·` (U+00B7 followed by U+0020); ASCII fallback `[ ] ` +- **soft warning**: `⚠` (U+26A0 followed by U+0020); ASCII fallback `[!] ` +- **hard skip / conflict**: `✗` (U+2717 followed by U+0020); ASCII fallback `[X] ` +- **unsupported / N/A**: `—` (U+2014 followed by U+0020); ASCII fallback `[-] ` + +Column alignment SHALL be preserved across the emoji and ASCII forms by treating each token's width as three display cells. + +The `init` subcommand SHALL organise its output under named phase headings: `Environment`, `Clients to wire`, `Apply`, `Pre-warm` (omitted when no pre-warm runs in this invocation), and `Next`. Phase headings SHALL appear at the left margin without a leading state glyph; rows under a phase heading SHALL be indented exactly two spaces. + +The opt-out mechanism (`--no-leaf` CLI flag and `SOURCEGRAPH_NO_LEAF=1` env var) SHALL apply to the entire state-glyph vocabulary and to any banner `🌿` mark in `init` output — the same `LeafFormatter.Suppressed` flag that controls server tool responses. + +#### Scenario: Successful first-run renders all five phases +- **WHEN** `sourcegraph-mcp init --yes` is invoked in a repo with one solution and Claude Code's project config absent, with `--prewarm` set +- **THEN** stdout contains exactly five phase headings — `Environment`, `Clients to wire`, `Apply`, `Pre-warm`, `Next` — each on its own line at the left margin; rows under each heading are two-space-indented; the `Apply` phase contains a row beginning with `🌿 wrote` for the `claude-code` writer + +#### Scenario: --no-leaf substitutes ASCII fallbacks +- **WHEN** `sourcegraph-mcp init --yes --no-leaf` is invoked +- **THEN** no `🌿` (U+1F33F) appears on stdout; every state-glyph position contains a three-character ASCII token from the set `[x] `, `[ ] `, `[!] `, `[X] `, `[-] `; phase headings render identically to the emoji-enabled path; the banner line shows `SourceGraph init` without a leading leaf + +#### Scenario: --print-only omits the Pre-warm phase +- **WHEN** `sourcegraph-mcp init --yes --print-only` is invoked +- **THEN** the `Pre-warm` phase heading does not appear on stdout; the `Apply` phase still renders with each row showing the would-write verb under the configured glyph language + +#### Scenario: SOURCEGRAPH_NO_LEAF env var matches --no-leaf flag behaviour +- **WHEN** `sourcegraph-mcp init --yes` is invoked with `SOURCEGRAPH_NO_LEAF=1` in the environment and without the `--no-leaf` flag +- **THEN** the rendered output is byte-identical to the `--yes --no-leaf` invocation against the same repo state + +### Requirement: init batched client picker +When `init` is invoked interactively (stdin is a tty AND `--yes` was not passed AND `--print-only` was not passed), the `Clients to wire` phase SHALL render every supported client as a row showing its default-selected state with the state-glyph language defined in the `init output state-glyph language` requirement (`🌿`/`[x]` selected, `·`/`[ ]` off), then prompt the user exactly once with a batched prompt allowing one of the following responses: + +1. Empty input, `y`, or `Y` — accept the displayed defaults verbatim. +2. `n` or `N` — deselect every row; no client is wired. +3. A whitespace-separated sequence of tokens of the form `+` or `-` — start from the displayed defaults, then flip each named client (`+` adds to the selection, `-` removes from it). Slugs that don't match the supported set SHALL produce a warning on stderr and SHALL be ignored. + +On any input that doesn't match one of those three forms, the prompt SHALL be re-displayed once with the example shown; a second invalid input SHALL be treated as `n` (deselect all) and the run SHALL proceed. + +The supported slug set SHALL match the `--client` flag's supported values: `claude-code`, `copilot`, `cursor`, `continue`, `claude-desktop`. + +The picker default for each client SHALL be computed from `OnboardingDetector` signals: + +- `claude-code`: default-on always. +- `copilot`: default-on always. +- `cursor`: default-on iff `/.cursor/` directory or `~/.cursor/mcp.json` exists; otherwise default-off. +- `continue`: default-on iff `/.continue/` directory or `~/.continue/mcp/sourcegraph.yaml` exists; otherwise default-off. +- `claude-desktop`: default-on iff the platform-specific Claude Desktop config file exists, OR if the `--claude-desktop` flag was passed; otherwise default-off. + +The `--client ` flag SHALL override the picker entirely (the rows are rendered but only the listed clients are selected, regardless of defaults). The `--no-` flag SHALL force the named client off in the displayed defaults. + +#### Scenario: Empty input accepts displayed defaults +- **WHEN** the picker shows `claude-code` and `copilot` as default-on (`🌿`) and `cursor`, `continue`, `claude-desktop` as default-off (`·`); user presses Enter at the prompt +- **THEN** `claude-code` and `copilot` writers run; `cursor`, `continue`, `claude-desktop` writers do not + +#### Scenario: `+slug -slug` edits the default selection +- **WHEN** the picker shows `claude-code` and `copilot` as default-on; user types `+cursor -copilot` and presses Enter +- **THEN** `claude-code` and `cursor` writers run; `copilot` writer does not; `continue` and `claude-desktop` writers do not (off-defaults preserved) + +#### Scenario: Unknown slug warns and is ignored +- **WHEN** the user types `+sublime` at the picker prompt +- **THEN** a warning is printed to stderr naming the unknown slug; the picker proceeds with the displayed defaults (the unknown token is dropped) + +#### Scenario: `n` deselects all +- **WHEN** the user types `n` at the picker prompt +- **THEN** no writer runs; the `Apply` phase shows the line `No clients selected. Nothing to do.`; the process exits `0` + +#### Scenario: Detection-driven cursor default +- **WHEN** the user runs `sourcegraph-mcp init` interactively in a repo where `/.cursor/` exists; defaults are computed +- **THEN** the picker's `cursor` row is rendered with the `🌿` (or `[x]`) state glyph; pressing Enter wires `cursor` along with `claude-code` and `copilot` + +#### Scenario: Detection-driven continue default-off when no install fingerprint +- **WHEN** the user runs `sourcegraph-mcp init` interactively in a repo with no `.continue/` directory and no `~/.continue/` directory +- **THEN** the picker's `continue` row is rendered with the `·` (or `[ ]`) state glyph; pressing Enter does NOT wire `continue` + +### Requirement: init --diff conflict preview +The `init` subcommand SHALL accept a `--diff` flag. When the flag is set, AND a writer's plan would land in `SkipExistingDiffers`, AND the run is not under `--print-only`, the subcommand SHALL render a unified diff with three lines of surrounding context comparing the existing target file's bytes against the writer's proposed `ContentBytes`, printed to stdout under the conflicting `Apply` row as a hanging-detail block. The diff `---` header SHALL name the target file path; the `+++` header SHALL name `.proposed`. + +`--diff` SHALL be read-only by default. When combined with `--force`, the diff SHALL be printed first AND the write SHALL then proceed (the existing `--force` behaviour, augmented with the diff print). Without `--force`, the file SHALL be left unchanged and the process SHALL exit `2`. + +`--diff` SHALL be a no-op for plans other than `SkipExistingDiffers` (`Insert`, `NoOpAlreadyMatches`, `ReplaceOurs`, `SkipHasComments`, `SkipUnsupported`). + +#### Scenario: --diff shows the unified diff and exits 2 without --force +- **WHEN** `/.mcp.json` contains an `mcpServers.sourcegraph` entry whose `args` array differs from what `init` would write, and `sourcegraph-mcp init --yes --client claude-code --diff` is invoked +- **THEN** the `Apply` phase shows `✗ conflict — skipped claude-code .mcp.json` (or `[X] conflict — skipped ...` under `--no-leaf`); a unified diff is printed below the row indented one level further, with `---` and `+++` headers naming `.mcp.json` and `.mcp.json.proposed` respectively, and `-`/`+` markers on the differing lines; the file is not modified; the process exits `2` + +#### Scenario: --diff combined with --force prints diff then writes +- **WHEN** the same conflicting `/.mcp.json` is present and `sourcegraph-mcp init --yes --client claude-code --diff --force` is invoked +- **THEN** the unified diff is printed first; then the `Apply` phase contains `🌿 replaced claude-code .mcp.json` (or `[x] replaced ...` under `--no-leaf`); the file is rewritten with the proposed content; the process exits `0` + +#### Scenario: --diff on an insert plan is a no-op +- **WHEN** `/.mcp.json` does not exist and `sourcegraph-mcp init --yes --client claude-code --diff` is invoked +- **THEN** the `Apply` phase shows `🌿 wrote claude-code .mcp.json`; no diff output is printed; the process exits `0` + +#### Scenario: --diff on a SkipHasComments plan is a no-op +- **WHEN** `/.mcp.json` contains line comments (`// ...`) outside string literals and `sourcegraph-mcp init --yes --client claude-code --diff` is invoked +- **THEN** today's comment-aware degraded-mode behaviour runs unchanged: the would-write snippet is printed to stdout with the `# config has comments at ...` warning, the file is not modified, and no unified diff is produced; the process exits `0` diff --git a/openspec/changes/archive/2026-05-11-polish-init-onboarding/specs/mcp-config/spec.md b/openspec/changes/archive/2026-05-11-polish-init-onboarding/specs/mcp-config/spec.md new file mode 100644 index 00000000..67b9485f --- /dev/null +++ b/openspec/changes/archive/2026-05-11-polish-init-onboarding/specs/mcp-config/spec.md @@ -0,0 +1,26 @@ +## MODIFIED Requirements + +### Requirement: Project-scoped defaults; user-scope opt-in +The `init` subcommand SHALL default each client's write target to that client's project-scoped path when one exists. Writing to a user-scoped path SHALL require explicit per-client opt-in via one of: + +1. The `--user-` flag (for clients that have both project- and user-scope paths). +2. A `--client ` selection naming a client whose only target is user-scope (today: `claude-desktop`). +3. Interactive picker confirmation: the row for a user-scope-only client SHALL be visible by default and SHALL be selected for the run (either by being default-on under detection OR by being added via `+slug` in the batched prompt). + +Claude Desktop SHALL remain user-scope only (no project-scope path exists). The picker row for Claude Desktop SHALL be visible by default in interactive mode. Claude Desktop's default-on state in the picker SHALL be driven by detection of its platform-specific config file (`%APPDATA%\Claude\claude_desktop_config.json` on Windows, `~/Library/Application Support/Claude/claude_desktop_config.json` on macOS, `~/.config/Claude/claude_desktop_config.json` on Linux): default-on iff the file exists, default-off otherwise. The `--claude-desktop` CLI flag SHALL force the default-on state regardless of detection. + +#### Scenario: Default init in a fresh repo skips Claude Desktop +- **WHEN** `sourcegraph-mcp init --yes` is invoked with no `--user-*` flags, no `--claude-desktop` flag, no `--client` flag, and no detected Claude Desktop config file on the platform-specific path, in a repo with a `.slnx` and Claude Code installed +- **THEN** `/.mcp.json` is written; no file under the user's home directory is read or written; Claude Desktop is not wired + +#### Scenario: Detected Claude Desktop config defaults the picker on +- **WHEN** `sourcegraph-mcp init --yes` is invoked on a system where `~/Library/Application Support/Claude/claude_desktop_config.json` already exists (macOS path), with no explicit `--claude-desktop` flag +- **THEN** the Claude Desktop user-scope file is written or merged into (preserving any non-`sourcegraph` server entries already present); the closing report's `Apply` phase shows a `🌿` row for `claude-desktop` and names the user-scope path + +#### Scenario: --user-cursor writes to home +- **WHEN** `sourcegraph-mcp init --yes --client cursor --user-cursor` is invoked +- **THEN** `~/.cursor/mcp.json` is written or merged into; `/.cursor/mcp.json` is not touched; the closing report names the home-tree path explicitly + +#### Scenario: --claude-desktop forces wiring without a detected file +- **WHEN** `sourcegraph-mcp init --yes --claude-desktop` is invoked on a system where the platform-specific Claude Desktop config file does not exist +- **THEN** the user-scope Claude Desktop file is created at the platform-specific path; a new `mcpServers.sourcegraph` entry is inserted; the closing report names the created user-scope path diff --git a/openspec/changes/archive/2026-05-11-polish-init-onboarding/tasks.md b/openspec/changes/archive/2026-05-11-polish-init-onboarding/tasks.md new file mode 100644 index 00000000..e322b8f7 --- /dev/null +++ b/openspec/changes/archive/2026-05-11-polish-init-onboarding/tasks.md @@ -0,0 +1,54 @@ +## 1. Test impact audit (no code yet) + +- [x] 1.1 Enumerate every test that asserts on exact init output strings (`grep -rn -E '"✓|"⚠|"ⓘ|Heading|"Which clients|"Summary:|"Next:|"Pre-warming" tests/`); record the list in `notes/test-impact.md` under this change directory. +- [x] 1.2 For each pinned assertion, decide whether it stays string-exact (and updates to the new layout) or migrates to substring/invariant style. Mark each entry in the checklist. +- [x] 1.3 Identify tests that pipe per-row picker answers into `init` (rare; the supported automation is `--yes`). Each one needs to switch to `--yes --client ` or to the single batched-prompt input form. + +## 2. Foundation: state-glyph language + render helper + +- [x] 2.1 Add `src/DevBitsLab.Mcp.SourceGraph.Server/Cli/Rendering/StateGlyph.cs` exposing `static string For(StateGlyphKind kind)` that returns the emoji form by default and the ASCII fallback when `LeafFormatter.Suppressed` is true. Cover the five `StateGlyphKind` values: `On`, `Off`, `Warn`, `Skip`, `Unsupported`. +- [x] 2.2 Add unit tests for `StateGlyph` covering: every kind in emoji mode, every kind in `--no-leaf` mode, and the column-width property (every returned token is three display cells wide). +- [x] 2.3 Add `src/DevBitsLab.Mcp.SourceGraph.Server/Cli/Rendering/PathDisplay.cs` exposing `static string Render(string absolute, string root, string? homePath)` that prefers repo-relative form (no leading `./`), falls back to `~/`-substituted form for user-tree paths, and uses absolute otherwise. Unit tests cover the three branches plus the "absolute under both" tiebreak (repo-relative wins). +- [x] 2.4 Add `src/DevBitsLab.Mcp.SourceGraph.Server/Cli/Rendering/InitRenderer.cs` exposing one method per phase: `RenderBanner`, `RenderEnvironment`, `RenderClientsToWire`, `RenderApplyRow`, `RenderPreWarmSummary`, `RenderNext`. Each method writes to an injected `TextWriter` (so tests can capture output without redirecting `Console.Out`). Internal pad widths are constants at the top of the file. +- [x] 2.5 Add tests for `InitRenderer` asserting phase-header presence, two-space indentation under headers, column alignment within each phase, and that no phase emits anything when its inputs are empty (e.g. `Pre-warm` is omitted on null input). + +## 3. Detection-driven picker defaults + Claude Desktop visibility + +- [x] 3.1 Extend `OnboardingDetector.DetectAsync` (or add a helper) to expose per-client "default-on?" booleans alongside today's `DetectedClientConfig` list. Match the rules in the spec's `init batched client picker` requirement: `claude-code` and `copilot` always on; `cursor` on iff `.cursor/` dir or `~/.cursor/mcp.json`; `continue` on iff `.continue/` dir or `~/.continue/mcp/sourcegraph.yaml`; `claude-desktop` on iff platform-specific config file present. +- [x] 3.2 Remove the `claudeDesktopOptedIn` gate from `InteractiveClientPicker` in `InitCli.cs`. Claude Desktop is always visible; its initial selection state comes from the detection-derived default (`true` if detected, `false` otherwise) OR is forced `true` by `--claude-desktop`. +- [x] 3.3 Update tests in `tests/Server.Tests/.../InitCliTests.cs` (or equivalent): add a fixture covering the detection-driven default-on for each client; add the `--claude-desktop` force-on path; remove the test asserting `claude-desktop` was filtered out of the picker. + +## 4. Batched picker grammar + +- [x] 4.1 Add `src/DevBitsLab.Mcp.SourceGraph.Server/Cli/BatchedPickerInput.cs` exposing `static PickerResult Parse(string raw, IReadOnlySet defaults, IReadOnlySet knownSlugs)`. Result carries the resolved selection set plus a list of unknown slugs that produced warnings. Empty/`y`/`Y` returns defaults; `n`/`N` returns empty selection; `+/-` tokens flip from defaults. +- [x] 4.2 Add unit tests covering each branch: empty input, `y`, `n`, single `+slug`, single `-slug`, mixed `+a -b +c`, unknown slug, malformed token (no `+`/`-` prefix). +- [x] 4.3 Replace `InteractiveClientPicker` in `InitCli.cs` with a single-prompt flow that calls `BatchedPickerInput.Parse`, re-prompts once on invalid input, and proceeds with `n`-behaviour on second invalid input. +- [x] 4.4 Update tests: any test that previously sent N per-row answers must now send one line of input. Verify the picker re-prompt path and the `n` shortcut. + +## 5. `--diff` flag + +- [x] 5.1 Add `DiffPlex` NuGet reference to `src/DevBitsLab.Mcp.SourceGraph.Server/DevBitsLab.Mcp.SourceGraph.Server.csproj` (pin to a current 1.x release; AOT-friendly). +- [x] 5.2 Add `--diff` flag to `CommandLine.cs` (parallel to `--force` and `--print-only`). +- [x] 5.3 Add `src/DevBitsLab.Mcp.SourceGraph.Server/Cli/Rendering/UnifiedDiffRenderer.cs` exposing `static void Render(byte[] existing, byte[] proposed, string fromLabel, string toLabel, TextWriter writer, int contextLines = 3)`. Use `DiffPlex.Chunkers.LineChunker` to split, render `---`/`+++` headers, then standard `@@ -a,b +c,d @@` hunks with three lines of context. +- [x] 5.4 In `InitCli.RunAsync`, when `cli.Diff && plan.Action == WriterAction.SkipExistingDiffers && !cli.PrintOnly`, call `UnifiedDiffRenderer.Render(existing, plan.ContentBytes, targetPath, targetPath + ".proposed", Console.Out)` after the `Apply` row prints and before recording the result. +- [x] 5.5 Tests cover: diff renders on `SkipExistingDiffers` without `--force` (exit 2); diff renders then writes proceed with `--force` (exit 0); `--diff` is no-op for `Insert`, `NoOpAlreadyMatches`, `SkipHasComments`. + +## 6. Phase-headed layout, leaf-state output + +- [x] 6.1 Replace the inline `Console.WriteLine` calls in `InitCli.RunAsync` and `PrintDetectionSummary` with calls into `InitRenderer`. The `Environment` phase consumes the detection result; the `Clients to wire` phase consumes the picker output; the `Apply` phase consumes the per-writer `WriterRunResult` list as it accumulates; the `Pre-warm` phase consumes the `PrewarmAsync` summary; the `Next` phase emits the verify suggestion. +- [x] 6.2 Update `PrintClosingReport` to render through `InitRenderer.RenderApplyRow` per result so every plan outcome — including `SkipHasComments` — surfaces its description in the report (close gap from today where `SkipHasComments` description prints mid-run but not in the report). +- [x] 6.3 Update `PrintPlanToStdout` (the `--print-only` helper) to emit the same row shape for `--print-only` mode — without phase headings other than `Apply`, since detection + picker phases are absent in `--yes --print-only` runs. Verify against the existing scenarios in the `init subcommand` requirement. +- [x] 6.4 Update tests: snapshot-based assertions regenerate; structural assertions (phase headings present in order, two-space indent under each, leaf glyph for `Insert` outcomes) replace any line-byte-exact assertions that fall through. + +## 7. Pre-warm summary line + +- [x] 7.1 In `PrewarmAsync`, after `WaitForExitAsync`, replace today's `Console.WriteLine($" pre-warm: exit {p.ExitCode} in {sw.Elapsed.TotalSeconds:F1}s")` with `InitRenderer.RenderPreWarmSummary(p.ExitCode, sw.Elapsed)`. On exit 0 the summary line uses `🌿 indexed in s`; on non-zero exit it uses `⚠ pre-warm exit after s`. +- [x] 7.2 Confirm the child indexer's stdout is still inherited (today's design — visibility during long-running indexer runs preserved). The summary line follows on completion. +- [x] 7.3 Tests: invoke `init --prewarm` against a fast fixture solution; assert the summary line shape under emoji and `--no-leaf` modes. + +## 8. Documentation + +- [x] 8.1 Update the Quickstart section in `README.md` to show the polished `init` output (sample block). Keep the prose flow and the existing flags lines; replace the example output paragraph. +- [x] 8.2 Update the "Claude Desktop" subsection of the README's `Wiring it into an MCP client` section to note that `--claude-desktop` is no longer required for the row to appear in the picker; clarify the detection-driven default-on behaviour. +- [x] 8.3 Add a one-line note in `CLAUDE.md` naming the state-glyph language (`🌿` = positive, `·` = off, `⚠` = warn, `✗` = skip, `—` = unsupported; under `--no-leaf` substitute `[x]/[ ]/[!]/[X]/[-]`) so future Claude sessions know what the rendering means at a glance. +- [x] 8.4 Run `openspec validate polish-init-onboarding --strict`; fix any structural issues. Confirm the change shows in `openspec list --json`. diff --git a/openspec/specs/cli/spec.md b/openspec/specs/cli/spec.md index d9d9da3b..d9935d63 100644 --- a/openspec/specs/cli/spec.md +++ b/openspec/specs/cli/spec.md @@ -221,25 +221,26 @@ The CLI SHALL accept a `sourcegraph-mcp embeddings ` top-level subcommand - **THEN** the printed status reflects the `someorg/other-model` directory only; the active model's data is not included in the report ### Requirement: init subcommand -The CLI SHALL accept `sourcegraph-mcp init` that runs an interactive (default) or flag-driven (`--yes`) onboarding flow producing per-client MCP configuration files, optionally pre-warming the index, and printing a closing report. Default writes SHALL be project-scoped (under `--root`, default CWD); user-scope writes SHALL require an explicit per-client opt-in flag. +The CLI SHALL accept `sourcegraph-mcp init` that runs an interactive (default) or flag-driven (`--yes`) onboarding flow producing per-client MCP configuration files, optionally pre-warming the index, and printing a closing report. Default writes SHALL be project-scoped (under `--root`, default CWD); user-scope writes SHALL require an explicit per-client opt-in flag OR — for clients with no project-scope path — interactive picker confirmation. The subcommand SHALL accept the following flags: -- `--yes` / `-y` — non-interactive; accept all defaults documented in this requirement. +- `--yes` / `-y` — non-interactive; accept all picker defaults documented in this requirement. - `--client ` (repeatable) — restrict to the listed clients (`claude-code`, `copilot`, `cursor`, `continue`, `claude-desktop`). - `--no-` — exclude one client even if it would otherwise be auto-selected. - `--user-` — write that client's config to its user-scope path instead of the project-scope path. -- `--claude-desktop` — required to wire Claude Desktop (no project-scope option exists for that client). +- `--claude-desktop` — force the picker default for the `claude-desktop` row to ON, overriding detection. (Back-compat alias from when the flag gated row visibility entirely; the row is now always visible in the picker.) - `--solution ` (repeatable) — override solution-discovery; passes through to `init-scopes` core logic when multiple solutions are configured. - `--no-embeddings` / `--no-history` — propagate the corresponding `serve` flag into the written `args` array. - `--prewarm` / `--no-prewarm` — opt in to / out of running `RoslynIndexer.IndexSolutionOnceAsync` after writing configs. - `--install-mode {global,local-tool,in-repo}` — choose the resulting `command`/`args` shape: `global` invokes `sourcegraph-mcp` directly (default); `local-tool` emits `command: "dotnet"`, `args: ["sourcegraph-mcp", ...]` and assumes the repo already has a `.config/dotnet-tools.json` listing the tool (created via `dotnet new tool-manifest && dotnet tool install DevBitsLab.Mcp.SourceGraph.Tool`; `init` does not create or merge the manifest in v1); `in-repo` emits `command: "dotnet"`, `args: ["run", "--project", "", "--no-build", "--", "serve", ...]`. - `--print-only` — print the per-client config snippets to stdout with `# would write to: ` comment lines; write no files. - `--force` — overwrite an existing `sourcegraph` server entry without prompting (in interactive mode) or without skipping (in `--yes` mode); never modifies other servers' entries. +- `--diff` — when a writer would emit `SkipExistingDiffers`, print a unified diff of the existing target file versus the proposed `ContentBytes` to stdout before recording the skip; read-only without `--force`. See the `init --diff conflict preview` requirement for full semantics. - `--root ` — repository root (default CWD). #### Scenario: Interactive init wires Claude Code in a fresh repo -- **WHEN** a user runs `sourcegraph-mcp init` in a repo containing `MySln.slnx` and accepts the defaults at every prompt +- **WHEN** a user runs `sourcegraph-mcp init` in a repo containing `MySln.slnx` and accepts the picker defaults at the single batched prompt - **THEN** `/.mcp.json` is written with the `mcpServers.sourcegraph` entry; the closing report names the file written and suggests `sourcegraph-mcp demo` as the next step #### Scenario: Non-interactive init for CI @@ -248,19 +249,27 @@ The subcommand SHALL accept the following flags: #### Scenario: Init merges into an existing config without clobbering other servers - **WHEN** the user has a pre-existing `/.mcp.json` containing an `mcpServers.other-server` entry, and `sourcegraph-mcp init --yes --client claude-code` is invoked -- **THEN** the resulting `.mcp.json` contains both `mcpServers.other-server` (unchanged) and a new `mcpServers.sourcegraph` entry; the closing report says `wired sourcegraph (existing other-server preserved)` +- **THEN** the resulting `.mcp.json` contains both `mcpServers.other-server` (unchanged) and a new `mcpServers.sourcegraph` entry; the closing report's `Apply` phase shows a `● wrote` row (brand-coloured ok-dot, or `[x] wrote` under `--no-leaf`) for `claude-code` and notes that an existing other-server entry was preserved #### Scenario: Init refuses to overwrite a differing existing entry without --force - **WHEN** the user has a pre-existing `/.mcp.json` containing an `mcpServers.sourcegraph` entry whose `args` differ from what we would write, and `sourcegraph-mcp init --yes --client claude-code` (without `--force`) is invoked -- **THEN** the file is left unchanged; the closing report includes a warning naming the file and suggesting `--force` to overwrite, and the process exits `2` +- **THEN** the file is left unchanged; the `Apply` phase contains a `✗ conflict — skipped` row for `claude-code` with a hanging detail line naming the file and suggesting `--diff` to inspect or `--force` to overwrite; the process exits `2` -#### Scenario: Claude Desktop requires --claude-desktop opt-in -- **WHEN** `sourcegraph-mcp init --yes` is invoked with no `--claude-desktop` flag -- **THEN** Claude Desktop's user-scope config file is not touched even if every other auto-detected client is wired +#### Scenario: Claude Desktop is always visible in the picker +- **WHEN** `sourcegraph-mcp init` is invoked interactively with no `--claude-desktop` flag and no detected Claude Desktop config file +- **THEN** the `Clients to wire` phase renders a `claude-desktop` row marked off-default (off-dot `○`, or `[ ]` under `--no-leaf`); pressing Enter at the batched picker prompt does NOT wire Claude Desktop, and the closing report does NOT include a Claude Desktop entry + +#### Scenario: Detection bumps Claude Desktop default-on +- **WHEN** `sourcegraph-mcp init --yes` is invoked on a system where `~/Library/Application Support/Claude/claude_desktop_config.json` already exists, with no explicit `--claude-desktop` flag +- **THEN** Claude Desktop's picker row is default-on; the `Apply` phase contains a `●` row for `claude-desktop` (user scope); no project-scope file is created or modified for Claude Desktop + +#### Scenario: --claude-desktop forces default-on +- **WHEN** `sourcegraph-mcp init --yes --claude-desktop` is invoked on a system where no Claude Desktop config file exists +- **THEN** Claude Desktop is wired anyway (a new platform-specific user-scope config file is created and the `mcpServers.sourcegraph` entry is inserted); the `Apply` phase shows a `● wrote` row (or `[x] wrote` under `--no-leaf`) for `claude-desktop` whose path renders with `~/` substitution where applicable #### Scenario: Pre-warm runs after writing configs - **WHEN** `sourcegraph-mcp init --yes --client claude-code --prewarm --solution ./MySln.slnx` is invoked -- **THEN** after the `.mcp.json` write completes, `RoslynIndexer.IndexSolutionOnceAsync` is invoked against `./MySln.slnx`; the closing report includes the line `pre-warmed index: N files in T s` +- **THEN** after the `.mcp.json` write completes, `RoslynIndexer.IndexSolutionOnceAsync` is invoked against `./MySln.slnx`; the `Pre-warm` phase summary line reads `● indexed MySln.slnx in T.Ts` (or `[x] indexed MySln.slnx in T.Ts` under `--no-leaf`) ### Requirement: doctor subcommand The CLI SHALL accept `sourcegraph-mcp doctor` that runs a read-only environment diagnostic and prints a per-check `pass | warn | fail` summary. The subcommand SHALL accept `--root ` and `--json` flags. The exit code SHALL follow the convention: `0` if every check passed, `2` if at least one warn was raised, `1` if any check produced a hard fail. @@ -311,3 +320,298 @@ The CLI SHALL preserve the existing `init-scopes` subcommand behaviour and the e - **WHEN** a user runs `sourcegraph-mcp init` in a repo containing `frontend.slnx` and `backend.slnx` - **THEN** the resulting `.sourcegraph.json` is identical to the file `init-scopes` would have written, and the closing report names both `frontend` and `backend` as configured scopes +### Requirement: init output status-dot language +The `init` subcommand SHALL render its human-readable output (banner, detection summary, picker rows, apply rows, pre-warm summary, closing report) using a single status-dot vocabulary applied uniformly across every row position. The leaf `🌿` is reserved for the brand mark in the banner line and in MCP tool responses; it MUST NOT appear in any per-row status position. The five row states and their tokens SHALL be: + +- **on / passed / wrote / unchanged**: `●` (U+25CF) in brand colour `#5fa07a`, followed by U+0020; ASCII fallback `[x] ` under `--no-leaf` or `SOURCEGRAPH_NO_LEAF=1` +- **off / not selected**: `○` (U+25CB); ASCII fallback `[ ] ` +- **soft warning**: `◐` (U+25D0) in warn colour `#e0a040`, followed by U+0020; ASCII fallback `[!] ` +- **hard skip / conflict / fail**: `✗` (U+2717) in fail colour `#d4544b`, followed by U+0020; ASCII fallback `[X] ` +- **unsupported / N/A**: `−` (U+2212) in muted colour, followed by U+0020; ASCII fallback `[-] ` + +Column alignment SHALL be preserved across the emoji and ASCII forms by treating each token's width as three display cells. + +The `init` subcommand SHALL organise its output under named phase headings: `Environment`, `Clients to wire`, `Apply`, `Pre-warm` (omitted when no pre-warm runs in this invocation), and `Next`. Each phase heading SHALL be prefixed with the section-leader glyph `◆` (U+25C6) in brand colour, followed by U+0020, then the heading name in brand-coloured bold (matching the dashboard's detail-view section headers). Under `--no-leaf` the `◆` leader downgrades to the bracketed ASCII token `[*]` (three display cells, same column width). Rows under a phase heading SHALL be indented exactly four spaces so the dot column lines up two cells inside the section leader. + +The opt-out mechanism (`--no-leaf` CLI flag and `SOURCEGRAPH_NO_LEAF=1` env var) SHALL apply to the entire status-dot vocabulary, the section-leader glyph, and any banner `🌿` brand mark in `init` output — the same `LeafFormatter.Suppressed` flag that controls server tool responses. + +#### Scenario: Successful first-run renders all five phases +- **WHEN** `sourcegraph-mcp init --yes` is invoked in a repo with one solution and Claude Code's project config absent, with `--prewarm` set +- **THEN** stdout contains exactly five phase headings — `◆ Environment`, `◆ Clients to wire`, `◆ Apply`, `◆ Pre-warm`, `◆ Next` — each on its own line at the left margin; rows under each heading are four-space-indented; the `Apply` phase contains a row beginning with `● wrote` for the `claude-code` writer; no `🌿` byte sequence appears in a row-status position + +#### Scenario: --no-leaf substitutes ASCII fallbacks +- **WHEN** `sourcegraph-mcp init --yes --no-leaf` is invoked +- **THEN** no `🌿` (U+1F33F), `◆` (U+25C6), `●` (U+25CF), `○` (U+25CB), `◐` (U+25D0), `✗` (U+2717), or `−` (U+2212) appears on stdout; every status position contains a three-character ASCII token from the set `[x] `, `[ ] `, `[!] `, `[X] `, `[-] `; every phase heading begins with the bracketed ASCII section leader `[*] `; the banner line shows `SourceGraph init` without a leading leaf + +#### Scenario: --print-only omits the Pre-warm phase +- **WHEN** `sourcegraph-mcp init --yes --print-only` is invoked +- **THEN** the `Pre-warm` phase heading does not appear on stdout; the `Apply` phase still renders with each row showing the would-write verb under the configured dot vocabulary + +#### Scenario: SOURCEGRAPH_NO_LEAF env var matches --no-leaf flag behaviour +- **WHEN** `sourcegraph-mcp init --yes` is invoked with `SOURCEGRAPH_NO_LEAF=1` in the environment and without the `--no-leaf` flag +- **THEN** the rendered output is byte-identical to the `--yes --no-leaf` invocation against the same repo state + +### Requirement: init batched client picker +When `init` is invoked interactively (stdin is a tty AND `--yes` was not passed AND `--print-only` was not passed), the `Clients to wire` phase SHALL render every supported client as a row showing its default-selected state with the status-dot vocabulary defined in the `init output status-dot language` requirement (`●`/`[x]` selected, `○`/`[ ]` off), then prompt the user exactly once with a batched prompt allowing one of the following responses: + +1. Empty input, `y`, or `Y` — accept the displayed defaults verbatim. +2. `n` or `N` — deselect every row; no client is wired. +3. A whitespace-separated sequence of tokens of the form `+` or `-` — start from the displayed defaults, then flip each named client (`+` adds to the selection, `-` removes from it). Slugs that don't match the supported set SHALL produce a warning on stderr and SHALL be ignored. + +On any input that doesn't match one of those three forms, the prompt SHALL be re-displayed once with the example shown; a second invalid input SHALL be treated as `n` (deselect all) and the run SHALL proceed. + +The supported slug set SHALL match the `--client` flag's supported values: `claude-code`, `copilot`, `cursor`, `continue`, `claude-desktop`. + +The picker default for each client SHALL be computed from `OnboardingDetector` signals: + +- `claude-code`: default-on always. +- `copilot`: default-on always. +- `cursor`: default-on iff `/.cursor/` directory or `~/.cursor/mcp.json` exists; otherwise default-off. +- `continue`: default-on iff `/.continue/` directory or `~/.continue/mcp/sourcegraph.yaml` exists; otherwise default-off. +- `claude-desktop`: default-on iff the platform-specific Claude Desktop config file exists, OR if the `--claude-desktop` flag was passed; otherwise default-off. + +The `--client ` flag SHALL override the picker entirely (the rows are rendered but only the listed clients are selected, regardless of defaults). The `--no-` flag SHALL force the named client off in the displayed defaults. + +#### Scenario: Empty input accepts displayed defaults +- **WHEN** the picker shows `claude-code` and `copilot` as default-on (`●`) and `cursor`, `continue`, `claude-desktop` as default-off (`○`); user presses Enter at the prompt +- **THEN** `claude-code` and `copilot` writers run; `cursor`, `continue`, `claude-desktop` writers do not + +#### Scenario: `+slug -slug` edits the default selection +- **WHEN** the picker shows `claude-code` and `copilot` as default-on; user types `+cursor -copilot` and presses Enter +- **THEN** `claude-code` and `cursor` writers run; `copilot` writer does not; `continue` and `claude-desktop` writers do not (off-defaults preserved) + +#### Scenario: Unknown slug warns and is ignored +- **WHEN** the user types `+sublime` at the picker prompt +- **THEN** a warning is printed to stderr naming the unknown slug; the picker proceeds with the displayed defaults (the unknown token is dropped) + +#### Scenario: `n` deselects all +- **WHEN** the user types `n` at the picker prompt +- **THEN** no writer runs; the `Apply` phase shows the line `No clients selected. Nothing to do.`; the process exits `0` + +#### Scenario: Detection-driven cursor default +- **WHEN** the user runs `sourcegraph-mcp init` interactively in a repo where `/.cursor/` exists; defaults are computed +- **THEN** the picker's `cursor` row is rendered with the `●` (or `[x]`) ok-dot; pressing Enter wires `cursor` along with `claude-code` and `copilot` + +#### Scenario: Detection-driven continue default-off when no install fingerprint +- **WHEN** the user runs `sourcegraph-mcp init` interactively in a repo with no `.continue/` directory and no `~/.continue/` directory +- **THEN** the picker's `continue` row is rendered with the `○` (or `[ ]`) off-dot; pressing Enter does NOT wire `continue` + +### Requirement: init --diff conflict preview +The `init` subcommand SHALL accept a `--diff` flag. When the flag is set, AND a writer's plan would land in `SkipExistingDiffers`, AND the run is not under `--print-only`, the subcommand SHALL render a unified diff with three lines of surrounding context comparing the existing target file's bytes against the writer's proposed `ContentBytes`, printed to stdout under the conflicting `Apply` row as a hanging-detail block. The diff `---` header SHALL name the target file path; the `+++` header SHALL name `.proposed`. + +`--diff` SHALL be read-only by default. When combined with `--force`, the diff SHALL be printed first AND the write SHALL then proceed (the existing `--force` behaviour, augmented with the diff print). Without `--force`, the file SHALL be left unchanged and the process SHALL exit `2`. + +`--diff` SHALL be a no-op for plans other than `SkipExistingDiffers` (`Insert`, `NoOpAlreadyMatches`, `ReplaceOurs`, `SkipHasComments`, `SkipUnsupported`). + +#### Scenario: --diff shows the unified diff and exits 2 without --force +- **WHEN** `/.mcp.json` contains an `mcpServers.sourcegraph` entry whose `args` array differs from what `init` would write, and `sourcegraph-mcp init --yes --client claude-code --diff` is invoked +- **THEN** the `Apply` phase shows `✗ conflict — skipped claude-code .mcp.json` (or `[X] conflict — skipped ...` under `--no-leaf`); a unified diff is printed below the row indented one level further, with `---` and `+++` headers naming `.mcp.json` and `.mcp.json.proposed` respectively, and `-`/`+` markers on the differing lines; the file is not modified; the process exits `2` + +#### Scenario: --diff combined with --force prints diff then writes +- **WHEN** the same conflicting `/.mcp.json` is present and `sourcegraph-mcp init --yes --client claude-code --diff --force` is invoked +- **THEN** the unified diff is printed first; then the `Apply` phase contains `● replaced claude-code .mcp.json` (or `[x] replaced ...` under `--no-leaf`); the file is rewritten with the proposed content; the process exits `0` + +#### Scenario: --diff on an insert plan is a no-op +- **WHEN** `/.mcp.json` does not exist and `sourcegraph-mcp init --yes --client claude-code --diff` is invoked +- **THEN** the `Apply` phase shows `● wrote claude-code .mcp.json`; no diff output is printed; the process exits `0` + +#### Scenario: --diff on a SkipHasComments plan is a no-op +- **WHEN** `/.mcp.json` contains line comments (`// ...`) outside string literals and `sourcegraph-mcp init --yes --client claude-code --diff` is invoked +- **THEN** today's comment-aware degraded-mode behaviour runs unchanged: the would-write snippet is printed to stdout with the `# config has comments at ...` warning, the file is not modified, and no unified diff is produced; the process exits `0` + +### Requirement: status subcommand +The CLI SHALL accept `sourcegraph-mcp status` that renders a single point-in-time snapshot of the operator console's five state surfaces — Environment, Scopes, Clients, Embeddings, Recent activity — to stdout, using the phase-headed layout and status-dot vocabulary defined elsewhere in this spec. + +The subcommand SHALL accept the following flags: + +- `--root ` — repository root (default CWD). +- `--json` — emit a stable JSON document instead of human-readable prose. See the `status snapshot data sources` requirement for the document shape. +- `--watch` — when stdin is a tty, re-render the snapshot in place on a poll interval. When stdin is not a tty, the flag is silently downgraded to a single snapshot. +- `--watch-interval ` — integer seconds between `--watch` re-renders (default `2`, minimum `1`). Ignored when `--watch` is not set. +- `--no-color` — disable ANSI colour codes in the human-readable output (independent of `--no-leaf`, which controls the glyph language). +- `--activity-bytes ` — tail at most N bytes from each JSONL log (default `524288`). + +The exit code SHALL follow the convention: `0` if every snapshot dimension is healthy; `2` if any dimension reports a warning (scope status `partial` or `indexing`, drift detected, embedding cache absent, git missing); `1` if any dimension hard-fails (`.NET 10` SDK absent, `.sourcegraph.json` malformed, scope status `degraded` with corruption, scope DB directory unwritable). + +`status` SHALL read SQLite databases (`_meta.db` and per-scope DBs) in read-only mode and SHALL NOT require a concurrent `sourcegraph-mcp serve` process. The subcommand SHALL tolerate a concurrently-running `serve` writing to the same DBs (SQLite WAL mode supports concurrent readers). + +#### Scenario: Healthy environment renders all five phases +- **WHEN** `sourcegraph-mcp status` is invoked in a repo with a valid `.sourcegraph.json`, .NET 10 + git on PATH, two ok scopes, the default embedding model populated, and a non-empty `usage.jsonl` +- **THEN** stdout contains exactly five `◆`-prefixed phase headings — `◆ Environment`, `◆ Scopes`, `◆ Clients`, `◆ Embeddings`, `◆ Recent activity` — each rendered with phase-internal rows using the status-dot vocabulary (`●` for healthy items); the process exits `0` + +#### Scenario: A degraded scope drops the exit code to 1 +- **WHEN** `sourcegraph-mcp status` is invoked in a repo where one configured scope has `status = "degraded"` in `_meta.db` and an integrity-check failure recorded +- **THEN** the `Scopes` phase renders that scope's row with the `✗` (or `[X]` under `--no-leaf`) fail-dot and a hanging-detail line naming the recommended action (`repair_scope mode=rebuild`); the process exits `1` + +#### Scenario: A partial scope drops the exit code to 2 +- **WHEN** `sourcegraph-mcp status` is invoked in a repo where one scope has `status = "partial"` and the `failed_projects` array is non-empty +- **THEN** the `Scopes` phase renders that scope's row with the `◐` (or `[!]`) warn-dot; the hanging-detail line lists the failed project names; the process exits `2` + +#### Scenario: --json emits the stable contract document +- **WHEN** `sourcegraph-mcp status --json` is invoked +- **THEN** stdout contains a single JSON document parseable as the `DashboardSnapshot` shape documented in `status snapshot data sources`; no human-readable prose precedes or follows the JSON; the document's `exit_code` field matches the process exit code + +#### Scenario: --watch refreshes in place under a tty +- **WHEN** `sourcegraph-mcp status --watch --watch-interval 1` is invoked under a tty and runs for 3 seconds before SIGINT +- **THEN** stdout contains the snapshot rendered 3 or 4 times (one initial frame + 2 or 3 refreshes); each refresh emits the ANSI cursor-home + clear-to-end sequence so the previous frame is overwritten in place; on SIGINT the process exits `0` cleanly with no stray "interrupted" message + +#### Scenario: --watch downgrades to single snapshot under non-tty +- **WHEN** `sourcegraph-mcp status --watch --watch-interval 1 | cat` is invoked +- **THEN** exactly one snapshot is rendered on stdout (no ANSI cursor codes, no re-renders); the process exits with the snapshot-evaluated exit code; the `--watch-interval` value is ignored + +### Requirement: status output status-dot language +The `status` subcommand SHALL use the same five-state status-dot vocabulary defined in the `init output status-dot language` requirement: `●` for on/passed/healthy in brand colour (ASCII fallback `[x] ` under `--no-leaf` or `SOURCEGRAPH_NO_LEAF=1`), `○` for off/inactive (ASCII `[ ] `), `◐` for warning in amber (ASCII `[!] `), `✗` for hard-fail in red (ASCII `[X] `), `−` for unsupported/N/A muted (ASCII `[-] `). The leaf `🌿` is reserved for the brand mark on the banner line and in MCP tool responses; it MUST NOT appear in any per-row status position. Column alignment SHALL be preserved across the emoji and ASCII forms at three display cells per token. + +The `status` subcommand SHALL organise its human-readable output under named phase headings: `Environment`, `Scopes`, `Clients`, `Embeddings`, `Recent activity`. Each phase heading SHALL be prefixed with the section-leader glyph `◆` (U+25C6) in brand colour, followed by U+0020, then the heading name (matching the dashboard's detail-view section headers). Under `--no-leaf` the leader downgrades to `[*]`. Rows under a phase heading SHALL be indented exactly four spaces so the dot column lines up two cells inside the section leader. + +The `--no-color` flag SHALL suppress ANSI colour codes but SHALL leave the dot-glyph vocabulary intact (emoji rendering doesn't require ANSI colour codes). + +#### Scenario: --no-leaf substitutes ASCII fallbacks +- **WHEN** `sourcegraph-mcp status --no-leaf` is invoked in a healthy repo +- **THEN** no `🌿` (U+1F33F), `◆` (U+25C6), `●` (U+25CF), `○` (U+25CB), `◐` (U+25D0), `✗` (U+2717), or `−` (U+2212) byte sequence appears on stdout; every status position contains a three-character ASCII token from the set `[x] `, `[ ] `, `[!] `, `[X] `, `[-] `; every phase heading begins with the bracketed ASCII section leader `[*] ` + +#### Scenario: SOURCEGRAPH_NO_LEAF env var matches --no-leaf flag behaviour +- **WHEN** `sourcegraph-mcp status` is invoked with `SOURCEGRAPH_NO_LEAF=1` in the environment and without the `--no-leaf` flag +- **THEN** the rendered output is byte-identical to the `--no-leaf` invocation against the same repo state + +#### Scenario: --no-color suppresses ANSI codes without affecting glyphs +- **WHEN** `sourcegraph-mcp status --no-color` is invoked under a tty +- **THEN** stdout contains no ANSI escape sequences (no `\x1b[` byte sequences); the status-dot vocabulary is unchanged (`●` still appears for healthy items unless `--no-leaf` is also set) + +### Requirement: status snapshot data sources +The snapshot rendered by `status` (and consumed by future operator-console renderers) SHALL aggregate five state surfaces in a single immutable record. Each surface SHALL be populated from the data sources documented below, and each SHALL be addressable in the `--json` output via a stable snake_case top-level field. + +**1. `environment`** — sourced from `OnboardingDetector.DetectAsync`. JSON fields: `dotnet_sdk_version` (string or null), `git_on_path` (bool), `repo_root_path` (absolute string), `solution_files` (array of absolute strings), `sourcegraph_config_status` (one of `missing`, `valid`, `malformed`), `sourcegraph_config_error` (string or null). + +**2. `scopes`** — sourced from `_meta.db` plus per-scope DB read-only queries. JSON shape: array of objects with fields `name` (string), `status` (one of `ok`, `partial`, `degraded`, `indexing`), `symbol_count` (integer), `reference_count` (integer), `last_indexed_at` (ISO-8601 string or null), `failed_projects` (array of strings, empty when `status != partial`), `failed_files` (array of strings, empty when `status != partial`), `isolated` (bool, true when the scope's config has `isolated: true`). + +**3. `clients`** — sourced from `OnboardingDetector.ClientConfigsDetected`. JSON shape: array of objects with fields `slug` (one of `claude-code`, `copilot`, `cursor`, `continue`, `claude-desktop`), `scope` (one of `project`, `user`), `path` (absolute string), `exists` (bool), `contains_sourcegraph_entry` (bool). + +**4. `embeddings`** — sourced from `EmbeddingsManager`. JSON fields: `model_id` (string, the active model identifier), `cache_dir` (absolute string), `cache_present` (bool), `total_bytes` (integer, sum of all cached files; zero when `cache_present == false`), `verified` (bool, true when the cache has been successfully `embeddings verify`-ed against pinned SHAs since the last `pull`). + +**5. `recent_activity`** — sourced from a byte-bounded tail of `.sourcegraph/usage.jsonl` and `.sourcegraph/heals.jsonl`, merged and sorted by timestamp ascending, capped at the most recent 50 entries. JSON shape: array of objects with fields `ts` (ISO-8601 string), `kind` (string — e.g. `tool_call`, `heal`, `boot_reconcile`), `scope` (string or null), `ok` (bool), `ms` (integer), `detail` (string or null — a one-line human-readable summary). + +The top-level JSON document SHALL also include: `built_at` (ISO-8601 string, when the snapshot was assembled), `usage_log_path` (absolute string), `heals_log_path` (absolute string), and `exit_code` (integer matching the process exit code: 0, 1, or 2). + +Additions to the JSON shape in future revisions SHALL be append-only — adding new top-level fields or new fields to nested objects is permitted; renaming or removing fields requires a major-version bump in the spec. + +#### Scenario: --json document has all six top-level surface fields +- **WHEN** `sourcegraph-mcp status --json` is invoked in any valid repo +- **THEN** the emitted JSON document has top-level keys `environment`, `scopes`, `clients`, `embeddings`, `recent_activity`, `built_at`, `usage_log_path`, `heals_log_path`, and `exit_code` — no other top-level keys exist at v1 + +#### Scenario: scopes array reflects _meta.db rows verbatim +- **WHEN** `sourcegraph-mcp status --json` is invoked in a repo with three configured scopes: `frontend` (ok), `backend` (partial, with `failed_projects: ["legacy.csproj", "old.csproj"]`), and `vendor` (ok, isolated) +- **THEN** the `scopes` array contains exactly three objects in declared-order; the `backend` row's `failed_projects` matches `["legacy.csproj", "old.csproj"]`; the `vendor` row's `isolated` is `true`; the `frontend` and `backend` rows' `isolated` is `false` + +#### Scenario: recent_activity merges and sorts both log files +- **WHEN** `sourcegraph-mcp status --json` is invoked in a repo whose `usage.jsonl` ends with three tool-call entries at timestamps T1 < T3 < T5 and whose `heals.jsonl` ends with two heal entries at T2 and T4 (interleaved with the usage entries) +- **THEN** the `recent_activity` array contains all five entries in timestamp-ascending order (T1, T2, T3, T4, T5); each entry's `kind` field reflects its source log (`tool_call` for `usage.jsonl` rows, `heal` / `boot_reconcile` / etc. for `heals.jsonl` rows) + +#### Scenario: snapshot tolerates a partial trailing JSONL line +- **WHEN** `sourcegraph-mcp status --json` is invoked while a concurrent `serve` process is mid-write to `usage.jsonl` (the last byte is mid-object, no trailing newline) +- **THEN** the partial line is dropped silently from `recent_activity`; the snapshot reports the prior complete entries; no parse-error appears in stderr; the process exits successfully + +### Requirement: dashboard subcommand +The CLI SHALL accept `sourcegraph-mcp dashboard` that renders a full-screen Spectre.Console-backed live operator console consuming the `DashboardSnapshot` defined in the `status snapshot data sources` requirement. The dashboard SHALL display five sections — `Environment`, `Scopes`, `Clients`, `Embeddings`, `Recent activity` — backed by the same snapshot the `status` subcommand renders, and SHALL refresh that snapshot on a polling + watcher hybrid (see below). + +The subcommand SHALL accept the following flags: + +- `--root ` — repository root (default CWD); rendered in the dashboard header bar using the relative / `~/`-substituted form (matching `polish-init-onboarding`'s path-rendering rule). +- `--no-color` — disable ANSI colour codes; composes with the `NO_COLOR` env var Spectre honours natively. +- `--no-leaf` / `SOURCEGRAPH_NO_LEAF=1` — substitute the ASCII status-dot fallback (matches the `init output status-dot language` and `status output status-dot language` requirements above). + +The dashboard SHALL refresh its snapshot using a hybrid model: + +1. **Polling**: a timer rebuilds the snapshot at most once per `1000 ms`. +2. **Watcher**: `FileSystemWatcher` instances on `/.sourcegraph/usage.jsonl` and `/.sourcegraph/heals.jsonl` trigger a debounced rebuild `100 ms` after the most recent write event. + +The two triggers SHALL coalesce: any rebuild request that fires within `1000 ms` of the previous rebuild SHALL be dropped (the most recent snapshot satisfies it). Snapshot rebuilds SHALL run on the thread-pool; rendering SHALL be confined to the UI thread. + +The dashboard SHALL declare minimum terminal dimensions of `80 columns × 24 rows`. When the current terminal is smaller at startup, the dashboard SHALL print `terminal too small (need ≥80×24)` to stderr and exit with code `2`. The dashboard SHALL respond to terminal resize events by re-rendering the layout against the new dimensions. + +The dashboard SHALL exit cleanly with code `0` on `q`, `Q`, or `Ctrl+C`; on uncaught exception, exit `1` after restoring the terminal cursor. + +#### Scenario: Successful launch renders the five sections +- **WHEN** `sourcegraph-mcp dashboard` is invoked under a `100 × 40` terminal in a healthy repo +- **THEN** the first frame contains a Spectre layout with five labelled sections — `Environment`, `Scopes`, `Clients`, `Embeddings`, `Recent activity` — each rendered with phase-internal rows using the status-dot vocabulary; the header bar contains the dashboard banner with `🌿 SourceGraph` (or `[x] SourceGraph` under `--no-leaf`), the version, and the relative-path-rendered `--root`; the footer bar contains the key-binding hints `[q] quit [?] help [↑↓] nav [Enter] details` + +#### Scenario: Recent activity surfaces within 200 ms of a JSONL write +- **WHEN** the dashboard is running and a concurrent `serve` process appends one line to `usage.jsonl` +- **THEN** the `Recent activity` section re-renders to include the new entry within 200 ms of the write (100 ms watcher debounce + render latency); the snapshot's poll-tick rebuild SHALL NOT also fire for that event (the coalescing rule prevents the duplicate rebuild) + +#### Scenario: Polling rebuild fires when no JSONL activity +- **WHEN** the dashboard runs for 5 seconds with no JSONL writes +- **THEN** the snapshot rebuilds at most 5 times (once per second); each rebuild's wall-clock time is recorded; no rebuild's duration exceeds 100 ms under the healthy-repo fixture + +#### Scenario: Tiny terminal refuses to render +- **WHEN** `sourcegraph-mcp dashboard` is invoked under a `60 × 20` terminal +- **THEN** stderr contains the line `terminal too small (need ≥80×24)`; the process exits with code `2` without entering Spectre's `Live` mode + +#### Scenario: q exits cleanly +- **WHEN** the dashboard is running and the user presses `q` +- **THEN** the Spectre `Live` block exits; the cursor is restored to visible state; no ANSI escape sequence remains in the terminal's output buffer; the process exits with code `0` + +### Requirement: Bare command dispatch +The CLI SHALL accept `sourcegraph-mcp` invoked with no positional arguments and no `--help` / `-h` flag, and SHALL dispatch to either the `dashboard` subcommand or the `status` subcommand based on the stdio disposition: when every standard stream is attached to a tty (`Console.IsInputRedirected == false` AND `Console.IsOutputRedirected == false` AND `Console.IsErrorRedirected == false` AND `Environment.UserInteractive == true`), the dispatch target SHALL be `dashboard`; otherwise (any stream redirected, or the process running non-interactively) the dispatch target SHALL be `status`. + +The dashboard requires all three streams attached: stdin for key input, stdout and stderr for ANSI cursor positioning. Routing on stdin alone would let `sourcegraph-mcp | cat` (stdin a tty, stdout piped) enter the dashboard and pour ANSI redraw codes into the pipe — which is why every stream is checked. + +Flags passed alongside the bare invocation (e.g. `sourcegraph-mcp --root /work/Repo`) SHALL be propagated to the dispatched subcommand. + +The behaviour of `sourcegraph-mcp --help` / `sourcegraph-mcp -h` SHALL be unchanged from today: print the help text and exit `0`. + +#### Scenario: Bare invocation under a tty enters the dashboard +- **WHEN** a user runs `sourcegraph-mcp` (no positional args, no `--help`) in an interactive terminal +- **THEN** the dashboard launches as if `sourcegraph-mcp dashboard` had been invoked; on `q` the process exits `0` + +#### Scenario: Bare invocation under a pipe runs status +- **WHEN** `sourcegraph-mcp | cat` is invoked +- **THEN** the static `status` snapshot is printed to stdout (no ANSI control codes for live rendering); the process exits with the snapshot's exit code (0, 1, or 2); the dashboard is NOT entered + +#### Scenario: --help still prints help +- **WHEN** `sourcegraph-mcp --help` is invoked (in either tty or redirected stdin) +- **THEN** stdout contains the help text starting with `sourcegraph-mcp — live code source graph MCP server for .NET`; the dashboard is NOT entered; the process exits `0` + +#### Scenario: Bare invocation with --root propagates the flag +- **WHEN** `sourcegraph-mcp --root /some/other/repo` is invoked under a tty +- **THEN** the dashboard launches against `/some/other/repo` (the header bar names that root); the `--root` flag is passed through to the snapshot builder + +### Requirement: Dashboard action confirmation +The `dashboard` subcommand SHALL gate every state-mutating action that is irreversible or that affects user-visible state outside `/.sourcegraph/` behind a Spectre confirmation prompt. The prompt SHALL render as a modal overlay with the text ` ? [y/N]`, default `No`, dismissable with `Esc` or `n` (both treated as `No`). + +Specifically, the following in-place actions SHALL gate behind the confirm modal: + +- `[R]` rebuild selected scope (archives the current scope DB before re-indexing). +- `[u]` unwire selected client (removes the `mcpServers.sourcegraph` entry from the target config file). + +The following in-place actions SHALL NOT gate (they are idempotent / additive): + +- `[r]` reindex selected scope (`reconcile_drift`). +- `[w]` wire missing client (calls the InitCli writer; results in `Insert` or `NoOpAlreadyMatches`). +- `[p]` embeddings pull (idempotent against a populated cache). +- `[v]` embeddings verify (read-only). + +Read-only actions (`↑↓`, `Tab`, `Enter`, `Esc`, `q`, `?`, `s`) SHALL NOT gate. + +Guided actions (`[i]`, `[d]`, `[l]`, `[e]`) SHALL NOT gate at the dashboard layer — the guided subcommand carries its own interaction model. + +#### Scenario: `[u]` unwire prompts for confirmation +- **WHEN** the user navigates to a wired client row, presses `u`, and the confirm prompt appears +- **THEN** the prompt renders with the text `unwire claude-code? [y/N]` (or equivalent for the selected client); pressing `Esc` dismisses the prompt with no file modification; the `.mcp.json` file's mtime is unchanged; the dashboard returns to the Clients section with no error + +#### Scenario: `[u]` unwire proceeds on explicit yes +- **WHEN** the user presses `u` on a wired `claude-code` row and answers `y` to the confirm prompt +- **THEN** the `mcpServers.sourcegraph` entry is removed from `/.mcp.json` (other entries preserved); the snapshot rebuilds; the Clients section's `claude-code` row state-label flips from `wired` (brand-green) to `not wired` (muted grey); the row's leading-column selection indicator (`◉` selected, `○` not selected) is unaffected + +#### Scenario: `[r]` reindex does NOT prompt +- **WHEN** the user navigates to a scope row and presses `r` +- **THEN** `reconcile_drift` runs immediately for the selected scope; no confirm prompt appears; the scope's status dot optionally flips to a transient `indexing` indicator while the operation runs; on completion the row re-renders with the updated state + +#### Scenario: `[R]` rebuild prompts for confirmation +- **WHEN** the user presses `R` on a scope row +- **THEN** the confirm prompt renders with the text `rebuild backend? [y/N]` (or the slug of the selected scope); `n` or `Esc` dismisses with no action; `y` triggers `repair_scope mode=rebuild` for the selected scope + +#### Scenario: `[v]` embeddings verify does NOT prompt +- **WHEN** the user presses `v` +- **THEN** `embeddings verify` runs against the active model immediately; no confirm prompt appears; the Embeddings section's `verified` indicator updates on completion (or surfaces a `◐`/`✗` status dot if a SHA mismatch is detected) + diff --git a/openspec/specs/mcp-config/spec.md b/openspec/specs/mcp-config/spec.md index 1f4d38c3..86cba5c9 100644 --- a/openspec/specs/mcp-config/spec.md +++ b/openspec/specs/mcp-config/spec.md @@ -138,16 +138,30 @@ The five v1 writers SHALL emit: - **THEN** the per-server JSON object inside `mcpServers.sourcegraph` is identical in both files (same `command`, same `args`); the only difference is the file path ### Requirement: Project-scoped defaults; user-scope opt-in -The `init` subcommand SHALL default each client's write target to that client's project-scoped path when one exists. Writing to a user-scoped path SHALL require an explicit per-client opt-in flag. Claude Desktop is the only client without a project-scope option; it SHALL require an explicit `--claude-desktop` flag to be wired at all, and SHALL NOT be auto-selected even when its config file is detected on disk. +The `init` subcommand SHALL default each client's write target to that client's project-scoped path when one exists. Writing to a user-scoped path SHALL require explicit per-client opt-in via one of: -#### Scenario: Default init touches no user-tree files -- **WHEN** `sourcegraph-mcp init --yes` is invoked with no `--user-*` or `--claude-desktop` flags, in a repo with a `.slnx` and the user's machine has Claude Code, Cursor, and Claude Desktop all installed -- **THEN** `/.mcp.json` and `/.cursor/mcp.json` are written; no file under the user's home directory is read or written; Claude Desktop is not wired +1. The `--user-` flag (for clients that have both project- and user-scope paths). +2. A `--client ` selection naming a client whose only target is user-scope (today: `claude-desktop`). +3. Interactive picker confirmation: the row for a user-scope-only client SHALL be visible by default and SHALL be selected for the run (either by being default-on under detection OR by being added via `+slug` in the batched prompt). + +Claude Desktop SHALL remain user-scope only (no project-scope path exists). The picker row for Claude Desktop SHALL be visible by default in interactive mode. Claude Desktop's default-on state in the picker SHALL be driven by detection of its platform-specific config file (`%APPDATA%\Claude\claude_desktop_config.json` on Windows, `~/Library/Application Support/Claude/claude_desktop_config.json` on macOS, `~/.config/Claude/claude_desktop_config.json` on Linux): default-on iff the file exists, default-off otherwise. The `--claude-desktop` CLI flag SHALL force the default-on state regardless of detection. + +#### Scenario: Default init in a fresh repo skips Claude Desktop +- **WHEN** `sourcegraph-mcp init --yes` is invoked with no `--user-*` flags, no `--claude-desktop` flag, no `--client` flag, and no detected Claude Desktop config file on the platform-specific path, in a repo with a `.slnx` and Claude Code installed +- **THEN** `/.mcp.json` is written; no file under the user's home directory is read or written; Claude Desktop is not wired + +#### Scenario: Detected Claude Desktop config defaults the picker on +- **WHEN** `sourcegraph-mcp init --yes` is invoked on a system where `~/Library/Application Support/Claude/claude_desktop_config.json` already exists (macOS path), with no explicit `--claude-desktop` flag +- **THEN** the Claude Desktop user-scope file is written or merged into (preserving any non-`sourcegraph` server entries already present); the closing report's `Apply` phase shows a `●` row for `claude-desktop` (the per-row state glyph for an OK outcome — the leaf brand-mark rides only on the title bar and MCP tool responses, not on individual rows) and names the user-scope path #### Scenario: --user-cursor writes to home - **WHEN** `sourcegraph-mcp init --yes --client cursor --user-cursor` is invoked - **THEN** `~/.cursor/mcp.json` is written or merged into; `/.cursor/mcp.json` is not touched; the closing report names the home-tree path explicitly +#### Scenario: --claude-desktop forces wiring without a detected file +- **WHEN** `sourcegraph-mcp init --yes --claude-desktop` is invoked on a system where the platform-specific Claude Desktop config file does not exist +- **THEN** the user-scope Claude Desktop file is created at the platform-specific path; a new `mcpServers.sourcegraph` entry is inserted; the closing report names the created user-scope path + ### Requirement: Merge-by-server-name semantics Each writer SHALL read any pre-existing target file before writing, parse it, and produce one of six plans: @@ -182,3 +196,4 @@ When a writer detects that its target file contains JavaScript-style line commen #### Scenario: Hand-commented .mcp.json triggers degraded mode - **WHEN** `/.mcp.json` contains lines like `// only enable in dev` above an `mcpServers` object, and `init --yes --client claude-code` runs - **THEN** the file is not modified, stdout includes the snippet that would have been written to it preceded by the warning line, and the process exits `0` (degrade is informational, not an error) + diff --git a/src/DevBitsLab.Mcp.SourceGraph.Server/Cli/BatchedPickerInput.cs b/src/DevBitsLab.Mcp.SourceGraph.Server/Cli/BatchedPickerInput.cs new file mode 100644 index 00000000..40adac59 --- /dev/null +++ b/src/DevBitsLab.Mcp.SourceGraph.Server/Cli/BatchedPickerInput.cs @@ -0,0 +1,86 @@ +namespace DevBitsLab.Mcp.SourceGraph.Server.Cli; + +/// +/// Outcome of parsing one batched-picker input line. holds the resolved +/// slug set after applying the displayed defaults plus any +/- edits; +/// lists tokens that didn't match a known client (the caller emits a +/// warn for each); is true when the raw input was malformed (neither +/// empty / y / n nor a sequence of +slug/-slug tokens) — the picker +/// reprompts once and treats a second invalid input as n. +/// +internal sealed record PickerResult( + IReadOnlySet Selection, + IReadOnlyList UnknownSlugs, + bool NeedsReprompt); + +/// +/// Parses the single batched picker prompt input. Grammar: +/// +/// Empty / y / Y — accept the displayed defaults verbatim. +/// n / N — deselect every row. +/// Whitespace-separated +slug / -slug tokens — start from defaults; flip each +/// named client. Unknown slugs warn (collected into ) +/// and are dropped from the selection delta. +/// Anything else — is true. +/// +/// +internal static class BatchedPickerInput +{ + public static PickerResult Parse(string raw, IReadOnlySet defaults, IReadOnlySet knownSlugs) + { + var trimmed = (raw ?? string.Empty).Trim(); + if (string.IsNullOrEmpty(trimmed) || trimmed.Equals("y", StringComparison.OrdinalIgnoreCase)) + { + return new PickerResult( + Selection: new HashSet(defaults, StringComparer.Ordinal), + UnknownSlugs: Array.Empty(), + NeedsReprompt: false); + } + if (trimmed.Equals("n", StringComparison.OrdinalIgnoreCase)) + { + return AllOff(); + } + + // Tokenise on whitespace; expect every token to start with `+` or `-`. Anything else + // signals "reprompt". + var tokens = trimmed.Split(new[] { ' ', '\t', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); + var current = new HashSet(defaults, StringComparer.Ordinal); + var unknown = new List(); + + foreach (var tok in tokens) + { + if (tok.Length < 2 || (tok[0] != '+' && tok[0] != '-')) + { + // Malformed token — kick the whole parse into reprompt mode. + return new PickerResult( + Selection: defaults, + UnknownSlugs: Array.Empty(), + NeedsReprompt: true); + } + var op = tok[0]; + var slug = tok.Substring(1); + if (!knownSlugs.Contains(slug)) + { + unknown.Add(slug); + continue; + } + if (op == '+') current.Add(slug); + else current.Remove(slug); + } + + // Spec invariant: "unknown slug warns and is ignored". A token list that contains + // only unknown slugs still parses successfully — we proceed with the defaults and the + // unknown-slug warning list. Reprompt only fires on a malformed token (handled above). + return new PickerResult( + Selection: current, + UnknownSlugs: unknown, + NeedsReprompt: false); + } + + /// Helper returning the "deselect all" result used as the fallthrough on second + /// invalid input. + public static PickerResult AllOff() => new( + Selection: new HashSet(StringComparer.Ordinal), + UnknownSlugs: Array.Empty(), + NeedsReprompt: false); +} diff --git a/src/DevBitsLab.Mcp.SourceGraph.Server/Cli/CommandLine.cs b/src/DevBitsLab.Mcp.SourceGraph.Server/Cli/CommandLine.cs index 83fb92ea..dcb6db81 100644 --- a/src/DevBitsLab.Mcp.SourceGraph.Server/Cli/CommandLine.cs +++ b/src/DevBitsLab.Mcp.SourceGraph.Server/Cli/CommandLine.cs @@ -49,6 +49,9 @@ internal sealed class CommandLine public bool Force { get; private init; } /// True when --print-only was passed; consumed by init to emit per-client config snippets to stdout without writing files. public bool PrintOnly { get; private init; } + /// True when --diff was passed; consumed by init to render a unified diff + /// of existing-vs-proposed content when a writer would land in SkipExistingDiffers. + public bool Diff { get; private init; } /// Tristate: true = --prewarm, false = --no-prewarm, null = unspecified (use the default for the active mode: on under interactive, off under --yes). public bool? Prewarm { get; private init; } /// Selected install mode for init's emitted command + args: global (default), local-tool, or in-repo. @@ -67,6 +70,12 @@ internal sealed class CommandLine public bool Json { get; private init; } /// True when --no-color was passed; consumed by demo to suppress the green-leaf glyph on per-line output. Independent of , which is the server-wide opt-out. public bool NoColor { get; private init; } + /// True when --watch was passed; consumed by status to enter a polling redraw loop under a tty. + public bool Watch { get; private init; } + /// The integer seconds passed via --watch-interval <n>; null means "use the built-in default" (2 s). + public int? WatchInterval { get; private init; } + /// The byte cap passed via --activity-bytes <N>; null means "use the built-in default" (524288). + public int? ActivityBytes { get; private init; } public static CommandLine Parse(string[] args) { @@ -94,6 +103,7 @@ public static CommandLine Parse(string[] args) var yes = false; var force = false; var printOnly = false; + var diff = false; bool? prewarm = null; string? installMode = null; var clients = new List(); @@ -103,6 +113,9 @@ public static CommandLine Parse(string[] args) var solutions = new List(); var json = false; var noColor = false; + var watch = false; + int? watchInterval = null; + int? activityBytes = null; for (var i = 1; i < args.Length; i++) { @@ -166,6 +179,9 @@ public static CommandLine Parse(string[] args) case "--print-only": printOnly = true; break; + case "--diff": + diff = true; + break; case "--prewarm": prewarm = true; break; @@ -195,6 +211,15 @@ public static CommandLine Parse(string[] args) case "--no-color": noColor = true; break; + case "--watch": + watch = true; + break; + case "--watch-interval": + watchInterval = RequirePositiveInt(args, ref i, a); + break; + case "--activity-bytes": + activityBytes = RequirePositiveInt(args, ref i, a); + break; default: if (subcommand == "index" && solution is null && !a.StartsWith('-')) { @@ -238,6 +263,7 @@ public static CommandLine Parse(string[] args) Yes = yes, Force = force, PrintOnly = printOnly, + Diff = diff, Prewarm = prewarm, InstallMode = installMode, Clients = clients, @@ -247,6 +273,9 @@ public static CommandLine Parse(string[] args) Solutions = solutions, Json = json, NoColor = noColor, + Watch = watch, + WatchInterval = watchInterval, + ActivityBytes = activityBytes, }; } @@ -305,6 +334,34 @@ private static void AssertExpanded(string value, string flag) sourcegraph-mcp — live code source graph MCP server for .NET Usage: + sourcegraph-mcp + Bare invocation. Under a tty (interactive terminal), drops into the live operator + dashboard. Under redirected stdin / non-interactive contexts, prints the static + `status` snapshot. Use `--help` to see all subcommands; flags like `--root` pass + through to the dispatched subcommand. + + sourcegraph-mcp dashboard [--root ] [--no-color] [--no-leaf] [--activity-bytes ] + Full-screen Spectre.Console-backed live operator console. Home view shows a + 5-row at-a-glance summary plus a 1-5 numbered menu; selecting a row opens that + section's detail view, which re-renders on a 1-second poll + filesystem watcher on + the JSONL logs. + + Keys (home): [↑↓/jk] select menu row, [Enter] open, [1-5] jump to view, + [?] help, [q]/[Ctrl+C] quit, [s] force-refresh snapshot. + + Keys (detail): [↑↓/jk] navigate row, [Enter] primary action (reindex on Scopes, + toggle wire on Clients, pull on Embeddings), [Esc] or [h] back to home, + [r] reindex / [R] rebuild scope (confirm) / [N] new scope / [D] delete scope + (confirm) / [d] demo on Scopes; [w] wire / [u] unwire client (confirm) on Clients; + [p] pull / [v] verify on Embeddings; [l] open log in $PAGER / [e] open + .sourcegraph.json in $EDITOR; [i] guided init from any view; [q]/[Ctrl+C] quit. + + Selection indicator: ◉ in brand-green for the focused row, ○ otherwise. Status + for each row is colour-coded inline (ok/wired = green, partial/indexing = amber, + degraded/failed = red, off = grey). + + Requires ≥80×24 terminal; smaller terminals exit with code 2. + sourcegraph-mcp serve [--solution ] [--db ] [--root ] [--model ] [--no-embeddings] [--no-model-download] [--no-history] Run the MCP stdio server. With --solution given, registers an implicit single-scope `default` mapped to that solution. Otherwise reads `.sourcegraph.json` from --root @@ -321,19 +378,31 @@ Delete all rows from the graph database (schema preserved). sourcegraph-mcp init [--yes] [--client ] [--no-] [--user-] [--claude-desktop] [--solution ] [--install-mode ] - [--print-only] [--force] [--prewarm | --no-prewarm] + [--print-only] [--force] [--diff] [--prewarm | --no-prewarm] [--no-embeddings] [--no-history] [--root ] Interactive (default) or flag-driven onboarding flow. Detects environment, picks MCP clients, writes per-client config files (project-scoped by default), and optionally pre-warms the index. First-class clients: claude-code, copilot, cursor, continue, claude-desktop. Use --print-only for a CI-friendly preview that writes - nothing. + nothing. Use --diff to preview a unified diff when a writer would skip an existing + differing entry; combined with --force, the diff prints before the overwrite. sourcegraph-mcp doctor [--root ] [--json] Read-only environment diagnostic. Reports SDK/git/solution/config/per-client status. Exit 0 = all-pass; 2 = at least one warning; 1 = hard failure. --json emits a machine-readable {checks, exit_code} document instead of glyph output. + sourcegraph-mcp status [--root ] [--json] [--watch] [--watch-interval ] + [--no-color] [--activity-bytes ] + One-screen operator console: aggregates Environment, Scopes, Clients, Embeddings, + and Recent activity into a phase-headed snapshot. Reads SQLite DBs in read-only + mode and works whether `serve` is running concurrently or not. Exit 0 = all-pass; + 2 = any warn (partial scope, missing git, absent embedding cache); 1 = any hard + fail (no .NET 10 SDK, malformed .sourcegraph.json, degraded scope, unwritable DB + dir). --json emits a stable snake_case DashboardSnapshot document. --watch enters + a polling redraw loop under a tty (interval default 2s); piped invocations + downgrade silently to a single snapshot. + sourcegraph-mcp demo [--scope ] [--root ] [--no-color] Run four canned operations (ping, graph_stats, search_symbols, find_definition) against the active scope's DB and print leaf-stamped markdown — the same shape diff --git a/src/DevBitsLab.Mcp.SourceGraph.Server/Cli/DoctorCli.cs b/src/DevBitsLab.Mcp.SourceGraph.Server/Cli/DoctorCli.cs index 0f6885c8..c58e9024 100644 --- a/src/DevBitsLab.Mcp.SourceGraph.Server/Cli/DoctorCli.cs +++ b/src/DevBitsLab.Mcp.SourceGraph.Server/Cli/DoctorCli.cs @@ -1,5 +1,5 @@ using System.Text.Json; -using DevBitsLab.Mcp.SourceGraph.Storage; +using DevBitsLab.Mcp.SourceGraph.Server.Cli.Snapshot; namespace DevBitsLab.Mcp.SourceGraph.Server.Cli; @@ -8,6 +8,15 @@ namespace DevBitsLab.Mcp.SourceGraph.Server.Cli; /// diagnostic. Reports SDK / git / solution / config / per-client-wiring status. Exit codes /// follow the vocabulary list --strict precedent: 0 on all-pass, 2 on any warn, /// 1 on any hard fail. +/// +/// +/// Implementation note: the check list is projected from a +/// built by , not from a direct +/// OnboardingDetector.DetectAsync call. Observable behaviour — message wording, exit +/// codes, JSON shape — is preserved verbatim (pinned by DoctorCliGoldenTests); the +/// refactor moves doctor and status onto the same data source so the two surfaces +/// can't drift. +/// /// internal static class DoctorCli { @@ -15,113 +24,122 @@ public static async Task RunAsync(CommandLine cli) { var jsonMode = cli.Json; var root = cli.ResolvedRepoRoot(); - var detection = await OnboardingDetector.DetectAsync(root).ConfigureAwait(false); + // Build the unified snapshot. Doctor doesn't use the JSONL activity tail or per-scope + // counts at v1, but pulling from the same builder ensures any future cross-surface + // invariant (drift, integrity check, …) lands in doctor automatically. + var snapshot = await SnapshotBuilder.BuildAsync(root, new SnapshotOptions()).ConfigureAwait(false); + + var checks = ProjectChecksFromSnapshot(snapshot); + + if (jsonMode) + { + EmitJson(checks); + } + else + { + EmitHuman(checks); + } + + if (checks.Any(c => c.Status == DoctorStatus.Fail)) return 1; + if (checks.Any(c => c.Status == DoctorStatus.Warn)) return 2; + return 0; + } + + /// + /// Map a to today's eight-check doctor list. The mapping is + /// the documented byte-stable contract; every wording string here is identical to the + /// pre-refactor doctor output (pinned by the golden-file tests). + /// + private static List ProjectChecksFromSnapshot(DashboardSnapshot snapshot) + { var checks = new List(); + var env = snapshot.Environment; // 1. .NET SDK. - if (string.IsNullOrEmpty(detection.DotnetSdkVersion)) + if (string.IsNullOrEmpty(env.DotnetSdkVersion)) { checks.Add(new("dotnet-sdk", DoctorStatus.Fail, "no .NET SDK on PATH (>= 10.0 required); see https://dotnet.microsoft.com/download")); } else { - var ok = SdkVersionMeetsMin(detection.DotnetSdkVersion, major: 10); + var ok = SdkVersionMeetsMin(env.DotnetSdkVersion, major: 10); checks.Add(new("dotnet-sdk", ok ? DoctorStatus.Pass : DoctorStatus.Fail, - ok ? $".NET SDK {detection.DotnetSdkVersion}" - : $".NET SDK {detection.DotnetSdkVersion} is below the required 10.0")); + ok ? $".NET SDK {env.DotnetSdkVersion}" + : $".NET SDK {env.DotnetSdkVersion} is below the required 10.0")); } // 2. git. - checks.Add(new("git", detection.GitOnPath ? DoctorStatus.Pass : DoctorStatus.Warn, - detection.GitOnPath + checks.Add(new("git", env.GitOnPath ? DoctorStatus.Pass : DoctorStatus.Warn, + env.GitOnPath ? "git on PATH" : "git not on PATH — `who_authored` and `recent_changes` will return empty; pass --no-history to silence")); // 3. Repo root readable. - checks.Add(new("repo-root", Directory.Exists(detection.RepoRootPath) ? DoctorStatus.Pass : DoctorStatus.Fail, - $"repo root: {detection.RepoRootPath}")); + checks.Add(new("repo-root", Directory.Exists(env.RepoRootPath) ? DoctorStatus.Pass : DoctorStatus.Fail, + $"repo root: {env.RepoRootPath}")); // 4. Solution discoverable. - checks.Add(detection.SolutionFiles.Count > 0 + checks.Add(env.SolutionFiles.Count > 0 ? new DoctorCheck("solutions", DoctorStatus.Pass, - $"discovered {detection.SolutionFiles.Count} solution(s): {string.Join(", ", detection.SolutionFiles.Select(Path.GetFileName))}") + $"discovered {env.SolutionFiles.Count} solution(s): {string.Join(", ", env.SolutionFiles.Select(Path.GetFileName))}") : new DoctorCheck("solutions", DoctorStatus.Warn, "no .slnx/.sln files at repo root — run `sourcegraph-mcp init --solution ` if you want to scaffold a config explicitly")); // 5. .sourcegraph.json status. - switch (detection.SourceGraphConfigStatus) + switch (env.SourceGraphConfigStatus) { - case SourceGraphConfigStatus.Valid: + case "valid": checks.Add(new("sourcegraph-config", DoctorStatus.Pass, ".sourcegraph.json parses cleanly")); break; - case SourceGraphConfigStatus.Missing: + case "missing": checks.Add(new("sourcegraph-config", DoctorStatus.Pass, "no .sourcegraph.json (single-scope synth path)")); break; - case SourceGraphConfigStatus.Malformed: + case "malformed": checks.Add(new("sourcegraph-config", DoctorStatus.Fail, - $".sourcegraph.json malformed: {detection.SourceGraphConfigError}")); + $".sourcegraph.json malformed: {env.SourceGraphConfigError}")); break; } - // 6. Embedding model cache. - var modelCachePath = ResolveModelCachePath(); - if (Directory.Exists(modelCachePath)) + // 6. Embedding model cache. Pre-refactor doctor reported "present" iff + // `Directory.Exists(cache_dir)` regardless of whether it held files, then printed total + // bytes (which can legitimately be zero for a freshly-created empty dir). We preserve + // that wording here by querying `Directory.Exists` separately rather than reading + // `cache_present` (which the snapshot exposes with the stricter "has files" semantics + // documented in the JSON contract). + var emb = snapshot.Embeddings; + if (Directory.Exists(emb.CacheDir)) { - long total = 0; - try - { - foreach (var f in Directory.EnumerateFiles(modelCachePath, "*", SearchOption.AllDirectories)) - { - try { total += new FileInfo(f).Length; } - catch (IOException) { /* best-effort: skip unreadable files */ } - catch (UnauthorizedAccessException) { /* best-effort */ } - } - } - catch (IOException) { /* best-effort: cache dir disappeared mid-walk */ } - catch (UnauthorizedAccessException) { /* best-effort */ } checks.Add(new("embedding-cache", DoctorStatus.Pass, - $"embedding model cache present at {modelCachePath} ({total / 1024 / 1024} MB)")); + $"embedding model cache present at {emb.CacheDir} ({emb.TotalBytes / 1024 / 1024} MB)")); } else { checks.Add(new("embedding-cache", DoctorStatus.Warn, - $"embedding model cache absent at {modelCachePath} — `semantic_search` will return its disabled-message until model files are placed there (or pass --no-embeddings to silence)")); + $"embedding model cache absent at {emb.CacheDir} — `semantic_search` will return its disabled-message until model files are placed there (or pass --no-embeddings to silence)")); } - // 7. Per-scope DB writability. - var scopeDir = Path.Join(detection.RepoRootPath, ".sourcegraph", "scopes"); + // 7. Per-scope DB writability. Computed inline; snapshot doesn't carry writability today + // (it's a function of permission state, not data). + var scopeDir = Path.Join(env.RepoRootPath, ".sourcegraph", "scopes"); var dbWritable = TestWritability(scopeDir); checks.Add(new("db-writable", dbWritable ? DoctorStatus.Pass : DoctorStatus.Fail, dbWritable ? $"per-scope DB dir writable: {scopeDir}" : $"per-scope DB dir not writable: {scopeDir}")); - // 8. Per-client config files. Only existing config files are reported; an absent - // optional config slot is not a finding. - foreach (var c in detection.ClientConfigsDetected.Where(x => x.Exists)) + // 8. Per-client config files. Walk the snapshot's `clients` list (the unified version of + // `OnboardingDetectionResult.ClientConfigsDetected`); preserve the existing rule: only + // existing config files are reported, absent slots are not a finding. + foreach (var c in snapshot.Clients.Where(x => x.Exists)) { var status = c.ContainsSourcegraphEntry ? DoctorStatus.Pass : DoctorStatus.Warn; var msg = c.ContainsSourcegraphEntry - ? $"{c.Client.ToSlug()} config wired ({(c.IsUserScope ? "user" : "project")}: {c.Path})" - : $"{c.Client.ToSlug()} config exists but has no sourcegraph entry — run `sourcegraph-mcp init --client {c.Client.ToSlug()}` ({(c.IsUserScope ? "user" : "project")}: {c.Path})"; - checks.Add(new($"client-{c.Client.ToSlug()}", status, msg)); + ? $"{c.Slug} config wired ({c.Scope}: {c.Path})" + : $"{c.Slug} config exists but has no sourcegraph entry — run `sourcegraph-mcp init --client {c.Slug}` ({c.Scope}: {c.Path})"; + checks.Add(new($"client-{c.Slug}", status, msg)); } - - // Output. - if (jsonMode) - { - EmitJson(checks); - } - else - { - EmitHuman(checks); - } - - // Exit code. - if (checks.Any(c => c.Status == DoctorStatus.Fail)) return 1; - if (checks.Any(c => c.Status == DoctorStatus.Warn)) return 2; - return 0; + return checks; } private static void EmitHuman(List checks) @@ -184,16 +202,6 @@ private static bool SdkVersionMeetsMin(string version, int major) return int.TryParse(version.AsSpan(0, firstDot), out var ver) && ver >= major; } - private static string ResolveModelCachePath() - { - // Single source of truth: ModelStore.DefaultCacheDir() is a pure calculation that the - // generator's path resolution also goes through. Calling it directly keeps doctor and - // the live path in lockstep — duplicating the resolution let an earlier prefix mismatch - // (`sourcegraph-mcp/models` vs `devbitslab.sourcegraph/models`) make doctor warn even - // when the cache was actually present. - return DevBitsLab.Mcp.SourceGraph.Embeddings.ModelStore.DefaultCacheDir(); - } - private static bool TestWritability(string dir) { try diff --git a/src/DevBitsLab.Mcp.SourceGraph.Server/Cli/InitCli.cs b/src/DevBitsLab.Mcp.SourceGraph.Server/Cli/InitCli.cs index 38f2b879..5e8e82a4 100644 --- a/src/DevBitsLab.Mcp.SourceGraph.Server/Cli/InitCli.cs +++ b/src/DevBitsLab.Mcp.SourceGraph.Server/Cli/InitCli.cs @@ -1,5 +1,8 @@ using System.Diagnostics; +using System.Reflection; using DevBitsLab.Mcp.SourceGraph.Server.Cli.ClientConfigWriters; +using DevBitsLab.Mcp.SourceGraph.Server.Cli.Rendering; +using DevBitsLab.Mcp.SourceGraph.Server.Dashboard; using DevBitsLab.Mcp.SourceGraph.Storage; namespace DevBitsLab.Mcp.SourceGraph.Server.Cli; @@ -9,25 +12,27 @@ namespace DevBitsLab.Mcp.SourceGraph.Server.Cli; /// MCP clients (interactive or flag-driven), runs each client's writer, optionally pre-warms /// the index, and prints a closing report. Project-scoped writes are the default; user-scope /// writes require an explicit per-client flag. +/// +/// The rendering layer lives in ; this method composes the phase +/// transitions and threads the right inputs into each renderer call. The split keeps the policy +/// (what fires when, what's selected) here and the presentation (column widths, glyphs, two- +/// space indentation) in the renderer module so future operator-console work can reuse the same +/// row primitives. /// internal static class InitCli { - /// Banner printed at the top of an interactive init session, immediately - /// before the detection summary. - private const string Heading = "🌿 SourceGraph init"; - public static async Task RunAsync(CommandLine cli) { var root = cli.ResolvedRepoRoot(); + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile, + Environment.SpecialFolderOption.DoNotVerify); var detection = await OnboardingDetector.DetectAsync(root).ConfigureAwait(false); var interactive = !cli.Yes && !cli.PrintOnly && IsStdinInteractive(); if (!cli.PrintOnly) { - Console.WriteLine(Heading); - Console.WriteLine(); - PrintDetectionSummary(detection); - Console.WriteLine(); + InitRenderer.RenderBanner(Console.Out); + InitRenderer.RenderEnvironment(Console.Out, detection, root, home); } if (detection.SourceGraphConfigStatus == SourceGraphConfigStatus.Malformed) @@ -61,6 +66,13 @@ await Console.Error.WriteLineAsync( var installMode = ParseInstallMode(cli.InstallMode); + // Apply phase heading — emitted once, before the writer loop, so progress rows can stream + // under it as each writer runs. + if (!cli.PrintOnly) + { + InitRenderer.RenderApplyHeading(Console.Out); + } + // Run each writer. Collect results for the closing report. var results = new List(); foreach (var (clientId, useUserScope) in enabledClients) @@ -82,6 +94,11 @@ await Console.Error.WriteLineAsync( _ => $"client `{clientId.ToSlug()}` has no {(useUserScope ? "user" : "project")}-scope target path", }; Console.Error.WriteLine($"warn: skipping {clientId.ToSlug()} ({(useUserScope ? "user" : "project")}): {msg}"); + if (!cli.PrintOnly) + { + InitRenderer.RenderApplyRow(Console.Out, WriterAction.SkipUnsupported, + clientId.ToSlug(), targetPath ?? "(no target path)", msg, root, home); + } results.Add(new WriterRunResult(clientId, useUserScope, "(no target path)", WriterAction.SkipUnsupported, msg)); continue; @@ -93,8 +110,14 @@ await Console.Error.WriteLineAsync( } catch (IOException ex) { + var msg = $"could not read existing file: {ex.Message}"; + if (!cli.PrintOnly) + { + InitRenderer.RenderApplyRow(Console.Out, WriterAction.SkipExistingDiffers, + clientId.ToSlug(), targetPath, msg, root, home); + } results.Add(new WriterRunResult(clientId, useUserScope, targetPath, - WriterAction.SkipExistingDiffers, $"could not read existing file: {ex.Message}")); + WriterAction.SkipExistingDiffers, msg)); continue; } // Force `--no-history` into the emitted args when git isn't on PATH. Without git the @@ -123,23 +146,51 @@ await Console.Error.WriteLineAsync( continue; } + // --diff: when the plan would skip-on-conflict (or under --force, would overwrite a + // differing entry), render a unified diff of existing-vs-proposed bytes so the user + // can see what changes. --force composes: print the diff first, then proceed with + // the write. The diff is a no-op for Insert / NoOpAlreadyMatches / SkipHasComments / + // SkipUnsupported plans — those carry no useful "what changed" comparison. + var diffApplies = cli.Diff && existing is not null && ( + plan.Action == WriterAction.SkipExistingDiffers || + plan.Action == WriterAction.ReplaceOurs); + if (diffApplies) + { + Rendering.UnifiedDiffRenderer.Render( + existing!, + plan.ContentBytes, + fromLabel: plan.TargetPath, + toLabel: plan.TargetPath + ".proposed", + writer: Console.Out); + } + try { writer.Apply(plan); } catch (IOException ex) { + var msg = $"apply failed: {ex.Message}"; + InitRenderer.RenderApplyRow(Console.Out, WriterAction.SkipExistingDiffers, + clientId.ToSlug(), plan.TargetPath, msg, root, home); results.Add(new WriterRunResult(clientId, useUserScope, plan.TargetPath, - WriterAction.SkipExistingDiffers, $"apply failed: {ex.Message}")); + WriterAction.SkipExistingDiffers, msg)); continue; } catch (UnauthorizedAccessException ex) { + var msg = $"apply failed: {ex.Message}"; + InitRenderer.RenderApplyRow(Console.Out, WriterAction.SkipExistingDiffers, + clientId.ToSlug(), plan.TargetPath, msg, root, home); results.Add(new WriterRunResult(clientId, useUserScope, plan.TargetPath, - WriterAction.SkipExistingDiffers, $"apply failed: {ex.Message}")); + WriterAction.SkipExistingDiffers, msg)); continue; } + // Render the Apply row for the outcome. + InitRenderer.RenderApplyRow(Console.Out, plan.Action, + clientId.ToSlug(), plan.TargetPath, plan.Description, root, home); + // Comment-aware degraded path: even when not --print-only, a SkipHasComments outcome // emits the snippet to stdout so the user can paste manually. if (plan.Action == WriterAction.SkipHasComments) @@ -161,8 +212,11 @@ await Console.Error.WriteLineAsync( if (!cli.PrintOnly) { - Console.WriteLine(); - PrintClosingReport(results); + InitRenderer.RenderNext(Console.Out, new[] + { + "Open this repo in your MCP client.", + "Verify with `sourcegraph-mcp demo`.", + }); } // Exit code: 0 unless any writer skipped because of a conflict; 2 in that case (matches @@ -181,22 +235,6 @@ private static bool IsStdinInteractive() catch (IOException) { return false; } } - private static void PrintDetectionSummary(OnboardingDetectionResult d) - { - Console.WriteLine($" ✓ .NET SDK : {d.DotnetSdkVersion ?? "(not detected)"}"); - Console.WriteLine($" {(d.GitOnPath ? "✓" : "⚠")} git on PATH : {(d.GitOnPath ? "yes" : "no (--no-history will be implied)")}"); - Console.WriteLine($" ✓ repo root : {d.RepoRootPath}"); - Console.WriteLine($" ✓ solutions detected : {(d.SolutionFiles.Count == 0 ? "(none)" : string.Join(", ", d.SolutionFiles.Select(Path.GetFileName)))}"); - var sgState = d.SourceGraphConfigStatus switch - { - SourceGraphConfigStatus.Valid => "valid", - SourceGraphConfigStatus.Missing => "missing (single-scope synth path)", - SourceGraphConfigStatus.Malformed => $"MALFORMED — {d.SourceGraphConfigError}", - _ => "?", - }; - Console.WriteLine($" ✓ .sourcegraph.json : {sgState}"); - } - /// /// Decide whether to use --solution <path> mode (single solution, args carry the /// resolved path) or --root mode (multi-scope, args carry the workspace folder). @@ -277,15 +315,13 @@ private static string TryRelativeWorkspacePath(string root, string absolute) } else { - // Default candidates: the four project-scoped clients. Claude Desktop is opt-in only. - candidates = new HashSet - { - ClientId.ClaudeCode, - ClientId.Copilot, - ClientId.Cursor, - ClientId.Continue, - }; + // Default candidates come from detection-driven signals. claude-code and copilot are + // always on; cursor / continue / claude-desktop flip based on what's installed on this + // machine (matches the `init batched client picker` requirement). + var defaults = OnboardingDetector.ComputePickerDefaults(d.RepoRootPath); + candidates = new HashSet(defaults.Where(kv => kv.Value).Select(kv => kv.Key)); } + // --claude-desktop forces default-on regardless of detection (documented escape hatch). if (cli.ClaudeDesktop) candidates.Add(ClientId.ClaudeDesktop); // Apply --no- drops. Slugs that don't parse to a known ClientId are silently @@ -298,7 +334,7 @@ private static string TryRelativeWorkspacePath(string root, string absolute) // Interactive picker (only when no explicit --client was given). if (interactive && cli.Clients.Count == 0) { - candidates = InteractiveClientPicker(candidates, d, cli.ClaudeDesktop); + candidates = InteractiveClientPicker(candidates, d); } // Map to (id, useUserScope). User-scope is requested via --user-. @@ -314,20 +350,50 @@ private static string TryRelativeWorkspacePath(string root, string absolute) } private static HashSet InteractiveClientPicker( - HashSet autoSelected, OnboardingDetectionResult d, bool claudeDesktopOptedIn) + HashSet autoSelected, OnboardingDetectionResult d) { - Console.WriteLine("Which clients should I wire up? (Enter to accept, type 'n' to skip a client)"); + // Section-6 wiring will replace this method with the batched-grammar picker. Today's body + // is the per-row [Y/n] flow with every client (including claude-desktop) always visible. + Console.WriteLine("Which clients should I wire up? Type '+slug -slug' to edit, Enter to accept, 'n' to skip all."); + var defaults = OnboardingDetector.ComputePickerDefaults(d.RepoRootPath); var picked = new HashSet(); - var visible = Enum.GetValues() - .Where(id => id != ClientId.ClaudeDesktop || claudeDesktopOptedIn); - foreach (var id in visible) - { - var defaultYes = autoSelected.Contains(id); - var marker = defaultYes ? "[Y/n]" : "[y/N]"; - Console.Write($" {id.ToSlug(),-15} {marker} "); - var line = Console.ReadLine(); - var answer = NormaliseYesNo(line, defaultYes); - if (answer) picked.Add(id); + var slugs = new List(); + foreach (var id in Enum.GetValues()) + { + var defaultYes = autoSelected.Contains(id) || defaults[id]; + var glyph = DashboardTheme.DotPlain(defaultYes ? StatusKind.Ok : StatusKind.Off); + Console.WriteLine($" {glyph}{id.ToSlug(),-15}"); + if (defaultYes) slugs.Add(id.ToSlug()); + } + var knownSlugs = new HashSet(StringComparer.Ordinal) + { + "claude-code", "copilot", "cursor", "continue", "claude-desktop", + }; + Console.Write(" Accept defaults? [Y/n] or edit (e.g. \"+cursor -copilot\"): "); + var raw = Console.ReadLine(); + var defaultsSet = new HashSet(slugs, StringComparer.Ordinal); + var result = BatchedPickerInput.Parse(raw ?? string.Empty, defaultsSet, knownSlugs); + if (result.NeedsReprompt) + { + Console.Write(" Invalid input. Accept defaults? [Y/n] or edit (e.g. \"+cursor -copilot\"): "); + raw = Console.ReadLine(); + result = BatchedPickerInput.Parse(raw ?? string.Empty, defaultsSet, knownSlugs); + if (result.NeedsReprompt) + { + // Second invalid input: treat as 'n' (deselect all) and proceed. + result = BatchedPickerInput.AllOff(); + } + } + foreach (var slug in result.UnknownSlugs) + { + Console.Error.WriteLine($"warn: unknown picker slug ignored: {slug}"); + } + foreach (var slug in result.Selection) + { + if (ClientIdExtensions.TryParseSlug(slug, out var id)) + { + picked.Add(id); + } } return picked; } @@ -402,34 +468,6 @@ private static void PrintPlanToStdout(WriterPlan plan) Console.WriteLine(); } - private static void PrintClosingReport(List results) - { - Console.WriteLine("Summary:"); - foreach (var r in results) - { - var glyph = r.Action switch - { - WriterAction.Insert => "✓ wrote", - WriterAction.ReplaceOurs => "✓ replaced", - WriterAction.NoOpAlreadyMatches => "= no change", - WriterAction.SkipExistingDiffers => "⚠ skipped (conflict)", - WriterAction.SkipHasComments => "⚠ skipped (comments)", - WriterAction.SkipUnsupported => "ⓘ skipped (unsupported)", - _ => "? ", - }; - var scope = r.UserScope ? "user" : "project"; - Console.WriteLine($" {glyph,-22} {r.ClientId.ToSlug(),-15} ({scope}) → {r.TargetPath}"); - if (r.Action is WriterAction.SkipExistingDiffers or WriterAction.SkipUnsupported) - { - Console.WriteLine($" {r.Description}"); - } - } - Console.WriteLine(); - Console.WriteLine("Next:"); - Console.WriteLine(" • Open this repo in your MCP client."); - Console.WriteLine(" • Verify with `sourcegraph-mcp demo`."); - } - private static async Task PrewarmAsync(string solutionPath, string root) { // Resolve to an absolute path. ${workspaceFolder} expansion handled by ExpandTokens. @@ -441,51 +479,58 @@ private static async Task PrewarmAsync(string solutionPath, string root) Console.Error.WriteLine($"warn: --prewarm requested but solution not found at {abs}"); return; } - Console.WriteLine(); - Console.WriteLine($"Pre-warming index against {Path.GetFileName(abs)}…"); + var solutionName = Path.GetFileName(abs); + InitRenderer.RenderPreWarmHeading(Console.Out, solutionName); var sw = Stopwatch.StartNew(); // Shell out for the pre-warm so we don't re-create the indexer construction graph that - // lives in Program.cs. Three invocation strategies, tried in order — the first one that - // starts wins. The order is chosen so the path that actually works in each install - // mode is hit first: - // 1. `dotnet sourcegraph-mcp index ` — works for global tool install AND for the - // .NET local-tool manifest pattern. Most common in practice. - // 2. ` index ` — works when the current process is launched via a - // native apphost (e.g. running in-tree via `dotnet run` produces an apphost binary - // whose Environment.ProcessPath is the apphost itself, not "dotnet"). - // 3. `dotnet index ` — last-resort fallback when neither of the - // above starts: re-invokes the same .dll under `dotnet exec`-style launch. - var attempts = new List<(string FileName, string[] Args)> - { - ("dotnet", new[] { "sourcegraph-mcp", "index", abs }), - }; + // lives in Program.cs. The strategy is "re-invoke the same binary we are now," because + // it's guaranteed to know the `index` subcommand and live with no install dependency. + // + // The entry binary is discovered in two ways with different reliability: + // + // 1. `Assembly.GetEntryAssembly()?.Location` — the entry .dll path. Reliable in + // every run mode (dev `dotnet `, global tool, `dotnet publish` apphost, + // local-tool manifest); empty only under single-file deployment. + // + // 2. `Environment.ProcessPath` — the executable that started the process. In dev + // mode this is the dotnet host (e.g. `/usr/local/share/dotnet/dotnet`), which is + // NOT a sourcegraph binary — so we only use it when it looks like an apphost + // (path ends with `sourcegraph-mcp` / `sourcegraph-mcp.exe`). + // + // Strategies are tried in order; the first one that starts AND exits 0 wins. We try + // the entry-self forms first because they always work in dev mode (the most common + // local-test context). The `dotnet sourcegraph-mcp` global-tool form is the fallback + // for the single-file edge case where neither entry hint is available. + var attempts = new List<(string FileName, string[] Args)>(); + var entryDll = Assembly.GetEntryAssembly()?.Location; + if (!string.IsNullOrEmpty(entryDll) && entryDll.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)) + { + attempts.Add(("dotnet", new[] { entryDll, "index", abs })); + } var processPath = Environment.ProcessPath; - if (!string.IsNullOrEmpty(processPath)) + if (!string.IsNullOrEmpty(processPath) && IsSourceGraphApphost(processPath)) { - // Heuristic: a `.dll` at ProcessPath means we're being run as `dotnet `; any - // other extension (none on Unix, `.exe` on Windows) means an apphost. - if (processPath.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)) - { - attempts.Add(("dotnet", new[] { processPath, "index", abs })); - } - else - { - attempts.Add((processPath, new[] { "index", abs })); - } + attempts.Add((processPath, new[] { "index", abs })); } - + // Global-tool fallback. Only reachable when neither entry hint resolved (single-file + // publish without an embedded dll path). Carries the original implementation's + // assumption that `sourcegraph-mcp` is installed on PATH via `dotnet tool install -g`. + attempts.Add(("dotnet", new[] { "sourcegraph-mcp", "index", abs })); + + // Capture stderr from each attempt rather than inheriting it; we only surface output + // from the attempt we accept (exit 0) so a transient first-strategy failure doesn't + // leak a confusing "Could not execute…" message before the (successful) fallback. + // stdout we still inherit because successful indexer runs print progress that's worth + // seeing live; an interleave with our own rendering is acceptable since the indexer is + // the only live writer at this point. + string? lastStderr = null; + int? lastExit = null; foreach (var (fileName, args) in attempts) { - // Inherit stdout/stderr instead of redirecting. The original implementation - // redirected both pipes but never drained them, which would deadlock the child - // once a buffer filled (a real `index` pass on a sizeable solution easily - // produces enough output to hit that). Inheriting lets the user see indexer - // progress live AND avoids the deadlock entirely; we only need to know the - // child's exit code, which comes from WaitForExitAsync. var psi = new ProcessStartInfo(fileName) { RedirectStandardOutput = false, - RedirectStandardError = false, + RedirectStandardError = true, UseShellExecute = false, }; foreach (var a in args) psi.ArgumentList.Add(a); @@ -493,10 +538,24 @@ private static async Task PrewarmAsync(string solutionPath, string root) { using var p = Process.Start(psi); if (p is null) continue; + // Drain stderr concurrently with the wait so a chatty stderr can't deadlock + // the child. We re-emit it only if this attempt wins. + var stderrTask = p.StandardError.ReadToEndAsync(); await p.WaitForExitAsync().ConfigureAwait(false); - sw.Stop(); - Console.WriteLine($" pre-warm: exit {p.ExitCode} in {sw.Elapsed.TotalSeconds:F1}s"); - return; + var stderr = await stderrTask.ConfigureAwait(false); + if (p.ExitCode == 0) + { + if (!string.IsNullOrEmpty(stderr)) Console.Error.Write(stderr); + sw.Stop(); + InitRenderer.RenderPreWarmSummary(Console.Out, p.ExitCode, sw.Elapsed, solutionName); + return; + } + // Non-zero exit. Could be "tool not installed" (try next) OR "indexer failed" + // (would surface as the final result if no later attempt wins). Remember and + // continue. + lastStderr = stderr; + lastExit = p.ExitCode; + continue; } catch (System.ComponentModel.Win32Exception) { @@ -514,7 +573,30 @@ private static async Task PrewarmAsync(string solutionPath, string root) return; } } - Console.Error.WriteLine("warn: pre-warm could not start any subprocess (tried `dotnet sourcegraph-mcp` + the current entry binary). Run `sourcegraph-mcp index ` manually if needed."); + // No strategy succeeded. Re-emit the most recent stderr (closest to what the user + // would have wanted to see) and a single summary line for the closing report. + sw.Stop(); + if (!string.IsNullOrEmpty(lastStderr)) Console.Error.Write(lastStderr); + if (lastExit.HasValue) + { + InitRenderer.RenderPreWarmSummary(Console.Out, lastExit.Value, sw.Elapsed, solutionName); + } + else + { + Console.Error.WriteLine("warn: pre-warm could not start any subprocess. Run `sourcegraph-mcp index ` manually if needed."); + } + } + + /// + /// Heuristic: is a sourcegraph-mcp apphost (vs. the dotnet + /// host or some unrelated launcher)? Apphosts produced by dotnet publish and + /// dotnet tool install -g name themselves after the assembly entry-point; we accept + /// any file whose base name (without `.exe`) is exactly sourcegraph-mcp. + /// + private static bool IsSourceGraphApphost(string processPath) + { + var name = Path.GetFileNameWithoutExtension(processPath); + return string.Equals(name, "sourcegraph-mcp", StringComparison.OrdinalIgnoreCase); } private sealed record WriterRunResult( diff --git a/src/DevBitsLab.Mcp.SourceGraph.Server/Cli/OnboardingDetector.cs b/src/DevBitsLab.Mcp.SourceGraph.Server/Cli/OnboardingDetector.cs index dacf028b..190278d4 100644 --- a/src/DevBitsLab.Mcp.SourceGraph.Server/Cli/OnboardingDetector.cs +++ b/src/DevBitsLab.Mcp.SourceGraph.Server/Cli/OnboardingDetector.cs @@ -134,6 +134,47 @@ private static IReadOnlyList DetectClientConfigs(string ro if (!string.IsNullOrEmpty(desktop)) yield return (ClientId.ClaudeDesktop, desktop); } + /// + /// Per-client picker default-on signal, computed from detection state. Used by the polished + /// init picker so first-run defaults track what the user actually has installed. + /// Rules (see the init batched client picker requirement): + /// + /// claude-code: always on (project-scoped .mcp.json is the canonical wire-up). + /// copilot: always on (.vscode/mcp.json is the committed-config pattern). + /// cursor: on iff <root>/.cursor/ directory OR ~/.cursor/mcp.json exists. + /// continue: on iff <root>/.continue/ directory OR ~/.continue/mcp/sourcegraph.yaml exists. + /// claude-desktop: on iff the platform-specific config file exists. + /// + /// + public static IReadOnlyDictionary ComputePickerDefaults(string root) + { + var rooted = string.IsNullOrEmpty(root) ? Directory.GetCurrentDirectory() : Path.GetFullPath(root); + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile, + Environment.SpecialFolderOption.DoNotVerify); + + var cursorOn = Directory.Exists(Path.Join(rooted, ".cursor")) + || (!string.IsNullOrEmpty(home) && File.Exists(Path.Join(home, ".cursor", "mcp.json"))); + + var continueOn = Directory.Exists(Path.Join(rooted, ".continue")) + || (!string.IsNullOrEmpty(home) && File.Exists(Path.Join(home, ".continue", "mcp", "sourcegraph.yaml"))); + + var claudeDesktopOn = false; + if (!string.IsNullOrEmpty(home)) + { + var desktopPath = ClaudeDesktopUserPath(home); + claudeDesktopOn = !string.IsNullOrEmpty(desktopPath) && File.Exists(desktopPath); + } + + return new Dictionary + { + [ClientId.ClaudeCode] = true, + [ClientId.Copilot] = true, + [ClientId.Cursor] = cursorOn, + [ClientId.Continue] = continueOn, + [ClientId.ClaudeDesktop] = claudeDesktopOn, + }; + } + /// /// Per-OS Claude Desktop config path: %APPDATA%\Claude\ on Windows, /// ~/Library/Application Support/Claude/ on macOS, ~/.config/Claude/ elsewhere. diff --git a/src/DevBitsLab.Mcp.SourceGraph.Server/Cli/Rendering/InitRenderer.cs b/src/DevBitsLab.Mcp.SourceGraph.Server/Cli/Rendering/InitRenderer.cs new file mode 100644 index 00000000..0ce80796 --- /dev/null +++ b/src/DevBitsLab.Mcp.SourceGraph.Server/Cli/Rendering/InitRenderer.cs @@ -0,0 +1,245 @@ +using DevBitsLab.Mcp.SourceGraph.Server.Cli.ClientConfigWriters; +using DevBitsLab.Mcp.SourceGraph.Server.Dashboard; +using DevBitsLab.Mcp.SourceGraph.Server.Tools; + +namespace DevBitsLab.Mcp.SourceGraph.Server.Cli.Rendering; + +/// +/// Phase-organised renderer for the init subcommand. One method per phase, every method +/// takes a so tests can capture output without redirecting +/// . The dot vocabulary lives in ; path +/// display lives in ; this class composes the row layouts. +/// +/// +/// Five phases: Environment, Clients to wire, Apply, Pre-warm +/// (omitted when no pre-warm runs), Next. Each phase heading is prefixed with the +/// section leader () to match the +/// dashboard's detail-view headers; row content under each heading is indented four spaces so +/// the dot column lines up under the section name. Every row-status position uses the +/// dot vocabulary (); the leaf 🌿 is reserved for +/// the banner brand mark and MCP tool responses only. +/// +/// +internal static class InitRenderer +{ + // Column widths — kept as constants so future phase additions reuse the same alignment. + // Rows under section headers use four-space indent so the dot column sits two cells inside + // the ◆ leader column (matches the dashboard's detail-view body indent). + private const string Indent = " "; + + // 18-char column for the Environment phase key label, picked to accommodate + // ".sourcegraph.json" (the longest label in today's detection summary). + private const int EnvKeyWidth = 18; + + // 15-char column for slug labels (longest slug is "claude-desktop" at 14 chars). + private const int SlugWidth = 15; + + // 18-char column for the Apply phase verb label so the slug column lines up. + private const int VerbWidth = 18; + + /// + /// Writes the banner line. The leading leaf is the brand mark only; the per-row status + /// language switched to dots when the visual was unified with the dashboard. Suppressed when + /// is true. + /// + public static void RenderBanner(TextWriter writer, string? version = null) + { + var prefix = LeafFormatter.Suppressed ? "" : LeafFormatter.Mark; + var versionTail = string.IsNullOrEmpty(version) ? "" : " " + version; + writer.WriteLine($"{prefix}SourceGraph init{versionTail}"); + writer.WriteLine(); + } + + /// + /// Writes the Environment phase: SDK version, git availability, repo root, solutions + /// detected, .sourcegraph.json status. Each row uses the dot vocabulary + /// ( for present/pass, for soft + /// warnings like missing git, for hard errors). + /// + public static void RenderEnvironment(TextWriter writer, OnboardingDetectionResult detection, string root, string? home) + { + writer.WriteLine(DashboardTheme.SectionHeaderPlain("Environment")); + + // .NET SDK + WriteEnvRow(writer, + kind: detection.DotnetSdkVersion is null ? StatusKind.Warn : StatusKind.Ok, + key: ".NET SDK", + value: detection.DotnetSdkVersion ?? "(not detected)"); + + // git on PATH + WriteEnvRow(writer, + kind: detection.GitOnPath ? StatusKind.Ok : StatusKind.Warn, + key: "git on PATH", + value: detection.GitOnPath ? "yes" : "no (--no-history will be implied)"); + + // repo root + WriteEnvRow(writer, + kind: StatusKind.Ok, + key: "repo root", + value: PathDisplay.Render(detection.RepoRootPath, root, home)); + + // solutions + var solutionsRendered = detection.SolutionFiles.Count == 0 + ? "(none)" + : string.Join(", ", detection.SolutionFiles.Select(p => PathDisplay.Render(p, root, home))); + WriteEnvRow(writer, + kind: detection.SolutionFiles.Count == 0 ? StatusKind.Warn : StatusKind.Ok, + key: "solutions", + value: solutionsRendered); + + // .sourcegraph.json + var (sgKind, sgValue) = detection.SourceGraphConfigStatus switch + { + SourceGraphConfigStatus.Valid => (StatusKind.Ok, "valid"), + SourceGraphConfigStatus.Missing => (StatusKind.Ok, "missing (single-scope synth path)"), + SourceGraphConfigStatus.Malformed => (StatusKind.Fail, $"MALFORMED — {detection.SourceGraphConfigError}"), + _ => (StatusKind.Warn, "?"), + }; + WriteEnvRow(writer, sgKind, ".sourcegraph.json", sgValue); + + writer.WriteLine(); + } + + /// + /// Writes the Clients to wire phase: one row per known client showing its + /// default-selected state under the dot vocabulary. Used to display the picker defaults + /// before the batched prompt. + /// + public static void RenderClientsToWire( + TextWriter writer, + IReadOnlyList rows) + { + if (rows.Count == 0) return; + writer.WriteLine(DashboardTheme.SectionHeaderPlain("Clients to wire")); + foreach (var row in rows) + { + var kind = row.DefaultOn ? StatusKind.Ok : StatusKind.Off; + var glyph = DashboardTheme.DotPlain(kind); + var slug = row.Slug.PadRight(SlugWidth); + var scope = row.Scope.PadRight(8); + writer.WriteLine($"{Indent}{glyph}{slug} {scope} {row.Detail}"); + } + writer.WriteLine(); + } + + /// + /// Writes the heading line for the Apply phase. Rows are appended one at a time by + /// as each writer runs, so progress is visible mid-run instead + /// of being buffered until the end. + /// + public static void RenderApplyHeading(TextWriter writer) + { + writer.WriteLine(DashboardTheme.SectionHeaderPlain("Apply")); + } + + /// + /// Writes a single Apply row for one outcome plus its target slug + /// and rendered path. The hanging description line is emitted indented +4 when the action is + /// a skip or unsupported (so the user knows why), and elided otherwise. + /// + public static void RenderApplyRow( + TextWriter writer, + WriterAction action, + string slug, + string targetPath, + string description, + string root, + string? home) + { + var (kind, verb) = action switch + { + WriterAction.Insert => (StatusKind.Ok, "wrote"), + WriterAction.ReplaceOurs => (StatusKind.Ok, "replaced"), + WriterAction.NoOpAlreadyMatches => (StatusKind.Ok, "no change"), + WriterAction.SkipExistingDiffers => (StatusKind.Fail, "conflict — skipped"), + WriterAction.SkipHasComments => (StatusKind.Warn, "skipped — comments"), + WriterAction.SkipUnsupported => (StatusKind.Unsupported, "skipped — unsupported"), + _ => (StatusKind.Warn, "?"), + }; + + var glyph = DashboardTheme.DotPlain(kind); + var displayPath = PathDisplay.Render(targetPath, root, home); + var verbPadded = verb.PadRight(VerbWidth); + var slugPadded = slug.PadRight(SlugWidth); + writer.WriteLine($"{Indent}{glyph}{verbPadded} {slugPadded} {displayPath}"); + + // Hanging detail line for conflict / unsupported / comments — these are the cases where + // the user needs to read why nothing got written. + if (!string.IsNullOrEmpty(description) && + action is WriterAction.SkipExistingDiffers + or WriterAction.SkipUnsupported + or WriterAction.SkipHasComments) + { + // +6 indent so the detail text starts under the verb column (Indent=4 + glyph=2 cells + // worth of token width). + writer.WriteLine($"{Indent} {description}"); + } + } + + /// + /// Writes the Pre-warm phase heading + summary line. On success, the summary is a + /// single positive row naming the solution and elapsed time; on non-zero exit the row + /// degrades to a warning. The child indexer's stdout (already inherited at the + /// layer) appears between the heading and this + /// summary in real time. + /// + public static void RenderPreWarmSummary( + TextWriter writer, + int exitCode, + TimeSpan elapsed, + string solutionName) + { + if (exitCode == 0) + { + var glyph = DashboardTheme.DotPlain(StatusKind.Ok); + writer.WriteLine($"{Indent}{glyph}indexed {solutionName} in {elapsed.TotalSeconds:F1}s"); + } + else + { + var glyph = DashboardTheme.DotPlain(StatusKind.Warn); + writer.WriteLine($"{Indent}{glyph}pre-warm exit {exitCode} after {elapsed.TotalSeconds:F1}s"); + } + writer.WriteLine(); + } + + /// + /// Writes the heading for the Pre-warm phase. Caller is expected to emit indexer + /// output between this and the matching . + /// + public static void RenderPreWarmHeading(TextWriter writer, string solutionName) + { + writer.WriteLine(DashboardTheme.SectionHeaderPlain("Pre-warm")); + writer.WriteLine($"{Indent}pre-warming against {solutionName}…"); + } + + /// + /// Writes the Next phase: prose suggestions for what to do after init completes. + /// + public static void RenderNext(TextWriter writer, IReadOnlyList suggestions) + { + if (suggestions.Count == 0) return; + writer.WriteLine(); + writer.WriteLine(DashboardTheme.SectionHeaderPlain("Next")); + foreach (var s in suggestions) + { + writer.WriteLine($"{Indent}{s}"); + } + } + + private static void WriteEnvRow(TextWriter writer, StatusKind kind, string key, string value) + { + var glyph = DashboardTheme.DotPlain(kind); + var keyPadded = key.PadRight(EnvKeyWidth); + writer.WriteLine($"{Indent}{glyph}{keyPadded} {value}"); + } +} + +/// +/// One row in the picker display. Slug matches ; scope is +/// "project" or "user"; detail is free-text appended at the end of the row. +/// +internal sealed record ClientPickerRow( + string Slug, + bool DefaultOn, + string Scope, + string Detail); diff --git a/src/DevBitsLab.Mcp.SourceGraph.Server/Cli/Rendering/PathDisplay.cs b/src/DevBitsLab.Mcp.SourceGraph.Server/Cli/Rendering/PathDisplay.cs new file mode 100644 index 00000000..212a4a29 --- /dev/null +++ b/src/DevBitsLab.Mcp.SourceGraph.Server/Cli/Rendering/PathDisplay.cs @@ -0,0 +1,55 @@ +namespace DevBitsLab.Mcp.SourceGraph.Server.Cli.Rendering; + +/// +/// Renders an absolute path in the shortest form that's still unambiguous in a CLI context: +/// repo-relative when the path is inside --root, ~/-substituted when inside the +/// user's home directory, full absolute otherwise. Inside-repo wins the tie-break when a path +/// happens to be under both roots. +/// +internal static class PathDisplay +{ + /// + /// Returns the rendered form of against the given roots. The + /// repo-relative form omits any leading ./; the home form starts with ~/; the + /// absolute fall-through is whatever the caller passed in. Path separator is normalised to + /// the platform's native form via . + /// + public static string Render(string absolute, string root, string? homePath) + { + if (string.IsNullOrEmpty(absolute)) return absolute; + + // Repo-relative wins when the path is genuinely under root. The relative form must not + // escape with `..`; otherwise it's not "inside" by any honest definition. + if (!string.IsNullOrEmpty(root)) + { + var rel = SafeRelative(root, absolute); + if (rel is not null && !rel.StartsWith("..", StringComparison.Ordinal) && !Path.IsPathRooted(rel)) + { + return rel == "." ? "." : rel; + } + } + + if (!string.IsNullOrEmpty(homePath)) + { + var rel = SafeRelative(homePath, absolute); + if (rel is not null && !rel.StartsWith("..", StringComparison.Ordinal) && !Path.IsPathRooted(rel)) + { + return rel == "." ? "~" : "~" + Path.DirectorySeparatorChar + rel; + } + } + + return absolute; + } + + private static string? SafeRelative(string baseDir, string target) + { + try + { + return Path.GetRelativePath(baseDir, target); + } + catch (ArgumentException) + { + return null; + } + } +} diff --git a/src/DevBitsLab.Mcp.SourceGraph.Server/Cli/Rendering/StatusRenderer.cs b/src/DevBitsLab.Mcp.SourceGraph.Server/Cli/Rendering/StatusRenderer.cs new file mode 100644 index 00000000..d1517256 --- /dev/null +++ b/src/DevBitsLab.Mcp.SourceGraph.Server/Cli/Rendering/StatusRenderer.cs @@ -0,0 +1,236 @@ +using DevBitsLab.Mcp.SourceGraph.Server.Cli.Snapshot; +using DevBitsLab.Mcp.SourceGraph.Server.Dashboard; +using DevBitsLab.Mcp.SourceGraph.Server.Tools; + +namespace DevBitsLab.Mcp.SourceGraph.Server.Cli.Rendering; + +/// +/// Options controlling how writes prose to a . +/// +/// Repository root, used for path display. +/// Home directory, used for ~-substitution. +/// +/// When true, suppress ANSI colour codes. Distinct from +/// (which controls glyph language); --no-color never affects glyphs. +/// +internal sealed record StatusRenderOptions(string Root, string? Home, bool NoColor); + +/// +/// Phase-headed prose renderer for . Mirrors +/// 's shape — five phases (Environment, Scopes, Clients, Embeddings, +/// Recent activity), each emitting a -prefixed heading at the left margin followed by +/// four-space-indented rows. Dot vocabulary and ASCII fallback live in +/// ; column alignment is preserved across emoji and ASCII modes by +/// design. +/// +internal static class StatusRenderer +{ + private const string Indent = " "; + + // Column widths chosen to accommodate the longest label in each phase. + private const int EnvKeyWidth = 18; + // Scope status verb column: "indexing" (8) + buffer. + private const int ScopeStatusWidth = 10; + // Scope name column padded to align the status verb across rows. + private const int ScopeNameWidth = 18; + // Client slug column width: longest slug is "claude-desktop" (14). + private const int ClientSlugWidth = 15; + // Client scope column width: "project" / "user" (7). + private const int ClientScopeWidth = 8; + + /// + /// Render to using the + /// dot vocabulary. Section-leader () precedes each heading; rows under a heading + /// are indented four spaces and begin with the appropriate dot token. + /// + public static void RenderHuman( + DashboardSnapshot snapshot, + TextWriter writer, + StatusRenderOptions options) + { + RenderBanner(writer); + RenderEnvironment(writer, snapshot.Environment, options); + RenderScopes(writer, snapshot.Scopes); + RenderClients(writer, snapshot.Clients, options); + RenderEmbeddings(writer, snapshot.Embeddings, options); + RenderRecentActivity(writer, snapshot.RecentActivity); + } + + private static void RenderBanner(TextWriter writer) + { + var prefix = LeafFormatter.Suppressed ? "" : LeafFormatter.Mark; + writer.WriteLine($"{prefix}SourceGraph status"); + writer.WriteLine(); + } + + private static void RenderEnvironment(TextWriter writer, EnvironmentSurface env, StatusRenderOptions options) + { + writer.WriteLine(DashboardTheme.SectionHeaderPlain("Environment")); + // The dot predicates here must mirror `StatusCli.EvaluateExit` for the same surface. + // An empty SDK string is a hard-fail there (`string.IsNullOrEmpty`), and a missing repo + // root directory is a hard-fail too — rendering either as `Ok` would let the human + // surface say "healthy" while the exit code reports failure. + WriteRow(writer, + kind: string.IsNullOrEmpty(env.DotnetSdkVersion) ? StatusKind.Fail : StatusKind.Ok, + key: ".NET SDK", + value: env.DotnetSdkVersion ?? "(not detected)"); + WriteRow(writer, + kind: env.GitOnPath ? StatusKind.Ok : StatusKind.Warn, + key: "git on PATH", + value: env.GitOnPath ? "yes" : "no"); + WriteRow(writer, + kind: Directory.Exists(env.RepoRootPath) ? StatusKind.Ok : StatusKind.Fail, + key: "repo root", + value: PathDisplay.Render(env.RepoRootPath, options.Root, options.Home)); + var solutionsRendered = env.SolutionFiles.Count == 0 + ? "(none)" + : string.Join(", ", env.SolutionFiles.Select(p => PathDisplay.Render(p, options.Root, options.Home))); + WriteRow(writer, + kind: env.SolutionFiles.Count == 0 ? StatusKind.Warn : StatusKind.Ok, + key: "solutions", + value: solutionsRendered); + var (kind, value) = env.SourceGraphConfigStatus switch + { + "valid" => (StatusKind.Ok, "valid"), + "missing" => (StatusKind.Ok, "missing (single-scope synth path)"), + "malformed" => (StatusKind.Fail, $"MALFORMED — {env.SourceGraphConfigError}"), + _ => (StatusKind.Warn, env.SourceGraphConfigStatus), + }; + WriteRow(writer, kind, ".sourcegraph.json", value); + writer.WriteLine(); + } + + private static void RenderScopes(TextWriter writer, IReadOnlyList scopes) + { + writer.WriteLine(DashboardTheme.SectionHeaderPlain("Scopes")); + if (scopes.Count == 0) + { + writer.WriteLine($"{Indent}{DashboardTheme.DotPlain(StatusKind.Off)}(no scopes registered — run `sourcegraph-mcp serve` once to materialise)"); + writer.WriteLine(); + return; + } + foreach (var s in scopes) + { + var kind = s.Status switch + { + "ok" => StatusKind.Ok, + "partial" => StatusKind.Warn, + "degraded" => StatusKind.Fail, + "indexing" => StatusKind.Warn, + _ => StatusKind.Off, + }; + var glyph = DashboardTheme.DotPlain(kind); + var name = s.Name.PadRight(ScopeNameWidth); + var status = s.Status.PadRight(ScopeStatusWidth); + var ageLabel = s.LastIndexedAt.HasValue + ? FormatRelativeTime(DateTimeOffset.UtcNow - s.LastIndexedAt.Value) + : "(never)"; + writer.WriteLine( + $"{Indent}{glyph}{name} {status} {s.SymbolCount,8} symbols {s.ReferenceCount,8} refs {ageLabel}"); + // Hanging detail line for partial / degraded rows so the operator sees the failure + // names without consulting `scopes info`. + if (s.Status == "partial" && s.FailedProjects.Count > 0) + { + writer.WriteLine($"{Indent} failed projects: {string.Join(", ", s.FailedProjects)}"); + } + if (s.Status == "partial" && s.FailedFiles.Count > 0) + { + writer.WriteLine($"{Indent} failed files: {string.Join(", ", s.FailedFiles)}"); + } + if (s.Status == "degraded") + { + writer.WriteLine($"{Indent} recommended: run `repair_scope mode=rebuild`"); + } + } + writer.WriteLine(); + } + + private static void RenderClients(TextWriter writer, IReadOnlyList clients, StatusRenderOptions options) + { + writer.WriteLine(DashboardTheme.SectionHeaderPlain("Clients")); + if (clients.Count == 0) + { + writer.WriteLine($"{Indent}{DashboardTheme.DotPlain(StatusKind.Off)}(no client configs detected)"); + writer.WriteLine(); + return; + } + // Project-scope rows first, then user-scope, to match the init order. + foreach (var c in clients.OrderBy(c => c.Scope == "project" ? 0 : 1)) + { + var kind = c switch + { + { ContainsSourcegraphEntry: true } => StatusKind.Ok, + { Exists: true } => StatusKind.Off, + _ => StatusKind.Unsupported, + }; + var glyph = DashboardTheme.DotPlain(kind); + var slug = c.Slug.PadRight(ClientSlugWidth); + var scope = c.Scope.PadRight(ClientScopeWidth); + var pathDisplay = PathDisplay.Render(c.Path, options.Root, options.Home); + writer.WriteLine($"{Indent}{glyph}{slug} {scope} {pathDisplay}"); + } + writer.WriteLine(); + } + + private static void RenderEmbeddings(TextWriter writer, EmbeddingsSurface emb, StatusRenderOptions options) + { + writer.WriteLine(DashboardTheme.SectionHeaderPlain("Embeddings")); + var kind = emb.CachePresent ? StatusKind.Ok : StatusKind.Warn; + var verifiedLabel = emb.Verified ? "verified" : "unverified"; + var sizeLabel = emb.CachePresent ? FormatBytes(emb.TotalBytes) : "(absent)"; + writer.WriteLine($"{Indent}{DashboardTheme.DotPlain(kind)}{emb.ModelId} {sizeLabel} {verifiedLabel}"); + writer.WriteLine($"{Indent} cache: {PathDisplay.Render(emb.CacheDir, options.Root, options.Home)}"); + writer.WriteLine(); + } + + private static void RenderRecentActivity(TextWriter writer, IReadOnlyList activity) + { + writer.WriteLine(DashboardTheme.SectionHeaderPlain("Recent activity")); + if (activity.Count == 0) + { + writer.WriteLine($"{Indent}{DashboardTheme.DotPlain(StatusKind.Off)}(no recorded activity)"); + return; + } + foreach (var a in activity) + { + var kind = a.Ok ? StatusKind.Ok : StatusKind.Fail; + var time = a.Ts.ToLocalTime().ToString("HH:mm:ss"); + var name = (a.Detail ?? a.Kind).PadRight(20); + var scope = (a.Scope ?? "-").PadRight(12); + writer.WriteLine($"{Indent}{DashboardTheme.DotPlain(kind)}{time} {name} {scope} {a.Ms,5}ms"); + } + } + + private static void WriteRow(TextWriter writer, StatusKind kind, string key, string value) + { + var glyph = DashboardTheme.DotPlain(kind); + var keyPadded = key.PadRight(EnvKeyWidth); + writer.WriteLine($"{Indent}{glyph}{keyPadded} {value}"); + } + + /// + /// Format a as a short relative-time string like 2m ago / + /// 3h ago. Matches the operator-friendly shape used in scopes info. + /// + internal static string FormatRelativeTime(TimeSpan delta) + { + if (delta.TotalSeconds < 60) return $"{(int)Math.Max(0, delta.TotalSeconds)}s ago"; + if (delta.TotalMinutes < 60) return $"{(int)delta.TotalMinutes}m ago"; + if (delta.TotalHours < 48) return $"{(int)delta.TotalHours}h ago"; + return $"{(int)delta.TotalDays}d ago"; + } + + private static string FormatBytes(long bytes) + { + const double KiB = 1024; + const double MiB = KiB * 1024; + const double GiB = MiB * 1024; + return bytes switch + { + < (long)KiB => $"{bytes} B", + < (long)MiB => $"{bytes / KiB:F1} KiB", + < (long)GiB => $"{bytes / MiB:F1} MiB", + _ => $"{bytes / GiB:F2} GiB", + }; + } +} diff --git a/src/DevBitsLab.Mcp.SourceGraph.Server/Cli/Rendering/UnifiedDiffRenderer.cs b/src/DevBitsLab.Mcp.SourceGraph.Server/Cli/Rendering/UnifiedDiffRenderer.cs new file mode 100644 index 00000000..a10cf336 --- /dev/null +++ b/src/DevBitsLab.Mcp.SourceGraph.Server/Cli/Rendering/UnifiedDiffRenderer.cs @@ -0,0 +1,154 @@ +using System.Text; +using DiffPlex; +using DiffPlex.Chunkers; +using DiffPlex.Model; + +namespace DevBitsLab.Mcp.SourceGraph.Server.Cli.Rendering; + +/// +/// Renders a minimal unified diff between two byte sequences (existing vs. proposed file content) +/// to a . Used by init --diff to preview what a writer would +/// change when its plan would otherwise land in SkipExistingDiffers. +/// +/// Output shape (3 lines of context by default): +/// +/// --- <fromLabel> +/// +++ <toLabel> +/// @@ -a,b +c,d @@ +/// context-line +/// -removed-line +/// +added-line +/// context-line +/// +/// +internal static class UnifiedDiffRenderer +{ + public static void Render( + byte[] existing, + byte[] proposed, + string fromLabel, + string toLabel, + TextWriter writer, + int contextLines = 3) + { + var existingText = Encoding.UTF8.GetString(existing ?? Array.Empty()); + var proposedText = Encoding.UTF8.GetString(proposed ?? Array.Empty()); + + // Headers + writer.WriteLine($"--- {fromLabel}"); + writer.WriteLine($"+++ {toLabel}"); + + // Compute a line-level diff. The Differ produces per-line ops; we group adjacent changes + // into hunks bounded by `contextLines` lines of leading/trailing context. + var differ = new Differ(); + var chunker = new LineChunker(); + var model = differ.CreateDiffs(existingText, proposedText, false, false, chunker); + + var pieces = model.DiffBlocks; + if (pieces.Count == 0) + { + // No differences — nothing more to emit. (Caller should check before invoking, but be + // tolerant.) + return; + } + + var oldLines = model.PiecesOld; + var newLines = model.PiecesNew; + + foreach (var hunk in GroupHunks(pieces.ToList(), oldLines.Length, newLines.Length, contextLines)) + { + // Header `@@ -a,b +c,d @@`. Standard unified-diff convention: the start line is + // 1-based when count > 0 and 0 when count == 0. The zero-count case represents a + // hunk where one side is empty (e.g. `@@ -0,0 +1,N @@` for content added to an + // empty file, or `@@ -1,N +0,0 @@` for a full deletion); parsers like `patch` + // depend on this exact form. + var oldCount = hunk.OldEnd - hunk.OldStart; + var newCount = hunk.NewEnd - hunk.NewStart; + var oldStart = oldCount == 0 ? 0 : hunk.OldStart + 1; + var newStart = newCount == 0 ? 0 : hunk.NewStart + 1; + writer.WriteLine($"@@ -{oldStart},{oldCount} +{newStart},{newCount} @@"); + + // Walk the hunk's range, emitting context / removals / additions. We only track the + // old-side index `oi` because every emitted line reads from `oldLines` (context, + // deletions) or addresses the new-side block range directly via `block.InsertStartB`. + // A new-side cursor isn't needed. + var oi = hunk.OldStart; + foreach (var block in hunk.Blocks) + { + // Emit any context lines that come before this block (up to block.DeleteStartA). + while (oi < block.DeleteStartA) + { + writer.WriteLine(" " + oldLines[oi]); + oi++; + } + // Emit deletions. + for (var i = 0; i < block.DeleteCountA; i++) + { + writer.WriteLine("-" + oldLines[oi + i]); + } + // Emit insertions. + for (var i = 0; i < block.InsertCountB; i++) + { + writer.WriteLine("+" + newLines[block.InsertStartB + i]); + } + oi += block.DeleteCountA; + } + // Emit trailing context up to OldEnd. + while (oi < hunk.OldEnd) + { + writer.WriteLine(" " + oldLines[oi]); + oi++; + } + } + } + + private static IReadOnlyList GroupHunks( + IReadOnlyList blocks, + int totalOld, + int totalNew, + int context) + { + var hunks = new List(); + if (blocks.Count == 0) return hunks; + + Hunk? current = null; + foreach (var b in blocks) + { + var oldStart = Math.Max(0, b.DeleteStartA - context); + var oldEnd = Math.Min(totalOld, b.DeleteStartA + b.DeleteCountA + context); + var newStart = Math.Max(0, b.InsertStartB - context); + var newEnd = Math.Min(totalNew, b.InsertStartB + b.InsertCountB + context); + + if (current is null || oldStart > current.OldEnd) + { + if (current is not null) hunks.Add(current); + current = new Hunk + { + OldStart = oldStart, + OldEnd = oldEnd, + NewStart = newStart, + NewEnd = newEnd, + Blocks = new List { b }, + }; + } + else + { + // Merge into the existing hunk by extending the windows. + current.OldEnd = Math.Max(current.OldEnd, oldEnd); + current.NewEnd = Math.Max(current.NewEnd, newEnd); + current.Blocks.Add(b); + } + } + if (current is not null) hunks.Add(current); + return hunks; + } + + private sealed class Hunk + { + public int OldStart; + public int OldEnd; + public int NewStart; + public int NewEnd; + public List Blocks = new(); + } +} diff --git a/src/DevBitsLab.Mcp.SourceGraph.Server/Cli/ScopesCli.cs b/src/DevBitsLab.Mcp.SourceGraph.Server/Cli/ScopesCli.cs index e18976d0..23bf3502 100644 --- a/src/DevBitsLab.Mcp.SourceGraph.Server/Cli/ScopesCli.cs +++ b/src/DevBitsLab.Mcp.SourceGraph.Server/Cli/ScopesCli.cs @@ -111,72 +111,161 @@ private static async Task RunAddAsync(CommandLine cli, ScopeConfig config, return 2; } var name = cli.Positional[1]; - if (!ScopeIdValidator.IsValid(name)) + if (string.IsNullOrEmpty(cli.SolutionPath)) { - await Console.Error.WriteLineAsync($"Invalid scope id '{name}'. Must match ^[a-z0-9][a-z0-9-]{{0,63}}$").ConfigureAwait(false); + await Console.Error.WriteLineAsync("`scopes add` requires --solution .").ConfigureAwait(false); return 2; } - if (config.Scopes.Any(s => s.Id == name)) + var result = AddScopeToConfig( + root, + config, + name, + cli.SolutionPath, + isolated: cli.Positional.Contains("--isolated")); + if (!result.Ok) { - await Console.Error.WriteLineAsync($"Scope '{name}' already exists.").ConfigureAwait(false); - return 1; + await Console.Error.WriteLineAsync(result.Message).ConfigureAwait(false); + return result.ExitCode; } - if (string.IsNullOrEmpty(cli.SolutionPath)) + Console.WriteLine(result.Message); + Console.WriteLine("A running sourcegraph-mcp server will pick up the change automatically."); + return 0; + } + + private static async Task RunRemoveAsync(CommandLine cli, ScopeConfig config, string root) + { + if (cli.Positional.Count < 2) { - await Console.Error.WriteLineAsync("`scopes add` requires --solution .").ConfigureAwait(false); + await Console.Error.WriteLineAsync("Usage: sourcegraph-mcp scopes remove ").ConfigureAwait(false); return 2; } - var solutionPath = cli.SolutionPath; - if (Path.IsPathRooted(solutionPath)) + var name = cli.Positional[1]; + var result = RemoveScopeFromConfig(root, config, name); + if (!result.Ok) + { + await Console.Error.WriteLineAsync(result.Message).ConfigureAwait(false); + return result.ExitCode; + } + Console.WriteLine(result.Message); + Console.WriteLine("A running sourcegraph-mcp server will pick up the change automatically."); + return 0; + } + + /// + /// Outcome of an in-process scope-config mutation. Lets non-CLI callers (the dashboard's + /// inline add/remove form) read the validation result without having to capture stdout/stderr + /// from / . + /// + /// True iff the mutation succeeded and the config was rewritten. + /// CLI-equivalent exit code: 0 on success, 1 for "not found" / "already exists", 2 for validation errors. + /// Human-readable summary suitable for either stdout (on success) or stderr (on failure). + internal sealed record ScopeMutationResult(bool Ok, int ExitCode, string Message); + + /// + /// Add a new scope to and persist the result to + /// <root>/.sourcegraph.json. Shared by the CLI scopes add subcommand and + /// the dashboard's inline add-scope form. + /// + /// + /// Validation mirrors the CLI path: rejects invalid kebab-case ids, refuses duplicates, + /// rebases absolute solution paths relative to when possible so the + /// resulting JSON stays portable across machines. On success the per-scope DB at + /// .sourcegraph/scopes/<name>.db is NOT pre-created — the live server (or the + /// next serve) materialises it. + /// + /// + internal static ScopeMutationResult AddScopeToConfig( + string root, + ScopeConfig config, + string name, + string solutionPath, + bool isolated) + { + if (!ScopeIdValidator.IsValid(name)) + { + return new ScopeMutationResult(false, 2, + $"Invalid scope id '{name}'. Must match ^[a-z0-9][a-z0-9-]{{0,63}}$"); + } + if (config.Scopes.Any(s => s.Id == name)) + { + return new ScopeMutationResult(false, 1, $"Scope '{name}' already exists."); + } + if (string.IsNullOrWhiteSpace(solutionPath)) + { + return new ScopeMutationResult(false, 2, "Solution path is required."); + } + var storedPath = solutionPath; + if (Path.IsPathRooted(storedPath)) { // Store the path relative to root when possible so the JSON file is portable. - var rooted = Path.GetFullPath(solutionPath); + var rooted = Path.GetFullPath(storedPath); var rel = Path.GetRelativePath(root, rooted); - if (!rel.StartsWith("..", StringComparison.Ordinal)) solutionPath = rel; - else solutionPath = rooted; + if (!rel.StartsWith("..", StringComparison.Ordinal)) storedPath = rel; + else storedPath = rooted; } var newScope = new Scope( Id: name, Name: name, Root: root, - ProjectSet: new ScopeProjectSet.Solutions(new[] { solutionPath }, Array.Empty()), - Isolated: cli.Positional.Contains("--isolated"), + ProjectSet: new ScopeProjectSet.Solutions(new[] { storedPath }, Array.Empty()), + Isolated: isolated, LastIndexedAt: DateTimeOffset.MinValue); var newScopes = config.Scopes.ToList(); newScopes.Add(newScope); var updated = new ScopeConfig(newScopes, config.DefaultScope); - ScopeConfigLoader.Save(root, updated); - Console.WriteLine($"Added scope '{name}' -> {solutionPath}"); - Console.WriteLine("A running sourcegraph-mcp server will pick up the change automatically."); - return 0; + try + { + ScopeConfigLoader.Save(root, updated); + } + catch (IOException ex) + { + return new ScopeMutationResult(false, 1, $"failed to write .sourcegraph.json: {ex.Message}"); + } + catch (UnauthorizedAccessException ex) + { + return new ScopeMutationResult(false, 1, $"failed to write .sourcegraph.json: {ex.Message}"); + } + return new ScopeMutationResult(true, 0, $"Added scope '{name}' -> {storedPath}"); } - private static async Task RunRemoveAsync(CommandLine cli, ScopeConfig config, string root) + /// + /// Remove the named scope from and persist the result. Shared by + /// the CLI scopes remove subcommand and the dashboard's remove-scope action. + /// + /// + /// The per-scope DB at .sourcegraph/scopes/<name>.db is intentionally NOT + /// deleted — a live server may still hold an open SQLite connection to it during the + /// live-remove grace window, and re-adding the same scope id reuses the existing DB + /// without a cold reindex. Stale on-disk DBs can be reaped later via a separate + /// scopes prune pass. + /// + /// + internal static ScopeMutationResult RemoveScopeFromConfig( + string root, + ScopeConfig config, + string name) { - if (cli.Positional.Count < 2) - { - await Console.Error.WriteLineAsync("Usage: sourcegraph-mcp scopes remove ").ConfigureAwait(false); - return 2; - } - var name = cli.Positional[1]; var existing = config.Scopes.FirstOrDefault(s => s.Id == name); if (existing is null) { - await Console.Error.WriteLineAsync($"Scope '{name}' not found.").ConfigureAwait(false); - return 1; + return new ScopeMutationResult(false, 1, $"Scope '{name}' not found."); } var newScopes = config.Scopes.Where(s => s.Id != name).ToList(); var newDefault = config.DefaultScope == name ? null : config.DefaultScope; var updated = new ScopeConfig(newScopes, newDefault); - ScopeConfigLoader.Save(root, updated); - // The per-scope DB on disk is preserved. A live server may still hold an open SQLite - // connection to it during the live-remove grace window; deleting from underneath would - // corrupt the running query. The DB is a rebuildable cache, so leaving it costs only - // disk space (recoverable later via a `scopes prune` command) and re-adding the same - // scope id reuses the existing DB without a cold reindex. - Console.WriteLine($"Removed scope '{name}'"); - Console.WriteLine("A running sourcegraph-mcp server will pick up the change automatically."); - return 0; + try + { + ScopeConfigLoader.Save(root, updated); + } + catch (IOException ex) + { + return new ScopeMutationResult(false, 1, $"failed to write .sourcegraph.json: {ex.Message}"); + } + catch (UnauthorizedAccessException ex) + { + return new ScopeMutationResult(false, 1, $"failed to write .sourcegraph.json: {ex.Message}"); + } + return new ScopeMutationResult(true, 0, $"Removed scope '{name}'"); } private static async Task RunInfoAsync(CommandLine cli, ScopeConfig config, string root) diff --git a/src/DevBitsLab.Mcp.SourceGraph.Server/Cli/Snapshot/DashboardSnapshot.cs b/src/DevBitsLab.Mcp.SourceGraph.Server/Cli/Snapshot/DashboardSnapshot.cs new file mode 100644 index 00000000..5f5e7fa3 --- /dev/null +++ b/src/DevBitsLab.Mcp.SourceGraph.Server/Cli/Snapshot/DashboardSnapshot.cs @@ -0,0 +1,133 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace DevBitsLab.Mcp.SourceGraph.Server.Cli.Snapshot; + +/// +/// One immutable point-in-time view of the operator-console state surfaces. Produced by +/// ; consumed by StatusRenderer (prose) and +/// (the --json serializer). +/// +/// +/// Five surfaces aggregate here in the order specified by the status snapshot data sources +/// requirement: , , , +/// , . Snake-case JSON shape is pinned by +/// via ; the +/// spec's contract is that additions are append-only. +/// +/// +internal sealed record DashboardSnapshot( + EnvironmentSurface Environment, + IReadOnlyList Scopes, + IReadOnlyList Clients, + EmbeddingsSurface Embeddings, + IReadOnlyList RecentActivity, + DateTimeOffset BuiltAt, + string UsageLogPath, + string HealsLogPath, + int ExitCode); + +/// +/// Environment surface — SDK, git, repo root, solution files, .sourcegraph.json status. Sourced +/// from OnboardingDetector.DetectAsync; carries the malformed-config error verbatim so +/// the renderer can surface it without re-running detection. +/// is one of "missing" | "valid" | "malformed". +/// +/// +/// Note: SourceGraph uses an explicit because +/// the default policy splits CamelCase on every +/// capital letter (`SourceGraph` → `source_graph_config_status`), which doesn't match the +/// documented field name `sourcegraph_config_status`. The override pins the contract. +/// +/// +internal sealed record EnvironmentSurface( + string? DotnetSdkVersion, + bool GitOnPath, + string RepoRootPath, + IReadOnlyList SolutionFiles, + [property: JsonPropertyName("sourcegraph_config_status")] string SourceGraphConfigStatus, + [property: JsonPropertyName("sourcegraph_config_error")] string? SourceGraphConfigError); + +/// +/// One row of the scopes surface. Mirrors the spec's required JSON fields exactly; the +/// value is one of "ok" | "partial" | "degraded" | "indexing". +/// and are empty when +/// isn't "partial". +/// +internal sealed record ScopeRow( + string Name, + string Status, + long SymbolCount, + long ReferenceCount, + DateTimeOffset? LastIndexedAt, + IReadOnlyList FailedProjects, + IReadOnlyList FailedFiles, + bool Isolated); + +/// +/// One row of the clients surface. matches +/// ClientIdExtensions.ToSlug; is one of "project" | "user". +/// +internal sealed record ClientRow( + string Slug, + string Scope, + string Path, + bool Exists, + bool ContainsSourcegraphEntry); + +/// +/// Embeddings surface — active model id, cache directory, presence/size, optional verified +/// flag. is conservatively false at v1 (the verify state isn't +/// persisted yet); flipping it true is a follow-up. +/// +internal sealed record EmbeddingsSurface( + string ModelId, + string CacheDir, + bool CachePresent, + long TotalBytes, + bool Verified); + +/// +/// One merged row from usage.jsonl + heals.jsonl, sorted by timestamp ascending, +/// capped at the most recent N entries. is the raw kind string from the +/// source log (tool_call for usage rows; heal, boot_reconcile, etc. for +/// heal rows). +/// +internal sealed record ActivityEntry( + DateTimeOffset Ts, + string Kind, + string? Scope, + bool Ok, + int Ms, + string? Detail); + +/// +/// Stable serializer for . Snake-case property naming via +/// is the contract; the system policy maps the +/// PascalCase C# property names (`DotnetSdkVersion`, `RepoRootPath`, …) to snake_case at the +/// wire (`dotnet_sdk_version`, `repo_root_path`, …) — adding a new C# property automatically +/// maps without per-field annotations. New fields are append-only by spec. +/// +internal static class DashboardSnapshotJson +{ + /// + /// The shared options instance for serializing/deserializing the dashboard snapshot. + /// Snake-case for property names so PascalCase records map to the documented JSON wire + /// shape; indented output keeps the --json document human-scannable. + /// + public static readonly JsonSerializerOptions Options = new() + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + WriteIndented = true, + IndentSize = 2, + DefaultIgnoreCondition = JsonIgnoreCondition.Never, + }; + + /// Serializes the snapshot to a UTF-8 JSON string using . + public static string Serialize(DashboardSnapshot snapshot) => + JsonSerializer.Serialize(snapshot, Options); + + /// Deserializes a JSON string back into the snapshot record. Used by tests only. + public static DashboardSnapshot? Deserialize(string json) => + JsonSerializer.Deserialize(json, Options); +} diff --git a/src/DevBitsLab.Mcp.SourceGraph.Server/Cli/Snapshot/JsonlTailReader.cs b/src/DevBitsLab.Mcp.SourceGraph.Server/Cli/Snapshot/JsonlTailReader.cs new file mode 100644 index 00000000..da5165e0 --- /dev/null +++ b/src/DevBitsLab.Mcp.SourceGraph.Server/Cli/Snapshot/JsonlTailReader.cs @@ -0,0 +1,112 @@ +using System.Text; +using System.Text.Json; + +namespace DevBitsLab.Mcp.SourceGraph.Server.Cli.Snapshot; + +/// +/// Tails the last N bytes of a JSONL log and parses each complete line into a +/// . Designed for usage.jsonl + heals.jsonl tails +/// consumed by — a partial first line (whose start byte fell +/// outside the tail window) is dropped, as is a trailing partial line (no \n at EOF, +/// i.e. a concurrent writer is still flushing). +/// +internal static class JsonlTailReader +{ + /// + /// Upper bound on the tail-window size, regardless of what the caller asked for. The + /// JSONL log is local-disk-only and read once per snapshot rebuild — there's no + /// legitimate need to materialise more than this in one call. Caps the allocation so a + /// pathological --activity-bytes value (whether mis-typed or hostile) can't OOM + /// the status/dashboard process. + /// + private const int MaxBytesFromEnd = 16 * 1024 * 1024; // 16 MiB + + /// + /// Open , seek to max(0, length - bytesFromEnd), drop the + /// partial first line (when the seek didn't land at offset 0), parse each remaining + /// complete line, and drop a trailing line missing its terminator. Returns an empty list + /// when the file doesn't exist or can't be opened. Best-effort — any IO/parse error during + /// a single line is swallowed so a corrupt or in-flight write doesn't poison the rest. + /// + /// + /// is silently clamped to + /// (16 MiB) so a caller passing an absurdly large window can't trigger a huge buffer + /// allocation. Larger windows than 16 MiB aren't useful in practice — the recent-activity + /// row cap (SnapshotOptions.RecentActivityCap, default 50) is the more meaningful + /// limiting factor anyway. + /// + public static IReadOnlyList TailLines(string path, int bytesFromEnd) + { + if (string.IsNullOrEmpty(path) || !File.Exists(path)) return Array.Empty(); + if (bytesFromEnd <= 0) return Array.Empty(); + bytesFromEnd = Math.Min(bytesFromEnd, MaxBytesFromEnd); + + byte[] buffer; + bool startsAtFileBeginning; + try + { + // Open with FileShare.ReadWrite so a concurrent writer doesn't lock us out. + using var fs = new FileStream( + path, + FileMode.Open, + FileAccess.Read, + FileShare.ReadWrite | FileShare.Delete); + var length = fs.Length; + if (length == 0) return Array.Empty(); + + var offset = Math.Max(0, length - bytesFromEnd); + startsAtFileBeginning = offset == 0; + fs.Seek(offset, SeekOrigin.Begin); + var size = (int)(length - offset); + buffer = new byte[size]; + var totalRead = 0; + while (totalRead < size) + { + var n = fs.Read(buffer, totalRead, size - totalRead); + if (n <= 0) break; + totalRead += n; + } + if (totalRead < size) + { + // Short read — only keep what we got. Truncating preserves the "drop the + // trailing partial line" invariant downstream. + Array.Resize(ref buffer, totalRead); + } + } + catch (IOException) { return Array.Empty(); } + catch (UnauthorizedAccessException) { return Array.Empty(); } + + var text = Encoding.UTF8.GetString(buffer); + // Split on '\n'; the last element will be empty when the buffer ended with '\n'. + var lines = text.Split('\n'); + + // Drop the partial first line when we seeked into the middle of the file. + var first = startsAtFileBeginning ? 0 : 1; + // Always drop the trailing element. Two cases, same outcome: + // - buffer ended with `\n`: Split puts an empty string between the final '\n' and + // EOF; skipping it costs nothing. + // - buffer did not end with `\n`: the trailing element is a partial mid-write line we + // MUST skip so a concurrent writer's torn line doesn't surface as garbage. + var last = lines.Length - 1; + var result = new List(Math.Max(0, last - first)); + for (var i = first; i < last; i++) + { + var line = lines[i]; + if (string.IsNullOrEmpty(line)) continue; + JsonElement element; + try + { + using var doc = JsonDocument.Parse(line); + element = doc.RootElement.Clone(); + } + catch (JsonException) + { + // Skip a single bad line — corruption from a torn write or future schema row + // shouldn't blow up the whole tail. + continue; + } + result.Add(element); + } + return result; + } +} diff --git a/src/DevBitsLab.Mcp.SourceGraph.Server/Cli/Snapshot/SnapshotBuilder.cs b/src/DevBitsLab.Mcp.SourceGraph.Server/Cli/Snapshot/SnapshotBuilder.cs new file mode 100644 index 00000000..56264df4 --- /dev/null +++ b/src/DevBitsLab.Mcp.SourceGraph.Server/Cli/Snapshot/SnapshotBuilder.cs @@ -0,0 +1,384 @@ +using System.Text.Json; +using DevBitsLab.Mcp.SourceGraph.Embeddings; +using DevBitsLab.Mcp.SourceGraph.Storage; +using Microsoft.Data.Sqlite; + +namespace DevBitsLab.Mcp.SourceGraph.Server.Cli.Snapshot; + +/// +/// Options influencing how aggregates state. The defaults match +/// the spec: 512 KiB tail from each JSONL log, 50 entries kept in recent_activity. +/// +/// Tail size (bytes from end) for both JSONL logs. +/// Maximum entries kept in recent_activity after merge. +/// Embedding model identifier to report. null falls back to +/// ; the value should mirror what --model +/// resolved to so status / dashboard reflect the effective model rather than the SDK default. +internal sealed record SnapshotOptions(int ActivityBytes = 524288, int RecentActivityCap = 50, string? ModelId = null); + +/// +/// Aggregates the five state surfaces under --root into a single immutable +/// . Reads SQLite handles in read-only mode and closes them +/// before returning so a concurrent serve writer is never contended. The snapshot is a +/// point-in-time view; callers re-call to refresh. +/// +/// +/// The field defaults to 0 here; callers (notably +/// StatusCli) overwrite it with the evaluated exit code before serializing. +/// +/// +internal static class SnapshotBuilder +{ + /// + /// Build a snapshot for . No IO that mutates state; every SQLite + /// handle is closed before this method returns. controls how + /// many JSONL tail bytes to read and how many merged entries to keep. + /// + public static async Task BuildAsync( + string root, + SnapshotOptions options, + CancellationToken ct = default) + { + var rooted = Path.GetFullPath(root); + + // 1. Environment surface — re-uses OnboardingDetector's existing detection logic. + var detection = await OnboardingDetector.DetectAsync(rooted, ct).ConfigureAwait(false); + var env = new EnvironmentSurface( + DotnetSdkVersion: detection.DotnetSdkVersion, + GitOnPath: detection.GitOnPath, + RepoRootPath: detection.RepoRootPath, + SolutionFiles: detection.SolutionFiles, + SourceGraphConfigStatus: detection.SourceGraphConfigStatus switch + { + SourceGraphConfigStatus.Valid => "valid", + SourceGraphConfigStatus.Malformed => "malformed", + _ => "missing", + }, + SourceGraphConfigError: detection.SourceGraphConfigError); + + // 2. Scopes surface — read-only walk of _meta.db + each per-scope DB. + var scopes = BuildScopes(rooted); + + // 3. Clients surface — re-uses the detection rows. + var clients = BuildClients(detection.ClientConfigsDetected); + + // 4. Embeddings surface — cache dir presence + size + active model id. + var embeddings = BuildEmbeddings(options.ModelId); + + // 5. Recent activity — tails both JSONL logs, merges, sorts, caps. + var usagePath = Path.Join(rooted, ScopeLayout.DotDir, "usage.jsonl"); + var healsPath = Path.Join(rooted, ScopeLayout.DotDir, "heals.jsonl"); + var activity = BuildActivity(usagePath, healsPath, options); + + return new DashboardSnapshot( + Environment: env, + Scopes: scopes, + Clients: clients, + Embeddings: embeddings, + RecentActivity: activity, + BuiltAt: DateTimeOffset.UtcNow, + UsageLogPath: usagePath, + HealsLogPath: healsPath, + ExitCode: 0); + } + + private static IReadOnlyList BuildScopes(string root) + { + var metaPath = ScopeLayout.MetaDbPath(root); + if (!File.Exists(metaPath)) return Array.Empty(); + + // We re-implement the read path here (rather than reusing SqliteScopeRegistry) because + // SqliteScopeRegistry opens in ReadWriteCreate and ensures the schema on connect; we + // want strict read-only so a fresh-snapshot call can't trigger writes against a DB + // that's being read by a concurrent serve writer. + var connectionString = new SqliteConnectionStringBuilder + { + DataSource = metaPath, + Mode = SqliteOpenMode.ReadOnly, + Pooling = false, + }.ConnectionString; + + var rows = new List(); + try + { + using var conn = new SqliteConnection(connectionString); + conn.Open(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + SELECT id, name, root, isolated, last_indexed_at, status, + failed_projects_json, failed_files_json + FROM scopes + ORDER BY id; + """; + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + var id = reader.GetString(0); + var name = reader.GetString(1); + var scopeRoot = reader.GetString(2); + var isolated = reader.GetInt64(3) != 0; + var lastIndexedMs = reader.GetInt64(4); + var status = reader.GetString(5); + var failedProjectsJson = reader.IsDBNull(6) ? "[]" : reader.GetString(6); + var failedFilesJson = reader.IsDBNull(7) ? "[]" : reader.GetString(7); + + var lastIndexed = lastIndexedMs > 0 + ? DateTimeOffset.FromUnixTimeMilliseconds(lastIndexedMs) + : (DateTimeOffset?)null; + + // Per-scope counts from /.sourcegraph/scopes/.db. Best-effort: a + // missing or unreadable per-scope DB yields zero counts rather than aborting + // the build. + var (symbolCount, refCount) = ReadCountsForScope(root, id); + + var failedProjects = ParseFailureNames(failedProjectsJson); + var failedFiles = ParseFailureNames(failedFilesJson); + + rows.Add(new ScopeRow( + Name: name, + Status: status, + SymbolCount: symbolCount, + ReferenceCount: refCount, + LastIndexedAt: lastIndexed, + FailedProjects: failedProjects, + FailedFiles: failedFiles, + Isolated: isolated)); + } + } + catch (SqliteException) + { + // The _meta.db may not yet have the v2 schema, or the file may be locked by a + // concurrent migrator. Either way, we surface an empty scopes list rather than + // crash — the renderer will note "no scopes" and the operator can run `serve` + // once to materialise the registry. + return Array.Empty(); + } + catch (IOException) + { + return Array.Empty(); + } + return rows; + } + + private static (long Symbols, long Refs) ReadCountsForScope(string root, string scopeId) + { + var dbPath = ScopeLayout.ScopeDbPath(root, scopeId); + if (!File.Exists(dbPath)) return (0, 0); + var connectionString = new SqliteConnectionStringBuilder + { + DataSource = dbPath, + Mode = SqliteOpenMode.ReadOnly, + Pooling = false, + }.ConnectionString; + try + { + using var conn = new SqliteConnection(connectionString); + conn.Open(); + + using var symCmd = conn.CreateCommand(); + symCmd.CommandText = "SELECT COUNT(*) FROM symbols;"; + var symbols = (long)(symCmd.ExecuteScalar() ?? 0L); + + using var refCmd = conn.CreateCommand(); + refCmd.CommandText = "SELECT COUNT(*) FROM refs;"; + var refs = (long)(refCmd.ExecuteScalar() ?? 0L); + return (symbols, refs); + } + catch (SqliteException) { return (0, 0); } + catch (IOException) { return (0, 0); } + } + + /// + /// Best-effort projection of a failed_projects_json / failed_files_json + /// payload into a plain string list. The persisted shape is an array of objects with at + /// least a ProjectPath or Path property; we read whichever string field is + /// present (so a future schema add of a new optional field doesn't break us). A malformed + /// payload yields an empty list. + /// + private static IReadOnlyList ParseFailureNames(string json) + { + if (string.IsNullOrEmpty(json) || json == "[]") return Array.Empty(); + try + { + using var doc = JsonDocument.Parse(json); + if (doc.RootElement.ValueKind != JsonValueKind.Array) return Array.Empty(); + var list = new List(doc.RootElement.GetArrayLength()); + foreach (var item in doc.RootElement.EnumerateArray()) + { + if (item.ValueKind == JsonValueKind.String) + { + list.Add(item.GetString() ?? ""); + } + else if (item.ValueKind == JsonValueKind.Object) + { + // The Core records use PascalCase (`ProjectPath`, `Path`) — peek each in + // order and take the first non-empty hit. + foreach (var name in new[] { "ProjectPath", "Path", "FilePath", "Name" }) + { + if (item.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String) + { + var s = v.GetString(); + if (!string.IsNullOrEmpty(s)) + { + list.Add(s); + break; + } + } + } + } + } + return list; + } + catch (JsonException) { return Array.Empty(); } + } + + private static IReadOnlyList BuildClients(IReadOnlyList detected) + { + if (detected.Count == 0) return Array.Empty(); + var rows = new List(detected.Count); + foreach (var c in detected) + { + rows.Add(new ClientRow( + Slug: c.Client.ToSlug(), + Scope: c.IsUserScope ? "user" : "project", + Path: c.Path, + Exists: c.Exists, + ContainsSourcegraphEntry: c.ContainsSourcegraphEntry)); + } + return rows; + } + + private static EmbeddingsSurface BuildEmbeddings(string? modelIdOverride) + { + // Use the root cache dir (the parent of per-model directories) to preserve doctor's + // pre-refactor wording — `embedding model cache present at (N MB)` — and to + // keep the snapshot's total_bytes summing every cached model rather than only the + // active one. The active model id stays available via . + // When the caller (CLI flag `--model`) overrode the model identity, surface that here so + // `status` / dashboard report the effective model rather than the SDK default. + var modelId = string.IsNullOrEmpty(modelIdOverride) ? DefaultEmbeddingModel.ModelId : modelIdOverride; + var cacheRoot = ModelStore.DefaultCacheDir(); + + if (!Directory.Exists(cacheRoot)) + { + return new EmbeddingsSurface( + ModelId: modelId, + CacheDir: cacheRoot, + CachePresent: false, + TotalBytes: 0, + Verified: false); + } + + long totalBytes = 0; + try + { + foreach (var f in Directory.EnumerateFiles(cacheRoot, "*", SearchOption.AllDirectories)) + { + try { totalBytes += new FileInfo(f).Length; } + catch (IOException) { /* skip unreadable file */ } + catch (UnauthorizedAccessException) { /* skip unreadable file */ } + } + } + catch (IOException) { /* directory disappeared mid-walk; report what we have */ } + catch (UnauthorizedAccessException) { /* cache walk denied */ } + + return new EmbeddingsSurface( + ModelId: modelId, + CacheDir: cacheRoot, + // Per spec: `total_bytes` is the sum of cached file sizes; `cache_present` is true + // when at least one byte landed. An empty cache dir reports `false` so the + // `status --json` consumer can tell "no model files" apart from "model files exist." + CachePresent: totalBytes > 0, + TotalBytes: totalBytes, + // Verified state is not persisted at v1; conservatively report false. The dedicated + // `sourcegraph-mcp embeddings verify` verb remains the way to confirm pinned SHAs. + Verified: false); + } + + private static IReadOnlyList BuildActivity( + string usagePath, + string healsPath, + SnapshotOptions options) + { + var entries = new List(); + foreach (var element in JsonlTailReader.TailLines(usagePath, options.ActivityBytes)) + { + var entry = ProjectUsageEntry(element); + if (entry is not null) entries.Add(entry); + } + foreach (var element in JsonlTailReader.TailLines(healsPath, options.ActivityBytes)) + { + var entry = ProjectHealEntry(element); + if (entry is not null) entries.Add(entry); + } + entries.Sort((a, b) => a.Ts.CompareTo(b.Ts)); + if (entries.Count > options.RecentActivityCap) + { + // Keep the most-recent N; drop from the head. + entries.RemoveRange(0, entries.Count - options.RecentActivityCap); + } + return entries; + } + + private static ActivityEntry? ProjectUsageEntry(JsonElement el) + { + if (el.ValueKind != JsonValueKind.Object) return null; + var ts = ReadTimestamp(el, "ts"); + if (ts is null) return null; + var tool = el.TryGetProperty("tool", out var t) && t.ValueKind == JsonValueKind.String + ? t.GetString() ?? "unknown" : "unknown"; + var scope = el.TryGetProperty("scope", out var s) && s.ValueKind == JsonValueKind.String + ? s.GetString() : null; + var ok = el.TryGetProperty("ok", out var o) && o.ValueKind != JsonValueKind.False; + var ms = el.TryGetProperty("ms", out var m) && m.ValueKind == JsonValueKind.Number + ? (int)Math.Round(m.GetDouble()) : 0; + return new ActivityEntry( + Ts: ts.Value, + // Usage log doesn't carry an explicit kind — every row is a tool call. Surfacing + // the literal string here lets renderers and the JSON contract distinguish the + // origin without consulting the row's tool name. + Kind: "tool_call", + Scope: scope, + Ok: ok, + Ms: ms, + Detail: tool); + } + + private static ActivityEntry? ProjectHealEntry(JsonElement el) + { + if (el.ValueKind != JsonValueKind.Object) return null; + var ts = ReadTimestamp(el, "ts"); + if (ts is null) return null; + var kind = el.TryGetProperty("kind", out var k) && k.ValueKind == JsonValueKind.String + ? k.GetString() ?? "heal" : "heal"; + var scope = el.TryGetProperty("scope", out var s) && s.ValueKind == JsonValueKind.String + ? s.GetString() : null; + var ok = el.TryGetProperty("ok", out var o) && o.ValueKind != JsonValueKind.False; + var ms = el.TryGetProperty("ms", out var m) && m.ValueKind == JsonValueKind.Number + ? (int)Math.Round(m.GetDouble()) : 0; + var details = el.TryGetProperty("details", out var d) && d.ValueKind == JsonValueKind.String + ? d.GetString() : null; + return new ActivityEntry( + Ts: ts.Value, + Kind: kind, + Scope: scope, + Ok: ok, + Ms: ms, + Detail: details); + } + + private static DateTimeOffset? ReadTimestamp(JsonElement el, string name) + { + if (!el.TryGetProperty(name, out var v)) return null; + if (v.ValueKind == JsonValueKind.String && + DateTimeOffset.TryParse(v.GetString(), out var parsed)) + { + return parsed; + } + if (v.ValueKind == JsonValueKind.Number && v.TryGetInt64(out var ms)) + { + return DateTimeOffset.FromUnixTimeMilliseconds(ms); + } + return null; + } +} diff --git a/src/DevBitsLab.Mcp.SourceGraph.Server/Cli/StatusCli.cs b/src/DevBitsLab.Mcp.SourceGraph.Server/Cli/StatusCli.cs new file mode 100644 index 00000000..966ce94c --- /dev/null +++ b/src/DevBitsLab.Mcp.SourceGraph.Server/Cli/StatusCli.cs @@ -0,0 +1,209 @@ +using DevBitsLab.Mcp.SourceGraph.Server.Cli.Rendering; +using DevBitsLab.Mcp.SourceGraph.Server.Cli.Snapshot; + +namespace DevBitsLab.Mcp.SourceGraph.Server.Cli; + +/// +/// Top-level sourcegraph-mcp status subcommand. Aggregates a +/// via , evaluates the exit code by walking each surface, +/// then dispatches to either or the JSON serializer. +/// +/// +/// Exit semantics: +/// +/// 0 — every surface healthy. +/// 2 — any surface warns (partial / indexing scope, embedding cache absent, git missing). +/// 1 — any surface hard-fails (SDK missing, .sourcegraph.json malformed, degraded scope, DB dir unwritable). +/// +/// +/// +/// +/// --watch is honoured only when BOTH stdin and stdout are attached to a tty. The default +/// probe is () => Console.IsInputRedirected || Console.IsOutputRedirected: if either side +/// is a pipe, file, or otherwise redirected, the watch loop's ANSI cursor codes would pollute the +/// downstream consumer, so we silently downgrade to a single snapshot. This covers both +/// status --watch | cat (stdout piped) and CI-style invocations (stdin redirected). The +/// probe is overridable via the internal overload +/// for tests. +/// +/// +internal static class StatusCli +{ + /// + /// Production entry point: resolves flags, builds the snapshot, renders, returns the + /// evaluated exit code. The "is stdio redirected?" probe defaults to a check covering + /// stdin AND stdout (so status --watch | cat correctly downgrades — stdout being a + /// pipe means the ANSI cursor codes would otherwise pour into the pipe). + /// + public static Task RunAsync(CommandLine cli) + => RunAsync(cli, static () => Console.IsInputRedirected || Console.IsOutputRedirected); + + /// + /// Test-injectable overload: same flow as the public + /// but routes the "is stdio redirected?" probe through the caller. Tests use this to make the + /// watch/one-shot branch decision deterministic regardless of the host runner's stdio state. + /// The probe should return true if EITHER stdin or stdout is redirected — watch mode needs + /// both attached to a tty so the redraw codes don't pollute a downstream consumer. + /// + internal static async Task RunAsync(CommandLine cli, Func isStdioRedirected) + { + var root = cli.ResolvedRepoRoot(); + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile, + Environment.SpecialFolderOption.DoNotVerify); + + // `--watch` + `--json` is rejected: the watch loop emits ANSI cursor-positioning codes + // before each re-render, which would interleave with the JSON document and break any + // downstream parser. A user wanting "live JSON" should either use the dashboard or wrap + // `status --json` in their own poller (which is what `--watch` is shorthand for in the + // human-readable case). + if (cli.Watch && cli.Json) + { + await Console.Error.WriteLineAsync( + "status: --watch and --json are mutually exclusive (the watch loop interleaves ANSI redraw codes with stdout, which would corrupt the JSON document).").ConfigureAwait(false); + return 2; + } + + var options = new SnapshotOptions( + ActivityBytes: cli.ActivityBytes ?? 524288, + RecentActivityCap: 50, + ModelId: cli.Model); + + if (cli.Watch && !isStdioRedirected()) + { + return await RunWatchAsync(cli, root, home, options).ConfigureAwait(false); + } + + // Default (non-watch / piped) path: build once, render once, exit. + var snapshot = await SnapshotBuilder.BuildAsync(root, options).ConfigureAwait(false); + snapshot = snapshot with { ExitCode = EvaluateExit(snapshot) }; + RenderOnce(snapshot, cli, root, home); + return snapshot.ExitCode; + } + + /// + /// Watch loop: re-renders the snapshot in place every --watch-interval seconds until + /// fires. Uses ANSI cursor-home + clear-to-end (no + /// external Spectre dependency); guarantees clean shutdown on Ctrl+C. + /// + /// + /// On Ctrl+C the watch loop returns 0 regardless of the most recent snapshot's exit + /// code. The spec scenario "--watch refreshes in place under a tty" explicitly requires + /// clean SIGINT exit semantics, and propagating a non-zero snapshot status (e.g. an + /// embedding-cache warning) would surprise an operator who hits Ctrl+C to exit a healthy + /// tail. The exit-code semantics on the one-shot path are unchanged. + /// + private static async Task RunWatchAsync(CommandLine cli, string root, string? home, SnapshotOptions options) + { + using var cts = new CancellationTokenSource(); + var cancelledViaCtrlC = false; + ConsoleCancelEventHandler handler = (_, e) => + { + e.Cancel = true; + cancelledViaCtrlC = true; + cts.Cancel(); + }; + Console.CancelKeyPress += handler; + var interval = TimeSpan.FromSeconds(Math.Max(1, cli.WatchInterval ?? 2)); + var first = true; + var lastExit = 0; + try + { + while (!cts.IsCancellationRequested) + { + // Wrap the whole loop body so an OperationCanceledException from either + // `BuildAsync` or `Task.Delay` (both honour `cts.Token`) is treated as a + // clean Ctrl+C exit. Without this, a Ctrl+C arriving mid-build would + // propagate out and `--watch` would return non-zero with a stack trace. + try + { + if (!first) + { + // Cursor home + clear to end. Stable on every terminal that honours ANSI. + Console.Write("\x1b[H\x1b[J"); + } + first = false; + var snap = await SnapshotBuilder.BuildAsync(root, options, cts.Token).ConfigureAwait(false); + snap = snap with { ExitCode = EvaluateExit(snap) }; + RenderOnce(snap, cli, root, home); + lastExit = snap.ExitCode; + await Task.Delay(interval, cts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) { /* clean exit on Ctrl+C */ } + } + } + finally + { + Console.CancelKeyPress -= handler; + } + return cancelledViaCtrlC ? 0 : lastExit; + } + + /// + /// One render — JSON or prose — to . Extracted so the watch loop + /// and the one-shot path share the same dispatch. + /// + private static void RenderOnce(DashboardSnapshot snapshot, CommandLine cli, string root, string? home) + { + if (cli.Json) + { + var json = DashboardSnapshotJson.Serialize(snapshot); + Console.Out.WriteLine(json); + return; + } + var options = new StatusRenderOptions(Root: root, Home: home, NoColor: cli.NoColor); + StatusRenderer.RenderHuman(snapshot, Console.Out, options); + } + + /// + /// Walk the snapshot surface-by-surface and decide the aggregate exit code per spec: + /// 1 on hard-fail, 2 on warn, 0 on healthy. + /// + public static int EvaluateExit(DashboardSnapshot snapshot) + { + var hardFail = false; + var warn = false; + + // Environment. + if (snapshot.Environment.SourceGraphConfigStatus == "malformed") hardFail = true; + if (string.IsNullOrEmpty(snapshot.Environment.DotnetSdkVersion)) hardFail = true; + if (!Directory.Exists(snapshot.Environment.RepoRootPath)) hardFail = true; + if (!snapshot.Environment.GitOnPath) warn = true; + // A repo with no detectable .slnx / .sln warns. Matches what StatusRenderer and + // DashboardRenderer.ComputeEnvironmentSummary surface as a warning dot, and what + // doctor reports as a warn check. Without this the exit code and the rendered + // surface disagree on "is this repo healthy?". + if (snapshot.Environment.SolutionFiles.Count == 0) warn = true; + + // Scopes. + foreach (var s in snapshot.Scopes) + { + if (s.Status == "degraded") hardFail = true; + if (s.Status is "partial" or "indexing") warn = true; + } + + // Per-scope DB dir writability — fail when the dir exists but isn't writable. We don't + // require the dir to exist (a fresh repo has none, and that's not a fail). + var scopeDir = Path.Join(snapshot.Environment.RepoRootPath, ".sourcegraph", "scopes"); + if (Directory.Exists(scopeDir) && !TestWritability(scopeDir)) hardFail = true; + + // Embeddings. + if (!snapshot.Embeddings.CachePresent) warn = true; + + if (hardFail) return 1; + if (warn) return 2; + return 0; + } + + private static bool TestWritability(string dir) + { + try + { + var probe = Path.Join(dir, ".sg-status-probe-" + Guid.NewGuid().ToString("N")); + File.WriteAllBytes(probe, Array.Empty()); + File.Delete(probe); + return true; + } + catch (IOException) { return false; } + catch (UnauthorizedAccessException) { return false; } + } +} diff --git a/src/DevBitsLab.Mcp.SourceGraph.Server/Dashboard/AddScopeForm.cs b/src/DevBitsLab.Mcp.SourceGraph.Server/Dashboard/AddScopeForm.cs new file mode 100644 index 00000000..c5781d5c --- /dev/null +++ b/src/DevBitsLab.Mcp.SourceGraph.Server/Dashboard/AddScopeForm.cs @@ -0,0 +1,142 @@ +using DevBitsLab.Mcp.SourceGraph.Core; +using DevBitsLab.Mcp.SourceGraph.Storage; +using Spectre.Console; + +namespace DevBitsLab.Mcp.SourceGraph.Server.Dashboard; + +/// +/// Inline form rendered by the dashboard's Scopes detail view when the operator presses +/// N. Collects the three fields required to add a scope — name, solution +/// path, and an optional isolated flag — using straight Spectre prompts (the same +/// pattern init's guided actions use), then delegates to +/// to validate and persist. +/// +/// +/// The form is rendered outside the dashboard's Live region (the caller drops out of +/// Live before invoking ). Validation errors re-prompt for the offending +/// field rather than dismissing the form. Cancellation is by EMPTY input at the name or +/// solution prompt (the prompts use .AllowEmpty() + a trim check) — returning empty +/// produces . Spectre's TextPrompt doesn't have a +/// first-class "Esc cancels" hook; this is the idiomatic dismissal path. +/// +/// +internal static class AddScopeForm +{ + /// Outcome of the form. Cancelled is distinct from Failure so the caller can suppress the toast. + internal enum Outcome { Saved, Cancelled, Failed } + + /// Result carried back to the dispatcher: outcome plus a short message + the persisted scope id when applicable. + internal sealed record Result(Outcome Outcome, string Message, string? ScopeId = null) + { + public static Result Cancelled => new(Outcome.Cancelled, "add scope cancelled", null); + } + + /// + /// Render the form against , persist the result via + /// , and return the outcome. The caller is + /// responsible for refreshing the dashboard snapshot on success. + /// + /// Spectre console for prompts; tests inject a TestConsole. + /// Repository root — the .sourcegraph.json location. + /// Current scope config snapshot; the form's validation runs against this. + public static Result Prompt(IAnsiConsole console, string root, ScopeConfig config) + { + console.WriteLine(); + console.MarkupLine($"[{DashboardTheme.MutedDim}]─── Add scope ───[/]"); + console.WriteLine(); + + // 1. Name — must be a valid kebab-case scope id and not already taken. Empty input + // dismisses the form (see the class docs). The validator accepts empty/whitespace as + // Success so the prompt returns rather than re-prompting; the post-prompt check below + // catches the empty case and returns Cancelled. (Previously the validator rejected + // empty as "name is required", which made the cancel path unreachable from real user + // input — they could only get out by typing something valid.) + console.MarkupLine($"[{DashboardTheme.MutedDim}](Leave a field empty and press Enter to cancel.)[/]"); + var name = console.Prompt( + new TextPrompt("[bold]Scope name:[/]") + .Validate(candidate => + { + if (string.IsNullOrWhiteSpace(candidate)) + return ValidationResult.Success(); // empty → cancel via post-prompt check + if (!ScopeIdValidator.IsValid(candidate.Trim())) + return ValidationResult.Error("[red]must match ^[[a-z0-9]][[a-z0-9-]]{0,63}$ (kebab-case slug)[/]"); + if (config.Scopes.Any(s => string.Equals(s.Id, candidate.Trim(), StringComparison.Ordinal))) + return ValidationResult.Error($"[red]scope '{Markup.Escape(candidate.Trim())}' already exists[/]"); + return ValidationResult.Success(); + }) + .AllowEmpty()).Trim(); + if (string.IsNullOrEmpty(name)) return Result.Cancelled; + + // 2. Solution path — must resolve to an existing file after env-var expansion. + // The inline form doesn't support glob patterns; users wanting glob-based scopes can + // edit `.sourcegraph.json` directly via `[e]`. Same empty-input cancel semantics as + // the name prompt above. + var solution = console.Prompt( + new TextPrompt("[bold]Solution path:[/]") + .Validate(candidate => + { + if (string.IsNullOrWhiteSpace(candidate)) + return ValidationResult.Success(); // empty → cancel via post-prompt check + var expanded = ExpandPath(candidate.Trim(), root); + if (!File.Exists(expanded)) + return ValidationResult.Error($"[red]file not found:[/] {Markup.Escape(expanded)}"); + return ValidationResult.Success(); + }) + .AllowEmpty()).Trim(); + if (string.IsNullOrEmpty(solution)) return Result.Cancelled; + + // 3. Isolated — default No (most scopes participate in scope="*" fan-out). + var isolated = console.Prompt( + new ConfirmationPrompt("[bold]Isolated?[/] (excluded from scope=\"*\" fan-out)") { DefaultValue = false }); + + // Persist via the shared core path. We pass the user-typed solution string (not the + // expanded one) so the on-disk JSON keeps the same shape `scopes add --solution ...` + // would emit; ScopesCli rebases absolute paths under when possible. + var save = Cli.ScopesCli.AddScopeToConfig(root, config, name, solution, isolated); + if (!save.Ok) + { + return new Result(Outcome.Failed, save.Message); + } + return new Result(Outcome.Saved, $"added scope '{name}'", ScopeId: name); + } + + /// + /// Expand placeholder syntax in the input — the same vocabulary CommandLine accepts + /// for --solution and friends, so the form's "file not found" validation accepts the + /// same paths the user would type in scopes add --solution. Supported: + /// + /// ${workspaceFolder} + /// ${HOME} → user profile + /// ~/ prefix → user profile + /// ${VAR} for any other env var, via + /// relative path → resolved against + /// + /// + private static string ExpandPath(string raw, string root) + { + var expanded = raw; + // ${workspaceFolder} → root (same fallback the CLI uses). + expanded = expanded.Replace("${workspaceFolder}", root, StringComparison.Ordinal); + // ${HOME} → user profile. + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile, + Environment.SpecialFolderOption.DoNotVerify); + expanded = expanded.Replace("${HOME}", home, StringComparison.Ordinal); + // ~/ prefix. + if (expanded.StartsWith("~/", StringComparison.Ordinal)) + { + expanded = Path.Join(home, expanded[2..]); + } + // Any remaining ${VAR} → process env. Reuse CommandLine.ExpandTokens so the dashboard + // and the CLI agree on the placeholder grammar. (The previous implementation called + // `Environment.ExpandEnvironmentVariables`, which only expands the `%VAR%` Windows + // form and silently passed `${FOO}` through unexpanded — the validation then said + // "file not found" for a path the CLI's `--solution` would have happily resolved.) + expanded = Cli.CommandLine.ExpandTokens(expanded); + // Relative path → resolve against root. + if (!Path.IsPathRooted(expanded)) + { + expanded = Path.GetFullPath(Path.Join(root, expanded)); + } + return expanded; + } +} diff --git a/src/DevBitsLab.Mcp.SourceGraph.Server/Dashboard/BareCommandDispatch.cs b/src/DevBitsLab.Mcp.SourceGraph.Server/Dashboard/BareCommandDispatch.cs new file mode 100644 index 00000000..2967be1b --- /dev/null +++ b/src/DevBitsLab.Mcp.SourceGraph.Server/Dashboard/BareCommandDispatch.cs @@ -0,0 +1,148 @@ +namespace DevBitsLab.Mcp.SourceGraph.Server.Dashboard; + +/// +/// Factored bare-command dispatch helper, per design Decision 2. Detects whether args +/// is a bare invocation (no positional subcommand AND no --help/-h flag), and if +/// so rewrites it to either ["dashboard"] or ["status"] based on the stdin +/// disposition. +/// +/// +/// Carved out as a pure helper so tests can drive the dispatch logic directly without spawning +/// a child process: the production environment probe (, +/// ) is injected as a delegate. +/// +/// +internal static class BareCommandDispatch +{ + /// + /// Compute the args array to feed into . When + /// is bare, prepends either dashboard (every stdio stream + /// attached to a tty AND UserInteractive) or status (any stream redirected, + /// or running non-interactively). + /// + /// + /// The probe checks stdio disposition (not just stdin): the dashboard needs stdin for key + /// input AND stdout + stderr to position the ANSI cursor. The production probe + /// bundles all four checks; the parameter + /// here is named to mirror the + /// production probe and keep tests honest about what they're stubbing. + /// + /// + /// + /// Bare-detection rules: args.Length == 0 OR the first non-flag positional is empty. + /// Crucially we skip the rewrite when --help/-h is present so the existing help + /// path stays reachable without typing a subcommand. Other flags pass through unchanged. + /// + /// + public static string[] Rewrite(string[] args, Func isStdioRedirectedOrNonInteractive) + { + if (HasHelpFlag(args)) return args; + if (!IsBare(args)) return args; + var target = isStdioRedirectedOrNonInteractive() ? "status" : "dashboard"; + // Prepend the verb; any flags already present propagate to the new subcommand. + var rewritten = new string[args.Length + 1]; + rewritten[0] = target; + Array.Copy(args, 0, rewritten, 1, args.Length); + return rewritten; + } + + /// + /// Production probe: returns true when ANY stdio stream is redirected or the process is + /// running non-interactively. The dashboard needs all three streams attached to a tty (stdin + /// for key input; stdout + stderr for ANSI cursor positioning). If any stream is a pipe or + /// file, we route to status instead — the headless surface that prints once and exits. + /// + /// + /// The previous version only checked stdin redirection, which mis-classified + /// sourcegraph-mcp | cat (stdin still a tty, stdout piped) as interactive and launched + /// the dashboard — its ANSI redraw codes then poured into the pipe instead of dispatching to + /// status as documented. The method name reflects the broader contract: "is ANY stdio + /// redirected, or are we non-interactive?". + /// + /// + public static bool IsStdioRedirectedOrNonInteractive() + { + try + { + if (Console.IsInputRedirected) return true; + if (Console.IsOutputRedirected) return true; + if (Console.IsErrorRedirected) return true; + // Environment.UserInteractive is false for service-account / non-tty contexts; we + // route those to `status` because the dashboard requires a tty to be useful. + return !Environment.UserInteractive; + } + catch (IOException) + { + // Console probe failed (unusual; can happen on detached stdio). Conservative fallback + // to "redirected" → `status`, the headless surface. + return true; + } + } + + private static bool HasHelpFlag(string[] args) + { + foreach (var a in args) + { + if (a is "-h" or "--help") return true; + } + return false; + } + + /// + /// "Bare" means: no positional subcommand anywhere in . We walk the + /// whole list, skipping over value-bearing flag tokens (a flag like --root /repo + /// consumes two tokens), and return true only if every token was consumed as a flag + /// or as a flag's value. Any positional (non--) token that isn't sitting in a value + /// slot is a subcommand and disqualifies the bare-rewrite path. + /// + /// + /// This composes correctly with bare invocations like sourcegraph-mcp --root /repo + /// (still bare → rewrite to dashboard --root /repo) and with subcommand-bearing + /// invocations like sourcegraph-mcp --root /repo serve (NOT bare → pass through to + /// serve). The set of value-bearing flag names mirrors what + /// calls RequireArg / RequirePositiveInt + /// on — kept in sync manually because cross-class coupling would be heavier than the + /// duplication. + /// + /// + internal static bool IsBare(string[] args) + { + if (args.Length == 0) return true; + for (var i = 0; i < args.Length; i++) + { + var token = args[i]; + if (!token.StartsWith('-')) + { + // A positional we didn't consume as a flag's value → this is a subcommand. + return false; + } + // Flag. Consume one extra token if this is a value-bearing flag. + if (ValueBearingFlags.Contains(token)) + { + i++; // skip the value + } + } + return true; + } + + /// + /// Flags that consumes a positional value after. + /// Adding a new value-bearing flag in CommandLine requires a matching entry here + /// (or the bare-detection misclassifies its value as a subcommand). Boolean flags are not + /// listed. + /// + private static readonly HashSet ValueBearingFlags = new(StringComparer.Ordinal) + { + "--solution", "-s", + "--db", + "--model", + "--root", + "--scope", + "--query-timeout-seconds", + "--query-row-limit", + "--install-mode", + "--client", + "--watch-interval", + "--activity-bytes", + }; +} diff --git a/src/DevBitsLab.Mcp.SourceGraph.Server/Dashboard/ConfirmModal.cs b/src/DevBitsLab.Mcp.SourceGraph.Server/Dashboard/ConfirmModal.cs new file mode 100644 index 00000000..ca5ed6d1 --- /dev/null +++ b/src/DevBitsLab.Mcp.SourceGraph.Server/Dashboard/ConfirmModal.cs @@ -0,0 +1,36 @@ +using Spectre.Console; + +namespace DevBitsLab.Mcp.SourceGraph.Server.Dashboard; + +/// +/// Modal confirmation prompt for destructive dashboard actions per design Decision 4. Renders +/// a Spectre with the text <verb> <target>? [y/N], +/// default No, dismissable with Esc or n. +/// +/// +/// The dispatcher gates every destructive in-place action through this modal: +/// (archive + cold-index), +/// (removes the sourcegraph entry from a +/// client config), and (removes a scope from +/// .sourcegraph.json; the on-disk per-scope DB is preserved as a re-add cache). All +/// other actions are read-only or idempotent and don't gate. +/// +/// +internal static class ConfirmModal +{ + /// + /// Show the prompt against and return the user's choice. Defaults + /// to false on Esc/Enter/n; only an explicit y/Y + /// returns true. + /// + public static bool Prompt(IAnsiConsole console, string verb, string target) + { + var prompt = new ConfirmationPrompt($"{verb} [yellow]{Markup.Escape(target)}[/]?") + { + DefaultValue = false, + // ShowChoices = true, + // ShowDefaultValue = true, + }; + return console.Prompt(prompt); + } +} diff --git a/src/DevBitsLab.Mcp.SourceGraph.Server/Dashboard/DashboardAction.cs b/src/DevBitsLab.Mcp.SourceGraph.Server/Dashboard/DashboardAction.cs new file mode 100644 index 00000000..a9a347fe --- /dev/null +++ b/src/DevBitsLab.Mcp.SourceGraph.Server/Dashboard/DashboardAction.cs @@ -0,0 +1,82 @@ +namespace DevBitsLab.Mcp.SourceGraph.Server.Dashboard; + +/// +/// The actions a single keystroke can dispatch. Three tiers per design Decision 3: +/// +/// +/// Read-only: navigation, force-refresh, quit, help. No state mutation, no gating. +/// In-place: Reindex, Wire, Pull, Verify (idempotent / additive, +/// no gating) and Rebuild, Unwire (destructive, gate behind ). +/// Guided: Init, Demo, OpenLog, OpenConfig — suspend Live, spawn +/// subprocess with inherited streams, resume. +/// +/// +internal enum DashboardAction +{ + None, + + // Read-only navigation + MoveUp, + MoveDown, + NextSection, + PreviousSection, + OpenDetail, + CloseDetail, + Quit, + ToggleHelp, + ForceRefresh, + + /// + /// Return to the home/welcome view. Bound to Esc + 'h'. Resets selection so re-entering a + /// detail view starts from row 0 of that view. + /// + GoHome, + /// Open the Scopes detail view (also bound to '1' from home). + OpenScopes, + /// Open the Clients detail view (also bound to '2' from home). + OpenClients, + /// Open the Embeddings detail view (also bound to '3' from home). + OpenEmbeddings, + /// Open the Recent activity detail view (also bound to '4' from home). + OpenRecentActivity, + /// Open the Environment detail view (also bound to '5' from home). + OpenEnvironment, + + /// + /// The section-aware "act on the selected row" action Enter is bound to. The dispatcher + /// in routes this to a section-specific concrete action (reindex + /// for Scopes, wire/unwire toggle for Clients, pull for Embeddings, etc.). Kept as a + /// distinct enum value rather than overloading so a future + /// detail-pane feature can reclaim Enter on a section-by-section basis. + /// + PrimaryAction, + + // In-place (no gate) + ReindexScope, + WireClient, + EmbeddingsPull, + EmbeddingsVerify, + + // In-place (gate via ConfirmModal) + RebuildScope, + UnwireClient, + /// + /// Remove the selected scope from .sourcegraph.json. The per-scope DB on disk is + /// preserved (re-add cache semantics, matching the CLI scopes remove behaviour). + /// Destructive — gated behind . + /// + RemoveScope, + + /// + /// Add a new scope to .sourcegraph.json. Triggers the dashboard's inline form + /// (scope name + solution path + isolated). Additive — no confirm modal; the form itself is + /// the deliberate action. + /// + AddScope, + + // Guided (suspend + subprocess) + InitGuided, + DemoGuided, + OpenLogInPager, + OpenConfigInEditor, +} diff --git a/src/DevBitsLab.Mcp.SourceGraph.Server/Dashboard/DashboardActions.cs b/src/DevBitsLab.Mcp.SourceGraph.Server/Dashboard/DashboardActions.cs new file mode 100644 index 00000000..e084d8b5 --- /dev/null +++ b/src/DevBitsLab.Mcp.SourceGraph.Server/Dashboard/DashboardActions.cs @@ -0,0 +1,775 @@ +using System.Diagnostics; +using System.Text.Json.Nodes; +using DevBitsLab.Mcp.SourceGraph.Core; +using DevBitsLab.Mcp.SourceGraph.Embeddings; +using DevBitsLab.Mcp.SourceGraph.Server.Cli; +using DevBitsLab.Mcp.SourceGraph.Server.Cli.ClientConfigWriters; +using DevBitsLab.Mcp.SourceGraph.Server.Cli.Snapshot; +using DevBitsLab.Mcp.SourceGraph.Storage; +using Microsoft.Extensions.Logging.Abstractions; +using Spectre.Console; + +namespace DevBitsLab.Mcp.SourceGraph.Server.Dashboard; + +/// +/// Selection state the dashboard threads through to its actions. The dashboard's main loop owns +/// the focused section and per-section row index; actions consume these as inputs but never +/// mutate them — navigation lives in . +/// +internal sealed record DashboardSelection(DashboardSection FocusedSection, int RowIndex); + +/// +/// Severity of the action's outcome — drives the toast colour in the renderer. Success is +/// brand-green, Warn amber, Fail red; Info renders muted (and the no-message +/// case is hidden entirely). +/// +internal enum ToastSeverity +{ + Success, + Warn, + Fail, + Info, +} + +/// +/// Outcome of one action invocation. Carries a short status message the dashboard surfaces in +/// its footer/status bar plus a flag the loop honours when the user pressed quit. Failure-mode +/// actions also set false so the status bar can render the failure inline. +/// +internal sealed record DashboardActionResult(bool Ok, bool Quit, string Message, ToastSeverity Severity = ToastSeverity.Success) +{ + public static DashboardActionResult Noop => new(Ok: true, Quit: false, Message: "", Severity: ToastSeverity.Info); + public static DashboardActionResult QuitSignal => new(Ok: true, Quit: true, Message: "quitting", Severity: ToastSeverity.Info); + public static DashboardActionResult Success(string msg) => new(Ok: true, Quit: false, Message: msg, Severity: ToastSeverity.Success); + public static DashboardActionResult Failure(string msg) => new(Ok: false, Quit: false, Message: msg, Severity: ToastSeverity.Fail); + public static DashboardActionResult Info(string msg) => new(Ok: true, Quit: false, Message: msg, Severity: ToastSeverity.Info); +} + +/// +/// Context passed to every action: the active snapshot, the user's selection, the console for +/// prompts, and the freshness source so an action can trigger an out-of-band rebuild on +/// completion. Tests inject mocks for console and freshness. +/// +internal sealed record DashboardActionContext( + DashboardSnapshot Snapshot, + DashboardSelection Selection, + IAnsiConsole Console, + FreshnessSource? Freshness, + string Root, + DashboardActionPolicy Policy); + +/// +/// Watchdog + subprocess timing knobs the dashboard reads. Production wiring uses the +/// instance; tests substitute a tighter watchdog to exercise the +/// 30-second branch without sleeping that long. +/// +internal sealed record DashboardActionPolicy(TimeSpan InPlaceWatchdog) +{ + /// 30-second watchdog per design "Risks / Trade-offs" mitigation. + public static readonly DashboardActionPolicy Default = new(InPlaceWatchdog: TimeSpan.FromSeconds(30)); +} + +/// +/// Action dispatcher for the dashboard. Each method takes a +/// and returns a the main loop renders in the footer. +/// +/// +/// In-place actions delegate to existing code paths (the same ones the headless CLI subcommands +/// use): for the embeddings tier; +/// for the wire/unwire tier; subprocess invocation of sourcegraph-mcp index <solution> +/// for the reindex/rebuild tier (LiveIndexService is a hosted service that needs the full +/// serve infrastructure — running it inline from a one-shot dashboard process would +/// require standing up the same DI graph; the spec's principle "no business logic forks into +/// the dashboard" is honoured by re-using the existing CLI verb instead). +/// +/// +/// +/// Destructive actions (, ) +/// gate via ; idempotent ones don't. The 30-second watchdog wraps +/// every in-place action; on timeout the action's task is cancelled and the failure surfaces +/// in the status bar. +/// +/// +internal static class DashboardActions +{ + /// Run against and return its outcome. + public static async Task RunAsync(DashboardAction action, DashboardActionContext ctx, + CancellationToken token = default) + { + return action switch + { + DashboardAction.None => DashboardActionResult.Noop, + DashboardAction.Quit => DashboardActionResult.QuitSignal, + + // Read-only actions resolved by the main loop's selection / view state — no work + // to do here. View transitions (GoHome / OpenScopes / …) are handled inline by the + // LoopState before this dispatcher is reached, but we list them so a stray call + // can't fall through to the default _. + DashboardAction.MoveUp or DashboardAction.MoveDown or + DashboardAction.NextSection or DashboardAction.PreviousSection or + DashboardAction.OpenDetail or DashboardAction.CloseDetail or + DashboardAction.ToggleHelp or + DashboardAction.GoHome or DashboardAction.OpenScopes or + DashboardAction.OpenClients or DashboardAction.OpenEmbeddings or + DashboardAction.OpenRecentActivity or DashboardAction.OpenEnvironment + => DashboardActionResult.Noop, + + // PrimaryAction (Enter) is mapped section-by-section by the caller (DashboardCli's + // dispatch helper rewrites it to one of ReindexScope / WireClient / UnwireClient / + // EmbeddingsPull). If it reaches the dispatcher as a raw PrimaryAction the caller's + // routing failed; treat as a no-op so the user gets a visible "nothing happened" + // toast rather than a hang. + DashboardAction.PrimaryAction => DashboardActionResult.Info("no primary action for this section"), + + DashboardAction.ForceRefresh => ForceRefresh(ctx), + + // In-place — gated (destructive) + DashboardAction.RebuildScope => await RebuildScopeAsync(ctx, token).ConfigureAwait(false), + DashboardAction.UnwireClient => await UnwireClientAsync(ctx, token).ConfigureAwait(false), + DashboardAction.RemoveScope => RemoveScope(ctx), + + // In-place — not gated + DashboardAction.ReindexScope => await ReindexScopeAsync(ctx, token).ConfigureAwait(false), + DashboardAction.WireClient => await WireClientAsync(ctx, token).ConfigureAwait(false), + DashboardAction.EmbeddingsPull => await EmbeddingsPullAsync(ctx, token).ConfigureAwait(false), + DashboardAction.EmbeddingsVerify => await EmbeddingsVerifyAsync(ctx, token).ConfigureAwait(false), + + // Inline form — suspends Live in the caller, runs Spectre prompts inline. + DashboardAction.AddScope => AddScope(ctx), + + // Guided + DashboardAction.InitGuided => await InitGuidedAsync(ctx, token).ConfigureAwait(false), + DashboardAction.DemoGuided => await DemoGuidedAsync(ctx, token).ConfigureAwait(false), + DashboardAction.OpenLogInPager => await OpenLogInPagerAsync(ctx, token).ConfigureAwait(false), + DashboardAction.OpenConfigInEditor => await OpenConfigInEditorAsync(ctx, token).ConfigureAwait(false), + + _ => DashboardActionResult.Noop, + }; + } + + private static DashboardActionResult ForceRefresh(DashboardActionContext ctx) + { + ctx.Freshness?.RequestImmediateRebuild(); + return DashboardActionResult.Success("snapshot refresh requested"); + } + + // ──────────────────────────────────────────────────────────────────────────────── + // In-place actions (destructive — gated) + // ──────────────────────────────────────────────────────────────────────────────── + + private static async Task RebuildScopeAsync(DashboardActionContext ctx, CancellationToken token) + { + if (ctx.Selection.FocusedSection != DashboardSection.Scopes) + return DashboardActionResult.Failure("rebuild requires the Scopes section selected"); + if (ctx.Snapshot.Scopes.Count == 0) + return DashboardActionResult.Failure("no scope to rebuild"); + if (ctx.Selection.RowIndex < 0 || ctx.Selection.RowIndex >= ctx.Snapshot.Scopes.Count) + return DashboardActionResult.Failure("selection out of range"); + var scope = ctx.Snapshot.Scopes[ctx.Selection.RowIndex]; + + // Refuse to rebuild while an index is already in flight on this scope. Rebuild's + // first step archives the existing DB and starts fresh; running that against a DB + // the live indexer is currently writing into races and can corrupt either side. + if (scope.Status == "indexing") + return DashboardActionResult.Failure( + $"refusing to rebuild '{scope.Name}': scope is currently indexing. Wait for it to finish first."); + + if (!ConfirmModal.Prompt(ctx.Console, "rebuild", scope.Name)) + return DashboardActionResult.Success("rebuild cancelled"); + + // The rebuild path runs by re-invoking `sourcegraph-mcp index ` for the scope. + // We need the scope's solution path; the snapshot doesn't carry it, so we read the + // .sourcegraph.json (or fall back to detection). For the synthesised default scope we + // probe the env for solution files. + var solution = ResolveSolutionForScope(ctx, scope.Name); + if (solution is null) + return DashboardActionResult.Failure($"no solution resolved for scope '{scope.Name}'"); + + return await RunWatchdoggedAsync(async () => + { + var rc = await RunSourceGraphCommandAsync(new[] { "index", solution }, token).ConfigureAwait(false); + ctx.Freshness?.RequestImmediateRebuild(); + return rc == 0 + ? DashboardActionResult.Success($"rebuilt scope '{scope.Name}'") + : DashboardActionResult.Failure($"rebuild exited with code {rc}"); + }, ctx, token).ConfigureAwait(false); + } + + private static async Task UnwireClientAsync(DashboardActionContext ctx, CancellationToken token) + { + if (ctx.Selection.FocusedSection != DashboardSection.Clients) + return DashboardActionResult.Failure("unwire requires the Clients section selected"); + var clients = OrderedClients(ctx.Snapshot); + if (clients.Count == 0) return DashboardActionResult.Failure("no client to unwire"); + if (ctx.Selection.RowIndex < 0 || ctx.Selection.RowIndex >= clients.Count) + return DashboardActionResult.Failure("selection out of range"); + var client = clients[ctx.Selection.RowIndex]; + if (!client.ContainsSourcegraphEntry) + return DashboardActionResult.Failure($"{client.Slug} is not currently wired"); + + if (!ConfirmModal.Prompt(ctx.Console, "unwire", client.Slug)) + return DashboardActionResult.Success("unwire cancelled"); + + return await RunWatchdoggedAsync(() => + { + var result = UnwireFromConfigFile(client.Path); + ctx.Freshness?.RequestImmediateRebuild(); + return Task.FromResult(result); + }, ctx, token).ConfigureAwait(false); + } + + /// + /// Remove the selected scope from .sourcegraph.json. Gated behind + /// ; the per-scope DB on disk is preserved (re-add cache semantics). + /// + private static DashboardActionResult RemoveScope(DashboardActionContext ctx) + { + if (ctx.Selection.FocusedSection != DashboardSection.Scopes) + return DashboardActionResult.Failure("remove requires the Scopes section selected"); + if (ctx.Snapshot.Scopes.Count == 0) + return DashboardActionResult.Failure("no scope to remove"); + if (ctx.Selection.RowIndex < 0 || ctx.Selection.RowIndex >= ctx.Snapshot.Scopes.Count) + return DashboardActionResult.Failure("selection out of range"); + var scope = ctx.Snapshot.Scopes[ctx.Selection.RowIndex]; + + // Refuse to remove the last scope. Without any scope, .sourcegraph.json would be + // empty and the server would fall back to its synth-default path — surprising to + // trigger from a single keystroke. If that's actually what the user wants, deleting + // .sourcegraph.json directly (via `[e]` editor) is the explicit way to get there. + if (ctx.Snapshot.Scopes.Count == 1) + return DashboardActionResult.Failure( + $"refusing to remove '{scope.Name}': it's the last scope. To revert to single-scope synth-default, delete .sourcegraph.json (open via `[e]`)"); + + // Refuse to remove a scope mid-index. The live-indexer process (in the `serve` host) + // holds an open write handle on this scope's DB; removing the scope entry from + // .sourcegraph.json mid-write orphans that handle and can leave the DB in an + // inconsistent state on rollback. Wait for indexing to finish first. + if (scope.Status == "indexing") + return DashboardActionResult.Failure( + $"refusing to remove '{scope.Name}': scope is currently indexing. Wait for it to finish, then try again."); + + if (!ConfirmModal.Prompt(ctx.Console, "remove scope", scope.Name)) + return DashboardActionResult.Success("remove cancelled"); + + ScopeConfig config; + try + { + config = ScopeConfigLoader.Load(ctx.Root); + } + catch (ScopeConfigException ex) + { + return DashboardActionResult.Failure($"failed to read .sourcegraph.json: {ex.Message}"); + } + var result = Cli.ScopesCli.RemoveScopeFromConfig(ctx.Root, config, scope.Name); + if (!result.Ok) + return DashboardActionResult.Failure(result.Message); + ctx.Freshness?.RequestImmediateRebuild(); + return DashboardActionResult.Success($"removed scope '{scope.Name}'"); + } + + /// + /// Show the inline add-scope form and persist the result. Runs synchronously inside the + /// outer-loop suspend window (Live region is exited before this is called, the same way + /// guided init/demo are dispatched). The form itself is the deliberate action — no + /// confirm-modal gate. + /// + private static DashboardActionResult AddScope(DashboardActionContext ctx) + { + if (ctx.Selection.FocusedSection != DashboardSection.Scopes) + return DashboardActionResult.Failure("add requires the Scopes section selected"); + + ScopeConfig config; + try + { + config = ScopeConfigLoader.Load(ctx.Root); + } + catch (ScopeConfigException ex) + { + return DashboardActionResult.Failure($"failed to read .sourcegraph.json: {ex.Message}"); + } + + var result = AddScopeForm.Prompt(ctx.Console, ctx.Root, config); + return result.Outcome switch + { + AddScopeForm.Outcome.Saved => TickAndReturn(ctx, DashboardActionResult.Success(result.Message)), + AddScopeForm.Outcome.Cancelled => DashboardActionResult.Info(result.Message), + AddScopeForm.Outcome.Failed => DashboardActionResult.Failure(result.Message), + _ => DashboardActionResult.Failure("unexpected add-scope form outcome"), + }; + + static DashboardActionResult TickAndReturn(DashboardActionContext ctx, DashboardActionResult r) + { + ctx.Freshness?.RequestImmediateRebuild(); + return r; + } + } + + // ──────────────────────────────────────────────────────────────────────────────── + // In-place actions (idempotent — no gate) + // ──────────────────────────────────────────────────────────────────────────────── + + private static async Task ReindexScopeAsync(DashboardActionContext ctx, CancellationToken token) + { + if (ctx.Selection.FocusedSection != DashboardSection.Scopes) + return DashboardActionResult.Failure("reindex requires the Scopes section selected"); + if (ctx.Snapshot.Scopes.Count == 0) return DashboardActionResult.Failure("no scope to reindex"); + if (ctx.Selection.RowIndex < 0 || ctx.Selection.RowIndex >= ctx.Snapshot.Scopes.Count) + return DashboardActionResult.Failure("selection out of range"); + var scope = ctx.Snapshot.Scopes[ctx.Selection.RowIndex]; + + // Refuse to reindex while an index is already in flight. Two write paths on the same + // per-scope DB will at best serialise on SQLite's busy timeout and at worst corrupt + // under concurrent indexer state. + if (scope.Status == "indexing") + return DashboardActionResult.Failure( + $"refusing to reindex '{scope.Name}': scope is currently indexing. Wait for it to finish first."); + + var solution = ResolveSolutionForScope(ctx, scope.Name); + if (solution is null) + return DashboardActionResult.Failure($"no solution resolved for scope '{scope.Name}'"); + + return await RunWatchdoggedAsync(async () => + { + var rc = await RunSourceGraphCommandAsync(new[] { "index", solution }, token).ConfigureAwait(false); + ctx.Freshness?.RequestImmediateRebuild(); + return rc == 0 + ? DashboardActionResult.Success($"reindexed scope '{scope.Name}'") + : DashboardActionResult.Failure($"reindex exited with code {rc}"); + }, ctx, token).ConfigureAwait(false); + } + + private static async Task WireClientAsync(DashboardActionContext ctx, CancellationToken token) + { + if (ctx.Selection.FocusedSection != DashboardSection.Clients) + return DashboardActionResult.Failure("wire requires the Clients section selected"); + var clients = OrderedClients(ctx.Snapshot); + if (clients.Count == 0) return DashboardActionResult.Failure("no client to wire"); + if (ctx.Selection.RowIndex < 0 || ctx.Selection.RowIndex >= clients.Count) + return DashboardActionResult.Failure("selection out of range"); + var client = clients[ctx.Selection.RowIndex]; + if (client.ContainsSourcegraphEntry) + return DashboardActionResult.Success($"{client.Slug} already wired (no change)"); + if (!ClientIdExtensions.TryParseSlug(client.Slug, out var clientId)) + return DashboardActionResult.Failure($"unknown client slug '{client.Slug}'"); + + return await RunWatchdoggedAsync(() => + { + var writer = MakeWriter(clientId); + var existing = ReadFileBytesIfExists(client.Path); + var solutions = ctx.Snapshot.Environment.SolutionFiles; + var solutionPath = solutions.Count > 0 ? solutions[0] : null; + var writerCtx = new WriterContext( + Root: ctx.Root, + TargetPath: client.Path, + UseUserScope: client.Scope == "user", + InstallMode: InstallMode.Global, + SolutionPath: solutionPath, + ServerProjectPath: null, + NoEmbeddings: false, + NoHistory: !ctx.Snapshot.Environment.GitOnPath, + Force: false, + ExistingContent: existing); + var plan = writer.Plan(writerCtx); + try + { + writer.Apply(plan); + } + catch (Exception ex) + { + return Task.FromResult(DashboardActionResult.Failure($"wire failed: {ex.Message}")); + } + ctx.Freshness?.RequestImmediateRebuild(); + var msg = plan.Action switch + { + WriterAction.Insert => $"wired {client.Slug}", + WriterAction.ReplaceOurs => $"replaced {client.Slug}", + WriterAction.NoOpAlreadyMatches => $"{client.Slug} already matches", + WriterAction.SkipExistingDiffers => $"{client.Slug} differs — use `i` to run init --force", + WriterAction.SkipHasComments => $"{client.Slug} has JS comments; paste manually", + _ => $"{client.Slug} skipped: {plan.Description}", + }; + var ok = plan.Action is WriterAction.Insert or WriterAction.ReplaceOurs or WriterAction.NoOpAlreadyMatches; + return Task.FromResult(ok ? DashboardActionResult.Success(msg) : DashboardActionResult.Failure(msg)); + }, ctx, token).ConfigureAwait(false); + } + + private static async Task EmbeddingsPullAsync(DashboardActionContext ctx, CancellationToken token) + { + return await RunWatchdoggedAsync(async () => + { + var mgr = BuildEmbeddingsManager(ctx); + try + { + var status = await mgr.PullAsync(ctx.Snapshot.Embeddings.ModelId, token).ConfigureAwait(false); + ctx.Freshness?.RequestImmediateRebuild(); + return DashboardActionResult.Success($"pulled {status.ModelId} ({status.Files.Count} files)"); + } + catch (Exception ex) + { + return DashboardActionResult.Failure($"pull failed: {ex.Message}"); + } + }, ctx, token).ConfigureAwait(false); + } + + private static async Task EmbeddingsVerifyAsync(DashboardActionContext ctx, CancellationToken token) + { + // Refuse to verify when there are no cached files to verify against. VerifyAsync + // against an absent cache returns an empty status struct that the per-file mismatch + // check below reads as "0 bad files" and would report success — misleading to a user + // who pressed `v` to check the cache they don't have. Send them to `[p]` instead. + if (!ctx.Snapshot.Embeddings.CachePresent) + return DashboardActionResult.Failure( + "no embedding cache to verify — press [p] to pull the active model first"); + + return await RunWatchdoggedAsync(async () => + { + var mgr = BuildEmbeddingsManager(ctx); + try + { + var status = await mgr.VerifyAsync(ctx.Snapshot.Embeddings.ModelId, token).ConfigureAwait(false); + var bad = status.Files.Count(f => f.Match == false); + ctx.Freshness?.RequestImmediateRebuild(); + return bad == 0 + ? DashboardActionResult.Success($"verified {status.ModelId}") + : DashboardActionResult.Failure($"verify mismatch on {bad} file(s)"); + } + catch (Exception ex) + { + return DashboardActionResult.Failure($"verify failed: {ex.Message}"); + } + }, ctx, token).ConfigureAwait(false); + } + + // ──────────────────────────────────────────────────────────────────────────────── + // Guided actions (suspend → subprocess → resume) + // ──────────────────────────────────────────────────────────────────────────────── + + private static Task InitGuidedAsync(DashboardActionContext ctx, CancellationToken token) + => RunGuidedAsync(ctx, new[] { "init", "--root", ctx.Root }, "init", token); + + private static Task DemoGuidedAsync(DashboardActionContext ctx, CancellationToken token) + { + var args = new List { "demo", "--root", ctx.Root }; + if (ctx.Selection.FocusedSection == DashboardSection.Scopes + && ctx.Selection.RowIndex >= 0 && ctx.Selection.RowIndex < ctx.Snapshot.Scopes.Count) + { + args.Add("--scope"); + args.Add(ctx.Snapshot.Scopes[ctx.Selection.RowIndex].Name); + } + return RunGuidedAsync(ctx, args.ToArray(), "demo", token); + } + + private static Task OpenLogInPagerAsync(DashboardActionContext ctx, CancellationToken token) + { + var pager = Environment.GetEnvironmentVariable("PAGER"); + if (string.IsNullOrEmpty(pager)) pager = "less -R"; + var (file, baseArgs) = SplitCommandLine(pager); + var args = new List(baseArgs) { ctx.Snapshot.UsageLogPath }; + return RunGuidedSubprocessAsync(ctx, file, args.ToArray(), "pager", token); + } + + private static Task OpenConfigInEditorAsync(DashboardActionContext ctx, CancellationToken token) + { + var editor = Environment.GetEnvironmentVariable("EDITOR"); + if (string.IsNullOrEmpty(editor)) editor = "vi"; + var (file, baseArgs) = SplitCommandLine(editor); + var cfg = Path.Join(ctx.Root, ".sourcegraph.json"); + var args = new List(baseArgs) { cfg }; + return RunGuidedSubprocessAsync(ctx, file, args.ToArray(), "editor", token); + } + + /// + /// Split a shell-style command-line string into its executable and initial argument list. + /// Honours common conventions: + /// + /// Tokens separated by ASCII whitespace. + /// Double-quoted segments preserve embedded whitespace (e.g. "My App"). + /// Single-quoted segments work the same way (handy for paths with spaces on Unix). + /// + /// Used to parse $PAGER / $EDITOR values like "less -R", + /// "code --wait", or "vim -p", where the env var carries args alongside the + /// executable name. The previous implementation passed the entire string to + /// as the file name, which fails to start when there are any + /// arguments embedded in the variable. + /// + internal static (string File, IReadOnlyList Args) SplitCommandLine(string commandLine) + { + if (string.IsNullOrWhiteSpace(commandLine)) + return ("", Array.Empty()); + + var tokens = new List(); + var current = new System.Text.StringBuilder(); + char? quote = null; + foreach (var c in commandLine) + { + if (quote.HasValue) + { + if (c == quote.Value) { quote = null; } + else { current.Append(c); } + continue; + } + if (c == '"' || c == '\'') { quote = c; continue; } + if (char.IsWhiteSpace(c)) + { + if (current.Length > 0) { tokens.Add(current.ToString()); current.Clear(); } + continue; + } + current.Append(c); + } + if (current.Length > 0) tokens.Add(current.ToString()); + + if (tokens.Count == 0) return ("", Array.Empty()); + var file = tokens[0]; + var args = tokens.Count > 1 ? tokens.GetRange(1, tokens.Count - 1) : new List(); + return (file, args); + } + + /// + /// Suspend Spectre's Live (caller is responsible for doing that), spawn the subprocess with + /// inherited stdio, await exit, and return. The caller is the dashboard main loop which + /// drops out of the AnsiConsole.Live block around this call. + /// + private static async Task RunGuidedAsync(DashboardActionContext ctx, string[] sgArgs, string label, CancellationToken token) + { + var (file, args) = ResolveSourceGraphLaunch(sgArgs); + return await RunGuidedSubprocessAsync(ctx, file, args, label, token).ConfigureAwait(false); + } + + private static async Task RunGuidedSubprocessAsync(DashboardActionContext ctx, string file, string[] args, string label, CancellationToken token) + { + try + { + // Pin the subprocess CWD to the dashboard's --root so editors, pagers, and any + // implicit-CWD-aware tool operate against the same repo the snapshot describes — + // not the dashboard process's launch directory, which may be elsewhere. + var psi = new ProcessStartInfo(file) + { + UseShellExecute = false, + RedirectStandardOutput = false, + RedirectStandardError = false, + RedirectStandardInput = false, + WorkingDirectory = ctx.Root, + }; + foreach (var a in args) psi.ArgumentList.Add(a); + using var p = Process.Start(psi); + if (p is null) return DashboardActionResult.Failure($"{label} subprocess failed to start"); + await p.WaitForExitAsync(token).ConfigureAwait(false); + ctx.Freshness?.RequestImmediateRebuild(); + return p.ExitCode == 0 + ? DashboardActionResult.Success($"{label} completed") + : DashboardActionResult.Failure($"{label} exited with code {p.ExitCode}"); + } + catch (Exception ex) + { + return DashboardActionResult.Failure($"{label} failed: {ex.Message}"); + } + } + + // ──────────────────────────────────────────────────────────────────────────────── + // Helpers + // ──────────────────────────────────────────────────────────────────────────────── + + /// + /// Run an action under the policy's watchdog. On timeout the inner task is cancelled and a + /// failure result is surfaced; on inner success the result is returned verbatim. Failures + /// from inside the inner task pass through unchanged. + /// + public static async Task RunWatchdoggedAsync(Func> body, + DashboardActionContext ctx, CancellationToken outer) + { + var policy = ctx.Policy; + // The body delegate is parameterless by design — every in-place action it wraps reaches + // outside our process (writer file IO, SQLite, EmbeddingsManager subprocess) and there's + // no reliable cancellation handle inside those code paths. The watchdog race below + // therefore only times out the WAIT; the inner task itself is left to drain on its own + // after we report the timeout. Don't allocate a linked CTS here just to throw it away. + try + { + var inner = body(); + var done = await Task.WhenAny(inner, Task.Delay(policy.InPlaceWatchdog, outer)).ConfigureAwait(false); + if (done == inner) return await inner.ConfigureAwait(false); + // Watchdog tripped — the dashboard reports the timeout and the next snapshot tick + // redraws whatever state the still-running body eventually produces. + return DashboardActionResult.Failure($"action exceeded {policy.InPlaceWatchdog.TotalSeconds:F0}s watchdog"); + } + catch (OperationCanceledException) + { + return DashboardActionResult.Failure("action cancelled"); + } + } + + private static IClientConfigWriter MakeWriter(ClientId id) => id switch + { + ClientId.ClaudeCode => new ClaudeCodeWriter(), + ClientId.Copilot => new CopilotWriter(), + ClientId.Cursor => new CursorWriter(), + ClientId.Continue => new ContinueWriter(), + ClientId.ClaudeDesktop => new ClaudeDesktopWriter(), + _ => throw new ArgumentOutOfRangeException(nameof(id), id, "no writer for this ClientId"), + }; + + private static byte[]? ReadFileBytesIfExists(string path) + { + try { return File.Exists(path) ? File.ReadAllBytes(path) : null; } + catch (IOException) { return null; } + catch (UnauthorizedAccessException) { return null; } + } + + private static EmbeddingsManager BuildEmbeddingsManager(DashboardActionContext ctx) + { + var modelInfo = new EmbeddingModelInfo( + ctx.Snapshot.Embeddings.ModelId, + DefaultEmbeddingModel.Dimension); + var store = new ModelStore(NullLogger.Instance); + return new EmbeddingsManager(store, modelInfo, NullLogger.Instance); + } + + /// + /// Snapshot clients in display order (project-scope first then user-scope), matching the + /// rendering order so the selection cursor maps to the right row. + /// + private static IReadOnlyList OrderedClients(DashboardSnapshot snapshot) => + snapshot.Clients.OrderBy(c => c.Scope == "project" ? 0 : 1).ToArray(); + + /// + /// Resolve the solution path for a named scope from .sourcegraph.json; falls back to + /// the first detected solution under --root when the scope name is the synthesised + /// default. Returns null when no solution can be determined. + /// + internal static string? ResolveSolutionForScope(DashboardActionContext ctx, string scopeName) + { + try + { + var config = ScopeConfigLoader.Load(ctx.Root); + foreach (var s in config.Scopes) + { + if (string.Equals(s.Name, scopeName, StringComparison.Ordinal) + && s.ProjectSet is ScopeProjectSet.Solutions solutions + && solutions.Items.Count > 0) + { + var first = solutions.Items[0]; + return Path.IsPathRooted(first) ? first : Path.Join(s.Root, first); + } + } + } + catch (ScopeConfigException) { /* fall through to env detection */ } + catch (IOException) { /* fall through to env detection */ } + + // Fall back to the first detected solution under for the synthesised default scope. + var detected = ctx.Snapshot.Environment.SolutionFiles; + return detected.Count > 0 ? detected[0] : null; + } + + /// + /// Locate the right way to relaunch sourcegraph-mcp. Uses the same strategy as + /// InitCli.PrewarmAsync's pre-warm path: + /// + /// + /// Assembly.GetEntryAssembly()?.Location — reliable in every run mode + /// (dev dotnet <dll>, global tool, dotnet publish apphost). Empty only + /// under single-file deployment. + /// Environment.ProcessPath — the executable that started the process. In dev + /// mode this is the dotnet host (not a sourcegraph binary), so we only accept it when its + /// base name is exactly sourcegraph-mcp (the apphost form). + /// dotnet sourcegraph-mcp — global-tool fallback when neither hint resolved. + /// + /// + /// The previous implementation read Environment.ProcessPath first and assumed + /// anything not ending in .dll was a sourcegraph apphost — which meant + /// dotnet <dll> runs (where ProcessPath IS the dotnet host) constructed + /// dotnet index <sln> and failed loudly. + /// + internal static (string File, string[] Args) ResolveSourceGraphLaunch(string[] sgArgs) + { + var entryDll = System.Reflection.Assembly.GetEntryAssembly()?.Location; + if (!string.IsNullOrEmpty(entryDll) && entryDll.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)) + { + var args = new List { entryDll }; + args.AddRange(sgArgs); + return ("dotnet", args.ToArray()); + } + var processPath = Environment.ProcessPath; + if (!string.IsNullOrEmpty(processPath)) + { + var baseName = Path.GetFileNameWithoutExtension(processPath); + if (string.Equals(baseName, "sourcegraph-mcp", StringComparison.OrdinalIgnoreCase)) + { + return (processPath, sgArgs); + } + } + return ("sourcegraph-mcp", sgArgs); + } + + /// + /// Re-invoke sourcegraph-mcp with the given arguments, inheriting stdio so the + /// invocation is visible to the user when the dashboard isn't in Live mode. For in-place + /// actions we capture nothing (the caller already cleared the live region); the user sees + /// indexer output stream past, which is the same shape as init's pre-warm. + /// + internal static async Task RunSourceGraphCommandAsync(string[] sgArgs, CancellationToken token) + { + var (file, args) = ResolveSourceGraphLaunch(sgArgs); + var psi = new ProcessStartInfo(file) + { + UseShellExecute = false, + RedirectStandardOutput = false, + RedirectStandardError = false, + RedirectStandardInput = false, + }; + foreach (var a in args) psi.ArgumentList.Add(a); + using var p = Process.Start(psi); + if (p is null) return -1; + await p.WaitForExitAsync(token).ConfigureAwait(false); + return p.ExitCode; + } + + /// + /// Remove the sourcegraph entry from a JSON-based MCP config file. Used by the unwire + /// action. Honours the same comment-aware refusal that the writer's Plan/Apply path does — + /// we leave a config with JS comments alone and tell the user to edit manually. + /// + internal static DashboardActionResult UnwireFromConfigFile(string path) + { + if (!File.Exists(path)) return DashboardActionResult.Failure($"config not found: {path}"); + byte[] bytes; + try { bytes = File.ReadAllBytes(path); } + catch (IOException ex) { return DashboardActionResult.Failure($"read failed: {ex.Message}"); } + catch (UnauthorizedAccessException ex) { return DashboardActionResult.Failure($"read denied: {ex.Message}"); } + + // Comment-aware refusal: same chokepoint the writer uses. + var text = System.Text.Encoding.UTF8.GetString(bytes); + if (CommentDetector.HasJsonComments(text)) + return DashboardActionResult.Failure("config has JS comments — edit `e` to remove manually"); + + JsonNode? root; + try { root = JsonNode.Parse(bytes); } + catch (System.Text.Json.JsonException ex) { return DashboardActionResult.Failure($"malformed config: {ex.Message}"); } + if (root is not JsonObject topObj) return DashboardActionResult.Failure("config root is not an object"); + + // Try the canonical key `mcpServers` (Claude Code / Cursor / Claude Desktop) AND + // `servers` (Copilot). The dashboard doesn't know which writer authored the file at + // unwire time; trying both is honest given the unified "remove the sourcegraph entry" + // contract. + var removed = TryRemoveSourcegraphEntry(topObj, "mcpServers") + || TryRemoveSourcegraphEntry(topObj, "servers"); + if (!removed) + return DashboardActionResult.Failure("no sourcegraph entry to remove"); + + try + { + var json = topObj.ToJsonString(WriterJson.Indented2) + "\n"; + File.WriteAllText(path, json, new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + return DashboardActionResult.Success($"unwired sourcegraph from {Path.GetFileName(path)}"); + } + catch (IOException ex) { return DashboardActionResult.Failure($"write failed: {ex.Message}"); } + catch (UnauthorizedAccessException ex) { return DashboardActionResult.Failure($"write denied: {ex.Message}"); } + } + + private static bool TryRemoveSourcegraphEntry(JsonObject root, string topKey) + { + if (root[topKey] is not JsonObject map) return false; + if (!map.ContainsKey("sourcegraph")) return false; + map.Remove("sourcegraph"); + return true; + } +} diff --git a/src/DevBitsLab.Mcp.SourceGraph.Server/Dashboard/DashboardCli.cs b/src/DevBitsLab.Mcp.SourceGraph.Server/Dashboard/DashboardCli.cs new file mode 100644 index 00000000..c5a279df --- /dev/null +++ b/src/DevBitsLab.Mcp.SourceGraph.Server/Dashboard/DashboardCli.cs @@ -0,0 +1,692 @@ +using System.Reflection; +using DevBitsLab.Mcp.SourceGraph.Server.Cli; +using DevBitsLab.Mcp.SourceGraph.Server.Cli.Snapshot; +using DevBitsLab.Mcp.SourceGraph.Server.Tools; +using Spectre.Console; +using Spectre.Console.Rendering; + +namespace DevBitsLab.Mcp.SourceGraph.Server.Dashboard; + +/// +/// Entry point for the sourcegraph-mcp dashboard subcommand. Composes +/// + + +/// + into a live operator console. +/// +/// +/// The main loop runs three concurrent activities: (1) snapshot rebuilds from +/// , (2) keystroke polling from , +/// (3) Spectre's re-rendering on snapshot change. They communicate +/// via a single -of-actions on the UI thread +/// so the renderer is the only writer to the terminal. +/// +/// +/// +/// View model: the dashboard has a landing view (summary block +/// + numeric menu) and five detail views (Scopes, Clients, Embeddings, Recent activity, +/// Environment). The user navigates from home into a detail view via Enter (on the highlighted +/// menu item) or number keys 1–5, and returns to home with Esc or 'h'. +/// +/// +internal static class DashboardCli +{ + /// + /// Run the dashboard loop. Returns exit code 0 on quit / Ctrl+C, exit code 2 when the + /// terminal is below the minimum dimensions, exit code 1 on unexpected exception (after + /// restoring the cursor). + /// + public static async Task RunAsync(CommandLine cli, CancellationToken token = default) + { + var root = cli.ResolvedRepoRoot(); + + // Apply --no-leaf early so glyph rendering reflects the user's preference for the + // whole session (matches the same chokepoint Program.cs uses for the serve path). + if (cli.NoLeaf || string.Equals(Environment.GetEnvironmentVariable(LeafFormatter.EnvVarName), "1", StringComparison.Ordinal)) + { + LeafFormatter.Suppressed = true; + } + + // Minimum dimensions probe per spec scenario "Tiny terminal refuses to render". + int width, height; + try + { + width = Console.WindowWidth; + height = Console.WindowHeight; + } + catch (IOException) + { + // No tty — operator probably wanted `status` but invoked `dashboard` explicitly. + // The bare-command dispatch should have caught this earlier; defensive belt here. + await Console.Error.WriteLineAsync("dashboard requires a tty; use `sourcegraph-mcp status` for redirected stdout").ConfigureAwait(false); + return 2; + } + if (width < DashboardLayout.MinimumWidth || height < DashboardLayout.MinimumHeight) + { + await Console.Error.WriteLineAsync( + $"terminal too small (need ≥{DashboardLayout.MinimumWidth}×{DashboardLayout.MinimumHeight})").ConfigureAwait(false); + return 2; + } + + // Honour --no-color by configuring Spectre's console; the env-var NO_COLOR is honoured + // by Spectre natively. + var console = AnsiConsole.Console; + if (cli.NoColor) + { + console = AnsiConsole.Create(new AnsiConsoleSettings + { + ColorSystem = ColorSystemSupport.NoColors, + Ansi = AnsiSupport.No, + }); + } + + var options = new SnapshotOptions( + ActivityBytes: cli.ActivityBytes ?? 524288, + RecentActivityCap: 50, + ModelId: cli.Model); + using var freshness = new FreshnessSource(root, options); + + var loop = new LoopState(console, freshness, root, GetVersion()); + freshness.SnapshotChanged += loop.OnSnapshotReady; + freshness.Start(); + + // Wait briefly for the first snapshot so the initial draw isn't a blank skeleton; if + // BuildAsync takes longer than 2 s we proceed with no snapshot and the placeholder + // panels render. + for (var i = 0; i < 20 && loop.Snapshot is null; i++) + { + await Task.Delay(100, token).ConfigureAwait(false); + } + + try + { + await loop.RunAsync(token).ConfigureAwait(false); + return loop.ExitCode; + } + catch (OperationCanceledException) { return 0; } + catch (Exception ex) + { + // Restore cursor before surfacing the error. + try { Console.CursorVisible = true; } catch { } + await Console.Error.WriteLineAsync($"dashboard error: {ex.Message}").ConfigureAwait(false); + return 1; + } + } + + /// + /// Cleaned version string for the header bar. Reads the assembly's + /// (SourceLink stamps it) and falls back + /// to . + /// + private static string GetVersion() + { + var asm = typeof(DashboardCli).Assembly; + var info = asm.GetCustomAttribute()?.InformationalVersion; + if (!string.IsNullOrEmpty(info)) + { + var plus = info.IndexOf('+'); + return plus > 0 ? info[..plus] : info; + } + return asm.GetName().Version?.ToString(3) ?? "0.0.0"; + } + + /// + /// Toast state: text + when it was set + severity. The renderer fades it out as wall-clock + /// time advances; an empty hides the toast entirely. + /// + internal readonly record struct ToastState(string Text, DateTimeOffset SetAt, ToastSeverity Severity); + + /// + /// Encapsulates the per-frame state of the main loop: current snapshot, view, selection, + /// help-overlay flag, status message, exit code. Keeping it as an instance class (vs. a + /// pile of locals in ) lets the snapshot-changed event handler and + /// the key-handler share state without a tangle of captured locals. + /// + private sealed class LoopState + { + private readonly IAnsiConsole _console; + private readonly FreshnessSource _freshness; + private readonly DashboardRenderOptions _renderOptions; + private readonly Lock _gate = new(); + private DashboardSnapshot? _snapshot; + private DashboardView _currentView = DashboardView.Home; + private int _homeMenuIndex; + private int _selectedRow; + private bool _helpOpen; + private ToastState _toast = new("", DateTimeOffset.MinValue, ToastSeverity.Info); + private bool _dirty = true; + + public LoopState(IAnsiConsole console, FreshnessSource freshness, string root, string version) + { + _console = console; + _freshness = freshness; + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile, + Environment.SpecialFolderOption.DoNotVerify); + _renderOptions = new DashboardRenderOptions(Root: root, Home: home, Version: version); + } + + public int ExitCode { get; private set; } + public DashboardSnapshot? Snapshot { get { lock (_gate) return _snapshot; } } + + public void OnSnapshotReady(DashboardSnapshot snapshot) + { + lock (_gate) + { + _snapshot = snapshot; + _dirty = true; + } + } + + public async Task RunAsync(CancellationToken token) + { + // The Live block owns the terminal's alternate region while it's active. Guided + // actions (`init`, `demo`, `$PAGER`, `$EDITOR`) inherit stdio and write directly to + // the same terminal — running them inside the Live callback interleaves their + // output with Spectre's cursor-positioning escapes and leaves the terminal in a + // broken state. + // + // The outer loop here handles that explicitly: each Live session runs until the + // user quits, a guided action is picked, or the token cancels. On a guided action + // we exit the Live callback, run the subprocess against a "clean" terminal, then + // re-enter Live for the next session. + while (!token.IsCancellationRequested) + { + var layout = DashboardLayout.Build(Console.WindowWidth, Console.WindowHeight); + DashboardAction? pendingGuided = null; + var quit = false; + + await _console.Live(layout).StartAsync(async ctx => + { + // Tight loop: poll for keys with a short timeout, redraw on every snapshot + // change or selection change, exit on Quit or a guided action. + while (!token.IsCancellationRequested) + { + if (Console.KeyAvailable) + { + var key = Console.ReadKey(intercept: true); + if (!DashboardKeyMap.TryResolve(key, out var action)) + continue; + if (action == DashboardAction.Quit) + { + ExitCode = 0; + quit = true; + return; + } + // PrimaryAction (Enter) is view-dependent: on detail views it resolves + // to a section-specific concrete action that may or may not need to + // suspend the Live region (e.g. UnwireClient shows a ConfirmModal, + // ReindexScope shells out with inherited stdio). Pre-resolve here so + // the HandleNavigation suspend-Live check sees the CONCRETE action. + // If we left it as the generic PrimaryAction, it would always be + // classified as stay-in-Live and the inner ConfirmModal / subprocess + // would fight the Live region → Spectre concurrency error. Home's + // PrimaryAction stays generic — it's a view-transition handled + // inside DispatchActionAsync. + action = ResolvePrimaryIfDetailView(action); + var stayInLive = HandleNavigation(action); + if (!stayInLive) + { + // Suspend-Live action: leave Live so the prompt / subprocess / + // editor has the terminal to itself. The outer loop runs it and + // re-enters Live afterwards. + pendingGuided = action; + return; + } + await DispatchActionAsync(action, token).ConfigureAwait(false); + MarkDirty(); + } + if (IsDirty()) + { + Redraw(layout); + ctx.Refresh(); + } + await Task.Delay(50, token).ConfigureAwait(false); + } + }).ConfigureAwait(false); + + if (quit || token.IsCancellationRequested) break; + + if (pendingGuided.HasValue) + { + // Live is now stopped; the subprocess sees a normal terminal. After it + // returns we mark the snapshot dirty so the next Live iteration redraws + // immediately rather than showing the stale frame. + await DispatchActionAsync(pendingGuided.Value, token).ConfigureAwait(false); + MarkDirty(); + } + } + } + + /// + /// When is and the + /// current view is a detail view (anything other than Home), resolve it to the concrete + /// section action it would trigger — so the downstream suspend-Live check can see whether + /// that concrete action needs the terminal exclusively. Home stays generic: its primary + /// action is "open the highlighted menu item", which is a view-transition handled inside + /// . + /// + /// + /// Without this pre-resolution, Enter on the Clients view (which can resolve to + /// via the toggle) would reach the dispatcher + /// still classified as PrimaryAction, bypass the suspend-Live branch in + /// , and pop the inside the Live + /// region — Spectre then throws "Trying to run one or more interactive functions + /// concurrently." The same trap applied to via + /// Enter on the Scopes view. + /// + /// + private DashboardAction ResolvePrimaryIfDetailView(DashboardAction action) + { + if (action != DashboardAction.PrimaryAction) return action; + DashboardSnapshot? snap; + DashboardView view; + int selectedRow; + lock (_gate) + { + snap = _snapshot; + view = _currentView; + selectedRow = _selectedRow; + } + if (snap is null || view == DashboardView.Home) return action; + var resolved = DashboardPrimaryAction.ResolveForView(view, selectedRow, snap); + // None means "this view has no primary action" (Environment / RecentActivity). Leave + // the action as PrimaryAction so the dispatcher surfaces the documented hint toast. + return resolved == DashboardAction.None ? action : resolved; + } + + /// + /// Resolve navigation-only actions (up/down/help) without going through the + /// dispatcher. Returns false for actions in the suspend-Live set so the caller + /// knows to drop out of the Live region before running them. + /// + private bool HandleNavigation(DashboardAction action) + { + switch (action) + { + case DashboardAction.MoveUp: + MoveSelection(-1); + return true; + case DashboardAction.MoveDown: + MoveSelection(+1); + return true; + case DashboardAction.ToggleHelp: + lock (_gate) _helpOpen = !_helpOpen; + MarkDirty(); + return true; + default: + return !DashboardLiveSuspend.RequiresSuspend(action); + } + } + + private async Task DispatchActionAsync(DashboardAction action, CancellationToken token) + { + DashboardSnapshot? snap; + DashboardView currentView; + int homeMenuIndex; + int selectedRow; + lock (_gate) + { + snap = _snapshot; + currentView = _currentView; + homeMenuIndex = _homeMenuIndex; + selectedRow = _selectedRow; + } + if (snap is null) return; + + // ────────────────────────────────────────────────────────────────────── + // View transitions (no work for the action dispatcher to do) + // ────────────────────────────────────────────────────────────────────── + var target = TargetViewFor(action); + if (target.HasValue) + { + SwitchView(target.Value); + return; + } + + // PrimaryAction (Enter) is view-dependent: + // - Home → open the highlighted menu item's view + // - Detail → dispatch the section's primary action + if (action == DashboardAction.PrimaryAction) + { + if (currentView == DashboardView.Home) + { + var entries = DashboardRenderer.HomeMenuEntries; + if (homeMenuIndex >= 0 && homeMenuIndex < entries.Length) + { + SwitchView(entries[homeMenuIndex].View); + } + return; + } + action = DashboardPrimaryAction.ResolveForView(currentView, selectedRow, snap); + if (action == DashboardAction.None) + { + // Surface a discoverability hint instead of hanging silently. + lock (_gate) + { + _toast = new ToastState( + "no primary action for this view", + DateTimeOffset.UtcNow, ToastSeverity.Info); + _dirty = true; + } + return; + } + } + + // Section-specific action keys only resolve in their owning view; gate silently. + if (!IsActionAllowedInView(action, currentView)) + { + return; + } + + var section = currentView.ToSection() ?? DashboardSection.Scopes; + var sel = new DashboardSelection(section, selectedRow); + + var actionCtx = new DashboardActionContext( + Snapshot: snap, + Selection: sel, + Console: _console, + Freshness: _freshness, + Root: _renderOptions.Root, + Policy: DashboardActionPolicy.Default); + var result = await DashboardActions.RunAsync(action, actionCtx, token).ConfigureAwait(false); + if (result.Quit) ExitCode = 0; + lock (_gate) + { + _toast = string.IsNullOrEmpty(result.Message) + ? new ToastState("", DateTimeOffset.MinValue, ToastSeverity.Info) + : new ToastState(result.Message, DateTimeOffset.UtcNow, result.Severity); + } + } + + private static bool IsActionAllowedInView(DashboardAction action, DashboardView view) => + DashboardViewGating.IsAllowed(action, view); + + /// + /// Map a view-transition action (, + /// , …) to the view it lands on. Returns null + /// for actions that aren't view transitions. + /// + private static DashboardView? TargetViewFor(DashboardAction action) => action switch + { + DashboardAction.GoHome => DashboardView.Home, + DashboardAction.OpenScopes => DashboardView.Scopes, + DashboardAction.OpenClients => DashboardView.Clients, + DashboardAction.OpenEmbeddings => DashboardView.Embeddings, + DashboardAction.OpenRecentActivity => DashboardView.RecentActivity, + DashboardAction.OpenEnvironment => DashboardView.Environment, + _ => null, + }; + + private void SwitchView(DashboardView view) + { + lock (_gate) + { + _currentView = view; + _selectedRow = 0; + _helpOpen = false; + _dirty = true; + } + } + + private void MoveSelection(int delta) + { + lock (_gate) + { + if (_snapshot is null) return; + var count = CountRowsIn(_currentView, _snapshot); + if (count <= 0) return; + if (_currentView == DashboardView.Home) + { + _homeMenuIndex = ((_homeMenuIndex + delta) % count + count) % count; + } + else + { + _selectedRow = ((_selectedRow + delta) % count + count) % count; + } + _dirty = true; + } + } + + /// + /// How many selectable rows live in the given view? Home has 5 (one per menu item); + /// detail views report their data-row count (or 1 for Embeddings / 0 for Environment, + /// which is read-only). + /// + private static int CountRowsIn(DashboardView view, DashboardSnapshot snapshot) => view switch + { + DashboardView.Home => DashboardRenderer.HomeMenuEntries.Length, + DashboardView.Scopes => snapshot.Scopes.Count, + DashboardView.Clients => snapshot.Clients.Count, + DashboardView.Embeddings => 1, + DashboardView.RecentActivity => Math.Min(snapshot.RecentActivity.Count, 24), + DashboardView.Environment => 0, + _ => 0, + }; + + private void MarkDirty() { lock (_gate) _dirty = true; } + private bool IsDirty() + { + lock (_gate) + { + // Force a redraw while a toast is visible so its fade-out animation actually plays + // (the renderer reads the toast's age relative to now to fade it out; without + // this, no input + no snapshot change = no redraw = stuck-looking toast). + if (_toast.Text.Length > 0) + { + var age = DateTimeOffset.UtcNow - _toast.SetAt; + if (age < TimeSpan.FromSeconds(5)) _dirty = true; + else _toast = new ToastState("", DateTimeOffset.MinValue, ToastSeverity.Info); + } + var d = _dirty; + _dirty = false; + return d; + } + } + + private void Redraw(Layout layout) + { + DashboardSnapshot? snap; + DashboardView view; + int homeMenuIndex; + int selectedRow; + bool helpOpen; + ToastState toast; + lock (_gate) + { + snap = _snapshot; + view = _currentView; + homeMenuIndex = _homeMenuIndex; + selectedRow = _selectedRow; + helpOpen = _helpOpen; + toast = _toast; + } + + if (snap is null) + { + layout[DashboardLayout.HeaderRegion].Update(new Panel(new Markup("[grey]Loading snapshot…[/]")) { Border = BoxBorder.None }); + return; + } + + layout[DashboardLayout.HeaderRegion].Update(DashboardRenderer.BuildHeader(snap, _renderOptions, view)); + + IRenderable body = view switch + { + DashboardView.Home => DashboardRenderer.BuildHome(snap, _renderOptions, homeMenuIndex), + DashboardView.Scopes => DashboardRenderer.BuildScopesDetail(snap, _renderOptions, selectedRow), + DashboardView.Clients => DashboardRenderer.BuildClientsDetail(snap, _renderOptions, selectedRow), + DashboardView.Embeddings => DashboardRenderer.BuildEmbeddingsDetail(snap, _renderOptions), + DashboardView.RecentActivity => DashboardRenderer.BuildRecentActivityDetail(snap, _renderOptions, selectedRow), + DashboardView.Environment => DashboardRenderer.BuildEnvironmentDetail(snap, _renderOptions), + _ => DashboardRenderer.BuildHome(snap, _renderOptions, homeMenuIndex), + }; + layout[DashboardLayout.BodyRegion].Update(body); + + var footer = helpOpen + ? RenderHelpFooter() + : DashboardRenderer.BuildFooter(toast, view); + layout[DashboardLayout.FooterRegion].Update(footer); + } + + private static IRenderable RenderHelpFooter() + { + return new Panel(new Markup($"[grey]{Markup.Escape(DashboardKeyMap.HelpText)}[/]")) + { + Border = BoxBorder.Rounded, + Header = new PanelHeader("[bold]Key reference (press `?` again to close)[/]"), + Padding = new Padding(1, 0, 1, 0), + }; + } + } +} + +/// +/// Gates which dashboard actions are allowed in which views. Lifted out of +/// 's private LoopState so unit tests can pin the table without +/// standing up the full loop. +/// +/// +/// The rule: section-specific action keys (r, R, w, u, p, +/// v) only fire their action in the matching detail view; firing them from another view +/// is dropped silently. View-agnostic actions (quit, force refresh, guided actions, view +/// transitions) are always allowed. +/// +/// +internal static class DashboardViewGating +{ + /// + /// True iff is allowed to dispatch in . + /// Returns false for section-specific actions targeted at the wrong view, true otherwise. + /// + public static bool IsAllowed(DashboardAction action, DashboardView view) => action switch + { + // View-agnostic actions — always allowed. + DashboardAction.Quit or DashboardAction.ForceRefresh or + DashboardAction.ToggleHelp or DashboardAction.None => true, + DashboardAction.InitGuided or DashboardAction.DemoGuided or + DashboardAction.OpenLogInPager or DashboardAction.OpenConfigInEditor => true, + // View transitions — always allowed (the dispatcher handles them inline). + DashboardAction.GoHome or DashboardAction.OpenScopes or + DashboardAction.OpenClients or DashboardAction.OpenEmbeddings or + DashboardAction.OpenRecentActivity or DashboardAction.OpenEnvironment => true, + + // Scopes-only + DashboardAction.ReindexScope or DashboardAction.RebuildScope or + DashboardAction.AddScope or DashboardAction.RemoveScope => view == DashboardView.Scopes, + // Clients-only + DashboardAction.WireClient or DashboardAction.UnwireClient => view == DashboardView.Clients, + // Embeddings-only + DashboardAction.EmbeddingsPull or DashboardAction.EmbeddingsVerify => view == DashboardView.Embeddings, + + _ => true, + }; +} + +/// +/// Routes the Enter (PrimaryAction) key inside a detail view to a concrete action based +/// on which view is active. Lifted out of 's private LoopState so unit +/// tests can pin the per-view dispatch (including the Clients wire/unwire toggle) without +/// standing up the full loop. +/// +internal static class DashboardPrimaryAction +{ + /// + /// Map Enter (PrimaryAction) to the section-specific concrete action for a detail + /// view. Home is handled directly by the loop (open the highlighted menu item) and never + /// reaches this method. + /// + public static DashboardAction ResolveForView(DashboardView view, int selectedRow, DashboardSnapshot snap) => + view switch + { + DashboardView.Scopes => DashboardAction.ReindexScope, + DashboardView.Clients => ResolveClientToggle(selectedRow, snap), + DashboardView.Embeddings => DashboardAction.EmbeddingsPull, + DashboardView.RecentActivity => DashboardAction.None, + DashboardView.Environment => DashboardAction.None, + _ => DashboardAction.None, + }; + + /// + /// Legacy entry point preserved for unit tests that drove the previous section-based + /// resolver. Re-implemented in terms of so the two paths can't + /// drift. + /// + public static DashboardAction Resolve(DashboardSelection sel, DashboardSnapshot snap) + { + var view = sel.FocusedSection switch + { + DashboardSection.Scopes => DashboardView.Scopes, + DashboardSection.Clients => DashboardView.Clients, + DashboardSection.Embeddings => DashboardView.Embeddings, + DashboardSection.RecentActivity => DashboardView.RecentActivity, + DashboardSection.Environment => DashboardView.Environment, + _ => DashboardView.Home, + }; + return ResolveForView(view, sel.RowIndex, snap); + } + + private static DashboardAction ResolveClientToggle(int selectedRow, DashboardSnapshot snap) + { + // Mirror DashboardActions.OrderedClients ordering so the same row index resolves. + var clients = snap.Clients.OrderBy(c => c.Scope == "project" ? 0 : 1).ToArray(); + if (selectedRow < 0 || selectedRow >= clients.Length) return DashboardAction.WireClient; + return clients[selectedRow].ContainsSourcegraphEntry + ? DashboardAction.UnwireClient + : DashboardAction.WireClient; + } +} + +/// +/// Classifies dashboard actions by whether running them inside Spectre's Live region is +/// safe. Lifted out of 's private LoopState so the main loop and unit +/// tests share a single source of truth for the "must suspend Live before running" decision. +/// +/// +/// Spectre throws InvalidOperationException: "Trying to run one or more interactive functions +/// concurrently" if a ConfirmationPrompt / TextPrompt / Status / +/// Progress is started while a Live block is active. Subprocesses that inherit +/// stdio also collide — their output interleaves with Live's cursor codes and corrupts the +/// terminal state. The fix is to drop the Live block, run the action against a clean terminal, +/// then re-enter Live. This predicate names that set explicitly so the loop's pre-resolution +/// step (see ResolvePrimaryIfDetailView) can route Enter-resolved actions through +/// the same suspend gate as their key-bound siblings. +/// +/// +internal static class DashboardLiveSuspend +{ + /// + /// True iff must be run with Spectre's Live region suspended. + /// + /// + /// Membership audit: + /// + /// / + /// / / + /// — subprocess with inherited stdio. + /// — Spectre TextPrompt form. + /// / + /// / + /// (Spectre ConfirmationPrompt). + /// / + /// — also shell out to sourcegraph-mcp index; needs the terminal for the + /// indexer's own progress lines. + /// + /// + /// + /// + /// Stay-in-place (NOT listed here): (writer file IO + /// only), / + /// (HTTP / file IO only, no terminal interaction). + /// + /// + public static bool RequiresSuspend(DashboardAction action) => action switch + { + DashboardAction.InitGuided or + DashboardAction.DemoGuided or + DashboardAction.OpenLogInPager or + DashboardAction.OpenConfigInEditor or + DashboardAction.AddScope or + DashboardAction.RemoveScope or + DashboardAction.UnwireClient or + DashboardAction.ReindexScope or + DashboardAction.RebuildScope => true, + _ => false, + }; +} diff --git a/src/DevBitsLab.Mcp.SourceGraph.Server/Dashboard/DashboardKeyMap.cs b/src/DevBitsLab.Mcp.SourceGraph.Server/Dashboard/DashboardKeyMap.cs new file mode 100644 index 00000000..562633b3 --- /dev/null +++ b/src/DevBitsLab.Mcp.SourceGraph.Server/Dashboard/DashboardKeyMap.cs @@ -0,0 +1,198 @@ +using DevBitsLab.Mcp.SourceGraph.Server.Tools; + +namespace DevBitsLab.Mcp.SourceGraph.Server.Dashboard; + +/// +/// Central key-binding table for the dashboard. One static lookup that every keystroke runs +/// through; aliases (j, k, hEsc) are +/// explicit table entries so unit tests can assert the documented surface exhaustively. +/// +/// +/// The home/detail-view rewrite changed the navigation model: Tab is no longer in service +/// (no section cycle). Esc / h return to home; 1..5 jump from the home +/// menu directly into a detail view. Section action keys (r, R, w, u, +/// p, v) only resolve their work in the matching detail view; the dispatcher +/// gates them silently in non-matching views. +/// +/// +/// +/// Unmapped keys return false with . The dispatcher +/// drops these silently — TUIs that beep on every unknown keystroke get annoying fast. +/// +/// +internal static class DashboardKeyMap +{ + /// + /// Resolve a key press to a dashboard action. Returns true iff the key is bound to + /// something other than . + /// + public static bool TryResolve(ConsoleKeyInfo key, out DashboardAction action) + { + action = Resolve(key); + return action != DashboardAction.None; + } + + private static DashboardAction Resolve(ConsoleKeyInfo key) + { + // Ctrl+C → Quit (same as `q`). Spectre's Live block surfaces Ctrl+C via the cancellation + // token; we honour it here as a defensive belt-and-suspenders so a Ctrl+C arriving + // through Console.ReadKey also exits cleanly. + if ((key.Modifiers & ConsoleModifiers.Control) == ConsoleModifiers.Control) + { + return key.Key switch + { + ConsoleKey.C => DashboardAction.Quit, + _ => DashboardAction.None, + }; + } + + // Arrow keys + Enter + Esc. + switch (key.Key) + { + case ConsoleKey.UpArrow: return DashboardAction.MoveUp; + case ConsoleKey.DownArrow: return DashboardAction.MoveDown; + case ConsoleKey.Enter: return DashboardAction.PrimaryAction; + case ConsoleKey.Escape: return DashboardAction.GoHome; + case ConsoleKey.Spacebar: return DashboardAction.None; // reserved + // Tab keys are no longer bound — the section cycle is gone, replaced by the + // home-view menu + view-aware navigation. + } + + // Letter accelerators are case-sensitive: `r` reindex vs `R` rebuild differ. KeyChar is + // the resolved character (with shift applied); we dispatch on it directly. Unknown + // letters fall through to None. + return key.KeyChar switch + { + // Read-only + 'q' or 'Q' => DashboardAction.Quit, + '?' => DashboardAction.ToggleHelp, + 's' => DashboardAction.ForceRefresh, + 'j' => DashboardAction.MoveDown, + 'k' => DashboardAction.MoveUp, + // 'h' is the documented vim-style alias for Esc → home. + 'h' => DashboardAction.GoHome, + + // Number keys: direct jumps to a detail view. Only meaningful from home but harmless + // from any view — the dispatcher treats them as view transitions regardless. + '1' => DashboardAction.OpenScopes, + '2' => DashboardAction.OpenClients, + '3' => DashboardAction.OpenEmbeddings, + '4' => DashboardAction.OpenRecentActivity, + '5' => DashboardAction.OpenEnvironment, + + // In-place (no gate). The dispatcher gates each to its matching view at run time. + 'r' => DashboardAction.ReindexScope, + 'w' => DashboardAction.WireClient, + 'p' => DashboardAction.EmbeddingsPull, + 'v' => DashboardAction.EmbeddingsVerify, + + // In-place (gate) + 'R' => DashboardAction.RebuildScope, + 'u' => DashboardAction.UnwireClient, + 'D' => DashboardAction.RemoveScope, + + // Inline form (additive — no confirm modal; the form is the deliberate action) + 'N' => DashboardAction.AddScope, + + // Guided + 'i' => DashboardAction.InitGuided, + 'd' => DashboardAction.DemoGuided, + 'l' => DashboardAction.OpenLogInPager, + 'e' => DashboardAction.OpenConfigInEditor, + + _ => DashboardAction.None, + }; + } + + /// + /// Per-view footer hint. The home view shows its own menu-style hint; each detail view shows + /// only the keys that actually do something in its scope. + /// + /// + /// Returns Spectre markup with key glyphs in and labels in + /// . Honours : + /// in the no-leaf path unicode glyphs (, ↑↓) are replaced with bracketed + /// ASCII tokens ([Enter], [Up/Dn]). + /// + /// + public static string For(DashboardView view) + { + if (LeafFormatter.Suppressed) + { + // CRITICAL: the returned string is wrapped in `new Markup(...)` at the callsite + // (see DashboardRenderer.BuildFooter). Spectre.Markup parses `[...]` as style tags + // by default, so naked `[Enter]` / `[Up/Dn]` would throw at render time (Spectre + // tries to parse them as styles, fails, and surfaces an InvalidOperationException). + // The escape convention is to double the brackets — `[[Enter]]` renders as the + // literal text `[Enter]`. We construct each hint string with doubled brackets up + // front rather than calling Markup.Escape so the result composes cleanly with the + // outer wrapping. + return view switch + { + DashboardView.Home => + "[[Up/Dn]] select [[Enter]] open 1-5 jump [[?]] help [[q]] quit", + DashboardView.Scopes => + "[[Up/Dn]] row [[Enter]]/[[r]] reindex [[R]] rebuild [[N]] new [[D]] delete [[d]] demo [[Esc]] home [[q]] quit", + DashboardView.Clients => + "[[Up/Dn]] row [[Enter]] toggle wire [[w]] wire [[u]] unwire [[Esc]] home [[q]] quit", + DashboardView.Embeddings => + "[[Enter]]/[[p]] pull [[v]] verify [[Esc]] home [[q]] quit", + DashboardView.RecentActivity => + "[[Up/Dn]] row [[l]] open full log [[Esc]] home [[q]] quit", + DashboardView.Environment => + "[[Esc]] home [[q]] quit", + _ => "[[Esc]] home [[q]] quit", + }; + } + + var b = DashboardTheme.Brand; + var m = DashboardTheme.Muted; + return view switch + { + DashboardView.Home => + $"[{b}]↑↓[/] [{m}]select[/] [{b}]⏎[/] [{m}]open[/] [{b}]1-5[/] [{m}]jump[/] [{b}]?[/] [{m}]help[/] [{b}]q[/] [{m}]quit[/]", + DashboardView.Scopes => + $"[{b}]↑↓[/] [{m}]row[/] [{b}]⏎/r[/] [{m}]reindex[/] [{b}]R[/] [{m}]rebuild[/] [{b}]N[/] [{m}]new[/] [{b}]D[/] [{m}]delete[/] [{b}]d[/] [{m}]demo[/] [{b}]Esc[/] [{m}]home[/] [{b}]q[/] [{m}]quit[/]", + DashboardView.Clients => + $"[{b}]↑↓[/] [{m}]row[/] [{b}]⏎[/] [{m}]toggle wire[/] [{b}]w[/] [{m}]wire[/] [{b}]u[/] [{m}]unwire[/] [{b}]Esc[/] [{m}]home[/] [{b}]q[/] [{m}]quit[/]", + DashboardView.Embeddings => + $"[{b}]⏎/p[/] [{m}]pull[/] [{b}]v[/] [{m}]verify[/] [{b}]Esc[/] [{m}]home[/] [{b}]q[/] [{m}]quit[/]", + DashboardView.RecentActivity => + $"[{b}]↑↓[/] [{m}]row[/] [{b}]l[/] [{m}]open full log[/] [{b}]Esc[/] [{m}]home[/] [{b}]q[/] [{m}]quit[/]", + DashboardView.Environment => + $"[{b}]Esc[/] [{m}]home[/] [{b}]q[/] [{m}]quit[/]", + _ => $"[{b}]Esc[/] [{m}]home[/] [{b}]q[/] [{m}]quit[/]", + }; + } + + /// + /// Multi-line key reference shown by the inline help overlay (`?`). Same vocabulary as the + /// dashboard subcommand's man-page section; centralised here so docs and runtime can't drift. + /// + public const string HelpText = """ + Navigation: + ↑/↓ or j/k Move row within current view + 1-5 Jump from Home into a detail view + Enter Primary action (open menu item / section primary) + Esc or h Return to Home + q / Ctrl+C Quit (exit 0) + ? Toggle this help + s Force refresh snapshot now + + Section actions (only effective in the matching detail view): + r Reindex selected scope (reconcile_drift) + R Rebuild selected scope (CONFIRM) + N New scope (inline form: name + solution path + isolated) + D Delete selected scope (CONFIRM) + w Wire missing client + u Unwire selected client (CONFIRM) + p Embeddings pull (active model) + v Embeddings verify + + Guided actions (suspend + subprocess): + i Run `sourcegraph-mcp init` + d Run `sourcegraph-mcp demo` for selected scope + l Open usage.jsonl in $PAGER + e Open .sourcegraph.json in $EDITOR + """; +} diff --git a/src/DevBitsLab.Mcp.SourceGraph.Server/Dashboard/DashboardLayout.cs b/src/DevBitsLab.Mcp.SourceGraph.Server/Dashboard/DashboardLayout.cs new file mode 100644 index 00000000..c2d29523 --- /dev/null +++ b/src/DevBitsLab.Mcp.SourceGraph.Server/Dashboard/DashboardLayout.cs @@ -0,0 +1,59 @@ +using Spectre.Console; + +namespace DevBitsLab.Mcp.SourceGraph.Server.Dashboard; + +/// +/// Composes the home + detail-view Spectre for the dashboard. The minimum +/// terminal dimensions are 80 × 24; smaller terminals get a single panel announcing the +/// limit per the spec's "Tiny terminal refuses to render" scenario. +/// +/// +/// The layout is intentionally simple: a fixed header region, a flexible body region (the active +/// view's renderer fills this), and a fixed footer region. The body is repurposed per view — +/// home view fills it with the summary block + menu; detail views fill it with a full-width +/// table + selected drawer. +/// +/// +internal static class DashboardLayout +{ + public const int MinimumWidth = 80; + public const int MinimumHeight = 24; + + /// Region names — keep in sync with the layout assembled in . + public const string HeaderRegion = "header"; + /// The single body region. Every view (home + each detail) writes here. + public const string BodyRegion = "body"; + public const string FooterRegion = "footer"; + + /// + /// Build the layout. Returns either the three-region composition (header / body / footer) or + /// a one-cell "too small" panel when the dimensions don't meet the minimum. + /// + public static Layout Build(int width, int height) + { + if (width < MinimumWidth || height < MinimumHeight) + { + return new Layout("root").Update(BuildTooSmall(width, height)); + } + + // Vertical composition: + // header (3 rows: brand line + separator + spacer) + // body (flex — the active view writes here) + // footer (5 rows: separator + hint row + spacer + toast) + var root = new Layout("root").SplitRows( + new Layout(HeaderRegion).Size(3), + new Layout(BodyRegion), + new Layout(FooterRegion).Size(5)); + return root; + } + + private static Panel BuildTooSmall(int width, int height) + { + var msg = $"terminal too small (need ≥{MinimumWidth}×{MinimumHeight}; got {width}×{height})"; + return new Panel(new Markup($"[red]{Markup.Escape(msg)}[/]")) + { + Border = BoxBorder.Rounded, + Padding = new Padding(2, 1, 2, 1), + }; + } +} diff --git a/src/DevBitsLab.Mcp.SourceGraph.Server/Dashboard/DashboardRenderer.cs b/src/DevBitsLab.Mcp.SourceGraph.Server/Dashboard/DashboardRenderer.cs new file mode 100644 index 00000000..79d81c13 --- /dev/null +++ b/src/DevBitsLab.Mcp.SourceGraph.Server/Dashboard/DashboardRenderer.cs @@ -0,0 +1,816 @@ +using DevBitsLab.Mcp.SourceGraph.Server.Cli.Rendering; +using DevBitsLab.Mcp.SourceGraph.Server.Cli.Snapshot; +using DevBitsLab.Mcp.SourceGraph.Server.Tools; +using Spectre.Console; +using Spectre.Console.Rendering; + +namespace DevBitsLab.Mcp.SourceGraph.Server.Dashboard; + +/// +/// Display options for the dashboard renderer. Colour suppression is NOT a field here — +/// --no-color is handled one layer up in DashboardCli.RunAsync by constructing an +/// with ColorSystemSupport.NoColors, which +/// makes every Markup tag in this renderer render without ANSI. There's no second knob needed +/// inside the renderer itself. +/// +/// Repository root; used for path display in the header bar and per-row paths. +/// User home directory; used for ~/ substitution. +/// Server version string shown in the header. +internal sealed record DashboardRenderOptions(string Root, string? Home, string Version); + +/// +/// Per-view builders for the dashboard. Built around the +/// palette + glyph vocabulary — borderless, dot-driven. The home +/// view shows a summary + numeric menu; each detail view shows a full table plus a per-row +/// Selected: drawer. +/// +/// +/// Each Build* method returns a self-contained so unit tests +/// can capture them via AnsiConsole.Record without standing up the full layout. The +/// selection cursor is threaded through as the optional selectedRow parameter so the +/// renderer stays pure (no hidden static state). +/// +/// +internal static class DashboardRenderer +{ + /// + /// The dashboard header: brand leaf + bold name + dimmed path · version. When + /// is a detail view, a breadcrumb (› Section) is appended + /// and a right-aligned [Esc / h] back to home hint is shown. + /// + public static IRenderable BuildHeader(DashboardSnapshot snapshot, DashboardRenderOptions options, + DashboardView view = DashboardView.Home) + { + var leaf = LeafFormatter.Suppressed ? "[x]" : DashboardTheme.BrandLeaf; + var leafText = Markup.Escape(leaf); + var version = Markup.Escape(options.Version); + // PathDisplay's repo-relative rule would render the root path against itself as ".", + // which tells the operator nothing about which repo they're looking at. The header + // wants the path itself, so we apply only the home-relative rule (~/...) and fall + // back to absolute by passing an empty base — this disables the repo-relative branch. + var rendered = Markup.Escape(PathDisplay.Render(options.Root, root: "", options.Home)); + var brand = DashboardTheme.Brand; + var muted = DashboardTheme.Muted; + var mutedDim = DashboardTheme.MutedDim; + + string leftCell; + string rightCell; + if (view == DashboardView.Home) + { + leftCell = $"{leafText} [bold {brand}]SourceGraph[/]"; + rightCell = $"[{muted}]{rendered} · v{version}[/]"; + } + else + { + var sectionName = Markup.Escape(view.DisplayName()); + leftCell = $"{leafText} [bold {brand}]SourceGraph[/] [{mutedDim}]›[/] [bold {brand}]{sectionName}[/]"; + // The back-to-home hint goes in the right cell on detail views; the path·version + // line is on home only (consistent with the mock). + rightCell = $"[{muted}]{Markup.Escape("[Esc / h] back to home")}[/]"; + } + + // Two-cell grid for left/right alignment on the header line. + var headerGrid = new Grid() + .AddColumn(new GridColumn().NoWrap()) + .AddColumn(new GridColumn().NoWrap().RightAligned()); + headerGrid.AddRow(new Markup(leftCell), new Markup(rightCell)); + + var separator = $"[{mutedDim}]{Markup.Escape(new string('─', 72))}[/]"; + + var rows = new Rows(headerGrid, new Markup(separator)); + return new Padder(rows).PadLeft(1).PadRight(1).PadTop(0).PadBottom(0); + } + + /// The two-row footer: per-view key hint + toast row. Toast colour follows severity. + public static IRenderable BuildFooter(DashboardCli.ToastState toast = default, + DashboardView view = DashboardView.Home) + { + var separator = $"[{DashboardTheme.MutedDim}]{Markup.Escape(new string('─', 72))}[/]"; + + // The footer hint is now view-aware — each view advertises only the keys that do + // something in its scope. + var hintMarkup = new Markup(DashboardKeyMap.For(view)); + + IRenderable toastRow = string.IsNullOrEmpty(toast.Text) + ? new Markup(" ") // keeps the row height stable + : new Markup(RenderToast(toast)); + + var rows = new Rows(new Markup(separator), hintMarkup, new Markup(""), toastRow); + return new Padder(rows).PadLeft(1).PadRight(1).PadTop(0).PadBottom(0); + } + + /// + /// Convenience overload that lets old callers pass a bare message; severity defaults to + /// . Used by tests and any caller that doesn't have a + /// handy. View defaults to . + /// + public static IRenderable BuildFooter(string statusMessage) + { + var toast = string.IsNullOrEmpty(statusMessage) + ? default + : new DashboardCli.ToastState(statusMessage, DateTimeOffset.UtcNow, ToastSeverity.Info); + return BuildFooter(toast, DashboardView.Home); + } + + /// Render the toast text with colour/fade based on age + severity. + private static string RenderToast(DashboardCli.ToastState toast) + { + var age = DateTimeOffset.UtcNow - toast.SetAt; + // First 1.5 s: full colour. Next 3.5 s: muted. Past 5 s: empty (the caller hides it). + string colour; + if (age < TimeSpan.FromMilliseconds(1500)) + { + colour = toast.Severity switch + { + ToastSeverity.Success => DashboardTheme.Ok, + ToastSeverity.Warn => DashboardTheme.Warn, + ToastSeverity.Fail => DashboardTheme.Fail, + _ => DashboardTheme.Muted, + }; + } + else + { + colour = DashboardTheme.Muted; + } + var prefix = toast.Severity switch + { + ToastSeverity.Success => LeafFormatter.Suppressed ? "[x]" : "✓", + ToastSeverity.Warn => LeafFormatter.Suppressed ? "[!]" : "⚠", + ToastSeverity.Fail => LeafFormatter.Suppressed ? "[X]" : "✗", + _ => LeafFormatter.Suppressed ? "[ ]" : "·", + }; + return $" [{colour}]{Markup.Escape(prefix)} {Markup.Escape(toast.Text)}[/]"; + } + + // ──────────────────────────────────────────────────────────────────────────────── + // Home view + // ──────────────────────────────────────────────────────────────────────────────── + + /// + /// The home / welcome view: at-a-glance summary block (one row per area) + numeric menu. + /// + /// Current dashboard snapshot. + /// Render options (path display etc.). + /// Index of the currently highlighted menu item (0..4). + public static IRenderable BuildHome(DashboardSnapshot snapshot, DashboardRenderOptions options, int menuIndex) + { + var rows = new List(); + rows.Add(new Markup("")); // breathing room under the title-bar separator + + // Summary block — five rows, one per area. + var summary = BuildHomeSummary(snapshot, options); + rows.Add(summary); + + AppendDivider(rows); + + // Menu — five entries (one per detail view). + rows.Add(BuildHomeMenu(menuIndex)); + + return new Padder(new Rows(rows)).PadLeft(2).PadRight(1); + } + + /// + /// Build the at-a-glance summary block. Five rows, one per snapshot area, each rendering a + /// status dot + label + compact summary text. + /// + private static IRenderable BuildHomeSummary(DashboardSnapshot snapshot, DashboardRenderOptions options) + { + var (envKind, envText) = ComputeEnvironmentSummary(snapshot, options); + var (scopesKind, scopesText) = ComputeScopesSummary(snapshot); + var (clientsKind, clientsText) = ComputeClientsSummary(snapshot); + var (embKind, embText) = ComputeEmbeddingsSummary(snapshot); + var (recentKind, recentText) = ComputeRecentSummary(snapshot); + + var grid = new Grid() + .AddColumn(new GridColumn().NoWrap().Width(3)) // dot + .AddColumn(new GridColumn().NoWrap().Width(24)) // label (bold) + .AddColumn(new GridColumn().NoWrap()); // summary text (muted) + + AddSummaryRow(grid, envKind, "Environment", envText); + AddSummaryRow(grid, scopesKind, "Scopes", scopesText); + AddSummaryRow(grid, clientsKind, "Clients", clientsText); + AddSummaryRow(grid, embKind, "Embeddings", embText); + AddSummaryRow(grid, recentKind, "Recent activity", recentText); + return grid; + } + + private static void AddSummaryRow(Grid grid, StatusKind kind, string label, string text) + { + grid.AddRow( + new Markup(DashboardTheme.Dot(kind)), + new Markup($"[bold]{Markup.Escape(label)}[/]"), + new Markup($"[{DashboardTheme.Muted}]{Markup.Escape(text)}[/]")); + } + + /// Build the numeric menu — 1..5 mapping to each detail view. + private static IRenderable BuildHomeMenu(int menuIndex) + { + var entries = HomeMenuEntries; + var grid = new Grid() + .AddColumn(new GridColumn().NoWrap().Width(2)) // selection bar + .AddColumn(new GridColumn().NoWrap().Width(4)) // number + .AddColumn(new GridColumn().NoWrap().Width(20)) // section name + .AddColumn(new GridColumn().NoWrap()); // description + + for (var i = 0; i < entries.Length; i++) + { + var e = entries[i]; + var selected = i == menuIndex; + var nameMarkup = selected + ? $"[bold {DashboardTheme.Brand}]{Markup.Escape(e.Name)}[/]" + : $"[{DashboardTheme.Muted}]{Markup.Escape(e.Name)}[/]"; + var numberMarkup = selected + ? $"[bold {DashboardTheme.Brand}]{Markup.Escape(e.Number)}[/]" + : $"[{DashboardTheme.Muted}]{Markup.Escape(e.Number)}[/]"; + var descMarkup = $"[{DashboardTheme.Muted}]{Markup.Escape(e.Description)}[/]"; + grid.AddRow( + SelectionMarker(selected), + new Markup(numberMarkup), + new Markup(nameMarkup), + new Markup(descMarkup)); + } + return grid; + } + + /// Static menu entries — number, view, name, description. + internal static readonly (string Number, DashboardView View, string Name, string Description)[] HomeMenuEntries = + { + ("1", DashboardView.Scopes, "Scopes", "Reindex, rebuild, inspect per scope"), + ("2", DashboardView.Clients, "Clients", "Wire / unwire MCP clients"), + ("3", DashboardView.Embeddings, "Embeddings", "Pull, verify the embedding cache"), + ("4", DashboardView.RecentActivity, "Recent activity", "Live log of tool calls and heals"), + ("5", DashboardView.Environment, "Environment", "Build / git / repo detail (read-only)"), + }; + + // ──────────────────────────────────────────────────────────────────────────────── + // Summary computations (home view) + // ──────────────────────────────────────────────────────────────────────────────── + + private static (StatusKind, string) ComputeEnvironmentSummary(DashboardSnapshot snapshot, DashboardRenderOptions options) + { + var env = snapshot.Environment; + var parts = new List(); + if (!string.IsNullOrEmpty(env.DotnetSdkVersion)) parts.Add(env.DotnetSdkVersion); + if (env.GitOnPath) parts.Add("git"); + if (env.SolutionFiles.Count > 0) + { + parts.Add(Path.GetFileName(env.SolutionFiles[0])); + } + var text = parts.Count == 0 ? "(no environment detected)" : string.Join(" · ", parts); + + StatusKind kind; + if (env.DotnetSdkVersion is null || env.SourceGraphConfigStatus == "malformed") + kind = StatusKind.Fail; + else if (!env.GitOnPath || env.SolutionFiles.Count == 0) + kind = StatusKind.Warn; + else + kind = StatusKind.Ok; + return (kind, text); + } + + private static (StatusKind, string) ComputeScopesSummary(DashboardSnapshot snapshot) + { + var scopes = snapshot.Scopes; + var total = scopes.Count; + if (total == 0) return (StatusKind.Off, "(none)"); + + // Single-pass histogram over the scope statuses. Cheaper than four LINQ Counts and + // keeps the dispatch close to the labels. + int ok = 0, partial = 0, indexing = 0, degraded = 0; + foreach (var s in scopes) + { + switch (s.Status) + { + case "ok": ok++; break; + case "partial": partial++; break; + case "indexing": indexing++; break; + case "degraded": degraded++; break; + } + } + + var bits = new List { $"{total} total" }; + if (ok > 0) bits.Add($"{ok} ok"); + if (partial > 0) bits.Add($"{partial} partial"); + if (indexing > 0) bits.Add($"{indexing} indexing"); + if (degraded > 0) bits.Add($"{degraded} degraded"); + + StatusKind kind; + if (degraded > 0) kind = StatusKind.Fail; + else if (partial > 0 || indexing > 0) kind = StatusKind.Warn; + else kind = StatusKind.Ok; + return (kind, string.Join(" · ", bits)); + } + + private static (StatusKind, string) ComputeClientsSummary(DashboardSnapshot snapshot) + { + var total = snapshot.Clients.Count; + var wired = snapshot.Clients.Count(c => c.ContainsSourcegraphEntry); + if (total == 0) return (StatusKind.Off, "0 / 0 wired"); + var kind = wired > 0 ? StatusKind.Ok : StatusKind.Off; + return (kind, $"{wired} / {total} wired"); + } + + private static (StatusKind, string) ComputeEmbeddingsSummary(DashboardSnapshot snapshot) + { + var emb = snapshot.Embeddings; + if (!emb.CachePresent) + { + return (StatusKind.Warn, $"{emb.ModelId} · (absent)"); + } + return (StatusKind.Ok, $"{emb.ModelId} · {FormatBytes(emb.TotalBytes)}"); + } + + private static (StatusKind, string) ComputeRecentSummary(DashboardSnapshot snapshot) + { + var activity = snapshot.RecentActivity; + if (activity.Count == 0) return (StatusKind.Off, "(no activity yet)"); + // Most recent entry leads the label. + var latest = activity.OrderByDescending(a => a.Ts).First(); + var rel = FormatRelativeTime(DateTimeOffset.UtcNow - latest.Ts); + var kind = latest.Ok ? StatusKind.Ok : StatusKind.Fail; + var name = latest.Detail ?? latest.Kind; + return (kind, $"{activity.Count} events · last: {name} · {rel}"); + } + + // ──────────────────────────────────────────────────────────────────────────────── + // Detail views + // ──────────────────────────────────────────────────────────────────────────────── + + /// The Environment detail view: read-only key/value table; no row selection. + public static IRenderable BuildEnvironmentDetail(DashboardSnapshot snapshot, DashboardRenderOptions options) + { + var env = snapshot.Environment; + var rows = new List { new Markup("") }; + + var grid = new Grid() + .AddColumn(new GridColumn().NoWrap().Width(3)) // dot + .AddColumn(new GridColumn().NoWrap().Width(22)) // key + .AddColumn(new GridColumn().NoWrap()); // value + + // .NET SDK + AddEnvDetailRow(grid, + env.DotnetSdkVersion is null ? StatusKind.Fail : StatusKind.Ok, + ".NET SDK", + env.DotnetSdkVersion ?? "(not detected)"); + AddEnvDetailRow(grid, + env.GitOnPath ? StatusKind.Ok : StatusKind.Warn, + "git on PATH", + env.GitOnPath ? "yes" : "no"); + AddEnvDetailRow(grid, + StatusKind.Ok, + "repo root", + PathDisplay.Render(env.RepoRootPath, options.Root, options.Home)); + var solutions = env.SolutionFiles.Count == 0 + ? "(none detected)" + : $"{string.Join(", ", env.SolutionFiles.Select(p => PathDisplay.Render(p, options.Root, options.Home)))} ({env.SolutionFiles.Count} detected)"; + AddEnvDetailRow(grid, + env.SolutionFiles.Count == 0 ? StatusKind.Warn : StatusKind.Ok, + "solutions", + solutions); + var (cfgKind, cfgValue) = env.SourceGraphConfigStatus switch + { + "valid" => (StatusKind.Ok, "valid"), + "missing" => (StatusKind.Ok, "missing (single-scope synth path)"), + "malformed" => (StatusKind.Fail, $"MALFORMED — {env.SourceGraphConfigError}"), + _ => (StatusKind.Warn, env.SourceGraphConfigStatus), + }; + AddEnvDetailRow(grid, cfgKind, ".sourcegraph.json", cfgValue); + + rows.Add(grid); + return new Padder(new Rows(rows)).PadLeft(2).PadRight(1); + } + + private static void AddEnvDetailRow(Grid grid, StatusKind kind, string key, string value) + { + grid.AddRow( + new Markup(DashboardTheme.Dot(kind)), + new Markup($"[bold]{Markup.Escape(key)}[/]"), + new Markup(Markup.Escape(value))); + } + + /// The Scopes detail view: full per-scope table + Selected drawer + failed-projects bullets. + public static IRenderable BuildScopesDetail(DashboardSnapshot snapshot, DashboardRenderOptions options, + int selectedRow = 0, int maxVisibleRows = 8) + { + var rows = new List { new Markup("") }; + if (snapshot.Scopes.Count == 0) + { + rows.Add(new Markup($" {DashboardTheme.Dot(StatusKind.Off)} [{DashboardTheme.Muted}](no scopes registered — run `sourcegraph-mcp serve` once to materialise)[/]")); + return new Padder(new Rows(rows)).PadLeft(2).PadRight(1); + } + + // Column widths chosen to fit the 80-col minimum. Sum: 2 + 14 + 10 + 11 + 11 + 12 + 8 = 68 + // cells of content + 3 of padding = 71 cells. Leaves room for the body region's own + // padding without triggering column collapse. + const int nameW = 14, statusW = 10, symW = 11, refsW = 11, ageW = 12, failedW = 8; + + // Header row. + var hcol = DashboardTheme.MutedDim; + rows.Add(new Markup(string.Concat( + Cell("", 2), + Cell("Name", nameW, hcol), + Cell("Status", statusW, hcol), + Cell("Symbols", symW, hcol, rightAligned: true), + Cell("Refs", refsW, hcol, rightAligned: true), + Cell("Last indexed", ageW, hcol), + Cell("Failed", failedW, hcol)))); + + var (start, end, moreAbove, moreBelow) = Viewport(snapshot.Scopes.Count, selectedRow, maxVisibleRows); + if (moreAbove > 0) rows.Add(new Markup($" [{DashboardTheme.MutedDim}]↑ {moreAbove} more[/]")); + for (var i = start; i < end; i++) + { + var s = snapshot.Scopes[i]; + var kind = MapScopeStatus(s.Status); + var ageLabel = s.LastIndexedAt.HasValue + ? FormatRelativeTime(DateTimeOffset.UtcNow - s.LastIndexedAt.Value) + : "(never)"; + var failed = s.FailedProjects.Count > 0 + ? $"{s.FailedProjects.Count} proj{(s.FailedProjects.Count == 1 ? "" : "s")}" + : "—"; + var selected = i == selectedRow; + var nameColor = selected ? $"bold {DashboardTheme.Brand}" : ""; + + var line = string.Concat( + DashboardTheme.SelectionDot(selected) + " ", + Cell(s.Name, nameW, nameColor), + Cell(s.Status, statusW, DashboardTheme.ColorFor(kind)), + Cell($"{s.SymbolCount:N0}", symW, rightAligned: true), + Cell($"{s.ReferenceCount:N0}", refsW, rightAligned: true), + Cell(ageLabel, ageW, DashboardTheme.Muted), + Cell(failed, failedW, DashboardTheme.Muted)); + rows.Add(new Markup(line)); + } + if (moreBelow > 0) rows.Add(new Markup($" [{DashboardTheme.MutedDim}]↓ {moreBelow} more[/]")); + + // Inline separator + Selected drawer. + if (selectedRow >= 0 && selectedRow < snapshot.Scopes.Count) + { + AppendDivider(rows); + rows.Add(BuildScopeDrawer(snapshot.Scopes[selectedRow])); + } + return new Padder(new Rows(rows)).PadLeft(2).PadRight(1); + } + + private static StatusKind MapScopeStatus(string status) => status switch + { + "ok" => StatusKind.Ok, + "partial" => StatusKind.Warn, + "degraded" => StatusKind.Fail, + "indexing" => StatusKind.Warn, + _ => StatusKind.Off, + }; + + private static IRenderable BuildScopeDrawer(ScopeRow scope) + { + var ageLabel = scope.LastIndexedAt.HasValue + ? FormatRelativeTime(DateTimeOffset.UtcNow - scope.LastIndexedAt.Value) + : "(never)"; + var statusText = scope.Status switch + { + "partial" when scope.FailedProjects.Count > 0 => + $"partial — {scope.FailedProjects.Count} project{(scope.FailedProjects.Count == 1 ? "" : "s")} failed", + _ => scope.Status, + }; + var fields = new List<(string Key, string Value)> + { + ("status", statusText), + ("symbols", scope.SymbolCount.ToString("N0")), + ("refs", scope.ReferenceCount.ToString("N0")), + ("last", ageLabel), + ("database", $".sourcegraph/scopes/{scope.Name}.db"), + }; + if (scope.Isolated) + { + fields.Add(("isolated", "yes")); + } + var hasFailedProjects = scope.FailedProjects.Count > 0; + return BuildSelectedDrawer( + title: $"Selected: {scope.Name}", + fields: fields, + bullets: hasFailedProjects ? scope.FailedProjects : null, + bulletsHeader: hasFailedProjects ? "Failed projects:" : null); + } + + /// The Clients detail view: full per-client table + Selected drawer. + public static IRenderable BuildClientsDetail(DashboardSnapshot snapshot, DashboardRenderOptions options, + int selectedRow = 0, int maxVisibleRows = 8) + { + var rows = new List { new Markup("") }; + if (snapshot.Clients.Count == 0) + { + rows.Add(new Markup($" {DashboardTheme.Dot(StatusKind.Off)} [{DashboardTheme.Muted}](no client configs detected)[/]")); + return new Padder(new Rows(rows)).PadLeft(2).PadRight(1); + } + + var ordered = snapshot.Clients.OrderBy(c => c.Scope == "project" ? 0 : 1).ToArray(); + // Column widths chosen so total + padding fits comfortably inside the dashboard's + // 80-col minimum width — 2 (selection) + 16 + 9 + 14 + 28 = 69 cells of content + 3 + // of left/right padding = 72. Path is truncated with an ellipsis if it exceeds 28 cells. + const int slugW = 16, scopeW = 9, stateW = 14, pathW = 28; + + var (start, end, moreAbove, moreBelow) = Viewport(ordered.Length, selectedRow, maxVisibleRows); + if (moreAbove > 0) + { + rows.Add(new Markup($" [{DashboardTheme.MutedDim}]↑ {moreAbove} more[/]")); + } + for (var i = start; i < end; i++) + { + var c = ordered[i]; + var (kind, stateLabel) = c switch + { + { ContainsSourcegraphEntry: true } => (StatusKind.Ok, "wired"), + { Exists: true } => (StatusKind.Off, "not wired"), + _ => (StatusKind.Unsupported, "not present"), + }; + var path = PathDisplay.Render(c.Path, options.Root, options.Home); + var selected = i == selectedRow; + var slugColor = selected ? $"bold {DashboardTheme.Brand}" : ""; + // Selection column: glyph already carries colour markup, just need a trailing space + // to make the cell two cells wide. + var line = string.Concat( + DashboardTheme.SelectionDot(selected) + " ", + Cell(c.Slug, slugW, slugColor), + Cell(c.Scope, scopeW, DashboardTheme.Muted), + Cell(stateLabel, stateW, DashboardTheme.ColorFor(kind)), + Cell(path, pathW, DashboardTheme.Muted)); + rows.Add(new Markup(line)); + } + if (moreBelow > 0) + { + rows.Add(new Markup($" [{DashboardTheme.MutedDim}]↓ {moreBelow} more[/]")); + } + + if (selectedRow >= 0 && selectedRow < ordered.Length) + { + AppendDivider(rows); + rows.Add(BuildClientDrawer(ordered[selectedRow], options)); + } + return new Padder(new Rows(rows)).PadLeft(2).PadRight(1); + } + + private static IRenderable BuildClientDrawer(ClientRow client, DashboardRenderOptions options) + { + string state = client switch + { + { ContainsSourcegraphEntry: true } => "wired — entry present in config", + { Exists: true } => "file exists but no sourcegraph entry — pressing ⏎ will wire it", + _ => "file does not exist — pressing ⏎ will create it", + }; + var target = PathDisplay.Render(client.Path, options.Root, options.Home); + var fields = new List<(string Key, string Value)> + { + ("scope", client.Scope), + ("target", target), + ("state", state), + }; + return BuildSelectedDrawer($"Selected: {client.Slug}", fields); + } + + /// The Embeddings detail view: model identity + four headline values + hint. + public static IRenderable BuildEmbeddingsDetail(DashboardSnapshot snapshot, DashboardRenderOptions options) + { + var emb = snapshot.Embeddings; + var rows = new List { new Markup("") }; + + // Embeddings shows a single conceptual row (one model) so there's no selection cue — + // the leading-indicator column is dropped entirely; the key column carries the label + // and the value column carries the value. + var grid = new Grid() + .AddColumn(new GridColumn().NoWrap().Width(14)) // key + .AddColumn(new GridColumn().NoWrap()); // value + + var pairs = new (string Key, string Value)[] + { + ("Model", emb.ModelId), + ("Cache", PathDisplay.Render(emb.CacheDir, options.Root, options.Home)), + ("Size", emb.CachePresent ? FormatBytes(emb.TotalBytes) : "(absent)"), + ("Verified", emb.Verified ? "yes" : "no"), + }; + foreach (var (key, value) in pairs) + { + grid.AddRow( + new Markup($"[bold]{Markup.Escape(key)}[/]"), + new Markup(Markup.Escape(value))); + } + rows.Add(grid); + + rows.Add(new Markup("")); + rows.Add(new Markup($"[{DashboardTheme.Muted}] Tip: run `sourcegraph-mcp embeddings status` for the per-file view.[/]")); + + return new Padder(new Rows(rows)).PadLeft(2).PadRight(1); + } + + /// The Recent activity detail view: scrollable log + Selected drawer. + public static IRenderable BuildRecentActivityDetail(DashboardSnapshot snapshot, DashboardRenderOptions options, + int selectedRow = 0, int maxRows = 24) + { + var rows = new List { new Markup("") }; + if (snapshot.RecentActivity.Count == 0) + { + rows.Add(new Markup($" {DashboardTheme.Dot(StatusKind.Off)} [{DashboardTheme.Muted}](no recorded activity)[/]")); + return new Padder(new Rows(rows)).PadLeft(2).PadRight(1); + } + + var rowsList = snapshot.RecentActivity + .OrderByDescending(a => a.Ts) + .Take(maxRows) + .ToArray(); + + // Column widths chosen to fit the 80-col minimum. Sum: 2 + 10 + 24 + 14 + 3 + 12 = 65 + // cells of content + 3 padding = 68. Detail column truncated to 24 with ellipsis if + // longer. The status-dot column is 2 cells (DashboardTheme.Dot returns "● " etc.). + const int timeW = 10, nameW = 24, scopeW = 14, msW = 12; + + var (start, end, moreAbove, moreBelow) = Viewport(rowsList.Length, selectedRow, maxRows); + if (moreAbove > 0) rows.Add(new Markup($" [{DashboardTheme.MutedDim}]↑ {moreAbove} more[/]")); + for (var i = start; i < end; i++) + { + var a = rowsList[i]; + var kind = a.Ok ? StatusKind.Ok : StatusKind.Fail; + var time = a.Ts.ToLocalTime().ToString("HH:mm:ss"); + var name = a.Detail ?? a.Kind; + var scope = a.Scope ?? "-"; + var msLabel = a.Ok ? FormatMillis(a.Ms) : $"failed: {FormatMillis(a.Ms)}"; + var selected = i == selectedRow; + var nameColor = selected ? $"bold {DashboardTheme.Brand}" : ""; + + var line = string.Concat( + DashboardTheme.SelectionDot(selected) + " ", + Cell(time, timeW, DashboardTheme.Muted), + Cell(name, nameW, nameColor), + Cell(scope, scopeW, DashboardTheme.Muted), + DashboardTheme.Dot(kind) + " ", + Cell(msLabel, msW, DashboardTheme.Muted)); + rows.Add(new Markup(line)); + } + if (moreBelow > 0) rows.Add(new Markup($" [{DashboardTheme.MutedDim}]↓ {moreBelow} more[/]")); + + if (selectedRow >= 0 && selectedRow < rowsList.Length) + { + var sel = rowsList[selectedRow]; + AppendDivider(rows); + var time = sel.Ts.ToLocalTime().ToString("HH:mm:ss"); + var name = sel.Detail ?? sel.Kind; + var statusText = sel.Ok ? "ok" : "failed"; + var fields = new List<(string Key, string Value)> + { + ("scope", sel.Scope ?? "-"), + ("status", statusText), + ("duration", FormatMillis(sel.Ms)), + ("detail", sel.Detail ?? "(none)"), + }; + rows.Add(BuildSelectedDrawer($"Selected: {name} @ {time}", fields)); + } + return new Padder(new Rows(rows)).PadLeft(2).PadRight(1); + } + + // ──────────────────────────────────────────────────────────────────────────────── + // Selected: drawer helper + // ──────────────────────────────────────────────────────────────────────────────── + + /// + /// Build the Selected: drawer that lives under each detail view's table. Renders the + /// title in bold, the key/value fields aligned, and an optional bullet list (used by the + /// Scopes view for failed-projects). + /// + private static IRenderable BuildSelectedDrawer(string title, IEnumerable<(string Key, string Value)> fields, + IEnumerable? bullets = null, string? bulletsHeader = null) + { + var rows = new List + { + new Markup($"[bold {DashboardTheme.Brand}]{Markup.Escape(title)}[/]"), + }; + var grid = new Grid() + .AddColumn(new GridColumn().NoWrap().Width(2)) // indent + .AddColumn(new GridColumn().NoWrap().Width(12)) // key + .AddColumn(new GridColumn().NoWrap()); // value + foreach (var (k, v) in fields) + { + grid.AddRow( + new Markup(" "), + new Markup($"[{DashboardTheme.MutedDim}]{Markup.Escape(k)}[/]"), + new Markup(Markup.Escape(v))); + } + rows.Add(grid); + + if (bullets is not null) + { + rows.Add(new Markup("")); + if (!string.IsNullOrEmpty(bulletsHeader)) + { + rows.Add(new Markup($"[{DashboardTheme.MutedDim}]{Markup.Escape(bulletsHeader)}[/]")); + } + foreach (var b in bullets) + { + rows.Add(new Markup($" [{DashboardTheme.Muted}]•[/] {Markup.Escape(b)}")); + } + } + return new Rows(rows); + } + + // ──────────────────────────────────────────────────────────────────────────────── + // Shared helpers + // ──────────────────────────────────────────────────────────────────────────────── + + /// + /// Append a blank line + horizontal separator + blank line to . Every + /// detail view uses this exact pattern to visually divide the data table from the + /// Selected: drawer, so factor it out to keep the layout consistent. + /// + private static void AppendDivider(List rows) + { + rows.Add(new Markup("")); + rows.Add(new Markup($"[{DashboardTheme.MutedDim}]{Markup.Escape(new string('─', 72))}[/]")); + rows.Add(new Markup("")); + } + + /// + /// Render the leading selection-indicator cell. Selected rows get a brand-coloured fisheye + /// (filled circle with built-in outline); non-selected rows get a muted hollow + /// . Replaces the prior bar-plus-status-dot pattern: under the new model, + /// the leading dot communicates "you are here" and the row's status meaning lives in the + /// colour of its status word. Honours . + /// + private static Markup SelectionMarker(bool selected) => new(DashboardTheme.SelectionDot(selected)); + + /// + /// Build a fixed-width cell as a Markup-ready string. Truncates with an ellipsis when + /// is wider than , pads with spaces + /// otherwise. The result is safe to concatenate with other cell strings into a single + /// line — sidestepping Spectre's column + /// negotiation entirely. The Grid approach mis-handled narrow body widths by squeezing + /// columns down to 1-char width and then "no-wrapping" each character onto its own line + /// (`p\nr\no\nj\ne\nc\nt`); pre-formatting bypasses that whole pathology. + /// + /// Raw text — exactly what the user sees, no markup. + /// Total cell width in display cells. + /// Optional Spectre colour tag (e.g. "#5fa07a" or "grey50 bold"); empty for default. + /// Pad to the LEFT of the content when true. + internal static string Cell(string plain, int width, string colorTag = "", bool rightAligned = false) + { + if (width <= 0) return string.Empty; + string visible; + if (plain.Length > width) + { + // Truncate with a single ellipsis. width == 1 collapses to just the ellipsis. + visible = width == 1 ? "…" : plain[..(width - 1)] + "…"; + } + else + { + visible = plain; + } + var padCount = Math.Max(0, width - visible.Length); + var pad = padCount == 0 ? string.Empty : new string(' ', padCount); + var escaped = Markup.Escape(visible); + var inner = string.IsNullOrEmpty(colorTag) ? escaped : $"[{colorTag}]{escaped}[/]"; + return rightAligned ? pad + inner : inner + pad; + } + + /// + /// Compute a viewport over that keeps + /// visible within rows. + /// Returns the [start, end) indices to render plus the "more above"/"more below" counts + /// the caller renders as hint rows. When the list fits, start=0 and end=totalRows. + /// + internal static (int Start, int End, int MoreAbove, int MoreBelow) Viewport( + int totalRows, int selectedRow, int maxVisible) + { + if (totalRows <= maxVisible) return (0, totalRows, 0, 0); + if (maxVisible <= 0) return (0, 0, 0, totalRows); + // Keep selected near the centre; clamp at list ends so the slice always has maxVisible rows. + var half = maxVisible / 2; + var start = Math.Max(0, Math.Min(selectedRow - half, totalRows - maxVisible)); + var end = start + maxVisible; + return (start, end, start, totalRows - end); + } + + /// Operator-friendly relative time like 2m ago / 3h ago; matches StatusRenderer. + internal static string FormatRelativeTime(TimeSpan delta) + { + if (delta.TotalSeconds < 60) return $"{(int)Math.Max(0, delta.TotalSeconds)}s ago"; + if (delta.TotalMinutes < 60) return $"{(int)delta.TotalMinutes}m ago"; + if (delta.TotalHours < 48) return $"{(int)delta.TotalHours}h ago"; + return $"{(int)delta.TotalDays}d ago"; + } + + /// Operator-friendly latency: 42 ms, 1.2 s. + private static string FormatMillis(int ms) + { + if (ms < 1000) return $"{ms} ms"; + return $"{ms / 1000.0:F1} s"; + } + + private static string FormatBytes(long bytes) + { + const double KiB = 1024; + const double MiB = KiB * 1024; + const double GiB = MiB * 1024; + return bytes switch + { + < (long)KiB => $"{bytes} B", + < (long)MiB => $"{bytes / KiB:F1} KiB", + < (long)GiB => $"{bytes / MiB:F1} MiB", + _ => $"{bytes / GiB:F2} GiB", + }; + } +} diff --git a/src/DevBitsLab.Mcp.SourceGraph.Server/Dashboard/DashboardSection.cs b/src/DevBitsLab.Mcp.SourceGraph.Server/Dashboard/DashboardSection.cs new file mode 100644 index 00000000..fdb33e3a --- /dev/null +++ b/src/DevBitsLab.Mcp.SourceGraph.Server/Dashboard/DashboardSection.cs @@ -0,0 +1,67 @@ +namespace DevBitsLab.Mcp.SourceGraph.Server.Dashboard; + +/// +/// The five sections the dashboard renders in order. Identifies the focused section for +/// navigation; row selection within a section is tracked separately in the dashboard state. +/// +internal enum DashboardSection +{ + Environment, + Scopes, + Clients, + Embeddings, + RecentActivity, +} + +/// +/// The dashboard's top-level view state. Distinct from : a view is +/// what the renderer is currently filling the body region with; a section identifies a per-area +/// surface in the snapshot. has no matching section — it's the +/// welcome/landing view with the menu + at-a-glance summary block. Every other view has a 1:1 +/// mapping to a . +/// +internal enum DashboardView +{ + /// Welcome / landing view: summary block + numeric menu. + Home, + /// Scopes detail: full per-scope table + selected-scope drawer. + Scopes, + /// Clients detail: full per-client table + selected-client drawer. + Clients, + /// Embeddings detail: model id / cache / size / verified flag. + Embeddings, + /// Recent activity detail: scrollable log of tool calls + heals. + RecentActivity, + /// Environment detail: SDK / git / repo root / solutions / config (read-only). + Environment, +} + +/// Helpers for mapping between and . +internal static class DashboardViewExtensions +{ + /// + /// Map a detail view to its corresponding . Returns null for + /// (which has no underlying section surface). + /// + public static DashboardSection? ToSection(this DashboardView view) => view switch + { + DashboardView.Scopes => DashboardSection.Scopes, + DashboardView.Clients => DashboardSection.Clients, + DashboardView.Embeddings => DashboardSection.Embeddings, + DashboardView.RecentActivity => DashboardSection.RecentActivity, + DashboardView.Environment => DashboardSection.Environment, + _ => null, + }; + + /// Human-readable section name as it appears in detail-view breadcrumbs. + public static string DisplayName(this DashboardView view) => view switch + { + DashboardView.Home => "Home", + DashboardView.Scopes => "Scopes", + DashboardView.Clients => "Clients", + DashboardView.Embeddings => "Embeddings", + DashboardView.RecentActivity => "Recent activity", + DashboardView.Environment => "Environment", + _ => view.ToString(), + }; +} diff --git a/src/DevBitsLab.Mcp.SourceGraph.Server/Dashboard/DashboardTheme.cs b/src/DevBitsLab.Mcp.SourceGraph.Server/Dashboard/DashboardTheme.cs new file mode 100644 index 00000000..7b3e9c54 --- /dev/null +++ b/src/DevBitsLab.Mcp.SourceGraph.Server/Dashboard/DashboardTheme.cs @@ -0,0 +1,213 @@ +using DevBitsLab.Mcp.SourceGraph.Server.Tools; + +namespace DevBitsLab.Mcp.SourceGraph.Server.Dashboard; + +/// +/// Status kinds the dashboard theme renders as colored dots. This is the single vocabulary used +/// across the dashboard, init, and status: the leaf 🌿 is reserved for the +/// brand mark (title bar / MCP tool responses); every row-status position uses the dot +/// vocabulary below. +/// +internal enum StatusKind +{ + /// Healthy / wired / present / ok. + Ok, + /// Soft warning — non-fatal, attention worthwhile. + Warn, + /// Hard failure — degraded / broken / blocked. + Fail, + /// Inactive — file exists but no sourcegraph entry, model cache absent, etc. + Off, + /// Unsupported — no writer / no handler for this combination. + Unsupported, +} + +/// +/// Centralised palette + glyph vocabulary for the dashboard. Constants only — no logic beyond a +/// single colorised helper that builds the dot Markup. Hex colours work in 256-colour terminals; +/// Spectre downgrades them to the nearest ANSI16 colour automatically when the terminal can't +/// render hex. +/// +/// +/// Two related vocabularies share this surface. The status vocabulary (●/◐/✗/○/−) +/// is shared by every surface — dashboard summary block, init, status — so an +/// operator who learns one mode immediately reads the others; +/// activates an ASCII fallback ([x] / [!] / [X] / [ ] / [-]) +/// so the no-leaf path produces the same token vocabulary everywhere. The selection +/// vocabulary ( selected, not selected; ASCII fallback [>] / +/// [ ]) is dashboard-only — it lives in the leading column of selectable rows in detail +/// views and the home menu, while the row's status meaning shifts to the colour of its status +/// word. The leaf 🌿 is reserved for the brand mark (title bars + MCP tool responses) +/// and never appears in a row-status position. +/// +/// +internal static class DashboardTheme +{ + // ─── Palette ──────────────────────────────────────────────────────────────────── + /// Brand colour — the sage-green the project uses everywhere. + public const string Brand = "#5fa07a"; + /// Brand dim — for unfocused-section headers and subordinate decoration. + public const string BrandDim = "#3d6c52"; + /// Healthy state colour. Same hex as ; named so callsites read. + public const string Ok = "#5fa07a"; + /// Warning colour — muted amber. + public const string Warn = "#e0a040"; + /// Failure colour — muted red. + public const string Fail = "#d4544b"; + /// Muted text — section bodies that aren't currently focused, footer hint. + public const string Muted = "grey50"; + /// Very-muted text — toast fade-to-disappear, second-line hint keys. + public const string MutedDim = "grey39"; + + // ─── Glyphs (Unicode, color-rendered separately) ──────────────────────────────── + /// Section-header leader. One glyph in precedes the title. + public const string SectionLeader = "◆"; + /// Healthy / on / present dot. + public const string DotOn = "●"; + /// Inactive / off / available-but-empty dot. + public const string DotOff = "○"; + /// Warning / partial / degraded dot (half-filled circle). + public const string DotWarn = "◐"; + /// Failure / broken dot (cross). Distinct shape from dots so colourblind users still see the difference. + public const string DotFail = "✗"; + /// Unsupported / N/A dot. + public const string DotUnsupported = "−"; + /// + /// Selected-row indicator — Unicode "FISHEYE" (U+25C9): filled circle with a built-in + /// outline ring. Visually pops against the muted so keyboard + /// navigation reads at a glance. + /// + public const string SelectedDot = "◉"; + /// Non-selected row indicator — plain hollow circle (U+25CB), in muted grey. + public const string UnselectedDot = "○"; + /// The leaf glyph reserved for the title bar only. Other surfaces switched to dots. + public const string BrandLeaf = "🌿"; + + /// + /// Render a status dot as Spectre Markup. Honours + /// — under --no-leaf the output is the same + /// ASCII bracket-token vocabulary emits, with no colour markup, so the + /// dashboard, init, and status agree on a single fallback token per state. + /// + public static string Dot(StatusKind kind) + { + if (LeafFormatter.Suppressed) + { + // Escape the brackets so Spectre's Markup parser doesn't read them as tag delimiters + // when this string is wrapped in `new Markup(...)`. The token vocabulary + // ([x] / [!] / [X] / [ ] / [-]) is the same shape DotPlain emits. + return kind switch + { + StatusKind.Ok => "[[x]]", + StatusKind.Warn => "[[!]]", + StatusKind.Fail => "[[X]]", + StatusKind.Off => "[[ ]]", + StatusKind.Unsupported => "[[-]]", + _ => "[[ ]]", + }; + } + return kind switch + { + StatusKind.Ok => $"[{Ok}]{DotOn}[/]", + StatusKind.Warn => $"[{Warn}]{DotWarn}[/]", + StatusKind.Fail => $"[{Fail}]{DotFail}[/]", + StatusKind.Off => $"[{Muted}]{DotOff}[/]", + StatusKind.Unsupported => $"[{Muted}]{DotUnsupported}[/]", + _ => $"[{Muted}]{DotOff}[/]", + }; + } + + /// + /// Render a status dot as a plain-text token for surfaces that write straight to a + /// (e.g. init, status). Returns the dot + /// glyph followed by a single space so the token occupies a stable three-cell column. Under + /// the bracketed ASCII fallback is returned instead — + /// same vocabulary as the Markup-bearing , but without Spectre + /// escape-doubling and without colour markup so the bytes are safe to pipe / capture / diff. + /// + public static string DotPlain(StatusKind kind) + { + if (LeafFormatter.Suppressed) + { + return kind switch + { + StatusKind.Ok => "[x] ", + StatusKind.Warn => "[!] ", + StatusKind.Fail => "[X] ", + StatusKind.Off => "[ ] ", + StatusKind.Unsupported => "[-] ", + _ => "[ ] ", + }; + } + return kind switch + { + StatusKind.Ok => $"{DotOn} ", + StatusKind.Warn => $"{DotWarn} ", + StatusKind.Fail => $"{DotFail} ", + StatusKind.Off => $"{DotOff} ", + StatusKind.Unsupported => $"{DotUnsupported} ", + _ => $"{DotOff} ", + }; + } + + /// + /// Render a section header like "◆ Environment" for plain-text writers. The leader + /// glyph is the same the dashboard's Spectre layer uses for its + /// detail-view section headers; here we emit just the glyph + space + name with no ANSI + /// colour codes so the bytes are pipe / test-capture safe. Under + /// the leader downgrades to a bracketed ASCII token + /// ("[*]") keeping the three-cell column width consistent with the dot tokens. + /// + public static string SectionHeaderPlain(string name) + { + var leader = LeafFormatter.Suppressed ? "[*]" : SectionLeader; + return $"{leader} {name}"; + } + + /// + /// Render the selection indicator at the start of a selectable row. Selected rows get a + /// brand-coloured fisheye (filled circle with built-in outline) so the eye locks + /// onto the focused row; non-selected rows get a quiet hollow in muted grey. + /// Honours with bracket-token fallbacks + /// ([>] selected, [ ] not). The bracket tokens are escape-doubled so + /// Spectre's Markup parser won't interpret them as tag delimiters when this string + /// is wrapped in new Markup(...). + /// + public static string SelectionDot(bool selected) + { + if (LeafFormatter.Suppressed) + { + return selected ? "[[>]]" : "[[ ]]"; + } + return selected + ? $"[{Brand}]{SelectedDot}[/]" + : $"[{Muted}]{UnselectedDot}[/]"; + } + + /// + /// Plain-text variant of with no colour markup, for surfaces + /// that write straight to a . Returns the glyph followed + /// by a single space (or the bracketed ASCII fallback under ) + /// so the token occupies a stable column. + /// + public static string SelectionDotPlain(bool selected) + { + if (LeafFormatter.Suppressed) + { + return selected ? "[>] " : "[ ] "; + } + return selected ? $"{SelectedDot} " : $"{UnselectedDot} "; + } + + /// + /// Look up the hex colour for a given . Used by detail-view row + /// renderers that colour the status word in place of a leading status-dot column. + /// + public static string ColorFor(StatusKind kind) => kind switch + { + StatusKind.Ok => Ok, + StatusKind.Warn => Warn, + StatusKind.Fail => Fail, + _ => Muted, + }; +} diff --git a/src/DevBitsLab.Mcp.SourceGraph.Server/Dashboard/FreshnessSource.cs b/src/DevBitsLab.Mcp.SourceGraph.Server/Dashboard/FreshnessSource.cs new file mode 100644 index 00000000..6c2d97c2 --- /dev/null +++ b/src/DevBitsLab.Mcp.SourceGraph.Server/Dashboard/FreshnessSource.cs @@ -0,0 +1,240 @@ +using DevBitsLab.Mcp.SourceGraph.Server.Cli.Snapshot; +using DevBitsLab.Mcp.SourceGraph.Storage; + +namespace DevBitsLab.Mcp.SourceGraph.Server.Dashboard; + +/// +/// Hybrid poll-plus-watcher refresh source for the dashboard. Rebuilds a +/// on a 1-second poll AND on debounced +/// events against the two JSONL logs +/// (usage.jsonl, heals.jsonl). Both triggers coalesce so at most one rebuild +/// fires per second under sustained activity. +/// +/// +/// Lifecycle: construct with , +/// subscribe to , call . Snapshot rebuilds run on +/// the thread-pool via ; subscribers receive callbacks on the +/// thread-pool thread that completed the rebuild. The dashboard's main loop marshals back to the +/// UI thread by enqueueing the latest snapshot for the next Spectre Live render tick. +/// +/// +/// +/// Disposal stops both the poll timer and both watchers; in-flight rebuilds complete naturally +/// (no cancellation token is threaded through because +/// is read-only and short-lived). +/// +/// +internal sealed class FreshnessSource : IDisposable +{ + /// Coalescing window: at most one rebuild per this much wall-clock time. + public static readonly TimeSpan MinRebuildInterval = TimeSpan.FromMilliseconds(1000); + + /// Debounce delay after a watcher event before the rebuild fires. + public static readonly TimeSpan WatcherDebounce = TimeSpan.FromMilliseconds(100); + + private readonly string _root; + private readonly SnapshotOptions _options; + private readonly ISnapshotSource _snapshotSource; + private readonly Lock _gate = new(); + + private Timer? _pollTimer; + private Timer? _debounceTimer; + private FileSystemWatcher? _usageWatcher; + private FileSystemWatcher? _healsWatcher; + private DateTimeOffset _lastRebuildStarted = DateTimeOffset.MinValue; + private bool _rebuildInFlight; + private DashboardSnapshot? _latest; + private volatile bool _disposed; + + /// + /// Construct a freshness source rooted at . The optional + /// override exists for tests: production callers pass + /// null and the real is used. + /// + public FreshnessSource(string root, SnapshotOptions options, ISnapshotSource? snapshotSource = null) + { + _root = root; + _options = options; + _snapshotSource = snapshotSource ?? RealSnapshotSource.Instance; + } + + /// Most-recent successfully built snapshot, or null when none has completed yet. + public DashboardSnapshot? Latest + { + get { lock (_gate) return _latest; } + } + + /// Fires after each successful rebuild, on the thread-pool thread that completed it. + public event Action? SnapshotChanged; + + /// + /// Start the poll timer and both file-watchers, and trigger an immediate initial rebuild so + /// subscribers see a snapshot without waiting for the first poll tick. Safe to call once per + /// instance; subsequent calls are no-ops. + /// + public void Start() + { + lock (_gate) + { + if (_disposed) throw new ObjectDisposedException(nameof(FreshnessSource)); + if (_pollTimer is not null) return; // already started + + _pollTimer = new Timer(_ => OnPollTick(), state: null, + dueTime: MinRebuildInterval, period: MinRebuildInterval); + + _debounceTimer = new Timer(_ => OnDebounceFire(), state: null, + dueTime: Timeout.Infinite, period: Timeout.Infinite); + + var dotDir = Path.Join(_root, ScopeLayout.DotDir); + // Create the .sourcegraph dotdir if it doesn't exist yet so the FileSystemWatchers + // attach reliably from a cold start. Without this, launching the dashboard before + // `serve` (or before any process has created `.sourcegraph/`) leaves the watchers + // un-attached — `usage.jsonl` / `heals.jsonl` writes wouldn't trigger sub-second + // refresh; recent activity would only surface on the 1-second poll. + try { Directory.CreateDirectory(dotDir); } + catch (IOException) { /* best-effort; the poll loop still gives 1s freshness */ } + catch (UnauthorizedAccessException) { /* best-effort */ } + if (Directory.Exists(dotDir)) + { + _usageWatcher = TryStartWatcher(dotDir, "usage.jsonl"); + _healsWatcher = TryStartWatcher(dotDir, "heals.jsonl"); + } + } + // Immediate first rebuild outside the lock so SnapshotChanged subscribers can be wired + // before the callback fires. + TriggerRebuild(immediate: true); + } + + /// Stop timers and watchers. Idempotent; safe to call multiple times. + public void Stop() + { + lock (_gate) + { + _pollTimer?.Dispose(); + _pollTimer = null; + _debounceTimer?.Dispose(); + _debounceTimer = null; + _usageWatcher?.Dispose(); + _usageWatcher = null; + _healsWatcher?.Dispose(); + _healsWatcher = null; + } + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + Stop(); + } + + /// + /// Force a rebuild outside the coalescing window. Used by the dashboard's force-refresh key + /// (s) so the operator can demand a fresh snapshot without waiting for the next tick. + /// + public void RequestImmediateRebuild() => TriggerRebuild(immediate: true); + + private FileSystemWatcher? TryStartWatcher(string dir, string fileName) + { + try + { + var w = new FileSystemWatcher(dir, fileName) + { + NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size | NotifyFilters.CreationTime, + EnableRaisingEvents = true, + }; + w.Changed += OnWatcherEvent; + w.Created += OnWatcherEvent; + return w; + } + catch (IOException) { return null; } + catch (UnauthorizedAccessException) { return null; } + catch (ArgumentException) { return null; } + } + + private void OnWatcherEvent(object _, FileSystemEventArgs __) + { + // Schedule a single rebuild after WatcherDebounce; subsequent events within the window + // reset the timer (one-shot semantics — period = Infinite). + lock (_gate) + { + if (_disposed) return; + _debounceTimer?.Change(WatcherDebounce, Timeout.InfiniteTimeSpan); + } + } + + private void OnPollTick() => TriggerRebuild(immediate: false); + + private void OnDebounceFire() => TriggerRebuild(immediate: false); + + private void TriggerRebuild(bool immediate) + { + DateTimeOffset now = DateTimeOffset.UtcNow; + lock (_gate) + { + if (_disposed) return; + if (_rebuildInFlight) return; // a rebuild is already running; the in-flight result satisfies this trigger + if (!immediate && now - _lastRebuildStarted < MinRebuildInterval) return; // coalesce + _lastRebuildStarted = now; + _rebuildInFlight = true; + } + _ = Task.Run(RebuildAsync); + } + + private async Task RebuildAsync() + { + try + { + var snapshot = await _snapshotSource.BuildAsync(_root, _options, CancellationToken.None).ConfigureAwait(false); + // Re-check `_disposed` under the lock before publishing. An in-flight rebuild + // scheduled before Dispose() can still complete naturally after teardown — if we + // assigned to `_latest` or fired `SnapshotChanged` here, dashboard subscribers + // could run after their state was torn down (use-after-dispose / late UI updates). + // BuildAsync isn't cancellable today; the disposed-guard is the cheapest correct + // bound on subscriber lifetime. + bool publish; + lock (_gate) + { + if (_disposed) return; + _latest = snapshot; + publish = true; + } + if (publish) + { + // Fire outside the lock so subscribers can't deadlock against a re-entrant request. + try { SnapshotChanged?.Invoke(snapshot); } + catch { /* subscriber threw; not our problem — keep the source alive */ } + } + } + catch (IOException) + { + // Repo went away mid-build (rare). Leave _latest unchanged; next tick will retry. + } + catch (Exception) + { + // Defensive: any unexpected snapshot failure must not kill the freshness loop. The + // dashboard surfaces the stale snapshot until the next rebuild succeeds. + } + finally + { + lock (_gate) { _rebuildInFlight = false; } + } + } + + /// + /// Test seam: tests inject a synthetic snapshot source to deterministically count rebuilds + /// without dragging the real into the freshness-coalescing + /// assertions. Production wiring uses . + /// + internal interface ISnapshotSource + { + Task BuildAsync(string root, SnapshotOptions options, CancellationToken ct); + } + + private sealed class RealSnapshotSource : ISnapshotSource + { + public static readonly RealSnapshotSource Instance = new(); + public Task BuildAsync(string root, SnapshotOptions options, CancellationToken ct) + => SnapshotBuilder.BuildAsync(root, options, ct); + } +} diff --git a/src/DevBitsLab.Mcp.SourceGraph.Server/DevBitsLab.Mcp.SourceGraph.Server.csproj b/src/DevBitsLab.Mcp.SourceGraph.Server/DevBitsLab.Mcp.SourceGraph.Server.csproj index 95f6fb9d..20e6a55d 100644 --- a/src/DevBitsLab.Mcp.SourceGraph.Server/DevBitsLab.Mcp.SourceGraph.Server.csproj +++ b/src/DevBitsLab.Mcp.SourceGraph.Server/DevBitsLab.Mcp.SourceGraph.Server.csproj @@ -33,6 +33,8 @@ + + diff --git a/src/DevBitsLab.Mcp.SourceGraph.Server/Program.cs b/src/DevBitsLab.Mcp.SourceGraph.Server/Program.cs index 5d8fa7d8..7033b157 100644 --- a/src/DevBitsLab.Mcp.SourceGraph.Server/Program.cs +++ b/src/DevBitsLab.Mcp.SourceGraph.Server/Program.cs @@ -6,6 +6,7 @@ using DevBitsLab.Mcp.SourceGraph.Sdk; using DevBitsLab.Mcp.SourceGraph.Server; using DevBitsLab.Mcp.SourceGraph.Server.Cli; +using DevBitsLab.Mcp.SourceGraph.Server.Dashboard; using DevBitsLab.Mcp.SourceGraph.Server.Observability; using DevBitsLab.Mcp.SourceGraph.Server.Plugins; using DevBitsLab.Mcp.SourceGraph.Server.Scoping; @@ -16,6 +17,13 @@ using Microsoft.Extensions.Logging; using ModelContextProtocol.Server; +// Bare-command dispatch: a bare `sourcegraph-mcp` invocation drops into the dashboard when ALL +// stdio streams are attached to a tty (stdin for key input, stdout + stderr for ANSI cursor +// positioning) AND the process is interactive. Any redirected stream → route to `status`, the +// headless surface. The rewrite is a no-op when a positional subcommand or `--help` is already +// present. See openspec/changes/add-operator-dashboard/design.md decision 2. +args = BareCommandDispatch.Rewrite(args, BareCommandDispatch.IsStdioRedirectedOrNonInteractive); + CommandLine cli; try { @@ -43,6 +51,8 @@ "clear" => await RunClearAsync(cli).ConfigureAwait(false), "init" => await InitCli.RunAsync(cli).ConfigureAwait(false), "doctor" => await DoctorCli.RunAsync(cli).ConfigureAwait(false), + "status" => await StatusCli.RunAsync(cli).ConfigureAwait(false), + "dashboard" => await DashboardCli.RunAsync(cli).ConfigureAwait(false), "demo" => await DemoCli.RunAsync(cli).ConfigureAwait(false), "init-scopes" => await ScopesCli.RunInitAsync(cli).ConfigureAwait(false), "scopes" => await ScopesCli.RunSubcommandAsync(cli).ConfigureAwait(false), diff --git a/src/DevBitsLab.Mcp.SourceGraph.Storage/ScopeConfig.cs b/src/DevBitsLab.Mcp.SourceGraph.Storage/ScopeConfig.cs index 6db1a68f..0280518f 100644 --- a/src/DevBitsLab.Mcp.SourceGraph.Storage/ScopeConfig.cs +++ b/src/DevBitsLab.Mcp.SourceGraph.Storage/ScopeConfig.cs @@ -98,11 +98,37 @@ public static ScopeConfig Load(string repoRoot, IReadOnlyList? discovere throw new ScopeConfigException($"{FileName} is not valid JSON: {ex.Message}", ex); } - if (dto.Scopes is null || dto.Scopes.Count == 0) + // A missing `scopes` property is structurally different from an explicitly-empty + // array and is still rejected — that shape almost always means the file is + // half-written (plugin-only config, hand-edit accidentally removed the key, etc.). + // Throwing gives the operator a clear signal rather than silently behaving as + // zero-scopes. + if (dto.Scopes is null) { throw new ScopeConfigException($"{FileName} has no `scopes` array."); } + // An EXPLICITLY EMPTY `scopes` array (`"scopes": []`) is treated as a recoverable + // empty config — not a hard error. This is the natural state the file lands in after + // `scopes remove` wipes the last entry (or after a deliberate hand-edit). Throwing + // here used to leave the user stuck: every subsequent `scopes add` / dashboard `[N]` + // re-loaded the same file and bailed before the user could add a fresh scope. + // Returning an empty `ScopeConfig` lets the caller recover — the first added scope + // simply becomes the only one. + if (dto.Scopes.Count == 0) + { + // Project plugins through ParsePlugins so plugin configuration survives the + // remove-last-scope round-trip (no silent data loss if the user had plugins + // configured alongside scopes). DefaultScope is null'd out — carrying through a + // value that points at a scope id which no longer exists would set up callers + // (demo, scope resolution) to fail downstream even though the loader is + // explicitly in recovery mode. + return new ScopeConfig( + Array.Empty(), + DefaultScope: null, + Plugins: ParsePlugins(dto.Plugins)); + } + var seen = new HashSet(StringComparer.Ordinal); var scopes = new List(dto.Scopes.Count); for (var i = 0; i < dto.Scopes.Count; i++) diff --git a/tests/DevBitsLab.Mcp.SourceGraph.Tests/Cli/Rendering/BatchedPickerInputTests.cs b/tests/DevBitsLab.Mcp.SourceGraph.Tests/Cli/Rendering/BatchedPickerInputTests.cs new file mode 100644 index 00000000..acd9c81d --- /dev/null +++ b/tests/DevBitsLab.Mcp.SourceGraph.Tests/Cli/Rendering/BatchedPickerInputTests.cs @@ -0,0 +1,135 @@ +using DevBitsLab.Mcp.SourceGraph.Server.Cli; +using FluentAssertions; +using Xunit; + +namespace DevBitsLab.Mcp.SourceGraph.Tests.Cli.Rendering; + +/// +/// Covers the parser for the single batched-picker prompt input. Grammar branches: empty / y / Y +/// (defaults); n / N (deselect all); +/- slugs (flip from defaults); unknown slug (warn and skip); +/// malformed token (reprompt signal). +/// +public sealed class BatchedPickerInputTests +{ + private static readonly IReadOnlySet KnownSlugs = new HashSet(StringComparer.Ordinal) + { + "claude-code", "copilot", "cursor", "continue", "claude-desktop", + }; + + private static IReadOnlySet Defaults(params string[] slugs) => + new HashSet(slugs, StringComparer.Ordinal); + + [Fact] + public void EmptyInput_returnsDefaults() + { + var defaults = Defaults("claude-code", "copilot"); + var result = BatchedPickerInput.Parse("", defaults, KnownSlugs); + result.NeedsReprompt.Should().BeFalse(); + result.Selection.Should().BeEquivalentTo(defaults); + result.UnknownSlugs.Should().BeEmpty(); + } + + [Fact] + public void WhitespaceOnly_returnsDefaults() + { + var defaults = Defaults("claude-code", "copilot"); + var result = BatchedPickerInput.Parse(" \t ", defaults, KnownSlugs); + result.NeedsReprompt.Should().BeFalse(); + result.Selection.Should().BeEquivalentTo(defaults); + } + + [Fact] + public void LowerY_returnsDefaults() + { + var defaults = Defaults("claude-code", "copilot"); + var result = BatchedPickerInput.Parse("y", defaults, KnownSlugs); + result.Selection.Should().BeEquivalentTo(defaults); + result.NeedsReprompt.Should().BeFalse(); + } + + [Fact] + public void UpperY_returnsDefaults() + { + var defaults = Defaults("claude-code", "copilot"); + var result = BatchedPickerInput.Parse("Y", defaults, KnownSlugs); + result.Selection.Should().BeEquivalentTo(defaults); + } + + [Fact] + public void LowerN_deselectsAll() + { + var defaults = Defaults("claude-code", "copilot"); + var result = BatchedPickerInput.Parse("n", defaults, KnownSlugs); + result.Selection.Should().BeEmpty(); + result.NeedsReprompt.Should().BeFalse(); + } + + [Fact] + public void UpperN_deselectsAll() + { + var defaults = Defaults("claude-code", "copilot"); + var result = BatchedPickerInput.Parse("N", defaults, KnownSlugs); + result.Selection.Should().BeEmpty(); + } + + [Fact] + public void PlusSlug_addsToDefaults() + { + var defaults = Defaults("claude-code", "copilot"); + var result = BatchedPickerInput.Parse("+cursor", defaults, KnownSlugs); + result.NeedsReprompt.Should().BeFalse(); + result.Selection.Should().BeEquivalentTo(new[] { "claude-code", "copilot", "cursor" }); + } + + [Fact] + public void MinusSlug_removesFromDefaults() + { + var defaults = Defaults("claude-code", "copilot"); + var result = BatchedPickerInput.Parse("-copilot", defaults, KnownSlugs); + result.NeedsReprompt.Should().BeFalse(); + result.Selection.Should().BeEquivalentTo(new[] { "claude-code" }); + } + + [Fact] + public void MixedPlusMinus_appliesInOrder() + { + var defaults = Defaults("claude-code", "copilot"); + var result = BatchedPickerInput.Parse("+cursor -copilot +continue", defaults, KnownSlugs); + result.NeedsReprompt.Should().BeFalse(); + result.Selection.Should().BeEquivalentTo(new[] { "claude-code", "cursor", "continue" }); + } + + [Fact] + public void UnknownSlug_warnsAndIgnores() + { + var defaults = Defaults("claude-code"); + var result = BatchedPickerInput.Parse("+sublime", defaults, KnownSlugs); + result.NeedsReprompt.Should().BeFalse(); + result.Selection.Should().BeEquivalentTo(new[] { "claude-code" }); + result.UnknownSlugs.Should().BeEquivalentTo(new[] { "sublime" }); + } + + [Fact] + public void MalformedToken_signalsReprompt() + { + var defaults = Defaults("claude-code"); + var result = BatchedPickerInput.Parse("cursor", defaults, KnownSlugs); + result.NeedsReprompt.Should().BeTrue(); + } + + [Fact] + public void GarbageInput_signalsReprompt() + { + var defaults = Defaults("claude-code"); + var result = BatchedPickerInput.Parse("xyz123", defaults, KnownSlugs); + result.NeedsReprompt.Should().BeTrue(); + } + + [Fact] + public void AllOff_isEmpty() + { + var result = BatchedPickerInput.AllOff(); + result.Selection.Should().BeEmpty(); + result.NeedsReprompt.Should().BeFalse(); + } +} diff --git a/tests/DevBitsLab.Mcp.SourceGraph.Tests/Cli/Rendering/InitRendererTests.cs b/tests/DevBitsLab.Mcp.SourceGraph.Tests/Cli/Rendering/InitRendererTests.cs new file mode 100644 index 00000000..2578f8fe --- /dev/null +++ b/tests/DevBitsLab.Mcp.SourceGraph.Tests/Cli/Rendering/InitRendererTests.cs @@ -0,0 +1,284 @@ +using DevBitsLab.Mcp.SourceGraph.Server.Cli; +using DevBitsLab.Mcp.SourceGraph.Server.Cli.ClientConfigWriters; +using DevBitsLab.Mcp.SourceGraph.Server.Cli.Rendering; +using DevBitsLab.Mcp.SourceGraph.Server.Tools; +using FluentAssertions; +using Xunit; + +namespace DevBitsLab.Mcp.SourceGraph.Tests.Cli.Rendering; + +/// +/// Covers : phase-header presence (◆-prefixed), indentation invariant +/// under headers, dot-glyph presence in Apply rows, and the "omit when inputs empty" rule for +/// Pre-warm. After the visual was unified with the dashboard the row tokens are +/// ////; the leaf 🌿 stays only on the banner. +/// +[Collection("CliConsole")] +public sealed class InitRendererTests : IDisposable +{ + private readonly bool _initialSuppressed; + + public InitRendererTests() + { + _initialSuppressed = LeafFormatter.Suppressed; + LeafFormatter.Suppressed = false; + } + + public void Dispose() + { + LeafFormatter.Suppressed = _initialSuppressed; + } + + [Fact] + public void RenderBanner_emitsHeading() + { + using var sw = new StringWriter(); + InitRenderer.RenderBanner(sw); + sw.ToString().Should().Contain("SourceGraph init"); + } + + [Fact] + public void RenderBanner_underNoLeaf_omitsLeafGlyph() + { + LeafFormatter.Suppressed = true; + using var sw = new StringWriter(); + InitRenderer.RenderBanner(sw); + sw.ToString().Should().NotContain("🌿"); + } + + [Fact] + public void RenderEnvironment_includesHeading_andIndentsRows() + { + var detection = new OnboardingDetectionResult( + DotnetSdkVersion: "10.0.100", + GitOnPath: true, + RepoRootPath: "/tmp/myrepo", + SolutionFiles: new[] { "/tmp/myrepo/MyApp.slnx" }, + SourceGraphConfigStatus: SourceGraphConfigStatus.Missing, + SourceGraphConfigError: null, + ClientConfigsDetected: Array.Empty()); + using var sw = new StringWriter(); + InitRenderer.RenderEnvironment(sw, detection, root: "/tmp/myrepo", home: "/home/test"); + var lines = sw.ToString().Split('\n'); + // The heading is the section-leader (◆) followed by the phase name. + lines.Should().Contain(l => l.Contains("Environment") && !l.StartsWith(" "), + "phase heading at left margin"); + // Every non-empty, non-heading line under the phase is indented four spaces (matches the + // dashboard's detail-view body indent). + var phaseRows = lines.SkipWhile(l => !l.Contains("Environment") || l.StartsWith(" ")) + .Skip(1) + .TakeWhile(l => !string.IsNullOrWhiteSpace(l) && !IsPhaseHeading(l)); + foreach (var row in phaseRows) + { + row.Should().StartWith(" ", $"row '{row}' must be indented four spaces under Environment"); + } + } + + [Fact] + public void RenderEnvironment_emitsOkDotForPassRow_andWarnForMissingGit() + { + var detection = new OnboardingDetectionResult( + DotnetSdkVersion: "10.0.100", + GitOnPath: false, + RepoRootPath: "/tmp/myrepo", + SolutionFiles: new[] { "/tmp/myrepo/MyApp.slnx" }, + SourceGraphConfigStatus: SourceGraphConfigStatus.Missing, + SourceGraphConfigError: null, + ClientConfigsDetected: Array.Empty()); + using var sw = new StringWriter(); + InitRenderer.RenderEnvironment(sw, detection, root: "/tmp/myrepo", home: "/home/test"); + var output = sw.ToString(); + // Leaves are reserved for the banner; row-status uses the dot vocabulary. + output.Should().NotContain("🌿"); + output.Should().Contain("●"); // pass rows + output.Should().Contain("◐"); // git-missing row (warn → half-circle) + } + + [Fact] + public void RenderEnvironment_emitsSectionLeader() + { + var detection = new OnboardingDetectionResult( + DotnetSdkVersion: "10.0.100", + GitOnPath: true, + RepoRootPath: "/tmp/myrepo", + SolutionFiles: new[] { "/tmp/myrepo/MyApp.slnx" }, + SourceGraphConfigStatus: SourceGraphConfigStatus.Missing, + SourceGraphConfigError: null, + ClientConfigsDetected: Array.Empty()); + using var sw = new StringWriter(); + InitRenderer.RenderEnvironment(sw, detection, root: "/tmp/myrepo", home: "/home/test"); + // ◆ section leader precedes the heading in brand position. + sw.ToString().Should().Contain("◆ Environment"); + } + + [Fact] + public void RenderClientsToWire_emitsHeadingAndIndentedRows() + { + var rows = new[] + { + new ClientPickerRow("claude-code", DefaultOn: true, Scope: "project", Detail: ".mcp.json"), + new ClientPickerRow("cursor", DefaultOn: false, Scope: "project", Detail: ".cursor/mcp.json"), + }; + using var sw = new StringWriter(); + InitRenderer.RenderClientsToWire(sw, rows); + var lines = sw.ToString().Split('\n'); + lines.Should().Contain(l => l.Contains("Clients to wire") && !l.StartsWith(" ")); + var slugRows = lines.Where(l => l.Contains("claude-code") || l.Contains("cursor")).ToList(); + slugRows.Should().AllSatisfy(l => l.Should().StartWith(" "), "rows under headings are 4-space indented"); + // Default-on row has the ok-dot; default-off has the off-dot. + sw.ToString().Should().Contain("●").And.Contain("○"); + } + + [Fact] + public void RenderClientsToWire_emptyRows_emitsNothing() + { + using var sw = new StringWriter(); + InitRenderer.RenderClientsToWire(sw, Array.Empty()); + sw.ToString().Should().BeEmpty(); + } + + [Fact] + public void RenderApplyRow_insertEmitsOkDotAndVerb() + { + using var sw = new StringWriter(); + InitRenderer.RenderApplyRow(sw, + WriterAction.Insert, + slug: "claude-code", + targetPath: "/tmp/myrepo/.mcp.json", + description: "would create new config", + root: "/tmp/myrepo", + home: "/home/test"); + var line = sw.ToString(); + line.Should().StartWith(" ● ").And.Contain("wrote").And.Contain("claude-code").And.Contain(".mcp.json"); + } + + [Fact] + public void RenderApplyRow_skipExistingDiffersEmitsFailDotAndDescription() + { + using var sw = new StringWriter(); + InitRenderer.RenderApplyRow(sw, + WriterAction.SkipExistingDiffers, + slug: "claude-code", + targetPath: "/tmp/myrepo/.mcp.json", + description: "existing differs", + root: "/tmp/myrepo", + home: "/home/test"); + var output = sw.ToString(); + output.Should().Contain("✗ ").And.Contain("conflict").And.Contain("existing differs"); + } + + [Fact] + public void RenderApplyRow_skipHasCommentsEmitsWarnDotAndDescription() + { + using var sw = new StringWriter(); + InitRenderer.RenderApplyRow(sw, + WriterAction.SkipHasComments, + slug: "claude-code", + targetPath: "/tmp/myrepo/.mcp.json", + description: "config has comments at /tmp/.mcp.json — please paste manually", + root: "/tmp/myrepo", + home: "/home/test"); + var output = sw.ToString(); + output.Should().Contain("◐ ").And.Contain("comments"); + } + + [Fact] + public void RenderApplyRow_skipUnsupportedEmitsDashDotAndDescription() + { + using var sw = new StringWriter(); + InitRenderer.RenderApplyRow(sw, + WriterAction.SkipUnsupported, + slug: "copilot", + targetPath: "/some/path", + description: "user-scope Copilot wiring is not supported in v1", + root: "/tmp/myrepo", + home: "/home/test"); + var output = sw.ToString(); + output.Should().Contain("− ").And.Contain("unsupported").And.Contain("user-scope Copilot"); + } + + [Fact] + public void RenderApplyRow_noOpEmitsOkDotAndNoChange() + { + using var sw = new StringWriter(); + InitRenderer.RenderApplyRow(sw, + WriterAction.NoOpAlreadyMatches, + slug: "claude-code", + targetPath: "/tmp/myrepo/.mcp.json", + description: "already wired", + root: "/tmp/myrepo", + home: "/home/test"); + var line = sw.ToString(); + line.Should().Contain("●").And.Contain("no change"); + } + + [Fact] + public void RenderPreWarmSummary_exitZero_emitsOkDotAndElapsed() + { + using var sw = new StringWriter(); + InitRenderer.RenderPreWarmSummary(sw, exitCode: 0, elapsed: TimeSpan.FromSeconds(11.4), solutionName: "MyApp.slnx"); + sw.ToString().Should().Contain("●").And.Contain("indexed").And.Contain("MyApp.slnx").And.Contain("11.4"); + } + + [Fact] + public void RenderPreWarmSummary_nonZeroExit_emitsWarn() + { + using var sw = new StringWriter(); + InitRenderer.RenderPreWarmSummary(sw, exitCode: 2, elapsed: TimeSpan.FromSeconds(7.0), solutionName: "MyApp.slnx"); + sw.ToString().Should().Contain("◐").And.Contain("pre-warm exit 2"); + } + + [Fact] + public void RenderNext_empty_emitsNothing() + { + using var sw = new StringWriter(); + InitRenderer.RenderNext(sw, Array.Empty()); + sw.ToString().Should().BeEmpty(); + } + + [Fact] + public void RenderNext_emitsHeadingAndIndentedSuggestions() + { + using var sw = new StringWriter(); + InitRenderer.RenderNext(sw, new[] { "Run sourcegraph-mcp demo", "Verify with usage_stats" }); + var lines = sw.ToString().Split('\n'); + lines.Should().Contain(l => l.Contains("Next") && !l.StartsWith(" ")); + var nextRows = lines.SkipWhile(l => !l.Contains("Next") || l.StartsWith(" ")) + .Skip(1) + .Where(l => !string.IsNullOrWhiteSpace(l)); + nextRows.Should().AllSatisfy(l => l.Should().StartWith(" ")); + } + + [Fact] + public void NoLeaf_substitutesAsciiTokens_forRowsAndHeader() + { + // Under --no-leaf the dot vocabulary collapses to the bracketed ASCII tokens; the section + // leader downgrades to [*] so the header still leads with a 3-cell glyph. + LeafFormatter.Suppressed = true; + var detection = new OnboardingDetectionResult( + DotnetSdkVersion: "10.0.100", + GitOnPath: true, + RepoRootPath: "/tmp/myrepo", + SolutionFiles: new[] { "/tmp/myrepo/MyApp.slnx" }, + SourceGraphConfigStatus: SourceGraphConfigStatus.Missing, + SourceGraphConfigError: null, + ClientConfigsDetected: Array.Empty()); + using var sw = new StringWriter(); + InitRenderer.RenderEnvironment(sw, detection, root: "/tmp/myrepo", home: "/home/test"); + var output = sw.ToString(); + output.Should().NotContain("●").And.NotContain("◐").And.NotContain("✗").And.NotContain("○").And.NotContain("−"); + output.Should().NotContain("◆"); + // ASCII row tokens. + output.Should().Contain("[x] "); + // Header substitution. + output.Should().Contain("[*] Environment"); + } + + private static bool IsPhaseHeading(string line) + { + // Heuristic: known phase names appear at left margin (after ◆ or [*] section leader). + return (line.StartsWith("◆") || line.StartsWith("[*]")) + && (line.Contains("Environment") || line.Contains("Clients to wire") + || line.Contains("Apply") || line.Contains("Pre-warm") || line.Contains("Next")); + } +} diff --git a/tests/DevBitsLab.Mcp.SourceGraph.Tests/Cli/Rendering/PathDisplayTests.cs b/tests/DevBitsLab.Mcp.SourceGraph.Tests/Cli/Rendering/PathDisplayTests.cs new file mode 100644 index 00000000..09056f82 --- /dev/null +++ b/tests/DevBitsLab.Mcp.SourceGraph.Tests/Cli/Rendering/PathDisplayTests.cs @@ -0,0 +1,79 @@ +using DevBitsLab.Mcp.SourceGraph.Server.Cli.Rendering; +using FluentAssertions; +using Xunit; + +namespace DevBitsLab.Mcp.SourceGraph.Tests.Cli.Rendering; + +/// +/// Covers : repo-relative form preferred when inside --root; +/// ~/ substitution when inside the home dir; absolute fall-through otherwise; and the +/// tie-break (under both → repo-relative wins). +/// +public sealed class PathDisplayTests +{ + [Fact] + public void Render_underRoot_returnsRelative() + { + var root = Path.Join(Path.GetTempPath(), "myrepo"); + var inside = Path.Join(root, ".mcp.json"); + var rendered = PathDisplay.Render(inside, root, homePath: "/home/test"); + rendered.Should().Be(".mcp.json"); + } + + [Fact] + public void Render_underRootNested_returnsRelative() + { + var root = Path.Join(Path.GetTempPath(), "myrepo"); + var inside = Path.Join(root, ".vscode", "mcp.json"); + var rendered = PathDisplay.Render(inside, root, homePath: "/home/test"); + rendered.Should().Be(Path.Join(".vscode", "mcp.json")); + } + + [Fact] + public void Render_underHome_returnsTildePrefixed() + { + var home = Path.Join(Path.GetTempPath(), "fake-home"); + var inHome = Path.Join(home, ".cursor", "mcp.json"); + // root is far away — not under it. + var root = Path.Join(Path.GetTempPath(), "other-repo"); + var rendered = PathDisplay.Render(inHome, root, home); + rendered.Should().StartWith("~" + Path.DirectorySeparatorChar); + rendered.Should().EndWith(Path.Join(".cursor", "mcp.json")); + } + + [Fact] + public void Render_neitherRootNorHome_returnsAbsolute() + { + var path = "/var/log/something.log"; + var root = "/home/test/repo"; + var home = "/home/test"; + var rendered = PathDisplay.Render(path, root, home); + rendered.Should().Be(path); + } + + [Fact] + public void Render_underBothRootAndHome_prefersRoot() + { + // A pathological setup where root is itself under home — repo-relative should win. + var home = Path.Join(Path.GetTempPath(), "fake-home2"); + var root = Path.Join(home, "myrepo"); + var inside = Path.Join(root, ".mcp.json"); + var rendered = PathDisplay.Render(inside, root, home); + rendered.Should().Be(".mcp.json", "repo-relative is preferred over ~/-relative when both apply"); + } + + [Fact] + public void Render_emptyPath_passthrough() + { + var rendered = PathDisplay.Render("", "/root", "/home"); + rendered.Should().Be(""); + } + + [Fact] + public void Render_homeNull_fallsThroughToAbsolute() + { + var path = "/var/log/test.log"; + var rendered = PathDisplay.Render(path, "/some/other/root", homePath: null); + rendered.Should().Be(path); + } +} diff --git a/tests/DevBitsLab.Mcp.SourceGraph.Tests/Cli/Rendering/StatusRendererTests.cs b/tests/DevBitsLab.Mcp.SourceGraph.Tests/Cli/Rendering/StatusRendererTests.cs new file mode 100644 index 00000000..0cc1dd35 --- /dev/null +++ b/tests/DevBitsLab.Mcp.SourceGraph.Tests/Cli/Rendering/StatusRendererTests.cs @@ -0,0 +1,209 @@ +using DevBitsLab.Mcp.SourceGraph.Server.Cli.Rendering; +using DevBitsLab.Mcp.SourceGraph.Server.Cli.Snapshot; +using DevBitsLab.Mcp.SourceGraph.Server.Tools; +using FluentAssertions; +using Xunit; + +namespace DevBitsLab.Mcp.SourceGraph.Tests.Cli.Rendering; + +/// +/// Tests for : ◆-prefixed phase headings, four-space indentation +/// invariant under each heading, the dot vocabulary in both emoji and ASCII modes, and the +/// per-row mappings (partial scope → warn dot + hanging detail; degraded scope → fail dot + +/// repair hint). +/// +[Collection("CliConsole")] +public sealed class StatusRendererTests : IDisposable +{ + private readonly bool _initialSuppressed; + + public StatusRendererTests() + { + _initialSuppressed = LeafFormatter.Suppressed; + LeafFormatter.Suppressed = false; + } + + public void Dispose() + { + LeafFormatter.Suppressed = _initialSuppressed; + } + + [Fact] + public void Render_healthy_emitsFivePhaseHeadings() + { + var snap = Healthy(); + var output = Render(snap); + output.Should().Contain("Environment").And.Contain("Scopes").And.Contain("Clients") + .And.Contain("Embeddings").And.Contain("Recent activity"); + // Section leader ◆ should appear on each heading. + output.Should().Contain("◆ Environment"); + output.Should().Contain("◆ Scopes"); + output.Should().Contain("◆ Clients"); + output.Should().Contain("◆ Embeddings"); + output.Should().Contain("◆ Recent activity"); + } + + [Fact] + public void Render_eachRow_underHeading_isFourSpaceIndented() + { + var snap = Healthy(); + var output = Render(snap); + // Spot-check: after each phase's "◆ " line, the next non-blank line must start + // with four spaces (the body indent under the section leader). + var lines = output.Split('\n'); + var headings = new[] { "Environment", "Scopes", "Clients", "Embeddings", "Recent activity" }; + foreach (var heading in headings) + { + var idx = Array.FindIndex(lines, l => l.TrimEnd() == $"◆ {heading}"); + idx.Should().BeGreaterThan(-1, $"phase heading '◆ {heading}' should appear"); + // Find the next non-blank line after the heading. + var next = lines.Skip(idx + 1).FirstOrDefault(l => !string.IsNullOrWhiteSpace(l)); + next.Should().NotBeNull(); + next!.Should().StartWith(" ", $"rows under '◆ {heading}' should be four-space indented"); + } + } + + [Fact] + public void Render_partialScope_emitsWarnDotAndFailedProjects() + { + var snap = Healthy() with + { + Scopes = new[] + { + new ScopeRow("backend", "partial", 100, 200, + new DateTimeOffset(2025, 1, 1, 12, 0, 0, TimeSpan.Zero), + new[] { "Legacy.csproj", "Old.csproj" }, + Array.Empty(), + false), + }, + }; + var output = Render(snap); + // Emoji-mode: ◐ (half-circle / warn) for partial. + output.Should().Contain("◐"); + output.Should().Contain("backend"); + output.Should().Contain("Legacy.csproj"); + output.Should().Contain("Old.csproj"); + } + + [Fact] + public void Render_degradedScope_emitsFailDotAndRepairHint() + { + var snap = Healthy() with + { + Scopes = new[] + { + new ScopeRow("a", "degraded", 0, 0, null, + Array.Empty(), Array.Empty(), false), + }, + }; + var output = Render(snap); + output.Should().Contain("✗"); + output.Should().Contain("repair_scope"); + } + + [Fact] + public void Render_noLeaf_substitutesAsciiGlyphs() + { + LeafFormatter.Suppressed = true; + var snap = Healthy(); + var output = Render(snap); + output.Should().NotContain("🌿"); + // None of the emoji dot characters should appear. + output.Should().NotContain("●").And.NotContain("○").And.NotContain("◐").And.NotContain("✗").And.NotContain("−"); + // ◆ section leader downgrades to [*]. + output.Should().NotContain("◆"); + output.Should().Contain("[*] Environment"); + // Three-cell-wide ASCII row tokens: [x] / [ ] / [!] / [X] / [-] + output.Should().MatchRegex(@"\[[ xX!\-]\] "); + // Phase headings render the same name in both modes. + output.Should().Contain("Environment").And.Contain("Scopes"); + } + + [Fact] + public void Render_emptyClients_emitsParentheticalNotice() + { + var snap = Healthy() with { Clients = Array.Empty() }; + var output = Render(snap); + output.Should().Contain("(no client configs detected)"); + } + + [Fact] + public void Render_emptyActivity_emitsParentheticalNotice() + { + var snap = Healthy() with { RecentActivity = Array.Empty() }; + var output = Render(snap); + output.Should().Contain("(no recorded activity)"); + } + + [Fact] + public void Render_clientWithEntry_emitsOkDot() + { + var snap = Healthy() with + { + Clients = new[] + { + new ClientRow("claude-code", "project", "/r/.mcp.json", true, true), + new ClientRow("cursor", "user", "/h/.cursor/mcp.json", true, false), + new ClientRow("continue", "project", "/r/.continue/mcp/sourcegraph.yaml", false, false), + }, + }; + var output = Render(snap); + var lines = output.Split('\n'); + // claude-code with entry → ● (ok). + lines.Should().Contain(l => l.Contains("claude-code") && l.Contains("●")); + // cursor exists but no entry → ○ (off). + lines.Should().Contain(l => l.Contains("cursor") && l.Contains("○")); + // continue absent → − (unsupported). + lines.Should().Contain(l => l.Contains("continue") && l.Contains("−")); + } + + [Fact] + public void FormatRelativeTime_shortDelta_secondsAgo() + { + StatusRenderer.FormatRelativeTime(TimeSpan.FromSeconds(45)).Should().Be("45s ago"); + StatusRenderer.FormatRelativeTime(TimeSpan.FromMinutes(11)).Should().Be("11m ago"); + StatusRenderer.FormatRelativeTime(TimeSpan.FromHours(5)).Should().Be("5h ago"); + StatusRenderer.FormatRelativeTime(TimeSpan.FromDays(3)).Should().Be("3d ago"); + } + + private static string Render(DashboardSnapshot snap) + { + var sw = new StringWriter(); + var options = new StatusRenderOptions(Root: "/r", Home: "/h", NoColor: false); + StatusRenderer.RenderHuman(snap, sw, options); + return sw.ToString(); + } + + private static DashboardSnapshot Healthy() => new( + Environment: new EnvironmentSurface( + DotnetSdkVersion: "10.0.100", + GitOnPath: true, + RepoRootPath: "/r", + SolutionFiles: new[] { "/r/x.slnx" }, + SourceGraphConfigStatus: "valid", + SourceGraphConfigError: null), + Scopes: new[] + { + new ScopeRow("default", "ok", 1000, 2000, + new DateTimeOffset(2025, 1, 1, 12, 0, 0, TimeSpan.Zero), + Array.Empty(), Array.Empty(), false), + }, + Clients: new[] + { + new ClientRow("claude-code", "project", "/r/.mcp.json", true, true), + }, + Embeddings: new EmbeddingsSurface( + ModelId: "jinaai/jina-embeddings-v2-base-code", + CacheDir: "/h/.cache/devbitslab.sourcegraph/models", + CachePresent: true, + TotalBytes: 614 * 1024 * 1024, + Verified: false), + RecentActivity: new[] + { + new ActivityEntry(DateTimeOffset.UtcNow.AddMinutes(-1), "tool_call", "default", true, 3, "search_symbols"), + }, + BuiltAt: DateTimeOffset.UtcNow, + UsageLogPath: "/r/.sourcegraph/usage.jsonl", + HealsLogPath: "/r/.sourcegraph/heals.jsonl", + ExitCode: 0); +} diff --git a/tests/DevBitsLab.Mcp.SourceGraph.Tests/Cli/Rendering/UnifiedDiffRendererTests.cs b/tests/DevBitsLab.Mcp.SourceGraph.Tests/Cli/Rendering/UnifiedDiffRendererTests.cs new file mode 100644 index 00000000..aaf31ea7 --- /dev/null +++ b/tests/DevBitsLab.Mcp.SourceGraph.Tests/Cli/Rendering/UnifiedDiffRendererTests.cs @@ -0,0 +1,78 @@ +using System.Text; +using DevBitsLab.Mcp.SourceGraph.Server.Cli.Rendering; +using FluentAssertions; +using Xunit; + +namespace DevBitsLab.Mcp.SourceGraph.Tests.Cli.Rendering; + +/// +/// Covers : header shape, simple change-line marker, no-output +/// when inputs are equal. +/// +public sealed class UnifiedDiffRendererTests +{ + [Fact] + public void Render_emitsFromAndToHeaders() + { + var existing = Encoding.UTF8.GetBytes("line1\nline2\nline3\n"); + var proposed = Encoding.UTF8.GetBytes("line1\nline2-changed\nline3\n"); + using var sw = new StringWriter(); + UnifiedDiffRenderer.Render(existing, proposed, ".mcp.json", ".mcp.json.proposed", sw); + var output = sw.ToString(); + output.Should().Contain("--- .mcp.json"); + output.Should().Contain("+++ .mcp.json.proposed"); + } + + [Fact] + public void Render_emitsAdditionAndRemovalLines() + { + var existing = Encoding.UTF8.GetBytes("a\nb\nc\n"); + var proposed = Encoding.UTF8.GetBytes("a\nB\nc\n"); + using var sw = new StringWriter(); + UnifiedDiffRenderer.Render(existing, proposed, "from", "to", sw); + var output = sw.ToString(); + output.Should().Contain("-b"); + output.Should().Contain("+B"); + } + + [Fact] + public void Render_emitsHunkHeader() + { + var existing = Encoding.UTF8.GetBytes("a\nb\nc\nd\ne\n"); + var proposed = Encoding.UTF8.GetBytes("a\nb\nC\nd\ne\n"); + using var sw = new StringWriter(); + UnifiedDiffRenderer.Render(existing, proposed, "from", "to", sw); + var output = sw.ToString(); + output.Should().Contain("@@"); + } + + [Fact] + public void Render_equalInputs_emitsHeadersOnly() + { + // No change → no hunks. Headers still get written (the caller decides whether to bother + // calling Render in the first place). `TextWriter.WriteLine` honours the host's line + // terminator (`\n` on Unix, `\r\n` on Windows), so we split on both forms to keep this + // test cross-platform — splitting on `'\n'` alone leaves a trailing `\r` on Windows that + // the byte-exact `Be(...)` assertion catches. + var same = Encoding.UTF8.GetBytes("hello\nworld\n"); + using var sw = new StringWriter(); + UnifiedDiffRenderer.Render(same, same, "from", "to", sw); + var lines = sw.ToString().Split(new[] { "\r\n", "\n" }, StringSplitOptions.None) + .Where(l => l.Length > 0).ToList(); + lines.Should().HaveCount(2); + lines[0].Should().Be("--- from"); + lines[1].Should().Be("+++ to"); + } + + [Fact] + public void Render_handlesEmptyExisting() + { + var proposed = Encoding.UTF8.GetBytes("new content\n"); + using var sw = new StringWriter(); + UnifiedDiffRenderer.Render(Array.Empty(), proposed, "from", "to", sw); + var output = sw.ToString(); + output.Should().Contain("--- from"); + output.Should().Contain("+++ to"); + output.Should().Contain("+new content"); + } +} diff --git a/tests/DevBitsLab.Mcp.SourceGraph.Tests/Cli/Snapshot/DashboardSnapshotJsonTests.cs b/tests/DevBitsLab.Mcp.SourceGraph.Tests/Cli/Snapshot/DashboardSnapshotJsonTests.cs new file mode 100644 index 00000000..7e6339bf --- /dev/null +++ b/tests/DevBitsLab.Mcp.SourceGraph.Tests/Cli/Snapshot/DashboardSnapshotJsonTests.cs @@ -0,0 +1,230 @@ +using System.Text.Json; +using DevBitsLab.Mcp.SourceGraph.Server.Cli.Snapshot; +using FluentAssertions; +using Xunit; + +namespace DevBitsLab.Mcp.SourceGraph.Tests.Cli.Snapshot; + +/// +/// Pins the snake-case JSON contract for . The wire shape is a +/// stable, append-only public surface (per the status snapshot data sources requirement); +/// these tests would break if a future refactor silently renamed or dropped a documented field. +/// +public sealed class DashboardSnapshotJsonTests +{ + private static DashboardSnapshot SampleSnapshot() => new( + Environment: new EnvironmentSurface( + DotnetSdkVersion: "10.0.100", + GitOnPath: true, + RepoRootPath: "/repo", + SolutionFiles: new[] { "/repo/MyApp.slnx" }, + SourceGraphConfigStatus: "valid", + SourceGraphConfigError: null), + Scopes: new[] + { + new ScopeRow( + Name: "frontend", + Status: "ok", + SymbolCount: 1000, + ReferenceCount: 2000, + LastIndexedAt: new DateTimeOffset(2025, 1, 1, 12, 0, 0, TimeSpan.Zero), + FailedProjects: Array.Empty(), + FailedFiles: Array.Empty(), + Isolated: false), + }, + Clients: new[] + { + new ClientRow("claude-code", "project", "/repo/.mcp.json", true, true), + }, + Embeddings: new EmbeddingsSurface( + ModelId: "jinaai/jina-embeddings-v2-base-code", + CacheDir: "/home/user/.cache/devbitslab.sourcegraph/models", + CachePresent: true, + TotalBytes: 614000000, + Verified: false), + RecentActivity: new[] + { + new ActivityEntry( + Ts: new DateTimeOffset(2025, 1, 1, 12, 1, 0, TimeSpan.Zero), + Kind: "tool_call", + Scope: "frontend", + Ok: true, + Ms: 12, + Detail: "search_symbols"), + }, + BuiltAt: new DateTimeOffset(2025, 1, 1, 12, 5, 0, TimeSpan.Zero), + UsageLogPath: "/repo/.sourcegraph/usage.jsonl", + HealsLogPath: "/repo/.sourcegraph/heals.jsonl", + ExitCode: 0); + + [Fact] + public void Serialize_emitsSnakeCaseTopLevelKeys() + { + var json = DashboardSnapshotJson.Serialize(SampleSnapshot()); + using var doc = JsonDocument.Parse(json); + var keys = doc.RootElement.EnumerateObject().Select(p => p.Name).ToList(); + keys.Should().BeEquivalentTo(new[] + { + "environment", + "scopes", + "clients", + "embeddings", + "recent_activity", + "built_at", + "usage_log_path", + "heals_log_path", + "exit_code", + }); + } + + [Fact] + public void Serialize_environmentSurface_hasDocumentedFieldNames() + { + var json = DashboardSnapshotJson.Serialize(SampleSnapshot()); + using var doc = JsonDocument.Parse(json); + var env = doc.RootElement.GetProperty("environment"); + var keys = env.EnumerateObject().Select(p => p.Name).ToList(); + keys.Should().BeEquivalentTo(new[] + { + "dotnet_sdk_version", + "git_on_path", + "repo_root_path", + "solution_files", + "sourcegraph_config_status", + "sourcegraph_config_error", + }); + } + + [Fact] + public void Serialize_scopeRow_hasDocumentedFieldNames() + { + var json = DashboardSnapshotJson.Serialize(SampleSnapshot()); + using var doc = JsonDocument.Parse(json); + var scope = doc.RootElement.GetProperty("scopes")[0]; + var keys = scope.EnumerateObject().Select(p => p.Name).ToList(); + keys.Should().BeEquivalentTo(new[] + { + "name", + "status", + "symbol_count", + "reference_count", + "last_indexed_at", + "failed_projects", + "failed_files", + "isolated", + }); + } + + [Fact] + public void Serialize_clientRow_hasDocumentedFieldNames() + { + var json = DashboardSnapshotJson.Serialize(SampleSnapshot()); + using var doc = JsonDocument.Parse(json); + var client = doc.RootElement.GetProperty("clients")[0]; + var keys = client.EnumerateObject().Select(p => p.Name).ToList(); + keys.Should().BeEquivalentTo(new[] + { + "slug", + "scope", + "path", + "exists", + "contains_sourcegraph_entry", + }); + } + + [Fact] + public void Serialize_embeddingsSurface_hasDocumentedFieldNames() + { + var json = DashboardSnapshotJson.Serialize(SampleSnapshot()); + using var doc = JsonDocument.Parse(json); + var emb = doc.RootElement.GetProperty("embeddings"); + var keys = emb.EnumerateObject().Select(p => p.Name).ToList(); + keys.Should().BeEquivalentTo(new[] + { + "model_id", + "cache_dir", + "cache_present", + "total_bytes", + "verified", + }); + } + + [Fact] + public void Serialize_activityEntry_hasDocumentedFieldNames() + { + var json = DashboardSnapshotJson.Serialize(SampleSnapshot()); + using var doc = JsonDocument.Parse(json); + var entry = doc.RootElement.GetProperty("recent_activity")[0]; + var keys = entry.EnumerateObject().Select(p => p.Name).ToList(); + keys.Should().BeEquivalentTo(new[] + { + "ts", + "kind", + "scope", + "ok", + "ms", + "detail", + }); + } + + [Fact] + public void RoundTrip_preservesContent() + { + var original = SampleSnapshot(); + var json = DashboardSnapshotJson.Serialize(original); + var roundtripped = DashboardSnapshotJson.Deserialize(json); + roundtripped.Should().NotBeNull(); + roundtripped!.Environment.DotnetSdkVersion.Should().Be("10.0.100"); + roundtripped.Environment.GitOnPath.Should().BeTrue(); + roundtripped.Scopes.Should().HaveCount(1); + roundtripped.Scopes[0].Status.Should().Be("ok"); + roundtripped.Scopes[0].SymbolCount.Should().Be(1000); + roundtripped.Clients.Should().HaveCount(1); + roundtripped.Clients[0].Slug.Should().Be("claude-code"); + roundtripped.Clients[0].ContainsSourcegraphEntry.Should().BeTrue(); + roundtripped.Embeddings.ModelId.Should().Be("jinaai/jina-embeddings-v2-base-code"); + roundtripped.RecentActivity.Should().HaveCount(1); + roundtripped.RecentActivity[0].Kind.Should().Be("tool_call"); + roundtripped.ExitCode.Should().Be(0); + } + + [Fact] + public void Deserialize_acceptsHandWrittenSnakeCase() + { + // Pin the deserialization mapping by parsing a hand-written fixture that uses the + // documented JSON field names. If any of these names drift in the future, this test + // is the canary. + var fixtureJson = """ + { + "environment": { + "dotnet_sdk_version": "10.0.0", + "git_on_path": true, + "repo_root_path": "/r", + "solution_files": [], + "sourcegraph_config_status": "missing", + "sourcegraph_config_error": null + }, + "scopes": [], + "clients": [], + "embeddings": { + "model_id": "m", + "cache_dir": "/c", + "cache_present": false, + "total_bytes": 0, + "verified": false + }, + "recent_activity": [], + "built_at": "2025-01-01T00:00:00+00:00", + "usage_log_path": "/u", + "heals_log_path": "/h", + "exit_code": 2 + } + """; + var snap = DashboardSnapshotJson.Deserialize(fixtureJson); + snap.Should().NotBeNull(); + snap!.Environment.SourceGraphConfigStatus.Should().Be("missing"); + snap.ExitCode.Should().Be(2); + snap.UsageLogPath.Should().Be("/u"); + snap.HealsLogPath.Should().Be("/h"); + } +} diff --git a/tests/DevBitsLab.Mcp.SourceGraph.Tests/Cli/Snapshot/SnapshotBuilderTests.cs b/tests/DevBitsLab.Mcp.SourceGraph.Tests/Cli/Snapshot/SnapshotBuilderTests.cs new file mode 100644 index 00000000..e5d537e7 --- /dev/null +++ b/tests/DevBitsLab.Mcp.SourceGraph.Tests/Cli/Snapshot/SnapshotBuilderTests.cs @@ -0,0 +1,275 @@ +using System.Text.Json; +using DevBitsLab.Mcp.SourceGraph.Core; +using DevBitsLab.Mcp.SourceGraph.Server.Cli.Snapshot; +using DevBitsLab.Mcp.SourceGraph.Storage; +using FluentAssertions; +using Xunit; + +namespace DevBitsLab.Mcp.SourceGraph.Tests.Cli.Snapshot; + +/// +/// Round-trip tests for . Each test builds a deterministic fixture +/// repo on disk (temp directory, real SQLite files, real JSONL contents), invokes +/// , and asserts that every surface populates from the +/// expected source. +/// +/// +/// The builder opens SQLite handles in read-only mode and closes them before returning, so +/// these tests can re-build the same fixture multiple times without lock contention. +/// +/// +public sealed class SnapshotBuilderTests : IDisposable +{ + private readonly string _tempRoot; + private readonly string _scopesDir; + + public SnapshotBuilderTests() + { + _tempRoot = Path.Join(Path.GetTempPath(), "sg-snapshot-tests-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempRoot); + _scopesDir = ScopeLayout.ScopesDirectory(_tempRoot); + Directory.CreateDirectory(_scopesDir); + } + + public void Dispose() + { + try { Directory.Delete(_tempRoot, recursive: true); } + catch (IOException) { /* best-effort */ } + catch (UnauthorizedAccessException) { /* best-effort */ } + } + + [Fact] + public async Task BuildAsync_emptyRepo_returnsSnapshotWithDefaultExitCode() + { + var snap = await SnapshotBuilder.BuildAsync(_tempRoot, new SnapshotOptions()); + snap.Environment.RepoRootPath.Should().Be(Path.GetFullPath(_tempRoot)); + snap.Scopes.Should().BeEmpty(); + // Clients always include user-scope detection (may be present if dev has them at home); + // assert the array shape is reasonable rather than empty. + snap.Clients.Should().NotBeNull(); + snap.RecentActivity.Should().BeEmpty(); + snap.ExitCode.Should().Be(0, "the builder defaults exit_code; the caller fills it in"); + snap.UsageLogPath.Should().EndWith(".sourcegraph" + Path.DirectorySeparatorChar + "usage.jsonl"); + snap.HealsLogPath.Should().EndWith(".sourcegraph" + Path.DirectorySeparatorChar + "heals.jsonl"); + } + + [Fact] + public async Task BuildAsync_environment_pinsSolutionFiles() + { + File.WriteAllText(Path.Join(_tempRoot, "MyApp.slnx"), ""); + var snap = await SnapshotBuilder.BuildAsync(_tempRoot, new SnapshotOptions()); + snap.Environment.SolutionFiles.Should().ContainSingle() + .Which.Should().EndWith("MyApp.slnx"); + snap.Environment.SourceGraphConfigStatus.Should().Be("missing"); + } + + [Fact] + public async Task BuildAsync_environment_capturesMalformedConfig() + { + File.WriteAllText(Path.Join(_tempRoot, ".sourcegraph.json"), "{ broken json"); + var snap = await SnapshotBuilder.BuildAsync(_tempRoot, new SnapshotOptions()); + snap.Environment.SourceGraphConfigStatus.Should().Be("malformed"); + snap.Environment.SourceGraphConfigError.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task BuildAsync_scopes_readsRegistryRows() + { + // Stand up _meta.db with two scopes: an "ok" frontend and a "partial" backend. + await using (var registry = new SqliteScopeRegistry(ScopeLayout.MetaDbPath(_tempRoot))) + { + await registry.EnsureSchemaAsync(); + await registry.UpsertAsync(new Storage.ScopeRow( + Id: "frontend", + Name: "Frontend", + Root: _tempRoot, + ProjectSetJson: "{}", + Isolated: false, + LastIndexedAt: new DateTimeOffset(2025, 1, 1, 12, 0, 0, TimeSpan.Zero), + Status: "ok", + StatusMessage: null, + FailedProjects: Array.Empty(), + FailedFiles: Array.Empty())); + await registry.UpsertAsync(new Storage.ScopeRow( + Id: "backend", + Name: "Backend", + Root: _tempRoot, + ProjectSetJson: "{}", + Isolated: false, + LastIndexedAt: new DateTimeOffset(2025, 1, 2, 12, 0, 0, TimeSpan.Zero), + Status: "partial", + StatusMessage: "1 project failed", + FailedProjects: new[] { new ProjectFailure("Bad.csproj", "compilation null") }, + FailedFiles: Array.Empty())); + } + + var snap = await SnapshotBuilder.BuildAsync(_tempRoot, new SnapshotOptions()); + snap.Scopes.Should().HaveCount(2); + + var backend = snap.Scopes.Single(s => s.Name == "Backend"); + backend.Status.Should().Be("partial"); + backend.FailedProjects.Should().ContainSingle().Which.Should().Contain("Bad.csproj"); + backend.LastIndexedAt.Should().Be(new DateTimeOffset(2025, 1, 2, 12, 0, 0, TimeSpan.Zero)); + + var frontend = snap.Scopes.Single(s => s.Name == "Frontend"); + frontend.Status.Should().Be("ok"); + frontend.FailedProjects.Should().BeEmpty(); + } + + [Fact] + public async Task BuildAsync_scopes_pullsSymbolAndRefCountsFromPerScopeDb() + { + // Stand up _meta.db with a default scope plus a per-scope DB containing some rows. + await using (var registry = new SqliteScopeRegistry(ScopeLayout.MetaDbPath(_tempRoot))) + { + await registry.EnsureSchemaAsync(); + await registry.UpsertAsync(new Storage.ScopeRow( + Id: "default", + Name: "default", + Root: _tempRoot, + ProjectSetJson: "{}", + Isolated: false, + LastIndexedAt: DateTimeOffset.UtcNow, + Status: "ok", + StatusMessage: null, + FailedProjects: Array.Empty(), + FailedFiles: Array.Empty())); + } + + var dbPath = ScopeLayout.ScopeDbPath(_tempRoot, "default"); + await using (var store = new SqliteGraphStore(dbPath)) + { + await store.EnsureSchemaAsync(); + var fileId = await store.UpsertFileAsync( + Path.Join(_tempRoot, "A.cs"), + contentSha256: new byte[32], + indexedAt: DateTimeOffset.UtcNow); + var symId = await store.UpsertSymbolAsync( + canonicalKey: "csharp:T:A", + new Symbol( + Id: 0, Name: "A", Fqn: "A", Kind: "class", + FileId: fileId, StartLine: 1, StartCol: 1, EndLine: 1, EndCol: 1, + Signature: null, ContainerId: null)); + await store.BulkInsertReferencesAsync(new[] + { + new SymbolReference(Id: 0, SymbolId: symId, FileId: fileId, Line: 2, Col: 1, Kind: ReferenceKind.Call), + }); + } + + var snap = await SnapshotBuilder.BuildAsync(_tempRoot, new SnapshotOptions()); + var def = snap.Scopes.Single(s => s.Name == "default"); + def.SymbolCount.Should().Be(1); + def.ReferenceCount.Should().Be(1); + } + + [Fact] + public async Task BuildAsync_recentActivity_mergesAndSortsBothLogs() + { + var dotDir = Path.Join(_tempRoot, ScopeLayout.DotDir); + Directory.CreateDirectory(dotDir); + var usagePath = Path.Join(dotDir, "usage.jsonl"); + var healsPath = Path.Join(dotDir, "heals.jsonl"); + + // Write timestamps T1 < T2 < T3 < T4 < T5 across both files so the merged result must + // be ascending regardless of file order. + File.WriteAllText(usagePath, + JsonSerializer.Serialize(new { ts = "2025-01-01T12:00:00Z", tool = "search_symbols", ok = true, ms = 5, scope = "default" }) + "\n" + + JsonSerializer.Serialize(new { ts = "2025-01-01T12:00:30Z", tool = "find_definition", ok = true, ms = 8, scope = "default" }) + "\n" + + JsonSerializer.Serialize(new { ts = "2025-01-01T12:01:00Z", tool = "graph_stats", ok = true, ms = 2, scope = "default" }) + "\n"); + File.WriteAllText(healsPath, + JsonSerializer.Serialize(new { ts = "2025-01-01T12:00:15Z", kind = "boot_reconcile", scope = "default", ok = true, ms = 100, details = "ready" }) + "\n" + + JsonSerializer.Serialize(new { ts = "2025-01-01T12:00:45Z", kind = "heal", scope = "default", ok = true, ms = 50, details = "drift" }) + "\n"); + + var snap = await SnapshotBuilder.BuildAsync(_tempRoot, new SnapshotOptions()); + snap.RecentActivity.Should().HaveCount(5); + var times = snap.RecentActivity.Select(a => a.Ts).ToList(); + times.Should().BeInAscendingOrder(); + snap.RecentActivity.Select(a => a.Kind).Should().Contain(new[] { "tool_call", "boot_reconcile", "heal" }); + } + + [Fact] + public async Task BuildAsync_recentActivity_tolerablePartialTrailingLine() + { + var dotDir = Path.Join(_tempRoot, ScopeLayout.DotDir); + Directory.CreateDirectory(dotDir); + var usagePath = Path.Join(dotDir, "usage.jsonl"); + + // Two complete lines + a partial (no trailing newline, half-object). + File.WriteAllText(usagePath, + JsonSerializer.Serialize(new { ts = "2025-01-01T12:00:00Z", tool = "search_symbols", ok = true, ms = 5, scope = "default" }) + "\n" + + JsonSerializer.Serialize(new { ts = "2025-01-01T12:00:30Z", tool = "graph_stats", ok = true, ms = 2, scope = "default" }) + "\n" + + "{\"ts\":\"2025-01-01T12:01"); // intentionally truncated + + var snap = await SnapshotBuilder.BuildAsync(_tempRoot, new SnapshotOptions()); + // The partial line is dropped; two complete entries remain. + snap.RecentActivity.Should().HaveCount(2); + } + + [Fact] + public async Task BuildAsync_recentActivity_capsAtRecentActivityCap() + { + var dotDir = Path.Join(_tempRoot, ScopeLayout.DotDir); + Directory.CreateDirectory(dotDir); + var usagePath = Path.Join(dotDir, "usage.jsonl"); + + // 100 entries, ascending timestamps. With cap = 10, only the most recent 10 stay. + using (var w = new StreamWriter(usagePath)) + { + var start = new DateTimeOffset(2025, 1, 1, 12, 0, 0, TimeSpan.Zero); + for (var i = 0; i < 100; i++) + { + var ts = start.AddSeconds(i).ToString("O"); + w.WriteLine(JsonSerializer.Serialize(new { ts, tool = $"t{i}", ok = true, ms = 1, scope = "default" })); + } + } + + var snap = await SnapshotBuilder.BuildAsync(_tempRoot, new SnapshotOptions(RecentActivityCap: 10)); + snap.RecentActivity.Should().HaveCount(10); + // The cap keeps the latest entries — last one has tool == "t99". + snap.RecentActivity[^1].Detail.Should().Be("t99"); + } + + [Fact] + public async Task BuildAsync_concurrent_writes_tolerated() + { + // Spawn a writer that appends to usage.jsonl while BuildAsync runs ten times in a loop; + // assert no exception, monotonically-ordered activity within each result. + var dotDir = Path.Join(_tempRoot, ScopeLayout.DotDir); + Directory.CreateDirectory(dotDir); + var usagePath = Path.Join(dotDir, "usage.jsonl"); + + using var cts = new CancellationTokenSource(); + var writer = Task.Run(async () => + { + var i = 0; + while (!cts.Token.IsCancellationRequested) + { + var ts = DateTimeOffset.UtcNow.ToString("O"); + var line = JsonSerializer.Serialize(new { ts, tool = $"t{i++}", ok = true, ms = 1 }) + "\n"; + try + { + using var fs = new FileStream(usagePath, FileMode.Append, FileAccess.Write, FileShare.Read | FileShare.Write | FileShare.Delete); + var bytes = System.Text.Encoding.UTF8.GetBytes(line); + await fs.WriteAsync(bytes, 0, bytes.Length).ConfigureAwait(false); + } + catch (IOException) { /* file share race; retry */ } + await Task.Delay(1).ConfigureAwait(false); + } + }); + + try + { + for (var run = 0; run < 10; run++) + { + var snap = await SnapshotBuilder.BuildAsync(_tempRoot, new SnapshotOptions()); + // Even with the concurrent writer, no exception; activity should be ordered. + snap.RecentActivity.Select(a => a.Ts).Should().BeInAscendingOrder(); + } + } + finally + { + cts.Cancel(); + try { await writer; } catch (OperationCanceledException) { } + } + } +} diff --git a/tests/DevBitsLab.Mcp.SourceGraph.Tests/Cli/Snapshot/StatusCliTests.cs b/tests/DevBitsLab.Mcp.SourceGraph.Tests/Cli/Snapshot/StatusCliTests.cs new file mode 100644 index 00000000..b564e206 --- /dev/null +++ b/tests/DevBitsLab.Mcp.SourceGraph.Tests/Cli/Snapshot/StatusCliTests.cs @@ -0,0 +1,234 @@ +using System.Text.Json; +using DevBitsLab.Mcp.SourceGraph.Server.Cli; +using DevBitsLab.Mcp.SourceGraph.Server.Cli.Snapshot; +using FluentAssertions; +using Xunit; + +namespace DevBitsLab.Mcp.SourceGraph.Tests.Cli.Snapshot; + +/// +/// End-to-end coverage for the status subcommand: exit-code evaluation, JSON shape, +/// human-readable phase headings, and the partial-trailing-line scenario. +/// +[Collection("CliConsole")] +public sealed class StatusCliTests : IDisposable +{ + private readonly string _tempRoot; + private readonly TextWriter _originalStdout; + private readonly TextWriter _originalStderr; + private readonly StringWriter _stdout = new(); + private readonly StringWriter _stderr = new(); + + public StatusCliTests() + { + _tempRoot = Path.Join(Path.GetTempPath(), "sg-status-tests-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempRoot); + _originalStdout = Console.Out; + _originalStderr = Console.Error; + Console.SetOut(_stdout); + Console.SetError(_stderr); + } + + public void Dispose() + { + Console.SetOut(_originalStdout); + Console.SetError(_originalStderr); + try { Directory.Delete(_tempRoot, recursive: true); } + catch (IOException) { /* best-effort */ } + catch (UnauthorizedAccessException) { /* best-effort */ } + } + + [Fact] + public async Task Status_emptyRepo_emitsFivePhases() + { + var cli = CommandLine.Parse(new[] { "status", "--root", _tempRoot }); + var rc = await StatusCli.RunAsync(cli); + rc.Should().BeOneOf(0, 2); + var output = _stdout.ToString(); + output.Should().Contain("Environment"); + output.Should().Contain("Scopes"); + output.Should().Contain("Clients"); + output.Should().Contain("Embeddings"); + output.Should().Contain("Recent activity"); + } + + [Fact] + public async Task Status_json_hasTopLevelKeys() + { + var cli = CommandLine.Parse(new[] { "status", "--root", _tempRoot, "--json" }); + var rc = await StatusCli.RunAsync(cli); + var output = _stdout.ToString(); + output.Should().StartWith("{"); + using var doc = JsonDocument.Parse(output); + var keys = doc.RootElement.EnumerateObject().Select(p => p.Name).ToList(); + keys.Should().BeEquivalentTo(new[] + { + "environment", "scopes", "clients", "embeddings", + "recent_activity", "built_at", "usage_log_path", "heals_log_path", "exit_code", + }); + doc.RootElement.GetProperty("exit_code").GetInt32().Should().Be(rc); + } + + [Fact] + public async Task Status_partialTrailingLine_droppedFromActivity() + { + var dotDir = Path.Join(_tempRoot, ".sourcegraph"); + Directory.CreateDirectory(dotDir); + File.WriteAllText(Path.Join(dotDir, "usage.jsonl"), + JsonSerializer.Serialize(new { ts = "2025-01-01T12:00:00Z", tool = "ok", ok = true, ms = 1, scope = "default" }) + "\n" + + JsonSerializer.Serialize(new { ts = "2025-01-01T12:00:01Z", tool = "also_ok", ok = true, ms = 1, scope = "default" }) + "\n" + + "{\"ts\":\"2025-01-01T12:00"); // truncated mid-string + + var cli = CommandLine.Parse(new[] { "status", "--root", _tempRoot, "--json" }); + await StatusCli.RunAsync(cli); + var output = _stdout.ToString(); + using var doc = JsonDocument.Parse(output); + doc.RootElement.GetProperty("recent_activity").GetArrayLength().Should().Be(2); + _stderr.ToString().Should().NotContain("error"); + } + + [Fact] + public async Task Status_malformedConfig_exits1() + { + File.WriteAllText(Path.Join(_tempRoot, ".sourcegraph.json"), "{ broken"); + var cli = CommandLine.Parse(new[] { "status", "--root", _tempRoot }); + var rc = await StatusCli.RunAsync(cli); + rc.Should().Be(1); + } + + [Fact] + public async Task Status_noLeaf_substitutesAsciiTokens() + { + // SOURCEGRAPH_NO_LEAF=1 mirrors --no-leaf; the renderer ought to swap every glyph to its + // [x] / [ ] / [!] / [X] / [-] ASCII form. + Environment.SetEnvironmentVariable("SOURCEGRAPH_NO_LEAF", "1"); + var initial = DevBitsLab.Mcp.SourceGraph.Server.Tools.LeafFormatter.Suppressed; + DevBitsLab.Mcp.SourceGraph.Server.Tools.LeafFormatter.Suppressed = true; + try + { + var cli = CommandLine.Parse(new[] { "status", "--root", _tempRoot }); + await StatusCli.RunAsync(cli); + var output = _stdout.ToString(); + output.Should().NotContain("🌿"); + output.Should().MatchRegex(@"\[[ xX!\-]\] "); + } + finally + { + DevBitsLab.Mcp.SourceGraph.Server.Tools.LeafFormatter.Suppressed = initial; + Environment.SetEnvironmentVariable("SOURCEGRAPH_NO_LEAF", null); + } + } + + [Fact] + public void EvaluateExit_healthyEnv_returnsZero() + { + var snap = BuildSyntheticSnapshot(); + StatusCli.EvaluateExit(snap).Should().Be(0); + } + + [Fact] + public void EvaluateExit_missingGit_returnsTwo() + { + var snap = BuildSyntheticSnapshot() with + { + Environment = BuildSyntheticSnapshot().Environment with { GitOnPath = false }, + }; + StatusCli.EvaluateExit(snap).Should().Be(2); + } + + [Fact] + public void EvaluateExit_missingSdk_returnsOne() + { + var snap = BuildSyntheticSnapshot() with + { + Environment = BuildSyntheticSnapshot().Environment with { DotnetSdkVersion = null }, + }; + StatusCli.EvaluateExit(snap).Should().Be(1); + } + + [Fact] + public void EvaluateExit_degradedScope_returnsOne() + { + var basis = BuildSyntheticSnapshot(); + var snap = basis with + { + Scopes = new[] + { + new ScopeRow("a", "degraded", 0, 0, null, + Array.Empty(), Array.Empty(), false), + }, + }; + StatusCli.EvaluateExit(snap).Should().Be(1); + } + + [Fact] + public void EvaluateExit_partialScope_returnsTwo() + { + var basis = BuildSyntheticSnapshot(); + var snap = basis with + { + Scopes = new[] + { + new ScopeRow("a", "partial", 1, 2, null, + new[] { "Bad.csproj" }, Array.Empty(), false), + }, + }; + StatusCli.EvaluateExit(snap).Should().Be(2); + } + + [Fact] + public void EvaluateExit_malformedConfig_returnsOne() + { + var basis = BuildSyntheticSnapshot(); + var snap = basis with + { + Environment = basis.Environment with + { + SourceGraphConfigStatus = "malformed", + SourceGraphConfigError = "broken", + }, + }; + StatusCli.EvaluateExit(snap).Should().Be(1); + } + + [Fact] + public void EvaluateExit_cacheAbsent_returnsTwo() + { + var basis = BuildSyntheticSnapshot(); + var snap = basis with + { + Embeddings = basis.Embeddings with { CachePresent = false, TotalBytes = 0 }, + }; + StatusCli.EvaluateExit(snap).Should().Be(2); + } + + /// + /// A synthetic healthy snapshot: existing repo dir + present .NET SDK + git + embeddings cache. + /// Tests mutate one field at a time via with-expressions to probe each branch of + /// . + /// + private DashboardSnapshot BuildSyntheticSnapshot() => new( + Environment: new EnvironmentSurface( + DotnetSdkVersion: "10.0.100", + GitOnPath: true, + RepoRootPath: _tempRoot, + // Include one synthetic solution path so the "healthy environment" baseline matches + // EvaluateExit's warning rule: a repo with no detectable .slnx / .sln warns + // (consistent with doctor + StatusRenderer + DashboardRenderer). + SolutionFiles: new[] { Path.Join(_tempRoot, "Test.slnx") }, + SourceGraphConfigStatus: "missing", + SourceGraphConfigError: null), + Scopes: Array.Empty(), + Clients: Array.Empty(), + Embeddings: new EmbeddingsSurface( + ModelId: "m", + CacheDir: "/c", + CachePresent: true, + TotalBytes: 100, + Verified: false), + RecentActivity: Array.Empty(), + BuiltAt: DateTimeOffset.UtcNow, + UsageLogPath: "/u", + HealsLogPath: "/h", + ExitCode: 0); +} diff --git a/tests/DevBitsLab.Mcp.SourceGraph.Tests/Cli/Snapshot/StatusCliWatchTests.cs b/tests/DevBitsLab.Mcp.SourceGraph.Tests/Cli/Snapshot/StatusCliWatchTests.cs new file mode 100644 index 00000000..f533a305 --- /dev/null +++ b/tests/DevBitsLab.Mcp.SourceGraph.Tests/Cli/Snapshot/StatusCliWatchTests.cs @@ -0,0 +1,109 @@ +using DevBitsLab.Mcp.SourceGraph.Server.Cli; +using DevBitsLab.Mcp.SourceGraph.Server.Cli.Snapshot; +using FluentAssertions; +using Xunit; + +namespace DevBitsLab.Mcp.SourceGraph.Tests.Cli.Snapshot; + +/// +/// Coverage for the --watch mode. Under a test harness +/// is always true, so the documented "silently downgrade to single snapshot" path is +/// exercised. The redraw loop itself is covered indirectly via the loop body invoked through +/// the snapshot builder (which is FD-stable when called repeatedly). +/// +[Collection("CliConsole")] +public sealed class StatusCliWatchTests : IDisposable +{ + private readonly string _tempRoot; + private readonly TextWriter _originalStdout; + private readonly StringWriter _stdout = new(); + + public StatusCliWatchTests() + { + _tempRoot = Path.Join(Path.GetTempPath(), "sg-status-watch-tests-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempRoot); + _originalStdout = Console.Out; + Console.SetOut(_stdout); + } + + public void Dispose() + { + Console.SetOut(_originalStdout); + try { Directory.Delete(_tempRoot, recursive: true); } + catch (IOException) { /* best-effort */ } + catch (UnauthorizedAccessException) { /* best-effort */ } + } + + [Fact] + public async Task Status_watch_underNonTty_downgradesToSingleSnapshot() + { + // Force the stdin-redirected branch deterministically via the injectable probe overload — + // some test runners (IDE / custom harnesses) don't redirect stdin, which would otherwise + // make the assertion depend on the host environment. + var cli = CommandLine.Parse(new[] { "status", "--root", _tempRoot, "--watch", "--watch-interval", "1" }); + var rc = await StatusCli.RunAsync(cli, isStdioRedirected: () => true); + // The downgraded path renders exactly once and returns. No ANSI clear codes appear + // because the watch loop isn't entered. + var output = _stdout.ToString(); + output.Should().Contain("Environment"); + output.Should().NotContain("\x1b[H\x1b[J"); + rc.Should().BeOneOf(0, 1, 2); + } + + [Fact] + public async Task Status_watchAndJson_areMutuallyExclusive() + { + // The watch loop emits ANSI cursor codes (`\x1b[H\x1b[J`) before each redraw; combining + // those with `--json` would corrupt the JSON document on stdout. The subcommand rejects + // the combination with exit 2 and an error message on stderr. + var stderrCapture = new StringWriter(); + var savedStderr = Console.Error; + Console.SetError(stderrCapture); + try + { + var cli = CommandLine.Parse(new[] { "status", "--root", _tempRoot, "--watch", "--json" }); + var rc = await StatusCli.RunAsync(cli); + rc.Should().Be(2); + stderrCapture.ToString().Should().Contain("--watch and --json are mutually exclusive"); + // No JSON document was written to stdout. + _stdout.ToString().Should().BeEmpty(); + } + finally + { + Console.SetError(savedStderr); + } + } + + [Fact] + public async Task Status_buildAsync_repeatedCalls_doNotLeakSqliteHandles() + { + // Approximate FD-leak guard: build the snapshot 30 times in tight succession and assert + // the process can continue (no resource exhaustion). The builder opens its handles in + // `using` scopes, so a regression that "forgets" to dispose would fail this with an + // SqliteException after ~hundreds of opens. Use a lower bound (30) so the test stays + // fast on CI while still proving the dispose path. + var dotDir = Path.Join(_tempRoot, ".sourcegraph"); + Directory.CreateDirectory(dotDir); + // Put a real _meta.db in place so the scope branch opens a handle each call. + var registry = new DevBitsLab.Mcp.SourceGraph.Storage.SqliteScopeRegistry( + DevBitsLab.Mcp.SourceGraph.Storage.ScopeLayout.MetaDbPath(_tempRoot)); + await registry.EnsureSchemaAsync(); + await registry.UpsertAsync(new DevBitsLab.Mcp.SourceGraph.Storage.ScopeRow( + Id: "default", + Name: "default", + Root: _tempRoot, + ProjectSetJson: "{}", + Isolated: false, + LastIndexedAt: DateTimeOffset.UtcNow, + Status: "ok", + StatusMessage: null)); + await registry.DisposeAsync(); + + for (var i = 0; i < 30; i++) + { + var snap = await SnapshotBuilder.BuildAsync(_tempRoot, new SnapshotOptions()); + snap.Scopes.Should().HaveCount(1); + } + // No exception → no FD leak that mattered. Done. + } +} diff --git a/tests/DevBitsLab.Mcp.SourceGraph.Tests/Dashboard/BareCommandDispatchTests.cs b/tests/DevBitsLab.Mcp.SourceGraph.Tests/Dashboard/BareCommandDispatchTests.cs new file mode 100644 index 00000000..311ededa --- /dev/null +++ b/tests/DevBitsLab.Mcp.SourceGraph.Tests/Dashboard/BareCommandDispatchTests.cs @@ -0,0 +1,143 @@ +using DevBitsLab.Mcp.SourceGraph.Server.Dashboard; +using FluentAssertions; +using Xunit; + +namespace DevBitsLab.Mcp.SourceGraph.Tests.Dashboard; + +/// +/// Covers : the rewrite is bare-only (no subcommand present), +/// --help/-h is never rewritten, the dispatch target is dashboard under a +/// tty and status under redirected stdin, and flags pass through unchanged. +/// +/// +/// The production probe () can't be flipped from unit +/// tests; the helper takes a delegate so tests drive the dispatch logic directly. This is the +/// reason is factored out from Program.Main. +/// +/// +public sealed class BareCommandDispatchTests +{ + [Fact] + public void Rewrite_empty_args_underTty_returnsDashboard() + { + var result = BareCommandDispatch.Rewrite(Array.Empty(), () => false); + result.Should().ContainSingle().Which.Should().Be("dashboard"); + } + + [Fact] + public void Rewrite_empty_args_redirectedStdin_returnsStatus() + { + var result = BareCommandDispatch.Rewrite(Array.Empty(), () => true); + result.Should().ContainSingle().Which.Should().Be("status"); + } + + [Fact] + public void Rewrite_helpFlag_leavesArgsUnchanged() + { + var args = new[] { "--help" }; + var result = BareCommandDispatch.Rewrite(args, () => false); + result.Should().BeSameAs(args); + } + + [Fact] + public void Rewrite_shortHelpFlag_leavesArgsUnchanged() + { + var args = new[] { "-h" }; + var result = BareCommandDispatch.Rewrite(args, () => false); + result.Should().BeSameAs(args); + } + + [Fact] + public void Rewrite_existingSubcommand_leavesArgsUnchanged() + { + var args = new[] { "serve", "--solution", "/x.slnx" }; + var result = BareCommandDispatch.Rewrite(args, () => false); + result.Should().BeSameAs(args); + } + + [Fact] + public void Rewrite_flagsOnlyBare_underTty_prependsDashboard_andPreservesFlags() + { + // `sourcegraph-mcp --root /repo` is a bare invocation with a propagated flag. + var result = BareCommandDispatch.Rewrite(new[] { "--root", "/repo" }, () => false); + result.Should().BeEquivalentTo(new[] { "dashboard", "--root", "/repo" }, o => o.WithStrictOrdering()); + } + + [Fact] + public void Rewrite_flagsOnlyBare_redirected_prependsStatus_andPreservesFlags() + { + var result = BareCommandDispatch.Rewrite(new[] { "--root", "/repo" }, () => true); + result.Should().BeEquivalentTo(new[] { "status", "--root", "/repo" }, o => o.WithStrictOrdering()); + } + + [Fact] + public void Rewrite_emptyArgsWithHelpAfter_unreachable() + { + // Defensive: an args array with --help anywhere never rewrites, even if the help flag + // is the only token. This matches the "--help still prints help" scenario in the spec. + var args = new[] { "--help" }; + var result = BareCommandDispatch.Rewrite(args, () => false); + result.Should().BeSameAs(args); + } + + [Fact] + public void IsBare_emptyArgs_returnsTrue() + { + BareCommandDispatch.IsBare(Array.Empty()).Should().BeTrue(); + } + + [Fact] + public void IsBare_subcommandArg_returnsFalse() + { + BareCommandDispatch.IsBare(new[] { "serve" }).Should().BeFalse(); + BareCommandDispatch.IsBare(new[] { "dashboard" }).Should().BeFalse(); + BareCommandDispatch.IsBare(new[] { "status" }).Should().BeFalse(); + } + + [Fact] + public void IsBare_flagFirst_returnsTrue() + { + // Only flags → bare; the user wanted the default subcommand. + BareCommandDispatch.IsBare(new[] { "--root", "/x" }).Should().BeTrue(); + BareCommandDispatch.IsBare(new[] { "--no-color" }).Should().BeTrue(); + } + + [Fact] + public void IsBare_valueBearingFlagThenSubcommand_returnsFalse() + { + // `sourcegraph-mcp --root /repo serve` carries a subcommand AFTER a value-bearing flag. + // Treating it as bare would rewrite to `["dashboard", "--root", "/repo", "serve"]` and + // confuse CommandLine.Parse. The walker must skip `--root`'s value (`/repo`) and detect + // `serve` as a positional → not bare. + BareCommandDispatch.IsBare(new[] { "--root", "/repo", "serve" }).Should().BeFalse(); + BareCommandDispatch.IsBare(new[] { "--scope", "frontend", "status" }).Should().BeFalse(); + BareCommandDispatch.IsBare(new[] { "--solution", "/x.slnx", "index" }).Should().BeFalse(); + } + + [Fact] + public void IsBare_booleanFlagsThenSubcommand_returnsFalse() + { + // Boolean flags don't consume a following token — a positional that follows is still + // a subcommand. + BareCommandDispatch.IsBare(new[] { "--no-color", "status" }).Should().BeFalse(); + BareCommandDispatch.IsBare(new[] { "--yes", "--print-only", "init" }).Should().BeFalse(); + } + + [Fact] + public void IsBare_multipleValueBearingFlags_returnsTrue() + { + // Bare invocation with two value-bearing flags chained: each consumes its value, no + // positional remains. + BareCommandDispatch.IsBare(new[] { "--root", "/repo", "--model", "someorg/m" }).Should().BeTrue(); + } + + [Fact] + public void Rewrite_valueBearingFlagThenSubcommand_passesThrough() + { + // Regression guard for the IsBare bug: `--root /repo serve` must reach the existing + // `serve` subcommand unchanged, not be rewritten to `dashboard --root /repo serve`. + var args = new[] { "--root", "/repo", "serve" }; + var result = BareCommandDispatch.Rewrite(args, () => false); + result.Should().BeSameAs(args); + } +} diff --git a/tests/DevBitsLab.Mcp.SourceGraph.Tests/Dashboard/ConfirmModalTests.cs b/tests/DevBitsLab.Mcp.SourceGraph.Tests/Dashboard/ConfirmModalTests.cs new file mode 100644 index 00000000..811a0bc6 --- /dev/null +++ b/tests/DevBitsLab.Mcp.SourceGraph.Tests/Dashboard/ConfirmModalTests.cs @@ -0,0 +1,66 @@ +using DevBitsLab.Mcp.SourceGraph.Server.Dashboard; +using FluentAssertions; +using Spectre.Console; +using Spectre.Console.Testing; +using Xunit; + +namespace DevBitsLab.Mcp.SourceGraph.Tests.Dashboard; + +/// +/// Tests for : drives the prompt against a Spectre +/// with scripted input, asserts the returned boolean. Spectre's +/// TestConsole implements and lets us push key/string input +/// the prompt consumes via . +/// +public sealed class ConfirmModalTests +{ + [Fact] + public void Prompt_userEntersY_returnsTrue() + { + var console = new TestConsole(); + console.Input.PushTextWithEnter("y"); + var result = ConfirmModal.Prompt(console, "unwire", "claude-code"); + result.Should().BeTrue(); + } + + [Fact] + public void Prompt_userEntersN_returnsFalse() + { + var console = new TestConsole(); + console.Input.PushTextWithEnter("n"); + var result = ConfirmModal.Prompt(console, "unwire", "claude-code"); + result.Should().BeFalse(); + } + + [Fact] + public void Prompt_userJustPressesEnter_returnsDefaultFalse() + { + // Spectre's ConfirmationPrompt with DefaultValue=false takes Enter as the default. + var console = new TestConsole(); + console.Input.PushTextWithEnter(""); + var result = ConfirmModal.Prompt(console, "rebuild", "backend"); + result.Should().BeFalse(); + } + + [Fact] + public void Prompt_includesTargetInOutput() + { + var console = new TestConsole(); + console.Input.PushTextWithEnter("n"); + ConfirmModal.Prompt(console, "rebuild", "frontend"); + console.Output.Should().Contain("frontend"); + } + + [Fact] + public void Prompt_userPressesEsc_returnsDefaultFalse() + { + // Esc is the documented non-destructive dismissal path. In Spectre's TextPrompt-derived + // ConfirmationPrompt, Escape clears the pending input buffer without submitting; the + // subsequent Enter then commits the unset value, which takes the default (false). + var console = new TestConsole(); + console.Input.PushKey(ConsoleKey.Escape); + console.Input.PushKey(ConsoleKey.Enter); + var result = ConfirmModal.Prompt(console, "remove", "backend"); + result.Should().BeFalse(); + } +} diff --git a/tests/DevBitsLab.Mcp.SourceGraph.Tests/Dashboard/DashboardActionsTests.cs b/tests/DevBitsLab.Mcp.SourceGraph.Tests/Dashboard/DashboardActionsTests.cs new file mode 100644 index 00000000..31406a8c --- /dev/null +++ b/tests/DevBitsLab.Mcp.SourceGraph.Tests/Dashboard/DashboardActionsTests.cs @@ -0,0 +1,733 @@ +using DevBitsLab.Mcp.SourceGraph.Server.Cli.Snapshot; +using DevBitsLab.Mcp.SourceGraph.Server.Dashboard; +using FluentAssertions; +using Spectre.Console.Testing; +using Xunit; + +namespace DevBitsLab.Mcp.SourceGraph.Tests.Dashboard; + +/// +/// Tests for : confirm-modal gating on destructive actions +/// (R rebuild, u unwire), no-gate on idempotent ones (r reindex, w +/// wire, p pull, v verify), the 30-second watchdog branch via a synthetic +/// long-running body, and the unwire JSON-config-rewrite happy path. +/// +/// +/// Tests don't exercise the subprocess-spawning paths (reindex/rebuild/guided actions); those +/// shell out to sourcegraph-mcp index which would re-enter the full indexer graph. +/// Instead we drive the test seams: , +/// , the cancel-on-confirm-deny path. +/// +/// +public sealed class DashboardActionsTests : IDisposable +{ + private readonly string _tempRoot; + private readonly string _configPath; + + public DashboardActionsTests() + { + _tempRoot = Path.Join(Path.GetTempPath(), "sg-actions-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempRoot); + _configPath = Path.Join(_tempRoot, ".mcp.json"); + } + + public void Dispose() + { + try { Directory.Delete(_tempRoot, recursive: true); } catch { } + } + + [Fact] + public async Task RunAsync_Quit_returnsQuitSignal() + { + var ctx = MakeContext(MakeSnapshot()); + var result = await DashboardActions.RunAsync(DashboardAction.Quit, ctx); + result.Quit.Should().BeTrue(); + result.Ok.Should().BeTrue(); + } + + [Fact] + public async Task RunAsync_ForceRefresh_returnsSuccess_andRequestsRebuild() + { + var ctx = MakeContext(MakeSnapshot()); + var result = await DashboardActions.RunAsync(DashboardAction.ForceRefresh, ctx); + result.Ok.Should().BeTrue(); + result.Message.Should().Contain("refresh"); + } + + [Fact] + public async Task RunAsync_navigationActions_returnNoop() + { + var ctx = MakeContext(MakeSnapshot()); + var move = await DashboardActions.RunAsync(DashboardAction.MoveUp, ctx); + move.Quit.Should().BeFalse(); + move.Ok.Should().BeTrue(); + move.Message.Should().BeEmpty(); + } + + [Fact] + public async Task UnwireClient_userDeclinesConfirm_doesNotMutateFile() + { + File.WriteAllText(_configPath, """ + { + "mcpServers": { + "sourcegraph": { "command": "sourcegraph-mcp", "args": ["serve"] }, + "other": { "command": "x" } + } + } + """); + var snap = MakeSnapshot() with + { + Clients = new[] + { + new ClientRow("claude-code", "project", _configPath, true, true), + }, + }; + var console = new TestConsole(); + console.Input.PushTextWithEnter("n"); // user declines + var ctx = new DashboardActionContext( + Snapshot: snap, + Selection: new DashboardSelection(DashboardSection.Clients, 0), + Console: console, + Freshness: null, + Root: _tempRoot, + Policy: DashboardActionPolicy.Default); + var before = File.ReadAllText(_configPath); + var result = await DashboardActions.RunAsync(DashboardAction.UnwireClient, ctx); + result.Ok.Should().BeTrue(); + result.Message.Should().Contain("cancelled"); + File.ReadAllText(_configPath).Should().Be(before, "file is unchanged when user declines confirm"); + } + + [Fact] + public async Task UnwireClient_userConfirms_removesEntryFromConfig() + { + File.WriteAllText(_configPath, """ + { + "mcpServers": { + "sourcegraph": { "command": "sourcegraph-mcp", "args": ["serve"] }, + "other": { "command": "x" } + } + } + """); + var snap = MakeSnapshot() with + { + Clients = new[] + { + new ClientRow("claude-code", "project", _configPath, true, true), + }, + }; + var console = new TestConsole(); + console.Input.PushTextWithEnter("y"); // user confirms + var ctx = new DashboardActionContext( + Snapshot: snap, + Selection: new DashboardSelection(DashboardSection.Clients, 0), + Console: console, + Freshness: null, + Root: _tempRoot, + Policy: DashboardActionPolicy.Default); + var result = await DashboardActions.RunAsync(DashboardAction.UnwireClient, ctx); + result.Ok.Should().BeTrue(); + var after = File.ReadAllText(_configPath); + after.Should().NotContain("\"sourcegraph\":"); + after.Should().Contain("\"other\":", "other entries are preserved"); + } + + [Fact] + public void UnwireFromConfigFile_missingFile_returnsFailure() + { + var result = DashboardActions.UnwireFromConfigFile(Path.Join(_tempRoot, "missing.json")); + result.Ok.Should().BeFalse(); + result.Message.Should().Contain("not found"); + } + + [Fact] + public void UnwireFromConfigFile_malformedJson_returnsFailure() + { + File.WriteAllText(_configPath, "{ broken"); + var result = DashboardActions.UnwireFromConfigFile(_configPath); + result.Ok.Should().BeFalse(); + result.Message.Should().Contain("malformed"); + } + + [Fact] + public void UnwireFromConfigFile_noSourcegraphEntry_returnsFailure() + { + File.WriteAllText(_configPath, """ + { + "mcpServers": { + "other": { "command": "x" } + } + } + """); + var result = DashboardActions.UnwireFromConfigFile(_configPath); + result.Ok.Should().BeFalse(); + result.Message.Should().Contain("no sourcegraph"); + } + + [Fact] + public void UnwireFromConfigFile_copilotShape_removesFromServersKey() + { + // Copilot uses `servers` instead of `mcpServers`; the dashboard tries both. + File.WriteAllText(_configPath, """ + { + "servers": { + "sourcegraph": { "type": "stdio", "command": "sourcegraph-mcp" } + } + } + """); + var result = DashboardActions.UnwireFromConfigFile(_configPath); + result.Ok.Should().BeTrue(); + File.ReadAllText(_configPath).Should().NotContain("sourcegraph"); + } + + [Fact] + public void UnwireFromConfigFile_configWithComments_refuses() + { + File.WriteAllText(_configPath, """ + // a comment + { + "mcpServers": { + "sourcegraph": { "command": "sourcegraph-mcp" } + } + } + """); + var result = DashboardActions.UnwireFromConfigFile(_configPath); + result.Ok.Should().BeFalse(); + result.Message.Should().Contain("comments"); + } + + [Fact] + public async Task RunWatchdoggedAsync_underTimeout_returnsBodyResult() + { + var ctx = MakeContext(MakeSnapshot(), new DashboardActionPolicy(TimeSpan.FromSeconds(2))); + var result = await DashboardActions.RunWatchdoggedAsync( + async () => { await Task.Yield(); return DashboardActionResult.Success("done"); }, + ctx, + CancellationToken.None); + result.Ok.Should().BeTrue(); + result.Message.Should().Be("done"); + } + + [Fact] + public async Task RunWatchdoggedAsync_overTimeout_returnsFailure() + { + // Use a very tight watchdog so the test runs fast. + var ctx = MakeContext(MakeSnapshot(), new DashboardActionPolicy(TimeSpan.FromMilliseconds(50))); + var result = await DashboardActions.RunWatchdoggedAsync( + async () => { await Task.Delay(2000); return DashboardActionResult.Success("never"); }, + ctx, + CancellationToken.None); + result.Ok.Should().BeFalse(); + result.Message.Should().Contain("watchdog"); + } + + [Fact] + public async Task RebuildScope_userDeclinesConfirm_returnsCancelled() + { + var snap = MakeSnapshot(); + var console = new TestConsole(); + console.Input.PushTextWithEnter("n"); + var ctx = new DashboardActionContext( + Snapshot: snap, + Selection: new DashboardSelection(DashboardSection.Scopes, 0), + Console: console, + Freshness: null, + Root: _tempRoot, + Policy: DashboardActionPolicy.Default); + var result = await DashboardActions.RunAsync(DashboardAction.RebuildScope, ctx); + result.Ok.Should().BeTrue(); + result.Message.Should().Contain("cancelled"); + } + + [Fact] + public async Task ReindexScope_wrongSection_returnsFailure() + { + // r requires the Scopes section. With Clients selected, it short-circuits. + var ctx = new DashboardActionContext( + Snapshot: MakeSnapshot(), + Selection: new DashboardSelection(DashboardSection.Clients, 0), + Console: new TestConsole(), + Freshness: null, + Root: _tempRoot, + Policy: DashboardActionPolicy.Default); + var result = await DashboardActions.RunAsync(DashboardAction.ReindexScope, ctx); + result.Ok.Should().BeFalse(); + result.Message.Should().Contain("Scopes"); + } + + [Fact] + public void ResolvePrimaryAction_Clients_wiredRow_dispatchesUnwire() + { + // Enter on a Clients row whose ContainsSourcegraphEntry == true must resolve to + // UnwireClient — the wire/unwire toggle the redesign added. + var snap = MakeSnapshot() with + { + Clients = new[] + { + new ClientRow("claude-code", "project", _configPath, Exists: true, ContainsSourcegraphEntry: true), + }, + }; + var sel = new DashboardSelection(DashboardSection.Clients, 0); + var action = DashboardPrimaryAction.Resolve(sel, snap); + action.Should().Be(DashboardAction.UnwireClient); + } + + [Fact] + public void ResolvePrimaryAction_Clients_unwiredRow_dispatchesWire() + { + var snap = MakeSnapshot() with + { + Clients = new[] + { + new ClientRow("claude-code", "project", _configPath, Exists: true, ContainsSourcegraphEntry: false), + }, + }; + var sel = new DashboardSelection(DashboardSection.Clients, 0); + var action = DashboardPrimaryAction.Resolve(sel, snap); + action.Should().Be(DashboardAction.WireClient); + } + + [Fact] + public void ResolvePrimaryAction_Scopes_dispatchesReindex() + { + var sel = new DashboardSelection(DashboardSection.Scopes, 0); + DashboardPrimaryAction.Resolve(sel, MakeSnapshot()).Should().Be(DashboardAction.ReindexScope); + } + + [Fact] + public void ResolvePrimaryAction_Embeddings_dispatchesPull() + { + var sel = new DashboardSelection(DashboardSection.Embeddings, 0); + DashboardPrimaryAction.Resolve(sel, MakeSnapshot()).Should().Be(DashboardAction.EmbeddingsPull); + } + + [Fact] + public void ResolvePrimaryAction_RecentActivity_returnsNone() + { + // No first-class detail pane yet; routing returns None so the dispatcher can surface + // a discoverability hint instead of hanging. + var sel = new DashboardSelection(DashboardSection.RecentActivity, 0); + DashboardPrimaryAction.Resolve(sel, MakeSnapshot()).Should().Be(DashboardAction.None); + } + + [Fact] + public void ResolveForView_Scopes_dispatchesReindex() + { + // The view-aware entry point that LoopState uses; same dispatch outcome as the legacy + // section-based one, but takes a DashboardView directly. + DashboardPrimaryAction.ResolveForView(DashboardView.Scopes, 0, MakeSnapshot()) + .Should().Be(DashboardAction.ReindexScope); + } + + [Fact] + public void ResolveForView_Clients_wired_dispatchesUnwire() + { + var snap = MakeSnapshot() with + { + Clients = new[] + { + new ClientRow("claude-code", "project", _configPath, Exists: true, ContainsSourcegraphEntry: true), + }, + }; + DashboardPrimaryAction.ResolveForView(DashboardView.Clients, 0, snap) + .Should().Be(DashboardAction.UnwireClient); + } + + [Fact] + public void ResolveForView_Embeddings_dispatchesPull() + { + DashboardPrimaryAction.ResolveForView(DashboardView.Embeddings, 0, MakeSnapshot()) + .Should().Be(DashboardAction.EmbeddingsPull); + } + + [Fact] + public void ResolveForView_Environment_dispatchesNone() + { + // Environment is read-only; no primary action. + DashboardPrimaryAction.ResolveForView(DashboardView.Environment, 0, MakeSnapshot()) + .Should().Be(DashboardAction.None); + } + + [Fact] + public async Task RunAsync_viewTransitionActions_returnNoop() + { + // The new view-transition actions (GoHome / OpenScopes / …) are handled by the LoopState + // before they ever reach the dispatcher. The dispatcher returns Noop for them so a stray + // call doesn't surface as "no primary action" toast. + var ctx = MakeContext(MakeSnapshot()); + foreach (var a in new[] + { + DashboardAction.GoHome, DashboardAction.OpenScopes, DashboardAction.OpenClients, + DashboardAction.OpenEmbeddings, DashboardAction.OpenRecentActivity, DashboardAction.OpenEnvironment, + }) + { + var r = await DashboardActions.RunAsync(a, ctx); + r.Ok.Should().BeTrue($"{a} should be Noop-success"); + r.Message.Should().BeEmpty($"{a} should not produce a status toast"); + } + } + + [Fact] + public void DashboardActionResult_severity_factoryDefaults() + { + // Spec: Success → Success, Failure → Fail, Noop → Info, Info → Info. + DashboardActionResult.Success("x").Severity.Should().Be(ToastSeverity.Success); + DashboardActionResult.Failure("x").Severity.Should().Be(ToastSeverity.Fail); + DashboardActionResult.Noop.Severity.Should().Be(ToastSeverity.Info); + DashboardActionResult.Info("x").Severity.Should().Be(ToastSeverity.Info); + } + + [Fact] + public void ResolveSourceGraphLaunch_prefers_processPath_dll() + { + // ProcessPath in test runs is typically the testhost or vstest .dll; the helper should + // detect the .dll suffix and prepend `dotnet`. + var (file, args) = DashboardActions.ResolveSourceGraphLaunch(new[] { "index", "/x.sln" }); + if (Environment.ProcessPath?.EndsWith(".dll", StringComparison.OrdinalIgnoreCase) == true) + { + file.Should().Be("dotnet"); + args[0].Should().EndWith(".dll"); + args.Last().Should().Be("/x.sln"); + } + else + { + // Apphost case: ProcessPath itself is the binary. + file.Should().NotBeNullOrEmpty(); + } + } + + // ──────────────────────────────────────────────────────────────────────────────── + // Scope management (Add / Remove) — dashboard's `N` and `D` actions + // ──────────────────────────────────────────────────────────────────────────────── + + [Fact] + public async Task RemoveScope_userDeclinesConfirm_doesNotMutateConfig() + { + var sgPath = Path.Join(_tempRoot, ".sourcegraph.json"); + var slnPath = Path.Join(_tempRoot, "x.slnx"); + File.WriteAllText(slnPath, ""); + File.WriteAllText(sgPath, """ + { + "scopes": [ + { "name": "default", "solutions": ["x.slnx"] }, + { "name": "other", "solutions": ["x.slnx"] } + ] + } + """); + var console = new Spectre.Console.Testing.TestConsole(); + console.Input.PushTextWithEnter("n"); // decline confirm + var ctx = new DashboardActionContext( + Snapshot: MakeSnapshotWithTwoScopes(), + Selection: new DashboardSelection(DashboardSection.Scopes, 0), + Console: console, + Freshness: null, + Root: _tempRoot, + Policy: DashboardActionPolicy.Default); + var before = File.ReadAllText(sgPath); + var result = await DashboardActions.RunAsync(DashboardAction.RemoveScope, ctx); + result.Ok.Should().BeTrue(); + result.Message.Should().Contain("cancelled"); + File.ReadAllText(sgPath).Should().Be(before, "config is unchanged when user declines confirm"); + } + + [Fact] + public async Task RemoveScope_userConfirms_removesFromConfig() + { + var sgPath = Path.Join(_tempRoot, ".sourcegraph.json"); + var slnPath = Path.Join(_tempRoot, "x.slnx"); + File.WriteAllText(slnPath, ""); + File.WriteAllText(sgPath, """ + { + "scopes": [ + { "name": "default", "solutions": ["x.slnx"] }, + { "name": "other", "solutions": ["x.slnx"] } + ] + } + """); + var console = new Spectre.Console.Testing.TestConsole(); + console.Input.PushTextWithEnter("y"); // confirm + var ctx = new DashboardActionContext( + Snapshot: MakeSnapshotWithTwoScopes(), + Selection: new DashboardSelection(DashboardSection.Scopes, 0), + Console: console, + Freshness: null, + Root: _tempRoot, + Policy: DashboardActionPolicy.Default); + var result = await DashboardActions.RunAsync(DashboardAction.RemoveScope, ctx); + result.Ok.Should().BeTrue(); + var after = File.ReadAllText(sgPath); + after.Should().NotContain("\"name\": \"default\""); + after.Should().Contain("\"name\": \"other\"", "other scopes are preserved"); + } + + [Fact] + public async Task RemoveScope_lastScope_isRefused() + { + var sgPath = Path.Join(_tempRoot, ".sourcegraph.json"); + File.WriteAllText(Path.Join(_tempRoot, "x.slnx"), ""); + File.WriteAllText(sgPath, """ + { + "scopes": [ + { "name": "only", "solutions": ["x.slnx"] } + ] + } + """); + var console = new Spectre.Console.Testing.TestConsole(); + var ctx = new DashboardActionContext( + Snapshot: MakeSnapshot(), + Selection: new DashboardSelection(DashboardSection.Scopes, 0), + Console: console, + Freshness: null, + Root: _tempRoot, + Policy: DashboardActionPolicy.Default); + var before = File.ReadAllText(sgPath); + var result = await DashboardActions.RunAsync(DashboardAction.RemoveScope, ctx); + result.Ok.Should().BeFalse(); + result.Message.Should().Contain("last scope"); + File.ReadAllText(sgPath).Should().Be(before, "guard refuses before any file mutation"); + } + + [Fact] + public async Task RemoveScope_indexingScope_isRefused() + { + var sgPath = Path.Join(_tempRoot, ".sourcegraph.json"); + File.WriteAllText(Path.Join(_tempRoot, "x.slnx"), ""); + File.WriteAllText(sgPath, """ + { + "scopes": [ + { "name": "default", "solutions": ["x.slnx"] }, + { "name": "other", "solutions": ["x.slnx"] } + ] + } + """); + var indexingSnapshot = MakeSnapshotWithTwoScopes() with + { + Scopes = new[] + { + new ScopeRow("default", "indexing", 0, 0, null, + Array.Empty(), Array.Empty(), false), + new ScopeRow("other", "ok", 100, 200, DateTimeOffset.UtcNow, + Array.Empty(), Array.Empty(), false), + }, + }; + var ctx = new DashboardActionContext( + Snapshot: indexingSnapshot, + Selection: new DashboardSelection(DashboardSection.Scopes, 0), + Console: new Spectre.Console.Testing.TestConsole(), + Freshness: null, + Root: _tempRoot, + Policy: DashboardActionPolicy.Default); + var result = await DashboardActions.RunAsync(DashboardAction.RemoveScope, ctx); + result.Ok.Should().BeFalse(); + result.Message.Should().Contain("indexing"); + } + + [Fact] + public async Task RemoveScope_wrongSection_returnsFailure() + { + var ctx = new DashboardActionContext( + Snapshot: MakeSnapshot(), + Selection: new DashboardSelection(DashboardSection.Clients, 0), + Console: new Spectre.Console.Testing.TestConsole(), + Freshness: null, + Root: _tempRoot, + Policy: DashboardActionPolicy.Default); + var result = await DashboardActions.RunAsync(DashboardAction.RemoveScope, ctx); + result.Ok.Should().BeFalse(); + result.Message.Should().Contain("Scopes"); + } + + [Fact] + public void AddScopeToConfig_validInput_persistsScope() + { + var sgPath = Path.Join(_tempRoot, ".sourcegraph.json"); + var slnPath = Path.Join(_tempRoot, "x.slnx"); + File.WriteAllText(slnPath, ""); + var config = DevBitsLab.Mcp.SourceGraph.Storage.ScopeConfigLoader.Synthesise( + _tempRoot, + new[] { slnPath }); + var result = DevBitsLab.Mcp.SourceGraph.Server.Cli.ScopesCli.AddScopeToConfig( + _tempRoot, config, "frontend", slnPath, isolated: false); + result.Ok.Should().BeTrue(); + File.Exists(sgPath).Should().BeTrue(); + File.ReadAllText(sgPath).Should().Contain("frontend"); + } + + [Fact] + public void AddScopeToConfig_invalidName_returnsFailure() + { + var slnPath = Path.Join(_tempRoot, "x.slnx"); + File.WriteAllText(slnPath, ""); + var config = DevBitsLab.Mcp.SourceGraph.Storage.ScopeConfigLoader.Synthesise( + _tempRoot, + new[] { slnPath }); + // Capital letters violate the kebab-case slug rule. + var result = DevBitsLab.Mcp.SourceGraph.Server.Cli.ScopesCli.AddScopeToConfig( + _tempRoot, config, "Frontend", slnPath, isolated: false); + result.Ok.Should().BeFalse(); + result.ExitCode.Should().Be(2); + result.Message.Should().Contain("Invalid"); + } + + [Fact] + public void AddScopeToConfig_duplicateName_returnsFailure() + { + var slnPath = Path.Join(_tempRoot, "x.slnx"); + File.WriteAllText(slnPath, ""); + var config = DevBitsLab.Mcp.SourceGraph.Storage.ScopeConfigLoader.Synthesise( + _tempRoot, + new[] { slnPath }); + // The synthesised default config already has a scope named "default". + var result = DevBitsLab.Mcp.SourceGraph.Server.Cli.ScopesCli.AddScopeToConfig( + _tempRoot, config, "default", slnPath, isolated: false); + result.Ok.Should().BeFalse(); + result.ExitCode.Should().Be(1); + result.Message.Should().Contain("already exists"); + } + + [Fact] + public void RemoveScopeFromConfig_missingName_returnsFailure() + { + var slnPath = Path.Join(_tempRoot, "x.slnx"); + File.WriteAllText(slnPath, ""); + var config = DevBitsLab.Mcp.SourceGraph.Storage.ScopeConfigLoader.Synthesise( + _tempRoot, + new[] { slnPath }); + var result = DevBitsLab.Mcp.SourceGraph.Server.Cli.ScopesCli.RemoveScopeFromConfig( + _tempRoot, config, "nonexistent"); + result.Ok.Should().BeFalse(); + result.ExitCode.Should().Be(1); + result.Message.Should().Contain("not found"); + } + + [Fact] + public void RemoveScopeFromConfig_existing_persistsRemoval() + { + var sgPath = Path.Join(_tempRoot, ".sourcegraph.json"); + var slnPath = Path.Join(_tempRoot, "x.slnx"); + File.WriteAllText(slnPath, ""); + File.WriteAllText(sgPath, """ + { + "scopes": [ + { "name": "frontend", "solutions": ["x.slnx"] }, + { "name": "backend", "solutions": ["x.slnx"] } + ] + } + """); + var config = DevBitsLab.Mcp.SourceGraph.Storage.ScopeConfigLoader.Load(_tempRoot); + var result = DevBitsLab.Mcp.SourceGraph.Server.Cli.ScopesCli.RemoveScopeFromConfig( + _tempRoot, config, "frontend"); + result.Ok.Should().BeTrue(); + var after = File.ReadAllText(sgPath); + after.Should().NotContain("\"name\": \"frontend\""); + after.Should().Contain("\"name\": \"backend\""); + } + + [Fact] + public void DashboardViewGating_AddScope_isScopesOnly() + { + DashboardViewGating.IsAllowed(DashboardAction.AddScope, DashboardView.Scopes).Should().BeTrue(); + DashboardViewGating.IsAllowed(DashboardAction.AddScope, DashboardView.Clients).Should().BeFalse(); + DashboardViewGating.IsAllowed(DashboardAction.AddScope, DashboardView.Home).Should().BeFalse(); + } + + [Fact] + public void DashboardViewGating_RemoveScope_isScopesOnly() + { + DashboardViewGating.IsAllowed(DashboardAction.RemoveScope, DashboardView.Scopes).Should().BeTrue(); + DashboardViewGating.IsAllowed(DashboardAction.RemoveScope, DashboardView.Clients).Should().BeFalse(); + DashboardViewGating.IsAllowed(DashboardAction.RemoveScope, DashboardView.Home).Should().BeFalse(); + } + + // ──────────────────────────────────────────────────────────────────────────────── + // Helpers + // ──────────────────────────────────────────────────────────────────────────────── + + private DashboardActionContext MakeContext(DashboardSnapshot snap, DashboardActionPolicy? policy = null) => + new( + Snapshot: snap, + Selection: new DashboardSelection(DashboardSection.Scopes, 0), + Console: new TestConsole(), + Freshness: null, + Root: _tempRoot, + Policy: policy ?? DashboardActionPolicy.Default); + + private DashboardSnapshot MakeSnapshot() => new( + Environment: new EnvironmentSurface( + DotnetSdkVersion: "10.0.100", + GitOnPath: true, + RepoRootPath: _tempRoot, + SolutionFiles: new[] { Path.Join(_tempRoot, "x.slnx") }, + SourceGraphConfigStatus: "missing", + SourceGraphConfigError: null), + Scopes: new[] + { + new ScopeRow("default", "ok", 100, 200, DateTimeOffset.UtcNow, + Array.Empty(), Array.Empty(), false), + }, + Clients: new[] + { + new ClientRow("claude-code", "project", _configPath, true, true), + }, + Embeddings: new EmbeddingsSurface( + ModelId: "test-model", + CacheDir: Path.Join(_tempRoot, "cache"), + CachePresent: false, + TotalBytes: 0, + Verified: false), + RecentActivity: Array.Empty(), + BuiltAt: DateTimeOffset.UtcNow, + UsageLogPath: Path.Join(_tempRoot, ".sourcegraph", "usage.jsonl"), + HealsLogPath: Path.Join(_tempRoot, ".sourcegraph", "heals.jsonl"), + ExitCode: 0); + + /// + /// Variant with two scopes (`default` + `other`) for tests that exercise the remove-scope + /// path, which now refuses to remove the last remaining scope. The two-scope shape lets + /// the action complete without firing the "last scope" guard. + /// + private DashboardSnapshot MakeSnapshotWithTwoScopes() => MakeSnapshot() with + { + Scopes = new[] + { + new ScopeRow("default", "ok", 100, 200, DateTimeOffset.UtcNow, + Array.Empty(), Array.Empty(), false), + new ScopeRow("other", "ok", 50, 75, DateTimeOffset.UtcNow, + Array.Empty(), Array.Empty(), false), + }, + }; + + // ──────────────────────────────────────────────────────────────────────────────── + // SplitCommandLine — $PAGER / $EDITOR argument parsing + // ──────────────────────────────────────────────────────────────────────────────── + + [Theory] + [InlineData("", "", new string[0])] + [InlineData("less", "less", new string[0])] + [InlineData("less -R", "less", new[] { "-R" })] + [InlineData("code --wait", "code", new[] { "--wait" })] + [InlineData("vim -p", "vim", new[] { "-p" })] + [InlineData("/usr/local/bin/less -R --quit", "/usr/local/bin/less", new[] { "-R", "--quit" })] + public void SplitCommandLine_splitsWhitespaceTokens(string input, string expectedFile, string[] expectedArgs) + { + var (file, args) = DashboardActions.SplitCommandLine(input); + file.Should().Be(expectedFile); + args.Should().Equal(expectedArgs); + } + + [Fact] + public void SplitCommandLine_doubleQuotedTokenPreservesSpaces() + { + var (file, args) = DashboardActions.SplitCommandLine("\"My Editor.exe\" --wait /tmp/foo"); + file.Should().Be("My Editor.exe"); + args.Should().Equal("--wait", "/tmp/foo"); + } + + [Fact] + public void SplitCommandLine_singleQuotedTokenPreservesSpaces() + { + var (file, args) = DashboardActions.SplitCommandLine("/usr/bin/code 'My Folder/file.txt'"); + file.Should().Be("/usr/bin/code"); + args.Should().Equal("My Folder/file.txt"); + } +} diff --git a/tests/DevBitsLab.Mcp.SourceGraph.Tests/Dashboard/DashboardCliViewTests.cs b/tests/DevBitsLab.Mcp.SourceGraph.Tests/Dashboard/DashboardCliViewTests.cs new file mode 100644 index 00000000..fd3c0fbe --- /dev/null +++ b/tests/DevBitsLab.Mcp.SourceGraph.Tests/Dashboard/DashboardCliViewTests.cs @@ -0,0 +1,321 @@ +using DevBitsLab.Mcp.SourceGraph.Server.Cli.Snapshot; +using DevBitsLab.Mcp.SourceGraph.Server.Dashboard; +using FluentAssertions; +using Xunit; + +namespace DevBitsLab.Mcp.SourceGraph.Tests.Dashboard; + +/// +/// View-model tests for the dashboard CLI: per-view primary-action dispatch, per-view action +/// gating, and the end-to-end key → action → view-transition contract for number keys and +/// Esc/h. Drives the static seams (, +/// , ) +/// without standing up the full LoopState (which depends on a live terminal). +/// +public sealed class DashboardCliViewTests +{ + // ──────────────────────────────────────────────────────────────────────────────── + // Number keys open the right view + // ──────────────────────────────────────────────────────────────────────────────── + + [Theory] + [InlineData('1', (int)DashboardAction.OpenScopes)] + [InlineData('2', (int)DashboardAction.OpenClients)] + [InlineData('3', (int)DashboardAction.OpenEmbeddings)] + [InlineData('4', (int)DashboardAction.OpenRecentActivity)] + [InlineData('5', (int)DashboardAction.OpenEnvironment)] + public void NumberKey_resolvesToMatchingOpenAction(char number, int expectedInt) + { + // The same key resolves the same way from any view — the LoopState dispatches view + // transitions regardless of the current view, so '2' from inside Embeddings still + // jumps to Clients. + var key = new ConsoleKeyInfo(number, ConsoleKey.D0 + (number - '0'), false, false, false); + DashboardKeyMap.TryResolve(key, out var action).Should().BeTrue(); + action.Should().Be((DashboardAction)expectedInt); + } + + [Fact] + public void Esc_resolvesToGoHome() + { + var key = new ConsoleKeyInfo((char)27, ConsoleKey.Escape, false, false, false); + DashboardKeyMap.TryResolve(key, out var action).Should().BeTrue(); + action.Should().Be(DashboardAction.GoHome); + } + + [Fact] + public void HKey_resolvesToGoHome() + { + var key = new ConsoleKeyInfo('h', ConsoleKey.H, false, false, false); + DashboardKeyMap.TryResolve(key, out var action).Should().BeTrue(); + action.Should().Be(DashboardAction.GoHome); + } + + // ──────────────────────────────────────────────────────────────────────────────── + // Section-specific actions are gated to the matching view + // ──────────────────────────────────────────────────────────────────────────────── + + [Fact] + public void Reindex_inScopesView_isAllowed() + { + DashboardViewGating.IsAllowed(DashboardAction.ReindexScope, DashboardView.Scopes).Should().BeTrue(); + } + + [Theory] + [InlineData((int)DashboardView.Clients)] + [InlineData((int)DashboardView.Embeddings)] + [InlineData((int)DashboardView.RecentActivity)] + [InlineData((int)DashboardView.Environment)] + [InlineData((int)DashboardView.Home)] + public void Reindex_inOtherView_isDropped(int viewInt) + { + // Firing 'r' (ReindexScope) from a non-Scopes view should drop silently — no toast, + // no Failure. The dispatcher returns without invoking the action. + DashboardViewGating.IsAllowed(DashboardAction.ReindexScope, (DashboardView)viewInt).Should().BeFalse(); + DashboardViewGating.IsAllowed(DashboardAction.RebuildScope, (DashboardView)viewInt).Should().BeFalse(); + } + + [Fact] + public void Wire_inClientsView_isAllowed() + { + DashboardViewGating.IsAllowed(DashboardAction.WireClient, DashboardView.Clients).Should().BeTrue(); + DashboardViewGating.IsAllowed(DashboardAction.UnwireClient, DashboardView.Clients).Should().BeTrue(); + } + + [Theory] + [InlineData((int)DashboardView.Scopes)] + [InlineData((int)DashboardView.Embeddings)] + [InlineData((int)DashboardView.RecentActivity)] + [InlineData((int)DashboardView.Environment)] + [InlineData((int)DashboardView.Home)] + public void Wire_inOtherView_isDropped(int viewInt) + { + DashboardViewGating.IsAllowed(DashboardAction.WireClient, (DashboardView)viewInt).Should().BeFalse(); + DashboardViewGating.IsAllowed(DashboardAction.UnwireClient, (DashboardView)viewInt).Should().BeFalse(); + } + + [Fact] + public void Pull_inEmbeddingsView_isAllowed() + { + DashboardViewGating.IsAllowed(DashboardAction.EmbeddingsPull, DashboardView.Embeddings).Should().BeTrue(); + DashboardViewGating.IsAllowed(DashboardAction.EmbeddingsVerify, DashboardView.Embeddings).Should().BeTrue(); + } + + [Theory] + [InlineData((int)DashboardView.Scopes)] + [InlineData((int)DashboardView.Clients)] + [InlineData((int)DashboardView.RecentActivity)] + [InlineData((int)DashboardView.Environment)] + [InlineData((int)DashboardView.Home)] + public void Pull_inOtherView_isDropped(int viewInt) + { + DashboardViewGating.IsAllowed(DashboardAction.EmbeddingsPull, (DashboardView)viewInt).Should().BeFalse(); + DashboardViewGating.IsAllowed(DashboardAction.EmbeddingsVerify, (DashboardView)viewInt).Should().BeFalse(); + } + + // ──────────────────────────────────────────────────────────────────────────────── + // View-agnostic actions always allowed + // ──────────────────────────────────────────────────────────────────────────────── + + [Theory] + [InlineData((int)DashboardView.Home)] + [InlineData((int)DashboardView.Scopes)] + [InlineData((int)DashboardView.Clients)] + [InlineData((int)DashboardView.Embeddings)] + [InlineData((int)DashboardView.RecentActivity)] + [InlineData((int)DashboardView.Environment)] + public void Quit_isAllowedInEveryView(int viewInt) + { + DashboardViewGating.IsAllowed(DashboardAction.Quit, (DashboardView)viewInt).Should().BeTrue(); + DashboardViewGating.IsAllowed(DashboardAction.ForceRefresh, (DashboardView)viewInt).Should().BeTrue(); + DashboardViewGating.IsAllowed(DashboardAction.ToggleHelp, (DashboardView)viewInt).Should().BeTrue(); + } + + [Theory] + [InlineData((int)DashboardView.Home)] + [InlineData((int)DashboardView.Scopes)] + [InlineData((int)DashboardView.Clients)] + [InlineData((int)DashboardView.Embeddings)] + [InlineData((int)DashboardView.RecentActivity)] + [InlineData((int)DashboardView.Environment)] + public void ViewTransitions_areAllowedInEveryView(int viewInt) + { + var view = (DashboardView)viewInt; + DashboardViewGating.IsAllowed(DashboardAction.GoHome, view).Should().BeTrue(); + DashboardViewGating.IsAllowed(DashboardAction.OpenScopes, view).Should().BeTrue(); + DashboardViewGating.IsAllowed(DashboardAction.OpenClients, view).Should().BeTrue(); + DashboardViewGating.IsAllowed(DashboardAction.OpenEmbeddings, view).Should().BeTrue(); + DashboardViewGating.IsAllowed(DashboardAction.OpenRecentActivity, view).Should().BeTrue(); + DashboardViewGating.IsAllowed(DashboardAction.OpenEnvironment, view).Should().BeTrue(); + } + + [Theory] + [InlineData((int)DashboardView.Home)] + [InlineData((int)DashboardView.Scopes)] + [InlineData((int)DashboardView.Clients)] + [InlineData((int)DashboardView.Embeddings)] + [InlineData((int)DashboardView.RecentActivity)] + [InlineData((int)DashboardView.Environment)] + public void GuidedActions_alwaysAllowed(int viewInt) + { + var view = (DashboardView)viewInt; + DashboardViewGating.IsAllowed(DashboardAction.InitGuided, view).Should().BeTrue(); + DashboardViewGating.IsAllowed(DashboardAction.DemoGuided, view).Should().BeTrue(); + DashboardViewGating.IsAllowed(DashboardAction.OpenLogInPager, view).Should().BeTrue(); + DashboardViewGating.IsAllowed(DashboardAction.OpenConfigInEditor, view).Should().BeTrue(); + } + + // ──────────────────────────────────────────────────────────────────────────────── + // Display name / section mapping + // ──────────────────────────────────────────────────────────────────────────────── + + [Theory] + [InlineData((int)DashboardView.Home, "Home")] + [InlineData((int)DashboardView.Scopes, "Scopes")] + [InlineData((int)DashboardView.Clients, "Clients")] + [InlineData((int)DashboardView.Embeddings, "Embeddings")] + [InlineData((int)DashboardView.RecentActivity, "Recent activity")] + [InlineData((int)DashboardView.Environment, "Environment")] + public void DashboardView_DisplayName(int viewInt, string expected) + { + ((DashboardView)viewInt).DisplayName().Should().Be(expected); + } + + [Fact] + public void DashboardView_Home_hasNoSection() + { + DashboardView.Home.ToSection().Should().BeNull(); + } + + [Theory] + [InlineData((int)DashboardView.Scopes, (int)DashboardSection.Scopes)] + [InlineData((int)DashboardView.Clients, (int)DashboardSection.Clients)] + [InlineData((int)DashboardView.Embeddings, (int)DashboardSection.Embeddings)] + [InlineData((int)DashboardView.RecentActivity, (int)DashboardSection.RecentActivity)] + [InlineData((int)DashboardView.Environment, (int)DashboardSection.Environment)] + public void DashboardView_DetailViews_mapToSections(int viewInt, int sectionInt) + { + ((DashboardView)viewInt).ToSection().Should().Be((DashboardSection)sectionInt); + } + + // ──────────────────────────────────────────────────────────────────────────────── + // Primary action dispatch (view-aware) + // ──────────────────────────────────────────────────────────────────────────────── + + [Fact] + public void PrimaryAction_inEnvironment_isNone() + { + // Environment is read-only — Enter does nothing. + DashboardPrimaryAction.ResolveForView(DashboardView.Environment, 0, MakeSnapshot()) + .Should().Be(DashboardAction.None); + } + + [Fact] + public void PrimaryAction_inRecentActivity_isNone() + { + // Recent activity has no first-class detail view yet — Enter surfaces a hint via toast. + DashboardPrimaryAction.ResolveForView(DashboardView.RecentActivity, 0, MakeSnapshot()) + .Should().Be(DashboardAction.None); + } + + // ──────────────────────────────────────────────────────────────────────────────── + // Live-suspend classification (regression for "Enter on already-wired client crashed + // with Spectre concurrency error" — the resolved UnwireClient must be classified as + // suspend-Live so the main loop drops out of the Live region before ConfirmModal opens) + // ──────────────────────────────────────────────────────────────────────────────── + + [Theory] + [InlineData((int)DashboardAction.UnwireClient, true)] + [InlineData((int)DashboardAction.RemoveScope, true)] + [InlineData((int)DashboardAction.RebuildScope, true)] + [InlineData((int)DashboardAction.ReindexScope, true)] + [InlineData((int)DashboardAction.AddScope, true)] + [InlineData((int)DashboardAction.InitGuided, true)] + [InlineData((int)DashboardAction.DemoGuided, true)] + [InlineData((int)DashboardAction.OpenLogInPager, true)] + [InlineData((int)DashboardAction.OpenConfigInEditor, true)] + [InlineData((int)DashboardAction.WireClient, false)] + [InlineData((int)DashboardAction.EmbeddingsPull, false)] + [InlineData((int)DashboardAction.EmbeddingsVerify, false)] + [InlineData((int)DashboardAction.MoveUp, false)] + [InlineData((int)DashboardAction.PrimaryAction, false)] + public void LiveSuspend_classifiesActions(int actionInt, bool requiresSuspend) + { + DashboardLiveSuspend.RequiresSuspend((DashboardAction)actionInt) + .Should().Be(requiresSuspend); + } + + [Fact] + public void EnterOnWiredClient_resolvesToUnwire_andRequiresSuspend() + { + // The composition that previously crashed: Enter on Clients view + a wired client + // resolves to UnwireClient (which shows ConfirmModal). The main loop relies on + // RequiresSuspend(resolved) to drop Live BEFORE the prompt opens. If this returns + // false we're back to the Spectre "interactive functions concurrently" exception. + var snap = MakeSnapshot() with + { + Clients = new[] + { + new ClientRow("continue", "project", "/r/.continue/mcp/sourcegraph.yaml", true, true), + }, + }; + var resolved = DashboardPrimaryAction.ResolveForView(DashboardView.Clients, 0, snap); + resolved.Should().Be(DashboardAction.UnwireClient); + DashboardLiveSuspend.RequiresSuspend(resolved).Should().BeTrue(); + } + + [Fact] + public void EnterOnUnwiredClient_resolvesToWire_andStaysInLive() + { + // The other half of the toggle: an unwired client resolves to WireClient, which is + // pure file IO and safely runs inside the Live region. + var snap = MakeSnapshot() with + { + Clients = new[] + { + new ClientRow("continue", "project", "/r/.continue/mcp/sourcegraph.yaml", true, false), + }, + }; + var resolved = DashboardPrimaryAction.ResolveForView(DashboardView.Clients, 0, snap); + resolved.Should().Be(DashboardAction.WireClient); + DashboardLiveSuspend.RequiresSuspend(resolved).Should().BeFalse(); + } + + [Fact] + public void EnterOnScopesView_resolvesToReindex_andRequiresSuspend() + { + // Enter on Scopes view dispatches reindex, which shells out to `sourcegraph-mcp index` + // with inherited stdio — must suspend Live for the same reason as the modal path. + var resolved = DashboardPrimaryAction.ResolveForView(DashboardView.Scopes, 0, MakeSnapshot()); + resolved.Should().Be(DashboardAction.ReindexScope); + DashboardLiveSuspend.RequiresSuspend(resolved).Should().BeTrue(); + } + + private static DashboardSnapshot MakeSnapshot() => new( + Environment: new EnvironmentSurface( + DotnetSdkVersion: "10.0.100", + GitOnPath: true, + RepoRootPath: "/r", + SolutionFiles: new[] { "/r/x.slnx" }, + SourceGraphConfigStatus: "valid", + SourceGraphConfigError: null), + Scopes: new[] + { + new ScopeRow("default", "ok", 1000, 2000, DateTimeOffset.UtcNow, + Array.Empty(), Array.Empty(), false), + }, + Clients: new[] + { + new ClientRow("claude-code", "project", "/r/.mcp.json", true, true), + }, + Embeddings: new EmbeddingsSurface( + ModelId: "test-model", + CacheDir: "/h/.cache", + CachePresent: true, + TotalBytes: 1024, + Verified: false), + RecentActivity: Array.Empty(), + BuiltAt: DateTimeOffset.UtcNow, + UsageLogPath: "/r/.sourcegraph/usage.jsonl", + HealsLogPath: "/r/.sourcegraph/heals.jsonl", + ExitCode: 0); +} diff --git a/tests/DevBitsLab.Mcp.SourceGraph.Tests/Dashboard/DashboardKeyMapTests.cs b/tests/DevBitsLab.Mcp.SourceGraph.Tests/Dashboard/DashboardKeyMapTests.cs new file mode 100644 index 00000000..8e9d8101 --- /dev/null +++ b/tests/DevBitsLab.Mcp.SourceGraph.Tests/Dashboard/DashboardKeyMapTests.cs @@ -0,0 +1,183 @@ +using DevBitsLab.Mcp.SourceGraph.Server.Dashboard; +using FluentAssertions; +using Xunit; + +namespace DevBitsLab.Mcp.SourceGraph.Tests.Dashboard; + +/// +/// Covers : every documented binding resolves to exactly one action, +/// alias bindings (j, k, hEsc) resolve to +/// the same target, and unmapped keys return false without throwing. +/// +public sealed class DashboardKeyMapTests +{ + // Pass DashboardAction as int in the [MemberData] tuples — the type is internal and xunit + // 2.x's row serialiser only handles types public-visible from outside the assembly. The + // method casts back inside the body. + public static IEnumerable DocumentedKeys() + { + // (description, ConsoleKey, KeyChar, modifiers, expected action as int) + yield return new object[] { "UpArrow", ConsoleKey.UpArrow, '\0', (int)ConsoleModifiers.None, (int)DashboardAction.MoveUp }; + yield return new object[] { "DownArrow", ConsoleKey.DownArrow, '\0', (int)ConsoleModifiers.None, (int)DashboardAction.MoveDown }; + yield return new object[] { "Enter", ConsoleKey.Enter, '\r', (int)ConsoleModifiers.None, (int)DashboardAction.PrimaryAction }; + // Esc and 'h' both fire GoHome under the new view-aware model. + yield return new object[] { "Esc", ConsoleKey.Escape, (char)27, (int)ConsoleModifiers.None, (int)DashboardAction.GoHome }; + yield return new object[] { "h home", ConsoleKey.H, 'h', (int)ConsoleModifiers.None, (int)DashboardAction.GoHome }; + yield return new object[] { "q", ConsoleKey.Q, 'q', (int)ConsoleModifiers.None, (int)DashboardAction.Quit }; + yield return new object[] { "Q", ConsoleKey.Q, 'Q', (int)ConsoleModifiers.Shift, (int)DashboardAction.Quit }; + yield return new object[] { "Ctrl+C", ConsoleKey.C, '\u0003', (int)ConsoleModifiers.Control, (int)DashboardAction.Quit }; + yield return new object[] { "?", ConsoleKey.Oem2, '?', (int)ConsoleModifiers.Shift, (int)DashboardAction.ToggleHelp }; + yield return new object[] { "s", ConsoleKey.S, 's', (int)ConsoleModifiers.None, (int)DashboardAction.ForceRefresh }; + yield return new object[] { "j alias for down", ConsoleKey.J, 'j', (int)ConsoleModifiers.None, (int)DashboardAction.MoveDown }; + yield return new object[] { "k alias for up", ConsoleKey.K, 'k', (int)ConsoleModifiers.None, (int)DashboardAction.MoveUp }; + // Numeric jumps from home (also work from detail views — dispatcher resolves the transition). + yield return new object[] { "1 → Scopes", ConsoleKey.D1, '1', (int)ConsoleModifiers.None, (int)DashboardAction.OpenScopes }; + yield return new object[] { "2 → Clients", ConsoleKey.D2, '2', (int)ConsoleModifiers.None, (int)DashboardAction.OpenClients }; + yield return new object[] { "3 → Embeddings", ConsoleKey.D3, '3', (int)ConsoleModifiers.None, (int)DashboardAction.OpenEmbeddings }; + yield return new object[] { "4 → Recent activity", ConsoleKey.D4, '4', (int)ConsoleModifiers.None, (int)DashboardAction.OpenRecentActivity }; + yield return new object[] { "5 → Environment", ConsoleKey.D5, '5', (int)ConsoleModifiers.None, (int)DashboardAction.OpenEnvironment }; + // Section actions — keymap is view-agnostic; dispatcher gates per-view. + yield return new object[] { "r reindex", ConsoleKey.R, 'r', (int)ConsoleModifiers.None, (int)DashboardAction.ReindexScope }; + yield return new object[] { "R rebuild", ConsoleKey.R, 'R', (int)ConsoleModifiers.Shift, (int)DashboardAction.RebuildScope }; + yield return new object[] { "N new scope", ConsoleKey.N, 'N', (int)ConsoleModifiers.Shift, (int)DashboardAction.AddScope }; + yield return new object[] { "D delete scope", ConsoleKey.D, 'D', (int)ConsoleModifiers.Shift, (int)DashboardAction.RemoveScope }; + yield return new object[] { "w wire", ConsoleKey.W, 'w', (int)ConsoleModifiers.None, (int)DashboardAction.WireClient }; + yield return new object[] { "u unwire", ConsoleKey.U, 'u', (int)ConsoleModifiers.None, (int)DashboardAction.UnwireClient }; + yield return new object[] { "p pull", ConsoleKey.P, 'p', (int)ConsoleModifiers.None, (int)DashboardAction.EmbeddingsPull }; + yield return new object[] { "v verify", ConsoleKey.V, 'v', (int)ConsoleModifiers.None, (int)DashboardAction.EmbeddingsVerify }; + yield return new object[] { "i init", ConsoleKey.I, 'i', (int)ConsoleModifiers.None, (int)DashboardAction.InitGuided }; + yield return new object[] { "d demo", ConsoleKey.D, 'd', (int)ConsoleModifiers.None, (int)DashboardAction.DemoGuided }; + yield return new object[] { "l log pager", ConsoleKey.L, 'l', (int)ConsoleModifiers.None, (int)DashboardAction.OpenLogInPager }; + yield return new object[] { "e editor", ConsoleKey.E, 'e', (int)ConsoleModifiers.None, (int)DashboardAction.OpenConfigInEditor }; + } + + [Theory] + [MemberData(nameof(DocumentedKeys))] + public void TryResolve_documentedKey_resolvesToExpectedAction(string description, ConsoleKey key, char keyChar, int modifiers, int expectedInt) + { + var expected = (DashboardAction)expectedInt; + var info = new ConsoleKeyInfo(keyChar, key, + shift: ((ConsoleModifiers)modifiers & ConsoleModifiers.Shift) != 0, + alt: ((ConsoleModifiers)modifiers & ConsoleModifiers.Alt) != 0, + control: ((ConsoleModifiers)modifiers & ConsoleModifiers.Control) != 0); + var ok = DashboardKeyMap.TryResolve(info, out var action); + ok.Should().BeTrue($"binding '{description}' should resolve"); + action.Should().Be(expected, $"binding '{description}'"); + } + + [Fact] + public void TryResolve_Tab_isNoLongerBound() + { + // Tab cycle was removed in the home/detail-view rewrite; Tab now resolves to None so + // pressing it from any view is silently dropped (no spurious navigation). + var info = new ConsoleKeyInfo('\t', ConsoleKey.Tab, shift: false, alt: false, control: false); + DashboardKeyMap.TryResolve(info, out var action).Should().BeFalse(); + action.Should().Be(DashboardAction.None); + } + + [Fact] + public void TryResolve_unmappedLetter_returnsFalse() + { + // 'z' is unmapped; should return false without throwing. + var info = new ConsoleKeyInfo('z', ConsoleKey.Z, shift: false, alt: false, control: false); + var ok = DashboardKeyMap.TryResolve(info, out var action); + ok.Should().BeFalse(); + action.Should().Be(DashboardAction.None); + } + + [Fact] + public void TryResolve_F1_returnsFalse() + { + // Function keys are unmapped. + var info = new ConsoleKeyInfo('\0', ConsoleKey.F1, shift: false, alt: false, control: false); + DashboardKeyMap.TryResolve(info, out _).Should().BeFalse(); + } + + [Fact] + public void TryResolve_jAndDownArrow_resolveToSameAction() + { + // Alias invariant: j is the documented vim-style alias for ↓. + DashboardKeyMap.TryResolve(new ConsoleKeyInfo('j', ConsoleKey.J, false, false, false), out var ja); + DashboardKeyMap.TryResolve(new ConsoleKeyInfo('\0', ConsoleKey.DownArrow, false, false, false), out var down); + ja.Should().Be(down); + } + + [Fact] + public void TryResolve_kAndUpArrow_resolveToSameAction() + { + DashboardKeyMap.TryResolve(new ConsoleKeyInfo('k', ConsoleKey.K, false, false, false), out var ka); + DashboardKeyMap.TryResolve(new ConsoleKeyInfo('\0', ConsoleKey.UpArrow, false, false, false), out var up); + ka.Should().Be(up); + } + + [Fact] + public void TryResolve_hAndEsc_resolveToSameAction() + { + // 'h' is the documented vim-style alias for Esc → GoHome. + DashboardKeyMap.TryResolve(new ConsoleKeyInfo('h', ConsoleKey.H, false, false, false), out var ha); + DashboardKeyMap.TryResolve(new ConsoleKeyInfo((char)27, ConsoleKey.Escape, false, false, false), out var esc); + ha.Should().Be(esc); + ha.Should().Be(DashboardAction.GoHome); + } + + [Fact] + public void For_home_advertisesMenuKeys() + { + // The home-view footer hint mentions selection + open + jump + help + quit. + var hint = DashboardKeyMap.For(DashboardView.Home); + hint.Should().Contain("select"); + hint.Should().Contain("open"); + hint.Should().Contain("quit"); + } + + [Fact] + public void For_scopes_advertisesScopeActionKeys() + { + var hint = DashboardKeyMap.For(DashboardView.Scopes); + hint.Should().Contain("reindex"); + hint.Should().Contain("rebuild"); + hint.Should().Contain("new"); // [N] new scope + hint.Should().Contain("delete"); // [D] delete scope + hint.Should().Contain("home"); + } + + [Fact] + public void For_clients_advertisesWireUnwire() + { + var hint = DashboardKeyMap.For(DashboardView.Clients); + hint.Should().Contain("wire"); + hint.Should().Contain("unwire"); + } + + [Fact] + public void For_environment_isHomeAndQuitOnly() + { + var hint = DashboardKeyMap.For(DashboardView.Environment); + hint.Should().Contain("home"); + hint.Should().Contain("quit"); + hint.Should().NotContain("reindex"); + hint.Should().NotContain("wire"); + hint.Should().NotContain("pull"); + } + + [Fact] + public void HelpText_mentionsEveryDocumentedKey() + { + // Sanity: the on-screen help text should at least mention each tier of key. Catches the + // common drift where the table grows but the inline overlay forgets a binding. + DashboardKeyMap.HelpText.Should() + .Contain("Navigation") + .And.Contain("Reindex") + .And.Contain("Rebuild") + .And.Contain("New scope") + .And.Contain("Delete selected scope") + .And.Contain("Wire") + .And.Contain("Unwire") + .And.Contain("Embeddings pull") + .And.Contain("Embeddings verify") + .And.Contain("init") + .And.Contain("demo") + // Home navigation should be discoverable in the help. + .And.Contain("Home"); + } +} diff --git a/tests/DevBitsLab.Mcp.SourceGraph.Tests/Dashboard/DashboardRendererTests.cs b/tests/DevBitsLab.Mcp.SourceGraph.Tests/Dashboard/DashboardRendererTests.cs new file mode 100644 index 00000000..a218bc8c --- /dev/null +++ b/tests/DevBitsLab.Mcp.SourceGraph.Tests/Dashboard/DashboardRendererTests.cs @@ -0,0 +1,483 @@ +using DevBitsLab.Mcp.SourceGraph.Server.Cli.Snapshot; +using DevBitsLab.Mcp.SourceGraph.Server.Dashboard; +using DevBitsLab.Mcp.SourceGraph.Server.Tools; +using FluentAssertions; +using Spectre.Console; +using Spectre.Console.Rendering; +using Xunit; + +namespace DevBitsLab.Mcp.SourceGraph.Tests.Dashboard; + +/// +/// Renderer tests for the dashboard's home view + per-detail-view +/// builders. Spectre's API captures the rendered output as a +/// string for assertion; we don't compare against a frozen golden file because that would +/// couple the test to Spectre's internal escape sequences (which can shift across versions). +/// Instead we assert structural invariants: title appears, breadcrumb present on detail views, +/// Selected: drawer appears when selection is valid, ASCII fallback under --no-leaf. +/// +[Collection("CliConsole")] +public sealed class DashboardRendererTests : IDisposable +{ + private readonly bool _initialSuppressed; + + public DashboardRendererTests() + { + _initialSuppressed = LeafFormatter.Suppressed; + LeafFormatter.Suppressed = false; + } + + public void Dispose() + { + LeafFormatter.Suppressed = _initialSuppressed; + } + + // ──────────────────────────────────────────────────────────────────────────────── + // Home view + // ──────────────────────────────────────────────────────────────────────────────── + + [Fact] + public void BuildHome_emitsAllFiveSummaryLabels() + { + var output = RenderToString(_ => DashboardRenderer.BuildHome(Healthy(), DefaultOptions(), menuIndex: 0)); + output.Should().Contain("Environment"); + output.Should().Contain("Scopes"); + output.Should().Contain("Clients"); + output.Should().Contain("Embeddings"); + output.Should().Contain("Recent activity"); + } + + [Fact] + public void BuildHome_emitsAllFiveMenuNumbers() + { + var output = RenderToString(_ => DashboardRenderer.BuildHome(Healthy(), DefaultOptions(), menuIndex: 0)); + // The numeric menu prefixes 1..5 should all appear. + foreach (var n in new[] { "1", "2", "3", "4", "5" }) + { + output.Should().Contain(n, $"menu should advertise number {n}"); + } + } + + [Fact] + public void BuildHome_highlightedRow_drawsSelectionDot() + { + var output = RenderToString(_ => DashboardRenderer.BuildHome(Healthy(), DefaultOptions(), menuIndex: 2)); + // Brand-coloured fisheye ◉ marks the highlighted menu row; muted ○ for the rest. + output.Should().Contain(DashboardTheme.SelectedDot); + output.Should().Contain(DashboardTheme.UnselectedDot); + } + + [Fact] + public void BuildHome_emptyScopes_summaryRowIsOff() + { + var snap = Healthy() with { Scopes = Array.Empty() }; + var output = RenderToString(_ => DashboardRenderer.BuildHome(snap, DefaultOptions(), menuIndex: 0)); + // Empty-scopes summary text uses the "(none)" tag. + output.Should().Contain("Scopes"); + output.Should().Contain("(none)"); + } + + [Fact] + public void BuildHome_recentActivity_summarisesLastEvent() + { + var output = RenderToString(_ => DashboardRenderer.BuildHome(Healthy(), DefaultOptions(), menuIndex: 0)); + // The "last: " segment is the most informative bit; assert it appears. + output.Should().Contain("last:"); + } + + // ──────────────────────────────────────────────────────────────────────────────── + // Detail views + // ──────────────────────────────────────────────────────────────────────────────── + + [Fact] + public void BuildScopesDetail_emitsRowsAndDrawer() + { + var snap = Healthy() with + { + Scopes = new[] + { + new ScopeRow("frontend", "ok", 1000, 2000, + new DateTimeOffset(2025, 1, 1, 12, 0, 0, TimeSpan.Zero), + Array.Empty(), Array.Empty(), false), + new ScopeRow("backend", "partial", 500, 1000, + new DateTimeOffset(2025, 1, 1, 11, 0, 0, TimeSpan.Zero), + new[] { "Legacy.csproj" }, Array.Empty(), false), + }, + }; + var output = RenderToString(_ => DashboardRenderer.BuildScopesDetail(snap, DefaultOptions(), selectedRow: 1)); + output.Should().Contain("frontend"); + output.Should().Contain("backend"); + // Selection drawer for the selected (backend) scope. + output.Should().Contain("Selected: backend"); + // Failed-projects bullets for the partial scope. + output.Should().Contain("Failed projects"); + output.Should().Contain("Legacy.csproj"); + } + + [Fact] + public void BuildScopesDetail_emptyScopes_emitsParenthetical() + { + var snap = Healthy() with { Scopes = Array.Empty() }; + var output = RenderToString(_ => DashboardRenderer.BuildScopesDetail(snap, DefaultOptions())); + output.Should().Contain("(no scopes registered"); + } + + [Fact] + public void BuildScopesDetail_focused_drawsCursorOnSelectedRow() + { + var snap = Healthy() with + { + Scopes = new[] + { + new ScopeRow("a", "ok", 1, 1, null, Array.Empty(), Array.Empty(), false), + new ScopeRow("b", "ok", 1, 1, null, Array.Empty(), Array.Empty(), false), + }, + }; + var output = RenderToString(_ => DashboardRenderer.BuildScopesDetail(snap, DefaultOptions(), selectedRow: 1)); + // The new model: ◉ marks the selected row in the leading column; ○ marks non-selected. + output.Should().Contain(DashboardTheme.SelectedDot); + output.Should().Contain(DashboardTheme.UnselectedDot); + } + + [Fact] + public void BuildClientsDetail_emitsRowsAndDrawer() + { + var output = RenderToString(_ => DashboardRenderer.BuildClientsDetail(Healthy(), DefaultOptions(), selectedRow: 0)); + output.Should().Contain("claude-code"); + output.Should().Contain("Selected: claude-code"); + output.Should().Contain("scope"); + output.Should().Contain("target"); + output.Should().Contain("state"); + } + + [Fact] + public void BuildClientsDetail_emptyClients_emitsParentheticalNotice() + { + var snap = Healthy() with { Clients = Array.Empty() }; + var output = RenderToString(_ => DashboardRenderer.BuildClientsDetail(snap, DefaultOptions())); + output.Should().Contain("(no client configs detected)"); + } + + [Fact] + public void BuildEmbeddingsDetail_emitsModelAndCache() + { + var output = RenderToString(_ => DashboardRenderer.BuildEmbeddingsDetail(Healthy(), DefaultOptions())); + output.Should().Contain("Model"); + output.Should().Contain("jina-embeddings"); + output.Should().Contain("Cache"); + output.Should().Contain("Verified"); + } + + [Fact] + public void BuildEmbeddingsDetail_absentCache_marksAsAbsent() + { + var snap = Healthy() with + { + Embeddings = new EmbeddingsSurface( + ModelId: "jinaai/jina-embeddings-v2-base-code", + CacheDir: "/h/.cache", + CachePresent: false, + TotalBytes: 0, + Verified: false), + }; + var output = RenderToString(_ => DashboardRenderer.BuildEmbeddingsDetail(snap, DefaultOptions())); + output.Should().Contain("(absent)"); + } + + [Fact] + public void BuildRecentActivityDetail_emitsTimes_andDetails() + { + var output = RenderToString(_ => DashboardRenderer.BuildRecentActivityDetail(Healthy(), DefaultOptions(), selectedRow: 0)); + output.Should().Contain("search_symbols"); + output.Should().Contain("Selected:"); + } + + [Fact] + public void BuildRecentActivityDetail_emptyActivity_emitsParenthetical() + { + var snap = Healthy() with { RecentActivity = Array.Empty() }; + var output = RenderToString(_ => DashboardRenderer.BuildRecentActivityDetail(snap, DefaultOptions())); + output.Should().Contain("(no recorded activity)"); + } + + [Fact] + public void BuildEnvironmentDetail_emitsKeysOnly_noRowSelection() + { + var output = RenderToString(_ => DashboardRenderer.BuildEnvironmentDetail(Healthy(), DefaultOptions())); + output.Should().Contain(".NET SDK"); + output.Should().Contain("git on PATH"); + output.Should().Contain("repo root"); + output.Should().Contain(".sourcegraph.json"); + } + + // ──────────────────────────────────────────────────────────────────────────────── + // Header / footer / fallback + // ──────────────────────────────────────────────────────────────────────────────── + + [Fact] + public void BuildHeader_home_includesBrandMarkAndPath() + { + var output = RenderToString(_ => DashboardRenderer.BuildHeader(Healthy(), DefaultOptions(), DashboardView.Home)); + output.Should().Contain("SourceGraph"); + if (!LeafFormatter.Suppressed) + output.Should().Contain("🌿"); + else + output.Should().Contain("[x]"); + } + + [Fact] + public void BuildHeader_detailView_includesBreadcrumbAndBackHint() + { + var output = RenderToString(_ => DashboardRenderer.BuildHeader(Healthy(), DefaultOptions(), DashboardView.Clients)); + output.Should().Contain("SourceGraph"); + // Section name in the breadcrumb. + output.Should().Contain("Clients"); + // Back-to-home hint right-aligned. + output.Should().Contain("back to home"); + } + + [Fact] + public void BuildHeader_detailView_includesBreadcrumbForRecentActivity() + { + // The Recent Activity view uses a multi-word display name; make sure that name appears. + var output = RenderToString(_ => DashboardRenderer.BuildHeader(Healthy(), DefaultOptions(), DashboardView.RecentActivity)); + output.Should().Contain("Recent activity"); + } + + [Fact] + public void BuildFooter_home_advertisesHomeKeys() + { + var output = RenderToString(_ => DashboardRenderer.BuildFooter(default, DashboardView.Home)); + // Home footer mentions menu navigation, jump, help, quit. + output.Should().Contain("select"); + output.Should().Contain("open"); + output.Should().Contain("quit"); + } + + [Fact] + public void BuildFooter_scopesView_advertisesReindexAndRebuild() + { + var output = RenderToString(_ => DashboardRenderer.BuildFooter(default, DashboardView.Scopes)); + output.Should().Contain("reindex"); + output.Should().Contain("rebuild"); + output.Should().Contain("home"); + } + + [Fact] + public void BuildFooter_clientsView_advertisesWireAndUnwire() + { + var output = RenderToString(_ => DashboardRenderer.BuildFooter(default, DashboardView.Clients)); + output.Should().Contain("wire"); + output.Should().Contain("unwire"); + output.Should().Contain("home"); + } + + [Fact] + public void BuildFooter_embeddingsView_advertisesPullAndVerify() + { + var output = RenderToString(_ => DashboardRenderer.BuildFooter(default, DashboardView.Embeddings)); + output.Should().Contain("pull"); + output.Should().Contain("verify"); + output.Should().Contain("home"); + } + + [Fact] + public void BuildFooter_environmentView_advertisesHomeAndQuitOnly() + { + var output = RenderToString(_ => DashboardRenderer.BuildFooter(default, DashboardView.Environment)); + output.Should().Contain("home"); + output.Should().Contain("quit"); + // No wire / reindex / pull keys — Environment is read-only. + output.Should().NotContain("reindex"); + output.Should().NotContain("pull"); + } + + [Fact] + public void Render_noLeaf_substitutesAsciiGlyphs() + { + LeafFormatter.Suppressed = true; + // Use the Environment detail builder — it's the simplest dot-bearing view and renders + // each dot in a stable position regardless of column wrap. Spectre's default test + // console width can split [x] across two visual lines if the row's text column wraps; + // Environment detail has short row content and the dot survives intact. + var output = RenderToString(_ => DashboardRenderer.BuildEnvironmentDetail(Healthy(), DefaultOptions())); + output.Should().NotContain("🌿"); + // ASCII fallback tokens used by DashboardTheme in --no-leaf mode. + output.Should().MatchRegex(@"\[[ xX!\-]\]"); + } + + [Fact] + public void FormatRelativeTime_shortDelta_secondsAgo() + { + DashboardRenderer.FormatRelativeTime(TimeSpan.FromSeconds(45)).Should().Be("45s ago"); + DashboardRenderer.FormatRelativeTime(TimeSpan.FromMinutes(11)).Should().Be("11m ago"); + DashboardRenderer.FormatRelativeTime(TimeSpan.FromHours(5)).Should().Be("5h ago"); + DashboardRenderer.FormatRelativeTime(TimeSpan.FromDays(3)).Should().Be("3d ago"); + } + + [Fact] + public void SelectionDot_default_returnsFisheyeAndHollowCircle() + { + // Default mode: selected → brand-coloured fisheye ◉; not-selected → muted hollow ○. + var sel = DashboardTheme.SelectionDot(selected: true); + var unsel = DashboardTheme.SelectionDot(selected: false); + sel.Should().Contain(DashboardTheme.SelectedDot); + sel.Should().Contain(DashboardTheme.Brand); + unsel.Should().Contain(DashboardTheme.UnselectedDot); + unsel.Should().Contain(DashboardTheme.Muted); + } + + [Fact] + public void SelectionDot_noLeaf_returnsBracketTokens() + { + // Under --no-leaf: selected → [[>]], not-selected → [[ ]] (escape-doubled brackets so + // Spectre's Markup parser doesn't interpret them). + LeafFormatter.Suppressed = true; + var sel = DashboardTheme.SelectionDot(selected: true); + var unsel = DashboardTheme.SelectionDot(selected: false); + sel.Should().Be("[[>]]"); + unsel.Should().Be("[[ ]]"); + } + + [Fact] + public void HomeMenuEntries_haveStableShape() + { + // The menu drives both the renderer (selection cursor) and the dispatcher (number-key + // jumps land on the right view). Lock in the order so a refactor can't accidentally + // remap '3' from Embeddings to something else. + DashboardRenderer.HomeMenuEntries.Should().HaveCount(5); + DashboardRenderer.HomeMenuEntries[0].View.Should().Be(DashboardView.Scopes); + DashboardRenderer.HomeMenuEntries[1].View.Should().Be(DashboardView.Clients); + DashboardRenderer.HomeMenuEntries[2].View.Should().Be(DashboardView.Embeddings); + DashboardRenderer.HomeMenuEntries[3].View.Should().Be(DashboardView.RecentActivity); + DashboardRenderer.HomeMenuEntries[4].View.Should().Be(DashboardView.Environment); + } + + // ──────────────────────────────────────────────────────────────────────────────── + // Cell / Viewport helpers (the deterministic-row-layout primitives that replaced Spectre Grid) + // ──────────────────────────────────────────────────────────────────────────────── + + [Theory] + [InlineData("foo", 8, "foo ")] // pads right + [InlineData("toolongname", 8, "toolong…")] // truncates with ellipsis + [InlineData("", 5, " ")] // empty pads to width + [InlineData("a", 1, "a")] // exact fit + [InlineData("ab", 1, "…")] // collapses to just the ellipsis at width 1 + public void Cell_padsAndTruncatesAsExpected(string plain, int width, string expected) + { + // Cell with no color tag returns the raw padded/truncated text (with Markup.Escape applied). + // For the inputs here, none of the characters are markup-significant so Markup.Escape is a no-op. + DashboardRenderer.Cell(plain, width).Should().Be(expected); + } + + [Fact] + public void Cell_appliesColorTag_andTruncates() + { + // The colour wraps the truncated visible text; padding is OUTSIDE the colour tag so the + // ANSI reset doesn't apply to the trailing spaces. + var result = DashboardRenderer.Cell("toolong", 5, "red"); + result.Should().Be("[red]tool…[/]"); + } + + [Fact] + public void Cell_rightAligns() + { + var result = DashboardRenderer.Cell("42", 5, rightAligned: true); + result.Should().Be(" 42"); + } + + [Fact] + public void Viewport_listFitsWithinWindow_returnsFullRange() + { + var (start, end, above, below) = DashboardRenderer.Viewport(totalRows: 3, selectedRow: 1, maxVisible: 8); + start.Should().Be(0); + end.Should().Be(3); + above.Should().Be(0); + below.Should().Be(0); + } + + [Fact] + public void Viewport_listLargerThanWindow_centersOnSelected() + { + // 20 rows, 8 visible, selected at row 10 → window centered around row 10. + var (start, end, above, below) = DashboardRenderer.Viewport(totalRows: 20, selectedRow: 10, maxVisible: 8); + (end - start).Should().Be(8); + (start <= 10 && 10 < end).Should().BeTrue("selected row must be inside the window"); + above.Should().BeGreaterThan(0); + below.Should().BeGreaterThan(0); + } + + [Fact] + public void Viewport_selectedNearEnd_clampsToTail() + { + // 20 rows, 8 visible, selected at last row → window slides to the end. + var (start, end, above, below) = DashboardRenderer.Viewport(totalRows: 20, selectedRow: 19, maxVisible: 8); + start.Should().Be(12); + end.Should().Be(20); + above.Should().Be(12); + below.Should().Be(0); + } + + [Fact] + public void Viewport_selectedAtStart_clampsToHead() + { + var (start, end, above, below) = DashboardRenderer.Viewport(totalRows: 20, selectedRow: 0, maxVisible: 8); + start.Should().Be(0); + end.Should().Be(8); + above.Should().Be(0); + below.Should().Be(12); + } + + // ──────────────────────────────────────────────────────────────────────────────── + // Helpers + // ──────────────────────────────────────────────────────────────────────────────── + + private static string RenderToString(Func build) + { + var console = AnsiConsole.Create(new AnsiConsoleSettings + { + // Plain output for tests — no colour codes to slice through. + ColorSystem = ColorSystemSupport.NoColors, + Ansi = AnsiSupport.No, + Out = new AnsiConsoleOutput(new StringWriter()), + }); + console.Write(build(console)); + return ((StringWriter)console.Profile.Out.Writer).ToString(); + } + + private static DashboardRenderOptions DefaultOptions() => + new(Root: "/r", Home: "/h", Version: "0.8.0"); + + private static DashboardSnapshot Healthy() => new( + Environment: new EnvironmentSurface( + DotnetSdkVersion: "10.0.100", + GitOnPath: true, + RepoRootPath: "/r", + SolutionFiles: new[] { "/r/x.slnx" }, + SourceGraphConfigStatus: "valid", + SourceGraphConfigError: null), + Scopes: new[] + { + new ScopeRow("default", "ok", 1000, 2000, + new DateTimeOffset(2025, 1, 1, 12, 0, 0, TimeSpan.Zero), + Array.Empty(), Array.Empty(), false), + }, + Clients: new[] + { + new ClientRow("claude-code", "project", "/r/.mcp.json", true, true), + }, + Embeddings: new EmbeddingsSurface( + ModelId: "jinaai/jina-embeddings-v2-base-code", + CacheDir: "/h/.cache/devbitslab.sourcegraph/models", + CachePresent: true, + TotalBytes: 614 * 1024 * 1024, + Verified: false), + RecentActivity: new[] + { + new ActivityEntry(DateTimeOffset.UtcNow.AddMinutes(-1), "tool_call", "default", true, 3, "search_symbols"), + }, + BuiltAt: DateTimeOffset.UtcNow, + UsageLogPath: "/r/.sourcegraph/usage.jsonl", + HealsLogPath: "/r/.sourcegraph/heals.jsonl", + ExitCode: 0); +} diff --git a/tests/DevBitsLab.Mcp.SourceGraph.Tests/Dashboard/FreshnessSourceTests.cs b/tests/DevBitsLab.Mcp.SourceGraph.Tests/Dashboard/FreshnessSourceTests.cs new file mode 100644 index 00000000..572fce47 --- /dev/null +++ b/tests/DevBitsLab.Mcp.SourceGraph.Tests/Dashboard/FreshnessSourceTests.cs @@ -0,0 +1,154 @@ +using DevBitsLab.Mcp.SourceGraph.Server.Cli.Snapshot; +using DevBitsLab.Mcp.SourceGraph.Server.Dashboard; +using FluentAssertions; +using Xunit; +// Disambiguate ScopeRow (the snapshot row vs the storage row). +using ScopeRow = DevBitsLab.Mcp.SourceGraph.Server.Cli.Snapshot.ScopeRow; +// ScopeLayout lives in Storage; named-import to keep the using terse. +using ScopeLayout = DevBitsLab.Mcp.SourceGraph.Storage.ScopeLayout; + +namespace DevBitsLab.Mcp.SourceGraph.Tests.Dashboard; + +/// +/// Tests for : that the coalescing rule holds under bursts of +/// watcher events, that the poll tick produces at most one rebuild per second, and that +/// disposal stops the loop cleanly. We inject a synthetic ISnapshotSource via the +/// internal test seam so we can count rebuilds deterministically without standing up a real +/// repo / SQLite registry. +/// +public sealed class FreshnessSourceTests : IDisposable +{ + private readonly string _tempRoot; + + public FreshnessSourceTests() + { + _tempRoot = Path.Join(Path.GetTempPath(), "sg-freshness-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempRoot); + Directory.CreateDirectory(Path.Join(_tempRoot, ScopeLayout.DotDir)); + } + + public void Dispose() + { + try { Directory.Delete(_tempRoot, recursive: true); } catch { } + } + + [Fact] + public async Task Start_fires_initialRebuild_inUnder200ms() + { + var source = new CountingSnapshotSource(); + using var fs = new FreshnessSource(_tempRoot, new SnapshotOptions(), source); + var rebuilds = 0; + fs.SnapshotChanged += _ => Interlocked.Increment(ref rebuilds); + fs.Start(); + // Give the immediate-on-Start rebuild time to settle. + await WaitForAsync(() => rebuilds >= 1, TimeSpan.FromMilliseconds(500)); + rebuilds.Should().Be(1, "Start() triggers exactly one immediate rebuild"); + } + + [Fact] + public async Task PollTick_under_steadyState_atMostOneRebuildPerSecond() + { + var source = new CountingSnapshotSource(); + using var fs = new FreshnessSource(_tempRoot, new SnapshotOptions(), source); + fs.Start(); + // Let the loop run for ~2.5 seconds; expect rebuilds: 1 immediate + 2 polls = 3. + await Task.Delay(2500); + // Allow a +/- 1 slack for clock skew on the timer. + source.Count.Should().BeInRange(2, 4); + } + + [Fact] + public async Task WatcherBurst_within_window_coalescesToOneExtraRebuild() + { + var source = new CountingSnapshotSource(); + using var fs = new FreshnessSource(_tempRoot, new SnapshotOptions(), source); + fs.Start(); + await WaitForAsync(() => source.Count >= 1, TimeSpan.FromMilliseconds(500)); + var baseline = source.Count; + + // Fire 10 rapid watcher events on usage.jsonl. The 100 ms debounce coalesces them all. + var usagePath = Path.Join(_tempRoot, ScopeLayout.DotDir, "usage.jsonl"); + for (var i = 0; i < 10; i++) + { + File.AppendAllText(usagePath, $"{{\"ts\":\"2025-01-01T00:00:00Z\",\"tool\":\"x{i}\"}}\n"); + await Task.Delay(10); + } + // Wait long enough for the 100 ms debounce + rebuild to complete. + await WaitForAsync(() => source.Count > baseline, TimeSpan.FromSeconds(2)); + // We expect at most a few extra rebuilds (1 from the burst itself; possibly 1 more from + // a poll tick that lands within our wait window). Strict assertion: never 10. + (source.Count - baseline).Should().BeLessThanOrEqualTo(3, "the burst of 10 events should coalesce"); + } + + [Fact] + public void Dispose_stopsAllResources() + { + var source = new CountingSnapshotSource(); + var fs = new FreshnessSource(_tempRoot, new SnapshotOptions(), source); + fs.Start(); + fs.Dispose(); + // Second Dispose is a no-op. + fs.Dispose(); + // The dispose path doesn't throw and leaves the source's last count fixed. + } + + [Fact] + public async Task RequestImmediateRebuild_bypassesCoalesce() + { + var source = new CountingSnapshotSource(); + using var fs = new FreshnessSource(_tempRoot, new SnapshotOptions(), source); + fs.Start(); + await WaitForAsync(() => source.Count >= 1, TimeSpan.FromMilliseconds(500)); + var baseline = source.Count; + // 50ms is well inside the 1s coalescing window — the point of this test is that + // `RequestImmediateRebuild()` deliberately bypasses that window. A coalesced rebuild + // wouldn't fire for another ~950ms; the immediate path runs now. + await Task.Delay(50); + fs.RequestImmediateRebuild(); + await WaitForAsync(() => source.Count > baseline, TimeSpan.FromSeconds(2)); + source.Count.Should().BeGreaterThan(baseline); + } + + private static async Task WaitForAsync(Func predicate, TimeSpan timeout) + { + var sw = System.Diagnostics.Stopwatch.StartNew(); + while (sw.Elapsed < timeout) + { + if (predicate()) return; + await Task.Delay(25); + } + // Returning silently here would let a broken FreshnessSource appear "fine" — the test + // would assert against an unchanged baseline and pass spuriously. Fail loudly with the + // elapsed time so a timeout is clearly distinguishable from a real assertion failure. + throw new Xunit.Sdk.XunitException( + $"WaitForAsync timed out after {sw.Elapsed.TotalMilliseconds:F0}ms (timeout: {timeout.TotalMilliseconds:F0}ms); predicate never returned true."); + } + + private sealed class CountingSnapshotSource : FreshnessSource.ISnapshotSource + { + private int _count; + public int Count => Volatile.Read(ref _count); + + public Task BuildAsync(string root, SnapshotOptions options, CancellationToken ct) + { + Interlocked.Increment(ref _count); + var snap = new DashboardSnapshot( + Environment: new EnvironmentSurface( + DotnetSdkVersion: "10.0.100", + GitOnPath: true, + RepoRootPath: root, + SolutionFiles: Array.Empty(), + SourceGraphConfigStatus: "missing", + SourceGraphConfigError: null), + Scopes: Array.Empty(), + Clients: Array.Empty(), + Embeddings: new EmbeddingsSurface("test", "/tmp", false, 0, false), + RecentActivity: Array.Empty(), + BuiltAt: DateTimeOffset.UtcNow, + UsageLogPath: Path.Join(root, ".sourcegraph", "usage.jsonl"), + HealsLogPath: Path.Join(root, ".sourcegraph", "heals.jsonl"), + ExitCode: 0); + return Task.FromResult(snap); + } + } +} diff --git a/tests/DevBitsLab.Mcp.SourceGraph.Tests/DevBitsLab.Mcp.SourceGraph.Tests.csproj b/tests/DevBitsLab.Mcp.SourceGraph.Tests/DevBitsLab.Mcp.SourceGraph.Tests.csproj index b1052bdf..d7122ff1 100644 --- a/tests/DevBitsLab.Mcp.SourceGraph.Tests/DevBitsLab.Mcp.SourceGraph.Tests.csproj +++ b/tests/DevBitsLab.Mcp.SourceGraph.Tests/DevBitsLab.Mcp.SourceGraph.Tests.csproj @@ -16,6 +16,7 @@ all + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/tests/DevBitsLab.Mcp.SourceGraph.Tests/DoctorCli/golden/healthy.human.txt b/tests/DevBitsLab.Mcp.SourceGraph.Tests/DoctorCli/golden/healthy.human.txt new file mode 100644 index 00000000..45288be6 --- /dev/null +++ b/tests/DevBitsLab.Mcp.SourceGraph.Tests/DoctorCli/golden/healthy.human.txt @@ -0,0 +1,11 @@ +🌿 SourceGraph doctor + + [OK] dotnet-sdk .NET SDK __SDK_VERSION__ + [OK] git git on PATH + [OK] repo-root repo root: __ROOT__ + [OK] solutions discovered 1 solution(s): Test.slnx + [OK] sourcegraph-config no .sourcegraph.json (single-scope synth path) + [WARN] embedding-cache embedding model cache absent at __CACHE_DIR__ — `semantic_search` will return its disabled-message until model files are placed there (or pass --no-embeddings to silence) + [OK] db-writable per-scope DB dir writable: __ROOT__/.sourcegraph/scopes + +summary: 6 pass, 1 warn, 0 fail diff --git a/tests/DevBitsLab.Mcp.SourceGraph.Tests/DoctorCli/golden/healthy.json b/tests/DevBitsLab.Mcp.SourceGraph.Tests/DoctorCli/golden/healthy.json new file mode 100644 index 00000000..4e30370e --- /dev/null +++ b/tests/DevBitsLab.Mcp.SourceGraph.Tests/DoctorCli/golden/healthy.json @@ -0,0 +1,40 @@ +{ + "checks": [ + { + "name": "dotnet-sdk", + "status": "pass", + "message": ".NET SDK __SDK_VERSION__" + }, + { + "name": "git", + "status": "pass", + "message": "git on PATH" + }, + { + "name": "repo-root", + "status": "pass", + "message": "repo root: __ROOT__" + }, + { + "name": "solutions", + "status": "pass", + "message": "discovered 1 solution(s): Test.slnx" + }, + { + "name": "sourcegraph-config", + "status": "pass", + "message": "no .sourcegraph.json (single-scope synth path)" + }, + { + "name": "embedding-cache", + "status": "warn", + "message": "embedding model cache absent at __CACHE_DIR__ \u2014 \u0060semantic_search\u0060 will return its disabled-message until model files are placed there (or pass --no-embeddings to silence)" + }, + { + "name": "db-writable", + "status": "pass", + "message": "per-scope DB dir writable: __ROOT__/.sourcegraph/scopes" + } + ], + "exit_code": 2 +} diff --git a/tests/DevBitsLab.Mcp.SourceGraph.Tests/DoctorCli/golden/partial.json b/tests/DevBitsLab.Mcp.SourceGraph.Tests/DoctorCli/golden/partial.json new file mode 100644 index 00000000..83ee63a5 --- /dev/null +++ b/tests/DevBitsLab.Mcp.SourceGraph.Tests/DoctorCli/golden/partial.json @@ -0,0 +1,40 @@ +{ + "checks": [ + { + "name": "dotnet-sdk", + "status": "pass", + "message": ".NET SDK __SDK_VERSION__" + }, + { + "name": "git", + "status": "pass", + "message": "git on PATH" + }, + { + "name": "repo-root", + "status": "pass", + "message": "repo root: __ROOT__" + }, + { + "name": "solutions", + "status": "pass", + "message": "discovered 1 solution(s): Test.slnx" + }, + { + "name": "sourcegraph-config", + "status": "fail", + "message": ".sourcegraph.json malformed: .sourcegraph.json is not valid JSON: \u0027t\u0027 is an invalid start of a property name. Expected a \u0027\u0022\u0027. Path: $ | LineNumber: 0 | BytePositionInLine: 2." + }, + { + "name": "embedding-cache", + "status": "warn", + "message": "embedding model cache absent at __CACHE_DIR__ \u2014 \u0060semantic_search\u0060 will return its disabled-message until model files are placed there (or pass --no-embeddings to silence)" + }, + { + "name": "db-writable", + "status": "pass", + "message": "per-scope DB dir writable: __ROOT__/.sourcegraph/scopes" + } + ], + "exit_code": 1 +} diff --git a/tests/DevBitsLab.Mcp.SourceGraph.Tests/DoctorCliGoldenTests.cs b/tests/DevBitsLab.Mcp.SourceGraph.Tests/DoctorCliGoldenTests.cs new file mode 100644 index 00000000..46583b6f --- /dev/null +++ b/tests/DevBitsLab.Mcp.SourceGraph.Tests/DoctorCliGoldenTests.cs @@ -0,0 +1,260 @@ +using System.Text.RegularExpressions; +using DevBitsLab.Mcp.SourceGraph.Server.Cli; +using Xunit; + +namespace DevBitsLab.Mcp.SourceGraph.Tests; + +/// +/// Golden-file parity tests for the doctor subcommand. The intent is to pin the +/// observable output bytes — both prose and --json — through the upcoming refactor +/// of DoctorCli onto the snapshot. If a future change shifts the wording, the +/// emitted shape, or the exit-code semantics, these tests will fail at the diff site and +/// the change author can update the golden file deliberately. +/// +/// +/// Machine-specific values (SDK version, home-relative paths, cache sizes) are normalised +/// to placeholders before comparison so the goldens are stable across dev machines and CI +/// runners. Normalisation is intentionally narrow: only fields the doctor surface emits +/// from 's detection or from ModelStore.DefaultCacheDir. +/// +/// +/// To keep the goldens deterministic across hosts — a dev machine with an embedding cache +/// and Claude Desktop installed produces different doctor output than a fresh CI runner — +/// the fixture isolates HOME / USERPROFILE / XDG_CACHE_HOME / +/// LOCALAPPDATA / APPDATA to temp directories for the duration of each test. +/// Detection then sees a clean home with no cache and no client configs, which matches the +/// shape committed in the golden files. +/// +/// +[Collection("CliConsole")] +public sealed class DoctorCliGoldenTests : IDisposable +{ + private readonly string _tempRoot; + private readonly string _isolatedHome; + private readonly string _isolatedCache; + private readonly TextWriter _originalStdout; + private readonly TextWriter _originalStderr; + private readonly StringWriter _stdout = new(); + private readonly StringWriter _stderr = new(); + private readonly Dictionary _savedEnv = new(); + + public DoctorCliGoldenTests() + { + _tempRoot = Path.Join(Path.GetTempPath(), "sg-doctor-golden-" + Guid.NewGuid().ToString("N")); + _isolatedHome = Path.Join(Path.GetTempPath(), "sg-doctor-home-" + Guid.NewGuid().ToString("N")); + _isolatedCache = Path.Join(Path.GetTempPath(), "sg-doctor-cache-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempRoot); + Directory.CreateDirectory(_isolatedHome); + Directory.CreateDirectory(_isolatedCache); + + // Isolate detection-relevant env vars so the golden output is independent of the dev + // machine's actual state (an existing embedding cache or a Claude Desktop install would + // otherwise leak into the doctor output and diverge from CI). + SaveAndSet("HOME", _isolatedHome); + SaveAndSet("USERPROFILE", _isolatedHome); + SaveAndSet("XDG_CACHE_HOME", _isolatedCache); + SaveAndSet("LOCALAPPDATA", _isolatedCache); + SaveAndSet("APPDATA", Path.Join(_isolatedHome, "AppData", "Roaming")); + + _originalStdout = Console.Out; + _originalStderr = Console.Error; + Console.SetOut(_stdout); + Console.SetError(_stderr); + } + + public void Dispose() + { + Console.SetOut(_originalStdout); + Console.SetError(_originalStderr); + foreach (var (key, value) in _savedEnv) + { + Environment.SetEnvironmentVariable(key, value); + } + try { Directory.Delete(_tempRoot, recursive: true); } + catch (IOException) { /* best-effort cleanup */ } + catch (UnauthorizedAccessException) { /* best-effort cleanup */ } + try { Directory.Delete(_isolatedHome, recursive: true); } + catch (IOException) { /* best-effort cleanup */ } + catch (UnauthorizedAccessException) { /* best-effort cleanup */ } + try { Directory.Delete(_isolatedCache, recursive: true); } + catch (IOException) { /* best-effort cleanup */ } + catch (UnauthorizedAccessException) { /* best-effort cleanup */ } + } + + private void SaveAndSet(string name, string value) + { + _savedEnv[name] = Environment.GetEnvironmentVariable(name); + Environment.SetEnvironmentVariable(name, value); + } + + [Fact] + public async Task Doctor_json_healthy_matchesGolden() + { + File.WriteAllText(Path.Join(_tempRoot, "Test.slnx"), ""); + var cli = CommandLine.Parse(new[] { "doctor", "--root", _tempRoot, "--json" }); + var rc = await DoctorCli.RunAsync(cli); + Assert.Contains(rc, new[] { 0, 2 }); + var actual = Normalise(_stdout.ToString(), _tempRoot); + AssertOrCreateGolden(actual, "healthy.json"); + } + + [Fact] + public async Task Doctor_json_partial_matchesGolden() + { + // "Partial" fixture: a malformed `.sourcegraph.json` triggers a hard-fail check; + // the other checks pass. Pinning the json output catches any change in either the + // human-readable message text or the json field ordering. + File.WriteAllText(Path.Join(_tempRoot, ".sourcegraph.json"), "{ this is broken"); + File.WriteAllText(Path.Join(_tempRoot, "Test.slnx"), ""); + var cli = CommandLine.Parse(new[] { "doctor", "--root", _tempRoot, "--json" }); + var rc = await DoctorCli.RunAsync(cli); + Assert.Equal(1, rc); + var actual = Normalise(_stdout.ToString(), _tempRoot); + AssertOrCreateGolden(actual, "partial.json"); + } + + [Fact] + public async Task Doctor_human_healthy_matchesGolden() + { + File.WriteAllText(Path.Join(_tempRoot, "Test.slnx"), ""); + var cli = CommandLine.Parse(new[] { "doctor", "--root", _tempRoot }); + var rc = await DoctorCli.RunAsync(cli); + Assert.Contains(rc, new[] { 0, 2 }); + var actual = Normalise(_stdout.ToString(), _tempRoot); + AssertOrCreateGolden(actual, "healthy.human.txt"); + } + + /// + /// Compare against the bytes at tests/.../DoctorCli/golden/{name}. + /// If the golden file doesn't yet exist, write it and fail loudly so the change author knows + /// they're seeding a new baseline rather than asserting against one. + /// + /// + /// We deliberately avoid FluentAssertions' .Should().Be(expected, because) here: its + /// failure-message formatter routes the failure text (which embeds ) + /// through , and JSON output's {/} + /// literals get interpreted as format placeholders, which throws before the real diff is + /// shown. xunit's formats no template — it just + /// reports the byte-diff position cleanly. + /// + private static void AssertOrCreateGolden(string actual, string name) + { + var goldenPath = LocateGoldenFile(name); + if (!File.Exists(goldenPath)) + { + Directory.CreateDirectory(Path.GetDirectoryName(goldenPath)!); + File.WriteAllText(goldenPath, actual); + throw new Xunit.Sdk.XunitException( + $"Golden file '{name}' did not exist; wrote a fresh baseline at {goldenPath}. " + + "Re-run the test to assert against the newly-seeded golden."); + } + var expected = File.ReadAllText(goldenPath); + Assert.Equal(expected, actual); + } + + private static string LocateGoldenFile(string name) + { + // Walk upward from the test assembly's location until we find a directory that contains + // tests/DevBitsLab.Mcp.SourceGraph.Tests. That's the repo's tests dir. + var current = AppContext.BaseDirectory; + for (int i = 0; i < 8; i++) + { + var candidate = Path.Join(current, "tests", "DevBitsLab.Mcp.SourceGraph.Tests", "DoctorCli", "golden", name); + if (Directory.Exists(Path.GetDirectoryName(candidate)!)) + { + return candidate; + } + // Try repo-root-anchored layout for the case where current is inside bin/. + var local = Path.Join(current, "DoctorCli", "golden", name); + if (Directory.Exists(Path.GetDirectoryName(local)!)) + { + return local; + } + current = Path.GetDirectoryName(current) ?? current; + } + // Final fallback: drop the file next to the test assembly (CI sandboxes don't have a + // writeable source tree, so the AssertOrCreateGolden path will write a fresh baseline + // there if needed and the assertion will run against that copy on subsequent runs). + return Path.Join(AppContext.BaseDirectory, "DoctorCli", "golden", name); + } + + /// + /// Strip machine-specific bits from a doctor output so the golden file is stable across + /// dev machines and CI runners. Replaces: + /// - .NET SDK 9.0.123.NET SDK __SDK_VERSION__ + /// - Absolute temp-root paths → __ROOT__ + /// - User home directory → __HOME__ + /// - Cache size (NNN MB)(__SIZE__) + /// - Fluent Assertions' license preamble (it's written to on + /// first use and leaks into our capture; pre-stripped so the golden stays focused on + /// the actual doctor output). + /// - Windows backslash separators collapsed to forward slashes so the same goldens work + /// on Linux / macOS / Windows runners (path separators aren't part of doctor's + /// observable contract). + /// + private static string Normalise(string s, string tempRoot) + { + // Fluent Assertions writes a license preamble starting with " Warning:" the first + // time an assertion runs in the process. It's a Console.Out side effect, not part of + // doctor's output; strip everything from that marker to the end before normalising the + // doctor surface. + var faIdx = s.IndexOf("\n Warning:", StringComparison.Ordinal); + if (faIdx >= 0) + { + s = s.Substring(0, faIdx); + // Drop any trailing whitespace/newlines left after truncation. + s = s.TrimEnd() + "\n"; + } + + // Path-separator normalisation. The two output forms (JSON vs human-readable) need + // different handling and are mutually exclusive in a single call, so we branch on the + // first non-whitespace character: + // + // - JSON: a lone `\` is part of an escape sequence (`—`, `'`, `\n`, …) and + // MUST NOT be touched. A Windows path separator appears as the escaped form `\\` + // (two chars). We collapse only `\\` → `/`. + // + // - Human-readable: backslashes only appear as Windows path separators (the doctor + // surface emits no other backslash use), so a blanket single `\` → `/` is safe. + // + // After the in-string normalisation, the local `tempRoot` / `home` variables we replace + // below are also normalised to `/` so they match the post-normalisation form of `s`. + var isJson = s.TrimStart().StartsWith('{'); + s = isJson ? s.Replace(@"\\", "/") : s.Replace('\\', '/'); + tempRoot = tempRoot.Replace('\\', '/'); + + s = s.Replace(tempRoot, "__ROOT__"); + + // Cache-dir substitution runs BEFORE the home substitution. Reason: on Windows the + // fixture's USERPROFILE override doesn't reach `Environment.GetFolderPath(UserProfile)` + // — .NET uses SHGetKnownFolderPath, which reads from the registry, not the env var — + // so `home` ends up being the runner's REAL home (e.g. `C:/Users/runneradmin`). The + // isolated cache dir is rooted at `Path.GetTempPath()` which on Windows lives under + // that real home (`C:/Users/runneradmin/AppData/Local/Temp/sg-doctor-cache-…`). If + // we ran the home replace first, it would consume the `C:/Users/runneradmin` prefix + // of the cache path and the cache regex would only catch what's left, producing + // `__HOME____CACHE_DIR__`. By doing cache first we consume the full cache path + // before home can partial-match it. + // + // The regex allows an optional `[A-Za-z]:` drive prefix (Windows after backslash + // normalisation has paths like `C:/Users/...`); on Unix it just matches a path + // starting at `/`. + s = Regex.Replace(s, @"(?:[A-Za-z]:)?/[A-Za-z0-9_./-]+/devbitslab\.sourcegraph/models", "__CACHE_DIR__"); + + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile, + Environment.SpecialFolderOption.DoNotVerify); + if (!string.IsNullOrEmpty(home)) + { + home = home.Replace('\\', '/'); + s = s.Replace(home, "__HOME__"); + } + // Normalise common machine-specific fields. + s = Regex.Replace(s, @"\.NET SDK \d+\.\d+\.\d+", ".NET SDK __SDK_VERSION__"); + s = Regex.Replace(s, @"\(\d+ MB\)", "(__SIZE__)"); + // Belt-and-braces: catch any post-home-replace `__HOME__/.cache/...` form that wasn't + // matched by the absolute-path regex above. Mostly defensive — the cache-first ordering + // makes this unreachable in normal cases. + s = Regex.Replace(s, @"__HOME__/\.cache/devbitslab\.sourcegraph/models", "__CACHE_DIR__"); + return s; + } +} diff --git a/tests/DevBitsLab.Mcp.SourceGraph.Tests/OnboardingCliTests.cs b/tests/DevBitsLab.Mcp.SourceGraph.Tests/OnboardingCliTests.cs index 49b41d16..eb75a6d3 100644 --- a/tests/DevBitsLab.Mcp.SourceGraph.Tests/OnboardingCliTests.cs +++ b/tests/DevBitsLab.Mcp.SourceGraph.Tests/OnboardingCliTests.cs @@ -149,16 +149,31 @@ public async Task Init_noCursor_skipsCursor() } [Fact] - public async Task Init_claudeDesktopOptIn_isRequired() + public async Task Init_claudeDesktop_isExplicitlyOptedOutByDefault() { - var cli = ParseInit("--print-only"); + // Under the polished init, Claude Desktop is detection-driven: default-on iff its + // platform-specific config exists. To make this test independent of the dev's actual + // home directory, we explicitly opt out with --no-claude-desktop. + var cli = ParseInit("--print-only", "--no-claude-desktop"); await InitCli.RunAsync(cli); var output = _stdout.ToString(); - // Claude Desktop's user-scope path is platform specific but always contains "Claude". - // Without --claude-desktop, it should be absent. output.Should().NotContain("claude_desktop_config.json"); } + [Fact] + public async Task Init_claudeDesktop_forcedOnByFlag() + { + // --claude-desktop forces the picker default-on regardless of detection. The + // --print-only path means the would-write snippet is emitted but no user-tree file is + // touched. + var cli = ParseInit("--print-only", "--claude-desktop"); + await InitCli.RunAsync(cli); + var output = _stdout.ToString(); + // The Claude Desktop user-scope path always contains "Claude" (case-sensitive per the + // platform-specific layout). + output.Should().Contain("claude_desktop_config.json"); + } + [Fact] public async Task Init_existingMatching_reportsNoChange() { @@ -187,7 +202,9 @@ public async Task Init_userCopilot_exitsZero_withInformationalSkip() var rc = await InitCli.RunAsync(cli); rc.Should().Be(0); _stderr.ToString().Should().Contain("user-scope Copilot"); - _stdout.ToString().Should().Contain("skipped (unsupported)"); + // The polished renderer emits "skipped — unsupported" with an em-dash for the skip-row + // verb; the substring "unsupported" identifies the kind regardless of separator style. + _stdout.ToString().Should().Contain("unsupported"); } [Fact] @@ -219,6 +236,58 @@ public async Task Init_malformedSourcegraphJson_failsFast() rc.Should().Be(1); _stderr.ToString().Should().Contain("malformed"); } + + [Fact] + public async Task Init_diff_rendersUnifiedDiff_onSkipExistingDiffers() + { + // Pre-populate a config whose sourcegraph entry differs from what init would write, + // then invoke init --diff. We expect the diff headers, a non-zero exit, and the file + // left unchanged. + var path = Path.Join(_tempRoot, ".mcp.json"); + File.WriteAllText(path, + "{ \"mcpServers\": { \"sourcegraph\": { \"command\": \"old-command\", \"args\": [] } } }"); + + var cli = ParseInit("--client", "claude-code", "--diff"); + var rc = await InitCli.RunAsync(cli); + rc.Should().Be(2); + var output = _stdout.ToString(); + output.Should().Contain("---"); + output.Should().Contain("+++"); + output.Should().Contain(".proposed"); + // File unchanged. + var json = JsonNode.Parse(File.ReadAllText(path))!.AsObject(); + json["mcpServers"]!["sourcegraph"]!["command"]!.GetValue().Should().Be("old-command"); + } + + [Fact] + public async Task Init_diff_force_rendersThenWrites() + { + var path = Path.Join(_tempRoot, ".mcp.json"); + File.WriteAllText(path, + "{ \"mcpServers\": { \"sourcegraph\": { \"command\": \"old-command\", \"args\": [] } } }"); + + var cli = ParseInit("--client", "claude-code", "--diff", "--force"); + var rc = await InitCli.RunAsync(cli); + rc.Should().Be(0); + var output = _stdout.ToString(); + output.Should().Contain("---"); + output.Should().Contain("+++"); + // File rewritten with the proposed content. + var json = JsonNode.Parse(File.ReadAllText(path))!.AsObject(); + json["mcpServers"]!["sourcegraph"]!["command"]!.GetValue().Should().Be("sourcegraph-mcp"); + } + + [Fact] + public async Task Init_diff_onInsert_isNoOp() + { + // No existing file → Insert plan. --diff is supposed to be a no-op for non-conflict + // plans; no diff headers should appear. + var cli = ParseInit("--client", "claude-code", "--diff"); + var rc = await InitCli.RunAsync(cli); + rc.Should().Be(0); + var output = _stdout.ToString(); + output.Should().NotContain(".proposed"); + } } /// diff --git a/tests/DevBitsLab.Mcp.SourceGraph.Tests/ScopeTests.cs b/tests/DevBitsLab.Mcp.SourceGraph.Tests/ScopeTests.cs index 845bfa6a..4f8c5f1b 100644 --- a/tests/DevBitsLab.Mcp.SourceGraph.Tests/ScopeTests.cs +++ b/tests/DevBitsLab.Mcp.SourceGraph.Tests/ScopeTests.cs @@ -87,6 +87,85 @@ public void ScopeConfigLoader_readsMultiScopeJson() } } + [Fact] + public void ScopeConfigLoader_emptyScopesArray_returnsEmptyConfigForRecovery() + { + // When the user removes the last scope (via CLI `scopes remove` or hand-edit), + // `.sourcegraph.json` ends up with `"scopes": []`. The loader treats that as a + // recoverable empty config — not a hard error — so subsequent `scopes add` / + // dashboard `[N]` can write a fresh scope over the empty file. Throwing here used to + // leave the user stuck with "malformed .sourcegraph.json" on every load. + var tmp = Path.Combine(Path.GetTempPath(), "scope-tests-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tmp); + try + { + File.WriteAllText(Path.Combine(tmp, ".sourcegraph.json"), """ + { "scopes": [] } + """); + var loaded = ScopeConfigLoader.Load(tmp); + loaded.Scopes.Should().BeEmpty(); + } + finally + { + Directory.Delete(tmp, recursive: true); + } + } + + [Fact] + public void ScopeConfigLoader_emptyScopes_addScopeThenRoundTrip_persistsOnlyTheNewScope() + { + // End-to-end recovery: load empty -> add -> save -> reload should show exactly one + // scope (the new one), no synth-default sneaking in. + var tmp = Path.Combine(Path.GetTempPath(), "scope-tests-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tmp); + try + { + File.WriteAllText(Path.Combine(tmp, ".sourcegraph.json"), """{ "scopes": [] }"""); + File.WriteAllText(Path.Combine(tmp, "x.slnx"), ""); + var emptyConfig = ScopeConfigLoader.Load(tmp); + var result = Server.Cli.ScopesCli.AddScopeToConfig(tmp, emptyConfig, "fresh", "x.slnx", isolated: false); + result.Ok.Should().BeTrue(); + var reloaded = ScopeConfigLoader.Load(tmp); + reloaded.Scopes.Should().HaveCount(1); + reloaded.Scopes[0].Name.Should().Be("fresh"); + } + finally + { + Directory.Delete(tmp, recursive: true); + } + } + + [Fact] + public void ScopeConfigLoader_emptyScopesArray_preservesPluginsAndNullsDefaultScope() + { + // Recovery path invariants: plugins[] survives (no silent data loss), and + // default_scope is null'd out so callers don't follow a stale id into a deleted scope. + var tmp = Path.Combine(Path.GetTempPath(), "scope-tests-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tmp); + try + { + File.WriteAllText(Path.Combine(tmp, ".sourcegraph.json"), """ + { + "scopes": [], + "default_scope": "removed-scope", + "plugins": [ + { "package": "MyOrg.MyPlugin", "version": "1.2.3" } + ] + } + """); + var loaded = ScopeConfigLoader.Load(tmp); + loaded.Scopes.Should().BeEmpty(); + loaded.DefaultScope.Should().BeNull("a default_scope pointing at a deleted scope is more dangerous than silently dropping it during recovery"); + loaded.Plugins.Should().HaveCount(1); + loaded.Plugins![0].Package.Should().Be("MyOrg.MyPlugin"); + loaded.Plugins![0].Version.Should().Be("1.2.3"); + } + finally + { + Directory.Delete(tmp, recursive: true); + } + } + [Fact] public void ScopeConfigLoader_rejectsMalformedJson() {