From a98339b44117922a19e55aa44044285c6366086c Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Sat, 23 May 2026 02:41:59 +0000 Subject: [PATCH 01/31] docs(openspec): draft init/config UX overhaul (3 changes) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds three sequential OpenSpec changes for the netclaw init/config UX overhaul planned in /home/petabridge/.claude/plans/so-a-big-ux-mossy-newt.md: 1. section-editor-abstraction — introduces ISectionEditor, registry, merge-on-save, single-step orchestrator mode, and refactors Provider/Identity/Posture to be reentrant. Lays the contract every future editable section must honor. Closes #455. 2. netclaw-config-command — new menu-driven `netclaw config` TUI command composing 10 section editors (Search, 3x chat channels, Exposure Mode, Security Posture, Audience Profiles, Outbound + Inbound Webhooks, External Skills, Skill Feeds, Browser Automation), plus a generic ListEditor framework, 4 new doctor checks, 12 smoke tapes + audit. Closes #1150. 3. simplify-netclaw-init — trims `netclaw init` to provider + identity + posture, adds existing-config refusal + --force backup-and-reset paths, post-flight nudge pointing operators at `netclaw config`. All three validated with `openspec validate --type change`. Sequential dependencies: A enables the abstraction, B introduces the command and editors, C cuts the long wizard. PR review can sequence the implementation accordingly. No production code changes in this PR; planning artifacts only. --- .../netclaw-config-command/.openspec.yaml | 2 + .../changes/netclaw-config-command/design.md | 259 ++++++++ .../netclaw-config-command/proposal.md | 190 ++++++ .../specs/feature-selection-wizard/spec.md | 59 ++ .../specs/netclaw-cli/spec.md | 33 + .../specs/netclaw-config-command/spec.md | 578 ++++++++++++++++++ .../changes/netclaw-config-command/tasks.md | 263 ++++++++ .../section-editor-abstraction/.openspec.yaml | 2 + .../section-editor-abstraction/design.md | 213 +++++++ .../section-editor-abstraction/proposal.md | 117 ++++ .../specs/netclaw-onboarding/spec.md | 112 ++++ .../specs/section-editor-abstraction/spec.md | 326 ++++++++++ .../section-editor-abstraction/tasks.md | 163 +++++ .../simplify-netclaw-init/.openspec.yaml | 2 + .../changes/simplify-netclaw-init/design.md | 208 +++++++ .../changes/simplify-netclaw-init/proposal.md | 152 +++++ .../specs/netclaw-onboarding/spec.md | 177 ++++++ .../changes/simplify-netclaw-init/tasks.md | 183 ++++++ 18 files changed, 3039 insertions(+) create mode 100644 openspec/changes/netclaw-config-command/.openspec.yaml create mode 100644 openspec/changes/netclaw-config-command/design.md create mode 100644 openspec/changes/netclaw-config-command/proposal.md create mode 100644 openspec/changes/netclaw-config-command/specs/feature-selection-wizard/spec.md create mode 100644 openspec/changes/netclaw-config-command/specs/netclaw-cli/spec.md create mode 100644 openspec/changes/netclaw-config-command/specs/netclaw-config-command/spec.md create mode 100644 openspec/changes/netclaw-config-command/tasks.md create mode 100644 openspec/changes/section-editor-abstraction/.openspec.yaml create mode 100644 openspec/changes/section-editor-abstraction/design.md create mode 100644 openspec/changes/section-editor-abstraction/proposal.md create mode 100644 openspec/changes/section-editor-abstraction/specs/netclaw-onboarding/spec.md create mode 100644 openspec/changes/section-editor-abstraction/specs/section-editor-abstraction/spec.md create mode 100644 openspec/changes/section-editor-abstraction/tasks.md create mode 100644 openspec/changes/simplify-netclaw-init/.openspec.yaml create mode 100644 openspec/changes/simplify-netclaw-init/design.md create mode 100644 openspec/changes/simplify-netclaw-init/proposal.md create mode 100644 openspec/changes/simplify-netclaw-init/specs/netclaw-onboarding/spec.md create mode 100644 openspec/changes/simplify-netclaw-init/tasks.md diff --git a/openspec/changes/netclaw-config-command/.openspec.yaml b/openspec/changes/netclaw-config-command/.openspec.yaml new file mode 100644 index 000000000..0f0616986 --- /dev/null +++ b/openspec/changes/netclaw-config-command/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-23 diff --git a/openspec/changes/netclaw-config-command/design.md b/openspec/changes/netclaw-config-command/design.md new file mode 100644 index 000000000..524c72339 --- /dev/null +++ b/openspec/changes/netclaw-config-command/design.md @@ -0,0 +1,259 @@ +## Context + +The `section-editor-abstraction` change (predecessor) introduced the +`ISectionEditor` contract, the `SectionEditorRegistry`, the merge-on-save +plumbing, and the single-step `WizardOrchestrator` mode. It refactored +Provider, Identity, and Posture into reentrant section editors but did +not introduce any new user-facing command. The linear `netclaw init` +wizard still owns the only path to configuration changes today, +including for sections that operators routinely tweak post-install +(search provider, channels, exposure mode, webhooks, skill feeds, +external skill directories, audience profiles, browser automation). + +This change introduces `netclaw config` as the canonical menu-driven +editor for those sections, composes ten new `ISectionEditor` +implementations (plus reuses the three from Change A indirectly through +the dashboard), introduces the multi-value `ListEditor` component, +and hardens the menu registry audit so the menu and its editors cannot +drift apart in subsequent work. The buggy feature-selection step from +#1150 is removed and its responsibility moves to the new +`AudienceProfilesSectionEditor`, with a smoke tape that exercises +arrow-nav and toggle keystrokes. + +## Goals / Non-Goals + +**Goals:** + +- Ship a menu-driven, reentrant TUI editor for the ten sections + operators actually want to change post-install, with doctor-blessed + saves and merge-on-save preserving every unrelated section. +- Reuse the Change A abstraction without forking: every editor in this + change is an `ISectionEditor` instance and runs inside the existing + `WizardOrchestrator` (now in single-step mode). +- Establish the generic list editor + item editor pattern so future + multi-value sections inherit add/edit/remove UX without re-inventing + it. +- Close #1150 by replacing the broken feature-selection step with the + Audience Profiles editor, exercised by a tape that drives the + failing keystrokes from the bug report. +- Activate the menu registry audit's full contract: every editor in + the registry must have a smoke tape and a round-trip xUnit test, + enforced at CI time. + +**Non-Goals:** + +- Simplifying the init wizard (third change). +- Hot-reloading the running daemon on config change. +- Editing inbound webhook route files from the TUI. +- Refactoring `netclaw provider`/`model`/`mcp` CLI subcommands. +- Identity changes post-install (renaming the agent stays a file-edit + task for MVP). +- Editing telemetry, logging, memory tuning, session timeouts, + sub-agent timeouts, shell hard-deny patterns, or scheduling on/off + from the TUI (file-edit only). +- Export/import config bundle or factory reset commands. +- Installing Playwright from the TUI (instructions sub-page only). + +## Decisions + +### D1. Dashboard is a single Termina page with a flat registry + +`ConfigDashboardPage` walks `SectionEditorRegistry.All()` once and +renders the editors in registration order, grouped by `Category` only +for visual presentation. The registry stays flat; the audit, the +round-trip test base class, and the smoke-tape lookup all key off +`SectionId`. Twelve editors render comfortably in a standard 80×24 +terminal without scrolling. + +Alternative considered: a tree-structured registry with first-class +parent/child nodes. Rejected because every "tree" need today is +satisfied by a `Category` string tag and a heavier structure would +complicate the audit, the registry resolution, and the round-trip +tests for no current benefit. + +### D2. Sub-page items via modal sub-orchestrators, not nested pages + +When the `ListEditor` opens a sub-page (e.g. Outbound Webhooks edit +form), the host invokes a fresh `WizardOrchestrator` in single-step mode +on the sub-page's viewmodel. The sub-orchestrator's Save returns its +result to the parent list; the parent list updates in-memory state and +re-renders. This keeps step lifecycle uniform across the whole config +command and avoids a separate "nested page" lifecycle. + +Alternative considered: a stack-of-pages model in Termina layout +where the sub-page is part of the same rendering pass. Rejected +because the sub-orchestrator model already exists from Change A and +adding a parallel stack would split the lifecycle. + +### D3. Doctor blessing is per-editor on save, never inline-per-field + +When a section editor saves, the host builds the candidate merged +config in memory, resolves only that editor's `RelevantDoctorChecks`, +and runs them against the candidate. The dashboard's "Run full doctor" +item is the only entry point for cross-section checks. Per-field +validation lives in the editor's own form (e.g. URL parsing) and is +distinct from doctor blessing. + +Alternative considered: per-field inline validation backed by doctor. +Rejected because doctor checks are designed to operate over complete +config sections, not single fields; running them on every keystroke +would produce confusing transient errors as the operator fills in +related fields. + +### D4. List editor `+ Add` row as a list member, not a separate action bar + +`ListEditor` renders `+ Add ` as the last row of the list +itself. Navigation is uniform (arrow keys move through items, Enter +activates) and there is no modal handoff between "list section" and +"action section." The `+ Add` row is visually distinct (different +glyph, no status) so operators do not mistake it for a data row. + +Alternative considered: a fixed action bar at the list bottom with +explicit `[ Add ]`, `[ Edit ]`, `[ Remove ]` buttons. Rejected +because every TUI list editor we model on (lazygit, k9s, git rebase +interactive) uses inline rows for adds, and the modal handoff +between list and action bar adds keystrokes for no benefit. + +### D5. Inline `d`/`y` confirm for list deletes; modal confirm for credential removal + +List deletes (`d` on a focused item) get a single-key inline +`Remove? [y/N]` prompt because the cost of an accidental delete is low +(operator re-adds the item from memory). Credential removal uses a +default-Cancel modal confirm because the cost is higher (operator +must re-enter or rotate the credential externally). Both confirm +patterns are inherited from Change A's secret-handling contract. + +### D6. New schema section for `BrowserAutomation` + +The schema gains `BrowserAutomation { Enabled: bool, +PlaywrightVersion?: string }` as a top-level section with `Enabled` +defaulting to `false` so existing configs validate without a fix +pass. A matching `BrowserAutomationConfig.cs` lives in +`Netclaw.Configuration`. The browser-automation step today writes +its state into `McpServers` indirectly; this change formalizes the +section so the editor and doctor check have a stable home. + +Alternative considered: keep using `McpServers` as the implicit +home. Rejected because conflating browser-automation with MCP +server config makes both harder to reason about; the doctor check +needs to look in one place. + +### D7. Audit promotion from soft-warn to hard-fail in this change + +In Change A the menu-registry audit allowed missing tape files +without failing (the `netclaw config` command did not exist yet). In +this change the command exists, so the audit's tape-existence check +flips to hard-fail. New section editors added in future PRs cannot +ship without a tape and a round-trip test. + +Alternative considered: keep tape-existence as soft-warn. Rejected +because the contract is only as strong as its weakest enforced rule; +soft-warn drifts into "we'll get to it" which is exactly the failure +mode the audit exists to prevent. + +### D8. Daemon-restart nudge is a stderr line, not a screen + +After a save-and-quit, Termina tears down and the operator returns to +the shell. The nudge prints to stderr after Termina exits so it +remains on screen even after the TUI clears. It is suppressed when +no writes occurred or when the daemon is not running, to avoid +nagging. + +Alternative considered: render the nudge as a final post-flight screen +inside Termina. Rejected because the operator may dismiss the screen +without reading it; a stderr line persists in the scroll buffer. + +### D9. `config-no-init.tape` covers the refusal path + +The refuse-when-no-config behavior is exercised by its own tape and +assertion. This avoids overloading any single section-editor tape with +the refusal scenario and keeps the audit's "tape per registered +editor" rule clean (the refusal tape is not associated with any +registry entry). + +### D10. Editor file layout under `Tui/Sections/
/` + +Each section editor lives in its own folder under +`src/Netclaw.Cli/Tui/Sections/`. Chat-channel editors get a +`Channels/` parent folder, webhooks get a `Webhooks/` parent. The +folder layout mirrors the menu's visual grouping for discoverability +while keeping the registry flat. + +## Risks / Trade-offs + +- [CI runtime increase] Twelve new smoke tapes plus the no-init refusal + tape add roughly 5–10 minutes to PR-gating smoke runs. → Mitigation: + smoke tapes are inherently parallelizable; if the wall-clock cost + becomes a problem, parallelize tape execution before reducing + coverage. + +- [Audit false positives during partial PRs] During implementation a + contributor may add a section editor before its tape lands. + → Mitigation: the audit's failure message names the missing artifact + explicitly. The convention is "tape and round-trip test land in the + same commit as the editor." PR review enforces it. + +- [Schema migration ergonomics] Adding `BrowserAutomation` as a new + top-level section is one of the few schema additions in this work. + → Mitigation: `"Enabled": false` default lets existing configs + validate; `SchemaFixResolver` auto-inserts the missing key on + next `netclaw doctor --fix` run. Per CLAUDE.md schema sync rule, + the schema and `BrowserAutomationConfig.cs` ship in the same PR. + +- [Reuse of existing channel-audience UX] The new chat-channel + section editors host the existing `channel-audience-tui` + cycling behavior, which is established and tested. → Mitigation: + the section editors compose the existing TUI components rather + than re-implementing them; the channel-audience-tui requirements + remain authoritative. + +- [Doctor checks that probe network endpoints] `SkillFeedsDoctorCheck` + and Slack/Discord/Mattermost `Test Connection` actions reach out + to remote services. → Mitigation: probing is warn-only or + user-initiated. Doctor errors that block save are local-only + (schema validity, key/backend pairing, etc.). + +- [Audience Profiles editor's keystroke contract] If Termina's + `SelectionListNode` has a latent bug (which #1150 implies), arrow + nav and Space toggle may misbehave. → Mitigation: the + `config-audience.tape` smoke tape drives exactly those keystrokes + and the assertion verifies the resulting state. If the underlying + component is broken at the Termina level, this tape will fail and + the bug must be fixed before merge. + +- [Removed feature-selection step on re-run] Operators who currently + rely on the feature-selection step in `netclaw init` lose it. + → Mitigation: PRD-004 and the `feature-selection-wizard` spec + delta document the relocation. The new Audience Profiles editor + is reachable from one menu entry away. Migration text in the PR + description points operators at the new path. + +## Migration Plan + +This change ships net-new behavior (`netclaw config`) plus a single +behavior removal (the feature-selection step in init). Migration +considerations: + +1. Land the change. `netclaw init` no longer shows the + feature-selection step on re-run; existing `netclaw.json` keeps + its feature-flag values untouched. +2. Operators who want to change feature flags post-install run + `netclaw config → Audience Profiles → `. +3. The new `BrowserAutomation` schema section is auto-inserted by + `netclaw doctor --fix` on existing installs (or appears + automatically when `netclaw config` runs over an existing + config that lacks it — the merge writer creates the section + with `Enabled: false` when the operator opens the editor). +4. Daemon restart is required for live config changes to take + effect; the stderr nudge instructs operators to restart when + relevant. + +Rollback: revert the change. `netclaw config` disappears from the +CLI surface. The feature-selection step returns to `netclaw init` +on re-run. The audit returns to Change A's soft-warn tape-existence +behavior. `netclaw.json` values written by `netclaw config` remain +valid against the schema and continue to be respected at runtime. + +## Open Questions + +None at execution time. All architectural decisions are locked above. diff --git a/openspec/changes/netclaw-config-command/proposal.md b/openspec/changes/netclaw-config-command/proposal.md new file mode 100644 index 000000000..41f172405 --- /dev/null +++ b/openspec/changes/netclaw-config-command/proposal.md @@ -0,0 +1,190 @@ +## Why + +After the `section-editor-abstraction` change lands, Netclaw has the +machinery to share editable sections between the init wizard and any new +command — but no command actually consumes it. Operators still have no way +to change live configuration (search provider, exposure mode, channels, +webhooks, skill feeds, external skill directories, Playwright, audience +profiles, security posture) without hand-editing `netclaw.json`. This +change introduces `netclaw config`, a menu-driven TUI editor that composes +the abstraction's section editors into a single dashboard with reentrant +section-by-section editing, doctor-blessed save, and a CI-enforced audit +that prevents the menu and the editors from drifting apart over time. + +This change also retires the buggy team/public feature-toggle screen in +the existing init wizard (#1150) by replacing it with the new Audience +Profiles section editor, which exercises arrow navigation and toggle +keystrokes under a smoke tape rather than relying on undertested +hand-coded input handling. + +Source PRDs: `PRD-004-cli-onboarding-and-config.md`, +`PRD-001-netclaw-mvp.md`, `PRD-002-gateway-security-envelope.md`. + +## What Changes + +- Add a new `netclaw config` top-level CLI command that launches Termina + with a `ConfigDashboardPage` rendering every entry in + `SectionEditorRegistry`. The dashboard computes per-section status + (`✓` configured / `⚠` warning / `✗` error / `–` default) by running + each editor's `RelevantDoctorChecks` on entry. Selecting a section + opens its editor in single-step orchestrator mode; on save the + section's checks run inline and either block (on errors), render a + "Save anyway" affordance (on warnings), or accept the write (on + clean). Returning from an editor refreshes the affected section's + status. +- Add a "Run full doctor" item at the dashboard's tail that invokes the + existing `DoctorRunner` with the same exit-code semantics as + `netclaw doctor`, plus a "Quit" item. +- Add the dashboard's existing-config refusal: if `netclaw.json` is + absent, `netclaw config` prints "No configuration found. Run + `netclaw init` first." and exits non-zero. The dashboard does not + render against a default skeleton. +- Add a generic `ListEditor` Termina component and a per-shape + `IItemEditor` contract. Day-one item-editor implementations: + `PathItemEditor` (External Skill Directories), `WebhookItemEditor` + (Outbound Webhooks — sub-page form with name + URL + auth header), + `SkillFeedItemEditor` (Skill Feeds — sub-page form with name + URL + + Bearer token), and `IdentifierItemEditor` (channel IDs, user IDs, + trusted-proxy CIDRs). Simple items edit inline; complex items open + sub-pages. Multi-value sections gain a uniform Add / Edit / Remove + affordance with default-Cancel destructive confirms. +- Add ten new `ISectionEditor` implementations registered in the menu: + Search Provider, Slack Channels, Discord Channels, Mattermost + Channels, Exposure Mode (covering Daemon host/port, trusted proxies, + and per-mode sub-forms for Reverse Proxy / Tailscale / Cloudflare), + Security Posture, Audience Profiles, Outbound Webhooks, Inbound + Webhooks, External Skill Directories, Skill Feeds, Browser + Automation. Slack/Discord/Mattermost share a `"Chat Channels"` + category for menu grouping; the registry treats them as three + independent editors. +- Add the Audience Profiles section editor as the replacement for the + init wizard's broken feature-selection step. The editor SHALL exercise + `↑/↓` navigation between audience tiers, `Space` to toggle individual + per-audience feature flags, and explicit `Reset to posture default` + affordance. A dedicated smoke tape (`config-audience.tape`) drives + these keystrokes and asserts the resulting `Tools.AudienceProfiles` + state. +- Add the Exposure Mode section editor with mode-conditional sub-forms. + Trusted Proxies multi-value list, Reverse Proxy external base URL, + Tailscale auth key (secret), and Cloudflare Tunnel token (secret) are + all reachable from one editor. The editor migrates the responsibility + previously covered by `init-wizard-reverse-proxy.tape` from init into + the config command. +- Add four new doctor checks invoked by the new editors: + `SearchBackendDoctorCheck` (backend-key pairing), + `ExternalSkillSourcesDoctorCheck` (each path is a readable + directory), `SkillFeedsDoctorCheck` (reachability, warn-only — remote + endpoints are allowed to be transiently down), and + `BrowserAutomationDoctorCheck` (Playwright binary present when + feature is enabled). +- Add a new top-level schema section + `BrowserAutomation { Enabled: bool, PlaywrightVersion?: string }` and + the matching `BrowserAutomationConfig.cs`. Schema sync per CLAUDE.md + rule. `"Enabled"` defaults to `false` so `SchemaFixResolver` can + auto-insert on upgrade. +- Add twelve new smoke tapes (`config-search.tape`, + `config-slack.tape`, `config-discord.tape`, `config-mattermost.tape`, + `config-exposure-mode.tape`, `config-posture.tape`, + `config-audience.tape`, `config-outbound-webhooks.tape`, + `config-inbound-webhooks.tape`, `config-external-skills.tape`, + `config-skill-feeds.tape`, `config-browser-automation.tape`) and a + `config-no-init.tape` that asserts the refusal path. Each tape has a + matching assertion script that checks the modified field changed and + unrelated sections are byte-identical to the pre-stage fixture. +- Add round-trip xUnit test classes for all ten new section editors, + derived from `SectionEditorTestBase` introduced in the prior + change. The Change A test pattern carries forward unchanged. +- Activate the `MenuRegistryAuditTests` smoke-tape existence check + (gated as soft-warn in Change A) into a hard fail: any registered + editor without `tests/smoke/tapes/config-.tape` + fails the audit. +- Closes #1150 (feature toggles broken for team/public dispositions — + the buggy screen is removed and its responsibility moves to Audience + Profiles). + +**In scope (MVP):** the `netclaw config` command, the dashboard, +single-step editor hosting, ten new section editors, four new doctor +checks, the new `BrowserAutomation` schema section, generic list and +item editors, twelve new smoke tapes + the no-init refusal tape, ten +new round-trip xUnit test classes, the hardened audit, and a stderr +"daemon restart required to apply changes" nudge when the daemon is +running at config-command exit. + +**Out of scope:** simplification of `netclaw init` (third change), +hot-reload of the running daemon on config change, export/import config +bundle, factory reset, route-file editing for inbound webhooks, +identity beyond what init sets (renaming the agent post-install remains +a file-edit task), telemetry/logging/memory/session/sub-agent/scheduling +config knobs (file-edit only), shell hard-deny patterns (file-edit +only), Playwright installation from within the TUI (instructions +sub-page only), and refactor of `netclaw provider`/`model`/`mcp` CLI +subcommands. + +## Capabilities + +### New Capabilities + +- `netclaw-config-command`: contract for the `netclaw config` command — + command-level lifecycle, dashboard rendering, per-section status + computation, single-step editor hosting, doctor blessing on save, + refusal when no config exists, daemon-restart nudge at exit, + list/item editor framework, and the ten section editors' shared + obligations. + +### Modified Capabilities + +- `netclaw-cli`: add `netclaw config` to the operator CLI surface; add + the `Quit` and `Run full doctor` dashboard items as standard + affordances. +- `feature-selection-wizard`: remove the feature-selection step from + `netclaw init`. The deployment-wide feature toggles previously written + by that step move to the Audience Profiles section editor in + `netclaw config`, exposed per audience and per feature with the + keystroke contract required by #1150. +- `channel-audience-tui`: re-host the existing channel-audience + cycling behavior as the per-channel-editor sub-screen, retaining + the requirement that audience defaults derive from posture but + letting the operator override per-channel from the config command. + +## Impact + +**Affected systems:** + +- CLI command surface (`Netclaw.Cli.Program` routing, + `Netclaw.Cli.Config.ConfigCommand` new class). +- Termina TUI (`Netclaw.Cli.Tui.Sections.ConfigDashboardPage`, + `ConfigDashboardViewModel`, `ListEditor`, four item editors). +- Ten new section editors under + `src/Netclaw.Cli/Tui/Sections/{Search,Channels/{Slack,Discord,Mattermost},ExposureMode,SecurityPosture,AudienceProfiles,Webhooks/{Outbound,Inbound},ExternalSkills,SkillFeeds,BrowserAutomation}/`. +- Doctor system gains four checks under + `src/Netclaw.Cli/Doctor/Checks/`. +- Schema (`netclaw-config.v1.schema.json`) gains the `BrowserAutomation` + top-level section. +- Configuration types (`src/Netclaw.Configuration/BrowserAutomationConfig.cs`). +- Test surface gains twelve smoke tapes, ten round-trip test classes, + and a hardened menu registry audit. + +**Security and operational impact:** + +- Secret-handling contract from Change A applies to every secret-bearing + field across the ten new editors. No new secret display surface is + introduced; "Remove credential" is the only path that deletes a + secret value. +- Doctor checks scoped to each editor run inline on save; cross-section + checks remain gated to the dashboard's "Run full doctor" action. No + network-probing check blocks save by default (`SkillFeedsDoctorCheck` + is warn-only) so transient outages do not lock operators out of + editing. +- The hardened audit prevents the menu and editors from drifting: + adding a new menu entry without its tape or round-trip test fails + CI immediately. +- Existing daemon does not hot-reload. A stderr nudge at config-command + exit instructs operators to restart the daemon to apply changes when + the daemon is detected as running; otherwise the nudge is omitted. +- The feature-selection step's removal is a behavioral change for + operators on non-Personal postures who re-run `netclaw init` over + existing config: they no longer see the step. Its responsibility + moves to `netclaw config → Audience Profiles`. PRD-004 is updated + in this change to reflect the new shape. +- No persistence schema changes. No new actor or session contract + changes. No external network dependencies introduced. diff --git a/openspec/changes/netclaw-config-command/specs/feature-selection-wizard/spec.md b/openspec/changes/netclaw-config-command/specs/feature-selection-wizard/spec.md new file mode 100644 index 000000000..3f1706e8e --- /dev/null +++ b/openspec/changes/netclaw-config-command/specs/feature-selection-wizard/spec.md @@ -0,0 +1,59 @@ +## REMOVED Requirements + +### Requirement: Feature selection wizard step + +**Reason**: The init-wizard feature-selection step (issue #1150) had broken +keystroke handling for Team and Public audience toggles. Its responsibility +moves to the new `AudienceProfilesSectionEditor` in `netclaw config`, +which renders per-audience feature toggles with documented arrow-nav and +Space-toggle semantics under a CI-gated smoke tape +(`config-audience.tape`). + +**Migration**: Operators previously walked this step at the end of +`netclaw init` for non-Personal postures. After this change, the init +wizard skips the feature-selection step entirely; deployment-wide +defaults are derived from the selected security posture +(per `Requirement: Audience defaults from posture` in the +`channel-audience-tui` capability) and per-audience feature toggles are +edited via `netclaw config → Audience Profiles`. Existing +`netclaw.json` files retain whatever feature-flag values they hold; +the new Audience Profiles editor preserves customizations. + +## MODIFIED Requirements + +### Requirement: Feature config Enabled flags + +The configuration schema SHALL include `Enabled` boolean properties for +Memory, Search, SkillSync, SubAgents, and Webhooks sections, plus a +top-level `Scheduling` section whose only property is `Enabled`. These +flags SHALL be written by either the init wizard's posture-default +cascade or the `AudienceProfilesSectionEditor` in `netclaw config`. +Both writers SHALL emit byte-identical output for equivalent input. + +#### Scenario: Disabled memory writes Enabled false + +- **GIVEN** the operator disabled memory in the Audience Profiles + editor (under any audience) and saved +- **WHEN** the editor's merge writer completes +- **THEN** `Memory.Enabled` is `false` in `netclaw.json` + +#### Scenario: Disabled search writes Enabled false + +- **GIVEN** the operator disabled search in the Audience Profiles + editor and saved +- **WHEN** the editor's merge writer completes +- **THEN** `Search.Enabled` is `false` in `netclaw.json` + +#### Scenario: Disabled scheduling writes top-level Scheduling.Enabled false + +- **GIVEN** the operator disabled scheduling in the Audience Profiles + editor and saved +- **WHEN** the editor's merge writer completes +- **THEN** `Scheduling.Enabled` is `false` in `netclaw.json` +- **AND** `Scheduling` contains no other properties in this change + +#### Scenario: Default Personal config has all features enabled + +- **GIVEN** the operator selected Personal posture at init +- **WHEN** the init wizard's merge writer completes +- **THEN** all `Enabled` flags default to `true` diff --git a/openspec/changes/netclaw-config-command/specs/netclaw-cli/spec.md b/openspec/changes/netclaw-config-command/specs/netclaw-cli/spec.md new file mode 100644 index 000000000..2c38340ce --- /dev/null +++ b/openspec/changes/netclaw-config-command/specs/netclaw-cli/spec.md @@ -0,0 +1,33 @@ +## ADDED Requirements + +### Requirement: Config command surface + +The CLI SHALL expose `netclaw config` as a top-level command. The +command SHALL be offline (no daemon connection), SHALL operate on +local config files only, and SHALL behave per the +`netclaw-config-command` capability. `netclaw config --help` SHALL +print a one-paragraph description and exit zero. Invocations with any +positional argument SHALL print usage and exit non-zero in this change +(subcommands such as `netclaw config show|validate` remain reserved +for future work and SHALL NOT execute as a side effect). + +#### Scenario: Help text describes the command + +- **WHEN** the operator runs `netclaw config --help` +- **THEN** the command exits with status 0 +- **AND** stdout contains a one-paragraph description naming + "interactive configuration editor" +- **AND** stdout references the `netclaw init` companion command + +#### Scenario: Unknown subcommand rejected + +- **WHEN** the operator runs `netclaw config foo` +- **THEN** the command exits with non-zero status +- **AND** stderr contains usage text + +#### Scenario: No-args invocation launches dashboard + +- **WHEN** the operator runs `netclaw config` with no arguments +- **AND** `netclaw.json` exists +- **THEN** the dashboard launches per the + `netclaw-config-command` capability diff --git a/openspec/changes/netclaw-config-command/specs/netclaw-config-command/spec.md b/openspec/changes/netclaw-config-command/specs/netclaw-config-command/spec.md new file mode 100644 index 000000000..6067f06bb --- /dev/null +++ b/openspec/changes/netclaw-config-command/specs/netclaw-config-command/spec.md @@ -0,0 +1,578 @@ +## ADDED Requirements + +### Requirement: Config command launches dashboard + +`netclaw config` SHALL launch Termina with a dashboard page rendering every +registered `ISectionEditor` from `SectionEditorRegistry`, plus a "Run full +doctor" item and a "Quit" item at the dashboard tail. The command SHALL +operate offline (no daemon connection required) and SHALL read/write +local config files only. + +#### Scenario: Dashboard renders all registered editors + +- **GIVEN** the CLI is configured with the day-one editor registry + (Search, Slack, Discord, Mattermost, ExposureMode, SecurityPosture, + AudienceProfiles, OutboundWebhooks, InboundWebhooks, ExternalSkills, + SkillFeeds, BrowserAutomation) +- **WHEN** the operator runs `netclaw config` +- **THEN** Termina opens with a dashboard listing every editor, with + status badges computed per editor +- **AND** the tail shows a "Run full doctor" item and a "Quit" item + +#### Scenario: Config command does not require daemon + +- **GIVEN** the Netclaw daemon is not running +- **WHEN** the operator runs `netclaw config` +- **THEN** the command starts and renders the dashboard normally +- **AND** no daemon RPC or HTTP call is made + +### Requirement: Refuse when no config exists + +`netclaw config` SHALL detect a missing `netclaw.json` at startup and +refuse to render the dashboard. The command SHALL print +`No configuration found. Run \`netclaw init\` first.` to stderr and exit +with a non-zero exit code. + +#### Scenario: No config refusal exits non-zero + +- **GIVEN** `~/.netclaw/config/netclaw.json` does not exist +- **WHEN** the operator runs `netclaw config` +- **THEN** the command prints `No configuration found. Run \`netclaw init\` first.` + to stderr +- **AND** exits with a non-zero exit code +- **AND** does not render any Termina UI + +### Requirement: Dashboard status badges + +The dashboard SHALL render a status badge for every section editor by +computing `GetStatus(currentConfig)` and running the editor's +`RelevantDoctorChecks` against the on-disk config at dashboard entry. +The badge vocabulary SHALL be: `✓` configured (all checks pass), +`⚠` configured but at least one check warns, `✗` configured but at +least one check errors, and `–` not set / default. Badges SHALL be +recomputed on return from a section editor save. + +#### Scenario: Configured-and-passing section shows checkmark + +- **GIVEN** the Search section is configured with backend `duckduckgo` +- **AND** `ConfigSchemaDoctorCheck` and `SearchBackendDoctorCheck` + both pass +- **WHEN** the dashboard renders +- **THEN** the Search row shows `✓` + +#### Scenario: Configured-and-warning section shows warning glyph + +- **GIVEN** the Search section is configured with backend `brave` and a + rate-limited API key +- **AND** `SearchBackendDoctorCheck` returns WARN +- **WHEN** the dashboard renders +- **THEN** the Search row shows `⚠` + +#### Scenario: Unset section shows dash + +- **GIVEN** the Outbound Webhooks section has no configured webhooks +- **WHEN** the dashboard renders +- **THEN** the Outbound Webhooks row shows `–` + +### Requirement: Sub-grouping by category + +Section editors that declare the same `Category` value SHALL be grouped +visually in the dashboard under that category label. The label itself +SHALL be unselectable; only the editor rows underneath it accept focus. +Grouping SHALL NOT affect the registry's flat enumeration or the audit's +per-editor checks. + +#### Scenario: Chat-channels group renders three siblings + +- **GIVEN** the Slack, Discord, and Mattermost editors declare + `Category = "Chat Channels"` +- **WHEN** the dashboard renders +- **THEN** the three rows render under a "Chat Channels" group label +- **AND** the group label cannot be selected or activated +- **AND** the dashboard registry audit still treats the three as + independent registered editors + +### Requirement: Section editor hosting + +Opening a section from the dashboard SHALL launch the editor's +`IWizardStepViewModel` (produced by `CreateEditor(context)`) inside a +single-step `WizardOrchestrator`. The orchestrator SHALL drive save and +cancel semantics exactly as in the linear wizard, then return control +to the dashboard. The dashboard SHALL refresh the affected section's +status before re-rendering. + +#### Scenario: Open editor, save, return + +- **GIVEN** the dashboard is displayed with the Search row focused +- **WHEN** the operator presses Enter +- **THEN** the Search section editor opens in single-step mode +- **AND** the editor's UI matches the section editor contract (pre-filled + non-secret fields, masked empty secret fields) +- **AND** on Save the orchestrator writes via the merge layer and returns + to the dashboard +- **AND** the dashboard re-renders with the updated Search status badge + +#### Scenario: Open editor, cancel, return without write + +- **GIVEN** the dashboard is displayed with the Search row focused +- **WHEN** the operator opens the editor, changes the backend selector, + and presses Esc +- **THEN** the editor shows the unsaved-changes discard confirm dialog +- **AND** on confirm-discard, control returns to the dashboard +- **AND** no `netclaw.json` write occurred +- **AND** the dashboard re-renders with the unchanged Search status badge + +### Requirement: Doctor blessing on section save + +When a section editor saves, the host SHALL build a candidate merged +config in memory, resolve the editor's `RelevantDoctorChecks`, and run +each check against the candidate. If any check returns ERROR, the +host SHALL block the save, surface an inline error banner, and keep +focus inside the editor. If any check returns WARN (and no ERROR), the +host SHALL render an inline warning banner with a `Save anyway` +affordance and a `Cancel` affordance. If all checks pass, the host +SHALL write the merged candidate to disk and return to the dashboard. + +#### Scenario: Error-level check blocks save + +- **GIVEN** the Search editor is open with backend `brave` selected and + the API key field left blank (no stored key) +- **WHEN** the operator saves +- **THEN** `SearchBackendDoctorCheck` returns ERROR +- **AND** the inline error banner displays the check's message +- **AND** the Save button is disabled until the error condition is + cleared + +#### Scenario: Warn-level check surfaces banner with override + +- **GIVEN** the Skill Feeds editor is open with a feed whose URL is + currently unreachable +- **WHEN** the operator saves +- **THEN** `SkillFeedsDoctorCheck` returns WARN +- **AND** the inline warning banner displays the check's message +- **AND** the host renders `[ Save anyway ]` and `[ Cancel ]` +- **AND** activating Save anyway writes the merged candidate to disk + +#### Scenario: Clean checks write to disk + +- **GIVEN** the Search editor is open with backend `duckduckgo` and no + required API key +- **WHEN** the operator saves +- **THEN** all relevant checks pass +- **AND** the merge writer produces a new `netclaw.json` with only the + Search section changed +- **AND** control returns to the dashboard + +### Requirement: Run full doctor item + +The dashboard SHALL include a "Run full doctor" item at the tail that +invokes `DoctorRunner` against the on-disk config and renders results +on a doctor results page. The results page SHALL list each check's +status (PASS/WARN/ERROR/SKIPPED) with summary text. Pressing Esc or +activating the page's "Back to dashboard" action SHALL return to the +dashboard with no config write performed. + +#### Scenario: Full doctor lists every check + +- **GIVEN** the dashboard is displayed and the daemon-restart status + is irrelevant +- **WHEN** the operator selects "Run full doctor" +- **THEN** `DoctorRunner` runs every registered check against on-disk + config +- **AND** the results page renders one row per check with PASS/WARN/ERROR + status and check name + +#### Scenario: Full doctor does not modify config + +- **GIVEN** the dashboard's "Run full doctor" item runs +- **WHEN** results render and the operator returns to the dashboard +- **THEN** no config file write has occurred +- **AND** the dashboard's per-section status badges reflect the same + on-disk state as before + +### Requirement: Daemon-restart nudge at exit + +`netclaw config` SHALL print a stderr nudge at exit instructing the +operator to restart the daemon for changes to take effect, when (a) at +least one config or secrets write occurred during the session AND (b) +the daemon is currently running. If either condition is false, the +nudge SHALL be omitted. + +#### Scenario: Daemon running plus config change emits nudge + +- **GIVEN** the daemon is running +- **AND** the operator saved at least one section during the session +- **WHEN** the operator quits the dashboard +- **THEN** the stderr nudge `Config saved. Restart the daemon to apply + changes: netclaw daemon stop && netclaw daemon start` is printed +- **AND** the command exits with status 0 + +#### Scenario: Daemon not running suppresses nudge + +- **GIVEN** the daemon is not running +- **AND** the operator saved at least one section during the session +- **WHEN** the operator quits the dashboard +- **THEN** no nudge is printed +- **AND** the command exits with status 0 + +#### Scenario: No writes suppresses nudge regardless of daemon state + +- **GIVEN** the operator opened the dashboard, browsed editors, but + saved nothing +- **WHEN** the operator quits +- **THEN** no nudge is printed regardless of daemon state + +### Requirement: Generic list editor component + +The CLI SHALL provide a generic `ListEditor` Termina component +parameterized by an `IItemEditor` describing the item shape. The +component SHALL render an Add row at the bottom (`+ Add `), an +inline-or-sub-page edit affordance per item depending on +`IItemEditor.RequiresSubPage`, an inline delete affordance keyed to +`d` with single-key confirmation for low-stakes deletes, and overall +Save / Cancel affordances. The list editor SHALL preserve item +identity across edit by consulting `IItemEditor.KeyOf(item)` so that +in-place renames (rather than delete + add) round-trip correctly. + +#### Scenario: Inline edit for simple items + +- **GIVEN** an `ExternalSkills.Sources` list with three path entries +- **WHEN** the operator presses Enter on a focused row +- **THEN** an inline single-line input overlay replaces the row +- **AND** Enter saves the edit to in-memory list state +- **AND** Esc cancels without modifying state + +#### Scenario: Sub-page edit for complex items + +- **GIVEN** an `Notifications.Webhooks` list with two configured + webhooks +- **WHEN** the operator presses Enter on a focused row +- **THEN** a sub-page form opens showing every webhook field +- **AND** Save on the sub-page returns to the list with the in-memory + webhook updated +- **AND** Cancel on the sub-page returns to the list with no change + +#### Scenario: Delete confirmation prevents accidental removal + +- **GIVEN** a focused list item +- **WHEN** the operator presses `d` +- **THEN** an inline `Remove? [y/N]` prompt replaces the row's display +- **AND** pressing `y` removes the item from in-memory state +- **AND** any other key cancels the deletion + +#### Scenario: Item identity preserved on in-place rename + +- **GIVEN** a webhook list with an entry whose `KeyOf` returns + `"critical-pager"` +- **WHEN** the operator edits the entry and changes its name to + `pagerduty-prod` +- **THEN** the list save records a single update (not a delete + add) +- **AND** the underlying `Notifications.Webhooks` array contains exactly + one entry with the new name and the preserved auth header + (per the secret-handling contract) + +### Requirement: Search Provider editor + +The dashboard SHALL include a `SearchSectionEditor` +(`SectionId = "Search"`) for editing the search backend and its +credentials. The editor SHALL present a single-selection list among +`Brave`, `DuckDuckGo`, `SearXng (self-hosted)`. Backend-dependent +fields SHALL render: Brave shows an API key input (secret-handling +contract); SearXng shows an instance URL input; DuckDuckGo shows no +additional fields. The editor SHALL declare `RelevantDoctorChecks` = +`{ConfigSchemaDoctorCheck, SearchBackendDoctorCheck}`. + +#### Scenario: Switching to DuckDuckGo preserves stored Brave key + +- **GIVEN** the Search section is configured with backend `brave` and a + stored Brave API key +- **WHEN** the operator switches the backend to `duckduckgo` and saves +- **THEN** `netclaw.json` records `Search.Backend = "duckduckgo"` +- **AND** `secrets.json` retains the Brave API key encrypted at its + original location + +#### Scenario: Brave without key blocks save + +- **GIVEN** the Search section is unconfigured +- **WHEN** the operator selects `brave`, leaves the key empty, and saves +- **THEN** `SearchBackendDoctorCheck` returns ERROR +- **AND** the save is blocked + +### Requirement: Chat channel editors + +The dashboard SHALL include three independently-registered chat-channel +section editors: `SlackSectionEditor` (`SectionId = "Slack"`), +`DiscordSectionEditor` (`SectionId = "Discord"`), and +`MattermostSectionEditor` (`SectionId = "Mattermost"`). Each editor +SHALL declare `Category = "Chat Channels"` for menu grouping. Each +editor SHALL surface its platform's authentication tokens +(per-platform secret-handling contract), an allowed-channels list, +an allowed-users list, the DMs-enabled toggle, the channel audience +profile selector, and a Test Connection affordance that runs the +existing per-platform probe and renders results in an inline banner. + +#### Scenario: Slack editor exposes both bot and app tokens with leave-blank-to-keep + +- **GIVEN** the Slack section has both bot and app tokens stored +- **WHEN** the operator opens the Slack section editor +- **THEN** both token fields render empty with "configured — leave blank + to keep" hint +- **AND** saving with both fields blank preserves both stored tokens + +#### Scenario: Discord editor exposes single token + +- **GIVEN** the Discord section is unconfigured +- **WHEN** the operator opens the Discord section editor +- **THEN** one token field is displayed with "(not set)" hint +- **AND** no app-token field exists (Discord uses a single bot token) + +#### Scenario: Mattermost editor exposes server URL plus token + +- **GIVEN** the Mattermost section is unconfigured +- **WHEN** the operator opens the Mattermost section editor +- **THEN** a Server URL text field is displayed in addition to the token + field + +#### Scenario: Test Connection renders inline banner + +- **GIVEN** the Slack editor is open with valid tokens entered +- **WHEN** the operator activates Test Connection +- **THEN** the existing Slack probe runs in-process +- **AND** results render in an inline banner with workspace name and + channel-access summary + +### Requirement: Exposure Mode editor + +The dashboard SHALL include an `ExposureModeSectionEditor` +(`SectionId = "Daemon.ExposureMode"`) that lets the operator select +among `Local`, `Reverse Proxy`, `Tailscale`, `Cloudflare Tunnel`. The +editor SHALL surface mode-conditional sub-forms: Reverse Proxy +requires an external base URL plus a trusted-proxy CIDR list; Tailscale +requires an auth-key secret plus hostname; Cloudflare Tunnel requires a +tunnel-token secret plus optional access-policy email domain. The +editor SHALL also surface daemon host and port. `RelevantDoctorChecks` +SHALL include `ConfigSchemaDoctorCheck` and the existing +`ExposureModeDoctorCheck`. + +#### Scenario: Local mode requires no sub-form + +- **GIVEN** the Exposure Mode editor is open with `Local` selected +- **WHEN** the operator saves +- **THEN** `Daemon.ExposureMode = "Local"` is written +- **AND** no trusted-proxy or tunnel configuration is required + +#### Scenario: Reverse Proxy without trusted proxies blocks save + +- **GIVEN** the Exposure Mode editor is open with `Reverse Proxy` + selected +- **AND** the trusted-proxy list is empty +- **WHEN** the operator saves +- **THEN** `ExposureModeDoctorCheck` returns ERROR +- **AND** the save is blocked + +### Requirement: Security Posture editor + +The dashboard SHALL include a `SecurityPostureSectionEditor` +(`SectionId = "Security.Posture"`) presenting `Personal`, `Team`, +`Enterprise` posture choices with descriptive subtitles. When the +operator changes posture and the existing `Tools.AudienceProfiles` +section has been customized away from the prior posture's defaults, +the editor SHALL surface a three-option cascade dialog: cancel, +apply posture with overwrite, or apply posture preserving custom +profiles. + +#### Scenario: Cascade dialog presents three options + +- **GIVEN** the current posture is `Personal` and the Team audience + profile has been customized in `Tools.AudienceProfiles` +- **WHEN** the operator selects `Team` and saves +- **THEN** the cascade dialog opens with default focus on `Cancel` +- **AND** options are: `Cancel — keep current posture`, + `Apply new posture, overwrite profiles`, + `Apply new posture, keep custom profiles` + +#### Scenario: Default focus prevents accidental overwrite + +- **GIVEN** the cascade dialog is open +- **WHEN** the operator presses Enter or Esc +- **THEN** the dialog cancels the posture change +- **AND** `Tools.AudienceProfiles` is unchanged + +### Requirement: Audience Profiles editor + +The dashboard SHALL include an `AudienceProfilesSectionEditor` +(`SectionId = "Tools.AudienceProfiles"`) replacing the init wizard's +feature-selection step. The editor SHALL render an audience picker for +`Personal`, `Team`, `Public`. Opening an audience SHALL display a +per-audience editor with one toggleable row per feature +(`memory`, `search`, `skills`, `scheduling`, `sub-agents`, +`webhooks`), a shell-mode selector for that audience, an approval +policy selector, and a `Reset to posture default` affordance. Arrow +keys SHALL navigate rows; `Space` SHALL toggle the focused checkbox; +`Enter` on a checkbox row SHALL also toggle (alternative to Space). +`RelevantDoctorChecks` SHALL include `ConfigSchemaDoctorCheck` and +`ToolAudienceProfilesDoctorCheck`. + +#### Scenario: Down-arrow then Space toggles second row + +- **GIVEN** the Team audience editor is open +- **AND** initial focus is on the first feature row (`memory`, + currently enabled) +- **WHEN** the operator presses `↓` then `Space` +- **THEN** focus moves to the second row (`search`) +- **AND** the `search` toggle flips (off if it was on, on if it was + off) +- **AND** the change is reflected in `Tools.AudienceProfiles.Team` + when the editor saves + +#### Scenario: Reset to posture default replaces all toggles + +- **GIVEN** the Team audience editor is open with several custom + toggle states +- **WHEN** the operator activates `Reset to posture default` +- **THEN** every toggle and the shell-mode selector revert to the + current posture's default mapping for the Team audience + +### Requirement: Outbound Webhooks editor + +The dashboard SHALL include an `OutboundWebhooksSectionEditor` +(`SectionId = "Notifications.Webhooks"`) presenting the existing +multi-value array via the generic `ListEditor` with the +`WebhookItemEditor` sub-page form. Each webhook SHALL be editable +with name, URL, optional auth-header value (secret-handling contract), +and optional event filter. Add/edit/remove SHALL produce a correctly +merged `Notifications.Webhooks` array. + +#### Scenario: Add second webhook preserves first + +- **GIVEN** `Notifications.Webhooks` contains one entry `ops-alerts` +- **WHEN** the operator opens the editor, adds a new webhook + `critical-pager`, and saves +- **THEN** `Notifications.Webhooks` is a two-entry array +- **AND** the first entry is byte-identical to its pre-save state + +### Requirement: Inbound Webhooks editor + +The dashboard SHALL include an `InboundWebhooksSectionEditor` +(`SectionId = "Webhooks"`) presenting the feature-flag toggle plus +the request-timeout integer field. Route file editing SHALL remain +file-based and out of this editor's scope. `RelevantDoctorChecks` +SHALL include `ConfigSchemaDoctorCheck` and the existing +`InboundWebhookRoutesDoctorCheck`. + +#### Scenario: Enabling inbound webhooks with no routes surfaces warning + +- **GIVEN** `~/.netclaw/config/webhooks/` contains zero route files +- **WHEN** the operator enables inbound webhooks and saves +- **THEN** `InboundWebhookRoutesDoctorCheck` returns WARN +- **AND** the inline warning banner explains routes must be added via + files +- **AND** Save anyway writes `Webhooks.Enabled = true` + +### Requirement: External Skill Directories editor + +The dashboard SHALL include an `ExternalSkillsSectionEditor` +(`SectionId = "ExternalSkills"`) presenting the existing path array +via the generic `ListEditor` with the `PathItemEditor` inline-edit +shape. The editor SHALL validate each path on save: existence, +directory-ness, readability. Errors SHALL render inline below the +relevant row. `RelevantDoctorChecks` SHALL include +`ConfigSchemaDoctorCheck` and the new +`ExternalSkillSourcesDoctorCheck`. + +#### Scenario: Non-existent path blocks save + +- **GIVEN** the External Skills editor is open with a newly-added path + pointing at a non-existent directory +- **WHEN** the operator saves +- **THEN** `ExternalSkillSourcesDoctorCheck` returns ERROR +- **AND** the row renders the error inline +- **AND** the save is blocked + +### Requirement: Skill Feeds editor + +The dashboard SHALL include a `SkillFeedsSectionEditor` +(`SectionId = "SkillFeeds"`) presenting the existing feed array via +the generic `ListEditor` with the `SkillFeedItemEditor` sub-page +form. Each feed SHALL expose name, URL, optional Bearer API key +(secret-handling contract), and a Test Connection affordance. +`RelevantDoctorChecks` SHALL include `ConfigSchemaDoctorCheck` and +the new `SkillFeedsDoctorCheck` (WARN-only on reachability so transient +remote outages do not lock operators out of editing). + +#### Scenario: Unreachable feed surfaces warning but allows save + +- **GIVEN** the Skill Feeds editor is open with a feed pointing at an + unreachable URL +- **WHEN** the operator saves +- **THEN** `SkillFeedsDoctorCheck` returns WARN +- **AND** the inline warning banner displays "feed unreachable" +- **AND** activating Save anyway writes the merged config + +### Requirement: Browser Automation editor + +The dashboard SHALL include a `BrowserAutomationSectionEditor` +(`SectionId = "BrowserAutomation"`) presenting the feature-flag toggle +and a status indicator showing whether Playwright is installed and at +which version. If Playwright is not installed, the toggle SHALL be +disabled and an "Install instructions" sub-page SHALL be reachable +from the editor footer. The installation itself SHALL NOT be invoked +from inside the TUI; the sub-page SHALL print platform-appropriate +shell commands and instruct the operator to re-open the editor after +installing. `RelevantDoctorChecks` SHALL include +`ConfigSchemaDoctorCheck` and the new +`BrowserAutomationDoctorCheck`. + +#### Scenario: Toggle disabled when Playwright absent + +- **GIVEN** the Browser Automation editor is open +- **AND** Playwright is not installed on the host +- **WHEN** the editor renders +- **THEN** the `Browser automation enabled` toggle is disabled +- **AND** the editor footer shows `[ Install instructions → ]` + +#### Scenario: Enabling without Playwright blocks save + +- **GIVEN** the Browser Automation editor is open +- **AND** Playwright is not installed +- **AND** the editor is somehow holding `Enabled = true` (e.g. from a + hand-edited file) +- **WHEN** the operator saves +- **THEN** `BrowserAutomationDoctorCheck` returns ERROR +- **AND** the save is blocked with remediation guidance + +### Requirement: Smoke tape per editor and the no-init refusal + +The smoke-test harness SHALL include a tape per registered section +editor at `tests/smoke/tapes/config-.tape` plus a +matching assertion script at +`tests/smoke/assertions/config-.sh`. The harness +SHALL also include `config-no-init.tape` and its assertion exercising +the refuse-when-no-config path. Each section-editor tape SHALL +pre-stage existing `netclaw.json` and `secrets.json` fixtures, +exercise at least one save round-trip, and the assertion SHALL verify +the modified field changed and all other top-level sections are +byte-identical. + +#### Scenario: Audit fails when an editor lacks a tape + +- **GIVEN** a newly-added `ISectionEditor` registered in the menu +- **AND** no tape file at `tests/smoke/tapes/config-.tape` +- **WHEN** `MenuRegistryAuditTests` runs +- **THEN** the test fails with a message naming the missing tape path + +#### Scenario: Audience tape exercises arrow nav and toggle + +- **WHEN** `config-audience.tape` runs +- **THEN** the tape sends `↓`, `Space`, `↑`, `Space` keystrokes within + the Team audience editor +- **AND** the assertion verifies the per-feature toggle state in + `Tools.AudienceProfiles.Team` + +#### Scenario: No-config refusal exits non-zero + +- **GIVEN** the smoke test harness stages a `NETCLAW_HOME` containing + no `config/netclaw.json` +- **WHEN** `config-no-init.tape` runs `netclaw config` +- **THEN** the command exits with non-zero status +- **AND** the assertion observes the refusal message on stderr diff --git a/openspec/changes/netclaw-config-command/tasks.md b/openspec/changes/netclaw-config-command/tasks.md new file mode 100644 index 000000000..4ff3b3978 --- /dev/null +++ b/openspec/changes/netclaw-config-command/tasks.md @@ -0,0 +1,263 @@ +## 1. OpenSpec planning artifacts and traceability + +- [ ] 1.1 Confirm proposal, design, and spec deltas cover the + `netclaw config` command, the dashboard, ten section editors, the + generic list/item editor framework, the four new doctor checks, the + schema addition for `BrowserAutomation`, twelve smoke tapes plus the + no-init refusal tape, ten round-trip xUnit test classes, and the + hardened menu registry audit. +- [ ] 1.2 Verify traceability references to `PRD-004`, `PRD-001`, and + `PRD-002` across change artifacts. +- [ ] 1.3 Run `openspec validate netclaw-config-command --type change` + and resolve all issues. + +## 2. Schema and configuration types + +- [ ] 2.1 Add a `BrowserAutomation` top-level section to + `src/Netclaw.Configuration/Schemas/netclaw-config.v1.schema.json` + with `Enabled` (bool, default `false`) and `PlaywrightVersion` + (string, optional). Use `additionalProperties: false`. +- [ ] 2.2 Add `src/Netclaw.Configuration/BrowserAutomationConfig.cs` + matching the schema. +- [ ] 2.3 Update existing exemption list / schema-fix entries as needed + so `SchemaFixResolver` can auto-insert `BrowserAutomation` on + upgrade. + +## 3. Dashboard scaffolding + +- [ ] 3.1 Add `src/Netclaw.Cli/Config/ConfigCommand.cs` as the + top-level command class wired into `Netclaw.Cli.Program` routing. +- [ ] 3.2 Add `src/Netclaw.Cli/Tui/Sections/ConfigDashboardPage.cs` and + `ConfigDashboardViewModel.cs` rendering each `ISectionEditor` from + the registry, plus "Run full doctor" and "Quit" items. +- [ ] 3.3 Implement per-section status badge computation at dashboard + entry (runs each editor's `RelevantDoctorChecks` against on-disk + config and caches results until the editor saves). +- [ ] 3.4 Implement category grouping (siblings sharing `Category` + render under a single unselectable label). +- [ ] 3.5 Implement no-config refusal path: detect missing + `netclaw.json` at startup, print refusal to stderr, exit non-zero. +- [ ] 3.6 Implement daemon-restart nudge: detect running daemon at + exit; print stderr line only when (a) at least one section saved + during the session AND (b) the daemon is running. + +## 4. Generic list/item editor framework + +- [ ] 4.1 Add `src/Netclaw.Cli/Tui/Sections/Components/IItemEditor.cs` + with `DisplayRow`, `KeyOf`, `RequiresSubPage`, + `CreateSubPageEditor`, `EditInline`, `AddInline`. +- [ ] 4.2 Add `src/Netclaw.Cli/Tui/Sections/Components/ListEditor.cs` + implementing add (inline `+ Add` row), edit (inline or sub-page + depending on item editor), remove (single-key `d` then `[y/N]` + prompt), Save / Cancel, in-place rename via `KeyOf` semantics. +- [ ] 4.3 Add `PathItemEditor` (inline string edit; validates path + existence/readability lazily on parent save). +- [ ] 4.4 Add `IdentifierItemEditor` (inline string edit; used by + channel-ID lists, user-ID lists, trusted-proxy CIDR list). +- [ ] 4.5 Add `WebhookItemEditor` (sub-page form: name, URL, optional + auth-header secret-handling, optional event filter). +- [ ] 4.6 Add `SkillFeedItemEditor` (sub-page form: name, URL, + optional Bearer API key secret-handling, Test Connection + affordance). + +## 5. Shared editor components + +- [ ] 5.1 Add `ValidationBanner` component for the inline + errors-and-warnings band above the action row. +- [ ] 5.2 Add `DiscardChangesPrompt` (used on Esc-with-dirty-state in + any editor). +- [ ] 5.3 Add `RemoveCredentialPrompt` (default-Cancel modal confirm + for any secret removal). + +## 6. Section editors — single-value + +- [ ] 6.1 `SearchSectionEditor` (`SectionId = "Search"`): backend + selector + conditional API key / SearXng URL fields. Honor + `ExistingConfig`. `RelevantDoctorChecks`: + `{ConfigSchemaDoctorCheck, SearchBackendDoctorCheck}`. +- [ ] 6.2 `SecurityPostureSectionEditor` + (`SectionId = "Security.Posture"`): three-choice posture list with + cascade dialog (Cancel | Overwrite | Keep custom) when changing + posture over customized `Tools.AudienceProfiles`. +- [ ] 6.3 `AudienceProfilesSectionEditor` + (`SectionId = "Tools.AudienceProfiles"`): audience picker + (Personal | Team | Public) opening per-audience editor with + toggleable feature rows, shell-mode selector, approval policy + selector, and "Reset to posture default" affordance. MUST exercise + arrow nav + Space toggle (#1150 contract). +- [ ] 6.4 `InboundWebhooksSectionEditor` (`SectionId = "Webhooks"`): + feature-flag toggle + request timeout integer. +- [ ] 6.5 `BrowserAutomationSectionEditor` + (`SectionId = "BrowserAutomation"`): feature-flag toggle with + Playwright detection at entry; install-instructions sub-page when + Playwright absent. + +## 7. Section editors — multi-value (compose ListEditor) + +- [ ] 7.1 `OutboundWebhooksSectionEditor` + (`SectionId = "Notifications.Webhooks"`) using + `WebhookItemEditor`. +- [ ] 7.2 `ExternalSkillsSectionEditor` + (`SectionId = "ExternalSkills"`) using `PathItemEditor`. +- [ ] 7.3 `SkillFeedsSectionEditor` (`SectionId = "SkillFeeds"`) using + `SkillFeedItemEditor`. + +## 8. Section editors — chat channels (composite) + +- [ ] 8.1 `SlackSectionEditor` (`SectionId = "Slack"`, + `Category = "Chat Channels"`): bot token + app token, allowed + channels list, allowed users list, DMs toggle, audience profile + selector, Test Connection. Reuses + `channel-audience-tui` cycling component for the channel list. +- [ ] 8.2 `DiscordSectionEditor` (`SectionId = "Discord"`, + `Category = "Chat Channels"`): single bot token, same affordances + otherwise. +- [ ] 8.3 `MattermostSectionEditor` (`SectionId = "Mattermost"`, + `Category = "Chat Channels"`): server URL + bot token, same + affordances otherwise. + +## 9. Section editor — exposure mode (composite) + +- [ ] 9.1 `ExposureModeSectionEditor` + (`SectionId = "Daemon.ExposureMode"`): mode selector (Local | + Reverse Proxy | Tailscale | Cloudflare Tunnel), daemon host/port + fields, mode-conditional sub-forms. +- [ ] 9.2 Reverse Proxy sub-form: external base URL + trusted + proxies list (via `ListEditor` + `IdentifierItemEditor`). +- [ ] 9.3 Tailscale sub-form: auth key (secret) + hostname. +- [ ] 9.4 Cloudflare Tunnel sub-form: tunnel token (secret) + + optional access-policy email domain. + +## 10. New doctor checks + +- [ ] 10.1 `SearchBackendDoctorCheck` (validates backend ↔ required + credential pairing; ERROR when Brave/SearXng configured without + required field). +- [ ] 10.2 `ExternalSkillSourcesDoctorCheck` (validates each path is + an existing readable directory). +- [ ] 10.3 `SkillFeedsDoctorCheck` (validates URL reachability; + WARN-only — transient outages don't block saves). +- [ ] 10.4 `BrowserAutomationDoctorCheck` (ERROR when + `BrowserAutomation.Enabled = true` and Playwright binary not + resolvable from PATH). +- [ ] 10.5 Register each new check via the existing doctor + registration extensions so they participate in + `netclaw doctor` runs. + +## 11. DI wiring + +- [ ] 11.1 Register all ten new editors via + `services.AddSectionEditor()` in the CLI DI composition + root. +- [ ] 11.2 Confirm registry construction fails fast on any duplicate + `SectionId`. +- [ ] 11.3 Wire `ConfigCommand` into the CLI top-level command + dispatch. + +## 12. Round-trip xUnit tests (Layer 2) + +- [ ] 12.1 `SearchSectionEditorTests` covering single-value path and + the DuckDuckGo ↔ Brave backend switch preserves Brave key + scenario. +- [ ] 12.2 `SlackSectionEditorTests` covering reentrancy across + channel-list + user-list + secret-handling for both tokens. +- [ ] 12.3 `DiscordSectionEditorTests`. +- [ ] 12.4 `MattermostSectionEditorTests` (incl. server URL field). +- [ ] 12.5 `ExposureModeSectionEditorTests` covering all four mode + sub-forms. +- [ ] 12.6 `SecurityPostureSectionEditorTests` covering all three + cascade options. +- [ ] 12.7 `AudienceProfilesSectionEditorTests` covering toggle + rount-trip and posture-default reset. +- [ ] 12.8 `OutboundWebhooksSectionEditorTests` covering add / + edit / remove / in-place rename preserves item identity. +- [ ] 12.9 `InboundWebhooksSectionEditorTests`. +- [ ] 12.10 `ExternalSkillsSectionEditorTests` (incl. invalid-path + inline validation). +- [ ] 12.11 `SkillFeedsSectionEditorTests` (incl. WARN-only reachability + behavior). +- [ ] 12.12 `BrowserAutomationSectionEditorTests` (incl. + toggle-disabled-when-absent behavior). + +## 13. Smoke tapes (Layer 1) + +- [ ] 13.1 `config-search.tape` + assertion: pre-stage Brave + key, + switch to DuckDuckGo, save, assert backend=duckduckgo and Brave + key preserved. +- [ ] 13.2 `config-slack.tape` + assertion: pre-stage tokens + 2 + channels, add 1 channel, save, assert 3 channels and tokens + unchanged. +- [ ] 13.3 `config-discord.tape` + assertion. +- [ ] 13.4 `config-mattermost.tape` + assertion (incl. URL + token + + channel). +- [ ] 13.5 `config-exposure-mode.tape` + assertion: pre-stage Local, + switch to Reverse Proxy, add CIDR, save, assert mode and CIDR + changes plus byte-equal unrelated sections. Migrates coverage + from former `init-wizard-reverse-proxy.tape`. +- [ ] 13.6 `config-posture.tape` + assertion: change Personal → + Team, accept cascade, save, assert posture and audience-default + changes. +- [ ] 13.7 `config-audience.tape` + assertion: exercise `↓`, + `Space`, `↑`, `Space` keystrokes on Team audience editor, save, + assert `Tools.AudienceProfiles.Team` toggle state. This tape is + the #1150 regression guard. +- [ ] 13.8 `config-outbound-webhooks.tape` + assertion: pre-stage 1 + webhook, add 2nd via sub-page, save, assert array length 2 and + first byte-identical. +- [ ] 13.9 `config-inbound-webhooks.tape` + assertion. +- [ ] 13.10 `config-external-skills.tape` + assertion: pre-stage 1 + path, add 1 + remove the original via `d`, save, assert single + remaining new entry. +- [ ] 13.11 `config-skill-feeds.tape` + assertion: pre-stage empty, + add 1 feed with Bearer key via sub-page, save, assert feed in + config + key in secrets. +- [ ] 13.12 `config-browser-automation.tape` + assertion: pre-stage + Playwright absent, open install instructions, exit without save, + assert no config write. +- [ ] 13.13 `config-no-init.tape` + assertion: stage empty + `NETCLAW_HOME`, run `netclaw config`, assert non-zero exit and + stderr refusal message. + +## 14. Menu registry audit promotion + +- [ ] 14.1 In `MenuRegistryAuditTests`, flip the smoke-tape + existence check from soft-warn to hard-fail. The test asserts a + matching tape file at `tests/smoke/tapes/config-.tape` + for every registered editor. +- [ ] 14.2 Update the audit's failure-message text to name (a) the + editor's `SectionId`, (b) the missing artifact path, (c) the + remediation step ("add a tape" / "add a test class" / "declare + `RelevantDoctorChecks` or `[NoDoctorChecks]`"). + +## 15. PRD-004 update + +- [ ] 15.1 Update `docs/prd/PRD-004-cli-onboarding-and-config.md`: + replace the "reentrant init dashboard" wording with the + simplified-init + `netclaw config` split. List the ten section + editors as the menu surface. +- [ ] 15.2 Cross-reference issues #455 (closed in Change A) and + #1150 (closed in this change). + +## 16. Quality gates + +- [ ] 16.1 `dotnet build` clean. +- [ ] 16.2 `dotnet test` clean: all round-trip tests pass; audit + passes (every registered editor has tape + test class + doctor + checks); existing tests remain green. +- [ ] 16.3 `./scripts/smoke/run-smoke.sh light` clean (all 12 new + config tapes plus the no-init refusal tape pass). +- [ ] 16.4 `dotnet slopwatch analyze` reports no new violations. +- [ ] 16.5 `./scripts/Add-FileHeaders.ps1 -Verify` reports clean. +- [ ] 16.6 `openspec validate netclaw-config-command --type change` + passes. + +## 17. Documentation + +- [ ] 17.1 Update CLI `--help` text for `netclaw config` so the + command is discoverable from `netclaw --help`. +- [ ] 17.2 Update `feeds/skills/.system/files/netclaw-operations/SKILL.md` + per CLAUDE.md system-skills sync rule, adding a section that + describes `netclaw config` and the ten editable sections. Bump + `metadata.version`. +- [ ] 17.3 PR description closes #1150 and references this OpenSpec + change ID. diff --git a/openspec/changes/section-editor-abstraction/.openspec.yaml b/openspec/changes/section-editor-abstraction/.openspec.yaml new file mode 100644 index 000000000..0f0616986 --- /dev/null +++ b/openspec/changes/section-editor-abstraction/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-23 diff --git a/openspec/changes/section-editor-abstraction/design.md b/openspec/changes/section-editor-abstraction/design.md new file mode 100644 index 000000000..8a48d0dde --- /dev/null +++ b/openspec/changes/section-editor-abstraction/design.md @@ -0,0 +1,213 @@ +## Context + +The `netclaw init` wizard composed of `WizardOrchestrator` + a fixed list of +`IWizardStepViewModel`s produces a runnable Netclaw configuration but treats +the on-disk state as a write-once target. There is no shared abstraction for +"the editable surface of one configuration section," so every section's input +collection, validation, and persistence logic lives inline in its step +viewmodel. Three foundations from PR #432 partially anticipate the shared +abstraction: + +- `WizardContext.ExistingConfig` is declared on the context object but + never populated. +- `ConfigFileHelper` and `ProviderCredentialWriter` already implement the + load-merge-write pattern, used today by `netclaw provider`/`model`/`mcp` + CLI subcommands. +- Each `IWizardStepViewModel.OnEnter(context, direction)` already receives a + direction marker, but no step uses it. + +This change formalizes the shared abstraction so the next change can compose +existing step viewmodels into the new `netclaw config` command without +forking their logic. It also closes the long-standing reentrancy gap (#455): +re-running `netclaw init` over an existing install now produces a sensible +pre-filled wizard with merge-on-save semantics, rather than the prior +undefined behavior. + +## Goals / Non-Goals + +**Goals:** + +- Define `ISectionEditor` such that any step viewmodel implementing it can + be hosted either by the linear init wizard or by a single-step + orchestrator that the next change introduces, with no per-host behavior + difference visible to the user. +- Lock in three operational contracts that future section editors must + honor: reentrancy (pre-fill from `ExistingConfig`), secret handling + (never rehydrate; "leave blank to keep"), and merge-on-save + (byte-equality of every other top-level section). +- Establish the audit + test harness up-front so the contracts are enforced + from the first registered editor, not retrofitted later when drift has + already begun. +- Refactor Provider, Identity, and Posture step viewmodels to implement + `ISectionEditor`. Behavior inside today's linear init wizard remains + observable-equivalent for first-run. +- Close #455 (reentrant init) as a byproduct of populating `ExistingConfig` + at entry and switching `WizardConfigBuilder` to merge-on-save. + +**Non-Goals:** + +- Introducing the `netclaw config` command (next change). +- Adding the remaining seven section editors (next change). +- Simplifying the init wizard's step list to provider + identity + + posture only (third change). +- Hot-reload of the running daemon on config change (out of scope; remains + a documented manual-restart limitation). +- Section editor UI for sections that today are file-edited only + (`Persistence`, `Logging`, `Telemetry`, etc.) — these stay on the + exemption list. +- Reworking `netclaw provider`/`model`/`mcp` CLI subcommands to share + backing logic with the new abstraction. Their existing behavior is + unchanged; future work may unify them. + +## Decisions + +### D1. `ISectionEditor` as a viewmodel factory, not a viewmodel base class + +The interface returns an `IWizardStepViewModel` from `CreateEditor(context)` +rather than extending the existing viewmodel base. This keeps the +orchestrator's lifecycle contract authoritative and avoids multiple +inheritance / diamond issues for step viewmodels that already extend a +shared base. It also lets a single `ISectionEditor` produce different +viewmodels for different contexts in the future (e.g. a future +"compact" view) without changing the interface. + +Alternative considered: make `ISectionEditor` itself extend +`IWizardStepViewModel`. Rejected because it conflates "this thing is a +runnable step" with "this thing describes an editable section in the +registry"; the dashboard and audit code want the metadata without +constructing a runnable step. + +### D2. Merge-on-save via existing `ConfigFileHelper` primitives + +`WizardConfigBuilder` is refactored to call `ConfigFileHelper.LoadConfigFiles` +and `GetOrCreateSection` rather than building a fresh dictionary. The +existing primitives have already been proven by `ProviderCredentialWriter` +and the CLI subcommands; no new merge code is introduced. Each editor +contributes via an explicit `SectionContribution` record carrying +`Dictionary` for non-secrets and +`Dictionary` for secrets. The merge writer applies +the actions deterministically; "blank means X" is the editor's job to +interpret, not the merge layer's. + +Alternative considered: introduce a fresh JSON-patch-style operation log. +Rejected because the existing dictionary-based pattern is already in +production use and a parallel mechanism would introduce a forking point. + +### D3. Secret-presence lookup as a first-class API + +`ConfigFileHelper.SecretPresent(paths, sectionId, key)` is added to satisfy +the "configured / not set" hint without exposing the decrypted value. This +keeps the secret-handling contract enforceable at the type level: editors +that need to show the hint cannot accidentally hold the decrypted value +because the API does not return one. + +Alternative considered: have editors call the secrets protector and discard +the decrypted value after a length check. Rejected because the decrypted +value would still transit through process memory; a presence-only API +guarantees the value is never decrypted at all. + +### D4. Audit walks the menu registry, not the full schema + +`MenuRegistryAuditTests` walks `SectionEditorRegistry.All()`. Schema +sections without a registered editor are not audited unless they appear +in the exemption list. The audit's purpose is to enforce contracts on +editors we ship, not to demand editors for every schema knob; the +exemption list is the explicit "we know about this section and choose +not to expose it" record. + +Alternative considered: walk the schema and require every top-level +section to either have an editor or an exemption. Rejected per planning +discussion: forcing editors for every schema knob produces shallow, +unhelpful UIs for sections nobody edits via TUI. The menu-driven audit +prevents drift on the surfaces we promise to users, which is the failure +mode that actually matters. + +### D5. Refactor exactly three editors in this change + +Provider, Identity, and Posture are the three steps that survive in the +simplified init wizard (third change). Refactoring them here lets us +verify the abstraction end-to-end against real editors without entangling +this change with the larger config-command surface. The remaining seven +editors are introduced as new `ISectionEditor` implementations in the +next change, alongside the dashboard that hosts them. + +Alternative considered: refactor all ten existing init steps at once. +Rejected because it bloats this PR and ties the abstraction's correctness +to behavioral equivalence across far more surface area than necessary to +prove the contract. + +### D6. `ExistingConfig` is `Dictionary`, not strongly typed + +Reuses the type already declared on `WizardContext`. Strongly-typed access +would require introducing a parallel typed view of `netclaw.json`, which +defeats the schema-as-source-of-truth principle. The dictionary form is +also forgiving across schema versions: an unknown key simply doesn't +surface in any editor's slice. + +Alternative considered: bind to typed `*Config` records via +`IConfiguration`. Rejected because the merge step would then need to +re-emit the typed records as JSON, multiplying the round-trip surface +area and introducing per-property null/default ambiguity. + +### D7. `WizardOrchestrator` gets a single-step constructor, not a new class + +Existing orchestration logic (back/forward, dirty tracking, save flow) +already covers the single-step case; we add a constructor and a mode +flag rather than a parallel orchestrator type. This keeps the +orchestrator the single authority on step lifecycle. + +Alternative considered: introduce `SectionEditorRunner` as a separate +host. Rejected because behavior would inevitably drift between two +orchestrators over time. + +## Risks / Trade-offs + +- [Refactor risk] Touching three existing step viewmodels could regress + first-run init behavior. → Mitigation: existing `init-wizard.tape` + smoke test continues to gate every PR. Round-trip xUnit tests added in + this change provide finer-grained protection than the tape alone. + +- [Merge-on-save regressions] If the merge logic loses precision on edge + shapes (`JsonElement` value kinds, nested arrays), unrelated sections + could silently change. → Mitigation: round-trip tests assert + byte-equality of unmodified sections. The existing `ConfigFileHelper` + already handles the JsonElement coercion path; we extend its coverage, + not rewrite it. + +- [Vacuous audit] At the end of this change, the registry contains only + three editors and the audit asserts a small surface. The audit's value + scales with the next change. → Mitigation: the audit is wired now so + that adding any editor in the next change automatically tightens the + enforcement; no follow-up wiring step is required. + +- [Secrets in `ExistingConfig`] The parsed `netclaw.json` may include + schema fields that are themselves sensitive (e.g. allowed user IDs, + email domains). → Mitigation: only `secrets.json` is exempted from + context loading; non-secret PII present in `netclaw.json` is no more + exposed than today. Section editors that render lists of IDs already + display them in clear; this is unchanged. + +- [Schema sections added without registry update] Future schema additions + not in the exemption list and not bound to an editor would fail the + audit immediately on their first PR. → Mitigation: this is the intended + behavior. The exemption list is updated in the same PR that adds the + schema section. + +## Migration Plan + +This change is internal-only and observable behavior is preserved for +first-run init. No data migration is required. The deploy story: + +1. Land this change. `netclaw init` continues to behave identically for + first-run installs; re-runs over existing config now pre-populate + fields and merge on save (previously undefined). +2. The next change introduces `netclaw config`. No further migration + needed. + +Rollback: revert the change. `WizardContext.ExistingConfig` returns to its +declared-but-unused state. `WizardConfigBuilder` returns to overwrite. +First-run behavior is unaffected. + +## Open Questions + +None at execution time. All architectural decisions are locked above. diff --git a/openspec/changes/section-editor-abstraction/proposal.md b/openspec/changes/section-editor-abstraction/proposal.md new file mode 100644 index 000000000..c5c746cea --- /dev/null +++ b/openspec/changes/section-editor-abstraction/proposal.md @@ -0,0 +1,117 @@ +## Why + +Netclaw's `netclaw init` wizard is a linear forward-pass over a hardcoded step +sequence with no reentrancy: re-running it over an existing install is +undefined, and changing one configuration knob requires editing +`netclaw.json` by hand. Existing single-section CLI editors +(`netclaw provider`, `netclaw model`, `netclaw mcp`) prove the load-merge-write +pattern works, but they duplicate logic with the wizard rather than sharing it. +This change introduces the shared abstraction that both the init wizard and a +forthcoming `netclaw config` command (next change) will compose, completes the +long-deferred reentrancy of `netclaw init` (#455), and makes future config +knobs reentrant by construction. + +Source PRDs: `PRD-004-cli-onboarding-and-config.md`, `PRD-001-netclaw-mvp.md`. + +## What Changes + +- Add a `ISectionEditor` interface in `Netclaw.Cli.Tui.Sections`. Each instance + describes one editable configuration section: schema-keyed identity, + dashboard summary, status badge computation, relevant doctor checks, and a + factory that returns a `IWizardStepViewModel` runnable either by the wizard + orchestrator or standalone. +- Add `SectionEditorRegistry`, `SectionStatus`, `SectionContribution` + (carrying explicit `FieldAction` and `SecretAction` per field), and + `SectionEditorExemptions` (documented opt-outs for schema sections that + intentionally have no TUI editor). +- Add a single-step constructor to `WizardOrchestrator` so a section editor can + be run outside the linear wizard with the same lifecycle, save, and cancel + semantics. +- Populate `WizardContext.ExistingConfig` at `netclaw init` entry when an + existing `netclaw.json` is present. Each refactored section editor's + `OnEnter()` pre-fills non-secret fields from its slice. +- Switch `WizardConfigBuilder.WriteConfigFile()` from "build fresh + + overwrite" to "load existing + merge + write," matching the pattern already + used by `ProviderCredentialWriter`. Apply the same load-merge-write rule to + the secrets writer. +- Refactor three existing init step viewmodels — Provider, Identity, + SecurityPosture — to implement `ISectionEditor`. Behavior inside the linear + init wizard is unchanged for first-run; reentrant pre-population is gained + for the next change's config command. +- Establish day-one reentrancy contracts in code: secrets never rehydrate + to screen (masked input with "leave blank to keep" semantics), and + section saves preserve every other top-level section in `netclaw.json` and + `secrets.json` byte-for-byte. +- Add a `MenuRegistryAuditTests` xUnit test that walks the registry and + asserts each registered editor declares non-empty `RelevantDoctorChecks` + (or carries an explicit `[NoDoctorChecks]` justification attribute), has a + registered round-trip test class, and — once the config command lands in + the next change — has a matching smoke tape. In this change the audit runs + vacuously over a registry containing the three refactored editors. +- Add a `SectionEditorTestBase` xUnit harness with shared round-trip + scenarios: `RoundTrip_NoOpEdit_PreservesConfig`, + `RoundTrip_SingleFieldEdit_UpdatesOnlyThatField`, + `Secrets_BlankSubmit_PreservesExistingSecret`, + `Secrets_NonBlankSubmit_ReplacesSecret`, + `Secrets_RemoveAction_DeletesSecret`. Concrete subclasses for the three + refactored editors are included. +- Add `ConfigFileHelper.SecretPresent(paths, section, key)` so editors can + render "configured — leave blank to keep" hints without decrypting the + secret value (#455 contract: never rehydrate secrets to the screen). +- Closes #455 (`netclaw init` reentrancy gap). + +**In scope (MVP):** the abstraction, registry, exemption list, audit and +round-trip test harnesses, single-step orchestrator mode, merge-on-save for +both `netclaw.json` and `secrets.json`, `ExistingConfig` population at init +entry, and refactor of Provider/Identity/Posture to implement the contract. + +**Out of scope:** the new `netclaw config` command itself (next change), the +remaining nine section editors (next change), simplification of the init +wizard step list (third change), and hot-reload of the running daemon on +config changes. + +## Capabilities + +### New Capabilities + +- `section-editor-abstraction`: contract requirements for the reusable + editable-section abstraction — `ISectionEditor`, registry semantics, + reentrancy contract, secret-handling contract, merge-on-save semantics, + and audit obligations for every registered editor. + +### Modified Capabilities + +- `netclaw-onboarding`: `netclaw init` SHALL populate `WizardContext.ExistingConfig` + at entry from on-disk config, and section editors SHALL pre-fill non-secret + fields from it in `OnEnter()` while leaving secret fields empty with the + documented "configured" hint. The wizard's terminal write SHALL be a merge + over existing config, not an overwrite. + +## Impact + +**Affected systems:** + +- CLI init wizard wiring (`Netclaw.Cli.Program`, + `Netclaw.Cli.Tui.Wizard.WizardOrchestrator`, + `Netclaw.Cli.Tui.Wizard.WizardConfigBuilder`, + `Netclaw.Cli.Tui.Wizard.WizardContext`). +- Three init step viewmodels (`ProviderStepViewModel`, `IdentityStepViewModel`, + `SecurityPostureStepViewModel`) gain `ISectionEditor` implementations. +- Config merge helper (`Netclaw.Cli.Config.ConfigFileHelper`) gains + `SecretPresent(...)`. +- New test surface under `tests/Netclaw.Cli.Tests/Tui/Sections/` covering the + abstraction and the three refactored editors. + +**Security and operational impact:** + +- Secrets are never re-rendered to the TUI; the new `SecretPresent` lookup + returns existence only, never the decrypted value. This preserves the + default-deny posture for credential display. +- Merge-on-save replaces overwrite-on-save. The contract guarantee is + byte-equality of all other top-level sections in `netclaw.json` and + `secrets.json`. Round-trip tests enforce the guarantee. +- Re-running `netclaw init` over an existing config is no longer undefined; + in this change the wizard pre-fills fields and merges on save. Explicit + "existing-config refusal" UX lands in the third change. +- No new network surface, no new persistence schema, no new daemon + contract changes. diff --git a/openspec/changes/section-editor-abstraction/specs/netclaw-onboarding/spec.md b/openspec/changes/section-editor-abstraction/specs/netclaw-onboarding/spec.md new file mode 100644 index 000000000..a2b358f09 --- /dev/null +++ b/openspec/changes/section-editor-abstraction/specs/netclaw-onboarding/spec.md @@ -0,0 +1,112 @@ +## ADDED Requirements + +### Requirement: Reentrant init pre-population + +`netclaw init` SHALL load existing `netclaw.json` and `secrets.json` at +entry and assign the parsed top-level dictionary to +`WizardContext.ExistingConfig`. When the wizard runs over an existing +install, every step viewmodel implementing `ISectionEditor` SHALL pre-fill +non-secret UI fields from its slice in `ExistingConfig` and SHALL render +secret-bearing fields empty with the documented hint text indicating +whether the underlying secret is present. Steps that do not implement +`ISectionEditor` SHALL preserve their first-run behavior in this change. + +#### Scenario: Provider step pre-fills from existing config + +- **GIVEN** `netclaw.json` contains a configured `Providers.anthropic` + entry +- **WHEN** `netclaw init` enters the Provider step +- **THEN** the provider list opens with `anthropic` as the focused + selection +- **AND** any API key input renders empty with "configured — leave blank + to keep" hint text +- **AND** the OAuth token expiry date displays as previously stored + +#### Scenario: Identity step pre-fills from existing config + +- **GIVEN** `netclaw.json` contains a previously-set agent name, user + name, and timezone +- **WHEN** `netclaw init` enters the Identity step +- **THEN** each text field opens with the previously-set value as the + default + +#### Scenario: Security Posture step pre-fills from existing config + +- **GIVEN** `netclaw.json` contains a previously-set deployment posture +- **WHEN** `netclaw init` enters the Security Posture step +- **THEN** the posture list opens with the previously-set posture as the + focused selection + +#### Scenario: Fresh install leaves ExistingConfig null + +- **GIVEN** no `netclaw.json` exists on disk +- **WHEN** `netclaw init` enters the wizard +- **THEN** `WizardContext.ExistingConfig` is `null` +- **AND** every step renders its first-run defaults + +### Requirement: Merge-on-save for init wizard + +`netclaw init` SHALL produce its terminal `netclaw.json` write as a merge +of the wizard's accumulated contributions over the existing on-disk file +(or a fresh skeleton when no file exists). For every top-level section +the wizard did not contribute to, the resulting file SHALL be +byte-identical to its pre-write state. The same merge rule SHALL apply +to `secrets.json`. + +#### Scenario: Re-running init preserves unrelated sections + +- **GIVEN** `netclaw.json` contains configured `Slack`, `Discord`, and + `Search` sections +- **AND** `netclaw init` is re-run and only the Provider step is + modified +- **WHEN** the wizard completes and writes +- **THEN** the resulting `netclaw.json` contains the updated `Providers` + section +- **AND** `Slack`, `Discord`, and `Search` are byte-identical to their + pre-write state + +#### Scenario: Re-running init preserves unrelated secrets + +- **GIVEN** `secrets.json` contains a Brave API key and Slack bot/app + tokens +- **AND** `netclaw init` is re-run and only the Provider step's API key + is changed +- **WHEN** the wizard completes and writes +- **THEN** the resulting `secrets.json` contains the new provider API key +- **AND** the Brave API key and Slack tokens are byte-identical to their + pre-write state + +#### Scenario: First-run write produces a complete file + +- **GIVEN** no `netclaw.json` exists on disk +- **WHEN** the wizard completes and writes +- **THEN** the resulting `netclaw.json` contains every section the + wizard contributed to +- **AND** validates against `netclaw-config.v1.schema.json` + +### Requirement: Secrets never rehydrate to the wizard UI + +No step in `netclaw init` SHALL display the decrypted value of any +secret stored in `secrets.json`. Secret-bearing inputs SHALL render +empty masked fields whose hint text indicates whether a value exists, +following the secret-handling contract defined in the +`section-editor-abstraction` capability. + +#### Scenario: Re-run shows stored API key as configured-not-displayed + +- **GIVEN** `secrets.json` contains a stored Brave API key +- **WHEN** `netclaw init` is re-run and reaches a step that would render + the API key field +- **THEN** the field renders empty +- **AND** the hint text reads "configured — leave blank to keep" +- **AND** no part of the decrypted key appears anywhere on screen + +#### Scenario: Re-run with blank submit preserves the stored secret + +- **GIVEN** `secrets.json` contains a stored Brave API key +- **WHEN** `netclaw init` is re-run and the user leaves the API key + field blank and continues +- **THEN** the wizard's terminal write does not rewrite the stored + encrypted value +- **AND** the Brave API key is byte-identical in `secrets.json` + pre-write and post-write diff --git a/openspec/changes/section-editor-abstraction/specs/section-editor-abstraction/spec.md b/openspec/changes/section-editor-abstraction/specs/section-editor-abstraction/spec.md new file mode 100644 index 000000000..73dca0abf --- /dev/null +++ b/openspec/changes/section-editor-abstraction/specs/section-editor-abstraction/spec.md @@ -0,0 +1,326 @@ +## ADDED Requirements + +### Requirement: Section editor interface + +The CLI SHALL define a `ISectionEditor` contract in +`Netclaw.Cli.Tui.Sections` that describes a single editable configuration +section. Each implementation SHALL declare a stable `SectionId` whose value +matches a schema key in `netclaw-config.v1.schema.json` (dotted-path form is +permitted for nested sections such as `Daemon.ExposureMode` and +`Tools.AudienceProfiles`), a user-facing `DisplayName`, an optional +`Category` grouping label, a `GetStatus` method returning +`SectionStatus.{Default, Configured, Warning, Error, Missing}` from current +on-disk config, a secret-redacting `Summary` for dashboard display, a +non-empty `RelevantDoctorChecks` collection (or an explicit +`[NoDoctorChecks]` justification attribute), and a `CreateEditor` +factory that returns an `IWizardStepViewModel`. + +#### Scenario: Editor declares schema-keyed identity + +- **WHEN** a class implements `ISectionEditor` +- **THEN** its `SectionId` resolves to a top-level or dotted-path key in + `netclaw-config.v1.schema.json` +- **AND** the audit (defined under "Menu registry audit") fails if the + identifier resolves to no schema key and the section is not on the + documented exemption list + +#### Scenario: Editor exposes status and summary without decrypting secrets + +- **GIVEN** an `ISectionEditor` whose section owns a secret in `secrets.json` +- **WHEN** the editor produces `GetStatus(...)` and `Summary(...)` +- **THEN** the returned status reflects on-disk configured/default/error + state +- **AND** the summary string contains no secret value or last-N characters + of any secret + +#### Scenario: Editor declares relevant doctor checks + +- **WHEN** a class implements `ISectionEditor` +- **THEN** `RelevantDoctorChecks` contains at least one doctor check type, + OR the implementing class is annotated with + `[NoDoctorChecks(justification: "")]` +- **AND** the audit fails when neither condition holds + +#### Scenario: Editor produces a step viewmodel that the orchestrator can run + +- **GIVEN** an `ISectionEditor` and a `WizardContext` +- **WHEN** `CreateEditor(context)` is invoked +- **THEN** the returned `IWizardStepViewModel` is runnable inside the + existing `WizardOrchestrator` +- **AND** it is also runnable in single-step orchestrator mode (see + "Single-step orchestrator") + +### Requirement: Section editor registry + +The CLI SHALL provide a DI-discovered `SectionEditorRegistry` holding every +registered `ISectionEditor`. Registration SHALL occur via the extension +method `services.AddSectionEditor()`. The registry SHALL expose at +minimum `IReadOnlyList All()` and +`ISectionEditor Get(string sectionId)`. Section identity SHALL be unique +within the registry. + +#### Scenario: Editors are resolved via dependency injection + +- **GIVEN** a DI container with `AddSectionEditor()` + invoked at startup +- **WHEN** the container resolves `SectionEditorRegistry` +- **THEN** `registry.All()` returns a list containing the registered editor +- **AND** `registry.Get("Providers")` returns the same instance + +#### Scenario: Duplicate section identity is rejected + +- **GIVEN** two `ISectionEditor` implementations claiming the same + `SectionId` +- **WHEN** the DI container builds the registry +- **THEN** registry construction fails fast with an exception naming the + duplicate identifier + +### Requirement: Section editor exemption list + +The CLI SHALL maintain a documented exemption list at +`Netclaw.Cli.Tui.Sections.SectionEditorExemptions` enumerating schema +sections that intentionally have no TUI editor. Each entry SHALL carry a +machine-readable category (e.g. "internal-only", "set-once-at-install", +"covered by CLI subcommand", "covered by another editor", "out of MVP +scope"). The exemption list SHALL be the only mechanism by which an +unregistered schema section avoids audit failure. + +#### Scenario: Schema section absent from registry and absent from exemptions + +- **GIVEN** the schema declares a top-level section `Foo` +- **AND** no `ISectionEditor` implementation has `SectionId = "Foo"` +- **AND** `"Foo"` is not present in `SectionEditorExemptions` +- **WHEN** the menu registry audit runs +- **THEN** the audit fails with a message naming the section + +#### Scenario: Schema section in exemption list + +- **GIVEN** the schema declares a top-level section `Persistence` +- **AND** no editor exists for it +- **AND** `"Persistence"` is present in `SectionEditorExemptions` with + category `"set-once-at-install"` +- **WHEN** the audit runs +- **THEN** the audit does not fail for `Persistence` + +### Requirement: Single-step orchestrator mode + +`WizardOrchestrator` SHALL support construction with a single +`IWizardStepViewModel` and a `WizardContext`, running that step +standalone without the linear-wizard step list. `GoNext()` from the step +SHALL invoke save-and-exit semantics; `GoBack()` or `Esc` SHALL invoke +cancel-and-exit semantics. `IsApplicable` filtering and step-to-step +navigation SHALL be skipped in this mode. + +#### Scenario: Single step runs to save + +- **GIVEN** a section editor's step viewmodel and a context +- **WHEN** a `WizardOrchestrator` is constructed in single-step mode +- **AND** the step invokes `GoNext()` +- **THEN** the orchestrator runs the save path +- **AND** returns control to the caller after disk write completes + +#### Scenario: Single step cancels without saving + +- **GIVEN** a section editor in single-step mode +- **WHEN** the step invokes `GoBack()` or the user presses Esc +- **THEN** the orchestrator returns without writing +- **AND** disk state is unchanged + +### Requirement: Reentrancy contract + +Every `ISectionEditor` SHALL honor the following reentrancy contract: +on `OnEnter(context, NavigationDirection.Forward)`, if +`context.ExistingConfig` is non-null, the editor SHALL read its slice +keyed by `SectionId` and pre-fill non-secret UI fields from that slice; +secret-bearing fields SHALL remain empty, with the documented hint text +indicating whether the underlying secret is present. + +#### Scenario: Non-secret fields pre-fill from ExistingConfig + +- **GIVEN** an editor with `SectionId = "Search"` +- **AND** `context.ExistingConfig["Search"]` contains + `{ "Backend": "brave" }` +- **WHEN** the editor's step viewmodel enters in the Forward direction +- **THEN** the backend selector renders with `brave` as the + current/selected value + +#### Scenario: Secret-bearing fields render empty regardless of disk state + +- **GIVEN** an editor with a secret-bearing field whose underlying value is + stored encrypted in `secrets.json` +- **WHEN** the editor enters in the Forward direction +- **THEN** the secret input field renders empty +- **AND** the field hint reads "configured — leave blank to keep" when the + underlying secret exists, or "(not set)" otherwise + +### Requirement: Secret-handling contract + +Section editors SHALL render every secret-bearing field as an empty masked +input. Blank-on-save SHALL preserve the existing encrypted secret value +without rewriting it. Non-blank-on-save SHALL replace the existing value +with the newly entered one. An explicit "Remove credential" action SHALL +be the only path that deletes a secret value from `secrets.json`. Under no +circumstance SHALL the decrypted value of a stored secret be displayed to +the user. + +#### Scenario: Blank submit preserves existing secret + +- **GIVEN** an editor with a secret-bearing field that has a stored value +- **WHEN** the user leaves the field empty and saves +- **THEN** the merge writer records `SecretAction.Preserve` for the field +- **AND** `secrets.json` is byte-identical for that key after the write + +#### Scenario: Non-blank submit replaces stored secret + +- **GIVEN** an editor with a secret-bearing field that has a stored value +- **WHEN** the user enters a new masked value and saves +- **THEN** the merge writer records `SecretAction.Replace(newValue)` +- **AND** `secrets.json` is rewritten with the new encrypted value at the + corresponding key + +#### Scenario: Remove credential deletes stored secret + +- **GIVEN** an editor with a secret-bearing field that has a stored value +- **WHEN** the user activates "Remove credential" and confirms (default + Cancel) +- **THEN** the merge writer records `SecretAction.Remove` +- **AND** the corresponding key is absent from the rewritten `secrets.json` + +### Requirement: Merge-on-save semantics + +Section editors SHALL produce a `SectionContribution` carrying explicit +`FieldAction.{Preserve, Replace, Remove}` per non-secret field and +`SecretAction.{Preserve, Replace, Remove}` per secret field. The merge +writer SHALL load existing `netclaw.json` and `secrets.json` as mutable +dictionaries, apply the contribution's actions to the editor's section, +and write the resulting documents. After a section save, every other +top-level section in both files SHALL be byte-identical to its pre-save +state. + +#### Scenario: Editing one section preserves all others + +- **GIVEN** `netclaw.json` contains sections `Providers`, `Slack`, `Search`, + `ExposureMode` +- **WHEN** the user opens the Search editor, modifies the `Backend` field, + and saves +- **THEN** `Providers`, `Slack`, `ExposureMode` are byte-identical in the + resulting file +- **AND** only `Search` has changed + +#### Scenario: Empty-array semantic distinct from missing key + +- **GIVEN** an editor for a section containing a multi-value list +- **WHEN** the user removes all entries and saves +- **THEN** the resulting `netclaw.json` writes the list as an empty array + `[]` +- **AND** the corresponding schema key is present and not removed + +### Requirement: Existing-config population at init entry + +When `netclaw init` launches, the entry point SHALL load +`netclaw.json` and `secrets.json` via `ConfigFileHelper.LoadConfigFiles` +and assign the parsed `netclaw.json` dictionary to +`WizardContext.ExistingConfig`. Secret values from `secrets.json` SHALL +NOT be loaded into the context; only an existence indicator (via +`ConfigFileHelper.SecretPresent(...)`) SHALL be queryable by editors. + +#### Scenario: First-run leaves ExistingConfig null + +- **GIVEN** no `netclaw.json` exists on disk +- **WHEN** `netclaw init` enters the wizard +- **THEN** `WizardContext.ExistingConfig` is `null` + +#### Scenario: Re-run populates ExistingConfig + +- **GIVEN** `netclaw.json` exists on disk +- **WHEN** `netclaw init` enters the wizard +- **THEN** `WizardContext.ExistingConfig` contains the parsed top-level + dictionary +- **AND** no decrypted secret values are present anywhere in the context + +### Requirement: Secret-presence lookup without decryption + +`ConfigFileHelper` SHALL expose a method +`bool SecretPresent(NetclawPaths paths, string sectionId, string key)` +that returns whether the specified secret key exists in `secrets.json` +without decrypting or returning its value. The method SHALL be the sole +hint source for editors deciding between "configured — leave blank to +keep" and "(not set)" placeholders. + +#### Scenario: Existing secret reports present + +- **GIVEN** `secrets.json` contains an encrypted value at + `Search.BraveApiKey` +- **WHEN** `SecretPresent(paths, "Search", "BraveApiKey")` is invoked +- **THEN** the result is `true` +- **AND** the decrypted value is never materialized in memory by this call + +#### Scenario: Missing secret reports absent + +- **GIVEN** `secrets.json` does not contain a value at + `Search.BraveApiKey` +- **WHEN** `SecretPresent(paths, "Search", "BraveApiKey")` is invoked +- **THEN** the result is `false` + +### Requirement: Round-trip test harness + +The test project SHALL provide an abstract +`SectionEditorTestBase` carrying the canonical shared +reentrancy and merge scenarios: `RoundTrip_NoOpEdit_PreservesConfig`, +`RoundTrip_SingleFieldEdit_UpdatesOnlyThatField`, +`Secrets_BlankSubmit_PreservesExistingSecret`, +`Secrets_NonBlankSubmit_ReplacesSecret`, +`Secrets_RemoveAction_DeletesSecret`. Concrete subclasses SHALL exist for +every registered `ISectionEditor`. + +#### Scenario: Base scenarios are inherited by every concrete subclass + +- **WHEN** a developer adds a new `ISectionEditor` implementation and + registers it +- **THEN** the project will not pass `dotnet test` until a corresponding + subclass of `SectionEditorTestBase` exists +- **AND** the menu registry audit fails when the subclass is missing + +#### Scenario: Round-trip no-op preserves config byte-for-byte + +- **GIVEN** a stocked existing-config fixture +- **WHEN** the editor's step viewmodel runs `OnEnter`, makes no changes, + and saves +- **THEN** the resulting `netclaw.json` and `secrets.json` are + byte-identical to the fixture + +### Requirement: Menu registry audit + +The test project SHALL include `MenuRegistryAuditTests` that walks +`SectionEditorRegistry` and asserts, for every registered editor: a +matching concrete `SectionEditorTestBase` subclass exists, the +editor's `RelevantDoctorChecks` is non-empty (or the class is annotated +with `[NoDoctorChecks]`), and — once smoke tapes ship for the editor in +the next change — a matching tape file exists at +`tests/smoke/tapes/config-.tape`. The audit SHALL +report all failures in one assertion message naming each missing +artifact. + +#### Scenario: Missing round-trip test class fails the audit + +- **GIVEN** a registered `ISectionEditor` without a matching + `SectionEditorTestBase` subclass +- **WHEN** `MenuRegistryAuditTests` runs +- **THEN** the test fails with a message naming the missing test class + +#### Scenario: Empty RelevantDoctorChecks without justification fails the audit + +- **GIVEN** a registered `ISectionEditor` whose `RelevantDoctorChecks` + returns no entries +- **AND** whose class is not annotated with `[NoDoctorChecks]` +- **WHEN** `MenuRegistryAuditTests` runs +- **THEN** the test fails with a message naming the editor + +#### Scenario: Vacuous registry passes the audit + +- **GIVEN** a registry containing only the three Change A editors + (Provider, Identity, Posture) +- **AND** each has a matching round-trip test class and non-empty + `RelevantDoctorChecks` +- **WHEN** `MenuRegistryAuditTests` runs +- **THEN** the audit passes diff --git a/openspec/changes/section-editor-abstraction/tasks.md b/openspec/changes/section-editor-abstraction/tasks.md new file mode 100644 index 000000000..f94028569 --- /dev/null +++ b/openspec/changes/section-editor-abstraction/tasks.md @@ -0,0 +1,163 @@ +## 1. OpenSpec planning artifacts and traceability + +- [ ] 1.1 Confirm proposal, design, and spec deltas cover the + `ISectionEditor` contract, registry, single-step orchestrator mode, + exemption list, secret-handling rules, merge-on-save semantics, + reentrant pre-population, and audit/test harness obligations. +- [ ] 1.2 Verify traceability references to `PRD-004` and `PRD-001` across + change artifacts. +- [ ] 1.3 Run `openspec validate section-editor-abstraction --type change` + and resolve all issues. + +## 2. Core abstraction + +- [ ] 2.1 Add `src/Netclaw.Cli/Tui/Sections/ISectionEditor.cs` with + `SectionId`, `DisplayName`, `Category?`, `GetStatus`, `Summary`, + `RelevantDoctorChecks`, `CreateEditor`. +- [ ] 2.2 Add `src/Netclaw.Cli/Tui/Sections/SectionStatus.cs` with the + `Default | Configured | Warning | Error | Missing` enum. +- [ ] 2.3 Add `src/Netclaw.Cli/Tui/Sections/SectionContribution.cs` with + `FieldAction.{Preserve, Replace, Remove}` and + `SecretAction.{Preserve, Replace, Remove}` discriminated unions plus a + contribution record carrying the per-field dictionaries. +- [ ] 2.4 Add `src/Netclaw.Cli/Tui/Sections/NoDoctorChecksAttribute.cs` + carrying a required `justification` string for editors that genuinely + have no relevant checks. + +## 3. Registry and exemption list + +- [ ] 3.1 Add `src/Netclaw.Cli/Tui/Sections/SectionEditorRegistry.cs` with + `All()` and `Get(string sectionId)` methods. Construction fails fast on + duplicate `SectionId`. +- [ ] 3.2 Add `services.AddSectionEditor()` DI extension on + `IServiceCollection` registering the editor as `ISectionEditor` + (transient) and as itself (for direct test resolution). +- [ ] 3.3 Add `src/Netclaw.Cli/Tui/Sections/SectionEditorExemptions.cs` + with the documented exemption set and per-entry category metadata. +- [ ] 3.4 Wire `SectionEditorRegistry` and the three Change A editors + (Provider, Identity, Posture) into the existing CLI DI composition root. + +## 4. Single-step orchestrator mode + +- [ ] 4.1 Add a single-step constructor to `WizardOrchestrator` accepting + one `IWizardStepViewModel` and a `WizardContext`. +- [ ] 4.2 In single-step mode, `GoNext()` triggers save-and-exit; + `GoBack()` / `Esc` triggers cancel-and-exit. Step-to-step filtering + via `IsApplicable` is skipped. +- [ ] 4.3 Add orchestrator-level unit tests covering save-and-exit and + cancel-and-exit single-step paths. + +## 5. Merge-on-save plumbing + +- [ ] 5.1 Refactor `WizardConfigBuilder.WriteConfigFile` to load existing + `netclaw.json` via `ConfigFileHelper.LoadConfigFiles`, apply each + step's `SectionContribution`, and write the merged dictionary back. + Sections not contributed to remain byte-identical. +- [ ] 5.2 Refactor the wizard's secrets writer to load existing + `secrets.json` and apply each contribution's `SecretAction`s. Blank + on a secret-bearing field maps to `Preserve`; explicit + `SecretAction.Remove` deletes the key. +- [ ] 5.3 Add `ConfigFileHelper.SecretPresent(paths, sectionId, key)` that + inspects `secrets.json` for the key's existence without invoking the + data-protection unprotect path. Unit-test against a fixture with both + present and absent values. +- [ ] 5.4 Update `WizardOrchestrator.WriteConfig` to drive the new merge + path. Existing first-run behavior remains observable-equivalent because + the empty-existing path collapses to the previous overwrite shape. + +## 6. ExistingConfig population at init entry + +- [ ] 6.1 At the `netclaw init` entry point in `Netclaw.Cli.Program`, load + `netclaw.json` via `ConfigFileHelper.LoadConfigFiles` and assign the + parsed dictionary to `WizardContext.ExistingConfig`. Leave secrets out + of the context entirely. +- [ ] 6.2 Remove the "Deferred — not implemented yet" comment block on + `WizardContext.ExistingConfig` and document the populated-at-entry + semantics. +- [ ] 6.3 Confirm the wizard's lifetime owns `ExistingConfig` for the + duration of the run; the dictionary is read-only after entry. + +## 7. Refactor three existing init step viewmodels + +- [ ] 7.1 `ProviderStepViewModel`: implement `ISectionEditor` (SectionId + `Providers`). Honor `ExistingConfig` in `OnEnter(direction)` for + provider type, endpoint, auth method, model selection, and OAuth + token expiry. API key field renders empty with "configured — leave + blank to keep" hint when `SecretPresent` returns true. +- [ ] 7.2 `IdentityStepViewModel`: implement `ISectionEditor` (SectionId + `Identity`). Honor `ExistingConfig` for agent name, user name, + timezone, comm style, workspaces directory, webhook URL. (Step is + trimmed in the third change; this change keeps existing fields.) +- [ ] 7.3 `SecurityPostureStepViewModel`: implement `ISectionEditor` + (SectionId `Security.Posture`, dotted path). Honor `ExistingConfig` + for the posture selection and posture-default cascade. +- [ ] 7.4 Each refactored editor declares non-empty + `RelevantDoctorChecks` referencing the existing checks that scope to + the editor's section. +- [ ] 7.5 Each refactored editor produces a `SectionContribution` from + its viewmodel state on save; the orchestrator collects contributions + and routes them through the new merge writer. + +## 8. Round-trip test harness + +- [ ] 8.1 Add + `tests/Netclaw.Cli.Tests/Tui/Sections/SectionEditorTestBase.cs` + abstract harness with the five canonical scenarios: + `RoundTrip_NoOpEdit_PreservesConfig`, + `RoundTrip_SingleFieldEdit_UpdatesOnlyThatField`, + `Secrets_BlankSubmit_PreservesExistingSecret`, + `Secrets_NonBlankSubmit_ReplacesSecret`, + `Secrets_RemoveAction_DeletesSecret`. +- [ ] 8.2 Concrete test class for `ProviderSectionEditor` covering + provider, endpoint, model, OAuth, and API-key paths. +- [ ] 8.3 Concrete test class for `IdentitySectionEditor`. +- [ ] 8.4 Concrete test class for `SecurityPostureSectionEditor`, + including the posture-cascade write semantics. + +## 9. Menu registry audit + +- [ ] 9.1 Add + `tests/Netclaw.Cli.Tests/Tui/Sections/MenuRegistryAuditTests.cs` with + a single test that walks `SectionEditorRegistry.All()` and asserts: + every registered editor has a `SectionEditorTestBase` + subclass; every editor has non-empty `RelevantDoctorChecks` or + `[NoDoctorChecks]`; and (gated by file existence, no error if absent + in this change) a smoke tape at + `tests/smoke/tapes/config-.tape` exists when present. +- [ ] 9.2 Audit failure message lists all missing artifacts in one + assertion message, naming each editor + missing piece. +- [ ] 9.3 Smoke-tape file existence is checked but not required at the + audit level until the next change lands; comment in the test + documents the cutover. + +## 10. Existing test suite preservation + +- [ ] 10.1 Run `./scripts/smoke/run-smoke.sh init-wizard` and confirm the + existing init-wizard tape passes unchanged. +- [ ] 10.2 Run `./scripts/smoke/run-smoke.sh init-wizard-reverse-proxy` + and confirm the existing reverse-proxy tape passes unchanged. +- [ ] 10.3 Run the full `./scripts/smoke/run-smoke.sh light` and confirm + no regressions. + +## 11. Quality gates + +- [ ] 11.1 `dotnet build` clean across the solution. +- [ ] 11.2 `dotnet test` clean: all new round-trip tests pass; audit + passes vacuously over the three registered editors; existing tests + remain green. +- [ ] 11.3 `dotnet slopwatch analyze` reports no new violations. +- [ ] 11.4 `./scripts/Add-FileHeaders.ps1 -Verify` reports clean. +- [ ] 11.5 `openspec validate section-editor-abstraction --type change` + passes. + +## 12. Documentation and traceability + +- [ ] 12.1 Update `PROJECT_CONTEXT.md` or `TOOLING.md` if the abstraction + changes the way operators or contributors are expected to add editable + sections (a one-liner pointing at `ISectionEditor` is sufficient at + this stage). +- [ ] 12.2 Update PRD-004 with a forward reference to the + `netclaw config` command landing in the next change; this change does + not yet introduce it. +- [ ] 12.3 PR description closes #455 (reentrant init) and references this + OpenSpec change ID. diff --git a/openspec/changes/simplify-netclaw-init/.openspec.yaml b/openspec/changes/simplify-netclaw-init/.openspec.yaml new file mode 100644 index 000000000..0f0616986 --- /dev/null +++ b/openspec/changes/simplify-netclaw-init/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-23 diff --git a/openspec/changes/simplify-netclaw-init/design.md b/openspec/changes/simplify-netclaw-init/design.md new file mode 100644 index 000000000..31237a60d --- /dev/null +++ b/openspec/changes/simplify-netclaw-init/design.md @@ -0,0 +1,208 @@ +## Context + +The `section-editor-abstraction` change (Change A) refactored Provider, +Identity, and Posture step viewmodels into reentrant `ISectionEditor`s +and switched the wizard's terminal write to merge-on-save. The +`netclaw-config-command` change (Change B) introduced +`netclaw config` and the ten section editors that now own the +configuration surfaces previously walked by the init wizard. With both +changes landed, `netclaw init` is the only piece left that still +treats configuration as a single big linear flow. + +This change trims the wizard to provider + identity + posture so new +operators reach `netclaw chat` after three prompts, and makes the +existing-config-on-re-run behavior explicit (refuse + offer `--force`) +instead of the prior undefined behavior. The wizard's previous +breadth — Slack/Discord/Mattermost setup, ACL, search, browser +automation, MCP servers, exposure mode, channel audience configuration, +feature toggles, external skills, skill feeds, webhook URL — moves +entirely to `netclaw config`. None of those surfaces are deleted; they +just leave the init step list. + +## Goals / Non-Goals + +**Goals:** + +- Reduce time-to-first-chat for new operators: three prompts after + provider selection (provider auth + model selection are part of the + Provider step's existing sub-flow). +- Make re-running `netclaw init` over an existing install a + well-defined operation: refuse with helpful pointers by default, and + offer `--force` for a backed-up reset. +- Preserve the existing posture-default cascade: Personal / Team / + Enterprise still drive the initial `Tools.AudienceProfiles` mapping + written at init time. +- Migrate the reverse-proxy exposure-mode init tape coverage to the + `netclaw config` smoke tape introduced in Change B. + +**Non-Goals:** + +- Deleting any `ISectionEditor` class that lived as an init step. The + classes survive as `netclaw config` editors after Change B. +- Renaming or re-architecting `netclaw config`. +- Changing posture-default mappings. +- Introducing an Identity section editor in `netclaw config`. Renaming + the agent post-install remains a file-edit (or `init --force`) task + for MVP. +- Hot-reload of the running daemon on init completion. + +## Decisions + +### D1. Step list reduced to three; classes preserved + +The init wizard's `WizardOrchestrator` step composition is reduced from +the current 12-entry list to exactly three: Provider, Identity, +Posture. The other `ISectionEditor` implementations (Search, Slack, +Discord, Mattermost, Exposure, AudienceProfiles, OutboundWebhooks, +InboundWebhooks, ExternalSkills, SkillFeeds, BrowserAutomation) remain +registered in the registry and reachable via `netclaw config` — +they're just not part of `netclaw init`'s step list. + +Alternative considered: delete the step viewmodel classes that +weren't on the init list. Rejected because they ARE the section +editors `netclaw config` runs; the same class serves both. Keeping +one class per editable section is the whole point of the +`ISectionEditor` abstraction. + +### D2. Existing-config detection refuses by default, allows `--force` + +Re-running `netclaw init` over an existing install in the current +code is undefined behavior. After Change A's merge-on-save plus +`ExistingConfig` pre-population, a naive re-run would silently +re-walk the wizard and re-write whatever the operator typed. That's +confusing — `netclaw init` is named for "initial setup," not "edit." +The right behavior is: + +- Default: refuse with a clear message pointing at `netclaw config` + for live edits. +- Force: explicit `--force` flag triggers a type-to-confirm backup + and proceeds as a fresh first-run. Backup is rename-aside + (`netclaw.json.bak.`); operators retain manual recovery. + +Alternative considered: have `netclaw init` re-running over existing +config auto-launch `netclaw config`. Rejected because it conflates +two commands; an operator typing `netclaw init` after install +expects setup behavior, not menu-edit behavior. Refusing is clearer. + +### D3. Trimmed Identity step preserves three fields, defaults the rest + +`IdentityStepViewModel`'s field set drops to agent name + user name ++ timezone. The previously-prompted fields (webhook URL, +communication style, workspaces directory) use their existing +defaults and are not exposed in init. Operators wanting to change +them post-install edit `netclaw.json` directly until a future +Identity section editor lands. + +Alternative considered: add a "Show advanced fields" affordance in +the trimmed Identity step. Rejected because it re-introduces the +"long wizard" feel; the explicit out-of-MVP file-edit path is the +right scope discipline. + +### D4. Post-flight nudge in Termina + stderr after teardown + +The post-flight screen inside Termina confirms what was set, reports +health-check pass/fail, and prints the next-step nudge ("Run +`netclaw chat` to start, or `netclaw config` to configure ..."). On +Termina teardown the same one-line nudge prints to stderr so it +remains visible after the TUI clears. This dual-path matches Change +B's daemon-restart nudge pattern. + +Alternative considered: just print the nudge to stderr after exit +without a Termina screen. Rejected because operators benefit from +seeing setup-complete confirmation while the TUI is still up; the +stderr line is a fallback for cases where the operator's terminal +emulator wipes the screen on Termina exit. + +### D5. Reverse-proxy tape migrates to config, not deleted outright + +`init-wizard-reverse-proxy.tape` exercises an exposure-mode flow +that today lives inside the init wizard. With exposure mode moved +to `netclaw config`, the equivalent flow is `config-exposure-mode.tape` +(introduced in Change B). This change deletes the init-side tape +because its coverage is fully owned by the config-side tape. Net +tape count for exposure-mode regression coverage remains 1. + +### D6. New init tapes for refuse-and-force paths + +The refuse path and the `--force` reset path need explicit smoke +coverage, otherwise a future change could regress them silently. +Two new tapes: + +- `init-existing-config-refuse.tape` — pre-stages a config and + asserts refusal text + exit zero on TTY confirm. +- `init-force-reset.tape` — pre-stages a config, runs `--force`, + types `reset` to confirm, completes the short flow, asserts the + .bak files exist and a fresh `netclaw.json` was written. + +Both are short tapes (likely <40 lines each). The new init tape +total is 3 (down from the current 2: one is revised, one is deleted, +two are added). + +### D7. PRD-004 update lands in this change + +PRD-004's "reentrant init dashboard" wording was authored before this +sequence of changes locked the simplified-init + `netclaw config` +split. The wording is updated in this change to match the shipped +shape; cross-references to issues #455 (closed in Change A) and +#1150 (closed in Change B) are added. + +## Risks / Trade-offs + +- [Behavior change for re-runs] Operators who have been + re-running `netclaw init` to tweak config (against the prior + undefined behavior) will be refused after this change. → + Mitigation: the refusal message names `netclaw config` and + `netclaw init --force` explicitly. Documentation update in + PRD-004 references the new behavior. Existing-config detection + is consistent across TTY and non-TTY contexts. + +- [Posture-default writes happen non-interactively now] Operators on + Team or Enterprise postures no longer walk a feature-selection + step at init. They see the defaults applied automatically and can + override per-audience later. → Mitigation: the Change B Audience + Profiles editor is the documented place to tune; PRD-004 names it. + +- [Identity field loss for new installs] New operators no longer + set webhook URL, communication style, or workspaces directory at + init. → Mitigation: defaults are reasonable; webhook URL belongs + in Outbound Webhooks (Change B's section editor); workspaces + directory and communication style are file-edit-only for MVP and + documented as such in PRD-004. + +- [.bak files accumulate on repeated forces] Each `--force` reset + creates a new pair of timestamped .bak files. After many forces + the directory could grow. → Mitigation: this is the operator's + responsibility; the .bak files are theirs to manage. The + type-to-confirm gate ensures forced resets are deliberate, so + accumulation is bounded by intentional operator action. + +- [CI surprise on non-TTY re-runs] Existing CI scripts that called + `netclaw init` non-interactively over a populated config would + silently re-walk previously. After this change they exit non-zero. + → Mitigation: the new behavior is the safe one. Any CI that was + relying on undefined re-run behavior was already buggy; the + non-zero exit makes the breakage visible. Migration is to call + `netclaw config` (programmatic CLI use is via the + CLI subcommands `netclaw provider/model/mcp`, not `netclaw config`). + +## Migration Plan + +1. Land Changes A and B before this change. +2. Land this change. Existing operators on Personal posture: their + re-runs now refuse cleanly. Existing operators on Team or + Enterprise: same. Operators wanting to edit anything use + `netclaw config`; operators wanting a clean slate use + `netclaw init --force`. +3. PRD-004 update is part of this change's PR. +4. The CHANGELOG / release notes call out the simplified-init + behavior change so operators are not surprised on upgrade. + +Rollback: revert this change. The wizard returns to its 12-step +linear form. Existing-config detection disappears (re-runs go back +to undefined behavior). The two new init tapes are deleted; the +init-wizard-reverse-proxy tape returns. `netclaw config` remains +available as long as Change B remains. + +## Open Questions + +None at execution time. All architectural decisions are locked above. diff --git a/openspec/changes/simplify-netclaw-init/proposal.md b/openspec/changes/simplify-netclaw-init/proposal.md new file mode 100644 index 000000000..56b095929 --- /dev/null +++ b/openspec/changes/simplify-netclaw-init/proposal.md @@ -0,0 +1,152 @@ +## Why + +`netclaw init` is the first-impression experience for every new Netclaw +operator, and it has grown into a 12-step linear wizard that walks +through provider selection, security posture, feature selection, +channel pickers and per-channel sub-flows, search backend, browser +automation, identity, external skills, skill feeds, exposure mode, and +a final health check. This is the longest single point of abandonment +for new installs. After the `section-editor-abstraction` change +introduced reentrancy and the `netclaw-config-command` change moved +ongoing configuration to a menu-driven editor, the init wizard's +purpose is now strictly bootstrap: produce a minimum-viable config +that lets the operator reach `netclaw chat` as quickly as possible. +This change cuts the wizard down to three prompts — provider, +identity, posture — and routes operators to `netclaw config` for +everything else. It also makes the existing-config detection behavior +explicit (refuse with a helpful message; offer `--force` for a backed-up +reset) instead of leaving re-runs as undefined behavior. + +Source PRDs: `PRD-004-cli-onboarding-and-config.md`, +`PRD-001-netclaw-mvp.md`. + +## What Changes + +- Trim `netclaw init` to three steps + a terminal write/health-check: + - **Step 1: Provider** — reuse existing `ProviderStepViewModel` + (refactored to `ISectionEditor` in Change A) end-to-end. + - **Step 2: Identity** — trimmed to agent name, user name (what the + agent calls the operator), and timezone. Drop the webhook URL + prompt, the workspaces-directory prompt, and the communication-style + prompt. Defaults remain available for the dropped values. + - **Step 3: Security Posture** — reuse existing + `SecurityPostureStepViewModel` (refactored in Change A). The + posture choice applies the posture-default `Tools.AudienceProfiles` + mapping in-memory before the terminal write; operators tune + per-audience later via `netclaw config → Audience Profiles`. + - **Terminal**: write merged config and run the existing health-check. +- Remove from `netclaw init` the following step viewmodels (the + corresponding `ISectionEditor` implementations introduced in Change B + remain in `netclaw config`): `ChannelPickerStepViewModel`, + `ChannelsStepViewModel`, `FeatureSelectionStepViewModel`, + `SearchStepViewModel`, `SlackStepViewModel`, `DiscordStepViewModel`, + `MattermostStepViewModel`, `ExposureModeStepViewModel`, + `BrowserAutomationStepViewModel`, `ExternalSkillsStepViewModel`, + `SkillFeedsStepViewModel`. The classes are not deleted (they live on + as section editors); only their participation in the init step list + is removed. +- Add a post-flight screen inside Termina that confirms what was set, + reports health-check pass/fail, and points operators at + `netclaw config` for further configuration. On Termina teardown, the + same one-line nudge prints to stderr so it remains visible after the + TUI clears: `Setup complete. Run \`netclaw chat\` to start, or + \`netclaw config\` to configure channels, webhooks, search, and + more.` +- Add explicit existing-config detection at `netclaw init` entry. When + `netclaw.json` exists and `--force` was not passed, the command + renders a refusal screen (TTY) or prints to stderr (non-TTY) + pointing operators at `netclaw config` for edits or + `netclaw init --force` to reset. Exit zero in TTY-confirmed + acknowledgement; exit non-zero in non-TTY usage so CI catches the + surprise. +- Add `netclaw init --force` behavior: when an existing config is + present, the command opens a type-to-confirm backup screen. On + confirm, `netclaw.json` is renamed to `netclaw.json.bak.` + and `secrets.json` is renamed to `secrets.json.bak.`. The + wizard then proceeds as a fresh first-run. Operators must re-enter + credentials; the .bak files are preserved for manual recovery. +- Revise `tests/smoke/tapes/init-wizard.tape` and its assertion + script to exercise the three-step flow (provider + identity + + posture) plus the post-flight screen. The tape shortens from + ~150 lines to ~50. +- Delete `tests/smoke/tapes/init-wizard-reverse-proxy.tape` and its + assertion. Reverse-proxy coverage migrates to + `config-exposure-mode.tape` introduced in Change B. +- Add two new smoke tapes covering the new init UX: + - `init-existing-config-refuse.tape` — pre-stage a `netclaw.json`, + run `netclaw init`, assert refusal message + zero exit. + - `init-force-reset.tape` — pre-stage a `netclaw.json`, run + `netclaw init --force`, type "reset" to confirm, complete the + short flow, assert `.bak.*` files exist and new config is + written. +- Update PRD-004 to reflect the simplified-init + `netclaw config` + shape: the original "reentrant init dashboard" wording is replaced + with the documented two-command split. + +**In scope (MVP):** trimming the wizard to provider + identity + +posture, the post-flight screen and stderr nudge, the existing-config +refusal and `--force` reset paths, revising the existing init tape, +deleting the reverse-proxy init tape, and adding two new init tapes +covering the refuse and force paths. + +**Out of scope:** any behavioral change to `netclaw config` (it +already exists from the previous change); deleting the existing init +step viewmodel classes (they continue to back the section editors in +`netclaw config`); migrating identity-related setup that today lives +inside the trimmed Identity step (workspaces directory, communication +style — these continue to use their existing defaults silently for +MVP; operators wanting to change them edit the file directly until +a future Identity section editor lands); changes to PRD-002 or +posture defaults. + +## Capabilities + +### Modified Capabilities + +- `netclaw-onboarding`: the init wizard's collected inputs SHALL be + trimmed to provider, identity (agent name + user name + timezone), + and security posture. The wizard SHALL detect existing config at + entry and refuse (or offer `--force` reset). The wizard SHALL show + a post-flight screen pointing operators at `netclaw config`. + +## Impact + +**Affected systems:** + +- CLI entry point (`Netclaw.Cli.Program`) gains the existing-config + detection branch and the `--force` flag. +- Init wizard step list (`Netclaw.Cli.Tui.Wizard.WizardOrchestrator` + composition) is reduced to three viewmodels. +- `IdentityStepViewModel` is trimmed (no class removal; field set is + reduced). The viewmodel continues to satisfy the `ISectionEditor` + contract introduced in Change A. +- Init smoke tape (`tests/smoke/tapes/init-wizard.tape`) is rewritten; + reverse-proxy tape is deleted; two new init tapes added. +- PRD-004 is updated to match the simplified-init + `netclaw config` + shape. + +**Security and operational impact:** + +- Existing-config refusal prevents accidental re-runs from blasting + through an existing install. The `--force` path explicitly backs up + both `netclaw.json` and `secrets.json` to timestamped `.bak.*` + files; operators retain a manual recovery path. The force path + requires a type-to-confirm because the operation moves credentials + out of the active file (forcing re-entry). +- Trimming Identity drops the in-wizard webhook URL prompt. The + outbound-webhook surface was already available via `netclaw config → + Outbound Webhooks` (Change B); operators with active webhook + configurations are not affected (their existing webhook entries + remain). Operators on a fresh install no longer set up a webhook + during init; they do so in `netclaw config` post-bootstrap. +- The simplified init reduces the time-to-first-chat for new + operators. No new network surface, no new persistence schema, no + new daemon contract change. +- Posture's audience-profile cascade continues to be applied on init + (Personal posture sets all features enabled; Team and Enterprise + set audience-appropriate defaults). Operators on Team or Enterprise + who used to walk the feature-selection step now get the same + posture-default mapping written non-interactively and can tune via + `netclaw config → Audience Profiles`. +- No change to the daemon. No change to existing CLI subcommands + (`netclaw provider`, `netclaw model`, `netclaw mcp`). diff --git a/openspec/changes/simplify-netclaw-init/specs/netclaw-onboarding/spec.md b/openspec/changes/simplify-netclaw-init/specs/netclaw-onboarding/spec.md new file mode 100644 index 000000000..45baaf1ab --- /dev/null +++ b/openspec/changes/simplify-netclaw-init/specs/netclaw-onboarding/spec.md @@ -0,0 +1,177 @@ +## MODIFIED Requirements + +### Requirement: Guided onboarding + +`netclaw init` SHALL provide a three-step guided setup collecting LLM +provider configuration, identity (agent name, operator name, timezone), +and security posture. On completion, the wizard SHALL apply the +posture-default `Tools.AudienceProfiles` mapping in-memory, write the +merged config and secrets via the merge-on-save writer, and run the +existing health check to verify the baseline configuration is +functional. If daemon startup fails because configuration validation +rejects the resulting exposure-mode or remote-auth topology, the +wizard SHALL surface that failure as a structured setup error with +remediation guidance. The wizard SHALL NOT collect Slack credentials, +ACL inputs, search backend, browser automation, memory provider, +MCP server configuration, exposure mode, channels, audience-specific +feature flags, external skill directories, skill feeds, or webhook +URLs during this flow. Those sections SHALL be configured via +`netclaw config` after first-run setup completes. + +The wizard SHALL NOT write `AGENTS.md` to disk during identity file +generation. AGENTS.md is binary-controlled firmware loaded from +embedded resources at runtime. The wizard SHALL continue to write +`SOUL.md` and `TOOLING.md` as operator-mutable identity files. + +For non-Personal postures, the wizard SHALL apply the posture-default +feature-flag mapping non-interactively (memory, search, skills, +scheduling, sub-agents, webhooks) per the posture's documented +defaults. The wizard SHALL NOT present a separate feature-selection +step. Operators wanting to override these defaults per-audience SHALL +use `netclaw config → Audience Profiles`. + +#### Scenario: First-time setup + +- **WHEN** operator runs `netclaw init` on a fresh install +- **THEN** the wizard collects provider, identity (agent name, user + name, timezone), and security posture inputs +- **AND** writes a runnable baseline configuration via the merge-on-save + writer +- **AND** writes SOUL.md and TOOLING.md to `~/.netclaw/identity/` +- **AND** does NOT write AGENTS.md (or writes a reference-only stub) +- **AND** does NOT prompt for Slack, ACL, search, browser automation, + exposure mode, channels, audience-feature flags, external skills, + skill feeds, or webhook URLs + +#### Scenario: Identity files written on completion + +- **WHEN** the wizard completes and writes config +- **THEN** `SOUL.md` is written from the embedded SOUL template +- **AND** `TOOLING.md` is written from the embedded TOOLING template +- **AND** `AGENTS.md` is NOT written from a template + +#### Scenario: Posture cascade applied non-interactively + +- **GIVEN** the operator selected `Team` posture +- **WHEN** the wizard completes its terminal write +- **THEN** `Tools.AudienceProfiles.Team` is populated with the + posture-default mapping (memory, search, skills, scheduling, + sub-agents enabled; webhooks disabled per posture rule) +- **AND** the wizard does not show a separate feature-selection step +- **AND** the operator can edit per-audience features via + `netclaw config → Audience Profiles` + +#### Scenario: Exposure-mode startup validation failure shown cleanly + +- **GIVEN** the operator completes `netclaw init` +- **AND** the written configuration causes `ExposureModeValidationService` + to reject daemon startup +- **WHEN** the health-check step starts the daemon +- **THEN** the wizard shows a failed health-check item containing the + validation message +- **AND** the wizard includes remediation guidance for fixing the + exposure/auth configuration +- **AND** the operator is not shown a raw stack trace + +#### Scenario: Startup validation failure does not degrade to generic readiness timeout + +- **GIVEN** daemon startup fails immediately because exposure validation + rejects the configuration +- **WHEN** the health-check step polls daemon readiness +- **THEN** the wizard reports the actual startup validation failure +- **AND** it does NOT report only `Daemon did not become ready` unless + the failure reason is genuinely unavailable + +#### Scenario: Post-flight nudge points to netclaw config + +- **GIVEN** the wizard completes its terminal write successfully +- **WHEN** the health check passes +- **THEN** Termina displays a post-flight screen confirming what was + set +- **AND** Termina displays a line directing the operator at + `netclaw config` for further configuration +- **AND** after Termina teardown the same one-line nudge prints to + stderr so it remains visible after the TUI clears + +## ADDED Requirements + +### Requirement: Existing-config detection at init entry + +`netclaw init` SHALL detect the presence of a previously-written +`netclaw.json` at startup. When detected and `--force` was not passed, +the command SHALL refuse to proceed: in a TTY it renders a refusal +screen pointing operators at `netclaw config` for live edits or +`netclaw init --force` to reset; in non-TTY usage it prints the +refusal to stderr. The TTY path SHALL exit with status 0 after the +operator acknowledges; the non-TTY path SHALL exit with non-zero +status so CI catches the surprise. + +#### Scenario: TTY refusal shows actionable guidance and exits zero + +- **GIVEN** `netclaw.json` exists on disk +- **AND** `netclaw init` is run in an interactive TTY without `--force` +- **WHEN** the command starts +- **THEN** Termina renders a refusal screen that names both alternative + commands: `netclaw config` and `netclaw init --force` +- **AND** the operator presses Enter to acknowledge +- **AND** the command exits with status 0 +- **AND** `netclaw.json` and `secrets.json` are unchanged + +#### Scenario: Non-TTY refusal exits non-zero + +- **GIVEN** `netclaw.json` exists on disk +- **AND** `netclaw init` is run with stdout/stderr redirected (not a TTY) +- **AND** `--force` was not passed +- **WHEN** the command starts +- **THEN** the refusal text prints to stderr +- **AND** the command exits with non-zero status +- **AND** `netclaw.json` and `secrets.json` are unchanged + +#### Scenario: No existing config proceeds normally + +- **GIVEN** no `netclaw.json` exists on disk +- **WHEN** `netclaw init` is run +- **THEN** the wizard proceeds to Step 1 (Provider) without showing the + refusal screen + +### Requirement: Force-reset backup flow + +`netclaw init --force` SHALL detect existing config and require an +explicit type-to-confirm before proceeding. On confirm, the command +SHALL rename `~/.netclaw/config/netclaw.json` to +`netclaw.json.bak.` and +`~/.netclaw/config/secrets.json` to `secrets.json.bak.`. +The wizard SHALL then proceed as a fresh first-run. The .bak files +SHALL be preserved on disk so operators retain a manual recovery +path. The command SHALL print the .bak file paths to the post-flight +screen so operators know where the prior config went. + +#### Scenario: Force without confirm leaves config unchanged + +- **GIVEN** `netclaw.json` exists on disk +- **AND** `netclaw init --force` is run in an interactive TTY +- **WHEN** the confirm screen renders and the operator cancels +- **THEN** the command exits with status 0 +- **AND** `netclaw.json` and `secrets.json` are unchanged + +#### Scenario: Force with confirm backs up and proceeds + +- **GIVEN** `netclaw.json` and `secrets.json` exist on disk +- **AND** `netclaw init --force` is run in an interactive TTY +- **WHEN** the operator types "reset" and confirms +- **THEN** the original `netclaw.json` is renamed to + `netclaw.json.bak.` +- **AND** the original `secrets.json` is renamed to + `secrets.json.bak.` +- **AND** the wizard proceeds to Step 1 (Provider) with + `WizardContext.ExistingConfig` set to `null` +- **AND** on successful completion the post-flight screen lists the + .bak file paths + +#### Scenario: Force on a fresh install behaves as plain init + +- **GIVEN** no `netclaw.json` exists on disk +- **AND** `netclaw init --force` is run +- **WHEN** the command starts +- **THEN** no backup screen is shown (nothing to back up) +- **AND** the wizard proceeds to Step 1 (Provider) normally diff --git a/openspec/changes/simplify-netclaw-init/tasks.md b/openspec/changes/simplify-netclaw-init/tasks.md new file mode 100644 index 000000000..ada4d874f --- /dev/null +++ b/openspec/changes/simplify-netclaw-init/tasks.md @@ -0,0 +1,183 @@ +## 1. OpenSpec planning artifacts and traceability + +- [ ] 1.1 Confirm proposal, design, and spec deltas cover the trimmed + three-step init flow, existing-config refusal, `--force` reset with + backup, post-flight nudge, and the smoke-tape revisions. +- [ ] 1.2 Verify traceability references to `PRD-004` and `PRD-001` + across change artifacts. +- [ ] 1.3 Run `openspec validate simplify-netclaw-init --type change` + and resolve all issues. + +## 2. CLI entry point + +- [ ] 2.1 Update `Netclaw.Cli.Program` `netclaw init` dispatch to + parse the new `--force` flag. Unknown flags produce usage error + and non-zero exit. +- [ ] 2.2 Add existing-config detection at init entry: if + `netclaw.json` exists and `--force` was not passed, branch to the + refusal path (TTY screen vs non-TTY stderr). +- [ ] 2.3 Implement non-TTY refusal: print + `Netclaw is already initialized at . Run \`netclaw config\` + to edit, or \`netclaw init --force\` to reset.` to stderr; exit + with non-zero status. +- [ ] 2.4 Implement TTY refusal: launch Termina with a single-screen + refusal page; default focus on `[ OK ]`; Enter or Esc exits with + status 0. + +## 3. `--force` reset path + +- [ ] 3.1 When `--force` is passed and `netclaw.json` exists, launch + Termina with the type-to-confirm backup screen. The text + acknowledges both `netclaw.json` and `secrets.json` will be moved + aside. +- [ ] 3.2 Default focus on `[ Cancel ]`; the `[ Reset and continue ]` + button is enabled only when the operator types `reset` into the + confirm input. +- [ ] 3.3 On confirm, rename `netclaw.json` → + `netclaw.json.bak.` and `secrets.json` → + `secrets.json.bak.` atomically. Generate timestamp once + per invocation so the two files share a suffix. +- [ ] 3.4 After backup, proceed into the three-step wizard as a fresh + first-run (`WizardContext.ExistingConfig = null`). +- [ ] 3.5 On successful post-flight, list the .bak file paths in the + post-flight screen so the operator knows where the prior config + went. +- [ ] 3.6 `--force` with no existing config silently behaves as plain + `netclaw init` (no backup screen). + +## 4. Wizard step list trim + +- [ ] 4.1 Reduce `WizardOrchestrator`'s init-side step list to exactly + three viewmodels: Provider, Identity, Posture. Health check remains + the terminal step. +- [ ] 4.2 Remove from the init step list (NOT delete the classes): + `ChannelPickerStepViewModel`, `ChannelsStepViewModel`, + `FeatureSelectionStepViewModel`, `SearchStepViewModel`, + `SlackStepViewModel`, `DiscordStepViewModel`, + `MattermostStepViewModel`, `ExposureModeStepViewModel`, + `BrowserAutomationStepViewModel`, `ExternalSkillsStepViewModel`, + `SkillFeedsStepViewModel`. These classes continue to back + `netclaw config` section editors per Change B. +- [ ] 4.3 Verify each removed class is still registered with the DI + container as an `ISectionEditor` so `netclaw config` continues to + resolve them. + +## 5. Identity step trim + +- [ ] 5.1 In `IdentityStepViewModel`, retain only the agent-name, + user-name, and timezone fields when running inside the init step + list. The class's `ISectionEditor` implementation may continue to + expose additional fields for future post-install editing; the init + step's view SHALL omit them. +- [ ] 5.2 Remove from the init wizard's Identity view: webhook URL + prompt, communication-style prompt, workspaces-directory prompt. + Their default values are preserved silently. +- [ ] 5.3 Validate fields per existing rules (agent name required, no + whitespace; user name required; timezone validates against + `TimeZoneInfo.FindSystemTimeZoneById`). + +## 6. Posture cascade write + +- [ ] 6.1 In the Posture step's `ContributeConfig` (or the wizard's + terminal write path), apply the posture-default + `Tools.AudienceProfiles` mapping for the selected posture + (Personal: all features on; Team: per-audience defaults per + posture rule; Enterprise: stricter defaults). +- [ ] 6.2 The cascade SHALL write only `Tools.AudienceProfiles` + entries that the operator has not explicitly customized in + `ExistingConfig`. On fresh first-run `ExistingConfig` is null, so + the full posture default applies. + +## 7. Post-flight screen + +- [ ] 7.1 Add a post-flight Termina page showing: provider summary + ("Anthropic — claude-sonnet-4-6"), identity summary ("Netclaw, + aaron, America/Los_Angeles"), posture, health-check status. +- [ ] 7.2 If health check fails, show the failure message and a + `[ Back to Posture ]` action that returns to the Posture step. +- [ ] 7.3 If health check passes, show a `[ Done ]` action and the + nudge text: + `Run \`netclaw chat\` to start, or \`netclaw config\` to configure + channels, webhooks, search, and more.` +- [ ] 7.4 On Termina teardown after a successful Done, print the same + one-line nudge to stderr so it remains visible after the TUI + clears. +- [ ] 7.5 When `--force` reset was used, append the .bak file paths + to the post-flight screen and stderr. + +## 8. Smoke tape revisions + +- [ ] 8.1 Rewrite `tests/smoke/tapes/init-wizard.tape` to exercise + the three-step flow plus post-flight. Target ≤ 60 lines. +- [ ] 8.2 Rewrite `tests/smoke/assertions/init-wizard.sh` to assert + only the bootstrap fields: provider config, models config, identity + files (`SOUL.md`, `TOOLING.md`), posture, and doctor exit code 0 + or 2. +- [ ] 8.3 Delete `tests/smoke/tapes/init-wizard-reverse-proxy.tape` + and `tests/smoke/assertions/init-wizard-reverse-proxy.sh`. + Reverse-proxy coverage is owned by `config-exposure-mode.tape` + from Change B. + +## 9. New smoke tapes + +- [ ] 9.1 Add `tests/smoke/tapes/init-existing-config-refuse.tape`: + pre-stage a `netclaw.json`, run `netclaw init`, observe the TTY + refusal screen, press Enter to acknowledge, assert exit 0. +- [ ] 9.2 Add `tests/smoke/assertions/init-existing-config-refuse.sh`: + assert the pre-staged config is byte-identical post-run. +- [ ] 9.3 Add `tests/smoke/tapes/init-force-reset.tape`: pre-stage a + `netclaw.json`, run `netclaw init --force`, type `reset`, confirm, + complete the three-step flow, assert post-flight Done. +- [ ] 9.4 Add `tests/smoke/assertions/init-force-reset.sh`: assert + (a) a `netclaw.json.bak.*` file exists with the original content, + (b) the new `netclaw.json` reflects what the tape typed, (c) + doctor exits 0 or 2. + +## 10. Documentation + +- [ ] 10.1 Update `docs/prd/PRD-004-cli-onboarding-and-config.md` to + replace the "reentrant init dashboard" wording with the documented + simplified-init + `netclaw config` split. List the three init steps + and reference `netclaw config` for the rest. +- [ ] 10.2 Cross-reference issues #455 and #1150 in PRD-004's Cross- + References section. +- [ ] 10.3 Update `feeds/skills/.system/files/netclaw-identity/SKILL.md` + (per CLAUDE.md system-skills sync rule) so the agent knows the + trimmed identity field set and the `netclaw config` path for + per-audience editing. Bump `metadata.version`. +- [ ] 10.4 Update CLI `--help` text so `netclaw init --help` documents + the trimmed flow and the `--force` flag. + +## 11. Quality gates + +- [ ] 11.1 `dotnet build` clean. +- [ ] 11.2 `dotnet test` clean: round-trip tests for Provider, + Identity, Posture still pass against the trimmed Identity field + set; menu registry audit passes (all editors registered, tapes + exist, test classes exist). +- [ ] 11.3 `./scripts/smoke/run-smoke.sh init-wizard` passes the + rewritten tape. +- [ ] 11.4 `./scripts/smoke/run-smoke.sh light` passes (incl. the two + new init tapes and the 12 `netclaw config` tapes from Change B). +- [ ] 11.5 `dotnet slopwatch analyze` reports no new violations. +- [ ] 11.6 `./scripts/Add-FileHeaders.ps1 -Verify` reports clean. +- [ ] 11.7 `openspec validate simplify-netclaw-init --type change` + passes. + +## 12. Manual acceptance + +- [ ] 12.1 Fresh install (no `~/.netclaw/`): `netclaw init` reaches + working chat in ≤ 3 prompts after provider selection. Verified by + walking through the wizard manually. +- [ ] 12.2 Re-run init over existing config without `--force`: + refusal screen renders, Enter acknowledges, exit 0, config + unchanged. +- [ ] 12.3 Re-run init over existing config with `--force`: confirm + screen renders, type-to-confirm gate works, .bak files created + with matching timestamps, fresh three-step flow runs, new config + written. +- [ ] 12.4 Non-TTY refusal: `netclaw init > /dev/null 2>&1` over an + existing config exits non-zero. +- [ ] 12.5 PR description references this OpenSpec change ID and + cross-references #455 (closed in Change A) and #1150 (closed in + Change B) as already-closed precedents. From dba00078fe45a1bab413713524cf6234c6df795e Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Sat, 23 May 2026 16:22:45 +0000 Subject: [PATCH 02/31] docs: address self-scrutiny review of openspec changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-review pass on the three init/config UX changes surfaced real issues. Fixes: UI mockups landed in the repo (were stuck in ~/.claude/plans/): - docs/ui/TUI-002-netclaw-config-wireframes.md (dashboard + 12 editors + 8 page templates + doctor results + nudge) - docs/ui/TUI-003-simplified-init-wireframes.md (3 init steps + post-flight + refusal + force-reset confirm) - Each change's design.md references its corresponding wireframe document as the authoritative visual contract ISectionEditor vs menu split (Change A): - ISectionEditor gains `bool ShowInMenu` flag (default true) - MenuRegistryAuditTests waives tape-existence check for ShowInMenu == false editors (e.g. Provider, Identity covered by init-wizard.tape and the netclaw provider CLI) - Round-trip test + RelevantDoctorChecks contract still applies to every registered editor regardless of ShowInMenu Schema/SectionId mismatches: - Identity is NOT a top-level schema key; added to exemption list with category "synthetic-spans-multiple-sections" and ShowInMenu = false in Change A's tasks - Top-level Security, Daemon, Tools added to exemption list in Change B's tasks with category "covered by another editor's dotted-path SectionId" naming the covering editor - Exemption-list spec scenarios cover both top-level and dotted-path coverage netclaw config show|validate reserved (Change B): - Reserved subcommands now print an explicit "not yet implemented; PRD-004" notice and exit non-zero, preserving the documented future surface (previously rejected as unknown) Important items tightened across the changes: - Change B section editors explicitly REFACTOR existing init step viewmodels (not create duplicates) where the section already has an init step - Daemon-restart nudge now specifies the PID-file + TCP probe with a 250 ms bound; timeout suppresses the nudge (conservative) - In-place rename for list items now specifies originalKey/newKey tracking, secrets-store rekey, and array-position preservation - BrowserAutomation schema-migration scenarios cover both the editor opening over a pre-existing config and doctor --fix auto-insert - --force non-TTY refusal scenario added in Change C - .bak filename collision handled via -1/-2 suffix; timestamp moves from unix-seconds to unix-millis - Multi-instance editing and Test Connection partial-failure shape documented in Change B's design Risks section All three changes re-validated: openspec validate section-editor-abstraction --type change ✓ openspec validate netclaw-config-command --type change ✓ openspec validate simplify-netclaw-init --type change ✓ --- docs/ui/TUI-002-netclaw-config-wireframes.md | 1150 +++++++++++++++++ docs/ui/TUI-003-simplified-init-wireframes.md | 328 +++++ .../changes/netclaw-config-command/design.md | 24 + .../specs/netclaw-cli/spec.md | 34 +- .../specs/netclaw-config-command/spec.md | 61 +- .../changes/netclaw-config-command/tasks.md | 83 +- .../section-editor-abstraction/design.md | 32 + .../specs/section-editor-abstraction/spec.md | 78 +- .../section-editor-abstraction/tasks.md | 30 +- .../changes/simplify-netclaw-init/design.md | 8 + .../specs/netclaw-onboarding/spec.md | 46 +- .../changes/simplify-netclaw-init/tasks.md | 11 +- 12 files changed, 1814 insertions(+), 71 deletions(-) create mode 100644 docs/ui/TUI-002-netclaw-config-wireframes.md create mode 100644 docs/ui/TUI-003-simplified-init-wireframes.md diff --git a/docs/ui/TUI-002-netclaw-config-wireframes.md b/docs/ui/TUI-002-netclaw-config-wireframes.md new file mode 100644 index 000000000..ed3e4854a --- /dev/null +++ b/docs/ui/TUI-002-netclaw-config-wireframes.md @@ -0,0 +1,1150 @@ +# TUI-002: `netclaw config` Wireframes + +Source PRDs: `PRD-004`, `PRD-001`, `PRD-002` + +Backing OpenSpec change: `openspec/changes/netclaw-config-command/` + +Companion: `TUI-001-command-wireframes.md` (init wizard + chat + plain CLI), +`TUI-003-simplified-init-wireframes.md` (the trimmed init flow that ships +alongside `netclaw config`). + +## Overview + +`netclaw config` is a menu-driven Termina TUI command for live configuration +editing. Operators reach every editable section without leaving the terminal, +without re-entering existing secrets, and without hand-editing +`netclaw.json`. Each section editor is reentrant by construction (pre-fills +non-secret fields from on-disk state) and doctor-blessed on save (relevant +checks run against the candidate config before write). + +Twelve editors ship day one: + +| Editor | SectionId | Category | Multi-value | +|-------------------------|------------------------------|-----------------|-------------| +| Search Provider | `Search` | — | no | +| Slack Channels | `Slack` | Chat Channels | partial | +| Discord Channels | `Discord` | Chat Channels | partial | +| Mattermost Channels | `Mattermost` | Chat Channels | partial | +| Exposure Mode | `Daemon.ExposureMode` | — | partial | +| Security Posture | `Security.Posture` | — | no | +| Audience Profiles | `Tools.AudienceProfiles` | — | partial | +| Outbound Webhooks | `Notifications.Webhooks` | — | yes | +| Inbound Webhooks | `Webhooks` | — | no | +| External Skill Dirs | `ExternalSkills` | — | yes | +| Skill Feeds | `SkillFeeds` | — | yes | +| Browser Automation | `BrowserAutomation` | — | no | + +## Termina Component Vocabulary + +All wireframes reference Termina 0.5.1 components (same as TUI-001): + +- **PanelNode** — bordered container with optional title +- **TextInputNode** — single or multi-line text input (masked variant for secrets) +- **SelectionListNode** — keyboard-navigable option list (single or multi-select) +- **TextNode** — static or dynamic text block +- **SpinnerNode** — animated progress indicator (used for Test Connection actions) + +## Conventions + +### Status glyph vocabulary + +| Glyph | Meaning | +|-------|---------| +| `✓` | Section configured, all relevant doctor checks pass | +| `⚠` | Section configured, at least one check returns WARN | +| `✗` | Section configured, at least one check returns ERROR (blocks save) | +| `–` | Section unset / default / disabled | +| `▸` | Currently focused row | + +A footer hint on the dashboard reads: +`✓ ok · ⚠ warning · ✗ error · – not set` + +### Keystroke conventions + +| Key | Effect | +|-----------------|-----------------------------------------------------------------------| +| `↑` / `↓` | Move focus within list | +| `←` / `→` | Move focus across action row (Save / Cancel / etc.) | +| `Tab` / `Shift+Tab` | Move focus across fields in a form | +| `Enter` | Activate focused element (open editor, submit, toggle) | +| `Esc` | Cancel / go back. Confirms discard if section has unsaved changes. | +| `d` | In list editors: delete focused item (with inline `[y/N]` confirm) | +| `q` | Dashboard quit only | +| `Space` | Toggle focused checkbox | + +### Footer hint style + +Every page renders a single-line footer at the bottom listing the relevant +keystrokes for that page. Page-specific. Common combinations defined in the +page templates below. + +### Title bar conventions + +Every page has a single-line title bar at top, framed by the panel border: + +``` +╭─ ───────────────────────────────... +``` + +Sub-pages use a breadcrumb form: + +``` +╭─ Outbound Webhooks › Edit "critical-pager" ──... +``` + +--- + +## Navigation tree + +``` +netclaw config + └── Config.0 Dashboard ◀─ all editors return here on Save/Cancel + ├── Config.1 Search Provider + ├── Config.2 Slack Channels + ├── Config.3 Discord Channels + ├── Config.4 Mattermost Channels + ├── Config.5 Exposure Mode + ├── Config.6 Security Posture + ├── Config.7 Audience Profiles ← addresses #1150 + ├── Config.8 Outbound Webhooks + ├── Config.9 Inbound Webhooks + ├── Config.10 External Skill Directories + ├── Config.11 Skill Feeds + ├── Config.12 Browser Automation + ├── Config.D Run full doctor + └── Quit + +netclaw config (when no netclaw.json exists) + └── Config.E0 Refuse with `netclaw init` pointer ─── exit non-zero +``` + +--- + +## Page templates + +Reusable patterns referenced by the per-editor sections below. + +### T1. Single-value editor (no secret, no sub-pages) + +``` +╭─
───────────────────────────────────────────╮ +│ │ +│ : │ +│ │ +│ │ +│ : │ +│ │ +│ │ +│ [ Save ] [ Cancel ] │ +│ │ +│ Tab next · Enter activate · Esc cancel │ +╰─────────────────────────────────────────────────────────────╯ +``` + +Transitions: +- `Tab` cycles fields. +- `Enter` on Save → run blessing → write or block. +- `Enter` or `Esc` on Cancel → discard-confirm (T7) if dirty → return to dashboard. + +### T2. Multi-value list with inline edits + +``` +╭─
───────────────────────────────────────────╮ +│ │ +│ ▸ │ +│ │ +│ │ +│ │ +│ + Add │ +│ │ +│ [ Save ] [ Cancel ] │ +│ │ +│ ↑/↓ navigate · Enter edit · d remove · Esc cancel │ +╰─────────────────────────────────────────────────────────────╯ +``` + +Transitions: +- `Enter` on an item → inline edit overlay (single-line input). +- `Enter` on `+ Add` → inline empty input overlay. +- `d` on an item → inline `Remove? [y/N]` prompt; `y` removes, anything else cancels. +- `Enter` on Save → write list to schema array → return to dashboard. +- `Esc` on Cancel → discard-confirm if dirty. + +### T3. Multi-value list with sub-page items + +Same as T2 visually. `Enter` on item or `+ Add` opens a sub-page (T4) +instead of inline edit. + +``` +╭─
───────────────────────────────────────────╮ +│ │ +│ ▸ │ +│ │ +│ │ +│ + Add │ +│ │ +│ [ Save ] [ Cancel ] │ +│ │ +│ ↑/↓ navigate · Enter edit · d remove · Esc cancel │ +╰─────────────────────────────────────────────────────────────╯ +``` + +### T4. Item sub-page (form) + +``` +╭─ ──────────────────────────────╮ +│ │ +│ : │ +│ │ +│ │ +│ : │ +│ │ +│ │ +│ [ Save ] [ Cancel ] [ Delete ] │ +│ │ +│ Tab next · Enter activate · Esc cancel │ +╰─────────────────────────────────────────────────────────────╯ +``` + +`Delete` button shown only on Edit mode, not Add. Activating it → T5 with +destructive copy. + +Transitions: +- `Save` returns to the parent list with the new/updated item applied to + in-memory state. Disk write happens on the parent's outer `Save`. +- `Cancel` returns to parent list without applying. +- `Delete` opens T5; on confirm, removes from in-memory list, returns to + parent. + +### T5. Confirmation dialog (default-Cancel) + +``` +╭─ ──────────────────────────────────────────╮ +│ │ +│ │ +│ │ +│ ▸ [ Cancel ] [ Yes, ] │ +│ │ +│ Default: Cancel (Esc or Enter) │ +╰─────────────────────────────────────────────────────────────╯ +``` + +Default focus on Cancel. `Enter` or `Esc` cancels. `Tab` + `Enter` on +"Yes" confirms. + +### T6. Inline validation banner + +Rendered above the action row of any editor while doctor blessing finds +issues. ERROR variant: + +``` +│ ╭─ Issues ───────────────────────────────────────────────╮ │ +│ │ ✗ Brave backend requires an API key │ │ +│ │ ⚠ Endpoint TLS certificate expires in 14 days │ │ +│ ╰────────────────────────────────────────────────────────╯ │ +│ │ +│ [ Save ] (disabled) [ Cancel ] │ +``` + +WARN-only variant: + +``` +│ ╭─ Warnings ─────────────────────────────────────────────╮ │ +│ │ ⚠ Endpoint TLS certificate expires in 14 days │ │ +│ ╰────────────────────────────────────────────────────────╯ │ +│ │ +│ [ Save anyway ] [ Cancel ] │ +``` + +### T7. Unsaved-changes discard confirm + +``` +╭─ Discard changes? ──────────────────────────────────────────╮ +│ │ +│ You have unsaved changes in this section. │ +│ Closing now will lose them. │ +│ │ +│ ▸ [ Keep editing ] [ Discard ] │ +│ │ +│ Default: Keep editing (Esc or Enter) │ +╰─────────────────────────────────────────────────────────────╯ +``` + +Shown when user hits Esc on a section editor with dirty state. + +### T8. Empty list placeholder + +``` +╭─
───────────────────────────────────────────╮ +│ │ +│ (no configured) │ +│ │ +│ ▸ + Add │ +│ │ +│ [ Save ] [ Cancel ] │ +│ │ +│ Enter add · Esc cancel │ +╰─────────────────────────────────────────────────────────────╯ +``` + +Shown when a list editor opens with zero items. + +--- + +## Config.0 — Dashboard + +``` +╭─ Netclaw Configuration ─────────────────────────────────────╮ +│ │ +│ ▸ Search Provider ✓ Brave │ +│ Chat Channels │ +│ Slack ✓ 3 channels, 2 users │ +│ Discord – not configured │ +│ Mattermost – not configured │ +│ Exposure Mode ✓ Local │ +│ Security Posture ✓ Personal │ +│ Audience Profiles ✓ default │ +│ Outbound Webhooks ⚠ 2 configured, 1 unreachable │ +│ Inbound Webhooks – disabled │ +│ External Skill Dirs ✓ 2 directories │ +│ Skill Feeds – none │ +│ Browser Automation – disabled │ +│ │ +│ ────────── │ +│ Run full doctor │ +│ Quit │ +│ │ +│ ↑/↓ navigate · Enter open · q quit · ✓ ok · ⚠ warn · ✗ err │ +╰─────────────────────────────────────────────────────────────╯ +``` + +**Status computation:** on dashboard entry, each editor's +`GetStatus(currentConfig)` runs (with `RelevantDoctorChecks` against +on-disk state). Results cached for the dashboard session; re-computed +when returning from a saved editor. + +**Sub-grouping indentation:** chat-channel rows render at +2 indent under +the "Chat Channels" label. The label itself is unselectable. + +**No "Save dashboard" action:** the dashboard is purely a navigation +layer. All saves are at section granularity. + +### Layout structure + +``` +PanelNode (outer: "Netclaw Configuration") +├── SelectionListNode (single-select; entries from SectionEditorRegistry +│ grouped by Category, plus "Run full doctor" and +│ "Quit" tail items) +└── TextNode (footer hint line) +``` + +--- + +## Config.E0 — No-config refusal + +Rendered when `~/.netclaw/config/netclaw.json` is missing at launch. + +``` +╭─ No Netclaw configuration found ────────────────────────────╮ +│ │ +│ No configuration file at: │ +│ ~/.netclaw/config/netclaw.json │ +│ │ +│ Run `netclaw init` to create one. │ +│ │ +│ [ OK ] │ +│ │ +│ Enter exit │ +╰─────────────────────────────────────────────────────────────╯ +``` + +Non-interactive (when stdout is not a TTY, e.g. CI): prints +`No configuration found. Run \`netclaw init\` first.` to stderr and exits +non-zero. The interactive variant exits zero after acknowledgement. + +--- + +## Config.1 — Search Provider + +### 1.1 Main editor + +``` +╭─ Search Provider ───────────────────────────────────────────╮ +│ │ +│ Backend: │ +│ ▸ Brave (current) │ +│ DuckDuckGo │ +│ SearXng (self-hosted) │ +│ │ +│ Brave API key: │ +│ ╭────────────────────────────────────────────────────────╮ │ +│ │ │ │ +│ ╰────────────────────────────────────────────────────────╯ │ +│ (configured — leave blank to keep) │ +│ │ +│ SearXng instance URL: │ +│ ╭────────────────────────────────────────────────────────╮ │ +│ │ (not applicable — only required for SearXng) │ │ +│ ╰────────────────────────────────────────────────────────╯ │ +│ │ +│ [ Save ] [ Cancel ] [ Remove credential ] │ +│ │ +│ Tab next · Enter activate · Esc cancel │ +╰─────────────────────────────────────────────────────────────╯ +``` + +**Field conditionality:** Brave API key disabled when backend ≠ Brave; +SearXng URL disabled when backend ≠ SearXng; DuckDuckGo has no fields. + +**Reentrancy:** Backend selector pre-fills from current config. API key +field is empty regardless; hint indicates "configured" or "not set" +based on `ConfigFileHelper.SecretPresent(...)`. + +### 1.2 Remove credential confirm (T5) + +``` +╭─ Remove Brave API key? ─────────────────────────────────────╮ +│ │ +│ This deletes your Brave API key from secrets.json. │ +│ Search will fall back to DuckDuckGo unless you set a new │ +│ key. You can re-enter at any time. │ +│ │ +│ ▸ [ Cancel ] [ Yes, remove ] │ +│ │ +│ Default: Cancel (Esc or Enter) │ +╰─────────────────────────────────────────────────────────────╯ +``` + +**Doctor checks** (`RelevantDoctorChecks`): `ConfigSchemaDoctorCheck`, +`SearchBackendDoctorCheck`. + +--- + +## Config.2 — Slack Channels + +### 2.1 Main editor + +``` +╭─ Slack Channels ────────────────────────────────────────────╮ +│ │ +│ Bot token: │ +│ ╭────────────────────────────────────────────────────────╮ │ +│ │ │ │ +│ ╰────────────────────────────────────────────────────────╯ │ +│ (configured — leave blank to keep) │ +│ │ +│ App token (Socket Mode): │ +│ ╭────────────────────────────────────────────────────────╮ │ +│ │ │ │ +│ ╰────────────────────────────────────────────────────────╯ │ +│ (configured — leave blank to keep) │ +│ │ +│ Allowed channels: 3 configured → │ +│ Allowed users: 2 configured → │ +│ DMs enabled: [ X ] yes │ +│ Audience profile: Personal │ +│ │ +│ [ Save ] [ Cancel ] [ Test connection ] │ +│ [ Remove credentials ] │ +│ │ +│ Tab next · Enter activate · Esc cancel │ +╰─────────────────────────────────────────────────────────────╯ +``` + +Sub-pages: +- "Allowed channels" → 2.2 list editor. +- "Allowed users" → 2.3 list editor. + +### 2.2 Allowed channels list (T2) + +``` +╭─ Slack Channels › Allowed channel IDs ──────────────────────╮ +│ │ +│ ▸ C01ABCDE │ +│ C01FGHIJ │ +│ C01KLMNO │ +│ │ +│ + Add channel ID │ +│ │ +│ [ Save ] [ Cancel ] │ +│ │ +│ ↑/↓ navigate · Enter edit · d remove · Esc cancel │ +╰─────────────────────────────────────────────────────────────╯ +``` + +`Save` here is "apply to in-memory state and return to 2.1." Disk write +happens when 2.1 itself saves. + +### 2.3 Allowed users list + +Same shape as 2.2 with user IDs. Uses `IdentifierItemEditor`. + +### 2.4 Test connection (inline banner) + +Runs the existing Slack probe logic from `SlackStepViewModel`; result +rendered in an inline banner above the action row: + +``` +│ ╭─ Connection test ──────────────────────────────────────╮ │ +│ │ ✓ Bot token valid (workspace: petabridge) │ │ +│ │ ✓ Socket Mode app token valid │ │ +│ │ ✓ Bot has access to 3 of 3 configured channels │ │ +│ ╰────────────────────────────────────────────────────────╯ │ +``` + +Failure shape: + +``` +│ ╭─ Connection test ──────────────────────────────────────╮ │ +│ │ ✗ Bot token invalid: 401 invalid_auth │ │ +│ │ Check `xoxb-` token in the Slack app config │ │ +│ ╰────────────────────────────────────────────────────────╯ │ +``` + +Test results never modify config; they're advisory before Save. + +### 2.5 Remove credentials confirm (T5) + +``` +╭─ Remove Slack credentials? ─────────────────────────────────╮ +│ │ +│ This deletes both the Slack bot token and the Socket │ +│ Mode app token from secrets.json. Slack will be │ +│ disconnected until you re-enter both. Allowed channels │ +│ and users are preserved in netclaw.json. │ +│ │ +│ ▸ [ Cancel ] [ Yes, remove ] │ +│ │ +│ Default: Cancel (Esc or Enter) │ +╰─────────────────────────────────────────────────────────────╯ +``` + +**Doctor checks:** `ConfigSchemaDoctorCheck`, `SlackAuthDoctorCheck`, +`SlackAclDoctorCheck`. + +--- + +## Config.3 — Discord Channels + +Structurally identical to 2.x except: +- Single token field (bot token only; no app token). +- Otherwise: allowed channels list, allowed users list, DMs toggle, + audience profile, test connection, remove credentials. + +(Layouts identical to 2.1–2.5 with the App token row removed.) + +**Doctor checks:** `ConfigSchemaDoctorCheck`, `DiscordAuthDoctorCheck`. + +--- + +## Config.4 — Mattermost Channels + +Structurally identical to 2.x plus: +- `Server URL` text field at the top. +- Same token, channels, users, DMs, audience profile, test connection, + remove credentials. + +``` +╭─ Mattermost Channels ───────────────────────────────────────╮ +│ │ +│ Server URL: │ +│ ╭────────────────────────────────────────────────────────╮ │ +│ │ https://chat.example.com │ │ +│ ╰────────────────────────────────────────────────────────╯ │ +│ │ +│ Bot token: │ +│ ╭────────────────────────────────────────────────────────╮ │ +│ │ │ │ +│ ╰────────────────────────────────────────────────────────╯ │ +│ (configured — leave blank to keep) │ +│ │ +│ Allowed channels: 5 configured → │ +│ Allowed users: 3 configured → │ +│ DMs enabled: [ X ] yes │ +│ Audience profile: Team │ +│ │ +│ [ Save ] [ Cancel ] [ Test connection ] │ +│ [ Remove credentials ] │ +│ │ +│ Tab next · Enter activate · Esc cancel │ +╰─────────────────────────────────────────────────────────────╯ +``` + +**Doctor checks:** `ConfigSchemaDoctorCheck`, `MattermostAuthDoctorCheck`. + +--- + +## Config.5 — Exposure Mode + +### 5.1 Mode selection + +``` +╭─ Exposure Mode ─────────────────────────────────────────────╮ +│ │ +│ How is Netclaw reachable from outside the host? │ +│ │ +│ ▸ Local │ +│ 127.0.0.1 only. No external exposure. │ +│ │ +│ Reverse Proxy │ +│ Behind nginx/Caddy/etc. Trusted proxies required. │ +│ │ +│ Tailscale │ +│ Auth via Tailscale identity. Mesh network required. │ +│ │ +│ Cloudflare Tunnel │ +│ Cloudflare access-protected. Tunnel credentials needed. │ +│ │ +│ ────── │ +│ Daemon host: 127.0.0.1 │ +│ Daemon port: 5199 │ +│ │ +│ [ Configure mode → ] [ Save ] [ Cancel ] │ +│ │ +│ ↑/↓ navigate · Tab to buttons · Enter activate │ +╰─────────────────────────────────────────────────────────────╯ +``` + +**Conditionality:** "Configure mode →" button is enabled only when +selected mode requires sub-config (Reverse Proxy, Tailscale, Cloudflare). +Local has no sub-config. + +### 5.2 Reverse Proxy sub-form (T1-shaped) + +``` +╭─ Exposure Mode › Reverse Proxy ─────────────────────────────╮ +│ │ +│ External base URL (must be HTTPS): │ +│ ╭────────────────────────────────────────────────────────╮ │ +│ │ https://netclaw.example.com │ │ +│ ╰────────────────────────────────────────────────────────╯ │ +│ │ +│ Trusted proxies (CIDR list): 2 configured → │ +│ │ +│ [ Apply ] [ Cancel ] │ +│ │ +│ Tab next · Enter activate · Esc cancel │ +╰─────────────────────────────────────────────────────────────╯ +``` + +Trusted proxies row → 5.5 list editor. + +### 5.3 Tailscale sub-form + +``` +╭─ Exposure Mode › Tailscale ─────────────────────────────────╮ +│ │ +│ Tailscale auth key: │ +│ ╭────────────────────────────────────────────────────────╮ │ +│ │ │ │ +│ ╰────────────────────────────────────────────────────────╯ │ +│ (configured — leave blank to keep) │ +│ │ +│ Hostname on tailnet: netclaw │ +│ │ +│ [ Apply ] [ Cancel ] [ Remove auth key ] │ +│ │ +╰─────────────────────────────────────────────────────────────╯ +``` + +### 5.4 Cloudflare Tunnel sub-form + +``` +╭─ Exposure Mode › Cloudflare Tunnel ─────────────────────────╮ +│ │ +│ Tunnel token: │ +│ ╭────────────────────────────────────────────────────────╮ │ +│ │ │ │ +│ ╰────────────────────────────────────────────────────────╯ │ +│ (configured — leave blank to keep) │ +│ │ +│ Access policy email domain (optional): │ +│ ╭────────────────────────────────────────────────────────╮ │ +│ │ │ │ +│ ╰────────────────────────────────────────────────────────╯ │ +│ │ +│ [ Apply ] [ Cancel ] [ Remove tunnel token ] │ +│ │ +╰─────────────────────────────────────────────────────────────╯ +``` + +### 5.5 Trusted proxies list (T2 with `IdentifierItemEditor`) + +``` +╭─ Exposure Mode › Trusted Proxies ───────────────────────────╮ +│ │ +│ ▸ 10.0.0.0/8 │ +│ 192.168.1.0/24 │ +│ │ +│ + Add CIDR │ +│ │ +│ [ Save ] [ Cancel ] │ +│ │ +│ ↑/↓ navigate · Enter edit · d remove · Esc cancel │ +╰─────────────────────────────────────────────────────────────╯ +``` + +**Doctor checks:** `ConfigSchemaDoctorCheck`, `ExposureModeDoctorCheck`. + +--- + +## Config.6 — Security Posture + +### 6.1 Posture selection (T1-shaped) + +``` +╭─ Security Posture ──────────────────────────────────────────╮ +│ │ +│ Current posture: Personal │ +│ │ +│ ▸ Personal │ +│ Just me. Local-only by default. Tools have wide access. │ +│ │ +│ Team │ +│ Small team via Slack/Discord. Audience-restricted tools. │ +│ │ +│ Enterprise │ +│ Production deployment. Strict audience profiles, audit. │ +│ │ +│ [ Save ] [ Cancel ] │ +│ │ +│ ↑/↓ navigate · Tab to buttons · Enter activate │ +╰─────────────────────────────────────────────────────────────╯ +``` + +### 6.2 Cascade warning (T5 variant — three options) + +Shown only when changing posture AND `Tools.AudienceProfiles` has been +customized away from the prior posture's defaults. + +``` +╭─ Posture change affects Audience Profiles ──────────────────╮ +│ │ +│ You have customized Audience Profiles. Changing posture │ +│ will overwrite them with the new posture's defaults. │ +│ │ +│ ▸ [ Cancel — keep current posture ] │ +│ [ Apply new posture, overwrite profiles ] │ +│ [ Apply new posture, keep custom profiles ] │ +│ │ +│ Default: Cancel (Esc or Enter) │ +╰─────────────────────────────────────────────────────────────╯ +``` + +**Doctor checks:** `ConfigSchemaDoctorCheck`, `SecurityPolicyDoctorCheck`. + +--- + +## Config.7 — Audience Profiles *(addresses #1150)* + +### 7.1 Audience selection + +``` +╭─ Audience Profiles ─────────────────────────────────────────╮ +│ │ +│ Configure tool access per audience tier. │ +│ │ +│ ▸ Personal ✓ Default for posture: Personal │ +│ Team ✓ Default for posture: Personal │ +│ Public ✓ Default for posture: Personal │ +│ │ +│ ────── │ +│ │ +│ Shell mode (global): HostAllowed │ +│ │ +│ [ Cancel ] │ +│ │ +│ ↑/↓ navigate · Enter edit audience · Esc cancel │ +╰─────────────────────────────────────────────────────────────╯ +``` + +### 7.2 Per-audience editor + +``` +╭─ Audience Profiles › Team ──────────────────────────────────╮ +│ │ +│ Tools enabled for the Team audience: │ +│ │ +│ [ X ] memory │ +│ [ X ] search │ +│ [ X ] skills │ +│ [ ] scheduling │ +│ [ X ] sub-agents │ +│ [ ] webhooks │ +│ │ +│ Shell mode for Team: SandboxOnly │ +│ Approval policy: Required │ +│ │ +│ [ Save ] [ Cancel ] [ Reset to posture default ] │ +│ │ +│ ↑/↓ navigate · Space toggle · Tab to buttons · Esc cancel │ +╰─────────────────────────────────────────────────────────────╯ +``` + +**Key bindings critical to #1150:** + +- `↑` / `↓` MUST move focus between toggle rows. +- `Space` MUST toggle the focused checkbox. +- `Enter` on a checkbox row also toggles (alternative to Space). +- `Tab` moves to the action row. +- `Reset to posture default` replaces all toggles + shell mode with the + posture-default mapping. + +The `config-audience.tape` smoke tape explicitly exercises `↓`, `Space`, +`↑`, `Space` to lock in the keystroke contract. Regression in arrow +nav OR toggle is caught. + +**Doctor checks:** `ConfigSchemaDoctorCheck`, `ToolAudienceProfilesDoctorCheck`. + +--- + +## Config.8 — Outbound Webhooks + +### 8.1 List page (T3) + +``` +╭─ Outbound Webhooks ─────────────────────────────────────────╮ +│ │ +│ ▸ ops-alerts ✓ healthy │ +│ critical-pager ⚠ unreachable last 3 attempts │ +│ │ +│ + Add webhook │ +│ │ +│ [ Save ] [ Cancel ] │ +│ │ +│ ↑/↓ navigate · Enter edit · d remove · Esc cancel │ +╰─────────────────────────────────────────────────────────────╯ +``` + +Empty-state (T8): + +``` +╭─ Outbound Webhooks ─────────────────────────────────────────╮ +│ │ +│ (no webhooks configured) │ +│ │ +│ ▸ + Add webhook │ +│ │ +│ [ Save ] [ Cancel ] │ +│ │ +│ Enter add · Esc cancel │ +╰─────────────────────────────────────────────────────────────╯ +``` + +### 8.2 Add/edit form (T4) + +``` +╭─ Outbound Webhooks › Edit "critical-pager" ─────────────────╮ +│ │ +│ Name: │ +│ ╭────────────────────────────────────────────────────────╮ │ +│ │ critical-pager │ │ +│ ╰────────────────────────────────────────────────────────╯ │ +│ │ +│ URL: │ +│ ╭────────────────────────────────────────────────────────╮ │ +│ │ https://events.pagerduty.com/v2/enqueue │ │ +│ ╰────────────────────────────────────────────────────────╯ │ +│ │ +│ Auth header (e.g. "Authorization: Bearer ..."): │ +│ ╭────────────────────────────────────────────────────────╮ │ +│ │ │ │ +│ ╰────────────────────────────────────────────────────────╯ │ +│ (configured — leave blank to keep) │ +│ │ +│ Event filter (optional, comma-separated): │ +│ ╭────────────────────────────────────────────────────────╮ │ +│ │ session.error,session.compaction │ │ +│ ╰────────────────────────────────────────────────────────╯ │ +│ │ +│ [ Save ] [ Cancel ] [ Delete webhook ] │ +│ │ +│ Tab next · Enter activate · Esc cancel │ +╰─────────────────────────────────────────────────────────────╯ +``` + +### 8.3 Delete confirm (T5) + +``` +╭─ Remove webhook "critical-pager"? ──────────────────────────╮ +│ │ +│ This webhook will be removed from Notifications.Webhooks. │ +│ Any stored auth header for it will be deleted. │ +│ │ +│ ▸ [ Cancel ] [ Yes, remove ] │ +│ │ +│ Default: Cancel (Esc or Enter) │ +╰─────────────────────────────────────────────────────────────╯ +``` + +**Doctor checks:** `ConfigSchemaDoctorCheck`, `WebhookFormatDoctorCheck`. + +--- + +## Config.9 — Inbound Webhooks + +``` +╭─ Inbound Webhooks ──────────────────────────────────────────╮ +│ │ +│ Inbound webhooks let external systems trigger Netclaw │ +│ via signed HTTP requests. Routes are defined per webhook │ +│ under ~/.netclaw/config/webhooks/*.json (file-edited). │ +│ │ +│ [ X ] Inbound webhooks enabled │ +│ │ +│ Request timeout (seconds): 30 │ +│ │ +│ [ Save ] [ Cancel ] │ +│ │ +│ Tab next · Space toggle · Enter activate · Esc cancel │ +╰─────────────────────────────────────────────────────────────╯ +``` + +**Note:** route file editing remains file-based; this editor only +toggles the feature and sets the timeout. If user enables this flag +but no routes exist, `InboundWebhookRoutesDoctorCheck` (existing) +surfaces the empty-routes condition — per CLAUDE.md "fail loudly," +we do NOT silently default to dummy routes. + +**Doctor checks:** `ConfigSchemaDoctorCheck`, `InboundWebhookRoutesDoctorCheck`. + +--- + +## Config.10 — External Skill Directories + +### 10.1 List page (T2 with `PathItemEditor`) + +``` +╭─ External Skill Directories ────────────────────────────────╮ +│ │ +│ ▸ ~/.claude/skills │ +│ ~/work/team-skills │ +│ ~/personal-skills │ +│ │ +│ + Add directory │ +│ │ +│ [ Save ] [ Cancel ] │ +│ │ +│ ↑/↓ navigate · Enter edit · d remove · Esc cancel │ +╰─────────────────────────────────────────────────────────────╯ +``` + +Empty state per T8. + +### 10.2 Inline add/edit overlay + +``` +│ ~/work/team-skills │ +│ ╭─ Edit directory ───────────────────────────────────────╮ │ +│ │ ~/personal-skills_ │ │ +│ ╰────────────────────────────────────────────────────────╯ │ +│ │ +│ [Enter] save · [Esc] cancel │ +``` + +Renders as an overlay row replacing the focused item. Validates: path +exists, is a directory, is readable. Errors render inline below the +input row. + +### 10.3 Inline delete confirm + +When `d` pressed on a focused item: + +``` +│ ▸ ~/.claude/skills Remove? [y/N] │ +``` + +Single-keypress. `y` removes; anything else cancels. No modal. + +**Doctor checks:** `ConfigSchemaDoctorCheck`, `ExternalSkillSourcesDoctorCheck`. + +--- + +## Config.11 — Skill Feeds + +### 11.1 List page (T3 with `SkillFeedItemEditor`) + +``` +╭─ Skill Feeds ───────────────────────────────────────────────╮ +│ │ +│ ▸ corp-internal-feed ✓ reachable │ +│ legacy-feed ✗ 403 forbidden │ +│ │ +│ + Add feed │ +│ │ +│ [ Save ] [ Cancel ] │ +│ │ +│ ↑/↓ navigate · Enter edit · d remove · Esc cancel │ +╰─────────────────────────────────────────────────────────────╯ +``` + +### 11.2 Add/edit form (T4) + +``` +╭─ Skill Feeds › Edit "corp-internal-feed" ───────────────────╮ +│ │ +│ Name: │ +│ ╭────────────────────────────────────────────────────────╮ │ +│ │ corp-internal-feed │ │ +│ ╰────────────────────────────────────────────────────────╯ │ +│ │ +│ Feed URL: │ +│ ╭────────────────────────────────────────────────────────╮ │ +│ │ https://skills.internal.corp/manifest.json │ │ +│ ╰────────────────────────────────────────────────────────╯ │ +│ │ +│ API key (Bearer token, optional): │ +│ ╭────────────────────────────────────────────────────────╮ │ +│ │ │ │ +│ ╰────────────────────────────────────────────────────────╯ │ +│ (configured — leave blank to keep) │ +│ │ +│ [ Save ] [ Cancel ] [ Test connection ] │ +│ [ Delete feed ] │ +│ │ +│ Tab next · Enter activate · Esc cancel │ +╰─────────────────────────────────────────────────────────────╯ +``` + +### 11.3 Delete confirm (T5) + +``` +╭─ Remove feed "legacy-feed"? ────────────────────────────────╮ +│ │ +│ This feed will be removed from SkillFeeds.Feeds. Any │ +│ stored Bearer token for it will be deleted. │ +│ │ +│ ▸ [ Cancel ] [ Yes, remove ] │ +│ │ +│ Default: Cancel (Esc or Enter) │ +╰─────────────────────────────────────────────────────────────╯ +``` + +**Doctor checks:** `ConfigSchemaDoctorCheck`, `SkillFeedsDoctorCheck` +(WARN-only — transient outages don't block saves). + +--- + +## Config.12 — Browser Automation + +### 12.1 Status & toggle (Playwright not installed) + +``` +╭─ Browser Automation ────────────────────────────────────────╮ +│ │ +│ Headless browser support via Playwright. Used by the │ +│ `browser` tool for web scraping and form interaction. │ +│ │ +│ Status: Playwright not installed │ +│ │ +│ [ ] Browser automation enabled │ +│ (cannot enable until Playwright is installed) │ +│ │ +│ [ Install instructions → ] [ Cancel ] │ +│ │ +│ Tab next · Enter activate · Esc cancel │ +╰─────────────────────────────────────────────────────────────╯ +``` + +### 12.2 Status & toggle (Playwright installed) + +``` +╭─ Browser Automation ────────────────────────────────────────╮ +│ │ +│ Status: Playwright installed (v1.42.0) │ +│ │ +│ [ X ] Browser automation enabled │ +│ │ +│ [ Save ] [ Cancel ] [ Uninstall instructions → ] │ +│ │ +│ Tab next · Space toggle · Enter activate · Esc cancel │ +╰─────────────────────────────────────────────────────────────╯ +``` + +### 12.3 Install instructions sub-page + +``` +╭─ Browser Automation › Install Playwright ───────────────────╮ +│ │ +│ Playwright is not currently installed. To install: │ +│ │ +│ 1. Run: │ +│ dotnet tool install --global Microsoft.Playwright.CLI│ +│ │ +│ 2. Then: │ +│ playwright install chromium │ +│ │ +│ After installation, return to this editor and re-open to │ +│ detect the installation. │ +│ │ +│ [ OK ] │ +│ │ +│ Enter exit │ +╰─────────────────────────────────────────────────────────────╯ +``` + +**Why not shell out to install:** installing global tooling from a TUI +is too magical and platform-fragile. Print instructions; let the user +run them in their shell. Detection on re-open is automatic +(`BrowserAutomationDoctorCheck` resolves `playwright` from PATH at +editor entry). + +**Doctor checks:** `ConfigSchemaDoctorCheck`, `BrowserAutomationDoctorCheck`. + +--- + +## Config.D — Run full doctor + +``` +╭─ Doctor — full configuration check ─────────────────────────╮ +│ │ +│ ✓ ConfigSchema OK │ +│ ✓ Providers OK │ +│ ✓ Models OK │ +│ ⚠ Search Brave API key valid but rate- │ +│ limited per recent probes │ +│ ✓ Slack OK │ +│ – Discord Not configured │ +│ – Mattermost Not configured │ +│ ✓ Exposure OK (Local) │ +│ ✓ AudienceProfiles OK │ +│ ✗ Notifications.Webhooks critical-pager unreachable │ +│ ✓ ExternalSkills OK │ +│ – SkillFeeds None configured │ +│ – BrowserAutomation Disabled │ +│ │ +│ Summary: 8 pass · 1 warning · 1 error · 4 skipped │ +│ │ +│ Exit code on close: 1 (errors present) │ +│ │ +│ [ Back to dashboard ] │ +│ │ +│ Enter back · Esc back │ +╰─────────────────────────────────────────────────────────────╯ +``` + +Invokes the same `DoctorRunner` used by `netclaw doctor`. Results page +renders status per check. + +--- + +## Daemon-restart nudge at exit + +Printed to stderr after Termina teardown when (a) at least one section +saved during the session AND (b) the daemon is currently running. + +``` +Config saved. Restart the daemon to apply changes: + netclaw daemon stop && netclaw daemon start +``` + +When the daemon is not running OR no saves occurred, the nudge is +omitted. + +**Daemon detection:** `netclaw config` uses the same lightweight probe +as `netclaw daemon status` (PID file lookup at the documented path, +falling back to a port-open check on the configured daemon port). The +probe is bounded to 250 ms; if the probe times out, the nudge is +omitted (conservative — better to miss the nudge than to falsely +suggest a restart). diff --git a/docs/ui/TUI-003-simplified-init-wireframes.md b/docs/ui/TUI-003-simplified-init-wireframes.md new file mode 100644 index 000000000..47acd44d7 --- /dev/null +++ b/docs/ui/TUI-003-simplified-init-wireframes.md @@ -0,0 +1,328 @@ +# TUI-003: Simplified `netclaw init` Wireframes + +Source PRDs: `PRD-004`, `PRD-001` + +Backing OpenSpec change: `openspec/changes/simplify-netclaw-init/` + +Companion: `TUI-001-command-wireframes.md` (prior 6-step init wizard, +superseded by this document), `TUI-002-netclaw-config-wireframes.md` +(the `netclaw config` command that owns post-bootstrap edits). + +## Overview + +`netclaw init` is trimmed from 12 steps to three: LLM provider, +identity, security posture. The goal is time-to-first-chat. Everything +else (channels, search, webhooks, exposure mode, audience profiles, +skill feeds, external skill directories, browser automation, MCP +servers) moves to `netclaw config` (see TUI-002). + +Existing-config detection is now explicit: re-running over an existing +install refuses with helpful pointers, or accepts `--force` to back +up and reset. + +## Termina Component Vocabulary + +Same as TUI-001 / TUI-002: + +- **PanelNode** — bordered container with optional title +- **TextInputNode** — single or multi-line text input (masked variant for secrets) +- **SelectionListNode** — keyboard-navigable option list +- **TextNode** — static or dynamic text block +- **SpinnerNode** — animated progress indicator (post-flight health check) + +## Conventions + +Glyphs and keystrokes follow TUI-002 conventions. Init-specific: + +- Title bar shows step indicator `Step of 3: `. +- Step navigation: Tab cycles fields; Enter on Next advances; Enter or + Esc on Back returns; Esc on a step with dirty state triggers discard + confirm (see TUI-002 T7). + +--- + +## Navigation tree + +``` +netclaw init (fresh install — no existing config) + ├── Init.1 Provider selection (+ existing auth sub-flow) + ├── Init.2 Identity (agent name, user name, timezone) + ├── Init.3 Security Posture + └── Init.4 Post-flight (health-check, summary) ─── exit + stderr nudge + +netclaw init (existing config detected, no --force) + └── Init.E1 Refuse + suggest `netclaw config` or `netclaw init --force` + +netclaw init --force (existing config detected) + └── Init.E2 Backup confirm ──→ Init.1 (proceeds as fresh) + +netclaw init --force (no existing config) + └── Init.1 (proceeds as fresh; no backup screen) +``` + +--- + +## Init.1 — Provider selection + +Reuses existing `ProviderStepViewModel` (refactored to `ISectionEditor` +in `section-editor-abstraction` change). After the provider type is +picked, the existing auth sub-flow runs (auth method → endpoint → API +key or OAuth device flow → model selection). Behavior unchanged from +prior versions. + +``` +╭─ Netclaw Setup — Step 1 of 3: LLM Provider ─────────────────╮ +│ │ +│ Choose your LLM provider: │ +│ │ +│ ▸ Anthropic │ +│ OpenAI │ +│ OpenRouter │ +│ GitHub Copilot │ +│ Ollama (local, no API key) │ +│ OpenAI-compatible (custom endpoint) │ +│ │ +│ ↑/↓ navigate · Enter select · Esc quit │ +╰─────────────────────────────────────────────────────────────╯ +``` + +**Transitions:** + +- `Enter` → existing auth sub-flow (TUI-001 covers the sub-flow shapes). +- `Esc` → quit setup (with discard confirm if anything was entered). + +**Reentrancy:** in the rare case `netclaw init` runs over existing +config (only via `--force` reset; otherwise the command refuses +at Init.E1), the provider selector pre-fills the existing provider +type. API key field renders empty per the secret-handling contract +(`configured — leave blank to keep`). + +--- + +## Init.2 — Identity + +Trimmed `IdentityStepViewModel` (see Change C tasks 5.x). Drops the +prior webhook URL prompt, the workspaces-directory prompt, and the +communication-style prompt. Keeps agent name, user name, timezone. + +``` +╭─ Netclaw Setup — Step 2 of 3: Identity ─────────────────────╮ +│ │ +│ Your provider is configured. Now let's set up the agent. │ +│ │ +│ Agent name: │ +│ ╭────────────────────────────────────────────────────────╮ │ +│ │ Netclaw │ │ +│ ╰────────────────────────────────────────────────────────╯ │ +│ │ +│ Your name (what the agent calls you): │ +│ ╭────────────────────────────────────────────────────────╮ │ +│ │ │ │ +│ ╰────────────────────────────────────────────────────────╯ │ +│ │ +│ Timezone (IANA name): │ +│ ╭────────────────────────────────────────────────────────╮ │ +│ │ America/Los_Angeles │ │ +│ ╰────────────────────────────────────────────────────────╯ │ +│ │ +│ [ Next ] [ Back ] [ Cancel ] │ +│ │ +│ Tab next · Enter activate · Esc cancel │ +╰─────────────────────────────────────────────────────────────╯ +``` + +**Transitions:** + +- `Next` → Init.3. +- `Back` → Init.1. +- `Cancel` → discard confirm → exit. + +**Validation:** Agent name required, no whitespace. User name required. +Timezone validates against `TimeZoneInfo.FindSystemTimeZoneById`. + +**Dropped fields' defaults:** webhook URL is left unset (operators add +operational webhooks via `netclaw config → Outbound Webhooks`). +Workspaces directory defaults to `~/.netclaw/workspaces`. Communication +style defaults to neutral. These remain editable via file edit for now +(future Identity section editor in `netclaw config` is out of MVP +scope). + +--- + +## Init.3 — Security Posture + +Reuses existing `SecurityPostureStepViewModel`. + +``` +╭─ Netclaw Setup — Step 3 of 3: Security Posture ─────────────╮ +│ │ +│ How will Netclaw be used? │ +│ │ +│ ▸ Personal │ +│ Just me. Local-only by default. Tools have wide access. │ +│ │ +│ Team │ +│ Small team via Slack/Discord. Audience-restricted tools. │ +│ │ +│ Enterprise │ +│ Production deployment. Strict audience profiles, audit. │ +│ │ +│ [ Next ] [ Back ] [ Cancel ] │ +│ │ +│ ↑/↓ navigate · Tab to buttons · Enter activate │ +╰─────────────────────────────────────────────────────────────╯ +``` + +**Transitions:** + +- `Next` (Enter on Next button OR Enter on a posture row) → applies + posture-default `Tools.AudienceProfiles` mapping in-memory → + proceeds to Init.4 (terminal write + health check). +- `Back` → Init.2. + +**Posture cascade applied non-interactively (no separate feature +selection step):** + +| Posture | Audience.Personal | Audience.Team | Audience.Public | Shell mode | +|------------|-------------------|-----------------------------|----------------------------|---------------| +| Personal | all features on | n/a (Personal-only) | n/a | HostAllowed | +| Team | all features on | search+memory+skills on; webhooks off | webhooks off; memory off | SandboxOnly | +| Enterprise | search+memory on | search+memory on | nothing on | SandboxOnly | + +Operators override per-audience post-install via `netclaw config → +Audience Profiles`. + +--- + +## Init.4 — Post-flight + +After Init.3 applies posture, the wizard writes merged config + secrets ++ runs the existing health check + shows results. + +``` +╭─ Netclaw Setup — Setup Complete ────────────────────────────╮ +│ │ +│ ✓ Provider configured: Anthropic (claude-sonnet-4-6) │ +│ ✓ Identity set: Netclaw (aaron, America/Los_Angeles) │ +│ ✓ Posture: Personal │ +│ ✓ Configuration written to ~/.netclaw/config/netclaw.json │ +│ ✓ Health check passed │ +│ │ +│ ────── │ +│ │ +│ Run `netclaw chat` to start talking to your agent. │ +│ Run `netclaw config` to set up channels, search, webhooks, │ +│ external skills, browser automation, and more. │ +│ │ +│ [ Done ] │ +│ │ +│ Enter exit │ +╰─────────────────────────────────────────────────────────────╯ +``` + +**Transitions:** + +- `Enter` → Termina tears down. The same two-line nudge is also printed + to stderr after exit so users see it even after the TUI clears. + +**Failure path:** if health check fails (doctor errors), the page shows +the errors and a `[ Back to Posture ]` action instead of `[ Done ]`. +Operator returns to Init.3 to fix. + +### Post-flight when `--force` was used + +When `netclaw init --force` triggered a backup, the post-flight screen +appends a `.bak` file disclosure section so operators know where the +prior config went: + +``` +│ ────── │ +│ Previous configuration backed up to: │ +│ ~/.netclaw/config/netclaw.json.bak.1716508800 │ +│ ~/.netclaw/config/secrets.json.bak.1716508800 │ +│ │ +│ Restore manually if needed. │ +``` + +The same paths are printed to stderr after Termina teardown. + +--- + +## Init.E1 — Existing config refusal + +Rendered when `netclaw init` is invoked, `~/.netclaw/config/netclaw.json` +exists, and `--force` was not passed. + +``` +╭─ Netclaw is already initialized ────────────────────────────╮ +│ │ +│ Found existing configuration: │ +│ ~/.netclaw/config/netclaw.json │ +│ │ +│ To edit your configuration interactively, run: │ +│ netclaw config │ +│ │ +│ To start over from scratch (existing config backed up): │ +│ netclaw init --force │ +│ │ +│ [ OK ] │ +│ │ +│ Enter exit │ +╰─────────────────────────────────────────────────────────────╯ +``` + +**Non-interactive variant** (when stdout is not a TTY, e.g. CI): +prints the same text to stderr and exits non-zero. The interactive +variant exits zero on acknowledgement. + +--- + +## Init.E2 — Force-reset backup confirm + +Rendered when `netclaw init --force` runs and existing config is +detected. + +``` +╭─ Reset Netclaw configuration? ──────────────────────────────╮ +│ │ +│ This will: │ +│ • Move netclaw.json → netclaw.json.bak.<timestamp> │ +│ • Move secrets.json → secrets.json.bak.<timestamp> │ +│ • Start setup from scratch │ +│ │ +│ Your old config is preserved as a .bak file; you can │ +│ restore it manually if needed. │ +│ │ +│ Type "reset" to confirm: │ +│ ╭────────────────────────────────────────────────────────╮ │ +│ │ │ │ +│ ╰────────────────────────────────────────────────────────╯ │ +│ │ +│ ▸ [ Cancel ] [ Reset and continue ] │ +│ │ +╰─────────────────────────────────────────────────────────────╯ +``` + +**Type-to-confirm here because this is genuinely destructive** (running +config + secrets get moved aside, fresh setup writes new ones). +Single-Y/N is insufficient. + +**Transitions:** + +- `Cancel` → exit zero. Config unchanged. +- `Reset and continue` (enabled only when "reset" typed) → backup + performed (rename atomically; timestamp generated once per + invocation so both files share a suffix) → proceed to Init.1. + +**Non-TTY refusal:** `netclaw init --force > /dev/null 2>&1` cannot +prompt for the type-to-confirm. The command SHALL refuse in non-TTY +contexts with `--force` requires interactive confirm and exit non-zero. + +**`--force` over no existing config:** silently behaves as plain +`netclaw init` (no backup screen, no extra prompt). + +**Backup timestamp collision avoidance:** the timestamp suffix uses +unix-milliseconds (`netclaw.json.bak.<millis>`). On the extremely +unlikely event of a collision (two `--force` invocations in the same +millisecond), an auto-increment suffix is appended +(`netclaw.json.bak.<millis>-1`). diff --git a/openspec/changes/netclaw-config-command/design.md b/openspec/changes/netclaw-config-command/design.md index 524c72339..203c7532c 100644 --- a/openspec/changes/netclaw-config-command/design.md +++ b/openspec/changes/netclaw-config-command/design.md @@ -1,5 +1,11 @@ ## Context +**UI wireframes:** every page introduced by this change is mocked in +`docs/ui/TUI-002-netclaw-config-wireframes.md` (dashboard, all 12 section +editors, list editor templates T1–T8, doctor results page, daemon +restart nudge). Implementors SHALL treat TUI-002 as the visual contract; +this design document explains decisions and trade-offs around it. + The `section-editor-abstraction` change (predecessor) introduced the `ISectionEditor` contract, the `SectionEditorRegistry`, the merge-on-save plumbing, and the single-step `WizardOrchestrator` mode. It refactored @@ -228,6 +234,24 @@ while keeping the registry flat. is reachable from one menu entry away. Migration text in the PR description points operators at the new path. +- [Multi-instance editing] Two concurrent `netclaw config` processes + on the same install would both load → merge → write to the same + `netclaw.json` and `secrets.json`. → Mitigation: out of MVP scope; + semantics are last-write-wins per the file's atomic tmp-rename + write. Documented as a known limitation. File locks are deferred + until there is concrete evidence of operators running multiple + TUI editors simultaneously. + +- [Test Connection partial failure shape] Slack/Discord/Mattermost + Test Connection actions probe several capabilities (auth, channel + access, DM access). Some sub-probes may succeed while others + fail. → Mitigation: the result banner SHALL render one line per + sub-probe with its own status glyph (`✓ Bot token valid`, + `✗ Channel C01ABCDE not in workspace`). Network timeouts SHALL + render as `⚠ probe timed out` rather than a fatal failure, since + the operator may have a transient network issue. Test Connection + is advisory only; it never blocks the editor's Save. + ## Migration Plan This change ships net-new behavior (`netclaw config`) plus a single diff --git a/openspec/changes/netclaw-config-command/specs/netclaw-cli/spec.md b/openspec/changes/netclaw-config-command/specs/netclaw-cli/spec.md index 2c38340ce..7f4f7bf93 100644 --- a/openspec/changes/netclaw-config-command/specs/netclaw-cli/spec.md +++ b/openspec/changes/netclaw-config-command/specs/netclaw-cli/spec.md @@ -6,10 +6,11 @@ The CLI SHALL expose `netclaw config` as a top-level command. The command SHALL be offline (no daemon connection), SHALL operate on local config files only, and SHALL behave per the `netclaw-config-command` capability. `netclaw config --help` SHALL -print a one-paragraph description and exit zero. Invocations with any -positional argument SHALL print usage and exit non-zero in this change -(subcommands such as `netclaw config show|validate` remain reserved -for future work and SHALL NOT execute as a side effect). +print a one-paragraph description and exit zero. `netclaw config show` +and `netclaw config validate` are RESERVED subcommands (PRD-004) and +SHALL print a not-yet-implemented notice and exit non-zero in this +change, preserving the documented future surface. Unknown subcommands +SHALL print usage and exit non-zero. #### Scenario: Help text describes the command @@ -18,12 +19,33 @@ for future work and SHALL NOT execute as a side effect). - **AND** stdout contains a one-paragraph description naming "interactive configuration editor" - **AND** stdout references the `netclaw init` companion command +- **AND** stdout lists the reserved `show` and `validate` subcommands + with a "not yet implemented; see PRD-004" note -#### Scenario: Unknown subcommand rejected +#### Scenario: Reserved subcommand show exits non-zero with reservation notice + +- **WHEN** the operator runs `netclaw config show` +- **THEN** stderr contains + `\`netclaw config show\` is reserved for future use (PRD-004) and is + not yet implemented.` +- **AND** the command exits with non-zero status +- **AND** no `netclaw.json` write occurs + +#### Scenario: Reserved subcommand validate exits non-zero with reservation notice + +- **WHEN** the operator runs `netclaw config validate` +- **THEN** stderr contains + `\`netclaw config validate\` is reserved for future use (PRD-004) + and is not yet implemented.` +- **AND** the command exits with non-zero status +- **AND** no `netclaw.json` write occurs + +#### Scenario: Unknown subcommand rejected with usage - **WHEN** the operator runs `netclaw config foo` - **THEN** the command exits with non-zero status -- **AND** stderr contains usage text +- **AND** stderr contains usage text naming the dashboard launch + (`netclaw config` with no args) and the reserved subcommands #### Scenario: No-args invocation launches dashboard diff --git a/openspec/changes/netclaw-config-command/specs/netclaw-config-command/spec.md b/openspec/changes/netclaw-config-command/specs/netclaw-config-command/spec.md index 6067f06bb..d24231490 100644 --- a/openspec/changes/netclaw-config-command/specs/netclaw-config-command/spec.md +++ b/openspec/changes/netclaw-config-command/specs/netclaw-config-command/spec.md @@ -195,8 +195,14 @@ dashboard with no config write performed. `netclaw config` SHALL print a stderr nudge at exit instructing the operator to restart the daemon for changes to take effect, when (a) at least one config or secrets write occurred during the session AND (b) -the daemon is currently running. If either condition is false, the -nudge SHALL be omitted. +the daemon is currently running. Daemon-running detection SHALL reuse +the same probe used by `netclaw daemon status` (PID-file check at the +documented daemon path, falling back to a TCP-open check on the +configured daemon port). The probe SHALL be bounded by a 250 ms +timeout; on timeout the nudge SHALL be omitted (conservative — missing +a true-positive nudge is preferable to a false-positive nudge after a +network hiccup). If either condition is false, the nudge SHALL be +omitted. #### Scenario: Daemon running plus config change emits nudge @@ -222,6 +228,16 @@ nudge SHALL be omitted. - **WHEN** the operator quits - **THEN** no nudge is printed regardless of daemon state +#### Scenario: Daemon-detection probe timeout suppresses nudge + +- **GIVEN** the operator saved at least one section during the session +- **AND** the PID-file lookup fails (file absent or unreadable) +- **AND** the TCP-open check on the daemon port exceeds the 250 ms + bound +- **WHEN** the operator quits the dashboard +- **THEN** no nudge is printed +- **AND** the command exits with status 0 + ### Requirement: Generic list editor component The CLI SHALL provide a generic `ListEditor<T>` Termina component @@ -264,12 +280,21 @@ in-place renames (rather than delete + add) round-trip correctly. - **GIVEN** a webhook list with an entry whose `KeyOf` returns `"critical-pager"` +- **AND** the entry's auth header is stored under that key in + `secrets.json` (e.g. `Notifications.Webhooks.critical-pager.AuthHeader`) - **WHEN** the operator edits the entry and changes its name to `pagerduty-prod` -- **THEN** the list save records a single update (not a delete + add) -- **AND** the underlying `Notifications.Webhooks` array contains exactly - one entry with the new name and the preserved auth header - (per the secret-handling contract) +- **THEN** the list editor tracks the rename via the `(originalKey, + newKey)` pair across the edit lifecycle +- **AND** the merge writer locates the underlying schema-array entry + by `originalKey` (not by array index), replaces the name and other + fields, and writes the updated entry at the same array position +- **AND** the corresponding secrets-store key is renamed from + `originalKey` to `newKey` atomically; the stored encrypted value + for `originalKey` is unchanged in encrypted form and re-keyed +- **AND** the resulting `Notifications.Webhooks` array contains + exactly one entry, named `pagerduty-prod`, with the previously + stored auth header still configured ### Requirement: Search Provider editor @@ -541,6 +566,30 @@ installing. `RelevantDoctorChecks` SHALL include - **THEN** `BrowserAutomationDoctorCheck` returns ERROR - **AND** the save is blocked with remediation guidance +#### Scenario: Existing config without BrowserAutomation section opens cleanly + +- **GIVEN** an existing `netclaw.json` written prior to this change + that lacks a top-level `BrowserAutomation` section +- **WHEN** the operator opens the Browser Automation editor +- **THEN** the editor renders with the toggle reflecting + `Enabled = false` (schema default) +- **AND** no schema-validation error is surfaced for the missing + section +- **AND** the merge writer treats a no-op exit as a true no-op (no + speculative `BrowserAutomation` section is written until the + operator explicitly saves a non-default state) + +#### Scenario: SchemaFixResolver auto-insert tolerates missing section on doctor --fix + +- **GIVEN** an existing `netclaw.json` written prior to this change + that lacks the `BrowserAutomation` section +- **WHEN** the operator runs `netclaw doctor --fix` +- **THEN** `SchemaFixResolver` inserts + `BrowserAutomation: { Enabled: false }` using the schema's default + value +- **AND** subsequent `ConfigSchemaDoctorCheck` runs pass without + warning + ### Requirement: Smoke tape per editor and the no-init refusal The smoke-test harness SHALL include a tape per registered section diff --git a/openspec/changes/netclaw-config-command/tasks.md b/openspec/changes/netclaw-config-command/tasks.md index 4ff3b3978..7b434a9cd 100644 --- a/openspec/changes/netclaw-config-command/tasks.md +++ b/openspec/changes/netclaw-config-command/tasks.md @@ -71,55 +71,75 @@ ## 6. Section editors — single-value -- [ ] 6.1 `SearchSectionEditor` (`SectionId = "Search"`): backend - selector + conditional API key / SearXng URL fields. Honor +These editors REUSE existing step viewmodels where possible. Each +existing step viewmodel is REFACTORED to implement `ISectionEditor` +(per Change A's contract) and is moved into the new folder structure +under `src/Netclaw.Cli/Tui/Sections/<Section>/`. No new duplicate +classes are created for sections that today have an init step +viewmodel; the same class serves both init (when in the trimmed step +list, post Change C) and `netclaw config` (single-step mode). + +- [ ] 6.1 `SearchSectionEditor` (`SectionId = "Search"`, + `ShowInMenu = true`): refactor of existing `SearchStepViewModel`. + Backend selector + conditional API key / SearXng URL fields. Honor `ExistingConfig`. `RelevantDoctorChecks`: `{ConfigSchemaDoctorCheck, SearchBackendDoctorCheck}`. - [ ] 6.2 `SecurityPostureSectionEditor` - (`SectionId = "Security.Posture"`): three-choice posture list with - cascade dialog (Cancel | Overwrite | Keep custom) when changing - posture over customized `Tools.AudienceProfiles`. + (`SectionId = "Security.Posture"`, `ShowInMenu = true`): refactored + to `ISectionEditor` in Change A; this change adds the cascade dialog + (Cancel | Overwrite | Keep custom) when changing posture over + customized `Tools.AudienceProfiles`. - [ ] 6.3 `AudienceProfilesSectionEditor` - (`SectionId = "Tools.AudienceProfiles"`): audience picker - (Personal | Team | Public) opening per-audience editor with - toggleable feature rows, shell-mode selector, approval policy - selector, and "Reset to posture default" affordance. MUST exercise - arrow nav + Space toggle (#1150 contract). -- [ ] 6.4 `InboundWebhooksSectionEditor` (`SectionId = "Webhooks"`): - feature-flag toggle + request timeout integer. + (`SectionId = "Tools.AudienceProfiles"`, `ShowInMenu = true`): NEW + editor (no init-step equivalent — the buggy `FeatureSelectionStepViewModel` + is replaced by this editor). Audience picker (Personal | Team | Public) + opening per-audience editor with toggleable feature rows, + shell-mode selector, approval policy selector, and "Reset to + posture default" affordance. MUST exercise arrow nav + Space toggle + (#1150 contract). +- [ ] 6.4 `InboundWebhooksSectionEditor` (`SectionId = "Webhooks"`, + `ShowInMenu = true`): NEW editor. Feature-flag toggle + request + timeout integer. - [ ] 6.5 `BrowserAutomationSectionEditor` - (`SectionId = "BrowserAutomation"`): feature-flag toggle with - Playwright detection at entry; install-instructions sub-page when - Playwright absent. + (`SectionId = "BrowserAutomation"`, `ShowInMenu = true`): refactor + of existing `BrowserAutomationStepViewModel`. Feature-flag toggle + with Playwright detection at entry; install-instructions sub-page + when Playwright absent. ## 7. Section editors — multi-value (compose ListEditor) - [ ] 7.1 `OutboundWebhooksSectionEditor` - (`SectionId = "Notifications.Webhooks"`) using - `WebhookItemEditor`. + (`SectionId = "Notifications.Webhooks"`, `ShowInMenu = true`): NEW + editor. Uses `WebhookItemEditor`. - [ ] 7.2 `ExternalSkillsSectionEditor` - (`SectionId = "ExternalSkills"`) using `PathItemEditor`. -- [ ] 7.3 `SkillFeedsSectionEditor` (`SectionId = "SkillFeeds"`) using - `SkillFeedItemEditor`. + (`SectionId = "ExternalSkills"`, `ShowInMenu = true`): refactor of + existing `ExternalSkillsStepViewModel`. Uses `PathItemEditor`. +- [ ] 7.3 `SkillFeedsSectionEditor` (`SectionId = "SkillFeeds"`, + `ShowInMenu = true`): refactor of existing `SkillFeedsStepViewModel`. + Uses `SkillFeedItemEditor`. ## 8. Section editors — chat channels (composite) - [ ] 8.1 `SlackSectionEditor` (`SectionId = "Slack"`, - `Category = "Chat Channels"`): bot token + app token, allowed + `Category = "Chat Channels"`, `ShowInMenu = true`): refactor of + existing `SlackStepViewModel`. Bot token + app token, allowed channels list, allowed users list, DMs toggle, audience profile - selector, Test Connection. Reuses - `channel-audience-tui` cycling component for the channel list. + selector, Test Connection. Reuses `channel-audience-tui` cycling + component for the channel list. - [ ] 8.2 `DiscordSectionEditor` (`SectionId = "Discord"`, - `Category = "Chat Channels"`): single bot token, same affordances + `Category = "Chat Channels"`, `ShowInMenu = true`): refactor of + existing `DiscordStepViewModel`. Single bot token, same affordances otherwise. - [ ] 8.3 `MattermostSectionEditor` (`SectionId = "Mattermost"`, - `Category = "Chat Channels"`): server URL + bot token, same + `Category = "Chat Channels"`, `ShowInMenu = true`): refactor of + existing `MattermostStepViewModel`. Server URL + bot token, same affordances otherwise. ## 9. Section editor — exposure mode (composite) - [ ] 9.1 `ExposureModeSectionEditor` - (`SectionId = "Daemon.ExposureMode"`): mode selector (Local | + (`SectionId = "Daemon.ExposureMode"`, `ShowInMenu = true`): refactor + of existing `ExposureModeStepViewModel`. Mode selector (Local | Reverse Proxy | Tailscale | Cloudflare Tunnel), daemon host/port fields, mode-conditional sub-forms. - [ ] 9.2 Reverse Proxy sub-form: external base URL + trusted @@ -127,6 +147,17 @@ - [ ] 9.3 Tailscale sub-form: auth key (secret) + hostname. - [ ] 9.4 Cloudflare Tunnel sub-form: tunnel token (secret) + optional access-policy email domain. +- [ ] 9.5 Add `Daemon` to `SectionEditorExemptions` with category + `"covered by another editor's dotted-path SectionId"` naming + `Daemon.ExposureMode` as the owner. The non-exposure parts of + `Daemon` (host, port, trusted proxies) are part of the + ExposureModeSectionEditor's surface. +- [ ] 9.6 Add `Security` to `SectionEditorExemptions` with category + `"covered by another editor's dotted-path SectionId"` naming + `Security.Posture`. +- [ ] 9.7 Add `Tools` to `SectionEditorExemptions` with category + `"covered by another editor's dotted-path SectionId"` naming + `Tools.AudienceProfiles`. ## 10. New doctor checks diff --git a/openspec/changes/section-editor-abstraction/design.md b/openspec/changes/section-editor-abstraction/design.md index 8a48d0dde..d7c2de416 100644 --- a/openspec/changes/section-editor-abstraction/design.md +++ b/openspec/changes/section-editor-abstraction/design.md @@ -1,5 +1,12 @@ ## Context +**UI wireframes:** SecurityPosture's appearance inside `netclaw config` +is in `docs/ui/TUI-002-netclaw-config-wireframes.md` (§ Config.6). +Provider and Identity remain init-only and their wireframes are in +`docs/ui/TUI-003-simplified-init-wireframes.md` (§ Init.1, Init.2) +once Change C lands; for this change they continue to use the prior +init wizard wireframes documented in `docs/ui/TUI-001-command-wireframes.md`. + The `netclaw init` wizard composed of `WizardOrchestrator` + a fixed list of `IWizardStepViewModel`s produces a runnable Netclaw configuration but treats the on-disk state as a write-once target. There is no shared abstraction for @@ -115,6 +122,31 @@ editors we ship, not to demand editors for every schema knob; the exemption list is the explicit "we know about this section and choose not to expose it" record. +The audit distinguishes three kinds of editor: + +- **`ShowInMenu == true` editors with a top-level `SectionId`** (e.g. + `Search`, `Slack`). Require: round-trip test class, non-empty + `RelevantDoctorChecks` (or `[NoDoctorChecks]`), AND a smoke tape at + `tests/smoke/tapes/config-<sectionid-lower>.tape` (once the + `netclaw config` dashboard exists from the next change). +- **`ShowInMenu == true` editors with a dotted-path `SectionId`** (e.g. + `Security.Posture`, `Daemon.ExposureMode`, `Tools.AudienceProfiles`). + Same requirements as above. The top-level parent section (e.g. + `Security`) must appear in `SectionEditorExemptions` with a + "covered by another editor" entry naming the dotted-path editor as + the canonical owner. +- **`ShowInMenu == false` editors** (e.g. `Providers`, `Identity`). + Require: round-trip test class and `RelevantDoctorChecks`. Smoke-tape + existence is NOT required — these editors run inside the init wizard + (covered by `init-wizard.tape`) or via dedicated CLI subcommands + (covered by their respective tapes). + +The synthetic-identifier case (e.g. `Identity`, which spans several +schema sections rather than owning one) is treated as `ShowInMenu == +false` and must appear in the exemption list with category +`"synthetic-spans-multiple-sections"` so reviewers can see it's not a +real schema key. + Alternative considered: walk the schema and require every top-level section to either have an editor or an exemption. Rejected per planning discussion: forcing editors for every schema knob produces shallow, diff --git a/openspec/changes/section-editor-abstraction/specs/section-editor-abstraction/spec.md b/openspec/changes/section-editor-abstraction/specs/section-editor-abstraction/spec.md index 73dca0abf..51ecf3d56 100644 --- a/openspec/changes/section-editor-abstraction/specs/section-editor-abstraction/spec.md +++ b/openspec/changes/section-editor-abstraction/specs/section-editor-abstraction/spec.md @@ -7,8 +7,12 @@ The CLI SHALL define a `ISectionEditor` contract in section. Each implementation SHALL declare a stable `SectionId` whose value matches a schema key in `netclaw-config.v1.schema.json` (dotted-path form is permitted for nested sections such as `Daemon.ExposureMode` and -`Tools.AudienceProfiles`), a user-facing `DisplayName`, an optional -`Category` grouping label, a `GetStatus` method returning +`Tools.AudienceProfiles`; a synthetic-identifier form is permitted ONLY for +editors whose data spans multiple schema sections, in which case the editor +MUST appear in the documented exemption list), a user-facing `DisplayName`, +an optional `Category` grouping label, a `bool ShowInMenu` flag (default +`true`; editors that participate in init but are not exposed in the +`netclaw config` menu SHALL return `false`), a `GetStatus` method returning `SectionStatus.{Default, Configured, Warning, Error, Missing}` from current on-disk config, a secret-redacting `Summary` for dashboard display, a non-empty `RelevantDoctorChecks` collection (or an explicit @@ -50,6 +54,18 @@ factory that returns an `IWizardStepViewModel`. - **AND** it is also runnable in single-step orchestrator mode (see "Single-step orchestrator") +#### Scenario: Editor opts out of the netclaw config menu + +- **GIVEN** an `ISectionEditor` whose section is owned by the init + wizard or a CLI subcommand and is not exposed for ad-hoc editing + via `netclaw config` +- **WHEN** the editor declares `ShowInMenu => false` +- **THEN** the dashboard SHALL NOT render the editor as a menu entry +- **AND** the menu registry audit's smoke-tape existence check + SHALL NOT require a `config-<sectionid>.tape` for that editor +- **AND** the round-trip test contract SHALL still apply (the editor + must have a `SectionEditorTestBase<TEditor>` subclass) + ### Requirement: Section editor registry The CLI SHALL provide a DI-discovered `SectionEditorRegistry` holding every @@ -79,11 +95,17 @@ within the registry. The CLI SHALL maintain a documented exemption list at `Netclaw.Cli.Tui.Sections.SectionEditorExemptions` enumerating schema -sections that intentionally have no TUI editor. Each entry SHALL carry a -machine-readable category (e.g. "internal-only", "set-once-at-install", -"covered by CLI subcommand", "covered by another editor", "out of MVP -scope"). The exemption list SHALL be the only mechanism by which an -unregistered schema section avoids audit failure. +sections that intentionally have no top-level TUI editor. Each entry +SHALL carry a machine-readable category (e.g. "internal-only", +"set-once-at-install", "covered by CLI subcommand", "covered by +another editor's dotted-path SectionId", "synthetic-spans-multiple-sections", +"out of MVP scope"). The exemption list SHALL be the only mechanism +by which an unregistered schema section avoids audit failure. The +audit SHALL consider a top-level schema section "covered" when ANY +registered editor's `SectionId` starts with `<section>.` (dotted-path +ownership); such top-level sections still require an exemption-list +entry naming the covering editor to make the relationship explicit +and reviewable. #### Scenario: Schema section absent from registry and absent from exemptions @@ -102,6 +124,19 @@ unregistered schema section avoids audit failure. - **WHEN** the audit runs - **THEN** the audit does not fail for `Persistence` +#### Scenario: Top-level schema section covered by a dotted-path editor + +- **GIVEN** the schema declares a top-level section `Security` +- **AND** an editor with `SectionId = "Security.Posture"` is + registered +- **AND** `"Security"` is present in `SectionEditorExemptions` with + category `"covered by another editor's dotted-path SectionId"` + naming `Security.Posture` +- **WHEN** the audit runs +- **THEN** the audit does not fail for `Security` +- **AND** the audit's failure-message vocabulary treats the + exemption's "covering editor" reference as the canonical owner + ### Requirement: Single-step orchestrator mode `WizardOrchestrator` SHALL support construction with a single @@ -293,13 +328,15 @@ every registered `ISectionEditor`. The test project SHALL include `MenuRegistryAuditTests` that walks `SectionEditorRegistry` and asserts, for every registered editor: a -matching concrete `SectionEditorTestBase<TEditor>` subclass exists, the +matching concrete `SectionEditorTestBase<TEditor>` subclass exists; the editor's `RelevantDoctorChecks` is non-empty (or the class is annotated -with `[NoDoctorChecks]`), and — once smoke tapes ship for the editor in -the next change — a matching tape file exists at -`tests/smoke/tapes/config-<section-lowercase>.tape`. The audit SHALL -report all failures in one assertion message naming each missing -artifact. +with `[NoDoctorChecks]`); and, for editors with `ShowInMenu == true`, +once smoke tapes ship for the editor in the next change, a matching +tape file exists at `tests/smoke/tapes/config-<section-lowercase>.tape`. +Editors with `ShowInMenu == false` are exempt from the tape-existence +check (they participate in init or in CLI subcommands; init-side +coverage is provided by `init-wizard.tape`). The audit SHALL report +all failures in one assertion message naming each missing artifact. #### Scenario: Missing round-trip test class fails the audit @@ -322,5 +359,20 @@ artifact. (Provider, Identity, Posture) - **AND** each has a matching round-trip test class and non-empty `RelevantDoctorChecks` +- **AND** Provider and Identity declare `ShowInMenu == false` while + Posture declares `ShowInMenu == true` - **WHEN** `MenuRegistryAuditTests` runs - **THEN** the audit passes +- **AND** the audit does not require a `config-providers.tape`, + `config-identity.tape`, or `config-security.posture.tape` for the + `ShowInMenu == false` editors + +#### Scenario: ShowInMenu editor missing its smoke tape fails the audit + +- **GIVEN** a registered editor with `ShowInMenu == true` +- **AND** no file at + `tests/smoke/tapes/config-<sectionid-lower>.tape` +- **AND** the `netclaw config` command exists (tape requirement is + active per the change that introduces the dashboard) +- **WHEN** `MenuRegistryAuditTests` runs +- **THEN** the audit fails with a message naming the missing tape diff --git a/openspec/changes/section-editor-abstraction/tasks.md b/openspec/changes/section-editor-abstraction/tasks.md index f94028569..379fb52e5 100644 --- a/openspec/changes/section-editor-abstraction/tasks.md +++ b/openspec/changes/section-editor-abstraction/tasks.md @@ -79,18 +79,26 @@ ## 7. Refactor three existing init step viewmodels -- [ ] 7.1 `ProviderStepViewModel`: implement `ISectionEditor` (SectionId - `Providers`). Honor `ExistingConfig` in `OnEnter(direction)` for - provider type, endpoint, auth method, model selection, and OAuth - token expiry. API key field renders empty with "configured — leave - blank to keep" hint when `SecretPresent` returns true. -- [ ] 7.2 `IdentityStepViewModel`: implement `ISectionEditor` (SectionId - `Identity`). Honor `ExistingConfig` for agent name, user name, - timezone, comm style, workspaces directory, webhook URL. (Step is - trimmed in the third change; this change keeps existing fields.) +- [ ] 7.1 `ProviderStepViewModel`: implement `ISectionEditor` + (SectionId `Providers`, `ShowInMenu = false` — covered by the + existing `netclaw provider` CLI per D3 of the planning doc). Honor + `ExistingConfig` in `OnEnter(direction)` for provider type, endpoint, + auth method, model selection, and OAuth token expiry. API key field + renders empty with "configured — leave blank to keep" hint when + `SecretPresent` returns true. +- [ ] 7.2 `IdentityStepViewModel`: implement `ISectionEditor` + (SectionId `Identity` as a synthetic identifier — Identity is NOT a + top-level schema key; identity data spans `Workspaces`, + `Notifications`, and identity files like `SOUL.md`. Add the + synthetic ID `Identity` to `SectionEditorExemptions` with category + `"synthetic-spans-multiple-sections"`. `ShowInMenu = false` — set + once at init in MVP). Honor `ExistingConfig` for agent name, user + name, timezone, comm style, workspaces directory, webhook URL. (Step + is trimmed in the third change; this change keeps existing fields.) - [ ] 7.3 `SecurityPostureStepViewModel`: implement `ISectionEditor` - (SectionId `Security.Posture`, dotted path). Honor `ExistingConfig` - for the posture selection and posture-default cascade. + (SectionId `Security.Posture`, dotted path; `ShowInMenu = true` — + surfaces in the dashboard in Change B). Honor `ExistingConfig` for + the posture selection and posture-default cascade. - [ ] 7.4 Each refactored editor declares non-empty `RelevantDoctorChecks` referencing the existing checks that scope to the editor's section. diff --git a/openspec/changes/simplify-netclaw-init/design.md b/openspec/changes/simplify-netclaw-init/design.md index 31237a60d..7749bc45b 100644 --- a/openspec/changes/simplify-netclaw-init/design.md +++ b/openspec/changes/simplify-netclaw-init/design.md @@ -1,5 +1,13 @@ ## Context +**UI wireframes:** every page introduced by this change — the three +init steps, the post-flight screen, the existing-config refusal +(Init.E1), and the force-reset backup confirm (Init.E2) — is mocked +in `docs/ui/TUI-003-simplified-init-wireframes.md`. Implementors SHALL +treat TUI-003 as the visual contract for this change. The companion +TUI-002 mocks `netclaw config`, which is the destination operators are +nudged toward at post-flight. + The `section-editor-abstraction` change (Change A) refactored Provider, Identity, and Posture step viewmodels into reentrant `ISectionEditor`s and switched the wizard's terminal write to merge-on-save. The diff --git a/openspec/changes/simplify-netclaw-init/specs/netclaw-onboarding/spec.md b/openspec/changes/simplify-netclaw-init/specs/netclaw-onboarding/spec.md index 45baaf1ab..35c4a9603 100644 --- a/openspec/changes/simplify-netclaw-init/specs/netclaw-onboarding/spec.md +++ b/openspec/changes/simplify-netclaw-init/specs/netclaw-onboarding/spec.md @@ -139,12 +139,20 @@ status so CI catches the surprise. `netclaw init --force` SHALL detect existing config and require an explicit type-to-confirm before proceeding. On confirm, the command SHALL rename `~/.netclaw/config/netclaw.json` to -`netclaw.json.bak.<unix-timestamp>` and -`~/.netclaw/config/secrets.json` to `secrets.json.bak.<unix-timestamp>`. -The wizard SHALL then proceed as a fresh first-run. The .bak files -SHALL be preserved on disk so operators retain a manual recovery -path. The command SHALL print the .bak file paths to the post-flight -screen so operators know where the prior config went. +`netclaw.json.bak.<unix-millis>` and +`~/.netclaw/config/secrets.json` to `secrets.json.bak.<unix-millis>`. +A single timestamp SHALL be generated per invocation so both files +share a suffix. On the extremely unlikely event of a collision (an +existing file at the chosen suffix), an auto-incrementing dash +suffix SHALL be appended (`.bak.<unix-millis>-1`, `-2`, ...) until a +free filename is found. The wizard SHALL then proceed as a fresh +first-run. The .bak files SHALL be preserved on disk so operators +retain a manual recovery path. The command SHALL print the .bak file +paths to the post-flight screen so operators know where the prior +config went. `netclaw init --force` SHALL refuse to run in non-TTY +contexts (no stdin or no terminal-controlled stdout) because the +type-to-confirm prompt cannot be rendered safely; the command SHALL +print a non-TTY refusal message to stderr and exit non-zero. #### Scenario: Force without confirm leaves config unchanged @@ -175,3 +183,29 @@ screen so operators know where the prior config went. - **WHEN** the command starts - **THEN** no backup screen is shown (nothing to back up) - **AND** the wizard proceeds to Step 1 (Provider) normally + +#### Scenario: Force in non-TTY context refuses + +- **GIVEN** `netclaw.json` exists on disk +- **AND** `netclaw init --force` is run with stdout or stdin not a TTY + (e.g. piped, redirected, or in CI) +- **WHEN** the command starts +- **THEN** stderr contains + `\`netclaw init --force\` requires an interactive terminal for the + reset confirmation. Run it from a TTY.` +- **AND** the command exits with non-zero status +- **AND** the existing `netclaw.json` and `secrets.json` are + unchanged +- **AND** no .bak files are created + +#### Scenario: Force handles existing .bak filename collision + +- **GIVEN** `netclaw.json` exists on disk +- **AND** a previously-created backup at + `~/.netclaw/config/netclaw.json.bak.<expected-millis>` already + exists (e.g. from a prior force run within the same millisecond) +- **WHEN** the operator types "reset" and confirms +- **THEN** the backup uses + `netclaw.json.bak.<expected-millis>-1` (and the corresponding + `secrets.json.bak.<expected-millis>-1`) +- **AND** the existing backup file is not overwritten diff --git a/openspec/changes/simplify-netclaw-init/tasks.md b/openspec/changes/simplify-netclaw-init/tasks.md index ada4d874f..5e41b17c5 100644 --- a/openspec/changes/simplify-netclaw-init/tasks.md +++ b/openspec/changes/simplify-netclaw-init/tasks.md @@ -34,9 +34,11 @@ button is enabled only when the operator types `reset` into the confirm input. - [ ] 3.3 On confirm, rename `netclaw.json` → - `netclaw.json.bak.<unix-ts>` and `secrets.json` → - `secrets.json.bak.<unix-ts>` atomically. Generate timestamp once - per invocation so the two files share a suffix. + `netclaw.json.bak.<unix-millis>` and `secrets.json` → + `secrets.json.bak.<unix-millis>` atomically. Generate the + millisecond timestamp once per invocation so the two files share a + suffix. If a file already exists at the chosen suffix, append a + dash-counter (`-1`, `-2`, …) until a free name is found. - [ ] 3.4 After backup, proceed into the three-step wizard as a fresh first-run (`WizardContext.ExistingConfig = null`). - [ ] 3.5 On successful post-flight, list the .bak file paths in the @@ -44,6 +46,9 @@ went. - [ ] 3.6 `--force` with no existing config silently behaves as plain `netclaw init` (no backup screen). +- [ ] 3.7 `--force` in a non-TTY context (stdin or stdout not a + terminal) SHALL refuse with the documented stderr message and + exit non-zero before any file mutation. ## 4. Wizard step list trim From 116aefa344321469e9d96c3db1b8efcad4f72dd3 Mon Sep 17 00:00:00 2001 From: Aaron Stannard <aaron@petabridge.com> Date: Sun, 24 May 2026 17:47:34 +0000 Subject: [PATCH 03/31] docs(openspec): align init and config workflows --- docs/prd/PRD-004-cli-onboarding-and-config.md | 132 ++- docs/spec/SPEC-007-guided-onboarding.md | 91 +- docs/ui/TUI-001-command-wireframes.md | 83 +- docs/ui/TUI-002-netclaw-config-wireframes.md | 451 +++++----- docs/ui/TUI-003-simplified-init-wireframes.md | 245 +++--- .../changes/netclaw-config-command/design.md | 404 ++++----- .../netclaw-config-command/proposal.md | 279 +++---- .../specs/feature-selection-wizard/spec.md | 78 +- .../specs/netclaw-cli/spec.md | 75 +- .../specs/netclaw-config-command/spec.md | 778 ++++-------------- .../changes/netclaw-config-command/tasks.md | 421 +++------- .../section-editor-abstraction/design.md | 309 ++----- .../section-editor-abstraction/proposal.md | 168 ++-- .../specs/netclaw-onboarding/spec.md | 129 +-- .../specs/section-editor-abstraction/spec.md | 426 ++-------- .../section-editor-abstraction/tasks.md | 219 ++--- .../changes/simplify-netclaw-init/design.md | 275 ++----- .../changes/simplify-netclaw-init/proposal.md | 190 ++--- .../specs/netclaw-onboarding/spec.md | 306 +++---- .../changes/simplify-netclaw-init/tasks.md | 245 ++---- .../specs/feature-selection-wizard/spec.md | 39 +- openspec/specs/netclaw-cli/spec.md | 62 +- openspec/specs/netclaw-config-command/spec.md | 209 +++++ openspec/specs/netclaw-onboarding/spec.md | 173 +++- .../specs/section-editor-abstraction/spec.md | 114 +++ 25 files changed, 2324 insertions(+), 3577 deletions(-) create mode 100644 openspec/specs/netclaw-config-command/spec.md create mode 100644 openspec/specs/section-editor-abstraction/spec.md diff --git a/docs/prd/PRD-004-cli-onboarding-and-config.md b/docs/prd/PRD-004-cli-onboarding-and-config.md index 91260aaa8..41c7b8234 100644 --- a/docs/prd/PRD-004-cli-onboarding-and-config.md +++ b/docs/prd/PRD-004-cli-onboarding-and-config.md @@ -9,6 +9,8 @@ commands, Cocona + Termina frameworks) - Revised: 2026-02-23 (daemon + thin client split, daemon management commands, offline vs daemon-required command categorization) +- Revised: 2026-05-24 (bootstrap-only `init`, domain-oriented `config`, + init-owned identity re-entry, explicit reset flow) - Depends on: `PRD-001`, `PRD-002` ## Goal @@ -44,42 +46,70 @@ Netclaw ships as two binaries (see PRD-001 for full architecture): - Configuration files contain API keys/secrets — config read/write commands operate on local files directly, never query config over the wire -## Two-Phase Onboarding +## Bootstrap and Ongoing Configuration -### Phase 1: CLI Wizard (`netclaw init`) +### Bootstrap: `netclaw init` -Technical setup, no LLM required. `netclaw init` runs as a **lightweight mode** -— no Akka actor system, no persistence, no SignalR. Only config services are -booted. Provider testing uses direct DI service calls (`ChatClientFactory`), -not REST endpoints. +Technical setup, no daemon required. `netclaw init` runs as a lightweight +offline mode: no Akka actor system, no SignalR, no runtime session host. +Provider testing uses direct DI service calls and local validation. -The wizard is **reentrant** — re-running `netclaw init` detects existing config -and shows a section dashboard with status per section. Each section is -independently enterable for modification. First-run guides linearly through -all steps. +`netclaw init` is bootstrap-first and intentionally short. -Steps: +Fresh-install flow: 1. LLM provider configuration (endpoint URL, API key or OAuth device flow, - model selection, connectivity test via direct HTTP to provider) -2. Slack app setup (bot token, app token for Socket Mode) -3. ACL bootstrap (owner identity, initial channel rules) -4. MCP server configuration (optional — Memorizer recommended) -5. Exposure mode selection (local-only default) -6. Health check (verify Slack connection, LLM reachability, MCP connectivity) + model selection, connectivity test) +2. Identity setup (workspaces directory, user name, timezone) with init-owned + regeneration of `SOUL.md` and `TOOLING.md` +3. Security posture (`Personal`, `Team`, `Public`) +4. Enabled Features for `Team` and `Public` only +5. Final validation / health check / next steps -### Phase 2: Conversational Personality Bootstrap (first `netclaw chat`) +Existing-install flow: -Agent-driven setup, requires running LLM: +1. `Redo identity setup` +2. `Open configuration editor` +3. `Start over from scratch` +4. `Cancel` -1. "Hi, I'm Netclaw. Let me learn about you and your setup." -2. Ask about projects to register (repo paths on disk) -3. Discover environment capabilities (scan for installed tools) -4. Write PERSONALITY.md, USER.md, environment inventory -5. Confirm readiness +`Start over from scratch` is owned by the existing-install init menu, not a +hidden flag. It opens a scope selector: -Phase 2 is triggered automatically on first `netclaw chat` if personality files -don't exist. It can also be re-triggered via CLI (`netclaw personality reset`). +1. `Reset setup only` +2. `Full reset` +3. `Cancel` + +Both destructive paths require double confirmation. + +`Reset setup only` archives and recreates setup-owned state while preserving +working data such as the SQLite database, logs, projects, schedules, +environment, and skills. `Full reset` wipes the entire Netclaw home except the +installed binary payload. + +### Ongoing Settings: `netclaw config` + +`netclaw config` is the main post-install settings surface. It is a +domain-oriented Termina TUI, not a flat dump of raw config sections. + +Top-level domains: + +1. `Inference Providers` +2. `Models` +3. `Channels` +4. `Inbound Webhooks` +5. `Skill Sources` +6. `Search` +7. `Browser Automation` +8. `Telemetry & Alerting` +9. `Security & Access` + +Command ownership stays explicit: + +1. `netclaw init` owns bootstrap and identity re-entry +2. `netclaw config` owns normal post-install tuning +3. `netclaw provider` and `netclaw model` remain their canonical standalone + entrypoints and may be routed to from `netclaw config` ## Command Surface (MVP) @@ -101,12 +131,16 @@ don't exist. It can also be re-triggered via CLI (`netclaw personality reset`). ### TUI-Interactive Commands (Termina, offline) -- `netclaw init` — guided first-time setup wizard (7-step TUI wizard). Reads - and writes local config files directly. No daemon required. +- `netclaw init` — guided bootstrap wizard plus rare existing-install + identity/reset re-entry. Reads and writes local config files directly. No + daemon required. +- `netclaw config` — domain-oriented post-install settings dashboard. Reads and + writes local config files directly. No daemon required. +- `netclaw provider` — bare invocation launches interactive provider manager. +- `netclaw model` — bare invocation launches interactive model manager. ### Onboarding and Configuration (Plain CLI, offline) -- `netclaw config show|validate` — display/validate current configuration - `netclaw personality reset` — re-trigger conversational personality setup - `netclaw project list|add|remove` — project registry management (local files) - `netclaw environment scan|show` — capability self-discovery (scans local system) @@ -155,11 +189,22 @@ Onboarding captures all Phase 1 setup items in a stepwise flow. `netclaw init` SHALL support an interactive guided onboarding flow that: 1. Captures LLM provider configuration (OpenRouter default, OAuth or API key) -2. Configures Slack Socket Mode credentials (bot token + app token) -3. Scaffolds ACL in default-deny mode with owner identity -4. Optionally configures MCP servers (Memorizer recommended) -5. Selects exposure mode (local default) -6. Runs final validation and prints next-step run commands +2. Captures init-owned identity settings and regenerates `SOUL.md` / + `TOOLING.md` +3. Selects security posture (`Personal`, `Team`, `Public`) +4. Continues into Enabled Features when posture is `Team` or `Public` +5. Runs final validation and prints next-step run commands + +### CLI-001B Post-Install Configuration + +`netclaw config` SHALL be the primary post-install settings surface. It SHALL: + +1. Launch a domain-oriented dashboard +2. Route providers/models to their dedicated interactive managers +3. Group `Security Posture`, `Enabled Features`, `Audience Profiles`, and + `Exposure Mode` under `Security & Access` +4. Refuse with a plain non-zero message directing the operator to + `netclaw init` when no install exists ### CLI-002 Validation @@ -192,10 +237,12 @@ and active tool grants for the session. Commands default to read-only behavior unless explicit write/apply flags are provided. -### CLI-007 Onboarding Resume +### CLI-007 Existing-Install Re-entry -The onboarding flow SHALL be resumable and indicate which setup steps are -completed, pending, or invalid. +When `netclaw init` runs on an existing install, it SHALL present an explicit +action menu rather than silently re-entering the full bootstrap flow. Identity +re-entry remains init-owned; all normal configuration edits route to +`netclaw config`. ### CLI-008 Project Registration @@ -218,9 +265,11 @@ Results are persisted to the environment inventory file. ### CLI-010 TUI Commands -`netclaw init` and `netclaw chat` SHALL use Termina 0.5.1 for interactive TUI -rendering. All other commands SHALL use plain console output. TUI commands SHALL -launch Termina as a hosted service within the mode-selected host builder. +`netclaw init`, `netclaw config`, and `netclaw chat` SHALL use Termina 0.5.1 +for interactive TUI rendering. Bare `netclaw provider` and `netclaw model` +SHALL also use Termina. All other commands SHALL use plain console output. TUI +commands SHALL launch Termina as a hosted service within the mode-selected host +builder. ### CLI-011 Chat Thin Client @@ -267,7 +316,8 @@ endpoints. No TUI rendering. This is the primary production entry point. 2. Every high-risk command has confirmation or explicit `--yes` semantics. 3. Error output includes remediation guidance. 4. Fresh install reaches a runnable baseline in one guided flow. -5. Personality bootstrap triggers automatically on first conversation. +5. Existing installs can re-enter identity setup or open `netclaw config` + without replaying full bootstrap. 6. Environment scan discovers and persists capability inventory. 7. Project registration persists project registry to disk. diff --git a/docs/spec/SPEC-007-guided-onboarding.md b/docs/spec/SPEC-007-guided-onboarding.md index 0b6ec4788..de987770b 100644 --- a/docs/spec/SPEC-007-guided-onboarding.md +++ b/docs/spec/SPEC-007-guided-onboarding.md @@ -4,66 +4,81 @@ Source PRDs: `PRD-004`, `PRD-002`, `PRD-005` ## Purpose -Define the guided onboarding flow for first-time Netclaw setup. +Define the bootstrap-first `netclaw init` experience and its limited +existing-install re-entry paths. ## Entry Points - `netclaw init` (interactive default) -- `netclaw init --resume` - `netclaw init --non-interactive ...` for automation -## Onboarding Steps +## Fresh-Install Flow -### Step 1: Environment Check +### Step 1: Provider Setup -- verify required runtime version -- verify writable config path -- detect existing partial setup +- select provider type +- collect credentials or OAuth device flow inputs +- assign the initial model +- validate provider authentication and connectivity -### Step 2: Slack Setup +### Step 2: Identity -- collect and validate `SLACK_BOT_TOKEN` -- collect and validate `SLACK_APP_TOKEN` -- test Socket Mode connectivity +- collect workspaces directory +- collect user name +- collect timezone +- regenerate `SOUL.md` and `TOOLING.md` -### Step 3: Provider Setup +### Step 3: Security Posture -- default selection: OpenRouter -- collect provider credentials and default model -- validate provider authentication with dry-run request +- choose `Personal`, `Team`, or `Public` +- keep posture distinct from both Enabled Features and Audience Profiles -### Step 4: ACL Bootstrap +### Step 4: Enabled Features -- create default-deny ACL template -- capture owner identifiers and allowed channels -- set mention/ambient behavior per channel +- shown automatically for `Team` and `Public` +- skipped for `Personal` +- controls deployment-wide runtime enablement only -### Step 5: Security Profile +### Step 5: Final Validation -- choose exposure mode (`local`, `reverse-proxy`, `tailscale-serve`, - `tailscale-funnel`, `cloudflare-tunnel`) -- for `reverse-proxy`: collect `Daemon.Host` (must be non-loopback) and - `Daemon.TrustedProxies` (≥1 IP or CIDR entry required to advance — matches - the daemon's startup validator so the wizard cannot emit a non-startable - config), then show an informational notice with the resulting serving URL - (`http://{Host}:{Port}`) before continuing -- enforce policy prerequisites for selected mode +- run config and health validation +- show summary with remediation guidance on failure +- output next-step commands (`netclaw chat`, `netclaw config`) -### Step 6: Final Validation +## Existing-Install Flow -- run config and ACL validation -- show summary with red/yellow/green status -- output next run commands +When an install already exists, `netclaw init` SHALL NOT replay the full +bootstrap flow by default. Instead it presents: -## Resume Behavior +1. `Redo identity setup` +2. `Open configuration editor` +3. `Start over from scratch` +4. `Cancel` -- incomplete steps are persisted with status -- resumed onboarding starts at first incomplete step -- validated completed steps can be skipped with explicit confirmation +### Identity Re-entry + +- remains init-owned +- reuses the identity form with existing values prefilled +- continues into the bot-assisted identity conversation + +### Start Over From Scratch + +- opens a second dialog with: + - `Reset setup only` + - `Full reset` + - `Cancel` +- both destructive options require double confirmation + +`Reset setup only` preserves working data such as the SQLite database, logs, +projects, schedules, environment, and skills. + +`Full reset` wipes the entire Netclaw home except the binary payload. ## Safety Requirements - secrets are never echoed in plain text -- risky internet-reachable exposure modes require explicit confirmation text -- audience/posture choice and exposure mode remain separate decisions +- structurally invalid config blocks save without override +- runtime/probe failures may offer explicit `Save anyway` +- posture, Enabled Features, and Audience Profiles remain separate decisions - onboarding must fail closed if validation fails +- `netclaw init --force` is not part of this flow diff --git a/docs/ui/TUI-001-command-wireframes.md b/docs/ui/TUI-001-command-wireframes.md index 7f52668da..d921c17d8 100644 --- a/docs/ui/TUI-001-command-wireframes.md +++ b/docs/ui/TUI-001-command-wireframes.md @@ -15,6 +15,7 @@ single-shot CLI commands suitable for scripting. | Command | Interface | Framework | |----------------------|--------------|-----------| | `netclaw init` | TUI | Termina (lightweight mode — no Akka) | +| `netclaw config` | TUI | Termina (offline settings dashboard) | | `netclaw chat` | TUI | Termina (daemon mode — full stack) | | `netclaw provider` | Dual-mode | Termina (bare) / Plain CLI (with subcommand) | | `netclaw model` | Dual-mode | Termina (bare) / Plain CLI (with args) | @@ -33,85 +34,15 @@ All wireframes reference actual Termina 0.5.1 components: --- -## `netclaw init` — Onboarding Wizard (TUI) +## `netclaw init` and `netclaw config` -Interactive 6-step setup wizard. Termina hosts the full wizard as a single -application with step navigation. +The dedicated wireframes for the bootstrap-only init flow and the post-install +config dashboard live in: -### Wireframe - -``` -╭─ Netclaw Setup ──────────────────────────────────────────────╮ -│ │ -│ Step 2 of 6: Slack Configuration [■■□□□□□] 33% │ -│ │ -│ ╭─ Slack Bot Token ───────────────────────────────────────╮ │ -│ │ xoxb-************************************ │ │ -│ ╰─────────────────────────────────────────────────────────╯ │ -│ │ -│ ╭─ Slack App Token ───────────────────────────────────────╮ │ -│ │ xapp-************************************ │ │ -│ ╰─────────────────────────────────────────────────────────╯ │ -│ │ -│ ℹ Socket Mode requires both tokens. See: │ -│ https://api.slack.com/apis/socket-mode │ -│ │ -│ [Enter] Next [Esc] Back [Ctrl+Q] Quit │ -╰──────────────────────────────────────────────────────────────╯ -``` - -### Components Per Step - -| Step | Title | Components | -|------|------------------------|-------------------------------------------------------| -| 1 | LLM Provider | SelectionListNode (OpenRouter/Anthropic/OpenAI/Ollama) + auth branch (API key or OAuth device flow) | -| 2 | Slack Configuration | TextInputNode (bot token) + TextInputNode (app token) | -| 3 | ACL Bootstrap | TextInputNode (owner identity) + SelectionListNode (initial channels) | -| 4 | MCP Servers | SelectionListNode (Memorizer recommended / custom / skip) | -| 5 | Exposure Mode | SelectionListNode (local-only default / tailscale / cloudflare) | -| 6 | Health Check | TextNode (validation results with SpinnerNodes → checkmarks) | - -### Layout Structure - -``` -PanelNode (outer: "Netclaw Setup") -├── TextNode (step indicator + progress bar) -├── [step-specific components] -│ ├── TextInputNode (for text/secret input, masked for tokens) -│ ├── SelectionListNode (for choice input) -│ └── SpinnerNode (for live validation) -├── TextNode (help text / contextual guidance) -└── TextNode (key bindings: Enter/Esc/Ctrl+Q) -``` - -### Step Detail: Health Check (Step 6) - -``` -╭─ Netclaw Setup ──────────────────────────────────────────────╮ -│ │ -│ Step 6 of 6: Health Check [■■■■■■■] 100% │ -│ │ -│ Verifying configuration... │ -│ │ -│ ✓ LLM provider reachable (OpenRouter) │ -│ ✓ Slack bot token valid │ -│ ✓ Slack app token valid │ -│ ✓ MCP: memorizer connected (12 tools) │ -│ ● Exposure: local-only (loopback-only daemon access) │ -│ │ -│ All checks passed. Run `netclaw run` to start. │ -│ │ -│ [Enter] Finish [Esc] Back [Ctrl+Q] Quit │ -╰──────────────────────────────────────────────────────────────╯ -``` - -### Behaviors +- `TUI-003-simplified-init-wireframes.md` +- `TUI-002-netclaw-config-wireframes.md` -- Progress bar uses block characters (■□) rendered via TextNode -- Secret inputs (API keys, tokens) use masked TextInputNode -- Step 6 (Health Check) runs all probes in sequence with SpinnerNode → result -- [Esc] navigates back to previous step; [Ctrl+Q] exits with confirmation -- Config file written to `~/.netclaw/config/netclaw.json` on completion +This document intentionally does not duplicate those detailed flows. --- diff --git a/docs/ui/TUI-002-netclaw-config-wireframes.md b/docs/ui/TUI-002-netclaw-config-wireframes.md index ed3e4854a..52d99a195 100644 --- a/docs/ui/TUI-002-netclaw-config-wireframes.md +++ b/docs/ui/TUI-002-netclaw-config-wireframes.md @@ -10,29 +10,14 @@ alongside `netclaw config`). ## Overview -`netclaw config` is a menu-driven Termina TUI command for live configuration -editing. Operators reach every editable section without leaving the terminal, -without re-entering existing secrets, and without hand-editing -`netclaw.json`. Each section editor is reentrant by construction (pre-fills -non-secret fields from on-disk state) and doctor-blessed on save (relevant -checks run against the candidate config before write). - -Twelve editors ship day one: - -| Editor | SectionId | Category | Multi-value | -|-------------------------|------------------------------|-----------------|-------------| -| Search Provider | `Search` | — | no | -| Slack Channels | `Slack` | Chat Channels | partial | -| Discord Channels | `Discord` | Chat Channels | partial | -| Mattermost Channels | `Mattermost` | Chat Channels | partial | -| Exposure Mode | `Daemon.ExposureMode` | — | partial | -| Security Posture | `Security.Posture` | — | no | -| Audience Profiles | `Tools.AudienceProfiles` | — | partial | -| Outbound Webhooks | `Notifications.Webhooks` | — | yes | -| Inbound Webhooks | `Webhooks` | — | no | -| External Skill Dirs | `ExternalSkills` | — | yes | -| Skill Feeds | `SkillFeeds` | — | yes | -| Browser Automation | `BrowserAutomation` | — | no | +`netclaw config` is a menu-driven Termina TUI command for post-install +configuration. The root is domain-oriented and navigation-first rather than a +flat list of every editable leaf. Operators reach the high-churn settings +surfaces without leaving the terminal, without re-entering existing secrets, +and without hand-editing `netclaw.json`. + +Leaf editors remain reentrant by construction and validate before save, but +the root dashboard groups them by operator intent. ## Termina Component Vocabulary @@ -98,24 +83,31 @@ Sub-pages use a breadcrumb form: ``` netclaw config - └── Config.0 Dashboard ◀─ all editors return here on Save/Cancel - ├── Config.1 Search Provider - ├── Config.2 Slack Channels - ├── Config.3 Discord Channels - ├── Config.4 Mattermost Channels - ├── Config.5 Exposure Mode - ├── Config.6 Security Posture - ├── Config.7 Audience Profiles ← addresses #1150 - ├── Config.8 Outbound Webhooks - ├── Config.9 Inbound Webhooks - ├── Config.10 External Skill Directories - ├── Config.11 Skill Feeds - ├── Config.12 Browser Automation - ├── Config.D Run full doctor + └── Config.0 Domain dashboard + ├── Config.1 Inference Providers ──→ routes to `netclaw provider` + ├── Config.2 Models ──→ routes to `netclaw model` + ├── Config.3 Channels + │ ├── Slack + │ ├── Discord + │ └── Mattermost + ├── Config.4 Inbound Webhooks + ├── Config.5 Skill Sources + │ ├── External Skill Directories + │ └── Skill Feeds + ├── Config.6 Search + ├── Config.7 Browser Automation + ├── Config.8 Telemetry & Alerting + │ ├── Telemetry + │ └── Outbound Webhooks + ├── Config.9 Security & Access + │ ├── Security Posture + │ ├── Enabled Features + │ ├── Audience Profiles ← addresses #1150 + │ └── Exposure Mode └── Quit netclaw config (when no netclaw.json exists) - └── Config.E0 Refuse with `netclaw init` pointer ─── exit non-zero + └── prints refusal to stderr and exits non-zero ``` --- @@ -291,83 +283,107 @@ Shown when a list editor opens with zero items. --- -## Config.0 — Dashboard +## Config.0 — Domain dashboard ``` ╭─ Netclaw Configuration ─────────────────────────────────────╮ │ │ -│ ▸ Search Provider ✓ Brave │ -│ Chat Channels │ -│ Slack ✓ 3 channels, 2 users │ -│ Discord – not configured │ -│ Mattermost – not configured │ -│ Exposure Mode ✓ Local │ -│ Security Posture ✓ Personal │ -│ Audience Profiles ✓ default │ -│ Outbound Webhooks ⚠ 2 configured, 1 unreachable │ -│ Inbound Webhooks – disabled │ -│ External Skill Dirs ✓ 2 directories │ -│ Skill Feeds – none │ -│ Browser Automation – disabled │ -│ │ -│ ────────── │ -│ Run full doctor │ +│ ▸ Inference Providers 2 configured │ +│ Models 3 roles assigned │ +│ Channels 2 enabled │ +│ Inbound Webhooks – disabled │ +│ Skill Sources 2 dirs · 1 feed │ +│ Search ✓ Brave │ +│ Browser Automation – disabled │ +│ Telemetry & Alerting OTLP off · 1 webhook │ +│ Security & Access Team · 4/6 enabled │ +│ │ │ Quit │ │ │ │ ↑/↓ navigate · Enter open · q quit · ✓ ok · ⚠ warn · ✗ err │ ╰─────────────────────────────────────────────────────────────╯ ``` -**Status computation:** on dashboard entry, each editor's -`GetStatus(currentConfig)` runs (with `RelevantDoctorChecks` against -on-disk state). Results cached for the dashboard session; re-computed -when returning from a saved editor. +**Status computation:** each domain row shows a concise aggregate summary of +the underlying leaf editors or routed command state. -**Sub-grouping indentation:** chat-channel rows render at +2 indent under -the "Chat Channels" label. The label itself is unselectable. - -**No "Save dashboard" action:** the dashboard is purely a navigation -layer. All saves are at section granularity. +**No root save action:** the dashboard is purely a navigation layer. All saves +are at leaf-editor granularity. ### Layout structure ``` PanelNode (outer: "Netclaw Configuration") -├── SelectionListNode (single-select; entries from SectionEditorRegistry -│ grouped by Category, plus "Run full doctor" and -│ "Quit" tail items) +├── SelectionListNode (single-select; domain entries plus Quit) └── TextNode (footer hint line) ``` --- -## Config.E0 — No-config refusal +## Config.1 — Inference Providers + +Selecting `Inference Providers` hands off to the existing `netclaw provider` +TUI. In this branch, that handoff is one-way: provider manager behavior stays +unchanged and does not grow a config-dashboard back-stack. + +## Config.2 — Models + +Selecting `Models` hands off to the existing `netclaw model` TUI. Model +manager behavior stays unchanged in this branch. + +--- + +## No-config refusal + +When `~/.netclaw/config/netclaw.json` is missing, `netclaw config` does not +start Termina at all. It prints: + +`No configuration found. Run \`netclaw init\` first.` + +to stderr and exits non-zero. + +--- + +## Config.3 — Channels -Rendered when `~/.netclaw/config/netclaw.json` is missing at launch. +### 3.1 Channels sub-page ``` -╭─ No Netclaw configuration found ────────────────────────────╮ +╭─ Channels ──────────────────────────────────────────────────╮ │ │ -│ No configuration file at: │ -│ ~/.netclaw/config/netclaw.json │ +│ ▸ Slack 3 channels, 2 users │ +│ Discord not configured │ +│ Mattermost not configured │ │ │ -│ Run `netclaw init` to create one. │ +│ [ Open ] [ Back ] │ │ │ -│ [ OK ] │ -│ │ -│ Enter exit │ +│ ↑/↓ navigate · Enter open · Esc back │ ╰─────────────────────────────────────────────────────────────╯ ``` -Non-interactive (when stdout is not a TTY, e.g. CI): prints -`No configuration found. Run \`netclaw init\` first.` to stderr and exits -non-zero. The interactive variant exits zero after acknowledgement. +--- + +## Config.5 — Skill Sources + +### 5.1 Skill Sources sub-page + +``` +╭─ Skill Sources ─────────────────────────────────────────────╮ +│ │ +│ ▸ External Skill Directories 2 configured │ +│ Skill Feeds 1 configured │ +│ │ +│ [ Open ] [ Back ] │ +│ │ +│ ↑/↓ navigate · Enter open · Esc back │ +╰─────────────────────────────────────────────────────────────╯ +``` --- -## Config.1 — Search Provider +## Config.6 — Search -### 1.1 Main editor +### 6.1 Main editor ``` ╭─ Search Provider ───────────────────────────────────────────╮ @@ -401,7 +417,7 @@ SearXng URL disabled when backend ≠ SearXng; DuckDuckGo has no fields. field is empty regardless; hint indicates "configured" or "not set" based on `ConfigFileHelper.SecretPresent(...)`. -### 1.2 Remove credential confirm (T5) +### 6.2 Remove credential confirm (T5) ``` ╭─ Remove Brave API key? ─────────────────────────────────────╮ @@ -421,9 +437,9 @@ based on `ConfigFileHelper.SecretPresent(...)`. --- -## Config.2 — Slack Channels +## Config.3.2 — Slack Channels -### 2.1 Main editor +### 3.2.1 Main editor ``` ╭─ Slack Channels ────────────────────────────────────────────╮ @@ -453,10 +469,10 @@ based on `ConfigFileHelper.SecretPresent(...)`. ``` Sub-pages: -- "Allowed channels" → 2.2 list editor. -- "Allowed users" → 2.3 list editor. +- "Allowed channels" → 3.2.2 list editor. +- "Allowed users" → 3.2.3 list editor. -### 2.2 Allowed channels list (T2) +### 3.2.2 Allowed channels list (T2) ``` ╭─ Slack Channels › Allowed channel IDs ──────────────────────╮ @@ -473,14 +489,14 @@ Sub-pages: ╰─────────────────────────────────────────────────────────────╯ ``` -`Save` here is "apply to in-memory state and return to 2.1." Disk write -happens when 2.1 itself saves. +`Save` here is "apply to in-memory state and return to 3.2.1." Disk write +happens when 3.2.1 itself saves. -### 2.3 Allowed users list +### 3.2.3 Allowed users list Same shape as 2.2 with user IDs. Uses `IdentifierItemEditor`. -### 2.4 Test connection (inline banner) +### 3.2.4 Test connection (inline banner) Runs the existing Slack probe logic from `SlackStepViewModel`; result rendered in an inline banner above the action row: @@ -504,7 +520,7 @@ Failure shape: Test results never modify config; they're advisory before Save. -### 2.5 Remove credentials confirm (T5) +### 3.2.5 Remove credentials confirm (T5) ``` ╭─ Remove Slack credentials? ─────────────────────────────────╮ @@ -525,20 +541,20 @@ Test results never modify config; they're advisory before Save. --- -## Config.3 — Discord Channels +## Config.3.3 — Discord Channels Structurally identical to 2.x except: - Single token field (bot token only; no app token). - Otherwise: allowed channels list, allowed users list, DMs toggle, audience profile, test connection, remove credentials. -(Layouts identical to 2.1–2.5 with the App token row removed.) +(Layouts identical to 3.2.1–3.2.5 with the App token row removed.) **Doctor checks:** `ConfigSchemaDoctorCheck`, `DiscordAuthDoctorCheck`. --- -## Config.4 — Mattermost Channels +## Config.3.4 — Mattermost Channels Structurally identical to 2.x plus: - `Server URL` text field at the top. @@ -575,9 +591,9 @@ Structurally identical to 2.x plus: --- -## Config.5 — Exposure Mode +## Config.9.5 — Exposure Mode -### 5.1 Mode selection +### 9.5.1 Mode selection ``` ╭─ Exposure Mode ─────────────────────────────────────────────╮ @@ -590,11 +606,14 @@ Structurally identical to 2.x plus: │ Reverse Proxy │ │ Behind nginx/Caddy/etc. Trusted proxies required. │ │ │ -│ Tailscale │ -│ Auth via Tailscale identity. Mesh network required. │ +│ Tailscale Serve │ +│ Tailscale-served local access. │ +│ │ +│ Tailscale Funnel │ +│ Public Tailscale funnel exposure. │ │ │ │ Cloudflare Tunnel │ -│ Cloudflare access-protected. Tunnel credentials needed. │ +│ Cloudflare-managed tunnel access. │ │ │ │ ────── │ │ Daemon host: 127.0.0.1 │ @@ -606,20 +625,14 @@ Structurally identical to 2.x plus: ╰─────────────────────────────────────────────────────────────╯ ``` -**Conditionality:** "Configure mode →" button is enabled only when -selected mode requires sub-config (Reverse Proxy, Tailscale, Cloudflare). -Local has no sub-config. +**Conditionality:** `Configure mode →` is enabled only when the selected mode +requires sub-config. Local has no sub-config. -### 5.2 Reverse Proxy sub-form (T1-shaped) +### 9.5.2 Reverse Proxy sub-form (T1-shaped) ``` ╭─ Exposure Mode › Reverse Proxy ─────────────────────────────╮ │ │ -│ External base URL (must be HTTPS): │ -│ ╭────────────────────────────────────────────────────────╮ │ -│ │ https://netclaw.example.com │ │ -│ ╰────────────────────────────────────────────────────────╯ │ -│ │ │ Trusted proxies (CIDR list): 2 configured → │ │ │ │ [ Apply ] [ Cancel ] │ @@ -628,48 +641,45 @@ Local has no sub-config. ╰─────────────────────────────────────────────────────────────╯ ``` -Trusted proxies row → 5.5 list editor. +Trusted proxies row → 9.5.6 list editor. -### 5.3 Tailscale sub-form +### 9.5.3 Tailscale Serve sub-form ``` -╭─ Exposure Mode › Tailscale ─────────────────────────────────╮ +╭─ Exposure Mode › Tailscale Serve ───────────────────────────╮ │ │ -│ Tailscale auth key: │ -│ ╭────────────────────────────────────────────────────────╮ │ -│ │ │ │ -│ ╰────────────────────────────────────────────────────────╯ │ -│ (configured — leave blank to keep) │ +│ No Netclaw-managed credentials are stored here. │ │ │ -│ Hostname on tailnet: netclaw │ +│ Tunnel process: ▸ Managed on this host │ +│ Managed externally / sidecar │ │ │ -│ [ Apply ] [ Cancel ] [ Remove auth key ] │ +│ [ Apply ] [ Cancel ] │ │ │ ╰─────────────────────────────────────────────────────────────╯ ``` -### 5.4 Cloudflare Tunnel sub-form +### 9.5.4 Tailscale Funnel sub-form + +Same shape as Tailscale Serve, but with stronger public-exposure warning copy. + +### 9.5.5 Cloudflare Tunnel sub-form ``` ╭─ Exposure Mode › Cloudflare Tunnel ─────────────────────────╮ │ │ -│ Tunnel token: │ -│ ╭────────────────────────────────────────────────────────╮ │ -│ │ │ │ -│ ╰────────────────────────────────────────────────────────╯ │ -│ (configured — leave blank to keep) │ +│ No Netclaw-managed tunnel token is stored here. │ +│ Configure `cloudflared` outside Netclaw, then return for │ +│ validation. │ │ │ -│ Access policy email domain (optional): │ -│ ╭────────────────────────────────────────────────────────╮ │ -│ │ │ │ -│ ╰────────────────────────────────────────────────────────╯ │ +│ Tunnel process: ▸ Managed on this host │ +│ Managed externally / sidecar │ │ │ -│ [ Apply ] [ Cancel ] [ Remove tunnel token ] │ +│ [ Apply ] [ Cancel ] │ │ │ ╰─────────────────────────────────────────────────────────────╯ ``` -### 5.5 Trusted proxies list (T2 with `IdentifierItemEditor`) +### 9.5.6 Trusted proxies list (T2 with `IdentifierItemEditor`) ``` ╭─ Exposure Mode › Trusted Proxies ───────────────────────────╮ @@ -689,9 +699,27 @@ Trusted proxies row → 5.5 list editor. --- -## Config.6 — Security Posture +## Config.9 — Security & Access -### 6.1 Posture selection (T1-shaped) +### 9.1 Security & Access sub-page + +``` +╭─ Security & Access ─────────────────────────────────────────╮ +│ │ +│ ▸ Security Posture Team │ +│ Enabled Features 4/6 enabled │ +│ Audience Profiles Team customized │ +│ Exposure Mode Cloudflare Tunnel │ +│ │ +│ [ Open ] [ Back ] │ +│ │ +│ ↑/↓ navigate · Enter open · Esc back │ +╰─────────────────────────────────────────────────────────────╯ +``` + +## Config.9.1 — Security Posture + +### 9.1.1 Posture selection (T1-shaped) ``` ╭─ Security Posture ──────────────────────────────────────────╮ @@ -704,8 +732,9 @@ Trusted proxies row → 5.5 list editor. │ Team │ │ Small team via Slack/Discord. Audience-restricted tools. │ │ │ -│ Enterprise │ -│ Production deployment. Strict audience profiles, audit. │ +│ Public │ +│ Open to untrusted users. Strict defaults and access │ +│ controls. │ │ │ │ [ Save ] [ Cancel ] │ │ │ @@ -713,7 +742,7 @@ Trusted proxies row → 5.5 list editor. ╰─────────────────────────────────────────────────────────────╯ ``` -### 6.2 Cascade warning (T5 variant — three options) +### 9.1.2 Cascade warning (T5 variant — three options) Shown only when changing posture AND `Tools.AudienceProfiles` has been customized away from the prior posture's defaults. @@ -736,45 +765,66 @@ customized away from the prior posture's defaults. --- -## Config.7 — Audience Profiles *(addresses #1150)* +## Config.9.3 — Enabled Features -### 7.1 Audience selection +``` +╭─ Enabled Features ──────────────────────────────────────────╮ +│ │ +│ Toggle deployment-wide runtime features. Audience │ +│ exposure is configured separately in Audience Profiles. │ +│ │ +│ [ X ] memory │ +│ [ X ] search │ +│ [ X ] skills │ +│ [ X ] scheduling │ +│ [ X ] sub-agents │ +│ [ X ] webhooks │ +│ │ +│ [ Save ] [ Cancel ] │ +│ │ +│ ↑/↓ navigate · Space toggle · Tab to buttons │ +╰─────────────────────────────────────────────────────────────╯ +``` + +--- + +## Config.9.4 — Audience Profiles *(addresses #1150)* + +### 9.4.1 Audience selection ``` ╭─ Audience Profiles ─────────────────────────────────────────╮ │ │ -│ Configure tool access per audience tier. │ +│ Configure high-level access per audience tier. │ │ │ │ ▸ Personal ✓ Default for posture: Personal │ │ Team ✓ Default for posture: Personal │ │ Public ✓ Default for posture: Personal │ │ │ -│ ────── │ -│ │ -│ Shell mode (global): HostAllowed │ -│ │ │ [ Cancel ] │ │ │ │ ↑/↓ navigate · Enter edit audience · Esc cancel │ ╰─────────────────────────────────────────────────────────────╯ ``` -### 7.2 Per-audience editor +### 9.4.2 Per-audience editor ``` ╭─ Audience Profiles › Team ──────────────────────────────────╮ │ │ -│ Tools enabled for the Team audience: │ +│ Tool access for the Team audience: │ │ │ -│ [ X ] memory │ -│ [ X ] search │ -│ [ X ] skills │ -│ [ ] scheduling │ -│ [ X ] sub-agents │ -│ [ ] webhooks │ +│ [ X ] Read files │ +│ [ X ] Edit files │ +│ [ X ] Web access │ +│ [ X ] Skills │ +│ [ X ] Scheduling │ +│ [ X ] Change working directory │ │ │ -│ Shell mode for Team: SandboxOnly │ -│ Approval policy: Required │ +│ File access: Session only → │ +│ Incoming attachments: Common work files │ +│ MCP permissions: Manage in `netclaw mcp │ +│ permissions` → │ │ │ │ [ Save ] [ Cancel ] [ Reset to posture default ] │ │ │ @@ -788,8 +838,8 @@ customized away from the prior posture's defaults. - `Space` MUST toggle the focused checkbox. - `Enter` on a checkbox row also toggles (alternative to Space). - `Tab` moves to the action row. -- `Reset to posture default` replaces all toggles + shell mode with the - posture-default mapping. +- `Reset to posture default` replaces the full underlying audience profile, + including hidden MCP and approval settings, with the posture-default mapping. The `config-audience.tape` smoke tape explicitly exercises `↓`, `Space`, `↑`, `Space` to lock in the keystroke contract. Regression in arrow @@ -799,9 +849,47 @@ nav OR toggle is caught. --- -## Config.8 — Outbound Webhooks +## Config.8 — Telemetry & Alerting -### 8.1 List page (T3) +### 8.1 Telemetry & Alerting sub-page + +``` +╭─ Telemetry & Alerting ──────────────────────────────────────╮ +│ │ +│ ▸ Telemetry Disabled │ +│ Outbound Webhooks 2 configured │ +│ │ +│ [ Open ] [ Back ] │ +│ │ +│ ↑/↓ navigate · Enter open · Esc back │ +╰─────────────────────────────────────────────────────────────╯ +``` + +### 8.2 Telemetry editor + +``` +╭─ Telemetry & Alerting › Telemetry ──────────────────────────╮ +│ │ +│ Telemetry enabled: [ X ] yes │ +│ │ +│ OTLP endpoint: │ +│ ╭────────────────────────────────────────────────────────╮ │ +│ │ http://127.0.0.1:4317 │ │ +│ ╰────────────────────────────────────────────────────────╯ │ +│ │ +│ gRPC OTLP only. Netclaw expects collector port 4317. │ +│ │ +│ [ Save ] [ Cancel ] │ +│ │ +│ Tab next · Enter activate · Esc cancel │ +╰─────────────────────────────────────────────────────────────╯ +``` + +--- + +## Config.8.3 — Outbound Webhooks + +### 8.3.1 List page (T3) ``` ╭─ Outbound Webhooks ─────────────────────────────────────────╮ @@ -832,7 +920,7 @@ Empty-state (T8): ╰─────────────────────────────────────────────────────────────╯ ``` -### 8.2 Add/edit form (T4) +### 8.3.2 Add/edit form (T4) ``` ╭─ Outbound Webhooks › Edit "critical-pager" ─────────────────╮ @@ -864,7 +952,7 @@ Empty-state (T8): ╰─────────────────────────────────────────────────────────────╯ ``` -### 8.3 Delete confirm (T5) +### 8.3.3 Delete confirm (T5) ``` ╭─ Remove webhook "critical-pager"? ──────────────────────────╮ @@ -882,7 +970,7 @@ Empty-state (T8): --- -## Config.9 — Inbound Webhooks +## Config.4 — Inbound Webhooks ``` ╭─ Inbound Webhooks ──────────────────────────────────────────╮ @@ -911,7 +999,7 @@ we do NOT silently default to dummy routes. --- -## Config.10 — External Skill Directories +## Config.5.2 — External Skill Directories ### 10.1 List page (T2 with `PathItemEditor`) @@ -961,7 +1049,7 @@ Single-keypress. `y` removes; anything else cancels. No modal. --- -## Config.11 — Skill Feeds +## Config.5.3 — Skill Feeds ### 11.1 List page (T3 with `SkillFeedItemEditor`) @@ -1026,7 +1114,7 @@ Single-keypress. `y` removes; anything else cancels. No modal. --- -## Config.12 — Browser Automation +## Config.7 — Browser Automation ### 12.1 Status & toggle (Playwright not installed) @@ -1092,43 +1180,6 @@ editor entry). **Doctor checks:** `ConfigSchemaDoctorCheck`, `BrowserAutomationDoctorCheck`. ---- - -## Config.D — Run full doctor - -``` -╭─ Doctor — full configuration check ─────────────────────────╮ -│ │ -│ ✓ ConfigSchema OK │ -│ ✓ Providers OK │ -│ ✓ Models OK │ -│ ⚠ Search Brave API key valid but rate- │ -│ limited per recent probes │ -│ ✓ Slack OK │ -│ – Discord Not configured │ -│ – Mattermost Not configured │ -│ ✓ Exposure OK (Local) │ -│ ✓ AudienceProfiles OK │ -│ ✗ Notifications.Webhooks critical-pager unreachable │ -│ ✓ ExternalSkills OK │ -│ – SkillFeeds None configured │ -│ – BrowserAutomation Disabled │ -│ │ -│ Summary: 8 pass · 1 warning · 1 error · 4 skipped │ -│ │ -│ Exit code on close: 1 (errors present) │ -│ │ -│ [ Back to dashboard ] │ -│ │ -│ Enter back · Esc back │ -╰─────────────────────────────────────────────────────────────╯ -``` - -Invokes the same `DoctorRunner` used by `netclaw doctor`. Results page -renders status per check. - ---- - ## Daemon-restart nudge at exit Printed to stderr after Termina teardown when (a) at least one section diff --git a/docs/ui/TUI-003-simplified-init-wireframes.md b/docs/ui/TUI-003-simplified-init-wireframes.md index 47acd44d7..77c881659 100644 --- a/docs/ui/TUI-003-simplified-init-wireframes.md +++ b/docs/ui/TUI-003-simplified-init-wireframes.md @@ -10,15 +10,14 @@ superseded by this document), `TUI-002-netclaw-config-wireframes.md` ## Overview -`netclaw init` is trimmed from 12 steps to three: LLM provider, -identity, security posture. The goal is time-to-first-chat. Everything -else (channels, search, webhooks, exposure mode, audience profiles, -skill feeds, external skill directories, browser automation, MCP -servers) moves to `netclaw config` (see TUI-002). +`netclaw init` is trimmed to bootstrap plus a small existing-install menu. +The goal is time-to-first-chat. Everything else (channels, search, +webhooks, exposure mode, audience profiles, skill feeds, external skill +directories, browser automation, MCP servers, and other ongoing tuning) +moves to `netclaw config` (see TUI-002). -Existing-config detection is now explicit: re-running over an existing -install refuses with helpful pointers, or accepts `--force` to back -up and reset. +Existing-config detection is explicit: re-running over an existing install +opens a small action menu instead of replaying the full wizard. ## Termina Component Vocabulary @@ -46,18 +45,18 @@ Glyphs and keystrokes follow TUI-002 conventions. Init-specific: ``` netclaw init (fresh install — no existing config) ├── Init.1 Provider selection (+ existing auth sub-flow) - ├── Init.2 Identity (agent name, user name, timezone) + ├── Init.2 Identity (workspaces directory, user name, timezone) ├── Init.3 Security Posture - └── Init.4 Post-flight (health-check, summary) ─── exit + stderr nudge - -netclaw init (existing config detected, no --force) - └── Init.E1 Refuse + suggest `netclaw config` or `netclaw init --force` - -netclaw init --force (existing config detected) - └── Init.E2 Backup confirm ──→ Init.1 (proceeds as fresh) - -netclaw init --force (no existing config) - └── Init.1 (proceeds as fresh; no backup screen) + ├── Init.4 Enabled Features (Team/Public only) + └── Init.5 Post-flight (health-check, summary) ─── exit + stderr nudge + +netclaw init (existing config detected) + ├── Init.E1 Existing-install menu + ├── Init.2 Identity re-entry form (prefilled) + ├── Init.E2 Start-over scope chooser + ├── Init.E3 First destructive confirmation + ├── Init.E4 Second destructive confirmation + └── Init.1 / Init.2 / Init.3 / Init.4 / Init.5 as applicable ``` --- @@ -71,7 +70,7 @@ key or OAuth device flow → model selection). Behavior unchanged from prior versions. ``` -╭─ Netclaw Setup — Step 1 of 3: LLM Provider ─────────────────╮ +╭─ Netclaw Setup — Step 1: LLM Provider ──────────────────────╮ │ │ │ Choose your LLM provider: │ │ │ @@ -91,30 +90,24 @@ prior versions. - `Enter` → existing auth sub-flow (TUI-001 covers the sub-flow shapes). - `Esc` → quit setup (with discard confirm if anything was entered). -**Reentrancy:** in the rare case `netclaw init` runs over existing -config (only via `--force` reset; otherwise the command refuses -at Init.E1), the provider selector pre-fills the existing provider -type. API key field renders empty per the secret-handling contract +**Reentrancy:** when existing-install init routes into an init-owned +sub-flow, the provider selector pre-fills the existing provider type. +API key fields render empty per the secret-handling contract (`configured — leave blank to keep`). --- ## Init.2 — Identity -Trimmed `IdentityStepViewModel` (see Change C tasks 5.x). Drops the -prior webhook URL prompt, the workspaces-directory prompt, and the -communication-style prompt. Keeps agent name, user name, timezone. +Identity remains init-owned. The form reuses the familiar identity step, +prefilled from the existing install on re-entry, and hands off to the +bot-assisted identity conversation afterward. ``` -╭─ Netclaw Setup — Step 2 of 3: Identity ─────────────────────╮ +╭─ Netclaw Setup — Step 2: Identity ──────────────────────────╮ │ │ │ Your provider is configured. Now let's set up the agent. │ │ │ -│ Agent name: │ -│ ╭────────────────────────────────────────────────────────╮ │ -│ │ Netclaw │ │ -│ ╰────────────────────────────────────────────────────────╯ │ -│ │ │ Your name (what the agent calls you): │ │ ╭────────────────────────────────────────────────────────╮ │ │ │ │ │ @@ -125,6 +118,11 @@ communication-style prompt. Keeps agent name, user name, timezone. │ │ America/Los_Angeles │ │ │ ╰────────────────────────────────────────────────────────╯ │ │ │ +│ Workspaces directory: │ +│ ╭────────────────────────────────────────────────────────╮ │ +│ │ ~/.netclaw/workspaces │ │ +│ ╰────────────────────────────────────────────────────────╯ │ +│ │ │ [ Next ] [ Back ] [ Cancel ] │ │ │ │ Tab next · Enter activate · Esc cancel │ @@ -137,15 +135,12 @@ communication-style prompt. Keeps agent name, user name, timezone. - `Back` → Init.1. - `Cancel` → discard confirm → exit. -**Validation:** Agent name required, no whitespace. User name required. -Timezone validates against `TimeZoneInfo.FindSystemTimeZoneById`. +**Validation:** User name required. Timezone validates against +`TimeZoneInfo.FindSystemTimeZoneById`. Workspaces directory must be a +valid local path. -**Dropped fields' defaults:** webhook URL is left unset (operators add -operational webhooks via `netclaw config → Outbound Webhooks`). -Workspaces directory defaults to `~/.netclaw/workspaces`. Communication -style defaults to neutral. These remain editable via file edit for now -(future Identity section editor in `netclaw config` is out of MVP -scope). +On completion, the flow can continue into the existing bot-assisted +identity conversation that regenerates `SOUL.md` and `TOOLING.md`. --- @@ -154,7 +149,7 @@ scope). Reuses existing `SecurityPostureStepViewModel`. ``` -╭─ Netclaw Setup — Step 3 of 3: Security Posture ─────────────╮ +╭─ Netclaw Setup — Step 3: Security Posture ──────────────────╮ │ │ │ How will Netclaw be used? │ │ │ @@ -164,8 +159,9 @@ Reuses existing `SecurityPostureStepViewModel`. │ Team │ │ Small team via Slack/Discord. Audience-restricted tools. │ │ │ -│ Enterprise │ -│ Production deployment. Strict audience profiles, audit. │ +│ Public │ +│ Open to untrusted users. Strict defaults and access │ +│ controls. │ │ │ │ [ Next ] [ Back ] [ Cancel ] │ │ │ @@ -176,43 +172,66 @@ Reuses existing `SecurityPostureStepViewModel`. **Transitions:** - `Next` (Enter on Next button OR Enter on a posture row) → applies - posture-default `Tools.AudienceProfiles` mapping in-memory → - proceeds to Init.4 (terminal write + health check). + posture-default `Tools.AudienceProfiles` mapping in-memory. +- `Personal` proceeds directly to Init.5. +- `Team` and `Public` proceed to Init.4 (Enabled Features). - `Back` → Init.2. -**Posture cascade applied non-interactively (no separate feature -selection step):** +**Shell mode remains global:** the posture step writes the global shell +default. It does not create per-audience shell settings. -| Posture | Audience.Personal | Audience.Team | Audience.Public | Shell mode | -|------------|-------------------|-----------------------------|----------------------------|---------------| -| Personal | all features on | n/a (Personal-only) | n/a | HostAllowed | -| Team | all features on | search+memory+skills on; webhooks off | webhooks off; memory off | SandboxOnly | -| Enterprise | search+memory on | search+memory on | nothing on | SandboxOnly | +--- + +## Init.4 — Enabled Features -Operators override per-audience post-install via `netclaw config → -Audience Profiles`. +Shown only for `Team` and `Public`. This is deployment-wide runtime +enablement, not per-audience access policy. + +``` +╭─ Netclaw Setup — Step 4: Enabled Features ──────────────────╮ +│ │ +│ Choose which runtime features are enabled for this │ +│ deployment. Audience exposure is configured later in │ +│ `netclaw config`. │ +│ │ +│ [ X ] memory │ +│ [ X ] search │ +│ [ X ] skills │ +│ [ X ] scheduling │ +│ [ X ] sub-agents │ +│ [ X ] webhooks │ +│ │ +│ [ Next ] [ Back ] [ Cancel ] │ +│ │ +│ ↑/↓ navigate · Space toggle · Tab to buttons │ +╰─────────────────────────────────────────────────────────────╯ +``` + +`Personal` skips this step. `Team` and `Public` use different defaults, +but the toggles always write deployment-wide `Enabled` flags. --- -## Init.4 — Post-flight +## Init.5 — Post-flight -After Init.3 applies posture, the wizard writes merged config + secrets -+ runs the existing health check + shows results. +After the final step, the wizard writes merged config + secrets, runs the +existing health check, and shows results. ``` ╭─ Netclaw Setup — Setup Complete ────────────────────────────╮ │ │ │ ✓ Provider configured: Anthropic (claude-sonnet-4-6) │ -│ ✓ Identity set: Netclaw (aaron, America/Los_Angeles) │ +│ ✓ Identity set: aaron, America/Los_Angeles │ │ ✓ Posture: Personal │ +│ ✓ Enabled Features: all defaults applied │ │ ✓ Configuration written to ~/.netclaw/config/netclaw.json │ │ ✓ Health check passed │ │ │ │ ────── │ │ │ │ Run `netclaw chat` to start talking to your agent. │ -│ Run `netclaw config` to set up channels, search, webhooks, │ -│ external skills, browser automation, and more. │ +│ Run `netclaw config` to set up providers, models, │ +│ channels, webhooks, search, security, and more. │ │ │ │ [ Done ] │ │ │ @@ -226,103 +245,51 @@ After Init.3 applies posture, the wizard writes merged config + secrets to stderr after exit so users see it even after the TUI clears. **Failure path:** if health check fails (doctor errors), the page shows -the errors and a `[ Back to Posture ]` action instead of `[ Done ]`. -Operator returns to Init.3 to fix. +the errors and a `[ Back ]` action instead of `[ Done ]`. The operator +returns to the previous applicable step to fix. -### Post-flight when `--force` was used +## Init.E1 — Existing-install menu -When `netclaw init --force` triggered a backup, the post-flight screen -appends a `.bak` file disclosure section so operators know where the -prior config went: +Rendered when `netclaw init` detects an existing install. ``` -│ ────── │ -│ Previous configuration backed up to: │ -│ ~/.netclaw/config/netclaw.json.bak.1716508800 │ -│ ~/.netclaw/config/secrets.json.bak.1716508800 │ +╭─ Existing Netclaw install detected ─────────────────────────╮ │ │ -│ Restore manually if needed. │ -``` - -The same paths are printed to stderr after Termina teardown. - ---- - -## Init.E1 — Existing config refusal - -Rendered when `netclaw init` is invoked, `~/.netclaw/config/netclaw.json` -exists, and `--force` was not passed. - -``` -╭─ Netclaw is already initialized ────────────────────────────╮ -│ │ -│ Found existing configuration: │ -│ ~/.netclaw/config/netclaw.json │ -│ │ -│ To edit your configuration interactively, run: │ -│ netclaw config │ +│ Choose what to do next. │ │ │ -│ To start over from scratch (existing config backed up): │ -│ netclaw init --force │ +│ ▸ Redo identity setup │ +│ Open configuration editor │ +│ Start over from scratch │ +│ Cancel │ │ │ -│ [ OK ] │ -│ │ -│ Enter exit │ +│ ↑/↓ navigate · Enter select · Esc cancel │ ╰─────────────────────────────────────────────────────────────╯ ``` -**Non-interactive variant** (when stdout is not a TTY, e.g. CI): -prints the same text to stderr and exits non-zero. The interactive -variant exits zero on acknowledgement. +## Init.E2 — Start-over scope chooser ---- - -## Init.E2 — Force-reset backup confirm - -Rendered when `netclaw init --force` runs and existing config is -detected. +Rendered after `Start over from scratch`. ``` -╭─ Reset Netclaw configuration? ──────────────────────────────╮ +╭─ Start over from scratch ───────────────────────────────────╮ │ │ -│ This will: │ -│ • Move netclaw.json → netclaw.json.bak.<timestamp> │ -│ • Move secrets.json → secrets.json.bak.<timestamp> │ -│ • Start setup from scratch │ +│ Choose reset scope. │ │ │ -│ Your old config is preserved as a .bak file; you can │ -│ restore it manually if needed. │ +│ ▸ Reset setup only │ +│ Archive config, secrets, pairing/bootstrap state, and │ +│ identity files. Preserve DB, logs, projects, schedules, │ +│ environment, and skills. │ │ │ -│ Type "reset" to confirm: │ -│ ╭────────────────────────────────────────────────────────╮ │ -│ │ │ │ -│ ╰────────────────────────────────────────────────────────╯ │ +│ Full reset │ +│ Wipe the full Netclaw home except the binary payload. │ │ │ -│ ▸ [ Cancel ] [ Reset and continue ] │ +│ Cancel │ │ │ +│ ↑/↓ navigate · Enter select · Esc cancel │ ╰─────────────────────────────────────────────────────────────╯ ``` -**Type-to-confirm here because this is genuinely destructive** (running -config + secrets get moved aside, fresh setup writes new ones). -Single-Y/N is insufficient. - -**Transitions:** - -- `Cancel` → exit zero. Config unchanged. -- `Reset and continue` (enabled only when "reset" typed) → backup - performed (rename atomically; timestamp generated once per - invocation so both files share a suffix) → proceed to Init.1. - -**Non-TTY refusal:** `netclaw init --force > /dev/null 2>&1` cannot -prompt for the type-to-confirm. The command SHALL refuse in non-TTY -contexts with `--force` requires interactive confirm and exit non-zero. - -**`--force` over no existing config:** silently behaves as plain -`netclaw init` (no backup screen, no extra prompt). +## Init.E3 / Init.E4 — Double confirmation -**Backup timestamp collision avoidance:** the timestamp suffix uses -unix-milliseconds (`netclaw.json.bak.<millis>`). On the extremely -unlikely event of a collision (two `--force` invocations in the same -millisecond), an auto-increment suffix is appended -(`netclaw.json.bak.<millis>-1`). +Both reset scopes require two explicit confirmations before mutation. +Default focus stays on the non-destructive option. diff --git a/openspec/changes/netclaw-config-command/design.md b/openspec/changes/netclaw-config-command/design.md index 203c7532c..4adbce18b 100644 --- a/openspec/changes/netclaw-config-command/design.md +++ b/openspec/changes/netclaw-config-command/design.md @@ -1,283 +1,165 @@ ## Context -**UI wireframes:** every page introduced by this change is mocked in -`docs/ui/TUI-002-netclaw-config-wireframes.md` (dashboard, all 12 section -editors, list editor templates T1–T8, doctor results page, daemon -restart nudge). Implementors SHALL treat TUI-002 as the visual contract; -this design document explains decisions and trade-offs around it. - -The `section-editor-abstraction` change (predecessor) introduced the -`ISectionEditor` contract, the `SectionEditorRegistry`, the merge-on-save -plumbing, and the single-step `WizardOrchestrator` mode. It refactored -Provider, Identity, and Posture into reentrant section editors but did -not introduce any new user-facing command. The linear `netclaw init` -wizard still owns the only path to configuration changes today, -including for sections that operators routinely tweak post-install -(search provider, channels, exposure mode, webhooks, skill feeds, -external skill directories, audience profiles, browser automation). - -This change introduces `netclaw config` as the canonical menu-driven -editor for those sections, composes ten new `ISectionEditor` -implementations (plus reuses the three from Change A indirectly through -the dashboard), introduces the multi-value `ListEditor<T>` component, -and hardens the menu registry audit so the menu and its editors cannot -drift apart in subsequent work. The buggy feature-selection step from -#1150 is removed and its responsibility moves to the new -`AudienceProfilesSectionEditor`, with a smoke tape that exercises -arrow-nav and toggle keystrokes. +This change introduces `netclaw config` as the main post-install settings +surface. The IA is now locked as domain-oriented and heavier on sub-pages, +not a flat menu of every registered leaf editor. + +The section-editor abstraction remains the implementation substrate, but the +config command is free to group leaves, route some entries into existing +commands, and keep some capabilities out of scope entirely. ## Goals / Non-Goals **Goals:** -- Ship a menu-driven, reentrant TUI editor for the ten sections - operators actually want to change post-install, with doctor-blessed - saves and merge-on-save preserving every unrelated section. -- Reuse the Change A abstraction without forking: every editor in this - change is an `ISectionEditor` instance and runs inside the existing - `WizardOrchestrator` (now in single-step mode). -- Establish the generic list editor + item editor pattern so future - multi-value sections inherit add/edit/remove UX without re-inventing - it. -- Close #1150 by replacing the broken feature-selection step with the - Audience Profiles editor, exercised by a tape that drives the - failing keystrokes from the bug report. -- Activate the menu registry audit's full contract: every editor in - the registry must have a smoke tape and a round-trip xUnit test, - enforced at CI time. +- Ship `netclaw config` as the main post-install settings command. +- Use a domain-oriented root dashboard. +- Keep Security Posture, Enabled Features, Audience Profiles, and Exposure + Mode distinct under `Security & Access`. +- Keep the existing `Daemon` config shape and global shell-mode shape. +- Route MCP permissions editing out to `netclaw mcp permissions` instead of + duplicating it. +- Use generalized save validation across all leaf editors. **Non-Goals:** -- Simplifying the init wizard (third change). -- Hot-reloading the running daemon on config change. -- Editing inbound webhook route files from the TUI. -- Refactoring `netclaw provider`/`model`/`mcp` CLI subcommands. -- Identity changes post-install (renaming the agent stays a file-edit - task for MVP). -- Editing telemetry, logging, memory tuning, session timeouts, - sub-agent timeouts, shell hard-deny patterns, or scheduling on/off - from the TUI (file-edit only). -- Export/import config bundle or factory reset commands. -- Installing Playwright from the TUI (instructions sub-page only). +- Editing Identity here. +- Adding MCP Servers to this branch. +- Flattening the IA to match registry order. +- Refactoring command back-stack behavior. +- Adding new persisted exposure-mode fields outside the current config + shape. ## Decisions -### D1. Dashboard is a single Termina page with a flat registry - -`ConfigDashboardPage` walks `SectionEditorRegistry.All()` once and -renders the editors in registration order, grouped by `Category` only -for visual presentation. The registry stays flat; the audit, the -round-trip test base class, and the smoke-tape lookup all key off -`SectionId`. Twelve editors render comfortably in a standard 80×24 -terminal without scrolling. - -Alternative considered: a tree-structured registry with first-class -parent/child nodes. Rejected because every "tree" need today is -satisfied by a `Category` string tag and a heavier structure would -complicate the audit, the registry resolution, and the round-trip -tests for no current benefit. - -### D2. Sub-page items via modal sub-orchestrators, not nested pages - -When the `ListEditor<T>` opens a sub-page (e.g. Outbound Webhooks edit -form), the host invokes a fresh `WizardOrchestrator` in single-step mode -on the sub-page's viewmodel. The sub-orchestrator's Save returns its -result to the parent list; the parent list updates in-memory state and -re-renders. This keeps step lifecycle uniform across the whole config -command and avoids a separate "nested page" lifecycle. - -Alternative considered: a stack-of-pages model in Termina layout -where the sub-page is part of the same rendering pass. Rejected -because the sub-orchestrator model already exists from Change A and -adding a parallel stack would split the lifecycle. - -### D3. Doctor blessing is per-editor on save, never inline-per-field - -When a section editor saves, the host builds the candidate merged -config in memory, resolves only that editor's `RelevantDoctorChecks`, -and runs them against the candidate. The dashboard's "Run full doctor" -item is the only entry point for cross-section checks. Per-field -validation lives in the editor's own form (e.g. URL parsing) and is -distinct from doctor blessing. - -Alternative considered: per-field inline validation backed by doctor. -Rejected because doctor checks are designed to operate over complete -config sections, not single fields; running them on every keystroke -would produce confusing transient errors as the operator fills in -related fields. - -### D4. List editor `+ Add` row as a list member, not a separate action bar - -`ListEditor<T>` renders `+ Add <noun>` as the last row of the list -itself. Navigation is uniform (arrow keys move through items, Enter -activates) and there is no modal handoff between "list section" and -"action section." The `+ Add` row is visually distinct (different -glyph, no status) so operators do not mistake it for a data row. - -Alternative considered: a fixed action bar at the list bottom with -explicit `[ Add ]`, `[ Edit ]`, `[ Remove ]` buttons. Rejected -because every TUI list editor we model on (lazygit, k9s, git rebase -interactive) uses inline rows for adds, and the modal handoff -between list and action bar adds keystrokes for no benefit. - -### D5. Inline `d`/`y` confirm for list deletes; modal confirm for credential removal - -List deletes (`d` on a focused item) get a single-key inline -`Remove? [y/N]` prompt because the cost of an accidental delete is low -(operator re-adds the item from memory). Credential removal uses a -default-Cancel modal confirm because the cost is higher (operator -must re-enter or rotate the credential externally). Both confirm -patterns are inherited from Change A's secret-handling contract. - -### D6. New schema section for `BrowserAutomation` - -The schema gains `BrowserAutomation { Enabled: bool, -PlaywrightVersion?: string }` as a top-level section with `Enabled` -defaulting to `false` so existing configs validate without a fix -pass. A matching `BrowserAutomationConfig.cs` lives in -`Netclaw.Configuration`. The browser-automation step today writes -its state into `McpServers` indirectly; this change formalizes the -section so the editor and doctor check have a stable home. - -Alternative considered: keep using `McpServers` as the implicit -home. Rejected because conflating browser-automation with MCP -server config makes both harder to reason about; the doctor check -needs to look in one place. - -### D7. Audit promotion from soft-warn to hard-fail in this change - -In Change A the menu-registry audit allowed missing tape files -without failing (the `netclaw config` command did not exist yet). In -this change the command exists, so the audit's tape-existence check -flips to hard-fail. New section editors added in future PRs cannot -ship without a tape and a round-trip test. - -Alternative considered: keep tape-existence as soft-warn. Rejected -because the contract is only as strong as its weakest enforced rule; -soft-warn drifts into "we'll get to it" which is exactly the failure -mode the audit exists to prevent. - -### D8. Daemon-restart nudge is a stderr line, not a screen - -After a save-and-quit, Termina tears down and the operator returns to -the shell. The nudge prints to stderr after Termina exits so it -remains on screen even after the TUI clears. It is suppressed when -no writes occurred or when the daemon is not running, to avoid -nagging. - -Alternative considered: render the nudge as a final post-flight screen -inside Termina. Rejected because the operator may dismiss the screen -without reading it; a stderr line persists in the scroll buffer. - -### D9. `config-no-init.tape` covers the refusal path - -The refuse-when-no-config behavior is exercised by its own tape and -assertion. This avoids overloading any single section-editor tape with -the refusal scenario and keeps the audit's "tape per registered -editor" rule clean (the refusal tape is not associated with any -registry entry). - -### D10. Editor file layout under `Tui/Sections/<Section>/` - -Each section editor lives in its own folder under -`src/Netclaw.Cli/Tui/Sections/`. Chat-channel editors get a -`Channels/` parent folder, webhooks get a `Webhooks/` parent. The -folder layout mirrors the menu's visual grouping for discoverability -while keeping the registry flat. +### D1. Root IA is domain-oriented + +The dashboard root is a navigation page with domain entries, not a flat +list of all leaf editors. The root contains: + +- Inference Providers +- Models +- Channels +- Inbound Webhooks +- Skill Sources +- Search +- Browser Automation +- Telemetry & Alerting +- Security & Access + +Alternative considered: render every registered editor directly in one flat +screen. Rejected because the locked IA is intentionally domain-oriented and +heavier on sub-pages. + +### D2. Routed handoffs are valid top-level outcomes + +`Inference Providers` routes to `netclaw provider` and `Models` routes to +`netclaw model`. This branch accepts the handoff without redesigning +navigation history. + +Alternative considered: re-host provider and model editors inline. Rejected +for scope and because the user explicitly accepted routed handoffs here. + +### D3. Security posture, enabled features, and audience profiles are separate + +These concepts are explicitly decoupled: + +- Security Posture sets the deployment stance. +- Enabled Features controls deployment-wide runtime enablement. +- Audience Profiles is a curated high-level per-audience editor. + +For Team and Public posture flows, changing posture continues into Enabled +Features. Personal skips that continuation. + +Alternative considered: keep per-audience feature toggles inside Audience +Profiles. Rejected because runtime enablement is deployment-wide, not a +per-audience policy surface. + +### D4. Audience Profiles is curated, not raw config + +Audience Profiles edits only: + +- Tool Access (non-MCP) +- File Access +- Incoming Attachments +- Reset to posture default + +It does not expose per-audience runtime feature toggles, per-audience shell +mode, MCP grants, or approval-policy editing. Reset/overwrite resets the +full underlying profile, including hidden MCP/approval settings. + +### D5. MCP permissions route out to the existing command + +If an operator needs MCP access, grant, or approval editing, the config +surface directs them to `netclaw mcp permissions`. + +### D6. Exposure Mode keeps the existing `Daemon` shape + +Exposure Mode uses explicit modes: + +- Local +- Reverse Proxy +- Tailscale Serve +- Tailscale Funnel +- Cloudflare Tunnel + +`Daemon.ExposureMode` is the single active selector. Mode-specific dialogs +edit only fields already supported by the current config shape. Inactive +values remain preserved. + +Alternative considered: one collapsed Tailscale option or new per-mode +active flags. Rejected by the locked decisions. + +### D7. First non-local enablement may bootstrap pairing automatically + +If the operator enables a non-local exposure mode and no bootstrap/pairing +state exists, the config flow auto-pairs the current configuring client. If +bootstrap state exists but is orphaned or mismatched, the flow blocks and +points the operator to `netclaw doctor`, the docs, and issue `#875`. + +### D8. Validation is generalized, not one bug-specific rule + +Every leaf editor validates what it edits before save: paths, URIs, +credentials, binary presence, referenced entities, and remote resource +reachability where appropriate. Structural invalidity is a hard block; +runtime/probe failures can present `Save anyway`. + +This closes the planning gap around `#1151` by making validation a general +leaf-editor rule rather than a one-off search bug workaround. + +### D9. Missing install refuses before any TUI starts + +If no install/config exists, `netclaw config` prints a plain non-zero +message directing the operator to `netclaw init`. No partial dashboard or +placeholder shell renders. + +### D10. Coverage follows ownership + +Leaf editors get substantive round-trip and smoke coverage. Routed handoffs +get shallow routing coverage only. ## Risks / Trade-offs -- [CI runtime increase] Twelve new smoke tapes plus the no-init refusal - tape add roughly 5–10 minutes to PR-gating smoke runs. → Mitigation: - smoke tapes are inherently parallelizable; if the wall-clock cost - becomes a problem, parallelize tape execution before reducing - coverage. - -- [Audit false positives during partial PRs] During implementation a - contributor may add a section editor before its tape lands. - → Mitigation: the audit's failure message names the missing artifact - explicitly. The convention is "tape and round-trip test land in the - same commit as the editor." PR review enforces it. - -- [Schema migration ergonomics] Adding `BrowserAutomation` as a new - top-level section is one of the few schema additions in this work. - → Mitigation: `"Enabled": false` default lets existing configs - validate; `SchemaFixResolver` auto-inserts the missing key on - next `netclaw doctor --fix` run. Per CLAUDE.md schema sync rule, - the schema and `BrowserAutomationConfig.cs` ship in the same PR. - -- [Reuse of existing channel-audience UX] The new chat-channel - section editors host the existing `channel-audience-tui` - cycling behavior, which is established and tested. → Mitigation: - the section editors compose the existing TUI components rather - than re-implementing them; the channel-audience-tui requirements - remain authoritative. - -- [Doctor checks that probe network endpoints] `SkillFeedsDoctorCheck` - and Slack/Discord/Mattermost `Test Connection` actions reach out - to remote services. → Mitigation: probing is warn-only or - user-initiated. Doctor errors that block save are local-only - (schema validity, key/backend pairing, etc.). - -- [Audience Profiles editor's keystroke contract] If Termina's - `SelectionListNode` has a latent bug (which #1150 implies), arrow - nav and Space toggle may misbehave. → Mitigation: the - `config-audience.tape` smoke tape drives exactly those keystrokes - and the assertion verifies the resulting state. If the underlying - component is broken at the Termina level, this tape will fail and - the bug must be fixed before merge. - -- [Removed feature-selection step on re-run] Operators who currently - rely on the feature-selection step in `netclaw init` lose it. - → Mitigation: PRD-004 and the `feature-selection-wizard` spec - delta document the relocation. The new Audience Profiles editor - is reachable from one menu entry away. Migration text in the PR - description points operators at the new path. - -- [Multi-instance editing] Two concurrent `netclaw config` processes - on the same install would both load → merge → write to the same - `netclaw.json` and `secrets.json`. → Mitigation: out of MVP scope; - semantics are last-write-wins per the file's atomic tmp-rename - write. Documented as a known limitation. File locks are deferred - until there is concrete evidence of operators running multiple - TUI editors simultaneously. - -- [Test Connection partial failure shape] Slack/Discord/Mattermost - Test Connection actions probe several capabilities (auth, channel - access, DM access). Some sub-probes may succeed while others - fail. → Mitigation: the result banner SHALL render one line per - sub-probe with its own status glyph (`✓ Bot token valid`, - `✗ Channel C01ABCDE not in workspace`). Network timeouts SHALL - render as `⚠ probe timed out` rather than a fatal failure, since - the operator may have a transient network issue. Test Connection - is advisory only; it never blocks the editor's Save. +- The domain-oriented IA introduces more navigation depth. + Mitigation: the structure matches operator mental models and keeps the + root from becoming an unscannable flat list. +- Routed handoffs create command-context boundaries. + Mitigation: accepted for this branch; avoid stack refactors here. +- Audience Profiles hides some underlying settings. + Mitigation: that is intentional; reset/overwrite semantics explicitly + restore the full underlying profile, including hidden settings. +- Exposure-mode auto-pairing can fail on inconsistent state. + Mitigation: fail loudly and route to doctor/docs/#875 rather than doing + inline repair. ## Migration Plan -This change ships net-new behavior (`netclaw config`) plus a single -behavior removal (the feature-selection step in init). Migration -considerations: - -1. Land the change. `netclaw init` no longer shows the - feature-selection step on re-run; existing `netclaw.json` keeps - its feature-flag values untouched. -2. Operators who want to change feature flags post-install run - `netclaw config → Audience Profiles → <audience>`. -3. The new `BrowserAutomation` schema section is auto-inserted by - `netclaw doctor --fix` on existing installs (or appears - automatically when `netclaw config` runs over an existing - config that lacks it — the merge writer creates the section - with `Enabled: false` when the operator opens the editor). -4. Daemon restart is required for live config changes to take - effect; the stderr nudge instructs operators to restart when - relevant. - -Rollback: revert the change. `netclaw config` disappears from the -CLI surface. The feature-selection step returns to `netclaw init` -on re-run. The audit returns to Change A's soft-warn tape-existence -behavior. `netclaw.json` values written by `netclaw config` remain -valid against the schema and continue to be respected at runtime. +1. Land `netclaw config` as the primary post-install settings command. +2. Keep provider/model/MCP permission power-user commands in place. +3. Keep Identity in init. +4. Preserve existing config shape during migration; no `Daemon` section + rearrangement is required. ## Open Questions -None at execution time. All architectural decisions are locked above. +None. The locked decisions remove the earlier IA ambiguity. diff --git a/openspec/changes/netclaw-config-command/proposal.md b/openspec/changes/netclaw-config-command/proposal.md index 41f172405..c1154a961 100644 --- a/openspec/changes/netclaw-config-command/proposal.md +++ b/openspec/changes/netclaw-config-command/proposal.md @@ -1,190 +1,137 @@ ## Why -After the `section-editor-abstraction` change lands, Netclaw has the -machinery to share editable sections between the init wizard and any new -command — but no command actually consumes it. Operators still have no way -to change live configuration (search provider, exposure mode, channels, -webhooks, skill feeds, external skill directories, Playwright, audience -profiles, security posture) without hand-editing `netclaw.json`. This -change introduces `netclaw config`, a menu-driven TUI editor that composes -the abstraction's section editors into a single dashboard with reentrant -section-by-section editing, doctor-blessed save, and a CI-enforced audit -that prevents the menu and the editors from drifting apart over time. - -This change also retires the buggy team/public feature-toggle screen in -the existing init wizard (#1150) by replacing it with the new Audience -Profiles section editor, which exercises arrow navigation and toggle -keystrokes under a smoke tape rather than relying on undertested -hand-coded input handling. +After install, operators need one main settings surface. That surface is +now locked as `netclaw config`, while `netclaw init` is reduced to +bootstrap-only setup. The existing planning drifted toward a flat list of +leaf editors and duplicated advanced policy controls that already belong to +other commands. This change realigns the plan around the locked product +shape: + +- `netclaw config` is the main post-install settings surface. +- The root IA is domain-oriented, not a flat list of every leaf editor. +- Routed handoffs are acceptable for `Inference Providers -> netclaw provider` + and `Models -> netclaw model` without a navigation-stack refactor. +- MCP permission editing routes to `netclaw mcp permissions`; it is not + recreated inside `netclaw config`. Source PRDs: `PRD-004-cli-onboarding-and-config.md`, `PRD-001-netclaw-mvp.md`, `PRD-002-gateway-security-envelope.md`. ## What Changes -- Add a new `netclaw config` top-level CLI command that launches Termina - with a `ConfigDashboardPage` rendering every entry in - `SectionEditorRegistry`. The dashboard computes per-section status - (`✓` configured / `⚠` warning / `✗` error / `–` default) by running - each editor's `RelevantDoctorChecks` on entry. Selecting a section - opens its editor in single-step orchestrator mode; on save the - section's checks run inline and either block (on errors), render a - "Save anyway" affordance (on warnings), or accept the write (on - clean). Returning from an editor refreshes the affected section's - status. -- Add a "Run full doctor" item at the dashboard's tail that invokes the - existing `DoctorRunner` with the same exit-code semantics as - `netclaw doctor`, plus a "Quit" item. -- Add the dashboard's existing-config refusal: if `netclaw.json` is - absent, `netclaw config` prints "No configuration found. Run - `netclaw init` first." and exits non-zero. The dashboard does not - render against a default skeleton. -- Add a generic `ListEditor<T>` Termina component and a per-shape - `IItemEditor<T>` contract. Day-one item-editor implementations: - `PathItemEditor` (External Skill Directories), `WebhookItemEditor` - (Outbound Webhooks — sub-page form with name + URL + auth header), - `SkillFeedItemEditor` (Skill Feeds — sub-page form with name + URL + - Bearer token), and `IdentifierItemEditor` (channel IDs, user IDs, - trusted-proxy CIDRs). Simple items edit inline; complex items open - sub-pages. Multi-value sections gain a uniform Add / Edit / Remove - affordance with default-Cancel destructive confirms. -- Add ten new `ISectionEditor` implementations registered in the menu: - Search Provider, Slack Channels, Discord Channels, Mattermost - Channels, Exposure Mode (covering Daemon host/port, trusted proxies, - and per-mode sub-forms for Reverse Proxy / Tailscale / Cloudflare), - Security Posture, Audience Profiles, Outbound Webhooks, Inbound - Webhooks, External Skill Directories, Skill Feeds, Browser - Automation. Slack/Discord/Mattermost share a `"Chat Channels"` - category for menu grouping; the registry treats them as three - independent editors. -- Add the Audience Profiles section editor as the replacement for the - init wizard's broken feature-selection step. The editor SHALL exercise - `↑/↓` navigation between audience tiers, `Space` to toggle individual - per-audience feature flags, and explicit `Reset to posture default` - affordance. A dedicated smoke tape (`config-audience.tape`) drives - these keystrokes and asserts the resulting `Tools.AudienceProfiles` - state. -- Add the Exposure Mode section editor with mode-conditional sub-forms. - Trusted Proxies multi-value list, Reverse Proxy external base URL, - Tailscale auth key (secret), and Cloudflare Tunnel token (secret) are - all reachable from one editor. The editor migrates the responsibility - previously covered by `init-wizard-reverse-proxy.tape` from init into - the config command. -- Add four new doctor checks invoked by the new editors: - `SearchBackendDoctorCheck` (backend-key pairing), - `ExternalSkillSourcesDoctorCheck` (each path is a readable - directory), `SkillFeedsDoctorCheck` (reachability, warn-only — remote - endpoints are allowed to be transiently down), and - `BrowserAutomationDoctorCheck` (Playwright binary present when - feature is enabled). -- Add a new top-level schema section - `BrowserAutomation { Enabled: bool, PlaywrightVersion?: string }` and - the matching `BrowserAutomationConfig.cs`. Schema sync per CLAUDE.md - rule. `"Enabled"` defaults to `false` so `SchemaFixResolver` can - auto-insert on upgrade. -- Add twelve new smoke tapes (`config-search.tape`, - `config-slack.tape`, `config-discord.tape`, `config-mattermost.tape`, - `config-exposure-mode.tape`, `config-posture.tape`, - `config-audience.tape`, `config-outbound-webhooks.tape`, - `config-inbound-webhooks.tape`, `config-external-skills.tape`, - `config-skill-feeds.tape`, `config-browser-automation.tape`) and a - `config-no-init.tape` that asserts the refusal path. Each tape has a - matching assertion script that checks the modified field changed and - unrelated sections are byte-identical to the pre-stage fixture. -- Add round-trip xUnit test classes for all ten new section editors, - derived from `SectionEditorTestBase<TEditor>` introduced in the prior - change. The Change A test pattern carries forward unchanged. -- Activate the `MenuRegistryAuditTests` smoke-tape existence check - (gated as soft-warn in Change A) into a hard fail: any registered - editor without `tests/smoke/tapes/config-<section-lower>.tape` - fails the audit. -- Closes #1150 (feature toggles broken for team/public dispositions — - the buggy screen is removed and its responsibility moves to Audience - Profiles). - -**In scope (MVP):** the `netclaw config` command, the dashboard, -single-step editor hosting, ten new section editors, four new doctor -checks, the new `BrowserAutomation` schema section, generic list and -item editors, twelve new smoke tapes + the no-init refusal tape, ten -new round-trip xUnit test classes, the hardened audit, and a stderr -"daemon restart required to apply changes" nudge when the daemon is -running at config-command exit. - -**Out of scope:** simplification of `netclaw init` (third change), -hot-reload of the running daemon on config change, export/import config -bundle, factory reset, route-file editing for inbound webhooks, -identity beyond what init sets (renaming the agent post-install remains -a file-edit task), telemetry/logging/memory/session/sub-agent/scheduling -config knobs (file-edit only), shell hard-deny patterns (file-edit -only), Playwright installation from within the TUI (instructions -sub-page only), and refactor of `netclaw provider`/`model`/`mcp` CLI -subcommands. +- Add a top-level `netclaw config` command that launches a domain-oriented + navigation dashboard rather than a flat registry dump. +- The root dashboard SHALL include these areas for this branch: + - Inference Providers + - Models + - Channels + - Inbound Webhooks + - Skill Sources + - Search + - Browser Automation + - Telemetry & Alerting + - Security & Access +- Routed handoffs are first-class for: + - `Inference Providers` -> `netclaw provider` + - `Models` -> `netclaw model` + No back-stack refactor is required in this branch. +- `Channels` contains Slack, Discord, Mattermost. +- `Skill Sources` contains External Skills and Skill Feeds. +- `Telemetry & Alerting` contains Telemetry and Outbound Webhooks only in + this pass. Delivery policy tuning is deferred. +- `Security & Access` contains Security Posture, Enabled Features, + Audience Profiles, and Exposure Mode. +- Leave MCP Servers out of scope for this branch. Any MCP permissions, + grants, or approval editing SHALL route to `netclaw mcp permissions`. +- Keep posture values to `Personal`, `Team`, and `Public` only. +- Keep Security Posture, Enabled Features, and Audience Profiles as + separate concepts: + - Security Posture: selects the high-level operating stance. + - Enabled Features: deployment-wide runtime enablement. + - Audience Profiles: curated high-level per-audience editor. +- Audience Profiles SHALL remove per-audience feature toggles and + per-audience shell mode. Audience Profiles SHALL focus on: + - Tool Access (non-MCP) + - File Access + - Incoming Attachments + - Reset to posture default +- `Reset to posture default` / posture overwrite SHALL reset the full + underlying audience profile, including hidden MCP/approval settings for + that audience. +- Exposure Mode is edited under `Security & Access` and retains the + existing `Daemon` config shape. Modes remain explicit: + `Local`, `Reverse Proxy`, `Tailscale Serve`, `Tailscale Funnel`, + `Cloudflare Tunnel`. +- Each non-local exposure mode gets its own mode-specific dialog. `Local` + requires no extra setup. +- Keep a single active selector via `Daemon.ExposureMode`; do not add + per-mode active flags. Preserve inactive old values in config and ignore + them when inactive. +- Do not add or persist new exposure-specific fields that do not already + fit the current config shape. +- First-time enablement of a non-local exposure mode from `netclaw config` + SHALL auto-pair the current configuring client if no bootstrap/pairing + state exists yet. +- If existing bootstrap state is orphaned or mismatched, the editor SHALL + block and point the operator to `netclaw doctor`, the formal docs, and + issue `#875`. No inline repair is in scope. +- `netclaw config` on a missing install SHALL refuse with a plain non-zero + message directing the operator to `netclaw init`. No partial TUI renders. +- Validation is generalized across leaf editors: each leaf validates what + it edits before save, including local references and external probes when + relevant. Structurally invalid config remains non-overridable; runtime or + probe failures MAY offer `Save anyway`. +- Round-trip preservation and test assertions are semantic, not + byte-identical. +- Leaf editors receive substantive round-trip and smoke coverage. Routed + handoffs receive shallow routing coverage only. + +**In scope (MVP):** `netclaw config`, domain-oriented dashboard IA, routed +handoffs for providers/models, leaf editors for the in-scope areas above, +generalized validation behavior, exposure-mode dialogs within the existing +config shape, missing-install refusal, and coverage aligned to leaf-vs- +routed responsibilities. + +**Out of scope:** Identity editing, MCP Servers, MCP permissions editing +inside config, delivery-policy tuning, config-stack/back-stack redesign, +new exposure-specific persisted fields, inline bootstrap repair, and any +config-shape rearrangement of the existing `Daemon` or global shell mode +sections. ## Capabilities ### New Capabilities -- `netclaw-config-command`: contract for the `netclaw config` command — - command-level lifecycle, dashboard rendering, per-section status - computation, single-step editor hosting, doctor blessing on save, - refusal when no config exists, daemon-restart nudge at exit, - list/item editor framework, and the ten section editors' shared - obligations. +- `netclaw-config-command`: contract for the domain-oriented config + dashboard, routed handoffs, leaf-editor hosting, generalized validation, + missing-install refusal, and coverage expectations. ### Modified Capabilities -- `netclaw-cli`: add `netclaw config` to the operator CLI surface; add - the `Quit` and `Run full doctor` dashboard items as standard - affordances. -- `feature-selection-wizard`: remove the feature-selection step from - `netclaw init`. The deployment-wide feature toggles previously written - by that step move to the Audience Profiles section editor in - `netclaw config`, exposed per audience and per feature with the - keystroke contract required by #1150. -- `channel-audience-tui`: re-host the existing channel-audience - cycling behavior as the per-channel-editor sub-screen, retaining - the requirement that audience defaults derive from posture but - letting the operator override per-channel from the config command. +- `netclaw-cli`: add `netclaw config` as a top-level settings command. +- `feature-selection-wizard`: move post-install runtime enablement editing + to the `Enabled Features` leaf under `Security & Access`, while keeping + init bootstrap behavior aligned to posture. ## Impact **Affected systems:** -- CLI command surface (`Netclaw.Cli.Program` routing, - `Netclaw.Cli.Config.ConfigCommand` new class). -- Termina TUI (`Netclaw.Cli.Tui.Sections.ConfigDashboardPage`, - `ConfigDashboardViewModel`, `ListEditor<T>`, four item editors). -- Ten new section editors under - `src/Netclaw.Cli/Tui/Sections/{Search,Channels/{Slack,Discord,Mattermost},ExposureMode,SecurityPosture,AudienceProfiles,Webhooks/{Outbound,Inbound},ExternalSkills,SkillFeeds,BrowserAutomation}/`. -- Doctor system gains four checks under - `src/Netclaw.Cli/Doctor/Checks/`. -- Schema (`netclaw-config.v1.schema.json`) gains the `BrowserAutomation` - top-level section. -- Configuration types (`src/Netclaw.Configuration/BrowserAutomationConfig.cs`). -- Test surface gains twelve smoke tapes, ten round-trip test classes, - and a hardened menu registry audit. +- CLI routing for `netclaw config`. +- Termina config dashboard and sub-pages. +- Section-editor hosting for in-scope leaves. +- Routed handoff affordances for provider/model commands. +- Exposure-mode editing and validation. +- Test surface for leaf editors, routing coverage, and generalized save + validation. **Security and operational impact:** -- Secret-handling contract from Change A applies to every secret-bearing - field across the ten new editors. No new secret display surface is - introduced; "Remove credential" is the only path that deletes a - secret value. -- Doctor checks scoped to each editor run inline on save; cross-section - checks remain gated to the dashboard's "Run full doctor" action. No - network-probing check blocks save by default (`SkillFeedsDoctorCheck` - is warn-only) so transient outages do not lock operators out of - editing. -- The hardened audit prevents the menu and editors from drifting: - adding a new menu entry without its tape or round-trip test fails - CI immediately. -- Existing daemon does not hot-reload. A stderr nudge at config-command - exit instructs operators to restart the daemon to apply changes when - the daemon is detected as running; otherwise the nudge is omitted. -- The feature-selection step's removal is a behavioral change for - operators on non-Personal postures who re-run `netclaw init` over - existing config: they no longer see the step. Its responsibility - moves to `netclaw config → Audience Profiles`. PRD-004 is updated - in this change to reflect the new shape. -- No persistence schema changes. No new actor or session contract - changes. No external network dependencies introduced. +- Ongoing settings now have one primary post-install home. +- Audience Profiles no longer duplicate MCP permissions or raw low-level + policy editing. +- Exposure-mode changes keep the existing config shape and preserve + inactive values. +- Validation behavior is generalized beyond issue `#1151`; structural + invalidity still blocks writes, while runtime reachability failures can + be overridden with `Save anyway`. diff --git a/openspec/changes/netclaw-config-command/specs/feature-selection-wizard/spec.md b/openspec/changes/netclaw-config-command/specs/feature-selection-wizard/spec.md index 3f1706e8e..834b826a8 100644 --- a/openspec/changes/netclaw-config-command/specs/feature-selection-wizard/spec.md +++ b/openspec/changes/netclaw-config-command/specs/feature-selection-wizard/spec.md @@ -1,59 +1,41 @@ -## REMOVED Requirements - -### Requirement: Feature selection wizard step - -**Reason**: The init-wizard feature-selection step (issue #1150) had broken -keystroke handling for Team and Public audience toggles. Its responsibility -moves to the new `AudienceProfilesSectionEditor` in `netclaw config`, -which renders per-audience feature toggles with documented arrow-nav and -Space-toggle semantics under a CI-gated smoke tape -(`config-audience.tape`). - -**Migration**: Operators previously walked this step at the end of -`netclaw init` for non-Personal postures. After this change, the init -wizard skips the feature-selection step entirely; deployment-wide -defaults are derived from the selected security posture -(per `Requirement: Audience defaults from posture` in the -`channel-audience-tui` capability) and per-audience feature toggles are -edited via `netclaw config → Audience Profiles`. Existing -`netclaw.json` files retain whatever feature-flag values they hold; -the new Audience Profiles editor preserves customizations. - ## MODIFIED Requirements -### Requirement: Feature config Enabled flags +### Requirement: Post-install runtime feature editing SHALL move to Enabled Features -The configuration schema SHALL include `Enabled` boolean properties for -Memory, Search, SkillSync, SubAgents, and Webhooks sections, plus a -top-level `Scheduling` section whose only property is `Enabled`. These -flags SHALL be written by either the init wizard's posture-default -cascade or the `AudienceProfilesSectionEditor` in `netclaw config`. -Both writers SHALL emit byte-identical output for equivalent input. +Post-install runtime feature editing SHALL move to +`netclaw config -> Security & Access -> Enabled Features`, not to Audience +Profiles. -#### Scenario: Disabled memory writes Enabled false +**Reason**: Runtime feature enablement is deployment-wide and remains a +separate concept from Security Posture and Audience Profiles. -- **GIVEN** the operator disabled memory in the Audience Profiles - editor (under any audience) and saved -- **WHEN** the editor's merge writer completes -- **THEN** `Memory.Enabled` is `false` in `netclaw.json` +Audience Profiles remains a curated per-audience access editor and SHALL NOT +own per-audience runtime feature toggles. -#### Scenario: Disabled search writes Enabled false +#### Scenario: Post-install feature editing does not use Audience Profiles -- **GIVEN** the operator disabled search in the Audience Profiles - editor and saved -- **WHEN** the editor's merge writer completes -- **THEN** `Search.Enabled` is `false` in `netclaw.json` +- **GIVEN** the operator wants to change deployment-wide search or memory + enablement after install +- **WHEN** they use `netclaw config` +- **THEN** the change is made in `Enabled Features` +- **AND** Audience Profiles is not used for that runtime toggle -#### Scenario: Disabled scheduling writes top-level Scheduling.Enabled false +### Requirement: Feature config Enabled flags -- **GIVEN** the operator disabled scheduling in the Audience Profiles - editor and saved -- **WHEN** the editor's merge writer completes -- **THEN** `Scheduling.Enabled` is `false` in `netclaw.json` -- **AND** `Scheduling` contains no other properties in this change +The configuration schema SHALL include deployment-wide `Enabled` flags for +the applicable runtime features. These flags MAY be set during bootstrap +and SHALL be editable post-install through the Enabled Features leaf. The +post-install editor and bootstrap flow SHALL preserve config semantics for +equivalent inputs; byte-identical serialization is not required. + +#### Scenario: Enabled Features writes deployment-wide flags + +- **GIVEN** the operator disables search in Enabled Features +- **WHEN** the editor saves +- **THEN** `Search.Enabled` is `false` in `netclaw.json` -#### Scenario: Default Personal config has all features enabled +#### Scenario: Personal posture default keeps all features enabled -- **GIVEN** the operator selected Personal posture at init -- **WHEN** the init wizard's merge writer completes -- **THEN** all `Enabled` flags default to `true` +- **GIVEN** the operator selected Personal posture during bootstrap +- **WHEN** bootstrap finalizes config +- **THEN** deployment-wide runtime features default to enabled diff --git a/openspec/changes/netclaw-config-command/specs/netclaw-cli/spec.md b/openspec/changes/netclaw-config-command/specs/netclaw-cli/spec.md index 7f4f7bf93..80e96c8a3 100644 --- a/openspec/changes/netclaw-config-command/specs/netclaw-cli/spec.md +++ b/openspec/changes/netclaw-config-command/specs/netclaw-cli/spec.md @@ -2,54 +2,31 @@ ### Requirement: Config command surface -The CLI SHALL expose `netclaw config` as a top-level command. The -command SHALL be offline (no daemon connection), SHALL operate on -local config files only, and SHALL behave per the -`netclaw-config-command` capability. `netclaw config --help` SHALL -print a one-paragraph description and exit zero. `netclaw config show` -and `netclaw config validate` are RESERVED subcommands (PRD-004) and -SHALL print a not-yet-implemented notice and exit non-zero in this -change, preserving the documented future surface. Unknown subcommands -SHALL print usage and exit non-zero. - -#### Scenario: Help text describes the command +The CLI SHALL expose `netclaw config` as a top-level command. The command +SHALL operate on local config files and SHALL behave per the +`netclaw-config-command` capability. + +If no config exists, `netclaw config` SHALL print a plain message directing +the operator to `netclaw init` and exit non-zero without launching Termina. + +#### Scenario: Help text describes config as post-install settings surface - **WHEN** the operator runs `netclaw config --help` -- **THEN** the command exits with status 0 -- **AND** stdout contains a one-paragraph description naming - "interactive configuration editor" -- **AND** stdout references the `netclaw init` companion command -- **AND** stdout lists the reserved `show` and `validate` subcommands - with a "not yet implemented; see PRD-004" note - -#### Scenario: Reserved subcommand show exits non-zero with reservation notice - -- **WHEN** the operator runs `netclaw config show` -- **THEN** stderr contains - `\`netclaw config show\` is reserved for future use (PRD-004) and is - not yet implemented.` -- **AND** the command exits with non-zero status -- **AND** no `netclaw.json` write occurs - -#### Scenario: Reserved subcommand validate exits non-zero with reservation notice - -- **WHEN** the operator runs `netclaw config validate` -- **THEN** stderr contains - `\`netclaw config validate\` is reserved for future use (PRD-004) - and is not yet implemented.` -- **AND** the command exits with non-zero status -- **AND** no `netclaw.json` write occurs - -#### Scenario: Unknown subcommand rejected with usage - -- **WHEN** the operator runs `netclaw config foo` -- **THEN** the command exits with non-zero status -- **AND** stderr contains usage text naming the dashboard launch - (`netclaw config` with no args) and the reserved subcommands - -#### Scenario: No-args invocation launches dashboard - -- **WHEN** the operator runs `netclaw config` with no arguments -- **AND** `netclaw.json` exists -- **THEN** the dashboard launches per the - `netclaw-config-command` capability +- **THEN** the command exits zero +- **AND** help text describes `netclaw config` as the main post-install + settings surface +- **AND** help text references `netclaw init` as the bootstrap companion + +#### Scenario: No-args invocation launches dashboard on configured install + +- **GIVEN** `netclaw.json` exists +- **WHEN** the operator runs `netclaw config` +- **THEN** the domain-oriented dashboard launches + +#### Scenario: Missing install refuses with plain message + +- **GIVEN** `netclaw.json` does not exist +- **WHEN** the operator runs `netclaw config` +- **THEN** stderr contains `No configuration found. Run \`netclaw init\` first.` +- **AND** the command exits non-zero +- **AND** no partial TUI starts diff --git a/openspec/changes/netclaw-config-command/specs/netclaw-config-command/spec.md b/openspec/changes/netclaw-config-command/specs/netclaw-config-command/spec.md index d24231490..d13b43aa5 100644 --- a/openspec/changes/netclaw-config-command/specs/netclaw-config-command/spec.md +++ b/openspec/changes/netclaw-config-command/specs/netclaw-config-command/spec.md @@ -1,627 +1,205 @@ ## ADDED Requirements -### Requirement: Config command launches dashboard +### Requirement: Config command launches a domain-oriented dashboard -`netclaw config` SHALL launch Termina with a dashboard page rendering every -registered `ISectionEditor` from `SectionEditorRegistry`, plus a "Run full -doctor" item and a "Quit" item at the dashboard tail. The command SHALL -operate offline (no daemon connection required) and SHALL read/write -local config files only. +`netclaw config` SHALL launch a domain-oriented settings dashboard. The +root SHALL be navigation-first and SHALL NOT be a flat list of every +registered leaf editor. -#### Scenario: Dashboard renders all registered editors +The root SHALL include: -- **GIVEN** the CLI is configured with the day-one editor registry - (Search, Slack, Discord, Mattermost, ExposureMode, SecurityPosture, - AudienceProfiles, OutboundWebhooks, InboundWebhooks, ExternalSkills, - SkillFeeds, BrowserAutomation) -- **WHEN** the operator runs `netclaw config` -- **THEN** Termina opens with a dashboard listing every editor, with - status badges computed per editor -- **AND** the tail shows a "Run full doctor" item and a "Quit" item +- `Inference Providers` +- `Models` +- `Channels` +- `Inbound Webhooks` +- `Skill Sources` +- `Search` +- `Browser Automation` +- `Telemetry & Alerting` +- `Security & Access` -#### Scenario: Config command does not require daemon +#### Scenario: Root dashboard shows domain entries -- **GIVEN** the Netclaw daemon is not running +- **GIVEN** a configured install - **WHEN** the operator runs `netclaw config` -- **THEN** the command starts and renders the dashboard normally -- **AND** no daemon RPC or HTTP call is made +- **THEN** the root dashboard opens with the documented domain entries +- **AND** it does not render a flat dump of every registered leaf editor -### Requirement: Refuse when no config exists +### Requirement: Missing install refuses before TUI startup -`netclaw config` SHALL detect a missing `netclaw.json` at startup and -refuse to render the dashboard. The command SHALL print -`No configuration found. Run \`netclaw init\` first.` to stderr and exit -with a non-zero exit code. +`netclaw config` SHALL detect a missing install/config before starting the +TUI. It SHALL print `No configuration found. Run \`netclaw init\` first.` +to stderr and exit non-zero. -#### Scenario: No config refusal exits non-zero +#### Scenario: No install refusal renders no TUI - **GIVEN** `~/.netclaw/config/netclaw.json` does not exist - **WHEN** the operator runs `netclaw config` -- **THEN** the command prints `No configuration found. Run \`netclaw init\` first.` - to stderr -- **AND** exits with a non-zero exit code -- **AND** does not render any Termina UI - -### Requirement: Dashboard status badges - -The dashboard SHALL render a status badge for every section editor by -computing `GetStatus(currentConfig)` and running the editor's -`RelevantDoctorChecks` against the on-disk config at dashboard entry. -The badge vocabulary SHALL be: `✓` configured (all checks pass), -`⚠` configured but at least one check warns, `✗` configured but at -least one check errors, and `–` not set / default. Badges SHALL be -recomputed on return from a section editor save. - -#### Scenario: Configured-and-passing section shows checkmark - -- **GIVEN** the Search section is configured with backend `duckduckgo` -- **AND** `ConfigSchemaDoctorCheck` and `SearchBackendDoctorCheck` - both pass -- **WHEN** the dashboard renders -- **THEN** the Search row shows `✓` - -#### Scenario: Configured-and-warning section shows warning glyph - -- **GIVEN** the Search section is configured with backend `brave` and a - rate-limited API key -- **AND** `SearchBackendDoctorCheck` returns WARN -- **WHEN** the dashboard renders -- **THEN** the Search row shows `⚠` - -#### Scenario: Unset section shows dash - -- **GIVEN** the Outbound Webhooks section has no configured webhooks -- **WHEN** the dashboard renders -- **THEN** the Outbound Webhooks row shows `–` - -### Requirement: Sub-grouping by category - -Section editors that declare the same `Category` value SHALL be grouped -visually in the dashboard under that category label. The label itself -SHALL be unselectable; only the editor rows underneath it accept focus. -Grouping SHALL NOT affect the registry's flat enumeration or the audit's -per-editor checks. - -#### Scenario: Chat-channels group renders three siblings - -- **GIVEN** the Slack, Discord, and Mattermost editors declare - `Category = "Chat Channels"` -- **WHEN** the dashboard renders -- **THEN** the three rows render under a "Chat Channels" group label -- **AND** the group label cannot be selected or activated -- **AND** the dashboard registry audit still treats the three as - independent registered editors - -### Requirement: Section editor hosting - -Opening a section from the dashboard SHALL launch the editor's -`IWizardStepViewModel` (produced by `CreateEditor(context)`) inside a -single-step `WizardOrchestrator`. The orchestrator SHALL drive save and -cancel semantics exactly as in the linear wizard, then return control -to the dashboard. The dashboard SHALL refresh the affected section's -status before re-rendering. - -#### Scenario: Open editor, save, return - -- **GIVEN** the dashboard is displayed with the Search row focused -- **WHEN** the operator presses Enter -- **THEN** the Search section editor opens in single-step mode -- **AND** the editor's UI matches the section editor contract (pre-filled - non-secret fields, masked empty secret fields) -- **AND** on Save the orchestrator writes via the merge layer and returns - to the dashboard -- **AND** the dashboard re-renders with the updated Search status badge - -#### Scenario: Open editor, cancel, return without write - -- **GIVEN** the dashboard is displayed with the Search row focused -- **WHEN** the operator opens the editor, changes the backend selector, - and presses Esc -- **THEN** the editor shows the unsaved-changes discard confirm dialog -- **AND** on confirm-discard, control returns to the dashboard -- **AND** no `netclaw.json` write occurred -- **AND** the dashboard re-renders with the unchanged Search status badge - -### Requirement: Doctor blessing on section save - -When a section editor saves, the host SHALL build a candidate merged -config in memory, resolve the editor's `RelevantDoctorChecks`, and run -each check against the candidate. If any check returns ERROR, the -host SHALL block the save, surface an inline error banner, and keep -focus inside the editor. If any check returns WARN (and no ERROR), the -host SHALL render an inline warning banner with a `Save anyway` -affordance and a `Cancel` affordance. If all checks pass, the host -SHALL write the merged candidate to disk and return to the dashboard. - -#### Scenario: Error-level check blocks save - -- **GIVEN** the Search editor is open with backend `brave` selected and - the API key field left blank (no stored key) -- **WHEN** the operator saves -- **THEN** `SearchBackendDoctorCheck` returns ERROR -- **AND** the inline error banner displays the check's message -- **AND** the Save button is disabled until the error condition is - cleared +- **THEN** the command prints the refusal message to stderr +- **AND** exits non-zero +- **AND** no partial TUI is rendered -#### Scenario: Warn-level check surfaces banner with override +### Requirement: Routed handoffs SHALL be first-class config outcomes -- **GIVEN** the Skill Feeds editor is open with a feed whose URL is - currently unreachable -- **WHEN** the operator saves -- **THEN** `SkillFeedsDoctorCheck` returns WARN -- **AND** the inline warning banner displays the check's message -- **AND** the host renders `[ Save anyway ]` and `[ Cancel ]` -- **AND** activating Save anyway writes the merged candidate to disk +The config dashboard SHALL treat routed handoffs as first-class config +outcomes and MAY route specific domain entries into existing commands +instead of re-hosting the full editor inline. In this branch, `Inference +Providers` SHALL route to `netclaw provider` and `Models` SHALL route to +`netclaw model`. -#### Scenario: Clean checks write to disk +#### Scenario: Inference Providers routes to provider command -- **GIVEN** the Search editor is open with backend `duckduckgo` and no - required API key -- **WHEN** the operator saves -- **THEN** all relevant checks pass -- **AND** the merge writer produces a new `netclaw.json` with only the - Search section changed -- **AND** control returns to the dashboard - -### Requirement: Run full doctor item - -The dashboard SHALL include a "Run full doctor" item at the tail that -invokes `DoctorRunner` against the on-disk config and renders results -on a doctor results page. The results page SHALL list each check's -status (PASS/WARN/ERROR/SKIPPED) with summary text. Pressing Esc or -activating the page's "Back to dashboard" action SHALL return to the -dashboard with no config write performed. - -#### Scenario: Full doctor lists every check - -- **GIVEN** the dashboard is displayed and the daemon-restart status - is irrelevant -- **WHEN** the operator selects "Run full doctor" -- **THEN** `DoctorRunner` runs every registered check against on-disk - config -- **AND** the results page renders one row per check with PASS/WARN/ERROR - status and check name - -#### Scenario: Full doctor does not modify config - -- **GIVEN** the dashboard's "Run full doctor" item runs -- **WHEN** results render and the operator returns to the dashboard -- **THEN** no config file write has occurred -- **AND** the dashboard's per-section status badges reflect the same - on-disk state as before - -### Requirement: Daemon-restart nudge at exit - -`netclaw config` SHALL print a stderr nudge at exit instructing the -operator to restart the daemon for changes to take effect, when (a) at -least one config or secrets write occurred during the session AND (b) -the daemon is currently running. Daemon-running detection SHALL reuse -the same probe used by `netclaw daemon status` (PID-file check at the -documented daemon path, falling back to a TCP-open check on the -configured daemon port). The probe SHALL be bounded by a 250 ms -timeout; on timeout the nudge SHALL be omitted (conservative — missing -a true-positive nudge is preferable to a false-positive nudge after a -network hiccup). If either condition is false, the nudge SHALL be -omitted. - -#### Scenario: Daemon running plus config change emits nudge - -- **GIVEN** the daemon is running -- **AND** the operator saved at least one section during the session -- **WHEN** the operator quits the dashboard -- **THEN** the stderr nudge `Config saved. Restart the daemon to apply - changes: netclaw daemon stop && netclaw daemon start` is printed -- **AND** the command exits with status 0 - -#### Scenario: Daemon not running suppresses nudge - -- **GIVEN** the daemon is not running -- **AND** the operator saved at least one section during the session -- **WHEN** the operator quits the dashboard -- **THEN** no nudge is printed -- **AND** the command exits with status 0 - -#### Scenario: No writes suppresses nudge regardless of daemon state - -- **GIVEN** the operator opened the dashboard, browsed editors, but - saved nothing -- **WHEN** the operator quits -- **THEN** no nudge is printed regardless of daemon state - -#### Scenario: Daemon-detection probe timeout suppresses nudge - -- **GIVEN** the operator saved at least one section during the session -- **AND** the PID-file lookup fails (file absent or unreadable) -- **AND** the TCP-open check on the daemon port exceeds the 250 ms - bound -- **WHEN** the operator quits the dashboard -- **THEN** no nudge is printed -- **AND** the command exits with status 0 - -### Requirement: Generic list editor component - -The CLI SHALL provide a generic `ListEditor<T>` Termina component -parameterized by an `IItemEditor<T>` describing the item shape. The -component SHALL render an Add row at the bottom (`+ Add <noun>`), an -inline-or-sub-page edit affordance per item depending on -`IItemEditor.RequiresSubPage`, an inline delete affordance keyed to -`d` with single-key confirmation for low-stakes deletes, and overall -Save / Cancel affordances. The list editor SHALL preserve item -identity across edit by consulting `IItemEditor.KeyOf(item)` so that -in-place renames (rather than delete + add) round-trip correctly. - -#### Scenario: Inline edit for simple items - -- **GIVEN** an `ExternalSkills.Sources` list with three path entries -- **WHEN** the operator presses Enter on a focused row -- **THEN** an inline single-line input overlay replaces the row -- **AND** Enter saves the edit to in-memory list state -- **AND** Esc cancels without modifying state - -#### Scenario: Sub-page edit for complex items - -- **GIVEN** an `Notifications.Webhooks` list with two configured - webhooks -- **WHEN** the operator presses Enter on a focused row -- **THEN** a sub-page form opens showing every webhook field -- **AND** Save on the sub-page returns to the list with the in-memory - webhook updated -- **AND** Cancel on the sub-page returns to the list with no change - -#### Scenario: Delete confirmation prevents accidental removal - -- **GIVEN** a focused list item -- **WHEN** the operator presses `d` -- **THEN** an inline `Remove? [y/N]` prompt replaces the row's display -- **AND** pressing `y` removes the item from in-memory state -- **AND** any other key cancels the deletion - -#### Scenario: Item identity preserved on in-place rename - -- **GIVEN** a webhook list with an entry whose `KeyOf` returns - `"critical-pager"` -- **AND** the entry's auth header is stored under that key in - `secrets.json` (e.g. `Notifications.Webhooks.critical-pager.AuthHeader`) -- **WHEN** the operator edits the entry and changes its name to - `pagerduty-prod` -- **THEN** the list editor tracks the rename via the `(originalKey, - newKey)` pair across the edit lifecycle -- **AND** the merge writer locates the underlying schema-array entry - by `originalKey` (not by array index), replaces the name and other - fields, and writes the updated entry at the same array position -- **AND** the corresponding secrets-store key is renamed from - `originalKey` to `newKey` atomically; the stored encrypted value - for `originalKey` is unchanged in encrypted form and re-keyed -- **AND** the resulting `Notifications.Webhooks` array contains - exactly one entry, named `pagerduty-prod`, with the previously - stored auth header still configured - -### Requirement: Search Provider editor - -The dashboard SHALL include a `SearchSectionEditor` -(`SectionId = "Search"`) for editing the search backend and its -credentials. The editor SHALL present a single-selection list among -`Brave`, `DuckDuckGo`, `SearXng (self-hosted)`. Backend-dependent -fields SHALL render: Brave shows an API key input (secret-handling -contract); SearXng shows an instance URL input; DuckDuckGo shows no -additional fields. The editor SHALL declare `RelevantDoctorChecks` = -`{ConfigSchemaDoctorCheck, SearchBackendDoctorCheck}`. - -#### Scenario: Switching to DuckDuckGo preserves stored Brave key - -- **GIVEN** the Search section is configured with backend `brave` and a - stored Brave API key -- **WHEN** the operator switches the backend to `duckduckgo` and saves -- **THEN** `netclaw.json` records `Search.Backend = "duckduckgo"` -- **AND** `secrets.json` retains the Brave API key encrypted at its - original location - -#### Scenario: Brave without key blocks save - -- **GIVEN** the Search section is unconfigured -- **WHEN** the operator selects `brave`, leaves the key empty, and saves -- **THEN** `SearchBackendDoctorCheck` returns ERROR -- **AND** the save is blocked - -### Requirement: Chat channel editors - -The dashboard SHALL include three independently-registered chat-channel -section editors: `SlackSectionEditor` (`SectionId = "Slack"`), -`DiscordSectionEditor` (`SectionId = "Discord"`), and -`MattermostSectionEditor` (`SectionId = "Mattermost"`). Each editor -SHALL declare `Category = "Chat Channels"` for menu grouping. Each -editor SHALL surface its platform's authentication tokens -(per-platform secret-handling contract), an allowed-channels list, -an allowed-users list, the DMs-enabled toggle, the channel audience -profile selector, and a Test Connection affordance that runs the -existing per-platform probe and renders results in an inline banner. - -#### Scenario: Slack editor exposes both bot and app tokens with leave-blank-to-keep - -- **GIVEN** the Slack section has both bot and app tokens stored -- **WHEN** the operator opens the Slack section editor -- **THEN** both token fields render empty with "configured — leave blank - to keep" hint -- **AND** saving with both fields blank preserves both stored tokens - -#### Scenario: Discord editor exposes single token - -- **GIVEN** the Discord section is unconfigured -- **WHEN** the operator opens the Discord section editor -- **THEN** one token field is displayed with "(not set)" hint -- **AND** no app-token field exists (Discord uses a single bot token) - -#### Scenario: Mattermost editor exposes server URL plus token - -- **GIVEN** the Mattermost section is unconfigured -- **WHEN** the operator opens the Mattermost section editor -- **THEN** a Server URL text field is displayed in addition to the token - field - -#### Scenario: Test Connection renders inline banner - -- **GIVEN** the Slack editor is open with valid tokens entered -- **WHEN** the operator activates Test Connection -- **THEN** the existing Slack probe runs in-process -- **AND** results render in an inline banner with workspace name and - channel-access summary - -### Requirement: Exposure Mode editor - -The dashboard SHALL include an `ExposureModeSectionEditor` -(`SectionId = "Daemon.ExposureMode"`) that lets the operator select -among `Local`, `Reverse Proxy`, `Tailscale`, `Cloudflare Tunnel`. The -editor SHALL surface mode-conditional sub-forms: Reverse Proxy -requires an external base URL plus a trusted-proxy CIDR list; Tailscale -requires an auth-key secret plus hostname; Cloudflare Tunnel requires a -tunnel-token secret plus optional access-policy email domain. The -editor SHALL also surface daemon host and port. `RelevantDoctorChecks` -SHALL include `ConfigSchemaDoctorCheck` and the existing -`ExposureModeDoctorCheck`. - -#### Scenario: Local mode requires no sub-form - -- **GIVEN** the Exposure Mode editor is open with `Local` selected -- **WHEN** the operator saves -- **THEN** `Daemon.ExposureMode = "Local"` is written -- **AND** no trusted-proxy or tunnel configuration is required +- **GIVEN** the operator selects `Inference Providers` +- **WHEN** the handoff is activated +- **THEN** the flow routes to `netclaw provider` +- **AND** no config-dashboard back-stack refactor is required -#### Scenario: Reverse Proxy without trusted proxies blocks save +### Requirement: Security & Access separates posture, features, profiles, and exposure -- **GIVEN** the Exposure Mode editor is open with `Reverse Proxy` - selected -- **AND** the trusted-proxy list is empty -- **WHEN** the operator saves -- **THEN** `ExposureModeDoctorCheck` returns ERROR -- **AND** the save is blocked - -### Requirement: Security Posture editor - -The dashboard SHALL include a `SecurityPostureSectionEditor` -(`SectionId = "Security.Posture"`) presenting `Personal`, `Team`, -`Enterprise` posture choices with descriptive subtitles. When the -operator changes posture and the existing `Tools.AudienceProfiles` -section has been customized away from the prior posture's defaults, -the editor SHALL surface a three-option cascade dialog: cancel, -apply posture with overwrite, or apply posture preserving custom -profiles. - -#### Scenario: Cascade dialog presents three options - -- **GIVEN** the current posture is `Personal` and the Team audience - profile has been customized in `Tools.AudienceProfiles` -- **WHEN** the operator selects `Team` and saves -- **THEN** the cascade dialog opens with default focus on `Cancel` -- **AND** options are: `Cancel — keep current posture`, - `Apply new posture, overwrite profiles`, - `Apply new posture, keep custom profiles` - -#### Scenario: Default focus prevents accidental overwrite - -- **GIVEN** the cascade dialog is open -- **WHEN** the operator presses Enter or Esc -- **THEN** the dialog cancels the posture change -- **AND** `Tools.AudienceProfiles` is unchanged - -### Requirement: Audience Profiles editor - -The dashboard SHALL include an `AudienceProfilesSectionEditor` -(`SectionId = "Tools.AudienceProfiles"`) replacing the init wizard's -feature-selection step. The editor SHALL render an audience picker for -`Personal`, `Team`, `Public`. Opening an audience SHALL display a -per-audience editor with one toggleable row per feature -(`memory`, `search`, `skills`, `scheduling`, `sub-agents`, -`webhooks`), a shell-mode selector for that audience, an approval -policy selector, and a `Reset to posture default` affordance. Arrow -keys SHALL navigate rows; `Space` SHALL toggle the focused checkbox; -`Enter` on a checkbox row SHALL also toggle (alternative to Space). -`RelevantDoctorChecks` SHALL include `ConfigSchemaDoctorCheck` and -`ToolAudienceProfilesDoctorCheck`. - -#### Scenario: Down-arrow then Space toggles second row - -- **GIVEN** the Team audience editor is open -- **AND** initial focus is on the first feature row (`memory`, - currently enabled) -- **WHEN** the operator presses `↓` then `Space` -- **THEN** focus moves to the second row (`search`) -- **AND** the `search` toggle flips (off if it was on, on if it was - off) -- **AND** the change is reflected in `Tools.AudienceProfiles.Team` - when the editor saves - -#### Scenario: Reset to posture default replaces all toggles - -- **GIVEN** the Team audience editor is open with several custom - toggle states +The `Security & Access` area SHALL contain separate entries for Security +Posture, Enabled Features, Audience Profiles, and Exposure Mode. + +Security Posture, Enabled Features, and Audience Profiles SHALL remain +distinct concepts: + +- Security Posture selects the deployment stance. +- Enabled Features controls deployment-wide runtime enablement. +- Audience Profiles edits curated per-audience high-level access rules. + +#### Scenario: Team posture continues into enabled-features flow + +- **GIVEN** the operator changes Security Posture to `Team` +- **WHEN** the posture change flow completes +- **THEN** the config flow continues into Enabled Features + +#### Scenario: Personal posture skips enabled-features continuation + +- **GIVEN** the operator changes Security Posture to `Personal` +- **WHEN** the posture change flow completes +- **THEN** the config flow does not force an Enabled Features continuation + +### Requirement: Audience Profiles is curated and excludes MCP editing + +The Audience Profiles editor SHALL be a curated high-level editor. It SHALL +focus on: + +- Tool Access (non-MCP) +- File Access +- Incoming Attachments +- Reset to posture default + +It SHALL NOT expose: + +- per-audience runtime feature toggles +- per-audience shell mode +- MCP grants/access editing +- raw approval-policy editing + +MCP access/grants/approval editing SHALL route to `netclaw mcp permissions`. + +#### Scenario: Audience Profiles omits per-audience feature toggles + +- **WHEN** the operator opens Audience Profiles +- **THEN** the UI does not offer per-audience runtime feature toggles +- **AND** runtime enablement remains owned by Enabled Features + +#### Scenario: Reset to posture default resets full underlying profile + +- **GIVEN** an audience has customized visible settings and hidden MCP or + approval settings - **WHEN** the operator activates `Reset to posture default` -- **THEN** every toggle and the shell-mode selector revert to the - current posture's default mapping for the Team audience - -### Requirement: Outbound Webhooks editor - -The dashboard SHALL include an `OutboundWebhooksSectionEditor` -(`SectionId = "Notifications.Webhooks"`) presenting the existing -multi-value array via the generic `ListEditor<T>` with the -`WebhookItemEditor` sub-page form. Each webhook SHALL be editable -with name, URL, optional auth-header value (secret-handling contract), -and optional event filter. Add/edit/remove SHALL produce a correctly -merged `Notifications.Webhooks` array. - -#### Scenario: Add second webhook preserves first - -- **GIVEN** `Notifications.Webhooks` contains one entry `ops-alerts` -- **WHEN** the operator opens the editor, adds a new webhook - `critical-pager`, and saves -- **THEN** `Notifications.Webhooks` is a two-entry array -- **AND** the first entry is byte-identical to its pre-save state - -### Requirement: Inbound Webhooks editor - -The dashboard SHALL include an `InboundWebhooksSectionEditor` -(`SectionId = "Webhooks"`) presenting the feature-flag toggle plus -the request-timeout integer field. Route file editing SHALL remain -file-based and out of this editor's scope. `RelevantDoctorChecks` -SHALL include `ConfigSchemaDoctorCheck` and the existing -`InboundWebhookRoutesDoctorCheck`. - -#### Scenario: Enabling inbound webhooks with no routes surfaces warning - -- **GIVEN** `~/.netclaw/config/webhooks/` contains zero route files -- **WHEN** the operator enables inbound webhooks and saves -- **THEN** `InboundWebhookRoutesDoctorCheck` returns WARN -- **AND** the inline warning banner explains routes must be added via - files -- **AND** Save anyway writes `Webhooks.Enabled = true` - -### Requirement: External Skill Directories editor - -The dashboard SHALL include an `ExternalSkillsSectionEditor` -(`SectionId = "ExternalSkills"`) presenting the existing path array -via the generic `ListEditor<T>` with the `PathItemEditor` inline-edit -shape. The editor SHALL validate each path on save: existence, -directory-ness, readability. Errors SHALL render inline below the -relevant row. `RelevantDoctorChecks` SHALL include -`ConfigSchemaDoctorCheck` and the new -`ExternalSkillSourcesDoctorCheck`. - -#### Scenario: Non-existent path blocks save - -- **GIVEN** the External Skills editor is open with a newly-added path - pointing at a non-existent directory -- **WHEN** the operator saves -- **THEN** `ExternalSkillSourcesDoctorCheck` returns ERROR -- **AND** the row renders the error inline -- **AND** the save is blocked +- **THEN** the full underlying audience profile is reset to posture + defaults +- **AND** hidden MCP and approval settings for that audience are reset as + well + +### Requirement: Exposure Mode preserves current config shape + +The Exposure Mode editor SHALL keep the existing `Daemon` config shape. It +SHALL use `Daemon.ExposureMode` as the single active selector and SHALL NOT +introduce per-mode active flags. + +Supported explicit modes are: + +- `Local` +- `Reverse Proxy` +- `Tailscale Serve` +- `Tailscale Funnel` +- `Cloudflare Tunnel` + +Each non-local mode SHALL use its own mode-specific dialog. `Local` +requires no extra setup. Inactive old values SHALL be preserved and ignored +when inactive. + +#### Scenario: Switching modes preserves inactive values + +- **GIVEN** the config contains previously saved Cloudflare Tunnel values +- **AND** `Daemon.ExposureMode` is currently `Reverse Proxy` +- **WHEN** the operator edits Reverse Proxy settings and saves +- **THEN** the inactive Cloudflare values remain preserved in config +- **AND** the active mode remains determined only by `Daemon.ExposureMode` + +### Requirement: First non-local exposure enablement SHALL bootstrap pairing when needed + +The flow SHALL auto-pair the current configuring client when the operator +first enables a non-local exposure mode from `netclaw config` and no +bootstrap/pairing state exists. + +If bootstrap state is orphaned or mismatched, the flow SHALL block and +direct the operator to `netclaw doctor`, formal docs, and issue `#875`. -### Requirement: Skill Feeds editor +#### Scenario: Missing bootstrap state auto-pairs current client -The dashboard SHALL include a `SkillFeedsSectionEditor` -(`SectionId = "SkillFeeds"`) presenting the existing feed array via -the generic `ListEditor<T>` with the `SkillFeedItemEditor` sub-page -form. Each feed SHALL expose name, URL, optional Bearer API key -(secret-handling contract), and a Test Connection affordance. -`RelevantDoctorChecks` SHALL include `ConfigSchemaDoctorCheck` and -the new `SkillFeedsDoctorCheck` (WARN-only on reachability so transient -remote outages do not lock operators out of editing). +- **GIVEN** the operator enables `Tailscale Serve` +- **AND** no bootstrap or pairing state exists yet +- **WHEN** the save flow runs +- **THEN** the current configuring client is auto-paired before the mode is + finalized -#### Scenario: Unreachable feed surfaces warning but allows save +#### Scenario: Orphaned bootstrap state blocks save -- **GIVEN** the Skill Feeds editor is open with a feed pointing at an - unreachable URL +- **GIVEN** the operator enables a non-local exposure mode +- **AND** existing bootstrap state is orphaned or mismatched +- **WHEN** the save flow validates exposure setup +- **THEN** the save is blocked +- **AND** the operator is directed to `netclaw doctor`, formal docs, and + issue `#875` + +### Requirement: Leaf validation is generalized + +Every config leaf editor SHALL validate what it edits before save. +Validation SHALL cover local structural validity and any relevant probes +such as paths, URIs, auth, binary presence, or remote reachability. + +Structurally invalid config SHALL block save without override. +Runtime/probe failures MAY present `Save anyway`. + +#### Scenario: Structural error blocks save with no override + +- **GIVEN** a leaf editor contains an invalid URI or malformed config + reference - **WHEN** the operator saves -- **THEN** `SkillFeedsDoctorCheck` returns WARN -- **AND** the inline warning banner displays "feed unreachable" -- **AND** activating Save anyway writes the merged config - -### Requirement: Browser Automation editor - -The dashboard SHALL include a `BrowserAutomationSectionEditor` -(`SectionId = "BrowserAutomation"`) presenting the feature-flag toggle -and a status indicator showing whether Playwright is installed and at -which version. If Playwright is not installed, the toggle SHALL be -disabled and an "Install instructions" sub-page SHALL be reachable -from the editor footer. The installation itself SHALL NOT be invoked -from inside the TUI; the sub-page SHALL print platform-appropriate -shell commands and instruct the operator to re-open the editor after -installing. `RelevantDoctorChecks` SHALL include -`ConfigSchemaDoctorCheck` and the new -`BrowserAutomationDoctorCheck`. - -#### Scenario: Toggle disabled when Playwright absent - -- **GIVEN** the Browser Automation editor is open -- **AND** Playwright is not installed on the host -- **WHEN** the editor renders -- **THEN** the `Browser automation enabled` toggle is disabled -- **AND** the editor footer shows `[ Install instructions → ]` - -#### Scenario: Enabling without Playwright blocks save - -- **GIVEN** the Browser Automation editor is open -- **AND** Playwright is not installed -- **AND** the editor is somehow holding `Enabled = true` (e.g. from a - hand-edited file) +- **THEN** save is blocked +- **AND** no `Save anyway` affordance is shown + +#### Scenario: Probe failure offers Save anyway + +- **GIVEN** a leaf editor is structurally valid +- **AND** a remote reachability or runtime probe fails - **WHEN** the operator saves -- **THEN** `BrowserAutomationDoctorCheck` returns ERROR -- **AND** the save is blocked with remediation guidance - -#### Scenario: Existing config without BrowserAutomation section opens cleanly - -- **GIVEN** an existing `netclaw.json` written prior to this change - that lacks a top-level `BrowserAutomation` section -- **WHEN** the operator opens the Browser Automation editor -- **THEN** the editor renders with the toggle reflecting - `Enabled = false` (schema default) -- **AND** no schema-validation error is surfaced for the missing - section -- **AND** the merge writer treats a no-op exit as a true no-op (no - speculative `BrowserAutomation` section is written until the - operator explicitly saves a non-default state) - -#### Scenario: SchemaFixResolver auto-insert tolerates missing section on doctor --fix - -- **GIVEN** an existing `netclaw.json` written prior to this change - that lacks the `BrowserAutomation` section -- **WHEN** the operator runs `netclaw doctor --fix` -- **THEN** `SchemaFixResolver` inserts - `BrowserAutomation: { Enabled: false }` using the schema's default - value -- **AND** subsequent `ConfigSchemaDoctorCheck` runs pass without - warning - -### Requirement: Smoke tape per editor and the no-init refusal - -The smoke-test harness SHALL include a tape per registered section -editor at `tests/smoke/tapes/config-<section-lowercase>.tape` plus a -matching assertion script at -`tests/smoke/assertions/config-<section-lowercase>.sh`. The harness -SHALL also include `config-no-init.tape` and its assertion exercising -the refuse-when-no-config path. Each section-editor tape SHALL -pre-stage existing `netclaw.json` and `secrets.json` fixtures, -exercise at least one save round-trip, and the assertion SHALL verify -the modified field changed and all other top-level sections are -byte-identical. - -#### Scenario: Audit fails when an editor lacks a tape - -- **GIVEN** a newly-added `ISectionEditor` registered in the menu -- **AND** no tape file at `tests/smoke/tapes/config-<sectionid>.tape` -- **WHEN** `MenuRegistryAuditTests` runs -- **THEN** the test fails with a message naming the missing tape path - -#### Scenario: Audience tape exercises arrow nav and toggle - -- **WHEN** `config-audience.tape` runs -- **THEN** the tape sends `↓`, `Space`, `↑`, `Space` keystrokes within - the Team audience editor -- **AND** the assertion verifies the per-feature toggle state in - `Tools.AudienceProfiles.Team` - -#### Scenario: No-config refusal exits non-zero - -- **GIVEN** the smoke test harness stages a `NETCLAW_HOME` containing - no `config/netclaw.json` -- **WHEN** `config-no-init.tape` runs `netclaw config` -- **THEN** the command exits with non-zero status -- **AND** the assertion observes the refusal message on stderr +- **THEN** the editor may show `Save anyway` +- **AND** the operator can choose to persist the structurally valid config + +### Requirement: Coverage follows leaf ownership + +Leaf editors SHALL receive substantive round-trip and smoke coverage. +Routed handoffs SHALL receive shallow routing coverage only. Preservation +assertions SHALL be semantic, not byte-identical. + +#### Scenario: Routed handoff does not require leaf round-trip suite + +- **GIVEN** `Inference Providers` routes to `netclaw provider` +- **WHEN** coverage is defined for the config dashboard +- **THEN** the handoff requires routing coverage +- **AND** it does not require a duplicate leaf-editor round-trip suite in + this change diff --git a/openspec/changes/netclaw-config-command/tasks.md b/openspec/changes/netclaw-config-command/tasks.md index 7b434a9cd..ab79010da 100644 --- a/openspec/changes/netclaw-config-command/tasks.md +++ b/openspec/changes/netclaw-config-command/tasks.md @@ -1,294 +1,133 @@ ## 1. OpenSpec planning artifacts and traceability -- [ ] 1.1 Confirm proposal, design, and spec deltas cover the - `netclaw config` command, the dashboard, ten section editors, the - generic list/item editor framework, the four new doctor checks, the - schema addition for `BrowserAutomation`, twelve smoke tapes plus the - no-init refusal tape, ten round-trip xUnit test classes, and the - hardened menu registry audit. -- [ ] 1.2 Verify traceability references to `PRD-004`, `PRD-001`, and - `PRD-002` across change artifacts. -- [ ] 1.3 Run `openspec validate netclaw-config-command --type change` - and resolve all issues. - -## 2. Schema and configuration types - -- [ ] 2.1 Add a `BrowserAutomation` top-level section to - `src/Netclaw.Configuration/Schemas/netclaw-config.v1.schema.json` - with `Enabled` (bool, default `false`) and `PlaywrightVersion` - (string, optional). Use `additionalProperties: false`. -- [ ] 2.2 Add `src/Netclaw.Configuration/BrowserAutomationConfig.cs` - matching the schema. -- [ ] 2.3 Update existing exemption list / schema-fix entries as needed - so `SchemaFixResolver` can auto-insert `BrowserAutomation` on - upgrade. - -## 3. Dashboard scaffolding - -- [ ] 3.1 Add `src/Netclaw.Cli/Config/ConfigCommand.cs` as the - top-level command class wired into `Netclaw.Cli.Program` routing. -- [ ] 3.2 Add `src/Netclaw.Cli/Tui/Sections/ConfigDashboardPage.cs` and - `ConfigDashboardViewModel.cs` rendering each `ISectionEditor` from - the registry, plus "Run full doctor" and "Quit" items. -- [ ] 3.3 Implement per-section status badge computation at dashboard - entry (runs each editor's `RelevantDoctorChecks` against on-disk - config and caches results until the editor saves). -- [ ] 3.4 Implement category grouping (siblings sharing `Category` - render under a single unselectable label). -- [ ] 3.5 Implement no-config refusal path: detect missing - `netclaw.json` at startup, print refusal to stderr, exit non-zero. -- [ ] 3.6 Implement daemon-restart nudge: detect running daemon at - exit; print stderr line only when (a) at least one section saved - during the session AND (b) the daemon is running. - -## 4. Generic list/item editor framework - -- [ ] 4.1 Add `src/Netclaw.Cli/Tui/Sections/Components/IItemEditor.cs` - with `DisplayRow`, `KeyOf`, `RequiresSubPage`, - `CreateSubPageEditor`, `EditInline`, `AddInline`. -- [ ] 4.2 Add `src/Netclaw.Cli/Tui/Sections/Components/ListEditor.cs` - implementing add (inline `+ Add` row), edit (inline or sub-page - depending on item editor), remove (single-key `d` then `[y/N]` - prompt), Save / Cancel, in-place rename via `KeyOf` semantics. -- [ ] 4.3 Add `PathItemEditor` (inline string edit; validates path - existence/readability lazily on parent save). -- [ ] 4.4 Add `IdentifierItemEditor` (inline string edit; used by - channel-ID lists, user-ID lists, trusted-proxy CIDR list). -- [ ] 4.5 Add `WebhookItemEditor` (sub-page form: name, URL, optional - auth-header secret-handling, optional event filter). -- [ ] 4.6 Add `SkillFeedItemEditor` (sub-page form: name, URL, - optional Bearer API key secret-handling, Test Connection - affordance). - -## 5. Shared editor components - -- [ ] 5.1 Add `ValidationBanner` component for the inline - errors-and-warnings band above the action row. -- [ ] 5.2 Add `DiscardChangesPrompt` (used on Esc-with-dirty-state in - any editor). -- [ ] 5.3 Add `RemoveCredentialPrompt` (default-Cancel modal confirm - for any secret removal). - -## 6. Section editors — single-value - -These editors REUSE existing step viewmodels where possible. Each -existing step viewmodel is REFACTORED to implement `ISectionEditor` -(per Change A's contract) and is moved into the new folder structure -under `src/Netclaw.Cli/Tui/Sections/<Section>/`. No new duplicate -classes are created for sections that today have an init step -viewmodel; the same class serves both init (when in the trimmed step -list, post Change C) and `netclaw config` (single-step mode). - -- [ ] 6.1 `SearchSectionEditor` (`SectionId = "Search"`, - `ShowInMenu = true`): refactor of existing `SearchStepViewModel`. - Backend selector + conditional API key / SearXng URL fields. Honor - `ExistingConfig`. `RelevantDoctorChecks`: - `{ConfigSchemaDoctorCheck, SearchBackendDoctorCheck}`. -- [ ] 6.2 `SecurityPostureSectionEditor` - (`SectionId = "Security.Posture"`, `ShowInMenu = true`): refactored - to `ISectionEditor` in Change A; this change adds the cascade dialog - (Cancel | Overwrite | Keep custom) when changing posture over - customized `Tools.AudienceProfiles`. -- [ ] 6.3 `AudienceProfilesSectionEditor` - (`SectionId = "Tools.AudienceProfiles"`, `ShowInMenu = true`): NEW - editor (no init-step equivalent — the buggy `FeatureSelectionStepViewModel` - is replaced by this editor). Audience picker (Personal | Team | Public) - opening per-audience editor with toggleable feature rows, - shell-mode selector, approval policy selector, and "Reset to - posture default" affordance. MUST exercise arrow nav + Space toggle - (#1150 contract). -- [ ] 6.4 `InboundWebhooksSectionEditor` (`SectionId = "Webhooks"`, - `ShowInMenu = true`): NEW editor. Feature-flag toggle + request - timeout integer. -- [ ] 6.5 `BrowserAutomationSectionEditor` - (`SectionId = "BrowserAutomation"`, `ShowInMenu = true`): refactor - of existing `BrowserAutomationStepViewModel`. Feature-flag toggle - with Playwright detection at entry; install-instructions sub-page - when Playwright absent. - -## 7. Section editors — multi-value (compose ListEditor) - -- [ ] 7.1 `OutboundWebhooksSectionEditor` - (`SectionId = "Notifications.Webhooks"`, `ShowInMenu = true`): NEW - editor. Uses `WebhookItemEditor`. -- [ ] 7.2 `ExternalSkillsSectionEditor` - (`SectionId = "ExternalSkills"`, `ShowInMenu = true`): refactor of - existing `ExternalSkillsStepViewModel`. Uses `PathItemEditor`. -- [ ] 7.3 `SkillFeedsSectionEditor` (`SectionId = "SkillFeeds"`, - `ShowInMenu = true`): refactor of existing `SkillFeedsStepViewModel`. - Uses `SkillFeedItemEditor`. - -## 8. Section editors — chat channels (composite) - -- [ ] 8.1 `SlackSectionEditor` (`SectionId = "Slack"`, - `Category = "Chat Channels"`, `ShowInMenu = true`): refactor of - existing `SlackStepViewModel`. Bot token + app token, allowed - channels list, allowed users list, DMs toggle, audience profile - selector, Test Connection. Reuses `channel-audience-tui` cycling - component for the channel list. -- [ ] 8.2 `DiscordSectionEditor` (`SectionId = "Discord"`, - `Category = "Chat Channels"`, `ShowInMenu = true`): refactor of - existing `DiscordStepViewModel`. Single bot token, same affordances - otherwise. -- [ ] 8.3 `MattermostSectionEditor` (`SectionId = "Mattermost"`, - `Category = "Chat Channels"`, `ShowInMenu = true`): refactor of - existing `MattermostStepViewModel`. Server URL + bot token, same - affordances otherwise. - -## 9. Section editor — exposure mode (composite) - -- [ ] 9.1 `ExposureModeSectionEditor` - (`SectionId = "Daemon.ExposureMode"`, `ShowInMenu = true`): refactor - of existing `ExposureModeStepViewModel`. Mode selector (Local | - Reverse Proxy | Tailscale | Cloudflare Tunnel), daemon host/port - fields, mode-conditional sub-forms. -- [ ] 9.2 Reverse Proxy sub-form: external base URL + trusted - proxies list (via `ListEditor<T>` + `IdentifierItemEditor`). -- [ ] 9.3 Tailscale sub-form: auth key (secret) + hostname. -- [ ] 9.4 Cloudflare Tunnel sub-form: tunnel token (secret) + - optional access-policy email domain. -- [ ] 9.5 Add `Daemon` to `SectionEditorExemptions` with category - `"covered by another editor's dotted-path SectionId"` naming - `Daemon.ExposureMode` as the owner. The non-exposure parts of - `Daemon` (host, port, trusted proxies) are part of the - ExposureModeSectionEditor's surface. -- [ ] 9.6 Add `Security` to `SectionEditorExemptions` with category - `"covered by another editor's dotted-path SectionId"` naming - `Security.Posture`. -- [ ] 9.7 Add `Tools` to `SectionEditorExemptions` with category - `"covered by another editor's dotted-path SectionId"` naming - `Tools.AudienceProfiles`. - -## 10. New doctor checks - -- [ ] 10.1 `SearchBackendDoctorCheck` (validates backend ↔ required - credential pairing; ERROR when Brave/SearXng configured without - required field). -- [ ] 10.2 `ExternalSkillSourcesDoctorCheck` (validates each path is - an existing readable directory). -- [ ] 10.3 `SkillFeedsDoctorCheck` (validates URL reachability; - WARN-only — transient outages don't block saves). -- [ ] 10.4 `BrowserAutomationDoctorCheck` (ERROR when - `BrowserAutomation.Enabled = true` and Playwright binary not - resolvable from PATH). -- [ ] 10.5 Register each new check via the existing doctor - registration extensions so they participate in - `netclaw doctor` runs. - -## 11. DI wiring - -- [ ] 11.1 Register all ten new editors via - `services.AddSectionEditor<TEditor>()` in the CLI DI composition - root. -- [ ] 11.2 Confirm registry construction fails fast on any duplicate - `SectionId`. -- [ ] 11.3 Wire `ConfigCommand` into the CLI top-level command - dispatch. - -## 12. Round-trip xUnit tests (Layer 2) - -- [ ] 12.1 `SearchSectionEditorTests` covering single-value path and - the DuckDuckGo ↔ Brave backend switch preserves Brave key - scenario. -- [ ] 12.2 `SlackSectionEditorTests` covering reentrancy across - channel-list + user-list + secret-handling for both tokens. -- [ ] 12.3 `DiscordSectionEditorTests`. -- [ ] 12.4 `MattermostSectionEditorTests` (incl. server URL field). -- [ ] 12.5 `ExposureModeSectionEditorTests` covering all four mode - sub-forms. -- [ ] 12.6 `SecurityPostureSectionEditorTests` covering all three - cascade options. -- [ ] 12.7 `AudienceProfilesSectionEditorTests` covering toggle - rount-trip and posture-default reset. -- [ ] 12.8 `OutboundWebhooksSectionEditorTests` covering add / - edit / remove / in-place rename preserves item identity. -- [ ] 12.9 `InboundWebhooksSectionEditorTests`. -- [ ] 12.10 `ExternalSkillsSectionEditorTests` (incl. invalid-path - inline validation). -- [ ] 12.11 `SkillFeedsSectionEditorTests` (incl. WARN-only reachability - behavior). -- [ ] 12.12 `BrowserAutomationSectionEditorTests` (incl. - toggle-disabled-when-absent behavior). - -## 13. Smoke tapes (Layer 1) - -- [ ] 13.1 `config-search.tape` + assertion: pre-stage Brave + key, - switch to DuckDuckGo, save, assert backend=duckduckgo and Brave - key preserved. -- [ ] 13.2 `config-slack.tape` + assertion: pre-stage tokens + 2 - channels, add 1 channel, save, assert 3 channels and tokens - unchanged. -- [ ] 13.3 `config-discord.tape` + assertion. -- [ ] 13.4 `config-mattermost.tape` + assertion (incl. URL + token + - channel). -- [ ] 13.5 `config-exposure-mode.tape` + assertion: pre-stage Local, - switch to Reverse Proxy, add CIDR, save, assert mode and CIDR - changes plus byte-equal unrelated sections. Migrates coverage - from former `init-wizard-reverse-proxy.tape`. -- [ ] 13.6 `config-posture.tape` + assertion: change Personal → - Team, accept cascade, save, assert posture and audience-default - changes. -- [ ] 13.7 `config-audience.tape` + assertion: exercise `↓`, - `Space`, `↑`, `Space` keystrokes on Team audience editor, save, - assert `Tools.AudienceProfiles.Team` toggle state. This tape is - the #1150 regression guard. -- [ ] 13.8 `config-outbound-webhooks.tape` + assertion: pre-stage 1 - webhook, add 2nd via sub-page, save, assert array length 2 and - first byte-identical. -- [ ] 13.9 `config-inbound-webhooks.tape` + assertion. -- [ ] 13.10 `config-external-skills.tape` + assertion: pre-stage 1 - path, add 1 + remove the original via `d`, save, assert single - remaining new entry. -- [ ] 13.11 `config-skill-feeds.tape` + assertion: pre-stage empty, - add 1 feed with Bearer key via sub-page, save, assert feed in - config + key in secrets. -- [ ] 13.12 `config-browser-automation.tape` + assertion: pre-stage - Playwright absent, open install instructions, exit without save, - assert no config write. -- [ ] 13.13 `config-no-init.tape` + assertion: stage empty - `NETCLAW_HOME`, run `netclaw config`, assert non-zero exit and - stderr refusal message. - -## 14. Menu registry audit promotion - -- [ ] 14.1 In `MenuRegistryAuditTests`, flip the smoke-tape - existence check from soft-warn to hard-fail. The test asserts a - matching tape file at `tests/smoke/tapes/config-<sectionid>.tape` - for every registered editor. -- [ ] 14.2 Update the audit's failure-message text to name (a) the - editor's `SectionId`, (b) the missing artifact path, (c) the - remediation step ("add a tape" / "add a test class" / "declare - `RelevantDoctorChecks` or `[NoDoctorChecks]`"). - -## 15. PRD-004 update - -- [ ] 15.1 Update `docs/prd/PRD-004-cli-onboarding-and-config.md`: - replace the "reentrant init dashboard" wording with the - simplified-init + `netclaw config` split. List the ten section - editors as the menu surface. -- [ ] 15.2 Cross-reference issues #455 (closed in Change A) and - #1150 (closed in this change). - -## 16. Quality gates - -- [ ] 16.1 `dotnet build` clean. -- [ ] 16.2 `dotnet test` clean: all round-trip tests pass; audit - passes (every registered editor has tape + test class + doctor - checks); existing tests remain green. -- [ ] 16.3 `./scripts/smoke/run-smoke.sh light` clean (all 12 new - config tapes plus the no-init refusal tape pass). -- [ ] 16.4 `dotnet slopwatch analyze` reports no new violations. -- [ ] 16.5 `./scripts/Add-FileHeaders.ps1 -Verify` reports clean. -- [ ] 16.6 `openspec validate netclaw-config-command --type change` +- [ ] 1.1 Confirm proposal, design, and spec deltas reflect the + domain-oriented config IA and the locked ownership split. +- [ ] 1.2 Remove planning language that still assumes Enterprise posture, + per-audience runtime feature toggles, per-audience shell mode, inline + MCP permission editing, flat dashboards, or byte-identical assertions. +- [ ] 1.3 Run `openspec validate netclaw-config-command --type change`. + +## 2. Command entry and refusal behavior + +- [ ] 2.1 Add `netclaw config` to CLI routing. +- [ ] 2.2 Refuse with a plain non-zero message when no install/config is + present: direct operators to `netclaw init` and render no TUI. +- [ ] 2.3 Keep `--help` discoverable from `netclaw --help`. + +## 3. Root dashboard IA + +- [ ] 3.1 Implement the root dashboard as domain navigation, not a flat + list of every leaf editor. +- [ ] 3.2 Add these root entries: Inference Providers, Models, Channels, + Inbound Webhooks, Skill Sources, Search, Browser Automation, + Telemetry & Alerting, Security & Access. +- [ ] 3.3 Add Quit and Run Full Doctor affordances at the root. + +## 4. Routed handoffs + +- [ ] 4.1 Route `Inference Providers` to `netclaw provider`. +- [ ] 4.2 Route `Models` to `netclaw model`. +- [ ] 4.3 Add shallow routing coverage for both handoffs. + +## 5. Channels area + +- [ ] 5.1 Add `Channels` sub-page containing Slack, Discord, Mattermost. +- [ ] 5.2 Keep each channel editor as a leaf with substantive validation + and round-trip coverage. + +## 6. Skill Sources area + +- [ ] 6.1 Add `Skill Sources` sub-page containing External Skills and + Skill Feeds. +- [ ] 6.2 Keep validation for paths, URIs, auth, and reachability aligned + to the generalized save-validation rule. + +## 7. Telemetry & Alerting area + +- [ ] 7.1 Add `Telemetry & Alerting` sub-page. +- [ ] 7.2 Include Telemetry and Outbound Webhooks only in this pass. +- [ ] 7.3 Defer delivery-policy tuning. + +## 8. Security & Access area + +- [ ] 8.1 Add `Security & Access` sub-page. +- [ ] 8.2 Include Security Posture, Enabled Features, Audience Profiles, + and Exposure Mode. +- [ ] 8.3 Keep posture values to `Personal`, `Team`, and `Public` only. + +## 9. Security Posture leaf + +- [ ] 9.1 Keep Security Posture distinct from Enabled Features and + Audience Profiles. +- [ ] 9.2 When posture changes to Team or Public, continue into Enabled + Features. +- [ ] 9.3 When posture changes to Personal, skip the Enabled Features + continuation. +- [ ] 9.4 Support overwrite/reset behavior that resets the full underlying + audience profile when requested. + +## 10. Enabled Features leaf + +- [ ] 10.1 Implement Enabled Features as deployment-wide runtime + enablement. +- [ ] 10.2 Do not represent Enabled Features as per-audience policy. +- [ ] 10.3 Cover runtime-enablement editing with substantive round-trip and + smoke tests. + +## 11. Audience Profiles leaf + +- [ ] 11.1 Implement Audience Profiles as a curated high-level editor. +- [ ] 11.2 Remove per-audience feature toggles from this editor. +- [ ] 11.3 Remove per-audience shell mode from this editor. +- [ ] 11.4 Limit editable concerns to Tool Access (non-MCP), File Access, + Incoming Attachments, and Reset to posture default. +- [ ] 11.5 Ensure reset/overwrite resets the full underlying audience + profile, including hidden MCP and approval settings. +- [ ] 11.6 Route MCP access/grants/approval editing to + `netclaw mcp permissions` instead of recreating it here. + +## 12. Exposure Mode leaf + +- [ ] 12.1 Implement explicit modes: Local, Reverse Proxy, + Tailscale Serve, Tailscale Funnel, Cloudflare Tunnel. +- [ ] 12.2 Keep a single active selector via `Daemon.ExposureMode`. +- [ ] 12.3 Do not add per-mode active flags. +- [ ] 12.4 Keep the existing `Daemon` config shape; do not rearrange + config sections. +- [ ] 12.5 Preserve inactive old values and ignore them when inactive. +- [ ] 12.6 Give each non-local mode its own dialog; Local requires no + extra setup. +- [ ] 12.7 Do not add new persisted exposure-specific fields that do not + exist in the current config shape. +- [ ] 12.8 On first non-local enablement, auto-pair the current + configuring client when no bootstrap/pairing state exists. +- [ ] 12.9 If bootstrap state is orphaned or mismatched, block and point + the operator to `netclaw doctor`, formal docs, and issue `#875`. + +## 13. Validation model + +- [ ] 13.1 Apply generalized pre-save validation to every leaf editor. +- [ ] 13.2 Validate paths, URIs, auth, binary presence, local references, + and remote reachability where relevant. +- [ ] 13.3 Keep structurally invalid config as a hard block. +- [ ] 13.4 Allow `Save anyway` only for runtime/probe failures. +- [ ] 13.5 Update planning/tests around `#1151` so validation is framed as + a cross-editor rule, not just a narrow search regression. + +## 14. Coverage + +- [ ] 14.1 Add substantive round-trip tests for leaf editors. +- [ ] 14.2 Add substantive smoke tapes for leaf editors. +- [ ] 14.3 Use semantic preservation assertions, not byte-identical file + assertions. +- [ ] 14.4 Add shallow routing coverage for routed handoffs only. + +## 15. Quality gates + +- [ ] 15.1 `dotnet build` clean. +- [ ] 15.2 `dotnet test` clean. +- [ ] 15.3 `./scripts/smoke/run-smoke.sh light` clean. +- [ ] 15.4 `dotnet slopwatch analyze` clean. +- [ ] 15.5 `./scripts/Add-FileHeaders.ps1 -Verify` clean. +- [ ] 15.6 `openspec validate netclaw-config-command --type change` passes. - -## 17. Documentation - -- [ ] 17.1 Update CLI `--help` text for `netclaw config` so the - command is discoverable from `netclaw --help`. -- [ ] 17.2 Update `feeds/skills/.system/files/netclaw-operations/SKILL.md` - per CLAUDE.md system-skills sync rule, adding a section that - describes `netclaw config` and the ten editable sections. Bump - `metadata.version`. -- [ ] 17.3 PR description closes #1150 and references this OpenSpec - change ID. diff --git a/openspec/changes/section-editor-abstraction/design.md b/openspec/changes/section-editor-abstraction/design.md index d7c2de416..5300a82a1 100644 --- a/openspec/changes/section-editor-abstraction/design.md +++ b/openspec/changes/section-editor-abstraction/design.md @@ -1,245 +1,108 @@ ## Context -**UI wireframes:** SecurityPosture's appearance inside `netclaw config` -is in `docs/ui/TUI-002-netclaw-config-wireframes.md` (§ Config.6). -Provider and Identity remain init-only and their wireframes are in -`docs/ui/TUI-003-simplified-init-wireframes.md` (§ Init.1, Init.2) -once Change C lands; for this change they continue to use the prior -init wizard wireframes documented in `docs/ui/TUI-001-command-wireframes.md`. - -The `netclaw init` wizard composed of `WizardOrchestrator` + a fixed list of -`IWizardStepViewModel`s produces a runnable Netclaw configuration but treats -the on-disk state as a write-once target. There is no shared abstraction for -"the editable surface of one configuration section," so every section's input -collection, validation, and persistence logic lives inline in its step -viewmodel. Three foundations from PR #432 partially anticipate the shared -abstraction: - -- `WizardContext.ExistingConfig` is declared on the context object but - never populated. -- `ConfigFileHelper` and `ProviderCredentialWriter` already implement the - load-merge-write pattern, used today by `netclaw provider`/`model`/`mcp` - CLI subcommands. -- Each `IWizardStepViewModel.OnEnter(context, direction)` already receives a - direction marker, but no step uses it. - -This change formalizes the shared abstraction so the next change can compose -existing step viewmodels into the new `netclaw config` command without -forking their logic. It also closes the long-standing reentrancy gap (#455): -re-running `netclaw init` over an existing install now produces a sensible -pre-filled wizard with merge-on-save semantics, rather than the prior -undefined behavior. +This change defines the reusable leaf-editor contract that both +bootstrap-time init flows and the later `netclaw config` command will use. +The locked product shape matters: + +- `netclaw init` is bootstrap, not the main editor. +- `netclaw config` is the main post-install surface. +- Identity stays init-owned. + +So the abstraction should model reusable leaf editors and semantic writes, +not a specific top-level dashboard layout. ## Goals / Non-Goals **Goals:** -- Define `ISectionEditor` such that any step viewmodel implementing it can - be hosted either by the linear init wizard or by a single-step - orchestrator that the next change introduces, with no per-host behavior - difference visible to the user. -- Lock in three operational contracts that future section editors must - honor: reentrancy (pre-fill from `ExistingConfig`), secret handling - (never rehydrate; "leave blank to keep"), and merge-on-save - (byte-equality of every other top-level section). -- Establish the audit + test harness up-front so the contracts are enforced - from the first registered editor, not retrofitted later when drift has - already begun. -- Refactor Provider, Identity, and Posture step viewmodels to implement - `ISectionEditor`. Behavior inside today's linear init wizard remains - observable-equivalent for first-run. -- Close #455 (reentrant init) as a byproduct of populating `ExistingConfig` - at entry and switching `WizardConfigBuilder` to merge-on-save. +- Define `ISectionEditor` as the reusable leaf-editor contract. +- Support init-owned editors and config-owned editors without forcing them + all into one menu. +- Preserve existing config semantically on save, including inactive + exposure-mode values and unrelated sections. +- Keep secrets masked and non-rehydratable. +- Refactor the bootstrap leaves that matter to the locked split: + Provider, Identity, Security Posture, Enabled Features. **Non-Goals:** -- Introducing the `netclaw config` command (next change). -- Adding the remaining seven section editors (next change). -- Simplifying the init wizard's step list to provider + identity + - posture only (third change). -- Hot-reload of the running daemon on config change (out of scope; remains - a documented manual-restart limitation). -- Section editor UI for sections that today are file-edited only - (`Persistence`, `Logging`, `Telemetry`, etc.) — these stay on the - exemption list. -- Reworking `netclaw provider`/`model`/`mcp` CLI subcommands to share - backing logic with the new abstraction. Their existing behavior is - unchanged; future work may unify them. +- Defining the `netclaw config` IA. +- Making Identity editable from `netclaw config`. +- Forcing all schema sections into TUI editors. +- Byte-identical JSON preservation. ## Decisions -### D1. `ISectionEditor` as a viewmodel factory, not a viewmodel base class - -The interface returns an `IWizardStepViewModel` from `CreateEditor(context)` -rather than extending the existing viewmodel base. This keeps the -orchestrator's lifecycle contract authoritative and avoids multiple -inheritance / diamond issues for step viewmodels that already extend a -shared base. It also lets a single `ISectionEditor` produce different -viewmodels for different contexts in the future (e.g. a future -"compact" view) without changing the interface. - -Alternative considered: make `ISectionEditor` itself extend -`IWizardStepViewModel`. Rejected because it conflates "this thing is a -runnable step" with "this thing describes an editable section in the -registry"; the dashboard and audit code want the metadata without -constructing a runnable step. - -### D2. Merge-on-save via existing `ConfigFileHelper` primitives - -`WizardConfigBuilder` is refactored to call `ConfigFileHelper.LoadConfigFiles` -and `GetOrCreateSection` rather than building a fresh dictionary. The -existing primitives have already been proven by `ProviderCredentialWriter` -and the CLI subcommands; no new merge code is introduced. Each editor -contributes via an explicit `SectionContribution` record carrying -`Dictionary<string, FieldAction>` for non-secrets and -`Dictionary<string, SecretAction>` for secrets. The merge writer applies -the actions deterministically; "blank means X" is the editor's job to -interpret, not the merge layer's. - -Alternative considered: introduce a fresh JSON-patch-style operation log. -Rejected because the existing dictionary-based pattern is already in -production use and a parallel mechanism would introduce a forking point. - -### D3. Secret-presence lookup as a first-class API - -`ConfigFileHelper.SecretPresent(paths, sectionId, key)` is added to satisfy -the "configured / not set" hint without exposing the decrypted value. This -keeps the secret-handling contract enforceable at the type level: editors -that need to show the hint cannot accidentally hold the decrypted value -because the API does not return one. - -Alternative considered: have editors call the secrets protector and discard -the decrypted value after a length check. Rejected because the decrypted -value would still transit through process memory; a presence-only API -guarantees the value is never decrypted at all. - -### D4. Audit walks the menu registry, not the full schema - -`MenuRegistryAuditTests` walks `SectionEditorRegistry.All()`. Schema -sections without a registered editor are not audited unless they appear -in the exemption list. The audit's purpose is to enforce contracts on -editors we ship, not to demand editors for every schema knob; the -exemption list is the explicit "we know about this section and choose -not to expose it" record. - -The audit distinguishes three kinds of editor: - -- **`ShowInMenu == true` editors with a top-level `SectionId`** (e.g. - `Search`, `Slack`). Require: round-trip test class, non-empty - `RelevantDoctorChecks` (or `[NoDoctorChecks]`), AND a smoke tape at - `tests/smoke/tapes/config-<sectionid-lower>.tape` (once the - `netclaw config` dashboard exists from the next change). -- **`ShowInMenu == true` editors with a dotted-path `SectionId`** (e.g. - `Security.Posture`, `Daemon.ExposureMode`, `Tools.AudienceProfiles`). - Same requirements as above. The top-level parent section (e.g. - `Security`) must appear in `SectionEditorExemptions` with a - "covered by another editor" entry naming the dotted-path editor as - the canonical owner. -- **`ShowInMenu == false` editors** (e.g. `Providers`, `Identity`). - Require: round-trip test class and `RelevantDoctorChecks`. Smoke-tape - existence is NOT required — these editors run inside the init wizard - (covered by `init-wizard.tape`) or via dedicated CLI subcommands - (covered by their respective tapes). - -The synthetic-identifier case (e.g. `Identity`, which spans several -schema sections rather than owning one) is treated as `ShowInMenu == -false` and must appear in the exemption list with category -`"synthetic-spans-multiple-sections"` so reviewers can see it's not a -real schema key. - -Alternative considered: walk the schema and require every top-level -section to either have an editor or an exemption. Rejected per planning -discussion: forcing editors for every schema knob produces shallow, -unhelpful UIs for sections nobody edits via TUI. The menu-driven audit -prevents drift on the surfaces we promise to users, which is the failure -mode that actually matters. - -### D5. Refactor exactly three editors in this change - -Provider, Identity, and Posture are the three steps that survive in the -simplified init wizard (third change). Refactoring them here lets us -verify the abstraction end-to-end against real editors without entangling -this change with the larger config-command surface. The remaining seven -editors are introduced as new `ISectionEditor` implementations in the -next change, alongside the dashboard that hosts them. - -Alternative considered: refactor all ten existing init steps at once. -Rejected because it bloats this PR and ties the abstraction's correctness -to behavioral equivalence across far more surface area than necessary to -prove the contract. - -### D6. `ExistingConfig` is `Dictionary<string, object>`, not strongly typed - -Reuses the type already declared on `WizardContext`. Strongly-typed access -would require introducing a parallel typed view of `netclaw.json`, which -defeats the schema-as-source-of-truth principle. The dictionary form is -also forgiving across schema versions: an unknown key simply doesn't -surface in any editor's slice. - -Alternative considered: bind to typed `*Config` records via -`IConfiguration`. Rejected because the merge step would then need to -re-emit the typed records as JSON, multiplying the round-trip surface -area and introducing per-property null/default ambiguity. - -### D7. `WizardOrchestrator` gets a single-step constructor, not a new class - -Existing orchestration logic (back/forward, dirty tracking, save flow) -already covers the single-step case; we add a constructor and a mode -flag rather than a parallel orchestrator type. This keeps the -orchestrator the single authority on step lifecycle. - -Alternative considered: introduce `SectionEditorRunner` as a separate -host. Rejected because behavior would inevitably drift between two -orchestrators over time. +### D1. The abstraction is for leaf editors, not dashboard IA -## Risks / Trade-offs +`ISectionEditor` describes the smallest reusable editable surface. The next +change may compose those leaves under domain pages such as `Channels` or +`Security & Access`, or route specific nodes to existing commands such as +`netclaw provider` and `netclaw model`. -- [Refactor risk] Touching three existing step viewmodels could regress - first-run init behavior. → Mitigation: existing `init-wizard.tape` - smoke test continues to gate every PR. Round-trip xUnit tests added in - this change provide finer-grained protection than the tape alone. - -- [Merge-on-save regressions] If the merge logic loses precision on edge - shapes (`JsonElement` value kinds, nested arrays), unrelated sections - could silently change. → Mitigation: round-trip tests assert - byte-equality of unmodified sections. The existing `ConfigFileHelper` - already handles the JsonElement coercion path; we extend its coverage, - not rewrite it. - -- [Vacuous audit] At the end of this change, the registry contains only - three editors and the audit asserts a small surface. The audit's value - scales with the next change. → Mitigation: the audit is wired now so - that adding any editor in the next change automatically tightens the - enforcement; no follow-up wiring step is required. - -- [Secrets in `ExistingConfig`] The parsed `netclaw.json` may include - schema fields that are themselves sensitive (e.g. allowed user IDs, - email domains). → Mitigation: only `secrets.json` is exempted from - context loading; non-secret PII present in `netclaw.json` is no more - exposed than today. Section editors that render lists of IDs already - display them in clear; this is unchanged. - -- [Schema sections added without registry update] Future schema additions - not in the exemption list and not bound to an editor would fail the - audit immediately on their first PR. → Mitigation: this is the intended - behavior. The exemption list is updated in the same PR that adds the - schema section. +Alternative considered: make the registry shape equal the config dashboard +shape. Rejected because the locked IA is domain-oriented and heavier on +sub-pages, while the reusable abstraction is leaf-oriented. -## Migration Plan +### D2. Merge-on-save is semantic, not byte-identical + +The merge layer preserves the meaning of unrelated sections and inactive +values, but ordering, whitespace, and exact serialized shape are not part of +the contract. Tests compare semantics, not raw file bytes. + +Alternative considered: keep byte-identical guarantees. Rejected because the +locked product decisions explicitly require semantic round-tripping and +inactive-value preservation without turning formatting into a compatibility +surface. + +### D3. Existing config loading supports init-owned re-entry, not init-as-editor + +`WizardContext.ExistingConfig` is populated when an init-owned flow needs to +re-enter existing state. This supports things like identity re-entry and +shared bootstrap leaves, but does not commit the product to "re-run init to +edit everything". + +Alternative considered: frame this change as full init reentrancy. Rejected +because the locked split moves ongoing editing to `netclaw config`. -This change is internal-only and observable behavior is preserved for -first-run init. No data migration is required. The deploy story: +### D4. Identity is synthetic and permanently init-owned in this branch -1. Land this change. `netclaw init` continues to behave identically for - first-run installs; re-runs over existing config now pre-populate - fields and merge on save (previously undefined). -2. The next change introduces `netclaw config`. No further migration - needed. +Identity spans config plus generated identity files, so it keeps a synthetic +`SectionId` and `ShowInMenu = false`. The config dashboard must not surface +Identity as just another settings page. + +### D5. Enabled Features is a separate reusable leaf from Security Posture + +Security Posture, Enabled Features, and Audience Profiles are distinct +concepts. This change therefore refactors posture and enabled-features as +separate leaves rather than encoding runtime feature enablement inside the +posture editor. + +### D6. Audit scope is registered leaf editors only + +Registered leaf editors require round-trip tests and validation contracts. +Future routed handoff entries are not leaf editors and only need shallow +routing coverage in the config command change. + +## Risks / Trade-offs + +- Refactoring four existing bootstrap leaves can regress init behavior. + Mitigation: keep init smoke coverage and add leaf-level round-trip tests. +- Semantic merge assertions are less strict than byte equality. + Mitigation: test meaningful preservation of unrelated values, + hidden/inactive values, and secrets behavior. +- A synthetic Identity editor can be confusing to reviewers. + Mitigation: keep the exemption entry explicit and document that Identity + remains init-owned. + +## Migration Plan -Rollback: revert the change. `WizardContext.ExistingConfig` returns to its -declared-but-unused state. `WizardConfigBuilder` returns to overwrite. -First-run behavior is unaffected. +1. Land the abstraction and the four leaf refactors. +2. The next change composes those leaves into the domain-oriented + `netclaw config` dashboard. +3. The third change constrains `netclaw init` to bootstrap-only behavior. ## Open Questions -None at execution time. All architectural decisions are locked above. +None. The abstraction is intentionally narrower after the locked product +decisions. diff --git a/openspec/changes/section-editor-abstraction/proposal.md b/openspec/changes/section-editor-abstraction/proposal.md index c5c746cea..1c6ec8455 100644 --- a/openspec/changes/section-editor-abstraction/proposal.md +++ b/openspec/changes/section-editor-abstraction/proposal.md @@ -1,117 +1,99 @@ ## Why -Netclaw's `netclaw init` wizard is a linear forward-pass over a hardcoded step -sequence with no reentrancy: re-running it over an existing install is -undefined, and changing one configuration knob requires editing -`netclaw.json` by hand. Existing single-section CLI editors -(`netclaw provider`, `netclaw model`, `netclaw mcp`) prove the load-merge-write -pattern works, but they duplicate logic with the wizard rather than sharing it. -This change introduces the shared abstraction that both the init wizard and a -forthcoming `netclaw config` command (next change) will compose, completes the -long-deferred reentrancy of `netclaw init` (#455), and makes future config -knobs reentrant by construction. - -Source PRDs: `PRD-004-cli-onboarding-and-config.md`, `PRD-001-netclaw-mvp.md`. +Netclaw needs one reusable editing contract for bootstrap-only init flows +and for the heavier post-install `netclaw config` command, but the product +split is now locked: + +- `netclaw init` is first-run bootstrap and then rarely used again. +- `netclaw config` is the main post-install settings surface. +- Identity remains `netclaw init` owned. + +That means the shared abstraction cannot assume a flat dashboard, cannot +assume every section is menu-editable, and cannot promise byte-identical +JSON preservation as a product contract. It needs to support reusable leaf +editors, routed handoffs, semantic merge-on-save, and init-owned surfaces +that are intentionally absent from `netclaw config`. + +Source PRDs: `PRD-004-cli-onboarding-and-config.md`, +`PRD-001-netclaw-mvp.md`. ## What Changes -- Add a `ISectionEditor` interface in `Netclaw.Cli.Tui.Sections`. Each instance - describes one editable configuration section: schema-keyed identity, - dashboard summary, status badge computation, relevant doctor checks, and a - factory that returns a `IWizardStepViewModel` runnable either by the wizard - orchestrator or standalone. -- Add `SectionEditorRegistry`, `SectionStatus`, `SectionContribution` - (carrying explicit `FieldAction` and `SecretAction` per field), and - `SectionEditorExemptions` (documented opt-outs for schema sections that - intentionally have no TUI editor). -- Add a single-step constructor to `WizardOrchestrator` so a section editor can - be run outside the linear wizard with the same lifecycle, save, and cancel - semantics. -- Populate `WizardContext.ExistingConfig` at `netclaw init` entry when an - existing `netclaw.json` is present. Each refactored section editor's - `OnEnter()` pre-fills non-secret fields from its slice. -- Switch `WizardConfigBuilder.WriteConfigFile()` from "build fresh + - overwrite" to "load existing + merge + write," matching the pattern already - used by `ProviderCredentialWriter`. Apply the same load-merge-write rule to - the secrets writer. -- Refactor three existing init step viewmodels — Provider, Identity, - SecurityPosture — to implement `ISectionEditor`. Behavior inside the linear - init wizard is unchanged for first-run; reentrant pre-population is gained - for the next change's config command. -- Establish day-one reentrancy contracts in code: secrets never rehydrate - to screen (masked input with "leave blank to keep" semantics), and - section saves preserve every other top-level section in `netclaw.json` and - `secrets.json` byte-for-byte. -- Add a `MenuRegistryAuditTests` xUnit test that walks the registry and - asserts each registered editor declares non-empty `RelevantDoctorChecks` - (or carries an explicit `[NoDoctorChecks]` justification attribute), has a - registered round-trip test class, and — once the config command lands in - the next change — has a matching smoke tape. In this change the audit runs - vacuously over a registry containing the three refactored editors. -- Add a `SectionEditorTestBase<TEditor>` xUnit harness with shared round-trip - scenarios: `RoundTrip_NoOpEdit_PreservesConfig`, - `RoundTrip_SingleFieldEdit_UpdatesOnlyThatField`, - `Secrets_BlankSubmit_PreservesExistingSecret`, - `Secrets_NonBlankSubmit_ReplacesSecret`, - `Secrets_RemoveAction_DeletesSecret`. Concrete subclasses for the three - refactored editors are included. -- Add `ConfigFileHelper.SecretPresent(paths, section, key)` so editors can - render "configured — leave blank to keep" hints without decrypting the - secret value (#455 contract: never rehydrate secrets to the screen). -- Closes #455 (`netclaw init` reentrancy gap). +- Add an `ISectionEditor` contract in `Netclaw.Cli.Tui.Sections` for + reusable leaf editors. Each editor describes one editable leaf surface: + stable identity, status/summary, relevant validation checks, and a + factory that returns an `IWizardStepViewModel` runnable either from + `netclaw init` or from `netclaw config`. +- Keep the registry flat at the leaf-editor level, but explicitly DO NOT + make the registry shape the `netclaw config` IA contract. The next change + is free to build a domain-oriented dashboard with grouped pages and + routed handoffs on top of the leaf registry. +- Add `SectionEditorRegistry`, `SectionStatus`, `SectionContribution`, and + `SectionEditorExemptions` so schema-backed leaves, dotted-path leaves, + and synthetic init-owned editors can all participate without pretending + everything is a top-level config page. +- Add single-step `WizardOrchestrator` hosting so one editor can run + standalone without the full init step list. +- Populate `WizardContext.ExistingConfig` from on-disk config when an + init-owned flow needs existing state, so init-owned editors can re-enter + with non-secret fields prefilled. +- Switch wizard/config persistence from overwrite semantics to semantic + merge-on-save. Unrelated sections and inactive per-mode values are + preserved semantically; formatting and property ordering are not part of + the contract. +- Refactor four existing bootstrap editors to implement `ISectionEditor`: + Provider, Identity, Security Posture, and Enabled Features. Identity + remains `ShowInMenu = false` because it stays init-owned. Security + Posture and Enabled Features become reusable leaf editors for the next + change's `Security & Access` area. +- Keep the secret-handling contract: secrets never rehydrate to screen; + masked inputs use "leave blank to keep" semantics; explicit removal is + the only delete path. +- Add `MenuRegistryAuditTests` and `SectionEditorTestBase<TEditor>` so + registered leaf editors require meaningful round-trip coverage and + validation declarations. Routed handoff entries in the next change are + covered separately and do not pretend to be leaf editors. **In scope (MVP):** the abstraction, registry, exemption list, audit and -round-trip test harnesses, single-step orchestrator mode, merge-on-save for -both `netclaw.json` and `secrets.json`, `ExistingConfig` population at init -entry, and refactor of Provider/Identity/Posture to implement the contract. +round-trip harnesses, single-step orchestrator mode, semantic merge-on-save +for `netclaw.json` and `secrets.json`, `ExistingConfig` population, and the +refactor of Provider / Identity / Security Posture / Enabled Features. -**Out of scope:** the new `netclaw config` command itself (next change), the -remaining nine section editors (next change), simplification of the init -wizard step list (third change), and hot-reload of the running daemon on -config changes. +**Out of scope:** the `netclaw config` command itself, the domain-oriented +dashboard IA, routed handoff nodes for `netclaw provider` / `netclaw model` + / `netclaw mcp permissions`, simplification of the init flow, and daemon +hot-reload. ## Capabilities ### New Capabilities -- `section-editor-abstraction`: contract requirements for the reusable - editable-section abstraction — `ISectionEditor`, registry semantics, - reentrancy contract, secret-handling contract, merge-on-save semantics, - and audit obligations for every registered editor. +- `section-editor-abstraction`: reusable leaf-editor contract for init and + config, including reentrancy, secret handling, semantic merge-on-save, + and audit obligations. ### Modified Capabilities -- `netclaw-onboarding`: `netclaw init` SHALL populate `WizardContext.ExistingConfig` - at entry from on-disk config, and section editors SHALL pre-fill non-secret - fields from it in `OnEnter()` while leaving secret fields empty with the - documented "configured" hint. The wizard's terminal write SHALL be a merge - over existing config, not an overwrite. +- `netclaw-onboarding`: init-owned editable flows SHALL load existing + config state when needed, prefill non-secret fields, keep secrets masked, + and write via semantic merge-on-save. ## Impact **Affected systems:** -- CLI init wizard wiring (`Netclaw.Cli.Program`, - `Netclaw.Cli.Tui.Wizard.WizardOrchestrator`, - `Netclaw.Cli.Tui.Wizard.WizardConfigBuilder`, - `Netclaw.Cli.Tui.Wizard.WizardContext`). -- Three init step viewmodels (`ProviderStepViewModel`, `IdentityStepViewModel`, - `SecurityPostureStepViewModel`) gain `ISectionEditor` implementations. -- Config merge helper (`Netclaw.Cli.Config.ConfigFileHelper`) gains - `SecretPresent(...)`. -- New test surface under `tests/Netclaw.Cli.Tests/Tui/Sections/` covering the - abstraction and the three refactored editors. +- CLI wizard infrastructure (`Program`, `WizardOrchestrator`, + `WizardConfigBuilder`, `WizardContext`). +- Bootstrap editors (`ProviderStepViewModel`, `IdentityStepViewModel`, + `SecurityPostureStepViewModel`, `FeatureSelectionStepViewModel` / its + Enabled Features successor naming). +- Config merge helper (`ConfigFileHelper`). +- Test surface under `tests/Netclaw.Cli.Tests/Tui/Sections/`. **Security and operational impact:** -- Secrets are never re-rendered to the TUI; the new `SecretPresent` lookup - returns existence only, never the decrypted value. This preserves the - default-deny posture for credential display. -- Merge-on-save replaces overwrite-on-save. The contract guarantee is - byte-equality of all other top-level sections in `netclaw.json` and - `secrets.json`. Round-trip tests enforce the guarantee. -- Re-running `netclaw init` over an existing config is no longer undefined; - in this change the wizard pre-fills fields and merges on save. Explicit - "existing-config refusal" UX lands in the third change. -- No new network surface, no new persistence schema, no new daemon - contract changes. +- Secrets remain non-rehydratable in the UI. +- Merge behavior preserves meaning, not file bytes. +- The abstraction now matches the locked product split instead of + implying that `netclaw init` is the long-term editor for ongoing + settings. diff --git a/openspec/changes/section-editor-abstraction/specs/netclaw-onboarding/spec.md b/openspec/changes/section-editor-abstraction/specs/netclaw-onboarding/spec.md index a2b358f09..e425c0beb 100644 --- a/openspec/changes/section-editor-abstraction/specs/netclaw-onboarding/spec.md +++ b/openspec/changes/section-editor-abstraction/specs/netclaw-onboarding/spec.md @@ -1,112 +1,43 @@ ## ADDED Requirements -### Requirement: Reentrant init pre-population +### Requirement: Init-owned editor re-entry SHALL use existing config state -`netclaw init` SHALL load existing `netclaw.json` and `secrets.json` at -entry and assign the parsed top-level dictionary to -`WizardContext.ExistingConfig`. When the wizard runs over an existing -install, every step viewmodel implementing `ISectionEditor` SHALL pre-fill -non-secret UI fields from its slice in `ExistingConfig` and SHALL render -secret-bearing fields empty with the documented hint text indicating -whether the underlying secret is present. Steps that do not implement -`ISectionEditor` SHALL preserve their first-run behavior in this change. +Init-owned editor re-entry SHALL load existing config into +`WizardContext.ExistingConfig` when `netclaw init` reuses a registered leaf +editor against an existing install, and SHALL prefill non-secret values from +that state. Secret-bearing fields SHALL remain masked and empty. -#### Scenario: Provider step pre-fills from existing config +#### Scenario: Provider re-entry keeps credential field masked -- **GIVEN** `netclaw.json` contains a configured `Providers.anthropic` - entry -- **WHEN** `netclaw init` enters the Provider step -- **THEN** the provider list opens with `anthropic` as the focused - selection -- **AND** any API key input renders empty with "configured — leave blank - to keep" hint text -- **AND** the OAuth token expiry date displays as previously stored +- **GIVEN** an existing provider configuration with stored credentials +- **WHEN** an init-owned provider flow re-enters +- **THEN** provider choice and non-secret fields are prefilled +- **AND** credential inputs remain blank with configured/not-set hint text -#### Scenario: Identity step pre-fills from existing config +#### Scenario: Identity re-entry prefills init-owned fields -- **GIVEN** `netclaw.json` contains a previously-set agent name, user - name, and timezone -- **WHEN** `netclaw init` enters the Identity step -- **THEN** each text field opens with the previously-set value as the - default +- **GIVEN** an existing install with agent name, operator name, and + timezone already set +- **WHEN** an init-owned identity flow re-enters +- **THEN** those non-secret fields are prefilled -#### Scenario: Security Posture step pre-fills from existing config +### Requirement: Init-owned writes use semantic merge -- **GIVEN** `netclaw.json` contains a previously-set deployment posture -- **WHEN** `netclaw init` enters the Security Posture step -- **THEN** the posture list opens with the previously-set posture as the - focused selection +Init-owned editor flows SHALL write changes through semantic merge-on-save. +Unrelated config meaning and unrelated stored secrets SHALL be preserved even +if the serialized file text changes. -#### Scenario: Fresh install leaves ExistingConfig null +#### Scenario: Identity-only edit preserves unrelated config meaning -- **GIVEN** no `netclaw.json` exists on disk -- **WHEN** `netclaw init` enters the wizard -- **THEN** `WizardContext.ExistingConfig` is `null` -- **AND** every step renders its first-run defaults +- **GIVEN** an existing install with configured channels, search, and + exposure settings +- **WHEN** an init-owned identity flow updates only identity-owned data +- **THEN** the unrelated config sections remain semantically unchanged -### Requirement: Merge-on-save for init wizard +#### Scenario: Blank secret submission preserves existing secret -`netclaw init` SHALL produce its terminal `netclaw.json` write as a merge -of the wizard's accumulated contributions over the existing on-disk file -(or a fresh skeleton when no file exists). For every top-level section -the wizard did not contribute to, the resulting file SHALL be -byte-identical to its pre-write state. The same merge rule SHALL apply -to `secrets.json`. - -#### Scenario: Re-running init preserves unrelated sections - -- **GIVEN** `netclaw.json` contains configured `Slack`, `Discord`, and - `Search` sections -- **AND** `netclaw init` is re-run and only the Provider step is - modified -- **WHEN** the wizard completes and writes -- **THEN** the resulting `netclaw.json` contains the updated `Providers` - section -- **AND** `Slack`, `Discord`, and `Search` are byte-identical to their - pre-write state - -#### Scenario: Re-running init preserves unrelated secrets - -- **GIVEN** `secrets.json` contains a Brave API key and Slack bot/app - tokens -- **AND** `netclaw init` is re-run and only the Provider step's API key - is changed -- **WHEN** the wizard completes and writes -- **THEN** the resulting `secrets.json` contains the new provider API key -- **AND** the Brave API key and Slack tokens are byte-identical to their - pre-write state - -#### Scenario: First-run write produces a complete file - -- **GIVEN** no `netclaw.json` exists on disk -- **WHEN** the wizard completes and writes -- **THEN** the resulting `netclaw.json` contains every section the - wizard contributed to -- **AND** validates against `netclaw-config.v1.schema.json` - -### Requirement: Secrets never rehydrate to the wizard UI - -No step in `netclaw init` SHALL display the decrypted value of any -secret stored in `secrets.json`. Secret-bearing inputs SHALL render -empty masked fields whose hint text indicates whether a value exists, -following the secret-handling contract defined in the -`section-editor-abstraction` capability. - -#### Scenario: Re-run shows stored API key as configured-not-displayed - -- **GIVEN** `secrets.json` contains a stored Brave API key -- **WHEN** `netclaw init` is re-run and reaches a step that would render - the API key field -- **THEN** the field renders empty -- **AND** the hint text reads "configured — leave blank to keep" -- **AND** no part of the decrypted key appears anywhere on screen - -#### Scenario: Re-run with blank submit preserves the stored secret - -- **GIVEN** `secrets.json` contains a stored Brave API key -- **WHEN** `netclaw init` is re-run and the user leaves the API key - field blank and continues -- **THEN** the wizard's terminal write does not rewrite the stored - encrypted value -- **AND** the Brave API key is byte-identical in `secrets.json` - pre-write and post-write +- **GIVEN** an init-owned flow includes a secret-bearing field with an + existing stored value +- **WHEN** the operator leaves that field blank and saves +- **THEN** the existing secret remains stored +- **AND** no decrypted value is shown in the UI diff --git a/openspec/changes/section-editor-abstraction/specs/section-editor-abstraction/spec.md b/openspec/changes/section-editor-abstraction/specs/section-editor-abstraction/spec.md index 51ecf3d56..8fa3990b6 100644 --- a/openspec/changes/section-editor-abstraction/specs/section-editor-abstraction/spec.md +++ b/openspec/changes/section-editor-abstraction/specs/section-editor-abstraction/spec.md @@ -1,378 +1,106 @@ ## ADDED Requirements -### Requirement: Section editor interface +### Requirement: Leaf editor interface -The CLI SHALL define a `ISectionEditor` contract in -`Netclaw.Cli.Tui.Sections` that describes a single editable configuration -section. Each implementation SHALL declare a stable `SectionId` whose value -matches a schema key in `netclaw-config.v1.schema.json` (dotted-path form is -permitted for nested sections such as `Daemon.ExposureMode` and -`Tools.AudienceProfiles`; a synthetic-identifier form is permitted ONLY for -editors whose data spans multiple schema sections, in which case the editor -MUST appear in the documented exemption list), a user-facing `DisplayName`, -an optional `Category` grouping label, a `bool ShowInMenu` flag (default -`true`; editors that participate in init but are not exposed in the -`netclaw config` menu SHALL return `false`), a `GetStatus` method returning -`SectionStatus.{Default, Configured, Warning, Error, Missing}` from current -on-disk config, a secret-redacting `Summary` for dashboard display, a -non-empty `RelevantDoctorChecks` collection (or an explicit -`[NoDoctorChecks]` justification attribute), and a `CreateEditor` -factory that returns an `IWizardStepViewModel`. +The CLI SHALL define an `ISectionEditor` contract for reusable editable +leaf surfaces. A leaf editor SHALL declare a stable `SectionId`, a +user-facing `DisplayName`, optional `Category`, `ShowInMenu`, status and +summary methods, relevant validation checks, and a factory that returns an +`IWizardStepViewModel` runnable in either init-owned flows or config-owned +single-step hosting. -#### Scenario: Editor declares schema-keyed identity +The contract SHALL describe leaf editing only. It SHALL NOT imply that the +top-level `netclaw config` IA is flat or identical to registry order. -- **WHEN** a class implements `ISectionEditor` -- **THEN** its `SectionId` resolves to a top-level or dotted-path key in - `netclaw-config.v1.schema.json` -- **AND** the audit (defined under "Menu registry audit") fails if the - identifier resolves to no schema key and the section is not on the - documented exemption list +#### Scenario: Registered leaf editor does not define dashboard shape -#### Scenario: Editor exposes status and summary without decrypting secrets +- **GIVEN** a registered leaf editor with `SectionId = "Search"` +- **WHEN** the config dashboard is later composed +- **THEN** the dashboard MAY place that leaf under a grouped page such as + `Search` or `Security & Access` +- **AND** the leaf editor contract remains valid regardless of the + top-level navigation shape -- **GIVEN** an `ISectionEditor` whose section owns a secret in `secrets.json` -- **WHEN** the editor produces `GetStatus(...)` and `Summary(...)` -- **THEN** the returned status reflects on-disk configured/default/error - state -- **AND** the summary string contains no secret value or last-N characters - of any secret +#### Scenario: Synthetic init-owned editor is allowed -#### Scenario: Editor declares relevant doctor checks +- **GIVEN** an editor such as `Identity` spans generated files and config + leaves +- **WHEN** it is registered with `ShowInMenu = false` +- **THEN** it MAY use a synthetic identifier when documented in the + exemption list +- **AND** it SHALL remain absent from the config dashboard menu -- **WHEN** a class implements `ISectionEditor` -- **THEN** `RelevantDoctorChecks` contains at least one doctor check type, - OR the implementing class is annotated with - `[NoDoctorChecks(justification: "<reason>")]` -- **AND** the audit fails when neither condition holds +### Requirement: Semantic merge-on-save -#### Scenario: Editor produces a step viewmodel that the orchestrator can run +Leaf editors SHALL persist changes through semantic merge-on-save. The merge +writer SHALL preserve unrelated sections and inactive values semantically. +Formatting, property order, and byte-for-byte file identity are NOT part of +the contract. -- **GIVEN** an `ISectionEditor` and a `WizardContext` -- **WHEN** `CreateEditor(context)` is invoked -- **THEN** the returned `IWizardStepViewModel` is runnable inside the - existing `WizardOrchestrator` -- **AND** it is also runnable in single-step orchestrator mode (see - "Single-step orchestrator") +#### Scenario: Editing one leaf preserves unrelated meaning -#### Scenario: Editor opts out of the netclaw config menu +- **GIVEN** `netclaw.json` contains configured `Providers`, `Slack`, + `Search`, and inactive exposure-mode values for modes other than the + current `Daemon.ExposureMode` +- **WHEN** the operator edits only the Search leaf and saves +- **THEN** `Search` reflects the requested change +- **AND** the unrelated sections and inactive exposure-mode values remain + semantically unchanged -- **GIVEN** an `ISectionEditor` whose section is owned by the init - wizard or a CLI subcommand and is not exposed for ad-hoc editing - via `netclaw config` -- **WHEN** the editor declares `ShowInMenu => false` -- **THEN** the dashboard SHALL NOT render the editor as a menu entry -- **AND** the menu registry audit's smoke-tape existence check - SHALL NOT require a `config-<sectionid>.tape` for that editor -- **AND** the round-trip test contract SHALL still apply (the editor - must have a `SectionEditorTestBase<TEditor>` subclass) +#### Scenario: No-op save may rewrite formatting without changing meaning -### Requirement: Section editor registry +- **GIVEN** an existing config file with non-canonical property order +- **WHEN** an editor performs a no-op save +- **THEN** the resulting file MAY differ in byte representation +- **AND** the resulting parsed config SHALL be semantically equivalent to + the original -The CLI SHALL provide a DI-discovered `SectionEditorRegistry` holding every -registered `ISectionEditor`. Registration SHALL occur via the extension -method `services.AddSectionEditor<TEditor>()`. The registry SHALL expose at -minimum `IReadOnlyList<ISectionEditor> All()` and -`ISectionEditor Get(string sectionId)`. Section identity SHALL be unique -within the registry. +### Requirement: Reentrancy contract for init-owned flows SHALL preserve existing state rules -#### Scenario: Editors are resolved via dependency injection +Init-owned re-entrant flows SHALL prefill non-secret fields from +`WizardContext.ExistingConfig` when init reuses a leaf editor against +existing state. Secret-bearing fields SHALL remain empty and masked, using +existence-only hint text. -- **GIVEN** a DI container with `AddSectionEditor<ProviderSectionEditor>()` - invoked at startup -- **WHEN** the container resolves `SectionEditorRegistry` -- **THEN** `registry.All()` returns a list containing the registered editor -- **AND** `registry.Get("Providers")` returns the same instance +#### Scenario: Existing non-secret values prefill -#### Scenario: Duplicate section identity is rejected +- **GIVEN** an init-owned flow enters the Security Posture editor with an + existing posture already configured +- **WHEN** the editor loads +- **THEN** the current posture is preselected -- **GIVEN** two `ISectionEditor` implementations claiming the same - `SectionId` -- **WHEN** the DI container builds the registry -- **THEN** registry construction fails fast with an exception naming the - duplicate identifier +#### Scenario: Stored secrets never rehydrate -### Requirement: Section editor exemption list - -The CLI SHALL maintain a documented exemption list at -`Netclaw.Cli.Tui.Sections.SectionEditorExemptions` enumerating schema -sections that intentionally have no top-level TUI editor. Each entry -SHALL carry a machine-readable category (e.g. "internal-only", -"set-once-at-install", "covered by CLI subcommand", "covered by -another editor's dotted-path SectionId", "synthetic-spans-multiple-sections", -"out of MVP scope"). The exemption list SHALL be the only mechanism -by which an unregistered schema section avoids audit failure. The -audit SHALL consider a top-level schema section "covered" when ANY -registered editor's `SectionId` starts with `<section>.` (dotted-path -ownership); such top-level sections still require an exemption-list -entry naming the covering editor to make the relationship explicit -and reviewable. - -#### Scenario: Schema section absent from registry and absent from exemptions - -- **GIVEN** the schema declares a top-level section `Foo` -- **AND** no `ISectionEditor` implementation has `SectionId = "Foo"` -- **AND** `"Foo"` is not present in `SectionEditorExemptions` -- **WHEN** the menu registry audit runs -- **THEN** the audit fails with a message naming the section - -#### Scenario: Schema section in exemption list - -- **GIVEN** the schema declares a top-level section `Persistence` -- **AND** no editor exists for it -- **AND** `"Persistence"` is present in `SectionEditorExemptions` with - category `"set-once-at-install"` -- **WHEN** the audit runs -- **THEN** the audit does not fail for `Persistence` - -#### Scenario: Top-level schema section covered by a dotted-path editor - -- **GIVEN** the schema declares a top-level section `Security` -- **AND** an editor with `SectionId = "Security.Posture"` is - registered -- **AND** `"Security"` is present in `SectionEditorExemptions` with - category `"covered by another editor's dotted-path SectionId"` - naming `Security.Posture` -- **WHEN** the audit runs -- **THEN** the audit does not fail for `Security` -- **AND** the audit's failure-message vocabulary treats the - exemption's "covering editor" reference as the canonical owner - -### Requirement: Single-step orchestrator mode - -`WizardOrchestrator` SHALL support construction with a single -`IWizardStepViewModel` and a `WizardContext`, running that step -standalone without the linear-wizard step list. `GoNext()` from the step -SHALL invoke save-and-exit semantics; `GoBack()` or `Esc` SHALL invoke -cancel-and-exit semantics. `IsApplicable` filtering and step-to-step -navigation SHALL be skipped in this mode. - -#### Scenario: Single step runs to save - -- **GIVEN** a section editor's step viewmodel and a context -- **WHEN** a `WizardOrchestrator` is constructed in single-step mode -- **AND** the step invokes `GoNext()` -- **THEN** the orchestrator runs the save path -- **AND** returns control to the caller after disk write completes - -#### Scenario: Single step cancels without saving - -- **GIVEN** a section editor in single-step mode -- **WHEN** the step invokes `GoBack()` or the user presses Esc -- **THEN** the orchestrator returns without writing -- **AND** disk state is unchanged - -### Requirement: Reentrancy contract - -Every `ISectionEditor` SHALL honor the following reentrancy contract: -on `OnEnter(context, NavigationDirection.Forward)`, if -`context.ExistingConfig` is non-null, the editor SHALL read its slice -keyed by `SectionId` and pre-fill non-secret UI fields from that slice; -secret-bearing fields SHALL remain empty, with the documented hint text -indicating whether the underlying secret is present. - -#### Scenario: Non-secret fields pre-fill from ExistingConfig - -- **GIVEN** an editor with `SectionId = "Search"` -- **AND** `context.ExistingConfig["Search"]` contains - `{ "Backend": "brave" }` -- **WHEN** the editor's step viewmodel enters in the Forward direction -- **THEN** the backend selector renders with `brave` as the - current/selected value - -#### Scenario: Secret-bearing fields render empty regardless of disk state - -- **GIVEN** an editor with a secret-bearing field whose underlying value is - stored encrypted in `secrets.json` -- **WHEN** the editor enters in the Forward direction -- **THEN** the secret input field renders empty -- **AND** the field hint reads "configured — leave blank to keep" when the - underlying secret exists, or "(not set)" otherwise - -### Requirement: Secret-handling contract - -Section editors SHALL render every secret-bearing field as an empty masked -input. Blank-on-save SHALL preserve the existing encrypted secret value -without rewriting it. Non-blank-on-save SHALL replace the existing value -with the newly entered one. An explicit "Remove credential" action SHALL -be the only path that deletes a secret value from `secrets.json`. Under no -circumstance SHALL the decrypted value of a stored secret be displayed to -the user. - -#### Scenario: Blank submit preserves existing secret - -- **GIVEN** an editor with a secret-bearing field that has a stored value -- **WHEN** the user leaves the field empty and saves -- **THEN** the merge writer records `SecretAction.Preserve` for the field -- **AND** `secrets.json` is byte-identical for that key after the write - -#### Scenario: Non-blank submit replaces stored secret - -- **GIVEN** an editor with a secret-bearing field that has a stored value -- **WHEN** the user enters a new masked value and saves -- **THEN** the merge writer records `SecretAction.Replace(newValue)` -- **AND** `secrets.json` is rewritten with the new encrypted value at the - corresponding key - -#### Scenario: Remove credential deletes stored secret - -- **GIVEN** an editor with a secret-bearing field that has a stored value -- **WHEN** the user activates "Remove credential" and confirms (default - Cancel) -- **THEN** the merge writer records `SecretAction.Remove` -- **AND** the corresponding key is absent from the rewritten `secrets.json` - -### Requirement: Merge-on-save semantics - -Section editors SHALL produce a `SectionContribution` carrying explicit -`FieldAction.{Preserve, Replace, Remove}` per non-secret field and -`SecretAction.{Preserve, Replace, Remove}` per secret field. The merge -writer SHALL load existing `netclaw.json` and `secrets.json` as mutable -dictionaries, apply the contribution's actions to the editor's section, -and write the resulting documents. After a section save, every other -top-level section in both files SHALL be byte-identical to its pre-save -state. - -#### Scenario: Editing one section preserves all others - -- **GIVEN** `netclaw.json` contains sections `Providers`, `Slack`, `Search`, - `ExposureMode` -- **WHEN** the user opens the Search editor, modifies the `Backend` field, - and saves -- **THEN** `Providers`, `Slack`, `ExposureMode` are byte-identical in the - resulting file -- **AND** only `Search` has changed - -#### Scenario: Empty-array semantic distinct from missing key - -- **GIVEN** an editor for a section containing a multi-value list -- **WHEN** the user removes all entries and saves -- **THEN** the resulting `netclaw.json` writes the list as an empty array - `[]` -- **AND** the corresponding schema key is present and not removed - -### Requirement: Existing-config population at init entry - -When `netclaw init` launches, the entry point SHALL load -`netclaw.json` and `secrets.json` via `ConfigFileHelper.LoadConfigFiles` -and assign the parsed `netclaw.json` dictionary to -`WizardContext.ExistingConfig`. Secret values from `secrets.json` SHALL -NOT be loaded into the context; only an existence indicator (via -`ConfigFileHelper.SecretPresent(...)`) SHALL be queryable by editors. - -#### Scenario: First-run leaves ExistingConfig null - -- **GIVEN** no `netclaw.json` exists on disk -- **WHEN** `netclaw init` enters the wizard -- **THEN** `WizardContext.ExistingConfig` is `null` - -#### Scenario: Re-run populates ExistingConfig - -- **GIVEN** `netclaw.json` exists on disk -- **WHEN** `netclaw init` enters the wizard -- **THEN** `WizardContext.ExistingConfig` contains the parsed top-level - dictionary -- **AND** no decrypted secret values are present anywhere in the context +- **GIVEN** an editor owns a secret-bearing field whose value exists in + `secrets.json` +- **WHEN** the editor loads +- **THEN** the field renders empty +- **AND** the hint indicates only whether a value exists +- **AND** the decrypted value is never displayed ### Requirement: Secret-presence lookup without decryption -`ConfigFileHelper` SHALL expose a method -`bool SecretPresent(NetclawPaths paths, string sectionId, string key)` -that returns whether the specified secret key exists in `secrets.json` -without decrypting or returning its value. The method SHALL be the sole -hint source for editors deciding between "configured — leave blank to -keep" and "(not set)" placeholders. - -#### Scenario: Existing secret reports present - -- **GIVEN** `secrets.json` contains an encrypted value at - `Search.BraveApiKey` -- **WHEN** `SecretPresent(paths, "Search", "BraveApiKey")` is invoked -- **THEN** the result is `true` -- **AND** the decrypted value is never materialized in memory by this call - -#### Scenario: Missing secret reports absent - -- **GIVEN** `secrets.json` does not contain a value at - `Search.BraveApiKey` -- **WHEN** `SecretPresent(paths, "Search", "BraveApiKey")` is invoked -- **THEN** the result is `false` - -### Requirement: Round-trip test harness - -The test project SHALL provide an abstract -`SectionEditorTestBase<TEditor>` carrying the canonical shared -reentrancy and merge scenarios: `RoundTrip_NoOpEdit_PreservesConfig`, -`RoundTrip_SingleFieldEdit_UpdatesOnlyThatField`, -`Secrets_BlankSubmit_PreservesExistingSecret`, -`Secrets_NonBlankSubmit_ReplacesSecret`, -`Secrets_RemoveAction_DeletesSecret`. Concrete subclasses SHALL exist for -every registered `ISectionEditor`. - -#### Scenario: Base scenarios are inherited by every concrete subclass - -- **WHEN** a developer adds a new `ISectionEditor` implementation and - registers it -- **THEN** the project will not pass `dotnet test` until a corresponding - subclass of `SectionEditorTestBase<TEditor>` exists -- **AND** the menu registry audit fails when the subclass is missing - -#### Scenario: Round-trip no-op preserves config byte-for-byte - -- **GIVEN** a stocked existing-config fixture -- **WHEN** the editor's step viewmodel runs `OnEnter`, makes no changes, - and saves -- **THEN** the resulting `netclaw.json` and `secrets.json` are - byte-identical to the fixture - -### Requirement: Menu registry audit - -The test project SHALL include `MenuRegistryAuditTests` that walks -`SectionEditorRegistry` and asserts, for every registered editor: a -matching concrete `SectionEditorTestBase<TEditor>` subclass exists; the -editor's `RelevantDoctorChecks` is non-empty (or the class is annotated -with `[NoDoctorChecks]`); and, for editors with `ShowInMenu == true`, -once smoke tapes ship for the editor in the next change, a matching -tape file exists at `tests/smoke/tapes/config-<section-lowercase>.tape`. -Editors with `ShowInMenu == false` are exempt from the tape-existence -check (they participate in init or in CLI subcommands; init-side -coverage is provided by `init-wizard.tape`). The audit SHALL report -all failures in one assertion message naming each missing artifact. - -#### Scenario: Missing round-trip test class fails the audit - -- **GIVEN** a registered `ISectionEditor` without a matching - `SectionEditorTestBase<TEditor>` subclass -- **WHEN** `MenuRegistryAuditTests` runs -- **THEN** the test fails with a message naming the missing test class +`ConfigFileHelper` SHALL expose an existence-only secret lookup API used by +leaf editors to decide between "configured - leave blank to keep" and +"(not set)". -#### Scenario: Empty RelevantDoctorChecks without justification fails the audit +#### Scenario: Presence lookup does not decrypt -- **GIVEN** a registered `ISectionEditor` whose `RelevantDoctorChecks` - returns no entries -- **AND** whose class is not annotated with `[NoDoctorChecks]` -- **WHEN** `MenuRegistryAuditTests` runs -- **THEN** the test fails with a message naming the editor +- **GIVEN** `secrets.json` contains an encrypted value for a leaf editor +- **WHEN** `SecretPresent(...)` is called +- **THEN** the result indicates presence or absence only +- **AND** the decrypted value is not materialized for UI display -#### Scenario: Vacuous registry passes the audit +### Requirement: Audit applies to registered leaf editors -- **GIVEN** a registry containing only the three Change A editors - (Provider, Identity, Posture) -- **AND** each has a matching round-trip test class and non-empty - `RelevantDoctorChecks` -- **AND** Provider and Identity declare `ShowInMenu == false` while - Posture declares `ShowInMenu == true` -- **WHEN** `MenuRegistryAuditTests` runs -- **THEN** the audit passes -- **AND** the audit does not require a `config-providers.tape`, - `config-identity.tape`, or `config-security.posture.tape` for the - `ShowInMenu == false` editors +The test project SHALL audit registered leaf editors for round-trip test +coverage and declared validation checks. `ShowInMenu = false` leaves remain +subject to round-trip coverage but are exempt from config-dashboard tape +requirements. -#### Scenario: ShowInMenu editor missing its smoke tape fails the audit +#### Scenario: Menu-hidden init-owned editor still needs a round-trip test -- **GIVEN** a registered editor with `ShowInMenu == true` -- **AND** no file at - `tests/smoke/tapes/config-<sectionid-lower>.tape` -- **AND** the `netclaw config` command exists (tape requirement is - active per the change that introduces the dashboard) -- **WHEN** `MenuRegistryAuditTests` runs -- **THEN** the audit fails with a message naming the missing tape +- **GIVEN** `Identity` is registered with `ShowInMenu = false` +- **WHEN** the registry audit runs +- **THEN** the audit requires a leaf-editor round-trip test class +- **AND** it does NOT require a config-dashboard smoke tape for Identity diff --git a/openspec/changes/section-editor-abstraction/tasks.md b/openspec/changes/section-editor-abstraction/tasks.md index 379fb52e5..e15d450fa 100644 --- a/openspec/changes/section-editor-abstraction/tasks.md +++ b/openspec/changes/section-editor-abstraction/tasks.md @@ -1,171 +1,100 @@ ## 1. OpenSpec planning artifacts and traceability -- [ ] 1.1 Confirm proposal, design, and spec deltas cover the - `ISectionEditor` contract, registry, single-step orchestrator mode, - exemption list, secret-handling rules, merge-on-save semantics, - reentrant pre-population, and audit/test harness obligations. -- [ ] 1.2 Verify traceability references to `PRD-004` and `PRD-001` across - change artifacts. +- [ ] 1.1 Confirm proposal, design, and spec deltas describe a leaf-editor + abstraction rather than a flat dashboard contract. +- [ ] 1.2 Confirm the artifacts reflect the locked split: `init` owns + bootstrap and Identity; `config` owns post-install editing. - [ ] 1.3 Run `openspec validate section-editor-abstraction --type change` - and resolve all issues. + and resolve issues. ## 2. Core abstraction -- [ ] 2.1 Add `src/Netclaw.Cli/Tui/Sections/ISectionEditor.cs` with - `SectionId`, `DisplayName`, `Category?`, `GetStatus`, `Summary`, - `RelevantDoctorChecks`, `CreateEditor`. -- [ ] 2.2 Add `src/Netclaw.Cli/Tui/Sections/SectionStatus.cs` with the - `Default | Configured | Warning | Error | Missing` enum. -- [ ] 2.3 Add `src/Netclaw.Cli/Tui/Sections/SectionContribution.cs` with - `FieldAction.{Preserve, Replace, Remove}` and - `SecretAction.{Preserve, Replace, Remove}` discriminated unions plus a - contribution record carrying the per-field dictionaries. -- [ ] 2.4 Add `src/Netclaw.Cli/Tui/Sections/NoDoctorChecksAttribute.cs` - carrying a required `justification` string for editors that genuinely - have no relevant checks. +- [ ] 2.1 Add `ISectionEditor` with `SectionId`, `DisplayName`, + `Category?`, `ShowInMenu`, `GetStatus`, `Summary`, + `RelevantDoctorChecks`, and `CreateEditor`. +- [ ] 2.2 Add `SectionStatus`. +- [ ] 2.3 Add `SectionContribution` with explicit field and secret + actions. +- [ ] 2.4 Add `[NoDoctorChecks]` justification support where truly needed. ## 3. Registry and exemption list -- [ ] 3.1 Add `src/Netclaw.Cli/Tui/Sections/SectionEditorRegistry.cs` with - `All()` and `Get(string sectionId)` methods. Construction fails fast on - duplicate `SectionId`. -- [ ] 3.2 Add `services.AddSectionEditor<TEditor>()` DI extension on - `IServiceCollection` registering the editor as `ISectionEditor` - (transient) and as itself (for direct test resolution). -- [ ] 3.3 Add `src/Netclaw.Cli/Tui/Sections/SectionEditorExemptions.cs` - with the documented exemption set and per-entry category metadata. -- [ ] 3.4 Wire `SectionEditorRegistry` and the three Change A editors - (Provider, Identity, Posture) into the existing CLI DI composition root. +- [ ] 3.1 Add `SectionEditorRegistry` with duplicate-ID fail-fast. +- [ ] 3.2 Add `AddSectionEditor<TEditor>()` DI registration. +- [ ] 3.3 Add `SectionEditorExemptions` entries for synthetic/init-owned + surfaces, including Identity. +- [ ] 3.4 Document that the registry is a leaf-editor registry and does + not dictate the future dashboard IA. ## 4. Single-step orchestrator mode -- [ ] 4.1 Add a single-step constructor to `WizardOrchestrator` accepting - one `IWizardStepViewModel` and a `WizardContext`. -- [ ] 4.2 In single-step mode, `GoNext()` triggers save-and-exit; - `GoBack()` / `Esc` triggers cancel-and-exit. Step-to-step filtering - via `IsApplicable` is skipped. -- [ ] 4.3 Add orchestrator-level unit tests covering save-and-exit and - cancel-and-exit single-step paths. - -## 5. Merge-on-save plumbing - -- [ ] 5.1 Refactor `WizardConfigBuilder.WriteConfigFile` to load existing - `netclaw.json` via `ConfigFileHelper.LoadConfigFiles`, apply each - step's `SectionContribution`, and write the merged dictionary back. - Sections not contributed to remain byte-identical. -- [ ] 5.2 Refactor the wizard's secrets writer to load existing - `secrets.json` and apply each contribution's `SecretAction`s. Blank - on a secret-bearing field maps to `Preserve`; explicit - `SecretAction.Remove` deletes the key. -- [ ] 5.3 Add `ConfigFileHelper.SecretPresent(paths, sectionId, key)` that - inspects `secrets.json` for the key's existence without invoking the - data-protection unprotect path. Unit-test against a fixture with both - present and absent values. -- [ ] 5.4 Update `WizardOrchestrator.WriteConfig` to drive the new merge - path. Existing first-run behavior remains observable-equivalent because - the empty-existing path collapses to the previous overwrite shape. - -## 6. ExistingConfig population at init entry - -- [ ] 6.1 At the `netclaw init` entry point in `Netclaw.Cli.Program`, load - `netclaw.json` via `ConfigFileHelper.LoadConfigFiles` and assign the - parsed dictionary to `WizardContext.ExistingConfig`. Leave secrets out - of the context entirely. -- [ ] 6.2 Remove the "Deferred — not implemented yet" comment block on - `WizardContext.ExistingConfig` and document the populated-at-entry - semantics. -- [ ] 6.3 Confirm the wizard's lifetime owns `ExistingConfig` for the - duration of the run; the dictionary is read-only after entry. - -## 7. Refactor three existing init step viewmodels - -- [ ] 7.1 `ProviderStepViewModel`: implement `ISectionEditor` - (SectionId `Providers`, `ShowInMenu = false` — covered by the - existing `netclaw provider` CLI per D3 of the planning doc). Honor - `ExistingConfig` in `OnEnter(direction)` for provider type, endpoint, - auth method, model selection, and OAuth token expiry. API key field - renders empty with "configured — leave blank to keep" hint when - `SecretPresent` returns true. -- [ ] 7.2 `IdentityStepViewModel`: implement `ISectionEditor` - (SectionId `Identity` as a synthetic identifier — Identity is NOT a - top-level schema key; identity data spans `Workspaces`, - `Notifications`, and identity files like `SOUL.md`. Add the - synthetic ID `Identity` to `SectionEditorExemptions` with category - `"synthetic-spans-multiple-sections"`. `ShowInMenu = false` — set - once at init in MVP). Honor `ExistingConfig` for agent name, user - name, timezone, comm style, workspaces directory, webhook URL. (Step - is trimmed in the third change; this change keeps existing fields.) -- [ ] 7.3 `SecurityPostureStepViewModel`: implement `ISectionEditor` - (SectionId `Security.Posture`, dotted path; `ShowInMenu = true` — - surfaces in the dashboard in Change B). Honor `ExistingConfig` for - the posture selection and posture-default cascade. -- [ ] 7.4 Each refactored editor declares non-empty - `RelevantDoctorChecks` referencing the existing checks that scope to - the editor's section. -- [ ] 7.5 Each refactored editor produces a `SectionContribution` from - its viewmodel state on save; the orchestrator collects contributions - and routes them through the new merge writer. +- [ ] 4.1 Add single-step hosting to `WizardOrchestrator`. +- [ ] 4.2 Ensure save exits and cancel exits work without linear step-list + navigation. +- [ ] 4.3 Add unit tests for single-step save and cancel. + +## 5. Semantic merge-on-save plumbing + +- [ ] 5.1 Refactor config writes to load existing config, apply + contributions, and preserve unrelated sections semantically. +- [ ] 5.2 Refactor secret writes to preserve blank submissions, replace on + non-blank, and remove only on explicit delete. +- [ ] 5.3 Preserve inactive values for exposure-mode and similar editors + when they are not the active leaf being changed. +- [ ] 5.4 Add `ConfigFileHelper.SecretPresent(...)` without decrypting + stored values. + +## 6. ExistingConfig population + +- [ ] 6.1 Populate `WizardContext.ExistingConfig` from on-disk config when + init enters an editor flow that needs existing state. +- [ ] 6.2 Keep secrets out of the context entirely. +- [ ] 6.3 Document that this supports init-owned re-entry, not init as the + main post-install editor. + +## 7. Refactor bootstrap leaves + +- [ ] 7.1 Refactor Provider to implement `ISectionEditor` + (`ShowInMenu = false`; owned by init / routed provider command). +- [ ] 7.2 Refactor Identity to implement `ISectionEditor` + (`ShowInMenu = false`; synthetic ID; init-owned). +- [ ] 7.3 Refactor Security Posture to implement `ISectionEditor` + (`ShowInMenu = true`; reusable under `Security & Access`). +- [ ] 7.4 Refactor Enabled Features to implement `ISectionEditor` + (`ShowInMenu = true`; separate from posture and audience profiles). +- [ ] 7.5 Ensure each refactored editor declares meaningful validation + checks and produces `SectionContribution` output. ## 8. Round-trip test harness -- [ ] 8.1 Add - `tests/Netclaw.Cli.Tests/Tui/Sections/SectionEditorTestBase.cs` - abstract harness with the five canonical scenarios: - `RoundTrip_NoOpEdit_PreservesConfig`, - `RoundTrip_SingleFieldEdit_UpdatesOnlyThatField`, - `Secrets_BlankSubmit_PreservesExistingSecret`, - `Secrets_NonBlankSubmit_ReplacesSecret`, - `Secrets_RemoveAction_DeletesSecret`. -- [ ] 8.2 Concrete test class for `ProviderSectionEditor` covering - provider, endpoint, model, OAuth, and API-key paths. -- [ ] 8.3 Concrete test class for `IdentitySectionEditor`. -- [ ] 8.4 Concrete test class for `SecurityPostureSectionEditor`, - including the posture-cascade write semantics. +- [ ] 8.1 Add `SectionEditorTestBase<TEditor>` with semantic round-trip, + secret-preservation, and targeted update scenarios. +- [ ] 8.2 Add Provider leaf tests. +- [ ] 8.3 Add Identity leaf tests. +- [ ] 8.4 Add Security Posture leaf tests. +- [ ] 8.5 Add Enabled Features leaf tests. ## 9. Menu registry audit -- [ ] 9.1 Add - `tests/Netclaw.Cli.Tests/Tui/Sections/MenuRegistryAuditTests.cs` with - a single test that walks `SectionEditorRegistry.All()` and asserts: - every registered editor has a `SectionEditorTestBase<TEditor>` - subclass; every editor has non-empty `RelevantDoctorChecks` or - `[NoDoctorChecks]`; and (gated by file existence, no error if absent - in this change) a smoke tape at - `tests/smoke/tapes/config-<sectionId-lower>.tape` exists when present. -- [ ] 9.2 Audit failure message lists all missing artifacts in one - assertion message, naming each editor + missing piece. -- [ ] 9.3 Smoke-tape file existence is checked but not required at the - audit level until the next change lands; comment in the test - documents the cutover. +- [ ] 9.1 Add `MenuRegistryAuditTests` for registered leaf editors. +- [ ] 9.2 Require round-trip tests and validation declarations for every + registered leaf editor. +- [ ] 9.3 Exempt `ShowInMenu = false` leaves from config smoke-tape + existence checks. +- [ ] 9.4 Document that routed handoff entries are tested separately in the + config command change. ## 10. Existing test suite preservation -- [ ] 10.1 Run `./scripts/smoke/run-smoke.sh init-wizard` and confirm the - existing init-wizard tape passes unchanged. -- [ ] 10.2 Run `./scripts/smoke/run-smoke.sh init-wizard-reverse-proxy` - and confirm the existing reverse-proxy tape passes unchanged. -- [ ] 10.3 Run the full `./scripts/smoke/run-smoke.sh light` and confirm - no regressions. +- [ ] 10.1 Keep current init smoke coverage passing. +- [ ] 10.2 Keep current reverse-proxy/init coverage passing until the later + config and init changes intentionally move it. ## 11. Quality gates -- [ ] 11.1 `dotnet build` clean across the solution. -- [ ] 11.2 `dotnet test` clean: all new round-trip tests pass; audit - passes vacuously over the three registered editors; existing tests - remain green. -- [ ] 11.3 `dotnet slopwatch analyze` reports no new violations. -- [ ] 11.4 `./scripts/Add-FileHeaders.ps1 -Verify` reports clean. +- [ ] 11.1 `dotnet build` clean. +- [ ] 11.2 `dotnet test` clean. +- [ ] 11.3 `dotnet slopwatch analyze` clean. +- [ ] 11.4 `./scripts/Add-FileHeaders.ps1 -Verify` clean. - [ ] 11.5 `openspec validate section-editor-abstraction --type change` passes. - -## 12. Documentation and traceability - -- [ ] 12.1 Update `PROJECT_CONTEXT.md` or `TOOLING.md` if the abstraction - changes the way operators or contributors are expected to add editable - sections (a one-liner pointing at `ISectionEditor` is sufficient at - this stage). -- [ ] 12.2 Update PRD-004 with a forward reference to the - `netclaw config` command landing in the next change; this change does - not yet introduce it. -- [ ] 12.3 PR description closes #455 (reentrant init) and references this - OpenSpec change ID. diff --git a/openspec/changes/simplify-netclaw-init/design.md b/openspec/changes/simplify-netclaw-init/design.md index 7749bc45b..7185badd2 100644 --- a/openspec/changes/simplify-netclaw-init/design.md +++ b/openspec/changes/simplify-netclaw-init/design.md @@ -1,216 +1,103 @@ ## Context -**UI wireframes:** every page introduced by this change — the three -init steps, the post-flight screen, the existing-config refusal -(Init.E1), and the force-reset backup confirm (Init.E2) — is mocked -in `docs/ui/TUI-003-simplified-init-wireframes.md`. Implementors SHALL -treat TUI-003 as the visual contract for this change. The companion -TUI-002 mocks `netclaw config`, which is the destination operators are -nudged toward at post-flight. - -The `section-editor-abstraction` change (Change A) refactored Provider, -Identity, and Posture step viewmodels into reentrant `ISectionEditor`s -and switched the wizard's terminal write to merge-on-save. The -`netclaw-config-command` change (Change B) introduced -`netclaw config` and the ten section editors that now own the -configuration surfaces previously walked by the init wizard. With both -changes landed, `netclaw init` is the only piece left that still -treats configuration as a single big linear flow. - -This change trims the wizard to provider + identity + posture so new -operators reach `netclaw chat` after three prompts, and makes the -existing-config-on-re-run behavior explicit (refuse + offer `--force`) -instead of the prior undefined behavior. The wizard's previous -breadth — Slack/Discord/Mattermost setup, ACL, search, browser -automation, MCP servers, exposure mode, channel audience configuration, -feature toggles, external skills, skill feeds, webhook URL — moves -entirely to `netclaw config`. None of those surfaces are deleted; they -just leave the init step list. +This change narrows `netclaw init` to what it is now supposed to be: +bootstrap. The old draft assumed re-run refusal plus `init --force`, and it +treated Team/Public feature configuration as something silently derived from +posture. The locked decisions are more specific: + +- Identity stays owned by init. +- `netclaw config` owns post-install editing. +- Team and Public posture flows continue into Enabled Features. +- Personal skips Enabled Features. +- Existing installs get an explicit action menu, not a plain refusal and + not a hidden force flag. ## Goals / Non-Goals **Goals:** -- Reduce time-to-first-chat for new operators: three prompts after - provider selection (provider auth + model selection are part of the - Provider step's existing sub-flow). -- Make re-running `netclaw init` over an existing install a - well-defined operation: refuse with helpful pointers by default, and - offer `--force` for a backed-up reset. -- Preserve the existing posture-default cascade: Personal / Team / - Enterprise still drive the initial `Tools.AudienceProfiles` mapping - written at init time. -- Migrate the reverse-proxy exposure-mode init tape coverage to the - `netclaw config` smoke tape introduced in Change B. +- Make first-run init a short bootstrap flow. +- Preserve Identity ownership inside init. +- Handle existing installs through an explicit menu. +- Remove `init --force` from the plan. +- Keep posture values to Personal / Team / Public. +- Keep Enabled Features separate from Audience Profiles. **Non-Goals:** -- Deleting any `ISectionEditor` class that lived as an init step. The - classes survive as `netclaw config` editors after Change B. -- Renaming or re-architecting `netclaw config`. -- Changing posture-default mappings. -- Introducing an Identity section editor in `netclaw config`. Renaming - the agent post-install remains a file-edit (or `init --force`) task - for MVP. -- Hot-reload of the running daemon on init completion. +- Making init the main post-install editor. +- Adding Enterprise posture. +- Putting Audience Profiles or MCP permissions inside init. +- Designing inline config repair for broken bootstrap state. ## Decisions -### D1. Step list reduced to three; classes preserved - -The init wizard's `WizardOrchestrator` step composition is reduced from -the current 12-entry list to exactly three: Provider, Identity, -Posture. The other `ISectionEditor` implementations (Search, Slack, -Discord, Mattermost, Exposure, AudienceProfiles, OutboundWebhooks, -InboundWebhooks, ExternalSkills, SkillFeeds, BrowserAutomation) remain -registered in the registry and reachable via `netclaw config` — -they're just not part of `netclaw init`'s step list. - -Alternative considered: delete the step viewmodel classes that -weren't on the init list. Rejected because they ARE the section -editors `netclaw config` runs; the same class serves both. Keeping -one class per editable section is the whole point of the -`ISectionEditor` abstraction. - -### D2. Existing-config detection refuses by default, allows `--force` - -Re-running `netclaw init` over an existing install in the current -code is undefined behavior. After Change A's merge-on-save plus -`ExistingConfig` pre-population, a naive re-run would silently -re-walk the wizard and re-write whatever the operator typed. That's -confusing — `netclaw init` is named for "initial setup," not "edit." -The right behavior is: - -- Default: refuse with a clear message pointing at `netclaw config` - for live edits. -- Force: explicit `--force` flag triggers a type-to-confirm backup - and proceeds as a fresh first-run. Backup is rename-aside - (`netclaw.json.bak.<ts>`); operators retain manual recovery. - -Alternative considered: have `netclaw init` re-running over existing -config auto-launch `netclaw config`. Rejected because it conflates -two commands; an operator typing `netclaw init` after install -expects setup behavior, not menu-edit behavior. Refusing is clearer. - -### D3. Trimmed Identity step preserves three fields, defaults the rest - -`IdentityStepViewModel`'s field set drops to agent name + user name -+ timezone. The previously-prompted fields (webhook URL, -communication style, workspaces directory) use their existing -defaults and are not exposed in init. Operators wanting to change -them post-install edit `netclaw.json` directly until a future -Identity section editor lands. - -Alternative considered: add a "Show advanced fields" affordance in -the trimmed Identity step. Rejected because it re-introduces the -"long wizard" feel; the explicit out-of-MVP file-edit path is the -right scope discipline. - -### D4. Post-flight nudge in Termina + stderr after teardown - -The post-flight screen inside Termina confirms what was set, reports -health-check pass/fail, and prints the next-step nudge ("Run -`netclaw chat` to start, or `netclaw config` to configure ..."). On -Termina teardown the same one-line nudge prints to stderr so it -remains visible after the TUI clears. This dual-path matches Change -B's daemon-restart nudge pattern. - -Alternative considered: just print the nudge to stderr after exit -without a Termina screen. Rejected because operators benefit from -seeing setup-complete confirmation while the TUI is still up; the -stderr line is a fallback for cases where the operator's terminal -emulator wipes the screen on Termina exit. - -### D5. Reverse-proxy tape migrates to config, not deleted outright - -`init-wizard-reverse-proxy.tape` exercises an exposure-mode flow -that today lives inside the init wizard. With exposure mode moved -to `netclaw config`, the equivalent flow is `config-exposure-mode.tape` -(introduced in Change B). This change deletes the init-side tape -because its coverage is fully owned by the config-side tape. Net -tape count for exposure-mode regression coverage remains 1. - -### D6. New init tapes for refuse-and-force paths - -The refuse path and the `--force` reset path need explicit smoke -coverage, otherwise a future change could regress them silently. -Two new tapes: - -- `init-existing-config-refuse.tape` — pre-stages a config and - asserts refusal text + exit zero on TTY confirm. -- `init-force-reset.tape` — pre-stages a config, runs `--force`, - types `reset` to confirm, completes the short flow, asserts the - .bak files exist and a fresh `netclaw.json` was written. - -Both are short tapes (likely <40 lines each). The new init tape -total is 3 (down from the current 2: one is revised, one is deleted, -two are added). - -### D7. PRD-004 update lands in this change - -PRD-004's "reentrant init dashboard" wording was authored before this -sequence of changes locked the simplified-init + `netclaw config` -split. The wording is updated in this change to match the shipped -shape; cross-references to issues #455 (closed in Change A) and -#1150 (closed in Change B) are added. +### D1. Existing installs get a menu, not refusal-plus-flag + +When `netclaw init` detects an existing install, it opens a menu with these +four choices: + +- Redo identity setup +- Open configuration editor +- Start over from scratch +- Cancel + +Alternative considered: refuse and point to `netclaw config`, with +`--force` for reset. Rejected because the user explicitly locked the menu +shape instead. + +### D2. Scratch reset is a two-stage destructive flow + +`Start over from scratch` opens a dialog with: + +- Reset setup only +- Full reset +- Cancel + +Either destructive path then requires double confirmation before mutation. + +### D3. Identity remains init-owned + +Existing-install identity edits stay in init via `Redo identity setup`. +This branch does not move Identity into `netclaw config`. + +### D4. Team/Public posture continues into Enabled Features + +Security Posture remains separate from Enabled Features. + +- Personal: posture flow ends without Enabled Features. +- Team/Public: posture flow automatically continues into Enabled Features. + +Alternative considered: keep silently applying runtime defaults with no +Enabled Features step. Rejected because the user explicitly locked the +continuation behavior. + +### D5. Audience Profiles stays out of init bootstrap + +Audience Profiles is a post-install curated editor in `netclaw config`. +Init does not expose per-audience access editing. + +### D6. Post-flight points to config for ongoing changes + +Successful bootstrap ends with a message directing the operator to +`netclaw chat` to start and `netclaw config` for ongoing settings. ## Risks / Trade-offs -- [Behavior change for re-runs] Operators who have been - re-running `netclaw init` to tweak config (against the prior - undefined behavior) will be refused after this change. → - Mitigation: the refusal message names `netclaw config` and - `netclaw init --force` explicitly. Documentation update in - PRD-004 references the new behavior. Existing-config detection - is consistent across TTY and non-TTY contexts. - -- [Posture-default writes happen non-interactively now] Operators on - Team or Enterprise postures no longer walk a feature-selection - step at init. They see the defaults applied automatically and can - override per-audience later. → Mitigation: the Change B Audience - Profiles editor is the documented place to tune; PRD-004 names it. - -- [Identity field loss for new installs] New operators no longer - set webhook URL, communication style, or workspaces directory at - init. → Mitigation: defaults are reasonable; webhook URL belongs - in Outbound Webhooks (Change B's section editor); workspaces - directory and communication style are file-edit-only for MVP and - documented as such in PRD-004. - -- [.bak files accumulate on repeated forces] Each `--force` reset - creates a new pair of timestamped .bak files. After many forces - the directory could grow. → Mitigation: this is the operator's - responsibility; the .bak files are theirs to manage. The - type-to-confirm gate ensures forced resets are deliberate, so - accumulation is bounded by intentional operator action. - -- [CI surprise on non-TTY re-runs] Existing CI scripts that called - `netclaw init` non-interactively over a populated config would - silently re-walk previously. After this change they exit non-zero. - → Mitigation: the new behavior is the safe one. Any CI that was - relying on undefined re-run behavior was already buggy; the - non-zero exit makes the breakage visible. Migration is to call - `netclaw config` (programmatic CLI use is via the - CLI subcommands `netclaw provider/model/mcp`, not `netclaw config`). +- Existing-install init now has more branching than the simple refusal + draft. Mitigation: the branches are explicit and operator-centered. +- Identity remaining in init means two different commands remain part of + the operator journey. Mitigation: this is the locked ownership split. +- Double confirmation adds a little friction to reset. Mitigation: that is + intentional for destructive actions. ## Migration Plan -1. Land Changes A and B before this change. -2. Land this change. Existing operators on Personal posture: their - re-runs now refuse cleanly. Existing operators on Team or - Enterprise: same. Operators wanting to edit anything use - `netclaw config`; operators wanting a clean slate use - `netclaw init --force`. -3. PRD-004 update is part of this change's PR. -4. The CHANGELOG / release notes call out the simplified-init - behavior change so operators are not surprised on upgrade. - -Rollback: revert this change. The wizard returns to its 12-step -linear form. Existing-config detection disappears (re-runs go back -to undefined behavior). The two new init tapes are deleted; the -init-wizard-reverse-proxy tape returns. `netclaw config` remains -available as long as Change B remains. +1. Land the bootstrap-only init rewrite. +2. Existing installs reaching `netclaw init` see the existing-install menu. +3. Ongoing settings move to `netclaw config`; identity changes remain in + init. ## Open Questions -None at execution time. All architectural decisions are locked above. +None. The menu wording and ownership split are locked. diff --git a/openspec/changes/simplify-netclaw-init/proposal.md b/openspec/changes/simplify-netclaw-init/proposal.md index 56b095929..094603797 100644 --- a/openspec/changes/simplify-netclaw-init/proposal.md +++ b/openspec/changes/simplify-netclaw-init/proposal.md @@ -1,152 +1,80 @@ ## Why -`netclaw init` is the first-impression experience for every new Netclaw -operator, and it has grown into a 12-step linear wizard that walks -through provider selection, security posture, feature selection, -channel pickers and per-channel sub-flows, search backend, browser -automation, identity, external skills, skill feeds, exposure mode, and -a final health check. This is the longest single point of abandonment -for new installs. After the `section-editor-abstraction` change -introduced reentrancy and the `netclaw-config-command` change moved -ongoing configuration to a menu-driven editor, the init wizard's -purpose is now strictly bootstrap: produce a minimum-viable config -that lets the operator reach `netclaw chat` as quickly as possible. -This change cuts the wizard down to three prompts — provider, -identity, posture — and routes operators to `netclaw config` for -everything else. It also makes the existing-config detection behavior -explicit (refuse with a helpful message; offer `--force` for a backed-up -reset) instead of leaving re-runs as undefined behavior. +`netclaw init` is now explicitly the first-run bootstrap command and then +rarely used again. The earlier planning still treated it as a re-runnable +general editor with a `--force` reset path. That contradicts the locked +product split: + +- `netclaw init` is for bootstrap. +- `netclaw config` is the main post-install settings surface. +- Identity remains `netclaw init` owned. + +This change rewrites init around that split. It trims init to the minimum +bootstrap flow, removes `init --force` from planning, and makes existing- +install behavior an explicit menu that either redoes identity, hands off to +the configuration editor, offers a guarded reset flow, or cancels. Source PRDs: `PRD-004-cli-onboarding-and-config.md`, `PRD-001-netclaw-mvp.md`. ## What Changes -- Trim `netclaw init` to three steps + a terminal write/health-check: - - **Step 1: Provider** — reuse existing `ProviderStepViewModel` - (refactored to `ISectionEditor` in Change A) end-to-end. - - **Step 2: Identity** — trimmed to agent name, user name (what the - agent calls the operator), and timezone. Drop the webhook URL - prompt, the workspaces-directory prompt, and the communication-style - prompt. Defaults remain available for the dropped values. - - **Step 3: Security Posture** — reuse existing - `SecurityPostureStepViewModel` (refactored in Change A). The - posture choice applies the posture-default `Tools.AudienceProfiles` - mapping in-memory before the terminal write; operators tune - per-audience later via `netclaw config → Audience Profiles`. - - **Terminal**: write merged config and run the existing health-check. -- Remove from `netclaw init` the following step viewmodels (the - corresponding `ISectionEditor` implementations introduced in Change B - remain in `netclaw config`): `ChannelPickerStepViewModel`, - `ChannelsStepViewModel`, `FeatureSelectionStepViewModel`, - `SearchStepViewModel`, `SlackStepViewModel`, `DiscordStepViewModel`, - `MattermostStepViewModel`, `ExposureModeStepViewModel`, - `BrowserAutomationStepViewModel`, `ExternalSkillsStepViewModel`, - `SkillFeedsStepViewModel`. The classes are not deleted (they live on - as section editors); only their participation in the init step list - is removed. -- Add a post-flight screen inside Termina that confirms what was set, - reports health-check pass/fail, and points operators at - `netclaw config` for further configuration. On Termina teardown, the - same one-line nudge prints to stderr so it remains visible after the - TUI clears: `Setup complete. Run \`netclaw chat\` to start, or - \`netclaw config\` to configure channels, webhooks, search, and - more.` -- Add explicit existing-config detection at `netclaw init` entry. When - `netclaw.json` exists and `--force` was not passed, the command - renders a refusal screen (TTY) or prints to stderr (non-TTY) - pointing operators at `netclaw config` for edits or - `netclaw init --force` to reset. Exit zero in TTY-confirmed - acknowledgement; exit non-zero in non-TTY usage so CI catches the - surprise. -- Add `netclaw init --force` behavior: when an existing config is - present, the command opens a type-to-confirm backup screen. On - confirm, `netclaw.json` is renamed to `netclaw.json.bak.<unix-ts>` - and `secrets.json` is renamed to `secrets.json.bak.<unix-ts>`. The - wizard then proceeds as a fresh first-run. Operators must re-enter - credentials; the .bak files are preserved for manual recovery. -- Revise `tests/smoke/tapes/init-wizard.tape` and its assertion - script to exercise the three-step flow (provider + identity + - posture) plus the post-flight screen. The tape shortens from - ~150 lines to ~50. -- Delete `tests/smoke/tapes/init-wizard-reverse-proxy.tape` and its - assertion. Reverse-proxy coverage migrates to - `config-exposure-mode.tape` introduced in Change B. -- Add two new smoke tapes covering the new init UX: - - `init-existing-config-refuse.tape` — pre-stage a `netclaw.json`, - run `netclaw init`, assert refusal message + zero exit. - - `init-force-reset.tape` — pre-stage a `netclaw.json`, run - `netclaw init --force`, type "reset" to confirm, complete the - short flow, assert `.bak.*` files exist and new config is - written. -- Update PRD-004 to reflect the simplified-init + `netclaw config` - shape: the original "reentrant init dashboard" wording is replaced - with the documented two-command split. - -**In scope (MVP):** trimming the wizard to provider + identity + -posture, the post-flight screen and stderr nudge, the existing-config -refusal and `--force` reset paths, revising the existing init tape, -deleting the reverse-proxy init tape, and adding two new init tapes -covering the refuse and force paths. - -**Out of scope:** any behavioral change to `netclaw config` (it -already exists from the previous change); deleting the existing init -step viewmodel classes (they continue to back the section editors in -`netclaw config`); migrating identity-related setup that today lives -inside the trimmed Identity step (workspaces directory, communication -style — these continue to use their existing defaults silently for -MVP; operators wanting to change them edit the file directly until -a future Identity section editor lands); changes to PRD-002 or -posture defaults. +- Trim first-run `netclaw init` to a bootstrap flow that gets operators to + a runnable install quickly. +- Keep posture values to `Personal`, `Team`, and `Public` only. +- Keep Security Posture, Enabled Features, and Audience Profiles as + separate concepts. +- First-run posture flow behavior: + - `Personal` skips Enabled Features. + - `Team` and `Public` automatically continue into Enabled Features. +- Enabled Features remains deployment-wide runtime enablement, not a + per-audience policy surface. +- Identity remains owned by init, not by `netclaw config`. +- On an existing install, `netclaw init` SHALL open a menu with exactly: + - `Redo identity setup` + - `Open configuration editor` + - `Start over from scratch` + - `Cancel` +- `Open configuration editor` routes to `netclaw config`. +- `Start over from scratch` opens a second dialog with exactly: + - `Reset setup only` + - `Full reset` + - `Cancel` + followed by a double confirmation before any destructive action. +- Remove `init --force` from planning entirely. +- Keep the post-flight messaging focused on the split: + bootstrap is complete, use `netclaw chat` to start and `netclaw config` + for ongoing settings. + +**In scope (MVP):** bootstrap-first init flow, existing-install menu, +guarded scratch-reset flow with double confirmation, posture and enabled- +features behavior aligned to the locked decisions, and init smoke coverage +updated to match. + +**Out of scope:** turning init back into the main settings surface, +recreating config editing inline, `--force`, Enterprise posture, or moving +Identity into `netclaw config`. ## Capabilities ### Modified Capabilities -- `netclaw-onboarding`: the init wizard's collected inputs SHALL be - trimmed to provider, identity (agent name + user name + timezone), - and security posture. The wizard SHALL detect existing config at - entry and refuse (or offer `--force` reset). The wizard SHALL show - a post-flight screen pointing operators at `netclaw config`. +- `netclaw-onboarding`: bootstrap-only init flow, explicit existing-install + menu, guarded scratch reset, and locked posture/enabled-features split. ## Impact **Affected systems:** -- CLI entry point (`Netclaw.Cli.Program`) gains the existing-config - detection branch and the `--force` flag. -- Init wizard step list (`Netclaw.Cli.Tui.Wizard.WizardOrchestrator` - composition) is reduced to three viewmodels. -- `IdentityStepViewModel` is trimmed (no class removal; field set is - reduced). The viewmodel continues to satisfy the `ISectionEditor` - contract introduced in Change A. -- Init smoke tape (`tests/smoke/tapes/init-wizard.tape`) is rewritten; - reverse-proxy tape is deleted; two new init tapes added. -- PRD-004 is updated to match the simplified-init + `netclaw config` - shape. +- CLI init entry handling. +- Init wizard step composition. +- Existing-install branching screens. +- Init smoke tapes and assertions. **Security and operational impact:** -- Existing-config refusal prevents accidental re-runs from blasting - through an existing install. The `--force` path explicitly backs up - both `netclaw.json` and `secrets.json` to timestamped `.bak.*` - files; operators retain a manual recovery path. The force path - requires a type-to-confirm because the operation moves credentials - out of the active file (forcing re-entry). -- Trimming Identity drops the in-wizard webhook URL prompt. The - outbound-webhook surface was already available via `netclaw config → - Outbound Webhooks` (Change B); operators with active webhook - configurations are not affected (their existing webhook entries - remain). Operators on a fresh install no longer set up a webhook - during init; they do so in `netclaw config` post-bootstrap. -- The simplified init reduces the time-to-first-chat for new - operators. No new network surface, no new persistence schema, no - new daemon contract change. -- Posture's audience-profile cascade continues to be applied on init - (Personal posture sets all features enabled; Team and Enterprise - set audience-appropriate defaults). Operators on Team or Enterprise - who used to walk the feature-selection step now get the same - posture-default mapping written non-interactively and can tune via - `netclaw config → Audience Profiles`. -- No change to the daemon. No change to existing CLI subcommands - (`netclaw provider`, `netclaw model`, `netclaw mcp`). +- Existing installs are not silently re-walked. +- Destructive reset behavior is explicit, menu-driven, and double- + confirmed. +- Identity remains under init ownership instead of becoming a second config + surface. diff --git a/openspec/changes/simplify-netclaw-init/specs/netclaw-onboarding/spec.md b/openspec/changes/simplify-netclaw-init/specs/netclaw-onboarding/spec.md index 35c4a9603..baa945ef0 100644 --- a/openspec/changes/simplify-netclaw-init/specs/netclaw-onboarding/spec.md +++ b/openspec/changes/simplify-netclaw-init/specs/netclaw-onboarding/spec.md @@ -2,210 +2,102 @@ ### Requirement: Guided onboarding -`netclaw init` SHALL provide a three-step guided setup collecting LLM -provider configuration, identity (agent name, operator name, timezone), -and security posture. On completion, the wizard SHALL apply the -posture-default `Tools.AudienceProfiles` mapping in-memory, write the -merged config and secrets via the merge-on-save writer, and run the -existing health check to verify the baseline configuration is -functional. If daemon startup fails because configuration validation -rejects the resulting exposure-mode or remote-auth topology, the -wizard SHALL surface that failure as a structured setup error with -remediation guidance. The wizard SHALL NOT collect Slack credentials, -ACL inputs, search backend, browser automation, memory provider, -MCP server configuration, exposure mode, channels, audience-specific -feature flags, external skill directories, skill feeds, or webhook -URLs during this flow. Those sections SHALL be configured via -`netclaw config` after first-run setup completes. - -The wizard SHALL NOT write `AGENTS.md` to disk during identity file -generation. AGENTS.md is binary-controlled firmware loaded from -embedded resources at runtime. The wizard SHALL continue to write -`SOUL.md` and `TOOLING.md` as operator-mutable identity files. - -For non-Personal postures, the wizard SHALL apply the posture-default -feature-flag mapping non-interactively (memory, search, skills, -scheduling, sub-agents, webhooks) per the posture's documented -defaults. The wizard SHALL NOT present a separate feature-selection -step. Operators wanting to override these defaults per-audience SHALL -use `netclaw config → Audience Profiles`. - -#### Scenario: First-time setup - -- **WHEN** operator runs `netclaw init` on a fresh install -- **THEN** the wizard collects provider, identity (agent name, user - name, timezone), and security posture inputs -- **AND** writes a runnable baseline configuration via the merge-on-save - writer -- **AND** writes SOUL.md and TOOLING.md to `~/.netclaw/identity/` -- **AND** does NOT write AGENTS.md (or writes a reference-only stub) -- **AND** does NOT prompt for Slack, ACL, search, browser automation, - exposure mode, channels, audience-feature flags, external skills, - skill feeds, or webhook URLs - -#### Scenario: Identity files written on completion - -- **WHEN** the wizard completes and writes config -- **THEN** `SOUL.md` is written from the embedded SOUL template -- **AND** `TOOLING.md` is written from the embedded TOOLING template -- **AND** `AGENTS.md` is NOT written from a template - -#### Scenario: Posture cascade applied non-interactively - -- **GIVEN** the operator selected `Team` posture -- **WHEN** the wizard completes its terminal write -- **THEN** `Tools.AudienceProfiles.Team` is populated with the - posture-default mapping (memory, search, skills, scheduling, - sub-agents enabled; webhooks disabled per posture rule) -- **AND** the wizard does not show a separate feature-selection step -- **AND** the operator can edit per-audience features via - `netclaw config → Audience Profiles` - -#### Scenario: Exposure-mode startup validation failure shown cleanly - -- **GIVEN** the operator completes `netclaw init` -- **AND** the written configuration causes `ExposureModeValidationService` - to reject daemon startup -- **WHEN** the health-check step starts the daemon -- **THEN** the wizard shows a failed health-check item containing the - validation message -- **AND** the wizard includes remediation guidance for fixing the - exposure/auth configuration -- **AND** the operator is not shown a raw stack trace - -#### Scenario: Startup validation failure does not degrade to generic readiness timeout - -- **GIVEN** daemon startup fails immediately because exposure validation - rejects the configuration -- **WHEN** the health-check step polls daemon readiness -- **THEN** the wizard reports the actual startup validation failure -- **AND** it does NOT report only `Daemon did not become ready` unless - the failure reason is genuinely unavailable - -#### Scenario: Post-flight nudge points to netclaw config - -- **GIVEN** the wizard completes its terminal write successfully -- **WHEN** the health check passes -- **THEN** Termina displays a post-flight screen confirming what was - set -- **AND** Termina displays a line directing the operator at - `netclaw config` for further configuration -- **AND** after Termina teardown the same one-line nudge prints to - stderr so it remains visible after the TUI clears - -## ADDED Requirements - -### Requirement: Existing-config detection at init entry - -`netclaw init` SHALL detect the presence of a previously-written -`netclaw.json` at startup. When detected and `--force` was not passed, -the command SHALL refuse to proceed: in a TTY it renders a refusal -screen pointing operators at `netclaw config` for live edits or -`netclaw init --force` to reset; in non-TTY usage it prints the -refusal to stderr. The TTY path SHALL exit with status 0 after the -operator acknowledges; the non-TTY path SHALL exit with non-zero -status so CI catches the surprise. - -#### Scenario: TTY refusal shows actionable guidance and exits zero - -- **GIVEN** `netclaw.json` exists on disk -- **AND** `netclaw init` is run in an interactive TTY without `--force` -- **WHEN** the command starts -- **THEN** Termina renders a refusal screen that names both alternative - commands: `netclaw config` and `netclaw init --force` -- **AND** the operator presses Enter to acknowledge -- **AND** the command exits with status 0 -- **AND** `netclaw.json` and `secrets.json` are unchanged - -#### Scenario: Non-TTY refusal exits non-zero - -- **GIVEN** `netclaw.json` exists on disk -- **AND** `netclaw init` is run with stdout/stderr redirected (not a TTY) -- **AND** `--force` was not passed -- **WHEN** the command starts -- **THEN** the refusal text prints to stderr -- **AND** the command exits with non-zero status -- **AND** `netclaw.json` and `secrets.json` are unchanged - -#### Scenario: No existing config proceeds normally - -- **GIVEN** no `netclaw.json` exists on disk -- **WHEN** `netclaw init` is run -- **THEN** the wizard proceeds to Step 1 (Provider) without showing the - refusal screen - -### Requirement: Force-reset backup flow - -`netclaw init --force` SHALL detect existing config and require an -explicit type-to-confirm before proceeding. On confirm, the command -SHALL rename `~/.netclaw/config/netclaw.json` to -`netclaw.json.bak.<unix-millis>` and -`~/.netclaw/config/secrets.json` to `secrets.json.bak.<unix-millis>`. -A single timestamp SHALL be generated per invocation so both files -share a suffix. On the extremely unlikely event of a collision (an -existing file at the chosen suffix), an auto-incrementing dash -suffix SHALL be appended (`.bak.<unix-millis>-1`, `-2`, ...) until a -free filename is found. The wizard SHALL then proceed as a fresh -first-run. The .bak files SHALL be preserved on disk so operators -retain a manual recovery path. The command SHALL print the .bak file -paths to the post-flight screen so operators know where the prior -config went. `netclaw init --force` SHALL refuse to run in non-TTY -contexts (no stdin or no terminal-controlled stdout) because the -type-to-confirm prompt cannot be rendered safely; the command SHALL -print a non-TTY refusal message to stderr and exit non-zero. - -#### Scenario: Force without confirm leaves config unchanged - -- **GIVEN** `netclaw.json` exists on disk -- **AND** `netclaw init --force` is run in an interactive TTY -- **WHEN** the confirm screen renders and the operator cancels -- **THEN** the command exits with status 0 -- **AND** `netclaw.json` and `secrets.json` are unchanged - -#### Scenario: Force with confirm backs up and proceeds - -- **GIVEN** `netclaw.json` and `secrets.json` exist on disk -- **AND** `netclaw init --force` is run in an interactive TTY -- **WHEN** the operator types "reset" and confirms -- **THEN** the original `netclaw.json` is renamed to - `netclaw.json.bak.<unix-timestamp>` -- **AND** the original `secrets.json` is renamed to - `secrets.json.bak.<unix-timestamp>` -- **AND** the wizard proceeds to Step 1 (Provider) with - `WizardContext.ExistingConfig` set to `null` -- **AND** on successful completion the post-flight screen lists the - .bak file paths - -#### Scenario: Force on a fresh install behaves as plain init - -- **GIVEN** no `netclaw.json` exists on disk -- **AND** `netclaw init --force` is run -- **WHEN** the command starts -- **THEN** no backup screen is shown (nothing to back up) -- **AND** the wizard proceeds to Step 1 (Provider) normally - -#### Scenario: Force in non-TTY context refuses - -- **GIVEN** `netclaw.json` exists on disk -- **AND** `netclaw init --force` is run with stdout or stdin not a TTY - (e.g. piped, redirected, or in CI) -- **WHEN** the command starts -- **THEN** stderr contains - `\`netclaw init --force\` requires an interactive terminal for the - reset confirmation. Run it from a TTY.` -- **AND** the command exits with non-zero status -- **AND** the existing `netclaw.json` and `secrets.json` are - unchanged -- **AND** no .bak files are created - -#### Scenario: Force handles existing .bak filename collision - -- **GIVEN** `netclaw.json` exists on disk -- **AND** a previously-created backup at - `~/.netclaw/config/netclaw.json.bak.<expected-millis>` already - exists (e.g. from a prior force run within the same millisecond) -- **WHEN** the operator types "reset" and confirms -- **THEN** the backup uses - `netclaw.json.bak.<expected-millis>-1` (and the corresponding - `secrets.json.bak.<expected-millis>-1`) -- **AND** the existing backup file is not overwritten +`netclaw init` SHALL provide bootstrap-first guided setup. The flow SHALL +collect provider configuration, identity, and security posture. Security +Posture, Enabled Features, and Audience Profiles are distinct concepts. + +If the operator selects `Personal`, the bootstrap flow SHALL skip Enabled +Features. + +If the operator selects `Team` or `Public`, the bootstrap flow SHALL +automatically continue into Enabled Features before final write. + +Audience Profiles editing SHALL NOT be part of init bootstrap; it belongs +to `netclaw config`. + +The wizard SHALL continue to write `SOUL.md` and `TOOLING.md`. Identity +remains init-owned in this branch. + +#### Scenario: Personal posture skips enabled-features bootstrap step + +- **GIVEN** the operator selected `Personal` +- **WHEN** the posture step completes +- **THEN** init does not open an Enabled Features step + +#### Scenario: Team posture continues into enabled-features bootstrap step + +- **GIVEN** the operator selected `Team` +- **WHEN** the posture step completes +- **THEN** init automatically continues into Enabled Features + +#### Scenario: Public posture continues into enabled-features bootstrap step + +- **GIVEN** the operator selected `Public` +- **WHEN** the posture step completes +- **THEN** init automatically continues into Enabled Features + +### ADDED Requirement: Existing-install init menu + +When `netclaw init` runs on an existing install, it SHALL open an action +menu with exactly these options: + +- `Redo identity setup` +- `Open configuration editor` +- `Start over from scratch` +- `Cancel` + +#### Scenario: Existing install opens action menu + +- **GIVEN** `netclaw.json` exists +- **WHEN** the operator runs `netclaw init` +- **THEN** init opens the existing-install menu with the documented four + options + +#### Scenario: Existing install routes to config editor + +- **GIVEN** the existing-install menu is open +- **WHEN** the operator chooses `Open configuration editor` +- **THEN** control routes to `netclaw config` + +#### Scenario: Existing install routes to init-owned identity flow + +- **GIVEN** the existing-install menu is open +- **WHEN** the operator chooses `Redo identity setup` +- **THEN** control routes to the init-owned identity flow + +### ADDED Requirement: Start-over flow is double-confirmed + +Choosing `Start over from scratch` SHALL open a second dialog with exactly: + +- `Reset setup only` +- `Full reset` +- `Cancel` + +Either destructive option SHALL require double confirmation before files are +mutated. + +#### Scenario: Start-over dialog presents reset choices + +- **GIVEN** the existing-install menu is open +- **WHEN** the operator chooses `Start over from scratch` +- **THEN** the second dialog presents `Reset setup only`, `Full reset`, and + `Cancel` + +#### Scenario: Destructive reset requires double confirmation + +- **GIVEN** the operator selected either `Reset setup only` or `Full reset` +- **WHEN** the destructive flow proceeds +- **THEN** two distinct confirmations are required before mutation + +### ADDED Requirement: No init-force flag in this flow + +This bootstrap flow SHALL NOT rely on a `netclaw init --force` mode. +Existing-install reset behavior is owned by the in-TUI existing-install +menu and start-over dialogs. + +#### Scenario: Existing-install reset does not require hidden flag + +- **GIVEN** an existing install +- **WHEN** the operator wants to restart setup +- **THEN** the path is available from the existing-install init menu +- **AND** it does not depend on `netclaw init --force` diff --git a/openspec/changes/simplify-netclaw-init/tasks.md b/openspec/changes/simplify-netclaw-init/tasks.md index 5e41b17c5..768418f5c 100644 --- a/openspec/changes/simplify-netclaw-init/tasks.md +++ b/openspec/changes/simplify-netclaw-init/tasks.md @@ -1,188 +1,63 @@ ## 1. OpenSpec planning artifacts and traceability -- [ ] 1.1 Confirm proposal, design, and spec deltas cover the trimmed - three-step init flow, existing-config refusal, `--force` reset with - backup, post-flight nudge, and the smoke-tape revisions. -- [ ] 1.2 Verify traceability references to `PRD-004` and `PRD-001` - across change artifacts. -- [ ] 1.3 Run `openspec validate simplify-netclaw-init --type change` - and resolve all issues. - -## 2. CLI entry point - -- [ ] 2.1 Update `Netclaw.Cli.Program` `netclaw init` dispatch to - parse the new `--force` flag. Unknown flags produce usage error - and non-zero exit. -- [ ] 2.2 Add existing-config detection at init entry: if - `netclaw.json` exists and `--force` was not passed, branch to the - refusal path (TTY screen vs non-TTY stderr). -- [ ] 2.3 Implement non-TTY refusal: print - `Netclaw is already initialized at <path>. Run \`netclaw config\` - to edit, or \`netclaw init --force\` to reset.` to stderr; exit - with non-zero status. -- [ ] 2.4 Implement TTY refusal: launch Termina with a single-screen - refusal page; default focus on `[ OK ]`; Enter or Esc exits with - status 0. - -## 3. `--force` reset path - -- [ ] 3.1 When `--force` is passed and `netclaw.json` exists, launch - Termina with the type-to-confirm backup screen. The text - acknowledges both `netclaw.json` and `secrets.json` will be moved - aside. -- [ ] 3.2 Default focus on `[ Cancel ]`; the `[ Reset and continue ]` - button is enabled only when the operator types `reset` into the - confirm input. -- [ ] 3.3 On confirm, rename `netclaw.json` → - `netclaw.json.bak.<unix-millis>` and `secrets.json` → - `secrets.json.bak.<unix-millis>` atomically. Generate the - millisecond timestamp once per invocation so the two files share a - suffix. If a file already exists at the chosen suffix, append a - dash-counter (`-1`, `-2`, …) until a free name is found. -- [ ] 3.4 After backup, proceed into the three-step wizard as a fresh - first-run (`WizardContext.ExistingConfig = null`). -- [ ] 3.5 On successful post-flight, list the .bak file paths in the - post-flight screen so the operator knows where the prior config - went. -- [ ] 3.6 `--force` with no existing config silently behaves as plain - `netclaw init` (no backup screen). -- [ ] 3.7 `--force` in a non-TTY context (stdin or stdout not a - terminal) SHALL refuse with the documented stderr message and - exit non-zero before any file mutation. - -## 4. Wizard step list trim - -- [ ] 4.1 Reduce `WizardOrchestrator`'s init-side step list to exactly - three viewmodels: Provider, Identity, Posture. Health check remains - the terminal step. -- [ ] 4.2 Remove from the init step list (NOT delete the classes): - `ChannelPickerStepViewModel`, `ChannelsStepViewModel`, - `FeatureSelectionStepViewModel`, `SearchStepViewModel`, - `SlackStepViewModel`, `DiscordStepViewModel`, - `MattermostStepViewModel`, `ExposureModeStepViewModel`, - `BrowserAutomationStepViewModel`, `ExternalSkillsStepViewModel`, - `SkillFeedsStepViewModel`. These classes continue to back - `netclaw config` section editors per Change B. -- [ ] 4.3 Verify each removed class is still registered with the DI - container as an `ISectionEditor` so `netclaw config` continues to - resolve them. - -## 5. Identity step trim - -- [ ] 5.1 In `IdentityStepViewModel`, retain only the agent-name, - user-name, and timezone fields when running inside the init step - list. The class's `ISectionEditor` implementation may continue to - expose additional fields for future post-install editing; the init - step's view SHALL omit them. -- [ ] 5.2 Remove from the init wizard's Identity view: webhook URL - prompt, communication-style prompt, workspaces-directory prompt. - Their default values are preserved silently. -- [ ] 5.3 Validate fields per existing rules (agent name required, no - whitespace; user name required; timezone validates against - `TimeZoneInfo.FindSystemTimeZoneById`). - -## 6. Posture cascade write - -- [ ] 6.1 In the Posture step's `ContributeConfig` (or the wizard's - terminal write path), apply the posture-default - `Tools.AudienceProfiles` mapping for the selected posture - (Personal: all features on; Team: per-audience defaults per - posture rule; Enterprise: stricter defaults). -- [ ] 6.2 The cascade SHALL write only `Tools.AudienceProfiles` - entries that the operator has not explicitly customized in - `ExistingConfig`. On fresh first-run `ExistingConfig` is null, so - the full posture default applies. - -## 7. Post-flight screen - -- [ ] 7.1 Add a post-flight Termina page showing: provider summary - ("Anthropic — claude-sonnet-4-6"), identity summary ("Netclaw, - aaron, America/Los_Angeles"), posture, health-check status. -- [ ] 7.2 If health check fails, show the failure message and a - `[ Back to Posture ]` action that returns to the Posture step. -- [ ] 7.3 If health check passes, show a `[ Done ]` action and the - nudge text: - `Run \`netclaw chat\` to start, or \`netclaw config\` to configure - channels, webhooks, search, and more.` -- [ ] 7.4 On Termina teardown after a successful Done, print the same - one-line nudge to stderr so it remains visible after the TUI - clears. -- [ ] 7.5 When `--force` reset was used, append the .bak file paths - to the post-flight screen and stderr. - -## 8. Smoke tape revisions - -- [ ] 8.1 Rewrite `tests/smoke/tapes/init-wizard.tape` to exercise - the three-step flow plus post-flight. Target ≤ 60 lines. -- [ ] 8.2 Rewrite `tests/smoke/assertions/init-wizard.sh` to assert - only the bootstrap fields: provider config, models config, identity - files (`SOUL.md`, `TOOLING.md`), posture, and doctor exit code 0 - or 2. -- [ ] 8.3 Delete `tests/smoke/tapes/init-wizard-reverse-proxy.tape` - and `tests/smoke/assertions/init-wizard-reverse-proxy.sh`. - Reverse-proxy coverage is owned by `config-exposure-mode.tape` - from Change B. - -## 9. New smoke tapes - -- [ ] 9.1 Add `tests/smoke/tapes/init-existing-config-refuse.tape`: - pre-stage a `netclaw.json`, run `netclaw init`, observe the TTY - refusal screen, press Enter to acknowledge, assert exit 0. -- [ ] 9.2 Add `tests/smoke/assertions/init-existing-config-refuse.sh`: - assert the pre-staged config is byte-identical post-run. -- [ ] 9.3 Add `tests/smoke/tapes/init-force-reset.tape`: pre-stage a - `netclaw.json`, run `netclaw init --force`, type `reset`, confirm, - complete the three-step flow, assert post-flight Done. -- [ ] 9.4 Add `tests/smoke/assertions/init-force-reset.sh`: assert - (a) a `netclaw.json.bak.*` file exists with the original content, - (b) the new `netclaw.json` reflects what the tape typed, (c) - doctor exits 0 or 2. - -## 10. Documentation - -- [ ] 10.1 Update `docs/prd/PRD-004-cli-onboarding-and-config.md` to - replace the "reentrant init dashboard" wording with the documented - simplified-init + `netclaw config` split. List the three init steps - and reference `netclaw config` for the rest. -- [ ] 10.2 Cross-reference issues #455 and #1150 in PRD-004's Cross- - References section. -- [ ] 10.3 Update `feeds/skills/.system/files/netclaw-identity/SKILL.md` - (per CLAUDE.md system-skills sync rule) so the agent knows the - trimmed identity field set and the `netclaw config` path for - per-audience editing. Bump `metadata.version`. -- [ ] 10.4 Update CLI `--help` text so `netclaw init --help` documents - the trimmed flow and the `--force` flag. - -## 11. Quality gates - -- [ ] 11.1 `dotnet build` clean. -- [ ] 11.2 `dotnet test` clean: round-trip tests for Provider, - Identity, Posture still pass against the trimmed Identity field - set; menu registry audit passes (all editors registered, tapes - exist, test classes exist). -- [ ] 11.3 `./scripts/smoke/run-smoke.sh init-wizard` passes the - rewritten tape. -- [ ] 11.4 `./scripts/smoke/run-smoke.sh light` passes (incl. the two - new init tapes and the 12 `netclaw config` tapes from Change B). -- [ ] 11.5 `dotnet slopwatch analyze` reports no new violations. -- [ ] 11.6 `./scripts/Add-FileHeaders.ps1 -Verify` reports clean. -- [ ] 11.7 `openspec validate simplify-netclaw-init --type change` +- [ ] 1.1 Remove all planning references to `netclaw init --force`. +- [ ] 1.2 Confirm the artifacts reflect bootstrap-only init and init-owned + Identity. +- [ ] 1.3 Run `openspec validate simplify-netclaw-init --type change`. + +## 2. First-run bootstrap flow + +- [ ] 2.1 Trim init to the bootstrap steps only. +- [ ] 2.2 Keep posture values to `Personal`, `Team`, `Public`. +- [ ] 2.3 Keep Security Posture, Enabled Features, and Audience Profiles + distinct in planning and implementation. +- [ ] 2.4 When posture is `Personal`, skip Enabled Features. +- [ ] 2.5 When posture is `Team` or `Public`, automatically continue into + Enabled Features. + +## 3. Existing-install init menu + +- [ ] 3.1 Detect an existing install before entering the first-run flow. +- [ ] 3.2 Show exactly these existing-install options: + `Redo identity setup`, `Open configuration editor`, + `Start over from scratch`, `Cancel`. +- [ ] 3.3 Route `Open configuration editor` to `netclaw config`. +- [ ] 3.4 Route `Redo identity setup` into the init-owned identity flow. + +## 4. Start-over flow + +- [ ] 4.1 Implement the `Start over from scratch` dialog with exactly: + `Reset setup only`, `Full reset`, `Cancel`. +- [ ] 4.2 Require double confirmation before either destructive action. +- [ ] 4.3 Remove all implementation planning tied to `--force` backup or + flag parsing. + +## 5. Identity ownership + +- [ ] 5.1 Keep Identity owned by init. +- [ ] 5.2 Remove any planning language that assumes Identity moves into + `netclaw config`. + +## 6. Post-flight messaging + +- [ ] 6.1 Point successful bootstrap users to `netclaw chat` and + `netclaw config`. +- [ ] 6.2 Keep messaging consistent with the bootstrap-vs-config split. + +## 7. Coverage + +- [ ] 7.1 Rewrite init smoke coverage for the bootstrap-first flow. +- [ ] 7.2 Add coverage for the existing-install action menu. +- [ ] 7.3 Add coverage for the start-over dialog and double confirmation. +- [ ] 7.4 Remove old smoke planning tied to `init --force`. + +## 8. Quality gates + +- [ ] 8.1 `dotnet build` clean. +- [ ] 8.2 `dotnet test` clean. +- [ ] 8.3 `./scripts/smoke/run-smoke.sh init-wizard` clean. +- [ ] 8.4 `./scripts/smoke/run-smoke.sh light` clean. +- [ ] 8.5 `dotnet slopwatch analyze` clean. +- [ ] 8.6 `./scripts/Add-FileHeaders.ps1 -Verify` clean. +- [ ] 8.7 `openspec validate simplify-netclaw-init --type change` passes. - -## 12. Manual acceptance - -- [ ] 12.1 Fresh install (no `~/.netclaw/`): `netclaw init` reaches - working chat in ≤ 3 prompts after provider selection. Verified by - walking through the wizard manually. -- [ ] 12.2 Re-run init over existing config without `--force`: - refusal screen renders, Enter acknowledges, exit 0, config - unchanged. -- [ ] 12.3 Re-run init over existing config with `--force`: confirm - screen renders, type-to-confirm gate works, .bak files created - with matching timestamps, fresh three-step flow runs, new config - written. -- [ ] 12.4 Non-TTY refusal: `netclaw init > /dev/null 2>&1` over an - existing config exits non-zero. -- [ ] 12.5 PR description references this OpenSpec change ID and - cross-references #455 (closed in Change A) and #1150 (closed in - Change B) as already-closed precedents. diff --git a/openspec/specs/feature-selection-wizard/spec.md b/openspec/specs/feature-selection-wizard/spec.md index fb76d35b4..fa31614f5 100644 --- a/openspec/specs/feature-selection-wizard/spec.md +++ b/openspec/specs/feature-selection-wizard/spec.md @@ -1,4 +1,9 @@ -## ADDED Requirements +## Purpose + +Define the bootstrap and post-install behavior of deployment-wide runtime +feature enablement, separate from posture and per-audience access policy. + +## Requirements ### Requirement: Feature selection wizard step @@ -55,6 +60,11 @@ level `Scheduling` section whose only property is `Enabled`. The Feature Selection wizard step SHALL write these flags to the config during `ContributeConfig()`. +These flags MAY be set during bootstrap and SHALL be editable post-install +through the `Enabled Features` leaf. The post-install editor and bootstrap +flow SHALL preserve config semantics for equivalent inputs; byte-identical +serialization is not required. + #### Scenario: Disabled memory writes Enabled false - **GIVEN** the operator disabled memory in Feature Selection @@ -67,6 +77,12 @@ Selection wizard step SHALL write these flags to the config during - **WHEN** config is finalized - **THEN** `Search.Enabled` is `false` in `netclaw.json` +#### Scenario: Enabled Features writes deployment-wide flags + +- **GIVEN** the operator disables search in Enabled Features +- **WHEN** the editor saves +- **THEN** `Search.Enabled` is `false` in `netclaw.json` + #### Scenario: Disabled scheduling writes top-level Scheduling.Enabled false - **GIVEN** the operator disabled scheduling in Feature Selection @@ -74,12 +90,31 @@ Selection wizard step SHALL write these flags to the config during - **THEN** `Scheduling.Enabled` is `false` in `netclaw.json` - **AND** `Scheduling` contains no other properties in this change -#### Scenario: Default Personal config has all features enabled +#### Scenario: Personal posture default keeps all features enabled - **GIVEN** the operator selected Personal posture (Feature Selection skipped) - **WHEN** config is finalized - **THEN** all `Enabled` flags default to `true` +### Requirement: Post-install runtime feature editing moves to Enabled Features + +Post-install runtime feature enablement SHALL remain deployment-wide and a +separate concept from Security Posture and Audience Profiles. Post-install +editing therefore moves to `netclaw config -> Security & Access -> Enabled +Features`, not to Audience Profiles. + +Audience Profiles remains a curated per-audience access editor and SHALL NOT +own per-audience runtime feature toggles. + +#### Scenario: Post-install feature editing does not use Audience Profiles + +- **GIVEN** the operator wants to change deployment-wide search or memory + enablement after install +- **WHEN** they use `netclaw config` +- **THEN** the change is made in `Enabled Features` +- **AND** Audience Profiles is not used for that runtime toggle + + ### Requirement: Feature flags respected at runtime Runtime subsystems SHALL check their respective `Enabled` config flag before diff --git a/openspec/specs/netclaw-cli/spec.md b/openspec/specs/netclaw-cli/spec.md index 453c5793d..f0c835c02 100644 --- a/openspec/specs/netclaw-cli/spec.md +++ b/openspec/specs/netclaw-cli/spec.md @@ -1,37 +1,50 @@ ## Purpose Define operator-facing CLI surface area for Netclaw: the `netclaw init` wizard, -the `netclaw doctor` diagnostic, and the `netclaw approvals` command for -managing persistent tool approvals. +the `netclaw doctor` diagnostic, the `netclaw config` settings surface, and the +`netclaw approvals` command for managing persistent tool approvals. ## Requirements -### Requirement: Init wizard approval mode selection - -The `netclaw init` wizard SHALL ask about shell approval mode when configuring -each audience profile that has shell access enabled. The wizard SHALL present -three options: Approval (recommended default), Unrestricted (HostAllowed with -no approval), and Off (shell disabled). The selected mode SHALL be written to -the audience profile's `ApprovalPolicy` in `netclaw.json`. For Personal, -selecting Approval SHALL explicitly write -`Tools.AudienceProfiles.Personal.ApprovalPolicy.ToolOverrides.shell_execute = "approval"` -rather than relying on runtime audience defaults. +### Requirement: Config command surface + +The CLI SHALL expose `netclaw config` as a top-level command. The command +SHALL operate on local config files and SHALL behave per the +`netclaw-config-command` capability. + +If no config exists, `netclaw config` SHALL print a plain message directing +the operator to `netclaw init` and exit non-zero without launching Termina. + +#### Scenario: Help text describes config as post-install settings surface + +- **WHEN** the operator runs `netclaw config --help` +- **THEN** the command exits zero +- **AND** help text describes `netclaw config` as the main post-install + settings surface +- **AND** help text references `netclaw init` as the bootstrap companion -#### Scenario: Init wizard prompts for Personal shell mode +#### Scenario: No-args invocation launches dashboard on configured install -- **GIVEN** the user is running `netclaw init` -- **WHEN** the wizard configures the Personal audience profile -- **AND** shell mode is not Off -- **THEN** the wizard asks: "Shell approval mode for Personal?" -- **AND** offers Approval (default), Unrestricted, and Off +- **GIVEN** `netclaw.json` exists +- **WHEN** the operator runs `netclaw config` +- **THEN** the domain-oriented dashboard launches -#### Scenario: Init wizard skips approval for audiences with shell off +#### Scenario: Missing install refuses with plain message -- **GIVEN** the user is running `netclaw init` -- **WHEN** the wizard configures an audience with shell mode Off -- **THEN** the wizard does NOT ask about approval mode for that audience +- **GIVEN** `netclaw.json` does not exist +- **WHEN** the operator runs `netclaw config` +- **THEN** stderr contains ``No configuration found. Run `netclaw init` first.`` +- **AND** the command exits non-zero +- **AND** no partial TUI starts -#### Scenario: Selection written to config +### Requirement: Personal shell approval defaults are explicit -- **GIVEN** the user selects "Approval" for Personal audience +When bootstrap selects `Personal` posture, the written config SHALL make the +recommended shell approval default explicit by writing +`Tools.AudienceProfiles.Personal.ApprovalPolicy.ToolOverrides.shell_execute = "approval"` +rather than relying on runtime-only implicit defaults. + +#### Scenario: Personal bootstrap writes explicit shell approval default + +- **GIVEN** the operator completes `netclaw init` with `Personal` posture - **WHEN** the wizard writes the config - **THEN** `netclaw.json` includes `Tools.AudienceProfiles.Personal.ApprovalPolicy.ToolOverrides.shell_execute = "approval"` @@ -278,4 +291,3 @@ SHALL remain a superset of the previous shape: existing `verb` and - **WHEN** the approvals list page renders - **THEN** each row shows the grant's relative creation time alongside its scope label - diff --git a/openspec/specs/netclaw-config-command/spec.md b/openspec/specs/netclaw-config-command/spec.md new file mode 100644 index 000000000..6b23361e3 --- /dev/null +++ b/openspec/specs/netclaw-config-command/spec.md @@ -0,0 +1,209 @@ +## Purpose + +Define the post-install `netclaw config` dashboard, its domain-oriented +navigation model, and the rules for how configuration editing routes or saves. + +## Requirements + +### Requirement: Config command launches a domain-oriented dashboard + +`netclaw config` SHALL launch a domain-oriented settings dashboard. The +root SHALL be navigation-first and SHALL NOT be a flat list of every +registered leaf editor. + +The root SHALL include: + +- `Inference Providers` +- `Models` +- `Channels` +- `Inbound Webhooks` +- `Skill Sources` +- `Search` +- `Browser Automation` +- `Telemetry & Alerting` +- `Security & Access` + +#### Scenario: Root dashboard shows domain entries + +- **GIVEN** a configured install +- **WHEN** the operator runs `netclaw config` +- **THEN** the root dashboard opens with the documented domain entries +- **AND** it does not render a flat dump of every registered leaf editor + +### Requirement: Missing install refuses before TUI startup + +`netclaw config` SHALL detect a missing install/config before starting the +TUI. It SHALL print ``No configuration found. Run `netclaw init` first.`` +to stderr and exit non-zero. + +#### Scenario: No install refusal renders no TUI + +- **GIVEN** `~/.netclaw/config/netclaw.json` does not exist +- **WHEN** the operator runs `netclaw config` +- **THEN** the command prints the refusal message to stderr +- **AND** exits non-zero +- **AND** no partial TUI is rendered + +### Requirement: Routed handoffs are first-class config outcomes + +The config dashboard SHALL allow specific domain entries to route into +existing commands instead of re-hosting the full editor inline. In this +branch, `Inference Providers` SHALL route to `netclaw provider` and +`Models` SHALL route to `netclaw model`. + +#### Scenario: Inference Providers routes to provider command + +- **GIVEN** the operator selects `Inference Providers` +- **WHEN** the handoff is activated +- **THEN** the flow routes to `netclaw provider` +- **AND** no config-dashboard back-stack refactor is required + +### Requirement: Security & Access separates posture, features, profiles, and exposure + +The `Security & Access` area SHALL contain separate entries for Security +Posture, Enabled Features, Audience Profiles, and Exposure Mode. + +Security Posture, Enabled Features, and Audience Profiles SHALL remain +distinct concepts: + +- Security Posture selects the deployment stance. +- Enabled Features controls deployment-wide runtime enablement. +- Audience Profiles edits curated per-audience high-level access rules. + +#### Scenario: Team posture continues into enabled-features flow + +- **GIVEN** the operator changes Security Posture to `Team` +- **WHEN** the posture change flow completes +- **THEN** the config flow continues into Enabled Features + +#### Scenario: Personal posture skips enabled-features continuation + +- **GIVEN** the operator changes Security Posture to `Personal` +- **WHEN** the posture change flow completes +- **THEN** the config flow does not force an Enabled Features continuation + +### Requirement: Audience Profiles is curated and excludes MCP editing + +The Audience Profiles editor SHALL be a curated high-level editor. It SHALL +focus on: + +- Tool Access (non-MCP) +- File Access +- Incoming Attachments +- Reset to posture default + +It SHALL NOT expose: + +- per-audience runtime feature toggles +- per-audience shell mode +- MCP grants/access editing +- raw approval-policy editing + +MCP access/grants/approval editing SHALL route to `netclaw mcp permissions`. + +#### Scenario: Audience Profiles omits per-audience feature toggles + +- **WHEN** the operator opens Audience Profiles +- **THEN** the UI does not offer per-audience runtime feature toggles +- **AND** runtime enablement remains owned by Enabled Features + +#### Scenario: Reset to posture default resets full underlying profile + +- **GIVEN** an audience has customized visible settings and hidden MCP or + approval settings +- **WHEN** the operator activates `Reset to posture default` +- **THEN** the full underlying audience profile is reset to posture + defaults +- **AND** hidden MCP and approval settings for that audience are reset as + well + +### Requirement: Exposure Mode preserves current config shape + +The Exposure Mode editor SHALL keep the existing `Daemon` config shape. It +SHALL use `Daemon.ExposureMode` as the single active selector and SHALL NOT +introduce per-mode active flags. + +Supported explicit modes are: + +- `Local` +- `Reverse Proxy` +- `Tailscale Serve` +- `Tailscale Funnel` +- `Cloudflare Tunnel` + +Each non-local mode SHALL use its own mode-specific dialog. `Local` +requires no extra setup. Inactive old values SHALL be preserved and ignored +when inactive. + +#### Scenario: Switching modes preserves inactive values + +- **GIVEN** the config contains previously saved Cloudflare Tunnel values +- **AND** `Daemon.ExposureMode` is currently `Reverse Proxy` +- **WHEN** the operator edits Reverse Proxy settings and saves +- **THEN** the inactive Cloudflare values remain preserved in config +- **AND** the active mode remains determined only by `Daemon.ExposureMode` + +### Requirement: First non-local exposure enablement may bootstrap pairing + +The flow SHALL auto-pair the current configuring client when the operator +first enables a non-local exposure mode from `netclaw config` and no +bootstrap/pairing state exists. + +If bootstrap state is orphaned or mismatched, the flow SHALL block and +direct the operator to `netclaw doctor`, formal docs, and issue `#875`. + +#### Scenario: Missing bootstrap state auto-pairs current client + +- **GIVEN** the operator enables `Tailscale Serve` +- **AND** no bootstrap or pairing state exists yet +- **WHEN** the save flow runs +- **THEN** the current configuring client is auto-paired before the mode is + finalized + +#### Scenario: Orphaned bootstrap state blocks save + +- **GIVEN** the operator enables a non-local exposure mode +- **AND** existing bootstrap state is orphaned or mismatched +- **WHEN** the save flow validates exposure setup +- **THEN** the save is blocked +- **AND** the operator is directed to `netclaw doctor`, formal docs, and + issue `#875` + +### Requirement: Leaf validation is generalized + +Every config leaf editor SHALL validate what it edits before save. +Validation SHALL cover local structural validity and any relevant probes +such as paths, URIs, auth, binary presence, or remote reachability. + +Structurally invalid config SHALL block save without override. +Runtime/probe failures MAY present `Save anyway`. + +#### Scenario: Structural error blocks save with no override + +- **GIVEN** a leaf editor contains an invalid URI or malformed config + reference +- **WHEN** the operator saves +- **THEN** save is blocked +- **AND** no `Save anyway` affordance is shown + +#### Scenario: Probe failure offers Save anyway + +- **GIVEN** a leaf editor is structurally valid +- **AND** a remote reachability or runtime probe fails +- **WHEN** the operator saves +- **THEN** the editor may show `Save anyway` +- **AND** the operator can choose to persist the structurally valid config + +### Requirement: Coverage follows leaf ownership + +Leaf editors SHALL receive substantive round-trip and smoke coverage. +Routed handoffs SHALL receive shallow routing coverage only. Preservation +assertions SHALL be semantic, not byte-identical. + +#### Scenario: Routed handoff does not require leaf round-trip suite + +- **GIVEN** `Inference Providers` routes to `netclaw provider` +- **WHEN** coverage is defined for the config dashboard +- **THEN** the handoff requires routing coverage +- **AND** it does not require a duplicate leaf-editor round-trip suite in + this change diff --git a/openspec/specs/netclaw-onboarding/spec.md b/openspec/specs/netclaw-onboarding/spec.md index c4bcbb24c..a97d1058d 100644 --- a/openspec/specs/netclaw-onboarding/spec.md +++ b/openspec/specs/netclaw-onboarding/spec.md @@ -1,34 +1,70 @@ -## MODIFIED Requirements +## Purpose + +Define the bootstrap-first `netclaw init` onboarding flow, its existing-install +branches, and the required validation and identity-file behavior for a runnable +baseline setup. + +## Requirements ### Requirement: Guided onboarding -The CLI SHALL provide guided setup through `netclaw init`. The onboarding -wizard SHALL collect Slack credentials, provider configuration, ACL inputs, -search backend, browser automation, memory provider selection, MCP server -configuration, and exposure mode selection. On completion, the wizard SHALL -run a health check to verify the baseline configuration is functional. If -daemon startup fails because configuration validation rejects the selected -exposure mode or remote-auth topology, the wizard SHALL surface that failure -as a structured setup error with remediation guidance. +The CLI SHALL provide bootstrap-first guided setup through `netclaw init`. +The onboarding wizard SHALL collect provider configuration, identity, and +security posture, then write a runnable baseline configuration. On +completion, the wizard SHALL run a health check to verify the baseline +configuration is functional. If daemon startup fails because configuration +validation rejects the selected exposure mode or remote-auth topology, the +wizard SHALL surface that failure as a structured setup error with +remediation guidance. + +Security Posture, Enabled Features, and Audience Profiles are distinct +concepts. + +If the operator selects `Personal`, the bootstrap flow SHALL skip Enabled +Features. + +If the operator selects `Team` or `Public`, the bootstrap flow SHALL +automatically continue into Enabled Features before final write. + +Audience Profiles editing SHALL NOT be part of init bootstrap; it belongs to +`netclaw config`. The wizard SHALL NOT write `AGENTS.md` to disk during identity file generation. AGENTS.md is binary-controlled firmware loaded from embedded resources at runtime. The wizard SHALL continue to write `SOUL.md` and -`TOOLING.md` as operator-mutable identity files. +`TOOLING.md` as operator-mutable identity files. Identity remains init-owned. -For non-Personal postures, the wizard SHALL also present a Feature Selection -step that writes deployment-wide `Enabled` switches. These switches SHALL NOT -implicitly rewrite Public audience allowlists. +For non-Personal postures, the Enabled Features step writes deployment-wide +`Enabled` switches. These switches SHALL NOT implicitly rewrite Public +audience allowlists. #### Scenario: First-time setup - **WHEN** operator runs `netclaw init` on a fresh install -- **THEN** guided setup collects provider, Slack, ACL, search, browser - automation, memory, and exposure mode inputs +- **THEN** guided setup collects provider, identity, and security posture + inputs - **AND** writes a runnable baseline configuration - **AND** writes SOUL.md and TOOLING.md to `~/.netclaw/identity/` - **AND** does NOT write AGENTS.md (or writes a reference-only stub) +#### Scenario: Personal posture skips enabled-features bootstrap step + +- **GIVEN** the operator selected `Personal` +- **WHEN** the posture step completes +- **THEN** init does not open an Enabled Features step + +#### Scenario: Team posture continues into enabled-features bootstrap step + +- **GIVEN** the operator selected `Team` +- **WHEN** the posture step completes +- **THEN** init automatically continues into Enabled Features + +#### Scenario: Public posture continues into enabled-features bootstrap step + +- **GIVEN** the operator selected `Public` +- **WHEN** the posture step completes +- **THEN** init automatically continues into Enabled Features + #### Scenario: Identity files written on completion - **WHEN** the wizard completes and writes config @@ -64,3 +100,110 @@ implicitly rewrite Public audience allowlists. - **THEN** the wizard reports the actual startup validation failure - **AND** it does NOT report only `Daemon did not become ready` unless the failure reason is genuinely unavailable + +### Requirement: Existing-install init menu + +When `netclaw init` runs on an existing install, it SHALL open an action menu +with exactly these options: + +- `Redo identity setup` +- `Open configuration editor` +- `Start over from scratch` +- `Cancel` + +#### Scenario: Existing install opens action menu + +- **GIVEN** `netclaw.json` exists +- **WHEN** the operator runs `netclaw init` +- **THEN** init opens the existing-install menu with the documented four + options + +#### Scenario: Existing install routes to config editor + +- **GIVEN** the existing-install menu is open +- **WHEN** the operator chooses `Open configuration editor` +- **THEN** control routes to `netclaw config` + +#### Scenario: Existing install routes to init-owned identity flow + +- **GIVEN** the existing-install menu is open +- **WHEN** the operator chooses `Redo identity setup` +- **THEN** control routes to the init-owned identity flow + +### Requirement: Start-over flow is double-confirmed + +Choosing `Start over from scratch` SHALL open a second dialog with exactly: + +- `Reset setup only` +- `Full reset` +- `Cancel` + +Either destructive option SHALL require double confirmation before files are +mutated. + +#### Scenario: Start-over dialog presents reset choices + +- **GIVEN** the existing-install menu is open +- **WHEN** the operator chooses `Start over from scratch` +- **THEN** the second dialog presents `Reset setup only`, `Full reset`, and + `Cancel` + +#### Scenario: Destructive reset requires double confirmation + +- **GIVEN** the operator selected either `Reset setup only` or `Full reset` +- **WHEN** the destructive flow proceeds +- **THEN** two distinct confirmations are required before mutation + +### Requirement: No init-force flag in this flow + +This bootstrap flow SHALL NOT rely on a `netclaw init --force` mode. +Existing-install reset behavior SHALL be owned by the in-TUI existing-install +menu and start-over dialogs. + +#### Scenario: Existing-install reset does not require hidden flag + +- **GIVEN** an existing install +- **WHEN** the operator wants to restart setup +- **THEN** the path is available from the existing-install init menu +- **AND** it does not depend on `netclaw init --force` + +### Requirement: Init-owned editor re-entry uses existing config state + +Init-owned editor re-entry on an existing install SHALL load existing config +into `WizardContext.ExistingConfig` and prefill non-secret values from that +state. Secret-bearing fields SHALL remain masked and empty. + +#### Scenario: Provider re-entry keeps credential field masked + +- **GIVEN** an existing provider configuration with stored credentials +- **WHEN** an init-owned provider flow re-enters +- **THEN** provider choice and non-secret fields are prefilled +- **AND** credential inputs remain blank with configured/not-set hint text + +#### Scenario: Identity re-entry prefills init-owned fields + +- **GIVEN** an existing install with agent name, operator name, and + timezone already set +- **WHEN** an init-owned identity flow re-enters +- **THEN** those non-secret fields are prefilled + +### Requirement: Init-owned writes use semantic merge + +Init-owned editor flows SHALL write changes through semantic merge-on-save. +Unrelated config meaning and unrelated stored secrets SHALL be preserved even +if the serialized file text changes. + +#### Scenario: Identity-only edit preserves unrelated config meaning + +- **GIVEN** an existing install with configured channels, search, and + exposure settings +- **WHEN** an init-owned identity flow updates only identity-owned data +- **THEN** the unrelated config sections remain semantically unchanged + +#### Scenario: Blank secret submission preserves existing secret + +- **GIVEN** an init-owned flow includes a secret-bearing field with an + existing stored value +- **WHEN** the operator leaves that field blank and saves +- **THEN** the existing secret remains stored +- **AND** no decrypted value is shown in the UI diff --git a/openspec/specs/section-editor-abstraction/spec.md b/openspec/specs/section-editor-abstraction/spec.md new file mode 100644 index 000000000..4b92d3ca5 --- /dev/null +++ b/openspec/specs/section-editor-abstraction/spec.md @@ -0,0 +1,114 @@ +# section-editor-abstraction Specification + +## Purpose + +Define the reusable CLI leaf-editor contract shared by bootstrap-only init +flows and future post-install config flows, including semantic persistence, +secret-safe re-entry, and audit obligations. + +## Requirements + +### Requirement: Leaf editor interface + +The CLI SHALL define an `ISectionEditor` contract for reusable editable +leaf surfaces. A leaf editor SHALL declare a stable `SectionId`, a +user-facing `DisplayName`, optional `Category`, `ShowInMenu`, status and +summary methods, relevant validation checks, and a factory that returns an +`IWizardStepViewModel` runnable in either init-owned flows or config-owned +single-step hosting. + +The contract SHALL describe leaf editing only. It SHALL NOT imply that the +top-level `netclaw config` IA is flat or identical to registry order. + +#### Scenario: Registered leaf editor does not define dashboard shape + +- **GIVEN** a registered leaf editor with `SectionId = "Search"` +- **WHEN** the config dashboard is later composed +- **THEN** the dashboard MAY place that leaf under a grouped page such as + `Search` or `Security & Access` +- **AND** the leaf editor contract remains valid regardless of the + top-level navigation shape + +#### Scenario: Synthetic init-owned editor is allowed + +- **GIVEN** an editor such as `Identity` spans generated files and config + leaves +- **WHEN** it is registered with `ShowInMenu = false` +- **THEN** it MAY use a synthetic identifier when documented in the + exemption list +- **AND** it SHALL remain absent from the config dashboard menu + +### Requirement: Semantic merge-on-save + +Leaf editors SHALL persist changes through semantic merge-on-save. The merge +writer SHALL preserve unrelated sections and inactive values semantically. +Formatting, property order, and byte-for-byte file identity are NOT part of +the contract. + +#### Scenario: Editing one leaf preserves unrelated meaning + +- **GIVEN** `netclaw.json` contains configured `Providers`, `Slack`, + `Search`, and inactive exposure-mode values for modes other than the + current `Daemon.ExposureMode` +- **WHEN** the operator edits only the Search leaf and saves +- **THEN** `Search` reflects the requested change +- **AND** the unrelated sections and inactive exposure-mode values remain + semantically unchanged + +#### Scenario: No-op save may rewrite formatting without changing meaning + +- **GIVEN** an existing config file with non-canonical property order +- **WHEN** an editor performs a no-op save +- **THEN** the resulting file MAY differ in byte representation +- **AND** the resulting parsed config SHALL be semantically equivalent to + the original + +### Requirement: Reentrancy contract for init-owned flows + +Init-owned re-entry flows SHALL prefill non-secret fields from +`WizardContext.ExistingConfig` when they reuse a leaf editor against existing +state. Secret-bearing fields SHALL remain empty and masked, using +existence-only hint text. + +#### Scenario: Existing non-secret values prefill + +- **GIVEN** an init-owned flow enters the Security Posture editor with an + existing posture already configured +- **WHEN** the editor loads +- **THEN** the current posture is preselected + +#### Scenario: Stored secrets never rehydrate + +- **GIVEN** an editor owns a secret-bearing field whose value exists in + `secrets.json` +- **WHEN** the editor loads +- **THEN** the field renders empty +- **AND** the hint indicates only whether a value exists +- **AND** the decrypted value is never displayed + +### Requirement: Secret-presence lookup without decryption + +`ConfigFileHelper` SHALL expose an existence-only secret lookup API used by +leaf editors to decide between "configured - leave blank to keep" and +"(not set)". + +#### Scenario: Presence lookup does not decrypt + +- **GIVEN** `secrets.json` contains an encrypted value for a leaf editor +- **WHEN** `SecretPresent(...)` is called +- **THEN** the result indicates presence or absence only +- **AND** the decrypted value is not materialized for UI display + +### Requirement: Audit applies to registered leaf editors + +The test project SHALL audit registered leaf editors for round-trip test +coverage and declared validation checks. `ShowInMenu = false` leaves remain +subject to round-trip coverage but are exempt from config-dashboard tape +requirements. + +#### Scenario: Menu-hidden init-owned editor still needs a round-trip test + +- **GIVEN** `Identity` is registered with `ShowInMenu = false` +- **WHEN** the registry audit runs +- **THEN** the audit requires a leaf-editor round-trip test class +- **AND** it does NOT require a config-dashboard smoke tape for Identity From e402c1e77b045f4a8e72ec0f1aed797c74733390 Mon Sep 17 00:00:00 2001 From: Aaron Stannard <aaron@petabridge.com> Date: Mon, 25 May 2026 18:59:35 +0000 Subject: [PATCH 04/31] feat(config): prototype schema-driven search editor Wire Search into netclaw config so we can validate preserved-state section editing, semantic config and secrets saves, and probe-gated warnings before expanding the dashboard. --- .../changes/netclaw-config-command/tasks.md | 26 +- .../section-editor-abstraction/tasks.md | 84 +-- .../Config/ConfigCommandTests.cs | 74 +++ .../SearchConfigEditorViewModelTests.cs | 168 ++++++ .../Tui/ConfigDashboardViewModelTests.cs | 81 +++ .../FeatureSelectionStepViewModelTests.cs | 31 ++ .../Tui/Wizard/IdentityStepViewModelTests.cs | 34 ++ .../Tui/Wizard/MenuRegistryAuditTests.cs | 102 ++++ .../Tui/Wizard/SectionEditorLeafTests.cs | 101 ++++ .../Tui/Wizard/SectionEditorTestBase.cs | 45 ++ .../Tui/Wizard/WizardConfigScenarioTests.cs | 42 ++ .../Tui/Wizard/WizardOrchestratorTests.cs | 19 + src/Netclaw.Cli/Config/ConfigCommand.cs | 46 ++ src/Netclaw.Cli/Config/ConfigFileHelper.cs | 131 +++++ src/Netclaw.Cli/Program.cs | 76 ++- .../SchemaDrivenConfigInfrastructure.cs | 488 ++++++++++++++++++ .../Tui/Config/SearchConfigEditorPage.cs | 334 ++++++++++++ .../Tui/Config/SearchConfigEditorViewModel.cs | 340 ++++++++++++ src/Netclaw.Cli/Tui/ConfigDashboardPage.cs | 116 +++++ .../Tui/ConfigDashboardViewModel.cs | 110 ++++ src/Netclaw.Cli/Tui/InitWizardViewModel.cs | 26 +- .../Sections/SectionEditorInfrastructure.cs | 156 ++++++ .../Steps/FeatureSelectionStepViewModel.cs | 103 +++- .../Tui/Wizard/Steps/IdentityStepViewModel.cs | 87 +++- .../Tui/Wizard/Steps/ProviderStepViewModel.cs | 152 +++++- .../Steps/SecurityPostureStepViewModel.cs | 77 ++- .../Tui/Wizard/WizardConfigBuilder.cs | 145 ++++-- src/Netclaw.Cli/Tui/Wizard/WizardContext.cs | 12 +- .../Tui/Wizard/WizardOrchestrator.cs | 21 + 29 files changed, 3111 insertions(+), 116 deletions(-) create mode 100644 src/Netclaw.Cli.Tests/Config/ConfigCommandTests.cs create mode 100644 src/Netclaw.Cli.Tests/Tui/Config/SearchConfigEditorViewModelTests.cs create mode 100644 src/Netclaw.Cli.Tests/Tui/ConfigDashboardViewModelTests.cs create mode 100644 src/Netclaw.Cli.Tests/Tui/Wizard/MenuRegistryAuditTests.cs create mode 100644 src/Netclaw.Cli.Tests/Tui/Wizard/SectionEditorLeafTests.cs create mode 100644 src/Netclaw.Cli.Tests/Tui/Wizard/SectionEditorTestBase.cs create mode 100644 src/Netclaw.Cli/Config/ConfigCommand.cs create mode 100644 src/Netclaw.Cli/Tui/Config/SchemaDrivenConfigInfrastructure.cs create mode 100644 src/Netclaw.Cli/Tui/Config/SearchConfigEditorPage.cs create mode 100644 src/Netclaw.Cli/Tui/Config/SearchConfigEditorViewModel.cs create mode 100644 src/Netclaw.Cli/Tui/ConfigDashboardPage.cs create mode 100644 src/Netclaw.Cli/Tui/ConfigDashboardViewModel.cs create mode 100644 src/Netclaw.Cli/Tui/Sections/SectionEditorInfrastructure.cs diff --git a/openspec/changes/netclaw-config-command/tasks.md b/openspec/changes/netclaw-config-command/tasks.md index ab79010da..277c9a8cd 100644 --- a/openspec/changes/netclaw-config-command/tasks.md +++ b/openspec/changes/netclaw-config-command/tasks.md @@ -1,33 +1,33 @@ ## 1. OpenSpec planning artifacts and traceability -- [ ] 1.1 Confirm proposal, design, and spec deltas reflect the +- [x] 1.1 Confirm proposal, design, and spec deltas reflect the domain-oriented config IA and the locked ownership split. -- [ ] 1.2 Remove planning language that still assumes Enterprise posture, +- [x] 1.2 Remove planning language that still assumes Enterprise posture, per-audience runtime feature toggles, per-audience shell mode, inline MCP permission editing, flat dashboards, or byte-identical assertions. -- [ ] 1.3 Run `openspec validate netclaw-config-command --type change`. +- [x] 1.3 Run `openspec validate netclaw-config-command --type change`. ## 2. Command entry and refusal behavior -- [ ] 2.1 Add `netclaw config` to CLI routing. -- [ ] 2.2 Refuse with a plain non-zero message when no install/config is +- [x] 2.1 Add `netclaw config` to CLI routing. +- [x] 2.2 Refuse with a plain non-zero message when no install/config is present: direct operators to `netclaw init` and render no TUI. -- [ ] 2.3 Keep `--help` discoverable from `netclaw --help`. +- [x] 2.3 Keep `--help` discoverable from `netclaw --help`. ## 3. Root dashboard IA -- [ ] 3.1 Implement the root dashboard as domain navigation, not a flat +- [x] 3.1 Implement the root dashboard as domain navigation, not a flat list of every leaf editor. -- [ ] 3.2 Add these root entries: Inference Providers, Models, Channels, +- [x] 3.2 Add these root entries: Inference Providers, Models, Channels, Inbound Webhooks, Skill Sources, Search, Browser Automation, Telemetry & Alerting, Security & Access. -- [ ] 3.3 Add Quit and Run Full Doctor affordances at the root. +- [x] 3.3 Add Quit and Run Full Doctor affordances at the root. ## 4. Routed handoffs -- [ ] 4.1 Route `Inference Providers` to `netclaw provider`. -- [ ] 4.2 Route `Models` to `netclaw model`. -- [ ] 4.3 Add shallow routing coverage for both handoffs. +- [x] 4.1 Route `Inference Providers` to `netclaw provider`. +- [x] 4.2 Route `Models` to `netclaw model`. +- [x] 4.3 Add shallow routing coverage for both handoffs. ## 5. Channels area @@ -129,5 +129,5 @@ - [ ] 15.3 `./scripts/smoke/run-smoke.sh light` clean. - [ ] 15.4 `dotnet slopwatch analyze` clean. - [ ] 15.5 `./scripts/Add-FileHeaders.ps1 -Verify` clean. -- [ ] 15.6 `openspec validate netclaw-config-command --type change` +- [x] 15.6 `openspec validate netclaw-config-command --type change` passes. diff --git a/openspec/changes/section-editor-abstraction/tasks.md b/openspec/changes/section-editor-abstraction/tasks.md index e15d450fa..fe5e8a070 100644 --- a/openspec/changes/section-editor-abstraction/tasks.md +++ b/openspec/changes/section-editor-abstraction/tasks.md @@ -1,100 +1,100 @@ ## 1. OpenSpec planning artifacts and traceability -- [ ] 1.1 Confirm proposal, design, and spec deltas describe a leaf-editor +- [x] 1.1 Confirm proposal, design, and spec deltas describe a leaf-editor abstraction rather than a flat dashboard contract. -- [ ] 1.2 Confirm the artifacts reflect the locked split: `init` owns +- [x] 1.2 Confirm the artifacts reflect the locked split: `init` owns bootstrap and Identity; `config` owns post-install editing. -- [ ] 1.3 Run `openspec validate section-editor-abstraction --type change` +- [x] 1.3 Run `openspec validate section-editor-abstraction --type change` and resolve issues. ## 2. Core abstraction -- [ ] 2.1 Add `ISectionEditor` with `SectionId`, `DisplayName`, +- [x] 2.1 Add `ISectionEditor` with `SectionId`, `DisplayName`, `Category?`, `ShowInMenu`, `GetStatus`, `Summary`, `RelevantDoctorChecks`, and `CreateEditor`. -- [ ] 2.2 Add `SectionStatus`. -- [ ] 2.3 Add `SectionContribution` with explicit field and secret +- [x] 2.2 Add `SectionStatus`. +- [x] 2.3 Add `SectionContribution` with explicit field and secret actions. -- [ ] 2.4 Add `[NoDoctorChecks]` justification support where truly needed. +- [x] 2.4 Add `[NoDoctorChecks]` justification support where truly needed. ## 3. Registry and exemption list -- [ ] 3.1 Add `SectionEditorRegistry` with duplicate-ID fail-fast. -- [ ] 3.2 Add `AddSectionEditor<TEditor>()` DI registration. -- [ ] 3.3 Add `SectionEditorExemptions` entries for synthetic/init-owned +- [x] 3.1 Add `SectionEditorRegistry` with duplicate-ID fail-fast. +- [x] 3.2 Add `AddSectionEditor<TEditor>()` DI registration. +- [x] 3.3 Add `SectionEditorExemptions` entries for synthetic/init-owned surfaces, including Identity. -- [ ] 3.4 Document that the registry is a leaf-editor registry and does +- [x] 3.4 Document that the registry is a leaf-editor registry and does not dictate the future dashboard IA. ## 4. Single-step orchestrator mode -- [ ] 4.1 Add single-step hosting to `WizardOrchestrator`. -- [ ] 4.2 Ensure save exits and cancel exits work without linear step-list +- [x] 4.1 Add single-step hosting to `WizardOrchestrator`. +- [x] 4.2 Ensure save exits and cancel exits work without linear step-list navigation. -- [ ] 4.3 Add unit tests for single-step save and cancel. +- [x] 4.3 Add unit tests for single-step save and cancel. ## 5. Semantic merge-on-save plumbing -- [ ] 5.1 Refactor config writes to load existing config, apply +- [x] 5.1 Refactor config writes to load existing config, apply contributions, and preserve unrelated sections semantically. -- [ ] 5.2 Refactor secret writes to preserve blank submissions, replace on +- [x] 5.2 Refactor secret writes to preserve blank submissions, replace on non-blank, and remove only on explicit delete. -- [ ] 5.3 Preserve inactive values for exposure-mode and similar editors +- [x] 5.3 Preserve inactive values for exposure-mode and similar editors when they are not the active leaf being changed. -- [ ] 5.4 Add `ConfigFileHelper.SecretPresent(...)` without decrypting +- [x] 5.4 Add `ConfigFileHelper.SecretPresent(...)` without decrypting stored values. ## 6. ExistingConfig population -- [ ] 6.1 Populate `WizardContext.ExistingConfig` from on-disk config when +- [x] 6.1 Populate `WizardContext.ExistingConfig` from on-disk config when init enters an editor flow that needs existing state. -- [ ] 6.2 Keep secrets out of the context entirely. -- [ ] 6.3 Document that this supports init-owned re-entry, not init as the +- [x] 6.2 Keep secrets out of the context entirely. +- [x] 6.3 Document that this supports init-owned re-entry, not init as the main post-install editor. ## 7. Refactor bootstrap leaves -- [ ] 7.1 Refactor Provider to implement `ISectionEditor` +- [x] 7.1 Refactor Provider to implement `ISectionEditor` (`ShowInMenu = false`; owned by init / routed provider command). -- [ ] 7.2 Refactor Identity to implement `ISectionEditor` +- [x] 7.2 Refactor Identity to implement `ISectionEditor` (`ShowInMenu = false`; synthetic ID; init-owned). -- [ ] 7.3 Refactor Security Posture to implement `ISectionEditor` +- [x] 7.3 Refactor Security Posture to implement `ISectionEditor` (`ShowInMenu = true`; reusable under `Security & Access`). -- [ ] 7.4 Refactor Enabled Features to implement `ISectionEditor` +- [x] 7.4 Refactor Enabled Features to implement `ISectionEditor` (`ShowInMenu = true`; separate from posture and audience profiles). -- [ ] 7.5 Ensure each refactored editor declares meaningful validation +- [x] 7.5 Ensure each refactored editor declares meaningful validation checks and produces `SectionContribution` output. ## 8. Round-trip test harness -- [ ] 8.1 Add `SectionEditorTestBase<TEditor>` with semantic round-trip, +- [x] 8.1 Add `SectionEditorTestBase<TEditor>` with semantic round-trip, secret-preservation, and targeted update scenarios. -- [ ] 8.2 Add Provider leaf tests. -- [ ] 8.3 Add Identity leaf tests. -- [ ] 8.4 Add Security Posture leaf tests. -- [ ] 8.5 Add Enabled Features leaf tests. +- [x] 8.2 Add Provider leaf tests. +- [x] 8.3 Add Identity leaf tests. +- [x] 8.4 Add Security Posture leaf tests. +- [x] 8.5 Add Enabled Features leaf tests. ## 9. Menu registry audit -- [ ] 9.1 Add `MenuRegistryAuditTests` for registered leaf editors. -- [ ] 9.2 Require round-trip tests and validation declarations for every +- [x] 9.1 Add `MenuRegistryAuditTests` for registered leaf editors. +- [x] 9.2 Require round-trip tests and validation declarations for every registered leaf editor. -- [ ] 9.3 Exempt `ShowInMenu = false` leaves from config smoke-tape +- [x] 9.3 Exempt `ShowInMenu = false` leaves from config smoke-tape existence checks. -- [ ] 9.4 Document that routed handoff entries are tested separately in the +- [x] 9.4 Document that routed handoff entries are tested separately in the config command change. ## 10. Existing test suite preservation -- [ ] 10.1 Keep current init smoke coverage passing. -- [ ] 10.2 Keep current reverse-proxy/init coverage passing until the later +- [x] 10.1 Keep current init smoke coverage passing. +- [x] 10.2 Keep current reverse-proxy/init coverage passing until the later config and init changes intentionally move it. ## 11. Quality gates -- [ ] 11.1 `dotnet build` clean. -- [ ] 11.2 `dotnet test` clean. -- [ ] 11.3 `dotnet slopwatch analyze` clean. -- [ ] 11.4 `./scripts/Add-FileHeaders.ps1 -Verify` clean. -- [ ] 11.5 `openspec validate section-editor-abstraction --type change` +- [x] 11.1 `dotnet build` clean. +- [x] 11.2 `dotnet test` clean. +- [x] 11.3 `dotnet slopwatch analyze` clean. +- [x] 11.4 `./scripts/Add-FileHeaders.ps1 -Verify` clean. +- [x] 11.5 `openspec validate section-editor-abstraction --type change` passes. diff --git a/src/Netclaw.Cli.Tests/Config/ConfigCommandTests.cs b/src/Netclaw.Cli.Tests/Config/ConfigCommandTests.cs new file mode 100644 index 000000000..a27cf0e1e --- /dev/null +++ b/src/Netclaw.Cli.Tests/Config/ConfigCommandTests.cs @@ -0,0 +1,74 @@ +// ----------------------------------------------------------------------- +// <copyright file="ConfigCommandTests.cs" company="Petabridge, LLC"> +// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com> +// </copyright> +// ----------------------------------------------------------------------- +using Netclaw.Cli.Config; +using Netclaw.Configuration; +using Netclaw.Tests.Utilities; +using Xunit; + +namespace Netclaw.Cli.Tests.Config; + +public sealed class ConfigCommandTests : IDisposable +{ + private readonly DisposableTempDir _dir = new(); + private readonly NetclawPaths _paths; + private readonly StringWriter _output = new(); + private readonly StringWriter _error = new(); + + public ConfigCommandTests() + { + _paths = new NetclawPaths(_dir.Path); + _paths.EnsureDirectoriesExist(); + } + + public void Dispose() + { + _output.Dispose(); + _error.Dispose(); + _dir.Dispose(); + } + + [Fact] + public void Help_describes_post_install_dashboard() + { + var exitCode = ConfigCommand.Run(["config", "--help"], _paths, _output, _error); + + Assert.Equal(0, exitCode); + Assert.Contains("main post-install settings dashboard", _output.ToString(), StringComparison.OrdinalIgnoreCase); + Assert.Contains("netclaw init", _output.ToString(), StringComparison.Ordinal); + Assert.Equal(string.Empty, _error.ToString()); + } + + [Fact] + public void Missing_install_refuses_before_tui_startup() + { + var exitCode = ConfigCommand.Run(["config"], _paths, _output, _error); + + Assert.Equal(1, exitCode); + Assert.Equal(ConfigCommand.MissingConfigMessage + Environment.NewLine, _error.ToString()); + Assert.Equal(string.Empty, _output.ToString()); + } + + [Fact] + public void Configured_install_allows_dashboard_launch() + { + File.WriteAllText(_paths.NetclawConfigPath, "{\"configVersion\":1}"); + + var exitCode = ConfigCommand.Run(["config"], _paths, _output, _error); + + Assert.Equal(0, exitCode); + Assert.Equal(string.Empty, _output.ToString()); + Assert.Equal(string.Empty, _error.ToString()); + } + + [Fact] + public void Unexpected_arguments_return_usage_error() + { + var exitCode = ConfigCommand.Run(["config", "extra"], _paths, _output, _error); + + Assert.Equal(1, exitCode); + Assert.Contains("Usage: netclaw config", _output.ToString(), StringComparison.Ordinal); + } +} diff --git a/src/Netclaw.Cli.Tests/Tui/Config/SearchConfigEditorViewModelTests.cs b/src/Netclaw.Cli.Tests/Tui/Config/SearchConfigEditorViewModelTests.cs new file mode 100644 index 000000000..2003cc973 --- /dev/null +++ b/src/Netclaw.Cli.Tests/Tui/Config/SearchConfigEditorViewModelTests.cs @@ -0,0 +1,168 @@ +// ----------------------------------------------------------------------- +// <copyright file="SearchConfigEditorViewModelTests.cs" company="Petabridge, LLC"> +// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com> +// </copyright> +// ----------------------------------------------------------------------- +using System.Net; +using System.Net.Http; +using System.Text; +using Netclaw.Cli.Tui.Config; +using Netclaw.Configuration; +using Netclaw.Configuration.Secrets; +using Netclaw.Tests.Utilities; +using Xunit; + +namespace Netclaw.Cli.Tests.Tui.Config; + +public sealed class SearchConfigEditorViewModelTests : IDisposable +{ + private readonly DisposableTempDir _dir = new(); + private readonly NetclawPaths _paths; + + public SearchConfigEditorViewModelTests() + { + _paths = new NetclawPaths(_dir.Path); + _paths.EnsureDirectoriesExist(); + File.WriteAllText(_paths.NetclawConfigPath, """ + { + "configVersion": 1, + "Search": { + "Backend": "duckduckgo" + } + } + """); + } + + public void Dispose() => _dir.Dispose(); + + [Fact] + public void Search_dashboard_entry_routes_to_real_editor() + { + using var vm = new Netclaw.Cli.Tui.ConfigDashboardViewModel(new Netclaw.Cli.Tui.ConfigDashboardNavigationState()); + string? route = null; + vm.RouteRequested = r => route = r; + + vm.Activate(vm.Items.Single(static item => item.Label == "Search")); + + Assert.Equal("/search", route); + } + + [Fact] + public void Fields_project_search_enabled_out_of_editor() + { + using var vm = new SearchConfigEditorViewModel(_paths); + + Assert.DoesNotContain(vm.Fields, static field => field.Path == "Search.Enabled"); + Assert.Contains(vm.Fields, static field => field.Path == "Search.Backend"); + } + + [Fact] + public async Task Brave_probe_failure_opens_override_dialog_before_save() + { + using var vm = new SearchConfigEditorViewModel(_paths, new StubHttpClientFactory(_ => + new HttpResponseMessage(HttpStatusCode.Unauthorized))); + + vm.SetFieldValue("Search.Backend", "brave"); + vm.SetFieldValue("Search.BraveApiKey", "bad-key"); + + await vm.SaveAsync(TestContext.Current.CancellationToken); + + Assert.Equal(SearchConfigEditorDialog.ProbeWarning, vm.ActiveDialog.Value); + Assert.Contains("authentication failed", vm.Status.Value.Text, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Save_anyway_persists_config_and_secret_semantically() + { + using var vm = new SearchConfigEditorViewModel(_paths); + + vm.SetFieldValue("Search.Backend", "brave"); + vm.SetFieldValue("Search.BraveApiKey", "BSA-live-key"); + vm.SaveWithoutProbeOverride(); + + var config = File.ReadAllText(_paths.NetclawConfigPath); + var secrets = File.ReadAllText(_paths.SecretsPath); + + Assert.Contains("\"Backend\": \"brave\"", config, StringComparison.Ordinal); + Assert.DoesNotContain("BraveApiKey", config, StringComparison.Ordinal); + Assert.Contains("BraveApiKey", secrets, StringComparison.Ordinal); + } + + [Fact] + public void Blank_secret_preserves_existing_secret() + { + var protector = SecretsProtection.CreateProtector(_paths); + var encrypted = protector.Protect("stored-secret"); + File.WriteAllText(_paths.SecretsPath, + "{\n" + + " \"configVersion\": 1,\n" + + " \"Search\": {\n" + + $" \"BraveApiKey\": \"{encrypted}\"\n" + + " }\n" + + "}\n"); + + using var vm = new SearchConfigEditorViewModel(_paths); + vm.SetFieldValue("Search.Backend", "brave"); + vm.SetFieldValue("Search.BraveApiKey", ""); + vm.SaveWithoutProbeOverride(); + + var secrets = File.ReadAllText(_paths.SecretsPath); + Assert.Contains(encrypted, secrets, StringComparison.Ordinal); + } + + [Fact] + public async Task Successful_probe_allows_save_without_dialog() + { + using var vm = new SearchConfigEditorViewModel(_paths, new StubHttpClientFactory(_ => + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{\"web\":{\"results\":[]}}", Encoding.UTF8, "application/json"), + })); + + vm.SetFieldValue("Search.Backend", "brave"); + vm.SetFieldValue("Search.BraveApiKey", "good-key"); + + await vm.SaveAsync(TestContext.Current.CancellationToken); + + Assert.Equal(SearchConfigEditorDialog.None, vm.ActiveDialog.Value); + Assert.Contains("Saved Search settings", vm.Status.Value.Text, StringComparison.Ordinal); + } + + [Fact] + public void Missing_required_backend_specific_fields_raise_structural_errors() + { + using var vm = new SearchConfigEditorViewModel(_paths); + + vm.SetFieldValue("Search.Backend", "searxng"); + vm.SetFieldValue("Search.SearXngEndpoint", ""); + + var issues = vm.ValidationSummary.Value.IssuesFor("Search.SearXngEndpoint"); + Assert.Contains(issues, static issue => issue.Message.Contains("requires an endpoint", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void Preserved_state_supports_in_memory_draft_edits() + { + using var vm = new SearchConfigEditorViewModel(_paths); + + vm.SetFieldValue("Search.Backend", "searxng"); + vm.SetFieldValue("Search.SearXngEndpoint", "https://search.example.com"); + vm.OnDeactivating(); + vm.OnActivated(); + + Assert.Equal("searxng", vm.FieldValues["Search.Backend"].Value); + Assert.Equal("https://search.example.com", vm.FieldValues["Search.SearXngEndpoint"].Value); + } + + private sealed class StubHttpClientFactory(Func<HttpRequestMessage, HttpResponseMessage> handler) : IHttpClientFactory + { + public HttpClient CreateClient(string name) + => new(new StubHttpMessageHandler(handler)); + } + + private sealed class StubHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> handler) : HttpMessageHandler + { + protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + => Task.FromResult(handler(request)); + } +} diff --git a/src/Netclaw.Cli.Tests/Tui/ConfigDashboardViewModelTests.cs b/src/Netclaw.Cli.Tests/Tui/ConfigDashboardViewModelTests.cs new file mode 100644 index 000000000..bb8d7f6c4 --- /dev/null +++ b/src/Netclaw.Cli.Tests/Tui/ConfigDashboardViewModelTests.cs @@ -0,0 +1,81 @@ +// ----------------------------------------------------------------------- +// <copyright file="ConfigDashboardViewModelTests.cs" company="Petabridge, LLC"> +// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com> +// </copyright> +// ----------------------------------------------------------------------- +using Netclaw.Cli.Tui; +using Xunit; + +namespace Netclaw.Cli.Tests.Tui; + +public sealed class ConfigDashboardViewModelTests +{ + [Fact] + public void Root_dashboard_contains_expected_domain_entries() + { + using var vm = new ConfigDashboardViewModel(new ConfigDashboardNavigationState()); + + var labels = vm.Items.Select(static item => item.Label).ToList(); + + Assert.Equal( + [ + "Inference Providers", + "Models", + "Channels", + "Inbound Webhooks", + "Skill Sources", + "Search", + "Browser Automation", + "Telemetry & Alerting", + "Security & Access", + "Run Full Doctor", + "Quit", + ], labels); + } + + [Fact] + public void Inference_providers_routes_to_provider_page() + { + using var vm = new ConfigDashboardViewModel(new ConfigDashboardNavigationState()); + string? navigatedRoute = null; + vm.RouteRequested = route => navigatedRoute = route; + + vm.Activate(vm.Items.Single(static item => item.Label == "Inference Providers")); + + Assert.Equal("/provider", navigatedRoute); + } + + [Fact] + public void Models_routes_to_model_page() + { + using var vm = new ConfigDashboardViewModel(new ConfigDashboardNavigationState()); + string? navigatedRoute = null; + vm.RouteRequested = route => navigatedRoute = route; + + vm.Activate(vm.Items.Single(static item => item.Label == "Models")); + + Assert.Equal("/model", navigatedRoute); + } + + [Fact] + public void Run_full_doctor_sets_pending_action_and_shuts_down() + { + var navigationState = new ConfigDashboardNavigationState(); + using var vm = new ConfigDashboardViewModel(navigationState); + + vm.Activate(vm.Items.Single(static item => item.Label == "Run Full Doctor")); + + Assert.Equal(ConfigDashboardAction.RunDoctor, navigationState.PendingAction); + Assert.True(vm.ShutdownRequestedForTest); + } + + [Fact] + public void Placeholder_sections_report_not_implemented_status() + { + using var vm = new ConfigDashboardViewModel(new ConfigDashboardNavigationState()); + + vm.Activate(vm.Items.Single(static item => item.Label == "Channels")); + + Assert.Contains("not implemented yet", vm.StatusMessage.Value, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/Netclaw.Cli.Tests/Tui/Wizard/FeatureSelectionStepViewModelTests.cs b/src/Netclaw.Cli.Tests/Tui/Wizard/FeatureSelectionStepViewModelTests.cs index 663778579..445aeeeef 100644 --- a/src/Netclaw.Cli.Tests/Tui/Wizard/FeatureSelectionStepViewModelTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/Wizard/FeatureSelectionStepViewModelTests.cs @@ -177,4 +177,35 @@ public void ContributeConfig_SkippedStep_ConfigDictionary_OmitsFeatureFlags() AssertNoEnabledKey(config, "Webhooks"); } + [Fact] + public void OnEnter_PrefillsFromExistingConfig() + { + using var step = new FeatureSelectionStepViewModel(); + using var context = new WizardContext + { + Paths = Context.Paths, + Registry = Context.Registry, + RequestRedraw = () => { }, + SelectedPosture = DeploymentPosture.Team, + ExistingConfig = new Dictionary<string, object> + { + ["Memory"] = new Dictionary<string, object> { ["Enabled"] = false }, + ["Search"] = new Dictionary<string, object> { ["Enabled"] = true }, + ["SkillSync"] = new Dictionary<string, object> { ["Enabled"] = false }, + ["Scheduling"] = new Dictionary<string, object> { ["Enabled"] = true }, + ["SubAgents"] = new Dictionary<string, object> { ["Enabled"] = false }, + ["Webhooks"] = new Dictionary<string, object> { ["Enabled"] = true } + } + }; + + step.OnEnter(context, NavigationDirection.Forward); + + Assert.False(step.IsFeatureEnabled(0)); + Assert.True(step.IsFeatureEnabled(1)); + Assert.False(step.IsFeatureEnabled(2)); + Assert.True(step.IsFeatureEnabled(3)); + Assert.False(step.IsFeatureEnabled(4)); + Assert.True(step.IsFeatureEnabled(5)); + } + } diff --git a/src/Netclaw.Cli.Tests/Tui/Wizard/IdentityStepViewModelTests.cs b/src/Netclaw.Cli.Tests/Tui/Wizard/IdentityStepViewModelTests.cs index d6e78fb01..70cbc8b63 100644 --- a/src/Netclaw.Cli.Tests/Tui/Wizard/IdentityStepViewModelTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/Wizard/IdentityStepViewModelTests.cs @@ -127,4 +127,38 @@ public void DefaultValues() Assert.Null(step.CommunicationStyle); Assert.Equal(TimeZoneInfo.Local.Id, step.UserTimezone); } + + [Fact] + public void OnEnter_PrefillsFromExistingConfig() + { + using var step = new IdentityStepViewModel(); + using var context = new WizardContext + { + Paths = Context.Paths, + Registry = Context.Registry, + RequestRedraw = () => { }, + ExistingConfig = new Dictionary<string, object> + { + ["Identity"] = new Dictionary<string, object> + { + ["AgentName"] = "ExistingBot", + ["CommunicationStyle"] = "Detailed & casual", + ["UserName"] = "Dana", + ["UserTimezone"] = "UTC" + }, + ["Workspaces"] = new Dictionary<string, object> + { + ["Directory"] = "/tmp/workspaces" + } + } + }; + + step.OnEnter(context, NavigationDirection.Forward); + + Assert.Equal("ExistingBot", step.AgentName); + Assert.Equal("Detailed & casual", step.CommunicationStyle); + Assert.Equal("Dana", step.UserName); + Assert.Equal("UTC", step.UserTimezone); + Assert.Equal("/tmp/workspaces", step.WorkspacesDirectory); + } } diff --git a/src/Netclaw.Cli.Tests/Tui/Wizard/MenuRegistryAuditTests.cs b/src/Netclaw.Cli.Tests/Tui/Wizard/MenuRegistryAuditTests.cs new file mode 100644 index 000000000..647ba2826 --- /dev/null +++ b/src/Netclaw.Cli.Tests/Tui/Wizard/MenuRegistryAuditTests.cs @@ -0,0 +1,102 @@ +// ----------------------------------------------------------------------- +// <copyright file="MenuRegistryAuditTests.cs" company="Petabridge, LLC"> +// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com> +// </copyright> +// ----------------------------------------------------------------------- +using Microsoft.Extensions.DependencyInjection; +using Netclaw.Cli.Provider; +using Netclaw.Cli.Tui.Sections; +using Netclaw.Cli.Tui.Wizard.Steps; +using Netclaw.Configuration; +using Netclaw.Providers; +using Xunit; + +namespace Netclaw.Cli.Tests.Tui.Wizard; + +public sealed class MenuRegistryAuditTests +{ + [Fact] + public void RegisteredLeafEditors_AreExpectedSet() + { + using var services = BuildServices(); + var registry = services.GetRequiredService<SectionEditorRegistry>(); + + var ids = registry.Editors.Select(e => e.SectionId).OrderBy(static x => x).ToArray(); + Assert.Equal(["feature-selection", "identity", "provider", "security-posture"], ids); + } + + [Fact] + public void RegisteredLeafEditors_DeclareDoctorChecks_OrJustifiedExemption() + { + using var services = BuildServices(); + var registry = services.GetRequiredService<SectionEditorRegistry>(); + + foreach (var editor in registry.Editors) + { + var hasChecks = editor.RelevantDoctorChecks.Count > 0; + var justification = SectionEditorAudit.GetDoctorCheckJustification(editor); + + Assert.True(hasChecks || !string.IsNullOrWhiteSpace(justification), + $"Section editor '{editor.SectionId}' must declare relevant doctor checks or a [NoDoctorChecks] justification."); + } + } + + [Fact] + public void MenuHiddenLeafEditors_AreLimitedToKnownInitOwnedExemptions() + { + using var services = BuildServices(); + var registry = services.GetRequiredService<SectionEditorRegistry>(); + + var hiddenEditors = registry.Editors.Where(e => !e.ShowInMenu).Select(e => e.SectionId).OrderBy(static x => x).ToArray(); + Assert.Equal(SectionEditorExemptions.ConfigSmokeExemptions.OrderBy(static x => x).ToArray(), hiddenEditors); + } + + [Fact] + public void RegisteredLeafEditors_HaveConcreteLeafTestClasses() + { + var expected = new Dictionary<string, string>(StringComparer.Ordinal) + { + ["provider"] = nameof(ProviderSectionEditorTests), + ["identity"] = nameof(IdentitySectionEditorTests), + ["security-posture"] = nameof(SecurityPostureSectionEditorTests), + ["feature-selection"] = nameof(FeatureSelectionSectionEditorTests) + }; + + using var services = BuildServices(); + var registry = services.GetRequiredService<SectionEditorRegistry>(); + var testTypeNames = typeof(MenuRegistryAuditTests).Assembly.GetTypes().Select(t => t.Name).ToHashSet(StringComparer.Ordinal); + + foreach (var editor in registry.Editors) + { + Assert.True(expected.TryGetValue(editor.SectionId, out var testTypeName), + $"Add a concrete section-editor test mapping for '{editor.SectionId}'."); + Assert.Contains(testTypeName, testTypeNames); + } + } + + private static ServiceProvider BuildServices() + { + var services = new ServiceCollection(); + services.AddSingleton(new NetclawPaths()); + services.AddSingleton(ProviderCommand.CreateDefaultRegistry()); + services.AddSingleton<IProviderProbe, FakeProviderProbe>(); + services + .AddSectionEditor<ProviderStepViewModel>() + .AddSectionEditor<IdentityStepViewModel>() + .AddSectionEditor<SecurityPostureStepViewModel>() + .AddSectionEditor<FeatureSelectionStepViewModel>(); + return services.BuildServiceProvider(); + } + + private sealed class FakeProviderProbe : IProviderProbe + { + public Task<ProviderProbeResult> ProbeAsync(string providerType, string? endpoint, string? apiKey, CancellationToken ct = default) + => Task.FromResult(new ProviderProbeResult(true, null, [])); + + public Task<ProviderProbeResult> ProbeAsync(ProviderEntry entry, CancellationToken ct = default) + => Task.FromResult(new ProviderProbeResult(true, null, [])); + + public Task<ProviderProbeResult> ProbeAsync(string providerType, string? endpoint, string? credential, AuthMethod authMethod, CancellationToken ct = default) + => Task.FromResult(new ProviderProbeResult(true, null, [])); + } +} diff --git a/src/Netclaw.Cli.Tests/Tui/Wizard/SectionEditorLeafTests.cs b/src/Netclaw.Cli.Tests/Tui/Wizard/SectionEditorLeafTests.cs new file mode 100644 index 000000000..fe49d32f1 --- /dev/null +++ b/src/Netclaw.Cli.Tests/Tui/Wizard/SectionEditorLeafTests.cs @@ -0,0 +1,101 @@ +// ----------------------------------------------------------------------- +// <copyright file="SectionEditorLeafTests.cs" company="Petabridge, LLC"> +// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com> +// </copyright> +// ----------------------------------------------------------------------- +using Netclaw.Cli.Provider; +using Netclaw.Cli.Tui.Sections; +using Netclaw.Cli.Tui.Wizard; +using Netclaw.Cli.Tui.Wizard.Steps; +using Netclaw.Configuration; +using Xunit; + +namespace Netclaw.Cli.Tests.Tui.Wizard; + +public sealed class ProviderSectionEditorTests : SectionEditorTestBase<ProviderStepViewModel> +{ + [Fact] + public void BuildContribution_BlankCredential_PreservesExistingSecret() + { + File.WriteAllText(Context.Paths.SecretsPath, """ + { "Providers": { "openai": { "ApiKey": "ENC:stored" } } } + """); + using var context = new WizardContext + { + Paths = Context.Paths, + Registry = ProviderCommand.CreateDefaultRegistry(), + RequestRedraw = () => { }, + ExistingConfig = new Dictionary<string, object> + { + ["Models"] = new Dictionary<string, object> + { + ["Main"] = new Dictionary<string, object> { ["Provider"] = "openai", ["ModelId"] = "gpt-4.1" } + }, + ["Providers"] = new Dictionary<string, object> + { + ["openai"] = new Dictionary<string, object> { ["Type"] = "openai", ["AuthMethod"] = "ApiKey" } + } + } + }; + + using var editor = CreateEditor(); + editor.OnEnter(context, NavigationDirection.Forward); + var contribution = editor.BuildContribution(editor); + + Assert.Contains(contribution.SecretActionsOrEmpty, a => a.Action == SectionSecretActionKind.Preserve); + } +} + +public sealed class IdentitySectionEditorTests : SectionEditorTestBase<IdentityStepViewModel> +{ + [Fact] + public void BuildContribution_WritesSyntheticIdentityFields() + { + using var editor = CreateEditor(); + editor.AgentName = "Netclaw"; + editor.UserTimezone = "UTC"; + + var contribution = editor.BuildContribution(editor); + + Assert.Contains(contribution.FieldActionsOrEmpty, a => a.Path == "Identity.AgentName"); + Assert.Contains(contribution.FieldActionsOrEmpty, a => a.Path == "Workspaces.Directory"); + } +} + +public sealed class SecurityPostureSectionEditorTests : SectionEditorTestBase<SecurityPostureStepViewModel> +{ + [Fact] + public void BuildContribution_PersonalPosture_PreservesShellApprovalDefaults() + { + using var editor = CreateEditor(); + editor.SelectedPosture = DeploymentPosture.Personal; + + var contribution = editor.BuildContribution(editor); + + Assert.Contains(contribution.FieldActionsOrEmpty, a => a.Path == "Security.DeploymentPosture" && Equals(a.Value, "Personal")); + Assert.Contains(contribution.FieldActionsOrEmpty, a => a.Path == "Tools"); + } +} + +public sealed class FeatureSelectionSectionEditorTests : SectionEditorTestBase<FeatureSelectionStepViewModel> +{ + [Fact] + public void BuildContribution_EmitsEnabledFlagsForAllFeatureLeaves() + { + using var editor = CreateEditor(); + using var context = new WizardContext + { + Paths = Context.Paths, + Registry = Context.Registry, + RequestRedraw = () => { }, + SelectedPosture = DeploymentPosture.Team + }; + editor.OnEnter(context, NavigationDirection.Forward); + + var contribution = editor.BuildContribution(editor); + + Assert.Equal(6, contribution.FieldActionsOrEmpty.Count); + Assert.Contains(contribution.FieldActionsOrEmpty, a => a.Path == "Memory.Enabled"); + Assert.Contains(contribution.FieldActionsOrEmpty, a => a.Path == "Webhooks.Enabled"); + } +} diff --git a/src/Netclaw.Cli.Tests/Tui/Wizard/SectionEditorTestBase.cs b/src/Netclaw.Cli.Tests/Tui/Wizard/SectionEditorTestBase.cs new file mode 100644 index 000000000..7b2a3ab56 --- /dev/null +++ b/src/Netclaw.Cli.Tests/Tui/Wizard/SectionEditorTestBase.cs @@ -0,0 +1,45 @@ +// ----------------------------------------------------------------------- +// <copyright file="SectionEditorTestBase.cs" company="Petabridge, LLC"> +// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com> +// </copyright> +// ----------------------------------------------------------------------- +using Microsoft.Extensions.DependencyInjection; +using Netclaw.Cli.Tui.Sections; +using Netclaw.Cli.Tui.Wizard; +using Netclaw.Cli.Tui.Wizard.Steps; +using Netclaw.Configuration; +using Netclaw.Providers; + +namespace Netclaw.Cli.Tests.Tui.Wizard; + +public abstract class SectionEditorTestBase<TEditor> : WizardStepTestBase + where TEditor : class, IWizardStepViewModel, ISectionEditor +{ + protected ServiceProvider BuildServices() + { + var services = new ServiceCollection(); + services.AddSingleton(Context.Paths); + services.AddSingleton(new ProviderDescriptorRegistry([])); + services.AddSingleton<IProviderProbe, FakeProviderProbe>(); + services.AddTransient<TEditor>(); + return services.BuildServiceProvider(); + } + + protected TEditor CreateEditor() + { + using var services = BuildServices(); + return ActivatorUtilities.CreateInstance<TEditor>(services); + } + + private sealed class FakeProviderProbe : IProviderProbe + { + public Task<ProviderProbeResult> ProbeAsync(string providerType, string? endpoint, string? apiKey, CancellationToken ct = default) + => Task.FromResult(new ProviderProbeResult(true, null, [])); + + public Task<ProviderProbeResult> ProbeAsync(ProviderEntry entry, CancellationToken ct = default) + => Task.FromResult(new ProviderProbeResult(true, null, [])); + + public Task<ProviderProbeResult> ProbeAsync(string providerType, string? endpoint, string? credential, AuthMethod authMethod, CancellationToken ct = default) + => Task.FromResult(new ProviderProbeResult(true, null, [])); + } +} diff --git a/src/Netclaw.Cli.Tests/Tui/Wizard/WizardConfigScenarioTests.cs b/src/Netclaw.Cli.Tests/Tui/Wizard/WizardConfigScenarioTests.cs index b70948417..c913b1bc3 100644 --- a/src/Netclaw.Cli.Tests/Tui/Wizard/WizardConfigScenarioTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/Wizard/WizardConfigScenarioTests.cs @@ -192,6 +192,42 @@ public void TeamPosture_ExposureTailscaleFunnel_WebhooksOn() AssertSectionEnabled(config, "Webhooks", true); } + [Fact] + public void ExistingConfig_SearchEdit_PreservesUnrelatedSections() + { + File.WriteAllText(Context.Paths.NetclawConfigPath, + """ + { + "configVersion": 1, + "Slack": { "Enabled": true, "SocketMode": true }, + "Daemon": { "ExposureMode": "reverse-proxy", "Host": "10.0.0.2", "TrustedProxies": ["10.0.0.0/24"] }, + "Search": { "Backend": "duckduckgo" } + } + """); + + using var context = new WizardContext + { + Paths = Context.Paths, + Registry = Context.Registry, + RequestRedraw = () => { }, + ExistingConfig = Netclaw.Cli.Config.ConfigFileHelper.LoadJsonDict(Context.Paths.NetclawConfigPath), + SelectedPosture = DeploymentPosture.Personal + }; + + var steps = new List<IWizardStepViewModel> + { + new SearchStepViewModel { SelectedBackend = SearchBackend.Brave } + }; + + using var orchestrator = new WizardOrchestrator(steps, context, singleStepMode: true); + orchestrator.WriteConfig(); + + var config = LoadWrittenConfig(); + Assert.True(config.ContainsKey("Slack")); + Assert.True(config.ContainsKey("Daemon")); + Assert.Equal("brave", GetSection(config, "Search")["Backend"]); + } + // ── Helpers ── private static List<IWizardStepViewModel> BuildCoreSteps() @@ -252,6 +288,12 @@ private Dictionary<string, object> AssembleConfig(List<IWizardStepViewModel> ste _orchestrator = new WizardOrchestrator(steps, Context); _orchestrator.WriteConfig(); + return LoadWrittenConfig(); + } + + private Dictionary<string, object> LoadWrittenConfig() + { + var json = File.ReadAllText(Context.Paths.NetclawConfigPath); var doc = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(json)!; return ConvertToDictionary(doc); diff --git a/src/Netclaw.Cli.Tests/Tui/Wizard/WizardOrchestratorTests.cs b/src/Netclaw.Cli.Tests/Tui/Wizard/WizardOrchestratorTests.cs index 310c97dba..780547cab 100644 --- a/src/Netclaw.Cli.Tests/Tui/Wizard/WizardOrchestratorTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/Wizard/WizardOrchestratorTests.cs @@ -80,6 +80,25 @@ public void GoNext_ReturnsFalse_AtEnd() Assert.False(orchestrator.GoNext()); // only one step, already complete } + [Fact] + public void SingleStepMode_GoNext_ReturnsFalse_AfterCurrentStepCompletes() + { + var steps = CreateSteps("a", "b"); + using var orchestrator = new WizardOrchestrator(steps, Context, singleStepMode: true); + + Assert.False(orchestrator.GoNext()); + Assert.Equal("a", orchestrator.CurrentStep!.StepId); + } + + [Fact] + public void SingleStepMode_GoBack_ReturnsFalse() + { + var steps = CreateSteps("a"); + using var orchestrator = new WizardOrchestrator(steps, Context, singleStepMode: true); + + Assert.False(orchestrator.GoBack()); + } + [Fact] public void GoNext_SkipsNonApplicableSteps() { diff --git a/src/Netclaw.Cli/Config/ConfigCommand.cs b/src/Netclaw.Cli/Config/ConfigCommand.cs new file mode 100644 index 000000000..4cddbc225 --- /dev/null +++ b/src/Netclaw.Cli/Config/ConfigCommand.cs @@ -0,0 +1,46 @@ +// ----------------------------------------------------------------------- +// <copyright file="ConfigCommand.cs" company="Petabridge, LLC"> +// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com> +// </copyright> +// ----------------------------------------------------------------------- +using Netclaw.Configuration; + +namespace Netclaw.Cli.Config; + +internal static class ConfigCommand +{ + internal const string MissingConfigMessage = "No configuration found. Run `netclaw init` first."; + + public static int Run(string[] args, NetclawPaths paths, TextWriter? output = null, TextWriter? error = null) + { + var writer = output ?? Console.Out; + var errorWriter = error ?? Console.Error; + + if (args.Length > 1 && CliArgsParser.IsHelpToken(args[1])) + return WriteHelp(writer); + + if (args.Length > 1) + { + writer.WriteLine("Usage: netclaw config"); + writer.WriteLine("Run `netclaw config --help` for details."); + return 1; + } + + if (!File.Exists(paths.NetclawConfigPath)) + { + errorWriter.WriteLine(MissingConfigMessage); + return 1; + } + + return 0; + } + + private static int WriteHelp(TextWriter writer) + { + writer.WriteLine("Usage: netclaw config"); + writer.WriteLine(); + writer.WriteLine("Launch the main post-install settings dashboard."); + writer.WriteLine("Use `netclaw init` for bootstrap setup on a new install."); + return 0; + } +} diff --git a/src/Netclaw.Cli/Config/ConfigFileHelper.cs b/src/Netclaw.Cli/Config/ConfigFileHelper.cs index 9cb7d36eb..dc98667c0 100644 --- a/src/Netclaw.Cli/Config/ConfigFileHelper.cs +++ b/src/Netclaw.Cli/Config/ConfigFileHelper.cs @@ -4,6 +4,7 @@ // </copyright> // ----------------------------------------------------------------------- using System.Text.Json; +using System.Text.Json.Nodes; using Netclaw.Cli.Json; using Netclaw.Configuration; using Netclaw.Configuration.Secrets; @@ -113,6 +114,79 @@ internal static void WriteSecretsFile(Configuration.NetclawPaths paths, Dictiona SecretsFileWriter.Write(paths.SecretsPath, data, options: JsonDefaults.Indented, protector: protector); } + internal static bool PathPresent(Dictionary<string, object> root, string path) + => TryGetPathValue(root, path, out _); + + internal static bool TryGetPathValue(Dictionary<string, object> root, string path, out object? value) + { + ArgumentNullException.ThrowIfNull(root); + ArgumentException.ThrowIfNullOrWhiteSpace(path); + + var segments = path.Split('.', StringSplitOptions.RemoveEmptyEntries); + object? current = root; + + foreach (var segment in segments) + { + if (!TryGetChildValue(current, segment, out current)) + { + value = null; + return false; + } + } + + value = NormalizeNodeValue(current); + return true; + } + + internal static void SetPathValue(Dictionary<string, object> root, string path, object? value) + { + ArgumentNullException.ThrowIfNull(root); + ArgumentException.ThrowIfNullOrWhiteSpace(path); + + var segments = path.Split('.', StringSplitOptions.RemoveEmptyEntries); + Dictionary<string, object> current = root; + + for (var i = 0; i < segments.Length - 1; i++) + { + var segment = segments[i]; + current = GetOrCreateSection(current, segment); + } + + current[segments[^1]] = value!; + } + + internal static bool RemovePath(Dictionary<string, object> root, string path) + { + ArgumentNullException.ThrowIfNull(root); + ArgumentException.ThrowIfNullOrWhiteSpace(path); + + var segments = path.Split('.', StringSplitOptions.RemoveEmptyEntries); + Dictionary<string, object>? current = root; + + for (var i = 0; i < segments.Length - 1; i++) + { + current = current is null ? null : GetSectionOrNull(current, segments[i]); + if (current is null) + return false; + } + + if (current is null) + return false; + + var removed = current.Remove(segments[^1]); + if (!removed) + return false; + + PruneEmptySections(root, segments); + return true; + } + + internal static bool SecretPresent(Configuration.NetclawPaths paths, string path) + { + var secrets = LoadJsonDict(paths.SecretsPath); + return PathPresent(secrets, path); + } + internal static string DecryptIfEncrypted(Configuration.NetclawPaths paths, string? value) { if (string.IsNullOrEmpty(value) || !ISecretsProtector.IsEncrypted(value)) @@ -121,4 +195,61 @@ internal static string DecryptIfEncrypted(Configuration.NetclawPaths paths, stri var protector = SecretsProtection.CreateProtector(paths); return protector.Unprotect(value); } + + private static bool TryGetChildValue(object? current, string segment, out object? child) + { + switch (current) + { + case Dictionary<string, object> dict when dict.TryGetValue(segment, out child): + return true; + case JsonObject jsonObject when jsonObject.TryGetPropertyValue(segment, out var node): + child = node; + return true; + case JsonElement element when element.ValueKind == JsonValueKind.Object && element.TryGetProperty(segment, out var property): + child = property; + return true; + default: + child = null; + return false; + } + } + + private static object? NormalizeNodeValue(object? value) + => value switch + { + JsonElement element when element.ValueKind == JsonValueKind.Object + => JsonSerializer.Deserialize<Dictionary<string, object>>(element.GetRawText()), + JsonElement element when element.ValueKind == JsonValueKind.Array + => JsonSerializer.Deserialize<object[]>(element.GetRawText()), + JsonElement element when element.ValueKind == JsonValueKind.String + => element.GetString(), + JsonElement element when element.ValueKind == JsonValueKind.True + => true, + JsonElement element when element.ValueKind == JsonValueKind.False + => false, + JsonElement element when element.ValueKind == JsonValueKind.Number && element.TryGetInt64(out var longValue) + => longValue, + JsonElement element when element.ValueKind == JsonValueKind.Number + => element.GetDouble(), + JsonNode node => node.Deserialize<object>(), + _ => value + }; + + private static void PruneEmptySections(Dictionary<string, object> root, string[] segments) + { + for (var depth = segments.Length - 1; depth > 0; depth--) + { + var parentPath = string.Join('.', segments.Take(depth)); + if (!TryGetPathValue(root, parentPath, out var parentValue) + || parentValue is not Dictionary<string, object> parentSection) + { + continue; + } + + if (parentSection.Count != 0) + break; + + RemovePath(root, parentPath); + } + } } diff --git a/src/Netclaw.Cli/Program.cs b/src/Netclaw.Cli/Program.cs index 9a22c1c55..89e785ee8 100644 --- a/src/Netclaw.Cli/Program.cs +++ b/src/Netclaw.Cli/Program.cs @@ -15,6 +15,7 @@ using Netclaw.Channels.Slack; using Netclaw.Cli; using Netclaw.Cli.Approvals; +using Netclaw.Cli.Config; using Netclaw.Cli.Daemon; using Netclaw.Cli.Discord; using Netclaw.Cli.Json; @@ -25,6 +26,9 @@ using Netclaw.Cli.Model; using Netclaw.Cli.Provider; using Netclaw.Cli.Tui; +using Netclaw.Cli.Tui.Config; +using Netclaw.Cli.Tui.Sections; +using Netclaw.Cli.Tui.Wizard.Steps; using Netclaw.Cli.Skills; using Netclaw.Cli.Update; using Netclaw.Cli.Webhooks; @@ -159,6 +163,11 @@ static async Task RunAsync(string[] args) builder.Services.AddSingleton(TimeProvider.System); builder.Services.AddSingleton<DaemonManager>(); builder.Services.AddSingleton<IBrowserAutomationBootstrapper, BrowserAutomationBootstrapper>(); + builder.Services + .AddSectionEditor<ProviderStepViewModel>() + .AddSectionEditor<IdentityStepViewModel>() + .AddSectionEditor<SecurityPostureStepViewModel>() + .AddSectionEditor<FeatureSelectionStepViewModel>(); // Register DaemonClient, ChatNavigationState, and SessionConfig for ChatPage // (uses freshly-written config from the wizard's WriteConfig) @@ -838,10 +847,71 @@ static async Task RunAsync(string[] args) return; } - // ── Config management stubs ── + // ── Config dashboard ── if (mode is "config") { - Console.WriteLine("netclaw config: not yet implemented"); + var configPaths = new NetclawPaths(); + configPaths.EnsureDirectoriesExist(); + + var configExitCode = ConfigCommand.Run(args, configPaths); + if (configExitCode != 0 || (args.Length > 1 && IsHelpToken(args[1]))) + { + Environment.ExitCode = configExitCode; + return; + } + + var builder = Host.CreateApplicationBuilder(args); + ConfigureConfigServices(builder.Services, builder.Configuration); + builder.Services.AddSingleton(configPaths); + builder.Services.AddSingleton(new ConfigDashboardNavigationState()); + builder.Services.AddProviderDescriptors(); + builder.Services.AddHttpClient("OAuthDeviceFlow"); + builder.Services.AddSingleton(sp => + new OAuthDeviceFlowService( + sp.GetRequiredService<IHttpClientFactory>().CreateClient("OAuthDeviceFlow"), + sp.GetService<TimeProvider>())); + builder.Services.AddSingleton(sp => + new OpenAiDeviceFlowService( + sp.GetRequiredService<IHttpClientFactory>().CreateClient("OAuthDeviceFlow"), + sp.GetService<TimeProvider>())); + builder.Services.AddSingleton<DeviceFlowServiceFactory>(); + builder.Logging.ClearProviders(); + builder.Logging.SetMinimumLevel(LogLevel.Warning); + + var traceFile = Path.Combine(Path.GetTempPath(), "netclaw-config-trace.log"); + builder.Services.AddTerminaFileTracing(traceFile, TerminaTraceCategory.All, TerminaTraceLevel.Trace); + + builder.Services.AddTermina("/config", t => + { + t.RegisterRoute<ConfigDashboardPage, ConfigDashboardViewModel>("/config"); + t.RegisterRoute<ProviderManagerPage, ProviderManagerViewModel>("/provider"); + t.RegisterRoute<ModelManagerPage, ModelManagerViewModel>("/model"); + t.RegisterRoute<SearchConfigEditorPage, SearchConfigEditorViewModel>("/search", Termina.Pages.NavigationBehavior.PreserveState); + }); + + using var host = builder.Build(); + await RunTerminaHostAsync(host); + + var navigationState = host.Services.GetRequiredService<ConfigDashboardNavigationState>(); + if (navigationState.PendingAction == ConfigDashboardAction.RunDoctor) + { + var doctorArgs = new[] { "doctor" }; + var doctorBuilder = Host.CreateApplicationBuilder(doctorArgs); + ConfigureConfigServices(doctorBuilder.Services, doctorBuilder.Configuration); + doctorBuilder.Services.AddHttpClient<ISlackProbe, SlackProbe>(); + doctorBuilder.Services.AddHttpClient<IDiscordProbe, DiscordProbe>(); + doctorBuilder.Services.AddDoctorChecks(); + doctorBuilder.Logging.ClearProviders(); + doctorBuilder.Logging.SetMinimumLevel(LogLevel.Warning); + + using var doctorHost = doctorBuilder.Build(); + using var scope = doctorHost.Services.CreateScope(); + var runner = scope.ServiceProvider.GetRequiredService<DoctorRunner>(); + var result = await runner.RunAsync(); + WriteDoctorResult(result); + Environment.ExitCode = result.ExitCode; + } + return; } @@ -1092,7 +1162,7 @@ static void WriteGeneralHelp() Console.WriteLine(" init First-run setup wizard"); Console.WriteLine(" update Check for and install updates"); Console.WriteLine(" version, --version Show CLI version"); - Console.WriteLine(" config Configuration management (planned)"); + Console.WriteLine(" config Main post-install settings dashboard"); Console.WriteLine(); Console.WriteLine("Run `netclaw <command> --help` for details on any command."); Console.WriteLine(); diff --git a/src/Netclaw.Cli/Tui/Config/SchemaDrivenConfigInfrastructure.cs b/src/Netclaw.Cli/Tui/Config/SchemaDrivenConfigInfrastructure.cs new file mode 100644 index 000000000..1af607842 --- /dev/null +++ b/src/Netclaw.Cli/Tui/Config/SchemaDrivenConfigInfrastructure.cs @@ -0,0 +1,488 @@ +// ----------------------------------------------------------------------- +// <copyright file="SchemaDrivenConfigInfrastructure.cs" company="Petabridge, LLC"> +// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com> +// </copyright> +// ----------------------------------------------------------------------- +using System.Text.Json.Nodes; +using Netclaw.Cli.Config; +using Netclaw.Configuration; + +namespace Netclaw.Cli.Tui.Config; + +internal enum ConfigFieldStorage +{ + ConfigFile, + SecretsFile, +} + +internal enum ConfigFieldWidget +{ + EnumSelection, + TextInput, + PasswordInput, +} + +internal enum ConfigFieldValueKind +{ + String, + Boolean, +} + +internal enum ConfigValidationSeverity +{ + Error, + Warning, +} + +internal enum ConfigStatusTone +{ + Neutral, + Success, + Warning, + Error, +} + +internal sealed record ConfigStatusMessage(string Text, ConfigStatusTone Tone); + +internal sealed record ConfigValidationIssue(string? Path, ConfigValidationSeverity Severity, string Message); + +internal sealed record ConfigValidationSummary(IReadOnlyList<ConfigValidationIssue> Issues) +{ + public static readonly ConfigValidationSummary Empty = new([]); + + public bool HasErrors => Issues.Any(static i => i.Severity == ConfigValidationSeverity.Error); + + public bool HasWarnings => Issues.Any(static i => i.Severity == ConfigValidationSeverity.Warning); + + public bool HasIssues => Issues.Count > 0; + + public IReadOnlyList<ConfigValidationIssue> IssuesFor(string path) + => [.. Issues.Where(i => string.Equals(i.Path, path, StringComparison.Ordinal))]; +} + +internal sealed record ConfigEnumOption(string Value, string Label); + +internal sealed record ConfigFieldMetadata( + bool IncludeInEditor = true, + string? Label = null, + ConfigFieldStorage Storage = ConfigFieldStorage.ConfigFile, + ConfigFieldWidget? Widget = null, + string? Placeholder = null, + string? Hint = null, + string? ApplicableWhenPath = null, + string? ApplicableWhenEquals = null, + string? InactiveText = null, + bool PreserveBlankSecret = true, + bool TrimDefaultOnSave = false, + IReadOnlyDictionary<string, string>? OptionLabels = null); + +internal sealed record ProjectedConfigField( + string Path, + string PropertyName, + string Label, + string? Description, + ConfigFieldValueKind ValueKind, + ConfigFieldStorage Storage, + ConfigFieldWidget Widget, + bool Nullable, + object? DefaultValue, + bool TrimDefaultOnSave, + bool PreserveBlankSecret, + string? Placeholder, + string? Hint, + string? ApplicableWhenPath, + string? ApplicableWhenEquals, + string? InactiveText, + IReadOnlyList<ConfigEnumOption> EnumOptions); + +internal static class SearchConfigMetadata +{ + public static IReadOnlyDictionary<string, ConfigFieldMetadata> Fields { get; } = + new Dictionary<string, ConfigFieldMetadata>(StringComparer.Ordinal) + { + ["Search.Enabled"] = new(IncludeInEditor: false), + ["Search.Backend"] = new( + Label: "Backend", + Widget: ConfigFieldWidget.EnumSelection, + Hint: "Select the search backend Netclaw should use for web search and URL fetch augmentation.", + TrimDefaultOnSave: true, + OptionLabels: new Dictionary<string, string>(StringComparer.Ordinal) + { + ["duckduckgo"] = "DuckDuckGo", + ["brave"] = "Brave", + ["searxng"] = "SearXng (self-hosted)", + }), + ["Search.BraveApiKey"] = new( + Label: "Brave API key", + Storage: ConfigFieldStorage.SecretsFile, + Widget: ConfigFieldWidget.PasswordInput, + Placeholder: "Enter Brave Search API key...", + Hint: "Stored in secrets.json. Leave blank to keep the existing key.", + ApplicableWhenPath: "Search.Backend", + ApplicableWhenEquals: "brave", + InactiveText: "(not applicable - only required for Brave)", + PreserveBlankSecret: true), + ["Search.SearXngEndpoint"] = new( + Label: "SearXng instance URL", + Widget: ConfigFieldWidget.TextInput, + Placeholder: "https://search.example.com", + Hint: "Enter the base URL of your SearXNG instance. JSON format must be enabled in settings.yml.", + ApplicableWhenPath: "Search.Backend", + ApplicableWhenEquals: "searxng", + InactiveText: "(not applicable - only required for SearXng)", + TrimDefaultOnSave: true), + }; +} + +internal sealed class ConfigSectionSchemaProjector +{ + private readonly JsonObject _schemaRoot; + + public ConfigSectionSchemaProjector() + { + var schemaText = EmbeddedSchemaLoader.LoadConfigSchema(EmbeddedSchemaLoader.CurrentSchemaVersion) + ?? throw new InvalidOperationException( + $"Missing embedded netclaw config schema v{EmbeddedSchemaLoader.CurrentSchemaVersion}."); + + _schemaRoot = JsonNode.Parse(schemaText) as JsonObject + ?? throw new InvalidOperationException("Embedded netclaw config schema is not a JSON object."); + } + + public IReadOnlyList<ProjectedConfigField> ProjectTopLevelSection( + string sectionName, + IReadOnlyDictionary<string, ConfigFieldMetadata> metadata) + { + if (_schemaRoot["properties"] is not JsonObject rootProperties + || rootProperties[sectionName] is not JsonObject sectionSchema + || sectionSchema["properties"] is not JsonObject sectionProperties) + { + throw new InvalidOperationException($"Section '{sectionName}' was not found in the embedded config schema."); + } + + var fields = new List<ProjectedConfigField>(); + foreach (var (propertyName, propertyNode) in sectionProperties) + { + if (propertyNode is not JsonObject propertySchema) + continue; + + var path = $"{sectionName}.{propertyName}"; + var fieldMetadata = metadata.TryGetValue(path, out var declared) ? declared : new ConfigFieldMetadata(); + if (!fieldMetadata.IncludeInEditor) + continue; + + var enumOptions = ReadEnumOptions(propertySchema, fieldMetadata); + var (valueKind, nullable) = ReadValueKind(propertySchema, enumOptions.Count > 0); + var defaultValue = ReadScalar(propertySchema["default"]); + var widget = fieldMetadata.Widget + ?? (enumOptions.Count > 0 ? ConfigFieldWidget.EnumSelection : ConfigFieldWidget.TextInput); + + fields.Add(new ProjectedConfigField( + Path: path, + PropertyName: propertyName, + Label: fieldMetadata.Label ?? ToDisplayLabel(propertyName), + Description: propertySchema["description"]?.GetValue<string>(), + ValueKind: valueKind, + Storage: fieldMetadata.Storage, + Widget: widget, + Nullable: nullable, + DefaultValue: defaultValue, + TrimDefaultOnSave: fieldMetadata.TrimDefaultOnSave, + PreserveBlankSecret: fieldMetadata.PreserveBlankSecret, + Placeholder: fieldMetadata.Placeholder, + Hint: fieldMetadata.Hint, + ApplicableWhenPath: fieldMetadata.ApplicableWhenPath, + ApplicableWhenEquals: fieldMetadata.ApplicableWhenEquals, + InactiveText: fieldMetadata.InactiveText, + EnumOptions: enumOptions)); + } + + return fields; + } + + private static IReadOnlyList<ConfigEnumOption> ReadEnumOptions(JsonObject propertySchema, ConfigFieldMetadata metadata) + { + if (propertySchema["enum"] is not JsonArray enumArray) + return []; + + var options = new List<ConfigEnumOption>(enumArray.Count); + foreach (var item in enumArray) + { + if (item is null) + continue; + + var value = item.GetValue<string>(); + var label = metadata.OptionLabels is not null && metadata.OptionLabels.TryGetValue(value, out var declared) + ? declared + : value; + options.Add(new ConfigEnumOption(value, label)); + } + + return options; + } + + private static (ConfigFieldValueKind ValueKind, bool Nullable) ReadValueKind(JsonObject propertySchema, bool hasEnum) + { + var types = ReadTypeNames(propertySchema["type"]); + var nullable = types.Contains("null", StringComparer.Ordinal); + if (hasEnum || types.Contains("string", StringComparer.Ordinal)) + return (ConfigFieldValueKind.String, nullable); + if (types.Contains("boolean", StringComparer.Ordinal)) + return (ConfigFieldValueKind.Boolean, nullable); + + throw new InvalidOperationException( + $"Schema-driven config editor does not yet support field type(s): {string.Join(", ", types)}."); + } + + private static IReadOnlyList<string> ReadTypeNames(JsonNode? node) + => node switch + { + JsonValue value => [value.GetValue<string>()], + JsonArray array => [.. array.Where(static item => item is not null).Select(static item => item!.GetValue<string>())], + _ => [] + }; + + private static object? ReadScalar(JsonNode? node) + => node switch + { + null => null, + JsonValue value when value.TryGetValue<string>(out var text) => text, + JsonValue value when value.TryGetValue<bool>(out var flag) => flag, + JsonValue value when value.TryGetValue<int>(out var number) => number, + JsonValue value when value.TryGetValue<long>(out var longNumber) => longNumber, + JsonValue value when value.TryGetValue<double>(out var floatingPoint) => floatingPoint, + _ => null + }; + + private static string ToDisplayLabel(string propertyName) + { + var label = propertyName + .Replace("Api", "API", StringComparison.Ordinal) + .Replace("Url", "URL", StringComparison.Ordinal); + + return string.Concat(label.Select((ch, index) + => index > 0 && char.IsUpper(ch) && !char.IsUpper(label[index - 1]) ? $" {ch}" : ch.ToString())); + } +} + +internal sealed class ConfigSectionEditSession +{ + private readonly NetclawPaths _paths; + private readonly IReadOnlyList<ProjectedConfigField> _fields; + private readonly Dictionary<string, ProjectedConfigField> _fieldsByPath; + private readonly Dictionary<string, object?> _originalValues = new(StringComparer.Ordinal); + private readonly Dictionary<string, object?> _currentValues = new(StringComparer.Ordinal); + private readonly Dictionary<string, string?> _persistedSecrets = new(StringComparer.Ordinal); + private readonly Dictionary<string, bool> _secretPresence = new(StringComparer.Ordinal); + private readonly bool _secretsFileExists; + + public ConfigSectionEditSession(NetclawPaths paths, IReadOnlyList<ProjectedConfigField> fields) + { + _paths = paths; + _fields = fields; + _fieldsByPath = fields.ToDictionary(static field => field.Path, StringComparer.Ordinal); + _secretsFileExists = File.Exists(paths.SecretsPath); + + var config = ConfigFileHelper.LoadJsonDict(paths.NetclawConfigPath); + var secrets = ConfigFileHelper.LoadJsonDict(paths.SecretsPath); + foreach (var field in _fields) + { + if (field.Storage == ConfigFieldStorage.SecretsFile) + { + var secret = ReadPersistedSecret(secrets, field.Path); + _persistedSecrets[field.Path] = secret; + _secretPresence[field.Path] = !string.IsNullOrWhiteSpace(secret); + _originalValues[field.Path] = null; + _currentValues[field.Path] = null; + continue; + } + + var current = ConfigFileHelper.TryGetPathValue(config, field.Path, out var stored) + ? NormalizeScalar(field, stored) + : NormalizeScalar(field, field.DefaultValue); + _originalValues[field.Path] = current; + _currentValues[field.Path] = current; + } + } + + public IReadOnlyList<ProjectedConfigField> Fields => _fields; + + public bool IsDirty => _fields.Any(IsFieldDirty); + + public object? GetValue(string path) + => _currentValues.TryGetValue(path, out var value) ? value : null; + + public string GetEditableString(string path) + => GetValue(path)?.ToString() ?? string.Empty; + + public string? GetEffectiveString(string path) + { + var field = GetField(path); + var current = NormalizeStringValue(GetValue(path)); + if (field.Storage == ConfigFieldStorage.SecretsFile) + return !string.IsNullOrWhiteSpace(current) ? current : NormalizeStringValue(_persistedSecrets[path]); + + return current; + } + + public bool IsApplicable(ProjectedConfigField field) + { + if (string.IsNullOrWhiteSpace(field.ApplicableWhenPath) + || string.IsNullOrWhiteSpace(field.ApplicableWhenEquals)) + { + return true; + } + + return string.Equals( + GetValue(field.ApplicableWhenPath)?.ToString(), + field.ApplicableWhenEquals, + StringComparison.OrdinalIgnoreCase); + } + + public bool HasPersistedSecret(string path) + => _secretPresence.TryGetValue(path, out var present) && present; + + public void SetValue(string path, object? value) + { + var field = GetField(path); + _currentValues[path] = NormalizeScalar(field, value); + } + + public void ResetDraft() + { + foreach (var field in _fields) + { + _currentValues[field.Path] = field.Storage == ConfigFieldStorage.SecretsFile + ? null + : _originalValues[field.Path]; + } + } + + public void Save() + { + var config = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath); + var secrets = ConfigFileHelper.LoadJsonDict(_paths.SecretsPath); + config["configVersion"] = EmbeddedSchemaLoader.CurrentSchemaVersion; + + foreach (var field in _fields) + { + if (!IsFieldDirty(field)) + continue; + + if (field.Storage == ConfigFieldStorage.SecretsFile) + { + SaveSecretField(secrets, field); + continue; + } + + SaveConfigField(config, field); + } + + ConfigFileHelper.WriteConfigFile(_paths.NetclawConfigPath, config); + if (_secretsFileExists || HasUserSecretData(secrets)) + ConfigFileHelper.WriteSecretsFile(_paths, secrets); + + AcceptCurrentValuesAsOriginal(); + } + + private static bool HasUserSecretData(Dictionary<string, object> secrets) + => secrets.Keys.Any(static key => !string.Equals(key, "configVersion", StringComparison.Ordinal)); + + private void SaveConfigField(Dictionary<string, object> config, ProjectedConfigField field) + { + var current = NormalizeScalar(field, _currentValues[field.Path]); + var shouldRemove = current is null + || field is { ValueKind: ConfigFieldValueKind.String } && string.IsNullOrWhiteSpace(current.ToString()) + || field.TrimDefaultOnSave && ValuesEqual(current, field.DefaultValue); + + if (shouldRemove) + { + ConfigFileHelper.RemovePath(config, field.Path); + return; + } + + ConfigFileHelper.SetPathValue(config, field.Path, current); + } + + private void SaveSecretField(Dictionary<string, object> secrets, ProjectedConfigField field) + { + var current = NormalizeStringValue(_currentValues[field.Path]); + if (string.IsNullOrWhiteSpace(current)) + return; + + ConfigFileHelper.SetPathValue(secrets, field.Path, current); + _persistedSecrets[field.Path] = current; + _secretPresence[field.Path] = true; + } + + private void AcceptCurrentValuesAsOriginal() + { + foreach (var field in _fields) + { + if (field.Storage == ConfigFieldStorage.SecretsFile) + { + _currentValues[field.Path] = null; + _originalValues[field.Path] = null; + continue; + } + + _originalValues[field.Path] = _currentValues[field.Path]; + } + } + + private bool IsFieldDirty(ProjectedConfigField field) + { + if (field.Storage == ConfigFieldStorage.SecretsFile) + return !string.IsNullOrWhiteSpace(GetEditableString(field.Path)); + + return !ValuesEqual(_originalValues[field.Path], _currentValues[field.Path]); + } + + private ProjectedConfigField GetField(string path) + => _fieldsByPath.TryGetValue(path, out var field) + ? field + : throw new InvalidOperationException($"Unknown projected field '{path}'."); + + private string? ReadPersistedSecret(Dictionary<string, object> secrets, string path) + { + if (!ConfigFileHelper.TryGetPathValue(secrets, path, out var rawValue) + || rawValue is null) + { + return null; + } + + return ConfigFileHelper.DecryptIfEncrypted(_paths, rawValue.ToString()); + } + + private static object? NormalizeScalar(ProjectedConfigField field, object? value) + => field.ValueKind switch + { + ConfigFieldValueKind.Boolean => NormalizeBooleanValue(value), + _ => NormalizeStringValue(value) + }; + + private static object? NormalizeBooleanValue(object? value) + => value switch + { + null => null, + bool flag => flag, + string text when bool.TryParse(text, out var parsed) => parsed, + _ => value + }; + + private static string? NormalizeStringValue(object? value) + { + var text = value?.ToString()?.Trim(); + return string.IsNullOrWhiteSpace(text) ? null : text; + } + + private static bool ValuesEqual(object? left, object? right) + => NormalizeComparable(left) == NormalizeComparable(right); + + private static string NormalizeComparable(object? value) + => value switch + { + null => string.Empty, + bool flag => flag ? "true" : "false", + _ => value.ToString() ?? string.Empty + }; +} diff --git a/src/Netclaw.Cli/Tui/Config/SearchConfigEditorPage.cs b/src/Netclaw.Cli/Tui/Config/SearchConfigEditorPage.cs new file mode 100644 index 000000000..e3bef71b7 --- /dev/null +++ b/src/Netclaw.Cli/Tui/Config/SearchConfigEditorPage.cs @@ -0,0 +1,334 @@ +// ----------------------------------------------------------------------- +// <copyright file="SearchConfigEditorPage.cs" company="Petabridge, LLC"> +// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com> +// </copyright> +// ----------------------------------------------------------------------- +using R3; +using Termina.Extensions; +using Termina.Input; +using Termina.Layout; +using Termina.Reactive; +using Termina.Rendering; +using Termina.Terminal; + +namespace Netclaw.Cli.Tui.Config; + +internal sealed class SearchConfigEditorPage : ReactivePage<SearchConfigEditorViewModel> +{ + private SelectionListNode<string>? _fieldList; + private SelectionListNode<string>? _enumList; + private SelectionListNode<string>? _dialogList; + private TextInputNode? _textInput; + private DynamicLayoutNode? _contentNode; + private readonly CompositeDisposable _contentSubscriptions = []; + private FocusTarget _focusTarget = FocusTarget.FieldList; + + private enum FocusTarget + { + FieldList, + FieldEditor, + Dialog, + } + + protected override void OnBound() + { + base.OnBound(); + ViewModel.Input.OfType<IInputEvent, KeyPressed>() + .Subscribe(HandleKeyPress) + .DisposeWith(Subscriptions); + } + + public override ILayoutNode BuildLayout() + { + return Layouts.Vertical() + .WithChild( + new PanelNode() + .WithTitle("Search") + .WithBorder(BorderStyle.Rounded) + .WithBorderColor(Color.Cyan) + .WithContent(BuildInnerLayout()) + .Fill()); + } + + private ILayoutNode BuildInnerLayout() + { + return Layouts.Vertical() + .WithSpacing(1) + .WithChild(BuildContent()) + .WithChild(Layouts.Empty().Fill()) + .WithChild(BuildStatusBar()) + .WithChild(BuildKeyBindings()); + } + + private LayoutNode BuildContent() + { + _contentNode = new DynamicLayoutNode(() => + { + _contentSubscriptions.Clear(); + _dialogList = null; + _enumList = null; + _textInput = null; + + return ViewModel.ActiveDialog.Value == SearchConfigEditorDialog.ProbeWarning + ? BuildProbeWarningDialog() + : BuildEditorLayout(); + }); + + ViewModel.SelectedIndex.Subscribe(_ => _contentNode.Invalidate()).DisposeWith(Subscriptions); + ViewModel.ValidationSummary.Subscribe(_ => _contentNode.Invalidate()).DisposeWith(Subscriptions); + ViewModel.ActiveDialog.Subscribe(_ => _contentNode.Invalidate()).DisposeWith(Subscriptions); + + return _contentNode; + } + + private ILayoutNode BuildEditorLayout() + { + var rows = ViewModel.Fields + .Select(field => + { + var issues = ViewModel.GetIssues(field); + var marker = issues.Count > 0 ? "!" : ViewModel.IsApplicable(field) ? " " : "-"; + var value = ViewModel.IsApplicable(field) ? ViewModel.GetDisplayValue(field) : ViewModel.GetInactiveText(field); + return $"{marker} {field.Label,-20} {value}"; + }) + .ToList(); + + _fieldList = Layouts.SelectionList(rows) + .WithMode(SelectionMode.Single) + .WithHighlightColors(Color.Black, Color.Cyan); + _fieldList.OnFocused(); + _focusTarget = FocusTarget.FieldList; + + _fieldList.SelectionConfirmed + .Subscribe(selected => + { + if (selected.Count == 0) + return; + + var index = rows.IndexOf(selected[0]); + if (index >= 0) + { + ViewModel.SelectedIndex.Value = index; + FocusEditor(); + } + }) + .DisposeWith(_contentSubscriptions); + + return Layouts.Horizontal() + .WithChild(Layouts.Vertical() + .WithChild(new TextNode(" Search fields").WithForeground(Color.White).Bold()) + .WithChild(_fieldList) + .Width(44)) + .WithChild(Layouts.Vertical().WithChild(BuildEditorPanel()).Fill()); + } + + private ILayoutNode BuildEditorPanel() + { + var field = ViewModel.SelectedField; + var issues = ViewModel.GetIssues(field); + + var layout = Layouts.Vertical() + .WithSpacing(1) + .WithChild(new TextNode($" {field.Label}").WithForeground(Color.White).Bold()); + + if (!string.IsNullOrWhiteSpace(field.Description)) + layout.WithChild(new TextNode($" {field.Description}").WithForeground(Color.BrightBlack)); + + if (!string.IsNullOrWhiteSpace(field.Hint)) + layout.WithChild(new TextNode($" {field.Hint}").WithForeground(Color.Cyan)); + + if (!ViewModel.IsApplicable(field)) + { + layout.WithChild(new TextNode($" {ViewModel.GetInactiveText(field)}").WithForeground(Color.BrightBlack)); + return layout; + } + + if (field.Widget == ConfigFieldWidget.EnumSelection) + { + var items = field.EnumOptions.Select(static option => option.Label).ToList(); + _enumList = Layouts.SelectionList(items) + .WithMode(SelectionMode.Single) + .WithHighlightColors(Color.Black, Color.Cyan); + _enumList.OnFocused(); + _focusTarget = FocusTarget.FieldEditor; + + _enumList.SelectionConfirmed + .Subscribe(selected => + { + if (selected.Count == 0) + return; + + var option = field.EnumOptions.FirstOrDefault(o => o.Label == selected[0]); + if (option is not null) + ViewModel.SetFieldValue(field.Path, option.Value); + }) + .DisposeWith(_contentSubscriptions); + + layout.WithChild(_enumList); + } + else + { + _textInput = new TextInputNode(); + if (field.Widget == ConfigFieldWidget.PasswordInput) + _textInput.AsPassword(); + if (!string.IsNullOrWhiteSpace(field.Placeholder)) + _textInput.WithPlaceholder(field.Placeholder); + + _textInput.Text = ViewModel.GetEditorSeed(field); + _textInput.OnFocused(); + _focusTarget = FocusTarget.FieldEditor; + + _textInput.Submitted + .Subscribe(text => ViewModel.SetFieldValue(field.Path, text)) + .DisposeWith(_contentSubscriptions); + + layout.WithChild(Netclaw.Cli.Tui.Wizard.Steps.WizardStepHelpers.BuildTextInputPanel(_textInput, field.Label)); + } + + foreach (var issue in issues) + layout.WithChild(new TextNode($" ! {issue.Message}").WithForeground(Color.Red)); + + return layout; + } + + private ILayoutNode BuildProbeWarningDialog() + { + var options = new List<string> + { + "Save anyway", + "Test again", + "Keep editing", + }; + + _dialogList = Layouts.SelectionList(options) + .WithMode(SelectionMode.Single) + .WithHighlightColors(Color.Black, Color.Yellow); + _dialogList.OnFocused(); + _focusTarget = FocusTarget.Dialog; + + _dialogList.SelectionConfirmed + .Subscribe(async selected => + { + if (selected.Count == 0) + return; + + switch (selected[0]) + { + case "Save anyway": + ViewModel.SaveWithoutProbeOverride(); + break; + case "Test again": + ViewModel.DismissDialog(); + await ViewModel.TestCurrentConfigurationAsync(); + break; + default: + ViewModel.DismissDialog(); + break; + } + }) + .DisposeWith(_contentSubscriptions); + + var message = ViewModel.LastProbeResult?.Message ?? "Search backend test failed."; + return new PanelNode() + .WithTitle("Probe Warning") + .WithBorder(BorderStyle.Rounded) + .WithBorderColor(Color.Yellow) + .WithContent( + Layouts.Vertical() + .WithSpacing(1) + .WithChild(new TextNode($" {message}").WithForeground(Color.Yellow)) + .WithChild(new TextNode(" Save anyway stores the config despite the failed runtime probe.") + .WithForeground(Color.BrightBlack)) + .WithChild(_dialogList)); + } + + private LayoutNode BuildStatusBar() + { + return ViewModel.Status + .Select(status => (ILayoutNode)(string.IsNullOrWhiteSpace(status.Text) + ? Layouts.Empty() + : new TextNode($" {status.Text}").WithForeground(ToColor(status.Tone)))) + .AsLayout() + .Height(1); + } + + private LayoutNode BuildKeyBindings() + { + return new TextNode(" [↑/↓] Navigate [Enter] Edit/Confirm [T] Test [S] Save [R] Reset [Esc] Back [Ctrl+Q] Quit") + .WithForeground(Color.BrightBlack) + .Height(1); + } + + private void HandleKeyPress(KeyPressed key) + { + var keyInfo = key.KeyInfo; + + if (keyInfo.Key == ConsoleKey.Q && keyInfo.Modifiers.HasFlag(ConsoleModifiers.Control)) + { + ViewModel.RequestQuit(); + return; + } + + if (keyInfo.Key == ConsoleKey.T) + { + _ = ViewModel.TestCurrentConfigurationAsync(); + return; + } + + if (keyInfo.Key == ConsoleKey.S) + { + _ = ViewModel.SaveAsync(); + return; + } + + if (keyInfo.Key == ConsoleKey.R) + { + ViewModel.ResetDraft(); + return; + } + + if (keyInfo.Key == ConsoleKey.Escape) + { + if (ViewModel.ActiveDialog.Value != SearchConfigEditorDialog.None) + { + ViewModel.DismissDialog(); + return; + } + + ViewModel.NavigateBack(); + return; + } + + switch (_focusTarget) + { + case FocusTarget.Dialog: + _dialogList?.HandleInput(keyInfo); + break; + case FocusTarget.FieldEditor when _enumList is not null: + _enumList.HandleInput(keyInfo); + break; + case FocusTarget.FieldEditor when _textInput is not null: + _textInput.HandleInput(keyInfo); + break; + default: + _fieldList?.HandleInput(keyInfo); + break; + } + + ViewModel.RequestRedraw(); + } + + private void FocusEditor() + { + _contentNode?.Invalidate(); + ViewModel.RequestRedraw(); + } + + private static Color ToColor(ConfigStatusTone tone) => tone switch + { + ConfigStatusTone.Success => Color.Green, + ConfigStatusTone.Warning => Color.Yellow, + ConfigStatusTone.Error => Color.Red, + _ => Color.White, + }; +} diff --git a/src/Netclaw.Cli/Tui/Config/SearchConfigEditorViewModel.cs b/src/Netclaw.Cli/Tui/Config/SearchConfigEditorViewModel.cs new file mode 100644 index 000000000..00b1fc447 --- /dev/null +++ b/src/Netclaw.Cli/Tui/Config/SearchConfigEditorViewModel.cs @@ -0,0 +1,340 @@ +// ----------------------------------------------------------------------- +// <copyright file="SearchConfigEditorViewModel.cs" company="Petabridge, LLC"> +// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com> +// </copyright> +// ----------------------------------------------------------------------- +using System.Net.Http; +using System.Text.Json; +using System.Text.Json.Nodes; +using Json.Schema; +using Netclaw.Cli.Config; +using Netclaw.Configuration; +using Netclaw.Search; +using R3; +using Termina.Reactive; + +namespace Netclaw.Cli.Tui.Config; + +internal enum SearchConfigEditorDialog +{ + None, + ProbeWarning, +} + +internal sealed record SearchProbeResult(bool Success, string Message, ConfigStatusTone Tone); + +internal sealed class SearchConfigEditorViewModel : ReactiveViewModel +{ + private readonly NetclawPaths _paths; + private readonly ConfigSectionEditSession _session; + private readonly JsonSchema _schema; + private readonly IHttpClientFactory? _httpClientFactory; + private readonly TimeProvider _timeProvider; + private ConfigValidationSummary _lastStructuralValidation = ConfigValidationSummary.Empty; + private SearchProbeResult? _lastProbeResult; + + internal Action<string>? RouteRequested { get; set; } + internal bool ShutdownRequestedForTest { get; private set; } + + public SearchConfigEditorViewModel( + NetclawPaths paths, + IHttpClientFactory? httpClientFactory = null, + TimeProvider? timeProvider = null) + { + _paths = paths; + _httpClientFactory = httpClientFactory; + _timeProvider = timeProvider ?? TimeProvider.System; + + var projector = new ConfigSectionSchemaProjector(); + Fields = projector.ProjectTopLevelSection("Search", SearchConfigMetadata.Fields); + _session = new ConfigSectionEditSession(paths, Fields); + + var schemaText = EmbeddedSchemaLoader.LoadConfigSchema(EmbeddedSchemaLoader.CurrentSchemaVersion) + ?? throw new InvalidOperationException( + $"Missing embedded netclaw config schema v{EmbeddedSchemaLoader.CurrentSchemaVersion}."); + _schema = JsonSchema.FromText(schemaText); + + SelectedIndex = new ReactiveProperty<int>(0); + Status = new ReactiveProperty<ConfigStatusMessage>(new ConfigStatusMessage(string.Empty, ConfigStatusTone.Neutral)); + ValidationSummary = new ReactiveProperty<ConfigValidationSummary>(ConfigValidationSummary.Empty); + ActiveDialog = new ReactiveProperty<SearchConfigEditorDialog>(SearchConfigEditorDialog.None); + + foreach (var field in Fields) + FieldValues[field.Path] = new ReactiveProperty<string>(_session.GetEditableString(field.Path)); + + Revalidate(); + } + + public IReadOnlyList<ProjectedConfigField> Fields { get; } + public Dictionary<string, ReactiveProperty<string>> FieldValues { get; } = new(StringComparer.Ordinal); + public ReactiveProperty<int> SelectedIndex { get; } + public ReactiveProperty<ConfigStatusMessage> Status { get; } + public ReactiveProperty<ConfigValidationSummary> ValidationSummary { get; } + public ReactiveProperty<SearchConfigEditorDialog> ActiveDialog { get; } + + public ProjectedConfigField SelectedField => Fields[SelectedIndex.Value]; + public bool IsDirty => _session.IsDirty; + public SearchProbeResult? LastProbeResult => _lastProbeResult; + + public override void Dispose() + { + foreach (var value in FieldValues.Values) + value.Dispose(); + + SelectedIndex.Dispose(); + Status.Dispose(); + ValidationSummary.Dispose(); + ActiveDialog.Dispose(); + base.Dispose(); + } + + public void MoveSelection(int delta) + { + if (Fields.Count == 0) + return; + + var next = Math.Clamp(SelectedIndex.Value + delta, 0, Fields.Count - 1); + if (next != SelectedIndex.Value) + SelectedIndex.Value = next; + } + + public void SetFieldValue(string path, string? value) + { + if (!FieldValues.TryGetValue(path, out var property)) + throw new InvalidOperationException($"Unknown search config field '{path}'."); + + property.Value = value ?? string.Empty; + _session.SetValue(path, property.Value); + Revalidate(); + RequestRedraw(); + } + + public string GetDisplayValue(ProjectedConfigField field) + { + if (field.Widget == ConfigFieldWidget.PasswordInput) + { + var edited = _session.GetEditableString(field.Path); + if (!string.IsNullOrWhiteSpace(edited)) + return "(new secret entered)"; + if (_session.HasPersistedSecret(field.Path)) + return "(stored secret preserved)"; + return field.InactiveText ?? string.Empty; + } + + var current = _session.GetEditableString(field.Path); + if (!string.IsNullOrWhiteSpace(current)) + return current; + + return field.DefaultValue?.ToString() ?? string.Empty; + } + + public string GetEditorSeed(ProjectedConfigField field) + => _session.GetEditableString(field.Path); + + public bool IsApplicable(ProjectedConfigField field) => _session.IsApplicable(field); + + public string GetInactiveText(ProjectedConfigField field) + => field.InactiveText ?? "(not applicable)"; + + public IReadOnlyList<ConfigValidationIssue> GetIssues(ProjectedConfigField field) + => ValidationSummary.Value.IssuesFor(field.Path); + + public void DismissDialog() + { + ActiveDialog.Value = SearchConfigEditorDialog.None; + RequestRedraw(); + } + + public async Task TestCurrentConfigurationAsync(CancellationToken ct = default) + { + Revalidate(); + if (_lastStructuralValidation.HasErrors) + { + Status.Value = new ConfigStatusMessage( + "Fix structural validation errors before testing this search configuration.", + ConfigStatusTone.Error); + RequestRedraw(); + return; + } + + _lastProbeResult = await ProbeAsync(ct); + Status.Value = new ConfigStatusMessage(_lastProbeResult.Message, _lastProbeResult.Tone); + RequestRedraw(); + } + + public async Task SaveAsync(CancellationToken ct = default) + { + Revalidate(); + if (_lastStructuralValidation.HasErrors) + { + Status.Value = new ConfigStatusMessage( + "Fix structural validation errors before saving.", + ConfigStatusTone.Error); + RequestRedraw(); + return; + } + + _lastProbeResult = await ProbeAsync(ct); + if (!_lastProbeResult.Success) + { + Status.Value = new ConfigStatusMessage(_lastProbeResult.Message, ConfigStatusTone.Warning); + ActiveDialog.Value = SearchConfigEditorDialog.ProbeWarning; + RequestRedraw(); + return; + } + + SaveWithoutProbeOverride(); + } + + public void SaveWithoutProbeOverride() + { + _session.Save(); + Revalidate(); + ActiveDialog.Value = SearchConfigEditorDialog.None; + Status.Value = new ConfigStatusMessage("Saved Search settings.", ConfigStatusTone.Success); + RequestRedraw(); + } + + public void ResetDraft() + { + _session.ResetDraft(); + foreach (var field in Fields) + FieldValues[field.Path].Value = _session.GetEditableString(field.Path); + + _lastProbeResult = null; + Revalidate(); + Status.Value = new ConfigStatusMessage("Reverted unsaved Search edits.", ConfigStatusTone.Neutral); + RequestRedraw(); + } + + public void NavigateBack() + { + RouteRequested?.Invoke("/config"); + Navigate?.Invoke("/config"); + } + + public void RequestQuit() + { + ShutdownRequestedForTest = true; + Shutdown(); + } + + private void Revalidate() + { + _lastStructuralValidation = ValidateDraft(); + ValidationSummary.Value = _lastStructuralValidation; + } + + private ConfigValidationSummary ValidateDraft() + { + var draft = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath); + foreach (var field in Fields) + { + var value = _session.GetValue(field.Path); + var shouldRemove = value is null + || field.ValueKind == ConfigFieldValueKind.String && string.IsNullOrWhiteSpace(value.ToString()) + || field.TrimDefaultOnSave && Equals(value?.ToString(), field.DefaultValue?.ToString()); + + if (shouldRemove) + ConfigFileHelper.RemovePath(draft, field.Path); + else + ConfigFileHelper.SetPathValue(draft, field.Path, value); + } + + draft["configVersion"] = EmbeddedSchemaLoader.CurrentSchemaVersion; + var node = JsonSerializer.SerializeToNode(draft) as JsonObject + ?? throw new InvalidOperationException("Search config draft did not serialize to a JSON object."); + + var evaluation = _schema.Evaluate(node, new EvaluationOptions + { + OutputFormat = OutputFormat.List, + }); + + var issues = new List<ConfigValidationIssue>(); + + foreach (var field in Fields) + { + if (!IsApplicable(field)) + continue; + + if (field.Path == "Search.Backend" && string.IsNullOrWhiteSpace(_session.GetEffectiveString(field.Path))) + { + issues.Add(new ConfigValidationIssue(field.Path, ConfigValidationSeverity.Error, "Choose a search backend.")); + } + + if (field.Path == "Search.BraveApiKey" + && string.Equals(_session.GetEffectiveString("Search.Backend"), "brave", StringComparison.OrdinalIgnoreCase) + && string.IsNullOrWhiteSpace(_session.GetEffectiveString(field.Path))) + { + issues.Add(new ConfigValidationIssue(field.Path, ConfigValidationSeverity.Error, "Brave requires an API key.")); + } + + if (field.Path == "Search.SearXngEndpoint" + && string.Equals(_session.GetEffectiveString("Search.Backend"), "searxng", StringComparison.OrdinalIgnoreCase) + && string.IsNullOrWhiteSpace(_session.GetEffectiveString(field.Path))) + { + issues.Add(new ConfigValidationIssue(field.Path, ConfigValidationSeverity.Error, "SearXNG requires an endpoint URL.")); + } + } + + if (!evaluation.IsValid && evaluation.Details is not null) + { + foreach (var detail in evaluation.Details.Where(static d => !d.IsValid && d.Errors is not null)) + { + var path = MapSchemaInstanceLocationToField(detail.InstanceLocation?.ToString()); + if (path is null) + continue; + + var message = string.Join("; ", detail.Errors!.Select(e => $"{e.Key}: {e.Value}")); + if (!issues.Any(i => i.Path == path && string.Equals(i.Message, message, StringComparison.Ordinal))) + issues.Add(new ConfigValidationIssue(path, ConfigValidationSeverity.Error, message)); + } + } + + return issues.Count == 0 ? ConfigValidationSummary.Empty : new ConfigValidationSummary(issues); + } + + private async Task<SearchProbeResult> ProbeAsync(CancellationToken ct) + { + var backend = _session.GetEffectiveString("Search.Backend") ?? SearchBackend.DuckDuckGo.ToWireValue(); + try + { + ISearchBackend searchBackend = backend switch + { + "brave" => new BraveSearchBackend( + _session.GetEffectiveString("Search.BraveApiKey") ?? string.Empty, + CreateHttpClient(), + _timeProvider), + "searxng" => new SearXngBackend( + _session.GetEffectiveString("Search.SearXngEndpoint") ?? string.Empty, + CreateHttpClient(), + _timeProvider), + _ => new DuckDuckGoBackend(CreateHttpClient(), _timeProvider), + }; + + var result = await searchBackend.SearchAsync("netclaw", 1, ct); + return result switch + { + SearchBackendResult.Success => new SearchProbeResult(true, "Search backend test succeeded.", ConfigStatusTone.Success), + SearchBackendResult.Error error => new SearchProbeResult(false, error.Message, ConfigStatusTone.Warning), + _ => new SearchProbeResult(false, "Search backend test failed.", ConfigStatusTone.Warning), + }; + } + catch (Exception ex) + { + return new SearchProbeResult(false, $"Search backend test failed: {ex.Message}", ConfigStatusTone.Warning); + } + } + + private HttpClient CreateHttpClient() + => _httpClientFactory?.CreateClient() ?? new HttpClient(); + + private static string? MapSchemaInstanceLocationToField(string? instanceLocation) + { + if (string.IsNullOrWhiteSpace(instanceLocation)) + return null; + + var path = instanceLocation.TrimStart('/').Replace('/', '.'); + return path.StartsWith("Search.", StringComparison.Ordinal) ? path : null; + } +} diff --git a/src/Netclaw.Cli/Tui/ConfigDashboardPage.cs b/src/Netclaw.Cli/Tui/ConfigDashboardPage.cs new file mode 100644 index 000000000..05bf3ac6c --- /dev/null +++ b/src/Netclaw.Cli/Tui/ConfigDashboardPage.cs @@ -0,0 +1,116 @@ +// ----------------------------------------------------------------------- +// <copyright file="ConfigDashboardPage.cs" company="Petabridge, LLC"> +// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com> +// </copyright> +// ----------------------------------------------------------------------- +using R3; +using Termina.Extensions; +using Termina.Input; +using Termina.Layout; +using Termina.Reactive; +using Termina.Rendering; +using Termina.Terminal; + +namespace Netclaw.Cli.Tui; + +public sealed class ConfigDashboardPage : ReactivePage<ConfigDashboardViewModel> +{ + private SelectionListNode<string>? _entryList; + + protected override void OnBound() + { + base.OnBound(); + ViewModel.Input.OfType<IInputEvent, KeyPressed>() + .Subscribe(HandleKeyPress) + .DisposeWith(Subscriptions); + } + + public override ILayoutNode BuildLayout() + { + return Layouts.Vertical() + .WithChild( + new PanelNode() + .WithTitle("Netclaw Config") + .WithBorder(BorderStyle.Rounded) + .WithBorderColor(Color.Cyan) + .WithContent(BuildInnerLayout()) + .Fill()); + } + + private ILayoutNode BuildInnerLayout() + { + return Layouts.Vertical() + .WithSpacing(1) + .WithChild(BuildList()) + .WithChild(Layouts.Empty().Fill()) + .WithChild(BuildStatusBar()) + .WithChild(BuildKeyBindings()); + } + + private ILayoutNode BuildList() + { + var rows = ViewModel.Items + .Select(item => $"{item.Label,-22} {item.Description}") + .ToList(); + + _entryList = Layouts.SelectionList(rows) + .WithMode(SelectionMode.Single) + .WithHighlightColors(Color.Black, Color.Cyan); + + _entryList.OnFocused(); + _entryList.SelectionConfirmed + .Subscribe(selected => + { + if (selected.Count == 0) + return; + + var index = rows.IndexOf(selected[0]); + if (index >= 0) + { + ViewModel.SelectedIndex.Value = index; + ViewModel.ActivateSelected(); + } + }) + .DisposeWith(Subscriptions); + + return Layouts.Vertical() + .WithChild(new TextNode(" Settings Areas").WithForeground(Color.White).Bold()) + .WithChild(_entryList); + } + + private LayoutNode BuildStatusBar() + { + return ViewModel.StatusMessage + .Select(msg => (ILayoutNode)(string.IsNullOrWhiteSpace(msg) + ? Layouts.Empty() + : new TextNode($" {msg}").WithForeground(Color.Yellow))) + .AsLayout() + .Height(1); + } + + private LayoutNode BuildKeyBindings() + { + return new TextNode(" [↑/↓] Navigate [Enter] Select [Esc] Quit [Ctrl+Q] Quit") + .WithForeground(Color.BrightBlack) + .Height(1); + } + + private void HandleKeyPress(KeyPressed key) + { + var keyInfo = key.KeyInfo; + if (keyInfo.Key == ConsoleKey.Q && keyInfo.Modifiers.HasFlag(ConsoleModifiers.Control)) + { + ViewModel.RequestQuit(); + return; + } + + if (keyInfo.Key == ConsoleKey.Escape) + { + ViewModel.RequestQuit(); + return; + } + + _entryList?.HandleInput(keyInfo); + ViewModel.RequestRedraw(); + } +} diff --git a/src/Netclaw.Cli/Tui/ConfigDashboardViewModel.cs b/src/Netclaw.Cli/Tui/ConfigDashboardViewModel.cs new file mode 100644 index 000000000..31a4c2282 --- /dev/null +++ b/src/Netclaw.Cli/Tui/ConfigDashboardViewModel.cs @@ -0,0 +1,110 @@ +// ----------------------------------------------------------------------- +// <copyright file="ConfigDashboardViewModel.cs" company="Petabridge, LLC"> +// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com> +// </copyright> +// ----------------------------------------------------------------------- +using R3; +using Termina.Reactive; + +namespace Netclaw.Cli.Tui; + +public enum ConfigDashboardAction +{ + None, + RunDoctor, +} + +public sealed class ConfigDashboardNavigationState +{ + public ConfigDashboardAction PendingAction { get; set; } +} + +public sealed record ConfigDashboardItem(string Label, string Description, string? Route = null, bool IsTerminal = false); + +/// <summary> +/// Root dashboard for <c>netclaw config</c>. Provider and model management are +/// routed into their dedicated TUIs; the remaining areas are scaffolded as +/// domain-oriented entries so config no longer lands on a stub. +/// </summary> +public sealed class ConfigDashboardViewModel : ReactiveViewModel +{ + private readonly ConfigDashboardNavigationState _navigationState; + + internal Action<string>? RouteRequested { get; set; } + internal bool ShutdownRequestedForTest { get; private set; } + + public ConfigDashboardViewModel(ConfigDashboardNavigationState navigationState) + { + _navigationState = navigationState; + } + + public ReactiveProperty<string> StatusMessage { get; } = new(""); + public ReactiveProperty<int> SelectedIndex { get; } = new(0); + + public IReadOnlyList<ConfigDashboardItem> Items { get; } = + [ + new("Inference Providers", "Manage provider definitions and authentication.", "/provider"), + new("Models", "Assign model roles and discover provider models.", "/model"), + new("Channels", "Slack, Discord, and Mattermost settings."), + new("Inbound Webhooks", "Configure inbound webhook routes and verification."), + new("Skill Sources", "External skills and private skill feeds."), + new("Search", "Search backend and credentials.", "/search"), + new("Browser Automation", "Browser automation provider settings."), + new("Telemetry & Alerting", "Telemetry and outbound webhook alerting."), + new("Security & Access", "Posture, enabled features, audience profiles, and exposure mode."), + new("Run Full Doctor", "Exit the dashboard and run `netclaw doctor`.", IsTerminal: true), + new("Quit", "Exit without changing settings.", IsTerminal: true), + ]; + + public void MoveSelection(int delta) + { + if (Items.Count == 0) + return; + + var next = Math.Clamp(SelectedIndex.Value + delta, 0, Items.Count - 1); + if (next != SelectedIndex.Value) + SelectedIndex.Value = next; + } + + public void ActivateSelected() + { + Activate(Items[SelectedIndex.Value]); + } + + internal void Activate(ConfigDashboardItem item) + { + if (item.Route is not null) + { + RouteRequested?.Invoke(item.Route); + Navigate?.Invoke(item.Route); + return; + } + + if (string.Equals(item.Label, "Run Full Doctor", StringComparison.Ordinal)) + { + _navigationState.PendingAction = ConfigDashboardAction.RunDoctor; + ShutdownRequestedForTest = true; + Shutdown(); + return; + } + + if (string.Equals(item.Label, "Quit", StringComparison.Ordinal)) + { + ShutdownRequestedForTest = true; + Shutdown(); + return; + } + + StatusMessage.Value = $"{item.Label} is not implemented yet in `netclaw config`."; + RequestRedraw(); + } + + public void RequestQuit() => Shutdown(); + + public override void Dispose() + { + StatusMessage.Dispose(); + SelectedIndex.Dispose(); + base.Dispose(); + } +} diff --git a/src/Netclaw.Cli/Tui/InitWizardViewModel.cs b/src/Netclaw.Cli/Tui/InitWizardViewModel.cs index 779c52db3..078436abf 100644 --- a/src/Netclaw.Cli/Tui/InitWizardViewModel.cs +++ b/src/Netclaw.Cli/Tui/InitWizardViewModel.cs @@ -5,6 +5,8 @@ // ----------------------------------------------------------------------- using Netclaw.Channels.Slack; using Netclaw.Cli.Daemon; +using Netclaw.Cli.Config; +using Netclaw.Cli.Tui.Sections; using Netclaw.Cli.Discord; using Netclaw.Cli.Tui.Wizard; using Netclaw.Cli.Tui.Wizard.Steps; @@ -29,6 +31,7 @@ public partial class InitWizardViewModel : ReactiveViewModel private readonly WizardOrchestrator _orchestrator; private readonly Dictionary<string, IWizardStepView> _stepViews; private readonly HealthCheckStepViewModel _healthCheckStep; + private readonly SectionEditorRegistry? _sectionEditors; /// <summary>The wizard orchestrator managing step sequencing.</summary> public WizardOrchestrator Orchestrator => _orchestrator; @@ -57,11 +60,12 @@ public InitWizardViewModel( DeviceFlowServiceFactory? oauthFactory = null, DaemonManager? daemonManager = null, DaemonApi? daemonApi = null, - IClipboardService? clipboardService = null) + IClipboardService? clipboardService = null, + SectionEditorRegistry? sectionEditors = null) : this(paths, registry, registry, slackProbe, discordProbe, navigationState: navigationState, oauthFactory: oauthFactory, daemonManager: daemonManager, daemonApi: daemonApi, - clipboardService: clipboardService) + clipboardService: clipboardService, sectionEditors: sectionEditors) { } @@ -78,14 +82,18 @@ internal InitWizardViewModel( DeviceFlowServiceFactory? oauthFactory = null, DaemonManager? daemonManager = null, DaemonApi? daemonApi = null, - IClipboardService? clipboardService = null) + IClipboardService? clipboardService = null, + SectionEditorRegistry? sectionEditors = null) { + _sectionEditors = sectionEditors; + // Create shared context _context = new WizardContext { Paths = paths, Registry = registry, - RequestRedraw = RequestRedraw + RequestRedraw = RequestRedraw, + ExistingConfig = LoadExistingConfig(paths) }; // Create step VMs in the canonical order: @@ -217,10 +225,20 @@ private void HandleGlobalKey(KeyPressed key) public override void Dispose() { + _sectionEditors?.Dispose(); _orchestrator.Dispose(); _context.Dispose(); base.Dispose(); } + + private static Dictionary<string, object>? LoadExistingConfig(NetclawPaths paths) + { + if (!File.Exists(paths.NetclawConfigPath)) + return null; + + var config = ConfigFileHelper.LoadJsonDict(paths.NetclawConfigPath); + return config.Count == 0 ? null : config; + } } /// <summary> diff --git a/src/Netclaw.Cli/Tui/Sections/SectionEditorInfrastructure.cs b/src/Netclaw.Cli/Tui/Sections/SectionEditorInfrastructure.cs new file mode 100644 index 000000000..28b86300c --- /dev/null +++ b/src/Netclaw.Cli/Tui/Sections/SectionEditorInfrastructure.cs @@ -0,0 +1,156 @@ +// ----------------------------------------------------------------------- +// <copyright file="SectionEditorInfrastructure.cs" company="Petabridge, LLC"> +// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com> +// </copyright> +// ----------------------------------------------------------------------- +using Microsoft.Extensions.DependencyInjection; +using Netclaw.Cli.Config; +using Netclaw.Cli.Tui.Wizard; + +namespace Netclaw.Cli.Tui.Sections; + +/// <summary> +/// Reusable leaf editor contract shared by init-owned flows and future config surfaces. +/// The registry is intentionally flat at the leaf level and does not define dashboard IA. +/// </summary> +public interface ISectionEditor +{ + string SectionId { get; } + string DisplayName { get; } + string? Category { get; } + bool ShowInMenu { get; } + SectionStatus GetStatus(WizardContext context); + string Summary(WizardContext context); + IReadOnlyList<string> RelevantDoctorChecks { get; } + IWizardStepViewModel CreateEditor(IServiceProvider services); + SectionContribution BuildContribution(IWizardStepViewModel editor); +} + +public enum SectionStatus +{ + NotConfigured, + Configured, + NeedsAttention, +} + +/// <summary> +/// Path-based merge instructions for one leaf editor. +/// Config and secret paths use dot-separated segments rooted at the top-level file object. +/// </summary> +public sealed record SectionContribution( + IReadOnlyList<SectionFieldAction>? FieldActions = null, + IReadOnlyList<SectionSecretAction>? SecretActions = null) +{ + public static readonly SectionContribution Empty = new([], []); + + public IReadOnlyList<SectionFieldAction> FieldActionsOrEmpty => FieldActions ?? []; + public IReadOnlyList<SectionSecretAction> SecretActionsOrEmpty => SecretActions ?? []; +} + +public sealed record SectionFieldAction(string Path, SectionFieldActionKind Action, object? Value = null); + +public sealed record SectionSecretAction(string Path, SectionSecretActionKind Action, object? Value = null); + +public enum SectionFieldActionKind +{ + Set, + Delete, +} + +public enum SectionSecretActionKind +{ + Preserve, + Set, + Delete, +} + +[AttributeUsage(AttributeTargets.Class, Inherited = false)] +public sealed class NoDoctorChecksAttribute(string justification) : Attribute +{ + public string Justification { get; } = justification; +} + +/// <summary> +/// Documents synthetic or init-owned surfaces that intentionally do not behave like config-menu entries. +/// Future routed handoff entries belong to the config command change and are audited separately. +/// </summary> +public static class SectionEditorExemptions +{ + public static readonly IReadOnlyDictionary<string, string> SyntheticOrInitOwned = + new Dictionary<string, string>(StringComparer.Ordinal) + { + ["provider"] = "Provider is an init-owned bootstrap leaf and later config surfaces may route to dedicated provider commands.", + ["identity"] = "Identity spans generated identity files and config-backed fields, so it remains init-owned and menu-hidden." + }; + + public static readonly IReadOnlySet<string> ConfigSmokeExemptions = + new HashSet<string>(StringComparer.Ordinal) + { + "provider", + "identity" + }; +} + +public sealed record SectionEditorRegistration(Type ImplementationType); + +/// <summary> +/// Registry of reusable leaf editors. It validates duplicate IDs eagerly and does not imply any future menu hierarchy. +/// </summary> +public sealed class SectionEditorRegistry : IDisposable +{ + private readonly List<ISectionEditor> _editors; + + public SectionEditorRegistry(IServiceProvider services, IEnumerable<SectionEditorRegistration> registrations) + { + _editors = []; + var ids = new HashSet<string>(StringComparer.Ordinal); + + foreach (var registration in registrations) + { + var editor = (ISectionEditor)ActivatorUtilities.CreateInstance(services, registration.ImplementationType); + if (!ids.Add(editor.SectionId)) + { + throw new InvalidOperationException( + $"Duplicate section editor ID '{editor.SectionId}'. Leaf editor IDs must be unique."); + } + + _editors.Add(editor); + } + } + + public IReadOnlyList<ISectionEditor> Editors => _editors; + + public ISectionEditor Get(string sectionId) + => _editors.FirstOrDefault(e => string.Equals(e.SectionId, sectionId, StringComparison.Ordinal)) + ?? throw new InvalidOperationException($"Unknown section editor '{sectionId}'."); + + public void Dispose() + { + foreach (var editor in _editors.OfType<IDisposable>()) + editor.Dispose(); + } +} + +public static class SectionEditorServiceCollectionExtensions +{ + public static IServiceCollection AddSectionEditor<TEditor>(this IServiceCollection services) + where TEditor : class, ISectionEditor + { + services.AddTransient<TEditor>(); + services.AddSingleton(new SectionEditorRegistration(typeof(TEditor))); + services.AddSingleton<SectionEditorRegistry>(); + return services; + } +} + +internal static class SectionEditorAudit +{ + public static string? GetDoctorCheckJustification(ISectionEditor editor) + => editor.GetType().GetCustomAttributes(typeof(NoDoctorChecksAttribute), inherit: false) + .OfType<NoDoctorChecksAttribute>() + .FirstOrDefault() + ?.Justification; + + public static bool HasExistingConfig(WizardContext context, string path) + => context.ExistingConfig is not null && ConfigFileHelper.PathPresent(context.ExistingConfig, path); +} diff --git a/src/Netclaw.Cli/Tui/Wizard/Steps/FeatureSelectionStepViewModel.cs b/src/Netclaw.Cli/Tui/Wizard/Steps/FeatureSelectionStepViewModel.cs index 85198d17a..715b10efd 100644 --- a/src/Netclaw.Cli/Tui/Wizard/Steps/FeatureSelectionStepViewModel.cs +++ b/src/Netclaw.Cli/Tui/Wizard/Steps/FeatureSelectionStepViewModel.cs @@ -4,6 +4,8 @@ // </copyright> // ----------------------------------------------------------------------- using Netclaw.Configuration; +using Netclaw.Cli.Config; +using Netclaw.Cli.Tui.Sections; namespace Netclaw.Cli.Tui.Wizard.Steps; @@ -11,7 +13,7 @@ namespace Netclaw.Cli.Tui.Wizard.Steps; /// Wizard step for selecting which deployment-wide features are enabled. /// Only shown for Team and Public postures (not Personal). /// </summary> -public sealed class FeatureSelectionStepViewModel : IWizardStepViewModel +public sealed class FeatureSelectionStepViewModel : IWizardStepViewModel, ISectionEditor { private WizardContext? _context; private readonly bool[] _enabledFlags = new bool[6]; @@ -40,6 +42,11 @@ public sealed class FeatureSelectionStepViewModel : IWizardStepViewModel public string StepId => WizardStepIds.FeatureSelection; public string DisplayTitle => "Feature Selection"; + public string SectionId => StepId; + public string DisplayName => "Enabled Features"; + public string? Category => "Security & Access"; + public bool ShowInMenu => true; + public IReadOnlyList<string> RelevantDoctorChecks => ["Config Schema"]; public bool IsApplicable(WizardContext context) => context.SelectedPosture != DeploymentPosture.Personal; @@ -77,6 +84,9 @@ public void OnEnter(WizardContext context, NavigationDirection direction) if (direction == NavigationDirection.Forward) { + if (TryPrefillFromExisting(context)) + return; + // Set defaults based on posture var allOn = context.SelectedPosture == DeploymentPosture.Team; Array.Fill(_enabledFlags, allOn); @@ -126,6 +136,97 @@ public Task ContributeHealthChecksAsync(HealthCheckRunner runner, CancellationTo return Task.CompletedTask; } + public SectionStatus GetStatus(WizardContext context) + => _enabledFlags.Any(static v => v) || HasAnyExistingSelection(context) + ? SectionStatus.Configured + : SectionStatus.NotConfigured; + + public string Summary(WizardContext context) + { + var enabled = CurrentEnabledFeatureNames(context).ToArray(); + return enabled.Length == 0 ? "All optional features disabled" : string.Join(", ", enabled); + } + + public IWizardStepViewModel CreateEditor(IServiceProvider services) + => ActivatorUtilities.CreateInstance<FeatureSelectionStepViewModel>(services); + + public SectionContribution BuildContribution(IWizardStepViewModel editor) + { + var vm = (FeatureSelectionStepViewModel)editor; + return new SectionContribution( + [ + new SectionFieldAction("Memory.Enabled", SectionFieldActionKind.Set, vm._enabledFlags[0]), + new SectionFieldAction("Search.Enabled", SectionFieldActionKind.Set, vm._enabledFlags[1]), + new SectionFieldAction("SkillSync.Enabled", SectionFieldActionKind.Set, vm._enabledFlags[2]), + new SectionFieldAction("Scheduling.Enabled", SectionFieldActionKind.Set, vm._enabledFlags[3]), + new SectionFieldAction("SubAgents.Enabled", SectionFieldActionKind.Set, vm._enabledFlags[4]), + new SectionFieldAction("Webhooks.Enabled", SectionFieldActionKind.Set, vm._enabledFlags[5]) + ]); + } + + private bool TryPrefillFromExisting(WizardContext context) + { + if (context.ExistingConfig is null) + return false; + + var mapped = new (string Path, int Index)[] + { + ("Memory.Enabled", 0), + ("Search.Enabled", 1), + ("SkillSync.Enabled", 2), + ("Scheduling.Enabled", 3), + ("SubAgents.Enabled", 4), + ("Webhooks.Enabled", 5) + }; + + var foundAny = false; + foreach (var (path, index) in mapped) + { + if (!ConfigFileHelper.TryGetPathValue(context.ExistingConfig, path, out var value) || value is not bool enabled) + continue; + + _enabledFlags[index] = enabled; + foundAny = true; + } + + return foundAny; + } + + private bool HasAnyExistingSelection(WizardContext context) + => CurrentEnabledFeatureNames(context).Any(); + + private IEnumerable<string> CurrentEnabledFeatureNames(WizardContext context) + { + for (var i = 0; i < FeatureNames.Length; i++) + { + if (_enabledFlags[i]) + { + yield return FeatureNames[i]; + continue; + } + + if (context.ExistingConfig is null) + continue; + + var path = i switch + { + 0 => "Memory.Enabled", + 1 => "Search.Enabled", + 2 => "SkillSync.Enabled", + 3 => "Scheduling.Enabled", + 4 => "SubAgents.Enabled", + 5 => "Webhooks.Enabled", + _ => throw new InvalidOperationException("Unexpected feature index.") + }; + + if (ConfigFileHelper.TryGetPathValue(context.ExistingConfig, path, out var value) + && value is bool enabled && enabled) + { + yield return FeatureNames[i]; + } + } + } + public void Dispose() { // Nothing to dispose diff --git a/src/Netclaw.Cli/Tui/Wizard/Steps/IdentityStepViewModel.cs b/src/Netclaw.Cli/Tui/Wizard/Steps/IdentityStepViewModel.cs index 86ae8ab62..afe87f268 100644 --- a/src/Netclaw.Cli/Tui/Wizard/Steps/IdentityStepViewModel.cs +++ b/src/Netclaw.Cli/Tui/Wizard/Steps/IdentityStepViewModel.cs @@ -5,6 +5,9 @@ // ----------------------------------------------------------------------- using System.Reflection; using System.Text.RegularExpressions; +using Microsoft.Extensions.DependencyInjection; +using Netclaw.Cli.Config; +using Netclaw.Cli.Tui.Sections; using Netclaw.Configuration; namespace Netclaw.Cli.Tui.Wizard.Steps; @@ -13,7 +16,8 @@ namespace Netclaw.Cli.Tui.Wizard.Steps; /// Wizard step for configuring agent identity (name, communication style, user profile, webhook, workspaces). /// 6 sub-steps: agent name → comm style → user name → timezone → workspaces directory → webhook URL. /// </summary> -public sealed class IdentityStepViewModel : IWizardStepViewModel +[NoDoctorChecks("Identity is synthetic and init-owned. Doctor coverage applies to the underlying config and generated identity files instead.")] +public sealed class IdentityStepViewModel : IWizardStepViewModel, ISectionEditor { private int _currentSubStep; private int _highWaterSubStep; @@ -21,6 +25,11 @@ public sealed class IdentityStepViewModel : IWizardStepViewModel public string StepId => WizardStepIds.Identity; public string DisplayTitle => "Identity"; + public string SectionId => StepId; + public string DisplayName => DisplayTitle; + public string? Category => null; + public bool ShowInMenu => false; + public IReadOnlyList<string> RelevantDoctorChecks => []; // ── State ── public string AgentName { get; set; } = "Netclaw"; @@ -71,6 +80,7 @@ public bool TryGoBack() public void OnEnter(WizardContext context, NavigationDirection direction) { _context = context; + PrefillFromExistingConfig(context); if (direction == NavigationDirection.Forward) _currentSubStep = 0; else if (direction == NavigationDirection.Back) @@ -112,6 +122,37 @@ public Task ContributeHealthChecksAsync(HealthCheckRunner runner, CancellationTo return Task.CompletedTask; } + public SectionStatus GetStatus(WizardContext context) + => HasPersistedIdentity(context) ? SectionStatus.Configured : SectionStatus.NotConfigured; + + public string Summary(WizardContext context) + { + var name = !string.IsNullOrWhiteSpace(AgentName) ? AgentName : ReadString(context, "Identity.AgentName"); + var timezone = !string.IsNullOrWhiteSpace(UserTimezone) ? UserTimezone : ReadString(context, "Identity.UserTimezone"); + return string.IsNullOrWhiteSpace(name) ? "Not configured" : string.IsNullOrWhiteSpace(timezone) ? name : $"{name} ({timezone})"; + } + + public IWizardStepViewModel CreateEditor(IServiceProvider services) + => ActivatorUtilities.CreateInstance<IdentityStepViewModel>(services); + + public SectionContribution BuildContribution(IWizardStepViewModel editor) + { + var vm = (IdentityStepViewModel)editor; + return new SectionContribution( + [ + new SectionFieldAction("Identity.AgentName", SectionFieldActionKind.Set, vm.AgentName), + new SectionFieldAction("Identity.CommunicationStyle", SectionFieldActionKind.Set, vm.CommunicationStyle ?? "Concise & casual"), + string.IsNullOrWhiteSpace(vm.UserName) + ? new SectionFieldAction("Identity.UserName", SectionFieldActionKind.Delete) + : new SectionFieldAction("Identity.UserName", SectionFieldActionKind.Set, vm.UserName), + new SectionFieldAction("Identity.UserTimezone", SectionFieldActionKind.Set, vm.UserTimezone), + new SectionFieldAction("Workspaces.Directory", SectionFieldActionKind.Set, vm.WorkspacesDirectory), + string.IsNullOrWhiteSpace(vm.WebhookUrl) + ? new SectionFieldAction("Notifications", SectionFieldActionKind.Delete) + : new SectionFieldAction("Notifications", SectionFieldActionKind.Set, BuildNotifications(vm.WebhookUrl!)) + ]); + } + /// <summary> /// Write SOUL.md and TOOLING.md identity files. Called during config finalization. /// Reads templates from embedded resources and substitutes placeholders. @@ -290,5 +331,49 @@ private static void SeedAgentFile(string directory, string fileName, string cont File.WriteAllText(path, content); } + private void PrefillFromExistingConfig(WizardContext context) + { + if (context.ExistingConfig is null) + return; + + AgentName = ReadString(context, "Identity.AgentName") ?? AgentName; + CommunicationStyle ??= ReadString(context, "Identity.CommunicationStyle"); + UserName ??= ReadString(context, "Identity.UserName"); + UserTimezone = ReadString(context, "Identity.UserTimezone") ?? UserTimezone; + WorkspacesDirectory = ReadString(context, "Workspaces.Directory") ?? WorkspacesDirectory; + + if (ConfigFileHelper.TryGetPathValue(context.ExistingConfig, "Notifications.Webhooks", out var webhooks) + && webhooks is object[] items + && items.Length > 0 + && items[0] is Dictionary<string, object> firstWebhook + && firstWebhook.TryGetValue("Url", out var urlValue) + && urlValue is string url) + { + WebhookUrl ??= url; + } + } + + private static bool HasPersistedIdentity(WizardContext context) + => !string.IsNullOrWhiteSpace(ReadString(context, "Identity.AgentName")); + + private static string? ReadString(WizardContext context, string path) + => context.ExistingConfig is not null + && ConfigFileHelper.TryGetPathValue(context.ExistingConfig, path, out var value) + ? value as string + : null; + + private static Dictionary<string, object> BuildNotifications(string webhookUrl) + => new() + { + ["Webhooks"] = new object[] + { + new Dictionary<string, object> + { + ["Url"] = webhookUrl, + ["Format"] = WebhookFormatDetection.InferFromUrl(webhookUrl).ToString() + } + } + }; + public void Dispose() { } } diff --git a/src/Netclaw.Cli/Tui/Wizard/Steps/ProviderStepViewModel.cs b/src/Netclaw.Cli/Tui/Wizard/Steps/ProviderStepViewModel.cs index d5eae3f5c..cb21997e9 100644 --- a/src/Netclaw.Cli/Tui/Wizard/Steps/ProviderStepViewModel.cs +++ b/src/Netclaw.Cli/Tui/Wizard/Steps/ProviderStepViewModel.cs @@ -4,8 +4,10 @@ // </copyright> // ----------------------------------------------------------------------- using System.Diagnostics; +using Microsoft.Extensions.DependencyInjection; using Netclaw.Cli.Config; using Netclaw.Cli.Tui; +using Netclaw.Cli.Tui.Sections; using Netclaw.Configuration; using Netclaw.Configuration.Secrets; using Netclaw.Providers; @@ -19,7 +21,7 @@ namespace Netclaw.Cli.Tui.Wizard.Steps; /// Sub-steps: 0=provider selection, 1=auth method, 2=credentials, 3=validation, /// 4=model selection, 5=OAuth device flow, 6=OAuth browser flow. /// </summary> -public sealed class ProviderStepViewModel : IWizardStepViewModel +public sealed class ProviderStepViewModel : IWizardStepViewModel, ISectionEditor { private static readonly TimeSpan ProbeHardTimeout = TimeSpan.FromSeconds(20); @@ -46,6 +48,11 @@ public ProviderStepViewModel( public string StepId => WizardStepIds.Provider; public string DisplayTitle => "LLM Provider"; + public string SectionId => StepId; + public string DisplayName => DisplayTitle; + public string? Category => null; + public bool ShowInMenu => false; + public IReadOnlyList<string> RelevantDoctorChecks => ["Config Schema", "Context Window"]; // ── State ── public string? SelectedProviderType { get; set; } @@ -53,6 +60,7 @@ public ProviderStepViewModel( public string? ApiKeyInput { get; set; } public string? EndpointInput { get; set; } public string? SelectedModelId { get; set; } + public bool HasStoredCredential { get; private set; } public List<DiscoveredModel> DiscoveredModels { get; } = []; public OAuthFlowCoordinator OAuth { get; } public ProviderDescriptorRegistry Registry => _registry; @@ -136,6 +144,7 @@ public bool TryGoBack() public void OnEnter(WizardContext context, NavigationDirection direction) { _context = context; + PrefillFromExistingConfig(context); if (direction == NavigationDirection.Back) _currentSubStep = _highWaterSubStep; } @@ -348,6 +357,147 @@ public Task ContributeHealthChecksAsync(HealthCheckRunner runner, CancellationTo return Task.CompletedTask; } + public SectionStatus GetStatus(WizardContext context) + => !string.IsNullOrWhiteSpace(SelectedProviderType) || ConfigFileHelper.PathPresent(context.ExistingConfig ?? [], "Providers") + ? SectionStatus.Configured + : SectionStatus.NotConfigured; + + public string Summary(WizardContext context) + { + var providerType = SelectedProviderType ?? ReadExistingProviderType(context); + var modelId = SelectedModelId ?? ReadExistingModelId(context); + if (string.IsNullOrWhiteSpace(providerType)) + return "Not configured"; + + return string.IsNullOrWhiteSpace(modelId) ? providerType : $"{providerType} / {modelId}"; + } + + public IWizardStepViewModel CreateEditor(IServiceProvider services) + => ActivatorUtilities.CreateInstance<ProviderStepViewModel>(services); + + public SectionContribution BuildContribution(IWizardStepViewModel editor) + { + var vm = (ProviderStepViewModel)editor; + if (string.IsNullOrWhiteSpace(vm.SelectedProviderType)) + return SectionContribution.Empty; + + var providerType = vm.SelectedProviderType.ToLowerInvariant(); + var fieldActions = new List<SectionFieldAction> + { + new("Providers", SectionFieldActionKind.Set, BuildProvidersDictionary(vm, providerType)), + new("Models.Main.Provider", SectionFieldActionKind.Set, providerType) + }; + + if (string.IsNullOrWhiteSpace(vm.SelectedModelId)) + fieldActions.Add(new SectionFieldAction("Models.Main.ModelId", SectionFieldActionKind.Delete)); + else + fieldActions.Add(new SectionFieldAction("Models.Main.ModelId", SectionFieldActionKind.Set, vm.SelectedModelId)); + + var secretPath = $"Providers.{providerType}"; + var secretActions = new List<SectionSecretAction>(); + if (!string.IsNullOrWhiteSpace(vm.ApiKeyInput)) + { + secretActions.Add(new SectionSecretAction(secretPath, SectionSecretActionKind.Set, + new Dictionary<string, object> { ["ApiKey"] = vm.ApiKeyInput })); + } + else if (vm.HasStoredCredential) + { + secretActions.Add(new SectionSecretAction(secretPath, SectionSecretActionKind.Preserve)); + } + + return new SectionContribution(fieldActions, secretActions); + } + + private void PrefillFromExistingConfig(WizardContext context) + { + if (context.ExistingConfig is null) + return; + + var providerType = ReadExistingProviderType(context); + if (string.IsNullOrWhiteSpace(providerType)) + return; + + SelectedProviderType ??= providerType; + SelectedModelId ??= ReadExistingModelId(context); + + if (ConfigFileHelper.TryGetPathValue(context.ExistingConfig, $"Providers.{providerType}.Endpoint", out var endpoint) + && endpoint is string endpointText) + { + EndpointInput ??= endpointText; + } + + if (ConfigFileHelper.TryGetPathValue(context.ExistingConfig, $"Providers.{providerType}.AuthMethod", out var authMethod) + && authMethod is string authMethodText + && Enum.TryParse<AuthMethod>(authMethodText, ignoreCase: true, out var parsed)) + { + SelectedAuthMethod = parsed; + } + + HasStoredCredential = ConfigFileHelper.SecretPresent(context.Paths, $"Providers.{providerType}.ApiKey") + || ConfigFileHelper.SecretPresent(context.Paths, $"Providers.{providerType}.OAuthAccessToken"); + } + + private static string? ReadExistingProviderType(WizardContext context) + { + if (context.ExistingConfig is null + || !ConfigFileHelper.TryGetPathValue(context.ExistingConfig, "Models.Main.Provider", out var provider) + || provider is not string providerText) + { + return null; + } + + return providerText; + } + + private static string? ReadExistingModelId(WizardContext context) + => context.ExistingConfig is not null + && ConfigFileHelper.TryGetPathValue(context.ExistingConfig, "Models.Main.ModelId", out var model) + ? model as string + : null; + + private Dictionary<string, object> BuildProvidersDictionary(ProviderStepViewModel vm, string providerType) + { + var providerEntry = new Dictionary<string, object> + { + [providerType] = BuildProviderEntry(vm, providerType) + }; + + if (_context?.ExistingConfig is not null + && ConfigFileHelper.TryGetPathValue(_context.ExistingConfig, "Providers", out var existing) + && existing is Dictionary<string, object> existingProviders) + { + foreach (var (key, value) in existingProviders) + { + if (!providerEntry.ContainsKey(key)) + providerEntry[key] = value; + } + } + + return providerEntry; + } + + private Dictionary<string, object> BuildProviderEntry(ProviderStepViewModel vm, string providerType) + { + var entry = new Dictionary<string, object> + { + ["Type"] = providerType + }; + + if (vm.SelectedAuthMethod != AuthMethod.None) + entry["AuthMethod"] = vm.SelectedAuthMethod.ToString(); + + var endpoint = !string.IsNullOrWhiteSpace(vm.EndpointInput) + ? vm.EndpointInput + : _registry.TryGet(providerType, out var descriptor) && descriptor.Auth is EndpointOnlyAuth + ? descriptor.DefaultEndpoint + : null; + + if (!string.IsNullOrWhiteSpace(endpoint)) + entry["Endpoint"] = endpoint; + + return entry; + } + public void Dispose() { CancelProbe(); diff --git a/src/Netclaw.Cli/Tui/Wizard/Steps/SecurityPostureStepViewModel.cs b/src/Netclaw.Cli/Tui/Wizard/Steps/SecurityPostureStepViewModel.cs index 400979d3a..d8fe00852 100644 --- a/src/Netclaw.Cli/Tui/Wizard/Steps/SecurityPostureStepViewModel.cs +++ b/src/Netclaw.Cli/Tui/Wizard/Steps/SecurityPostureStepViewModel.cs @@ -4,6 +4,8 @@ // </copyright> // ----------------------------------------------------------------------- using Netclaw.Configuration; +using Netclaw.Cli.Config; +using Netclaw.Cli.Tui.Sections; namespace Netclaw.Cli.Tui.Wizard.Steps; @@ -11,12 +13,17 @@ namespace Netclaw.Cli.Tui.Wizard.Steps; /// Wizard step for selecting the deployment security posture (Personal/Team/Public). /// Single sub-step, no async operations. /// </summary> -public sealed class SecurityPostureStepViewModel : IWizardStepViewModel +public sealed class SecurityPostureStepViewModel : IWizardStepViewModel, ISectionEditor { private WizardContext? _context; public string StepId => WizardStepIds.SecurityPosture; public string DisplayTitle => "Security Posture"; + public string SectionId => StepId; + public string DisplayName => DisplayTitle; + public string? Category => "Security & Access"; + public bool ShowInMenu => true; + public IReadOnlyList<string> RelevantDoctorChecks => ["Security Policy", "Tool Audience Profiles"]; public DeploymentPosture? SelectedPosture { get; set; } @@ -99,6 +106,74 @@ public Task ContributeHealthChecksAsync(HealthCheckRunner runner, CancellationTo return Task.CompletedTask; } + public SectionStatus GetStatus(WizardContext context) + => context.SelectedPosture.HasValue || SectionEditorAudit.HasExistingConfig(context, "Security.DeploymentPosture") + ? SectionStatus.Configured + : SectionStatus.NotConfigured; + + public string Summary(WizardContext context) + { + var posture = SelectedPosture + ?? context.SelectedPosture + ?? ReadExistingPosture(context); + + return posture?.ToString() ?? "Not configured"; + } + + public IWizardStepViewModel CreateEditor(IServiceProvider services) + => ActivatorUtilities.CreateInstance<SecurityPostureStepViewModel>(services); + + public SectionContribution BuildContribution(IWizardStepViewModel editor) + { + var vm = (SecurityPostureStepViewModel)editor; + var posture = vm.SelectedPosture ?? DeploymentPosture.Personal; + var shellMode = posture == DeploymentPosture.Personal + ? ShellExecutionMode.HostAllowed + : ShellExecutionMode.Off; + + return new SectionContribution( + [ + new SectionFieldAction("Security.DeploymentPosture", SectionFieldActionKind.Set, posture.ToString()), + new SectionFieldAction("Security.ShellExecutionMode", SectionFieldActionKind.Set, shellMode.ToString()), + new SectionFieldAction("Security.StrictDefaults", SectionFieldActionKind.Set, true), + new SectionFieldAction("Tools", SectionFieldActionKind.Set, BuildToolsDictionary(posture, shellMode)) + ]); + } + + private static DeploymentPosture? ReadExistingPosture(WizardContext context) + { + if (context.ExistingConfig is null + || !ConfigFileHelper.TryGetPathValue(context.ExistingConfig, "Security.DeploymentPosture", out var value)) + { + return null; + } + + return value is string text && Enum.TryParse<DeploymentPosture>(text, ignoreCase: true, out var parsed) + ? parsed + : null; + } + + private static Dictionary<string, object> BuildToolsDictionary(DeploymentPosture posture, ShellExecutionMode shellMode) + { + var profiles = ToolAudienceProfileDefaults.CreateProfiles(); + if (posture == DeploymentPosture.Personal) + { + profiles.Personal.ApprovalPolicy = new ToolApprovalConfig + { + ToolOverrides = new Dictionary<string, ToolApprovalMode>(StringComparer.Ordinal) + { + ["shell_execute"] = ToolApprovalMode.Approval + } + }; + } + + return new Dictionary<string, object> + { + ["ShellMode"] = shellMode.ToString(), + ["AudienceProfiles"] = profiles + }; + } + public void Dispose() { // Nothing to dispose diff --git a/src/Netclaw.Cli/Tui/Wizard/WizardConfigBuilder.cs b/src/Netclaw.Cli/Tui/Wizard/WizardConfigBuilder.cs index dd0a74ca0..ce8dd0b82 100644 --- a/src/Netclaw.Cli/Tui/Wizard/WizardConfigBuilder.cs +++ b/src/Netclaw.Cli/Tui/Wizard/WizardConfigBuilder.cs @@ -9,6 +9,7 @@ using Netclaw.Cli.Json; using Netclaw.Cli.Mcp; using Netclaw.Cli.Secrets; +using Netclaw.Cli.Tui.Sections; using Netclaw.Configuration; using Netclaw.Configuration.Secrets; @@ -21,10 +22,12 @@ namespace Netclaw.Cli.Tui.Wizard; public sealed class WizardConfigBuilder { private readonly NetclawPaths _paths; + private readonly Dictionary<string, object> _existingConfig; public WizardConfigBuilder(NetclawPaths paths) { _paths = paths; + _existingConfig = ConfigFileHelper.LoadJsonDict(paths.NetclawConfigPath); } // ── Typed sections populated by steps ── @@ -54,9 +57,7 @@ public void WriteConfigFile() { _paths.EnsureDirectoriesExist(); var config = BuildConfigDictionary(); - - File.WriteAllText(_paths.NetclawConfigPath, - JsonSerializer.Serialize(config, JsonDefaults.ConfigFile)); + ConfigFileHelper.WriteConfigFile(_paths.NetclawConfigPath, config); } /// <summary> @@ -64,14 +65,16 @@ public void WriteConfigFile() /// </summary> internal Dictionary<string, object> BuildConfigDictionary() { - var config = new Dictionary<string, object> - { - ["configVersion"] = 1 - }; + var config = _existingConfig.Count == 0 + ? new Dictionary<string, object>() + : new Dictionary<string, object>(_existingConfig, StringComparer.Ordinal); + + config["configVersion"] = 1; // Provider section if (Provider is not null) { + var providers = ConfigFileHelper.GetOrCreateSection(config, "Providers"); var providerEntry = new Dictionary<string, object> { ["Type"] = Provider.TypeKey @@ -79,19 +82,19 @@ internal Dictionary<string, object> BuildConfigDictionary() if (Provider.AuthMethod != AuthMethod.None) providerEntry["AuthMethod"] = Provider.AuthMethod.ToString(); + else + providerEntry.Remove("AuthMethod"); if (!string.IsNullOrWhiteSpace(Provider.Endpoint)) providerEntry["Endpoint"] = Provider.Endpoint; - config["Providers"] = new Dictionary<string, object> - { - [Provider.TypeKey] = providerEntry - }; + providers[Provider.TypeKey] = providerEntry; } // Models section if (Model is not null) { + var models = ConfigFileHelper.GetOrCreateSection(config, "Models"); var modelEntry = new Dictionary<string, object> { ["Provider"] = Model.Provider @@ -100,10 +103,7 @@ internal Dictionary<string, object> BuildConfigDictionary() if (!string.IsNullOrWhiteSpace(Model.ModelId)) modelEntry["ModelId"] = Model.ModelId; - config["Models"] = new Dictionary<string, object> - { - ["Main"] = modelEntry - }; + models["Main"] = modelEntry; } // Slack section @@ -202,17 +202,22 @@ internal Dictionary<string, object> BuildConfigDictionary() } // Search section - if (Search is not null && Search.Backend != SearchBackend.DuckDuckGo) + if (Search is not null) { - var searchSection = new Dictionary<string, object> + if (Search.Backend == SearchBackend.DuckDuckGo) { - ["Backend"] = Search.Backend.ToWireValue() - }; - - if (Search.Backend == SearchBackend.SearXng && !string.IsNullOrWhiteSpace(Search.SearXngEndpoint)) - searchSection["SearXngEndpoint"] = Search.SearXngEndpoint; + config.Remove("Search"); + } + else + { + var searchSection = ConfigFileHelper.GetOrCreateSection(config, "Search"); + searchSection["Backend"] = Search.Backend.ToWireValue(); - config["Search"] = searchSection; + if (Search.Backend == SearchBackend.SearXng && !string.IsNullOrWhiteSpace(Search.SearXngEndpoint)) + searchSection["SearXngEndpoint"] = Search.SearXngEndpoint; + else + searchSection.Remove("SearXngEndpoint"); + } } // Security section @@ -236,6 +241,22 @@ internal Dictionary<string, object> BuildConfigDictionary() }; } + if (Identity is not null) + { + config["Identity"] = new Dictionary<string, object> + { + ["AgentName"] = Identity.AgentName, + ["CommunicationStyle"] = Identity.CommunicationStyle, + ["UserTimezone"] = Identity.UserTimezone + }; + + if (!string.IsNullOrWhiteSpace(Identity.UserName) + && config["Identity"] is Dictionary<string, object> identity) + { + identity["UserName"] = Identity.UserName; + } + } + // Skill sync config["SkillSync"] = new Dictionary<string, object> { @@ -307,7 +328,7 @@ internal Dictionary<string, object> BuildConfigDictionary() } // Daemon section — only written for non-default exposure modes (local = omit) - if (Daemon is not null && Daemon.ExposureMode != ExposureMode.Local) + if (Daemon is not null) { var daemonSection = new Dictionary<string, object> { @@ -320,7 +341,10 @@ internal Dictionary<string, object> BuildConfigDictionary() if (Daemon.TrustedProxies.Count > 0) daemonSection["TrustedProxies"] = Daemon.TrustedProxies; - config["Daemon"] = daemonSection; + if (Daemon.ExposureMode != ExposureMode.Local) + config["Daemon"] = daemonSection; + else + config.Remove("Daemon"); } // Webhooks section — only written when enabled (disabled = default, omit) @@ -380,6 +404,25 @@ private static void MergeEnabledFlag(Dictionary<string, object> config, string s }; } } + + internal Dictionary<string, object> ApplyContribution(SectionContribution contribution) + { + var config = BuildConfigDictionary(); + foreach (var action in contribution.FieldActionsOrEmpty) + { + switch (action.Action) + { + case SectionFieldActionKind.Set: + ConfigFileHelper.SetPathValue(config, action.Path, action.Value); + break; + case SectionFieldActionKind.Delete: + ConfigFileHelper.RemovePath(config, action.Path); + break; + } + } + + return config; + } } /// <summary> @@ -389,10 +432,12 @@ public sealed class WizardSecretsBuilder { private readonly NetclawPaths _paths; private readonly Dictionary<string, object> _secrets = []; + private readonly Dictionary<string, object> _existingSecrets; public WizardSecretsBuilder(NetclawPaths paths) { _paths = paths; + _existingSecrets = ConfigFileHelper.LoadJsonDict(paths.SecretsPath); } internal NetclawPaths Paths => _paths; @@ -412,30 +457,40 @@ public void WriteSecretsFile() if (_secrets.Count == 0) return; - if (File.Exists(_paths.SecretsPath)) + var existingNode = JsonSerializer.SerializeToNode(_existingSecrets, JsonDefaults.ConfigFile)?.AsObject() + ?? []; + + foreach (var (key, value) in _secrets) { - var existingText = File.ReadAllText(_paths.SecretsPath); - var existingNode = JsonNode.Parse(existingText)?.AsObject(); - if (existingNode is not null) - { - foreach (var (key, value) in _secrets) - { - var segments = SecretsJsonUpdater.ParseKeyPath(key); - var node = JsonSerializer.SerializeToNode(value, JsonDefaults.ConfigFile); - if (node is JsonObject obj) - SecretsJsonUpdater.MergeObject(existingNode, segments, obj); - else - SecretsJsonUpdater.UpsertNode(existingNode, segments, node); - } + var segments = SecretsJsonUpdater.ParseKeyPath(key); + var node = JsonSerializer.SerializeToNode(value, JsonDefaults.ConfigFile); + if (node is JsonObject obj) + SecretsJsonUpdater.MergeObject(existingNode, segments, obj); + else + SecretsJsonUpdater.UpsertNode(existingNode, segments, node); + } + + SecretsFileWriter.Write(_paths.SecretsPath, existingNode.ToJsonString(JsonDefaults.ConfigFile), + protector: SensitiveStringTypeConverter.Protector); + } - SecretsFileWriter.Write(_paths.SecretsPath, existingNode.ToJsonString(JsonDefaults.ConfigFile), - protector: SensitiveStringTypeConverter.Protector); - return; + internal void ApplyContribution(SectionContribution contribution) + { + foreach (var action in contribution.SecretActionsOrEmpty) + { + switch (action.Action) + { + case SectionSecretActionKind.Preserve: + break; + case SectionSecretActionKind.Set: + ConfigFileHelper.SetPathValue(_secrets, action.Path, action.Value); + break; + case SectionSecretActionKind.Delete: + ConfigFileHelper.RemovePath(_secrets, action.Path); + ConfigFileHelper.RemovePath(_existingSecrets, action.Path); + break; } } - - SecretsFileWriter.Write(_paths.SecretsPath, _secrets, - options: JsonDefaults.ConfigFile, protector: SensitiveStringTypeConverter.Protector); } } diff --git a/src/Netclaw.Cli/Tui/Wizard/WizardContext.cs b/src/Netclaw.Cli/Tui/Wizard/WizardContext.cs index 2d56ef8a8..58ca159cf 100644 --- a/src/Netclaw.Cli/Tui/Wizard/WizardContext.cs +++ b/src/Netclaw.Cli/Tui/Wizard/WizardContext.cs @@ -57,12 +57,14 @@ public sealed class WizardContext : IDisposable public required Action RequestRedraw { get; init; } /// <summary> - /// Null for fresh init. When populated, steps should pre-populate their - /// fields from the existing config. (Deferred — not implemented yet.) + /// Null for fresh init. When populated, steps may pre-populate their + /// non-secret fields from the existing on-disk config. /// - /// Re-edit UX intent: when existing config is detected, the wizard should - /// offer "Start fresh" vs "Modify existing". "Start fresh" does NOT wipe - /// existing files until the health check/validate stage completes successfully. + /// Secrets are intentionally excluded from this snapshot. Secret-bearing UI + /// must use presence-only checks against secrets.json and keep fields blank. + /// + /// Re-edit UX intent: this supports init-owned re-entry for shared leaves, + /// not init as the long-term settings editor. /// </summary> public Dictionary<string, object>? ExistingConfig { get; init; } diff --git a/src/Netclaw.Cli/Tui/Wizard/WizardOrchestrator.cs b/src/Netclaw.Cli/Tui/Wizard/WizardOrchestrator.cs index 8b83e731c..916b3c3a6 100644 --- a/src/Netclaw.Cli/Tui/Wizard/WizardOrchestrator.cs +++ b/src/Netclaw.Cli/Tui/Wizard/WizardOrchestrator.cs @@ -4,6 +4,7 @@ // </copyright> // ----------------------------------------------------------------------- using Netclaw.Cli.Tui.Wizard.Steps; +using Netclaw.Cli.Tui.Sections; using R3; namespace Netclaw.Cli.Tui.Wizard; @@ -19,11 +20,18 @@ public sealed class WizardOrchestrator : IDisposable private readonly WizardContext _context; private List<IWizardStepViewModel> _activeSteps; private int _currentIndex; + private readonly bool _singleStepMode; public WizardOrchestrator(IReadOnlyList<IWizardStepViewModel> steps, WizardContext context) + : this(steps, context, singleStepMode: false) + { + } + + public WizardOrchestrator(IReadOnlyList<IWizardStepViewModel> steps, WizardContext context, bool singleStepMode) { _allSteps = steps; _context = context; + _singleStepMode = singleStepMode; _activeSteps = BuildInitialActiveSteps(); if (_activeSteps.Count > 0) @@ -86,6 +94,9 @@ public bool GoNext() current.OnLeave(); _activeSteps = RebuildActiveSteps(); + if (_singleStepMode) + return false; + var nextIndex = currentIdx + 1; if (nextIndex >= _activeSteps.Count) return false; // already at the end @@ -125,6 +136,9 @@ public bool GoBack() if (currentIdx <= 0) return false; // at the very beginning + if (_singleStepMode) + return false; + current.OnLeave(); _activeSteps = RebuildActiveSteps(); @@ -154,6 +168,13 @@ public void WriteConfig() { step.ContributeConfig(configBuilder); step.ContributeSecrets(secretsBuilder); + + if (step is ISectionEditor sectionEditor) + { + var contribution = sectionEditor.BuildContribution(step); + configBuilder.ApplyContribution(contribution); + secretsBuilder.ApplyContribution(contribution); + } } configBuilder.WriteConfigFile(); From 4f36e076fca5c55100d9cf2f0d5197c2967062a5 Mon Sep 17 00:00:00 2001 From: Aaron Stannard <aaron@petabridge.com> Date: Mon, 25 May 2026 22:17:10 +0000 Subject: [PATCH 05/31] fix(cli): stabilize init and config TUI flows --- scripts/smoke/lib/common.sh | 10 +- scripts/smoke/run-native-tape.sh | 3 + scripts/smoke/run-smoke.sh | 36 +++- .../Tui/ModelManagerViewModelTests.cs | 13 ++ .../Tui/ProviderManagerViewModelTests.cs | 13 ++ src/Netclaw.Cli/Config/ConfigFileHelper.cs | 2 +- src/Netclaw.Cli/Program.cs | 39 ++-- .../Tui/Config/SearchConfigEditorPage.cs | 179 ++++++++++++++---- src/Netclaw.Cli/Tui/ModelManagerViewModel.cs | 9 +- .../Tui/ProviderManagerViewModel.cs | 7 +- .../tapes/init-wizard-reverse-proxy.tape | 6 +- tests/smoke/tapes/preamble.tape | 4 +- 12 files changed, 259 insertions(+), 62 deletions(-) diff --git a/scripts/smoke/lib/common.sh b/scripts/smoke/lib/common.sh index 08b371cee..3f0a0dbb6 100755 --- a/scripts/smoke/lib/common.sh +++ b/scripts/smoke/lib/common.sh @@ -11,12 +11,14 @@ # START_TIMEOUT_SECONDS daemon start/health timeout (default: 180) # STOP_TIMEOUT_SECONDS daemon stop timeout (default: 90) # STEP_TIMEOUT_SECONDS per-command timeout (default: 120) -# DAEMON_HEALTH_URL health endpoint base (default loopback:5199) +# DAEMON_BASE_URL health endpoint base (default loopback:56199) +# DAEMON_PORT daemon listen port (default: port from DAEMON_BASE_URL or 56199) START_TIMEOUT_SECONDS="${START_TIMEOUT_SECONDS:-180}" STOP_TIMEOUT_SECONDS="${STOP_TIMEOUT_SECONDS:-90}" STEP_TIMEOUT_SECONDS="${STEP_TIMEOUT_SECONDS:-120}" -DAEMON_BASE_URL="${DAEMON_BASE_URL:-http://127.0.0.1:5199}" +DAEMON_BASE_URL="${DAEMON_BASE_URL:-http://127.0.0.1:56199}" +DAEMON_PORT="${DAEMON_PORT:-${DAEMON_BASE_URL##*:}}" # ── Output / counters ──────────────────────────────────────────────────────── @@ -105,7 +107,7 @@ pid_is_smoke_daemon() { [[ -n "$exe" && "$exe" == "$NETCLAW_SMOKE_DAEMON" ]] } -# ensure_daemon_port_free — block until 127.0.0.1:5199 has no LISTEN socket. +# ensure_daemon_port_free — block until the configured smoke daemon port has no LISTEN socket. # Every tape and scenario daemon binds the same fixed port; a daemon orphaned # by an earlier NETCLAW_HOME is invisible to `netclaw daemon stop` (which only # signals the PID in the current home's PID file) and will squat the port, @@ -114,7 +116,7 @@ pid_is_smoke_daemon() { # port is still held after the timeout OR if it is held by a non-smoke # process we refuse to touch. ensure_daemon_port_free() { - local port=5199 + local port="$DAEMON_PORT" local deadline=$((SECONDS + 30)) while (( SECONDS < deadline )); do local holders diff --git a/scripts/smoke/run-native-tape.sh b/scripts/smoke/run-native-tape.sh index 06656e70a..d22e49f17 100755 --- a/scripts/smoke/run-native-tape.sh +++ b/scripts/smoke/run-native-tape.sh @@ -24,6 +24,7 @@ # KEEP_TEMP set to 1 to retain the combined tape for inspection # TAPE_PREAMBLE preamble file to prepend (default: <TAPES_DIR>/preamble.tape) # TAPE_BODY_DIR directory holding <name>.tape (default: TAPES_DIR) +# TAPE_USER_HOME per-tape HOME dir; default <tmp>/user-home-<name> # # TAPE_PREAMBLE / TAPE_BODY_DIR let the `screenshots` mode of run-smoke.sh # point this runner at screenshot-preamble.tape and tests/smoke/tapes/ @@ -58,6 +59,7 @@ NETCLAW_BIN_DIR="$(cd "$(dirname "$NETCLAW_SMOKE_CLI")" && pwd)" # Per-tape NETCLAW_HOME on the host filesystem. NETCLAW_HOME="${NETCLAW_HOME:-$(mktemp -d)/tape-home-${TAPE_NAME}}" +TAPE_USER_HOME="${TAPE_USER_HOME:-$(mktemp -d "${TMPDIR:-/tmp}/user-home-${TAPE_NAME}.XXXXXX")}" # Preamble + body dir are overridable so the screenshots mode can swap in # screenshot-preamble.tape + tests/smoke/tapes/screenshots/. Defaults keep @@ -121,6 +123,7 @@ collect_failure_artifacts() { # is minimal. sed \ -e "s|__NETCLAW_HOME__|${NETCLAW_HOME}|g" \ + -e "s|__NETCLAW_USER_HOME__|${TAPE_USER_HOME}|g" \ -e "s|__NETCLAW_BIN_DIR__|${NETCLAW_BIN_DIR}|g" \ -e "s|__NETCLAW_DAEMON__|${NETCLAW_SMOKE_DAEMON}|g" \ -e "s|__TAPE_NAME__|${TAPE_NAME}|g" \ diff --git a/scripts/smoke/run-smoke.sh b/scripts/smoke/run-smoke.sh index c6a7ec0c8..4da5ab4b3 100755 --- a/scripts/smoke/run-smoke.sh +++ b/scripts/smoke/run-smoke.sh @@ -36,6 +36,7 @@ # SMOKE_OLLAMA_MODEL primary model (default: qwen2:0.5b) # SMOKE_OLLAMA_ALT_MODEL alternate model (default: all-minilm:latest) # SMOKE_LOG_DIR artifact dir (default: ./smoke-logs) +# SMOKE_DAEMON_PORT isolated daemon port (default: 56199) # KEEP_RUN_ROOT set 1 to keep the temp run root set -euo pipefail @@ -152,6 +153,9 @@ RUN_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/netclaw-smoke.XXXXXX")" export RUN_ROOT mkdir -p "${RUN_ROOT}/home" +SMOKE_DAEMON_PORT="${SMOKE_DAEMON_PORT:-56199}" +SMOKE_DAEMON_BASE_URL="http://127.0.0.1:${SMOKE_DAEMON_PORT}" + teardown_done=0 teardown() { [[ $teardown_done -eq 1 ]] && return 0 @@ -266,8 +270,17 @@ run_one_tape() { echo "Tape: ${tape}" echo "════════════════════════════════════════════════════════" local home="${RUN_ROOT}/home/tape-${tape}" + local user_home="${RUN_ROOT}/home/user-tape-${tape}" rm -rf "$home" - if ! NETCLAW_HOME="$home" \ + rm -rf "$user_home" + mkdir -p "$user_home" + if ! HOME="$user_home" \ + NETCLAW_HOME="$home" \ + TAPE_USER_HOME="$user_home" \ + NETCLAW_DAEMON_ENDPOINT="$SMOKE_DAEMON_BASE_URL" \ + NETCLAW_DAEMON__PORT="$SMOKE_DAEMON_PORT" \ + DAEMON_BASE_URL="$SMOKE_DAEMON_BASE_URL" \ + DAEMON_PORT="$SMOKE_DAEMON_PORT" \ NETCLAW_SMOKE_CLI="$NETCLAW_SMOKE_CLI" \ NETCLAW_SMOKE_DAEMON="$NETCLAW_SMOKE_DAEMON" \ ARTIFACT_DIR="${SMOKE_LOG_DIR}/tapes/${tape}" \ @@ -283,9 +296,18 @@ run_one_scenario() { echo "Scenario: ${scenario}" echo "════════════════════════════════════════════════════════" local home="${RUN_ROOT}/home/scenario-${scenario}" + local user_home="${RUN_ROOT}/home/user-scenario-${scenario}" rm -rf "$home" + rm -rf "$user_home" mkdir -p "$home" - if ! NETCLAW_HOME="$home" \ + mkdir -p "$user_home" + if ! HOME="$user_home" \ + NETCLAW_HOME="$home" \ + TAPE_USER_HOME="$user_home" \ + NETCLAW_DAEMON_ENDPOINT="$SMOKE_DAEMON_BASE_URL" \ + NETCLAW_DAEMON__PORT="$SMOKE_DAEMON_PORT" \ + DAEMON_BASE_URL="$SMOKE_DAEMON_BASE_URL" \ + DAEMON_PORT="$SMOKE_DAEMON_PORT" \ NETCLAW_SMOKE_CLI="$NETCLAW_SMOKE_CLI" \ NETCLAW_SMOKE_DAEMON="$NETCLAW_SMOKE_DAEMON" \ NETCLAW_DAEMON_PATH="$NETCLAW_SMOKE_DAEMON" \ @@ -309,8 +331,16 @@ run_shot_tape() { echo "Screenshot tape: ${tape}" echo "════════════════════════════════════════════════════════" local home="${RUN_ROOT}/home/shot-${tape}" + local user_home="${RUN_ROOT}/home/user-shot-${tape}" rm -rf "$home" - if ! NETCLAW_HOME="$home" \ + rm -rf "$user_home" + mkdir -p "$user_home" + if ! HOME="$user_home" \ + NETCLAW_HOME="$home" \ + NETCLAW_DAEMON_ENDPOINT="$SMOKE_DAEMON_BASE_URL" \ + NETCLAW_DAEMON__PORT="$SMOKE_DAEMON_PORT" \ + DAEMON_BASE_URL="$SMOKE_DAEMON_BASE_URL" \ + DAEMON_PORT="$SMOKE_DAEMON_PORT" \ NETCLAW_SMOKE_CLI="$NETCLAW_SMOKE_CLI" \ NETCLAW_SMOKE_DAEMON="$NETCLAW_SMOKE_DAEMON" \ ARTIFACT_DIR="${SMOKE_LOG_DIR}/tapes/shot-${tape}" \ diff --git a/src/Netclaw.Cli.Tests/Tui/ModelManagerViewModelTests.cs b/src/Netclaw.Cli.Tests/Tui/ModelManagerViewModelTests.cs index 25820c480..2d09f4f0c 100644 --- a/src/Netclaw.Cli.Tests/Tui/ModelManagerViewModelTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/ModelManagerViewModelTests.cs @@ -292,6 +292,19 @@ public void GoBack_FromSelectProvider_ReturnsToRoleOverview() Assert.Equal(ModelManagerState.RoleOverview, vm.CurrentState.Value); } + [Fact] + public void GoBack_FromRoleOverview_NavigatesToConfigWhenEmbedded() + { + using var vm = CreateViewModel(); + vm.CurrentState.Value = ModelManagerState.RoleOverview; + string? route = null; + vm.RouteRequested = r => route = r; + + vm.GoBack(); + + Assert.Equal("/config", route); + } + [Fact] public void Refresh_PopulatesDisplayNameFromRegistry() { diff --git a/src/Netclaw.Cli.Tests/Tui/ProviderManagerViewModelTests.cs b/src/Netclaw.Cli.Tests/Tui/ProviderManagerViewModelTests.cs index 26b997323..554d20d0c 100644 --- a/src/Netclaw.Cli.Tests/Tui/ProviderManagerViewModelTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/ProviderManagerViewModelTests.cs @@ -785,6 +785,19 @@ public void GoBack_FromList_ShutdownSignal() vm.GoBack(); } + [Fact] + public void GoBack_FromList_NavigatesToConfigWhenEmbedded() + { + using var vm = CreateViewModel(); + vm.CurrentState.Value = ProviderManagerState.List; + string? route = null; + vm.RouteRequested = r => route = r; + + vm.GoBack(); + + Assert.Equal("/config", route); + } + [Fact] public void DisplayProviders_ShowsMultipleInstancesOfSameType() { diff --git a/src/Netclaw.Cli/Config/ConfigFileHelper.cs b/src/Netclaw.Cli/Config/ConfigFileHelper.cs index dc98667c0..62f4b9f5d 100644 --- a/src/Netclaw.Cli/Config/ConfigFileHelper.cs +++ b/src/Netclaw.Cli/Config/ConfigFileHelper.cs @@ -102,7 +102,7 @@ internal static void WriteConfigFile(string path, Dictionary<string, object> dat var dir = Path.GetDirectoryName(path); if (dir is not null) Directory.CreateDirectory(dir); - File.WriteAllText(path, JsonSerializer.Serialize(data, JsonDefaults.Indented)); + File.WriteAllText(path, JsonSerializer.Serialize(data, JsonDefaults.ConfigFile)); } /// <summary> diff --git a/src/Netclaw.Cli/Program.cs b/src/Netclaw.Cli/Program.cs index 89e785ee8..4671f2fa9 100644 --- a/src/Netclaw.Cli/Program.cs +++ b/src/Netclaw.Cli/Program.cs @@ -102,7 +102,10 @@ static async Task RunAsync(string[] args) { if (args.Length > 1 && IsHelpToken(args[1])) { - WriteDoctorHelp(); + if (mode is "init") + WriteInitHelp(); + else + WriteDoctorHelp(); return; } @@ -216,7 +219,7 @@ static async Task RunAsync(string[] args) termina.RegisterRoute<ChatPage, ChatViewModel>("/chat"); }); - var initApp = builder.Build(); + using var initApp = builder.Build(); await RunTerminaHostAsync(initApp); return; } @@ -447,7 +450,7 @@ static async Task RunAsync(string[] args) termina.RegisterRoute<StatsPage, StatsViewModel>("/stats"); }); - var statsApp = builder.Build(); + using var statsApp = builder.Build(); await RunTerminaHostAsync(statsApp); return; } @@ -657,7 +660,8 @@ static async Task RunAsync(string[] args) builder.Services.AddTermina("/mcp-tools", t => t.RegisterRoute<McpToolPermissionsPage, McpToolPermissionsViewModel>("/mcp-tools")); - await RunTerminaHostAsync(builder.Build()); + using var mcpToolsHost = builder.Build(); + await RunTerminaHostAsync(mcpToolsHost); return; } @@ -715,7 +719,8 @@ static async Task RunAsync(string[] args) builder.Services.AddTermina("/provider", t => t.RegisterRoute<ProviderManagerPage, ProviderManagerViewModel>("/provider")); - await RunTerminaHostAsync(builder.Build()); + using var providerHost = builder.Build(); + await RunTerminaHostAsync(providerHost); return; } @@ -744,7 +749,8 @@ static async Task RunAsync(string[] args) builder.Services.AddTermina("/model", t => t.RegisterRoute<ModelManagerPage, ModelManagerViewModel>("/model")); - await RunTerminaHostAsync(builder.Build()); + using var modelHost = builder.Build(); + await RunTerminaHostAsync(modelHost); return; } @@ -773,7 +779,8 @@ static async Task RunAsync(string[] args) builder.Services.AddTermina("/approvals", t => t.RegisterRoute<ApprovalsManagerPage, ApprovalsManagerViewModel>("/approvals")); - await RunTerminaHostAsync(builder.Build()); + using var approvalsHost = builder.Build(); + await RunTerminaHostAsync(approvalsHost); return; } @@ -797,7 +804,8 @@ static async Task RunAsync(string[] args) builder.Services.AddTermina("/reminder", t => t.RegisterRoute<ReminderCreatePage, ReminderCreateViewModel>("/reminder")); - await RunTerminaHostAsync(builder.Build()); + using var reminderHost = builder.Build(); + await RunTerminaHostAsync(reminderHost); return; } @@ -890,9 +898,9 @@ static async Task RunAsync(string[] args) }); using var host = builder.Build(); + var navigationState = host.Services.GetRequiredService<ConfigDashboardNavigationState>(); await RunTerminaHostAsync(host); - var navigationState = host.Services.GetRequiredService<ConfigDashboardNavigationState>(); if (navigationState.PendingAction == ConfigDashboardAction.RunDoctor) { var doctorArgs = new[] { "doctor" }; @@ -1097,7 +1105,7 @@ static async Task RunAsync(string[] args) return; } - var app = webBuilder.Build(); + using var app = webBuilder.Build(); await RunTerminaHostAsync(app); } @@ -1123,7 +1131,8 @@ static async Task RunTerminaHostAsync(IHost host) return; } - await host.RunAsync(); + await host.StartAsync(); + await host.WaitForShutdownAsync(); } static void WriteDaemonResult(DaemonResult result) @@ -1209,6 +1218,14 @@ static void WriteDaemonDevicesHelp() Console.WriteLine("After revoking, the device will receive 401 on next connection attempt."); } +static void WriteInitHelp() +{ + Console.WriteLine("Usage: netclaw init"); + Console.WriteLine(); + Console.WriteLine("Run the first-run setup wizard for bootstrap configuration."); + Console.WriteLine("Use `netclaw config` for ongoing post-install settings changes."); +} + static void WriteDoctorHelp() { Console.WriteLine("Usage: netclaw doctor"); diff --git a/src/Netclaw.Cli/Tui/Config/SearchConfigEditorPage.cs b/src/Netclaw.Cli/Tui/Config/SearchConfigEditorPage.cs index e3bef71b7..9a071f39d 100644 --- a/src/Netclaw.Cli/Tui/Config/SearchConfigEditorPage.cs +++ b/src/Netclaw.Cli/Tui/Config/SearchConfigEditorPage.cs @@ -16,6 +16,7 @@ namespace Netclaw.Cli.Tui.Config; internal sealed class SearchConfigEditorPage : ReactivePage<SearchConfigEditorViewModel> { private SelectionListNode<string>? _fieldList; + private SelectionListNode<string>? _actionList; private SelectionListNode<string>? _enumList; private SelectionListNode<string>? _dialogList; private TextInputNode? _textInput; @@ -27,6 +28,7 @@ private enum FocusTarget { FieldList, FieldEditor, + ActionList, Dialog, } @@ -54,8 +56,9 @@ private ILayoutNode BuildInnerLayout() { return Layouts.Vertical() .WithSpacing(1) + .WithChild(new TextNode(" Configure how Netclaw performs web search and URL fetch augmentation.") + .WithForeground(Color.BrightBlack)) .WithChild(BuildContent()) - .WithChild(Layouts.Empty().Fill()) .WithChild(BuildStatusBar()) .WithChild(BuildKeyBindings()); } @@ -66,6 +69,7 @@ private LayoutNode BuildContent() { _contentSubscriptions.Clear(); _dialogList = null; + _actionList = null; _enumList = null; _textInput = null; @@ -83,21 +87,14 @@ private LayoutNode BuildContent() private ILayoutNode BuildEditorLayout() { - var rows = ViewModel.Fields - .Select(field => - { - var issues = ViewModel.GetIssues(field); - var marker = issues.Count > 0 ? "!" : ViewModel.IsApplicable(field) ? " " : "-"; - var value = ViewModel.IsApplicable(field) ? ViewModel.GetDisplayValue(field) : ViewModel.GetInactiveText(field); - return $"{marker} {field.Label,-20} {value}"; - }) - .ToList(); + var rows = ViewModel.Fields.Select(FormatFieldRow).ToList(); _fieldList = Layouts.SelectionList(rows) .WithMode(SelectionMode.Single) .WithHighlightColors(Color.Black, Color.Cyan); - _fieldList.OnFocused(); - _focusTarget = FocusTarget.FieldList; + + if (_focusTarget == FocusTarget.FieldList) + _fieldList.OnFocused(); _fieldList.SelectionConfirmed .Subscribe(selected => @@ -109,48 +106,73 @@ private ILayoutNode BuildEditorLayout() if (index >= 0) { ViewModel.SelectedIndex.Value = index; - FocusEditor(); + _focusTarget = FocusTarget.FieldEditor; + _contentNode?.Invalidate(); + ViewModel.RequestRedraw(); } }) .DisposeWith(_contentSubscriptions); return Layouts.Horizontal() - .WithChild(Layouts.Vertical() - .WithChild(new TextNode(" Search fields").WithForeground(Color.White).Bold()) - .WithChild(_fieldList) - .Width(44)) - .WithChild(Layouts.Vertical().WithChild(BuildEditorPanel()).Fill()); + .WithChild( + new PanelNode() + .WithTitle("Fields") + .WithBorder(BorderStyle.Rounded) + .WithBorderColor(Color.Cyan) + .WithContent( + Layouts.Vertical() + .WithChild(new TextNode(" Select a field to edit.").WithForeground(Color.BrightBlack)) + .WithChild(_fieldList)) + .Width(42)) + .WithChild( + Layouts.Vertical() + .WithSpacing(1) + .WithChild(BuildFieldCard()) + .WithChild(BuildActionCard()) + .Fill()); + } + + private string FormatFieldRow(ProjectedConfigField field) + { + var issues = ViewModel.GetIssues(field); + var marker = issues.Count > 0 ? "!" : ViewModel.IsApplicable(field) ? ">" : "-"; + var value = ViewModel.IsApplicable(field) + ? ViewModel.GetDisplayValue(field) + : "Inactive for current backend"; + var clippedValue = value.Length > 24 ? value[..21] + "..." : value; + return $"{marker} {field.Label,-22} {clippedValue}"; } - private ILayoutNode BuildEditorPanel() + private ILayoutNode BuildFieldCard() { var field = ViewModel.SelectedField; var issues = ViewModel.GetIssues(field); - var layout = Layouts.Vertical() + var content = Layouts.Vertical() .WithSpacing(1) .WithChild(new TextNode($" {field.Label}").WithForeground(Color.White).Bold()); if (!string.IsNullOrWhiteSpace(field.Description)) - layout.WithChild(new TextNode($" {field.Description}").WithForeground(Color.BrightBlack)); + content.WithChild(new TextNode($" {field.Description}").WithForeground(Color.BrightBlack)); if (!string.IsNullOrWhiteSpace(field.Hint)) - layout.WithChild(new TextNode($" {field.Hint}").WithForeground(Color.Cyan)); + content.WithChild(new TextNode($" {field.Hint}").WithForeground(Color.Cyan)); if (!ViewModel.IsApplicable(field)) { - layout.WithChild(new TextNode($" {ViewModel.GetInactiveText(field)}").WithForeground(Color.BrightBlack)); - return layout; + content.WithChild(new TextNode(" This field only matters for the currently selected backend.") + .WithForeground(Color.BrightBlack)); + content.WithChild(new TextNode($" {ViewModel.GetInactiveText(field)}").WithForeground(Color.BrightBlack)); } - - if (field.Widget == ConfigFieldWidget.EnumSelection) + else if (field.Widget == ConfigFieldWidget.EnumSelection) { var items = field.EnumOptions.Select(static option => option.Label).ToList(); _enumList = Layouts.SelectionList(items) .WithMode(SelectionMode.Single) .WithHighlightColors(Color.Black, Color.Cyan); - _enumList.OnFocused(); - _focusTarget = FocusTarget.FieldEditor; + + if (_focusTarget == FocusTarget.FieldEditor) + _enumList.OnFocused(); _enumList.SelectionConfirmed .Subscribe(selected => @@ -164,7 +186,7 @@ private ILayoutNode BuildEditorPanel() }) .DisposeWith(_contentSubscriptions); - layout.WithChild(_enumList); + content.WithChild(_enumList); } else { @@ -175,20 +197,83 @@ private ILayoutNode BuildEditorPanel() _textInput.WithPlaceholder(field.Placeholder); _textInput.Text = ViewModel.GetEditorSeed(field); - _textInput.OnFocused(); - _focusTarget = FocusTarget.FieldEditor; + if (_focusTarget == FocusTarget.FieldEditor) + _textInput.OnFocused(); _textInput.Submitted .Subscribe(text => ViewModel.SetFieldValue(field.Path, text)) .DisposeWith(_contentSubscriptions); - layout.WithChild(Netclaw.Cli.Tui.Wizard.Steps.WizardStepHelpers.BuildTextInputPanel(_textInput, field.Label)); + content.WithChild(Netclaw.Cli.Tui.Wizard.Steps.WizardStepHelpers.BuildTextInputPanel(_textInput, field.Label)); } foreach (var issue in issues) - layout.WithChild(new TextNode($" ! {issue.Message}").WithForeground(Color.Red)); + content.WithChild(new TextNode($" ! {issue.Message}").WithForeground(Color.Red)); + + return new PanelNode() + .WithTitle("Selected Field") + .WithBorder(BorderStyle.Rounded) + .WithBorderColor(Color.Cyan) + .WithContent(content); + } + + private ILayoutNode BuildActionCard() + { + var actions = new List<string> + { + "Test search backend", + "Save settings", + "Reset unsaved changes", + "Back to dashboard", + }; + + _actionList = Layouts.SelectionList(actions) + .WithMode(SelectionMode.Single) + .WithHighlightColors(Color.Black, Color.Green); + + if (_focusTarget == FocusTarget.ActionList) + _actionList.OnFocused(); + + _actionList.SelectionConfirmed + .Subscribe(async selected => + { + if (selected.Count == 0) + return; + + switch (selected[0]) + { + case "Test search backend": + await ViewModel.TestCurrentConfigurationAsync(); + break; + case "Save settings": + await ViewModel.SaveAsync(); + break; + case "Reset unsaved changes": + ViewModel.ResetDraft(); + break; + case "Back to dashboard": + ViewModel.NavigateBack(); + break; + } + }) + .DisposeWith(_contentSubscriptions); - return layout; + var backendField = ViewModel.Fields.First(static f => f.Path == "Search.Backend"); + var errorCount = ViewModel.ValidationSummary.Value.Issues.Count(static i => i.Severity == ConfigValidationSeverity.Error); + var dirtyText = ViewModel.IsDirty ? "Unsaved changes" : "No unsaved changes"; + var validationText = errorCount == 0 ? "Ready to test or save" : $"{errorCount} validation error(s)"; + + return new PanelNode() + .WithTitle("Actions") + .WithBorder(BorderStyle.Rounded) + .WithBorderColor(Color.Green) + .WithContent( + Layouts.Vertical() + .WithSpacing(1) + .WithChild(new TextNode($" Backend: {ViewModel.GetDisplayValue(backendField)}").WithForeground(Color.White)) + .WithChild(new TextNode($" {dirtyText}").WithForeground(Color.BrightBlack)) + .WithChild(new TextNode($" {validationText}").WithForeground(errorCount == 0 ? Color.BrightBlack : Color.Yellow)) + .WithChild(_actionList)); } private ILayoutNode BuildProbeWarningDialog() @@ -219,10 +304,12 @@ private ILayoutNode BuildProbeWarningDialog() break; case "Test again": ViewModel.DismissDialog(); + _focusTarget = FocusTarget.ActionList; await ViewModel.TestCurrentConfigurationAsync(); break; default: ViewModel.DismissDialog(); + _focusTarget = FocusTarget.FieldList; break; } }) @@ -254,7 +341,7 @@ private LayoutNode BuildStatusBar() private LayoutNode BuildKeyBindings() { - return new TextNode(" [↑/↓] Navigate [Enter] Edit/Confirm [T] Test [S] Save [R] Reset [Esc] Back [Ctrl+Q] Quit") + return new TextNode(" [↑/↓] Navigate [Enter] Confirm [Tab] Cycle focus [T] Test [S] Save [R] Reset [Esc] Back [Ctrl+Q] Quit") .WithForeground(Color.BrightBlack) .Height(1); } @@ -271,12 +358,14 @@ private void HandleKeyPress(KeyPressed key) if (keyInfo.Key == ConsoleKey.T) { + _focusTarget = FocusTarget.ActionList; _ = ViewModel.TestCurrentConfigurationAsync(); return; } if (keyInfo.Key == ConsoleKey.S) { + _focusTarget = FocusTarget.ActionList; _ = ViewModel.SaveAsync(); return; } @@ -292,6 +381,7 @@ private void HandleKeyPress(KeyPressed key) if (ViewModel.ActiveDialog.Value != SearchConfigEditorDialog.None) { ViewModel.DismissDialog(); + _focusTarget = FocusTarget.FieldList; return; } @@ -299,11 +389,20 @@ private void HandleKeyPress(KeyPressed key) return; } + if (keyInfo.Key == ConsoleKey.Tab && ViewModel.ActiveDialog.Value == SearchConfigEditorDialog.None) + { + CycleFocus(); + return; + } + switch (_focusTarget) { case FocusTarget.Dialog: _dialogList?.HandleInput(keyInfo); break; + case FocusTarget.ActionList: + _actionList?.HandleInput(keyInfo); + break; case FocusTarget.FieldEditor when _enumList is not null: _enumList.HandleInput(keyInfo); break; @@ -318,8 +417,16 @@ private void HandleKeyPress(KeyPressed key) ViewModel.RequestRedraw(); } - private void FocusEditor() + private void CycleFocus() { + _focusTarget = _focusTarget switch + { + FocusTarget.FieldList => FocusTarget.FieldEditor, + FocusTarget.FieldEditor => FocusTarget.ActionList, + FocusTarget.ActionList => FocusTarget.FieldList, + _ => FocusTarget.FieldList, + }; + _contentNode?.Invalidate(); ViewModel.RequestRedraw(); } diff --git a/src/Netclaw.Cli/Tui/ModelManagerViewModel.cs b/src/Netclaw.Cli/Tui/ModelManagerViewModel.cs index 0c2ee64f8..a00c1a7dc 100644 --- a/src/Netclaw.Cli/Tui/ModelManagerViewModel.cs +++ b/src/Netclaw.Cli/Tui/ModelManagerViewModel.cs @@ -39,6 +39,8 @@ public sealed class ModelManagerViewModel : ReactiveViewModel private readonly ProviderDescriptorRegistry? _registry; private CancellationTokenSource? _probeCts; + internal Action<string>? RouteRequested { get; set; } + public ReactiveProperty<ModelManagerState> CurrentState { get; } = new(ModelManagerState.RoleOverview); public ReactiveProperty<string> StatusMessage { get; } = new(""); public ReactiveProperty<bool> IsProbing { get; } = new(false); @@ -252,10 +254,13 @@ public void GoBack() ClearAssignmentState(); CurrentState.Value = ModelManagerState.RoleOverview; NotifyStateChanged(); - } + } break; default: - Shutdown(); + RouteRequested?.Invoke("/config"); + Navigate?.Invoke("/config"); + if (RouteRequested is null) + Shutdown(); break; } } diff --git a/src/Netclaw.Cli/Tui/ProviderManagerViewModel.cs b/src/Netclaw.Cli/Tui/ProviderManagerViewModel.cs index acf4dddfe..f34d6bc81 100644 --- a/src/Netclaw.Cli/Tui/ProviderManagerViewModel.cs +++ b/src/Netclaw.Cli/Tui/ProviderManagerViewModel.cs @@ -80,6 +80,8 @@ public sealed class ProviderManagerViewModel : ReactiveViewModel private readonly DeviceFlowServiceFactory? _oauthFactory; private CancellationTokenSource? _probeCts; + internal Action<string>? RouteRequested { get; set; } + public ReactiveProperty<ProviderManagerState> CurrentState { get; } = new(ProviderManagerState.Loading); public ReactiveProperty<string> StatusMessage { get; } = new(""); public ReactiveProperty<string> ErrorMessage { get; } = new(""); @@ -828,7 +830,10 @@ public void GoBack() CancelRename(); break; default: - Shutdown(); + RouteRequested?.Invoke("/config"); + Navigate?.Invoke("/config"); + if (RouteRequested is null) + Shutdown(); break; } } diff --git a/tests/smoke/tapes/init-wizard-reverse-proxy.tape b/tests/smoke/tapes/init-wizard-reverse-proxy.tape index dcbe19376..56c75eabe 100644 --- a/tests/smoke/tapes/init-wizard-reverse-proxy.tape +++ b/tests/smoke/tapes/init-wizard-reverse-proxy.tape @@ -104,8 +104,8 @@ Enter # Sub-step 3: notice with serving URL. Wait+Screen@10s /Reverse proxy configured/ -# Serving URL line is rendered as "Daemon will listen on: http://0.0.0.0:5199" -Wait+Screen@5s /http:\/\/0\.0\.0\.0:5199/ +# Serving URL line includes the configured bind address and smoke daemon port. +Wait+Screen@5s /http:\/\/0\.0\.0\.0:[0-9]+/ Enter # Sub-step 4: webhook toggle. @@ -122,7 +122,7 @@ Enter Wait+Screen@10s /Press Enter to run health checks/ Enter -# Daemon starts on 0.0.0.0:5199 but in reverse-proxy mode the CLI cannot +# Daemon starts on the configured reverse-proxy port, but in reverse-proxy mode the CLI cannot # auto-auth back to it via loopback (loopback auto-auth is intentionally # disabled for reverse-proxy to prevent a forwarded-header from inheriting # operator privileges). The wizard's chat-page handshake therefore gets 401 diff --git a/tests/smoke/tapes/preamble.tape b/tests/smoke/tapes/preamble.tape index cfb0c177f..c62943f3f 100644 --- a/tests/smoke/tapes/preamble.tape +++ b/tests/smoke/tapes/preamble.tape @@ -42,10 +42,12 @@ Hide # The host vhs-spawned bash may carry an unhelpful PS1; we set our own # below so anchors are deterministic. -Type "rm -rf __NETCLAW_HOME__ && mkdir -p __NETCLAW_HOME__" +Type "rm -rf __NETCLAW_HOME__ __NETCLAW_USER_HOME__ && mkdir -p __NETCLAW_HOME__ __NETCLAW_USER_HOME__" Enter Type "export PATH=__NETCLAW_BIN_DIR__:$PATH" Enter +Type "export HOME=__NETCLAW_USER_HOME__" +Enter Type "export NETCLAW_HOME=__NETCLAW_HOME__" Enter Type "export NETCLAW_DAEMON_PATH=__NETCLAW_DAEMON__" From 99e0c85608c06701956ab052ceed113ad3c0ff55 Mon Sep 17 00:00:00 2001 From: Aaron Stannard <aaron@petabridge.com> Date: Tue, 26 May 2026 02:28:32 +0000 Subject: [PATCH 06/31] docs(ui): add search config redesign POC --- docs/ui/README.md | 2 + ...earch-config-progressive-disclosure-poc.md | 307 ++++++++++++++++++ 2 files changed, 309 insertions(+) create mode 100644 docs/ui/TUI-004-search-config-progressive-disclosure-poc.md diff --git a/docs/ui/README.md b/docs/ui/README.md index bca114e02..4cfb93254 100644 --- a/docs/ui/README.md +++ b/docs/ui/README.md @@ -6,6 +6,8 @@ This directory contains management UI planning artifacts for Netclaw. - `UI-001-ops-console-mockup.md` - page architecture, wireframes, and component behavior +- `TUI-004-search-config-progressive-disclosure-poc.md` - redesign POC for the + Search settings flow using progressive disclosure - `TUI-001-command-wireframes.md` - Termina TUI wireframes for `netclaw init`, `netclaw chat`, and plain CLI commands - `ops-console-v1.html` - static high-fidelity mockup for visual direction diff --git a/docs/ui/TUI-004-search-config-progressive-disclosure-poc.md b/docs/ui/TUI-004-search-config-progressive-disclosure-poc.md new file mode 100644 index 000000000..9ca70b6f7 --- /dev/null +++ b/docs/ui/TUI-004-search-config-progressive-disclosure-poc.md @@ -0,0 +1,307 @@ +# TUI-004: Search Config Progressive Disclosure POC + +Source PRDs: `PRD-004` + +Related docs: + +- `docs/ui/TUI-002-netclaw-config-wireframes.md` +- `docs/prd/PRD-004-cli-onboarding-and-config.md` + +Status: design POC for replacing the current Search editor layout. + +## Why the current screen fails + +The current Search screen tries to show three separate concerns at once: + +1. information architecture (`Fields` list) +2. editing UI (`Selected Field`) +3. command surface (`Actions`) + +That breaks down in a terminal UI for a few reasons: + +- the operator has to understand the screen layout before they can do the task +- the `Actions` area reads like static text instead of an obvious next step +- irrelevant fields are visible before the backend choice has narrowed the problem +- the screen looks data-driven instead of goal-driven +- focus movement is ambiguous because there are multiple active-looking regions + +The operator's real task is much smaller: choose a provider, fill only the fields +that matter, test it, save it, go back. + +## Design goals + +1. One decision per screen. +2. Only show fields that matter for the chosen backend. +3. Keep the primary action obvious at every step. +4. Treat testing and save as the end of the flow, not a third parallel panel. + +## Recommended interaction model + +Use a short staged flow inside `/search`. + +### Stage 1: Search summary + +Purpose: orient the operator and let them decide whether they want to change, +test, or leave Search alone. + +Show only: + +- current backend +- whether a secret is already configured +- whether the current draft looks ready +- three actions: `Change provider`, `Test current config`, `Back` + +### Stage 2: Choose provider + +Purpose: make the only important decision first. + +Show a single selection list with one-line descriptions: + +- DuckDuckGo +- Brave +- SearXNG + +No form fields on this screen. + +### Stage 3: Configure selected provider + +Purpose: only collect the fields required for the selected backend. + +Behavior by backend: + +- DuckDuckGo: no extra fields, just confirmation, test, and save +- Brave: API key field only +- SearXNG: endpoint URL field only + +Show validation only when relevant. + +Actions live at the bottom of this form: + +- `Test` +- `Save` +- `Change provider` +- `Back` + +### Modal: Probe failure warning + +If structural validation passes but the runtime probe fails, show a blocking +warning dialog: + +- `Keep editing` +- `Test again` +- `Save anyway` + +This stays off the main screen until needed. + +## Workflow diagram + +```text +Dashboard + | + v +Search summary + | + +--> Back to dashboard + | + +--> Test current config + | | + | +--> success/failure status on summary + | + +--> Change provider + | + v + Choose provider + | + v + Provider-specific form + | + +--> Back + | | + | +--> Search summary + | + +--> Test + | | + | +--> success -> inline success state + | | + | +--> failure -> probe warning dialog + | + +--> Save + | + +--> structural error -> stay on form, show issues + | + +--> probe success -> persist and return to summary + | + +--> probe failure -> warning dialog + +Persist on save: + - Search.Backend -> netclaw.json + - Search.SearXngEndpoint -> netclaw.json + - Search.BraveApiKey -> secrets.json +``` + +## Mockups + +### Screen A: Search summary + +```text +╭─ Search ─────────────────────────────────────────────────────╮ +│ │ +│ Configure how Netclaw performs web search and URL fetch. │ +│ │ +│ Current provider: DuckDuckGo │ +│ Secret status: Not required │ +│ Last check: Ready │ +│ │ +│ ▸ Change provider │ +│ Test current configuration │ +│ Back to dashboard │ +│ │ +│ ↑/↓ navigate · Enter select · Esc back │ +╰─────────────────────────────────────────────────────────────╯ +``` + +Why this is better: + +- no editing surface until the operator asks to edit +- no dead-looking action panel +- summary is readable in under five seconds + +### Screen B: Choose provider + +```text +╭─ Search › Choose Provider ──────────────────────────────────╮ +│ │ +│ How should Netclaw search the web? │ +│ │ +│ ▸ DuckDuckGo │ +│ No key required. Good default for most installs. │ +│ │ +│ Brave │ +│ Faster search results. Requires an API key. │ +│ │ +│ SearXNG (self-hosted) │ +│ Use your own endpoint URL. │ +│ │ +│ Enter choose · Esc back │ +╰─────────────────────────────────────────────────────────────╯ +``` + +Why this is better: + +- the provider decision is isolated from credentials and actions +- descriptions answer "why would I pick this?" in place + +### Screen C1: Configure Brave + +```text +╭─ Search › Brave ────────────────────────────────────────────╮ +│ │ +│ Provider: Brave │ +│ │ +│ Brave API key │ +│ ╭────────────────────────────────────────────────────────╮ │ +│ │ │ │ +│ ╰────────────────────────────────────────────────────────╯ │ +│ Existing key is configured. Leave blank to keep it. │ +│ │ +│ [ Test ] [ Save ] [ Change provider ] [ Back ] │ +│ │ +│ Tab next · Enter activate · Esc back │ +╰─────────────────────────────────────────────────────────────╯ +``` + +### Screen C2: Configure SearXNG + +```text +╭─ Search › SearXNG ──────────────────────────────────────────╮ +│ │ +│ Provider: SearXNG │ +│ │ +│ Instance URL │ +│ ╭────────────────────────────────────────────────────────╮ │ +│ │ https://search.example.com │ │ +│ ╰────────────────────────────────────────────────────────╯ │ +│ Enter the base URL of your SearXNG instance. │ +│ │ +│ [ Test ] [ Save ] [ Change provider ] [ Back ] │ +│ │ +│ Tab next · Enter activate · Esc back │ +╰─────────────────────────────────────────────────────────────╯ +``` + +### Screen C3: Configure DuckDuckGo + +```text +╭─ Search › DuckDuckGo ───────────────────────────────────────╮ +│ │ +│ Provider: DuckDuckGo │ +│ │ +│ No extra settings are required for this provider. │ +│ │ +│ [ Test ] [ Save ] [ Change provider ] [ Back ] │ +│ │ +│ Tab next · Enter activate · Esc back │ +╰─────────────────────────────────────────────────────────────╯ +``` + +### Screen D: Probe failure warning + +```text +╭─ Search Test Warning ───────────────────────────────────────╮ +│ │ +│ Netclaw could not complete a live search using this │ +│ configuration. │ +│ │ +│ Brave returned: HTTP 401 Unauthorized │ +│ │ +│ ▸ Keep editing │ +│ Test again │ +│ Save anyway │ +│ │ +│ ↑/↓ navigate · Enter select · Esc keep editing │ +╰─────────────────────────────────────────────────────────────╯ +``` + +## Design principles for this screen + +### 1. Decision first, form second + +Do not show provider-specific fields until the backend is chosen. The backend +selection is the actual fork in the task. + +### 2. Actions belong to the current step + +Never keep a persistent side-panel of commands on screen. `Test`, `Save`, and +`Back` should appear only in the context of the current form or summary page. + +### 3. State should read like operator language, not schema language + +Prefer `Current provider`, `Existing key is configured`, and `No extra settings +required` over exposing raw field architecture like `Fields`, `Selected Field`, +or `Inactive for current backend`. + +## Implementation notes for the next POC + +The next implementation should replace the current `FieldList + FieldCard + +ActionCard` model with a small route-local state machine: + +- `Summary` +- `ChooseBackend` +- `ConfigureBackend` +- `ProbeWarning` + +That keeps the TUI interactive without making the operator manage focus across +three competing regions. + +## VHS validation plan after the redesign lands + +Once the new POC exists, validate it with a tight visual loop: + +1. add a dedicated Search VHS tape for each backend path +2. capture screenshots for summary, chooser, provider form, and warning dialog +3. run a visual design/usability review pass on those screenshots +4. tighten the layout until the screen is readable without explanation + +The key review question should be simple: + +"Can a first-time operator understand what to do next within five seconds?" From 97e200189ee05be171a1b96b20bfdc99e945876c Mon Sep 17 00:00:00 2001 From: Aaron Stannard <aaron@petabridge.com> Date: Tue, 26 May 2026 02:30:47 +0000 Subject: [PATCH 07/31] docs(ui): simplify search config mockups --- ...earch-config-progressive-disclosure-poc.md | 53 +++++++++++++++++-- 1 file changed, 49 insertions(+), 4 deletions(-) diff --git a/docs/ui/TUI-004-search-config-progressive-disclosure-poc.md b/docs/ui/TUI-004-search-config-progressive-disclosure-poc.md index 9ca70b6f7..2eba2eacc 100644 --- a/docs/ui/TUI-004-search-config-progressive-disclosure-poc.md +++ b/docs/ui/TUI-004-search-config-progressive-disclosure-poc.md @@ -34,6 +34,7 @@ that matter, test it, save it, go back. 2. Only show fields that matter for the chosen backend. 3. Keep the primary action obvious at every step. 4. Treat testing and save as the end of the flow, not a third parallel panel. +5. Keep quiet states quiet. ## Recommended interaction model @@ -47,10 +48,12 @@ test, or leave Search alone. Show only: - current backend -- whether a secret is already configured -- whether the current draft looks ready +- only backend-specific state that is actually meaningful - three actions: `Change provider`, `Test current config`, `Back` +Do not show filler copy like `Secret status: Not required` or `Last check: Ready` +for a quiet/default state. + ### Stage 2: Choose provider Purpose: make the only important decision first. @@ -75,6 +78,9 @@ Behavior by backend: Show validation only when relevant. +There is no standalone credential-management screen. Credential input only +appears inline on the provider form for backends that actually use one. + Actions live at the bottom of this form: - `Test` @@ -149,8 +155,7 @@ Persist on save: │ Configure how Netclaw performs web search and URL fetch. │ │ │ │ Current provider: DuckDuckGo │ -│ Secret status: Not required │ -│ Last check: Ready │ +│ No additional setup required. │ │ │ │ ▸ Change provider │ │ Test current configuration │ @@ -166,6 +171,25 @@ Why this is better: - no dead-looking action panel - summary is readable in under five seconds +### Screen A2: Search summary with meaningful state + +```text +╭─ Search ─────────────────────────────────────────────────────╮ +│ │ +│ Current provider: Brave │ +│ API key configured. │ +│ │ +│ ▸ Change provider │ +│ Test current configuration │ +│ Back to dashboard │ +│ │ +│ ↑/↓ navigate · Enter select · Esc back │ +╰─────────────────────────────────────────────────────────────╯ +``` + +If the current state is not meaningful, do not surface it. If it matters, +surface it in one short line. + ### Screen B: Choose provider ```text @@ -210,6 +234,9 @@ Why this is better: ╰─────────────────────────────────────────────────────────────╯ ``` +If no Brave credential is currently stored, omit the `Existing key is +configured` helper line entirely. + ### Screen C2: Configure SearXNG ```text @@ -280,6 +307,24 @@ Prefer `Current provider`, `Existing key is configured`, and `No extra settings required` over exposing raw field architecture like `Fields`, `Selected Field`, or `Inactive for current backend`. +### 4. No null-state metadata + +Do not render rows that only describe the absence of state. If a backend has no +credential concept, do not mention credentials. If there is no meaningful test +history or warning, do not render status copy. + +## Conditional rendering rules + +- DuckDuckGo summary should not mention credentials. +- DuckDuckGo form should not mention secret status. +- Brave summary may show `API key configured` or `API key required` when that is + materially useful. +- Brave form should only show `Leave blank to keep it` when a stored secret + already exists. +- SearXNG should never show secret-management copy. +- `Last check` or similar status copy should only appear after an explicit test + result or when surfacing a warning/error worth operator attention. + ## Implementation notes for the next POC The next implementation should replace the current `FieldList + FieldCard + From e7d53454fe976199c3783598880357a9bec0b2ba Mon Sep 17 00:00:00 2001 From: Aaron Stannard <aaron@petabridge.com> Date: Tue, 26 May 2026 18:45:55 +0000 Subject: [PATCH 08/31] refine(config): align search editor with init TUI --- scripts/smoke/run-smoke.sh | 7 +- .../SearchConfigEditorViewModelTests.cs | 71 ++- .../Tui/Config/SearchConfigEditorPage.cs | 539 +++++++++--------- .../Tui/Config/SearchConfigEditorViewModel.cs | 110 +++- src/Netclaw.Cli/Tui/ConfigDashboardPage.cs | 17 +- src/Netclaw.Cli/Tui/ModelManagerPage.cs | 22 +- src/Netclaw.Cli/Tui/NetclawTuiChrome.cs | 38 ++ src/Netclaw.Cli/Tui/ProviderManagerPage.cs | 61 +- .../Tui/Wizard/Steps/WizardStepHelpers.cs | 8 +- tests/smoke/assertions/config-search.sh | 30 + tests/smoke/tapes/config-search.tape | 75 +++ .../tapes/screenshots/config-search.tape | 50 ++ 12 files changed, 679 insertions(+), 349 deletions(-) create mode 100644 src/Netclaw.Cli/Tui/NetclawTuiChrome.cs create mode 100755 tests/smoke/assertions/config-search.sh create mode 100644 tests/smoke/tapes/config-search.tape create mode 100644 tests/smoke/tapes/screenshots/config-search.tape diff --git a/scripts/smoke/run-smoke.sh b/scripts/smoke/run-smoke.sh index 4da5ab4b3..7b19e0864 100755 --- a/scripts/smoke/run-smoke.sh +++ b/scripts/smoke/run-smoke.sh @@ -54,7 +54,7 @@ SMOKE_LOG_DIR="${SMOKE_LOG_DIR:-${ROOT_DIR}/smoke-logs}" # Cheapest harness checks first so a harness-level break fails fast # before paying for the wizard + probe tapes. -LIGHT_TAPES=(help init-wizard init-wizard-reverse-proxy provider-add provider-rename tui-cleanup) +LIGHT_TAPES=(help init-wizard init-wizard-reverse-proxy provider-add provider-rename config-search tui-cleanup) FULL_TAPES=("${LIGHT_TAPES[@]}") LIGHT_SCENARIOS=( @@ -74,12 +74,15 @@ FULL_SCENARIOS=("${LIGHT_SCENARIOS[@]}") # may emit several `Screenshot "/tmp/shot-<frame>.png"` directives. SHOT_FRAMES # is the full set of frame names the harness compares against baselines — it # MUST stay in sync with the Screenshot paths in those tapes. -SHOT_TAPES=(help wizard-screens provider-manager) +SHOT_TAPES=(help wizard-screens provider-manager config-search) SHOT_FRAMES=( help wizard-provider-picker wizard-security-posture provider-manager-empty + config-search-matrix + config-search-brave + config-search-searxng-edit ) usage() { diff --git a/src/Netclaw.Cli.Tests/Tui/Config/SearchConfigEditorViewModelTests.cs b/src/Netclaw.Cli.Tests/Tui/Config/SearchConfigEditorViewModelTests.cs index 2003cc973..e11775d5b 100644 --- a/src/Netclaw.Cli.Tests/Tui/Config/SearchConfigEditorViewModelTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/Config/SearchConfigEditorViewModelTests.cs @@ -56,6 +56,58 @@ public void Fields_project_search_enabled_out_of_editor() Assert.Contains(vm.Fields, static field => field.Path == "Search.Backend"); } + [Fact] + public void Starts_on_summary_screen() + { + using var vm = new SearchConfigEditorViewModel(_paths); + + Assert.Equal(SearchConfigEditorScreen.Summary, vm.CurrentScreen.Value); + Assert.Equal("duckduckgo", vm.CurrentBackendValue); + Assert.Equal("No additional setup required.", vm.GetSummaryStateText()); + } + + [Fact] + public void Selecting_brave_keeps_single_screen_matrix_active() + { + using var vm = new SearchConfigEditorViewModel(_paths); + + vm.BeginBackendSelection(); + vm.SelectBackendForEditing("brave"); + + Assert.Equal(SearchConfigEditorScreen.Summary, vm.CurrentScreen.Value); + Assert.Equal("brave", vm.CurrentBackendValue); + Assert.Equal("API key required.", vm.GetSummaryStateText()); + Assert.Equal("Search.BraveApiKey", vm.CurrentProviderField?.Path); + } + + [Fact] + public void Selecting_duckduckgo_has_no_provider_specific_field() + { + using var vm = new SearchConfigEditorViewModel(_paths); + + vm.BeginBackendSelection(); + vm.SelectBackendForEditing("duckduckgo"); + + Assert.Equal(SearchConfigEditorScreen.Summary, vm.CurrentScreen.Value); + Assert.Null(vm.CurrentProviderField); + Assert.Equal("No additional setup required.", vm.GetSummaryStateText()); + } + + [Fact] + public void Selecting_zero_config_provider_keeps_summary_quiet_when_effective_value_is_unchanged() + { + using var vm = new SearchConfigEditorViewModel(_paths); + + vm.BeginBackendSelection(); + vm.SelectBackendForEditing("brave"); + vm.BeginBackendSelection(); + vm.SelectBackendForEditing("duckduckgo"); + + Assert.Equal(SearchConfigEditorScreen.Summary, vm.CurrentScreen.Value); + Assert.False(vm.IsDirty); + Assert.Equal("duckduckgo", vm.CurrentBackendValue); + } + [Fact] public async Task Brave_probe_failure_opens_override_dialog_before_save() { @@ -110,6 +162,19 @@ public void Blank_secret_preserves_existing_secret() Assert.Contains(encrypted, secrets, StringComparison.Ordinal); } + [Fact] + public void Blank_secret_without_existing_value_is_still_structurally_invalid() + { + using var vm = new SearchConfigEditorViewModel(_paths); + + vm.SetFieldValue("Search.Backend", "brave"); + vm.SetFieldValue("Search.BraveApiKey", ""); + + var issues = vm.ValidationSummary.Value.IssuesFor("Search.BraveApiKey"); + Assert.Contains(issues, static issue => issue.Message.Contains("API key", StringComparison.OrdinalIgnoreCase)); + Assert.False(vm.HasPersistedSecret("Search.BraveApiKey")); + } + [Fact] public async Task Successful_probe_allows_save_without_dialog() { @@ -125,6 +190,7 @@ public async Task Successful_probe_allows_save_without_dialog() await vm.SaveAsync(TestContext.Current.CancellationToken); Assert.Equal(SearchConfigEditorDialog.None, vm.ActiveDialog.Value); + Assert.Equal(SearchConfigEditorScreen.Summary, vm.CurrentScreen.Value); Assert.Contains("Saved Search settings", vm.Status.Value.Text, StringComparison.Ordinal); } @@ -145,8 +211,9 @@ public void Preserved_state_supports_in_memory_draft_edits() { using var vm = new SearchConfigEditorViewModel(_paths); - vm.SetFieldValue("Search.Backend", "searxng"); - vm.SetFieldValue("Search.SearXngEndpoint", "https://search.example.com"); + vm.SelectBackendForEditing("searxng"); + vm.StageFieldValue("Search.SearXngEndpoint", "https://search.example.com"); + vm.CommitFieldValue("Search.SearXngEndpoint"); vm.OnDeactivating(); vm.OnActivated(); diff --git a/src/Netclaw.Cli/Tui/Config/SearchConfigEditorPage.cs b/src/Netclaw.Cli/Tui/Config/SearchConfigEditorPage.cs index 9a071f39d..3a93781c5 100644 --- a/src/Netclaw.Cli/Tui/Config/SearchConfigEditorPage.cs +++ b/src/Netclaw.Cli/Tui/Config/SearchConfigEditorPage.cs @@ -4,8 +4,8 @@ // </copyright> // ----------------------------------------------------------------------- using R3; +using Netclaw.Cli.Tui; using Termina.Extensions; -using Termina.Input; using Termina.Layout; using Termina.Reactive; using Termina.Rendering; @@ -15,53 +15,43 @@ namespace Netclaw.Cli.Tui.Config; internal sealed class SearchConfigEditorPage : ReactivePage<SearchConfigEditorViewModel> { - private SelectionListNode<string>? _fieldList; - private SelectionListNode<string>? _actionList; - private SelectionListNode<string>? _enumList; private SelectionListNode<string>? _dialogList; private TextInputNode? _textInput; private DynamicLayoutNode? _contentNode; private readonly CompositeDisposable _contentSubscriptions = []; - private FocusTarget _focusTarget = FocusTarget.FieldList; + private SearchFocusTarget _focusTarget = SearchFocusTarget.ProviderList; + private int _providerIndex; + private string? _editingFieldPath; + private string _editSeed = string.Empty; - private enum FocusTarget + private enum SearchFocusTarget { - FieldList, - FieldEditor, - ActionList, + ProviderList, + FieldInput, Dialog, } - protected override void OnBound() + public override void OnNavigatedTo() { - base.OnBound(); - ViewModel.Input.OfType<IInputEvent, KeyPressed>() - .Subscribe(HandleKeyPress) + base.OnNavigatedTo(); + + ViewModel.ActiveDialog.Subscribe(_ => _contentNode?.Invalidate()) + .DisposeWith(Subscriptions); + ViewModel.ValidationSummary.Subscribe(_ => _contentNode?.Invalidate()) + .DisposeWith(Subscriptions); + ViewModel.Status.Subscribe(_ => _contentNode?.Invalidate()) .DisposeWith(Subscriptions); } public override ILayoutNode BuildLayout() - { - return Layouts.Vertical() - .WithChild( - new PanelNode() - .WithTitle("Search") - .WithBorder(BorderStyle.Rounded) - .WithBorderColor(Color.Cyan) - .WithContent(BuildInnerLayout()) - .Fill()); - } + => NetclawTuiChrome.BuildPageFrame("Search", BuildInnerLayout()); private ILayoutNode BuildInnerLayout() - { - return Layouts.Vertical() + => Layouts.Vertical() .WithSpacing(1) - .WithChild(new TextNode(" Configure how Netclaw performs web search and URL fetch augmentation.") - .WithForeground(Color.BrightBlack)) .WithChild(BuildContent()) .WithChild(BuildStatusBar()) .WithChild(BuildKeyBindings()); - } private LayoutNode BuildContent() { @@ -69,227 +59,201 @@ private LayoutNode BuildContent() { _contentSubscriptions.Clear(); _dialogList = null; - _actionList = null; - _enumList = null; _textInput = null; - return ViewModel.ActiveDialog.Value == SearchConfigEditorDialog.ProbeWarning - ? BuildProbeWarningDialog() - : BuildEditorLayout(); - }); + if (ViewModel.ActiveDialog.Value == SearchConfigEditorDialog.ProbeWarning) + return BuildProbeWarningDialog(); - ViewModel.SelectedIndex.Subscribe(_ => _contentNode.Invalidate()).DisposeWith(Subscriptions); - ViewModel.ValidationSummary.Subscribe(_ => _contentNode.Invalidate()).DisposeWith(Subscriptions); - ViewModel.ActiveDialog.Subscribe(_ => _contentNode.Invalidate()).DisposeWith(Subscriptions); + return BuildProviderMatrixScreen(); + }); return _contentNode; } - private ILayoutNode BuildEditorLayout() + private ILayoutNode BuildProviderMatrixScreen() { - var rows = ViewModel.Fields.Select(FormatFieldRow).ToList(); + SyncProviderIndexToCurrentBackend(); - _fieldList = Layouts.SelectionList(rows) - .WithMode(SelectionMode.Single) - .WithHighlightColors(Color.Black, Color.Cyan); - - if (_focusTarget == FocusTarget.FieldList) - _fieldList.OnFocused(); - - _fieldList.SelectionConfirmed - .Subscribe(selected => - { - if (selected.Count == 0) - return; - - var index = rows.IndexOf(selected[0]); - if (index >= 0) - { - ViewModel.SelectedIndex.Value = index; - _focusTarget = FocusTarget.FieldEditor; - _contentNode?.Invalidate(); - ViewModel.RequestRedraw(); - } - }) - .DisposeWith(_contentSubscriptions); + var content = Layouts.Vertical() + .WithSpacing(1) + .WithChild(new TextNode(" Choose your web search provider:").WithForeground(Color.White)) + .WithChild(BuildProviderList()) + .WithChild(BuildProviderDetails()) + .WithChild(BuildMatrixState()) + .WithChild(BuildCommandRail()); - return Layouts.Horizontal() - .WithChild( - new PanelNode() - .WithTitle("Fields") - .WithBorder(BorderStyle.Rounded) - .WithBorderColor(Color.Cyan) - .WithContent( - Layouts.Vertical() - .WithChild(new TextNode(" Select a field to edit.").WithForeground(Color.BrightBlack)) - .WithChild(_fieldList)) - .Width(42)) - .WithChild( - Layouts.Vertical() - .WithSpacing(1) - .WithChild(BuildFieldCard()) - .WithChild(BuildActionCard()) - .Fill()); + return content; } - private string FormatFieldRow(ProjectedConfigField field) + private ILayoutNode BuildProviderList() { - var issues = ViewModel.GetIssues(field); - var marker = issues.Count > 0 ? "!" : ViewModel.IsApplicable(field) ? ">" : "-"; - var value = ViewModel.IsApplicable(field) - ? ViewModel.GetDisplayValue(field) - : "Inactive for current backend"; - var clippedValue = value.Length > 24 ? value[..21] + "..." : value; - return $"{marker} {field.Label,-22} {clippedValue}"; + var content = Layouts.Vertical(); + var options = ViewModel.BackendOptions; + for (var i = 0; i < options.Count; i++) + { + var option = options[i]; + var isFocused = _focusTarget == SearchFocusTarget.ProviderList && i == _providerIndex; + var isActive = string.Equals(option.Value, ViewModel.CurrentBackendValue, StringComparison.OrdinalIgnoreCase); + var marker = isActive ? "(*)" : "( )"; + var prefix = isFocused ? ">" : " "; + var line = $" {prefix} {marker} {option.Label,-18} {GetProviderRequirementText(option.Value)}"; + var color = isFocused ? Color.Cyan : Color.White; + + var node = new TextNode(line).WithForeground(color); + if (isActive) + node.Bold(); + + content.WithChild(node.Height(1)); + } + + return content; } - private ILayoutNode BuildFieldCard() + private ILayoutNode BuildProviderDetails() { - var field = ViewModel.SelectedField; - var issues = ViewModel.GetIssues(field); + var content = Layouts.Vertical().WithSpacing(1); - var content = Layouts.Vertical() - .WithSpacing(1) - .WithChild(new TextNode($" {field.Label}").WithForeground(Color.White).Bold()); - - if (!string.IsNullOrWhiteSpace(field.Description)) - content.WithChild(new TextNode($" {field.Description}").WithForeground(Color.BrightBlack)); + content.WithChild(new TextNode($" {ViewModel.CurrentBackendLabel}").WithForeground(Color.White).Bold()); - if (!string.IsNullOrWhiteSpace(field.Hint)) - content.WithChild(new TextNode($" {field.Hint}").WithForeground(Color.Cyan)); - - if (!ViewModel.IsApplicable(field)) + var field = ViewModel.CurrentProviderField; + if (field is null) { - content.WithChild(new TextNode(" This field only matters for the currently selected backend.") - .WithForeground(Color.BrightBlack)); - content.WithChild(new TextNode($" {ViewModel.GetInactiveText(field)}").WithForeground(Color.BrightBlack)); + content.WithChild(new TextNode(" No additional setup required.").WithForeground(Color.Gray)); + return content; } - else if (field.Widget == ConfigFieldWidget.EnumSelection) - { - var items = field.EnumOptions.Select(static option => option.Label).ToList(); - _enumList = Layouts.SelectionList(items) - .WithMode(SelectionMode.Single) - .WithHighlightColors(Color.Black, Color.Cyan); - if (_focusTarget == FocusTarget.FieldEditor) - _enumList.OnFocused(); + content.WithChild(IsEditingField(field) + ? BuildEditingFieldLayout(field) + : BuildReadonlyFieldLayout(field)); - _enumList.SelectionConfirmed - .Subscribe(selected => - { - if (selected.Count == 0) - return; - - var option = field.EnumOptions.FirstOrDefault(o => o.Label == selected[0]); - if (option is not null) - ViewModel.SetFieldValue(field.Path, option.Value); - }) - .DisposeWith(_contentSubscriptions); + foreach (var issue in ViewModel.GetCurrentProviderIssues()) + content.WithChild(new TextNode($" ! {issue.Message}").WithForeground(Color.Red)); - content.WithChild(_enumList); - } - else - { - _textInput = new TextInputNode(); - if (field.Widget == ConfigFieldWidget.PasswordInput) - _textInput.AsPassword(); - if (!string.IsNullOrWhiteSpace(field.Placeholder)) - _textInput.WithPlaceholder(field.Placeholder); + return content; + } - _textInput.Text = ViewModel.GetEditorSeed(field); - if (_focusTarget == FocusTarget.FieldEditor) - _textInput.OnFocused(); + private ILayoutNode BuildReadonlyFieldLayout(ProjectedConfigField field) + { + var displayValue = ViewModel.GetDisplayValue(field); + if (string.IsNullOrWhiteSpace(displayValue)) + displayValue = "(not configured)"; - _textInput.Submitted - .Subscribe(text => ViewModel.SetFieldValue(field.Path, text)) - .DisposeWith(_contentSubscriptions); + var valueColor = displayValue.StartsWith("(", StringComparison.Ordinal) + ? Color.Gray + : Color.White; - content.WithChild(Netclaw.Cli.Tui.Wizard.Steps.WizardStepHelpers.BuildTextInputPanel(_textInput, field.Label)); - } + var content = Layouts.Vertical() + .WithSpacing(1) + .WithChild(new TextNode($" {field.Label}:").WithForeground(Color.White)) + .WithChild(new TextNode($" {displayValue}").WithForeground(valueColor)) + .WithChild(new TextNode(" Press Enter to edit.").WithForeground(Color.Gray)); - foreach (var issue in issues) - content.WithChild(new TextNode($" ! {issue.Message}").WithForeground(Color.Red)); + var supportText = ViewModel.GetCurrentProviderSupportText(); + if (!string.IsNullOrWhiteSpace(supportText)) + content.WithChild(new TextNode($" {supportText}").WithForeground(Color.Gray)); - return new PanelNode() - .WithTitle("Selected Field") - .WithBorder(BorderStyle.Rounded) - .WithBorderColor(Color.Cyan) - .WithContent(content); + return content; } - private ILayoutNode BuildActionCard() + private ILayoutNode BuildEditingFieldLayout(ProjectedConfigField field) { - var actions = new List<string> - { - "Test search backend", - "Save settings", - "Reset unsaved changes", - "Back to dashboard", - }; + var content = Layouts.Vertical() + .WithSpacing(1) + .WithChild(new TextNode($" {field.Label}:").WithForeground(Color.White)); - _actionList = Layouts.SelectionList(actions) - .WithMode(SelectionMode.Single) - .WithHighlightColors(Color.Black, Color.Green); + _textInput = new TextInputNode(); + if (field.Widget == ConfigFieldWidget.PasswordInput) + _textInput.AsPassword(); + if (!string.IsNullOrWhiteSpace(field.Placeholder)) + _textInput.WithPlaceholder(field.Placeholder); - if (_focusTarget == FocusTarget.ActionList) - _actionList.OnFocused(); + _textInput.Text = ViewModel.GetEditorSeed(field); + if (_focusTarget == SearchFocusTarget.FieldInput) + _textInput.OnFocused(); - _actionList.SelectionConfirmed - .Subscribe(async selected => + _textInput.Submitted + .Subscribe(text => { - if (selected.Count == 0) - return; - - switch (selected[0]) + if (field.Path == "Search.BraveApiKey" + && string.IsNullOrWhiteSpace(text) + && !ViewModel.HasPersistedSecret(field.Path)) { - case "Test search backend": - await ViewModel.TestCurrentConfigurationAsync(); - break; - case "Save settings": - await ViewModel.SaveAsync(); - break; - case "Reset unsaved changes": - ViewModel.ResetDraft(); - break; - case "Back to dashboard": - ViewModel.NavigateBack(); - break; + ViewModel.Status.Value = new ConfigStatusMessage("Brave requires an API key.", ConfigStatusTone.Error); + ViewModel.RequestRedraw(); + return; } + + ViewModel.StageFieldValue(field.Path, text); + ViewModel.CommitFieldValue(field.Path); + _editingFieldPath = null; + _editSeed = string.Empty; + _focusTarget = SearchFocusTarget.ProviderList; + _contentNode?.Invalidate(); + ViewModel.RequestRedraw(); }) .DisposeWith(_contentSubscriptions); - var backendField = ViewModel.Fields.First(static f => f.Path == "Search.Backend"); - var errorCount = ViewModel.ValidationSummary.Value.Issues.Count(static i => i.Severity == ConfigValidationSeverity.Error); - var dirtyText = ViewModel.IsDirty ? "Unsaved changes" : "No unsaved changes"; - var validationText = errorCount == 0 ? "Ready to test or save" : $"{errorCount} validation error(s)"; - - return new PanelNode() - .WithTitle("Actions") - .WithBorder(BorderStyle.Rounded) - .WithBorderColor(Color.Green) - .WithContent( - Layouts.Vertical() - .WithSpacing(1) - .WithChild(new TextNode($" Backend: {ViewModel.GetDisplayValue(backendField)}").WithForeground(Color.White)) - .WithChild(new TextNode($" {dirtyText}").WithForeground(Color.BrightBlack)) - .WithChild(new TextNode($" {validationText}").WithForeground(errorCount == 0 ? Color.BrightBlack : Color.Yellow)) - .WithChild(_actionList)); + content.WithChild(NetclawTuiChrome.BuildTextInputPanel(_textInput, field.Label)); + content.WithChild(new TextNode(" Press Enter to apply or Esc to cancel edit.").WithForeground(Color.Gray)); + + var supportText = ViewModel.GetCurrentProviderSupportText(); + if (!string.IsNullOrWhiteSpace(supportText)) + content.WithChild(new TextNode($" {supportText}").WithForeground(Color.Gray)); + + return content; + } + + private ILayoutNode BuildMatrixState() + { + var children = Layouts.Vertical().WithSpacing(1); + var hasState = false; + + if (ViewModel.GetSummaryStateTone() == ConfigStatusTone.Warning) + { + children.WithChild(new TextNode($" {ViewModel.GetSummaryStateText()}").WithForeground(Color.Yellow)); + hasState = true; + } + + if (ViewModel.IsDirty) + { + children.WithChild(new TextNode(" Unsaved changes.").WithForeground(Color.Yellow)); + hasState = true; + } + + if (ViewModel.LastProbeResult is { } lastProbe) + { + children.WithChild(new TextNode($" Last test: {lastProbe.Message}").WithForeground(ToColor(lastProbe.Tone))); + hasState = true; + } + + return hasState ? children : Layouts.Empty(); + } + + private ILayoutNode BuildCommandRail() + { + var text = _focusTarget == SearchFocusTarget.FieldInput + ? " [Enter] Apply [Esc] Cancel edit" + : ViewModel.CurrentProviderField is null + ? " [T] Test [S] Save [Esc] Back" + : " [Enter] Edit [T] Test [S] Save [Esc] Back"; + + return new TextNode(text).WithForeground(Color.Gray); } private ILayoutNode BuildProbeWarningDialog() { var options = new List<string> { - "Save anyway", - "Test again", "Keep editing", + "Test again", + "Save anyway", }; _dialogList = Layouts.SelectionList(options) .WithMode(SelectionMode.Single) .WithHighlightColors(Color.Black, Color.Yellow); _dialogList.OnFocused(); - _focusTarget = FocusTarget.Dialog; + _focusTarget = SearchFocusTarget.Dialog; _dialogList.SelectionConfirmed .Subscribe(async selected => @@ -301,79 +265,73 @@ private ILayoutNode BuildProbeWarningDialog() { case "Save anyway": ViewModel.SaveWithoutProbeOverride(); + _focusTarget = SearchFocusTarget.ProviderList; break; case "Test again": ViewModel.DismissDialog(); - _focusTarget = FocusTarget.ActionList; + _focusTarget = SearchFocusTarget.ProviderList; await ViewModel.TestCurrentConfigurationAsync(); break; default: ViewModel.DismissDialog(); - _focusTarget = FocusTarget.FieldList; + _focusTarget = SearchFocusTarget.ProviderList; break; } }) .DisposeWith(_contentSubscriptions); var message = ViewModel.LastProbeResult?.Message ?? "Search backend test failed."; - return new PanelNode() - .WithTitle("Probe Warning") - .WithBorder(BorderStyle.Rounded) - .WithBorderColor(Color.Yellow) - .WithContent( - Layouts.Vertical() - .WithSpacing(1) - .WithChild(new TextNode($" {message}").WithForeground(Color.Yellow)) - .WithChild(new TextNode(" Save anyway stores the config despite the failed runtime probe.") - .WithForeground(Color.BrightBlack)) - .WithChild(_dialogList)); + return NetclawTuiChrome.BuildPanel( + "Search Test Warning", + Layouts.Vertical() + .WithSpacing(1) + .WithChild(new TextNode($" {message}").WithForeground(Color.Yellow)) + .WithChild(new TextNode(" Netclaw could not complete a live search using this configuration.") + .WithForeground(Color.Gray)) + .WithChild(_dialogList), + Color.Yellow); } private LayoutNode BuildStatusBar() - { - return ViewModel.Status - .Select(status => (ILayoutNode)(string.IsNullOrWhiteSpace(status.Text) - ? Layouts.Empty() - : new TextNode($" {status.Text}").WithForeground(ToColor(status.Tone)))) + => ViewModel.Status + .Select(status => NetclawTuiChrome.BuildStatusLine(status.Text, ToColor(status.Tone))) .AsLayout() .Height(1); - } private LayoutNode BuildKeyBindings() { - return new TextNode(" [↑/↓] Navigate [Enter] Confirm [Tab] Cycle focus [T] Test [S] Save [R] Reset [Esc] Back [Ctrl+Q] Quit") - .WithForeground(Color.BrightBlack) - .Height(1); + var text = _focusTarget switch + { + SearchFocusTarget.Dialog => " [↑/↓] Navigate [Enter] Confirm [Esc] Dismiss [Ctrl+Q] Quit", + SearchFocusTarget.FieldInput => " [Enter] Apply [Esc] Cancel edit [Ctrl+Q] Quit", + _ when ViewModel.CurrentProviderField is null => " [↑/↓] Navigate [T] Test [S] Save [Esc] Back [Ctrl+Q] Quit", + _ => " [↑/↓] Navigate [Enter] Edit [T] Test [S] Save [Esc] Back [Ctrl+Q] Quit", + }; + + return NetclawTuiChrome.BuildKeyHintLine(text); } - private void HandleKeyPress(KeyPressed key) + public override bool HandlePageInput(ConsoleKeyInfo keyInfo) { - var keyInfo = key.KeyInfo; + if (base.HandlePageInput(keyInfo)) + return true; if (keyInfo.Key == ConsoleKey.Q && keyInfo.Modifiers.HasFlag(ConsoleModifiers.Control)) { ViewModel.RequestQuit(); - return; + return true; } - if (keyInfo.Key == ConsoleKey.T) + if (_focusTarget != SearchFocusTarget.FieldInput && keyInfo.Key == ConsoleKey.T) { - _focusTarget = FocusTarget.ActionList; _ = ViewModel.TestCurrentConfigurationAsync(); - return; + return true; } - if (keyInfo.Key == ConsoleKey.S) + if (_focusTarget != SearchFocusTarget.FieldInput && keyInfo.Key == ConsoleKey.S) { - _focusTarget = FocusTarget.ActionList; _ = ViewModel.SaveAsync(); - return; - } - - if (keyInfo.Key == ConsoleKey.R) - { - ViewModel.ResetDraft(); - return; + return true; } if (keyInfo.Key == ConsoleKey.Escape) @@ -381,56 +339,125 @@ private void HandleKeyPress(KeyPressed key) if (ViewModel.ActiveDialog.Value != SearchConfigEditorDialog.None) { ViewModel.DismissDialog(); - _focusTarget = FocusTarget.FieldList; - return; + _focusTarget = SearchFocusTarget.ProviderList; + return true; + } + + if (_focusTarget == SearchFocusTarget.FieldInput) + { + CancelActiveEdit(); + return true; } ViewModel.NavigateBack(); - return; + return true; } - if (keyInfo.Key == ConsoleKey.Tab && ViewModel.ActiveDialog.Value == SearchConfigEditorDialog.None) + if (_focusTarget != SearchFocusTarget.FieldInput && keyInfo.Key == ConsoleKey.Enter) { - CycleFocus(); - return; + BeginInlineEdit(); + return true; } switch (_focusTarget) { - case FocusTarget.Dialog: + case SearchFocusTarget.Dialog: _dialogList?.HandleInput(keyInfo); break; - case FocusTarget.ActionList: - _actionList?.HandleInput(keyInfo); - break; - case FocusTarget.FieldEditor when _enumList is not null: - _enumList.HandleInput(keyInfo); - break; - case FocusTarget.FieldEditor when _textInput is not null: + case SearchFocusTarget.FieldInput when _textInput is not null: + var fieldPath = _editingFieldPath; _textInput.HandleInput(keyInfo); + if (_focusTarget == SearchFocusTarget.FieldInput + && !string.IsNullOrWhiteSpace(fieldPath)) + { + ViewModel.StageFieldValue(fieldPath, _textInput.Text); + } + break; default: - _fieldList?.HandleInput(keyInfo); + if (keyInfo.Key == ConsoleKey.UpArrow) + { + MoveProviderSelection(-1); + return true; + } + + if (keyInfo.Key == ConsoleKey.DownArrow) + { + MoveProviderSelection(1); + return true; + } + break; } ViewModel.RequestRedraw(); + return true; } - private void CycleFocus() + private void SyncProviderIndexToCurrentBackend() { - _focusTarget = _focusTarget switch - { - FocusTarget.FieldList => FocusTarget.FieldEditor, - FocusTarget.FieldEditor => FocusTarget.ActionList, - FocusTarget.ActionList => FocusTarget.FieldList, - _ => FocusTarget.FieldList, - }; + var index = ViewModel.BackendOptions + .Select((option, idx) => (option, idx)) + .FirstOrDefault(entry => string.Equals(entry.option.Value, ViewModel.CurrentBackendValue, StringComparison.OrdinalIgnoreCase)) + .idx; + + _providerIndex = Math.Clamp(index, 0, Math.Max(0, ViewModel.BackendOptions.Count - 1)); + } + + private void MoveProviderSelection(int delta) + { + if (ViewModel.BackendOptions.Count == 0) + return; + + var next = Math.Clamp(_providerIndex + delta, 0, ViewModel.BackendOptions.Count - 1); + if (next == _providerIndex) + return; + _providerIndex = next; + _editingFieldPath = null; + _editSeed = string.Empty; + + var option = ViewModel.BackendOptions[_providerIndex]; + ViewModel.SelectBackendForEditing(option.Value); + _contentNode?.Invalidate(); + } + + private void BeginInlineEdit() + { + if (ViewModel.CurrentProviderField is not { } field) + return; + + _editingFieldPath = field.Path; + _editSeed = ViewModel.GetEditorSeed(field); + _focusTarget = SearchFocusTarget.FieldInput; _contentNode?.Invalidate(); ViewModel.RequestRedraw(); } + private bool IsEditingField(ProjectedConfigField field) + => _focusTarget == SearchFocusTarget.FieldInput + && string.Equals(_editingFieldPath, field.Path, StringComparison.Ordinal); + + private void CancelActiveEdit() + { + if (_editingFieldPath is { } path) + ViewModel.StageFieldValue(path, _editSeed); + + _editingFieldPath = null; + _editSeed = string.Empty; + _focusTarget = SearchFocusTarget.ProviderList; + _contentNode?.Invalidate(); + ViewModel.RequestRedraw(); + } + + private static string GetProviderRequirementText(string backend) + => backend switch + { + "brave" => "Requires API key", + "searxng" => "Requires endpoint URL", + _ => "No setup required", + }; + private static Color ToColor(ConfigStatusTone tone) => tone switch { ConfigStatusTone.Success => Color.Green, diff --git a/src/Netclaw.Cli/Tui/Config/SearchConfigEditorViewModel.cs b/src/Netclaw.Cli/Tui/Config/SearchConfigEditorViewModel.cs index 00b1fc447..53dd308e2 100644 --- a/src/Netclaw.Cli/Tui/Config/SearchConfigEditorViewModel.cs +++ b/src/Netclaw.Cli/Tui/Config/SearchConfigEditorViewModel.cs @@ -21,6 +21,13 @@ internal enum SearchConfigEditorDialog ProbeWarning, } +internal enum SearchConfigEditorScreen +{ + Summary, + ChooseProvider, + ConfigureProvider, +} + internal sealed record SearchProbeResult(bool Success, string Message, ConfigStatusTone Tone); internal sealed class SearchConfigEditorViewModel : ReactiveViewModel @@ -30,6 +37,7 @@ internal sealed class SearchConfigEditorViewModel : ReactiveViewModel private readonly JsonSchema _schema; private readonly IHttpClientFactory? _httpClientFactory; private readonly TimeProvider _timeProvider; + private readonly Dictionary<string, ProjectedConfigField> _fieldsByPath; private ConfigValidationSummary _lastStructuralValidation = ConfigValidationSummary.Empty; private SearchProbeResult? _lastProbeResult; @@ -47,6 +55,7 @@ public SearchConfigEditorViewModel( var projector = new ConfigSectionSchemaProjector(); Fields = projector.ProjectTopLevelSection("Search", SearchConfigMetadata.Fields); + _fieldsByPath = Fields.ToDictionary(static field => field.Path, StringComparer.Ordinal); _session = new ConfigSectionEditSession(paths, Fields); var schemaText = EmbeddedSchemaLoader.LoadConfigSchema(EmbeddedSchemaLoader.CurrentSchemaVersion) @@ -58,6 +67,7 @@ public SearchConfigEditorViewModel( Status = new ReactiveProperty<ConfigStatusMessage>(new ConfigStatusMessage(string.Empty, ConfigStatusTone.Neutral)); ValidationSummary = new ReactiveProperty<ConfigValidationSummary>(ConfigValidationSummary.Empty); ActiveDialog = new ReactiveProperty<SearchConfigEditorDialog>(SearchConfigEditorDialog.None); + CurrentScreen = new ReactiveProperty<SearchConfigEditorScreen>(SearchConfigEditorScreen.Summary); foreach (var field in Fields) FieldValues[field.Path] = new ReactiveProperty<string>(_session.GetEditableString(field.Path)); @@ -71,10 +81,22 @@ public SearchConfigEditorViewModel( public ReactiveProperty<ConfigStatusMessage> Status { get; } public ReactiveProperty<ConfigValidationSummary> ValidationSummary { get; } public ReactiveProperty<SearchConfigEditorDialog> ActiveDialog { get; } + public ReactiveProperty<SearchConfigEditorScreen> CurrentScreen { get; } public ProjectedConfigField SelectedField => Fields[SelectedIndex.Value]; public bool IsDirty => _session.IsDirty; public SearchProbeResult? LastProbeResult => _lastProbeResult; + public string CurrentBackendValue => _session.GetEffectiveString("Search.Backend") ?? SearchBackend.DuckDuckGo.ToWireValue(); + public string CurrentBackendLabel => GetField("Search.Backend").EnumOptions + .FirstOrDefault(option => string.Equals(option.Value, CurrentBackendValue, StringComparison.OrdinalIgnoreCase))?.Label + ?? CurrentBackendValue; + public IReadOnlyList<ConfigEnumOption> BackendOptions => GetField("Search.Backend").EnumOptions; + public ProjectedConfigField? CurrentProviderField => CurrentBackendValue switch + { + "brave" => GetField("Search.BraveApiKey"), + "searxng" => GetField("Search.SearXngEndpoint"), + _ => null, + }; public override void Dispose() { @@ -85,6 +107,7 @@ public override void Dispose() Status.Dispose(); ValidationSummary.Dispose(); ActiveDialog.Dispose(); + CurrentScreen.Dispose(); base.Dispose(); } @@ -99,16 +122,36 @@ public void MoveSelection(int delta) } public void SetFieldValue(string path, string? value) + { + StageFieldValue(path, value); + CommitFieldValue(path); + } + + public void StageFieldValue(string path, string? value) { if (!FieldValues.TryGetValue(path, out var property)) throw new InvalidOperationException($"Unknown search config field '{path}'."); property.Value = value ?? string.Empty; + } + + public void CommitFieldValue(string path) + { + if (!FieldValues.TryGetValue(path, out var property)) + throw new InvalidOperationException($"Unknown search config field '{path}'."); + _session.SetValue(path, property.Value); + ClearTransientProbeState(); Revalidate(); RequestRedraw(); } + public void CommitCurrentProviderDraft() + { + if (CurrentProviderField is { } field) + CommitFieldValue(field.Path); + } + public string GetDisplayValue(ProjectedConfigField field) { if (field.Widget == ConfigFieldWidget.PasswordInput) @@ -129,7 +172,7 @@ public string GetDisplayValue(ProjectedConfigField field) } public string GetEditorSeed(ProjectedConfigField field) - => _session.GetEditableString(field.Path); + => FieldValues[field.Path].Value; public bool IsApplicable(ProjectedConfigField field) => _session.IsApplicable(field); @@ -139,6 +182,54 @@ public string GetInactiveText(ProjectedConfigField field) public IReadOnlyList<ConfigValidationIssue> GetIssues(ProjectedConfigField field) => ValidationSummary.Value.IssuesFor(field.Path); + public IReadOnlyList<ConfigValidationIssue> GetCurrentProviderIssues() + => CurrentProviderField is { } field ? ValidationSummary.Value.IssuesFor(field.Path) : []; + + public string GetSummaryStateText() + => CurrentBackendValue switch + { + "brave" => HasEffectiveValue("Search.BraveApiKey") ? "API key configured." : "API key required.", + "searxng" => HasEffectiveValue("Search.SearXngEndpoint") ? "Endpoint configured." : "Endpoint required.", + _ => "No additional setup required." + }; + + public ConfigStatusTone GetSummaryStateTone() + => CurrentBackendValue switch + { + "brave" when !HasEffectiveValue("Search.BraveApiKey") => ConfigStatusTone.Warning, + "searxng" when !HasEffectiveValue("Search.SearXngEndpoint") => ConfigStatusTone.Warning, + _ => ConfigStatusTone.Neutral, + }; + + public string? GetCurrentProviderSupportText() + => CurrentBackendValue switch + { + "brave" when HasPersistedSecret("Search.BraveApiKey") => "Existing key is configured. Leave blank to keep it.", + "searxng" => "Enter the base URL of your SearXNG instance.", + _ => null, + }; + + public bool HasPersistedSecret(string path) => _session.HasPersistedSecret(path); + + public void BeginBackendSelection() + { + CurrentScreen.Value = SearchConfigEditorScreen.Summary; + RequestRedraw(); + } + + public void SelectBackendForEditing(string backend) + { + SetFieldValue("Search.Backend", backend); + CurrentScreen.Value = SearchConfigEditorScreen.Summary; + RequestRedraw(); + } + + public void ReturnToSummary() + { + CurrentScreen.Value = SearchConfigEditorScreen.Summary; + RequestRedraw(); + } + public void DismissDialog() { ActiveDialog.Value = SearchConfigEditorDialog.None; @@ -147,6 +238,7 @@ public void DismissDialog() public async Task TestCurrentConfigurationAsync(CancellationToken ct = default) { + CommitCurrentProviderDraft(); Revalidate(); if (_lastStructuralValidation.HasErrors) { @@ -164,6 +256,7 @@ public async Task TestCurrentConfigurationAsync(CancellationToken ct = default) public async Task SaveAsync(CancellationToken ct = default) { + CommitCurrentProviderDraft(); Revalidate(); if (_lastStructuralValidation.HasErrors) { @@ -191,6 +284,7 @@ public void SaveWithoutProbeOverride() _session.Save(); Revalidate(); ActiveDialog.Value = SearchConfigEditorDialog.None; + CurrentScreen.Value = SearchConfigEditorScreen.Summary; Status.Value = new ConfigStatusMessage("Saved Search settings.", ConfigStatusTone.Success); RequestRedraw(); } @@ -225,6 +319,12 @@ private void Revalidate() ValidationSummary.Value = _lastStructuralValidation; } + private void ClearTransientProbeState() + { + _lastProbeResult = null; + Status.Value = new ConfigStatusMessage(string.Empty, ConfigStatusTone.Neutral); + } + private ConfigValidationSummary ValidateDraft() { var draft = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath); @@ -329,6 +429,14 @@ private async Task<SearchProbeResult> ProbeAsync(CancellationToken ct) private HttpClient CreateHttpClient() => _httpClientFactory?.CreateClient() ?? new HttpClient(); + private bool HasEffectiveValue(string path) + => !string.IsNullOrWhiteSpace(_session.GetEffectiveString(path)); + + private ProjectedConfigField GetField(string path) + => _fieldsByPath.TryGetValue(path, out var field) + ? field + : throw new InvalidOperationException($"Unknown search config field '{path}'."); + private static string? MapSchemaInstanceLocationToField(string? instanceLocation) { if (string.IsNullOrWhiteSpace(instanceLocation)) diff --git a/src/Netclaw.Cli/Tui/ConfigDashboardPage.cs b/src/Netclaw.Cli/Tui/ConfigDashboardPage.cs index 05bf3ac6c..be677c457 100644 --- a/src/Netclaw.Cli/Tui/ConfigDashboardPage.cs +++ b/src/Netclaw.Cli/Tui/ConfigDashboardPage.cs @@ -27,14 +27,7 @@ protected override void OnBound() public override ILayoutNode BuildLayout() { - return Layouts.Vertical() - .WithChild( - new PanelNode() - .WithTitle("Netclaw Config") - .WithBorder(BorderStyle.Rounded) - .WithBorderColor(Color.Cyan) - .WithContent(BuildInnerLayout()) - .Fill()); + return NetclawTuiChrome.BuildPageFrame("Netclaw Config", BuildInnerLayout()); } private ILayoutNode BuildInnerLayout() @@ -81,18 +74,14 @@ private ILayoutNode BuildList() private LayoutNode BuildStatusBar() { return ViewModel.StatusMessage - .Select(msg => (ILayoutNode)(string.IsNullOrWhiteSpace(msg) - ? Layouts.Empty() - : new TextNode($" {msg}").WithForeground(Color.Yellow))) + .Select(msg => NetclawTuiChrome.BuildStatusLine(msg, Color.Yellow)) .AsLayout() .Height(1); } private LayoutNode BuildKeyBindings() { - return new TextNode(" [↑/↓] Navigate [Enter] Select [Esc] Quit [Ctrl+Q] Quit") - .WithForeground(Color.BrightBlack) - .Height(1); + return NetclawTuiChrome.BuildKeyHintLine(" [↑/↓] Navigate [Enter] Select [Esc] Quit [Ctrl+Q] Quit"); } private void HandleKeyPress(KeyPressed key) diff --git a/src/Netclaw.Cli/Tui/ModelManagerPage.cs b/src/Netclaw.Cli/Tui/ModelManagerPage.cs index 5dcc9dba0..5656cda04 100644 --- a/src/Netclaw.Cli/Tui/ModelManagerPage.cs +++ b/src/Netclaw.Cli/Tui/ModelManagerPage.cs @@ -44,14 +44,7 @@ protected override void OnBound() public override ILayoutNode BuildLayout() { - return Layouts.Vertical() - .WithChild( - new PanelNode() - .WithTitle("Model Manager") - .WithBorder(BorderStyle.Rounded) - .WithBorderColor(Color.Cyan) - .WithContent(BuildInnerLayout()) - .Fill()); + return NetclawTuiChrome.BuildPageFrame("Model Manager", BuildInnerLayout()); } private ILayoutNode BuildInnerLayout() @@ -92,9 +85,7 @@ private LayoutNode BuildContent() private LayoutNode BuildStatusBar() { return ViewModel.StatusMessage - .Select(msg => (ILayoutNode)(string.IsNullOrWhiteSpace(msg) - ? Layouts.Empty() - : new TextNode($" {msg}").WithForeground(Color.Green))) + .Select(msg => NetclawTuiChrome.BuildStatusLine(msg, Color.Green)) .AsLayout() .Height(1); } @@ -113,7 +104,7 @@ private LayoutNode BuildKeyBindings() _ => " [\u2191/\u2193] Navigate [Enter] Select [Esc] Back [Ctrl+Q] Quit" }; - return (ILayoutNode)new TextNode(text).WithForeground(Color.BrightBlack); + return (ILayoutNode)NetclawTuiChrome.BuildKeyHintLine(text); }) .AsLayout() .Height(1); @@ -258,12 +249,7 @@ private ILayoutNode BuildDiscoverModels() .WithForeground(Color.White)) .WithChild(new TextNode("").Height(1)) .WithChild(new TextNode(" Enter model ID:").WithForeground(Color.White)) - .WithChild(new PanelNode() - .WithTitle("Model ID") - .WithBorder(BorderStyle.Rounded) - .WithBorderColor(Color.Gray) - .WithContent(_manualModelInput) - .Height(3)); + .WithChild(NetclawTuiChrome.BuildTextInputPanel(_manualModelInput, "Model ID")); } // Build model list with manual entry option diff --git a/src/Netclaw.Cli/Tui/NetclawTuiChrome.cs b/src/Netclaw.Cli/Tui/NetclawTuiChrome.cs new file mode 100644 index 000000000..4e42853d4 --- /dev/null +++ b/src/Netclaw.Cli/Tui/NetclawTuiChrome.cs @@ -0,0 +1,38 @@ +// ----------------------------------------------------------------------- +// <copyright file="NetclawTuiChrome.cs" company="Petabridge, LLC"> +// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com> +// </copyright> +// ----------------------------------------------------------------------- +using Termina.Layout; +using Termina.Rendering; +using Termina.Terminal; + +namespace Netclaw.Cli.Tui; + +internal static class NetclawTuiChrome +{ + internal static ILayoutNode BuildPageFrame(string title, ILayoutNode content, Color? borderColor = null) + => Layouts.Vertical() + .WithChild(BuildPanel(title, content, borderColor ?? Color.Cyan).Fill()); + + internal static PanelNode BuildPanel(string title, ILayoutNode content, Color borderColor) + => new PanelNode() + .WithTitle(title) + .WithBorder(BorderStyle.Rounded) + .WithBorderColor(borderColor) + .WithContent(content); + + internal static LayoutNode BuildTextInputPanel(TextInputNode input, string title) + => BuildPanel(title, input, Color.Gray) + .Height(3); + + internal static ILayoutNode BuildStatusLine(string? text, Color color) + => string.IsNullOrWhiteSpace(text) + ? Layouts.Empty() + : new TextNode($" {text}").WithForeground(color); + + internal static LayoutNode BuildKeyHintLine(string text) + => new TextNode(text) + .WithForeground(Color.BrightBlack) + .Height(1); +} diff --git a/src/Netclaw.Cli/Tui/ProviderManagerPage.cs b/src/Netclaw.Cli/Tui/ProviderManagerPage.cs index c0dc5628c..76bd59193 100644 --- a/src/Netclaw.Cli/Tui/ProviderManagerPage.cs +++ b/src/Netclaw.Cli/Tui/ProviderManagerPage.cs @@ -56,14 +56,7 @@ protected override void OnBound() public override ILayoutNode BuildLayout() { - return Layouts.Vertical() - .WithChild( - new PanelNode() - .WithTitle("Provider Manager") - .WithBorder(BorderStyle.Rounded) - .WithBorderColor(Color.Cyan) - .WithContent(BuildInnerLayout()) - .Fill()); + return NetclawTuiChrome.BuildPageFrame("Provider Manager", BuildInnerLayout()); } private ILayoutNode BuildInnerLayout() @@ -135,11 +128,9 @@ private LayoutNode BuildStatusBar() // validation feedback immediately. return ViewModel.ErrorMessage .CombineLatest(ViewModel.StatusMessage, (err, status) => (err, status)) - .Select(t => (ILayoutNode)(!string.IsNullOrWhiteSpace(t.err) - ? new TextNode($" {t.err}").WithForeground(Color.Red) - : !string.IsNullOrWhiteSpace(t.status) - ? new TextNode($" {t.status}").WithForeground(Color.Green) - : Layouts.Empty())) + .Select(t => !string.IsNullOrWhiteSpace(t.err) + ? NetclawTuiChrome.BuildStatusLine(t.err, Color.Red) + : NetclawTuiChrome.BuildStatusLine(t.status, Color.Green)) .AsLayout() .Height(1); } @@ -172,7 +163,7 @@ private LayoutNode BuildKeyBindings() _ => " [\u2191/\u2193] Navigate [Enter] Next [Esc] Back [Ctrl+Q] Quit" }; - return (ILayoutNode)new TextNode(text).WithForeground(Color.BrightBlack); + return (ILayoutNode)NetclawTuiChrome.BuildKeyHintLine(text); }) .AsLayout() .Height(1); @@ -339,12 +330,7 @@ private ILayoutNode BuildAddNameView() }) .DisposeWith(_stepSubs); - children.WithChild(new PanelNode() - .WithTitle("Name") - .WithBorder(BorderStyle.Rounded) - .WithBorderColor(Color.Gray) - .WithContent(_nameInput) - .Height(3)); + children.WithChild(NetclawTuiChrome.BuildTextInputPanel(_nameInput, "Name")); children.WithChild(new TextNode("").Height(1)); children.WithChild(new TextNode(" This is how the provider appears in `netclaw provider list`") @@ -412,12 +398,7 @@ private ILayoutNode BuildCredentialsView() }) .DisposeWith(_stepSubs); - children.WithChild(new PanelNode() - .WithTitle("API Key") - .WithBorder(BorderStyle.Rounded) - .WithBorderColor(Color.Gray) - .WithContent(_apiKeyInput) - .Height(3)); + children.WithChild(NetclawTuiChrome.BuildTextInputPanel(_apiKeyInput, "API Key")); if (descriptor.Auth.GetApiKeyGuidanceUrl() is { } guidanceUrl) { @@ -445,12 +426,7 @@ private ILayoutNode BuildCredentialsView() }) .DisposeWith(_stepSubs); - children.WithChild(new PanelNode() - .WithTitle("Endpoint") - .WithBorder(BorderStyle.Rounded) - .WithBorderColor(Color.Gray) - .WithContent(_endpointInput) - .Height(3)); + children.WithChild(NetclawTuiChrome.BuildTextInputPanel(_endpointInput, "Endpoint")); children.WithChild(new TextNode("").Height(1)); children.WithChild(new TextNode($" {descriptor.DisplayName} runs locally. No authentication required.") @@ -682,12 +658,7 @@ private ILayoutNode BuildRenameView() .Subscribe(text => ViewModel.ConfirmRename(text)) .DisposeWith(_stepSubs); - children.WithChild(new PanelNode() - .WithTitle("New name") - .WithBorder(BorderStyle.Rounded) - .WithBorderColor(Color.Gray) - .WithContent(_renameInput) - .Height(3)); + children.WithChild(NetclawTuiChrome.BuildTextInputPanel(_renameInput, "New name")); children.WithChild(new TextNode("").Height(1)); children.WithChild(new TextNode(" Renames the provider and cascades the change to any model") @@ -763,12 +734,7 @@ private ILayoutNode BuildFixCredentialsView() }) .DisposeWith(_stepSubs); - children.WithChild(new PanelNode() - .WithTitle("Endpoint") - .WithBorder(BorderStyle.Rounded) - .WithBorderColor(Color.Gray) - .WithContent(_endpointInput) - .Height(3)); + children.WithChild(NetclawTuiChrome.BuildTextInputPanel(_endpointInput, "Endpoint")); } else { @@ -790,12 +756,7 @@ private ILayoutNode BuildFixCredentialsView() }) .DisposeWith(_stepSubs); - children.WithChild(new PanelNode() - .WithTitle("API Key") - .WithBorder(BorderStyle.Rounded) - .WithBorderColor(Color.Gray) - .WithContent(_apiKeyInput) - .Height(3)); + children.WithChild(NetclawTuiChrome.BuildTextInputPanel(_apiKeyInput, "API Key")); if (descriptor.Auth.GetApiKeyGuidanceUrl() is { } guidanceUrl) { diff --git a/src/Netclaw.Cli/Tui/Wizard/Steps/WizardStepHelpers.cs b/src/Netclaw.Cli/Tui/Wizard/Steps/WizardStepHelpers.cs index 4b00531dc..990d8cb6c 100644 --- a/src/Netclaw.Cli/Tui/Wizard/Steps/WizardStepHelpers.cs +++ b/src/Netclaw.Cli/Tui/Wizard/Steps/WizardStepHelpers.cs @@ -4,6 +4,7 @@ // </copyright> // ----------------------------------------------------------------------- using R3; +using Netclaw.Cli.Tui; using Termina.Extensions; using Termina.Layout; using Termina.Reactive; @@ -46,12 +47,7 @@ internal static (SelectionListNode<SelectionOption<bool>> List, ILayoutNode Layo } internal static ILayoutNode BuildTextInputPanel(TextInputNode input, string title) - => new PanelNode() - .WithTitle(title) - .WithBorder(BorderStyle.Rounded) - .WithBorderColor(Color.Gray) - .WithContent(input) - .Height(3); + => NetclawTuiChrome.BuildTextInputPanel(input, title); internal static List<string> ParseUserIds(string? input) => string.IsNullOrWhiteSpace(input) diff --git a/tests/smoke/assertions/config-search.sh b/tests/smoke/assertions/config-search.sh new file mode 100755 index 000000000..87b510438 --- /dev/null +++ b/tests/smoke/assertions/config-search.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# config-search.tape post-tape assertion. +# +# Validates the redesigned Search flow persisted the expected DuckDuckGo +# backend back into netclaw.json and that no Brave API key leaked into the +# main config file. + +set -euo pipefail + +. "$(dirname "$0")/_lib.sh" + +assert_fail=0 + +echo "config-search: reading produced config..." +if [[ ! -f "$CONFIG_PATH" ]]; then + echo "FAIL: ${CONFIG_PATH} does not exist." >&2 + exit 1 +fi + +config_json="$(read_config_json)" + +assert_field '.Search.Backend' 'duckduckgo' "$config_json" || : +assert_field '(.Search | has("BraveApiKey"))' 'false' "$config_json" || : + +if (( assert_fail )); then + printf -- '--- netclaw.json contents ---\n%s\n' "$config_json" >&2 + exit 1 +fi + +echo "config-search: assertions passed." diff --git a/tests/smoke/tapes/config-search.tape b/tests/smoke/tapes/config-search.tape new file mode 100644 index 000000000..9e0ee90fe --- /dev/null +++ b/tests/smoke/tapes/config-search.tape @@ -0,0 +1,75 @@ +# config-search.tape — drive `netclaw config` into the redesigned Search flow. +# +# Covers: +# - dashboard -> Search provider matrix +# - provider selection happens directly from arrow navigation +# - invalid save blocked on missing Brave API key +# - provider switch back to DuckDuckGo from the same screen +# - exit Search, re-enter it, and confirm test + SearXNG entry still work +# +# Post-tape assertion validates the Search section persisted to netclaw.json. + +Output "/tmp/tape-config-search.gif" + +# ─── Seed minimal config so `netclaw config` can launch ──────────────────── +Type "mkdir -p $NETCLAW_HOME/config" +Enter +Type "backend=duckduckgo; jq -n --arg backend $backend '{configVersion:1,Search:{Backend:$backend}}' > $NETCLAW_HOME/config/netclaw.json" +Enter + +# ─── Launch ──────────────────────────────────────────────────────────────── +Type "netclaw config" +Enter + +# ─── Dashboard -> Search ────────────────────────────────────────────────── +Wait+Screen@10s /Settings Areas/ +# Search is row 6 in the dashboard list. +Down 5 +Enter + +# ─── Search provider matrix ─────────────────────────────────────────────── +Wait+Screen@10s /Providers/ +Wait+Screen@5s /No additional setup required/ + +# ─── Select Brave in-place and confirm invalid save stays inline ────────── +Down +Wait+Screen@10s /Brave API key/ +Wait+Screen@5s /Brave API key/ +Type "s" +Wait+Screen@10s /Brave requires an API key/ + +# ─── Switch provider back to DuckDuckGo ─────────────────────────────────── +Up +Wait+Screen@10s /\(\*\) DuckDuckGo/ + +# ─── Leave Search, then re-enter to validate preserved-page lifecycle ───── +Escape +Wait+Screen@10s /Settings Areas/ +Down 5 +Enter +Wait+Screen@10s /\(\*\) DuckDuckGo/ +Type "t" +Wait+Screen@10s /Last test:/ +Down 2 +Wait+Screen@10s /SearXng instance URL/ +Wait+Screen@5s /Enter the base URL of your SearXNG instance/ +Enter +Wait+Screen@10s /Press Enter to apply or Esc to cancel edit\./ +Type "https://search.test.local" +Enter +Wait+Screen@10s /https:\/\/search.test.local/ +Escape +Wait+Screen@10s /Unsaved changes\./ +Escape +Wait+Screen@10s /Settings Areas/ + +# ─── Back out to shell ──────────────────────────────────────────────────── +Ctrl+Q +Wait+Screen@10s /TAPE\$/ + +Type "echo CONFIG_SEARCH_EXIT=$?" +Enter +Wait+Screen@5s /CONFIG_SEARCH_EXIT=0/ + +Type "exit" +Enter diff --git a/tests/smoke/tapes/screenshots/config-search.tape b/tests/smoke/tapes/screenshots/config-search.tape new file mode 100644 index 000000000..c47f7741e --- /dev/null +++ b/tests/smoke/tapes/screenshots/config-search.tape @@ -0,0 +1,50 @@ +# config-search.tape (screenshot) — capture the redesigned Search flow screens. +# +# Frames captured: +# shot-config-search-matrix +# shot-config-search-brave +# shot-config-search-searxng-edit + +Output "/tmp/tape-shot-config-search.gif" + +# ─── Seed minimal config so `netclaw config` can launch ──────────────────── +Type "mkdir -p $NETCLAW_HOME/config" +Enter +Type "backend=duckduckgo; jq -n --arg backend $backend '{configVersion:1,Search:{Backend:$backend}}' > $NETCLAW_HOME/config/netclaw.json" +Enter + +# ─── Launch ──────────────────────────────────────────────────────────────── +Type "netclaw config" +Enter + +Wait+Screen@10s /Settings Areas/ +Down 5 +Enter + +# ─── Frame 1: Default provider matrix ────────────────────────────────────── +Wait+Screen@10s /\(\*\) DuckDuckGo/ +Sleep 1s +Screenshot "/tmp/shot-config-search-matrix.png" +Sleep 1s + +# ─── Frame 2: Brave selected in matrix ───────────────────────────────────── +Down +Wait+Screen@10s /Brave API key/ +Sleep 1s +Screenshot "/tmp/shot-config-search-brave.png" +Sleep 1s + +# ─── Frame 3: SearXNG inline edit mode ───────────────────────────────────── +Down +Wait+Screen@10s /SearXng instance URL/ +Enter +Wait+Screen@10s /Press Enter to apply or Esc to cancel edit\./ +Sleep 1s +Screenshot "/tmp/shot-config-search-searxng-edit.png" +Sleep 1s + +Ctrl+Q +Wait+Screen@10s /TAPE\$/ + +Type "exit" +Enter From 64905262be1d352d1ed4c5f03b0b0244e0ca02ab Mon Sep 17 00:00:00 2001 From: Aaron Stannard <aaron@petabridge.com> Date: Tue, 26 May 2026 21:13:19 +0000 Subject: [PATCH 09/31] refine(config): introduce typed search editor model --- .../Tui/Config/SearchConfigEditorPage.cs | 17 +- .../Tui/Config/SearchConfigEditorViewModel.cs | 423 ++++++++++-------- .../Tui/Config/SearchEditorModel.cs | 167 +++++++ 3 files changed, 398 insertions(+), 209 deletions(-) create mode 100644 src/Netclaw.Cli/Tui/Config/SearchEditorModel.cs diff --git a/src/Netclaw.Cli/Tui/Config/SearchConfigEditorPage.cs b/src/Netclaw.Cli/Tui/Config/SearchConfigEditorPage.cs index 3a93781c5..dc5c03b7e 100644 --- a/src/Netclaw.Cli/Tui/Config/SearchConfigEditorPage.cs +++ b/src/Netclaw.Cli/Tui/Config/SearchConfigEditorPage.cs @@ -174,17 +174,13 @@ private ILayoutNode BuildEditingFieldLayout(ProjectedConfigField field) _textInput.Submitted .Subscribe(text => { - if (field.Path == "Search.BraveApiKey" - && string.IsNullOrWhiteSpace(text) - && !ViewModel.HasPersistedSecret(field.Path)) + var result = ViewModel.CommitField(field.Path, text); + if (!result.Success) { - ViewModel.Status.Value = new ConfigStatusMessage("Brave requires an API key.", ConfigStatusTone.Error); ViewModel.RequestRedraw(); return; } - ViewModel.StageFieldValue(field.Path, text); - ViewModel.CommitFieldValue(field.Path); _editingFieldPath = null; _editSeed = string.Empty; _focusTarget = SearchFocusTarget.ProviderList; @@ -365,14 +361,7 @@ public override bool HandlePageInput(ConsoleKeyInfo keyInfo) _dialogList?.HandleInput(keyInfo); break; case SearchFocusTarget.FieldInput when _textInput is not null: - var fieldPath = _editingFieldPath; _textInput.HandleInput(keyInfo); - if (_focusTarget == SearchFocusTarget.FieldInput - && !string.IsNullOrWhiteSpace(fieldPath)) - { - ViewModel.StageFieldValue(fieldPath, _textInput.Text); - } - break; default: if (keyInfo.Key == ConsoleKey.UpArrow) @@ -441,7 +430,7 @@ private bool IsEditingField(ProjectedConfigField field) private void CancelActiveEdit() { if (_editingFieldPath is { } path) - ViewModel.StageFieldValue(path, _editSeed); + ViewModel.CommitField(path, _editSeed); _editingFieldPath = null; _editSeed = string.Empty; diff --git a/src/Netclaw.Cli/Tui/Config/SearchConfigEditorViewModel.cs b/src/Netclaw.Cli/Tui/Config/SearchConfigEditorViewModel.cs index 53dd308e2..c38d9ea8a 100644 --- a/src/Netclaw.Cli/Tui/Config/SearchConfigEditorViewModel.cs +++ b/src/Netclaw.Cli/Tui/Config/SearchConfigEditorViewModel.cs @@ -4,10 +4,6 @@ // </copyright> // ----------------------------------------------------------------------- using System.Net.Http; -using System.Text.Json; -using System.Text.Json.Nodes; -using Json.Schema; -using Netclaw.Cli.Config; using Netclaw.Configuration; using Netclaw.Search; using R3; @@ -24,23 +20,60 @@ internal enum SearchConfigEditorDialog internal enum SearchConfigEditorScreen { Summary, - ChooseProvider, - ConfigureProvider, } internal sealed record SearchProbeResult(bool Success, string Message, ConfigStatusTone Tone); +internal sealed record SearchFieldCommitResult(bool Success, IReadOnlyList<SearchEditorValidationIssue> Issues) +{ + public static readonly SearchFieldCommitResult Ok = new(true, []); + + public static SearchFieldCommitResult Invalid(IReadOnlyList<SearchEditorValidationIssue> issues) + => new(false, issues); +} + internal sealed class SearchConfigEditorViewModel : ReactiveViewModel { private readonly NetclawPaths _paths; - private readonly ConfigSectionEditSession _session; - private readonly JsonSchema _schema; + private readonly SearchEditorPersistenceMapper _mapper; + private readonly SearchEditorValidationAdapter _validator; private readonly IHttpClientFactory? _httpClientFactory; private readonly TimeProvider _timeProvider; - private readonly Dictionary<string, ProjectedConfigField> _fieldsByPath; - private ConfigValidationSummary _lastStructuralValidation = ConfigValidationSummary.Empty; + private SearchEditorModel _model; + private SearchEditorValidationResult _validation = SearchEditorValidationResult.Empty; private SearchProbeResult? _lastProbeResult; + public IReadOnlyList<ProjectedConfigField> Fields { get; } = + [ + new( + Path: "Search.Backend", + PropertyName: "Backend", + Label: "Backend", + Description: "Search backend identifier.", + ValueKind: ConfigFieldValueKind.String, + Storage: ConfigFieldStorage.ConfigFile, + Widget: ConfigFieldWidget.EnumSelection, + Nullable: false, + DefaultValue: SearchBackend.DuckDuckGo.ToWireValue(), + TrimDefaultOnSave: true, + PreserveBlankSecret: false, + Placeholder: null, + Hint: "Choose your web search provider.", + ApplicableWhenPath: null, + ApplicableWhenEquals: null, + InactiveText: null, + EnumOptions: + [ + new("duckduckgo", "DuckDuckGo"), + new("brave", "Brave"), + new("searxng", "SearXng (self-hosted)") + ]), + SearchFields.BraveApiKey, + SearchFields.SearXngEndpoint, + ]; + + public Dictionary<string, ReactiveProperty<string>> FieldValues { get; } = new(StringComparer.Ordinal); + internal Action<string>? RouteRequested { get; set; } internal bool ShutdownRequestedForTest { get; private set; } @@ -52,49 +85,46 @@ public SearchConfigEditorViewModel( _paths = paths; _httpClientFactory = httpClientFactory; _timeProvider = timeProvider ?? TimeProvider.System; + _mapper = new SearchEditorPersistenceMapper(); + _validator = new SearchEditorValidationAdapter(); + _model = _mapper.Load(paths); - var projector = new ConfigSectionSchemaProjector(); - Fields = projector.ProjectTopLevelSection("Search", SearchConfigMetadata.Fields); - _fieldsByPath = Fields.ToDictionary(static field => field.Path, StringComparer.Ordinal); - _session = new ConfigSectionEditSession(paths, Fields); - - var schemaText = EmbeddedSchemaLoader.LoadConfigSchema(EmbeddedSchemaLoader.CurrentSchemaVersion) - ?? throw new InvalidOperationException( - $"Missing embedded netclaw config schema v{EmbeddedSchemaLoader.CurrentSchemaVersion}."); - _schema = JsonSchema.FromText(schemaText); + foreach (var field in Fields) + FieldValues[field.Path] = new ReactiveProperty<string>(GetCurrentFieldValue(field.Path)); - SelectedIndex = new ReactiveProperty<int>(0); Status = new ReactiveProperty<ConfigStatusMessage>(new ConfigStatusMessage(string.Empty, ConfigStatusTone.Neutral)); ValidationSummary = new ReactiveProperty<ConfigValidationSummary>(ConfigValidationSummary.Empty); ActiveDialog = new ReactiveProperty<SearchConfigEditorDialog>(SearchConfigEditorDialog.None); CurrentScreen = new ReactiveProperty<SearchConfigEditorScreen>(SearchConfigEditorScreen.Summary); - - foreach (var field in Fields) - FieldValues[field.Path] = new ReactiveProperty<string>(_session.GetEditableString(field.Path)); - Revalidate(); } - public IReadOnlyList<ProjectedConfigField> Fields { get; } - public Dictionary<string, ReactiveProperty<string>> FieldValues { get; } = new(StringComparer.Ordinal); - public ReactiveProperty<int> SelectedIndex { get; } public ReactiveProperty<ConfigStatusMessage> Status { get; } public ReactiveProperty<ConfigValidationSummary> ValidationSummary { get; } public ReactiveProperty<SearchConfigEditorDialog> ActiveDialog { get; } public ReactiveProperty<SearchConfigEditorScreen> CurrentScreen { get; } - public ProjectedConfigField SelectedField => Fields[SelectedIndex.Value]; - public bool IsDirty => _session.IsDirty; + public bool IsDirty => ComputeIsDirty(); public SearchProbeResult? LastProbeResult => _lastProbeResult; - public string CurrentBackendValue => _session.GetEffectiveString("Search.Backend") ?? SearchBackend.DuckDuckGo.ToWireValue(); - public string CurrentBackendLabel => GetField("Search.Backend").EnumOptions - .FirstOrDefault(option => string.Equals(option.Value, CurrentBackendValue, StringComparison.OrdinalIgnoreCase))?.Label - ?? CurrentBackendValue; - public IReadOnlyList<ConfigEnumOption> BackendOptions => GetField("Search.Backend").EnumOptions; - public ProjectedConfigField? CurrentProviderField => CurrentBackendValue switch + public string CurrentBackendValue => _model.Backend.ToWireValue(); + public string CurrentBackendLabel => _model.Backend switch + { + SearchBackend.Brave => "Brave", + SearchBackend.SearXng => "SearXng (self-hosted)", + _ => "DuckDuckGo", + }; + + public IReadOnlyList<ConfigEnumOption> BackendOptions { get; } = + [ + new("duckduckgo", "DuckDuckGo"), + new("brave", "Brave"), + new("searxng", "SearXng (self-hosted)") + ]; + + public ProjectedConfigField? CurrentProviderField => _model.Backend switch { - "brave" => GetField("Search.BraveApiKey"), - "searxng" => GetField("Search.SearXngEndpoint"), + SearchBackend.Brave => SearchFields.BraveApiKey, + SearchBackend.SearXng => SearchFields.SearXngEndpoint, _ => null, }; @@ -103,7 +133,6 @@ public override void Dispose() foreach (var value in FieldValues.Values) value.Dispose(); - SelectedIndex.Dispose(); Status.Dispose(); ValidationSummary.Dispose(); ActiveDialog.Dispose(); @@ -111,73 +140,37 @@ public override void Dispose() base.Dispose(); } - public void MoveSelection(int delta) - { - if (Fields.Count == 0) - return; - - var next = Math.Clamp(SelectedIndex.Value + delta, 0, Fields.Count - 1); - if (next != SelectedIndex.Value) - SelectedIndex.Value = next; - } - - public void SetFieldValue(string path, string? value) + public SearchFieldCommitResult CommitField(string path, string? value) { - StageFieldValue(path, value); - CommitFieldValue(path); - } - - public void StageFieldValue(string path, string? value) - { - if (!FieldValues.TryGetValue(path, out var property)) - throw new InvalidOperationException($"Unknown search config field '{path}'."); - - property.Value = value ?? string.Empty; - } - - public void CommitFieldValue(string path) - { - if (!FieldValues.TryGetValue(path, out var property)) - throw new InvalidOperationException($"Unknown search config field '{path}'."); - - _session.SetValue(path, property.Value); + ApplyFieldValue(path, value); + SyncFieldValue(path); ClearTransientProbeState(); Revalidate(); - RequestRedraw(); - } - public void CommitCurrentProviderDraft() - { - if (CurrentProviderField is { } field) - CommitFieldValue(field.Path); - } - - public string GetDisplayValue(ProjectedConfigField field) - { - if (field.Widget == ConfigFieldWidget.PasswordInput) + var issues = _validation.IssuesFor(path); + if (issues.Count > 0) { - var edited = _session.GetEditableString(field.Path); - if (!string.IsNullOrWhiteSpace(edited)) - return "(new secret entered)"; - if (_session.HasPersistedSecret(field.Path)) - return "(stored secret preserved)"; - return field.InactiveText ?? string.Empty; + Status.Value = new ConfigStatusMessage(issues[0].Message, ConfigStatusTone.Error); + RequestRedraw(); + return SearchFieldCommitResult.Invalid(issues); } - var current = _session.GetEditableString(field.Path); - if (!string.IsNullOrWhiteSpace(current)) - return current; - - return field.DefaultValue?.ToString() ?? string.Empty; + Status.Value = new ConfigStatusMessage(string.Empty, ConfigStatusTone.Neutral); + RequestRedraw(); + return SearchFieldCommitResult.Ok; } - public string GetEditorSeed(ProjectedConfigField field) - => FieldValues[field.Path].Value; - - public bool IsApplicable(ProjectedConfigField field) => _session.IsApplicable(field); + public string GetDisplayValue(ProjectedConfigField field) + => field.Path switch + { + "Search.BraveApiKey" when !string.IsNullOrWhiteSpace(_model.Brave.ApiKeyDraft) => "(new secret entered)", + "Search.BraveApiKey" when _model.Brave.HasPersistedApiKey => "(stored secret preserved)", + "Search.SearXngEndpoint" when !string.IsNullOrWhiteSpace(_model.SearXng.Endpoint) => _model.SearXng.Endpoint!, + _ => field.InactiveText ?? string.Empty, + }; - public string GetInactiveText(ProjectedConfigField field) - => field.InactiveText ?? "(not applicable)"; + public string GetEditorSeed(ProjectedConfigField field) + => GetCurrentFieldValue(field.Path); public IReadOnlyList<ConfigValidationIssue> GetIssues(ProjectedConfigField field) => ValidationSummary.Value.IssuesFor(field.Path); @@ -186,30 +179,56 @@ public IReadOnlyList<ConfigValidationIssue> GetCurrentProviderIssues() => CurrentProviderField is { } field ? ValidationSummary.Value.IssuesFor(field.Path) : []; public string GetSummaryStateText() - => CurrentBackendValue switch + => _model.Backend switch { - "brave" => HasEffectiveValue("Search.BraveApiKey") ? "API key configured." : "API key required.", - "searxng" => HasEffectiveValue("Search.SearXngEndpoint") ? "Endpoint configured." : "Endpoint required.", + SearchBackend.Brave => HasEffectiveBraveKey() ? "API key configured." : "API key required.", + SearchBackend.SearXng => !string.IsNullOrWhiteSpace(_model.SearXng.Endpoint) ? "Endpoint configured." : "Endpoint required.", _ => "No additional setup required." }; public ConfigStatusTone GetSummaryStateTone() - => CurrentBackendValue switch + => _model.Backend switch { - "brave" when !HasEffectiveValue("Search.BraveApiKey") => ConfigStatusTone.Warning, - "searxng" when !HasEffectiveValue("Search.SearXngEndpoint") => ConfigStatusTone.Warning, + SearchBackend.Brave when !HasEffectiveBraveKey() => ConfigStatusTone.Warning, + SearchBackend.SearXng when string.IsNullOrWhiteSpace(_model.SearXng.Endpoint) => ConfigStatusTone.Warning, _ => ConfigStatusTone.Neutral, }; public string? GetCurrentProviderSupportText() - => CurrentBackendValue switch + => _model.Backend switch { - "brave" when HasPersistedSecret("Search.BraveApiKey") => "Existing key is configured. Leave blank to keep it.", - "searxng" => "Enter the base URL of your SearXNG instance.", + SearchBackend.Brave when _model.Brave.HasPersistedApiKey => "Existing key is configured. Leave blank to keep it.", + SearchBackend.SearXng => "Enter the base URL of your SearXNG instance.", _ => null, }; - public bool HasPersistedSecret(string path) => _session.HasPersistedSecret(path); + public bool HasPersistedSecret(string path) + => string.Equals(path, "Search.BraveApiKey", StringComparison.Ordinal) && _model.Brave.HasPersistedApiKey; + + public void SetFieldValue(string path, string? value) + => CommitField(path, value); + + public void StageFieldValue(string path, string? value) + { + if (!FieldValues.TryGetValue(path, out var property)) + throw new InvalidOperationException($"Unknown search config field '{path}'."); + + property.Value = value ?? string.Empty; + } + + public void CommitFieldValue(string path) + { + if (!FieldValues.TryGetValue(path, out var property)) + throw new InvalidOperationException($"Unknown search config field '{path}'."); + + CommitField(path, property.Value); + } + + public void CommitCurrentProviderDraft() + { + if (CurrentProviderField is { } field) + CommitFieldValue(field.Path); + } public void BeginBackendSelection() { @@ -219,7 +238,7 @@ public void BeginBackendSelection() public void SelectBackendForEditing(string backend) { - SetFieldValue("Search.Backend", backend); + CommitField("Search.Backend", backend); CurrentScreen.Value = SearchConfigEditorScreen.Summary; RequestRedraw(); } @@ -238,9 +257,8 @@ public void DismissDialog() public async Task TestCurrentConfigurationAsync(CancellationToken ct = default) { - CommitCurrentProviderDraft(); Revalidate(); - if (_lastStructuralValidation.HasErrors) + if (_validation.HasErrors) { Status.Value = new ConfigStatusMessage( "Fix structural validation errors before testing this search configuration.", @@ -256,9 +274,8 @@ public async Task TestCurrentConfigurationAsync(CancellationToken ct = default) public async Task SaveAsync(CancellationToken ct = default) { - CommitCurrentProviderDraft(); Revalidate(); - if (_lastStructuralValidation.HasErrors) + if (_validation.HasErrors) { Status.Value = new ConfigStatusMessage( "Fix structural validation errors before saving.", @@ -281,7 +298,9 @@ public async Task SaveAsync(CancellationToken ct = default) public void SaveWithoutProbeOverride() { - _session.Save(); + _mapper.Save(_paths, _model); + _model = _mapper.Load(_paths); + SyncAllFieldValues(); Revalidate(); ActiveDialog.Value = SearchConfigEditorDialog.None; CurrentScreen.Value = SearchConfigEditorScreen.Summary; @@ -291,10 +310,8 @@ public void SaveWithoutProbeOverride() public void ResetDraft() { - _session.ResetDraft(); - foreach (var field in Fields) - FieldValues[field.Path].Value = _session.GetEditableString(field.Path); - + _model = _mapper.Load(_paths); + SyncAllFieldValues(); _lastProbeResult = null; Revalidate(); Status.Value = new ConfigStatusMessage("Reverted unsaved Search edits.", ConfigStatusTone.Neutral); @@ -315,8 +332,9 @@ public void RequestQuit() private void Revalidate() { - _lastStructuralValidation = ValidateDraft(); - ValidationSummary.Value = _lastStructuralValidation; + _validation = _validator.Validate(_model); + ValidationSummary.Value = new ConfigValidationSummary( + _validation.Issues.Select(static issue => new ConfigValidationIssue(issue.FieldId, issue.Severity, issue.Message)).ToList()); } private void ClearTransientProbeState() @@ -325,88 +343,57 @@ private void ClearTransientProbeState() Status.Value = new ConfigStatusMessage(string.Empty, ConfigStatusTone.Neutral); } - private ConfigValidationSummary ValidateDraft() + private void ApplyFieldValue(string path, string? value) { - var draft = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath); - foreach (var field in Fields) + switch (path) { - var value = _session.GetValue(field.Path); - var shouldRemove = value is null - || field.ValueKind == ConfigFieldValueKind.String && string.IsNullOrWhiteSpace(value.ToString()) - || field.TrimDefaultOnSave && Equals(value?.ToString(), field.DefaultValue?.ToString()); - - if (shouldRemove) - ConfigFileHelper.RemovePath(draft, field.Path); - else - ConfigFileHelper.SetPathValue(draft, field.Path, value); + case "Search.Backend": + _model.Backend = ParseBackend(value); + break; + case "Search.BraveApiKey": + _model.Brave.ApiKeyDraft = Normalize(value); + break; + case "Search.SearXngEndpoint": + _model.SearXng.Endpoint = Normalize(value); + break; + default: + throw new InvalidOperationException($"Unknown search config field '{path}'."); } + } - draft["configVersion"] = EmbeddedSchemaLoader.CurrentSchemaVersion; - var node = JsonSerializer.SerializeToNode(draft) as JsonObject - ?? throw new InvalidOperationException("Search config draft did not serialize to a JSON object."); - - var evaluation = _schema.Evaluate(node, new EvaluationOptions + private string GetCurrentFieldValue(string path) + => path switch { - OutputFormat = OutputFormat.List, - }); + "Search.Backend" => _model.Backend.ToWireValue(), + "Search.BraveApiKey" => _model.Brave.ApiKeyDraft ?? string.Empty, + "Search.SearXngEndpoint" => _model.SearXng.Endpoint ?? string.Empty, + _ => string.Empty, + }; - var issues = new List<ConfigValidationIssue>(); + private void SyncFieldValue(string path) + { + if (FieldValues.TryGetValue(path, out var property)) + property.Value = GetCurrentFieldValue(path); + } + private void SyncAllFieldValues() + { foreach (var field in Fields) - { - if (!IsApplicable(field)) - continue; - - if (field.Path == "Search.Backend" && string.IsNullOrWhiteSpace(_session.GetEffectiveString(field.Path))) - { - issues.Add(new ConfigValidationIssue(field.Path, ConfigValidationSeverity.Error, "Choose a search backend.")); - } - - if (field.Path == "Search.BraveApiKey" - && string.Equals(_session.GetEffectiveString("Search.Backend"), "brave", StringComparison.OrdinalIgnoreCase) - && string.IsNullOrWhiteSpace(_session.GetEffectiveString(field.Path))) - { - issues.Add(new ConfigValidationIssue(field.Path, ConfigValidationSeverity.Error, "Brave requires an API key.")); - } - - if (field.Path == "Search.SearXngEndpoint" - && string.Equals(_session.GetEffectiveString("Search.Backend"), "searxng", StringComparison.OrdinalIgnoreCase) - && string.IsNullOrWhiteSpace(_session.GetEffectiveString(field.Path))) - { - issues.Add(new ConfigValidationIssue(field.Path, ConfigValidationSeverity.Error, "SearXNG requires an endpoint URL.")); - } - } - - if (!evaluation.IsValid && evaluation.Details is not null) - { - foreach (var detail in evaluation.Details.Where(static d => !d.IsValid && d.Errors is not null)) - { - var path = MapSchemaInstanceLocationToField(detail.InstanceLocation?.ToString()); - if (path is null) - continue; - - var message = string.Join("; ", detail.Errors!.Select(e => $"{e.Key}: {e.Value}")); - if (!issues.Any(i => i.Path == path && string.Equals(i.Message, message, StringComparison.Ordinal))) - issues.Add(new ConfigValidationIssue(path, ConfigValidationSeverity.Error, message)); - } - } - - return issues.Count == 0 ? ConfigValidationSummary.Empty : new ConfigValidationSummary(issues); + SyncFieldValue(field.Path); } private async Task<SearchProbeResult> ProbeAsync(CancellationToken ct) { - var backend = _session.GetEffectiveString("Search.Backend") ?? SearchBackend.DuckDuckGo.ToWireValue(); try { - ISearchBackend searchBackend = backend switch + ISearchBackend searchBackend = _model.Backend switch { - "brave" => new BraveSearchBackend( - _session.GetEffectiveString("Search.BraveApiKey") ?? string.Empty, + SearchBackend.Brave => new BraveSearchBackend( + _model.Brave.ApiKeyDraft ?? string.Empty, CreateHttpClient(), _timeProvider), - "searxng" => new SearXngBackend( - _session.GetEffectiveString("Search.SearXngEndpoint") ?? string.Empty, + SearchBackend.SearXng => new SearXngBackend( + _model.SearXng.Endpoint ?? string.Empty, CreateHttpClient(), _timeProvider), _ => new DuckDuckGoBackend(CreateHttpClient(), _timeProvider), @@ -427,22 +414,68 @@ private async Task<SearchProbeResult> ProbeAsync(CancellationToken ct) } private HttpClient CreateHttpClient() - => _httpClientFactory?.CreateClient() ?? new HttpClient(); - - private bool HasEffectiveValue(string path) - => !string.IsNullOrWhiteSpace(_session.GetEffectiveString(path)); + => _httpClientFactory?.CreateClient(string.Empty) ?? new HttpClient(); - private ProjectedConfigField GetField(string path) - => _fieldsByPath.TryGetValue(path, out var field) - ? field - : throw new InvalidOperationException($"Unknown search config field '{path}'."); + private bool HasEffectiveBraveKey() + => !string.IsNullOrWhiteSpace(_model.Brave.ApiKeyDraft) || _model.Brave.HasPersistedApiKey; - private static string? MapSchemaInstanceLocationToField(string? instanceLocation) + private bool ComputeIsDirty() { - if (string.IsNullOrWhiteSpace(instanceLocation)) - return null; + var persisted = _mapper.Load(_paths); + return persisted.Backend != _model.Backend + || !string.Equals(persisted.SearXng.Endpoint, _model.SearXng.Endpoint, StringComparison.Ordinal) + || !string.IsNullOrWhiteSpace(_model.Brave.ApiKeyDraft); + } + + private static SearchBackend ParseBackend(string? value) + => value?.Trim().ToLowerInvariant() switch + { + "brave" => SearchBackend.Brave, + "searxng" => SearchBackend.SearXng, + _ => SearchBackend.DuckDuckGo, + }; + + private static string? Normalize(string? value) + => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); - var path = instanceLocation.TrimStart('/').Replace('/', '.'); - return path.StartsWith("Search.", StringComparison.Ordinal) ? path : null; + private static class SearchFields + { + internal static readonly ProjectedConfigField BraveApiKey = new( + Path: "Search.BraveApiKey", + PropertyName: "BraveApiKey", + Label: "Brave API key", + Description: "Brave Search API key. Required when Backend is Brave. Stored in secrets.json.", + ValueKind: ConfigFieldValueKind.String, + Storage: ConfigFieldStorage.SecretsFile, + Widget: ConfigFieldWidget.PasswordInput, + Nullable: true, + DefaultValue: null, + TrimDefaultOnSave: false, + PreserveBlankSecret: true, + Placeholder: "Enter Brave Search API key...", + Hint: "Stored in secrets.json. Leave blank to keep the existing key.", + ApplicableWhenPath: "Search.Backend", + ApplicableWhenEquals: "brave", + InactiveText: "(not configured)", + EnumOptions: []); + + internal static readonly ProjectedConfigField SearXngEndpoint = new( + Path: "Search.SearXngEndpoint", + PropertyName: "SearXngEndpoint", + Label: "SearXng instance URL", + Description: "SearXNG instance base URL. Required when Backend is SearXng.", + ValueKind: ConfigFieldValueKind.String, + Storage: ConfigFieldStorage.ConfigFile, + Widget: ConfigFieldWidget.TextInput, + Nullable: true, + DefaultValue: null, + TrimDefaultOnSave: true, + PreserveBlankSecret: false, + Placeholder: "https://search.example.com", + Hint: "Enter the base URL of your SearXNG instance.", + ApplicableWhenPath: "Search.Backend", + ApplicableWhenEquals: "searxng", + InactiveText: "(not configured)", + EnumOptions: []); } } diff --git a/src/Netclaw.Cli/Tui/Config/SearchEditorModel.cs b/src/Netclaw.Cli/Tui/Config/SearchEditorModel.cs new file mode 100644 index 000000000..0db87152c --- /dev/null +++ b/src/Netclaw.Cli/Tui/Config/SearchEditorModel.cs @@ -0,0 +1,167 @@ +// ----------------------------------------------------------------------- +// <copyright file="SearchEditorModel.cs" company="Petabridge, LLC"> +// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com> +// </copyright> +// ----------------------------------------------------------------------- +using Microsoft.Extensions.Options; +using Netclaw.Cli.Config; +using Netclaw.Configuration; + +namespace Netclaw.Cli.Tui.Config; + +internal sealed class SearchEditorModel +{ + public SearchBackend Backend { get; set; } = SearchBackend.DuckDuckGo; + + public BraveSearchEditorModel Brave { get; } = new(); + + public SearXngSearchEditorModel SearXng { get; } = new(); +} + +internal sealed class BraveSearchEditorModel +{ + public string? ApiKeyDraft { get; set; } + + public bool HasPersistedApiKey { get; set; } +} + +internal sealed class SearXngSearchEditorModel +{ + public string? Endpoint { get; set; } +} + +internal sealed class SearchEditorValidator : IValidateOptions<SearchEditorModel> +{ + public ValidateOptionsResult Validate(string? name, SearchEditorModel options) + { + var errors = new List<string>(); + + if (options.Backend == SearchBackend.Brave + && string.IsNullOrWhiteSpace(options.Brave.ApiKeyDraft) + && !options.Brave.HasPersistedApiKey) + { + errors.Add("Brave requires an API key."); + } + + if (options.Backend == SearchBackend.SearXng) + { + if (string.IsNullOrWhiteSpace(options.SearXng.Endpoint)) + { + errors.Add("SearXNG requires an endpoint URL."); + } + else if (!Uri.TryCreate(options.SearXng.Endpoint, UriKind.Absolute, out _)) + { + errors.Add("SearXNG endpoint must be an absolute URL."); + } + } + + return errors.Count > 0 + ? ValidateOptionsResult.Fail(errors) + : ValidateOptionsResult.Success; + } +} + +internal sealed class SearchEditorPersistenceMapper +{ + internal SearchEditorModel Load(NetclawPaths paths) + { + var config = ConfigFileHelper.LoadJsonDict(paths.NetclawConfigPath); + var secrets = ConfigFileHelper.LoadJsonDict(paths.SecretsPath); + + var backend = ConfigFileHelper.TryGetPathValue(config, "Search.Backend", out var backendRaw) + ? ParseBackend(backendRaw?.ToString()) + : SearchBackend.DuckDuckGo; + + var endpoint = ConfigFileHelper.TryGetPathValue(config, "Search.SearXngEndpoint", out var endpointRaw) + ? endpointRaw?.ToString() + : null; + + var persistedBraveKey = ConfigFileHelper.TryGetPathValue(secrets, "Search.BraveApiKey", out var braveRaw) + ? ConfigFileHelper.DecryptIfEncrypted(paths, braveRaw?.ToString()) + : null; + + return new SearchEditorModel + { + Backend = backend, + Brave = + { + HasPersistedApiKey = !string.IsNullOrWhiteSpace(persistedBraveKey), + }, + SearXng = + { + Endpoint = Normalize(endpoint), + } + }; + } + + internal void Save(NetclawPaths paths, SearchEditorModel model) + { + var (config, secrets) = ConfigFileHelper.LoadConfigFiles(paths); + config["configVersion"] = EmbeddedSchemaLoader.CurrentSchemaVersion; + + ConfigFileHelper.SetPathValue(config, "Search.Backend", model.Backend.ToWireValue()); + + if (model.Backend == SearchBackend.SearXng && !string.IsNullOrWhiteSpace(model.SearXng.Endpoint)) + ConfigFileHelper.SetPathValue(config, "Search.SearXngEndpoint", model.SearXng.Endpoint); + else + ConfigFileHelper.RemovePath(config, "Search.SearXngEndpoint"); + + if (model.Backend == SearchBackend.Brave && !string.IsNullOrWhiteSpace(model.Brave.ApiKeyDraft)) + ConfigFileHelper.SetPathValue(secrets, "Search.BraveApiKey", model.Brave.ApiKeyDraft); + + ConfigFileHelper.WriteConfigFile(paths.NetclawConfigPath, config); + if (File.Exists(paths.SecretsPath) || ConfigFileHelper.PathPresent(secrets, "Search.BraveApiKey")) + ConfigFileHelper.WriteSecretsFile(paths, secrets); + } + + private static SearchBackend ParseBackend(string? value) + => value?.Trim().ToLowerInvariant() switch + { + "brave" => SearchBackend.Brave, + "searxng" => SearchBackend.SearXng, + _ => SearchBackend.DuckDuckGo, + }; + + private static string? Normalize(string? value) + => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); +} + +internal sealed record SearchEditorValidationIssue(string? FieldId, string Message, ConfigValidationSeverity Severity); + +internal sealed record SearchEditorValidationResult(IReadOnlyList<SearchEditorValidationIssue> Issues) +{ + public static readonly SearchEditorValidationResult Empty = new([]); + + public bool HasErrors => Issues.Any(static issue => issue.Severity == ConfigValidationSeverity.Error); + + public IReadOnlyList<SearchEditorValidationIssue> IssuesFor(string fieldId) + => [.. Issues.Where(issue => string.Equals(issue.FieldId, fieldId, StringComparison.Ordinal))]; +} + +internal sealed class SearchEditorValidationAdapter +{ + private readonly SearchEditorValidator _validator = new(); + + internal SearchEditorValidationResult Validate(SearchEditorModel model) + { + var result = _validator.Validate(name: null, model); + if (result.Succeeded) + return SearchEditorValidationResult.Empty; + + var failures = result.Failures ?? []; + var issues = new List<SearchEditorValidationIssue>(); + foreach (var failure in failures) + { + issues.Add(failure switch + { + var message when message.Contains("API key", StringComparison.OrdinalIgnoreCase) + => new SearchEditorValidationIssue("Search.BraveApiKey", message, ConfigValidationSeverity.Error), + var message when message.Contains("endpoint", StringComparison.OrdinalIgnoreCase) + => new SearchEditorValidationIssue("Search.SearXngEndpoint", message, ConfigValidationSeverity.Error), + _ => new SearchEditorValidationIssue(null, failure, ConfigValidationSeverity.Error), + }); + } + + return new SearchEditorValidationResult(issues); + } +} From c98f89af348710c095260142e72f37723e8b295d Mon Sep 17 00:00:00 2001 From: Aaron Stannard <aaron@petabridge.com> Date: Tue, 26 May 2026 21:41:31 +0000 Subject: [PATCH 10/31] refine(config): streamline search editor editing Preserve the active text input across redraws so inline edits keep their cursor position. Trim duplicate feedback so provider setup reads more cleanly. --- .../Tui/Config/SearchConfigEditorPage.cs | 61 +++++++++++++------ 1 file changed, 41 insertions(+), 20 deletions(-) diff --git a/src/Netclaw.Cli/Tui/Config/SearchConfigEditorPage.cs b/src/Netclaw.Cli/Tui/Config/SearchConfigEditorPage.cs index dc5c03b7e..2070c5b3f 100644 --- a/src/Netclaw.Cli/Tui/Config/SearchConfigEditorPage.cs +++ b/src/Netclaw.Cli/Tui/Config/SearchConfigEditorPage.cs @@ -17,6 +17,7 @@ internal sealed class SearchConfigEditorPage : ReactivePage<SearchConfigEditorVi { private SelectionListNode<string>? _dialogList; private TextInputNode? _textInput; + private string? _textInputFieldPath; private DynamicLayoutNode? _contentNode; private readonly CompositeDisposable _contentSubscriptions = []; private SearchFocusTarget _focusTarget = SearchFocusTarget.ProviderList; @@ -59,7 +60,6 @@ private LayoutNode BuildContent() { _contentSubscriptions.Clear(); _dialogList = null; - _textInput = null; if (ViewModel.ActiveDialog.Value == SearchConfigEditorDialog.ProbeWarning) return BuildProbeWarningDialog(); @@ -145,11 +145,15 @@ private ILayoutNode BuildReadonlyFieldLayout(ProjectedConfigField field) var content = Layouts.Vertical() .WithSpacing(1) .WithChild(new TextNode($" {field.Label}:").WithForeground(Color.White)) - .WithChild(new TextNode($" {displayValue}").WithForeground(valueColor)) - .WithChild(new TextNode(" Press Enter to edit.").WithForeground(Color.Gray)); + .WithChild(new TextNode($" {displayValue}").WithForeground(valueColor)); + + if (field.Widget == ConfigFieldWidget.PasswordInput && ViewModel.HasPersistedSecret(field.Path)) + content.WithChild(new TextNode(" Press Enter to replace the stored key.").WithForeground(Color.Gray)); + else + content.WithChild(new TextNode(" Press Enter to edit.").WithForeground(Color.Gray)); var supportText = ViewModel.GetCurrentProviderSupportText(); - if (!string.IsNullOrWhiteSpace(supportText)) + if (!string.IsNullOrWhiteSpace(supportText) && field.Widget != ConfigFieldWidget.PasswordInput) content.WithChild(new TextNode($" {supportText}").WithForeground(Color.Gray)); return content; @@ -161,17 +165,11 @@ private ILayoutNode BuildEditingFieldLayout(ProjectedConfigField field) .WithSpacing(1) .WithChild(new TextNode($" {field.Label}:").WithForeground(Color.White)); - _textInput = new TextInputNode(); - if (field.Widget == ConfigFieldWidget.PasswordInput) - _textInput.AsPassword(); - if (!string.IsNullOrWhiteSpace(field.Placeholder)) - _textInput.WithPlaceholder(field.Placeholder); - - _textInput.Text = ViewModel.GetEditorSeed(field); + var textInput = EnsureEditingTextInput(field); if (_focusTarget == SearchFocusTarget.FieldInput) - _textInput.OnFocused(); + textInput.OnFocused(); - _textInput.Submitted + textInput.Submitted .Subscribe(text => { var result = ViewModel.CommitField(field.Path, text); @@ -189,12 +187,8 @@ private ILayoutNode BuildEditingFieldLayout(ProjectedConfigField field) }) .DisposeWith(_contentSubscriptions); - content.WithChild(NetclawTuiChrome.BuildTextInputPanel(_textInput, field.Label)); - content.WithChild(new TextNode(" Press Enter to apply or Esc to cancel edit.").WithForeground(Color.Gray)); - - var supportText = ViewModel.GetCurrentProviderSupportText(); - if (!string.IsNullOrWhiteSpace(supportText)) - content.WithChild(new TextNode($" {supportText}").WithForeground(Color.Gray)); + content.WithChild(NetclawTuiChrome.BuildTextInputPanel(textInput, field.Label)); + content.WithChild(new TextNode(GetEditHint(field)).WithForeground(Color.Gray)); return content; } @@ -203,8 +197,9 @@ private ILayoutNode BuildMatrixState() { var children = Layouts.Vertical().WithSpacing(1); var hasState = false; + var currentProviderHasIssues = ViewModel.GetCurrentProviderIssues().Count > 0; - if (ViewModel.GetSummaryStateTone() == ConfigStatusTone.Warning) + if (!currentProviderHasIssues && ViewModel.GetSummaryStateTone() == ConfigStatusTone.Warning) { children.WithChild(new TextNode($" {ViewModel.GetSummaryStateText()}").WithForeground(Color.Yellow)); hasState = true; @@ -418,6 +413,8 @@ private void BeginInlineEdit() _editingFieldPath = field.Path; _editSeed = ViewModel.GetEditorSeed(field); + _textInput = null; + _textInputFieldPath = null; _focusTarget = SearchFocusTarget.FieldInput; _contentNode?.Invalidate(); ViewModel.RequestRedraw(); @@ -434,11 +431,35 @@ private void CancelActiveEdit() _editingFieldPath = null; _editSeed = string.Empty; + _textInput = null; + _textInputFieldPath = null; _focusTarget = SearchFocusTarget.ProviderList; _contentNode?.Invalidate(); ViewModel.RequestRedraw(); } + private TextInputNode EnsureEditingTextInput(ProjectedConfigField field) + { + if (_textInput is not null && string.Equals(_textInputFieldPath, field.Path, StringComparison.Ordinal)) + return _textInput; + + _textInput = new TextInputNode(); + _textInputFieldPath = field.Path; + + if (field.Widget == ConfigFieldWidget.PasswordInput) + _textInput.AsPassword(); + if (!string.IsNullOrWhiteSpace(field.Placeholder)) + _textInput.WithPlaceholder(field.Placeholder); + + _textInput.Text = ViewModel.GetEditorSeed(field); + return _textInput; + } + + private string GetEditHint(ProjectedConfigField field) + => field.Widget == ConfigFieldWidget.PasswordInput && ViewModel.HasPersistedSecret(field.Path) + ? " Enter a replacement key, then press Enter to apply or Esc to cancel." + : " Press Enter to apply or Esc to cancel edit."; + private static string GetProviderRequirementText(string backend) => backend switch { From 4856cf77f33a58cf2f9e0d7253d07507f1df64ff Mon Sep 17 00:00:00 2001 From: Aaron Stannard <aaron@petabridge.com> Date: Wed, 27 May 2026 18:20:39 +0000 Subject: [PATCH 11/31] refine(config): make search setup a focused workflow Keep search backend setup on an explicit path from provider selection through validation and save. Preserve inactive backend settings so switching providers does not silently wipe prior configuration. --- .../SearchConfigEditorViewModelTests.cs | 76 ++- .../Tui/Config/SearchConfigEditorPage.cs | 451 +++++++++--------- .../Tui/Config/SearchConfigEditorViewModel.cs | 238 +++++++-- .../Tui/Config/SearchEditorModel.cs | 4 +- tests/smoke/assertions/config-search.sh | 9 +- tests/smoke/tapes/config-search.tape | 55 +-- .../tapes/screenshots/config-search.tape | 35 +- 7 files changed, 516 insertions(+), 352 deletions(-) diff --git a/src/Netclaw.Cli.Tests/Tui/Config/SearchConfigEditorViewModelTests.cs b/src/Netclaw.Cli.Tests/Tui/Config/SearchConfigEditorViewModelTests.cs index e11775d5b..2bb549e05 100644 --- a/src/Netclaw.Cli.Tests/Tui/Config/SearchConfigEditorViewModelTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/Config/SearchConfigEditorViewModelTests.cs @@ -57,44 +57,42 @@ public void Fields_project_search_enabled_out_of_editor() } [Fact] - public void Starts_on_summary_screen() + public void Starts_on_provider_selection_screen() { using var vm = new SearchConfigEditorViewModel(_paths); - Assert.Equal(SearchConfigEditorScreen.Summary, vm.CurrentScreen.Value); + Assert.Equal(SearchConfigEditorScreen.ProviderSelection, vm.CurrentScreen.Value); Assert.Equal("duckduckgo", vm.CurrentBackendValue); - Assert.Equal("No additional setup required.", vm.GetSummaryStateText()); + Assert.Null(vm.CurrentProviderField); } [Fact] - public void Selecting_brave_keeps_single_screen_matrix_active() + public void Selecting_brave_moves_to_entry_state() { using var vm = new SearchConfigEditorViewModel(_paths); vm.BeginBackendSelection(); vm.SelectBackendForEditing("brave"); - Assert.Equal(SearchConfigEditorScreen.Summary, vm.CurrentScreen.Value); + Assert.Equal(SearchConfigEditorScreen.Entry, vm.CurrentScreen.Value); Assert.Equal("brave", vm.CurrentBackendValue); - Assert.Equal("API key required.", vm.GetSummaryStateText()); Assert.Equal("Search.BraveApiKey", vm.CurrentProviderField?.Path); } [Fact] - public void Selecting_duckduckgo_has_no_provider_specific_field() + public void Selecting_duckduckgo_enters_zero_config_workflow_state() { using var vm = new SearchConfigEditorViewModel(_paths); vm.BeginBackendSelection(); vm.SelectBackendForEditing("duckduckgo"); - Assert.Equal(SearchConfigEditorScreen.Summary, vm.CurrentScreen.Value); + Assert.Equal(SearchConfigEditorScreen.Entry, vm.CurrentScreen.Value); Assert.Null(vm.CurrentProviderField); - Assert.Equal("No additional setup required.", vm.GetSummaryStateText()); } [Fact] - public void Selecting_zero_config_provider_keeps_summary_quiet_when_effective_value_is_unchanged() + public void Selecting_zero_config_provider_keeps_workflow_clean_when_effective_value_is_unchanged() { using var vm = new SearchConfigEditorViewModel(_paths); @@ -103,7 +101,7 @@ public void Selecting_zero_config_provider_keeps_summary_quiet_when_effective_va vm.BeginBackendSelection(); vm.SelectBackendForEditing("duckduckgo"); - Assert.Equal(SearchConfigEditorScreen.Summary, vm.CurrentScreen.Value); + Assert.Equal(SearchConfigEditorScreen.Entry, vm.CurrentScreen.Value); Assert.False(vm.IsDirty); Assert.Equal("duckduckgo", vm.CurrentBackendValue); } @@ -114,12 +112,13 @@ public async Task Brave_probe_failure_opens_override_dialog_before_save() using var vm = new SearchConfigEditorViewModel(_paths, new StubHttpClientFactory(_ => new HttpResponseMessage(HttpStatusCode.Unauthorized))); - vm.SetFieldValue("Search.Backend", "brave"); - vm.SetFieldValue("Search.BraveApiKey", "bad-key"); + vm.SelectBackendForEditing("brave"); + vm.StageFieldValue("Search.BraveApiKey", "bad-key"); - await vm.SaveAsync(TestContext.Current.CancellationToken); + await vm.SubmitCurrentConfigurationAsync(TestContext.Current.CancellationToken); Assert.Equal(SearchConfigEditorDialog.ProbeWarning, vm.ActiveDialog.Value); + Assert.Equal(SearchConfigEditorScreen.Entry, vm.CurrentScreen.Value); Assert.Contains("authentication failed", vm.Status.Value.Text, StringComparison.OrdinalIgnoreCase); } @@ -175,6 +174,32 @@ public void Blank_secret_without_existing_value_is_still_structurally_invalid() Assert.False(vm.HasPersistedSecret("Search.BraveApiKey")); } + [Fact] + public void Switching_to_duckduckgo_preserves_inactive_searxng_endpoint() + { + File.WriteAllText(_paths.NetclawConfigPath, """ + { + "configVersion": 1, + "Search": { + "Backend": "searxng", + "SearXngEndpoint": "https://search.example.com" + } + } + """); + + using var vm = new SearchConfigEditorViewModel(_paths); + + vm.SelectBackendForEditing("duckduckgo"); + vm.SaveWithoutProbeOverride(); + + var reloaded = new SearchConfigEditorViewModel(_paths); + var config = File.ReadAllText(_paths.NetclawConfigPath); + + Assert.Contains("\"Backend\": \"duckduckgo\"", config, StringComparison.Ordinal); + Assert.Contains("\"SearXngEndpoint\": \"https://search.example.com\"", config, StringComparison.Ordinal); + Assert.Equal("https://search.example.com", reloaded.FieldValues["Search.SearXngEndpoint"].Value); + } + [Fact] public async Task Successful_probe_allows_save_without_dialog() { @@ -184,13 +209,13 @@ public async Task Successful_probe_allows_save_without_dialog() Content = new StringContent("{\"web\":{\"results\":[]}}", Encoding.UTF8, "application/json"), })); - vm.SetFieldValue("Search.Backend", "brave"); - vm.SetFieldValue("Search.BraveApiKey", "good-key"); + vm.SelectBackendForEditing("brave"); + vm.StageFieldValue("Search.BraveApiKey", "good-key"); - await vm.SaveAsync(TestContext.Current.CancellationToken); + await vm.SubmitCurrentConfigurationAsync(TestContext.Current.CancellationToken); Assert.Equal(SearchConfigEditorDialog.None, vm.ActiveDialog.Value); - Assert.Equal(SearchConfigEditorScreen.Summary, vm.CurrentScreen.Value); + Assert.Equal(SearchConfigEditorScreen.Saved, vm.CurrentScreen.Value); Assert.Contains("Saved Search settings", vm.Status.Value.Text, StringComparison.Ordinal); } @@ -221,6 +246,21 @@ public void Preserved_state_supports_in_memory_draft_edits() Assert.Equal("https://search.example.com", vm.FieldValues["Search.SearXngEndpoint"].Value); } + [Fact] + public void Invalid_endpoint_submission_keeps_typed_draft_without_mutating_accepted_value() + { + using var vm = new SearchConfigEditorViewModel(_paths); + + vm.SelectBackendForEditing("searxng"); + var field = Assert.IsType<ProjectedConfigField>(vm.CurrentProviderField); + + var result = vm.CommitField(field.Path, "search.local"); + + Assert.False(result.Success); + Assert.Equal("search.local", vm.FieldValues[field.Path].Value); + Assert.Equal("(not configured)", vm.GetDisplayValue(field)); + } + private sealed class StubHttpClientFactory(Func<HttpRequestMessage, HttpResponseMessage> handler) : IHttpClientFactory { public HttpClient CreateClient(string name) diff --git a/src/Netclaw.Cli/Tui/Config/SearchConfigEditorPage.cs b/src/Netclaw.Cli/Tui/Config/SearchConfigEditorPage.cs index 2070c5b3f..887de3ba9 100644 --- a/src/Netclaw.Cli/Tui/Config/SearchConfigEditorPage.cs +++ b/src/Netclaw.Cli/Tui/Config/SearchConfigEditorPage.cs @@ -15,22 +15,14 @@ namespace Netclaw.Cli.Tui.Config; internal sealed class SearchConfigEditorPage : ReactivePage<SearchConfigEditorViewModel> { + private static readonly string[] SpinnerFrames = ["\u280b", "\u2819", "\u2838", "\u2834", "\u2826", "\u2807"]; private SelectionListNode<string>? _dialogList; private TextInputNode? _textInput; private string? _textInputFieldPath; private DynamicLayoutNode? _contentNode; private readonly CompositeDisposable _contentSubscriptions = []; - private SearchFocusTarget _focusTarget = SearchFocusTarget.ProviderList; private int _providerIndex; - private string? _editingFieldPath; - private string _editSeed = string.Empty; - - private enum SearchFocusTarget - { - ProviderList, - FieldInput, - Dialog, - } + private bool _providerSelectionSynced; public override void OnNavigatedTo() { @@ -38,10 +30,23 @@ public override void OnNavigatedTo() ViewModel.ActiveDialog.Subscribe(_ => _contentNode?.Invalidate()) .DisposeWith(Subscriptions); - ViewModel.ValidationSummary.Subscribe(_ => _contentNode?.Invalidate()) + ViewModel.CurrentScreen.Subscribe(screen => + { + if (screen == SearchConfigEditorScreen.ProviderSelection) + _providerSelectionSynced = false; + + if (screen != SearchConfigEditorScreen.Entry) + ResetEntryInput(); + + _contentNode?.Invalidate(); + }) .DisposeWith(Subscriptions); ViewModel.Status.Subscribe(_ => _contentNode?.Invalidate()) .DisposeWith(Subscriptions); + ViewModel.ValidationSummary.Subscribe(_ => _contentNode?.Invalidate()) + .DisposeWith(Subscriptions); + ViewModel.ValidationSpinnerTick.Subscribe(_ => _contentNode?.Invalidate()) + .DisposeWith(Subscriptions); } public override ILayoutNode BuildLayout() @@ -64,179 +69,107 @@ private LayoutNode BuildContent() if (ViewModel.ActiveDialog.Value == SearchConfigEditorDialog.ProbeWarning) return BuildProbeWarningDialog(); - return BuildProviderMatrixScreen(); + return ViewModel.CurrentScreen.Value switch + { + SearchConfigEditorScreen.ProviderSelection => BuildProviderSelectionScreen(), + SearchConfigEditorScreen.Entry => BuildEntryScreen(), + SearchConfigEditorScreen.Validating => BuildValidatingScreen(), + SearchConfigEditorScreen.Saved => BuildSavedScreen(), + _ => Layouts.Empty(), + }; }); return _contentNode; } - private ILayoutNode BuildProviderMatrixScreen() + private ILayoutNode BuildProviderSelectionScreen() { - SyncProviderIndexToCurrentBackend(); - - var content = Layouts.Vertical() - .WithSpacing(1) - .WithChild(new TextNode(" Choose your web search provider:").WithForeground(Color.White)) - .WithChild(BuildProviderList()) - .WithChild(BuildProviderDetails()) - .WithChild(BuildMatrixState()) - .WithChild(BuildCommandRail()); - - return content; - } - - private ILayoutNode BuildProviderList() - { - var content = Layouts.Vertical(); - var options = ViewModel.BackendOptions; - for (var i = 0; i < options.Count; i++) + if (!_providerSelectionSynced) { - var option = options[i]; - var isFocused = _focusTarget == SearchFocusTarget.ProviderList && i == _providerIndex; - var isActive = string.Equals(option.Value, ViewModel.CurrentBackendValue, StringComparison.OrdinalIgnoreCase); - var marker = isActive ? "(*)" : "( )"; - var prefix = isFocused ? ">" : " "; - var line = $" {prefix} {marker} {option.Label,-18} {GetProviderRequirementText(option.Value)}"; - var color = isFocused ? Color.Cyan : Color.White; - - var node = new TextNode(line).WithForeground(color); - if (isActive) - node.Bold(); - - content.WithChild(node.Height(1)); + SyncProviderIndexToCurrentBackend(); + _providerSelectionSynced = true; } - return content; + return Layouts.Vertical() + .WithSpacing(1) + .WithChild(new TextNode(" Choose the backend Netclaw uses for web search.").WithForeground(Color.White)) + .WithChild(BuildProviderList()) + .WithChild(new TextNode($" {GetProviderDescription(ViewModel.BackendOptions[_providerIndex].Value)}").WithForeground(Color.Gray)); } - private ILayoutNode BuildProviderDetails() + private ILayoutNode BuildEntryScreen() { var content = Layouts.Vertical().WithSpacing(1); - - content.WithChild(new TextNode($" {ViewModel.CurrentBackendLabel}").WithForeground(Color.White).Bold()); - var field = ViewModel.CurrentProviderField; + if (field is null) { - content.WithChild(new TextNode(" No additional setup required.").WithForeground(Color.Gray)); + content.WithChild(new TextNode(" DuckDuckGo works without setup, but may hit bot detection.") + .WithForeground(Color.White)); + content.WithChild(new TextNode(" Press Enter to validate and save this provider selection.") + .WithForeground(Color.Gray)); return content; } - content.WithChild(IsEditingField(field) - ? BuildEditingFieldLayout(field) - : BuildReadonlyFieldLayout(field)); + var textInput = EnsureEditingTextInput(field); + textInput.OnFocused(); - foreach (var issue in ViewModel.GetCurrentProviderIssues()) - content.WithChild(new TextNode($" ! {issue.Message}").WithForeground(Color.Red)); + content.WithChild(new TextNode($" {GetEntryTitle(field)}").WithForeground(Color.White)); + content.WithChild(new TextNode($" {field.Label}").WithForeground(Color.White)); + content.WithChild(NetclawTuiChrome.BuildTextInputPanel(textInput, field.Label)); + content.WithChild(new TextNode($" {GetEntryHint(field)}").WithForeground(Color.Gray)); return content; } - private ILayoutNode BuildReadonlyFieldLayout(ProjectedConfigField field) + private ILayoutNode BuildValidatingScreen() { - var displayValue = ViewModel.GetDisplayValue(field); - if (string.IsNullOrWhiteSpace(displayValue)) - displayValue = "(not configured)"; - - var valueColor = displayValue.StartsWith("(", StringComparison.Ordinal) - ? Color.Gray - : Color.White; - - var content = Layouts.Vertical() + var frame = SpinnerFrames[ViewModel.ValidationSpinnerTick.Value % SpinnerFrames.Length]; + return Layouts.Vertical() .WithSpacing(1) - .WithChild(new TextNode($" {field.Label}:").WithForeground(Color.White)) - .WithChild(new TextNode($" {displayValue}").WithForeground(valueColor)); - - if (field.Widget == ConfigFieldWidget.PasswordInput && ViewModel.HasPersistedSecret(field.Path)) - content.WithChild(new TextNode(" Press Enter to replace the stored key.").WithForeground(Color.Gray)); - else - content.WithChild(new TextNode(" Press Enter to edit.").WithForeground(Color.Gray)); - - var supportText = ViewModel.GetCurrentProviderSupportText(); - if (!string.IsNullOrWhiteSpace(supportText) && field.Widget != ConfigFieldWidget.PasswordInput) - content.WithChild(new TextNode($" {supportText}").WithForeground(Color.Gray)); - - return content; + .WithChild(new TextNode(" Validating Search configuration...").WithForeground(Color.White)) + .WithChild(new TextNode($" {frame} {GetValidatingMessage()}").WithForeground(Color.Yellow)) + .WithChild(new TextNode(" This may take a few seconds.").WithForeground(Color.Gray)); } - private ILayoutNode BuildEditingFieldLayout(ProjectedConfigField field) - { - var content = Layouts.Vertical() + private ILayoutNode BuildSavedScreen() + => Layouts.Vertical() .WithSpacing(1) - .WithChild(new TextNode($" {field.Label}:").WithForeground(Color.White)); - - var textInput = EnsureEditingTextInput(field); - if (_focusTarget == SearchFocusTarget.FieldInput) - textInput.OnFocused(); - - textInput.Submitted - .Subscribe(text => - { - var result = ViewModel.CommitField(field.Path, text); - if (!result.Success) - { - ViewModel.RequestRedraw(); - return; - } - - _editingFieldPath = null; - _editSeed = string.Empty; - _focusTarget = SearchFocusTarget.ProviderList; - _contentNode?.Invalidate(); - ViewModel.RequestRedraw(); - }) - .DisposeWith(_contentSubscriptions); - - content.WithChild(NetclawTuiChrome.BuildTextInputPanel(textInput, field.Label)); - content.WithChild(new TextNode(GetEditHint(field)).WithForeground(Color.Gray)); - - return content; - } + .WithChild(new TextNode($" \u2714 {ViewModel.CurrentBackendLabel} validated and saved.").WithForeground(Color.Green)) + .WithChild(new TextNode(" Press Esc to return to Settings Areas or Up/Down to review providers.") + .WithForeground(Color.Gray)); - private ILayoutNode BuildMatrixState() + private ILayoutNode BuildProviderList() { - var children = Layouts.Vertical().WithSpacing(1); - var hasState = false; - var currentProviderHasIssues = ViewModel.GetCurrentProviderIssues().Count > 0; - - if (!currentProviderHasIssues && ViewModel.GetSummaryStateTone() == ConfigStatusTone.Warning) + var content = Layouts.Vertical(); + var options = ViewModel.BackendOptions; + for (var i = 0; i < options.Count; i++) { - children.WithChild(new TextNode($" {ViewModel.GetSummaryStateText()}").WithForeground(Color.Yellow)); - hasState = true; - } + var option = options[i]; + var isFocused = i == _providerIndex; + var isActive = string.Equals(option.Value, ViewModel.CurrentBackendValue, StringComparison.OrdinalIgnoreCase); + var marker = isActive ? "(*)" : "( )"; + var prefix = isFocused ? ">" : " "; + var status = IsConfigured(option.Value) ? "\u2713" : " "; + var line = $" {prefix} {marker} {option.Label,-20} {status}"; + var color = isFocused ? Color.Cyan : Color.White; - if (ViewModel.IsDirty) - { - children.WithChild(new TextNode(" Unsaved changes.").WithForeground(Color.Yellow)); - hasState = true; - } + var node = new TextNode(line).WithForeground(color); + if (isActive) + node.Bold(); - if (ViewModel.LastProbeResult is { } lastProbe) - { - children.WithChild(new TextNode($" Last test: {lastProbe.Message}").WithForeground(ToColor(lastProbe.Tone))); - hasState = true; + content.WithChild(node.Height(1)); } - return hasState ? children : Layouts.Empty(); - } - - private ILayoutNode BuildCommandRail() - { - var text = _focusTarget == SearchFocusTarget.FieldInput - ? " [Enter] Apply [Esc] Cancel edit" - : ViewModel.CurrentProviderField is null - ? " [T] Test [S] Save [Esc] Back" - : " [Enter] Edit [T] Test [S] Save [Esc] Back"; - - return new TextNode(text).WithForeground(Color.Gray); + return content; } private ILayoutNode BuildProbeWarningDialog() { var options = new List<string> { - "Keep editing", - "Test again", + "Retry validation", + "Back to edit", "Save anyway", }; @@ -244,7 +177,6 @@ private ILayoutNode BuildProbeWarningDialog() .WithMode(SelectionMode.Single) .WithHighlightColors(Color.Black, Color.Yellow); _dialogList.OnFocused(); - _focusTarget = SearchFocusTarget.Dialog; _dialogList.SelectionConfirmed .Subscribe(async selected => @@ -256,48 +188,50 @@ private ILayoutNode BuildProbeWarningDialog() { case "Save anyway": ViewModel.SaveWithoutProbeOverride(); - _focusTarget = SearchFocusTarget.ProviderList; break; - case "Test again": + case "Retry validation": ViewModel.DismissDialog(); - _focusTarget = SearchFocusTarget.ProviderList; - await ViewModel.TestCurrentConfigurationAsync(); + await ViewModel.SubmitCurrentConfigurationAsync(); break; default: ViewModel.DismissDialog(); - _focusTarget = SearchFocusTarget.ProviderList; break; } }) .DisposeWith(_contentSubscriptions); - var message = ViewModel.LastProbeResult?.Message ?? "Search backend test failed."; + var message = ViewModel.LastProbeResult?.Message ?? "Search validation failed."; return NetclawTuiChrome.BuildPanel( - "Search Test Warning", + "Search Validation Warning", Layouts.Vertical() .WithSpacing(1) - .WithChild(new TextNode($" {message}").WithForeground(Color.Yellow)) .WithChild(new TextNode(" Netclaw could not complete a live search using this configuration.") - .WithForeground(Color.Gray)) + .WithForeground(Color.White)) + .WithChild(new TextNode($" {message}").WithForeground(Color.Yellow)) .WithChild(_dialogList), Color.Yellow); } private LayoutNode BuildStatusBar() => ViewModel.Status - .Select(status => NetclawTuiChrome.BuildStatusLine(status.Text, ToColor(status.Tone))) + .Select(status => string.IsNullOrWhiteSpace(status.Text) + ? Layouts.Empty() + : NetclawTuiChrome.BuildStatusLine(status.Text, ToColor(status.Tone))) .AsLayout() .Height(1); private LayoutNode BuildKeyBindings() { - var text = _focusTarget switch - { - SearchFocusTarget.Dialog => " [↑/↓] Navigate [Enter] Confirm [Esc] Dismiss [Ctrl+Q] Quit", - SearchFocusTarget.FieldInput => " [Enter] Apply [Esc] Cancel edit [Ctrl+Q] Quit", - _ when ViewModel.CurrentProviderField is null => " [↑/↓] Navigate [T] Test [S] Save [Esc] Back [Ctrl+Q] Quit", - _ => " [↑/↓] Navigate [Enter] Edit [T] Test [S] Save [Esc] Back [Ctrl+Q] Quit", - }; + var text = ViewModel.ActiveDialog.Value == SearchConfigEditorDialog.ProbeWarning + ? " [↑/↓] Navigate [Enter] Select [Esc] Back to edit [Ctrl+Q] Quit" + : ViewModel.CurrentScreen.Value switch + { + SearchConfigEditorScreen.ProviderSelection => " [↑/↓] Navigate [Enter] Continue [Esc] Back [Ctrl+Q] Quit", + SearchConfigEditorScreen.Entry => " [Enter] Continue [Esc] Back [Ctrl+Q] Quit", + SearchConfigEditorScreen.Validating => " [Ctrl+Q] Quit", + SearchConfigEditorScreen.Saved => " [↑/↓] Navigate [Enter] Continue [Esc] Back [Ctrl+Q] Quit", + _ => " [Ctrl+Q] Quit", + }; return NetclawTuiChrome.BuildKeyHintLine(text); } @@ -313,71 +247,122 @@ public override bool HandlePageInput(ConsoleKeyInfo keyInfo) return true; } - if (_focusTarget != SearchFocusTarget.FieldInput && keyInfo.Key == ConsoleKey.T) + if (keyInfo.Key == ConsoleKey.Escape) { - _ = ViewModel.TestCurrentConfigurationAsync(); + if (ViewModel.ActiveDialog.Value != SearchConfigEditorDialog.None) + { + ViewModel.DismissDialog(); + _contentNode?.Invalidate(); + return true; + } + + if (ViewModel.CurrentScreen.Value == SearchConfigEditorScreen.Entry) + { + BeginProviderSelection(); + return true; + } + + ViewModel.NavigateBack(); return true; } - if (_focusTarget != SearchFocusTarget.FieldInput && keyInfo.Key == ConsoleKey.S) + if (ViewModel.ActiveDialog.Value == SearchConfigEditorDialog.ProbeWarning) { - _ = ViewModel.SaveAsync(); + _dialogList?.HandleInput(keyInfo); return true; } - if (keyInfo.Key == ConsoleKey.Escape) + if (ViewModel.CurrentScreen.Value == SearchConfigEditorScreen.ProviderSelection) { - if (ViewModel.ActiveDialog.Value != SearchConfigEditorDialog.None) + if (keyInfo.Key == ConsoleKey.UpArrow) { - ViewModel.DismissDialog(); - _focusTarget = SearchFocusTarget.ProviderList; + MoveProviderSelection(-1); return true; } - if (_focusTarget == SearchFocusTarget.FieldInput) + if (keyInfo.Key == ConsoleKey.DownArrow) { - CancelActiveEdit(); + MoveProviderSelection(1); + return true; + } + + if (keyInfo.Key == ConsoleKey.Enter) + { + var option = ViewModel.BackendOptions[_providerIndex]; + ViewModel.SelectBackendForEditing(option.Value); + ResetEntryInput(); + _contentNode?.Invalidate(); + ViewModel.RequestRedraw(); return true; } - ViewModel.NavigateBack(); return true; } - if (_focusTarget != SearchFocusTarget.FieldInput && keyInfo.Key == ConsoleKey.Enter) + if (ViewModel.CurrentScreen.Value == SearchConfigEditorScreen.Saved) { - BeginInlineEdit(); + if (keyInfo.Key == ConsoleKey.UpArrow) + { + MoveProviderSelection(-1); + return true; + } + + if (keyInfo.Key == ConsoleKey.DownArrow) + { + MoveProviderSelection(1); + return true; + } + + if (keyInfo.Key == ConsoleKey.Enter) + { + var option = ViewModel.BackendOptions[_providerIndex]; + ViewModel.SelectBackendForEditing(option.Value); + ResetEntryInput(); + _contentNode?.Invalidate(); + ViewModel.RequestRedraw(); + return true; + } + return true; } - switch (_focusTarget) + if (ViewModel.CurrentScreen.Value == SearchConfigEditorScreen.Entry) { - case SearchFocusTarget.Dialog: - _dialogList?.HandleInput(keyInfo); - break; - case SearchFocusTarget.FieldInput when _textInput is not null: - _textInput.HandleInput(keyInfo); - break; - default: - if (keyInfo.Key == ConsoleKey.UpArrow) - { - MoveProviderSelection(-1); - return true; - } + if (keyInfo.Key == ConsoleKey.Enter) + { + StageActiveInput(); + _ = ViewModel.SubmitCurrentConfigurationAsync(); + return true; + } - if (keyInfo.Key == ConsoleKey.DownArrow) - { - MoveProviderSelection(1); - return true; - } + if (_textInput is not null) + { + _textInput.HandleInput(keyInfo); + ViewModel.StageFieldValue(_textInputFieldPath!, _textInput.Text); + } - break; + ViewModel.RequestRedraw(); + return true; } - ViewModel.RequestRedraw(); return true; } + private void BeginProviderSelection() + { + _providerSelectionSynced = false; + ViewModel.BeginBackendSelection(); + ResetEntryInput(); + _contentNode?.Invalidate(); + ViewModel.RequestRedraw(); + } + + private void StageActiveInput() + { + if (_textInputFieldPath is not null && _textInput is not null) + ViewModel.StageFieldValue(_textInputFieldPath, _textInput.Text); + } + private void SyncProviderIndexToCurrentBackend() { var index = ViewModel.BackendOptions @@ -398,44 +383,14 @@ private void MoveProviderSelection(int delta) return; _providerIndex = next; - _editingFieldPath = null; - _editSeed = string.Empty; - - var option = ViewModel.BackendOptions[_providerIndex]; - ViewModel.SelectBackendForEditing(option.Value); - _contentNode?.Invalidate(); - } - - private void BeginInlineEdit() - { - if (ViewModel.CurrentProviderField is not { } field) - return; - - _editingFieldPath = field.Path; - _editSeed = ViewModel.GetEditorSeed(field); - _textInput = null; - _textInputFieldPath = null; - _focusTarget = SearchFocusTarget.FieldInput; _contentNode?.Invalidate(); ViewModel.RequestRedraw(); } - private bool IsEditingField(ProjectedConfigField field) - => _focusTarget == SearchFocusTarget.FieldInput - && string.Equals(_editingFieldPath, field.Path, StringComparison.Ordinal); - - private void CancelActiveEdit() + private void ResetEntryInput() { - if (_editingFieldPath is { } path) - ViewModel.CommitField(path, _editSeed); - - _editingFieldPath = null; - _editSeed = string.Empty; _textInput = null; _textInputFieldPath = null; - _focusTarget = SearchFocusTarget.ProviderList; - _contentNode?.Invalidate(); - ViewModel.RequestRedraw(); } private TextInputNode EnsureEditingTextInput(ProjectedConfigField field) @@ -452,20 +407,52 @@ private TextInputNode EnsureEditingTextInput(ProjectedConfigField field) _textInput.WithPlaceholder(field.Placeholder); _textInput.Text = ViewModel.GetEditorSeed(field); + if (!string.IsNullOrEmpty(_textInput.Text)) + _textInput.HandleInput(new ConsoleKeyInfo('\0', ConsoleKey.End, shift: false, alt: false, control: false)); + return _textInput; } - private string GetEditHint(ProjectedConfigField field) - => field.Widget == ConfigFieldWidget.PasswordInput && ViewModel.HasPersistedSecret(field.Path) - ? " Enter a replacement key, then press Enter to apply or Esc to cancel." - : " Press Enter to apply or Esc to cancel edit."; + private string GetProviderDescription(string backend) + => backend switch + { + "brave" => "Brave Search requires an API key and is usually more reliable than DuckDuckGo.", + "searxng" => "SearXNG uses your own endpoint URL and supports self-hosted search.", + _ => "DuckDuckGo works without setup, but may hit bot detection.", + }; + + private string GetEntryTitle(ProjectedConfigField field) + => field.Path switch + { + "Search.BraveApiKey" => "Brave Search requires an API key.", + _ => "Enter the base URL of your SearXNG instance.", + }; + + private string GetEntryHint(ProjectedConfigField field) + => field.Path switch + { + "Search.BraveApiKey" when ViewModel.HasPersistedSecret(field.Path) + => "Stored in secrets.json. Leave blank to keep the existing key. Press Enter to validate and save.", + "Search.BraveApiKey" + => "Stored in secrets.json. Press Enter to validate and save.", + _ => "Netclaw will validate the URL and probe it on Enter.", + }; + + private string GetValidatingMessage() + => ViewModel.CurrentBackendValue switch + { + "brave" => "Probing Brave Search", + "searxng" => "Probing SearXNG instance", + _ => "Validating DuckDuckGo configuration", + }; - private static string GetProviderRequirementText(string backend) + private bool IsConfigured(string backend) => backend switch { - "brave" => "Requires API key", - "searxng" => "Requires endpoint URL", - _ => "No setup required", + "brave" => !string.IsNullOrWhiteSpace(ViewModel.FieldValues["Search.BraveApiKey"].Value) + || ViewModel.HasPersistedSecret("Search.BraveApiKey"), + "searxng" => !string.IsNullOrWhiteSpace(ViewModel.FieldValues["Search.SearXngEndpoint"].Value), + _ => true, }; private static Color ToColor(ConfigStatusTone tone) => tone switch diff --git a/src/Netclaw.Cli/Tui/Config/SearchConfigEditorViewModel.cs b/src/Netclaw.Cli/Tui/Config/SearchConfigEditorViewModel.cs index c38d9ea8a..81e49a25b 100644 --- a/src/Netclaw.Cli/Tui/Config/SearchConfigEditorViewModel.cs +++ b/src/Netclaw.Cli/Tui/Config/SearchConfigEditorViewModel.cs @@ -4,6 +4,8 @@ // </copyright> // ----------------------------------------------------------------------- using System.Net.Http; +using System.Threading; +using Netclaw.Cli.Config; using Netclaw.Configuration; using Netclaw.Search; using R3; @@ -19,7 +21,10 @@ internal enum SearchConfigEditorDialog internal enum SearchConfigEditorScreen { - Summary, + ProviderSelection, + Entry, + Validating, + Saved, } internal sealed record SearchProbeResult(bool Success, string Message, ConfigStatusTone Tone); @@ -42,6 +47,7 @@ internal sealed class SearchConfigEditorViewModel : ReactiveViewModel private SearchEditorModel _model; private SearchEditorValidationResult _validation = SearchEditorValidationResult.Empty; private SearchProbeResult? _lastProbeResult; + private CancellationTokenSource? _validationSpinnerCts; public IReadOnlyList<ProjectedConfigField> Fields { get; } = [ @@ -95,7 +101,8 @@ public SearchConfigEditorViewModel( Status = new ReactiveProperty<ConfigStatusMessage>(new ConfigStatusMessage(string.Empty, ConfigStatusTone.Neutral)); ValidationSummary = new ReactiveProperty<ConfigValidationSummary>(ConfigValidationSummary.Empty); ActiveDialog = new ReactiveProperty<SearchConfigEditorDialog>(SearchConfigEditorDialog.None); - CurrentScreen = new ReactiveProperty<SearchConfigEditorScreen>(SearchConfigEditorScreen.Summary); + CurrentScreen = new ReactiveProperty<SearchConfigEditorScreen>(SearchConfigEditorScreen.ProviderSelection); + ValidationSpinnerTick = new ReactiveProperty<int>(0); Revalidate(); } @@ -103,6 +110,7 @@ public SearchConfigEditorViewModel( public ReactiveProperty<ConfigValidationSummary> ValidationSummary { get; } public ReactiveProperty<SearchConfigEditorDialog> ActiveDialog { get; } public ReactiveProperty<SearchConfigEditorScreen> CurrentScreen { get; } + public ReactiveProperty<int> ValidationSpinnerTick { get; } public bool IsDirty => ComputeIsDirty(); public SearchProbeResult? LastProbeResult => _lastProbeResult; @@ -128,8 +136,16 @@ public SearchConfigEditorViewModel( _ => null, }; + public bool IsCurrentBackendConfigured => _model.Backend switch + { + SearchBackend.Brave => HasEffectiveBraveKey(), + SearchBackend.SearXng => !string.IsNullOrWhiteSpace(_model.SearXng.Endpoint), + _ => true, + }; + public override void Dispose() { + CancelValidationSpinner(); foreach (var value in FieldValues.Values) value.Dispose(); @@ -137,17 +153,19 @@ public override void Dispose() ValidationSummary.Dispose(); ActiveDialog.Dispose(); CurrentScreen.Dispose(); + ValidationSpinnerTick.Dispose(); base.Dispose(); } public SearchFieldCommitResult CommitField(string path, string? value) { - ApplyFieldValue(path, value); - SyncFieldValue(path); - ClearTransientProbeState(); - Revalidate(); + StageFieldValue(path, value); + var candidate = CloneModel(_model); + ApplyFieldValue(candidate, path, value); + + var candidateValidation = _validator.Validate(candidate); + var issues = candidateValidation.IssuesFor(path); - var issues = _validation.IssuesFor(path); if (issues.Count > 0) { Status.Value = new ConfigStatusMessage(issues[0].Message, ConfigStatusTone.Error); @@ -155,6 +173,10 @@ public SearchFieldCommitResult CommitField(string path, string? value) return SearchFieldCommitResult.Invalid(issues); } + _model = candidate; + SyncFieldValue(path); + ClearTransientProbeState(); + Revalidate(); Status.Value = new ConfigStatusMessage(string.Empty, ConfigStatusTone.Neutral); RequestRedraw(); return SearchFieldCommitResult.Ok; @@ -170,7 +192,9 @@ public string GetDisplayValue(ProjectedConfigField field) }; public string GetEditorSeed(ProjectedConfigField field) - => GetCurrentFieldValue(field.Path); + => FieldValues.TryGetValue(field.Path, out var property) + ? property.Value + : GetCurrentFieldValue(field.Path); public IReadOnlyList<ConfigValidationIssue> GetIssues(ProjectedConfigField field) => ValidationSummary.Value.IssuesFor(field.Path); @@ -232,100 +256,110 @@ public void CommitCurrentProviderDraft() public void BeginBackendSelection() { - CurrentScreen.Value = SearchConfigEditorScreen.Summary; + CancelValidationSpinner(); + ActiveDialog.Value = SearchConfigEditorDialog.None; + CurrentScreen.Value = SearchConfigEditorScreen.ProviderSelection; + Status.Value = new ConfigStatusMessage(string.Empty, ConfigStatusTone.Neutral); RequestRedraw(); } public void SelectBackendForEditing(string backend) { CommitField("Search.Backend", backend); - CurrentScreen.Value = SearchConfigEditorScreen.Summary; + ActiveDialog.Value = SearchConfigEditorDialog.None; + CurrentScreen.Value = SearchConfigEditorScreen.Entry; RequestRedraw(); } public void ReturnToSummary() { - CurrentScreen.Value = SearchConfigEditorScreen.Summary; + BeginBackendSelection(); RequestRedraw(); } public void DismissDialog() { ActiveDialog.Value = SearchConfigEditorDialog.None; + Status.Value = new ConfigStatusMessage(string.Empty, ConfigStatusTone.Neutral); RequestRedraw(); } - public async Task TestCurrentConfigurationAsync(CancellationToken ct = default) + public async Task<bool> SubmitCurrentConfigurationAsync(CancellationToken ct = default) { - Revalidate(); - if (_validation.HasErrors) + if (CurrentProviderField is { } field) { - Status.Value = new ConfigStatusMessage( - "Fix structural validation errors before testing this search configuration.", - ConfigStatusTone.Error); - RequestRedraw(); - return; + var result = CommitField(field.Path, FieldValues[field.Path].Value); + if (!result.Success) + { + CurrentScreen.Value = SearchConfigEditorScreen.Entry; + RequestRedraw(); + return false; + } + } + else + { + ClearTransientProbeState(); + Revalidate(); + if (_validation.HasErrors) + { + Status.Value = BuildValidationErrorStatus("Fix structural validation errors before continuing."); + CurrentScreen.Value = SearchConfigEditorScreen.Entry; + RequestRedraw(); + return false; + } } - _lastProbeResult = await ProbeAsync(ct); - Status.Value = new ConfigStatusMessage(_lastProbeResult.Message, _lastProbeResult.Tone); - RequestRedraw(); + return await RunDynamicValidationAsync(persistOnSuccess: true, ct); } - public async Task SaveAsync(CancellationToken ct = default) + public async Task TestCurrentConfigurationAsync(CancellationToken ct = default) { Revalidate(); if (_validation.HasErrors) { - Status.Value = new ConfigStatusMessage( - "Fix structural validation errors before saving.", - ConfigStatusTone.Error); + Status.Value = BuildValidationErrorStatus( + "Fix structural validation errors before testing this search configuration."); + CurrentScreen.Value = SearchConfigEditorScreen.Entry; RequestRedraw(); return; } - _lastProbeResult = await ProbeAsync(ct); - if (!_lastProbeResult.Success) - { - Status.Value = new ConfigStatusMessage(_lastProbeResult.Message, ConfigStatusTone.Warning); - ActiveDialog.Value = SearchConfigEditorDialog.ProbeWarning; - RequestRedraw(); - return; - } - - SaveWithoutProbeOverride(); + await RunDynamicValidationAsync(persistOnSuccess: false, ct); } + public async Task SaveAsync(CancellationToken ct = default) + => await SubmitCurrentConfigurationAsync(ct); + public void SaveWithoutProbeOverride() { + CancelValidationSpinner(); _mapper.Save(_paths, _model); - _model = _mapper.Load(_paths); - SyncAllFieldValues(); - Revalidate(); + ReloadPersistedDraft(); ActiveDialog.Value = SearchConfigEditorDialog.None; - CurrentScreen.Value = SearchConfigEditorScreen.Summary; + CurrentScreen.Value = SearchConfigEditorScreen.Saved; Status.Value = new ConfigStatusMessage("Saved Search settings.", ConfigStatusTone.Success); RequestRedraw(); } public void ResetDraft() { - _model = _mapper.Load(_paths); - SyncAllFieldValues(); - _lastProbeResult = null; - Revalidate(); + ReloadPersistedDraft(); Status.Value = new ConfigStatusMessage("Reverted unsaved Search edits.", ConfigStatusTone.Neutral); RequestRedraw(); } public void NavigateBack() { + CancelValidationSpinner(); + ReloadPersistedDraft(); + Status.Value = new ConfigStatusMessage(string.Empty, ConfigStatusTone.Neutral); RouteRequested?.Invoke("/config"); Navigate?.Invoke("/config"); } public void RequestQuit() { + CancelValidationSpinner(); ShutdownRequestedForTest = true; Shutdown(); } @@ -343,18 +377,31 @@ private void ClearTransientProbeState() Status.Value = new ConfigStatusMessage(string.Empty, ConfigStatusTone.Neutral); } - private void ApplyFieldValue(string path, string? value) + private static SearchEditorModel CloneModel(SearchEditorModel source) + { + var clone = new SearchEditorModel + { + Backend = source.Backend, + }; + + clone.Brave.ApiKeyDraft = source.Brave.ApiKeyDraft; + clone.Brave.HasPersistedApiKey = source.Brave.HasPersistedApiKey; + clone.SearXng.Endpoint = source.SearXng.Endpoint; + return clone; + } + + private static void ApplyFieldValue(SearchEditorModel model, string path, string? value) { switch (path) { case "Search.Backend": - _model.Backend = ParseBackend(value); + model.Backend = ParseBackend(value); break; case "Search.BraveApiKey": - _model.Brave.ApiKeyDraft = Normalize(value); + model.Brave.ApiKeyDraft = Normalize(value); break; case "Search.SearXngEndpoint": - _model.SearXng.Endpoint = Normalize(value); + model.SearXng.Endpoint = Normalize(value); break; default: throw new InvalidOperationException($"Unknown search config field '{path}'."); @@ -382,6 +429,79 @@ private void SyncAllFieldValues() SyncFieldValue(field.Path); } + private void ReloadPersistedDraft() + { + CancelValidationSpinner(); + _model = _mapper.Load(_paths); + SyncAllFieldValues(); + _lastProbeResult = null; + Revalidate(); + ActiveDialog.Value = SearchConfigEditorDialog.None; + CurrentScreen.Value = SearchConfigEditorScreen.ProviderSelection; + } + + private async Task<bool> RunDynamicValidationAsync(bool persistOnSuccess, CancellationToken ct) + { + ActiveDialog.Value = SearchConfigEditorDialog.None; + CurrentScreen.Value = SearchConfigEditorScreen.Validating; + Status.Value = new ConfigStatusMessage(string.Empty, ConfigStatusTone.Neutral); + StartValidationSpinner(ct); + RequestRedraw(); + + _lastProbeResult = await ProbeAsync(ct); + if (!_lastProbeResult.Success) + { + CancelValidationSpinner(); + CurrentScreen.Value = SearchConfigEditorScreen.Entry; + Status.Value = new ConfigStatusMessage(_lastProbeResult.Message, ConfigStatusTone.Warning); + ActiveDialog.Value = SearchConfigEditorDialog.ProbeWarning; + RequestRedraw(); + return false; + } + + CancelValidationSpinner(); + if (persistOnSuccess) + { + SaveWithoutProbeOverride(); + return true; + } + + CurrentScreen.Value = SearchConfigEditorScreen.Entry; + Status.Value = new ConfigStatusMessage(_lastProbeResult.Message, _lastProbeResult.Tone); + RequestRedraw(); + return true; + } + + private void StartValidationSpinner(CancellationToken ct) + { + CancelValidationSpinner(); + ValidationSpinnerTick.Value = 0; + _validationSpinnerCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + _ = RunValidationSpinnerAsync(_validationSpinnerCts.Token); + } + + private void CancelValidationSpinner() + { + _validationSpinnerCts?.Cancel(); + _validationSpinnerCts?.Dispose(); + _validationSpinnerCts = null; + ValidationSpinnerTick.Value = 0; + } + + private async Task RunValidationSpinnerAsync(CancellationToken ct) + { + var tick = 0; + while (!ct.IsCancellationRequested) + { + try { await Task.Delay(120, ct); } + catch (OperationCanceledException) { return; } + + tick++; + ValidationSpinnerTick.Value = tick; + RequestRedraw(); + } + } + private async Task<SearchProbeResult> ProbeAsync(CancellationToken ct) { try @@ -389,7 +509,7 @@ private async Task<SearchProbeResult> ProbeAsync(CancellationToken ct) ISearchBackend searchBackend = _model.Backend switch { SearchBackend.Brave => new BraveSearchBackend( - _model.Brave.ApiKeyDraft ?? string.Empty, + GetEffectiveBraveApiKey(), CreateHttpClient(), _timeProvider), SearchBackend.SearXng => new SearXngBackend( @@ -416,6 +536,17 @@ private async Task<SearchProbeResult> ProbeAsync(CancellationToken ct) private HttpClient CreateHttpClient() => _httpClientFactory?.CreateClient(string.Empty) ?? new HttpClient(); + private string GetEffectiveBraveApiKey() + { + if (!string.IsNullOrWhiteSpace(_model.Brave.ApiKeyDraft)) + return _model.Brave.ApiKeyDraft; + + var secrets = ConfigFileHelper.LoadJsonDict(_paths.SecretsPath); + return ConfigFileHelper.TryGetPathValue(secrets, "Search.BraveApiKey", out var braveRaw) + ? ConfigFileHelper.DecryptIfEncrypted(_paths, braveRaw?.ToString()) ?? string.Empty + : string.Empty; + } + private bool HasEffectiveBraveKey() => !string.IsNullOrWhiteSpace(_model.Brave.ApiKeyDraft) || _model.Brave.HasPersistedApiKey; @@ -427,6 +558,15 @@ private bool ComputeIsDirty() || !string.IsNullOrWhiteSpace(_model.Brave.ApiKeyDraft); } + private ConfigStatusMessage BuildValidationErrorStatus(string fallbackMessage) + { + var issue = GetCurrentProviderIssues().FirstOrDefault() + ?? ValidationSummary.Value.Issues.FirstOrDefault(); + return issue is null + ? new ConfigStatusMessage(fallbackMessage, ConfigStatusTone.Error) + : new ConfigStatusMessage(issue.Message, ConfigStatusTone.Error); + } + private static SearchBackend ParseBackend(string? value) => value?.Trim().ToLowerInvariant() switch { diff --git a/src/Netclaw.Cli/Tui/Config/SearchEditorModel.cs b/src/Netclaw.Cli/Tui/Config/SearchEditorModel.cs index 0db87152c..0e06ab8a1 100644 --- a/src/Netclaw.Cli/Tui/Config/SearchEditorModel.cs +++ b/src/Netclaw.Cli/Tui/Config/SearchEditorModel.cs @@ -101,10 +101,8 @@ internal void Save(NetclawPaths paths, SearchEditorModel model) ConfigFileHelper.SetPathValue(config, "Search.Backend", model.Backend.ToWireValue()); - if (model.Backend == SearchBackend.SearXng && !string.IsNullOrWhiteSpace(model.SearXng.Endpoint)) + if (!string.IsNullOrWhiteSpace(model.SearXng.Endpoint)) ConfigFileHelper.SetPathValue(config, "Search.SearXngEndpoint", model.SearXng.Endpoint); - else - ConfigFileHelper.RemovePath(config, "Search.SearXngEndpoint"); if (model.Backend == SearchBackend.Brave && !string.IsNullOrWhiteSpace(model.Brave.ApiKeyDraft)) ConfigFileHelper.SetPathValue(secrets, "Search.BraveApiKey", model.Brave.ApiKeyDraft); diff --git a/tests/smoke/assertions/config-search.sh b/tests/smoke/assertions/config-search.sh index 87b510438..0057c2a23 100755 --- a/tests/smoke/assertions/config-search.sh +++ b/tests/smoke/assertions/config-search.sh @@ -1,9 +1,9 @@ #!/usr/bin/env bash # config-search.tape post-tape assertion. # -# Validates the redesigned Search flow persisted the expected DuckDuckGo -# backend back into netclaw.json and that no Brave API key leaked into the -# main config file. +# Validates the Search workflow persisted the expected SearXNG backend after +# dynamic validation failed and the operator chose save anyway, and that no +# Brave API key leaked into the main config file. set -euo pipefail @@ -19,7 +19,8 @@ fi config_json="$(read_config_json)" -assert_field '.Search.Backend' 'duckduckgo' "$config_json" || : +assert_field '.Search.Backend' 'searxng' "$config_json" || : +assert_field '.Search.SearXngEndpoint' 'https://search.test.local' "$config_json" || : assert_field '(.Search | has("BraveApiKey"))' 'false' "$config_json" || : if (( assert_fail )); then diff --git a/tests/smoke/tapes/config-search.tape b/tests/smoke/tapes/config-search.tape index 9e0ee90fe..d1cf1c7c4 100644 --- a/tests/smoke/tapes/config-search.tape +++ b/tests/smoke/tapes/config-search.tape @@ -1,11 +1,11 @@ -# config-search.tape — drive `netclaw config` into the redesigned Search flow. +# config-search.tape — drive `netclaw config` into the Search workflow. # # Covers: -# - dashboard -> Search provider matrix -# - provider selection happens directly from arrow navigation -# - invalid save blocked on missing Brave API key -# - provider switch back to DuckDuckGo from the same screen -# - exit Search, re-enter it, and confirm test + SearXNG entry still work +# - dashboard -> Search launcher workflow +# - provider selection -> entry state +# - static validation failure preserves typed input +# - dynamic validation failure opens explicit override flow +# - save-anyway persists and returns cleanly # # Post-tape assertion validates the Search section persisted to netclaw.json. @@ -27,39 +27,30 @@ Wait+Screen@10s /Settings Areas/ Down 5 Enter -# ─── Search provider matrix ─────────────────────────────────────────────── -Wait+Screen@10s /Providers/ -Wait+Screen@5s /No additional setup required/ +# ─── Search provider selection ──────────────────────────────────────────── +Wait+Screen@10s /Search/ +Wait+Screen@5s /DuckDuckGo works without setup/ -# ─── Select Brave in-place and confirm invalid save stays inline ────────── +# ─── Select Brave and confirm static validation blocks progression ───────── Down -Wait+Screen@10s /Brave API key/ -Wait+Screen@5s /Brave API key/ -Type "s" +Enter +Wait+Screen@10s /Brave Search requires an API key/ +Wait+Screen@5s /Stored in secrets.json/ +Enter Wait+Screen@10s /Brave requires an API key/ - -# ─── Switch provider back to DuckDuckGo ─────────────────────────────────── -Up -Wait+Screen@10s /\(\*\) DuckDuckGo/ - -# ─── Leave Search, then re-enter to validate preserved-page lifecycle ───── Escape -Wait+Screen@10s /Settings Areas/ -Down 5 -Enter -Wait+Screen@10s /\(\*\) DuckDuckGo/ -Type "t" -Wait+Screen@10s /Last test:/ -Down 2 -Wait+Screen@10s /SearXng instance URL/ -Wait+Screen@5s /Enter the base URL of your SearXNG instance/ + +# ─── Select SearXNG and complete happy path ─────────────────────────────── +Down Enter -Wait+Screen@10s /Press Enter to apply or Esc to cancel edit\./ +Wait+Screen@10s /Enter the base URL of your SearXNG instance/ Type "https://search.test.local" Enter -Wait+Screen@10s /https:\/\/search.test.local/ -Escape -Wait+Screen@10s /Unsaved changes\./ +Wait+Screen@10s /Validating Search configuration/ +Wait+Screen@10s /Search Validation Warning/ +Down 2 +Enter +Wait+Screen@10s /validated and saved/ Escape Wait+Screen@10s /Settings Areas/ diff --git a/tests/smoke/tapes/screenshots/config-search.tape b/tests/smoke/tapes/screenshots/config-search.tape index c47f7741e..45049ad2e 100644 --- a/tests/smoke/tapes/screenshots/config-search.tape +++ b/tests/smoke/tapes/screenshots/config-search.tape @@ -1,9 +1,9 @@ -# config-search.tape (screenshot) — capture the redesigned Search flow screens. +# config-search.tape (screenshot) — capture the Search workflow screens. # # Frames captured: -# shot-config-search-matrix -# shot-config-search-brave -# shot-config-search-searxng-edit +# shot-config-search-selection +# shot-config-search-brave-entry +# shot-config-search-saved Output "/tmp/tape-shot-config-search.gif" @@ -21,26 +21,33 @@ Wait+Screen@10s /Settings Areas/ Down 5 Enter -# ─── Frame 1: Default provider matrix ────────────────────────────────────── -Wait+Screen@10s /\(\*\) DuckDuckGo/ +# ─── Frame 1: Provider selection ─────────────────────────────────────────── +Wait+Screen@10s /Choose the backend Netclaw uses for web search/ Sleep 1s -Screenshot "/tmp/shot-config-search-matrix.png" +Screenshot "/tmp/shot-config-search-selection.png" Sleep 1s -# ─── Frame 2: Brave selected in matrix ───────────────────────────────────── +# ─── Frame 2: Brave entry state ──────────────────────────────────────────── Down -Wait+Screen@10s /Brave API key/ +Enter +Wait+Screen@10s /Brave Search requires an API key/ Sleep 1s -Screenshot "/tmp/shot-config-search-brave.png" +Screenshot "/tmp/shot-config-search-brave-entry.png" Sleep 1s -# ─── Frame 3: SearXNG inline edit mode ───────────────────────────────────── +# ─── Frame 3: Saved state ────────────────────────────────────────────────── +Escape Down -Wait+Screen@10s /SearXng instance URL/ Enter -Wait+Screen@10s /Press Enter to apply or Esc to cancel edit\./ +Wait+Screen@10s /Enter the base URL of your SearXNG instance/ +Type "https://search.test.local" +Enter +Wait+Screen@10s /Search Validation Warning/ +Down 2 +Enter +Wait+Screen@10s /validated and saved/ Sleep 1s -Screenshot "/tmp/shot-config-search-searxng-edit.png" +Screenshot "/tmp/shot-config-search-saved.png" Sleep 1s Ctrl+Q From f086d8d6a398549df3762636e5a45d55908561f5 Mon Sep 17 00:00:00 2001 From: Aaron Stannard <aaron@petabridge.com> Date: Wed, 27 May 2026 18:53:42 +0000 Subject: [PATCH 12/31] refine(config): keep search save flow in context Return Esc from the saved state to the Search backend list instead of exiting the editor. Clarify the provider markers so active and configured backends are visually distinct. --- .../Tui/Config/SearchConfigEditorPageTests.cs | 101 ++++++++++++++++++ .../Tui/Config/SearchConfigEditorPage.cs | 15 ++- tests/smoke/tapes/config-search.tape | 4 +- 3 files changed, 115 insertions(+), 5 deletions(-) create mode 100644 src/Netclaw.Cli.Tests/Tui/Config/SearchConfigEditorPageTests.cs diff --git a/src/Netclaw.Cli.Tests/Tui/Config/SearchConfigEditorPageTests.cs b/src/Netclaw.Cli.Tests/Tui/Config/SearchConfigEditorPageTests.cs new file mode 100644 index 000000000..4596bfd01 --- /dev/null +++ b/src/Netclaw.Cli.Tests/Tui/Config/SearchConfigEditorPageTests.cs @@ -0,0 +1,101 @@ +// ----------------------------------------------------------------------- +// <copyright file="SearchConfigEditorPageTests.cs" company="Petabridge, LLC"> +// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com> +// </copyright> +// ----------------------------------------------------------------------- +using Microsoft.Extensions.DependencyInjection; +using Netclaw.Cli.Tui.Config; +using Netclaw.Configuration; +using Netclaw.Tests.Utilities; +using Termina; +using Termina.Hosting; +using Termina.Input; +using Termina.Terminal; +using Xunit; + +namespace Netclaw.Cli.Tests.Tui.Config; + +public sealed class SearchConfigEditorPageTests : IDisposable +{ + private readonly DisposableTempDir _dir = new(); + private readonly NetclawPaths _paths; + + public SearchConfigEditorPageTests() + { + _paths = new NetclawPaths(_dir.Path); + _paths.EnsureDirectoriesExist(); + File.WriteAllText(_paths.NetclawConfigPath, """ + { + "configVersion": 1, + "Search": { + "Backend": "duckduckgo" + } + } + """); + } + + public void Dispose() => _dir.Dispose(); + + [Fact] + public async Task ProviderSelection_RendersActiveAndConfiguredLegend() + { + var (terminal, app, _) = CreateHeadlessApp(out var input); + input.EnqueueKey(ConsoleKey.Q, false, false, true); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await app.RunAsync(cts.Token); + + Assert.True(terminal.Contains("(*) active backend"), + $"Expected active-backend legend in terminal output. Screen:\n{terminal}"); + Assert.True(terminal.Contains("backend has saved setup"), + $"Expected configured-backend legend in terminal output. Screen:\n{terminal}"); + } + + [Fact] + public async Task SavedScreen_EscapeReturnsToProviderSelection() + { + var (terminal, app, vm) = CreateHeadlessApp(out var input); + + vm.SaveWithoutProbeOverride(); + + input.EnqueueKey(ConsoleKey.Escape); + input.EnqueueKey(ConsoleKey.Q, false, false, true); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await app.RunAsync(cts.Token); + + Assert.Equal(SearchConfigEditorScreen.ProviderSelection, vm.CurrentScreen.Value); + Assert.True(terminal.Contains("Choose the backend Netclaw uses for web search."), + $"Expected provider selection screen after Esc from saved state. Screen:\n{terminal}"); + } + + private (VirtualTerminal Terminal, TerminaApplication App, SearchConfigEditorViewModel Vm) + CreateHeadlessApp(out VirtualInputSource input) + { + var terminal = new VirtualTerminal(120, 40); + var virtualInput = new VirtualInputSource(); + input = virtualInput; + + SearchConfigEditorViewModel? capturedVm = null; + + var services = new ServiceCollection(); + services.AddSingleton<IAnsiTerminal>(terminal); + services.AddTerminaVirtualInput(virtualInput); + services.AddTermina("/search", builder => + { + builder.RegisterRoute<SearchConfigEditorPage, SearchConfigEditorViewModel>( + "/search", + _ => new SearchConfigEditorPage(), + _ => + { + capturedVm = new SearchConfigEditorViewModel(_paths); + return capturedVm; + }); + }); + + var sp = services.BuildServiceProvider(); + var app = sp.GetRequiredService<TerminaApplication>(); + + return (terminal, app, capturedVm!); + } +} diff --git a/src/Netclaw.Cli/Tui/Config/SearchConfigEditorPage.cs b/src/Netclaw.Cli/Tui/Config/SearchConfigEditorPage.cs index 887de3ba9..45b7767b9 100644 --- a/src/Netclaw.Cli/Tui/Config/SearchConfigEditorPage.cs +++ b/src/Netclaw.Cli/Tui/Config/SearchConfigEditorPage.cs @@ -94,6 +94,7 @@ private ILayoutNode BuildProviderSelectionScreen() .WithSpacing(1) .WithChild(new TextNode(" Choose the backend Netclaw uses for web search.").WithForeground(Color.White)) .WithChild(BuildProviderList()) + .WithChild(new TextNode(" (*) active backend ✓ backend has saved setup").WithForeground(Color.Gray)) .WithChild(new TextNode($" {GetProviderDescription(ViewModel.BackendOptions[_providerIndex].Value)}").WithForeground(Color.Gray)); } @@ -136,7 +137,7 @@ private ILayoutNode BuildSavedScreen() => Layouts.Vertical() .WithSpacing(1) .WithChild(new TextNode($" \u2714 {ViewModel.CurrentBackendLabel} validated and saved.").WithForeground(Color.Green)) - .WithChild(new TextNode(" Press Esc to return to Settings Areas or Up/Down to review providers.") + .WithChild(new TextNode(" Press Esc to return to Search backends or Up/Down to review providers.") .WithForeground(Color.Gray)); private ILayoutNode BuildProviderList() @@ -238,9 +239,6 @@ private LayoutNode BuildKeyBindings() public override bool HandlePageInput(ConsoleKeyInfo keyInfo) { - if (base.HandlePageInput(keyInfo)) - return true; - if (keyInfo.Key == ConsoleKey.Q && keyInfo.Modifiers.HasFlag(ConsoleModifiers.Control)) { ViewModel.RequestQuit(); @@ -262,10 +260,19 @@ public override bool HandlePageInput(ConsoleKeyInfo keyInfo) return true; } + if (ViewModel.CurrentScreen.Value == SearchConfigEditorScreen.Saved) + { + BeginProviderSelection(); + return true; + } + ViewModel.NavigateBack(); return true; } + if (base.HandlePageInput(keyInfo)) + return true; + if (ViewModel.ActiveDialog.Value == SearchConfigEditorDialog.ProbeWarning) { _dialogList?.HandleInput(keyInfo); diff --git a/tests/smoke/tapes/config-search.tape b/tests/smoke/tapes/config-search.tape index d1cf1c7c4..7136bb69c 100644 --- a/tests/smoke/tapes/config-search.tape +++ b/tests/smoke/tapes/config-search.tape @@ -52,9 +52,11 @@ Down 2 Enter Wait+Screen@10s /validated and saved/ Escape -Wait+Screen@10s /Settings Areas/ +Wait+Screen@10s /Choose the backend Netclaw uses for web search/ # ─── Back out to shell ──────────────────────────────────────────────────── +Escape +Wait+Screen@10s /Settings Areas/ Ctrl+Q Wait+Screen@10s /TAPE\$/ From 7d13ae8165f758b5827d4f3067fcebf84581c841 Mon Sep 17 00:00:00 2001 From: Aaron Stannard <aaron@petabridge.com> Date: Thu, 28 May 2026 00:21:34 +0000 Subject: [PATCH 13/31] feat(config): add workflow editor pilot --- scripts/smoke/run-smoke.sh | 2 +- .../ExposureModeConfigViewModelTests.cs | 106 +++++++++++ .../Tui/Config/SearchSectionSpecTests.cs | 45 +++++ .../Config/SecurityAccessViewModelTests.cs | 58 ++++++ .../Tui/ConfigDashboardViewModelTests.cs | 12 ++ .../Tui/Wizard/MenuRegistryAuditTests.cs | 8 +- .../Tui/Wizard/SectionEditorLeafTests.cs | 35 ++++ .../Tui/Wizard/WizardConfigScenarioTests.cs | 55 +----- src/Netclaw.Cli/Program.cs | 6 + .../Tui/Config/ExposureModeConfigPage.cs | 171 ++++++++++++++++++ .../Tui/Config/ExposureModeConfigViewModel.cs | 115 ++++++++++++ .../Tui/Config/SearchConfigEditorPage.cs | 83 +++------ .../Tui/Config/SearchConfigEditorViewModel.cs | 98 ++-------- .../Tui/Config/SearchSectionSpec.cs | 145 +++++++++++++++ .../Tui/Config/SecurityAccessPage.cs | 97 ++++++++++ .../Tui/Config/SecurityAccessViewModel.cs | 151 ++++++++++++++++ .../Tui/ConfigDashboardViewModel.cs | 2 +- src/Netclaw.Cli/Tui/InitWizardViewModel.cs | 5 +- .../Tui/Wizard/Steps/ExposureModeStepView.cs | 46 ++--- .../Wizard/Steps/ExposureModeStepViewModel.cs | 141 ++++++++++++++- .../Tui/Wizard/WizardConfigBuilder.cs | 16 +- .../Tui/Workflow/WorkflowViewComponents.cs | 113 ++++++++++++ .../ConfigValueMetadataProviderTests.cs | 43 +++++ .../ConfigValueAttribute.cs | 69 +++++++ src/Netclaw.Configuration/McpOAuthTokenSet.cs | 3 + src/Netclaw.Configuration/SearchConfig.cs | 3 + tests/smoke/assertions/config-exposure.sh | 29 +++ .../assertions/init-wizard-reverse-proxy.sh | 70 ------- tests/smoke/tapes/config-exposure.tape | 66 +++++++ .../tapes/init-wizard-reverse-proxy.tape | 148 --------------- tests/smoke/tapes/init-wizard.tape | 12 +- 31 files changed, 1486 insertions(+), 467 deletions(-) create mode 100644 src/Netclaw.Cli.Tests/Tui/Config/ExposureModeConfigViewModelTests.cs create mode 100644 src/Netclaw.Cli.Tests/Tui/Config/SearchSectionSpecTests.cs create mode 100644 src/Netclaw.Cli.Tests/Tui/Config/SecurityAccessViewModelTests.cs create mode 100644 src/Netclaw.Cli/Tui/Config/ExposureModeConfigPage.cs create mode 100644 src/Netclaw.Cli/Tui/Config/ExposureModeConfigViewModel.cs create mode 100644 src/Netclaw.Cli/Tui/Config/SearchSectionSpec.cs create mode 100644 src/Netclaw.Cli/Tui/Config/SecurityAccessPage.cs create mode 100644 src/Netclaw.Cli/Tui/Config/SecurityAccessViewModel.cs create mode 100644 src/Netclaw.Cli/Tui/Workflow/WorkflowViewComponents.cs create mode 100644 src/Netclaw.Configuration.Tests/ConfigValueMetadataProviderTests.cs create mode 100644 src/Netclaw.Configuration/ConfigValueAttribute.cs create mode 100755 tests/smoke/assertions/config-exposure.sh delete mode 100755 tests/smoke/assertions/init-wizard-reverse-proxy.sh create mode 100644 tests/smoke/tapes/config-exposure.tape delete mode 100644 tests/smoke/tapes/init-wizard-reverse-proxy.tape diff --git a/scripts/smoke/run-smoke.sh b/scripts/smoke/run-smoke.sh index 7b19e0864..2fc7bca43 100755 --- a/scripts/smoke/run-smoke.sh +++ b/scripts/smoke/run-smoke.sh @@ -54,7 +54,7 @@ SMOKE_LOG_DIR="${SMOKE_LOG_DIR:-${ROOT_DIR}/smoke-logs}" # Cheapest harness checks first so a harness-level break fails fast # before paying for the wizard + probe tapes. -LIGHT_TAPES=(help init-wizard init-wizard-reverse-proxy provider-add provider-rename config-search tui-cleanup) +LIGHT_TAPES=(help init-wizard config-exposure provider-add provider-rename config-search tui-cleanup) FULL_TAPES=("${LIGHT_TAPES[@]}") LIGHT_SCENARIOS=( diff --git a/src/Netclaw.Cli.Tests/Tui/Config/ExposureModeConfigViewModelTests.cs b/src/Netclaw.Cli.Tests/Tui/Config/ExposureModeConfigViewModelTests.cs new file mode 100644 index 000000000..c849eb5aa --- /dev/null +++ b/src/Netclaw.Cli.Tests/Tui/Config/ExposureModeConfigViewModelTests.cs @@ -0,0 +1,106 @@ +// ----------------------------------------------------------------------- +// <copyright file="ExposureModeConfigViewModelTests.cs" company="Petabridge, LLC"> +// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com> +// </copyright> +// ----------------------------------------------------------------------- +using Netclaw.Cli.Config; +using Netclaw.Cli.Tests.Tui.Wizard; +using Netclaw.Cli.Tui.Config; +using Netclaw.Configuration; +using Xunit; + +namespace Netclaw.Cli.Tests.Tui.Config; + +public sealed class ExposureModeConfigViewModelTests : WizardStepTestBase +{ + [Fact] + public void Constructor_prefills_existing_exposure_mode() + { + File.WriteAllText(Context.Paths.NetclawConfigPath, + """ + { + "configVersion": 1, + "Daemon": { + "ExposureMode": "reverse-proxy", + "Host": "10.0.0.5", + "TrustedProxies": ["10.0.0.0/24"] + } + } + """); + + using var vm = new ExposureModeConfigViewModel(Context.Paths); + + Assert.Equal(ExposureMode.ReverseProxy, vm.Step.SelectedMode); + Assert.Equal("10.0.0.5", vm.Step.Host); + Assert.Equal(["10.0.0.0/24"], vm.Step.TrustedProxies); + } + + [Fact] + public void Saving_tunnel_mode_preserves_unrelated_daemon_fields() + { + File.WriteAllText(Context.Paths.NetclawConfigPath, + """ + { + "configVersion": 1, + "Daemon": { + "ExposureMode": "local", + "Host": "127.0.0.1", + "Port": 5299, + "DisableSelfUpdate": true + } + } + """); + + using var vm = new ExposureModeConfigViewModel(Context.Paths); + vm.Step.SelectedMode = ExposureMode.TailscaleServe; + + vm.GoNext(); + vm.GoNext(); + + var config = ConfigFileHelper.LoadJsonDict(Context.Paths.NetclawConfigPath); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Daemon.ExposureMode", out var mode)); + Assert.Equal("tailscale-serve", mode); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Daemon.Port", out var port)); + Assert.Equal(5299L, port); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Daemon.DisableSelfUpdate", out var disableSelfUpdate)); + Assert.Equal(true, disableSelfUpdate); + Assert.False(ConfigFileHelper.TryGetPathValue(config, "Daemon.Host", out _)); + Assert.True(vm.IsSaved.Value); + } + + [Fact] + public void Saving_reverse_proxy_writes_mode_specific_fields() + { + using var vm = new ExposureModeConfigViewModel(Context.Paths); + vm.Step.SelectedMode = ExposureMode.ReverseProxy; + vm.Step.Host = "10.0.0.5"; + vm.Step.TrustedProxies = ["10.0.0.0/24"]; + + vm.GoNext(); + vm.GoNext(); + vm.GoNext(); + vm.GoNext(); + + var config = ConfigFileHelper.LoadJsonDict(Context.Paths.NetclawConfigPath); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Daemon.ExposureMode", out var mode)); + Assert.Equal("reverse-proxy", mode); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Daemon.Host", out var host)); + Assert.Equal("10.0.0.5", host); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Daemon.TrustedProxies", out var proxies)); + Assert.Equal(["10.0.0.0/24"], Assert.IsType<object[]>(proxies).Select(static item => item.ToString() ?? string.Empty).ToArray()); + } + + [Fact] + public void Escape_from_saved_state_returns_to_mode_selection_before_parent_route() + { + using var vm = new ExposureModeConfigViewModel(Context.Paths); + + vm.GoNext(); + Assert.True(vm.IsSaved.Value); + + vm.GoBack(); + + Assert.False(vm.IsSaved.Value); + Assert.Equal(0, vm.Step.CurrentSubStep); + } +} diff --git a/src/Netclaw.Cli.Tests/Tui/Config/SearchSectionSpecTests.cs b/src/Netclaw.Cli.Tests/Tui/Config/SearchSectionSpecTests.cs new file mode 100644 index 000000000..4149d772b --- /dev/null +++ b/src/Netclaw.Cli.Tests/Tui/Config/SearchSectionSpecTests.cs @@ -0,0 +1,45 @@ +// ----------------------------------------------------------------------- +// <copyright file="SearchSectionSpecTests.cs" company="Petabridge, LLC"> +// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com> +// </copyright> +// ----------------------------------------------------------------------- +using Netclaw.Configuration; +using Netclaw.Cli.Tui.Config; +using Xunit; + +namespace Netclaw.Cli.Tests.Tui.Config; + +public sealed class SearchSectionSpecTests +{ + [Fact] + public void Fields_are_projected_from_runtime_config_metadata_keys() + { + var spec = new SearchSectionSpec(); + + Assert.Contains(spec.Fields, field => field.Path == "Search.Backend"); + + var brave = Assert.Single(spec.Fields, field => field.Path == "Search.BraveApiKey"); + Assert.Equal(ConfigFieldStorage.SecretsFile, brave.Storage); + Assert.Equal(ConfigFieldWidget.PasswordInput, brave.Widget); + Assert.True(brave.PreserveBlankSecret); + + var searXng = Assert.Single(spec.Fields, field => field.Path == "Search.SearXngEndpoint"); + Assert.Equal(ConfigFieldStorage.ConfigFile, searXng.Storage); + Assert.Equal(ConfigFieldWidget.TextInput, searXng.Widget); + } + + [Fact] + public void Provider_field_follows_selected_backend() + { + var spec = new SearchSectionSpec(); + var model = new SearchEditorModel { Backend = SearchBackend.Brave }; + + Assert.Equal("Search.BraveApiKey", spec.GetProviderField(model)?.Path); + + model.Backend = SearchBackend.SearXng; + Assert.Equal("Search.SearXngEndpoint", spec.GetProviderField(model)?.Path); + + model.Backend = SearchBackend.DuckDuckGo; + Assert.Null(spec.GetProviderField(model)); + } +} diff --git a/src/Netclaw.Cli.Tests/Tui/Config/SecurityAccessViewModelTests.cs b/src/Netclaw.Cli.Tests/Tui/Config/SecurityAccessViewModelTests.cs new file mode 100644 index 000000000..bbcd1128d --- /dev/null +++ b/src/Netclaw.Cli.Tests/Tui/Config/SecurityAccessViewModelTests.cs @@ -0,0 +1,58 @@ +// ----------------------------------------------------------------------- +// <copyright file="SecurityAccessViewModelTests.cs" company="Petabridge, LLC"> +// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com> +// </copyright> +// ----------------------------------------------------------------------- +using Netclaw.Cli.Tui.Config; +using Netclaw.Cli.Tests.Tui.Wizard; +using Xunit; + +namespace Netclaw.Cli.Tests.Tui.Config; + +public sealed class SecurityAccessViewModelTests : WizardStepTestBase +{ + [Fact] + public void Security_access_lists_expected_leaf_entries() + { + using var vm = new SecurityAccessViewModel(Context.Paths); + + var labels = vm.Items.Select(static item => item.Label).ToArray(); + + Assert.Equal( + [ + "Security Posture", + "Enabled Features", + "Audience Profiles", + "Exposure Mode" + ], labels); + } + + [Fact] + public void Exposure_mode_routes_to_exposure_editor() + { + using var vm = new SecurityAccessViewModel(Context.Paths); + string? route = null; + vm.RouteRequested = value => route = value; + + vm.Activate(vm.Items.Single(static item => item.Label == "Exposure Mode")); + + Assert.Equal("/exposure-mode", route); + } + + [Fact] + public void Exposure_summary_reads_existing_daemon_mode() + { + File.WriteAllText(Context.Paths.NetclawConfigPath, + """ + { + "configVersion": 1, + "Daemon": { "ExposureMode": "cloudflare-tunnel" } + } + """); + + using var vm = new SecurityAccessViewModel(Context.Paths); + + var exposure = vm.Items.Single(static item => item.Label == "Exposure Mode"); + Assert.Equal("Cloudflare Tunnel", exposure.Summary); + } +} diff --git a/src/Netclaw.Cli.Tests/Tui/ConfigDashboardViewModelTests.cs b/src/Netclaw.Cli.Tests/Tui/ConfigDashboardViewModelTests.cs index bb8d7f6c4..6ce9b0a84 100644 --- a/src/Netclaw.Cli.Tests/Tui/ConfigDashboardViewModelTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/ConfigDashboardViewModelTests.cs @@ -57,6 +57,18 @@ public void Models_routes_to_model_page() Assert.Equal("/model", navigatedRoute); } + [Fact] + public void Security_access_routes_to_security_page() + { + using var vm = new ConfigDashboardViewModel(new ConfigDashboardNavigationState()); + string? navigatedRoute = null; + vm.RouteRequested = route => navigatedRoute = route; + + vm.Activate(vm.Items.Single(static item => item.Label == "Security & Access")); + + Assert.Equal("/security", navigatedRoute); + } + [Fact] public void Run_full_doctor_sets_pending_action_and_shuts_down() { diff --git a/src/Netclaw.Cli.Tests/Tui/Wizard/MenuRegistryAuditTests.cs b/src/Netclaw.Cli.Tests/Tui/Wizard/MenuRegistryAuditTests.cs index 647ba2826..0587b39f3 100644 --- a/src/Netclaw.Cli.Tests/Tui/Wizard/MenuRegistryAuditTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/Wizard/MenuRegistryAuditTests.cs @@ -22,7 +22,7 @@ public void RegisteredLeafEditors_AreExpectedSet() var registry = services.GetRequiredService<SectionEditorRegistry>(); var ids = registry.Editors.Select(e => e.SectionId).OrderBy(static x => x).ToArray(); - Assert.Equal(["feature-selection", "identity", "provider", "security-posture"], ids); + Assert.Equal(["exposure-mode", "feature-selection", "identity", "provider", "security-posture"], ids); } [Fact] @@ -59,7 +59,8 @@ public void RegisteredLeafEditors_HaveConcreteLeafTestClasses() ["provider"] = nameof(ProviderSectionEditorTests), ["identity"] = nameof(IdentitySectionEditorTests), ["security-posture"] = nameof(SecurityPostureSectionEditorTests), - ["feature-selection"] = nameof(FeatureSelectionSectionEditorTests) + ["feature-selection"] = nameof(FeatureSelectionSectionEditorTests), + ["exposure-mode"] = nameof(ExposureModeSectionEditorTests) }; using var services = BuildServices(); @@ -84,7 +85,8 @@ private static ServiceProvider BuildServices() .AddSectionEditor<ProviderStepViewModel>() .AddSectionEditor<IdentityStepViewModel>() .AddSectionEditor<SecurityPostureStepViewModel>() - .AddSectionEditor<FeatureSelectionStepViewModel>(); + .AddSectionEditor<FeatureSelectionStepViewModel>() + .AddSectionEditor<ExposureModeStepViewModel>(); return services.BuildServiceProvider(); } diff --git a/src/Netclaw.Cli.Tests/Tui/Wizard/SectionEditorLeafTests.cs b/src/Netclaw.Cli.Tests/Tui/Wizard/SectionEditorLeafTests.cs index fe49d32f1..f460809cb 100644 --- a/src/Netclaw.Cli.Tests/Tui/Wizard/SectionEditorLeafTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/Wizard/SectionEditorLeafTests.cs @@ -99,3 +99,38 @@ public void BuildContribution_EmitsEnabledFlagsForAllFeatureLeaves() Assert.Contains(contribution.FieldActionsOrEmpty, a => a.Path == "Webhooks.Enabled"); } } + +public sealed class ExposureModeSectionEditorTests : SectionEditorTestBase<ExposureModeStepViewModel> +{ + [Fact] + public void BuildContribution_ReverseProxy_EmitsExistingDaemonShapeFields() + { + using var editor = CreateEditor(); + editor.SelectedMode = ExposureMode.ReverseProxy; + editor.Host = "10.0.0.5"; + editor.TrustedProxies = ["10.0.0.0/24"]; + + var contribution = editor.BuildContribution(editor); + + Assert.Contains(contribution.FieldActionsOrEmpty, + a => a.Path == "Daemon.ExposureMode" && Equals(a.Value, "reverse-proxy")); + Assert.Contains(contribution.FieldActionsOrEmpty, + a => a.Path == "Daemon.Host" && Equals(a.Value, "10.0.0.5")); + Assert.Contains(contribution.FieldActionsOrEmpty, + a => a.Path == "Daemon.TrustedProxies" && Assert.IsType<string[]>(a.Value).SequenceEqual(["10.0.0.0/24"])); + } + + [Fact] + public void BuildContribution_Local_DropsActiveHostField() + { + using var editor = CreateEditor(); + editor.SelectedMode = ExposureMode.Local; + + var contribution = editor.BuildContribution(editor); + + Assert.Contains(contribution.FieldActionsOrEmpty, + a => a.Path == "Daemon.ExposureMode" && Equals(a.Value, "local")); + Assert.Contains(contribution.FieldActionsOrEmpty, + a => a.Path == "Daemon.Host" && a.Action == SectionFieldActionKind.Delete); + } +} diff --git a/src/Netclaw.Cli.Tests/Tui/Wizard/WizardConfigScenarioTests.cs b/src/Netclaw.Cli.Tests/Tui/Wizard/WizardConfigScenarioTests.cs index c913b1bc3..f26aa8f6c 100644 --- a/src/Netclaw.Cli.Tests/Tui/Wizard/WizardConfigScenarioTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/Wizard/WizardConfigScenarioTests.cs @@ -33,7 +33,6 @@ public void PersonalPosture_MinimalSetup_DoesNotDisableFeatures() var steps = BuildCoreSteps(); EnterAndConfigurePosture(steps, DeploymentPosture.Personal); ConfigureSearch(steps, SearchBackend.Brave); - ConfigureExposure(steps, ExposureMode.Local, webhooks: false); ConfigureIdentity(steps, "Netclaw", "America/Chicago"); var config = AssembleConfig(steps); @@ -54,7 +53,6 @@ public void TeamPosture_AllFeaturesEnabled() EnterAndConfigurePosture(steps, DeploymentPosture.Team); EnterFeatureSelection(steps); ConfigureSearch(steps, SearchBackend.DuckDuckGo); - ConfigureExposure(steps, ExposureMode.TailscaleServe, webhooks: true); ConfigureIdentity(steps, "TeamBot", "UTC"); var config = AssembleConfig(steps); @@ -68,8 +66,7 @@ public void TeamPosture_AllFeaturesEnabled() AssertSectionEnabled(config, "SubAgents", true); AssertSectionEnabled(config, "Webhooks", true); - var daemon = GetSection(config, "Daemon"); - Assert.Equal("tailscale-serve", daemon["ExposureMode"]); + Assert.False(config.ContainsKey("Daemon")); } [Fact] @@ -86,7 +83,6 @@ public void PublicPosture_SelectiveFeatures() featureStep.OnLeave(); ConfigureSearch(steps, SearchBackend.SearXng, searXngEndpoint: "https://search.example.com"); - ConfigureExposure(steps, ExposureMode.TailscaleFunnel, webhooks: false); ConfigureIdentity(steps, "PublicBot", "Europe/London"); var config = AssembleConfig(steps); @@ -103,8 +99,7 @@ public void PublicPosture_SelectiveFeatures() Assert.Equal("searxng", search["Backend"]); Assert.Equal("https://search.example.com", search["SearXngEndpoint"]); - var daemon = GetSection(config, "Daemon"); - Assert.Equal("tailscale-funnel", daemon["ExposureMode"]); + Assert.False(config.ContainsKey("Daemon")); } [Fact] @@ -121,7 +116,6 @@ public void TeamPosture_SelectivelyDisabledFeatures() featureStep.OnLeave(); ConfigureSearch(steps, SearchBackend.Brave); - ConfigureExposure(steps, ExposureMode.Local, webhooks: false); ConfigureIdentity(steps, "Netclaw", "America/New_York"); var config = AssembleConfig(steps); @@ -140,7 +134,6 @@ public void PersonalPosture_WithIdentityAndWorkspaces_ConfigMatchesChoices() var steps = BuildCoreSteps(); EnterAndConfigurePosture(steps, DeploymentPosture.Personal); ConfigureSearch(steps, SearchBackend.DuckDuckGo); - ConfigureExposure(steps, ExposureMode.Local, webhooks: false); var identityStep = GetStep<IdentityStepViewModel>(steps); identityStep.AgentName = "Jarvis"; @@ -158,40 +151,6 @@ public void PersonalPosture_WithIdentityAndWorkspaces_ConfigMatchesChoices() AssertNoDisabledFeatureFlags(config); } - [Fact] - public void PersonalPosture_ExposureModeLocal_NoDaemonSection() - { - var steps = BuildCoreSteps(); - EnterAndConfigurePosture(steps, DeploymentPosture.Personal); - ConfigureSearch(steps, SearchBackend.DuckDuckGo); - ConfigureExposure(steps, ExposureMode.Local, webhooks: false); - ConfigureIdentity(steps, "Netclaw", "UTC"); - - var config = AssembleConfig(steps); - - Assert.False(config.ContainsKey("Daemon")); - AssertNoEnabledKey(config, "Webhooks"); - } - - [Fact] - public void TeamPosture_ExposureTailscaleFunnel_WebhooksOn() - { - var steps = BuildCoreSteps(); - EnterAndConfigurePosture(steps, DeploymentPosture.Team); - EnterFeatureSelection(steps); - ConfigureSearch(steps, SearchBackend.Brave); - ConfigureExposure(steps, ExposureMode.TailscaleFunnel, webhooks: true); - ConfigureIdentity(steps, "Netclaw", "UTC"); - - var config = AssembleConfig(steps); - - var daemon = GetSection(config, "Daemon"); - Assert.Equal("tailscale-funnel", daemon["ExposureMode"]); - - // Webhooks: both the feature gate and the exposure step contribute - AssertSectionEnabled(config, "Webhooks", true); - } - [Fact] public void ExistingConfig_SearchEdit_PreservesUnrelatedSections() { @@ -237,8 +196,7 @@ private static List<IWizardStepViewModel> BuildCoreSteps() new SecurityPostureStepViewModel(), new FeatureSelectionStepViewModel(), new SearchStepViewModel(), - new IdentityStepViewModel(), - new ExposureModeStepViewModel() + new IdentityStepViewModel() ]; } @@ -269,13 +227,6 @@ private static void ConfigureSearch(List<IWizardStepViewModel> steps, SearchBack step.SearXngEndpoint = searXngEndpoint; } - private static void ConfigureExposure(List<IWizardStepViewModel> steps, ExposureMode mode, bool webhooks) - { - var step = GetStep<ExposureModeStepViewModel>(steps); - step.SelectedMode = mode; - step.WebhooksEnabled = webhooks; - } - private static void ConfigureIdentity(List<IWizardStepViewModel> steps, string name, string timezone) { var step = GetStep<IdentityStepViewModel>(steps); diff --git a/src/Netclaw.Cli/Program.cs b/src/Netclaw.Cli/Program.cs index 4671f2fa9..6b1493ef1 100644 --- a/src/Netclaw.Cli/Program.cs +++ b/src/Netclaw.Cli/Program.cs @@ -883,6 +883,10 @@ static async Task RunAsync(string[] args) sp.GetRequiredService<IHttpClientFactory>().CreateClient("OAuthDeviceFlow"), sp.GetService<TimeProvider>())); builder.Services.AddSingleton<DeviceFlowServiceFactory>(); + builder.Services + .AddSectionEditor<SecurityPostureStepViewModel>() + .AddSectionEditor<FeatureSelectionStepViewModel>() + .AddSectionEditor<ExposureModeStepViewModel>(); builder.Logging.ClearProviders(); builder.Logging.SetMinimumLevel(LogLevel.Warning); @@ -895,6 +899,8 @@ static async Task RunAsync(string[] args) t.RegisterRoute<ProviderManagerPage, ProviderManagerViewModel>("/provider"); t.RegisterRoute<ModelManagerPage, ModelManagerViewModel>("/model"); t.RegisterRoute<SearchConfigEditorPage, SearchConfigEditorViewModel>("/search", Termina.Pages.NavigationBehavior.PreserveState); + t.RegisterRoute<SecurityAccessPage, SecurityAccessViewModel>("/security"); + t.RegisterRoute<ExposureModeConfigPage, ExposureModeConfigViewModel>("/exposure-mode", Termina.Pages.NavigationBehavior.PreserveState); }); using var host = builder.Build(); diff --git a/src/Netclaw.Cli/Tui/Config/ExposureModeConfigPage.cs b/src/Netclaw.Cli/Tui/Config/ExposureModeConfigPage.cs new file mode 100644 index 000000000..9984b752d --- /dev/null +++ b/src/Netclaw.Cli/Tui/Config/ExposureModeConfigPage.cs @@ -0,0 +1,171 @@ +// ----------------------------------------------------------------------- +// <copyright file="ExposureModeConfigPage.cs" company="Petabridge, LLC"> +// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com> +// </copyright> +// ----------------------------------------------------------------------- +using Netclaw.Cli.Tui.Wizard; +using Netclaw.Cli.Tui.Workflow; +using Netclaw.Configuration; +using R3; +using Termina.Extensions; +using Termina.Input; +using Termina.Layout; +using Termina.Reactive; +using Termina.Rendering; +using Termina.Terminal; + +namespace Netclaw.Cli.Tui.Config; + +public sealed class ExposureModeConfigPage : ReactivePage<ExposureModeConfigViewModel> +{ + private DynamicLayoutNode? _contentNode; + private DynamicLayoutNode? _helpTextNode; + private readonly CompositeDisposable _stepSubs = []; + + protected override void OnBound() + { + base.OnBound(); + + ViewModel.Input.OfType<IInputEvent, KeyPressed>() + .Subscribe(HandleKeyPress) + .DisposeWith(Subscriptions); + + ViewModel.Input.OfType<IInputEvent, PasteEvent>() + .Subscribe(HandlePaste) + .DisposeWith(Subscriptions); + + ViewModel.OnStepContentChanged = () => + { + _stepSubs.Clear(); + _contentNode?.Invalidate(); + _helpTextNode?.Invalidate(); + }; + } + + public override ILayoutNode BuildLayout() + => NetclawTuiChrome.BuildPageFrame("Exposure Mode", BuildInnerLayout()); + + private ILayoutNode BuildInnerLayout() + => Layouts.Vertical() + .WithSpacing(1) + .WithChild(BuildContent()) + .WithChild(BuildHelpText()) + .WithChild(Layouts.Empty().Fill()) + .WithChild(BuildStatusBar()) + .WithChild(BuildKeyBindings()); + + private LayoutNode BuildContent() + { + _contentNode = new DynamicLayoutNode(() => + { + if (ViewModel.IsSaved.Value) + { + var modeLabel = FormatModeLabel(ViewModel.Step.SelectedMode); + return WorkflowViewComponents.BuildSavedScreen( + $"{modeLabel} exposure mode saved.", + "Press Esc to review exposure modes or Enter to return to Security & Access."); + } + + ViewModel.StepView.ClearFocusState(); + return ViewModel.StepView.BuildContent(ViewModel.Step, CreateCallbacks()); + }); + + return _contentNode; + } + + private LayoutNode BuildHelpText() + { + _helpTextNode = new DynamicLayoutNode(() => + { + if (ViewModel.IsSaved.Value) + return (ILayoutNode)new TextNode(" Saved state is local to this editor; Esc returns to the mode list first.").WithForeground(Color.Gray); + + return (ILayoutNode)new TextNode(ViewModel.Step.GetHelpText()).WithForeground(Color.Gray); + }); + + return _helpTextNode.Height(2); + } + + private LayoutNode BuildStatusBar() + => ViewModel.Context.StatusMessage + .Select(msg => (ILayoutNode)(string.IsNullOrWhiteSpace(msg) + ? Layouts.Empty() + : new TextNode($" {msg}").WithForeground(Color.Green))) + .AsLayout() + .Height(1); + + private LayoutNode BuildKeyBindings() + => ViewModel.IsSaved + .Select(saved => (ILayoutNode)new TextNode(saved + ? " [Enter] Security & Access [Esc] Review modes [Ctrl+Q] Quit" + : " [↑/↓] Navigate [Enter] Next/Save [Esc] Back [Ctrl+Q] Quit") + .WithForeground(Color.BrightBlack)) + .AsLayout() + .Height(1); + + public override bool HandlePageInput(ConsoleKeyInfo keyInfo) + { + if (base.HandlePageInput(keyInfo)) + return true; + + if (keyInfo.Key == ConsoleKey.Q && keyInfo.Modifiers.HasFlag(ConsoleModifiers.Control)) + { + ViewModel.RequestQuit(); + return true; + } + + return false; + } + + private void HandleKeyPress(KeyPressed key) + { + var keyInfo = key.KeyInfo; + if (keyInfo.Key == ConsoleKey.Escape) + { + ViewModel.GoBack(); + return; + } + + if (ViewModel.IsSaved.Value && keyInfo.Key == ConsoleKey.Enter) + { + ViewModel.GoNext(); + return; + } + + ViewModel.StepView.HandleKeyPress(key); + ViewModel.RequestRedraw(); + } + + private void HandlePaste(PasteEvent paste) + { + ViewModel.StepView.HandlePaste(paste); + ViewModel.RequestRedraw(); + } + + private StepViewCallbacks CreateCallbacks() + => new() + { + Subscriptions = _stepSubs, + InvalidateContent = () => _contentNode?.Invalidate(), + InvalidateHelp = () => _helpTextNode?.Invalidate(), + AdvanceStep = ViewModel.GoNext, + RequestRedraw = ViewModel.RequestRedraw, + }; + + private static string FormatModeLabel(ExposureMode mode) + => mode switch + { + ExposureMode.Local => "Local", + ExposureMode.ReverseProxy => "Reverse Proxy", + ExposureMode.TailscaleServe => "Tailscale Serve", + ExposureMode.TailscaleFunnel => "Tailscale Funnel", + ExposureMode.CloudflareTunnel => "Cloudflare Tunnel", + _ => mode.ToString() + }; + + public override void Dispose() + { + _stepSubs.Dispose(); + base.Dispose(); + } +} diff --git a/src/Netclaw.Cli/Tui/Config/ExposureModeConfigViewModel.cs b/src/Netclaw.Cli/Tui/Config/ExposureModeConfigViewModel.cs new file mode 100644 index 000000000..95ba8253d --- /dev/null +++ b/src/Netclaw.Cli/Tui/Config/ExposureModeConfigViewModel.cs @@ -0,0 +1,115 @@ +// ----------------------------------------------------------------------- +// <copyright file="ExposureModeConfigViewModel.cs" company="Petabridge, LLC"> +// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com> +// </copyright> +// ----------------------------------------------------------------------- +using Netclaw.Cli.Config; +using Netclaw.Cli.Tui.Wizard; +using Netclaw.Cli.Tui.Wizard.Steps; +using Netclaw.Configuration; +using Netclaw.Providers; +using R3; +using Termina.Reactive; + +namespace Netclaw.Cli.Tui.Config; + +public sealed class ExposureModeConfigViewModel : ReactiveViewModel +{ + private readonly WizardContext _context; + private readonly WizardOrchestrator _orchestrator; + private readonly ExposureModeStepViewModel _step; + + public ExposureModeConfigViewModel(NetclawPaths paths) + { + _step = new ExposureModeStepViewModel(includeWebhookToggle: false); + _context = new WizardContext + { + Paths = paths, + Registry = new ProviderDescriptorRegistry([]), + RequestRedraw = RequestRedraw, + ExistingConfig = LoadExistingConfig(paths) + }; + _orchestrator = new WizardOrchestrator([_step], _context, singleStepMode: true); + } + + internal Action<string>? RouteRequested { get; set; } + public WizardContext Context => _context; + public WizardOrchestrator Orchestrator => _orchestrator; + public ExposureModeStepViewModel Step => _step; + public ExposureModeStepView StepView { get; } = new(); + public ReactiveProperty<bool> IsSaved { get; } = new(false); + public Action? OnStepContentChanged { get; set; } + + public void GoNext() + { + if (IsSaved.Value) + { + BackToSecurityAccess(); + return; + } + + if (_orchestrator.GoNext()) + { + _context.StatusMessage.Value = ""; + NotifyContentChanged(); + return; + } + + _orchestrator.WriteConfig(); + IsSaved.Value = true; + _context.StatusMessage.Value = "Exposure mode saved."; + NotifyContentChanged(); + } + + public void GoBack() + { + if (IsSaved.Value) + { + IsSaved.Value = false; + _step.ReturnToModeSelection(); + _context.StatusMessage.Value = ""; + NotifyContentChanged(); + return; + } + + if (_orchestrator.GoBack()) + { + _context.StatusMessage.Value = ""; + NotifyContentChanged(); + return; + } + + BackToSecurityAccess(); + } + + public void RequestQuit() => Shutdown(); + + private void BackToSecurityAccess() + { + RouteRequested?.Invoke("/security"); + Navigate?.Invoke("/security"); + } + + private void NotifyContentChanged() + { + OnStepContentChanged?.Invoke(); + RequestRedraw(); + } + + public override void Dispose() + { + IsSaved.Dispose(); + _orchestrator.Dispose(); + _context.Dispose(); + base.Dispose(); + } + + private static Dictionary<string, object>? LoadExistingConfig(NetclawPaths paths) + { + if (!File.Exists(paths.NetclawConfigPath)) + return null; + + var config = ConfigFileHelper.LoadJsonDict(paths.NetclawConfigPath); + return config.Count == 0 ? null : config; + } +} diff --git a/src/Netclaw.Cli/Tui/Config/SearchConfigEditorPage.cs b/src/Netclaw.Cli/Tui/Config/SearchConfigEditorPage.cs index 45b7767b9..7faef36a0 100644 --- a/src/Netclaw.Cli/Tui/Config/SearchConfigEditorPage.cs +++ b/src/Netclaw.Cli/Tui/Config/SearchConfigEditorPage.cs @@ -5,6 +5,7 @@ // ----------------------------------------------------------------------- using R3; using Netclaw.Cli.Tui; +using Netclaw.Cli.Tui.Workflow; using Termina.Extensions; using Termina.Layout; using Termina.Reactive; @@ -90,55 +91,48 @@ private ILayoutNode BuildProviderSelectionScreen() _providerSelectionSynced = true; } - return Layouts.Vertical() - .WithSpacing(1) - .WithChild(new TextNode(" Choose the backend Netclaw uses for web search.").WithForeground(Color.White)) - .WithChild(BuildProviderList()) - .WithChild(new TextNode(" (*) active backend ✓ backend has saved setup").WithForeground(Color.Gray)) - .WithChild(new TextNode($" {GetProviderDescription(ViewModel.BackendOptions[_providerIndex].Value)}").WithForeground(Color.Gray)); + return WorkflowViewComponents.BuildSelectionScreen( + heading: "Choose the backend Netclaw uses for web search.", + selector: BuildProviderList(), + legend: ViewModel.ConfiguredLegend, + supportText: ViewModel.GetProviderDescription(ViewModel.BackendOptions[_providerIndex].Value)); } private ILayoutNode BuildEntryScreen() { - var content = Layouts.Vertical().WithSpacing(1); var field = ViewModel.CurrentProviderField; if (field is null) { - content.WithChild(new TextNode(" DuckDuckGo works without setup, but may hit bot detection.") - .WithForeground(Color.White)); - content.WithChild(new TextNode(" Press Enter to validate and save this provider selection.") - .WithForeground(Color.Gray)); - return content; + return WorkflowViewComponents.BuildSelectionScreen( + heading: "DuckDuckGo works without setup, but may hit bot detection.", + selector: Layouts.Empty(), + supportText: "Press Enter to validate and save this provider selection."); } var textInput = EnsureEditingTextInput(field); textInput.OnFocused(); - content.WithChild(new TextNode($" {GetEntryTitle(field)}").WithForeground(Color.White)); - content.WithChild(new TextNode($" {field.Label}").WithForeground(Color.White)); - content.WithChild(NetclawTuiChrome.BuildTextInputPanel(textInput, field.Label)); - - content.WithChild(new TextNode($" {GetEntryHint(field)}").WithForeground(Color.Gray)); - return content; + return WorkflowViewComponents.BuildEntryScreen( + title: ViewModel.GetEntryTitle(field), + fieldLabel: field.Label, + input: textInput, + hint: ViewModel.GetEntryHint(field)); } private ILayoutNode BuildValidatingScreen() { var frame = SpinnerFrames[ViewModel.ValidationSpinnerTick.Value % SpinnerFrames.Length]; - return Layouts.Vertical() - .WithSpacing(1) - .WithChild(new TextNode(" Validating Search configuration...").WithForeground(Color.White)) - .WithChild(new TextNode($" {frame} {GetValidatingMessage()}").WithForeground(Color.Yellow)) - .WithChild(new TextNode(" This may take a few seconds.").WithForeground(Color.Gray)); + return WorkflowViewComponents.BuildValidatingScreen( + heading: "Validating Search configuration...", + message: $"{frame} {ViewModel.GetValidatingMessage()}", + supportText: "This may take a few seconds."); } private ILayoutNode BuildSavedScreen() - => Layouts.Vertical() - .WithSpacing(1) - .WithChild(new TextNode($" \u2714 {ViewModel.CurrentBackendLabel} validated and saved.").WithForeground(Color.Green)) - .WithChild(new TextNode(" Press Esc to return to Search backends or Up/Down to review providers.") - .WithForeground(Color.Gray)); + => WorkflowViewComponents.BuildSavedScreen( + successText: ViewModel.GetSavedMessage(), + nextStepText: ViewModel.GetSavedNextStepText()); private ILayoutNode BuildProviderList() { @@ -420,39 +414,6 @@ private TextInputNode EnsureEditingTextInput(ProjectedConfigField field) return _textInput; } - private string GetProviderDescription(string backend) - => backend switch - { - "brave" => "Brave Search requires an API key and is usually more reliable than DuckDuckGo.", - "searxng" => "SearXNG uses your own endpoint URL and supports self-hosted search.", - _ => "DuckDuckGo works without setup, but may hit bot detection.", - }; - - private string GetEntryTitle(ProjectedConfigField field) - => field.Path switch - { - "Search.BraveApiKey" => "Brave Search requires an API key.", - _ => "Enter the base URL of your SearXNG instance.", - }; - - private string GetEntryHint(ProjectedConfigField field) - => field.Path switch - { - "Search.BraveApiKey" when ViewModel.HasPersistedSecret(field.Path) - => "Stored in secrets.json. Leave blank to keep the existing key. Press Enter to validate and save.", - "Search.BraveApiKey" - => "Stored in secrets.json. Press Enter to validate and save.", - _ => "Netclaw will validate the URL and probe it on Enter.", - }; - - private string GetValidatingMessage() - => ViewModel.CurrentBackendValue switch - { - "brave" => "Probing Brave Search", - "searxng" => "Probing SearXNG instance", - _ => "Validating DuckDuckGo configuration", - }; - private bool IsConfigured(string backend) => backend switch { diff --git a/src/Netclaw.Cli/Tui/Config/SearchConfigEditorViewModel.cs b/src/Netclaw.Cli/Tui/Config/SearchConfigEditorViewModel.cs index 81e49a25b..6a3391fd3 100644 --- a/src/Netclaw.Cli/Tui/Config/SearchConfigEditorViewModel.cs +++ b/src/Netclaw.Cli/Tui/Config/SearchConfigEditorViewModel.cs @@ -39,6 +39,7 @@ public static SearchFieldCommitResult Invalid(IReadOnlyList<SearchEditorValidati internal sealed class SearchConfigEditorViewModel : ReactiveViewModel { + private readonly SearchSectionSpec _spec; private readonly NetclawPaths _paths; private readonly SearchEditorPersistenceMapper _mapper; private readonly SearchEditorValidationAdapter _validator; @@ -49,34 +50,7 @@ internal sealed class SearchConfigEditorViewModel : ReactiveViewModel private SearchProbeResult? _lastProbeResult; private CancellationTokenSource? _validationSpinnerCts; - public IReadOnlyList<ProjectedConfigField> Fields { get; } = - [ - new( - Path: "Search.Backend", - PropertyName: "Backend", - Label: "Backend", - Description: "Search backend identifier.", - ValueKind: ConfigFieldValueKind.String, - Storage: ConfigFieldStorage.ConfigFile, - Widget: ConfigFieldWidget.EnumSelection, - Nullable: false, - DefaultValue: SearchBackend.DuckDuckGo.ToWireValue(), - TrimDefaultOnSave: true, - PreserveBlankSecret: false, - Placeholder: null, - Hint: "Choose your web search provider.", - ApplicableWhenPath: null, - ApplicableWhenEquals: null, - InactiveText: null, - EnumOptions: - [ - new("duckduckgo", "DuckDuckGo"), - new("brave", "Brave"), - new("searxng", "SearXng (self-hosted)") - ]), - SearchFields.BraveApiKey, - SearchFields.SearXngEndpoint, - ]; + public IReadOnlyList<ProjectedConfigField> Fields => _spec.Fields; public Dictionary<string, ReactiveProperty<string>> FieldValues { get; } = new(StringComparer.Ordinal); @@ -88,6 +62,7 @@ public SearchConfigEditorViewModel( IHttpClientFactory? httpClientFactory = null, TimeProvider? timeProvider = null) { + _spec = new SearchSectionSpec(); _paths = paths; _httpClientFactory = httpClientFactory; _timeProvider = timeProvider ?? TimeProvider.System; @@ -114,13 +89,9 @@ public SearchConfigEditorViewModel( public bool IsDirty => ComputeIsDirty(); public SearchProbeResult? LastProbeResult => _lastProbeResult; + public string ConfiguredLegend => _spec.GetConfiguredLegend(); public string CurrentBackendValue => _model.Backend.ToWireValue(); - public string CurrentBackendLabel => _model.Backend switch - { - SearchBackend.Brave => "Brave", - SearchBackend.SearXng => "SearXng (self-hosted)", - _ => "DuckDuckGo", - }; + public string CurrentBackendLabel => _spec.GetBackendLabel(_model.Backend); public IReadOnlyList<ConfigEnumOption> BackendOptions { get; } = [ @@ -129,12 +100,7 @@ public SearchConfigEditorViewModel( new("searxng", "SearXng (self-hosted)") ]; - public ProjectedConfigField? CurrentProviderField => _model.Backend switch - { - SearchBackend.Brave => SearchFields.BraveApiKey, - SearchBackend.SearXng => SearchFields.SearXngEndpoint, - _ => null, - }; + public ProjectedConfigField? CurrentProviderField => _spec.GetProviderField(_model); public bool IsCurrentBackendConfigured => _model.Backend switch { @@ -143,6 +109,18 @@ public SearchConfigEditorViewModel( _ => true, }; + public string GetProviderDescription(string backend) => _spec.GetProviderDescription(backend); + + public string GetEntryTitle(ProjectedConfigField field) => _spec.GetEntryTitle(field); + + public string GetEntryHint(ProjectedConfigField field) => _spec.GetEntryHint(field, _model); + + public string GetValidatingMessage() => _spec.GetValidatingMessage(_model); + + public string GetSavedMessage() => _spec.GetSavedMessage(_model); + + public string GetSavedNextStepText() => _spec.GetSavedNextStepText(); + public override void Dispose() { CancelValidationSpinner(); @@ -578,44 +556,4 @@ private static SearchBackend ParseBackend(string? value) private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); - private static class SearchFields - { - internal static readonly ProjectedConfigField BraveApiKey = new( - Path: "Search.BraveApiKey", - PropertyName: "BraveApiKey", - Label: "Brave API key", - Description: "Brave Search API key. Required when Backend is Brave. Stored in secrets.json.", - ValueKind: ConfigFieldValueKind.String, - Storage: ConfigFieldStorage.SecretsFile, - Widget: ConfigFieldWidget.PasswordInput, - Nullable: true, - DefaultValue: null, - TrimDefaultOnSave: false, - PreserveBlankSecret: true, - Placeholder: "Enter Brave Search API key...", - Hint: "Stored in secrets.json. Leave blank to keep the existing key.", - ApplicableWhenPath: "Search.Backend", - ApplicableWhenEquals: "brave", - InactiveText: "(not configured)", - EnumOptions: []); - - internal static readonly ProjectedConfigField SearXngEndpoint = new( - Path: "Search.SearXngEndpoint", - PropertyName: "SearXngEndpoint", - Label: "SearXng instance URL", - Description: "SearXNG instance base URL. Required when Backend is SearXng.", - ValueKind: ConfigFieldValueKind.String, - Storage: ConfigFieldStorage.ConfigFile, - Widget: ConfigFieldWidget.TextInput, - Nullable: true, - DefaultValue: null, - TrimDefaultOnSave: true, - PreserveBlankSecret: false, - Placeholder: "https://search.example.com", - Hint: "Enter the base URL of your SearXNG instance.", - ApplicableWhenPath: "Search.Backend", - ApplicableWhenEquals: "searxng", - InactiveText: "(not configured)", - EnumOptions: []); - } } diff --git a/src/Netclaw.Cli/Tui/Config/SearchSectionSpec.cs b/src/Netclaw.Cli/Tui/Config/SearchSectionSpec.cs new file mode 100644 index 000000000..17395ad87 --- /dev/null +++ b/src/Netclaw.Cli/Tui/Config/SearchSectionSpec.cs @@ -0,0 +1,145 @@ +// ----------------------------------------------------------------------- +// <copyright file="SearchSectionSpec.cs" company="Petabridge, LLC"> +// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com> +// </copyright> +// ----------------------------------------------------------------------- +using Netclaw.Configuration; + +namespace Netclaw.Cli.Tui.Config; + +/// <summary> +/// Authoritative editor contract for the Search workflow. This is intentionally limited to +/// editor semantics and persisted-file behavior; runtime config loading continues to bind from +/// IConfiguration using the existing netclaw.json + secrets.json + environment overlay. +/// </summary> +internal sealed class SearchSectionSpec +{ + private static readonly string BackendPath = ConfigValueMetadataProvider.Get<SearchConfig>(nameof(SearchConfig.Backend)).Key; + private static readonly string BraveApiKeyPath = ConfigValueMetadataProvider.Get<SearchConfig>(nameof(SearchConfig.BraveApiKey)).Key; + private static readonly string SearXngEndpointPath = ConfigValueMetadataProvider.Get<SearchConfig>(nameof(SearchConfig.SearXngEndpoint)).Key; + + internal IReadOnlyList<ProjectedConfigField> Fields { get; } = + [ + new( + Path: BackendPath, + PropertyName: nameof(SearchConfig.Backend), + Label: "Backend", + Description: "Search backend identifier.", + ValueKind: ConfigFieldValueKind.String, + Storage: ConfigFieldStorage.ConfigFile, + Widget: ConfigFieldWidget.EnumSelection, + Nullable: false, + DefaultValue: SearchBackend.DuckDuckGo.ToWireValue(), + TrimDefaultOnSave: true, + PreserveBlankSecret: false, + Placeholder: null, + Hint: "Choose your web search provider.", + ApplicableWhenPath: null, + ApplicableWhenEquals: null, + InactiveText: null, + EnumOptions: + [ + new("duckduckgo", "DuckDuckGo"), + new("brave", "Brave"), + new("searxng", "SearXng (self-hosted)") + ]), + new( + Path: BraveApiKeyPath, + PropertyName: nameof(SearchConfig.BraveApiKey), + Label: "Brave API key", + Description: "Brave Search API key. Required when Backend is Brave. Stored in secrets.json.", + ValueKind: ConfigFieldValueKind.String, + Storage: ConfigFieldStorage.SecretsFile, + Widget: ConfigFieldWidget.PasswordInput, + Nullable: true, + DefaultValue: null, + TrimDefaultOnSave: false, + PreserveBlankSecret: true, + Placeholder: "Enter Brave Search API key...", + Hint: "Stored in secrets.json. Leave blank to keep the existing key.", + ApplicableWhenPath: BackendPath, + ApplicableWhenEquals: "brave", + InactiveText: "(not configured)", + EnumOptions: []), + new( + Path: SearXngEndpointPath, + PropertyName: nameof(SearchConfig.SearXngEndpoint), + Label: "SearXng instance URL", + Description: "SearXNG instance base URL. Required when Backend is SearXng.", + ValueKind: ConfigFieldValueKind.String, + Storage: ConfigFieldStorage.ConfigFile, + Widget: ConfigFieldWidget.TextInput, + Nullable: true, + DefaultValue: null, + TrimDefaultOnSave: true, + PreserveBlankSecret: false, + Placeholder: "https://search.example.com", + Hint: "Enter the base URL of your SearXNG instance.", + ApplicableWhenPath: BackendPath, + ApplicableWhenEquals: "searxng", + InactiveText: "(not configured)", + EnumOptions: []) + ]; + + internal ProjectedConfigField? GetProviderField(SearchEditorModel model) + => model.Backend switch + { + SearchBackend.Brave => GetField(BraveApiKeyPath), + SearchBackend.SearXng => GetField(SearXngEndpointPath), + _ => null, + }; + + internal string GetProviderDescription(string backend) + => backend switch + { + "brave" => "Brave Search requires an API key and is usually more reliable than DuckDuckGo.", + "searxng" => "SearXNG uses your own endpoint URL and supports self-hosted search.", + _ => "DuckDuckGo works without setup, but may hit bot detection.", + }; + + internal string GetEntryTitle(ProjectedConfigField field) + => field.Path switch + { + var path when path == BraveApiKeyPath + => "Brave Search requires an API key.", + _ => "Enter the base URL of your SearXNG instance.", + }; + + internal string GetEntryHint(ProjectedConfigField field, SearchEditorModel model) + => field.Path switch + { + var path when path == BraveApiKeyPath + && model.Brave.HasPersistedApiKey + => "Stored in secrets.json. Leave blank to keep the existing key. Press Enter to validate and save.", + var path when path == BraveApiKeyPath + => "Stored in secrets.json. Press Enter to validate and save.", + _ => "Netclaw will validate the URL and probe it on Enter.", + }; + + internal string GetValidatingMessage(SearchEditorModel model) + => model.Backend switch + { + SearchBackend.Brave => "Probing Brave Search", + SearchBackend.SearXng => "Probing SearXNG instance", + _ => "Validating DuckDuckGo configuration", + }; + + internal string GetConfiguredLegend() => "(*) active backend ✓ backend has saved setup"; + + internal string GetSavedMessage(SearchEditorModel model) + => $"✔ {GetBackendLabel(model.Backend)} validated and saved."; + + internal string GetSavedNextStepText() + => "Press Esc to return to Search backends or Up/Down to review providers."; + + internal string GetBackendLabel(SearchBackend backend) + => backend switch + { + SearchBackend.Brave => "Brave", + SearchBackend.SearXng => "SearXng (self-hosted)", + _ => "DuckDuckGo", + }; + + private ProjectedConfigField GetField(string path) + => Fields.First(field => string.Equals(field.Path, path, StringComparison.Ordinal)); +} diff --git a/src/Netclaw.Cli/Tui/Config/SecurityAccessPage.cs b/src/Netclaw.Cli/Tui/Config/SecurityAccessPage.cs new file mode 100644 index 000000000..e93b74f7a --- /dev/null +++ b/src/Netclaw.Cli/Tui/Config/SecurityAccessPage.cs @@ -0,0 +1,97 @@ +// ----------------------------------------------------------------------- +// <copyright file="SecurityAccessPage.cs" company="Petabridge, LLC"> +// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com> +// </copyright> +// ----------------------------------------------------------------------- +using R3; +using Termina.Extensions; +using Termina.Input; +using Termina.Layout; +using Termina.Reactive; +using Termina.Rendering; +using Termina.Terminal; + +namespace Netclaw.Cli.Tui.Config; + +public sealed class SecurityAccessPage : ReactivePage<SecurityAccessViewModel> +{ + private SelectionListNode<string>? _entryList; + + protected override void OnBound() + { + base.OnBound(); + ViewModel.Input.OfType<IInputEvent, KeyPressed>() + .Subscribe(HandleKeyPress) + .DisposeWith(Subscriptions); + } + + public override ILayoutNode BuildLayout() + => NetclawTuiChrome.BuildPageFrame("Security & Access", BuildInnerLayout()); + + private ILayoutNode BuildInnerLayout() + => Layouts.Vertical() + .WithSpacing(1) + .WithChild(BuildList()) + .WithChild(Layouts.Empty().Fill()) + .WithChild(BuildStatusBar()) + .WithChild(BuildKeyBindings()); + + private ILayoutNode BuildList() + { + var rows = ViewModel.Items + .Select(static item => $"{item.Label,-20} {item.Summary,-20} {item.Description}") + .ToList(); + + _entryList = Layouts.SelectionList(rows) + .WithMode(SelectionMode.Single) + .WithHighlightColors(Color.Black, Color.Cyan); + + _entryList.OnFocused(); + _entryList.SelectionConfirmed + .Subscribe(selected => + { + if (selected.Count == 0) + return; + + var index = rows.IndexOf(selected[0]); + if (index >= 0) + { + ViewModel.SelectedIndex.Value = index; + ViewModel.ActivateSelected(); + } + }) + .DisposeWith(Subscriptions); + + return Layouts.Vertical() + .WithChild(new TextNode(" Security & Access").WithForeground(Color.White).Bold()) + .WithChild(_entryList); + } + + private LayoutNode BuildStatusBar() + => ViewModel.StatusMessage + .Select(msg => NetclawTuiChrome.BuildStatusLine(msg, Color.Yellow)) + .AsLayout() + .Height(1); + + private static LayoutNode BuildKeyBindings() + => NetclawTuiChrome.BuildKeyHintLine(" [↑/↓] Navigate [Enter] Open [Esc] Back [Ctrl+Q] Quit"); + + private void HandleKeyPress(KeyPressed key) + { + var keyInfo = key.KeyInfo; + if (keyInfo.Key == ConsoleKey.Q && keyInfo.Modifiers.HasFlag(ConsoleModifiers.Control)) + { + ViewModel.RequestQuit(); + return; + } + + if (keyInfo.Key == ConsoleKey.Escape) + { + ViewModel.BackToConfig(); + return; + } + + _entryList?.HandleInput(keyInfo); + ViewModel.RequestRedraw(); + } +} diff --git a/src/Netclaw.Cli/Tui/Config/SecurityAccessViewModel.cs b/src/Netclaw.Cli/Tui/Config/SecurityAccessViewModel.cs new file mode 100644 index 000000000..c8b92b407 --- /dev/null +++ b/src/Netclaw.Cli/Tui/Config/SecurityAccessViewModel.cs @@ -0,0 +1,151 @@ +// ----------------------------------------------------------------------- +// <copyright file="SecurityAccessViewModel.cs" company="Petabridge, LLC"> +// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com> +// </copyright> +// ----------------------------------------------------------------------- +using Netclaw.Cli.Config; +using Netclaw.Configuration; +using R3; +using Termina.Reactive; + +namespace Netclaw.Cli.Tui.Config; + +public sealed record SecurityAccessItem(string Label, string Summary, string Description, string? Route = null); + +public sealed class SecurityAccessViewModel : ReactiveViewModel +{ + private readonly NetclawPaths _paths; + + public SecurityAccessViewModel(NetclawPaths paths) + { + _paths = paths; + } + + internal Action<string>? RouteRequested { get; set; } + internal bool ShutdownRequestedForTest { get; private set; } + + public ReactiveProperty<string> StatusMessage { get; } = new(""); + public ReactiveProperty<int> SelectedIndex { get; } = new(0); + + public IReadOnlyList<SecurityAccessItem> Items => BuildItems(); + + public void MoveSelection(int delta) + { + var items = Items; + if (items.Count == 0) + return; + + var next = Math.Clamp(SelectedIndex.Value + delta, 0, items.Count - 1); + if (next != SelectedIndex.Value) + SelectedIndex.Value = next; + } + + public void ActivateSelected() + { + var items = Items; + if (items.Count == 0) + return; + + Activate(items[SelectedIndex.Value]); + } + + internal void Activate(SecurityAccessItem item) + { + if (item.Route is not null) + { + RouteRequested?.Invoke(item.Route); + Navigate?.Invoke(item.Route); + return; + } + + StatusMessage.Value = $"{item.Label} is not implemented yet in `netclaw config`."; + RequestRedraw(); + } + + public void BackToConfig() + { + RouteRequested?.Invoke("/config"); + Navigate?.Invoke("/config"); + } + + public void RequestQuit() + { + ShutdownRequestedForTest = true; + Shutdown(); + } + + public override void Dispose() + { + StatusMessage.Dispose(); + SelectedIndex.Dispose(); + base.Dispose(); + } + + private IReadOnlyList<SecurityAccessItem> BuildItems() + { + var config = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath); + return + [ + new("Security Posture", ReadPostureSummary(config), "Deployment trust stance."), + new("Enabled Features", ReadEnabledFeaturesSummary(config), "Deployment-wide runtime feature gates."), + new("Audience Profiles", "Not implemented", "Curated per-audience access rules."), + new("Exposure Mode", ReadExposureModeSummary(config), "Daemon reachability and tunnel topology.", "/exposure-mode") + ]; + } + + private static string ReadPostureSummary(Dictionary<string, object> config) + { + if (ConfigFileHelper.TryGetPathValue(config, "Security.DeploymentPosture", out var value) + && value is string posture + && !string.IsNullOrWhiteSpace(posture)) + { + return posture; + } + + return "Personal"; + } + + private static string ReadEnabledFeaturesSummary(Dictionary<string, object> config) + { + var paths = new[] + { + "Memory.Enabled", + "Search.Enabled", + "SkillSync.Enabled", + "Scheduling.Enabled", + "SubAgents.Enabled", + "Webhooks.Enabled" + }; + + var configured = 0; + var enabled = 0; + foreach (var path in paths) + { + if (!ConfigFileHelper.TryGetPathValue(config, path, out var value) || value is not bool flag) + continue; + + configured++; + if (flag) + enabled++; + } + + return configured == 0 ? "Defaults" : $"{enabled}/{paths.Length} enabled"; + } + + private static string ReadExposureModeSummary(Dictionary<string, object> config) + { + var mode = ExposureMode.Local; + if (ConfigFileHelper.TryGetPathValue(config, "Daemon.ExposureMode", out var value)) + mode = DaemonConfig.ParseExposureMode(value?.ToString()); + + return mode switch + { + ExposureMode.Local => "Local", + ExposureMode.ReverseProxy => "Reverse Proxy", + ExposureMode.TailscaleServe => "Tailscale Serve", + ExposureMode.TailscaleFunnel => "Tailscale Funnel", + ExposureMode.CloudflareTunnel => "Cloudflare Tunnel", + _ => mode.ToString() + }; + } +} diff --git a/src/Netclaw.Cli/Tui/ConfigDashboardViewModel.cs b/src/Netclaw.Cli/Tui/ConfigDashboardViewModel.cs index 31a4c2282..fc818d0bf 100644 --- a/src/Netclaw.Cli/Tui/ConfigDashboardViewModel.cs +++ b/src/Netclaw.Cli/Tui/ConfigDashboardViewModel.cs @@ -51,7 +51,7 @@ public ConfigDashboardViewModel(ConfigDashboardNavigationState navigationState) new("Search", "Search backend and credentials.", "/search"), new("Browser Automation", "Browser automation provider settings."), new("Telemetry & Alerting", "Telemetry and outbound webhook alerting."), - new("Security & Access", "Posture, enabled features, audience profiles, and exposure mode."), + new("Security & Access", "Posture, enabled features, audience profiles, and exposure mode.", "/security"), new("Run Full Doctor", "Exit the dashboard and run `netclaw doctor`.", IsTerminal: true), new("Quit", "Exit without changing settings.", IsTerminal: true), ]; diff --git a/src/Netclaw.Cli/Tui/InitWizardViewModel.cs b/src/Netclaw.Cli/Tui/InitWizardViewModel.cs index 078436abf..e00b70291 100644 --- a/src/Netclaw.Cli/Tui/InitWizardViewModel.cs +++ b/src/Netclaw.Cli/Tui/InitWizardViewModel.cs @@ -97,11 +97,10 @@ internal InitWizardViewModel( }; // Create step VMs in the canonical order: - // provider -> security-posture -> feature-selection -> channel-picker -> channels -> search -> browser-automation -> identity -> external-skills -> exposure-mode -> health-check + // provider -> security-posture -> feature-selection -> channel-picker -> channels -> search -> browser-automation -> identity -> external-skills -> health-check ProviderStep = new ProviderStepViewModel(registry, probe, oauthFactory); var securityPostureStep = new SecurityPostureStepViewModel(); var featureSelectionStep = new FeatureSelectionStepViewModel(); - var exposureModeStep = new ExposureModeStepViewModel(); var channelPickerStep = new ChannelPickerStepViewModel(slackProbe, discordProbe); var channelsStep = new ChannelsStepViewModel(); var searchStep = new SearchStepViewModel(); @@ -123,7 +122,6 @@ internal InitWizardViewModel( identityStep, externalSkillsStep, skillFeedsStep, - exposureModeStep, _healthCheckStep }; @@ -145,7 +143,6 @@ internal InitWizardViewModel( [WizardStepIds.Provider] = new ProviderStepView(clipboardService), [WizardStepIds.SecurityPosture] = new SecurityPostureStepView(), [WizardStepIds.FeatureSelection] = new FeatureSelectionStepView(), - [WizardStepIds.ExposureMode] = new ExposureModeStepView(), [WizardStepIds.ChannelPicker] = new ChannelPickerStepView(), [WizardStepIds.Channels] = new ChannelsStepView(), [WizardStepIds.Search] = new SearchStepView(), diff --git a/src/Netclaw.Cli/Tui/Wizard/Steps/ExposureModeStepView.cs b/src/Netclaw.Cli/Tui/Wizard/Steps/ExposureModeStepView.cs index 08efec7ae..3695c9d17 100644 --- a/src/Netclaw.Cli/Tui/Wizard/Steps/ExposureModeStepView.cs +++ b/src/Netclaw.Cli/Tui/Wizard/Steps/ExposureModeStepView.cs @@ -6,6 +6,7 @@ using System.Net; using System.Net.Sockets; using Netclaw.Configuration; +using Netclaw.Cli.Tui.Workflow; using R3; using Termina.Extensions; using Termina.Input; @@ -101,13 +102,11 @@ private ILayoutNode BuildModeSelection(ExposureModeStepViewModel vm, StepViewCal }) .DisposeWith(callbacks.Subscriptions); - return Layouts.Vertical() - .WithChild(new TextNode(" How will this Netclaw daemon be accessed?").WithForeground(Color.White)) - .WithSpacing(1) - .WithChild(modeList) - .WithSpacing(1) - .WithChild(new TextNode(" ⚠ = exposes daemon beyond this machine. Ensure auth is configured first.") - .WithForeground(Color.BrightBlack)); + return WorkflowViewComponents.BuildSelectionScreen( + heading: "How will this Netclaw daemon be accessed?", + selector: modeList, + supportText: "⚠ = exposes daemon beyond this machine. Ensure auth is configured first.", + supportColor: Color.BrightBlack); } private ILayoutNode BuildReverseProxyHost(ExposureModeStepViewModel vm, StepViewCallbacks callbacks) @@ -330,16 +329,15 @@ private ILayoutNode BuildTailscaleServeNotice(ExposureModeStepViewModel vm, Step .Subscribe(_ => callbacks.AdvanceStep()) .DisposeWith(callbacks.Subscriptions); - return Layouts.Vertical() - .WithChild(new TextNode(" Tailscale Serve: daemon accessible within your tailnet only.") - .WithForeground(Color.Cyan)) - .WithSpacing(1) - .WithChild(new TextNode(" Devices on your tailnet can reach the daemon. Not reachable from the public internet.") - .WithForeground(Color.BrightBlack)) - .WithChild(new TextNode(" Ensure `tailscaled` is running before starting Netclaw.") - .WithForeground(Color.BrightBlack)) - .WithSpacing(1) - .WithChild(_confirmList); + return WorkflowViewComponents.BuildNoticeScreen( + title: "Tailscale Serve: daemon accessible within your tailnet only.", + bodyLines: + [ + "Devices on your tailnet can reach the daemon. Not reachable from the public internet.", + "Ensure `tailscaled` is running before starting Netclaw." + ], + confirmation: _confirmList, + titleColor: Color.Cyan); } private ILayoutNode BuildWebhookToggle(ExposureModeStepViewModel vm, StepViewCallbacks callbacks) @@ -368,15 +366,11 @@ private ILayoutNode BuildWebhookToggle(ExposureModeStepViewModel vm, StepViewCal }) .DisposeWith(callbacks.Subscriptions); - return Layouts.Vertical() - .WithChild(new TextNode(" Should this daemon accept inbound webhooks?").WithForeground(Color.White)) - .WithSpacing(1) - .WithChild(webhookList) - .WithSpacing(1) - .WithChild(new TextNode(" Inbound webhooks let external services trigger autonomous runs via HTTP POST.") - .WithForeground(Color.BrightBlack)) - .WithChild(new TextNode(" This is separate from outbound notification webhooks.") - .WithForeground(Color.BrightBlack)); + return WorkflowViewComponents.BuildSelectionScreen( + heading: "Should this daemon accept inbound webhooks?", + selector: webhookList, + supportText: "Inbound webhooks let external services trigger autonomous runs via HTTP POST.\nThis is separate from outbound notification webhooks.", + supportColor: Color.BrightBlack); } private static string FormatServingUrl(string host) diff --git a/src/Netclaw.Cli/Tui/Wizard/Steps/ExposureModeStepViewModel.cs b/src/Netclaw.Cli/Tui/Wizard/Steps/ExposureModeStepViewModel.cs index f3a07eed9..ab914dd23 100644 --- a/src/Netclaw.Cli/Tui/Wizard/Steps/ExposureModeStepViewModel.cs +++ b/src/Netclaw.Cli/Tui/Wizard/Steps/ExposureModeStepViewModel.cs @@ -9,6 +9,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using Netclaw.Cli.Config; +using Netclaw.Cli.Tui.Sections; using Netclaw.Configuration; namespace Netclaw.Cli.Tui.Wizard.Steps; @@ -22,7 +23,7 @@ namespace Netclaw.Cli.Tui.Wizard.Steps; /// One <c>TextInputNode</c> per sub-step matches the established wizard pattern /// (see SlackStepView, IdentityStepView). /// </summary> -public sealed class ExposureModeStepViewModel : IWizardStepViewModel +public sealed class ExposureModeStepViewModel : IWizardStepViewModel, ISectionEditor { /// <summary>Default bind address suggested in the reverse-proxy config sub-step.</summary> public const string DefaultReverseProxyHost = "0.0.0.0"; @@ -35,13 +36,42 @@ public sealed class ExposureModeStepViewModel : IWizardStepViewModel private int _currentSubStep; private int _highWaterSubStep; + private readonly TimeProvider _timeProvider; // Bootstrap device state — populated during ContributeSecrets for non-Local modes. private string? _bootstrapRawToken; private PairedDevice? _bootstrapDevice; + public ExposureModeStepViewModel() + : this(TimeProvider.System, includeWebhookToggle: true) + { + } + + public ExposureModeStepViewModel(TimeProvider timeProvider) + : this(timeProvider, includeWebhookToggle: true) + { + } + + internal ExposureModeStepViewModel(bool includeWebhookToggle) + : this(TimeProvider.System, includeWebhookToggle) + { + } + + private ExposureModeStepViewModel(TimeProvider timeProvider, bool includeWebhookToggle) + { + _timeProvider = timeProvider; + IncludeWebhookToggle = includeWebhookToggle; + } + public string StepId => WizardStepIds.ExposureMode; public string DisplayTitle => "Network Exposure"; + public string SectionId => StepId; + public string DisplayName => "Exposure Mode"; + public string? Category => "Security & Access"; + public bool ShowInMenu => true; + public IReadOnlyList<string> RelevantDoctorChecks => ["Config Schema", "exposure-mode"]; + + internal bool IncludeWebhookToggle { get; } /// <summary>The selected exposure mode. Defaults to <see cref="ExposureMode.Local"/>.</summary> public ExposureMode SelectedMode { get; set; } = ExposureMode.Local; @@ -68,7 +98,14 @@ public sealed class ExposureModeStepViewModel : IWizardStepViewModel public int CurrentSubStep => _currentSubStep; /// <summary>Sub-step count varies by mode — see class summary.</summary> - public int SubStepCount => IsReverseProxy ? 5 : (NeedsConfirmation ? 3 : 2); + public int SubStepCount + { + get + { + var count = IsReverseProxy ? 4 : (NeedsConfirmation ? 2 : 1); + return IncludeWebhookToggle ? count + 1 : count; + } + } /// <summary>True when the selected mode requires a confirmation or notice screen.</summary> internal bool NeedsConfirmation => SelectedMode != ExposureMode.Local; @@ -90,14 +127,14 @@ public sealed class ExposureModeStepViewModel : IWizardStepViewModel internal int NoticeSubStep => IsReverseProxy ? 3 : 1; /// <summary>The sub-step index for the inbound webhook toggle (always last in the plan).</summary> - internal int WebhookSubStep => SubStepCount - 1; + internal int WebhookSubStep => IncludeWebhookToggle ? SubStepCount - 1 : -1; public string GetHelpText() { if (_currentSubStep == 0) return " Local is safest — daemon only reachable from this machine. Use tunnels for remote access."; - if (_currentSubStep == WebhookSubStep) + if (IncludeWebhookToggle && _currentSubStep == WebhookSubStep) return " Inbound webhooks let external services trigger autonomous runs via HTTP POST."; if (IsReverseProxy && _currentSubStep == ReverseProxyHostSubStep) @@ -152,6 +189,9 @@ public bool TryGoBack() public void OnEnter(WizardContext context, NavigationDirection direction) { + if (direction == NavigationDirection.Forward) + TryPrefillFromExisting(context); + if (direction == NavigationDirection.Back) { // SubStepCount depends on SelectedMode, which the operator can change @@ -167,6 +207,11 @@ public void OnEnter(WizardContext context, NavigationDirection direction) public void OnLeave() { } + internal void ReturnToModeSelection() + { + _currentSubStep = 0; + } + /// <summary> /// Writes the Daemon section (non-local modes) and Webhooks section (when enabled). /// For reverse-proxy mode the section also carries the operator-supplied bind address @@ -174,7 +219,7 @@ public void OnLeave() { } /// </summary> public void ContributeConfig(WizardConfigBuilder builder) { - if (SelectedMode != ExposureMode.Local) + if (IncludeWebhookToggle && SelectedMode != ExposureMode.Local) { builder.Daemon = new DaemonConfigSection { @@ -184,7 +229,7 @@ public void ContributeConfig(WizardConfigBuilder builder) }; } - if (WebhooksEnabled) + if (IncludeWebhookToggle && WebhooksEnabled) { builder.Webhooks = new WebhooksConfigSection { Enabled = true }; } @@ -213,7 +258,7 @@ public void ContributeSecrets(WizardSecretsBuilder builder) var saltHex = Convert.ToHexString(saltBytes).ToLowerInvariant(); var tokenHash = PairedDevice.ComputeTokenHash(rawToken, saltHex); - var now = DateTimeOffset.UtcNow; + var now = _timeProvider.GetUtcNow(); _bootstrapDevice = new PairedDevice { Name = Environment.MachineName, @@ -231,6 +276,39 @@ public void ContributeSecrets(WizardSecretsBuilder builder) public Task ContributeHealthChecksAsync(HealthCheckRunner runner, CancellationToken ct) => Task.CompletedTask; + public SectionStatus GetStatus(WizardContext context) => SectionStatus.Configured; + + public string Summary(WizardContext context) + => FormatModeLabel(ReadExistingMode(context)); + + public IWizardStepViewModel CreateEditor(IServiceProvider services) + => new ExposureModeStepViewModel(includeWebhookToggle: false); + + public SectionContribution BuildContribution(IWizardStepViewModel editor) + { + var vm = (ExposureModeStepViewModel)editor; + var actions = new List<SectionFieldAction> + { + new("Daemon.ExposureMode", SectionFieldActionKind.Set, vm.SelectedMode.ToWireValue()) + }; + + if (vm.SelectedMode == ExposureMode.ReverseProxy) + { + actions.Add(new SectionFieldAction("Daemon.Host", SectionFieldActionKind.Set, + string.IsNullOrWhiteSpace(vm.Host) ? DefaultReverseProxyHost : vm.Host)); + actions.Add(new SectionFieldAction("Daemon.TrustedProxies", SectionFieldActionKind.Set, + vm.TrustedProxies.ToArray())); + } + else + { + // Host participates in local/tunnel startup validation. Drop any old + // reverse-proxy bind address so non-reverse modes return to loopback defaults. + actions.Add(new SectionFieldAction("Daemon.Host", SectionFieldActionKind.Delete)); + } + + return new SectionContribution(actions); + } + /// <summary> /// Write the bootstrap paired device to <c>devices.json</c> so the daemon can start /// with at least one paired device. No-op for Local mode. @@ -269,5 +347,54 @@ private static bool HasExistingLocalDeviceToken(NetclawPaths paths) return !string.IsNullOrWhiteSpace(ConfigFileHelper.DecryptIfEncrypted(paths, rawToken)); } + private void TryPrefillFromExisting(WizardContext context) + { + if (context.ExistingConfig is null) + return; + + SelectedMode = ReadExistingMode(context); + + if (ConfigFileHelper.TryGetPathValue(context.ExistingConfig, "Daemon.Host", out var hostValue) + && hostValue is string host + && !string.IsNullOrWhiteSpace(host)) + { + Host = host; + } + + if (ConfigFileHelper.TryGetPathValue(context.ExistingConfig, "Daemon.TrustedProxies", out var proxiesValue)) + TrustedProxies = ReadTrustedProxies(proxiesValue); + } + + private static ExposureMode ReadExistingMode(WizardContext context) + { + if (context.ExistingConfig is null + || !ConfigFileHelper.TryGetPathValue(context.ExistingConfig, "Daemon.ExposureMode", out var modeValue)) + { + return ExposureMode.Local; + } + + return DaemonConfig.ParseExposureMode(modeValue?.ToString()); + } + + private static IReadOnlyList<string> ReadTrustedProxies(object? value) + => value switch + { + string[] strings => strings, + object[] objects => objects.Select(static item => item?.ToString()).Where(static item => !string.IsNullOrWhiteSpace(item)).Cast<string>().ToArray(), + IEnumerable<string> strings => strings.ToArray(), + _ => [] + }; + + private static string FormatModeLabel(ExposureMode mode) + => mode switch + { + ExposureMode.Local => "Local", + ExposureMode.ReverseProxy => "Reverse Proxy", + ExposureMode.TailscaleServe => "Tailscale Serve", + ExposureMode.TailscaleFunnel => "Tailscale Funnel", + ExposureMode.CloudflareTunnel => "Cloudflare Tunnel", + _ => mode.ToString() + }; + public void Dispose() { } } diff --git a/src/Netclaw.Cli/Tui/Wizard/WizardConfigBuilder.cs b/src/Netclaw.Cli/Tui/Wizard/WizardConfigBuilder.cs index ce8dd0b82..b4607f58a 100644 --- a/src/Netclaw.Cli/Tui/Wizard/WizardConfigBuilder.cs +++ b/src/Netclaw.Cli/Tui/Wizard/WizardConfigBuilder.cs @@ -23,6 +23,7 @@ public sealed class WizardConfigBuilder { private readonly NetclawPaths _paths; private readonly Dictionary<string, object> _existingConfig; + private readonly List<SectionContribution> _sectionContributions = []; public WizardConfigBuilder(NetclawPaths paths) { @@ -383,6 +384,7 @@ internal Dictionary<string, object> BuildConfigDictionary() MergeEnabledFlag(config, "Webhooks", FeatureSelections.WebhooksEnabled); } + ApplySectionContributions(config); return config; } @@ -405,9 +407,13 @@ private static void MergeEnabledFlag(Dictionary<string, object> config, string s } } - internal Dictionary<string, object> ApplyContribution(SectionContribution contribution) + internal void ApplyContribution(SectionContribution contribution) + { + _sectionContributions.Add(contribution); + } + + private static void ApplyContribution(Dictionary<string, object> config, SectionContribution contribution) { - var config = BuildConfigDictionary(); foreach (var action in contribution.FieldActionsOrEmpty) { switch (action.Action) @@ -420,8 +426,12 @@ internal Dictionary<string, object> ApplyContribution(SectionContribution contri break; } } + } - return config; + private void ApplySectionContributions(Dictionary<string, object> config) + { + foreach (var contribution in _sectionContributions) + ApplyContribution(config, contribution); } } diff --git a/src/Netclaw.Cli/Tui/Workflow/WorkflowViewComponents.cs b/src/Netclaw.Cli/Tui/Workflow/WorkflowViewComponents.cs new file mode 100644 index 000000000..defb0af07 --- /dev/null +++ b/src/Netclaw.Cli/Tui/Workflow/WorkflowViewComponents.cs @@ -0,0 +1,113 @@ +// ----------------------------------------------------------------------- +// <copyright file="WorkflowViewComponents.cs" company="Petabridge, LLC"> +// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com> +// </copyright> +// ----------------------------------------------------------------------- +using Netclaw.Cli.Tui.Wizard.Steps; +using Termina.Layout; +using Termina.Rendering; +using Termina.Terminal; + +namespace Netclaw.Cli.Tui.Workflow; + +/// <summary> +/// Narrow, reusable workflow-view building blocks for short setup-oriented flows. +/// These intentionally stay presentational and do not own navigation or validation. +/// </summary> +internal static class WorkflowViewComponents +{ + internal static ILayoutNode BuildSelectionScreen( + string heading, + ILayoutNode selector, + string? legend = null, + string? supportText = null, + Color? supportColor = null) + { + var layout = Layouts.Vertical() + .WithSpacing(1) + .WithChild(new TextNode($" {heading}").WithForeground(Color.White)) + .WithChild(selector); + + if (!string.IsNullOrWhiteSpace(legend)) + { + layout = layout.WithChild(new TextNode($" {legend}") + .WithForeground(Color.Gray)); + } + + if (!string.IsNullOrWhiteSpace(supportText)) + { + foreach (var line in SplitLines(supportText)) + { + layout = layout.WithChild(new TextNode($" {line}") + .WithForeground(supportColor ?? Color.Gray)); + } + } + + return layout; + } + + internal static ILayoutNode BuildEntryScreen( + string title, + string fieldLabel, + TextInputNode input, + string hint, + string? error = null) + { + var layout = Layouts.Vertical() + .WithSpacing(1) + .WithChild(new TextNode($" {title}").WithForeground(Color.White)) + .WithChild(new TextNode($" {fieldLabel}").WithForeground(Color.White)) + .WithChild(WizardStepHelpers.BuildTextInputPanel(input, fieldLabel)) + .WithChild(new TextNode($" {hint}").WithForeground(Color.Gray)); + + if (!string.IsNullOrWhiteSpace(error)) + { + layout = layout.WithChild(new TextNode($" ✗ {error}").WithForeground(Color.Red)); + } + + return layout; + } + + internal static ILayoutNode BuildValidatingScreen( + string heading, + string message, + string? supportText = null) + => Layouts.Vertical() + .WithSpacing(1) + .WithChild(new TextNode($" {heading}").WithForeground(Color.White)) + .WithChild(new TextNode($" {message}").WithForeground(Color.Yellow)) + .WithChild(string.IsNullOrWhiteSpace(supportText) + ? Layouts.Empty() + : new TextNode($" {supportText}").WithForeground(Color.Gray)); + + internal static ILayoutNode BuildSavedScreen(string successText, string nextStepText) + => Layouts.Vertical() + .WithSpacing(1) + .WithChild(new TextNode($" {successText}").WithForeground(Color.Green)) + .WithChild(new TextNode($" {nextStepText}").WithForeground(Color.Gray)); + + internal static ILayoutNode BuildNoticeScreen( + string title, + IEnumerable<string> bodyLines, + ILayoutNode confirmation, + Color? titleColor = null) + { + var layout = Layouts.Vertical() + .WithChild(new TextNode($" {title}").WithForeground(titleColor ?? Color.Cyan)) + .WithSpacing(1); + + foreach (var line in bodyLines) + { + layout = layout.WithChild(new TextNode($" {line}").WithForeground(Color.BrightBlack)); + } + + return layout + .WithSpacing(1) + .WithChild(confirmation); + } + + private static IEnumerable<string> SplitLines(string text) + => text.Replace("\r\n", "\n", StringComparison.Ordinal) + .Split('\n', StringSplitOptions.RemoveEmptyEntries) + .Select(static line => line.TrimEnd()); +} diff --git a/src/Netclaw.Configuration.Tests/ConfigValueMetadataProviderTests.cs b/src/Netclaw.Configuration.Tests/ConfigValueMetadataProviderTests.cs new file mode 100644 index 000000000..d11ab8353 --- /dev/null +++ b/src/Netclaw.Configuration.Tests/ConfigValueMetadataProviderTests.cs @@ -0,0 +1,43 @@ +// ----------------------------------------------------------------------- +// <copyright file="ConfigValueMetadataProviderTests.cs" company="Petabridge, LLC"> +// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com> +// </copyright> +// ----------------------------------------------------------------------- +using Xunit; + +namespace Netclaw.Configuration.Tests; + +public sealed class ConfigValueMetadataProviderTests +{ + [Fact] + public void Search_brave_api_key_metadata_marks_secret_and_secrets_store() + { + var metadata = ConfigValueMetadataProvider.Get<SearchConfig>(nameof(SearchConfig.BraveApiKey)); + + Assert.Equal("Search.BraveApiKey", metadata.Key); + Assert.Equal(ConfigPersistStore.SecretsJson, metadata.PersistTo); + Assert.True(metadata.IsSecret); + Assert.Equal(typeof(SensitiveString), metadata.ValueType); + } + + [Fact] + public void Search_backend_metadata_marks_config_store() + { + var metadata = ConfigValueMetadataProvider.Get<SearchConfig>(nameof(SearchConfig.Backend)); + + Assert.Equal("Search.Backend", metadata.Key); + Assert.Equal(ConfigPersistStore.NetclawJson, metadata.PersistTo); + Assert.False(metadata.IsSecret); + Assert.Equal(typeof(SearchBackend), metadata.ValueType); + } + + [Fact] + public void Mcp_oauth_tokens_metadata_marks_sidecar_store() + { + var metadata = ConfigValueMetadataProvider.Get<McpOAuthTokenSet>(nameof(McpOAuthTokenSet.AccessToken)); + + Assert.Equal("AccessToken", metadata.Key); + Assert.Equal(ConfigPersistStore.McpOAuthTokens, metadata.PersistTo); + Assert.True(metadata.IsSecret); + } +} diff --git a/src/Netclaw.Configuration/ConfigValueAttribute.cs b/src/Netclaw.Configuration/ConfigValueAttribute.cs new file mode 100644 index 000000000..5468e0d7a --- /dev/null +++ b/src/Netclaw.Configuration/ConfigValueAttribute.cs @@ -0,0 +1,69 @@ +// ----------------------------------------------------------------------- +// <copyright file="ConfigValueAttribute.cs" company="Petabridge, LLC"> +// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com> +// </copyright> +// ----------------------------------------------------------------------- +using System.Reflection; + +namespace Netclaw.Configuration; + +/// <summary> +/// Declares the logical configuration key and persisted home for a runtime config property. +/// These annotations are passive metadata for editoring and persistence helpers only; they do +/// not replace Netclaw's existing runtime IConfiguration overlay behavior. +/// </summary> +public enum ConfigPersistStore +{ + NetclawJson, + SecretsJson, + McpOAuthTokens, +} + +/// <summary> +/// Passive metadata describing where a runtime config value is persisted. +/// </summary> +[AttributeUsage(AttributeTargets.Property, Inherited = false)] +public sealed class ConfigValueAttribute : Attribute +{ + public required string Key { get; init; } + + public ConfigPersistStore PersistTo { get; init; } = ConfigPersistStore.NetclawJson; +} + +/// <summary> +/// Reflected metadata for a runtime config property annotated with <see cref="ConfigValueAttribute"/>. +/// </summary> +public sealed record ConfigValueMetadata( + string PropertyName, + string Key, + ConfigPersistStore PersistTo, + Type ValueType, + bool IsSecret); + +/// <summary> +/// Reflection helper for passive config metadata. +/// </summary> +public static class ConfigValueMetadataProvider +{ + public static ConfigValueMetadata Get<TConfig>(string propertyName) + => Get(typeof(TConfig), propertyName); + + public static ConfigValueMetadata Get(Type configType, string propertyName) + { + var property = configType.GetProperty(propertyName, BindingFlags.Instance | BindingFlags.Public) + ?? throw new InvalidOperationException( + $"Property '{propertyName}' was not found on config type '{configType.FullName}'."); + + var attribute = property.GetCustomAttribute<ConfigValueAttribute>() + ?? throw new InvalidOperationException( + $"Property '{configType.FullName}.{propertyName}' is missing [{nameof(ConfigValueAttribute)}]."); + + var valueType = Nullable.GetUnderlyingType(property.PropertyType) ?? property.PropertyType; + return new ConfigValueMetadata( + PropertyName: property.Name, + Key: attribute.Key, + PersistTo: attribute.PersistTo, + ValueType: valueType, + IsSecret: valueType == typeof(SensitiveString)); + } +} diff --git a/src/Netclaw.Configuration/McpOAuthTokenSet.cs b/src/Netclaw.Configuration/McpOAuthTokenSet.cs index dbd7cd352..f65499e14 100644 --- a/src/Netclaw.Configuration/McpOAuthTokenSet.cs +++ b/src/Netclaw.Configuration/McpOAuthTokenSet.cs @@ -12,12 +12,15 @@ namespace Netclaw.Configuration; public sealed class McpOAuthTokenSet { /// <summary>The current access token.</summary> + [ConfigValue(Key = "AccessToken", PersistTo = ConfigPersistStore.McpOAuthTokens)] public SensitiveString AccessToken { get; set; } = null!; /// <summary>Refresh token for obtaining new access tokens (optional).</summary> + [ConfigValue(Key = "RefreshToken", PersistTo = ConfigPersistStore.McpOAuthTokens)] public SensitiveString? RefreshToken { get; set; } /// <summary>When the access token expires (null = unknown/never).</summary> + [ConfigValue(Key = "ExpiresAt", PersistTo = ConfigPersistStore.McpOAuthTokens)] public DateTimeOffset? ExpiresAt { get; set; } /// <summary>Resolved client ID (from DCR or static config).</summary> diff --git a/src/Netclaw.Configuration/SearchConfig.cs b/src/Netclaw.Configuration/SearchConfig.cs index ae25e3b5e..6626585cd 100644 --- a/src/Netclaw.Configuration/SearchConfig.cs +++ b/src/Netclaw.Configuration/SearchConfig.cs @@ -20,17 +20,20 @@ public sealed class SearchConfig /// <summary> /// Search backend identifier. /// </summary> + [ConfigValue(Key = "Search.Backend", PersistTo = ConfigPersistStore.NetclawJson)] public SearchBackend Backend { get; set; } = SearchBackend.DuckDuckGo; /// <summary> /// Brave Search API subscription token. Required when Backend is "brave". /// Stored in secrets.json under Search.BraveApiKey. /// </summary> + [ConfigValue(Key = "Search.BraveApiKey", PersistTo = ConfigPersistStore.SecretsJson)] public SensitiveString? BraveApiKey { get; set; } /// <summary> /// SearXNG instance base URL (e.g., "http://searxng.local:8080"). /// Required when Backend is "searxng". /// </summary> + [ConfigValue(Key = "Search.SearXngEndpoint", PersistTo = ConfigPersistStore.NetclawJson)] public string? SearXngEndpoint { get; set; } } diff --git a/tests/smoke/assertions/config-exposure.sh b/tests/smoke/assertions/config-exposure.sh new file mode 100755 index 000000000..e93cdeb08 --- /dev/null +++ b/tests/smoke/assertions/config-exposure.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# config-exposure.tape post-tape assertion. + +set -euo pipefail + +. "$(dirname "$0")/_lib.sh" + +assert_fail=0 + +echo "config-exposure: reading produced config..." +if [[ ! -f "$CONFIG_PATH" ]]; then + echo "FAIL: ${CONFIG_PATH} does not exist." >&2 + exit 1 +fi + +config_json="$(read_config_json)" + +assert_field '.Daemon.ExposureMode' 'reverse-proxy' "$config_json" || : +assert_field '.Daemon.Host' '0.0.0.0' "$config_json" || : +assert_field '.Daemon.Port' '5299' "$config_json" || : +assert_field '.Daemon.DisableSelfUpdate' 'true' "$config_json" || : +assert_field '.Daemon.TrustedProxies[0]' '10.0.0.0/24' "$config_json" || : + +if (( assert_fail )); then + printf -- '--- netclaw.json contents ---\n%s\n' "$config_json" >&2 + exit 1 +fi + +echo "config-exposure: assertions passed." diff --git a/tests/smoke/assertions/init-wizard-reverse-proxy.sh b/tests/smoke/assertions/init-wizard-reverse-proxy.sh deleted file mode 100755 index 5aa051d80..000000000 --- a/tests/smoke/assertions/init-wizard-reverse-proxy.sh +++ /dev/null @@ -1,70 +0,0 @@ -#!/usr/bin/env bash -# init-wizard-reverse-proxy.tape post-tape assertion. -# -# Validates that the wizard surfaced reverse-proxy as an exposure mode and -# produced a startable config: -# 1) config/netclaw.json exists and parses as JSON -# 2) `netclaw doctor` does not report errors (exit 0 = clean, exit 2 = WARN ok) -# 3) Daemon section contains ExposureMode=reverse-proxy, Host=0.0.0.0, -# and TrustedProxies[0]=10.0.0.0/24 — what the tape typed. -# 4) Bootstrap device file exists (reverse-proxy is non-local so the -# wizard must seed at least one paired device). - -set -euo pipefail - -. "$(dirname "$0")/_lib.sh" - -assert_fail=0 - -echo "init-wizard-reverse-proxy: reading produced config..." -if [[ ! -f "$CONFIG_PATH" ]]; then - echo "FAIL: ${CONFIG_PATH} does not exist after wizard run." >&2 - ls -la "$NETCLAW_HOME" 2>&1 >&2 || true - exit 1 -fi - -config_json="$(read_config_json)" -if ! printf '%s' "$config_json" | jq empty >/dev/null 2>&1; then - echo "FAIL: ${CONFIG_PATH} is not valid JSON." >&2 - printf '%s\n' "$config_json" >&2 - exit 1 -fi - -echo "init-wizard-reverse-proxy: running 'netclaw doctor'..." -doctor_status=0 -"$NETCLAW_SMOKE_CLI" doctor || doctor_status=$? -if [[ $doctor_status -eq 1 ]]; then - echo "FAIL: netclaw doctor reported errors (exit 1)." >&2 - exit 1 -fi -if [[ $doctor_status -ne 0 && $doctor_status -ne 2 ]]; then - echo "FAIL: netclaw doctor exited with unexpected status $doctor_status." >&2 - exit 1 -fi - -echo "init-wizard-reverse-proxy: checking Daemon section..." -assert_field '.Daemon.ExposureMode' 'reverse-proxy' "$config_json" || : -assert_field '.Daemon.Host' '0.0.0.0' "$config_json" || : -assert_field '.Daemon.TrustedProxies[0]' '10.0.0.0/24' "$config_json" || : - -echo "init-wizard-reverse-proxy: confirming bootstrap device was seeded..." -devices_path="${NETCLAW_HOME}/config/devices.json" -if [[ ! -f "$devices_path" ]]; then - echo "FAIL: ${devices_path} not written — bootstrap device missing." >&2 - assert_fail=1 -else - device_count="$(jq 'length' "$devices_path" 2>/dev/null || echo 0)" - if [[ "$device_count" -lt 1 ]]; then - echo "FAIL: ${devices_path} contains no paired devices." >&2 - assert_fail=1 - else - echo " ok ${devices_path} has $device_count device(s)" - fi -fi - -if (( assert_fail )); then - printf -- '--- netclaw.json contents ---\n%s\n' "$config_json" >&2 - exit 1 -fi - -echo "init-wizard-reverse-proxy: assertions passed." diff --git a/tests/smoke/tapes/config-exposure.tape b/tests/smoke/tapes/config-exposure.tape new file mode 100644 index 000000000..181bff309 --- /dev/null +++ b/tests/smoke/tapes/config-exposure.tape @@ -0,0 +1,66 @@ +# config-exposure.tape — edit Exposure Mode from netclaw config. +# +# Exposure Mode is a post-install Security & Access leaf, not an init-wizard +# step. This tape exercises the configured route: +# netclaw config -> Security & Access -> Exposure Mode +# and verifies the reverse-proxy branch writes the existing Daemon config shape. + +Output "/tmp/tape-config-exposure.gif" + +# ─── Seed minimal installed config ─────────────────────────────────── +Type "mkdir -p $NETCLAW_HOME/config" +Enter +Type "jq -n '{configVersion:1, Daemon:{Port:5299, DisableSelfUpdate:true}}' > $NETCLAW_HOME/config/netclaw.json" +Enter + +# ─── Launch config dashboard ───────────────────────────────────────── +Type "netclaw config" +Enter +Wait+Screen@10s /Settings Areas/ + +# Root dashboard order: Inference Providers, Models, Channels, Inbound Webhooks, +# Skill Sources, Search, Browser Automation, Telemetry & Alerting, Security & Access. +Down 8 +Enter +Wait+Screen@10s /Security & Access/ + +# Security & Access order: Security Posture, Enabled Features, Audience Profiles, +# Exposure Mode. +Down 3 +Enter +Wait+Screen@10s /How will this Netclaw daemon be accessed/ + +# Select Reverse Proxy (second option). +Down +Enter + +# Accept default reverse-proxy bind address. +Wait+Screen@10s /Reverse proxy: bind address/ +Enter + +# Enter one trusted proxy CIDR. +Wait+Screen@10s /Reverse proxy: trusted proxies/ +Type "10.0.0.0/24" +Enter + +# Confirm notice and save. +Wait+Screen@10s /Reverse proxy configured/ +Wait+Screen@5s /http:\/\/0\.0\.0\.0:[0-9]+/ +Enter +Wait+Screen@10s /Reverse Proxy exposure mode saved/ + +# Saved-state back behavior: Esc returns to the mode list before parent page. +Escape +Wait+Screen@10s /How will this Netclaw daemon be accessed/ +Escape +Wait+Screen@10s /Security & Access/ +Ctrl+Q + +Wait+Screen@10s /TAPE\$/ + +Type "echo CONFIG_EXPOSURE_EXIT=$?" +Enter +Wait+Screen@5s /CONFIG_EXPOSURE_EXIT=0/ + +Type "exit" +Enter diff --git a/tests/smoke/tapes/init-wizard-reverse-proxy.tape b/tests/smoke/tapes/init-wizard-reverse-proxy.tape deleted file mode 100644 index 56c75eabe..000000000 --- a/tests/smoke/tapes/init-wizard-reverse-proxy.tape +++ /dev/null @@ -1,148 +0,0 @@ -# init-wizard-reverse-proxy.tape — Personal posture, Ollama, ReverseProxy exposure. -# -# Variant of init-wizard.tape that exercises the reverse-proxy branch of the -# Network Exposure step. Covers: -# - selecting "Reverse Proxy" from the exposure mode list (second option) -# - the bind-address text input (kept at the 0.0.0.0 default) -# - the trusted-proxies text input (one CIDR entered) -# - the medium-risk notice screen that shows the serving URL -# -# The post-tape assertion (init-wizard-reverse-proxy.sh) jq-checks the produced -# Daemon section and runs `netclaw doctor`. -# -# Synchronization rule (per tapes/README.md): no literal Sleep for step -# synchronization; every step has a Wait+Screen anchor pulled from the -# matching *StepView.cs. - -Output "/tmp/tape-init-wizard-reverse-proxy.gif" - -# ─── Launch ────────────────────────────────────────────────────────── -Type "netclaw init" -Enter - -# ─── Step 1: Provider ─────────────────────────────────────────────── -Wait+Screen@10s /Choose your LLM provider:/ -# Provider list ordering is alphabetical by TypeKey: -# anthropic, github-copilot, ollama, openai, openai-compatible, openrouter -# Two Downs from the Anthropic default land on Ollama. -Down 2 -Enter - -Wait+Screen@10s /endpoint:/ -Right 32 -Backspace 32 -Type "http://localhost:11434" -Enter - -Wait+Screen@45s /Select a model/ -# Termina list-key-handler wiring beat — see init-wizard.tape:46 -Sleep 1s -Down -Enter - -# ─── Step 2: Security Posture ──────────────────────────────────────── -Wait+Screen@10s /Who will interact with this Netclaw instance/ -Enter - -# ─── Step 3: Channel Picker ────────────────────────────────────────── -Wait+Screen@10s /Which channels would you like to connect/ -Type "d" - -# ─── Step 4: Web Search ───────────────────────────────────────────── -Wait+Screen@10s /Choose your web search provider/ -Enter - -# ─── Step 5: Browser Automation ────────────────────────────────────── -Wait+Screen@10s /Enable browser automation/ -Enter - -# ─── Step 6: Identity ─────────────────────────────────────────────── -Wait+Screen@10s /Agent name:/ -Enter - -Wait+Screen@10s /Communication style:/ -Enter - -Wait+Screen@10s /Your name:/ -Type "SmokeTester" -Enter - -Wait+Screen@10s /Your timezone:/ -Enter - -Wait+Screen@10s /Projects directory:/ -Enter - -Wait+Screen@10s /Notification webhook URL/ -Enter - -# ─── Step 8: Skill Feeds ──────────────────────────────────────────── -Wait+Screen@10s /Connect to a private skill server/ -Down -Enter - -# ─── Step 9: Network Exposure (reverse proxy branch) ──────────────── -Wait+Screen@10s /How will this Netclaw daemon be accessed/ -# Mode list (ExposureModeStepView.BuildModeSelection): -# 0: Local — loopback only, safest (recommended) ← default -# 1: Reverse Proxy — behind nginx, Caddy, Traefik, ... ← target -# 2: Tailscale Serve -# 3: Tailscale Funnel -# 4: Cloudflare Tunnel -Down -Enter - -# Sub-step 1: bind address. Default placeholder is 0.0.0.0; accept it. -Wait+Screen@10s /Reverse proxy: bind address/ -Enter - -# Sub-step 2: trusted proxies. Empty list is rejected by the ViewModel gate; -# enter one CIDR so the wizard can advance. -Wait+Screen@10s /Reverse proxy: trusted proxies/ -Type "10.0.0.0/24" -Enter - -# Sub-step 3: notice with serving URL. -Wait+Screen@10s /Reverse proxy configured/ -# Serving URL line includes the configured bind address and smoke daemon port. -Wait+Screen@5s /http:\/\/0\.0\.0\.0:[0-9]+/ -Enter - -# Sub-step 4: webhook toggle. -# Option order is [No (default), Yes]. Down+Enter selects "Yes — accept inbound -# webhook requests" so the produced netclaw.json gets a Webhooks section. -# (The assertion script does not validate the webhook choice; both branches -# work for the smoke run. We deliberately exercise the non-default branch to -# prove the toggle plumbing.) -Wait+Screen@10s /Should this daemon accept inbound webhooks/ -Down -Enter - -# ─── Step 10: Health Check ────────────────────────────────────────── -Wait+Screen@10s /Press Enter to run health checks/ -Enter - -# Daemon starts on the configured reverse-proxy port, but in reverse-proxy mode the CLI cannot -# auto-auth back to it via loopback (loopback auto-auth is intentionally -# disabled for reverse-proxy to prevent a forwarded-header from inheriting -# operator privileges). The wizard's chat-page handshake therefore gets 401 -# and the wizard exits to the shell instead of opening the TUI — that's -# correct behavior, not a bug. -# -# Wait for either terminal state: -# (a) chat-page ready bar (if a future change ever wires post-init differently) -# (b) the shell prompt re-appearing post-wizard-exit, with `netclaw init` -# still visible as the last-typed command -Wait+Screen@180s /(Ready \| qwen2:0\.5b|TAPE\$ netclaw init)/ -# No-op if we're already at the shell; quits the TUI if (a) above. -Ctrl+Q - -Wait+Screen@15s /TAPE\$ / - -# Sanity: re-check at the prompt that init reported success. -Type "echo INIT_EXIT=$?" -Enter -Wait+Screen@5s /INIT_EXIT=/ - -Type "exit" -Enter diff --git a/tests/smoke/tapes/init-wizard.tape b/tests/smoke/tapes/init-wizard.tape index 509cac8cf..06c223326 100644 --- a/tests/smoke/tapes/init-wizard.tape +++ b/tests/smoke/tapes/init-wizard.tape @@ -103,17 +103,7 @@ Wait+Screen@10s /Connect to a private skill server/ Down Enter -# ─── Step 9: Network Exposure ─────────────────────────────────────── -Wait+Screen@10s /How will this Netclaw daemon be accessed/ -# "Local — loopback only" is the first / default option. -Enter - -Wait+Screen@10s /Should this daemon accept inbound webhooks/ -# "No" — second option. -Down -Enter - -# ─── Step 10: Health Check ────────────────────────────────────────── +# ─── Step 9: Health Check ─────────────────────────────────────────── Wait+Screen@10s /Press Enter to run health checks/ Enter From 58140f98904657fecb5d24d8f8c6a61170378e35 Mon Sep 17 00:00:00 2001 From: Aaron Stannard <aaron@petabridge.com> Date: Thu, 28 May 2026 01:00:11 +0000 Subject: [PATCH 14/31] fix(tui): show active config selections --- .../Tui/Config/ExposureModeConfigPageTests.cs | 85 +++++++++++ .../Tui/Config/SearchConfigEditorPageTests.cs | 6 +- .../Tui/Config/SearchConfigEditorPage.cs | 105 +++---------- .../Tui/Config/SearchConfigEditorViewModel.cs | 1 - .../Tui/Config/SearchSectionSpec.cs | 2 - .../Tui/Wizard/Steps/ExposureModeStepView.cs | 63 ++++---- .../Tui/Workflow/ActiveSelectionList.cs | 142 ++++++++++++++++++ 7 files changed, 286 insertions(+), 118 deletions(-) create mode 100644 src/Netclaw.Cli.Tests/Tui/Config/ExposureModeConfigPageTests.cs create mode 100644 src/Netclaw.Cli/Tui/Workflow/ActiveSelectionList.cs diff --git a/src/Netclaw.Cli.Tests/Tui/Config/ExposureModeConfigPageTests.cs b/src/Netclaw.Cli.Tests/Tui/Config/ExposureModeConfigPageTests.cs new file mode 100644 index 000000000..d00712ec8 --- /dev/null +++ b/src/Netclaw.Cli.Tests/Tui/Config/ExposureModeConfigPageTests.cs @@ -0,0 +1,85 @@ +// ----------------------------------------------------------------------- +// <copyright file="ExposureModeConfigPageTests.cs" company="Petabridge, LLC"> +// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com> +// </copyright> +// ----------------------------------------------------------------------- +using Microsoft.Extensions.DependencyInjection; +using Netclaw.Cli.Tui.Config; +using Netclaw.Configuration; +using Netclaw.Tests.Utilities; +using Termina; +using Termina.Hosting; +using Termina.Input; +using Termina.Terminal; +using Xunit; + +namespace Netclaw.Cli.Tests.Tui.Config; + +public sealed class ExposureModeConfigPageTests : IDisposable +{ + private readonly DisposableTempDir _dir = new(); + private readonly NetclawPaths _paths; + + public ExposureModeConfigPageTests() + { + _paths = new NetclawPaths(_dir.Path); + _paths.EnsureDirectoriesExist(); + File.WriteAllText(_paths.NetclawConfigPath, """ + { + "configVersion": 1, + "Daemon": { + "ExposureMode": "reverse-proxy", + "Host": "10.0.0.5", + "TrustedProxies": ["10.0.0.0/24"] + } + } + """); + } + + public void Dispose() => _dir.Dispose(); + + [Fact] + public async Task ModeSelection_RendersActiveCheckboxForSavedExposureMode() + { + var (terminal, app, _) = CreateHeadlessApp(out var input); + input.EnqueueKey(ConsoleKey.Q, false, false, true); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await app.RunAsync(cts.Token); + + Assert.True(terminal.Contains("[x] active exposure mode"), + $"Expected active exposure-mode legend in terminal output. Screen:\n{terminal}"); + Assert.True(terminal.Contains("[x] Reverse Proxy"), + $"Expected saved reverse-proxy mode checkbox in terminal output. Screen:\n{terminal}"); + } + + private (VirtualTerminal Terminal, TerminaApplication App, ExposureModeConfigViewModel Vm) + CreateHeadlessApp(out VirtualInputSource input) + { + var terminal = new VirtualTerminal(120, 40); + var virtualInput = new VirtualInputSource(); + input = virtualInput; + + ExposureModeConfigViewModel? capturedVm = null; + + var services = new ServiceCollection(); + services.AddSingleton<IAnsiTerminal>(terminal); + services.AddTerminaVirtualInput(virtualInput); + services.AddTermina("/exposure", builder => + { + builder.RegisterRoute<ExposureModeConfigPage, ExposureModeConfigViewModel>( + "/exposure", + _ => new ExposureModeConfigPage(), + _ => + { + capturedVm = new ExposureModeConfigViewModel(_paths); + return capturedVm; + }); + }); + + var sp = services.BuildServiceProvider(); + var app = sp.GetRequiredService<TerminaApplication>(); + + return (terminal, app, capturedVm!); + } +} diff --git a/src/Netclaw.Cli.Tests/Tui/Config/SearchConfigEditorPageTests.cs b/src/Netclaw.Cli.Tests/Tui/Config/SearchConfigEditorPageTests.cs index 4596bfd01..904655d46 100644 --- a/src/Netclaw.Cli.Tests/Tui/Config/SearchConfigEditorPageTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/Config/SearchConfigEditorPageTests.cs @@ -37,7 +37,7 @@ public SearchConfigEditorPageTests() public void Dispose() => _dir.Dispose(); [Fact] - public async Task ProviderSelection_RendersActiveAndConfiguredLegend() + public async Task ProviderSelection_RendersActiveCheckboxAndConfiguredLegend() { var (terminal, app, _) = CreateHeadlessApp(out var input); input.EnqueueKey(ConsoleKey.Q, false, false, true); @@ -45,8 +45,10 @@ public async Task ProviderSelection_RendersActiveAndConfiguredLegend() using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); await app.RunAsync(cts.Token); - Assert.True(terminal.Contains("(*) active backend"), + Assert.True(terminal.Contains("[x] active backend"), $"Expected active-backend legend in terminal output. Screen:\n{terminal}"); + Assert.True(terminal.Contains("[x] DuckDuckGo"), + $"Expected active backend checkbox in terminal output. Screen:\n{terminal}"); Assert.True(terminal.Contains("backend has saved setup"), $"Expected configured-backend legend in terminal output. Screen:\n{terminal}"); } diff --git a/src/Netclaw.Cli/Tui/Config/SearchConfigEditorPage.cs b/src/Netclaw.Cli/Tui/Config/SearchConfigEditorPage.cs index 7faef36a0..016f3eae3 100644 --- a/src/Netclaw.Cli/Tui/Config/SearchConfigEditorPage.cs +++ b/src/Netclaw.Cli/Tui/Config/SearchConfigEditorPage.cs @@ -22,7 +22,7 @@ internal sealed class SearchConfigEditorPage : ReactivePage<SearchConfigEditorVi private string? _textInputFieldPath; private DynamicLayoutNode? _contentNode; private readonly CompositeDisposable _contentSubscriptions = []; - private int _providerIndex; + private ActiveSelectionList<ConfigEnumOption>? _providerList; private bool _providerSelectionSynced; public override void OnNavigatedTo() @@ -93,9 +93,9 @@ private ILayoutNode BuildProviderSelectionScreen() return WorkflowViewComponents.BuildSelectionScreen( heading: "Choose the backend Netclaw uses for web search.", - selector: BuildProviderList(), - legend: ViewModel.ConfiguredLegend, - supportText: ViewModel.GetProviderDescription(ViewModel.BackendOptions[_providerIndex].Value)); + selector: EnsureProviderList().AsLayout(), + legend: ActiveSelectionList<ConfigEnumOption>.BuildLegend("active backend", "backend has saved setup"), + supportText: ViewModel.GetProviderDescription(EnsureProviderList().FocusedOption.Value)); } private ILayoutNode BuildEntryScreen() @@ -134,30 +134,19 @@ private ILayoutNode BuildSavedScreen() successText: ViewModel.GetSavedMessage(), nextStepText: ViewModel.GetSavedNextStepText()); - private ILayoutNode BuildProviderList() - { - var content = Layouts.Vertical(); - var options = ViewModel.BackendOptions; - for (var i = 0; i < options.Count; i++) - { - var option = options[i]; - var isFocused = i == _providerIndex; - var isActive = string.Equals(option.Value, ViewModel.CurrentBackendValue, StringComparison.OrdinalIgnoreCase); - var marker = isActive ? "(*)" : "( )"; - var prefix = isFocused ? ">" : " "; - var status = IsConfigured(option.Value) ? "\u2713" : " "; - var line = $" {prefix} {marker} {option.Label,-20} {status}"; - var color = isFocused ? Color.Cyan : Color.White; - - var node = new TextNode(line).WithForeground(color); - if (isActive) - node.Bold(); - - content.WithChild(node.Height(1)); - } - - return content; - } + private ActiveSelectionList<ConfigEnumOption> EnsureProviderList() + => _providerList ??= new ActiveSelectionList<ConfigEnumOption>( + ViewModel.BackendOptions, + static option => option.Label, + option => string.Equals(option.Value, ViewModel.CurrentBackendValue, StringComparison.OrdinalIgnoreCase), + option => IsConfigured(option.Value) ? "✓" : " ", + SelectProviderForEditing, + () => + { + _contentNode?.Invalidate(); + ViewModel.RequestRedraw(); + }, + labelPadWidth: 20); private ILayoutNode BuildProbeWarningDialog() { @@ -275,55 +264,13 @@ public override bool HandlePageInput(ConsoleKeyInfo keyInfo) if (ViewModel.CurrentScreen.Value == SearchConfigEditorScreen.ProviderSelection) { - if (keyInfo.Key == ConsoleKey.UpArrow) - { - MoveProviderSelection(-1); - return true; - } - - if (keyInfo.Key == ConsoleKey.DownArrow) - { - MoveProviderSelection(1); - return true; - } - - if (keyInfo.Key == ConsoleKey.Enter) - { - var option = ViewModel.BackendOptions[_providerIndex]; - ViewModel.SelectBackendForEditing(option.Value); - ResetEntryInput(); - _contentNode?.Invalidate(); - ViewModel.RequestRedraw(); - return true; - } - + EnsureProviderList().HandleInput(keyInfo); return true; } if (ViewModel.CurrentScreen.Value == SearchConfigEditorScreen.Saved) { - if (keyInfo.Key == ConsoleKey.UpArrow) - { - MoveProviderSelection(-1); - return true; - } - - if (keyInfo.Key == ConsoleKey.DownArrow) - { - MoveProviderSelection(1); - return true; - } - - if (keyInfo.Key == ConsoleKey.Enter) - { - var option = ViewModel.BackendOptions[_providerIndex]; - ViewModel.SelectBackendForEditing(option.Value); - ResetEntryInput(); - _contentNode?.Invalidate(); - ViewModel.RequestRedraw(); - return true; - } - + EnsureProviderList().HandleInput(keyInfo); return true; } @@ -371,19 +318,13 @@ private void SyncProviderIndexToCurrentBackend() .FirstOrDefault(entry => string.Equals(entry.option.Value, ViewModel.CurrentBackendValue, StringComparison.OrdinalIgnoreCase)) .idx; - _providerIndex = Math.Clamp(index, 0, Math.Max(0, ViewModel.BackendOptions.Count - 1)); + EnsureProviderList().SetFocusedIndex(index, notify: false); } - private void MoveProviderSelection(int delta) + private void SelectProviderForEditing(ConfigEnumOption option) { - if (ViewModel.BackendOptions.Count == 0) - return; - - var next = Math.Clamp(_providerIndex + delta, 0, ViewModel.BackendOptions.Count - 1); - if (next == _providerIndex) - return; - - _providerIndex = next; + ViewModel.SelectBackendForEditing(option.Value); + ResetEntryInput(); _contentNode?.Invalidate(); ViewModel.RequestRedraw(); } diff --git a/src/Netclaw.Cli/Tui/Config/SearchConfigEditorViewModel.cs b/src/Netclaw.Cli/Tui/Config/SearchConfigEditorViewModel.cs index 6a3391fd3..9d73e7792 100644 --- a/src/Netclaw.Cli/Tui/Config/SearchConfigEditorViewModel.cs +++ b/src/Netclaw.Cli/Tui/Config/SearchConfigEditorViewModel.cs @@ -89,7 +89,6 @@ public SearchConfigEditorViewModel( public bool IsDirty => ComputeIsDirty(); public SearchProbeResult? LastProbeResult => _lastProbeResult; - public string ConfiguredLegend => _spec.GetConfiguredLegend(); public string CurrentBackendValue => _model.Backend.ToWireValue(); public string CurrentBackendLabel => _spec.GetBackendLabel(_model.Backend); diff --git a/src/Netclaw.Cli/Tui/Config/SearchSectionSpec.cs b/src/Netclaw.Cli/Tui/Config/SearchSectionSpec.cs index 17395ad87..8acc4cd06 100644 --- a/src/Netclaw.Cli/Tui/Config/SearchSectionSpec.cs +++ b/src/Netclaw.Cli/Tui/Config/SearchSectionSpec.cs @@ -124,8 +124,6 @@ internal string GetValidatingMessage(SearchEditorModel model) _ => "Validating DuckDuckGo configuration", }; - internal string GetConfiguredLegend() => "(*) active backend ✓ backend has saved setup"; - internal string GetSavedMessage(SearchEditorModel model) => $"✔ {GetBackendLabel(model.Backend)} validated and saved."; diff --git a/src/Netclaw.Cli/Tui/Wizard/Steps/ExposureModeStepView.cs b/src/Netclaw.Cli/Tui/Wizard/Steps/ExposureModeStepView.cs index 3695c9d17..a1052e453 100644 --- a/src/Netclaw.Cli/Tui/Wizard/Steps/ExposureModeStepView.cs +++ b/src/Netclaw.Cli/Tui/Wizard/Steps/ExposureModeStepView.cs @@ -25,7 +25,16 @@ namespace Netclaw.Cli.Tui.Wizard.Steps; /// </summary> public sealed class ExposureModeStepView : IWizardStepView { - private IDisposable? _modeList; + private static readonly IReadOnlyList<SelectionOption<ExposureMode>> ModeOptions = + [ + new(ExposureMode.Local, "Local — loopback only, safest (recommended)"), + new(ExposureMode.ReverseProxy, "Reverse Proxy — behind nginx, Caddy, Traefik, IIS, ALB, etc."), + new(ExposureMode.TailscaleServe, "Tailscale Serve — accessible within your tailnet"), + new(ExposureMode.TailscaleFunnel, "Tailscale Funnel — public internet ⚠"), + new(ExposureMode.CloudflareTunnel, "Cloudflare Tunnel — public internet ⚠") + ]; + + private ActiveSelectionList<SelectionOption<ExposureMode>>? _modeList; private SelectionListNode<string>? _confirmList; private IDisposable? _webhookList; private TextInputNode? _hostInput; @@ -45,6 +54,9 @@ public ILayoutNode BuildContent(IWizardStepViewModel stepVm, StepViewCallbacks c { var vm = (ExposureModeStepViewModel)stepVm; + if (vm.CurrentSubStep != 0) + _modeList = null; + if (vm.CurrentSubStep == 0) return BuildModeSelection(vm, callbacks); @@ -65,46 +77,32 @@ public ILayoutNode BuildContent(IWizardStepViewModel stepVm, StepViewCallbacks c private ILayoutNode BuildModeSelection(ExposureModeStepViewModel vm, StepViewCallbacks callbacks) { - var localOption = new SelectionOption<ExposureMode>(ExposureMode.Local, - "Local — loopback only, safest (recommended)"); - var reverseProxyOption = new SelectionOption<ExposureMode>(ExposureMode.ReverseProxy, - "Reverse Proxy — behind nginx, Caddy, Traefik, IIS, ALB, etc."); - var serveOption = new SelectionOption<ExposureMode>(ExposureMode.TailscaleServe, - "Tailscale Serve — accessible within your tailnet"); - var funnelOption = new SelectionOption<ExposureMode>(ExposureMode.TailscaleFunnel, - "Tailscale Funnel — public internet ⚠"); - var cloudflareOption = new SelectionOption<ExposureMode>(ExposureMode.CloudflareTunnel, - "Cloudflare Tunnel — public internet ⚠"); - - var modeList = Layouts.SelectionList<SelectionOption<ExposureMode>>( - [localOption, reverseProxyOption, serveOption, funnelOption, cloudflareOption], - static o => o.ToString()) - .WithMode(SelectionMode.Single) - .WithHighlightColors(Color.Black, Color.Cyan); - - _modeList = modeList; - modeList.OnFocused(); - _lastFocusedList = modeList; + _modeList = null; + _lastFocusedList = null; _lastFocusedInput = null; _confirmList = null; _webhookList = null; _hostInput = null; _trustedProxiesInput = null; - modeList.SelectionConfirmed - .Subscribe(selected => + var modeList = new ActiveSelectionList<SelectionOption<ExposureMode>>( + ModeOptions, + static option => option.Label, + option => option.Value == vm.SelectedMode, + confirmed: option => { - if (selected.Count > 0) - { - vm.SelectedMode = selected[0].Value; - callbacks.AdvanceStep(); - } - }) - .DisposeWith(callbacks.Subscriptions); + vm.SelectedMode = option.Value; + callbacks.AdvanceStep(); + }, + changed: callbacks.RequestRedraw); + modeList.FocusFirst(option => option.Value == vm.SelectedMode); + + _modeList = modeList; return WorkflowViewComponents.BuildSelectionScreen( heading: "How will this Netclaw daemon be accessed?", - selector: modeList, + selector: modeList.AsLayout(), + legend: ActiveSelectionList<SelectionOption<ExposureMode>>.BuildLegend("active exposure mode"), supportText: "⚠ = exposes daemon beyond this machine. Ensure auth is configured first.", supportColor: Color.BrightBlack); } @@ -389,6 +387,9 @@ private static string FormatServingUrl(string host) public bool HandleKeyPress(KeyPressed key) { + if (_modeList is not null && _modeList.HandleInput(key.KeyInfo)) + return true; + if (_lastFocusedList is not null) { _lastFocusedList.HandleInput(key.KeyInfo); diff --git a/src/Netclaw.Cli/Tui/Workflow/ActiveSelectionList.cs b/src/Netclaw.Cli/Tui/Workflow/ActiveSelectionList.cs new file mode 100644 index 000000000..118c8c86b --- /dev/null +++ b/src/Netclaw.Cli/Tui/Workflow/ActiveSelectionList.cs @@ -0,0 +1,142 @@ +// ----------------------------------------------------------------------- +// <copyright file="ActiveSelectionList.cs" company="Petabridge, LLC"> +// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com> +// </copyright> +// ----------------------------------------------------------------------- +using Termina.Layout; +using Termina.Reactive; +using Termina.Rendering; +using Termina.Terminal; + +namespace Netclaw.Cli.Tui.Workflow; + +internal sealed class ActiveSelectionList<T> +{ + private readonly IReadOnlyList<T> _options; + private readonly Func<T, string> _labelSelector; + private readonly Func<T, bool> _activeSelector; + private readonly Func<T, string?>? _statusSelector; + private readonly Action<T>? _confirmed; + private readonly Action? _changed; + private readonly int _labelPadWidth; + private readonly DynamicLayoutNode _layout; + + public ActiveSelectionList( + IReadOnlyList<T> options, + Func<T, string> labelSelector, + Func<T, bool> activeSelector, + Func<T, string?>? statusSelector = null, + Action<T>? confirmed = null, + Action? changed = null, + int focusedIndex = 0, + int labelPadWidth = 0) + { + _options = options; + _labelSelector = labelSelector; + _activeSelector = activeSelector; + _statusSelector = statusSelector; + _confirmed = confirmed; + _changed = changed; + _labelPadWidth = labelPadWidth; + FocusedIndex = ClampIndex(focusedIndex); + _layout = new DynamicLayoutNode(BuildRows); + } + + public int FocusedIndex { get; private set; } + + public T FocusedOption => _options[FocusedIndex]; + + public ILayoutNode AsLayout() => _layout; + + public void FocusFirst(Func<T, bool> predicate) + { + var index = _options + .Select((option, idx) => (option, idx)) + .FirstOrDefault(entry => predicate(entry.option)) + .idx; + + SetFocusedIndex(index, notify: false); + } + + public void SetFocusedIndex(int index, bool notify = true) + { + var next = ClampIndex(index); + if (next == FocusedIndex) + return; + + FocusedIndex = next; + if (notify) + Invalidate(); + else + _layout.Invalidate(); + } + + public bool HandleInput(ConsoleKeyInfo keyInfo) + { + if (_options.Count == 0) + return false; + + switch (keyInfo.Key) + { + case ConsoleKey.UpArrow: + Move(-1); + return true; + case ConsoleKey.DownArrow: + Move(1); + return true; + case ConsoleKey.Enter: + _confirmed?.Invoke(FocusedOption); + return true; + default: + return false; + } + } + + public static string BuildLegend(string activeLabel, string? statusLabel = null) + => statusLabel is null + ? $"[x] {activeLabel}" + : $"[x] {activeLabel} ✓ {statusLabel}"; + + private void Move(int delta) => SetFocusedIndex(FocusedIndex + delta); + + private int ClampIndex(int index) + => _options.Count == 0 + ? 0 + : Math.Clamp(index, 0, _options.Count - 1); + + private void Invalidate() + { + _layout.Invalidate(); + _changed?.Invoke(); + } + + private ILayoutNode BuildRows() + { + var content = Layouts.Vertical(); + var clampedFocusedIndex = ClampIndex(FocusedIndex); + for (var i = 0; i < _options.Count; i++) + { + var option = _options[i]; + var isFocused = i == clampedFocusedIndex; + var isActive = _activeSelector(option); + var prefix = isFocused ? ">" : " "; + var checkbox = isActive ? "[x]" : "[ ]"; + var label = _labelSelector(option); + if (_labelPadWidth > 0) + label = label.PadRight(_labelPadWidth); + + var line = $" {prefix} {checkbox} {label}"; + var status = _statusSelector?.Invoke(option); + if (status is not null) + line += $" {status}"; + + var node = new TextNode(line).WithForeground(isFocused ? Color.Cyan : Color.White); + if (isActive) + node.Bold(); + + content.WithChild(node.Height(1)); + } + + return content; + } +} From b2cbf15e9d70bc40b6512750f91c3877469e6bbe Mon Sep 17 00:00:00 2001 From: Aaron Stannard <aaron@petabridge.com> Date: Thu, 28 May 2026 01:24:32 +0000 Subject: [PATCH 15/31] fix(config): preserve inactive exposure settings --- docs/ui/TUI-002-netclaw-config-wireframes.md | 7 ++ .../.system/files/netclaw-operations/SKILL.md | 12 ++- .../ExposureModeConfigViewModelTests.cs | 55 +++++++++++ .../Tui/Wizard/SectionEditorLeafTests.cs | 12 ++- .../Tui/Sections/ConfigEditorStateStore.cs | 96 +++++++++++++++++++ .../Sections/SectionEditorInfrastructure.cs | 18 +++- .../Wizard/Steps/ExposureModeStepViewModel.cs | 81 +++++++++++++--- .../Tui/Wizard/WizardConfigBuilder.cs | 8 ++ 8 files changed, 273 insertions(+), 16 deletions(-) create mode 100644 src/Netclaw.Cli/Tui/Sections/ConfigEditorStateStore.cs diff --git a/docs/ui/TUI-002-netclaw-config-wireframes.md b/docs/ui/TUI-002-netclaw-config-wireframes.md index 52d99a195..3fca877fa 100644 --- a/docs/ui/TUI-002-netclaw-config-wireframes.md +++ b/docs/ui/TUI-002-netclaw-config-wireframes.md @@ -628,6 +628,13 @@ Structurally identical to 2.x plus: **Conditionality:** `Configure mode →` is enabled only when the selected mode requires sub-config. Local has no sub-config. +**Inactive values:** Mode-specific values are preserved for later reactivation, +but only active-mode fields remain in `netclaw.json`. For example, switching +from Reverse Proxy to Local removes runtime-active `Daemon.Host` and +`Daemon.TrustedProxies` so local startup validation remains loopback-only; the +config editor keeps the dormant reverse-proxy values in editor state and restores +them if Reverse Proxy is selected again. + ### 9.5.2 Reverse Proxy sub-form (T1-shaped) ``` diff --git a/feeds/skills/.system/files/netclaw-operations/SKILL.md b/feeds/skills/.system/files/netclaw-operations/SKILL.md index 8c068b445..d7db6f158 100644 --- a/feeds/skills/.system/files/netclaw-operations/SKILL.md +++ b/feeds/skills/.system/files/netclaw-operations/SKILL.md @@ -3,7 +3,7 @@ name: netclaw-operations description: "REQUIRED when the user asks about scheduling, reminders, cron jobs, timers, background jobs, diagnostics, troubleshooting, MCP tools, daemon health, identity updates, or Netclaw capabilities and self-maintenance." metadata: author: netclaw - version: "2.8.4" + version: "2.8.5" --- # Netclaw Operations @@ -811,6 +811,12 @@ Exposure diagnostics are fail-closed: `cloudflare-tunnel`) require their local tunnel process by default. `Daemon.SkipTunnelProcessCheck=true` is an explicit opt-in only for sidecar or host-managed tunnel topologies; all other exposure requirements still apply. +- The `netclaw config` Exposure Mode editor preserves dormant reverse-proxy + values in `~/.netclaw/config/editor-state.json` when switching to `local` or a + tunnel mode. Runtime-active `Daemon.Host` and `Daemon.TrustedProxies` are + removed from `netclaw.json` while inactive so local startup validation remains + loopback-only. Treat `editor-state.json` as passive editor state, not daemon + configuration. The `netclaw init` wizard's Network Exposure step offers all five modes — `local`, `reverse-proxy`, `tailscale-serve`, `tailscale-funnel`, @@ -826,7 +832,9 @@ is known. Config files: `~/.netclaw/config/netclaw.json` (daemon-owned base config, including `Daemon.Host`, `Daemon.Port`, `Daemon.ExposureMode`), `~/.netclaw/client/config.json` (local CLI endpoint state), -`~/.netclaw/config/secrets.json` (credentials — never display API keys). +`~/.netclaw/config/secrets.json` (credentials — never display API keys), and +`~/.netclaw/config/editor-state.json` (passive config-editor state for dormant +mode-specific values). ## Feature Kill Switches diff --git a/src/Netclaw.Cli.Tests/Tui/Config/ExposureModeConfigViewModelTests.cs b/src/Netclaw.Cli.Tests/Tui/Config/ExposureModeConfigViewModelTests.cs index c849eb5aa..d911cfaa8 100644 --- a/src/Netclaw.Cli.Tests/Tui/Config/ExposureModeConfigViewModelTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/Config/ExposureModeConfigViewModelTests.cs @@ -90,6 +90,61 @@ public void Saving_reverse_proxy_writes_mode_specific_fields() Assert.Equal(["10.0.0.0/24"], Assert.IsType<object[]>(proxies).Select(static item => item.ToString() ?? string.Empty).ToArray()); } + [Fact] + public void Saving_local_mode_preserves_reverse_proxy_values_for_reactivation() + { + File.WriteAllText(Context.Paths.NetclawConfigPath, + """ + { + "configVersion": 1, + "Daemon": { + "ExposureMode": "reverse-proxy", + "Host": "10.0.0.5", + "Port": 5299, + "DisableSelfUpdate": true, + "TrustedProxies": ["10.0.0.0/24"] + } + } + """); + + using (var vm = new ExposureModeConfigViewModel(Context.Paths)) + { + vm.Step.SelectedMode = ExposureMode.Local; + vm.GoNext(); + } + + var localConfig = ConfigFileHelper.LoadJsonDict(Context.Paths.NetclawConfigPath); + Assert.True(ConfigFileHelper.TryGetPathValue(localConfig, "Daemon.ExposureMode", out var localMode)); + Assert.Equal("local", localMode); + Assert.True(ConfigFileHelper.TryGetPathValue(localConfig, "Daemon.Port", out var port)); + Assert.Equal(5299L, port); + Assert.True(ConfigFileHelper.TryGetPathValue(localConfig, "Daemon.DisableSelfUpdate", out var disableSelfUpdate)); + Assert.Equal(true, disableSelfUpdate); + Assert.False(ConfigFileHelper.TryGetPathValue(localConfig, "Daemon.Host", out _)); + Assert.False(ConfigFileHelper.TryGetPathValue(localConfig, "Daemon.TrustedProxies", out _)); + + using (var vm = new ExposureModeConfigViewModel(Context.Paths)) + { + Assert.Equal(ExposureMode.Local, vm.Step.SelectedMode); + Assert.Equal("10.0.0.5", vm.Step.Host); + Assert.Equal(["10.0.0.0/24"], vm.Step.TrustedProxies); + + vm.Step.SelectedMode = ExposureMode.ReverseProxy; + vm.GoNext(); + vm.GoNext(); + vm.GoNext(); + vm.GoNext(); + } + + var reverseProxyConfig = ConfigFileHelper.LoadJsonDict(Context.Paths.NetclawConfigPath); + Assert.True(ConfigFileHelper.TryGetPathValue(reverseProxyConfig, "Daemon.ExposureMode", out var restoredMode)); + Assert.Equal("reverse-proxy", restoredMode); + Assert.True(ConfigFileHelper.TryGetPathValue(reverseProxyConfig, "Daemon.Host", out var restoredHost)); + Assert.Equal("10.0.0.5", restoredHost); + Assert.True(ConfigFileHelper.TryGetPathValue(reverseProxyConfig, "Daemon.TrustedProxies", out var restoredProxies)); + Assert.Equal(["10.0.0.0/24"], Assert.IsType<object[]>(restoredProxies).Select(static item => item.ToString() ?? string.Empty).ToArray()); + } + [Fact] public void Escape_from_saved_state_returns_to_mode_selection_before_parent_route() { diff --git a/src/Netclaw.Cli.Tests/Tui/Wizard/SectionEditorLeafTests.cs b/src/Netclaw.Cli.Tests/Tui/Wizard/SectionEditorLeafTests.cs index f460809cb..454c6cc20 100644 --- a/src/Netclaw.Cli.Tests/Tui/Wizard/SectionEditorLeafTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/Wizard/SectionEditorLeafTests.cs @@ -121,10 +121,12 @@ public void BuildContribution_ReverseProxy_EmitsExistingDaemonShapeFields() } [Fact] - public void BuildContribution_Local_DropsActiveHostField() + public void BuildContribution_Local_StashesInactiveReverseProxyFields() { using var editor = CreateEditor(); editor.SelectedMode = ExposureMode.Local; + editor.Host = "10.0.0.5"; + editor.TrustedProxies = ["10.0.0.0/24"]; var contribution = editor.BuildContribution(editor); @@ -132,5 +134,13 @@ public void BuildContribution_Local_DropsActiveHostField() a => a.Path == "Daemon.ExposureMode" && Equals(a.Value, "local")); Assert.Contains(contribution.FieldActionsOrEmpty, a => a.Path == "Daemon.Host" && a.Action == SectionFieldActionKind.Delete); + Assert.Contains(contribution.FieldActionsOrEmpty, + a => a.Path == "Daemon.TrustedProxies" && a.Action == SectionFieldActionKind.Delete); + Assert.Contains(contribution.StateActionsOrEmpty, + a => a is { SectionId: WizardStepIds.ExposureMode, Key: "ReverseProxy.Host", Action: SectionEditorStateActionKind.Set } + && Equals(a.Value, "10.0.0.5")); + Assert.Contains(contribution.StateActionsOrEmpty, + a => a is { SectionId: WizardStepIds.ExposureMode, Key: "ReverseProxy.TrustedProxies", Action: SectionEditorStateActionKind.Set } + && Assert.IsType<string[]>(a.Value).SequenceEqual(["10.0.0.0/24"])); } } diff --git a/src/Netclaw.Cli/Tui/Sections/ConfigEditorStateStore.cs b/src/Netclaw.Cli/Tui/Sections/ConfigEditorStateStore.cs new file mode 100644 index 000000000..9853d3b69 --- /dev/null +++ b/src/Netclaw.Cli/Tui/Sections/ConfigEditorStateStore.cs @@ -0,0 +1,96 @@ +// ----------------------------------------------------------------------- +// <copyright file="ConfigEditorStateStore.cs" company="Petabridge, LLC"> +// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com> +// </copyright> +// ----------------------------------------------------------------------- +using System.Text.Json; +using Netclaw.Cli.Config; +using Netclaw.Configuration; + +namespace Netclaw.Cli.Tui.Sections; + +/// <summary> +/// Passive editor-only state for values that must be dormant while inactive. +/// The daemon never reads this file; runtime config stays in <c>netclaw.json</c>. +/// </summary> +internal sealed class ConfigEditorStateStore(NetclawPaths paths) +{ + private const string FileName = "editor-state.json"; + private const string SectionsKey = "Sections"; + + private string StatePath => Path.Combine(paths.ConfigDirectory, FileName); + + internal void Apply(IEnumerable<SectionEditorStateAction> actions) + { + var actionList = actions.ToArray(); + if (actionList.Length == 0) + return; + + var state = LoadState(); + var sections = ConfigFileHelper.GetOrCreateSection(state, SectionsKey); + + foreach (var action in actionList) + { + var section = ConfigFileHelper.GetOrCreateSection(sections, action.SectionId); + switch (action.Action) + { + case SectionEditorStateActionKind.Set: + section[action.Key] = action.Value!; + break; + case SectionEditorStateActionKind.Delete: + section.Remove(action.Key); + break; + } + } + + WriteState(state); + } + + internal bool TryGetValue(string sectionId, string key, out object? value) + { + var state = LoadState(); + value = null; + + if (ConfigFileHelper.GetSectionOrNull(state, SectionsKey) is not { } sections + || ConfigFileHelper.GetSectionOrNull(sections, sectionId) is not { } section + || !section.TryGetValue(key, out var rawValue)) + { + return false; + } + + value = NormalizeValue(rawValue); + return true; + } + + private Dictionary<string, object> LoadState() + { + if (!File.Exists(StatePath)) + return new Dictionary<string, object> { ["configVersion"] = 1 }; + + return ConfigFileHelper.LoadJsonDict(StatePath); + } + + private void WriteState(Dictionary<string, object> state) + { + Directory.CreateDirectory(paths.ConfigDirectory); + ConfigFileHelper.WriteConfigFile(StatePath, state); + } + + private static object? NormalizeValue(object? value) + => value switch + { + JsonElement element when element.ValueKind == JsonValueKind.Array + => JsonSerializer.Deserialize<object[]>(element.GetRawText()), + JsonElement element when element.ValueKind == JsonValueKind.String + => element.GetString(), + JsonElement element when element.ValueKind == JsonValueKind.True + => true, + JsonElement element when element.ValueKind == JsonValueKind.False + => false, + JsonElement element when element.ValueKind == JsonValueKind.Number && element.TryGetInt64(out var longValue) + => longValue, + JsonElement element when element.ValueKind == JsonValueKind.Number + => element.GetDouble(), + _ => value + }; +} diff --git a/src/Netclaw.Cli/Tui/Sections/SectionEditorInfrastructure.cs b/src/Netclaw.Cli/Tui/Sections/SectionEditorInfrastructure.cs index 28b86300c..05db28198 100644 --- a/src/Netclaw.Cli/Tui/Sections/SectionEditorInfrastructure.cs +++ b/src/Netclaw.Cli/Tui/Sections/SectionEditorInfrastructure.cs @@ -39,18 +39,26 @@ public enum SectionStatus /// </summary> public sealed record SectionContribution( IReadOnlyList<SectionFieldAction>? FieldActions = null, - IReadOnlyList<SectionSecretAction>? SecretActions = null) + IReadOnlyList<SectionSecretAction>? SecretActions = null, + IReadOnlyList<SectionEditorStateAction>? StateActions = null) { - public static readonly SectionContribution Empty = new([], []); + public static readonly SectionContribution Empty = new([], [], []); public IReadOnlyList<SectionFieldAction> FieldActionsOrEmpty => FieldActions ?? []; public IReadOnlyList<SectionSecretAction> SecretActionsOrEmpty => SecretActions ?? []; + public IReadOnlyList<SectionEditorStateAction> StateActionsOrEmpty => StateActions ?? []; } public sealed record SectionFieldAction(string Path, SectionFieldActionKind Action, object? Value = null); public sealed record SectionSecretAction(string Path, SectionSecretActionKind Action, object? Value = null); +public sealed record SectionEditorStateAction( + string SectionId, + string Key, + SectionEditorStateActionKind Action, + object? Value = null); + public enum SectionFieldActionKind { Set, @@ -64,6 +72,12 @@ public enum SectionSecretActionKind Delete, } +public enum SectionEditorStateActionKind +{ + Set, + Delete, +} + [AttributeUsage(AttributeTargets.Class, Inherited = false)] public sealed class NoDoctorChecksAttribute(string justification) : Attribute { diff --git a/src/Netclaw.Cli/Tui/Wizard/Steps/ExposureModeStepViewModel.cs b/src/Netclaw.Cli/Tui/Wizard/Steps/ExposureModeStepViewModel.cs index ab914dd23..2763bb978 100644 --- a/src/Netclaw.Cli/Tui/Wizard/Steps/ExposureModeStepViewModel.cs +++ b/src/Netclaw.Cli/Tui/Wizard/Steps/ExposureModeStepViewModel.cs @@ -28,6 +28,9 @@ public sealed class ExposureModeStepViewModel : IWizardStepViewModel, ISectionEd /// <summary>Default bind address suggested in the reverse-proxy config sub-step.</summary> public const string DefaultReverseProxyHost = "0.0.0.0"; + private const string ReverseProxyHostStateKey = "ReverseProxy.Host"; + private const string ReverseProxyTrustedProxiesStateKey = "ReverseProxy.TrustedProxies"; + private static readonly JsonSerializerOptions DevicesJsonOptions = new() { WriteIndented = true, @@ -291,22 +294,42 @@ public SectionContribution BuildContribution(IWizardStepViewModel editor) { new("Daemon.ExposureMode", SectionFieldActionKind.Set, vm.SelectedMode.ToWireValue()) }; + var stateActions = new List<SectionEditorStateAction>(); if (vm.SelectedMode == ExposureMode.ReverseProxy) { - actions.Add(new SectionFieldAction("Daemon.Host", SectionFieldActionKind.Set, - string.IsNullOrWhiteSpace(vm.Host) ? DefaultReverseProxyHost : vm.Host)); + var host = string.IsNullOrWhiteSpace(vm.Host) ? DefaultReverseProxyHost : vm.Host; + var trustedProxies = vm.TrustedProxies.ToArray(); + actions.Add(new SectionFieldAction("Daemon.Host", SectionFieldActionKind.Set, host)); actions.Add(new SectionFieldAction("Daemon.TrustedProxies", SectionFieldActionKind.Set, - vm.TrustedProxies.ToArray())); + trustedProxies)); + + stateActions.Add(CreateStateAction(ReverseProxyHostStateKey, host, host != DefaultReverseProxyHost)); + stateActions.Add(CreateStateAction(ReverseProxyTrustedProxiesStateKey, trustedProxies, + trustedProxies.Length > 0)); } else { - // Host participates in local/tunnel startup validation. Drop any old - // reverse-proxy bind address so non-reverse modes return to loopback defaults. + var trustedProxies = vm.TrustedProxies.ToArray(); + + if (!string.IsNullOrWhiteSpace(vm.Host) + && !DaemonExposureValidator.IsLoopbackHost(vm.Host) + && vm.Host != DefaultReverseProxyHost) + { + stateActions.Add(CreateStateAction(ReverseProxyHostStateKey, vm.Host, keepValue: true)); + } + + stateActions.Add(CreateStateAction(ReverseProxyTrustedProxiesStateKey, trustedProxies, + trustedProxies.Length > 0)); + + // These fields are runtime-active whenever they remain under Daemon. + // Move dormant reverse-proxy values to editor state so local/tunnel + // startup validation ignores them until reverse-proxy mode is active again. actions.Add(new SectionFieldAction("Daemon.Host", SectionFieldActionKind.Delete)); + actions.Add(new SectionFieldAction("Daemon.TrustedProxies", SectionFieldActionKind.Delete)); } - return new SectionContribution(actions); + return new SectionContribution(actions, StateActions: stateActions); } /// <summary> @@ -353,16 +376,52 @@ private void TryPrefillFromExisting(WizardContext context) return; SelectedMode = ReadExistingMode(context); + var editorState = new ConfigEditorStateStore(context.Paths); - if (ConfigFileHelper.TryGetPathValue(context.ExistingConfig, "Daemon.Host", out var hostValue) - && hostValue is string host - && !string.IsNullOrWhiteSpace(host)) + if (SelectedMode == ExposureMode.ReverseProxy + && ConfigFileHelper.TryGetPathValue(context.ExistingConfig, "Daemon.Host", out var hostValue) + && TryReadHost(hostValue, out var activeHost)) + { + Host = activeHost; + } + else if (editorState.TryGetValue(SectionId, ReverseProxyHostStateKey, out var storedHostValue) + && TryReadHost(storedHostValue, out var storedHost)) + { + Host = storedHost; + } + else if (ConfigFileHelper.TryGetPathValue(context.ExistingConfig, "Daemon.Host", out var inactiveHostValue) + && TryReadHost(inactiveHostValue, out var inactiveHost) + && !DaemonExposureValidator.IsLoopbackHost(inactiveHost)) { - Host = host; + Host = inactiveHost; } - if (ConfigFileHelper.TryGetPathValue(context.ExistingConfig, "Daemon.TrustedProxies", out var proxiesValue)) + if (SelectedMode == ExposureMode.ReverseProxy + && ConfigFileHelper.TryGetPathValue(context.ExistingConfig, "Daemon.TrustedProxies", out var proxiesValue)) + { TrustedProxies = ReadTrustedProxies(proxiesValue); + } + else if (editorState.TryGetValue(SectionId, ReverseProxyTrustedProxiesStateKey, out var storedProxiesValue)) + { + TrustedProxies = ReadTrustedProxies(storedProxiesValue); + } + else if (ConfigFileHelper.TryGetPathValue(context.ExistingConfig, "Daemon.TrustedProxies", out var inactiveProxiesValue)) + { + TrustedProxies = ReadTrustedProxies(inactiveProxiesValue); + } + } + + private static SectionEditorStateAction CreateStateAction(string key, object? value, bool keepValue) + => new( + WizardStepIds.ExposureMode, + key, + keepValue ? SectionEditorStateActionKind.Set : SectionEditorStateActionKind.Delete, + value); + + private static bool TryReadHost(object? value, out string host) + { + host = value?.ToString() ?? string.Empty; + return !string.IsNullOrWhiteSpace(host); } private static ExposureMode ReadExistingMode(WizardContext context) diff --git a/src/Netclaw.Cli/Tui/Wizard/WizardConfigBuilder.cs b/src/Netclaw.Cli/Tui/Wizard/WizardConfigBuilder.cs index b4607f58a..08b508208 100644 --- a/src/Netclaw.Cli/Tui/Wizard/WizardConfigBuilder.cs +++ b/src/Netclaw.Cli/Tui/Wizard/WizardConfigBuilder.cs @@ -58,6 +58,7 @@ public void WriteConfigFile() { _paths.EnsureDirectoriesExist(); var config = BuildConfigDictionary(); + ApplyEditorStateContributions(); ConfigFileHelper.WriteConfigFile(_paths.NetclawConfigPath, config); } @@ -433,6 +434,13 @@ private void ApplySectionContributions(Dictionary<string, object> config) foreach (var contribution in _sectionContributions) ApplyContribution(config, contribution); } + + private void ApplyEditorStateContributions() + { + var stateStore = new ConfigEditorStateStore(_paths); + foreach (var contribution in _sectionContributions) + stateStore.Apply(contribution.StateActionsOrEmpty); + } } /// <summary> From 637447690d669ddf5311e6445e0b546fc6551154 Mon Sep 17 00:00:00 2001 From: Aaron Stannard <aaron@petabridge.com> Date: Thu, 28 May 2026 15:09:13 +0000 Subject: [PATCH 16/31] refine(config): centralize editor session merges --- .../Tui/Sections/ConfigEditorSessionTests.cs | 127 ++++++++++++++++++ .../Tui/Wizard/SectionEditorLeafTests.cs | 17 +++ .../Tui/Sections/ConfigEditorSession.cs | 106 +++++++++++++++ .../Sections/SectionEditorInfrastructure.cs | 23 +++- .../Tui/Wizard/Steps/ProviderStepViewModel.cs | 4 +- .../Tui/Wizard/WizardConfigBuilder.cs | 70 ++++------ 6 files changed, 302 insertions(+), 45 deletions(-) create mode 100644 src/Netclaw.Cli.Tests/Tui/Sections/ConfigEditorSessionTests.cs create mode 100644 src/Netclaw.Cli/Tui/Sections/ConfigEditorSession.cs diff --git a/src/Netclaw.Cli.Tests/Tui/Sections/ConfigEditorSessionTests.cs b/src/Netclaw.Cli.Tests/Tui/Sections/ConfigEditorSessionTests.cs new file mode 100644 index 000000000..2bd9e45b7 --- /dev/null +++ b/src/Netclaw.Cli.Tests/Tui/Sections/ConfigEditorSessionTests.cs @@ -0,0 +1,127 @@ +// ----------------------------------------------------------------------- +// <copyright file="ConfigEditorSessionTests.cs" company="Petabridge, LLC"> +// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com> +// </copyright> +// ----------------------------------------------------------------------- +using Netclaw.Cli.Config; +using Netclaw.Cli.Tui.Sections; +using Netclaw.Configuration; +using Netclaw.Tests.Utilities; +using Xunit; + +namespace Netclaw.Cli.Tests.Tui.Sections; + +public sealed class ConfigEditorSessionTests : IDisposable +{ + private readonly DisposableTempDir _dir = new(); + private readonly NetclawPaths _paths; + + public ConfigEditorSessionTests() + { + _paths = new NetclawPaths(_dir.Path); + _paths.EnsureDirectoriesExist(); + } + + public void Dispose() => _dir.Dispose(); + + [Fact] + public void Save_AppliesFieldActionsAndPreservesSiblings() + { + File.WriteAllText(_paths.NetclawConfigPath, + """ + { + "configVersion": 1, + "Daemon": { + "ExposureMode": "reverse-proxy", + "Host": "10.0.0.5", + "Port": 5299 + }, + "Security": { + "DeploymentPosture": "Team" + } + } + """); + + var session = new ConfigEditorSession(_paths); + session.Apply(new SectionContribution( + FieldActions: + [ + new SectionFieldAction("Daemon.ExposureMode", SectionFieldActionKind.Set, "local"), + new SectionFieldAction("Daemon.Host", SectionFieldActionKind.Delete) + ])); + + session.Save(); + + var config = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Daemon.ExposureMode", out var exposureMode)); + Assert.Equal("local", exposureMode); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Daemon.Port", out var port)); + Assert.Equal(5299L, port); + Assert.False(ConfigFileHelper.TryGetPathValue(config, "Daemon.Host", out _)); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Security.DeploymentPosture", out var posture)); + Assert.Equal("Team", posture); + } + + [Fact] + public void Save_AppliesSecretActionsAndPreservesUnrelatedSecrets() + { + File.WriteAllText(_paths.SecretsPath, + """ + { + "configVersion": 1, + "Providers": { + "openai": { + "ApiKey": "stored-provider-key" + } + }, + "Slack": { + "BotToken": "stored-slack-token" + } + } + """); + + var session = new ConfigEditorSession(_paths); + session.Apply(new SectionContribution( + SecretActions: + [ + new SectionSecretAction("Providers.openai.ApiKey", SectionSecretActionKind.Delete), + new SectionSecretAction("Search.BraveApiKey", SectionSecretActionKind.Set, new SensitiveString("new-brave-key")) + ])); + + session.Save(); + + var serializedSecrets = File.ReadAllText(_paths.SecretsPath); + Assert.DoesNotContain("new-brave-key", serializedSecrets, StringComparison.Ordinal); + Assert.DoesNotContain("***REDACTED***", serializedSecrets, StringComparison.Ordinal); + + var secrets = ConfigFileHelper.LoadJsonDict(_paths.SecretsPath); + Assert.False(ConfigFileHelper.TryGetPathValue(secrets, "Providers.openai.ApiKey", out _)); + Assert.True(ConfigFileHelper.TryGetPathValue(secrets, "Search.BraveApiKey", out var braveKey)); + Assert.Equal("new-brave-key", ConfigFileHelper.DecryptIfEncrypted(_paths, braveKey?.ToString())); + Assert.True(ConfigFileHelper.TryGetPathValue(secrets, "Slack.BotToken", out var slackToken)); + Assert.Equal("stored-slack-token", ConfigFileHelper.DecryptIfEncrypted(_paths, slackToken?.ToString())); + } + + [Fact] + public void Apply_StoresAndDeletesPassiveEditorState() + { + var session = new ConfigEditorSession(_paths); + session.Apply(new SectionContribution( + StateActions: + [ + new SectionEditorStateAction("exposure", "ReverseProxy.Host", SectionEditorStateActionKind.Set, "10.0.0.5") + ])); + + var state = new ConfigEditorStateStore(_paths); + Assert.True(state.TryGetValue("exposure", "ReverseProxy.Host", out var storedHost)); + Assert.Equal("10.0.0.5", storedHost); + + session.Apply(new SectionContribution( + StateActions: + [ + new SectionEditorStateAction("exposure", "ReverseProxy.Host", SectionEditorStateActionKind.Delete) + ])); + + Assert.False(state.TryGetValue("exposure", "ReverseProxy.Host", out _)); + } +} diff --git a/src/Netclaw.Cli.Tests/Tui/Wizard/SectionEditorLeafTests.cs b/src/Netclaw.Cli.Tests/Tui/Wizard/SectionEditorLeafTests.cs index 454c6cc20..9a0e8eb00 100644 --- a/src/Netclaw.Cli.Tests/Tui/Wizard/SectionEditorLeafTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/Wizard/SectionEditorLeafTests.cs @@ -14,6 +14,23 @@ namespace Netclaw.Cli.Tests.Tui.Wizard; public sealed class ProviderSectionEditorTests : SectionEditorTestBase<ProviderStepViewModel> { + [Fact] + public void BuildContribution_EnteredCredential_EmitsSensitiveSecretLeaf() + { + using var editor = CreateEditor(); + editor.SelectedProviderType = "openai"; + editor.SelectedModelId = "gpt-4.1"; + editor.ApiKeyInput = "sk-test"; + + var contribution = editor.BuildContribution(editor); + var action = Assert.Single(contribution.SecretActionsOrEmpty); + + Assert.Equal("Providers.openai.ApiKey", action.Path); + Assert.Equal(SectionSecretActionKind.Set, action.Action); + Assert.NotNull(action.Value); + Assert.Equal("sk-test", action.Value.Value); + } + [Fact] public void BuildContribution_BlankCredential_PreservesExistingSecret() { diff --git a/src/Netclaw.Cli/Tui/Sections/ConfigEditorSession.cs b/src/Netclaw.Cli/Tui/Sections/ConfigEditorSession.cs new file mode 100644 index 000000000..11cc0a333 --- /dev/null +++ b/src/Netclaw.Cli/Tui/Sections/ConfigEditorSession.cs @@ -0,0 +1,106 @@ +// ----------------------------------------------------------------------- +// <copyright file="ConfigEditorSession.cs" company="Petabridge, LLC"> +// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com> +// </copyright> +// ----------------------------------------------------------------------- +using Netclaw.Cli.Config; +using Netclaw.Cli.Json; +using Netclaw.Configuration; + +namespace Netclaw.Cli.Tui.Sections; + +/// <summary> +/// Shared merge pipeline for config leaf editors. It applies explicit editor +/// contributions to runtime config, secrets, and passive editor state. +/// </summary> +internal sealed class ConfigEditorSession +{ + private readonly NetclawPaths _paths; + private readonly ConfigEditorStateStore _stateStore; + private readonly bool _secretsFileExists; + private bool _secretsChanged; + + public ConfigEditorSession(NetclawPaths paths) + { + _paths = paths; + _stateStore = new ConfigEditorStateStore(paths); + _secretsFileExists = File.Exists(paths.SecretsPath); + Config = ConfigFileHelper.LoadJsonDict(paths.NetclawConfigPath); + Secrets = ConfigFileHelper.LoadJsonDict(paths.SecretsPath); + } + + internal Dictionary<string, object> Config { get; } + + internal Dictionary<string, object> Secrets { get; } + + public void Apply(SectionContribution contribution) + { + ApplyFieldActions(Config, contribution); + _secretsChanged |= ApplySecretActions(Secrets, contribution); + _stateStore.Apply(contribution.StateActionsOrEmpty); + } + + public void Save() + { + _paths.EnsureDirectoriesExist(); + Config["configVersion"] = EmbeddedSchemaLoader.CurrentSchemaVersion; + ConfigFileHelper.WriteConfigFile(_paths.NetclawConfigPath, Config); + + if (_secretsChanged && (_secretsFileExists || HasUserSecretData(Secrets))) + ConfigFileHelper.WriteSecretsFile(_paths, Secrets); + } + + internal static bool ApplyFieldActions(Dictionary<string, object> config, SectionContribution contribution) + { + var changed = false; + foreach (var action in contribution.FieldActionsOrEmpty) + { + switch (action.Action) + { + case SectionFieldActionKind.Set: + ConfigFileHelper.SetPathValue(config, action.Path, action.Value); + changed = true; + break; + case SectionFieldActionKind.Delete: + changed |= ConfigFileHelper.RemovePath(config, action.Path); + break; + } + } + + return changed; + } + + internal static bool ApplySecretActions(Dictionary<string, object> secrets, SectionContribution contribution) + { + var changed = false; + foreach (var action in contribution.SecretActionsOrEmpty) + { + switch (action.Action) + { + case SectionSecretActionKind.Preserve: + break; + case SectionSecretActionKind.Set: + ConfigFileHelper.SetPathValue(secrets, action.Path, action.Value); + changed = true; + break; + case SectionSecretActionKind.Delete: + changed |= ConfigFileHelper.RemovePath(secrets, action.Path); + break; + } + } + + return changed; + } + + internal static void ApplyEditorStateActions( + NetclawPaths paths, + IEnumerable<SectionContribution> contributions) + { + var stateStore = new ConfigEditorStateStore(paths); + foreach (var contribution in contributions) + stateStore.Apply(contribution.StateActionsOrEmpty); + } + + private static bool HasUserSecretData(Dictionary<string, object> secrets) + => secrets.Keys.Any(static key => !string.Equals(key, "configVersion", StringComparison.Ordinal)); +} diff --git a/src/Netclaw.Cli/Tui/Sections/SectionEditorInfrastructure.cs b/src/Netclaw.Cli/Tui/Sections/SectionEditorInfrastructure.cs index 05db28198..ce88947c9 100644 --- a/src/Netclaw.Cli/Tui/Sections/SectionEditorInfrastructure.cs +++ b/src/Netclaw.Cli/Tui/Sections/SectionEditorInfrastructure.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection; using Netclaw.Cli.Config; using Netclaw.Cli.Tui.Wizard; +using Netclaw.Configuration; namespace Netclaw.Cli.Tui.Sections; @@ -51,7 +52,27 @@ public sealed record SectionContribution( public sealed record SectionFieldAction(string Path, SectionFieldActionKind Action, object? Value = null); -public sealed record SectionSecretAction(string Path, SectionSecretActionKind Action, object? Value = null); +public sealed record SectionSecretAction +{ + public SectionSecretAction(string path, SectionSecretActionKind action, SensitiveString? value = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + + if (action == SectionSecretActionKind.Set && value is null) + throw new ArgumentNullException(nameof(value), "Secret set actions require a SensitiveString value."); + + if (action != SectionSecretActionKind.Set && value is not null) + throw new ArgumentException("Only secret set actions may carry a value.", nameof(value)); + + Path = path; + Action = action; + Value = value; + } + + public string Path { get; } + public SectionSecretActionKind Action { get; } + public SensitiveString? Value { get; } +} public sealed record SectionEditorStateAction( string SectionId, diff --git a/src/Netclaw.Cli/Tui/Wizard/Steps/ProviderStepViewModel.cs b/src/Netclaw.Cli/Tui/Wizard/Steps/ProviderStepViewModel.cs index cb21997e9..3130ac013 100644 --- a/src/Netclaw.Cli/Tui/Wizard/Steps/ProviderStepViewModel.cs +++ b/src/Netclaw.Cli/Tui/Wizard/Steps/ProviderStepViewModel.cs @@ -397,8 +397,8 @@ public SectionContribution BuildContribution(IWizardStepViewModel editor) var secretActions = new List<SectionSecretAction>(); if (!string.IsNullOrWhiteSpace(vm.ApiKeyInput)) { - secretActions.Add(new SectionSecretAction(secretPath, SectionSecretActionKind.Set, - new Dictionary<string, object> { ["ApiKey"] = vm.ApiKeyInput })); + secretActions.Add(new SectionSecretAction($"{secretPath}.ApiKey", SectionSecretActionKind.Set, + new SensitiveString(vm.ApiKeyInput))); } else if (vm.HasStoredCredential) { diff --git a/src/Netclaw.Cli/Tui/Wizard/WizardConfigBuilder.cs b/src/Netclaw.Cli/Tui/Wizard/WizardConfigBuilder.cs index 08b508208..03e5dc5a2 100644 --- a/src/Netclaw.Cli/Tui/Wizard/WizardConfigBuilder.cs +++ b/src/Netclaw.Cli/Tui/Wizard/WizardConfigBuilder.cs @@ -6,12 +6,10 @@ using System.Text.Json; using System.Text.Json.Nodes; using Netclaw.Cli.Config; -using Netclaw.Cli.Json; using Netclaw.Cli.Mcp; using Netclaw.Cli.Secrets; using Netclaw.Cli.Tui.Sections; using Netclaw.Configuration; -using Netclaw.Configuration.Secrets; namespace Netclaw.Cli.Tui.Wizard; @@ -414,20 +412,7 @@ internal void ApplyContribution(SectionContribution contribution) } private static void ApplyContribution(Dictionary<string, object> config, SectionContribution contribution) - { - foreach (var action in contribution.FieldActionsOrEmpty) - { - switch (action.Action) - { - case SectionFieldActionKind.Set: - ConfigFileHelper.SetPathValue(config, action.Path, action.Value); - break; - case SectionFieldActionKind.Delete: - ConfigFileHelper.RemovePath(config, action.Path); - break; - } - } - } + => ConfigEditorSession.ApplyFieldActions(config, contribution); private void ApplySectionContributions(Dictionary<string, object> config) { @@ -436,11 +421,7 @@ private void ApplySectionContributions(Dictionary<string, object> config) } private void ApplyEditorStateContributions() - { - var stateStore = new ConfigEditorStateStore(_paths); - foreach (var contribution in _sectionContributions) - stateStore.Apply(contribution.StateActionsOrEmpty); - } + => ConfigEditorSession.ApplyEditorStateActions(_paths, _sectionContributions); } /// <summary> @@ -451,10 +432,13 @@ public sealed class WizardSecretsBuilder private readonly NetclawPaths _paths; private readonly Dictionary<string, object> _secrets = []; private readonly Dictionary<string, object> _existingSecrets; + private readonly List<SectionContribution> _sectionContributions = []; + private readonly bool _secretsFileExists; public WizardSecretsBuilder(NetclawPaths paths) { _paths = paths; + _secretsFileExists = File.Exists(paths.SecretsPath); _existingSecrets = ConfigFileHelper.LoadJsonDict(paths.SecretsPath); } @@ -472,10 +456,22 @@ public void AddSection(string key, Dictionary<string, object> section) /// <summary>Write secrets.json if any secrets were contributed.</summary> public void WriteSecretsFile() { - if (_secrets.Count == 0) + var hasDirectSecrets = _secrets.Count > 0; + if (!hasDirectSecrets && _sectionContributions.Count == 0) return; - var existingNode = JsonSerializer.SerializeToNode(_existingSecrets, JsonDefaults.ConfigFile)?.AsObject() + var merged = _existingSecrets.Count == 0 + ? new Dictionary<string, object>() + : new Dictionary<string, object>(_existingSecrets, StringComparer.Ordinal); + + var contributionChanged = false; + foreach (var contribution in _sectionContributions) + contributionChanged |= ConfigEditorSession.ApplySecretActions(merged, contribution); + + if (!hasDirectSecrets && !contributionChanged) + return; + + var existingNode = JsonSerializer.SerializeToNode(merged, JsonDefaults.ConfigFile)?.AsObject() ?? []; foreach (var (key, value) in _secrets) @@ -488,28 +484,18 @@ public void WriteSecretsFile() SecretsJsonUpdater.UpsertNode(existingNode, segments, node); } - SecretsFileWriter.Write(_paths.SecretsPath, existingNode.ToJsonString(JsonDefaults.ConfigFile), - protector: SensitiveStringTypeConverter.Protector); - } - - internal void ApplyContribution(SectionContribution contribution) - { - foreach (var action in contribution.SecretActionsOrEmpty) + if (hasDirectSecrets || contributionChanged && (_secretsFileExists || HasUserSecretData(merged))) { - switch (action.Action) - { - case SectionSecretActionKind.Preserve: - break; - case SectionSecretActionKind.Set: - ConfigFileHelper.SetPathValue(_secrets, action.Path, action.Value); - break; - case SectionSecretActionKind.Delete: - ConfigFileHelper.RemovePath(_secrets, action.Path); - ConfigFileHelper.RemovePath(_existingSecrets, action.Path); - break; - } + SecretsFileWriter.Write(_paths.SecretsPath, existingNode.ToJsonString(JsonDefaults.ConfigFile), + protector: SensitiveStringTypeConverter.Protector); } } + + internal void ApplyContribution(SectionContribution contribution) + => _sectionContributions.Add(contribution); + + private static bool HasUserSecretData(Dictionary<string, object> secrets) + => secrets.Keys.Any(static key => !string.Equals(key, "configVersion", StringComparison.Ordinal)); } // ── Typed config section records ── From fbcfb2969a46264c73b3bcd785a6c3b9ef61ccc0 Mon Sep 17 00:00:00 2001 From: Aaron Stannard <aaron@petabridge.com> Date: Thu, 28 May 2026 15:20:03 +0000 Subject: [PATCH 17/31] fix(config): reset exposure editor on reopen --- src/Netclaw.Cli/Program.cs | 2 +- tests/smoke/tapes/config-exposure.tape | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/Netclaw.Cli/Program.cs b/src/Netclaw.Cli/Program.cs index 6b1493ef1..ad970d424 100644 --- a/src/Netclaw.Cli/Program.cs +++ b/src/Netclaw.Cli/Program.cs @@ -900,7 +900,7 @@ static async Task RunAsync(string[] args) t.RegisterRoute<ModelManagerPage, ModelManagerViewModel>("/model"); t.RegisterRoute<SearchConfigEditorPage, SearchConfigEditorViewModel>("/search", Termina.Pages.NavigationBehavior.PreserveState); t.RegisterRoute<SecurityAccessPage, SecurityAccessViewModel>("/security"); - t.RegisterRoute<ExposureModeConfigPage, ExposureModeConfigViewModel>("/exposure-mode", Termina.Pages.NavigationBehavior.PreserveState); + t.RegisterRoute<ExposureModeConfigPage, ExposureModeConfigViewModel>("/exposure-mode"); }); using var host = builder.Build(); diff --git a/tests/smoke/tapes/config-exposure.tape b/tests/smoke/tapes/config-exposure.tape index 181bff309..1b60e71aa 100644 --- a/tests/smoke/tapes/config-exposure.tape +++ b/tests/smoke/tapes/config-exposure.tape @@ -49,8 +49,12 @@ Wait+Screen@5s /http:\/\/0\.0\.0\.0:[0-9]+/ Enter Wait+Screen@10s /Reverse Proxy exposure mode saved/ -# Saved-state back behavior: Esc returns to the mode list before parent page. -Escape +# Returning to Security & Access and reopening Exposure Mode must not preserve +# the one-shot saved screen. +Enter +Wait+Screen@10s /Security & Access/ +Down 3 +Enter Wait+Screen@10s /How will this Netclaw daemon be accessed/ Escape Wait+Screen@10s /Security & Access/ From a41e353f85a77f192df0ca45840773e5e3b5faa5 Mon Sep 17 00:00:00 2001 From: Aaron Stannard <aaron@petabridge.com> Date: Thu, 28 May 2026 18:16:19 +0000 Subject: [PATCH 18/31] fix(config): return from search saved screen --- .../Config/SearchConfigEditorViewModelTests.cs | 15 +++++++++++++++ .../Tui/Config/SearchConfigEditorPage.cs | 6 ++++-- .../Tui/Config/SearchConfigEditorViewModel.cs | 2 ++ src/Netclaw.Cli/Tui/Config/SearchSectionSpec.cs | 2 +- tests/smoke/tapes/config-search.tape | 6 ++---- 5 files changed, 24 insertions(+), 7 deletions(-) diff --git a/src/Netclaw.Cli.Tests/Tui/Config/SearchConfigEditorViewModelTests.cs b/src/Netclaw.Cli.Tests/Tui/Config/SearchConfigEditorViewModelTests.cs index 2bb549e05..b63cc4d91 100644 --- a/src/Netclaw.Cli.Tests/Tui/Config/SearchConfigEditorViewModelTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/Config/SearchConfigEditorViewModelTests.cs @@ -106,6 +106,21 @@ public void Selecting_zero_config_provider_keeps_workflow_clean_when_effective_v Assert.Equal("duckduckgo", vm.CurrentBackendValue); } + [Fact] + public void Navigate_back_resets_preserved_editor_to_provider_selection() + { + using var vm = new SearchConfigEditorViewModel(_paths); + string? route = null; + vm.RouteRequested = value => route = value; + + vm.SaveWithoutProbeOverride(); + vm.NavigateBack(); + + Assert.Equal("/config", route); + Assert.Equal(SearchConfigEditorScreen.ProviderSelection, vm.CurrentScreen.Value); + Assert.Equal(SearchConfigEditorDialog.None, vm.ActiveDialog.Value); + } + [Fact] public async Task Brave_probe_failure_opens_override_dialog_before_save() { diff --git a/src/Netclaw.Cli/Tui/Config/SearchConfigEditorPage.cs b/src/Netclaw.Cli/Tui/Config/SearchConfigEditorPage.cs index 016f3eae3..f31e13438 100644 --- a/src/Netclaw.Cli/Tui/Config/SearchConfigEditorPage.cs +++ b/src/Netclaw.Cli/Tui/Config/SearchConfigEditorPage.cs @@ -213,7 +213,7 @@ private LayoutNode BuildKeyBindings() SearchConfigEditorScreen.ProviderSelection => " [↑/↓] Navigate [Enter] Continue [Esc] Back [Ctrl+Q] Quit", SearchConfigEditorScreen.Entry => " [Enter] Continue [Esc] Back [Ctrl+Q] Quit", SearchConfigEditorScreen.Validating => " [Ctrl+Q] Quit", - SearchConfigEditorScreen.Saved => " [↑/↓] Navigate [Enter] Continue [Esc] Back [Ctrl+Q] Quit", + SearchConfigEditorScreen.Saved => " [Enter] Settings Areas [Esc] Review backends [Ctrl+Q] Quit", _ => " [Ctrl+Q] Quit", }; @@ -270,7 +270,9 @@ public override bool HandlePageInput(ConsoleKeyInfo keyInfo) if (ViewModel.CurrentScreen.Value == SearchConfigEditorScreen.Saved) { - EnsureProviderList().HandleInput(keyInfo); + if (keyInfo.Key == ConsoleKey.Enter) + ViewModel.NavigateBack(); + return true; } diff --git a/src/Netclaw.Cli/Tui/Config/SearchConfigEditorViewModel.cs b/src/Netclaw.Cli/Tui/Config/SearchConfigEditorViewModel.cs index 9d73e7792..3ce92509e 100644 --- a/src/Netclaw.Cli/Tui/Config/SearchConfigEditorViewModel.cs +++ b/src/Netclaw.Cli/Tui/Config/SearchConfigEditorViewModel.cs @@ -329,6 +329,8 @@ public void NavigateBack() { CancelValidationSpinner(); ReloadPersistedDraft(); + ActiveDialog.Value = SearchConfigEditorDialog.None; + CurrentScreen.Value = SearchConfigEditorScreen.ProviderSelection; Status.Value = new ConfigStatusMessage(string.Empty, ConfigStatusTone.Neutral); RouteRequested?.Invoke("/config"); Navigate?.Invoke("/config"); diff --git a/src/Netclaw.Cli/Tui/Config/SearchSectionSpec.cs b/src/Netclaw.Cli/Tui/Config/SearchSectionSpec.cs index 8acc4cd06..3c8f34564 100644 --- a/src/Netclaw.Cli/Tui/Config/SearchSectionSpec.cs +++ b/src/Netclaw.Cli/Tui/Config/SearchSectionSpec.cs @@ -128,7 +128,7 @@ internal string GetSavedMessage(SearchEditorModel model) => $"✔ {GetBackendLabel(model.Backend)} validated and saved."; internal string GetSavedNextStepText() - => "Press Esc to return to Search backends or Up/Down to review providers."; + => "Press Enter to return to Settings Areas or Esc to review Search backends."; internal string GetBackendLabel(SearchBackend backend) => backend switch diff --git a/tests/smoke/tapes/config-search.tape b/tests/smoke/tapes/config-search.tape index 7136bb69c..768988c00 100644 --- a/tests/smoke/tapes/config-search.tape +++ b/tests/smoke/tapes/config-search.tape @@ -51,12 +51,10 @@ Wait+Screen@10s /Search Validation Warning/ Down 2 Enter Wait+Screen@10s /validated and saved/ -Escape -Wait+Screen@10s /Choose the backend Netclaw uses for web search/ +Enter +Wait+Screen@10s /Settings Areas/ # ─── Back out to shell ──────────────────────────────────────────────────── -Escape -Wait+Screen@10s /Settings Areas/ Ctrl+Q Wait+Screen@10s /TAPE\$/ From 65da27c32f08d48ea6d3a29d35a89835efc201c8 Mon Sep 17 00:00:00 2001 From: Aaron Stannard <aaron@petabridge.com> Date: Thu, 28 May 2026 19:04:06 +0000 Subject: [PATCH 19/31] refine(config): route search saves through editor session --- .../SearchConfigEditorViewModelTests.cs | 37 +++++++++++++++++++ .../Tui/Config/SearchEditorModel.cs | 37 ++++++++++++++----- 2 files changed, 64 insertions(+), 10 deletions(-) diff --git a/src/Netclaw.Cli.Tests/Tui/Config/SearchConfigEditorViewModelTests.cs b/src/Netclaw.Cli.Tests/Tui/Config/SearchConfigEditorViewModelTests.cs index b63cc4d91..bfabf81a3 100644 --- a/src/Netclaw.Cli.Tests/Tui/Config/SearchConfigEditorViewModelTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/Config/SearchConfigEditorViewModelTests.cs @@ -7,6 +7,7 @@ using System.Net.Http; using System.Text; using Netclaw.Cli.Tui.Config; +using Netclaw.Cli.Tui.Sections; using Netclaw.Configuration; using Netclaw.Configuration.Secrets; using Netclaw.Tests.Utilities; @@ -152,6 +153,42 @@ public void Save_anyway_persists_config_and_secret_semantically() Assert.Contains("\"Backend\": \"brave\"", config, StringComparison.Ordinal); Assert.DoesNotContain("BraveApiKey", config, StringComparison.Ordinal); Assert.Contains("BraveApiKey", secrets, StringComparison.Ordinal); + Assert.Contains("ENC:", secrets, StringComparison.Ordinal); + Assert.DoesNotContain("BSA-live-key", secrets, StringComparison.Ordinal); + Assert.DoesNotContain("***REDACTED***", secrets, StringComparison.Ordinal); + } + + [Fact] + public void Search_contribution_wraps_brave_api_key_as_sensitive_secret() + { + var mapper = new SearchEditorPersistenceMapper(); + var model = new SearchEditorModel { Backend = SearchBackend.Brave }; + model.Brave.ApiKeyDraft = "BSA-live-key"; + + var contribution = mapper.BuildContribution(model); + var secretAction = Assert.Single(contribution.SecretActionsOrEmpty); + + Assert.Equal("Search.BraveApiKey", secretAction.Path); + Assert.Equal(SectionSecretActionKind.Set, secretAction.Action); + Assert.NotNull(secretAction.Value); + Assert.Equal("BSA-live-key", secretAction.Value.Value); + Assert.Contains(contribution.FieldActionsOrEmpty, + action => action.Path == "Search.Backend" && Equals(action.Value, "brave")); + } + + [Fact] + public void Search_contribution_preserves_blank_existing_brave_secret() + { + var mapper = new SearchEditorPersistenceMapper(); + var model = new SearchEditorModel { Backend = SearchBackend.Brave }; + model.Brave.HasPersistedApiKey = true; + + var contribution = mapper.BuildContribution(model); + var secretAction = Assert.Single(contribution.SecretActionsOrEmpty); + + Assert.Equal("Search.BraveApiKey", secretAction.Path); + Assert.Equal(SectionSecretActionKind.Preserve, secretAction.Action); + Assert.Null(secretAction.Value); } [Fact] diff --git a/src/Netclaw.Cli/Tui/Config/SearchEditorModel.cs b/src/Netclaw.Cli/Tui/Config/SearchEditorModel.cs index 0e06ab8a1..1352c0daa 100644 --- a/src/Netclaw.Cli/Tui/Config/SearchEditorModel.cs +++ b/src/Netclaw.Cli/Tui/Config/SearchEditorModel.cs @@ -5,6 +5,7 @@ // ----------------------------------------------------------------------- using Microsoft.Extensions.Options; using Netclaw.Cli.Config; +using Netclaw.Cli.Tui.Sections; using Netclaw.Configuration; namespace Netclaw.Cli.Tui.Config; @@ -96,20 +97,36 @@ internal SearchEditorModel Load(NetclawPaths paths) internal void Save(NetclawPaths paths, SearchEditorModel model) { - var (config, secrets) = ConfigFileHelper.LoadConfigFiles(paths); - config["configVersion"] = EmbeddedSchemaLoader.CurrentSchemaVersion; + var session = new ConfigEditorSession(paths); + session.Apply(BuildContribution(model)); + session.Save(); + } - ConfigFileHelper.SetPathValue(config, "Search.Backend", model.Backend.ToWireValue()); + internal SectionContribution BuildContribution(SearchEditorModel model) + { + var fieldActions = new List<SectionFieldAction> + { + new("Search.Backend", SectionFieldActionKind.Set, model.Backend.ToWireValue()) + }; - if (!string.IsNullOrWhiteSpace(model.SearXng.Endpoint)) - ConfigFileHelper.SetPathValue(config, "Search.SearXngEndpoint", model.SearXng.Endpoint); + var endpoint = Normalize(model.SearXng.Endpoint); + if (!string.IsNullOrWhiteSpace(endpoint)) + fieldActions.Add(new SectionFieldAction("Search.SearXngEndpoint", SectionFieldActionKind.Set, endpoint)); - if (model.Backend == SearchBackend.Brave && !string.IsNullOrWhiteSpace(model.Brave.ApiKeyDraft)) - ConfigFileHelper.SetPathValue(secrets, "Search.BraveApiKey", model.Brave.ApiKeyDraft); + var secretActions = new List<SectionSecretAction>(); + if (model.Backend == SearchBackend.Brave) + { + var apiKey = Normalize(model.Brave.ApiKeyDraft); + if (!string.IsNullOrWhiteSpace(apiKey)) + secretActions.Add(new SectionSecretAction( + "Search.BraveApiKey", + SectionSecretActionKind.Set, + new SensitiveString(apiKey))); + else if (model.Brave.HasPersistedApiKey) + secretActions.Add(new SectionSecretAction("Search.BraveApiKey", SectionSecretActionKind.Preserve)); + } - ConfigFileHelper.WriteConfigFile(paths.NetclawConfigPath, config); - if (File.Exists(paths.SecretsPath) || ConfigFileHelper.PathPresent(secrets, "Search.BraveApiKey")) - ConfigFileHelper.WriteSecretsFile(paths, secrets); + return new SectionContribution(fieldActions, secretActions); } private static SearchBackend ParseBackend(string? value) From 47fb381d5e74a49c4971f77b738e84961fa760e7 Mon Sep 17 00:00:00 2001 From: Aaron Stannard <aaron@petabridge.com> Date: Fri, 29 May 2026 02:27:22 +0000 Subject: [PATCH 20/31] feat(config): inline enabled feature toggles --- docs/ui/TUI-002-netclaw-config-wireframes.md | 35 +++-- scripts/smoke/run-smoke.sh | 2 +- .../Config/SecurityAccessViewModelTests.cs | 66 ++++++++ .../Tui/Config/SecurityAccessPage.cs | 147 ++++++++++++++---- .../Tui/Config/SecurityAccessViewModel.cs | 109 +++++++++++-- .../Wizard/Steps/FeatureSelectionStepView.cs | 76 +++++---- .../Tui/Workflow/ActiveSelectionList.cs | 9 +- tests/smoke/assertions/config-features.sh | 31 ++++ tests/smoke/tapes/config-features.tape | 49 ++++++ 9 files changed, 421 insertions(+), 103 deletions(-) create mode 100755 tests/smoke/assertions/config-features.sh create mode 100644 tests/smoke/tapes/config-features.tape diff --git a/docs/ui/TUI-002-netclaw-config-wireframes.md b/docs/ui/TUI-002-netclaw-config-wireframes.md index 3fca877fa..eb7527817 100644 --- a/docs/ui/TUI-002-netclaw-config-wireframes.md +++ b/docs/ui/TUI-002-netclaw-config-wireframes.md @@ -708,7 +708,7 @@ Same shape as Tailscale Serve, but with stronger public-exposure warning copy. ## Config.9 — Security & Access -### 9.1 Security & Access sub-page +### 9.1 Security & Access page ``` ╭─ Security & Access ─────────────────────────────────────────╮ @@ -718,9 +718,9 @@ Same shape as Tailscale Serve, but with stronger public-exposure warning copy. │ Audience Profiles Team customized │ │ Exposure Mode Cloudflare Tunnel │ │ │ -│ [ Open ] [ Back ] │ +│ [ Open / Edit inline ] [ Back ] │ │ │ -│ ↑/↓ navigate · Enter open · Esc back │ +│ ↑/↓ navigate · Enter open/edit · Esc back │ ╰─────────────────────────────────────────────────────────────╯ ``` @@ -772,24 +772,27 @@ customized away from the prior posture's defaults. --- -## Config.9.3 — Enabled Features +## Config.9.3 — Enabled Features inline editor + +Enabled Features is edited inline within Security & Access rather than as a +separate route. It remains deployment-wide runtime enablement; audience +exposure is configured in Audience Profiles and MCP permissions. ``` -╭─ Enabled Features ──────────────────────────────────────────╮ -│ │ -│ Toggle deployment-wide runtime features. Audience │ -│ exposure is configured separately in Audience Profiles. │ +╭─ Security & Access ─────────────────────────────────────────╮ │ │ -│ [ X ] memory │ -│ [ X ] search │ -│ [ X ] skills │ -│ [ X ] scheduling │ -│ [ X ] sub-agents │ -│ [ X ] webhooks │ +│ Enabled Features │ +│ Toggle global runtime features. Audience exposure is │ +│ configured separately. │ │ │ -│ [ Save ] [ Cancel ] │ +│ ▶ [✓] memory │ +│ [✓] search │ +│ [✓] skills │ +│ [✓] scheduling │ +│ [✓] sub-agents │ +│ [✓] webhooks │ │ │ -│ ↑/↓ navigate · Space toggle · Tab to buttons │ +│ ↑/↓ navigate · Space/Enter toggle + save · Esc back │ ╰─────────────────────────────────────────────────────────────╯ ``` diff --git a/scripts/smoke/run-smoke.sh b/scripts/smoke/run-smoke.sh index 2fc7bca43..bc45bd42c 100755 --- a/scripts/smoke/run-smoke.sh +++ b/scripts/smoke/run-smoke.sh @@ -54,7 +54,7 @@ SMOKE_LOG_DIR="${SMOKE_LOG_DIR:-${ROOT_DIR}/smoke-logs}" # Cheapest harness checks first so a harness-level break fails fast # before paying for the wizard + probe tapes. -LIGHT_TAPES=(help init-wizard config-exposure provider-add provider-rename config-search tui-cleanup) +LIGHT_TAPES=(help init-wizard config-exposure config-features provider-add provider-rename config-search tui-cleanup) FULL_TAPES=("${LIGHT_TAPES[@]}") LIGHT_SCENARIOS=( diff --git a/src/Netclaw.Cli.Tests/Tui/Config/SecurityAccessViewModelTests.cs b/src/Netclaw.Cli.Tests/Tui/Config/SecurityAccessViewModelTests.cs index bbcd1128d..9c129b19c 100644 --- a/src/Netclaw.Cli.Tests/Tui/Config/SecurityAccessViewModelTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/Config/SecurityAccessViewModelTests.cs @@ -3,6 +3,7 @@ // Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com> // </copyright> // ----------------------------------------------------------------------- +using Netclaw.Cli.Config; using Netclaw.Cli.Tui.Config; using Netclaw.Cli.Tests.Tui.Wizard; using Xunit; @@ -39,6 +40,71 @@ public void Exposure_mode_routes_to_exposure_editor() Assert.Equal("/exposure-mode", route); } + [Fact] + public void Enabled_features_opens_inline_global_toggle_editor() + { + using var vm = new SecurityAccessViewModel(Context.Paths); + string? route = null; + vm.RouteRequested = value => route = value; + + vm.Activate(vm.Items.Single(static item => item.Label == "Enabled Features")); + + Assert.True(vm.EditingEnabledFeatures.Value); + Assert.Null(route); + } + + [Fact] + public void Enabled_features_summary_treats_missing_flags_as_enabled() + { + File.WriteAllText(Context.Paths.NetclawConfigPath, + """ + { + "configVersion": 1, + "Security": { "DeploymentPosture": "Team" } + } + """); + + using var vm = new SecurityAccessViewModel(Context.Paths); + + var features = vm.Items.Single(static item => item.Label == "Enabled Features"); + Assert.Equal("6/6 enabled", features.Summary); + } + + [Fact] + public void Toggle_selected_feature_persists_global_flag_and_preserves_siblings() + { + File.WriteAllText(Context.Paths.NetclawConfigPath, + """ + { + "configVersion": 1, + "Security": { "DeploymentPosture": "Team" }, + "Memory": { "Enabled": true }, + "Search": { + "Enabled": false, + "Backend": "searxng", + "SearXngEndpoint": "https://search.example.com" + }, + "SkillSync": { "Enabled": true }, + "Scheduling": { "Enabled": false }, + "SubAgents": { "Enabled": true }, + "Webhooks": { "Enabled": false } + } + """); + + using var vm = new SecurityAccessViewModel(Context.Paths); + vm.SelectedFeatureIndex.Value = 1; + + vm.ToggleSelectedFeature(); + + var config = ConfigFileHelper.LoadJsonDict(Context.Paths.NetclawConfigPath); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Search.Enabled", out var searchEnabled)); + Assert.Equal(true, searchEnabled); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Search.Backend", out var backend)); + Assert.Equal("searxng", backend); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Search.SearXngEndpoint", out var endpoint)); + Assert.Equal("https://search.example.com", endpoint); + } + [Fact] public void Exposure_summary_reads_existing_daemon_mode() { diff --git a/src/Netclaw.Cli/Tui/Config/SecurityAccessPage.cs b/src/Netclaw.Cli/Tui/Config/SecurityAccessPage.cs index e93b74f7a..b497d4bd8 100644 --- a/src/Netclaw.Cli/Tui/Config/SecurityAccessPage.cs +++ b/src/Netclaw.Cli/Tui/Config/SecurityAccessPage.cs @@ -15,7 +15,8 @@ namespace Netclaw.Cli.Tui.Config; public sealed class SecurityAccessPage : ReactivePage<SecurityAccessViewModel> { - private SelectionListNode<string>? _entryList; + private DynamicLayoutNode? _contentNode; + private DynamicLayoutNode? _keyBindingsNode; protected override void OnBound() { @@ -23,6 +24,20 @@ protected override void OnBound() ViewModel.Input.OfType<IInputEvent, KeyPressed>() .Subscribe(HandleKeyPress) .DisposeWith(Subscriptions); + + ViewModel.SelectedIndex + .Subscribe(_ => _contentNode?.Invalidate()) + .DisposeWith(Subscriptions); + ViewModel.SelectedFeatureIndex + .Subscribe(_ => _contentNode?.Invalidate()) + .DisposeWith(Subscriptions); + ViewModel.EditingEnabledFeatures + .Subscribe(_ => + { + _contentNode?.Invalidate(); + _keyBindingsNode?.Invalidate(); + }) + .DisposeWith(Subscriptions); } public override ILayoutNode BuildLayout() @@ -31,40 +46,72 @@ public override ILayoutNode BuildLayout() private ILayoutNode BuildInnerLayout() => Layouts.Vertical() .WithSpacing(1) - .WithChild(BuildList()) + .WithChild(BuildContent()) .WithChild(Layouts.Empty().Fill()) .WithChild(BuildStatusBar()) .WithChild(BuildKeyBindings()); - private ILayoutNode BuildList() + private ILayoutNode BuildContent() { - var rows = ViewModel.Items - .Select(static item => $"{item.Label,-20} {item.Summary,-20} {item.Description}") - .ToList(); + _contentNode = new DynamicLayoutNode(() => ViewModel.EditingEnabledFeatures.Value + ? BuildFeatureToggles() + : BuildSecurityMenu()); - _entryList = Layouts.SelectionList(rows) - .WithMode(SelectionMode.Single) - .WithHighlightColors(Color.Black, Color.Cyan); + return _contentNode; + } - _entryList.OnFocused(); - _entryList.SelectionConfirmed - .Subscribe(selected => - { - if (selected.Count == 0) - return; - - var index = rows.IndexOf(selected[0]); - if (index >= 0) - { - ViewModel.SelectedIndex.Value = index; - ViewModel.ActivateSelected(); - } - }) - .DisposeWith(Subscriptions); + private ILayoutNode BuildSecurityMenu() + { + var layout = Layouts.Vertical() + .WithChild(new TextNode(" Security & Access").WithForeground(Color.White).Bold()); + + var items = ViewModel.Items; + for (var i = 0; i < items.Count; i++) + { + var item = items[i]; + var selected = i == ViewModel.SelectedIndex.Value; + var prefix = selected ? " ▶ " : " "; + var line = $"{prefix}{item.Label,-20} {item.Summary,-20} {item.Description}"; + var node = new TextNode(line); + node = selected + ? node.WithForeground(Color.Cyan).Bold() + : node.WithForeground(Color.White); + layout = layout.WithChild(node); + } - return Layouts.Vertical() - .WithChild(new TextNode(" Security & Access").WithForeground(Color.White).Bold()) - .WithChild(_entryList); + return layout; + } + + private ILayoutNode BuildFeatureToggles() + { + var layout = Layouts.Vertical() + .WithChild(new TextNode(" Enabled Features").WithForeground(Color.White).Bold()) + .WithChild(new TextNode(" Toggle global runtime features. Audience exposure is configured separately.") + .WithForeground(Color.BrightBlack)) + .WithChild(Layouts.Empty().Height(1)); + + var names = ViewModel.FeatureNames; + var descriptions = ViewModel.FeatureDescriptions; + for (var i = 0; i < names.Count; i++) + { + var selected = i == ViewModel.SelectedFeatureIndex.Value; + var enabled = ViewModel.IsFeatureEnabled(i); + var prefix = selected ? " ▶ " : " "; + var marker = enabled ? "✓" : " "; + var line = $"{prefix}[{marker}] {names[i],-12} {descriptions[i]}"; + var node = new TextNode(line); + + if (selected) + node = node.WithForeground(Color.Cyan).Bold(); + else if (enabled) + node = node.WithForeground(Color.White); + else + node = node.WithForeground(Color.BrightBlack); + + layout = layout.WithChild(node); + } + + return layout; } private LayoutNode BuildStatusBar() @@ -73,8 +120,15 @@ private LayoutNode BuildStatusBar() .AsLayout() .Height(1); - private static LayoutNode BuildKeyBindings() - => NetclawTuiChrome.BuildKeyHintLine(" [↑/↓] Navigate [Enter] Open [Esc] Back [Ctrl+Q] Quit"); + private LayoutNode BuildKeyBindings() + { + _keyBindingsNode = new DynamicLayoutNode(() => NetclawTuiChrome.BuildKeyHintLine( + ViewModel.EditingEnabledFeatures.Value + ? " [↑/↓] Navigate [Space/Enter] Toggle + Save [Esc] Security & Access [Ctrl+Q] Quit" + : " [↑/↓] Navigate [Enter] Open [Esc] Back [Ctrl+Q] Quit")); + + return _keyBindingsNode.Height(1); + } private void HandleKeyPress(KeyPressed key) { @@ -91,7 +145,40 @@ private void HandleKeyPress(KeyPressed key) return; } - _entryList?.HandleInput(keyInfo); + if (ViewModel.EditingEnabledFeatures.Value) + { + switch (keyInfo.Key) + { + case ConsoleKey.UpArrow: + ViewModel.MoveFeatureSelection(-1); + break; + case ConsoleKey.DownArrow: + ViewModel.MoveFeatureSelection(1); + break; + case ConsoleKey.Spacebar: + case ConsoleKey.Enter: + ViewModel.ToggleSelectedFeature(); + _contentNode?.Invalidate(); + break; + } + + ViewModel.RequestRedraw(); + return; + } + + switch (keyInfo.Key) + { + case ConsoleKey.UpArrow: + ViewModel.MoveSelection(-1); + break; + case ConsoleKey.DownArrow: + ViewModel.MoveSelection(1); + break; + case ConsoleKey.Enter: + ViewModel.ActivateSelected(); + break; + } + ViewModel.RequestRedraw(); } } diff --git a/src/Netclaw.Cli/Tui/Config/SecurityAccessViewModel.cs b/src/Netclaw.Cli/Tui/Config/SecurityAccessViewModel.cs index c8b92b407..5abce036a 100644 --- a/src/Netclaw.Cli/Tui/Config/SecurityAccessViewModel.cs +++ b/src/Netclaw.Cli/Tui/Config/SecurityAccessViewModel.cs @@ -4,6 +4,8 @@ // </copyright> // ----------------------------------------------------------------------- using Netclaw.Cli.Config; +using Netclaw.Cli.Tui.Sections; +using Netclaw.Cli.Tui.Wizard.Steps; using Netclaw.Configuration; using R3; using Termina.Reactive; @@ -14,11 +16,24 @@ public sealed record SecurityAccessItem(string Label, string Summary, string Des public sealed class SecurityAccessViewModel : ReactiveViewModel { + private const int FeatureCount = 6; + private static readonly string[] FeatureConfigPaths = + [ + "Memory.Enabled", + "Search.Enabled", + "SkillSync.Enabled", + "Scheduling.Enabled", + "SubAgents.Enabled", + "Webhooks.Enabled" + ]; + private readonly NetclawPaths _paths; + private readonly bool[] _enabledFeatures = new bool[FeatureCount]; public SecurityAccessViewModel(NetclawPaths paths) { _paths = paths; + LoadEnabledFeatures(); } internal Action<string>? RouteRequested { get; set; } @@ -26,8 +41,12 @@ public SecurityAccessViewModel(NetclawPaths paths) public ReactiveProperty<string> StatusMessage { get; } = new(""); public ReactiveProperty<int> SelectedIndex { get; } = new(0); + public ReactiveProperty<bool> EditingEnabledFeatures { get; } = new(false); + public ReactiveProperty<int> SelectedFeatureIndex { get; } = new(0); public IReadOnlyList<SecurityAccessItem> Items => BuildItems(); + public IReadOnlyList<string> FeatureNames => FeatureSelectionStepViewModel.FeatureNames; + public IReadOnlyList<string> FeatureDescriptions => FeatureSelectionStepViewModel.FeatureDescriptions; public void MoveSelection(int delta) { @@ -42,6 +61,12 @@ public void MoveSelection(int delta) public void ActivateSelected() { + if (EditingEnabledFeatures.Value) + { + ToggleSelectedFeature(); + return; + } + var items = Items; if (items.Count == 0) return; @@ -51,6 +76,14 @@ public void ActivateSelected() internal void Activate(SecurityAccessItem item) { + if (item.Label == "Enabled Features") + { + EditingEnabledFeatures.Value = true; + StatusMessage.Value = ""; + RequestRedraw(); + return; + } + if (item.Route is not null) { RouteRequested?.Invoke(item.Route); @@ -64,10 +97,41 @@ internal void Activate(SecurityAccessItem item) public void BackToConfig() { + if (EditingEnabledFeatures.Value) + { + EditingEnabledFeatures.Value = false; + StatusMessage.Value = ""; + RequestRedraw(); + return; + } + RouteRequested?.Invoke("/config"); Navigate?.Invoke("/config"); } + public void MoveFeatureSelection(int delta) + { + var next = Math.Clamp(SelectedFeatureIndex.Value + delta, 0, FeatureCount - 1); + if (next != SelectedFeatureIndex.Value) + SelectedFeatureIndex.Value = next; + } + + public bool IsFeatureEnabled(int index) => _enabledFeatures[index]; + + public void ToggleSelectedFeature() + { + var index = SelectedFeatureIndex.Value; + _enabledFeatures[index] = !_enabledFeatures[index]; + + var session = new ConfigEditorSession(_paths); + session.Apply(BuildFeatureContribution()); + session.Save(); + + var state = _enabledFeatures[index] ? "enabled" : "disabled"; + StatusMessage.Value = $"{FeatureNames[index]} {state}. Saved."; + RequestRedraw(); + } + public void RequestQuit() { ShutdownRequestedForTest = true; @@ -78,9 +142,33 @@ public override void Dispose() { StatusMessage.Dispose(); SelectedIndex.Dispose(); + EditingEnabledFeatures.Dispose(); + SelectedFeatureIndex.Dispose(); base.Dispose(); } + private void LoadEnabledFeatures() + { + Array.Fill(_enabledFeatures, true); + var config = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath); + for (var i = 0; i < FeatureConfigPaths.Length; i++) + { + if (ConfigFileHelper.TryGetPathValue(config, FeatureConfigPaths[i], out var value) && value is bool enabled) + _enabledFeatures[i] = enabled; + } + } + + private SectionContribution BuildFeatureContribution() + => new( + [ + new SectionFieldAction(FeatureConfigPaths[0], SectionFieldActionKind.Set, _enabledFeatures[0]), + new SectionFieldAction(FeatureConfigPaths[1], SectionFieldActionKind.Set, _enabledFeatures[1]), + new SectionFieldAction(FeatureConfigPaths[2], SectionFieldActionKind.Set, _enabledFeatures[2]), + new SectionFieldAction(FeatureConfigPaths[3], SectionFieldActionKind.Set, _enabledFeatures[3]), + new SectionFieldAction(FeatureConfigPaths[4], SectionFieldActionKind.Set, _enabledFeatures[4]), + new SectionFieldAction(FeatureConfigPaths[5], SectionFieldActionKind.Set, _enabledFeatures[5]) + ]); + private IReadOnlyList<SecurityAccessItem> BuildItems() { var config = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath); @@ -107,29 +195,18 @@ private static string ReadPostureSummary(Dictionary<string, object> config) private static string ReadEnabledFeaturesSummary(Dictionary<string, object> config) { - var paths = new[] - { - "Memory.Enabled", - "Search.Enabled", - "SkillSync.Enabled", - "Scheduling.Enabled", - "SubAgents.Enabled", - "Webhooks.Enabled" - }; - - var configured = 0; var enabled = 0; - foreach (var path in paths) + foreach (var path in FeatureConfigPaths) { - if (!ConfigFileHelper.TryGetPathValue(config, path, out var value) || value is not bool flag) - continue; + var flag = true; + if (ConfigFileHelper.TryGetPathValue(config, path, out var value) && value is bool configuredFlag) + flag = configuredFlag; - configured++; if (flag) enabled++; } - return configured == 0 ? "Defaults" : $"{enabled}/{paths.Length} enabled"; + return $"{enabled}/{FeatureConfigPaths.Length} enabled"; } private static string ReadExposureModeSummary(Dictionary<string, object> config) diff --git a/src/Netclaw.Cli/Tui/Wizard/Steps/FeatureSelectionStepView.cs b/src/Netclaw.Cli/Tui/Wizard/Steps/FeatureSelectionStepView.cs index 425f79bdb..9b33ff84a 100644 --- a/src/Netclaw.Cli/Tui/Wizard/Steps/FeatureSelectionStepView.cs +++ b/src/Netclaw.Cli/Tui/Wizard/Steps/FeatureSelectionStepView.cs @@ -4,6 +4,7 @@ // </copyright> // ----------------------------------------------------------------------- using Netclaw.Configuration; +using Netclaw.Cli.Tui.Workflow; using Termina.Extensions; using Termina.Input; using Termina.Layout; @@ -19,38 +20,40 @@ namespace Netclaw.Cli.Tui.Wizard.Steps; public sealed class FeatureSelectionStepView : IWizardStepView { private int _cursorIndex; + private ActiveSelectionList<FeatureToggleOption>? _featureList; private StepViewCallbacks? _callbacks; private FeatureSelectionStepViewModel? _vm; public string StepId => WizardStepIds.FeatureSelection; + public bool ManagesOwnFocusState => true; + public ILayoutNode BuildContent(IWizardStepViewModel stepVm, StepViewCallbacks callbacks) { _callbacks = callbacks; _vm = (FeatureSelectionStepViewModel)stepVm; - var featureCount = FeatureSelectionStepViewModel.FeatureNames.Length; - if (_cursorIndex >= featureCount) _cursorIndex = featureCount - 1; + var options = FeatureToggleOption.All; + if (_cursorIndex >= options.Count) _cursorIndex = options.Count - 1; if (_cursorIndex < 0) _cursorIndex = 0; + _featureList = new ActiveSelectionList<FeatureToggleOption>( + options, + static option => option.Name.PadRight(12), + option => _vm.IsFeatureEnabled(option.Index), + static option => option.Description, + focusedIndex: _cursorIndex, + toggled: option => _vm.ToggleFeature(option.Index), + changed: () => + { + _cursorIndex = _featureList?.FocusedIndex ?? _cursorIndex; + callbacks.RequestRedraw(); + }); + var layout = Layouts.Vertical() .WithChild(new TextNode(" Select which features to enable for this deployment:").WithForeground(Color.White)) - .WithSpacing(1); - - for (var i = 0; i < featureCount; i++) - { - var isFocused = i == _cursorIndex; - var isEnabled = _vm.IsFeatureEnabled(i); - var prefix = isFocused ? " ▶ " : " "; - var checkbox = isEnabled ? "[x]" : "[ ]"; - var line = $"{prefix}{checkbox} {FeatureSelectionStepViewModel.FeatureNames[i]} — {FeatureSelectionStepViewModel.FeatureDescriptions[i]}"; - - var node = new TextNode(line); - node = isFocused - ? node.WithForeground(Color.Cyan).Bold() - : node.WithForeground(Color.White); - layout = layout.WithChild(node); - } + .WithSpacing(1) + .WithChild(_featureList.AsLayout()); layout = layout.WithSpacing(1) .WithChild(new TextNode(" Space to toggle, Enter to continue.") @@ -72,33 +75,14 @@ public bool HandleKeyPress(KeyPressed key) if (_vm is null) return false; - var keyInfo = key.KeyInfo; - var featureCount = FeatureSelectionStepViewModel.FeatureNames.Length; - - switch (keyInfo.Key) + switch (key.KeyInfo.Key) { - case ConsoleKey.UpArrow: - if (_cursorIndex > 0) _cursorIndex--; - break; - - case ConsoleKey.DownArrow: - if (_cursorIndex < featureCount - 1) _cursorIndex++; - break; - - case ConsoleKey.Spacebar: - _vm.ToggleFeature(_cursorIndex); - break; - case ConsoleKey.Enter: _callbacks?.AdvanceStep(); return true; - - default: - return false; } - _callbacks?.InvalidateAndRedraw(); - return true; + return _featureList?.HandleInput(key.KeyInfo) ?? false; } public void HandlePaste(PasteEvent paste) @@ -109,5 +93,19 @@ public void HandlePaste(PasteEvent paste) public void ClearFocusState() { _cursorIndex = 0; + _featureList = null; + } + + private sealed record FeatureToggleOption(int Index, string Name, string Description) + { + public static readonly IReadOnlyList<FeatureToggleOption> All = + [ + new(0, FeatureSelectionStepViewModel.FeatureNames[0], FeatureSelectionStepViewModel.FeatureDescriptions[0]), + new(1, FeatureSelectionStepViewModel.FeatureNames[1], FeatureSelectionStepViewModel.FeatureDescriptions[1]), + new(2, FeatureSelectionStepViewModel.FeatureNames[2], FeatureSelectionStepViewModel.FeatureDescriptions[2]), + new(3, FeatureSelectionStepViewModel.FeatureNames[3], FeatureSelectionStepViewModel.FeatureDescriptions[3]), + new(4, FeatureSelectionStepViewModel.FeatureNames[4], FeatureSelectionStepViewModel.FeatureDescriptions[4]), + new(5, FeatureSelectionStepViewModel.FeatureNames[5], FeatureSelectionStepViewModel.FeatureDescriptions[5]) + ]; } } diff --git a/src/Netclaw.Cli/Tui/Workflow/ActiveSelectionList.cs b/src/Netclaw.Cli/Tui/Workflow/ActiveSelectionList.cs index 118c8c86b..91f741278 100644 --- a/src/Netclaw.Cli/Tui/Workflow/ActiveSelectionList.cs +++ b/src/Netclaw.Cli/Tui/Workflow/ActiveSelectionList.cs @@ -18,6 +18,7 @@ internal sealed class ActiveSelectionList<T> private readonly Func<T, string?>? _statusSelector; private readonly Action<T>? _confirmed; private readonly Action? _changed; + private readonly Action<T>? _toggled; private readonly int _labelPadWidth; private readonly DynamicLayoutNode _layout; @@ -29,7 +30,8 @@ public ActiveSelectionList( Action<T>? confirmed = null, Action? changed = null, int focusedIndex = 0, - int labelPadWidth = 0) + int labelPadWidth = 0, + Action<T>? toggled = null) { _options = options; _labelSelector = labelSelector; @@ -37,6 +39,7 @@ public ActiveSelectionList( _statusSelector = statusSelector; _confirmed = confirmed; _changed = changed; + _toggled = toggled; _labelPadWidth = labelPadWidth; FocusedIndex = ClampIndex(focusedIndex); _layout = new DynamicLayoutNode(BuildRows); @@ -87,6 +90,10 @@ public bool HandleInput(ConsoleKeyInfo keyInfo) case ConsoleKey.Enter: _confirmed?.Invoke(FocusedOption); return true; + case ConsoleKey.Spacebar when _toggled is not null: + _toggled(FocusedOption); + Invalidate(); + return true; default: return false; } diff --git a/tests/smoke/assertions/config-features.sh b/tests/smoke/assertions/config-features.sh new file mode 100755 index 000000000..c344da9ef --- /dev/null +++ b/tests/smoke/assertions/config-features.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# config-features.tape post-tape assertion. + +set -euo pipefail + +. "$(dirname "$0")/_lib.sh" + +assert_fail=0 + +echo "config-features: reading produced config..." +if [[ ! -f "$CONFIG_PATH" ]]; then + echo "FAIL: ${CONFIG_PATH} does not exist." >&2 + exit 1 +fi + +config_json="$(read_config_json)" + +assert_field '.Memory.Enabled' 'true' "$config_json" || : +assert_field '.Search.Enabled' 'true' "$config_json" || : +assert_field '.Search.Backend' 'duckduckgo' "$config_json" || : +assert_field '.SkillSync.Enabled' 'true' "$config_json" || : +assert_field '.Scheduling.Enabled' 'false' "$config_json" || : +assert_field '.SubAgents.Enabled' 'true' "$config_json" || : +assert_field '.Webhooks.Enabled' 'false' "$config_json" || : + +if (( assert_fail )); then + printf -- '--- netclaw.json contents ---\n%s\n' "$config_json" >&2 + exit 1 +fi + +echo "config-features: assertions passed." diff --git a/tests/smoke/tapes/config-features.tape b/tests/smoke/tapes/config-features.tape new file mode 100644 index 000000000..45f2d3beb --- /dev/null +++ b/tests/smoke/tapes/config-features.tape @@ -0,0 +1,49 @@ +# config-features.tape — edit Enabled Features from netclaw config. +# +# Exercises: +# netclaw config -> Security & Access -> Enabled Features +# and verifies feature toggle persistence through the shared config editor save path. + +Output "/tmp/tape-config-features.gif" + +# ─── Seed minimal installed config ─────────────────────────────────── +Type "mkdir -p $NETCLAW_HOME/config" +Enter +Type "posture=Team; backend=duckduckgo; jq -n --arg posture $posture --arg backend $backend '{configVersion:1,Security:{DeploymentPosture:$posture},Memory:{Enabled:true},Search:{Enabled:false,Backend:$backend},SkillSync:{Enabled:true},Scheduling:{Enabled:false},SubAgents:{Enabled:true},Webhooks:{Enabled:false}}' > $NETCLAW_HOME/config/netclaw.json" +Enter + +# ─── Launch config dashboard ───────────────────────────────────────── +Type "netclaw config" +Enter +Wait+Screen@10s /Settings Areas/ + +# Root dashboard order: Inference Providers, Models, Channels, Inbound Webhooks, +# Skill Sources, Search, Browser Automation, Telemetry & Alerting, Security & Access. +Down 8 +Enter +Wait+Screen@10s /Security & Access/ + +# Security & Access order: Security Posture, Enabled Features, Audience Profiles, +# Exposure Mode. +Down +Enter +Wait+Screen@10s /Toggle global runtime features/ +Wait+Screen@5s /\[✓\] Memory/ +Wait+Screen@5s /\[ \] Search/ + +# Enable Search; inline toggles save immediately. +Down +Space +Wait+Screen@5s /\[✓\] Search/ +Escape +Wait+Screen@10s /Security & Access/ +Ctrl+Q + +Wait+Screen@10s /TAPE\$/ + +Type "echo CONFIG_FEATURES_EXIT=$?" +Enter +Wait+Screen@5s /CONFIG_FEATURES_EXIT=0/ + +Type "exit" +Enter From 1b6601c94f2b75b302bee1152d798dce5a744a82 Mon Sep 17 00:00:00 2001 From: Aaron Stannard <aaron@petabridge.com> Date: Fri, 29 May 2026 16:06:00 +0000 Subject: [PATCH 21/31] feat(config): add inline security editors --- docs/ui/TUI-002-netclaw-config-wireframes.md | 65 +- scripts/smoke/run-smoke.sh | 2 +- .../Config/SecurityAccessViewModelTests.cs | 62 +- .../Tui/Config/SecurityAccessPage.cs | 327 +++++++-- .../Tui/Config/SecurityAccessViewModel.cs | 666 +++++++++++++++++- tests/smoke/assertions/config-audience.sh | 29 + tests/smoke/assertions/config-posture.sh | 29 + tests/smoke/tapes/config-audience.tape | 45 ++ tests/smoke/tapes/config-posture.tape | 39 + 9 files changed, 1123 insertions(+), 141 deletions(-) create mode 100755 tests/smoke/assertions/config-audience.sh create mode 100755 tests/smoke/assertions/config-posture.sh create mode 100644 tests/smoke/tapes/config-audience.tape create mode 100644 tests/smoke/tapes/config-posture.tape diff --git a/docs/ui/TUI-002-netclaw-config-wireframes.md b/docs/ui/TUI-002-netclaw-config-wireframes.md index eb7527817..54faf3489 100644 --- a/docs/ui/TUI-002-netclaw-config-wireframes.md +++ b/docs/ui/TUI-002-netclaw-config-wireframes.md @@ -726,26 +726,26 @@ Same shape as Tailscale Serve, but with stronger public-exposure warning copy. ## Config.9.1 — Security Posture -### 9.1.1 Posture selection (T1-shaped) +### 9.1.1 Posture selection (inline T1-shaped) + +Security Posture is edited inline within Security & Access. Saving `Team` or +`Public` immediately continues into the inline Enabled Features editor so the +operator can review deployment-wide runtime gates. ``` -╭─ Security Posture ──────────────────────────────────────────╮ +╭─ Security & Access ─────────────────────────────────────────╮ │ │ +│ Security Posture │ │ Current posture: Personal │ │ │ -│ ▸ Personal │ -│ Just me. Local-only by default. Tools have wide access. │ -│ │ -│ Team │ -│ Small team via Slack/Discord. Audience-restricted tools. │ +│ ▶ [✓] Personal Just me. Local-only by default. Tools │ +│ have wide access. │ +│ [ ] Team Small team via Slack/Discord. Audience- │ +│ restricted tools. │ +│ [ ] Public Open to untrusted users. Strict defaults │ +│ and access controls. │ │ │ -│ Public │ -│ Open to untrusted users. Strict defaults and access │ -│ controls. │ -│ │ -│ [ Save ] [ Cancel ] │ -│ │ -│ ↑/↓ navigate · Tab to buttons · Enter activate │ +│ ↑/↓ navigate · Enter save · Esc back │ ╰─────────────────────────────────────────────────────────────╯ ``` @@ -760,9 +760,9 @@ customized away from the prior posture's defaults. │ You have customized Audience Profiles. Changing posture │ │ will overwrite them with the new posture's defaults. │ │ │ -│ ▸ [ Cancel — keep current posture ] │ -│ [ Apply new posture, overwrite profiles ] │ -│ [ Apply new posture, keep custom profiles ] │ +│ ▶ Cancel - keep current posture │ +│ Apply new posture, overwrite profiles │ +│ Apply new posture, keep custom profiles │ │ │ │ Default: Cancel (Esc or Enter) │ ╰─────────────────────────────────────────────────────────────╯ @@ -807,11 +807,9 @@ exposure is configured in Audience Profiles and MCP permissions. │ │ │ Configure high-level access per audience tier. │ │ │ -│ ▸ Personal ✓ Default for posture: Personal │ -│ Team ✓ Default for posture: Personal │ -│ Public ✓ Default for posture: Personal │ -│ │ -│ [ Cancel ] │ +│ ▶ Personal Default for posture: Personal │ +│ Team Default for posture: Personal │ +│ Public Default for posture: Personal │ │ │ │ ↑/↓ navigate · Enter edit audience · Esc cancel │ ╰─────────────────────────────────────────────────────────────╯ @@ -824,21 +822,20 @@ exposure is configured in Audience Profiles and MCP permissions. │ │ │ Tool access for the Team audience: │ │ │ -│ [ X ] Read files │ -│ [ X ] Edit files │ -│ [ X ] Web access │ -│ [ X ] Skills │ -│ [ X ] Scheduling │ -│ [ X ] Change working directory │ +│ ▶ [✓] Read files │ +│ [✓] Edit files │ +│ [✓] Web access │ +│ [✓] Skills │ +│ [✓] Scheduling │ +│ [✓] Change working directory │ │ │ │ File access: Session only → │ │ Incoming attachments: Common work files │ │ MCP permissions: Manage in `netclaw mcp │ │ permissions` → │ +│ [Reset] Reset to posture default │ │ │ -│ [ Save ] [ Cancel ] [ Reset to posture default ] │ -│ │ -│ ↑/↓ navigate · Space toggle · Tab to buttons · Esc cancel │ +│ ↑/↓ navigate · Space/Enter toggle/cycle · Esc back │ ╰─────────────────────────────────────────────────────────────╯ ``` @@ -847,13 +844,13 @@ exposure is configured in Audience Profiles and MCP permissions. - `↑` / `↓` MUST move focus between toggle rows. - `Space` MUST toggle the focused checkbox. - `Enter` on a checkbox row also toggles (alternative to Space). -- `Tab` moves to the action row. +- `Enter` on a cycle row advances to the next curated value. - `Reset to posture default` replaces the full underlying audience profile, including hidden MCP and approval settings, with the posture-default mapping. The `config-audience.tape` smoke tape explicitly exercises `↓`, `Space`, -`↑`, `Space` to lock in the keystroke contract. Regression in arrow -nav OR toggle is caught. +and `Esc` to lock in the keystroke contract. Regression in arrow nav, +toggle, or return behavior is caught. **Doctor checks:** `ConfigSchemaDoctorCheck`, `ToolAudienceProfilesDoctorCheck`. diff --git a/scripts/smoke/run-smoke.sh b/scripts/smoke/run-smoke.sh index bc45bd42c..f56c0643c 100755 --- a/scripts/smoke/run-smoke.sh +++ b/scripts/smoke/run-smoke.sh @@ -54,7 +54,7 @@ SMOKE_LOG_DIR="${SMOKE_LOG_DIR:-${ROOT_DIR}/smoke-logs}" # Cheapest harness checks first so a harness-level break fails fast # before paying for the wizard + probe tapes. -LIGHT_TAPES=(help init-wizard config-exposure config-features provider-add provider-rename config-search tui-cleanup) +LIGHT_TAPES=(help init-wizard config-exposure config-posture config-features config-audience provider-add provider-rename config-search tui-cleanup) FULL_TAPES=("${LIGHT_TAPES[@]}") LIGHT_SCENARIOS=( diff --git a/src/Netclaw.Cli.Tests/Tui/Config/SecurityAccessViewModelTests.cs b/src/Netclaw.Cli.Tests/Tui/Config/SecurityAccessViewModelTests.cs index 9c129b19c..c3984ea92 100644 --- a/src/Netclaw.Cli.Tests/Tui/Config/SecurityAccessViewModelTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/Config/SecurityAccessViewModelTests.cs @@ -6,6 +6,7 @@ using Netclaw.Cli.Config; using Netclaw.Cli.Tui.Config; using Netclaw.Cli.Tests.Tui.Wizard; +using Netclaw.Configuration; using Xunit; namespace Netclaw.Cli.Tests.Tui.Config; @@ -49,10 +50,69 @@ public void Enabled_features_opens_inline_global_toggle_editor() vm.Activate(vm.Items.Single(static item => item.Label == "Enabled Features")); - Assert.True(vm.EditingEnabledFeatures.Value); + Assert.Equal(SecurityAccessEditorMode.Features, vm.Mode.Value); Assert.Null(route); } + [Fact] + public void Security_posture_saves_posture_and_shell_defaults() + { + using var vm = new SecurityAccessViewModel(Context.Paths); + vm.OpenPostureEditor(); + vm.SelectedPostureIndex.Value = 1; + + vm.ApplySelectedPosture(); + + var config = ConfigFileHelper.LoadJsonDict(Context.Paths.NetclawConfigPath); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Security.DeploymentPosture", out var posture)); + Assert.Equal("Team", posture); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Security.ShellExecutionMode", out var securityShellMode)); + Assert.Equal("Off", securityShellMode); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Tools.ShellMode", out var toolsShellMode)); + Assert.Equal("Off", toolsShellMode); + Assert.Equal(SecurityAccessEditorMode.Features, vm.Mode.Value); + } + + [Fact] + public void Audience_profiles_opens_inline_audience_list() + { + using var vm = new SecurityAccessViewModel(Context.Paths); + string? route = null; + vm.RouteRequested = value => route = value; + + vm.Activate(vm.Items.Single(static item => item.Label == "Audience Profiles")); + + Assert.Equal(SecurityAccessEditorMode.AudienceList, vm.Mode.Value); + Assert.Null(route); + } + + [Fact] + public void Audience_profile_toggle_updates_selected_profile_only() + { + File.WriteAllText(Context.Paths.NetclawConfigPath, + """ + { + "configVersion": 1, + "Security": { "DeploymentPosture": "Team" } + } + """); + + using var vm = new SecurityAccessViewModel(Context.Paths); + vm.SelectedAudienceIndex.Value = 1; + vm.OpenSelectedAudienceProfile(); + vm.SelectedAudienceRowIndex.Value = (int)AudienceProfileRowKind.WebAccess; + + vm.ActivateSelectedAudienceProfileRow(); + + var config = ConfigFileHelper.LoadJsonDict(Context.Paths.NetclawConfigPath); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Tools.AudienceProfiles.Team.AllowedTools", out var teamTools)); + var teamAllowedTools = Assert.IsAssignableFrom<object[]>(teamTools); + Assert.DoesNotContain(teamAllowedTools, static tool => tool?.ToString() == "web_search"); + Assert.DoesNotContain(teamAllowedTools, static tool => tool?.ToString() == "web_fetch"); + + Assert.False(ConfigFileHelper.TryGetPathValue(config, "Tools.AudienceProfiles.Public.AllowedTools", out _)); + } + [Fact] public void Enabled_features_summary_treats_missing_flags_as_enabled() { diff --git a/src/Netclaw.Cli/Tui/Config/SecurityAccessPage.cs b/src/Netclaw.Cli/Tui/Config/SecurityAccessPage.cs index b497d4bd8..3cca2cfa1 100644 --- a/src/Netclaw.Cli/Tui/Config/SecurityAccessPage.cs +++ b/src/Netclaw.Cli/Tui/Config/SecurityAccessPage.cs @@ -25,19 +25,13 @@ protected override void OnBound() .Subscribe(HandleKeyPress) .DisposeWith(Subscriptions); - ViewModel.SelectedIndex - .Subscribe(_ => _contentNode?.Invalidate()) - .DisposeWith(Subscriptions); - ViewModel.SelectedFeatureIndex - .Subscribe(_ => _contentNode?.Invalidate()) - .DisposeWith(Subscriptions); - ViewModel.EditingEnabledFeatures - .Subscribe(_ => - { - _contentNode?.Invalidate(); - _keyBindingsNode?.Invalidate(); - }) - .DisposeWith(Subscriptions); + ViewModel.Mode.Subscribe(_ => InvalidateAll()).DisposeWith(Subscriptions); + ViewModel.SelectedIndex.Subscribe(_ => _contentNode?.Invalidate()).DisposeWith(Subscriptions); + ViewModel.SelectedPostureIndex.Subscribe(_ => _contentNode?.Invalidate()).DisposeWith(Subscriptions); + ViewModel.SelectedCascadeIndex.Subscribe(_ => _contentNode?.Invalidate()).DisposeWith(Subscriptions); + ViewModel.SelectedFeatureIndex.Subscribe(_ => _contentNode?.Invalidate()).DisposeWith(Subscriptions); + ViewModel.SelectedAudienceIndex.Subscribe(_ => _contentNode?.Invalidate()).DisposeWith(Subscriptions); + ViewModel.SelectedAudienceRowIndex.Subscribe(_ => _contentNode?.Invalidate()).DisposeWith(Subscriptions); } public override ILayoutNode BuildLayout() @@ -53,9 +47,15 @@ private ILayoutNode BuildInnerLayout() private ILayoutNode BuildContent() { - _contentNode = new DynamicLayoutNode(() => ViewModel.EditingEnabledFeatures.Value - ? BuildFeatureToggles() - : BuildSecurityMenu()); + _contentNode = new DynamicLayoutNode(() => ViewModel.Mode.Value switch + { + SecurityAccessEditorMode.Posture => BuildPostureEditor(), + SecurityAccessEditorMode.PostureCascade => BuildPostureCascade(), + SecurityAccessEditorMode.Features => BuildFeatureToggles(), + SecurityAccessEditorMode.AudienceList => BuildAudienceList(), + SecurityAccessEditorMode.AudienceProfile => BuildAudienceProfile(), + _ => BuildSecurityMenu() + }); return _contentNode; } @@ -63,20 +63,57 @@ private ILayoutNode BuildContent() private ILayoutNode BuildSecurityMenu() { var layout = Layouts.Vertical() - .WithChild(new TextNode(" Security & Access").WithForeground(Color.White).Bold()); + .WithChild(Header(" Security & Access")); var items = ViewModel.Items; for (var i = 0; i < items.Count; i++) { var item = items[i]; - var selected = i == ViewModel.SelectedIndex.Value; - var prefix = selected ? " ▶ " : " "; - var line = $"{prefix}{item.Label,-20} {item.Summary,-20} {item.Description}"; - var node = new TextNode(line); - node = selected - ? node.WithForeground(Color.Cyan).Bold() - : node.WithForeground(Color.White); - layout = layout.WithChild(node); + layout = layout.WithChild(Row( + $"{FocusPrefix(i == ViewModel.SelectedIndex.Value)}{item.Label,-20} {item.Summary,-20} {item.Description}", + i == ViewModel.SelectedIndex.Value)); + } + + return layout; + } + + private ILayoutNode BuildPostureEditor() + { + var layout = Layouts.Vertical() + .WithChild(Header(" Security Posture")) + .WithChild(Hint($" Current posture: {ViewModel.CurrentPosture}")) + .WithChild(Layouts.Empty().Height(1)); + + var options = ViewModel.PostureOptions; + for (var i = 0; i < options.Count; i++) + { + var option = options[i]; + var focused = i == ViewModel.SelectedPostureIndex.Value; + var active = option.Value == ViewModel.CurrentPosture; + layout = layout.WithChild(Row( + $"{FocusPrefix(focused)}[{Check(active)}] {option.Label,-10} {option.Description}", + focused, + active)); + } + + return layout; + } + + private ILayoutNode BuildPostureCascade() + { + var layout = Layouts.Vertical() + .WithChild(Header(" Posture change affects Audience Profiles")) + .WithChild(Hint(" You have customized Audience Profiles. Changing posture can overwrite them.")) + .WithChild(Layouts.Empty().Height(1)); + + var options = ViewModel.PostureCascadeOptions; + for (var i = 0; i < options.Count; i++) + { + var option = options[i]; + var focused = i == ViewModel.SelectedCascadeIndex.Value; + layout = layout.WithChild(Row( + $"{FocusPrefix(focused)}{option.Label,-42} {option.Description}", + focused)); } return layout; @@ -85,30 +122,77 @@ private ILayoutNode BuildSecurityMenu() private ILayoutNode BuildFeatureToggles() { var layout = Layouts.Vertical() - .WithChild(new TextNode(" Enabled Features").WithForeground(Color.White).Bold()) - .WithChild(new TextNode(" Toggle global runtime features. Audience exposure is configured separately.") - .WithForeground(Color.BrightBlack)) + .WithChild(Header(" Enabled Features")) + .WithChild(Hint(" Toggle global runtime features. Audience exposure is configured separately.")) .WithChild(Layouts.Empty().Height(1)); var names = ViewModel.FeatureNames; var descriptions = ViewModel.FeatureDescriptions; for (var i = 0; i < names.Count; i++) { - var selected = i == ViewModel.SelectedFeatureIndex.Value; + var focused = i == ViewModel.SelectedFeatureIndex.Value; var enabled = ViewModel.IsFeatureEnabled(i); - var prefix = selected ? " ▶ " : " "; - var marker = enabled ? "✓" : " "; - var line = $"{prefix}[{marker}] {names[i],-12} {descriptions[i]}"; - var node = new TextNode(line); - - if (selected) - node = node.WithForeground(Color.Cyan).Bold(); - else if (enabled) - node = node.WithForeground(Color.White); - else - node = node.WithForeground(Color.BrightBlack); - - layout = layout.WithChild(node); + layout = layout.WithChild(Row( + $"{FocusPrefix(focused)}[{Check(enabled)}] {names[i],-12} {descriptions[i]}", + focused, + enabled)); + } + + return layout; + } + + private ILayoutNode BuildAudienceList() + { + var layout = Layouts.Vertical() + .WithChild(Header(" Audience Profiles")) + .WithChild(Hint(" Configure high-level access per audience tier.")) + .WithChild(Layouts.Empty().Height(1)); + + var options = ViewModel.AudienceOptions; + for (var i = 0; i < options.Count; i++) + { + var option = options[i]; + var focused = i == ViewModel.SelectedAudienceIndex.Value; + var summary = ViewModel.AudienceSummary(option.Value); + layout = layout.WithChild(Row( + $"{FocusPrefix(focused)}{option.Label,-10} {summary,-30} {option.Description}", + focused)); + } + + return layout; + } + + private ILayoutNode BuildAudienceProfile() + { + var audience = ViewModel.AudienceOptions[ViewModel.SelectedAudienceIndex.Value]; + var layout = Layouts.Vertical() + .WithChild(Header($" Audience Profiles > {audience.Label}")) + .WithChild(Hint($" Tool access for the {audience.Label} audience.")) + .WithChild(Layouts.Empty().Height(1)); + + var rows = ViewModel.ProfileRows; + for (var i = 0; i < rows.Count; i++) + { + var row = rows[i]; + var focused = i == ViewModel.SelectedAudienceRowIndex.Value; + var line = row.Kind switch + { + AudienceProfileRowKind.FileAccess or AudienceProfileRowKind.IncomingAttachments => + $"{FocusPrefix(focused)}{row.Label,-25} {ViewModel.AudienceValue(row.Kind),-22} {row.Description}", + AudienceProfileRowKind.McpPermissions => + $"{FocusPrefix(focused)}{row.Label,-25} {ViewModel.AudienceValue(row.Kind),-22} {row.Description}", + AudienceProfileRowKind.ResetToDefault => + $"{FocusPrefix(focused)}[Reset] {row.Label,-27} {row.Description}", + _ => + $"{FocusPrefix(focused)}[{Check(ViewModel.IsAudienceToggleEnabled(row.Kind))}] {row.Label,-23} {row.Description}" + }; + + var enabled = row.Kind switch + { + AudienceProfileRowKind.FileAccess or AudienceProfileRowKind.IncomingAttachments or AudienceProfileRowKind.McpPermissions or AudienceProfileRowKind.ResetToDefault => true, + _ => ViewModel.IsAudienceToggleEnabled(row.Kind) + }; + layout = layout.WithChild(Row(line, focused, enabled)); } return layout; @@ -122,10 +206,15 @@ private LayoutNode BuildStatusBar() private LayoutNode BuildKeyBindings() { - _keyBindingsNode = new DynamicLayoutNode(() => NetclawTuiChrome.BuildKeyHintLine( - ViewModel.EditingEnabledFeatures.Value - ? " [↑/↓] Navigate [Space/Enter] Toggle + Save [Esc] Security & Access [Ctrl+Q] Quit" - : " [↑/↓] Navigate [Enter] Open [Esc] Back [Ctrl+Q] Quit")); + _keyBindingsNode = new DynamicLayoutNode(() => NetclawTuiChrome.BuildKeyHintLine(ViewModel.Mode.Value switch + { + SecurityAccessEditorMode.Posture => " [↑/↓] Navigate [Enter] Save [Esc] Security & Access [Ctrl+Q] Quit", + SecurityAccessEditorMode.PostureCascade => " [↑/↓] Navigate [Enter] Apply [Esc] Back [Ctrl+Q] Quit", + SecurityAccessEditorMode.Features => " [↑/↓] Navigate [Space/Enter] Toggle + Save [Esc] Security & Access [Ctrl+Q] Quit", + SecurityAccessEditorMode.AudienceList => " [↑/↓] Navigate [Enter] Edit Audience [Esc] Security & Access [Ctrl+Q] Quit", + SecurityAccessEditorMode.AudienceProfile => " [↑/↓] Navigate [Space/Enter] Toggle/Cycle [Esc] Audiences [Ctrl+Q] Quit", + _ => " [↑/↓] Navigate [Enter] Open [Esc] Back [Ctrl+Q] Quit" + })); return _keyBindingsNode.Height(1); } @@ -141,31 +230,38 @@ private void HandleKeyPress(KeyPressed key) if (keyInfo.Key == ConsoleKey.Escape) { - ViewModel.BackToConfig(); + ViewModel.GoBack(); return; } - if (ViewModel.EditingEnabledFeatures.Value) + switch (ViewModel.Mode.Value) { - switch (keyInfo.Key) - { - case ConsoleKey.UpArrow: - ViewModel.MoveFeatureSelection(-1); - break; - case ConsoleKey.DownArrow: - ViewModel.MoveFeatureSelection(1); - break; - case ConsoleKey.Spacebar: - case ConsoleKey.Enter: - ViewModel.ToggleSelectedFeature(); - _contentNode?.Invalidate(); - break; - } - - ViewModel.RequestRedraw(); - return; + case SecurityAccessEditorMode.Menu: + HandleMenuKey(keyInfo); + break; + case SecurityAccessEditorMode.Posture: + HandlePostureKey(keyInfo); + break; + case SecurityAccessEditorMode.PostureCascade: + HandleCascadeKey(keyInfo); + break; + case SecurityAccessEditorMode.Features: + HandleFeatureKey(keyInfo); + break; + case SecurityAccessEditorMode.AudienceList: + HandleAudienceListKey(keyInfo); + break; + case SecurityAccessEditorMode.AudienceProfile: + HandleAudienceProfileKey(keyInfo); + break; } + _contentNode?.Invalidate(); + ViewModel.RequestRedraw(); + } + + private void HandleMenuKey(ConsoleKeyInfo keyInfo) + { switch (keyInfo.Key) { case ConsoleKey.UpArrow: @@ -178,7 +274,106 @@ private void HandleKeyPress(KeyPressed key) ViewModel.ActivateSelected(); break; } + } - ViewModel.RequestRedraw(); + private void HandlePostureKey(ConsoleKeyInfo keyInfo) + { + switch (keyInfo.Key) + { + case ConsoleKey.UpArrow: + ViewModel.MovePostureSelection(-1); + break; + case ConsoleKey.DownArrow: + ViewModel.MovePostureSelection(1); + break; + case ConsoleKey.Enter: + ViewModel.ApplySelectedPosture(); + break; + } + } + + private void HandleCascadeKey(ConsoleKeyInfo keyInfo) + { + switch (keyInfo.Key) + { + case ConsoleKey.UpArrow: + ViewModel.MoveCascadeSelection(-1); + break; + case ConsoleKey.DownArrow: + ViewModel.MoveCascadeSelection(1); + break; + case ConsoleKey.Enter: + ViewModel.ApplySelectedCascadeOption(); + break; + } + } + + private void HandleFeatureKey(ConsoleKeyInfo keyInfo) + { + switch (keyInfo.Key) + { + case ConsoleKey.UpArrow: + ViewModel.MoveFeatureSelection(-1); + break; + case ConsoleKey.DownArrow: + ViewModel.MoveFeatureSelection(1); + break; + case ConsoleKey.Spacebar: + case ConsoleKey.Enter: + ViewModel.ToggleSelectedFeature(); + break; + } + } + + private void HandleAudienceListKey(ConsoleKeyInfo keyInfo) + { + switch (keyInfo.Key) + { + case ConsoleKey.UpArrow: + ViewModel.MoveAudienceSelection(-1); + break; + case ConsoleKey.DownArrow: + ViewModel.MoveAudienceSelection(1); + break; + case ConsoleKey.Enter: + ViewModel.OpenSelectedAudienceProfile(); + break; + } + } + + private void HandleAudienceProfileKey(ConsoleKeyInfo keyInfo) + { + switch (keyInfo.Key) + { + case ConsoleKey.UpArrow: + ViewModel.MoveAudienceRow(-1); + break; + case ConsoleKey.DownArrow: + ViewModel.MoveAudienceRow(1); + break; + case ConsoleKey.Spacebar: + case ConsoleKey.Enter: + ViewModel.ActivateSelectedAudienceProfileRow(); + break; + } + } + + private void InvalidateAll() + { + _contentNode?.Invalidate(); + _keyBindingsNode?.Invalidate(); + } + + private static TextNode Header(string text) => new TextNode(text).WithForeground(Color.White).Bold(); + private static TextNode Hint(string text) => new TextNode(text).WithForeground(Color.BrightBlack); + private static string FocusPrefix(bool focused) => focused ? " ▶ " : " "; + private static string Check(bool enabled) => enabled ? "✓" : " "; + + private static TextNode Row(string line, bool focused, bool enabled = true) + { + var node = new TextNode(line); + if (focused) + return node.WithForeground(Color.Cyan).Bold(); + return node.WithForeground(enabled ? Color.White : Color.BrightBlack); } } diff --git a/src/Netclaw.Cli/Tui/Config/SecurityAccessViewModel.cs b/src/Netclaw.Cli/Tui/Config/SecurityAccessViewModel.cs index 5abce036a..360ff56a1 100644 --- a/src/Netclaw.Cli/Tui/Config/SecurityAccessViewModel.cs +++ b/src/Netclaw.Cli/Tui/Config/SecurityAccessViewModel.cs @@ -3,7 +3,9 @@ // Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com> // </copyright> // ----------------------------------------------------------------------- +using System.Text.Json; using Netclaw.Cli.Config; +using Netclaw.Cli.Json; using Netclaw.Cli.Tui.Sections; using Netclaw.Cli.Tui.Wizard.Steps; using Netclaw.Configuration; @@ -14,9 +16,39 @@ namespace Netclaw.Cli.Tui.Config; public sealed record SecurityAccessItem(string Label, string Summary, string Description, string? Route = null); +public enum SecurityAccessEditorMode +{ + Menu, + Posture, + PostureCascade, + Features, + AudienceList, + AudienceProfile +} + +public sealed record SecurityPostureOption(DeploymentPosture Value, string Label, string Description); +public sealed record SecurityAudienceOption(TrustAudience Value, string Label, string Description); +public sealed record SecurityCascadeOption(string Label, string Description); +public sealed record AudienceProfileRow(AudienceProfileRowKind Kind, string Label, string Description); + +public enum AudienceProfileRowKind +{ + ReadFiles, + EditFiles, + WebAccess, + Skills, + Scheduling, + ChangeWorkingDirectory, + FileAccess, + IncomingAttachments, + McpPermissions, + ResetToDefault +} + public sealed class SecurityAccessViewModel : ReactiveViewModel { private const int FeatureCount = 6; + private const string ShellToolName = "shell_execute"; private static readonly string[] FeatureConfigPaths = [ "Memory.Enabled", @@ -27,8 +59,63 @@ public sealed class SecurityAccessViewModel : ReactiveViewModel "Webhooks.Enabled" ]; + private static readonly SecurityPostureOption[] Postures = + [ + new(DeploymentPosture.Personal, "Personal", "Just me. Local-only by default. Tools have wide access."), + new(DeploymentPosture.Team, "Team", "Small team via Slack/Discord. Audience-restricted tools."), + new(DeploymentPosture.Public, "Public", "Open to untrusted users. Strict defaults and access controls.") + ]; + + private static readonly SecurityAudienceOption[] Audiences = + [ + new(TrustAudience.Personal, "Personal", "Operator/local sessions."), + new(TrustAudience.Team, "Team", "Trusted internal channels."), + new(TrustAudience.Public, "Public", "Untrusted external users.") + ]; + + private static readonly SecurityCascadeOption[] CascadeOptions = + [ + new("Cancel - keep current posture", "Do not change posture or audience profiles."), + new("Apply new posture, overwrite profiles", "Reset all audience profiles to posture defaults."), + new("Apply new posture, keep custom profiles", "Only change deployment posture and shell defaults.") + ]; + + private static readonly AudienceProfileRow[] AudienceRows = + [ + new(AudienceProfileRowKind.ReadFiles, "Read files", "Read and list files within the file scope."), + new(AudienceProfileRowKind.EditFiles, "Edit files", "Write or patch files within the file scope."), + new(AudienceProfileRowKind.WebAccess, "Web access", "Use web_search and web_fetch."), + new(AudienceProfileRowKind.Skills, "Skills", "Manage and load skills."), + new(AudienceProfileRowKind.Scheduling, "Scheduling", "Create, list, cancel, and inspect reminders."), + new(AudienceProfileRowKind.ChangeWorkingDirectory, "Change working directory", "Let sessions switch workspace roots."), + new(AudienceProfileRowKind.FileAccess, "File access", "Cycle Off, Session only, or All files."), + new(AudienceProfileRowKind.IncomingAttachments, "Incoming attachments", "Cycle attachment categories accepted from channels."), + new(AudienceProfileRowKind.McpPermissions, "MCP permissions", "Managed in netclaw mcp permissions."), + new(AudienceProfileRowKind.ResetToDefault, "Reset to posture default", "Replace this full audience profile with the posture default.") + ]; + + private static readonly string[] ReadFileTools = ["file_read", "file_list", "attach_file"]; + private static readonly string[] EditFileTools = ["file_write", "file_edit"]; + private static readonly string[] WebTools = ["web_search", "web_fetch"]; + private static readonly string[] SkillTools = ["skill_manage"]; + private static readonly string[] SchedulingTools = ["set_reminder", "list_reminders", "cancel_reminder", "get_reminder_history"]; + private static readonly string[] WorkingDirectoryTools = ["set_working_directory"]; + private static readonly string[] KnownFirstPartyTools = + [ + "file_read", "file_list", "file_write", "file_edit", "attach_file", + "web_search", "web_fetch", "skill_manage", "set_reminder", + "list_reminders", "cancel_reminder", "get_reminder_history", + "set_working_directory", ShellToolName, "set_webhook", "delete_webhook", + "list_webhooks", "send_slack_message", "lookup_slack_user", + "send_discord_message", "send_mattermost_message", "lookup_mattermost_user", + "spawn_agent", "search_tools", "load_tool", "skill_load", + "skill_read_resource", "store_memory", "get_memories", "update_memory", + "find_memories", "check_background_job" + ]; + private readonly NetclawPaths _paths; private readonly bool[] _enabledFeatures = new bool[FeatureCount]; + private DeploymentPosture? _pendingPosture; public SecurityAccessViewModel(NetclawPaths paths) { @@ -40,13 +127,23 @@ public SecurityAccessViewModel(NetclawPaths paths) internal bool ShutdownRequestedForTest { get; private set; } public ReactiveProperty<string> StatusMessage { get; } = new(""); + public ReactiveProperty<SecurityAccessEditorMode> Mode { get; } = new(SecurityAccessEditorMode.Menu); public ReactiveProperty<int> SelectedIndex { get; } = new(0); - public ReactiveProperty<bool> EditingEnabledFeatures { get; } = new(false); + public ReactiveProperty<int> SelectedPostureIndex { get; } = new(0); + public ReactiveProperty<int> SelectedCascadeIndex { get; } = new(0); public ReactiveProperty<int> SelectedFeatureIndex { get; } = new(0); + public ReactiveProperty<int> SelectedAudienceIndex { get; } = new(0); + public ReactiveProperty<int> SelectedAudienceRowIndex { get; } = new(0); public IReadOnlyList<SecurityAccessItem> Items => BuildItems(); + public IReadOnlyList<SecurityPostureOption> PostureOptions => Postures; + public IReadOnlyList<SecurityCascadeOption> PostureCascadeOptions => CascadeOptions; + public IReadOnlyList<SecurityAudienceOption> AudienceOptions => Audiences; + public IReadOnlyList<AudienceProfileRow> ProfileRows => AudienceRows; public IReadOnlyList<string> FeatureNames => FeatureSelectionStepViewModel.FeatureNames; public IReadOnlyList<string> FeatureDescriptions => FeatureSelectionStepViewModel.FeatureDescriptions; + public TrustAudience SelectedAudience => Audiences[SelectedAudienceIndex.Value].Value; + public DeploymentPosture CurrentPosture => ReadPosture(ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath)); public void MoveSelection(int delta) { @@ -59,29 +156,52 @@ public void MoveSelection(int delta) SelectedIndex.Value = next; } + public void MovePostureSelection(int delta) => Move(SelectedPostureIndex, delta, Postures.Length); + public void MoveCascadeSelection(int delta) => Move(SelectedCascadeIndex, delta, CascadeOptions.Length); + public void MoveFeatureSelection(int delta) => Move(SelectedFeatureIndex, delta, FeatureCount); + public void MoveAudienceSelection(int delta) => Move(SelectedAudienceIndex, delta, Audiences.Length); + public void MoveAudienceRow(int delta) => Move(SelectedAudienceRowIndex, delta, AudienceRows.Length); + public void ActivateSelected() { - if (EditingEnabledFeatures.Value) + switch (Mode.Value) { - ToggleSelectedFeature(); - return; + case SecurityAccessEditorMode.Menu: + var items = Items; + if (items.Count > 0) + Activate(items[SelectedIndex.Value]); + break; + case SecurityAccessEditorMode.Posture: + ApplySelectedPosture(); + break; + case SecurityAccessEditorMode.PostureCascade: + ApplySelectedCascadeOption(); + break; + case SecurityAccessEditorMode.Features: + ToggleSelectedFeature(); + break; + case SecurityAccessEditorMode.AudienceList: + OpenSelectedAudienceProfile(); + break; + case SecurityAccessEditorMode.AudienceProfile: + ActivateSelectedAudienceProfileRow(); + break; } - - var items = Items; - if (items.Count == 0) - return; - - Activate(items[SelectedIndex.Value]); } internal void Activate(SecurityAccessItem item) { - if (item.Label == "Enabled Features") + switch (item.Label) { - EditingEnabledFeatures.Value = true; - StatusMessage.Value = ""; - RequestRedraw(); - return; + case "Security Posture": + OpenPostureEditor(); + return; + case "Enabled Features": + OpenFeatureEditor(); + return; + case "Audience Profiles": + OpenAudienceList(); + return; } if (item.Route is not null) @@ -95,25 +215,98 @@ internal void Activate(SecurityAccessItem item) RequestRedraw(); } - public void BackToConfig() + public void GoBack() + { + switch (Mode.Value) + { + case SecurityAccessEditorMode.AudienceProfile: + Mode.Value = SecurityAccessEditorMode.AudienceList; + StatusMessage.Value = ""; + RequestRedraw(); + return; + case SecurityAccessEditorMode.PostureCascade: + Mode.Value = SecurityAccessEditorMode.Posture; + _pendingPosture = null; + StatusMessage.Value = ""; + RequestRedraw(); + return; + case SecurityAccessEditorMode.Posture: + case SecurityAccessEditorMode.Features: + case SecurityAccessEditorMode.AudienceList: + Mode.Value = SecurityAccessEditorMode.Menu; + StatusMessage.Value = ""; + RequestRedraw(); + return; + } + + RouteRequested?.Invoke("/config"); + Navigate?.Invoke("/config"); + } + + public void OpenPostureEditor() + { + var current = CurrentPosture; + var index = Array.FindIndex(Postures, option => option.Value == current); + SelectedPostureIndex.Value = index < 0 ? 0 : index; + Mode.Value = SecurityAccessEditorMode.Posture; + StatusMessage.Value = ""; + RequestRedraw(); + } + + public void ApplySelectedPosture() { - if (EditingEnabledFeatures.Value) + var posture = Postures[SelectedPostureIndex.Value].Value; + if (posture == CurrentPosture) + { + StatusMessage.Value = $"{posture} posture is already active."; + RequestRedraw(); + return; + } + + _pendingPosture = posture; + if (AudienceProfilesCustomized()) { - EditingEnabledFeatures.Value = false; + SelectedCascadeIndex.Value = 0; + Mode.Value = SecurityAccessEditorMode.PostureCascade; StatusMessage.Value = ""; RequestRedraw(); return; } - RouteRequested?.Invoke("/config"); - Navigate?.Invoke("/config"); + SavePosture(posture, overwriteProfiles: true); + } + + public void ApplySelectedCascadeOption() + { + if (_pendingPosture is not { } posture) + { + Mode.Value = SecurityAccessEditorMode.Posture; + return; + } + + switch (SelectedCascadeIndex.Value) + { + case 0: + _pendingPosture = null; + Mode.Value = SecurityAccessEditorMode.Posture; + StatusMessage.Value = "Posture change cancelled."; + RequestRedraw(); + break; + case 1: + SavePosture(posture, overwriteProfiles: true); + break; + case 2: + SavePosture(posture, overwriteProfiles: false); + break; + } } - public void MoveFeatureSelection(int delta) + public void OpenFeatureEditor() { - var next = Math.Clamp(SelectedFeatureIndex.Value + delta, 0, FeatureCount - 1); - if (next != SelectedFeatureIndex.Value) - SelectedFeatureIndex.Value = next; + LoadEnabledFeatures(); + Mode.Value = SecurityAccessEditorMode.Features; + StatusMessage.Value = ""; + RequestRedraw(); } public bool IsFeatureEnabled(int index) => _enabledFeatures[index]; @@ -132,6 +325,104 @@ public void ToggleSelectedFeature() RequestRedraw(); } + public void OpenAudienceList() + { + Mode.Value = SecurityAccessEditorMode.AudienceList; + StatusMessage.Value = ""; + RequestRedraw(); + } + + public void OpenSelectedAudienceProfile() + { + SelectedAudienceRowIndex.Value = 0; + Mode.Value = SecurityAccessEditorMode.AudienceProfile; + StatusMessage.Value = ""; + RequestRedraw(); + } + + public string AudienceSummary(TrustAudience audience) + { + var profiles = LoadAudienceProfiles(); + var current = GetProfile(profiles, audience); + var defaults = GetProfile(BuildPostureProfiles(CurrentPosture), audience); + return JsonEquivalent(current, defaults) ? $"Default for posture: {CurrentPosture}" : "Customized"; + } + + public bool IsAudienceToggleEnabled(AudienceProfileRowKind kind) + { + var profile = GetSelectedProfile(); + return kind switch + { + AudienceProfileRowKind.ReadFiles => ToolGroupEnabled(profile, ReadFileTools), + AudienceProfileRowKind.EditFiles => ToolGroupEnabled(profile, EditFileTools), + AudienceProfileRowKind.WebAccess => ToolGroupEnabled(profile, WebTools), + AudienceProfileRowKind.Skills => ToolGroupEnabled(profile, SkillTools), + AudienceProfileRowKind.Scheduling => ToolGroupEnabled(profile, SchedulingTools), + AudienceProfileRowKind.ChangeWorkingDirectory => ToolGroupEnabled(profile, WorkingDirectoryTools), + _ => false + }; + } + + public string AudienceValue(AudienceProfileRowKind kind) + { + var profile = GetSelectedProfile(); + return kind switch + { + AudienceProfileRowKind.FileAccess => DescribeFilesystem(profile), + AudienceProfileRowKind.IncomingAttachments => DescribeAttachments(profile.ChannelAttachments), + AudienceProfileRowKind.McpPermissions => "Manage separately", + AudienceProfileRowKind.ResetToDefault => "", + _ => IsAudienceToggleEnabled(kind) ? "Enabled" : "Disabled" + }; + } + + public void ActivateSelectedAudienceProfileRow() + { + var row = AudienceRows[SelectedAudienceRowIndex.Value]; + switch (row.Kind) + { + case AudienceProfileRowKind.ReadFiles: + ToggleToolGroup(row.Kind, ReadFileTools); + return; + case AudienceProfileRowKind.EditFiles: + ToggleToolGroup(row.Kind, EditFileTools); + return; + case AudienceProfileRowKind.WebAccess: + ToggleToolGroup(row.Kind, WebTools); + return; + case AudienceProfileRowKind.Skills: + ToggleToolGroup(row.Kind, SkillTools); + return; + case AudienceProfileRowKind.Scheduling: + ToggleToolGroup(row.Kind, SchedulingTools); + return; + case AudienceProfileRowKind.ChangeWorkingDirectory: + ToggleToolGroup(row.Kind, WorkingDirectoryTools); + return; + case AudienceProfileRowKind.FileAccess: + CycleFileAccess(); + return; + case AudienceProfileRowKind.IncomingAttachments: + CycleIncomingAttachments(); + return; + case AudienceProfileRowKind.McpPermissions: + StatusMessage.Value = "Run `netclaw mcp permissions` to edit MCP server and tool grants."; + RequestRedraw(); + return; + case AudienceProfileRowKind.ResetToDefault: + ResetSelectedAudienceProfile(); + return; + } + } + + public void ResetSelectedAudienceProfile() + { + var profiles = BuildPostureProfiles(CurrentPosture); + SaveAudienceProfile(GetProfile(profiles, SelectedAudience)); + StatusMessage.Value = $"{AudienceLabel(SelectedAudience)} profile reset to {CurrentPosture} defaults."; + RequestRedraw(); + } + public void RequestQuit() { ShutdownRequestedForTest = true; @@ -141,12 +432,132 @@ public void RequestQuit() public override void Dispose() { StatusMessage.Dispose(); + Mode.Dispose(); SelectedIndex.Dispose(); - EditingEnabledFeatures.Dispose(); + SelectedPostureIndex.Dispose(); + SelectedCascadeIndex.Dispose(); SelectedFeatureIndex.Dispose(); + SelectedAudienceIndex.Dispose(); + SelectedAudienceRowIndex.Dispose(); base.Dispose(); } + private void SavePosture(DeploymentPosture posture, bool overwriteProfiles) + { + var shellMode = posture == DeploymentPosture.Personal + ? ShellExecutionMode.HostAllowed + : ShellExecutionMode.Off; + + var fieldActions = new List<SectionFieldAction> + { + new("Security.DeploymentPosture", SectionFieldActionKind.Set, posture.ToString()), + new("Security.ShellExecutionMode", SectionFieldActionKind.Set, shellMode.ToString()), + new("Security.StrictDefaults", SectionFieldActionKind.Set, true), + new("Tools.ShellMode", SectionFieldActionKind.Set, shellMode.ToString()) + }; + + if (overwriteProfiles) + fieldActions.Add(new SectionFieldAction("Tools.AudienceProfiles", SectionFieldActionKind.Set, BuildPostureProfiles(posture))); + + var session = new ConfigEditorSession(_paths); + session.Apply(new SectionContribution(fieldActions)); + session.Save(); + + _pendingPosture = null; + StatusMessage.Value = overwriteProfiles + ? $"{posture} posture saved and audience profiles reset." + : $"{posture} posture saved; custom audience profiles preserved."; + Mode.Value = posture == DeploymentPosture.Personal + ? SecurityAccessEditorMode.Menu + : SecurityAccessEditorMode.Features; + LoadEnabledFeatures(); + RequestRedraw(); + } + + private void ToggleToolGroup(AudienceProfileRowKind kind, IReadOnlyList<string> tools) + { + var profiles = LoadAudienceProfiles(); + var profile = GetProfile(profiles, SelectedAudience); + var enabled = ToolGroupEnabled(profile, tools); + EnsureAllowlist(profile); + if (enabled) + profile.AllowedTools.RemoveAll(tool => tools.Contains(tool, StringComparer.Ordinal)); + else + AddTools(profile.AllowedTools, tools); + + SaveAudienceProfile(profile); + StatusMessage.Value = $"{AudienceLabel(SelectedAudience)} {AudienceRows.Single(row => row.Kind == kind).Label} {(enabled ? "disabled" : "enabled")}. Saved."; + RequestRedraw(); + } + + private void CycleFileAccess() + { + var profiles = LoadAudienceProfiles(); + var profile = GetProfile(profiles, SelectedAudience); + var next = CurrentFilesystemLevel(profile) switch + { + FilesystemLevel.Off => FilesystemLevel.SessionOnly, + FilesystemLevel.SessionOnly => FilesystemLevel.AllFiles, + _ => FilesystemLevel.Off + }; + + ApplyFilesystemLevel(profile, next); + SaveAudienceProfile(profile); + StatusMessage.Value = $"{AudienceLabel(SelectedAudience)} file access set to {DescribeFilesystem(profile)}. Saved."; + RequestRedraw(); + } + + private void CycleIncomingAttachments() + { + var profiles = LoadAudienceProfiles(); + var profile = GetProfile(profiles, SelectedAudience); + var next = CurrentAttachmentLevel(profile.ChannelAttachments) switch + { + AttachmentLevel.None => AttachmentLevel.Images, + AttachmentLevel.Images => AttachmentLevel.CommonWorkFiles, + AttachmentLevel.CommonWorkFiles => AttachmentLevel.All, + _ => AttachmentLevel.None + }; + + profile.ChannelAttachments = BuildAttachmentPolicy(next); + SaveAudienceProfile(profile); + StatusMessage.Value = $"{AudienceLabel(SelectedAudience)} attachments set to {DescribeAttachments(profile.ChannelAttachments)}. Saved."; + RequestRedraw(); + } + + private void SaveAudienceProfile(ToolAudienceProfile profile) + { + var session = new ConfigEditorSession(_paths); + session.Apply(new SectionContribution( + [ + new SectionFieldAction($"Tools.AudienceProfiles.{AudienceConfigName(SelectedAudience)}", SectionFieldActionKind.Set, profile) + ])); + session.Save(); + } + + private ToolAudienceProfile GetSelectedProfile() + => GetProfile(LoadAudienceProfiles(), SelectedAudience); + + private ToolAudienceProfiles LoadAudienceProfiles() + { + var config = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath); + if (!ConfigFileHelper.TryGetPathValue(config, "Tools.AudienceProfiles", out var value) || value is null) + return BuildPostureProfiles(ReadPosture(config)); + + return ConvertConfigObject<ToolAudienceProfiles>(value, "Tools.AudienceProfiles"); + } + + private bool AudienceProfilesCustomized() + { + var config = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath); + if (!ConfigFileHelper.TryGetPathValue(config, "Tools.AudienceProfiles", out var value) || value is null) + return false; + + var existing = ConvertConfigObject<ToolAudienceProfiles>(value, "Tools.AudienceProfiles"); + var defaults = BuildPostureProfiles(ReadPosture(config)); + return !JsonEquivalent(existing, defaults); + } + private void LoadEnabledFeatures() { Array.Fill(_enabledFeatures, true); @@ -174,25 +585,13 @@ private IReadOnlyList<SecurityAccessItem> BuildItems() var config = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath); return [ - new("Security Posture", ReadPostureSummary(config), "Deployment trust stance."), + new("Security Posture", ReadPosture(config).ToString(), "Deployment trust stance."), new("Enabled Features", ReadEnabledFeaturesSummary(config), "Deployment-wide runtime feature gates."), - new("Audience Profiles", "Not implemented", "Curated per-audience access rules."), + new("Audience Profiles", ReadAudienceProfilesSummary(config), "Curated per-audience access rules."), new("Exposure Mode", ReadExposureModeSummary(config), "Daemon reachability and tunnel topology.", "/exposure-mode") ]; } - private static string ReadPostureSummary(Dictionary<string, object> config) - { - if (ConfigFileHelper.TryGetPathValue(config, "Security.DeploymentPosture", out var value) - && value is string posture - && !string.IsNullOrWhiteSpace(posture)) - { - return posture; - } - - return "Personal"; - } - private static string ReadEnabledFeaturesSummary(Dictionary<string, object> config) { var enabled = 0; @@ -209,6 +608,28 @@ private static string ReadEnabledFeaturesSummary(Dictionary<string, object> conf return $"{enabled}/{FeatureConfigPaths.Length} enabled"; } + private static string ReadAudienceProfilesSummary(Dictionary<string, object> config) + { + if (!ConfigFileHelper.TryGetPathValue(config, "Tools.AudienceProfiles", out var value) || value is null) + return "Defaults"; + + var existing = ConvertConfigObject<ToolAudienceProfiles>(value, "Tools.AudienceProfiles"); + var defaults = BuildPostureProfiles(ReadPosture(config)); + return JsonEquivalent(existing, defaults) ? "Defaults" : "Customized"; + } + + private static DeploymentPosture ReadPosture(Dictionary<string, object> config) + { + if (ConfigFileHelper.TryGetPathValue(config, "Security.DeploymentPosture", out var value) + && value is string posture + && Enum.TryParse<DeploymentPosture>(posture, ignoreCase: true, out var parsed)) + { + return parsed; + } + + return DeploymentPosture.Personal; + } + private static string ReadExposureModeSummary(Dictionary<string, object> config) { var mode = ExposureMode.Local; @@ -225,4 +646,171 @@ private static string ReadExposureModeSummary(Dictionary<string, object> config) _ => mode.ToString() }; } + + private static ToolAudienceProfiles BuildPostureProfiles(DeploymentPosture posture) + { + var profiles = ToolAudienceProfileDefaults.CreateProfiles(); + if (posture == DeploymentPosture.Personal) + { + profiles.Personal.ApprovalPolicy = new ToolApprovalConfig + { + ToolOverrides = new Dictionary<string, ToolApprovalMode>(StringComparer.Ordinal) + { + [ShellToolName] = ToolApprovalMode.Approval + } + }; + } + + return profiles; + } + + private static ToolAudienceProfile GetProfile(ToolAudienceProfiles profiles, TrustAudience audience) + => audience switch + { + TrustAudience.Personal => profiles.Personal, + TrustAudience.Team => profiles.Team, + TrustAudience.Public => profiles.Public, + _ => profiles.Public + }; + + private static string AudienceLabel(TrustAudience audience) + => audience switch + { + TrustAudience.Personal => "Personal", + TrustAudience.Team => "Team", + TrustAudience.Public => "Public", + _ => audience.ToString() + }; + + private static string AudienceConfigName(TrustAudience audience) => AudienceLabel(audience); + + private static bool ToolGroupEnabled(ToolAudienceProfile profile, IReadOnlyList<string> tools) + => profile.ToolsMode == ToolProfileMode.All + || tools.All(tool => profile.AllowedTools.Contains(tool, StringComparer.Ordinal)); + + private static void EnsureAllowlist(ToolAudienceProfile profile) + { + if (profile.ToolsMode == ToolProfileMode.Allowlist) + return; + + profile.ToolsMode = ToolProfileMode.Allowlist; + profile.AllowedTools = [.. KnownFirstPartyTools]; + } + + private static void AddTools(List<string> target, IReadOnlyList<string> tools) + { + foreach (var tool in tools) + { + if (!target.Contains(tool, StringComparer.Ordinal)) + target.Add(tool); + } + } + + private static FilesystemLevel CurrentFilesystemLevel(ToolAudienceProfile profile) + { + var modes = new[] { profile.ReadFiles.Mode, profile.WriteFiles.Mode, profile.AttachFiles.Mode }; + if (modes.All(static mode => mode == ToolFilesystemMode.All)) + return FilesystemLevel.AllFiles; + if (modes.All(static mode => mode == ToolFilesystemMode.None)) + return FilesystemLevel.Off; + return FilesystemLevel.SessionOnly; + } + + private static void ApplyFilesystemLevel(ToolAudienceProfile profile, FilesystemLevel level) + { + profile.ReadFiles = BuildFilesystemAccess(level); + profile.WriteFiles = BuildFilesystemAccess(level); + profile.AttachFiles = BuildFilesystemAccess(level); + } + + private static ToolFilesystemAccessProfile BuildFilesystemAccess(FilesystemLevel level) + => level switch + { + FilesystemLevel.Off => new ToolFilesystemAccessProfile { Mode = ToolFilesystemMode.None, Roots = [] }, + FilesystemLevel.AllFiles => new ToolFilesystemAccessProfile { Mode = ToolFilesystemMode.All, Roots = [] }, + _ => ToolAudienceProfileDefaults.CreateSessionScopedFilesystemAccess() + }; + + private static string DescribeFilesystem(ToolAudienceProfile profile) + => CurrentFilesystemLevel(profile) switch + { + FilesystemLevel.Off => "Off", + FilesystemLevel.AllFiles => "All files", + _ => "Session only" + }; + + private static AttachmentLevel CurrentAttachmentLevel(ChannelAttachmentPolicy? policy) + { + if (policy is null || policy.AllowedCategories.Count == 0) + return AttachmentLevel.None; + + var categories = policy.AllowedCategories; + if (Enum.GetValues<AttachmentCategory>().All(category => categories.Contains(category))) + return AttachmentLevel.All; + if (categories.Count == 1 && categories.Contains(AttachmentCategory.Image)) + return AttachmentLevel.Images; + return AttachmentLevel.CommonWorkFiles; + } + + private static ChannelAttachmentPolicy BuildAttachmentPolicy(AttachmentLevel level) + => level switch + { + AttachmentLevel.None => ChannelAttachmentPolicy.Empty, + AttachmentLevel.Images => ToolAudienceProfileDefaults.CreatePublicChannelAttachments(), + AttachmentLevel.All => ToolAudienceProfileDefaults.CreatePersonalChannelAttachments(), + _ => ToolAudienceProfileDefaults.CreateTeamChannelAttachments() + }; + + private static string DescribeAttachments(ChannelAttachmentPolicy? policy) + => CurrentAttachmentLevel(policy) switch + { + AttachmentLevel.None => "None", + AttachmentLevel.Images => "Images", + AttachmentLevel.All => "All attachments", + _ => "Common work files" + }; + + private static bool JsonEquivalent<T>(T left, T right) + => JsonSerializer.Serialize(left, JsonDefaults.ConfigFile) == JsonSerializer.Serialize(right, JsonDefaults.ConfigFile); + + private static T ConvertConfigObject<T>(object value, string path) + { + try + { + var json = value is JsonElement element + ? element.GetRawText() + : JsonSerializer.Serialize(value, JsonDefaults.ConfigFile); + return JsonSerializer.Deserialize<T>(json, JsonDefaults.ConfigRead) + ?? throw new InvalidOperationException($"{path} was empty."); + } + catch (Exception ex) when (ex is JsonException or InvalidOperationException) + { + throw new InvalidOperationException($"Unable to read {path} from config.", ex); + } + } + + private static void Move(ReactiveProperty<int> index, int delta, int count) + { + if (count == 0) + return; + + var next = Math.Clamp(index.Value + delta, 0, count - 1); + if (next != index.Value) + index.Value = next; + } + + private enum FilesystemLevel + { + Off, + SessionOnly, + AllFiles + } + + private enum AttachmentLevel + { + None, + Images, + CommonWorkFiles, + All + } } diff --git a/tests/smoke/assertions/config-audience.sh b/tests/smoke/assertions/config-audience.sh new file mode 100755 index 000000000..672401ae4 --- /dev/null +++ b/tests/smoke/assertions/config-audience.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# config-audience.tape post-tape assertion. + +set -euo pipefail + +. "$(dirname "$0")/_lib.sh" + +assert_fail=0 + +echo "config-audience: reading produced config..." +if [[ ! -f "$CONFIG_PATH" ]]; then + echo "FAIL: ${CONFIG_PATH} does not exist." >&2 + exit 1 +fi + +config_json="$(read_config_json)" + +assert_field '.Tools.AudienceProfiles.Team.ToolsMode' 'Allowlist' "$config_json" || : +assert_field '.Tools.AudienceProfiles.Team.AllowedTools | index("file_read") != null' 'true' "$config_json" || : +assert_field '.Tools.AudienceProfiles.Team.AllowedTools | index("web_search") == null' 'true' "$config_json" || : +assert_field '.Tools.AudienceProfiles.Team.AllowedTools | index("web_fetch") == null' 'true' "$config_json" || : +assert_field '.Tools.AudienceProfiles.Team.McpServerToolGrants' 'null' "$config_json" || : + +if (( assert_fail )); then + printf -- '--- netclaw.json contents ---\n%s\n' "$config_json" >&2 + exit 1 +fi + +echo "config-audience: assertions passed." diff --git a/tests/smoke/assertions/config-posture.sh b/tests/smoke/assertions/config-posture.sh new file mode 100755 index 000000000..ac17ba46f --- /dev/null +++ b/tests/smoke/assertions/config-posture.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# config-posture.tape post-tape assertion. + +set -euo pipefail + +. "$(dirname "$0")/_lib.sh" + +assert_fail=0 + +echo "config-posture: reading produced config..." +if [[ ! -f "$CONFIG_PATH" ]]; then + echo "FAIL: ${CONFIG_PATH} does not exist." >&2 + exit 1 +fi + +config_json="$(read_config_json)" + +assert_field '.Security.DeploymentPosture' 'Team' "$config_json" || : +assert_field '.Security.ShellExecutionMode' 'Off' "$config_json" || : +assert_field '.Security.StrictDefaults' 'true' "$config_json" || : +assert_field '.Tools.ShellMode' 'Off' "$config_json" || : +assert_field '.Tools.AudienceProfiles.Team.AllowedTools | index("web_search") != null' 'true' "$config_json" || : + +if (( assert_fail )); then + printf -- '--- netclaw.json contents ---\n%s\n' "$config_json" >&2 + exit 1 +fi + +echo "config-posture: assertions passed." diff --git a/tests/smoke/tapes/config-audience.tape b/tests/smoke/tapes/config-audience.tape new file mode 100644 index 000000000..ba8b560f3 --- /dev/null +++ b/tests/smoke/tapes/config-audience.tape @@ -0,0 +1,45 @@ +# config-audience.tape - edit Audience Profiles from netclaw config. +# +# Exercises: +# netclaw config -> Security & Access -> Audience Profiles -> Team +# and verifies curated per-audience tool toggles persist without exposing raw MCP editing. + +Output "/tmp/tape-config-audience.gif" + +# Seed minimal Team config; Audience Profiles should resolve from posture defaults. +Type "mkdir -p $NETCLAW_HOME/config" +Enter +Type "posture=Team; jq -n --arg posture $posture '{configVersion:1,Security:{DeploymentPosture:$posture}}' > $NETCLAW_HOME/config/netclaw.json" +Enter + +Type "netclaw config" +Enter +Wait+Screen@10s /Settings Areas/ +Down 8 +Enter +Wait+Screen@10s /Security & Access/ + +# Open Audience Profiles, edit Team, disable Web access. +Down 2 +Enter +Wait+Screen@10s /Configure high-level access per audience tier/ +Down +Enter +Wait+Screen@10s /Tool access for the Team audience/ +Down 2 +Space +Wait+Screen@10s /\[ \] Web access/ +Escape +Wait+Screen@10s /Configure high-level access per audience tier/ +Escape +Wait+Screen@10s /Security & Access/ +Ctrl+Q + +Wait+Screen@10s /TAPE\$/ + +Type "echo CONFIG_AUDIENCE_EXIT=$?" +Enter +Wait+Screen@5s /CONFIG_AUDIENCE_EXIT=0/ + +Type "exit" +Enter diff --git a/tests/smoke/tapes/config-posture.tape b/tests/smoke/tapes/config-posture.tape new file mode 100644 index 000000000..de3f8cb0e --- /dev/null +++ b/tests/smoke/tapes/config-posture.tape @@ -0,0 +1,39 @@ +# config-posture.tape - edit Security Posture from netclaw config. +# +# Exercises: +# netclaw config -> Security & Access -> Security Posture +# and verifies posture persistence plus automatic continuation into Enabled Features. + +Output "/tmp/tape-config-posture.gif" + +# Seed minimal installed config with Personal posture. +Type "mkdir -p $NETCLAW_HOME/config" +Enter +Type "posture=Personal; jq -n --arg posture $posture '{configVersion:1,Security:{DeploymentPosture:$posture}}' > $NETCLAW_HOME/config/netclaw.json" +Enter + +Type "netclaw config" +Enter +Wait+Screen@10s /Settings Areas/ +Down 8 +Enter +Wait+Screen@10s /Security & Access/ + +# Open Security Posture, switch Personal -> Team, then land in Enabled Features. +Enter +Wait+Screen@10s /Current posture: Personal/ +Down +Enter +Wait+Screen@10s /Toggle global runtime features/ +Escape +Wait+Screen@10s /Security & Access/ +Ctrl+Q + +Wait+Screen@10s /TAPE\$/ + +Type "echo CONFIG_POSTURE_EXIT=$?" +Enter +Wait+Screen@5s /CONFIG_POSTURE_EXIT=0/ + +Type "exit" +Enter From a3f926de9277c4bcdbb058ef1e2f37cc0ba0ec92 Mon Sep 17 00:00:00 2001 From: Aaron Stannard <aaron@petabridge.com> Date: Sat, 30 May 2026 02:27:08 +0000 Subject: [PATCH 22/31] refine(config): improve audience profile editor --- docs/ui/TUI-002-netclaw-config-wireframes.md | 56 ++++-- .../Config/SecurityAccessViewModelTests.cs | 61 ++++++ .../Tui/Sections/ConfigEditorSessionTests.cs | 40 ++++ .../Tui/Config/SecurityAccessPage.cs | 45 ++++- .../Tui/Config/SecurityAccessViewModel.cs | 187 +++++++++++++----- .../Tui/Sections/ConfigEditorSession.cs | 94 ++++++++- tests/smoke/tapes/config-audience.tape | 17 +- 7 files changed, 414 insertions(+), 86 deletions(-) diff --git a/docs/ui/TUI-002-netclaw-config-wireframes.md b/docs/ui/TUI-002-netclaw-config-wireframes.md index 54faf3489..150395210 100644 --- a/docs/ui/TUI-002-netclaw-config-wireframes.md +++ b/docs/ui/TUI-002-netclaw-config-wireframes.md @@ -715,7 +715,7 @@ Same shape as Tailscale Serve, but with stronger public-exposure warning copy. │ │ │ ▸ Security Posture Team │ │ Enabled Features 4/6 enabled │ -│ Audience Profiles Team customized │ +│ Audience Profiles Customized │ │ Exposure Mode Cloudflare Tunnel │ │ │ │ [ Open / Edit inline ] [ Back ] │ @@ -805,37 +805,54 @@ exposure is configured in Audience Profiles and MCP permissions. ``` ╭─ Audience Profiles ─────────────────────────────────────────╮ │ │ -│ Configure high-level access per audience tier. │ +│ System default posture: Team │ +│ Customize audience/channel access when it should differ. │ +│ * global default audience Customized = custom overrides │ │ │ -│ ▶ Personal Default for posture: Personal │ -│ Team Default for posture: Personal │ -│ Public Default for posture: Personal │ +│ ▶ Personal Operator/local sessions │ +│ * Team Trusted internal channels │ +│ Public Untrusted external users │ │ │ │ ↑/↓ navigate · Enter edit audience · Esc cancel │ ╰─────────────────────────────────────────────────────────────╯ ``` +When a profile differs from the current system posture baseline, only that row +gets a `Customized` override marker: + +``` +│ ▶ Personal Operator/local sessions │ +│ * Team Trusted internal channels Customized │ +│ Public Untrusted external users │ +``` + ### 9.4.2 Per-audience editor ``` -╭─ Audience Profiles › Team ──────────────────────────────────╮ +╭─ Audience Profile: Team ────────────────────────────────────╮ │ │ -│ Tool access for the Team audience: │ +│ System default posture: Team │ +│ Profile: No custom overrides │ │ │ -│ ▶ [✓] Read files │ -│ [✓] Edit files │ -│ [✓] Web access │ +│ Tools │ +│ ▶ [✓] File tools │ +│ [✓] Web │ │ [✓] Skills │ │ [✓] Scheduling │ -│ [✓] Change working directory │ +│ [✓] Change workspace │ +│ │ +│ Access │ +│ File scope [◀ Session only ▶] │ +│ Attachments [◀ Common work files ▶] │ +│ MCP grants [Open] netclaw mcp permissions │ +│ │ +│ Actions │ +│ Reset overrides [Reset] │ │ │ -│ File access: Session only → │ -│ Incoming attachments: Common work files │ -│ MCP permissions: Manage in `netclaw mcp │ -│ permissions` → │ -│ [Reset] Reset to posture default │ +│ Common work files: images, PDFs, documents, archives, │ +│ and media; excludes unknown file types. │ │ │ -│ ↑/↓ navigate · Space/Enter toggle/cycle · Esc back │ +│ ↑/↓ navigate · ←/→ change · Space/Enter toggle/apply │ ╰─────────────────────────────────────────────────────────────╯ ``` @@ -844,9 +861,10 @@ exposure is configured in Audience Profiles and MCP permissions. - `↑` / `↓` MUST move focus between toggle rows. - `Space` MUST toggle the focused checkbox. - `Enter` on a checkbox row also toggles (alternative to Space). +- `←` / `→` on a cycle row moves backward or forward through curated values. - `Enter` on a cycle row advances to the next curated value. -- `Reset to posture default` replaces the full underlying audience profile, - including hidden MCP and approval settings, with the posture-default mapping. +- `Reset overrides` replaces the full underlying audience profile, including + hidden MCP and approval settings, with the current posture baseline mapping. The `config-audience.tape` smoke tape explicitly exercises `↓`, `Space`, and `Esc` to lock in the keystroke contract. Regression in arrow nav, diff --git a/src/Netclaw.Cli.Tests/Tui/Config/SecurityAccessViewModelTests.cs b/src/Netclaw.Cli.Tests/Tui/Config/SecurityAccessViewModelTests.cs index c3984ea92..48dd0be42 100644 --- a/src/Netclaw.Cli.Tests/Tui/Config/SecurityAccessViewModelTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/Config/SecurityAccessViewModelTests.cs @@ -111,6 +111,67 @@ public void Audience_profile_toggle_updates_selected_profile_only() Assert.DoesNotContain(teamAllowedTools, static tool => tool?.ToString() == "web_fetch"); Assert.False(ConfigFileHelper.TryGetPathValue(config, "Tools.AudienceProfiles.Public.AllowedTools", out _)); + Assert.Equal("Customized", vm.AudienceOverrideMarker(TrustAudience.Team)); + Assert.Equal("", vm.AudienceOverrideMarker(TrustAudience.Public)); + Assert.Equal("Customized overrides", vm.SelectedAudienceOverrideStatus); + } + + [Fact] + public void Audience_profiles_summary_reports_overrides_not_defaults() + { + File.WriteAllText(Context.Paths.NetclawConfigPath, + """ + { + "configVersion": 1, + "Security": { "DeploymentPosture": "Team" } + } + """); + + using var vm = new SecurityAccessViewModel(Context.Paths); + + var audienceProfiles = vm.Items.Single(static item => item.Label == "Audience Profiles"); + Assert.Equal("No overrides", audienceProfiles.Summary); + Assert.Equal("", vm.AudienceOverrideMarker(TrustAudience.Team)); + Assert.False(vm.IsSystemDefaultAudience(TrustAudience.Personal)); + Assert.True(vm.IsSystemDefaultAudience(TrustAudience.Team)); + Assert.False(vm.IsSystemDefaultAudience(TrustAudience.Public)); + + vm.SelectedAudienceIndex.Value = 1; + vm.OpenSelectedAudienceProfile(); + vm.SelectedAudienceRowIndex.Value = (int)AudienceProfileRowKind.WebAccess; + vm.ActivateSelectedAudienceProfileRow(); + + audienceProfiles = vm.Items.Single(static item => item.Label == "Audience Profiles"); + Assert.Equal("Customized", audienceProfiles.Summary); + Assert.Equal("Customized", vm.AudienceOverrideMarker(TrustAudience.Team)); + } + + [Fact] + public void Audience_profile_file_scope_cycle_keeps_team_scope_restricted() + { + File.WriteAllText(Context.Paths.NetclawConfigPath, + """ + { + "configVersion": 1, + "Security": { "DeploymentPosture": "Team" } + } + """); + + using var vm = new SecurityAccessViewModel(Context.Paths); + vm.SelectedAudienceIndex.Value = 1; + vm.OpenSelectedAudienceProfile(); + vm.SelectedAudienceRowIndex.Value = (int)AudienceProfileRowKind.FileAccess; + + vm.ActivateSelectedAudienceProfileRow(); + vm.ActivateSelectedAudienceProfileRow(); + + var config = ConfigFileHelper.LoadJsonDict(Context.Paths.NetclawConfigPath); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Tools.AudienceProfiles.Team.ReadFiles.Mode", out var readMode)); + Assert.Equal("Roots", readMode); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Tools.AudienceProfiles.Team.WriteFiles.Mode", out var writeMode)); + Assert.Equal("Roots", writeMode); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Tools.AudienceProfiles.Team.AttachFiles.Mode", out var attachMode)); + Assert.Equal("Roots", attachMode); } [Fact] diff --git a/src/Netclaw.Cli.Tests/Tui/Sections/ConfigEditorSessionTests.cs b/src/Netclaw.Cli.Tests/Tui/Sections/ConfigEditorSessionTests.cs index 2bd9e45b7..835f7a773 100644 --- a/src/Netclaw.Cli.Tests/Tui/Sections/ConfigEditorSessionTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/Sections/ConfigEditorSessionTests.cs @@ -102,6 +102,46 @@ public void Save_AppliesSecretActionsAndPreservesUnrelatedSecrets() Assert.Equal("stored-slack-token", ConfigFileHelper.DecryptIfEncrypted(_paths, slackToken?.ToString())); } + [Fact] + public void Save_SecretSetNormalizesColonPathAndRemovesLiteralCollision() + { + File.WriteAllText(_paths.SecretsPath, + """ + { + "configVersion": 1, + "Search": { + "BraveApiKey": "old-brave-key", + "OtherSecret": "keep-search" + }, + "Search:BraveApiKey": "literal-collision", + "Slack": { + "BotToken": "stored-slack-token" + } + } + """); + + var session = new ConfigEditorSession(_paths); + session.Apply(new SectionContribution( + SecretActions: + [ + new SectionSecretAction("Search:BraveApiKey", SectionSecretActionKind.Set, new SensitiveString("new-brave-key")) + ])); + + session.Save(); + + var serializedSecrets = File.ReadAllText(_paths.SecretsPath); + Assert.DoesNotContain("\"Search:BraveApiKey\"", serializedSecrets, StringComparison.Ordinal); + Assert.DoesNotContain("new-brave-key", serializedSecrets, StringComparison.Ordinal); + + var secrets = ConfigFileHelper.LoadJsonDict(_paths.SecretsPath); + Assert.True(ConfigFileHelper.TryGetPathValue(secrets, "Search.BraveApiKey", out var braveKey)); + Assert.Equal("new-brave-key", ConfigFileHelper.DecryptIfEncrypted(_paths, braveKey?.ToString())); + Assert.True(ConfigFileHelper.TryGetPathValue(secrets, "Search.OtherSecret", out var otherSecret)); + Assert.Equal("keep-search", ConfigFileHelper.DecryptIfEncrypted(_paths, otherSecret?.ToString())); + Assert.True(ConfigFileHelper.TryGetPathValue(secrets, "Slack.BotToken", out var slackToken)); + Assert.Equal("stored-slack-token", ConfigFileHelper.DecryptIfEncrypted(_paths, slackToken?.ToString())); + } + [Fact] public void Apply_StoresAndDeletesPassiveEditorState() { diff --git a/src/Netclaw.Cli/Tui/Config/SecurityAccessPage.cs b/src/Netclaw.Cli/Tui/Config/SecurityAccessPage.cs index 3cca2cfa1..2c09b3d4e 100644 --- a/src/Netclaw.Cli/Tui/Config/SecurityAccessPage.cs +++ b/src/Netclaw.Cli/Tui/Config/SecurityAccessPage.cs @@ -145,7 +145,9 @@ private ILayoutNode BuildAudienceList() { var layout = Layouts.Vertical() .WithChild(Header(" Audience Profiles")) - .WithChild(Hint(" Configure high-level access per audience tier.")) + .WithChild(Hint($" System default posture: {ViewModel.CurrentPosture}")) + .WithChild(Hint(" Customize audience/channel access when it should differ.")) + .WithChild(Legend(" * global default audience Customized = custom overrides")) .WithChild(Layouts.Empty().Height(1)); var options = ViewModel.AudienceOptions; @@ -153,9 +155,10 @@ private ILayoutNode BuildAudienceList() { var option = options[i]; var focused = i == ViewModel.SelectedAudienceIndex.Value; - var summary = ViewModel.AudienceSummary(option.Value); + var marker = ViewModel.AudienceOverrideMarker(option.Value); + var defaultMarker = ViewModel.IsSystemDefaultAudience(option.Value) ? "*" : " "; layout = layout.WithChild(Row( - $"{FocusPrefix(focused)}{option.Label,-10} {summary,-30} {option.Description}", + $"{FocusPrefix(focused)}{defaultMarker} {option.Label,-9} {option.Description,-34} {marker}", focused)); } @@ -166,8 +169,9 @@ private ILayoutNode BuildAudienceProfile() { var audience = ViewModel.AudienceOptions[ViewModel.SelectedAudienceIndex.Value]; var layout = Layouts.Vertical() - .WithChild(Header($" Audience Profiles > {audience.Label}")) - .WithChild(Hint($" Tool access for the {audience.Label} audience.")) + .WithChild(Header($" Audience Profile: {audience.Label}")) + .WithChild(Hint($" System default posture: {ViewModel.CurrentPosture}")) + .WithChild(Hint($" Profile: {ViewModel.SelectedAudienceOverrideStatus}")) .WithChild(Layouts.Empty().Height(1)); var rows = ViewModel.ProfileRows; @@ -175,16 +179,23 @@ private ILayoutNode BuildAudienceProfile() { var row = rows[i]; var focused = i == ViewModel.SelectedAudienceRowIndex.Value; + if (row.Kind == AudienceProfileRowKind.FileTools) + layout = layout.WithChild(Section(" Tools")); + if (row.Kind == AudienceProfileRowKind.FileAccess) + layout = layout.WithChild(Layouts.Empty().Height(1)).WithChild(Section(" Access")); + if (row.Kind == AudienceProfileRowKind.ResetToDefault) + layout = layout.WithChild(Layouts.Empty().Height(1)).WithChild(Section(" Actions")); + var line = row.Kind switch { AudienceProfileRowKind.FileAccess or AudienceProfileRowKind.IncomingAttachments => - $"{FocusPrefix(focused)}{row.Label,-25} {ViewModel.AudienceValue(row.Kind),-22} {row.Description}", + $"{FocusPrefix(focused)}{row.Label,-14} {CycleValue(ViewModel.AudienceValue(row.Kind))}", AudienceProfileRowKind.McpPermissions => - $"{FocusPrefix(focused)}{row.Label,-25} {ViewModel.AudienceValue(row.Kind),-22} {row.Description}", + $"{FocusPrefix(focused)}{row.Label,-14} [Open] {ViewModel.AudienceValue(row.Kind)}", AudienceProfileRowKind.ResetToDefault => - $"{FocusPrefix(focused)}[Reset] {row.Label,-27} {row.Description}", + $"{FocusPrefix(focused)}{row.Label,-14} [Reset]", _ => - $"{FocusPrefix(focused)}[{Check(ViewModel.IsAudienceToggleEnabled(row.Kind))}] {row.Label,-23} {row.Description}" + $"{FocusPrefix(focused)}[{Check(ViewModel.IsAudienceToggleEnabled(row.Kind))}] {row.Label}" }; var enabled = row.Kind switch @@ -195,6 +206,11 @@ private ILayoutNode BuildAudienceProfile() layout = layout.WithChild(Row(line, focused, enabled)); } + var focusedRow = rows[ViewModel.SelectedAudienceRowIndex.Value]; + layout = layout + .WithChild(Layouts.Empty().Height(1)) + .WithChild(Hint($" {ViewModel.AudienceRowHelp(focusedRow.Kind)}")); + return layout; } @@ -212,7 +228,7 @@ private LayoutNode BuildKeyBindings() SecurityAccessEditorMode.PostureCascade => " [↑/↓] Navigate [Enter] Apply [Esc] Back [Ctrl+Q] Quit", SecurityAccessEditorMode.Features => " [↑/↓] Navigate [Space/Enter] Toggle + Save [Esc] Security & Access [Ctrl+Q] Quit", SecurityAccessEditorMode.AudienceList => " [↑/↓] Navigate [Enter] Edit Audience [Esc] Security & Access [Ctrl+Q] Quit", - SecurityAccessEditorMode.AudienceProfile => " [↑/↓] Navigate [Space/Enter] Toggle/Cycle [Esc] Audiences [Ctrl+Q] Quit", + SecurityAccessEditorMode.AudienceProfile => " [↑/↓] Navigate [←/→] Change [Space/Enter] Toggle/Apply [Esc] Audiences [Ctrl+Q] Quit", _ => " [↑/↓] Navigate [Enter] Open [Esc] Back [Ctrl+Q] Quit" })); @@ -351,6 +367,12 @@ private void HandleAudienceProfileKey(ConsoleKeyInfo keyInfo) case ConsoleKey.DownArrow: ViewModel.MoveAudienceRow(1); break; + case ConsoleKey.LeftArrow: + ViewModel.ChangeSelectedAudienceProfileRow(-1); + break; + case ConsoleKey.RightArrow: + ViewModel.ChangeSelectedAudienceProfileRow(1); + break; case ConsoleKey.Spacebar: case ConsoleKey.Enter: ViewModel.ActivateSelectedAudienceProfileRow(); @@ -365,9 +387,12 @@ private void InvalidateAll() } private static TextNode Header(string text) => new TextNode(text).WithForeground(Color.White).Bold(); + private static TextNode Section(string text) => new TextNode(text).WithForeground(Color.White).Bold(); + private static TextNode Legend(string text) => new TextNode(text).WithForeground(Color.White); private static TextNode Hint(string text) => new TextNode(text).WithForeground(Color.BrightBlack); private static string FocusPrefix(bool focused) => focused ? " ▶ " : " "; private static string Check(bool enabled) => enabled ? "✓" : " "; + private static string CycleValue(string value) => $"[◀ {value,-17} ▶]"; private static TextNode Row(string line, bool focused, bool enabled = true) { diff --git a/src/Netclaw.Cli/Tui/Config/SecurityAccessViewModel.cs b/src/Netclaw.Cli/Tui/Config/SecurityAccessViewModel.cs index 360ff56a1..d45dabf9e 100644 --- a/src/Netclaw.Cli/Tui/Config/SecurityAccessViewModel.cs +++ b/src/Netclaw.Cli/Tui/Config/SecurityAccessViewModel.cs @@ -33,8 +33,7 @@ public sealed record AudienceProfileRow(AudienceProfileRowKind Kind, string Labe public enum AudienceProfileRowKind { - ReadFiles, - EditFiles, + FileTools, WebAccess, Skills, Scheduling, @@ -82,20 +81,18 @@ public sealed class SecurityAccessViewModel : ReactiveViewModel private static readonly AudienceProfileRow[] AudienceRows = [ - new(AudienceProfileRowKind.ReadFiles, "Read files", "Read and list files within the file scope."), - new(AudienceProfileRowKind.EditFiles, "Edit files", "Write or patch files within the file scope."), - new(AudienceProfileRowKind.WebAccess, "Web access", "Use web_search and web_fetch."), - new(AudienceProfileRowKind.Skills, "Skills", "Manage and load skills."), - new(AudienceProfileRowKind.Scheduling, "Scheduling", "Create, list, cancel, and inspect reminders."), - new(AudienceProfileRowKind.ChangeWorkingDirectory, "Change working directory", "Let sessions switch workspace roots."), - new(AudienceProfileRowKind.FileAccess, "File access", "Cycle Off, Session only, or All files."), - new(AudienceProfileRowKind.IncomingAttachments, "Incoming attachments", "Cycle attachment categories accepted from channels."), - new(AudienceProfileRowKind.McpPermissions, "MCP permissions", "Managed in netclaw mcp permissions."), - new(AudienceProfileRowKind.ResetToDefault, "Reset to posture default", "Replace this full audience profile with the posture default.") + new(AudienceProfileRowKind.FileTools, "File tools", "Read, attach, write, and edit files."), + new(AudienceProfileRowKind.WebAccess, "Web", "web_search and web_fetch."), + new(AudienceProfileRowKind.Skills, "Skills", "Skill management tools."), + new(AudienceProfileRowKind.Scheduling, "Scheduling", "Reminder tools."), + new(AudienceProfileRowKind.ChangeWorkingDirectory, "Change workspace", "Allow workspace switching."), + new(AudienceProfileRowKind.FileAccess, "File scope", "Filesystem scope for file tools."), + new(AudienceProfileRowKind.IncomingAttachments, "Attachments", "Accepted channel attachment types."), + new(AudienceProfileRowKind.McpPermissions, "MCP grants", "Managed separately."), + new(AudienceProfileRowKind.ResetToDefault, "Reset overrides", "Restore this audience to the current posture baseline.") ]; - private static readonly string[] ReadFileTools = ["file_read", "file_list", "attach_file"]; - private static readonly string[] EditFileTools = ["file_write", "file_edit"]; + private static readonly string[] FileTools = ["file_read", "file_list", "attach_file", "file_write", "file_edit"]; private static readonly string[] WebTools = ["web_search", "web_fetch"]; private static readonly string[] SkillTools = ["skill_manage"]; private static readonly string[] SchedulingTools = ["set_reminder", "list_reminders", "cancel_reminder", "get_reminder_history"]; @@ -144,6 +141,7 @@ public SecurityAccessViewModel(NetclawPaths paths) public IReadOnlyList<string> FeatureDescriptions => FeatureSelectionStepViewModel.FeatureDescriptions; public TrustAudience SelectedAudience => Audiences[SelectedAudienceIndex.Value].Value; public DeploymentPosture CurrentPosture => ReadPosture(ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath)); + public string SelectedAudienceOverrideStatus => AudienceHasOverrides(SelectedAudience) ? "Customized overrides" : "No custom overrides"; public void MoveSelection(int delta) { @@ -340,21 +338,23 @@ public void OpenSelectedAudienceProfile() RequestRedraw(); } - public string AudienceSummary(TrustAudience audience) - { - var profiles = LoadAudienceProfiles(); - var current = GetProfile(profiles, audience); - var defaults = GetProfile(BuildPostureProfiles(CurrentPosture), audience); - return JsonEquivalent(current, defaults) ? $"Default for posture: {CurrentPosture}" : "Customized"; - } + public bool IsSystemDefaultAudience(TrustAudience audience) + => audience switch + { + TrustAudience.Personal => CurrentPosture == DeploymentPosture.Personal, + TrustAudience.Team => CurrentPosture == DeploymentPosture.Team, + TrustAudience.Public => CurrentPosture == DeploymentPosture.Public, + _ => false + }; + + public string AudienceOverrideMarker(TrustAudience audience) => AudienceHasOverrides(audience) ? "Customized" : ""; public bool IsAudienceToggleEnabled(AudienceProfileRowKind kind) { var profile = GetSelectedProfile(); return kind switch { - AudienceProfileRowKind.ReadFiles => ToolGroupEnabled(profile, ReadFileTools), - AudienceProfileRowKind.EditFiles => ToolGroupEnabled(profile, EditFileTools), + AudienceProfileRowKind.FileTools => ToolGroupEnabled(profile, FileTools), AudienceProfileRowKind.WebAccess => ToolGroupEnabled(profile, WebTools), AudienceProfileRowKind.Skills => ToolGroupEnabled(profile, SkillTools), AudienceProfileRowKind.Scheduling => ToolGroupEnabled(profile, SchedulingTools), @@ -370,7 +370,7 @@ public string AudienceValue(AudienceProfileRowKind kind) { AudienceProfileRowKind.FileAccess => DescribeFilesystem(profile), AudienceProfileRowKind.IncomingAttachments => DescribeAttachments(profile.ChannelAttachments), - AudienceProfileRowKind.McpPermissions => "Manage separately", + AudienceProfileRowKind.McpPermissions => "netclaw mcp permissions", AudienceProfileRowKind.ResetToDefault => "", _ => IsAudienceToggleEnabled(kind) ? "Enabled" : "Disabled" }; @@ -381,11 +381,8 @@ public void ActivateSelectedAudienceProfileRow() var row = AudienceRows[SelectedAudienceRowIndex.Value]; switch (row.Kind) { - case AudienceProfileRowKind.ReadFiles: - ToggleToolGroup(row.Kind, ReadFileTools); - return; - case AudienceProfileRowKind.EditFiles: - ToggleToolGroup(row.Kind, EditFileTools); + case AudienceProfileRowKind.FileTools: + ToggleToolGroup(row.Kind, FileTools); return; case AudienceProfileRowKind.WebAccess: ToggleToolGroup(row.Kind, WebTools); @@ -400,10 +397,10 @@ public void ActivateSelectedAudienceProfileRow() ToggleToolGroup(row.Kind, WorkingDirectoryTools); return; case AudienceProfileRowKind.FileAccess: - CycleFileAccess(); + CycleFileAccess(1); return; case AudienceProfileRowKind.IncomingAttachments: - CycleIncomingAttachments(); + CycleIncomingAttachments(1); return; case AudienceProfileRowKind.McpPermissions: StatusMessage.Value = "Run `netclaw mcp permissions` to edit MCP server and tool grants."; @@ -415,11 +412,43 @@ public void ActivateSelectedAudienceProfileRow() } } + public void ChangeSelectedAudienceProfileRow(int direction) + { + var row = AudienceRows[SelectedAudienceRowIndex.Value]; + switch (row.Kind) + { + case AudienceProfileRowKind.FileAccess: + CycleFileAccess(direction); + return; + case AudienceProfileRowKind.IncomingAttachments: + CycleIncomingAttachments(direction); + return; + } + } + + public string AudienceRowHelp(AudienceProfileRowKind kind) + { + var profile = GetSelectedProfile(); + return kind switch + { + AudienceProfileRowKind.FileTools => "File tools grant read/list/attach/write/edit; File scope below limits where they can operate.", + AudienceProfileRowKind.WebAccess => "Web grants web_search and web_fetch for this audience.", + AudienceProfileRowKind.Skills => "Skills grants skill management and loading tools for this audience.", + AudienceProfileRowKind.Scheduling => "Scheduling grants reminder create/list/cancel/history tools.", + AudienceProfileRowKind.ChangeWorkingDirectory => "Change workspace lets sessions switch workspace roots.", + AudienceProfileRowKind.FileAccess => DescribeFilesystemHelp(profile), + AudienceProfileRowKind.IncomingAttachments => DescribeAttachmentHelp(profile.ChannelAttachments), + AudienceProfileRowKind.McpPermissions => "MCP server and per-tool grants are managed in the dedicated MCP permissions editor.", + AudienceProfileRowKind.ResetToDefault => "Reset overrides restores this audience to the current global posture baseline, including hidden MCP and approval settings.", + _ => string.Empty + }; + } + public void ResetSelectedAudienceProfile() { var profiles = BuildPostureProfiles(CurrentPosture); SaveAudienceProfile(GetProfile(profiles, SelectedAudience)); - StatusMessage.Value = $"{AudienceLabel(SelectedAudience)} profile reset to {CurrentPosture} defaults."; + StatusMessage.Value = $"{AudienceLabel(SelectedAudience)} overrides reset to the {CurrentPosture} posture baseline."; RequestRedraw(); } @@ -490,16 +519,11 @@ private void ToggleToolGroup(AudienceProfileRowKind kind, IReadOnlyList<string> RequestRedraw(); } - private void CycleFileAccess() + private void CycleFileAccess(int direction) { var profiles = LoadAudienceProfiles(); var profile = GetProfile(profiles, SelectedAudience); - var next = CurrentFilesystemLevel(profile) switch - { - FilesystemLevel.Off => FilesystemLevel.SessionOnly, - FilesystemLevel.SessionOnly => FilesystemLevel.AllFiles, - _ => FilesystemLevel.Off - }; + var next = CycleValue(CurrentFilesystemLevel(profile), FilesystemLevelsFor(SelectedAudience), direction); ApplyFilesystemLevel(profile, next); SaveAudienceProfile(profile); @@ -507,17 +531,11 @@ private void CycleFileAccess() RequestRedraw(); } - private void CycleIncomingAttachments() + private void CycleIncomingAttachments(int direction) { var profiles = LoadAudienceProfiles(); var profile = GetProfile(profiles, SelectedAudience); - var next = CurrentAttachmentLevel(profile.ChannelAttachments) switch - { - AttachmentLevel.None => AttachmentLevel.Images, - AttachmentLevel.Images => AttachmentLevel.CommonWorkFiles, - AttachmentLevel.CommonWorkFiles => AttachmentLevel.All, - _ => AttachmentLevel.None - }; + var next = CycleValue(CurrentAttachmentLevel(profile.ChannelAttachments), AttachmentLevels, direction); profile.ChannelAttachments = BuildAttachmentPolicy(next); SaveAudienceProfile(profile); @@ -611,11 +629,19 @@ private static string ReadEnabledFeaturesSummary(Dictionary<string, object> conf private static string ReadAudienceProfilesSummary(Dictionary<string, object> config) { if (!ConfigFileHelper.TryGetPathValue(config, "Tools.AudienceProfiles", out var value) || value is null) - return "Defaults"; + return "No overrides"; var existing = ConvertConfigObject<ToolAudienceProfiles>(value, "Tools.AudienceProfiles"); var defaults = BuildPostureProfiles(ReadPosture(config)); - return JsonEquivalent(existing, defaults) ? "Defaults" : "Customized"; + return JsonEquivalent(existing, defaults) ? "No overrides" : "Customized"; + } + + private bool AudienceHasOverrides(TrustAudience audience) + { + var profiles = LoadAudienceProfiles(); + var current = GetProfile(profiles, audience); + var defaults = GetProfile(BuildPostureProfiles(CurrentPosture), audience); + return !JsonEquivalent(current, defaults); } private static DeploymentPosture ReadPosture(Dictionary<string, object> config) @@ -739,6 +765,14 @@ private static string DescribeFilesystem(ToolAudienceProfile profile) _ => "Session only" }; + private static string DescribeFilesystemHelp(ToolAudienceProfile profile) + => CurrentFilesystemLevel(profile) switch + { + FilesystemLevel.Off => "Off: file tools stay granted, but no filesystem paths are available.", + FilesystemLevel.AllFiles => "All files: unrestricted filesystem scope; intended only for Personal audiences.", + _ => "Session only: file tools stay inside the current session workspace." + }; + private static AttachmentLevel CurrentAttachmentLevel(ChannelAttachmentPolicy? policy) { if (policy is null || policy.AllowedCategories.Count == 0) @@ -770,6 +804,61 @@ private static string DescribeAttachments(ChannelAttachmentPolicy? policy) _ => "Common work files" }; + private static string DescribeAttachmentHelp(ChannelAttachmentPolicy? policy) + => CurrentAttachmentLevel(policy) switch + { + AttachmentLevel.None => "None: inbound channel attachments are rejected.", + AttachmentLevel.Images => "Images: allows image uploads only.", + AttachmentLevel.All => "All attachments: images, PDFs, documents, archives, media, and unknown file types.", + _ => "Common work files: images, PDFs, documents, archives, and media; excludes unknown file types." + }; + + private static readonly FilesystemLevel[] PersonalFilesystemLevels = + [ + FilesystemLevel.Off, + FilesystemLevel.SessionOnly, + FilesystemLevel.AllFiles + ]; + + private static readonly FilesystemLevel[] RestrictedFilesystemLevels = + [ + FilesystemLevel.Off, + FilesystemLevel.SessionOnly + ]; + + private static readonly AttachmentLevel[] AttachmentLevels = + [ + AttachmentLevel.None, + AttachmentLevel.Images, + AttachmentLevel.CommonWorkFiles, + AttachmentLevel.All + ]; + + private static IReadOnlyList<FilesystemLevel> FilesystemLevelsFor(TrustAudience audience) + => audience == TrustAudience.Personal ? PersonalFilesystemLevels : RestrictedFilesystemLevels; + + private static T CycleValue<T>(T current, IReadOnlyList<T> values, int direction) + { + if (values.Count == 0) + return current; + + var index = -1; + for (var i = 0; i < values.Count; i++) + { + if (EqualityComparer<T>.Default.Equals(values[i], current)) + { + index = i; + break; + } + } + + if (index < 0) + index = 0; + + var next = (index + Math.Sign(direction) + values.Count) % values.Count; + return values[next]; + } + private static bool JsonEquivalent<T>(T left, T right) => JsonSerializer.Serialize(left, JsonDefaults.ConfigFile) == JsonSerializer.Serialize(right, JsonDefaults.ConfigFile); diff --git a/src/Netclaw.Cli/Tui/Sections/ConfigEditorSession.cs b/src/Netclaw.Cli/Tui/Sections/ConfigEditorSession.cs index 11cc0a333..ad7ba2861 100644 --- a/src/Netclaw.Cli/Tui/Sections/ConfigEditorSession.cs +++ b/src/Netclaw.Cli/Tui/Sections/ConfigEditorSession.cs @@ -80,11 +80,11 @@ internal static bool ApplySecretActions(Dictionary<string, object> secrets, Sect case SectionSecretActionKind.Preserve: break; case SectionSecretActionKind.Set: - ConfigFileHelper.SetPathValue(secrets, action.Path, action.Value); + SetSecretPathValue(secrets, action.Path, action.Value!); changed = true; break; case SectionSecretActionKind.Delete: - changed |= ConfigFileHelper.RemovePath(secrets, action.Path); + changed |= RemoveSecretPath(secrets, action.Path); break; } } @@ -103,4 +103,94 @@ internal static void ApplyEditorStateActions( private static bool HasUserSecretData(Dictionary<string, object> secrets) => secrets.Keys.Any(static key => !string.Equals(key, "configVersion", StringComparison.Ordinal)); + + private static void SetSecretPathValue(Dictionary<string, object> secrets, string path, object value) + { + var segments = ParseSecretPath(path); + RemoveLiteralCollisionKeys(secrets, segments); + + var current = secrets; + for (var i = 0; i < segments.Length - 1; i++) + current = ConfigFileHelper.GetOrCreateSection(current, segments[i]); + + current[segments[^1]] = value; + } + + private static bool RemoveSecretPath(Dictionary<string, object> secrets, string path) + { + var segments = ParseSecretPath(path); + var changed = RemovePathBySegments(secrets, segments); + changed |= RemoveLiteralCollisionKeys(secrets, segments); + return changed; + } + + private static string[] ParseSecretPath(string path) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + + var segments = path.Split(['.', ':'], StringSplitOptions.None) + .Select(static segment => segment.Trim()) + .ToArray(); + + if (segments.Length == 0 || segments.Any(string.IsNullOrWhiteSpace)) + throw new InvalidOperationException("Secret path must be a non-empty dot or colon-delimited path."); + + return segments; + } + + private static bool RemovePathBySegments(Dictionary<string, object> root, IReadOnlyList<string> segments) + { + var current = root; + for (var i = 0; i < segments.Count - 1; i++) + { + var next = ConfigFileHelper.GetSectionOrNull(current, segments[i]); + if (next is null) + return false; + + current = next; + } + + var removed = current.Remove(segments[^1]); + if (removed) + PruneEmptySections(root, segments); + + return removed; + } + + private static bool RemoveLiteralCollisionKeys(Dictionary<string, object> root, IReadOnlyList<string> segments) + => RemoveLiteralCollisionKeys(root, segments, offset: 0); + + private static bool RemoveLiteralCollisionKeys(Dictionary<string, object> current, IReadOnlyList<string> segments, int offset) + { + var changed = false; + for (var end = offset + 2; end <= segments.Count; end++) + changed |= current.Remove(string.Join(':', segments.Skip(offset).Take(end - offset))); + + if (offset < segments.Count - 1 && ConfigFileHelper.GetSectionOrNull(current, segments[offset]) is { } child) + changed |= RemoveLiteralCollisionKeys(child, segments, offset + 1); + + return changed; + } + + private static void PruneEmptySections(Dictionary<string, object> root, IReadOnlyList<string> segments) + { + for (var depth = segments.Count - 1; depth > 0; depth--) + { + var parent = root; + for (var i = 0; i < depth - 1; i++) + { + var next = ConfigFileHelper.GetSectionOrNull(parent, segments[i]); + if (next is null) + return; + + parent = next; + } + + var key = segments[depth - 1]; + if (ConfigFileHelper.GetSectionOrNull(parent, key) is { Count: 0 }) + parent.Remove(key); + else + return; + } + } } diff --git a/tests/smoke/tapes/config-audience.tape b/tests/smoke/tapes/config-audience.tape index ba8b560f3..3567bd78f 100644 --- a/tests/smoke/tapes/config-audience.tape +++ b/tests/smoke/tapes/config-audience.tape @@ -6,7 +6,7 @@ Output "/tmp/tape-config-audience.gif" -# Seed minimal Team config; Audience Profiles should resolve from posture defaults. +# Seed minimal Team config; Audience Profiles should resolve from the system posture. Type "mkdir -p $NETCLAW_HOME/config" Enter Type "posture=Team; jq -n --arg posture $posture '{configVersion:1,Security:{DeploymentPosture:$posture}}' > $NETCLAW_HOME/config/netclaw.json" @@ -22,15 +22,20 @@ Wait+Screen@10s /Security & Access/ # Open Audience Profiles, edit Team, disable Web access. Down 2 Enter -Wait+Screen@10s /Configure high-level access per audience tier/ +Wait+Screen@10s /System default posture: Team/ +Wait+Screen@10s /\* Team/ Down Enter -Wait+Screen@10s /Tool access for the Team audience/ -Down 2 +Wait+Screen@10s /Audience Profile: Team/ +Down Space -Wait+Screen@10s /\[ \] Web access/ +Wait+Screen@10s /\[ \] Web/ +Down 4 +Wait+Screen@10s /\[◀ Session only/ +Down +Wait+Screen@10s /Common work files: images/ Escape -Wait+Screen@10s /Configure high-level access per audience tier/ +Wait+Screen@10s /System default posture: Team/ Escape Wait+Screen@10s /Security & Access/ Ctrl+Q From f84a367cbcf8cd6e884b34b0211bee5c485d5e6f Mon Sep 17 00:00:00 2001 From: Aaron Stannard <aaron@petabridge.com> Date: Sat, 30 May 2026 14:19:07 +0000 Subject: [PATCH 23/31] refine(config): use Termina back navigation for MCP grants --- docs/ui/TUI-002-netclaw-config-wireframes.md | 2 + .../Mcp/McpToolPermissionsPageTests.cs | 13 ++ .../Mcp/McpToolPermissionsViewModelTests.cs | 16 ++- .../Config/SecurityAccessNavigationTests.cs | 130 ++++++++++++++++++ .../Config/SecurityAccessViewModelTests.cs | 18 +++ .../Mcp/McpToolPermissionsNavigationState.cs | 25 ++++ src/Netclaw.Cli/Mcp/McpToolPermissionsPage.cs | 10 +- .../Mcp/McpToolPermissionsViewModel.cs | 27 +++- src/Netclaw.Cli/Program.cs | 11 +- .../Tui/Config/SecurityAccessViewModel.cs | 12 +- src/Netclaw.Cli/Tui/TuiNavigation.cs | 34 +++++ 11 files changed, 286 insertions(+), 12 deletions(-) create mode 100644 src/Netclaw.Cli.Tests/Tui/Config/SecurityAccessNavigationTests.cs create mode 100644 src/Netclaw.Cli/Mcp/McpToolPermissionsNavigationState.cs create mode 100644 src/Netclaw.Cli/Tui/TuiNavigation.cs diff --git a/docs/ui/TUI-002-netclaw-config-wireframes.md b/docs/ui/TUI-002-netclaw-config-wireframes.md index 150395210..4d17867df 100644 --- a/docs/ui/TUI-002-netclaw-config-wireframes.md +++ b/docs/ui/TUI-002-netclaw-config-wireframes.md @@ -863,6 +863,8 @@ gets a `Customized` override marker: - `Enter` on a checkbox row also toggles (alternative to Space). - `←` / `→` on a cycle row moves backward or forward through curated values. - `Enter` on a cycle row advances to the next curated value. +- `Enter` on `MCP grants` opens the MCP permissions TUI with this audience selected. +- `Esc` from the MCP permissions root returns through Termina history to the launching page. - `Reset overrides` replaces the full underlying audience profile, including hidden MCP and approval settings, with the current posture baseline mapping. diff --git a/src/Netclaw.Cli.Tests/Mcp/McpToolPermissionsPageTests.cs b/src/Netclaw.Cli.Tests/Mcp/McpToolPermissionsPageTests.cs index ad0602a01..09fd0aea6 100644 --- a/src/Netclaw.Cli.Tests/Mcp/McpToolPermissionsPageTests.cs +++ b/src/Netclaw.Cli.Tests/Mcp/McpToolPermissionsPageTests.cs @@ -300,6 +300,19 @@ public async Task ToolGrid_RightArrowOnServerEnabledRow_TogglesServerAccess() Assert.NotEqual(wasBefore, vm.IsServerAllowedForSelectedAudience()); } + [Fact] + public async Task Loading_Escape_QuitsInsteadOfStalling() + { + var (_, app, vm) = CreateHeadlessApp(out var input); + + input.EnqueueKey(ConsoleKey.Escape); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await app.RunAsync(cts.Token); + + Assert.Equal(ToolPermissionsState.Loading, vm.CurrentState.Value); + } + // ── Helpers ────────────────────────────────────────────────────────────── private (VirtualTerminal Terminal, TerminaApplication App, McpToolPermissionsViewModel Vm) diff --git a/src/Netclaw.Cli.Tests/Mcp/McpToolPermissionsViewModelTests.cs b/src/Netclaw.Cli.Tests/Mcp/McpToolPermissionsViewModelTests.cs index d1c02432f..fa2e6e836 100644 --- a/src/Netclaw.Cli.Tests/Mcp/McpToolPermissionsViewModelTests.cs +++ b/src/Netclaw.Cli.Tests/Mcp/McpToolPermissionsViewModelTests.cs @@ -28,11 +28,23 @@ public McpToolPermissionsViewModelTests() public void Dispose() => _dir.Dispose(); - private McpToolPermissionsViewModel CreateVm() + private McpToolPermissionsViewModel CreateVm(McpToolPermissionsNavigationState? navigationState = null) { var configuration = new ConfigurationBuilder().Build(); var daemonApi = new DaemonApi(new NoopHttpClientFactory(), configuration, _paths); - return new McpToolPermissionsViewModel(_paths, daemonApi); + return new McpToolPermissionsViewModel(_paths, daemonApi, navigationState); + } + + [Fact] + public void InitializeForTests_AppliesRequestedInitialAudience() + { + var navigationState = new McpToolPermissionsNavigationState(); + navigationState.RequestInitialAudience(TrustAudience.Team); + var vm = CreateVm(navigationState); + + vm.InitializeForTests(new McpServerName("notion"), new[] { "create-pages" }); + + Assert.Equal(TrustAudience.Team, vm.SelectedAudience); } [Fact] diff --git a/src/Netclaw.Cli.Tests/Tui/Config/SecurityAccessNavigationTests.cs b/src/Netclaw.Cli.Tests/Tui/Config/SecurityAccessNavigationTests.cs new file mode 100644 index 000000000..55eac99db --- /dev/null +++ b/src/Netclaw.Cli.Tests/Tui/Config/SecurityAccessNavigationTests.cs @@ -0,0 +1,130 @@ +// ----------------------------------------------------------------------- +// <copyright file="SecurityAccessNavigationTests.cs" company="Petabridge, LLC"> +// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com> +// </copyright> +// ----------------------------------------------------------------------- +using System.Net.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Netclaw.Cli.Daemon; +using Netclaw.Cli.Mcp; +using Netclaw.Cli.Tui; +using Netclaw.Cli.Tui.Config; +using Netclaw.Configuration; +using Netclaw.Tests.Utilities; +using Termina; +using Termina.Hosting; +using Termina.Input; +using Termina.Terminal; +using Xunit; + +namespace Netclaw.Cli.Tests.Tui.Config; + +public sealed class SecurityAccessNavigationTests : IDisposable +{ + private readonly DisposableTempDir _dir = new(); + private readonly NetclawPaths _paths; + + public SecurityAccessNavigationTests() + { + _paths = new NetclawPaths(_dir.Path); + _paths.EnsureDirectoriesExist(); + } + + public void Dispose() => _dir.Dispose(); + + [Fact] + public async Task McpGrants_Escape_ReturnsToSecurityUsingTerminaHistory() + { + File.WriteAllText(_paths.NetclawConfigPath, + """ + { + "configVersion": 1, + "Security": { "DeploymentPosture": "Team" } + } + """); + + var app = CreateHeadlessApp(out var input, out var securityVm, out var getMcpVm, out var navigation); + securityVm.SelectedAudienceIndex.Value = 1; + securityVm.OpenSelectedAudienceProfile(); + securityVm.SelectedAudienceRowIndex.Value = (int)AudienceProfileRowKind.McpPermissions; + + securityVm.ActivateSelectedAudienceProfileRow(); + input.EnqueueKey(ConsoleKey.Escape); + input.EnqueueKey(ConsoleKey.Q, false, false, true); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await app.RunAsync(cts.Token); + + var mcpVm = Assert.IsType<McpToolPermissionsViewModel>(getMcpVm()); + Assert.Equal("/security", app.CurrentPath); + Assert.Equal(TrustAudience.Team, mcpVm.SelectedAudience); + Assert.Equal(1, navigation.BackRequestsForTests); + } + + private TerminaApplication CreateHeadlessApp( + out VirtualInputSource input, + out SecurityAccessViewModel securityVm, + out Func<McpToolPermissionsViewModel?> getMcpVm, + out TuiNavigation navigation) + { + var terminal = new VirtualTerminal(120, 40); + var virtualInput = new VirtualInputSource(); + input = virtualInput; + + var navigationState = new McpToolPermissionsNavigationState(); + var tuiNavigation = new TuiNavigation(); + SecurityAccessViewModel? capturedSecurityVm = null; + McpToolPermissionsViewModel? capturedMcpVm = null; + + var configuration = new ConfigurationBuilder().Build(); + var daemonApi = new DaemonApi(new FailingHttpClientFactory(), configuration, _paths); + + var services = new ServiceCollection(); + services.AddSingleton<IAnsiTerminal>(terminal); + services.AddSingleton(navigationState); + services.AddSingleton(tuiNavigation); + services.AddTerminaVirtualInput(virtualInput); + services.AddTermina("/security", builder => + { + builder.RegisterRoute<SecurityAccessPage, SecurityAccessViewModel>( + "/security", + _ => new SecurityAccessPage(), + _ => + { + capturedSecurityVm = new SecurityAccessViewModel(_paths, navigationState); + return capturedSecurityVm; + }); + builder.RegisterRoute<McpToolPermissionsPage, McpToolPermissionsViewModel>( + "/mcp-tools", + _ => new McpToolPermissionsPage(), + _ => + { + capturedMcpVm = new McpToolPermissionsViewModel(_paths, daemonApi, navigationState, tuiNavigation); + return capturedMcpVm; + }); + }); + + var sp = services.BuildServiceProvider(); + var app = sp.GetRequiredService<TerminaApplication>(); + tuiNavigation.Attach(app); + + securityVm = capturedSecurityVm!; + getMcpVm = () => capturedMcpVm; + navigation = tuiNavigation; + return app; + } + + private sealed class FailingHttpHandler : HttpMessageHandler + { + protected override Task<HttpResponseMessage> SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + => throw new HttpRequestException("Test: no daemon available"); + } + + private sealed class FailingHttpClientFactory : IHttpClientFactory + { + public HttpClient CreateClient(string name) => new(new FailingHttpHandler()); + } +} diff --git a/src/Netclaw.Cli.Tests/Tui/Config/SecurityAccessViewModelTests.cs b/src/Netclaw.Cli.Tests/Tui/Config/SecurityAccessViewModelTests.cs index 48dd0be42..1858aa29f 100644 --- a/src/Netclaw.Cli.Tests/Tui/Config/SecurityAccessViewModelTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/Config/SecurityAccessViewModelTests.cs @@ -4,6 +4,7 @@ // </copyright> // ----------------------------------------------------------------------- using Netclaw.Cli.Config; +using Netclaw.Cli.Mcp; using Netclaw.Cli.Tui.Config; using Netclaw.Cli.Tests.Tui.Wizard; using Netclaw.Configuration; @@ -174,6 +175,23 @@ public void Audience_profile_file_scope_cycle_keeps_team_scope_restricted() Assert.Equal("Roots", attachMode); } + [Fact] + public void Audience_profile_mcp_grants_routes_to_permissions_for_selected_audience() + { + var navigationState = new McpToolPermissionsNavigationState(); + using var vm = new SecurityAccessViewModel(Context.Paths, navigationState); + string? route = null; + vm.RouteRequested = value => route = value; + vm.SelectedAudienceIndex.Value = 1; + vm.OpenSelectedAudienceProfile(); + vm.SelectedAudienceRowIndex.Value = (int)AudienceProfileRowKind.McpPermissions; + + vm.ActivateSelectedAudienceProfileRow(); + + Assert.Equal("/mcp-tools", route); + Assert.Equal(TrustAudience.Team, navigationState.ConsumeInitialAudience()); + } + [Fact] public void Enabled_features_summary_treats_missing_flags_as_enabled() { diff --git a/src/Netclaw.Cli/Mcp/McpToolPermissionsNavigationState.cs b/src/Netclaw.Cli/Mcp/McpToolPermissionsNavigationState.cs new file mode 100644 index 000000000..8a32a881e --- /dev/null +++ b/src/Netclaw.Cli/Mcp/McpToolPermissionsNavigationState.cs @@ -0,0 +1,25 @@ +// ----------------------------------------------------------------------- +// <copyright file="McpToolPermissionsNavigationState.cs" company="Petabridge, LLC"> +// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com> +// </copyright> +// ----------------------------------------------------------------------- +using Netclaw.Configuration; + +namespace Netclaw.Cli.Mcp; + +public sealed class McpToolPermissionsNavigationState +{ + private TrustAudience? _initialAudience; + + public void RequestInitialAudience(TrustAudience audience) + { + _initialAudience = audience; + } + + public TrustAudience? ConsumeInitialAudience() + { + var audience = _initialAudience; + _initialAudience = null; + return audience; + } +} diff --git a/src/Netclaw.Cli/Mcp/McpToolPermissionsPage.cs b/src/Netclaw.Cli/Mcp/McpToolPermissionsPage.cs index 6f3ab298f..ca3833314 100644 --- a/src/Netclaw.Cli/Mcp/McpToolPermissionsPage.cs +++ b/src/Netclaw.Cli/Mcp/McpToolPermissionsPage.cs @@ -282,14 +282,16 @@ private void HandleKeyPress(KeyPressed key) if (keyInfo.Key == ConsoleKey.Escape) { - if (ViewModel.CurrentState.Value == ToolPermissionsState.ServerList) + if (ViewModel.CurrentState.Value == ToolPermissionsState.ToolGrid) + { + _gridCursor = 0; + ViewModel.GoBack(); + } + else { ViewModel.RequestQuit(); - return; } - _gridCursor = 0; - ViewModel.GoBack(); return; } diff --git a/src/Netclaw.Cli/Mcp/McpToolPermissionsViewModel.cs b/src/Netclaw.Cli/Mcp/McpToolPermissionsViewModel.cs index 1e8f8905c..db9dba466 100644 --- a/src/Netclaw.Cli/Mcp/McpToolPermissionsViewModel.cs +++ b/src/Netclaw.Cli/Mcp/McpToolPermissionsViewModel.cs @@ -8,6 +8,7 @@ using Netclaw.Cli.Config; using Netclaw.Cli.Daemon; using Netclaw.Cli.Json; +using Netclaw.Cli.Tui; using Netclaw.Configuration; using Netclaw.Tools; using R3; @@ -27,11 +28,19 @@ public sealed class McpToolPermissionsViewModel : ReactiveViewModel { private readonly NetclawPaths _paths; private readonly DaemonApi _daemonApi; + private readonly McpToolPermissionsNavigationState? _navigationState; + private readonly TuiNavigation? _navigation; - public McpToolPermissionsViewModel(NetclawPaths paths, DaemonApi daemonApi) + public McpToolPermissionsViewModel( + NetclawPaths paths, + DaemonApi daemonApi, + McpToolPermissionsNavigationState? navigationState = null, + TuiNavigation? navigation = null) { _paths = paths; _daemonApi = daemonApi; + _navigationState = navigationState; + _navigation = navigation; } public ReactiveProperty<ToolPermissionsState> CurrentState { get; } = new(ToolPermissionsState.Loading); @@ -68,6 +77,7 @@ public McpToolPermissionsViewModel(NetclawPaths paths, DaemonApi daemonApi) public override void OnActivated() { base.OnActivated(); + ApplyPendingNavigationState(); _ = LoadServersAsync(); } @@ -120,6 +130,7 @@ public void SelectServer(McpServerName serverName) /// </summary> internal void InitializeForTests(McpServerName serverName, IEnumerable<string> tools) { + ApplyPendingNavigationState(); SelectedServer = serverName.Value; DiscoveredTools.Clear(); DiscoveredTools.AddRange(tools); @@ -640,7 +651,13 @@ private static bool IsServerAllowed(McpServerName serverName, ToolAudienceProfil _ => "Personal" }; - public void RequestQuit() => Shutdown(); + public void RequestQuit() + { + if (_navigation?.TryGoBack() == true) + return; + + Shutdown(); + } public void GoBack() { @@ -664,6 +681,12 @@ private void NotifyStateChanged() RequestRedraw(); } + private void ApplyPendingNavigationState() + { + if (_navigationState?.ConsumeInitialAudience() is { } audience) + SelectedAudience = audience; + } + private ToolConfig LoadToolConfig() { if (!File.Exists(_paths.NetclawConfigPath)) diff --git a/src/Netclaw.Cli/Program.cs b/src/Netclaw.Cli/Program.cs index ad970d424..9668b63b6 100644 --- a/src/Netclaw.Cli/Program.cs +++ b/src/Netclaw.Cli/Program.cs @@ -653,6 +653,8 @@ static async Task RunAsync(string[] args) ConfigureConfigServices(builder.Services, builder.Configuration); builder.Logging.ClearProviders(); builder.Logging.SetMinimumLevel(LogLevel.Warning); + builder.Services.AddSingleton<McpToolPermissionsNavigationState>(); + builder.Services.AddSingleton<TuiNavigation>(); var traceFile = Path.Combine(Path.GetTempPath(), "netclaw-mcp-tools-trace.log"); builder.Services.AddTerminaFileTracing(traceFile, TerminaTraceCategory.All, TerminaTraceLevel.Trace); @@ -872,6 +874,8 @@ static async Task RunAsync(string[] args) ConfigureConfigServices(builder.Services, builder.Configuration); builder.Services.AddSingleton(configPaths); builder.Services.AddSingleton(new ConfigDashboardNavigationState()); + builder.Services.AddSingleton<McpToolPermissionsNavigationState>(); + builder.Services.AddSingleton<TuiNavigation>(); builder.Services.AddProviderDescriptors(); builder.Services.AddHttpClient("OAuthDeviceFlow"); builder.Services.AddSingleton(sp => @@ -901,6 +905,7 @@ static async Task RunAsync(string[] args) t.RegisterRoute<SearchConfigEditorPage, SearchConfigEditorViewModel>("/search", Termina.Pages.NavigationBehavior.PreserveState); t.RegisterRoute<SecurityAccessPage, SecurityAccessViewModel>("/security"); t.RegisterRoute<ExposureModeConfigPage, ExposureModeConfigViewModel>("/exposure-mode"); + t.RegisterRoute<McpToolPermissionsPage, McpToolPermissionsViewModel>("/mcp-tools"); }); using var host = builder.Build(); @@ -1127,7 +1132,11 @@ static void WriteCrashLog(Exception ex) // Non-Termina hosts (headless mode) carry no TerminaApplication and run unguarded. static async Task RunTerminaHostAsync(IHost host) { - if (host.Services.GetService<TerminaApplication>() is not null && Console.IsInputRedirected) + var terminaApplication = host.Services.GetService<TerminaApplication>(); + if (terminaApplication is not null) + host.Services.GetService<TuiNavigation>()?.Attach(terminaApplication); + + if (terminaApplication is not null && Console.IsInputRedirected) { Console.Error.WriteLine( "netclaw: this command is an interactive terminal UI and needs a TTY (stdin is redirected)."); diff --git a/src/Netclaw.Cli/Tui/Config/SecurityAccessViewModel.cs b/src/Netclaw.Cli/Tui/Config/SecurityAccessViewModel.cs index d45dabf9e..c5abd18bb 100644 --- a/src/Netclaw.Cli/Tui/Config/SecurityAccessViewModel.cs +++ b/src/Netclaw.Cli/Tui/Config/SecurityAccessViewModel.cs @@ -6,6 +6,7 @@ using System.Text.Json; using Netclaw.Cli.Config; using Netclaw.Cli.Json; +using Netclaw.Cli.Mcp; using Netclaw.Cli.Tui.Sections; using Netclaw.Cli.Tui.Wizard.Steps; using Netclaw.Configuration; @@ -111,12 +112,16 @@ public sealed class SecurityAccessViewModel : ReactiveViewModel ]; private readonly NetclawPaths _paths; + private readonly McpToolPermissionsNavigationState? _mcpNavigationState; private readonly bool[] _enabledFeatures = new bool[FeatureCount]; private DeploymentPosture? _pendingPosture; - public SecurityAccessViewModel(NetclawPaths paths) + public SecurityAccessViewModel( + NetclawPaths paths, + McpToolPermissionsNavigationState? mcpNavigationState = null) { _paths = paths; + _mcpNavigationState = mcpNavigationState; LoadEnabledFeatures(); } @@ -403,8 +408,9 @@ public void ActivateSelectedAudienceProfileRow() CycleIncomingAttachments(1); return; case AudienceProfileRowKind.McpPermissions: - StatusMessage.Value = "Run `netclaw mcp permissions` to edit MCP server and tool grants."; - RequestRedraw(); + _mcpNavigationState?.RequestInitialAudience(SelectedAudience); + RouteRequested?.Invoke("/mcp-tools"); + Navigate?.Invoke("/mcp-tools"); return; case AudienceProfileRowKind.ResetToDefault: ResetSelectedAudienceProfile(); diff --git a/src/Netclaw.Cli/Tui/TuiNavigation.cs b/src/Netclaw.Cli/Tui/TuiNavigation.cs new file mode 100644 index 000000000..b3d5d198c --- /dev/null +++ b/src/Netclaw.Cli/Tui/TuiNavigation.cs @@ -0,0 +1,34 @@ +// ----------------------------------------------------------------------- +// <copyright file="TuiNavigation.cs" company="Petabridge, LLC"> +// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com> +// </copyright> +// ----------------------------------------------------------------------- +using Termina; + +namespace Netclaw.Cli.Tui; + +public sealed class TuiNavigation +{ + private TerminaApplication? _application; + + internal int BackRequestsForTests { get; private set; } + + public void Attach(TerminaApplication application) + { + ArgumentNullException.ThrowIfNull(application); + _application = application; + } + + public bool TryGoBack() + { + BackRequestsForTests++; + if (_application is null) + throw new InvalidOperationException("TUI navigation was requested before TerminaApplication was attached."); + + if (!_application.CanGoBack) + return false; + + _application.GoBack(); + return true; + } +} From f295d36774427186bbbcf523aeda9c10d1319e90 Mon Sep 17 00:00:00 2001 From: Aaron Stannard <aaron@petabridge.com> Date: Sat, 30 May 2026 16:08:29 +0000 Subject: [PATCH 24/31] refine(config): harden security access editors --- .../Tools/ToolAudienceProfileResolver.cs | 18 +- .../Mcp/McpToolPermissionsViewModelTests.cs | 231 ++++++++++++------ .../Config/SecurityAccessNavigationTests.cs | 7 +- .../Config/SecurityAccessViewModelTests.cs | 34 +++ .../Mcp/McpToolPermissionsViewModel.cs | 163 +++++++----- .../Tui/Config/SecurityAccessViewModel.cs | 44 +--- src/Netclaw.Cli/Tui/TuiNavigation.cs | 3 - .../ToolAudienceProfileDefaultsTests.cs | 120 ++++++--- .../ToolAudienceProfiles.cs | 60 ++++- 9 files changed, 434 insertions(+), 246 deletions(-) diff --git a/src/Netclaw.Actors/Tools/ToolAudienceProfileResolver.cs b/src/Netclaw.Actors/Tools/ToolAudienceProfileResolver.cs index 9ecb47534..151c9d01d 100644 --- a/src/Netclaw.Actors/Tools/ToolAudienceProfileResolver.cs +++ b/src/Netclaw.Actors/Tools/ToolAudienceProfileResolver.cs @@ -173,21 +173,5 @@ private static bool IsMcpToolAllowed(McpServerName serverName, ToolName toolName } private static bool IsProfileManagedTool(ToolName toolName) - => toolName.Value is "shell_execute" - or "file_read" - or "file_write" - or "file_edit" - or "file_list" - or "attach_file" - or "web_search" - or "web_fetch" - or "skill_manage" - or "set_webhook" - or "list_webhooks" - or "delete_webhook" - or "set_reminder" - or "list_reminders" - or "cancel_reminder" - or "get_reminder_history" - or "set_working_directory"; + => ToolAudienceProfileToolCatalog.IsProfileManaged(toolName.Value); } diff --git a/src/Netclaw.Cli.Tests/Mcp/McpToolPermissionsViewModelTests.cs b/src/Netclaw.Cli.Tests/Mcp/McpToolPermissionsViewModelTests.cs index fa2e6e836..93f096971 100644 --- a/src/Netclaw.Cli.Tests/Mcp/McpToolPermissionsViewModelTests.cs +++ b/src/Netclaw.Cli.Tests/Mcp/McpToolPermissionsViewModelTests.cs @@ -48,51 +48,65 @@ public void InitializeForTests_AppliesRequestedInitialAudience() } [Fact] - public void CycleServerDefault_StartingFromAuto_LandsOnDenyAfterTwoCycles() + public void InitializeForTests_ThrowsForMalformedConfig() { var vm = CreateVm(); - vm.InitializeForTests(new McpServerName("notion"), new[] { "create-pages", "search" }); - vm.SetSelectedAudienceForTests(TrustAudience.Personal); + File.WriteAllText(_paths.NetclawConfigPath, "{ not json"); - vm.CycleServerDefault(); - Assert.Equal(ToolApprovalMode.Approval, vm.GetServerDefault()); + Assert.ThrowsAny<JsonException>(() => + vm.InitializeForTests(new McpServerName("notion"), new[] { "create-pages" })); + } - vm.CycleServerDefault(); - Assert.Equal(ToolApprovalMode.Deny, vm.GetServerDefault()); + public static TheoryData<bool, ToolApprovalMode[]> ServerDefaultCycles => new() + { + { false, [ToolApprovalMode.Approval, ToolApprovalMode.Deny, ToolApprovalMode.Auto] }, + { true, [ToolApprovalMode.Deny, ToolApprovalMode.Approval, ToolApprovalMode.Auto] } + }; - vm.CycleServerDefault(); - Assert.Equal(ToolApprovalMode.Auto, vm.GetServerDefault()); - } + public static TheoryData<bool, ToolApprovalMode[]> ToolOverrideCycles => new() + { + { false, [ToolApprovalMode.Auto, ToolApprovalMode.Approval, ToolApprovalMode.Deny] }, + { true, [ToolApprovalMode.Deny, ToolApprovalMode.Approval, ToolApprovalMode.Auto] } + }; - [Fact] - public void CycleToolOverride_FromInherit_CyclesThroughAllModes() + [Theory] + [MemberData(nameof(ServerDefaultCycles))] + public void CycleServerDefault_CyclesThroughModes(bool reverse, ToolApprovalMode[] expectedModes) { var vm = CreateVm(); - vm.InitializeForTests(new McpServerName("notion"), new[] { "create-pages" }); + vm.InitializeForTests(new McpServerName("notion"), new[] { "create-pages", "search" }); vm.SetSelectedAudienceForTests(TrustAudience.Personal); - // Initial: inherit (effective mode resolves from server default / global default). - var (_, isInherited) = vm.GetEffectiveMode(new ToolName("create-pages")); - Assert.True(isInherited); + foreach (var expectedMode in expectedModes) + { + CycleServerDefault(vm, reverse); + Assert.Equal(expectedMode, vm.GetServerDefault()); + } + } - vm.CycleToolOverride(new ToolName("create-pages")); - var step1 = vm.GetEffectiveMode(new ToolName("create-pages")); - Assert.Equal(ToolApprovalMode.Auto, step1.Mode); - Assert.False(step1.IsInherited); + [Theory] + [MemberData(nameof(ToolOverrideCycles))] + public void CycleToolOverride_CyclesThroughModes(bool reverse, ToolApprovalMode[] expectedModes) + { + var vm = CreateVm(); + var toolName = new ToolName("create-pages"); + vm.InitializeForTests(new McpServerName("notion"), new[] { toolName.Value }); + vm.SetSelectedAudienceForTests(TrustAudience.Personal); - vm.CycleToolOverride(new ToolName("create-pages")); - var step2 = vm.GetEffectiveMode(new ToolName("create-pages")); - Assert.Equal(ToolApprovalMode.Approval, step2.Mode); - Assert.False(step2.IsInherited); + var (_, isInherited) = vm.GetEffectiveMode(toolName); + Assert.True(isInherited); - vm.CycleToolOverride(new ToolName("create-pages")); - var step3 = vm.GetEffectiveMode(new ToolName("create-pages")); - Assert.Equal(ToolApprovalMode.Deny, step3.Mode); - Assert.False(step3.IsInherited); + foreach (var expectedMode in expectedModes) + { + CycleToolOverride(vm, toolName, reverse); + var step = vm.GetEffectiveMode(toolName); + Assert.Equal(expectedMode, step.Mode); + Assert.False(step.IsInherited); + } - vm.CycleToolOverride(new ToolName("create-pages")); - var step4 = vm.GetEffectiveMode(new ToolName("create-pages")); - Assert.True(step4.IsInherited); + CycleToolOverride(vm, toolName, reverse); + var final = vm.GetEffectiveMode(toolName); + Assert.True(final.IsInherited); } [Fact] @@ -117,7 +131,7 @@ public void Save_WritesServerDefaultsAndOverridesAndRemovesInheritedEntries() vm.Save(); - var doc = JsonDocument.Parse(File.ReadAllText(_paths.NetclawConfigPath)); + using var doc = JsonDocument.Parse(File.ReadAllText(_paths.NetclawConfigPath)); var approvalPolicy = doc.RootElement .GetProperty("Tools") .GetProperty("AudienceProfiles") @@ -168,53 +182,6 @@ public void GetEffectiveMode_ReadsExistingMcpServerDefaultsFromConfig() Assert.Equal(ToolApprovalMode.Approval, vm.GetServerDefault()); } - [Fact] - public void CycleServerDefaultBack_StartingFromAuto_CyclesInReverse() - { - var vm = CreateVm(); - vm.InitializeForTests(new McpServerName("notion"), new[] { "create-pages", "search" }); - vm.SetSelectedAudienceForTests(TrustAudience.Personal); - - vm.CycleServerDefaultBack(); - Assert.Equal(ToolApprovalMode.Deny, vm.GetServerDefault()); - - vm.CycleServerDefaultBack(); - Assert.Equal(ToolApprovalMode.Approval, vm.GetServerDefault()); - - vm.CycleServerDefaultBack(); - Assert.Equal(ToolApprovalMode.Auto, vm.GetServerDefault()); - } - - [Fact] - public void CycleToolOverrideBack_FromInherit_CyclesThroughAllModesInReverse() - { - var vm = CreateVm(); - vm.InitializeForTests(new McpServerName("notion"), new[] { "create-pages" }); - vm.SetSelectedAudienceForTests(TrustAudience.Personal); - - var (_, isInherited) = vm.GetEffectiveMode(new ToolName("create-pages")); - Assert.True(isInherited); - - vm.CycleToolOverrideBack(new ToolName("create-pages")); - var step1 = vm.GetEffectiveMode(new ToolName("create-pages")); - Assert.Equal(ToolApprovalMode.Deny, step1.Mode); - Assert.False(step1.IsInherited); - - vm.CycleToolOverrideBack(new ToolName("create-pages")); - var step2 = vm.GetEffectiveMode(new ToolName("create-pages")); - Assert.Equal(ToolApprovalMode.Approval, step2.Mode); - Assert.False(step2.IsInherited); - - vm.CycleToolOverrideBack(new ToolName("create-pages")); - var step3 = vm.GetEffectiveMode(new ToolName("create-pages")); - Assert.Equal(ToolApprovalMode.Auto, step3.Mode); - Assert.False(step3.IsInherited); - - vm.CycleToolOverrideBack(new ToolName("create-pages")); - var step4 = vm.GetEffectiveMode(new ToolName("create-pages")); - Assert.True(step4.IsInherited); - } - [Fact] public void CycleToolOverride_ForwardThenBack_ReturnsToOriginalState() { @@ -266,6 +233,112 @@ public void ToggleServerAccess_DisablingClearsGrantedTools() Assert.False(vm.IsToolGranted(new ToolName(tool))); } + [Fact] + public void Save_DisablingServerFromAllowlistPreservesOtherServers() + { + File.WriteAllText(_paths.NetclawConfigPath, + """ + { + "configVersion": 1, + "Tools": { + "AudienceProfiles": { + "Team": { + "McpServersMode": "Allowlist", + "AllowedMcpServers": ["notion", "github"] + } + } + } + } + """); + + var vm = CreateVm(); + vm.Servers.Add(("notion", "running", 1)); + vm.Servers.Add(("github", "running", 1)); + vm.InitializeForTests(new McpServerName("notion"), new[] { "create-pages" }); + vm.SetSelectedAudienceForTests(TrustAudience.Team); + + vm.ToggleServerAccess(); + Assert.True(vm.Save()); + + using var doc = JsonDocument.Parse(File.ReadAllText(_paths.NetclawConfigPath)); + var team = GetAudienceProfile(doc, "Team"); + Assert.Equal("Allowlist", team.GetProperty("McpServersMode").GetString()); + + var servers = ReadAllowedServers(team); + Assert.DoesNotContain("notion", servers); + Assert.Contains("github", servers); + } + + [Fact] + public void Save_DisablingServerFromAllProfileConvertsToAllowlist() + { + File.WriteAllText(_paths.NetclawConfigPath, + """ + { + "configVersion": 1, + "McpServers": { + "github": { "Transport": "stdio" } + }, + "Tools": { + "AudienceProfiles": { + "Personal": { + "McpServersMode": "All" + } + } + } + } + """); + + var vm = CreateVm(); + vm.Servers.Add(("notion", "running", 1)); + vm.InitializeForTests(new McpServerName("notion"), new[] { "create-pages" }); + vm.SetSelectedAudienceForTests(TrustAudience.Personal); + + vm.ToggleServerAccess(); + Assert.True(vm.Save()); + + using var doc = JsonDocument.Parse(File.ReadAllText(_paths.NetclawConfigPath)); + var personal = GetAudienceProfile(doc, "Personal"); + Assert.Equal("Allowlist", personal.GetProperty("McpServersMode").GetString()); + + var servers = ReadAllowedServers(personal); + Assert.DoesNotContain("notion", servers); + Assert.Contains("github", servers); + + var reloaded = CreateVm(); + reloaded.InitializeForTests(new McpServerName("notion"), new[] { "create-pages" }); + reloaded.SetSelectedAudienceForTests(TrustAudience.Personal); + Assert.False(reloaded.IsServerAllowedForSelectedAudience()); + } + + private static void CycleServerDefault(McpToolPermissionsViewModel vm, bool reverse) + { + if (reverse) + vm.CycleServerDefaultBack(); + else + vm.CycleServerDefault(); + } + + private static void CycleToolOverride(McpToolPermissionsViewModel vm, ToolName toolName, bool reverse) + { + if (reverse) + vm.CycleToolOverrideBack(toolName); + else + vm.CycleToolOverride(toolName); + } + + private static JsonElement GetAudienceProfile(JsonDocument doc, string audienceName) + => doc.RootElement + .GetProperty("Tools") + .GetProperty("AudienceProfiles") + .GetProperty(audienceName); + + private static string[] ReadAllowedServers(JsonElement profile) + => profile.GetProperty("AllowedMcpServers") + .EnumerateArray() + .Select(static server => server.GetString() ?? string.Empty) + .ToArray(); + private sealed class NoopHttpClientFactory : IHttpClientFactory { public HttpClient CreateClient(string name) => new(); diff --git a/src/Netclaw.Cli.Tests/Tui/Config/SecurityAccessNavigationTests.cs b/src/Netclaw.Cli.Tests/Tui/Config/SecurityAccessNavigationTests.cs index 55eac99db..79cacda1e 100644 --- a/src/Netclaw.Cli.Tests/Tui/Config/SecurityAccessNavigationTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/Config/SecurityAccessNavigationTests.cs @@ -44,7 +44,7 @@ public async Task McpGrants_Escape_ReturnsToSecurityUsingTerminaHistory() } """); - var app = CreateHeadlessApp(out var input, out var securityVm, out var getMcpVm, out var navigation); + var app = CreateHeadlessApp(out var input, out var securityVm, out var getMcpVm); securityVm.SelectedAudienceIndex.Value = 1; securityVm.OpenSelectedAudienceProfile(); securityVm.SelectedAudienceRowIndex.Value = (int)AudienceProfileRowKind.McpPermissions; @@ -59,14 +59,12 @@ public async Task McpGrants_Escape_ReturnsToSecurityUsingTerminaHistory() var mcpVm = Assert.IsType<McpToolPermissionsViewModel>(getMcpVm()); Assert.Equal("/security", app.CurrentPath); Assert.Equal(TrustAudience.Team, mcpVm.SelectedAudience); - Assert.Equal(1, navigation.BackRequestsForTests); } private TerminaApplication CreateHeadlessApp( out VirtualInputSource input, out SecurityAccessViewModel securityVm, - out Func<McpToolPermissionsViewModel?> getMcpVm, - out TuiNavigation navigation) + out Func<McpToolPermissionsViewModel?> getMcpVm) { var terminal = new VirtualTerminal(120, 40); var virtualInput = new VirtualInputSource(); @@ -111,7 +109,6 @@ private TerminaApplication CreateHeadlessApp( securityVm = capturedSecurityVm!; getMcpVm = () => capturedMcpVm; - navigation = tuiNavigation; return app; } diff --git a/src/Netclaw.Cli.Tests/Tui/Config/SecurityAccessViewModelTests.cs b/src/Netclaw.Cli.Tests/Tui/Config/SecurityAccessViewModelTests.cs index 1858aa29f..918a24474 100644 --- a/src/Netclaw.Cli.Tests/Tui/Config/SecurityAccessViewModelTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/Config/SecurityAccessViewModelTests.cs @@ -117,6 +117,40 @@ public void Audience_profile_toggle_updates_selected_profile_only() Assert.Equal("Customized overrides", vm.SelectedAudienceOverrideStatus); } + [Fact] + public void Audience_profile_toggle_from_all_mode_materializes_profile_managed_allowlist() + { + File.WriteAllText(Context.Paths.NetclawConfigPath, + """ + { + "configVersion": 1, + "Security": { "DeploymentPosture": "Personal" } + } + """); + + using var vm = new SecurityAccessViewModel(Context.Paths); + vm.SelectedAudienceIndex.Value = 0; + vm.OpenSelectedAudienceProfile(); + vm.SelectedAudienceRowIndex.Value = (int)AudienceProfileRowKind.WebAccess; + + vm.ActivateSelectedAudienceProfileRow(); + + var config = ConfigFileHelper.LoadJsonDict(Context.Paths.NetclawConfigPath); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Tools.AudienceProfiles.Personal.ToolsMode", out var mode)); + Assert.Equal("Allowlist", mode); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Tools.AudienceProfiles.Personal.AllowedTools", out var tools)); + var allowedTools = Assert.IsAssignableFrom<object[]>(tools).Select(static tool => tool?.ToString() ?? string.Empty).ToArray(); + var expected = ToolAudienceProfileToolCatalog.ProfileManagedTools + .Except(ToolAudienceProfileToolCatalog.WebTools) + .ToArray(); + + Assert.Equal(expected, allowedTools); + Assert.Contains(ToolAudienceProfileToolCatalog.ShellExecute, allowedTools); + Assert.Contains(ToolAudienceProfileToolCatalog.SetWebhook, allowedTools); + Assert.DoesNotContain(ToolAudienceProfileToolCatalog.WebSearch, allowedTools); + Assert.DoesNotContain(ToolAudienceProfileToolCatalog.WebFetch, allowedTools); + } + [Fact] public void Audience_profiles_summary_reports_overrides_not_defaults() { diff --git a/src/Netclaw.Cli/Mcp/McpToolPermissionsViewModel.cs b/src/Netclaw.Cli/Mcp/McpToolPermissionsViewModel.cs index db9dba466..864598e67 100644 --- a/src/Netclaw.Cli/Mcp/McpToolPermissionsViewModel.cs +++ b/src/Netclaw.Cli/Mcp/McpToolPermissionsViewModel.cs @@ -85,33 +85,44 @@ private async Task LoadServersAsync() { StatusMessage.Value = "Loading MCP server statuses..."; + JsonElement statuses; try { - var statuses = await _daemonApi.GetMcpServerStatusesAsync(CancellationToken.None); - Servers.Clear(); + statuses = await _daemonApi.GetMcpServerStatusesAsync(CancellationToken.None); + } + catch (Exception ex) + { + StatusMessage.Value = $"Could not reach daemon: {ex.Message}"; + NotifyStateChanged(); + return; + } - foreach (var prop in statuses.EnumerateObject()) - { - var state = prop.Value.GetProperty("state").GetString() ?? "unknown"; - var toolCount = prop.Value.TryGetProperty("toolCount", out var tc) ? tc.GetInt32() : 0; - Servers.Add((prop.Name, state, toolCount)); - } + Servers.Clear(); - Profiles = LoadToolConfig().AudienceProfiles; + foreach (var prop in statuses.EnumerateObject()) + { + var state = prop.Value.GetProperty("state").GetString() ?? "unknown"; + var toolCount = prop.Value.TryGetProperty("toolCount", out var tc) ? tc.GetInt32() : 0; + Servers.Add((prop.Name, state, toolCount)); + } - if (Servers.Count == 0) - { - StatusMessage.Value = "No MCP servers connected. Start the daemon and configure servers first."; - } - else - { - StatusMessage.Value = ""; - CurrentState.Value = ToolPermissionsState.ServerList; - } + try + { + Profiles = LoadToolConfig().AudienceProfiles; } catch (Exception ex) { - StatusMessage.Value = $"Could not reach daemon: {ex.Message}"; + StatusMessage.Value = $"Could not load MCP permissions config: {ex.Message}"; + NotifyStateChanged(); + return; + } + + if (Servers.Count == 0) + StatusMessage.Value = "No MCP servers connected. Start the daemon and configure servers first."; + else + { + StatusMessage.Value = ""; + CurrentState.Value = ToolPermissionsState.ServerList; } NotifyStateChanged(); @@ -452,7 +463,7 @@ public bool Save() var toolsSection = ConfigFileHelper.GetOrCreateSection(config, "Tools"); var profilesSection = ConfigFileHelper.GetOrCreateSection(toolsSection, "AudienceProfiles"); - SaveServerAccess(profilesSection); + SaveServerAccess(config, profilesSection); SaveToolGrants(profilesSection); SaveServerDefaults(profilesSection); SaveToolOverrides(profilesSection); @@ -477,41 +488,60 @@ public bool Save() } } - private void SaveServerAccess(Dictionary<string, object> profilesSection) + private void SaveServerAccess(Dictionary<string, object> config, Dictionary<string, object> profilesSection) { + var knownServers = GetKnownMcpServers(config); foreach (var ((audienceName, serverName), allowed) in _pendingServerAccess) { var audienceSection = ConfigFileHelper.GetOrCreateSection(profilesSection, audienceName); + var profile = ResolveProfile(AudienceFromName(audienceName)); + var serverList = BuildAllowedServerList(profile, knownServers, serverName, allowed); - var serverList = audienceSection.TryGetValue("AllowedMcpServers", out var existingList) - && existingList is List<object> list - ? list.Select(o => o.ToString()!).ToList() - : []; + audienceSection["McpServersMode"] = profile.McpServersMode.ToString(); + audienceSection["AllowedMcpServers"] = serverList; + } + } - if (allowed && !serverList.Contains(serverName, StringComparer.OrdinalIgnoreCase)) - { - serverList.Add(serverName); - } - else if (!allowed) - { - serverList.RemoveAll(s => s.Equals(serverName, StringComparison.OrdinalIgnoreCase)); - } + private List<string> BuildAllowedServerList( + ToolAudienceProfile profile, + IReadOnlyList<string> knownServers, + string serverName, + bool allowed) + { + var serverList = profile.McpServersMode == ToolProfileMode.All + ? knownServers.ToList() + : profile.AllowedMcpServers.ToList(); - audienceSection["AllowedMcpServers"] = serverList; + profile.McpServersMode = ToolProfileMode.Allowlist; + if (allowed) + AddServer(serverList, serverName); + else + serverList.RemoveAll(s => s.Equals(serverName, StringComparison.OrdinalIgnoreCase)); - // Also update the in-memory profile so the UI reflects changes immediately - var profile = audienceName switch - { - "Public" => Profiles.Public, - "Team" => Profiles.Team, - _ => Profiles.Personal - }; - - if (allowed && !profile.AllowedMcpServers.Contains(serverName, StringComparer.OrdinalIgnoreCase)) - profile.AllowedMcpServers.Add(serverName); - else if (!allowed) - profile.AllowedMcpServers.RemoveAll(s => s.Equals(serverName, StringComparison.OrdinalIgnoreCase)); + profile.AllowedMcpServers = serverList; + return serverList; + } + + private IReadOnlyList<string> GetKnownMcpServers(Dictionary<string, object> config) + { + var names = new List<string>(); + foreach (var server in Servers) + AddServer(names, server.Name); + + if (ConfigFileHelper.TryGetPathValue(config, "McpServers", out var configuredServers) + && configuredServers is Dictionary<string, object> configuredServerMap) + { + foreach (var serverName in configuredServerMap.Keys) + AddServer(names, serverName); } + + return names; + } + + private static void AddServer(List<string> serverList, string serverName) + { + if (!serverList.Contains(serverName, StringComparer.OrdinalIgnoreCase)) + serverList.Add(serverName); } private void SaveToolGrants(Dictionary<string, object> profilesSection) @@ -619,12 +649,7 @@ public void ToggleServerAccess() { var audienceSection = ConfigFileHelper.GetOrCreateSection(profilesSection, audienceName); var approvalSection = ConfigFileHelper.GetOrCreateSection(audienceSection, "ApprovalPolicy"); - var profile = audienceName switch - { - "Public" => Profiles.Public, - "Team" => Profiles.Team, - _ => Profiles.Personal - }; + var profile = ResolveProfile(AudienceFromName(audienceName)); profile.ApprovalPolicy ??= new ToolApprovalConfig(); return (approvalSection, profile.ApprovalPolicy); } @@ -651,6 +676,13 @@ private static bool IsServerAllowed(McpServerName serverName, ToolAudienceProfil _ => "Personal" }; + private static TrustAudience AudienceFromName(string audienceName) => audienceName switch + { + "Public" => TrustAudience.Public, + "Team" => TrustAudience.Team, + _ => TrustAudience.Personal + }; + public void RequestQuit() { if (_navigation?.TryGoBack() == true) @@ -687,25 +719,26 @@ private void ApplyPendingNavigationState() SelectedAudience = audience; } + public override void Dispose() + { + CurrentState.Dispose(); + StateVersion.Dispose(); + StatusMessage.Dispose(); + base.Dispose(); + } + private ToolConfig LoadToolConfig() { if (!File.Exists(_paths.NetclawConfigPath)) return new ToolConfig(); - try - { - var text = File.ReadAllText(_paths.NetclawConfigPath); - using var doc = JsonDocument.Parse(text); + var text = File.ReadAllText(_paths.NetclawConfigPath); + using var doc = JsonDocument.Parse(text); - if (!doc.RootElement.TryGetProperty("Tools", out var toolsSection)) - return new ToolConfig(); - - return JsonSerializer.Deserialize<ToolConfig>(toolsSection.GetRawText(), JsonDefaults.EnumAware) - ?? new ToolConfig(); - } - catch - { + if (!doc.RootElement.TryGetProperty("Tools", out var toolsSection)) return new ToolConfig(); - } + + return JsonSerializer.Deserialize<ToolConfig>(toolsSection.GetRawText(), JsonDefaults.EnumAware) + ?? throw new InvalidDataException("Tools section could not be deserialized."); } } diff --git a/src/Netclaw.Cli/Tui/Config/SecurityAccessViewModel.cs b/src/Netclaw.Cli/Tui/Config/SecurityAccessViewModel.cs index c5abd18bb..6679780e1 100644 --- a/src/Netclaw.Cli/Tui/Config/SecurityAccessViewModel.cs +++ b/src/Netclaw.Cli/Tui/Config/SecurityAccessViewModel.cs @@ -47,8 +47,6 @@ public enum AudienceProfileRowKind public sealed class SecurityAccessViewModel : ReactiveViewModel { - private const int FeatureCount = 6; - private const string ShellToolName = "shell_execute"; private static readonly string[] FeatureConfigPaths = [ "Memory.Enabled", @@ -93,27 +91,15 @@ public sealed class SecurityAccessViewModel : ReactiveViewModel new(AudienceProfileRowKind.ResetToDefault, "Reset overrides", "Restore this audience to the current posture baseline.") ]; - private static readonly string[] FileTools = ["file_read", "file_list", "attach_file", "file_write", "file_edit"]; - private static readonly string[] WebTools = ["web_search", "web_fetch"]; - private static readonly string[] SkillTools = ["skill_manage"]; - private static readonly string[] SchedulingTools = ["set_reminder", "list_reminders", "cancel_reminder", "get_reminder_history"]; - private static readonly string[] WorkingDirectoryTools = ["set_working_directory"]; - private static readonly string[] KnownFirstPartyTools = - [ - "file_read", "file_list", "file_write", "file_edit", "attach_file", - "web_search", "web_fetch", "skill_manage", "set_reminder", - "list_reminders", "cancel_reminder", "get_reminder_history", - "set_working_directory", ShellToolName, "set_webhook", "delete_webhook", - "list_webhooks", "send_slack_message", "lookup_slack_user", - "send_discord_message", "send_mattermost_message", "lookup_mattermost_user", - "spawn_agent", "search_tools", "load_tool", "skill_load", - "skill_read_resource", "store_memory", "get_memories", "update_memory", - "find_memories", "check_background_job" - ]; + private static IReadOnlyList<string> FileTools => ToolAudienceProfileToolCatalog.FileTools; + private static IReadOnlyList<string> WebTools => ToolAudienceProfileToolCatalog.WebTools; + private static IReadOnlyList<string> SkillTools => ToolAudienceProfileToolCatalog.SkillTools; + private static IReadOnlyList<string> SchedulingTools => ToolAudienceProfileToolCatalog.SchedulingTools; + private static IReadOnlyList<string> WorkingDirectoryTools => ToolAudienceProfileToolCatalog.WorkingDirectoryTools; private readonly NetclawPaths _paths; private readonly McpToolPermissionsNavigationState? _mcpNavigationState; - private readonly bool[] _enabledFeatures = new bool[FeatureCount]; + private readonly bool[] _enabledFeatures = new bool[FeatureConfigPaths.Length]; private DeploymentPosture? _pendingPosture; public SecurityAccessViewModel( @@ -161,7 +147,7 @@ public void MoveSelection(int delta) public void MovePostureSelection(int delta) => Move(SelectedPostureIndex, delta, Postures.Length); public void MoveCascadeSelection(int delta) => Move(SelectedCascadeIndex, delta, CascadeOptions.Length); - public void MoveFeatureSelection(int delta) => Move(SelectedFeatureIndex, delta, FeatureCount); + public void MoveFeatureSelection(int delta) => Move(SelectedFeatureIndex, delta, FeatureConfigPaths.Length); public void MoveAudienceSelection(int delta) => Move(SelectedAudienceIndex, delta, Audiences.Length); public void MoveAudienceRow(int delta) => Move(SelectedAudienceRowIndex, delta, AudienceRows.Length); @@ -594,15 +580,9 @@ private void LoadEnabledFeatures() } private SectionContribution BuildFeatureContribution() - => new( - [ - new SectionFieldAction(FeatureConfigPaths[0], SectionFieldActionKind.Set, _enabledFeatures[0]), - new SectionFieldAction(FeatureConfigPaths[1], SectionFieldActionKind.Set, _enabledFeatures[1]), - new SectionFieldAction(FeatureConfigPaths[2], SectionFieldActionKind.Set, _enabledFeatures[2]), - new SectionFieldAction(FeatureConfigPaths[3], SectionFieldActionKind.Set, _enabledFeatures[3]), - new SectionFieldAction(FeatureConfigPaths[4], SectionFieldActionKind.Set, _enabledFeatures[4]), - new SectionFieldAction(FeatureConfigPaths[5], SectionFieldActionKind.Set, _enabledFeatures[5]) - ]); + => new(FeatureConfigPaths + .Select((path, index) => new SectionFieldAction(path, SectionFieldActionKind.Set, _enabledFeatures[index])) + .ToArray()); private IReadOnlyList<SecurityAccessItem> BuildItems() { @@ -688,7 +668,7 @@ private static ToolAudienceProfiles BuildPostureProfiles(DeploymentPosture postu { ToolOverrides = new Dictionary<string, ToolApprovalMode>(StringComparer.Ordinal) { - [ShellToolName] = ToolApprovalMode.Approval + [ToolAudienceProfileToolCatalog.ShellExecute] = ToolApprovalMode.Approval } }; } @@ -726,7 +706,7 @@ private static void EnsureAllowlist(ToolAudienceProfile profile) return; profile.ToolsMode = ToolProfileMode.Allowlist; - profile.AllowedTools = [.. KnownFirstPartyTools]; + profile.AllowedTools = [.. ToolAudienceProfileToolCatalog.ProfileManagedTools]; } private static void AddTools(List<string> target, IReadOnlyList<string> tools) diff --git a/src/Netclaw.Cli/Tui/TuiNavigation.cs b/src/Netclaw.Cli/Tui/TuiNavigation.cs index b3d5d198c..9e84fd5da 100644 --- a/src/Netclaw.Cli/Tui/TuiNavigation.cs +++ b/src/Netclaw.Cli/Tui/TuiNavigation.cs @@ -11,8 +11,6 @@ public sealed class TuiNavigation { private TerminaApplication? _application; - internal int BackRequestsForTests { get; private set; } - public void Attach(TerminaApplication application) { ArgumentNullException.ThrowIfNull(application); @@ -21,7 +19,6 @@ public void Attach(TerminaApplication application) public bool TryGoBack() { - BackRequestsForTests++; if (_application is null) throw new InvalidOperationException("TUI navigation was requested before TerminaApplication was attached."); diff --git a/src/Netclaw.Configuration.Tests/ToolAudienceProfileDefaultsTests.cs b/src/Netclaw.Configuration.Tests/ToolAudienceProfileDefaultsTests.cs index 7f826852a..1cd9e3c18 100644 --- a/src/Netclaw.Configuration.Tests/ToolAudienceProfileDefaultsTests.cs +++ b/src/Netclaw.Configuration.Tests/ToolAudienceProfileDefaultsTests.cs @@ -14,56 +14,85 @@ namespace Netclaw.Configuration.Tests; /// </summary> public sealed class ToolAudienceProfileDefaultsTests { - [Fact] - public void Public_default_grants_read_list_and_attach_only() + public static TheoryData<TrustAudience, string[]> DefaultAllowlists => new() { - var publicProfile = ToolAudienceProfileDefaults.CreatePublic(); - - Assert.Equal( - ["file_read", "file_list", "attach_file"], - publicProfile.AllowedTools); - Assert.DoesNotContain("file_write", publicProfile.AllowedTools); - Assert.DoesNotContain("file_edit", publicProfile.AllowedTools); - } + { + TrustAudience.Public, + [ + ToolAudienceProfileToolCatalog.FileRead, + ToolAudienceProfileToolCatalog.FileList, + ToolAudienceProfileToolCatalog.AttachFile + ] + }, + { + TrustAudience.Team, + [ + ToolAudienceProfileToolCatalog.FileRead, + ToolAudienceProfileToolCatalog.FileList, + ToolAudienceProfileToolCatalog.FileWrite, + ToolAudienceProfileToolCatalog.FileEdit, + ToolAudienceProfileToolCatalog.AttachFile, + ToolAudienceProfileToolCatalog.WebSearch, + ToolAudienceProfileToolCatalog.WebFetch, + ToolAudienceProfileToolCatalog.SkillManage, + ToolAudienceProfileToolCatalog.SetReminder, + ToolAudienceProfileToolCatalog.ListReminders, + ToolAudienceProfileToolCatalog.CancelReminder, + ToolAudienceProfileToolCatalog.GetReminderHistory, + ToolAudienceProfileToolCatalog.SetWorkingDirectory + ] + } + }; - [Fact] - public void Team_default_grants_file_web_scheduling_and_skill_tools() + public static TheoryData<TrustAudience, string[]> DefaultExcludedTools => new() { - var team = ToolAudienceProfileDefaults.CreateTeam().AllowedTools; + { + TrustAudience.Public, + [ + ToolAudienceProfileToolCatalog.FileWrite, + ToolAudienceProfileToolCatalog.FileEdit, + ToolAudienceProfileToolCatalog.WebSearch, + ToolAudienceProfileToolCatalog.WebFetch + ] + }, + { + TrustAudience.Team, + [ + ToolAudienceProfileToolCatalog.ShellExecute, + ToolAudienceProfileToolCatalog.SetWebhook, + ToolAudienceProfileToolCatalog.ListWebhooks, + ToolAudienceProfileToolCatalog.DeleteWebhook + ] + } + }; - Assert.Contains("file_read", team); - Assert.Contains("file_list", team); - Assert.Contains("file_write", team); - Assert.Contains("file_edit", team); - Assert.Contains("attach_file", team); - Assert.Contains("web_search", team); - Assert.Contains("web_fetch", team); - Assert.Contains("skill_manage", team); - Assert.Contains("set_reminder", team); - Assert.Contains("list_reminders", team); - Assert.Contains("cancel_reminder", team); - Assert.Contains("get_reminder_history", team); - Assert.Contains("set_working_directory", team); + [Theory] + [MemberData(nameof(DefaultAllowlists))] + public void Default_allowlists_match_expected_catalog_tools(TrustAudience audience, string[] expectedTools) + { + Assert.Equal(expectedTools, GetDefaultCatalogTools(audience)); + Assert.Equal(expectedTools, GetDefaultProfile(audience).AllowedTools); } [Fact] - public void Public_default_excludes_outbound_web_tools() + public void Profile_managed_catalog_covers_default_team_shell_and_webhook_tools() { - var publicProfile = ToolAudienceProfileDefaults.CreatePublic(); + var profileManaged = ToolAudienceProfileToolCatalog.ProfileManagedTools; - Assert.DoesNotContain("web_search", publicProfile.AllowedTools); - Assert.DoesNotContain("web_fetch", publicProfile.AllowedTools); + Assert.All(ToolAudienceProfileToolCatalog.TeamDefaultAllowedTools, + tool => Assert.Contains(tool, profileManaged)); + Assert.Contains(ToolAudienceProfileToolCatalog.ShellExecute, profileManaged); + Assert.All(ToolAudienceProfileToolCatalog.WebhookTools, + tool => Assert.Contains(tool, profileManaged)); } - [Fact] - public void Team_default_excludes_shell_and_webhook_tools() + [Theory] + [MemberData(nameof(DefaultExcludedTools))] + public void Default_allowlists_exclude_restricted_tools(TrustAudience audience, string[] excludedTools) { - var team = ToolAudienceProfileDefaults.CreateTeam().AllowedTools; + var allowedTools = GetDefaultProfile(audience).AllowedTools; - Assert.DoesNotContain("shell_execute", team); - Assert.DoesNotContain("set_webhook", team); - Assert.DoesNotContain("list_webhooks", team); - Assert.DoesNotContain("delete_webhook", team); + Assert.All(excludedTools, tool => Assert.DoesNotContain(tool, allowedTools)); } [Fact] @@ -87,4 +116,21 @@ public void Default_grants_are_monotonic_across_audiences() // Team ⊆ Personal — Personal grants every tool via ToolsMode.All. Assert.Equal(ToolProfileMode.All, ToolAudienceProfileDefaults.CreatePersonal().ToolsMode); } + + private static ToolAudienceProfile GetDefaultProfile(TrustAudience audience) + => audience switch + { + TrustAudience.Public => ToolAudienceProfileDefaults.CreatePublic(), + TrustAudience.Team => ToolAudienceProfileDefaults.CreateTeam(), + TrustAudience.Personal => ToolAudienceProfileDefaults.CreatePersonal(), + _ => throw new ArgumentOutOfRangeException(nameof(audience), audience, null) + }; + + private static IReadOnlyList<string> GetDefaultCatalogTools(TrustAudience audience) + => audience switch + { + TrustAudience.Public => ToolAudienceProfileToolCatalog.PublicDefaultAllowedTools, + TrustAudience.Team => ToolAudienceProfileToolCatalog.TeamDefaultAllowedTools, + _ => throw new ArgumentOutOfRangeException(nameof(audience), audience, null) + }; } diff --git a/src/Netclaw.Configuration/ToolAudienceProfiles.cs b/src/Netclaw.Configuration/ToolAudienceProfiles.cs index d579af27d..9c6123c6c 100644 --- a/src/Netclaw.Configuration/ToolAudienceProfiles.cs +++ b/src/Netclaw.Configuration/ToolAudienceProfiles.cs @@ -127,6 +127,56 @@ private static void ValidateProfile(string name, ToolAudienceProfile profile, Li ]; } +public static class ToolAudienceProfileToolCatalog +{ + public const string ShellExecute = "shell_execute"; + public const string FileRead = "file_read"; + public const string FileList = "file_list"; + public const string AttachFile = "attach_file"; + public const string FileWrite = "file_write"; + public const string FileEdit = "file_edit"; + public const string WebSearch = "web_search"; + public const string WebFetch = "web_fetch"; + public const string SkillManage = "skill_manage"; + public const string SetWebhook = "set_webhook"; + public const string ListWebhooks = "list_webhooks"; + public const string DeleteWebhook = "delete_webhook"; + public const string SetReminder = "set_reminder"; + public const string ListReminders = "list_reminders"; + public const string CancelReminder = "cancel_reminder"; + public const string GetReminderHistory = "get_reminder_history"; + public const string SetWorkingDirectory = "set_working_directory"; + + public static IReadOnlyList<string> FileTools { get; } = [FileRead, FileList, FileWrite, FileEdit, AttachFile]; + public static IReadOnlyList<string> WebTools { get; } = [WebSearch, WebFetch]; + public static IReadOnlyList<string> SkillTools { get; } = [SkillManage]; + public static IReadOnlyList<string> WebhookTools { get; } = [SetWebhook, ListWebhooks, DeleteWebhook]; + public static IReadOnlyList<string> SchedulingTools { get; } = [SetReminder, ListReminders, CancelReminder, GetReminderHistory]; + public static IReadOnlyList<string> WorkingDirectoryTools { get; } = [SetWorkingDirectory]; + + public static IReadOnlyList<string> PublicDefaultAllowedTools { get; } = [FileRead, FileList, AttachFile]; + + public static IReadOnlyList<string> TeamDefaultAllowedTools { get; } = + [ + .. FileTools, + .. WebTools, + .. SkillTools, + .. SchedulingTools, + .. WorkingDirectoryTools + ]; + + public static IReadOnlyList<string> ProfileManagedTools { get; } = + [ + .. TeamDefaultAllowedTools, + .. WebhookTools, + ShellExecute + ]; + + private static readonly HashSet<string> ProfileManagedToolSet = new(ProfileManagedTools, StringComparer.Ordinal); + + public static bool IsProfileManaged(string toolName) => ProfileManagedToolSet.Contains(toolName); +} + public static class ToolAudienceProfileDefaults { public const string SessionDirectoryToken = "{session_dir}"; @@ -150,7 +200,7 @@ public static class ToolAudienceProfileDefaults // session-directory scope rather than an unusable profile. public static ToolAudienceProfile CreatePublic() => new() { - AllowedTools = ["file_read", "file_list", "attach_file"], + AllowedTools = [.. ToolAudienceProfileToolCatalog.PublicDefaultAllowedTools], ReadFiles = CreateSessionScopedFilesystemAccess(), WriteFiles = CreateSessionScopedFilesystemAccess(), AttachFiles = CreateSessionScopedFilesystemAccess(), @@ -163,13 +213,7 @@ public static class ToolAudienceProfileDefaults // Monotonic invariant: Public ⊆ Team ⊆ Personal. public static ToolAudienceProfile CreateTeam() => new() { - AllowedTools = - [ - "file_read", "file_list", "file_write", "file_edit", "attach_file", - "web_search", "web_fetch", "skill_manage", "set_reminder", - "list_reminders", "cancel_reminder", "get_reminder_history", - "set_working_directory" - ], + AllowedTools = [.. ToolAudienceProfileToolCatalog.TeamDefaultAllowedTools], ReadFiles = CreateSessionScopedFilesystemAccess(), WriteFiles = CreateSessionScopedFilesystemAccess(), AttachFiles = CreateSessionScopedFilesystemAccess(), From bcaebb571f0d984aa611f85e485793c1b78d55b4 Mon Sep 17 00:00:00 2001 From: Aaron Stannard <aaron@petabridge.com> Date: Sat, 30 May 2026 17:38:31 +0000 Subject: [PATCH 25/31] feat(config): add channels summary page --- scripts/smoke/run-smoke.sh | 2 +- .../Config/ChannelsConfigNavigationTests.cs | 105 +++++ .../Config/ChannelsConfigViewModelTests.cs | 148 +++++++ .../Tui/ConfigDashboardViewModelTests.cs | 22 +- src/Netclaw.Cli/Program.cs | 1 + .../Tui/Config/ChannelsConfigPage.cs | 165 ++++++++ .../Tui/Config/ChannelsConfigViewModel.cs | 362 ++++++++++++++++++ .../Tui/ConfigDashboardViewModel.cs | 2 +- tests/smoke/assertions/config-channels.sh | 30 ++ tests/smoke/tapes/config-channels.tape | 46 +++ 10 files changed, 878 insertions(+), 5 deletions(-) create mode 100644 src/Netclaw.Cli.Tests/Tui/Config/ChannelsConfigNavigationTests.cs create mode 100644 src/Netclaw.Cli.Tests/Tui/Config/ChannelsConfigViewModelTests.cs create mode 100644 src/Netclaw.Cli/Tui/Config/ChannelsConfigPage.cs create mode 100644 src/Netclaw.Cli/Tui/Config/ChannelsConfigViewModel.cs create mode 100755 tests/smoke/assertions/config-channels.sh create mode 100644 tests/smoke/tapes/config-channels.tape diff --git a/scripts/smoke/run-smoke.sh b/scripts/smoke/run-smoke.sh index f56c0643c..e18a77d73 100755 --- a/scripts/smoke/run-smoke.sh +++ b/scripts/smoke/run-smoke.sh @@ -54,7 +54,7 @@ SMOKE_LOG_DIR="${SMOKE_LOG_DIR:-${ROOT_DIR}/smoke-logs}" # Cheapest harness checks first so a harness-level break fails fast # before paying for the wizard + probe tapes. -LIGHT_TAPES=(help init-wizard config-exposure config-posture config-features config-audience provider-add provider-rename config-search tui-cleanup) +LIGHT_TAPES=(help init-wizard config-exposure config-posture config-features config-audience config-channels provider-add provider-rename config-search tui-cleanup) FULL_TAPES=("${LIGHT_TAPES[@]}") LIGHT_SCENARIOS=( diff --git a/src/Netclaw.Cli.Tests/Tui/Config/ChannelsConfigNavigationTests.cs b/src/Netclaw.Cli.Tests/Tui/Config/ChannelsConfigNavigationTests.cs new file mode 100644 index 000000000..0d3900f4d --- /dev/null +++ b/src/Netclaw.Cli.Tests/Tui/Config/ChannelsConfigNavigationTests.cs @@ -0,0 +1,105 @@ +// ----------------------------------------------------------------------- +// <copyright file="ChannelsConfigNavigationTests.cs" company="Petabridge, LLC"> +// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com> +// </copyright> +// ----------------------------------------------------------------------- +using Microsoft.Extensions.DependencyInjection; +using Netclaw.Cli.Tui; +using Netclaw.Cli.Tui.Config; +using Netclaw.Configuration; +using Netclaw.Tests.Utilities; +using Termina; +using Termina.Hosting; +using Termina.Input; +using Termina.Terminal; +using Xunit; + +namespace Netclaw.Cli.Tests.Tui.Config; + +public sealed class ChannelsConfigNavigationTests : IDisposable +{ + private readonly DisposableTempDir _dir = new(); + private readonly NetclawPaths _paths; + + public ChannelsConfigNavigationTests() + { + _paths = new NetclawPaths(_dir.Path); + _paths.EnsureDirectoriesExist(); + File.WriteAllText(_paths.NetclawConfigPath, + """ + { + "configVersion": 1, + "Slack": { "Enabled": true, "AllowedChannelIds": ["C01"] } + } + """); + } + + public void Dispose() => _dir.Dispose(); + + [Fact] + public async Task Channels_Escape_ReturnsToDashboardUsingTerminaHistory() + { + var app = CreateHeadlessApp(out var input, out var dashboardVm, out var getChannelsVm); + dashboardVm.SelectedIndex.Value = dashboardVm.Items + .Select((item, index) => (item, index)) + .Single(entry => entry.item.Label == "Channels") + .index; + + dashboardVm.ActivateSelected(); + input.EnqueueKey(ConsoleKey.Escape); + input.EnqueueKey(ConsoleKey.Q, false, false, true); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await app.RunAsync(cts.Token); + + Assert.NotNull(getChannelsVm()); + Assert.Equal("/config", app.CurrentPath); + } + + private TerminaApplication CreateHeadlessApp( + out VirtualInputSource input, + out ConfigDashboardViewModel dashboardVm, + out Func<ChannelsConfigViewModel?> getChannelsVm) + { + var terminal = new VirtualTerminal(120, 40); + var virtualInput = new VirtualInputSource(); + input = virtualInput; + + var navigationState = new ConfigDashboardNavigationState(); + var tuiNavigation = new TuiNavigation(); + ConfigDashboardViewModel? capturedDashboardVm = null; + ChannelsConfigViewModel? capturedChannelsVm = null; + + var services = new ServiceCollection(); + services.AddSingleton<IAnsiTerminal>(terminal); + services.AddSingleton(tuiNavigation); + services.AddTerminaVirtualInput(virtualInput); + services.AddTermina("/config", builder => + { + builder.RegisterRoute<ConfigDashboardPage, ConfigDashboardViewModel>( + "/config", + _ => new ConfigDashboardPage(), + _ => + { + capturedDashboardVm = new ConfigDashboardViewModel(navigationState); + return capturedDashboardVm; + }); + builder.RegisterRoute<ChannelsConfigPage, ChannelsConfigViewModel>( + "/channels", + _ => new ChannelsConfigPage(), + _ => + { + capturedChannelsVm = new ChannelsConfigViewModel(_paths, tuiNavigation); + return capturedChannelsVm; + }); + }); + + var sp = services.BuildServiceProvider(); + var app = sp.GetRequiredService<TerminaApplication>(); + tuiNavigation.Attach(app); + + dashboardVm = capturedDashboardVm!; + getChannelsVm = () => capturedChannelsVm; + return app; + } +} diff --git a/src/Netclaw.Cli.Tests/Tui/Config/ChannelsConfigViewModelTests.cs b/src/Netclaw.Cli.Tests/Tui/Config/ChannelsConfigViewModelTests.cs new file mode 100644 index 000000000..a03ebd1b1 --- /dev/null +++ b/src/Netclaw.Cli.Tests/Tui/Config/ChannelsConfigViewModelTests.cs @@ -0,0 +1,148 @@ +// ----------------------------------------------------------------------- +// <copyright file="ChannelsConfigViewModelTests.cs" company="Petabridge, LLC"> +// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com> +// </copyright> +// ----------------------------------------------------------------------- +using Netclaw.Cli.Tui.Config; +using Netclaw.Configuration; +using Netclaw.Tests.Utilities; +using Xunit; + +namespace Netclaw.Cli.Tests.Tui.Config; + +public sealed class ChannelsConfigViewModelTests : IDisposable +{ + private readonly DisposableTempDir _dir = new(); + private readonly NetclawPaths _paths; + + public ChannelsConfigViewModelTests() + { + _paths = new NetclawPaths(_dir.Path); + _paths.EnsureDirectoriesExist(); + } + + public void Dispose() => _dir.Dispose(); + + [Fact] + public void Channels_page_lists_supported_chat_adapters() + { + using var vm = new ChannelsConfigViewModel(_paths); + + var labels = vm.Items.Select(static item => item.Label).ToArray(); + + Assert.Equal(["Slack", "Discord", "Mattermost"], labels); + } + + [Fact] + public void Provider_summaries_reflect_current_config() + { + WriteChannelConfig(); + WriteChannelSecrets(); + using var vm = new ChannelsConfigViewModel(_paths); + + var summaries = vm.Items.ToDictionary(static item => item.Label, static item => item.Summary); + + Assert.Equal("3 channels, 2 users", summaries["Slack"]); + Assert.Equal("disabled", summaries["Discord"]); + Assert.Equal("1 channel", summaries["Mattermost"]); + } + + [Fact] + public void Missing_provider_reports_not_configured() + { + File.WriteAllText(_paths.NetclawConfigPath, + """ + { + "configVersion": 1, + "Slack": { "Enabled": true, "AllowDirectMessages": true } + } + """); + using var vm = new ChannelsConfigViewModel(_paths); + + var summaries = vm.Items.ToDictionary(static item => item.Label, static item => item.Summary); + + Assert.Equal("DMs only", summaries["Slack"]); + Assert.Equal("not configured", summaries["Discord"]); + Assert.Equal("not configured", summaries["Mattermost"]); + } + + [Fact] + public void Provider_details_show_config_and_secret_state() + { + WriteChannelConfig(); + WriteChannelSecrets(); + using var vm = new ChannelsConfigViewModel(_paths); + vm.SelectedIndex.Value = 0; + + vm.OpenSelectedProvider(); + + var details = vm.SelectedDetails.ToDictionary(static detail => detail.Label, static detail => detail.Value); + Assert.Equal(ChannelsConfigMode.Details, vm.Mode.Value); + Assert.Equal("enabled", details["Status"]); + Assert.Equal("configured", details["Bot token"]); + Assert.Equal("configured", details["App token"]); + Assert.Equal("3 configured", details["Allowed channels"]); + Assert.Equal("2 configured", details["Allowed users"]); + Assert.Equal("enabled", details["DMs"]); + Assert.Equal("2 configured", details["Audience overrides"]); + } + + [Fact] + public void Back_from_details_returns_to_provider_list() + { + using var vm = new ChannelsConfigViewModel(_paths); + vm.OpenSelectedProvider(); + + vm.GoBack(); + + Assert.Equal(ChannelsConfigMode.Providers, vm.Mode.Value); + Assert.False(vm.ShutdownRequestedForTest); + } + + private void WriteChannelConfig() + { + File.WriteAllText(_paths.NetclawConfigPath, + """ + { + "configVersion": 1, + "Slack": { + "Enabled": true, + "SocketMode": true, + "AllowedChannelIds": ["C01", "C02", "C03"], + "AllowedUserIds": ["U01", "U02"], + "AllowDirectMessages": true, + "ChannelAudiences": { + "C01": "team", + "dm": "personal" + } + }, + "Discord": { + "Enabled": false, + "AllowedChannelIds": ["123"] + }, + "Mattermost": { + "Enabled": true, + "ServerUrl": "https://mattermost.example.com", + "DefaultChannelId": "town-square" + } + } + """); + } + + private void WriteChannelSecrets() + { + File.WriteAllText(_paths.SecretsPath, + """ + { + "configVersion": 1, + "Slack": { + "BotToken": "xoxb-test", + "AppToken": "xapp-test" + }, + "Mattermost": { + "BotToken": "mattermost-token" + } + } + """); + } +} diff --git a/src/Netclaw.Cli.Tests/Tui/ConfigDashboardViewModelTests.cs b/src/Netclaw.Cli.Tests/Tui/ConfigDashboardViewModelTests.cs index 6ce9b0a84..0e7b98732 100644 --- a/src/Netclaw.Cli.Tests/Tui/ConfigDashboardViewModelTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/ConfigDashboardViewModelTests.cs @@ -69,6 +69,18 @@ public void Security_access_routes_to_security_page() Assert.Equal("/security", navigatedRoute); } + [Fact] + public void Channels_routes_to_channels_page() + { + using var vm = new ConfigDashboardViewModel(new ConfigDashboardNavigationState()); + string? navigatedRoute = null; + vm.RouteRequested = route => navigatedRoute = route; + + vm.Activate(vm.Items.Single(static item => item.Label == "Channels")); + + Assert.Equal("/channels", navigatedRoute); + } + [Fact] public void Run_full_doctor_sets_pending_action_and_shuts_down() { @@ -81,12 +93,16 @@ public void Run_full_doctor_sets_pending_action_and_shuts_down() Assert.True(vm.ShutdownRequestedForTest); } - [Fact] - public void Placeholder_sections_report_not_implemented_status() + [Theory] + [InlineData("Inbound Webhooks")] + [InlineData("Skill Sources")] + [InlineData("Browser Automation")] + [InlineData("Telemetry & Alerting")] + public void Placeholder_sections_report_not_implemented_status(string label) { using var vm = new ConfigDashboardViewModel(new ConfigDashboardNavigationState()); - vm.Activate(vm.Items.Single(static item => item.Label == "Channels")); + vm.Activate(vm.Items.Single(item => item.Label == label)); Assert.Contains("not implemented yet", vm.StatusMessage.Value, StringComparison.OrdinalIgnoreCase); } diff --git a/src/Netclaw.Cli/Program.cs b/src/Netclaw.Cli/Program.cs index 9668b63b6..e8dcf6731 100644 --- a/src/Netclaw.Cli/Program.cs +++ b/src/Netclaw.Cli/Program.cs @@ -902,6 +902,7 @@ static async Task RunAsync(string[] args) t.RegisterRoute<ConfigDashboardPage, ConfigDashboardViewModel>("/config"); t.RegisterRoute<ProviderManagerPage, ProviderManagerViewModel>("/provider"); t.RegisterRoute<ModelManagerPage, ModelManagerViewModel>("/model"); + t.RegisterRoute<ChannelsConfigPage, ChannelsConfigViewModel>("/channels"); t.RegisterRoute<SearchConfigEditorPage, SearchConfigEditorViewModel>("/search", Termina.Pages.NavigationBehavior.PreserveState); t.RegisterRoute<SecurityAccessPage, SecurityAccessViewModel>("/security"); t.RegisterRoute<ExposureModeConfigPage, ExposureModeConfigViewModel>("/exposure-mode"); diff --git a/src/Netclaw.Cli/Tui/Config/ChannelsConfigPage.cs b/src/Netclaw.Cli/Tui/Config/ChannelsConfigPage.cs new file mode 100644 index 000000000..2f0f94ea7 --- /dev/null +++ b/src/Netclaw.Cli/Tui/Config/ChannelsConfigPage.cs @@ -0,0 +1,165 @@ +// ----------------------------------------------------------------------- +// <copyright file="ChannelsConfigPage.cs" company="Petabridge, LLC"> +// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com> +// </copyright> +// ----------------------------------------------------------------------- +using R3; +using Termina.Extensions; +using Termina.Input; +using Termina.Layout; +using Termina.Reactive; +using Termina.Rendering; +using Termina.Terminal; + +namespace Netclaw.Cli.Tui.Config; + +public sealed class ChannelsConfigPage : ReactivePage<ChannelsConfigViewModel> +{ + private DynamicLayoutNode? _contentNode; + private DynamicLayoutNode? _keyBindingsNode; + + protected override void OnBound() + { + base.OnBound(); + ViewModel.Input.OfType<IInputEvent, KeyPressed>() + .Subscribe(HandleKeyPress) + .DisposeWith(Subscriptions); + + ViewModel.Mode.Subscribe(_ => InvalidateAll()).DisposeWith(Subscriptions); + ViewModel.SelectedIndex.Subscribe(_ => _contentNode?.Invalidate()).DisposeWith(Subscriptions); + } + + public override ILayoutNode BuildLayout() + => NetclawTuiChrome.BuildPageFrame("Channels", BuildInnerLayout()); + + private ILayoutNode BuildInnerLayout() + => Layouts.Vertical() + .WithSpacing(1) + .WithChild(BuildContent()) + .WithChild(Layouts.Empty().Fill()) + .WithChild(BuildStatusBar()) + .WithChild(BuildKeyBindings()); + + private ILayoutNode BuildContent() + { + _contentNode = new DynamicLayoutNode(() => ViewModel.Mode.Value switch + { + ChannelsConfigMode.Details => BuildProviderDetails(), + _ => BuildProviderList() + }); + + return _contentNode; + } + + private ILayoutNode BuildProviderList() + { + var layout = Layouts.Vertical() + .WithChild(Header(" Chat Channels")) + .WithChild(Hint(" Configure transport-specific chat adapters.")) + .WithChild(Layouts.Empty().Height(1)); + + var items = ViewModel.Items; + for (var i = 0; i < items.Count; i++) + { + var item = items[i]; + var focused = i == ViewModel.SelectedIndex.Value; + layout = layout.WithChild(Row( + $"{FocusPrefix(focused)}{item.Label,-14} {item.Summary,-24} {item.Description}", + focused)); + } + + return layout; + } + + private ILayoutNode BuildProviderDetails() + { + var item = ViewModel.SelectedItem; + var layout = Layouts.Vertical() + .WithChild(Header($" {item.Label} Channels")) + .WithChild(Hint(" This view reflects current config and stored secrets.")) + .WithChild(Layouts.Empty().Height(1)); + + foreach (var detail in ViewModel.SelectedDetails) + { + layout = layout.WithChild(new TextNode($" {detail.Label,-18} {detail.Value}") + .WithForeground(Color.White)); + } + + layout = layout + .WithChild(Layouts.Empty().Height(1)) + .WithChild(Hint(" Editing transport fields will be added as leaf editors; this page preserves current values.")); + + return layout; + } + + private LayoutNode BuildStatusBar() + => ViewModel.StatusMessage + .Select(msg => NetclawTuiChrome.BuildStatusLine(msg, Color.Yellow)) + .AsLayout() + .Height(1); + + private LayoutNode BuildKeyBindings() + { + _keyBindingsNode = new DynamicLayoutNode(() => NetclawTuiChrome.BuildKeyHintLine(ViewModel.Mode.Value switch + { + ChannelsConfigMode.Details => " [Esc] Channels [Ctrl+Q] Quit", + _ => " [↑/↓] Navigate [Enter] Open [Esc] Back [Ctrl+Q] Quit" + })); + + return _keyBindingsNode.Height(1); + } + + private void HandleKeyPress(KeyPressed key) + { + var keyInfo = key.KeyInfo; + if (keyInfo.Key == ConsoleKey.Q && keyInfo.Modifiers.HasFlag(ConsoleModifiers.Control)) + { + ViewModel.RequestQuit(); + return; + } + + if (keyInfo.Key == ConsoleKey.Escape) + { + ViewModel.GoBack(); + return; + } + + if (ViewModel.Mode.Value == ChannelsConfigMode.Providers) + HandleProviderListKey(keyInfo); + + _contentNode?.Invalidate(); + ViewModel.RequestRedraw(); + } + + private void HandleProviderListKey(ConsoleKeyInfo keyInfo) + { + switch (keyInfo.Key) + { + case ConsoleKey.UpArrow: + ViewModel.MoveSelection(-1); + break; + case ConsoleKey.DownArrow: + ViewModel.MoveSelection(1); + break; + case ConsoleKey.Enter: + ViewModel.ActivateSelected(); + break; + } + } + + private void InvalidateAll() + { + _contentNode?.Invalidate(); + _keyBindingsNode?.Invalidate(); + } + + private static TextNode Header(string text) => new TextNode(text).WithForeground(Color.White).Bold(); + private static TextNode Hint(string text) => new TextNode(text).WithForeground(Color.BrightBlack); + private static string FocusPrefix(bool focused) => focused ? " > " : " "; + + private static TextNode Row(string line, bool focused) + { + var node = new TextNode(line); + return focused ? node.WithForeground(Color.Cyan).Bold() : node.WithForeground(Color.White); + } +} diff --git a/src/Netclaw.Cli/Tui/Config/ChannelsConfigViewModel.cs b/src/Netclaw.Cli/Tui/Config/ChannelsConfigViewModel.cs new file mode 100644 index 000000000..916c912f6 --- /dev/null +++ b/src/Netclaw.Cli/Tui/Config/ChannelsConfigViewModel.cs @@ -0,0 +1,362 @@ +// ----------------------------------------------------------------------- +// <copyright file="ChannelsConfigViewModel.cs" company="Petabridge, LLC"> +// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com> +// </copyright> +// ----------------------------------------------------------------------- +using System.Text.Json; +using Netclaw.Cli.Config; +using Netclaw.Configuration; +using R3; +using Termina.Reactive; + +namespace Netclaw.Cli.Tui.Config; + +public enum ChannelsConfigMode +{ + Providers, + Details +} + +public enum ChannelsConfigProvider +{ + Slack, + Discord, + Mattermost +} + +public sealed record ChannelsConfigItem( + ChannelsConfigProvider Provider, + string Label, + string Summary, + string Description); + +public sealed record ChannelsConfigDetail(string Label, string Value); + +public sealed class ChannelsConfigViewModel : ReactiveViewModel +{ + private static readonly ChannelProviderSpec[] Providers = + [ + new( + ChannelsConfigProvider.Slack, + "Slack", + "Socket Mode chat adapter.", + "Slack", + ["Slack.BotToken", "Slack.AppToken"]), + new( + ChannelsConfigProvider.Discord, + "Discord", + "Discord bot adapter.", + "Discord", + ["Discord.BotToken"]), + new( + ChannelsConfigProvider.Mattermost, + "Mattermost", + "Mattermost bot adapter.", + "Mattermost", + ["Mattermost.BotToken"]) + ]; + + private readonly NetclawPaths _paths; + private readonly TuiNavigation? _navigation; + + public ChannelsConfigViewModel(NetclawPaths paths, TuiNavigation? navigation = null) + { + _paths = paths; + _navigation = navigation; + } + + public ReactiveProperty<ChannelsConfigMode> Mode { get; } = new(ChannelsConfigMode.Providers); + public ReactiveProperty<int> SelectedIndex { get; } = new(0); + public ReactiveProperty<string> StatusMessage { get; } = new(""); + + internal bool ShutdownRequestedForTest { get; private set; } + + public IReadOnlyList<ChannelsConfigItem> Items => BuildItems(); + + public ChannelsConfigItem SelectedItem => Items[Math.Clamp(SelectedIndex.Value, 0, Items.Count - 1)]; + + public IReadOnlyList<ChannelsConfigDetail> SelectedDetails => BuildDetails(SelectedItem.Provider); + + public void MoveSelection(int delta) + { + var next = Math.Clamp(SelectedIndex.Value + delta, 0, Providers.Length - 1); + if (next != SelectedIndex.Value) + SelectedIndex.Value = next; + } + + public void ActivateSelected() + { + if (Mode.Value == ChannelsConfigMode.Providers) + OpenSelectedProvider(); + } + + internal void OpenSelectedProvider() + { + Mode.Value = ChannelsConfigMode.Details; + StatusMessage.Value = ""; + RequestRedraw(); + } + + public void GoBack() + { + if (Mode.Value == ChannelsConfigMode.Details) + { + Mode.Value = ChannelsConfigMode.Providers; + StatusMessage.Value = ""; + RequestRedraw(); + return; + } + + if (TryGoBack()) + return; + + RequestQuit(); + } + + public void RequestQuit() + { + ShutdownRequestedForTest = true; + Shutdown(); + } + + public override void Dispose() + { + Mode.Dispose(); + SelectedIndex.Dispose(); + StatusMessage.Dispose(); + base.Dispose(); + } + + private bool TryGoBack() + { + if (_navigation is null) + return false; + + try + { + return _navigation.TryGoBack(); + } + catch (InvalidOperationException) + { + return false; + } + } + + private IReadOnlyList<ChannelsConfigItem> BuildItems() + { + var config = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath); + return Providers + .Select(provider => new ChannelsConfigItem( + provider.Provider, + provider.Label, + ReadSummary(config, provider), + provider.Description)) + .ToArray(); + } + + private IReadOnlyList<ChannelsConfigDetail> BuildDetails(ChannelsConfigProvider providerValue) + { + var provider = Providers.Single(p => p.Provider == providerValue); + var config = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath); + var configured = SectionPresent(config, provider.SectionName) || HasAnySecret(provider.SecretPaths); + var enabled = configured && GetBool(config, $"{provider.SectionName}.Enabled", defaultValue: false); + var channels = ReadConfiguredChannels(config, provider.SectionName); + var users = GetStringArray(config, $"{provider.SectionName}.AllowedUserIds"); + var allowDms = GetBool(config, $"{provider.SectionName}.AllowDirectMessages", defaultValue: false); + var mentionOnly = GetBool(config, $"{provider.SectionName}.MentionOnly", defaultValue: true); + var mentionRequiredInDm = GetBool(config, $"{provider.SectionName}.MentionRequiredInDm", defaultValue: false); + var audienceOverrides = GetDictionaryCount(config, $"{provider.SectionName}.ChannelAudiences"); + + var details = new List<ChannelsConfigDetail> + { + new("Status", enabled ? "enabled" : configured ? "disabled" : "not configured") + }; + + AddCredentialDetails(details, provider); + + if (provider.Provider == ChannelsConfigProvider.Slack) + details.Add(new ChannelsConfigDetail("Socket Mode", GetBool(config, "Slack.SocketMode", defaultValue: true) ? "enabled" : "disabled")); + + if (provider.Provider == ChannelsConfigProvider.Mattermost) + { + details.Add(new ChannelsConfigDetail("Server URL", FormatOptional(GetString(config, "Mattermost.ServerUrl")))); + details.Add(new ChannelsConfigDetail("Callback URL", FormatOptional(GetString(config, "Mattermost.CallbackUrl")))); + } + + details.Add(new ChannelsConfigDetail("Default channel", FormatDefaultChannel(config, provider.SectionName))); + details.Add(new ChannelsConfigDetail("Allowed channels", FormatCount(channels.Count, "configured"))); + details.Add(new ChannelsConfigDetail("Allowed users", FormatCount(users.Count, "configured"))); + details.Add(new ChannelsConfigDetail("DMs", allowDms ? "enabled" : "disabled")); + details.Add(new ChannelsConfigDetail("Channel mentions", mentionOnly ? "required" : "not required")); + details.Add(new ChannelsConfigDetail("DM mentions", allowDms && mentionRequiredInDm ? "required" : "not required")); + details.Add(new ChannelsConfigDetail("Audience overrides", FormatCount(audienceOverrides, "configured"))); + + return details; + } + + private string ReadSummary(Dictionary<string, object> config, ChannelProviderSpec provider) + { + var configured = SectionPresent(config, provider.SectionName) || HasAnySecret(provider.SecretPaths); + if (!configured) + return "not configured"; + + var enabled = GetBool(config, $"{provider.SectionName}.Enabled", defaultValue: false); + if (!enabled) + return "disabled"; + + var channelCount = ReadConfiguredChannels(config, provider.SectionName).Count; + var userCount = GetStringArray(config, $"{provider.SectionName}.AllowedUserIds").Count; + var allowDms = GetBool(config, $"{provider.SectionName}.AllowDirectMessages", defaultValue: false); + + var parts = new List<string> + { + channelCount > 0 + ? Pluralize(channelCount, "channel", "channels") + : allowDms ? "DMs only" : "no channels" + }; + + if (userCount > 0) + parts.Add(Pluralize(userCount, "user", "users")); + + return string.Join(", ", parts); + } + + private void AddCredentialDetails(List<ChannelsConfigDetail> details, ChannelProviderSpec provider) + { + foreach (var path in provider.SecretPaths) + { + var label = path switch + { + "Slack.BotToken" => "Bot token", + "Slack.AppToken" => "App token", + "Discord.BotToken" => "Bot token", + "Mattermost.BotToken" => "Bot token", + _ => path + }; + + details.Add(new ChannelsConfigDetail(label, ConfigFileHelper.SecretPresent(_paths, path) ? "configured" : "missing")); + } + } + + private bool HasAnySecret(IReadOnlyList<string> paths) + => paths.Any(path => ConfigFileHelper.SecretPresent(_paths, path)); + + private static bool SectionPresent(Dictionary<string, object> config, string sectionName) + { + if (!ConfigFileHelper.TryGetPathValue(config, sectionName, out var value) || value is null) + return false; + + if (value is Dictionary<string, object>) + return true; + + throw new InvalidOperationException($"Configuration section '{sectionName}' must be an object."); + } + + private static bool GetBool(Dictionary<string, object> config, string path, bool defaultValue) + { + if (!ConfigFileHelper.TryGetPathValue(config, path, out var value) || value is null) + return defaultValue; + + return value is bool boolValue + ? boolValue + : throw new InvalidOperationException($"Configuration value '{path}' must be a boolean."); + } + + private static string? GetString(Dictionary<string, object> config, string path) + { + if (!ConfigFileHelper.TryGetPathValue(config, path, out var value) || value is null) + return null; + + return value is string stringValue + ? stringValue + : throw new InvalidOperationException($"Configuration value '{path}' must be a string."); + } + + private static IReadOnlyList<string> ReadConfiguredChannels(Dictionary<string, object> config, string sectionName) + { + var channels = new List<string>(); + channels.AddRange(GetStringArray(config, $"{sectionName}.AllowedChannelIds")); + + var defaultChannelId = GetString(config, $"{sectionName}.DefaultChannelId"); + if (!string.IsNullOrWhiteSpace(defaultChannelId)) + channels.Add(defaultChannelId); + + if (string.Equals(sectionName, "Slack", StringComparison.Ordinal)) + { + var defaultChannelName = GetString(config, "Slack.DefaultChannelName"); + if (!string.IsNullOrWhiteSpace(defaultChannelName)) + channels.Add(defaultChannelName.StartsWith('#') ? defaultChannelName : $"#{defaultChannelName}"); + } + + return channels + .Where(static value => !string.IsNullOrWhiteSpace(value)) + .Distinct(StringComparer.Ordinal) + .ToArray(); + } + + private static IReadOnlyList<string> GetStringArray(Dictionary<string, object> config, string path) + { + if (!ConfigFileHelper.TryGetPathValue(config, path, out var value) || value is null) + return []; + + if (value is object[] objectValues) + { + return objectValues + .Select(static item => item switch + { + string stringValue => stringValue, + JsonElement { ValueKind: JsonValueKind.String } element => element.GetString()!, + _ => throw new InvalidOperationException("Channel list values must be strings.") + }) + .Where(static item => !string.IsNullOrWhiteSpace(item)) + .ToArray(); + } + + if (value is string[] stringValues) + return stringValues.Where(static item => !string.IsNullOrWhiteSpace(item)).ToArray(); + + throw new InvalidOperationException($"Configuration value '{path}' must be an array of strings."); + } + + private static int GetDictionaryCount(Dictionary<string, object> config, string path) + { + if (!ConfigFileHelper.TryGetPathValue(config, path, out var value) || value is null) + return 0; + + return value is Dictionary<string, object> dict + ? dict.Count + : throw new InvalidOperationException($"Configuration value '{path}' must be an object."); + } + + private static string FormatDefaultChannel(Dictionary<string, object> config, string sectionName) + { + var id = GetString(config, $"{sectionName}.DefaultChannelId"); + if (!string.IsNullOrWhiteSpace(id)) + return id; + + if (string.Equals(sectionName, "Slack", StringComparison.Ordinal)) + { + var name = GetString(config, "Slack.DefaultChannelName"); + if (!string.IsNullOrWhiteSpace(name)) + return name.StartsWith('#') ? name : $"#{name}"; + } + + return "not set"; + } + + private static string FormatOptional(string? value) + => string.IsNullOrWhiteSpace(value) ? "not set" : value; + + private static string FormatCount(int count, string suffix) + => count == 0 ? "none" : $"{count} {suffix}"; + + private static string Pluralize(int count, string singular, string plural) + => count == 1 ? $"1 {singular}" : $"{count} {plural}"; + + private sealed record ChannelProviderSpec( + ChannelsConfigProvider Provider, + string Label, + string Description, + string SectionName, + IReadOnlyList<string> SecretPaths); +} diff --git a/src/Netclaw.Cli/Tui/ConfigDashboardViewModel.cs b/src/Netclaw.Cli/Tui/ConfigDashboardViewModel.cs index fc818d0bf..1f7a057cd 100644 --- a/src/Netclaw.Cli/Tui/ConfigDashboardViewModel.cs +++ b/src/Netclaw.Cli/Tui/ConfigDashboardViewModel.cs @@ -45,7 +45,7 @@ public ConfigDashboardViewModel(ConfigDashboardNavigationState navigationState) [ new("Inference Providers", "Manage provider definitions and authentication.", "/provider"), new("Models", "Assign model roles and discover provider models.", "/model"), - new("Channels", "Slack, Discord, and Mattermost settings."), + new("Channels", "Slack, Discord, and Mattermost settings.", "/channels"), new("Inbound Webhooks", "Configure inbound webhook routes and verification."), new("Skill Sources", "External skills and private skill feeds."), new("Search", "Search backend and credentials.", "/search"), diff --git a/tests/smoke/assertions/config-channels.sh b/tests/smoke/assertions/config-channels.sh new file mode 100755 index 000000000..1e4164108 --- /dev/null +++ b/tests/smoke/assertions/config-channels.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# config-channels.tape post-tape assertion. +# +# Validates the read-only Channels page did not mutate seeded channel config. + +set -euo pipefail + +. "$(dirname "$0")/_lib.sh" + +assert_fail=0 + +echo "config-channels: reading produced config..." +if [[ ! -f "$CONFIG_PATH" ]]; then + echo "FAIL: ${CONFIG_PATH} does not exist." >&2 + exit 1 +fi + +config_json="$(read_config_json)" + +assert_field '.Slack.Enabled' 'true' "$config_json" || : +assert_field '(.Slack.AllowedChannelIds | length)' '2' "$config_json" || : +assert_field '(.Slack.AllowedUserIds | length)' '1' "$config_json" || : +assert_field '.Mattermost.DefaultChannelId' 'town-square' "$config_json" || : + +if (( assert_fail )); then + printf -- '--- netclaw.json contents ---\n%s\n' "$config_json" >&2 + exit 1 +fi + +echo "config-channels: assertions passed." diff --git a/tests/smoke/tapes/config-channels.tape b/tests/smoke/tapes/config-channels.tape new file mode 100644 index 000000000..fb445da28 --- /dev/null +++ b/tests/smoke/tapes/config-channels.tape @@ -0,0 +1,46 @@ +# config-channels.tape - open Channels from netclaw config. +# +# Exercises: +# netclaw config -> Channels -> Slack details -> back to dashboard +# and verifies the read-only channel summary page can render existing config. + +Output "/tmp/tape-config-channels.gif" + +# Seed channel config so `netclaw config` can render useful summaries. +Type "mkdir -p $NETCLAW_HOME/config" +Enter +Type "c1=C01 c2=C02 u1=U01 mm=town-square; jq -n --arg c1 $c1 --arg c2 $c2 --arg u1 $u1 --arg mm $mm '{configVersion:1,Slack:{Enabled:true,AllowedChannelIds:[$c1,$c2],AllowedUserIds:[$u1]},Mattermost:{Enabled:true,DefaultChannelId:$mm}}' > $NETCLAW_HOME/config/netclaw.json" +Enter +Type "bot=xoxb-test app=xapp-test mm_token=mattermost-test; jq -n --arg bot $bot --arg app $app --arg mm_token $mm_token '{configVersion:1,Slack:{BotToken:$bot,AppToken:$app},Mattermost:{BotToken:$mm_token}}' > $NETCLAW_HOME/config/secrets.json" +Enter + +Type "netclaw config" +Enter +Wait+Screen@10s /Settings Areas/ + +# Channels is row 3 in the dashboard list. +Down 2 +Enter +Wait+Screen@10s /Chat Channels/ +Wait+Screen@10s /Slack/ +Wait+Screen@10s /2 channels, 1 user/ + +Enter +Wait+Screen@10s /Slack Channels/ +Wait+Screen@10s /Bot token/ +Wait+Screen@10s /Allowed channels[[:space:]]+2 configured/ + +Escape +Wait+Screen@10s /Chat Channels/ +Escape +Wait+Screen@10s /Settings Areas/ +Ctrl+Q + +Wait+Screen@10s /TAPE\$/ + +Type "echo CONFIG_CHANNELS_EXIT=$?" +Enter +Wait+Screen@5s /CONFIG_CHANNELS_EXIT=0/ + +Type "exit" +Enter From cf0b7f445adbfc699685a58ee0f39c79a96036be Mon Sep 17 00:00:00 2001 From: Aaron Stannard <aaron@petabridge.com> Date: Sun, 31 May 2026 14:37:17 +0000 Subject: [PATCH 26/31] feat(config): add channels management editor --- docs/ui/TUI-002-netclaw-config-wireframes.md | 279 ++- .../Config/ChannelsConfigNavigationTests.cs | 3 +- .../Config/ChannelsConfigViewModelTests.cs | 380 +++- src/Netclaw.Cli/Program.cs | 2 + .../Tui/Config/ChannelsConfigPage.cs | 687 ++++++- .../Tui/Config/ChannelsConfigViewModel.cs | 1614 +++++++++++++++-- .../Tui/Wizard/Steps/ChannelPickerStepView.cs | 4 +- .../Steps/ChannelPickerStepViewModel.cs | 78 +- .../Tui/Wizard/Steps/DiscordStepView.cs | 17 +- .../Tui/Wizard/Steps/DiscordStepViewModel.cs | 1 + .../Tui/Wizard/Steps/MattermostStepView.cs | 17 +- .../Wizard/Steps/MattermostStepViewModel.cs | 1 + .../Tui/Wizard/Steps/SlackStepView.cs | 34 +- .../Tui/Wizard/Steps/SlackStepViewModel.cs | 2 + tests/smoke/assertions/config-channels.sh | 24 +- tests/smoke/tapes/config-channels.tape | 56 +- 16 files changed, 2768 insertions(+), 431 deletions(-) diff --git a/docs/ui/TUI-002-netclaw-config-wireframes.md b/docs/ui/TUI-002-netclaw-config-wireframes.md index 4d17867df..10eeb8c9e 100644 --- a/docs/ui/TUI-002-netclaw-config-wireframes.md +++ b/docs/ui/TUI-002-netclaw-config-wireframes.md @@ -346,21 +346,134 @@ to stderr and exits non-zero. ## Config.3 — Channels -### 3.1 Channels sub-page +### 3.1 Channels picker ``` ╭─ Channels ──────────────────────────────────────────────────╮ │ │ -│ ▸ Slack 3 channels, 2 users │ -│ Discord not configured │ -│ Mattermost not configured │ +│ Which channels would you like to connect? │ │ │ -│ [ Open ] [ Back ] │ +│ ▶ [✓] Slack 2 channels, 1 user │ +│ [ ] Discord disabled, saved setup │ +│ [ ] Mattermost │ │ │ -│ ↑/↓ navigate · Enter open · Esc back │ +│ ↑/↓ to navigate, Space to toggle, Enter to open selected. │ +│ Unconfigured adapters open first-time setup. Configured │ +│ adapters open management without prompting for credentials.│ +│ │ +│ ↑/↓ navigate · Space toggle · Enter open · d save │ +╰─────────────────────────────────────────────────────────────╯ +``` + +Unconfigured adapters reuse the original `netclaw init` sub-flow visuals: + +- Slack: bot token -> Socket Mode app token -> channel names/IDs -> DMs -> + user access choice -> allowed user IDs when restricted. +- Discord: bot token -> channel IDs -> DMs -> user access choice -> allowed + user IDs when restricted. +- Mattermost: server URL -> bot token -> channel IDs -> DMs -> user access + choice -> allowed user IDs when restricted -> optional callback URL. + +**Save model:** First-time setup sub-flows update in-memory state, then drop +the operator directly into Channels & Permissions so every new channel gets an +explicit audience before save. Disk write happens only when the operator +returns to the picker and presses `d`/Done. The save uses the shared +config-editor merge pipeline, preserving unrelated config and secrets. + +**Secret reentrancy:** Configured adapters do not ask for credentials on +normal re-entry. Secret fields are shown only from first-time setup or explicit +Rotate credentials. If a stored secret exists, the field shows +`(configured - leave blank to keep)`. Blank submission preserves the existing +secret; entering a new value replaces it. + +**Disabled adapters:** Toggling off a previously configured adapter writes +`<Adapter>.Enabled = false` and preserves dormant channel/user fields plus +stored credentials. The daemon ignores those fields while the adapter is +disabled. + +**Validation:** Save blocks missing required credentials for enabled adapters +and invalid Mattermost server URLs. Connection probes remain doctor-owned in +this first pass. + +### 3.2 Adapter management menu + +``` +╭─ Channels ──────────────────────────────────────────────────╮ +│ │ +│ Slack is configured. │ +│ enabled · bot token configured · app token configured · │ +│ 2 channels · 1 user · DMs disabled │ +│ │ +│ What would you like to do? │ +│ │ +│ ▶ Manage channels and permissions │ +│ Add a Slack channel │ +│ Manage allowed users │ +│ Direct messages │ +│ Rotate credentials │ +│ Disable Slack │ +│ Reset Slack connection │ +│ │ +│ ↑/↓ navigate · Enter select · Esc Channels │ ╰─────────────────────────────────────────────────────────────╯ ``` +The same menu is used for Slack, Discord, and Mattermost. Disable/enable only +changes `<Adapter>.Enabled`; dormant channel fields and stored credentials are +preserved. Reset stages deletion of the adapter config section and secrets, +then returns to the picker. The deletion is written only when the operator +saves from the picker. + +### 3.3 Channels and permissions + +``` +╭─ Channels ──────────────────────────────────────────────────╮ +│ │ +│ Slack > Channels & Permissions │ +│ Configure allowed channels and their audience/trust level. │ +│ │ +│ ▶ C01 C01 [◀ Team ▶]│ +│ C02 C02 [◀ Team ▶]│ +│ Direct messages dm [◀ Personal ▶]│ +│ + Add channel │ +│ │ +│ Audience controls which tools and data this channel can use│ +│ │ +│ ↑/↓ navigate · ←/→ audience · Enter edit · a add · d remove │ +╰─────────────────────────────────────────────────────────────╯ +``` + +Channel rows write `<Adapter>.AllowedChannelIds` and +`<Adapter>.ChannelAudiences[channelId]`. The DM row writes +`<Adapter>.AllowDirectMessages` plus `<Adapter>.ChannelAudiences["dm"]`. +Removing a channel removes both the channel ID and its audience mapping. DM +audience is preserved when DMs are disabled so re-enabling DMs restores the +operator's last chosen audience. + +### 3.4 Credentials and reset + +``` +╭─ Channels ──────────────────────────────────────────────────╮ +│ │ +│ Slack > Credentials │ +│ Secret fields are blank by design. Leave blank to keep │ +│ existing secrets. │ +│ │ +│ Bot token: │ +│ ╭─ Bot token ────────────────────────────────────────────╮ │ +│ │ │ │ +│ ╰───────────────────────────────────────────────────────╯ │ +│ configured - leave blank to keep │ +│ │ +│ Tab field · Enter apply · Esc menu │ +╰─────────────────────────────────────────────────────────────╯ +``` + +Slack exposes bot token and Socket Mode app token. Discord exposes bot token. +Mattermost exposes server URL, bot token, and optional callback URL. Blank +secret submissions preserve existing secrets; non-blank secret submissions +replace only that secret. + --- ## Config.5 — Skill Sources @@ -437,160 +550,6 @@ based on `ConfigFileHelper.SecretPresent(...)`. --- -## Config.3.2 — Slack Channels - -### 3.2.1 Main editor - -``` -╭─ Slack Channels ────────────────────────────────────────────╮ -│ │ -│ Bot token: │ -│ ╭────────────────────────────────────────────────────────╮ │ -│ │ │ │ -│ ╰────────────────────────────────────────────────────────╯ │ -│ (configured — leave blank to keep) │ -│ │ -│ App token (Socket Mode): │ -│ ╭────────────────────────────────────────────────────────╮ │ -│ │ │ │ -│ ╰────────────────────────────────────────────────────────╯ │ -│ (configured — leave blank to keep) │ -│ │ -│ Allowed channels: 3 configured → │ -│ Allowed users: 2 configured → │ -│ DMs enabled: [ X ] yes │ -│ Audience profile: Personal │ -│ │ -│ [ Save ] [ Cancel ] [ Test connection ] │ -│ [ Remove credentials ] │ -│ │ -│ Tab next · Enter activate · Esc cancel │ -╰─────────────────────────────────────────────────────────────╯ -``` - -Sub-pages: -- "Allowed channels" → 3.2.2 list editor. -- "Allowed users" → 3.2.3 list editor. - -### 3.2.2 Allowed channels list (T2) - -``` -╭─ Slack Channels › Allowed channel IDs ──────────────────────╮ -│ │ -│ ▸ C01ABCDE │ -│ C01FGHIJ │ -│ C01KLMNO │ -│ │ -│ + Add channel ID │ -│ │ -│ [ Save ] [ Cancel ] │ -│ │ -│ ↑/↓ navigate · Enter edit · d remove · Esc cancel │ -╰─────────────────────────────────────────────────────────────╯ -``` - -`Save` here is "apply to in-memory state and return to 3.2.1." Disk write -happens when 3.2.1 itself saves. - -### 3.2.3 Allowed users list - -Same shape as 2.2 with user IDs. Uses `IdentifierItemEditor`. - -### 3.2.4 Test connection (inline banner) - -Runs the existing Slack probe logic from `SlackStepViewModel`; result -rendered in an inline banner above the action row: - -``` -│ ╭─ Connection test ──────────────────────────────────────╮ │ -│ │ ✓ Bot token valid (workspace: petabridge) │ │ -│ │ ✓ Socket Mode app token valid │ │ -│ │ ✓ Bot has access to 3 of 3 configured channels │ │ -│ ╰────────────────────────────────────────────────────────╯ │ -``` - -Failure shape: - -``` -│ ╭─ Connection test ──────────────────────────────────────╮ │ -│ │ ✗ Bot token invalid: 401 invalid_auth │ │ -│ │ Check `xoxb-` token in the Slack app config │ │ -│ ╰────────────────────────────────────────────────────────╯ │ -``` - -Test results never modify config; they're advisory before Save. - -### 3.2.5 Remove credentials confirm (T5) - -``` -╭─ Remove Slack credentials? ─────────────────────────────────╮ -│ │ -│ This deletes both the Slack bot token and the Socket │ -│ Mode app token from secrets.json. Slack will be │ -│ disconnected until you re-enter both. Allowed channels │ -│ and users are preserved in netclaw.json. │ -│ │ -│ ▸ [ Cancel ] [ Yes, remove ] │ -│ │ -│ Default: Cancel (Esc or Enter) │ -╰─────────────────────────────────────────────────────────────╯ -``` - -**Doctor checks:** `ConfigSchemaDoctorCheck`, `SlackAuthDoctorCheck`, -`SlackAclDoctorCheck`. - ---- - -## Config.3.3 — Discord Channels - -Structurally identical to 2.x except: -- Single token field (bot token only; no app token). -- Otherwise: allowed channels list, allowed users list, DMs toggle, - audience profile, test connection, remove credentials. - -(Layouts identical to 3.2.1–3.2.5 with the App token row removed.) - -**Doctor checks:** `ConfigSchemaDoctorCheck`, `DiscordAuthDoctorCheck`. - ---- - -## Config.3.4 — Mattermost Channels - -Structurally identical to 2.x plus: -- `Server URL` text field at the top. -- Same token, channels, users, DMs, audience profile, test connection, - remove credentials. - -``` -╭─ Mattermost Channels ───────────────────────────────────────╮ -│ │ -│ Server URL: │ -│ ╭────────────────────────────────────────────────────────╮ │ -│ │ https://chat.example.com │ │ -│ ╰────────────────────────────────────────────────────────╯ │ -│ │ -│ Bot token: │ -│ ╭────────────────────────────────────────────────────────╮ │ -│ │ │ │ -│ ╰────────────────────────────────────────────────────────╯ │ -│ (configured — leave blank to keep) │ -│ │ -│ Allowed channels: 5 configured → │ -│ Allowed users: 3 configured → │ -│ DMs enabled: [ X ] yes │ -│ Audience profile: Team │ -│ │ -│ [ Save ] [ Cancel ] [ Test connection ] │ -│ [ Remove credentials ] │ -│ │ -│ Tab next · Enter activate · Esc cancel │ -╰─────────────────────────────────────────────────────────────╯ -``` - -**Doctor checks:** `ConfigSchemaDoctorCheck`, `MattermostAuthDoctorCheck`. - ---- - ## Config.9.5 — Exposure Mode ### 9.5.1 Mode selection diff --git a/src/Netclaw.Cli.Tests/Tui/Config/ChannelsConfigNavigationTests.cs b/src/Netclaw.Cli.Tests/Tui/Config/ChannelsConfigNavigationTests.cs index 0d3900f4d..a338764a6 100644 --- a/src/Netclaw.Cli.Tests/Tui/Config/ChannelsConfigNavigationTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/Config/ChannelsConfigNavigationTests.cs @@ -4,6 +4,7 @@ // </copyright> // ----------------------------------------------------------------------- using Microsoft.Extensions.DependencyInjection; +using Netclaw.Cli.Tests.Tui; using Netclaw.Cli.Tui; using Netclaw.Cli.Tui.Config; using Netclaw.Configuration; @@ -89,7 +90,7 @@ private TerminaApplication CreateHeadlessApp( _ => new ChannelsConfigPage(), _ => { - capturedChannelsVm = new ChannelsConfigViewModel(_paths, tuiNavigation); + capturedChannelsVm = new ChannelsConfigViewModel(_paths, new FakeSlackProbe(), new FakeDiscordProbe(), tuiNavigation); return capturedChannelsVm; }); }); diff --git a/src/Netclaw.Cli.Tests/Tui/Config/ChannelsConfigViewModelTests.cs b/src/Netclaw.Cli.Tests/Tui/Config/ChannelsConfigViewModelTests.cs index a03ebd1b1..ae63ad641 100644 --- a/src/Netclaw.Cli.Tests/Tui/Config/ChannelsConfigViewModelTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/Config/ChannelsConfigViewModelTests.cs @@ -3,7 +3,12 @@ // Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com> // </copyright> // ----------------------------------------------------------------------- +using System.Text.Json; +using Netclaw.Actors.Channels; +using Netclaw.Cli.Config; +using Netclaw.Cli.Tests.Tui; using Netclaw.Cli.Tui.Config; +using Netclaw.Cli.Tui.Wizard.Steps; using Netclaw.Configuration; using Netclaw.Tests.Utilities; using Xunit; @@ -24,81 +29,341 @@ public ChannelsConfigViewModelTests() public void Dispose() => _dir.Dispose(); [Fact] - public void Channels_page_lists_supported_chat_adapters() + public void Channels_editor_hosts_original_channel_picker_adapters() { - using var vm = new ChannelsConfigViewModel(_paths); + using var vm = CreateViewModel(); - var labels = vm.Items.Select(static item => item.Label).ToArray(); + var labels = vm.Step.Adapters.Select(static item => item.DisplayName).ToArray(); Assert.Equal(["Slack", "Discord", "Mattermost"], labels); } [Fact] - public void Provider_summaries_reflect_current_config() + public void Existing_config_prefills_picker_and_adapter_drafts() { WriteChannelConfig(); WriteChannelSecrets(); - using var vm = new ChannelsConfigViewModel(_paths); + using var vm = CreateViewModel(); - var summaries = vm.Items.ToDictionary(static item => item.Label, static item => item.Summary); + var slack = vm.Step.GetAdapterViewModel<SlackStepViewModel>(ChannelType.Slack); + var mattermost = vm.Step.GetAdapterViewModel<MattermostStepViewModel>(ChannelType.Mattermost); - Assert.Equal("3 channels, 2 users", summaries["Slack"]); - Assert.Equal("disabled", summaries["Discord"]); - Assert.Equal("1 channel", summaries["Mattermost"]); + Assert.True(vm.Step.IsAdapterEnabled(ChannelType.Slack)); + Assert.False(vm.Step.IsAdapterEnabled(ChannelType.Discord)); + Assert.True(vm.Step.IsAdapterEnabled(ChannelType.Mattermost)); + Assert.Equal("3 channels, 2 users", vm.Step.GetAdapterSummary(0)); + Assert.Equal("disabled, saved setup", vm.Step.GetAdapterSummary(1)); + Assert.Equal("1 channel", vm.Step.GetAdapterSummary(2)); + Assert.True(slack.HasPersistedBotToken); + Assert.True(slack.HasPersistedAppToken); + Assert.Equal("C01, C02, C03", slack.ChannelNamesInput); + Assert.Equal("U01, U02", slack.AllowedUserIdsInput); + Assert.Equal("https://mattermost.example.com", mattermost.ServerUrl); + Assert.True(mattermost.HasPersistedBotToken); } [Fact] - public void Missing_provider_reports_not_configured() + public void Save_preserves_blank_existing_secrets_and_updates_config() + { + WriteChannelConfig(); + WriteChannelSecrets(); + using var vm = CreateViewModel(); + var slack = vm.Step.GetAdapterViewModel<SlackStepViewModel>(ChannelType.Slack); + slack.ChannelNamesInput = "C09"; + slack.AllowedUserIdsInput = "U09"; + + vm.Save(); + + var config = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Slack.AllowedChannelIds", out var channelsRaw)); + Assert.Equal(["C09"], ToStringArray(channelsRaw)); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Slack.DefaultChannelId", out var defaultChannel)); + Assert.Equal("C09", defaultChannel); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Slack.AllowedUserIds", out var usersRaw)); + Assert.Equal(["U09"], ToStringArray(usersRaw)); + + var secrets = ConfigFileHelper.LoadJsonDict(_paths.SecretsPath); + Assert.True(ConfigFileHelper.TryGetPathValue(secrets, "Slack.BotToken", out var botToken)); + Assert.Equal("xoxb-test", ConfigFileHelper.DecryptIfEncrypted(_paths, botToken?.ToString())); + Assert.True(ConfigFileHelper.TryGetPathValue(secrets, "Slack.AppToken", out var appToken)); + Assert.Equal("xapp-test", ConfigFileHelper.DecryptIfEncrypted(_paths, appToken?.ToString())); + } + + [Fact] + public void Save_sets_new_secret_without_serializing_plaintext() { File.WriteAllText(_paths.NetclawConfigPath, """ { - "configVersion": 1, - "Slack": { "Enabled": true, "AllowDirectMessages": true } + "configVersion": 1 } """); - using var vm = new ChannelsConfigViewModel(_paths); + using var vm = CreateViewModel(); + vm.Step.LoadAdapterState(ChannelType.Discord, enabled: true, summary: "1 channel", adapter => + { + var discord = (DiscordStepViewModel)adapter; + discord.DiscordEnabled = true; + discord.BotToken = "new-discord-token"; + discord.ChannelIdsInput = "123456789"; + }); - var summaries = vm.Items.ToDictionary(static item => item.Label, static item => item.Summary); + vm.Save(); - Assert.Equal("DMs only", summaries["Slack"]); - Assert.Equal("not configured", summaries["Discord"]); - Assert.Equal("not configured", summaries["Mattermost"]); + var serializedSecrets = File.ReadAllText(_paths.SecretsPath); + Assert.DoesNotContain("new-discord-token", serializedSecrets, StringComparison.Ordinal); + var secrets = ConfigFileHelper.LoadJsonDict(_paths.SecretsPath); + Assert.True(ConfigFileHelper.TryGetPathValue(secrets, "Discord.BotToken", out var token)); + Assert.Equal("new-discord-token", ConfigFileHelper.DecryptIfEncrypted(_paths, token?.ToString())); } [Fact] - public void Provider_details_show_config_and_secret_state() + public void Save_disabled_existing_provider_preserves_dormant_fields_and_secrets() { WriteChannelConfig(); WriteChannelSecrets(); - using var vm = new ChannelsConfigViewModel(_paths); - vm.SelectedIndex.Value = 0; + using var vm = CreateViewModel(); + + vm.Step.ToggleAdapter(0); + vm.Save(); + + var config = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Slack.Enabled", out var enabled)); + Assert.False(Assert.IsType<bool>(enabled)); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Slack.AllowedChannelIds", out var channelsRaw)); + Assert.Equal(3, ToStringArray(channelsRaw).Length); + + var secrets = ConfigFileHelper.LoadJsonDict(_paths.SecretsPath); + Assert.True(ConfigFileHelper.TryGetPathValue(secrets, "Slack.BotToken", out var botToken)); + Assert.Equal("xoxb-test", ConfigFileHelper.DecryptIfEncrypted(_paths, botToken?.ToString())); + } + + [Fact] + public void Save_blocks_enabled_provider_with_missing_required_secret() + { + File.WriteAllText(_paths.NetclawConfigPath, + """ + { + "configVersion": 1 + } + """); + using var vm = CreateViewModel(); + vm.Step.LoadAdapterState(ChannelType.Slack, enabled: true, summary: "configured", adapter => + { + var slack = (SlackStepViewModel)adapter; + slack.SlackEnabled = true; + slack.AppToken = "xapp-test"; + }); - vm.OpenSelectedProvider(); + vm.Save(); - var details = vm.SelectedDetails.ToDictionary(static detail => detail.Label, static detail => detail.Value); - Assert.Equal(ChannelsConfigMode.Details, vm.Mode.Value); - Assert.Equal("enabled", details["Status"]); - Assert.Equal("configured", details["Bot token"]); - Assert.Equal("configured", details["App token"]); - Assert.Equal("3 configured", details["Allowed channels"]); - Assert.Equal("2 configured", details["Allowed users"]); - Assert.Equal("enabled", details["DMs"]); - Assert.Equal("2 configured", details["Audience overrides"]); + Assert.False(vm.IsSaved.Value); + Assert.Equal("Slack bot token is required.", vm.Status.Value.Text); } [Fact] - public void Back_from_details_returns_to_provider_list() + public void Back_from_saved_returns_to_channel_picker() { - using var vm = new ChannelsConfigViewModel(_paths); - vm.OpenSelectedProvider(); + WriteChannelConfig(); + WriteChannelSecrets(); + using var vm = CreateViewModel(); + vm.Save(); vm.GoBack(); - Assert.Equal(ChannelsConfigMode.Providers, vm.Mode.Value); + Assert.False(vm.IsSaved.Value); Assert.False(vm.ShutdownRequestedForTest); } + [Fact] + public void Configured_adapter_opens_management_menu_without_token_subflow() + { + WriteChannelConfig(); + WriteChannelSecrets(); + using var vm = CreateViewModel(); + + Assert.True(vm.TryOpenSelectedAdapterManagement()); + + Assert.Equal(ChannelsConfigScreen.AdapterMenu, vm.Screen.Value); + Assert.False(vm.Step.IsInSubFlow); + Assert.Equal(ChannelType.Slack, vm.ActiveAdapterType); + } + + [Fact] + public void First_time_adapter_setup_opens_channel_permissions_before_save() + { + using var vm = CreateViewModel(); + vm.Step.ToggleAdapter(0); + var slack = vm.Step.GetAdapterViewModel<SlackStepViewModel>(ChannelType.Slack); + slack.BotToken = "xoxb-test"; + slack.AppToken = "xapp-test"; + slack.ChannelNamesInput = "C01"; + + for (var i = 0; i < 5; i++) + vm.GoNext(); + + Assert.Equal(ChannelsConfigScreen.ChannelPermissions, vm.Screen.Value); + Assert.Equal(ChannelType.Slack, vm.ActiveAdapterType); + Assert.Contains(vm.GetChannelRows(), row => row.Id == "C01" && !row.IsAddAction); + } + + [Fact] + public void Add_channel_preserves_credentials_and_writes_channel_audience() + { + WriteChannelConfig(); + WriteChannelSecrets(); + using var vm = CreateViewModel(); + vm.OpenAdapterManagement(ChannelType.Slack); + vm.BeginAddChannel(); + vm.AddChannelInput = "C09"; + vm.MoveAddChannelAudience(-1); // Team default -> Personal. + + vm.ApplyAddChannel(); + vm.Save(); + + var config = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Slack.AllowedChannelIds", out var channelsRaw)); + Assert.Equal(["C01", "C02", "C03", "C09"], ToStringArray(channelsRaw)); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Slack.ChannelAudiences", out var audiencesRaw)); + var audiences = ToStringDictionary(audiencesRaw); + Assert.Equal("personal", audiences["C09"]); + + var secrets = ConfigFileHelper.LoadJsonDict(_paths.SecretsPath); + Assert.True(ConfigFileHelper.TryGetPathValue(secrets, "Slack.BotToken", out var botToken)); + Assert.Equal("xoxb-test", ConfigFileHelper.DecryptIfEncrypted(_paths, botToken?.ToString())); + } + + [Fact] + public void Edit_channel_audience_writes_channel_audiences() + { + WriteChannelConfig(); + WriteChannelSecrets(); + using var vm = CreateViewModel(); + vm.OpenAdapterManagement(ChannelType.Slack); + + vm.OpenSelectedChannelAudience(); + vm.MoveAudienceSelection(1); // C01 Team -> Public. + vm.ApplyAudienceSelection(); + vm.Save(); + + var config = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Slack.ChannelAudiences", out var audiencesRaw)); + Assert.Equal("public", ToStringDictionary(audiencesRaw)["C01"]); + } + + [Fact] + public void Direct_message_audience_is_saved_without_touching_channels() + { + WriteChannelConfig(); + WriteChannelSecrets(); + using var vm = CreateViewModel(); + vm.OpenAdapterManagement(ChannelType.Slack); + vm.BeginDirectMessages(); + vm.ChangeDirectMessageAudience(1); // Personal -> Team. + + vm.ApplyDirectMessages(); + vm.Save(); + + var config = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Slack.AllowDirectMessages", out var allowDm)); + Assert.True(Assert.IsType<bool>(allowDm)); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Slack.ChannelAudiences", out var audiencesRaw)); + Assert.Equal("team", ToStringDictionary(audiencesRaw)["dm"]); + } + + [Fact] + public void Rotate_credentials_preserves_blank_secret_and_updates_nonblank_secret() + { + WriteChannelConfig(); + WriteChannelSecrets(); + using var vm = CreateViewModel(); + vm.OpenAdapterManagement(ChannelType.Slack); + vm.BeginRotateCredentials(); + vm.BotTokenInput = "xoxb-new"; + vm.AppTokenInput = string.Empty; + + vm.ApplyCredentials(); + vm.Save(); + + var secrets = ConfigFileHelper.LoadJsonDict(_paths.SecretsPath); + Assert.True(ConfigFileHelper.TryGetPathValue(secrets, "Slack.BotToken", out var botToken)); + Assert.Equal("xoxb-new", ConfigFileHelper.DecryptIfEncrypted(_paths, botToken?.ToString())); + Assert.True(ConfigFileHelper.TryGetPathValue(secrets, "Slack.AppToken", out var appToken)); + Assert.Equal("xapp-test", ConfigFileHelper.DecryptIfEncrypted(_paths, appToken?.ToString())); + } + + [Fact] + public void Reset_connection_deletes_config_section_and_secrets_on_save() + { + WriteChannelConfig(); + WriteChannelSecrets(); + using var vm = CreateViewModel(); + vm.OpenAdapterManagement(ChannelType.Slack); + var resetIndex = vm.GetManagementMenuItems() + .Select((item, index) => (item, index)) + .Single(entry => entry.item.Action == ChannelsManagementAction.ResetConnection) + .index; + vm.MoveManagementMenu(resetIndex); + vm.ActivateManagementMenuItem(); + vm.MoveResetConfirmation(1); + + vm.ApplyResetConfirmation(); + vm.Save(); + + var config = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath); + Assert.False(ConfigFileHelper.TryGetPathValue(config, "Slack", out _)); + var secrets = ConfigFileHelper.LoadJsonDict(_paths.SecretsPath); + Assert.False(ConfigFileHelper.TryGetPathValue(secrets, "Slack.BotToken", out _)); + Assert.False(ConfigFileHelper.TryGetPathValue(secrets, "Slack.AppToken", out _)); + } + + [Theory] + [InlineData(ChannelType.Discord, "Discord.AllowedChannelIds", "Discord.ChannelAudiences", "987654321")] + [InlineData(ChannelType.Mattermost, "Mattermost.AllowedChannelIds", "Mattermost.ChannelAudiences", "town-square-2")] + public void Add_channel_management_is_generic_for_discord_and_mattermost( + ChannelType type, + string channelsPath, + string audiencesPath, + string newChannelId) + { + WriteAllChannelConfig(); + WriteAllChannelSecrets(); + using var vm = CreateViewModel(); + vm.OpenAdapterManagement(type); + vm.BeginAddChannel(); + vm.AddChannelInput = newChannelId; + + vm.ApplyAddChannel(); + vm.Save(); + + var config = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath); + Assert.True(ConfigFileHelper.TryGetPathValue(config, channelsPath, out var channelsRaw)); + Assert.Contains(newChannelId, ToStringArray(channelsRaw)); + Assert.True(ConfigFileHelper.TryGetPathValue(config, audiencesPath, out var audiencesRaw)); + Assert.Equal("team", ToStringDictionary(audiencesRaw)[newChannelId]); + } + + private ChannelsConfigViewModel CreateViewModel() + => new(_paths, new FakeSlackProbe(), new FakeDiscordProbe()); + + private static string[] ToStringArray(object? raw) + => Assert.IsType<object[]>(raw).Select(static value => value switch + { + string text => text, + JsonElement { ValueKind: JsonValueKind.String } element => element.GetString()!, + _ => throw new InvalidOperationException("Expected string array value.") + }).ToArray(); + + private static Dictionary<string, string> ToStringDictionary(object? raw) + => Assert.IsType<Dictionary<string, object>>(raw).ToDictionary( + static kv => kv.Key, + static kv => kv.Value switch + { + string text => text, + JsonElement { ValueKind: JsonValueKind.String } element => element.GetString()!, + _ => throw new InvalidOperationException("Expected string dictionary value.") + }, + StringComparer.Ordinal); + private void WriteChannelConfig() { File.WriteAllText(_paths.NetclawConfigPath, @@ -145,4 +410,51 @@ private void WriteChannelSecrets() } """); } + + private void WriteAllChannelConfig() + { + File.WriteAllText(_paths.NetclawConfigPath, + """ + { + "configVersion": 1, + "Slack": { + "Enabled": true, + "SocketMode": true, + "AllowedChannelIds": ["C01"], + "ChannelAudiences": { "C01": "team" } + }, + "Discord": { + "Enabled": true, + "AllowedChannelIds": ["123456789"], + "ChannelAudiences": { "123456789": "team" } + }, + "Mattermost": { + "Enabled": true, + "ServerUrl": "https://mattermost.example.com", + "AllowedChannelIds": ["town-square"], + "ChannelAudiences": { "town-square": "team" } + } + } + """); + } + + private void WriteAllChannelSecrets() + { + File.WriteAllText(_paths.SecretsPath, + """ + { + "configVersion": 1, + "Slack": { + "BotToken": "xoxb-test", + "AppToken": "xapp-test" + }, + "Discord": { + "BotToken": "discord-token" + }, + "Mattermost": { + "BotToken": "mattermost-token" + } + } + """); + } } diff --git a/src/Netclaw.Cli/Program.cs b/src/Netclaw.Cli/Program.cs index e8dcf6731..cd11fb3d4 100644 --- a/src/Netclaw.Cli/Program.cs +++ b/src/Netclaw.Cli/Program.cs @@ -877,6 +877,8 @@ static async Task RunAsync(string[] args) builder.Services.AddSingleton<McpToolPermissionsNavigationState>(); builder.Services.AddSingleton<TuiNavigation>(); builder.Services.AddProviderDescriptors(); + builder.Services.AddHttpClient<ISlackProbe, SlackProbe>(); + builder.Services.AddHttpClient<IDiscordProbe, DiscordProbe>(); builder.Services.AddHttpClient("OAuthDeviceFlow"); builder.Services.AddSingleton(sp => new OAuthDeviceFlowService( diff --git a/src/Netclaw.Cli/Tui/Config/ChannelsConfigPage.cs b/src/Netclaw.Cli/Tui/Config/ChannelsConfigPage.cs index 2f0f94ea7..9cfd79ae9 100644 --- a/src/Netclaw.Cli/Tui/Config/ChannelsConfigPage.cs +++ b/src/Netclaw.Cli/Tui/Config/ChannelsConfigPage.cs @@ -3,6 +3,11 @@ // Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com> // </copyright> // ----------------------------------------------------------------------- +using Netclaw.Actors.Channels; +using Netclaw.Cli.Tui.Wizard; +using Netclaw.Cli.Tui.Wizard.Steps; +using Netclaw.Cli.Tui.Workflow; +using Netclaw.Configuration; using R3; using Termina.Extensions; using Termina.Input; @@ -16,17 +21,40 @@ namespace Netclaw.Cli.Tui.Config; public sealed class ChannelsConfigPage : ReactivePage<ChannelsConfigViewModel> { private DynamicLayoutNode? _contentNode; + private DynamicLayoutNode? _helpTextNode; private DynamicLayoutNode? _keyBindingsNode; + private TextInputNode? _singleInput; + private ChannelsConfigScreen? _singleInputScreen; + private string? _singleInputKey; + private readonly Dictionary<string, TextInputNode> _credentialInputs = []; + private ChannelType? _credentialInputAdapter; + private readonly CompositeDisposable _stepSubs = []; protected override void OnBound() { base.OnBound(); + ViewModel.Input.OfType<IInputEvent, KeyPressed>() .Subscribe(HandleKeyPress) .DisposeWith(Subscriptions); - ViewModel.Mode.Subscribe(_ => InvalidateAll()).DisposeWith(Subscriptions); - ViewModel.SelectedIndex.Subscribe(_ => _contentNode?.Invalidate()).DisposeWith(Subscriptions); + ViewModel.Input.OfType<IInputEvent, PasteEvent>() + .Subscribe(HandlePaste) + .DisposeWith(Subscriptions); + + ViewModel.IsSaved.Subscribe(_ => InvalidateAll()).DisposeWith(Subscriptions); + ViewModel.Screen.Subscribe(_ => + { + ResetTextInputs(); + InvalidateAll(); + }).DisposeWith(Subscriptions); + ViewModel.Status.Subscribe(_ => _contentNode?.Invalidate()).DisposeWith(Subscriptions); + ViewModel.OnStepContentChanged = () => + { + _contentNode?.Invalidate(); + _helpTextNode?.Invalidate(); + _keyBindingsNode?.Invalidate(); + }; } public override ILayoutNode BuildLayout() @@ -36,130 +64,711 @@ private ILayoutNode BuildInnerLayout() => Layouts.Vertical() .WithSpacing(1) .WithChild(BuildContent()) + .WithChild(BuildHelpText()) .WithChild(Layouts.Empty().Fill()) .WithChild(BuildStatusBar()) .WithChild(BuildKeyBindings()); - private ILayoutNode BuildContent() + private LayoutNode BuildContent() { - _contentNode = new DynamicLayoutNode(() => ViewModel.Mode.Value switch + _contentNode = new DynamicLayoutNode(() => { - ChannelsConfigMode.Details => BuildProviderDetails(), - _ => BuildProviderList() + if (ViewModel.IsSaved.Value) + { + return WorkflowViewComponents.BuildSavedScreen( + "Channel settings saved.", + "Press Enter to return to Settings Areas or Esc to review channels."); + } + + if (ViewModel.Screen.Value != ChannelsConfigScreen.Picker) + { + _stepSubs.Clear(); + ViewModel.StepView.ClearFocusState(); + return ViewModel.Screen.Value switch + { + ChannelsConfigScreen.AdapterMenu => BuildAdapterMenu(), + ChannelsConfigScreen.ChannelPermissions => BuildChannelPermissions(), + ChannelsConfigScreen.EditAudience => BuildEditAudience(), + ChannelsConfigScreen.AddChannel => BuildAddChannel(), + ChannelsConfigScreen.AllowedUsers => BuildAllowedUsers(), + ChannelsConfigScreen.DirectMessages => BuildDirectMessages(), + ChannelsConfigScreen.RotateCredentials => BuildRotateCredentials(), + ChannelsConfigScreen.ResetConfirm => BuildResetConfirmation(), + _ => Layouts.Empty() + }; + } + + if (!ViewModel.StepView.ManagesOwnFocusState) + { + _stepSubs.Clear(); + ViewModel.StepView.ClearFocusState(); + } + + return ViewModel.StepView.BuildContent(ViewModel.Step, CreateCallbacks()); }); return _contentNode; } - private ILayoutNode BuildProviderList() + private ILayoutNode BuildAdapterMenu() { var layout = Layouts.Vertical() - .WithChild(Header(" Chat Channels")) - .WithChild(Hint(" Configure transport-specific chat adapters.")) + .WithChild(Header($" {ViewModel.ActiveAdapterName} is configured.")) + .WithChild(Hint($" {ViewModel.GetActiveAdapterSummary()}")) + .WithChild(Layouts.Empty().Height(1)) + .WithChild(new TextNode(" What would you like to do?").WithForeground(Color.White)) .WithChild(Layouts.Empty().Height(1)); - var items = ViewModel.Items; + var items = ViewModel.GetManagementMenuItems(); for (var i = 0; i < items.Count; i++) { var item = items[i]; - var focused = i == ViewModel.SelectedIndex.Value; + var focused = i == ViewModel.ManagementMenuIndex; layout = layout.WithChild(Row( - $"{FocusPrefix(focused)}{item.Label,-14} {item.Summary,-24} {item.Description}", + $"{FocusPrefix(focused)}{item.Label,-36} {item.Description}", focused)); } return layout; } - private ILayoutNode BuildProviderDetails() + private ILayoutNode BuildChannelPermissions() + { + var layout = Layouts.Vertical() + .WithChild(Header($" {ViewModel.ActiveAdapterName} > Channels & Permissions")) + .WithChild(Hint(" Configure allowed channels and their audience/trust level.")) + .WithChild(Layouts.Empty().Height(1)); + + var rows = ViewModel.GetChannelRows(); + if (rows.Count == 1 && rows[0].IsAddAction) + { + layout = layout.WithChild(Hint(" No allowed channels configured.")); + } + + for (var i = 0; i < rows.Count; i++) + { + var row = rows[i]; + var focused = i == ViewModel.ChannelRowIndex; + var line = row.IsAddAction + ? $"{FocusPrefix(focused)}{row.DisplayName}" + : $"{FocusPrefix(focused)}{row.DisplayName,-28} {row.Id,-18} {AudienceCycle(row.Audience)}"; + layout = layout.WithChild(Row(line, focused)); + } + + return layout + .WithChild(Layouts.Empty().Height(1)) + .WithChild(Hint(" Audience controls which tools and data this channel can use.")); + } + + private ILayoutNode BuildEditAudience() { - var item = ViewModel.SelectedItem; + var label = ViewModel.EditingAudienceLabel ?? "channel"; + var id = ViewModel.EditingAudienceId ?? string.Empty; var layout = Layouts.Vertical() - .WithChild(Header($" {item.Label} Channels")) - .WithChild(Hint(" This view reflects current config and stored secrets.")) + .WithChild(Header($" {ViewModel.ActiveAdapterName} > {label}")) + .WithChild(Hint(ViewModel.EditingAudienceIsDm ? " Direct messages" : $" Channel ID: {id}")) + .WithChild(Layouts.Empty().Height(1)) + .WithChild(new TextNode(" Who is this channel for?").WithForeground(Color.White)) .WithChild(Layouts.Empty().Height(1)); - foreach (var detail in ViewModel.SelectedDetails) + for (var i = 0; i < ChannelsConfigViewModel.AudienceOptions.Count; i++) + { + var audience = ChannelsConfigViewModel.AudienceOptions[i]; + var focused = i == ViewModel.AudienceSelectionIndex; + layout = layout.WithChild(Row( + $"{FocusPrefix(focused)}{AudienceLabel(audience),-10} {AudienceDescription(audience)}", + focused)); + } + + return layout; + } + + private ILayoutNode BuildAddChannel() + { + var input = EnsureSingleInput(ChannelsConfigScreen.AddChannel, "channel", ViewModel.AddChannelInput, "channel ID or #name"); + input.OnFocused(); + + var layout = Layouts.Vertical() + .WithChild(Header($" {ViewModel.ActiveAdapterName} > Add Channel")) + .WithChild(new TextNode(" Channel name or ID:").WithForeground(Color.White)) + .WithChild(WizardStepHelpers.BuildTextInputPanel(input, "Channel")) + .WithChild(Layouts.Empty().Height(1)) + .WithChild(new TextNode(" Audience:").WithForeground(Color.White)); + + for (var i = 0; i < ChannelsConfigViewModel.AudienceOptions.Count; i++) { - layout = layout.WithChild(new TextNode($" {detail.Label,-18} {detail.Value}") - .WithForeground(Color.White)); + var audience = ChannelsConfigViewModel.AudienceOptions[i]; + var focused = i == ViewModel.AudienceSelectionIndex; + layout = layout.WithChild(Row( + $"{FocusPrefix(focused)}{AudienceLabel(audience),-10} {AudienceDescription(audience)}", + focused)); } - layout = layout + return layout.WithChild(Layouts.Empty().Height(1)) + .WithChild(Hint(" IDs are saved as entered. Names are normalized by removing a leading #.")); + } + + private ILayoutNode BuildAllowedUsers() + { + var input = EnsureSingleInput(ChannelsConfigScreen.AllowedUsers, "users", ViewModel.AllowedUsersInput, "U123, U456"); + input.OnFocused(); + + return Layouts.Vertical() + .WithChild(Header($" {ViewModel.ActiveAdapterName} > Allowed Users")) + .WithChild(Hint(" Leave blank to allow anyone in allowed channels.")) .WithChild(Layouts.Empty().Height(1)) - .WithChild(Hint(" Editing transport fields will be added as leaf editors; this page preserves current values.")); + .WithChild(new TextNode(" User IDs:").WithForeground(Color.White)) + .WithChild(WizardStepHelpers.BuildTextInputPanel(input, "User IDs")); + } + + private ILayoutNode BuildDirectMessages() + { + var layout = Layouts.Vertical() + .WithChild(Header($" {ViewModel.ActiveAdapterName} > Direct Messages")) + .WithChild(Hint(" Enable DMs only for audiences you trust.")) + .WithChild(Layouts.Empty().Height(1)); + + layout = layout.WithChild(Row( + $"{FocusPrefix(ViewModel.DirectMessagesRowIndex == 0)}[{Check(ViewModel.DirectMessagesEnabled)}] Allow direct messages", + ViewModel.DirectMessagesRowIndex == 0, + ViewModel.DirectMessagesEnabled)); + + var audience = ChannelsConfigViewModel.AudienceOptions[ViewModel.AudienceSelectionIndex]; + layout = layout.WithChild(Row( + $"{FocusPrefix(ViewModel.DirectMessagesRowIndex == 1)}DM audience [< {AudienceLabel(audience),-8} >]", + ViewModel.DirectMessagesRowIndex == 1)); + + return layout; + } + + private ILayoutNode BuildRotateCredentials() + { + var fields = ViewModel.GetCredentialFields(); + var layout = Layouts.Vertical() + .WithChild(Header($" {ViewModel.ActiveAdapterName} > Credentials")) + .WithChild(Hint(" Secret fields are blank by design. Leave blank to keep existing secrets.")) + .WithChild(Layouts.Empty().Height(1)); + + for (var i = 0; i < fields.Count; i++) + { + var field = fields[i]; + var input = EnsureCredentialInput(field); + if (i == ViewModel.CredentialFieldIndex) + input.OnFocused(); + + layout = layout + .WithChild(new TextNode($" {field.Label}:").WithForeground(i == ViewModel.CredentialFieldIndex ? Color.Cyan : Color.White)) + .WithChild(WizardStepHelpers.BuildTextInputPanel(input, field.Label)); + + if (!string.IsNullOrWhiteSpace(field.Hint)) + layout = layout.WithChild(Hint($" {field.Hint}")); + } + + return layout; + } + + private ILayoutNode BuildResetConfirmation() + { + var options = new[] { "Cancel", $"Yes, reset {ViewModel.ActiveAdapterName}" }; + var layout = Layouts.Vertical() + .WithChild(Header($" Reset {ViewModel.ActiveAdapterName} connection?")) + .WithChild(Hint($" This removes {ViewModel.ActiveAdapterName} credentials, allowed channels, allowed users,")) + .WithChild(Hint(" DM settings, and channel permission mappings after you save.")) + .WithChild(Layouts.Empty().Height(1)); + + for (var i = 0; i < options.Length; i++) + { + var focused = i == ViewModel.ResetConfirmIndex; + layout = layout.WithChild(Row($"{FocusPrefix(focused)}{options[i]}", focused)); + } return layout; } + private LayoutNode BuildHelpText() + { + _helpTextNode = new DynamicLayoutNode(() => + { + if (ViewModel.IsSaved.Value) + return (ILayoutNode)new TextNode(" Saved values were merged into netclaw.json and secrets.json.").WithForeground(Color.Gray); + + if (ViewModel.Screen.Value != ChannelsConfigScreen.Picker) + { + var help = ViewModel.Screen.Value switch + { + ChannelsConfigScreen.AdapterMenu => " Manage this adapter without re-entering credentials.", + ChannelsConfigScreen.ChannelPermissions => " Enter edits an audience. a adds a channel. d removes the selected channel.", + ChannelsConfigScreen.EditAudience => " Select the audience profile for this channel.", + ChannelsConfigScreen.AddChannel => " Enter applies the channel draft. Esc cancels.", + ChannelsConfigScreen.AllowedUsers => " Use comma-separated user IDs. Blank means unrestricted users in allowed channels.", + ChannelsConfigScreen.DirectMessages => " Space toggles DMs. Left/right changes the DM audience.", + ChannelsConfigScreen.RotateCredentials => " Blank secret fields preserve existing secrets. Tab switches fields.", + ChannelsConfigScreen.ResetConfirm => " Reset is staged until you save channel settings.", + _ => string.Empty + }; + return (ILayoutNode)new TextNode(help).WithForeground(Color.Gray); + } + + return (ILayoutNode)new TextNode(ViewModel.Step.GetHelpText()).WithForeground(Color.Gray); + }); + + return _helpTextNode.Height(2); + } + private LayoutNode BuildStatusBar() - => ViewModel.StatusMessage - .Select(msg => NetclawTuiChrome.BuildStatusLine(msg, Color.Yellow)) + => ViewModel.Status + .Select(status => (ILayoutNode)(string.IsNullOrWhiteSpace(status.Text) + ? Layouts.Empty() + : NetclawTuiChrome.BuildStatusLine(status.Text, ToColor(status.Tone)))) .AsLayout() .Height(1); private LayoutNode BuildKeyBindings() { - _keyBindingsNode = new DynamicLayoutNode(() => NetclawTuiChrome.BuildKeyHintLine(ViewModel.Mode.Value switch + _keyBindingsNode = new DynamicLayoutNode(() => { - ChannelsConfigMode.Details => " [Esc] Channels [Ctrl+Q] Quit", - _ => " [↑/↓] Navigate [Enter] Open [Esc] Back [Ctrl+Q] Quit" - })); + var text = ViewModel.IsSaved.Value + ? " [Enter] Settings Areas [Esc] Review channels [Ctrl+Q] Quit" + : ViewModel.Screen.Value switch + { + ChannelsConfigScreen.AdapterMenu => " [↑/↓] Navigate [Enter] Select [Esc] Channels [Ctrl+Q] Quit", + ChannelsConfigScreen.ChannelPermissions => " [↑/↓] Navigate [←/→] Audience [Enter] Edit [a] Add [d] Remove [Esc] Menu", + ChannelsConfigScreen.EditAudience => " [↑/↓] Navigate [Enter] Apply [Esc] Channels [Ctrl+Q] Quit", + ChannelsConfigScreen.AddChannel => " [↑/↓] Audience [Enter] Add [Esc] Channels [Ctrl+Q] Quit", + ChannelsConfigScreen.AllowedUsers => " [Enter] Apply [Esc] Menu [Ctrl+Q] Quit", + ChannelsConfigScreen.DirectMessages => " [↑/↓] Navigate [Space] Toggle [←/→] Audience [Enter] Apply [Esc] Menu", + ChannelsConfigScreen.RotateCredentials => " [Tab] Field [Enter] Apply [Esc] Menu [Ctrl+Q] Quit", + ChannelsConfigScreen.ResetConfirm => " [↑/↓] Navigate [Enter] Select [Esc] Menu [Ctrl+Q] Quit", + _ => ViewModel.Step.IsInSubFlow + ? " [Enter] Next [Esc] Back [Ctrl+Q] Quit" + : " [↑/↓] Navigate [Space] Toggle [Enter] Open [d] Save [Esc] Back [Ctrl+Q] Quit" + }; + + return NetclawTuiChrome.BuildKeyHintLine(text); + }); return _keyBindingsNode.Height(1); } - private void HandleKeyPress(KeyPressed key) + public override bool HandlePageInput(ConsoleKeyInfo keyInfo) + { + if (base.HandlePageInput(keyInfo)) + return true; + + return HandleKeyInfo(keyInfo); + } + + private bool HandleKeyInfo(ConsoleKeyInfo keyInfo) { - var keyInfo = key.KeyInfo; if (keyInfo.Key == ConsoleKey.Q && keyInfo.Modifiers.HasFlag(ConsoleModifiers.Control)) { ViewModel.RequestQuit(); - return; + return true; } if (keyInfo.Key == ConsoleKey.Escape) { ViewModel.GoBack(); + return true; + } + + if (ViewModel.IsSaved.Value) + { + if (keyInfo.Key == ConsoleKey.Enter) + ViewModel.GoNext(); + + return true; + } + + if (ViewModel.Screen.Value != ChannelsConfigScreen.Picker) + { + HandleManagementKey(keyInfo); + return true; + } + + if (TryOpenConfiguredAdapter(keyInfo)) + return true; + + if (!ViewModel.IsSaved.Value + && ViewModel.StepView.CapturesInput + && ViewModel.StepView.HandleKeyPress(new KeyPressed(keyInfo))) + { + ViewModel.RequestRedraw(); + return true; + } + + return false; + } + + private void HandleKeyPress(KeyPressed key) + => HandleKeyInfo(key.KeyInfo); + + private void HandlePaste(PasteEvent paste) + { + if (ViewModel.IsSaved.Value) + return; + + if (ViewModel.Screen.Value is ChannelsConfigScreen.AddChannel or ChannelsConfigScreen.AllowedUsers) + { + _singleInput?.HandlePaste(paste); + StageSingleInput(); + ViewModel.RequestRedraw(); + return; + } + + if (ViewModel.Screen.Value == ChannelsConfigScreen.RotateCredentials) + { + var fields = ViewModel.GetCredentialFields(); + if (fields.Count > 0) + { + var field = fields[ViewModel.CredentialFieldIndex]; + if (_credentialInputs.TryGetValue(field.Key, out var input)) + { + input.HandlePaste(paste); + ViewModel.StageCredentialDraftValue(field.Key, input.Text); + ViewModel.RequestRedraw(); + } + } + return; } - if (ViewModel.Mode.Value == ChannelsConfigMode.Providers) - HandleProviderListKey(keyInfo); + ViewModel.StepView.HandlePaste(paste); + ViewModel.RequestRedraw(); + } + + private bool TryOpenConfiguredAdapter(ConsoleKeyInfo keyInfo) + { + if (keyInfo.Key is not (ConsoleKey.Enter or ConsoleKey.E)) + return false; + + if (!ViewModel.TryOpenSelectedAdapterManagement()) + return false; + + ViewModel.RequestRedraw(); + return true; + } + + private void HandleManagementKey(ConsoleKeyInfo keyInfo) + { + switch (ViewModel.Screen.Value) + { + case ChannelsConfigScreen.AdapterMenu: + HandleAdapterMenuKey(keyInfo); + break; + case ChannelsConfigScreen.ChannelPermissions: + HandleChannelPermissionsKey(keyInfo); + break; + case ChannelsConfigScreen.EditAudience: + HandleEditAudienceKey(keyInfo); + break; + case ChannelsConfigScreen.AddChannel: + HandleAddChannelKey(keyInfo); + break; + case ChannelsConfigScreen.AllowedUsers: + HandleAllowedUsersKey(keyInfo); + break; + case ChannelsConfigScreen.DirectMessages: + HandleDirectMessagesKey(keyInfo); + break; + case ChannelsConfigScreen.RotateCredentials: + HandleRotateCredentialsKey(keyInfo); + break; + case ChannelsConfigScreen.ResetConfirm: + HandleResetConfirmKey(keyInfo); + break; + } _contentNode?.Invalidate(); + _helpTextNode?.Invalidate(); + _keyBindingsNode?.Invalidate(); ViewModel.RequestRedraw(); } - private void HandleProviderListKey(ConsoleKeyInfo keyInfo) + private void HandleAdapterMenuKey(ConsoleKeyInfo keyInfo) + { + switch (keyInfo.Key) + { + case ConsoleKey.UpArrow: + ViewModel.MoveManagementMenu(-1); + break; + case ConsoleKey.DownArrow: + ViewModel.MoveManagementMenu(1); + break; + case ConsoleKey.Enter: + ViewModel.ActivateManagementMenuItem(); + break; + } + } + + private void HandleChannelPermissionsKey(ConsoleKeyInfo keyInfo) + { + switch (keyInfo.Key) + { + case ConsoleKey.UpArrow: + ViewModel.MoveChannelRow(-1); + break; + case ConsoleKey.DownArrow: + ViewModel.MoveChannelRow(1); + break; + case ConsoleKey.LeftArrow: + ViewModel.ChangeSelectedChannelAudience(-1); + break; + case ConsoleKey.RightArrow: + ViewModel.ChangeSelectedChannelAudience(1); + break; + case ConsoleKey.Enter: + ViewModel.OpenSelectedChannelAudience(); + break; + case ConsoleKey.A: + ViewModel.BeginAddChannel(); + break; + case ConsoleKey.D: + ViewModel.RemoveSelectedChannel(); + break; + } + } + + private void HandleEditAudienceKey(ConsoleKeyInfo keyInfo) { switch (keyInfo.Key) { case ConsoleKey.UpArrow: - ViewModel.MoveSelection(-1); + ViewModel.MoveAudienceSelection(-1); break; case ConsoleKey.DownArrow: - ViewModel.MoveSelection(1); + ViewModel.MoveAudienceSelection(1); break; case ConsoleKey.Enter: - ViewModel.ActivateSelected(); + ViewModel.ApplyAudienceSelection(); break; } } + private void HandleAddChannelKey(ConsoleKeyInfo keyInfo) + { + switch (keyInfo.Key) + { + case ConsoleKey.UpArrow: + ViewModel.MoveAddChannelAudience(-1); + return; + case ConsoleKey.DownArrow: + ViewModel.MoveAddChannelAudience(1); + return; + case ConsoleKey.Enter: + StageSingleInput(); + ViewModel.ApplyAddChannel(); + return; + } + + _singleInput?.HandleInput(keyInfo); + StageSingleInput(); + } + + private void HandleAllowedUsersKey(ConsoleKeyInfo keyInfo) + { + if (keyInfo.Key == ConsoleKey.Enter) + { + StageSingleInput(); + ViewModel.ApplyAllowedUsers(); + return; + } + + _singleInput?.HandleInput(keyInfo); + StageSingleInput(); + } + + private void HandleDirectMessagesKey(ConsoleKeyInfo keyInfo) + { + switch (keyInfo.Key) + { + case ConsoleKey.UpArrow: + ViewModel.MoveDirectMessagesRow(-1); + break; + case ConsoleKey.DownArrow: + ViewModel.MoveDirectMessagesRow(1); + break; + case ConsoleKey.Spacebar when ViewModel.DirectMessagesRowIndex == 0: + ViewModel.ToggleDirectMessages(); + break; + case ConsoleKey.LeftArrow when ViewModel.DirectMessagesRowIndex == 1: + ViewModel.ChangeDirectMessageAudience(-1); + break; + case ConsoleKey.RightArrow when ViewModel.DirectMessagesRowIndex == 1: + ViewModel.ChangeDirectMessageAudience(1); + break; + case ConsoleKey.Enter: + ViewModel.ApplyDirectMessages(); + break; + } + } + + private void HandleRotateCredentialsKey(ConsoleKeyInfo keyInfo) + { + var fields = ViewModel.GetCredentialFields(); + if (fields.Count == 0) + return; + + if (keyInfo.Key == ConsoleKey.Tab) + { + ViewModel.MoveCredentialField(1); + return; + } + + if (keyInfo.Key == ConsoleKey.Enter) + { + StageCredentialInput(fields[ViewModel.CredentialFieldIndex]); + ViewModel.ApplyCredentials(); + return; + } + + var field = fields[ViewModel.CredentialFieldIndex]; + if (_credentialInputs.TryGetValue(field.Key, out var input)) + { + input.HandleInput(keyInfo); + StageCredentialInput(field); + } + } + + private void HandleResetConfirmKey(ConsoleKeyInfo keyInfo) + { + switch (keyInfo.Key) + { + case ConsoleKey.UpArrow: + ViewModel.MoveResetConfirmation(-1); + break; + case ConsoleKey.DownArrow: + ViewModel.MoveResetConfirmation(1); + break; + case ConsoleKey.Enter: + ViewModel.ApplyResetConfirmation(); + break; + } + } + + private StepViewCallbacks CreateCallbacks() + => new() + { + Subscriptions = _stepSubs, + InvalidateContent = () => _contentNode?.Invalidate(), + InvalidateHelp = () => _helpTextNode?.Invalidate(), + AdvanceStep = ViewModel.GoNext, + RequestRedraw = ViewModel.RequestRedraw, + }; + private void InvalidateAll() { _contentNode?.Invalidate(); + _helpTextNode?.Invalidate(); _keyBindingsNode?.Invalidate(); } + private TextInputNode EnsureSingleInput( + ChannelsConfigScreen screen, + string key, + string? seed, + string placeholder) + { + if (_singleInput is not null && _singleInputScreen == screen && string.Equals(_singleInputKey, key, StringComparison.Ordinal)) + return _singleInput; + + _singleInput = new TextInputNode().WithPlaceholder(placeholder); + _singleInput.Text = seed ?? string.Empty; + if (!string.IsNullOrEmpty(_singleInput.Text)) + _singleInput.HandleInput(new ConsoleKeyInfo('\0', ConsoleKey.End, shift: false, alt: false, control: false)); + _singleInputScreen = screen; + _singleInputKey = key; + return _singleInput; + } + + private TextInputNode EnsureCredentialInput(CredentialFieldSpec field) + { + if (_credentialInputAdapter != ViewModel.ActiveAdapterType) + { + _credentialInputs.Clear(); + _credentialInputAdapter = ViewModel.ActiveAdapterType; + } + + if (_credentialInputs.TryGetValue(field.Key, out var existing)) + return existing; + + var input = new TextInputNode().WithPlaceholder(field.Placeholder); + if (field.IsSecret) + input.AsPassword(); + + input.Text = ViewModel.GetCredentialDraftValue(field.Key) ?? string.Empty; + if (!string.IsNullOrEmpty(input.Text)) + input.HandleInput(new ConsoleKeyInfo('\0', ConsoleKey.End, shift: false, alt: false, control: false)); + + _credentialInputs[field.Key] = input; + return input; + } + + private void StageSingleInput() + { + if (_singleInputScreen == ChannelsConfigScreen.AddChannel) + ViewModel.AddChannelInput = _singleInput?.Text; + else if (_singleInputScreen == ChannelsConfigScreen.AllowedUsers) + ViewModel.AllowedUsersInput = _singleInput?.Text; + } + + private void StageCredentialInput(CredentialFieldSpec field) + { + if (_credentialInputs.TryGetValue(field.Key, out var input)) + ViewModel.StageCredentialDraftValue(field.Key, input.Text); + } + + private void ResetTextInputs() + { + _singleInput = null; + _singleInputScreen = null; + _singleInputKey = null; + _credentialInputs.Clear(); + _credentialInputAdapter = null; + } + private static TextNode Header(string text) => new TextNode(text).WithForeground(Color.White).Bold(); private static TextNode Hint(string text) => new TextNode(text).WithForeground(Color.BrightBlack); - private static string FocusPrefix(bool focused) => focused ? " > " : " "; + private static string FocusPrefix(bool focused) => focused ? " ▶ " : " "; + private static string Check(bool enabled) => enabled ? "✓" : " "; - private static TextNode Row(string line, bool focused) + private static TextNode Row(string line, bool focused, bool enabled = true) { var node = new TextNode(line); - return focused ? node.WithForeground(Color.Cyan).Bold() : node.WithForeground(Color.White); + if (focused) + return node.WithForeground(Color.Cyan).Bold(); + return node.WithForeground(enabled ? Color.White : Color.BrightBlack); + } + + private static string AudienceLabel(TrustAudience audience) => audience switch + { + TrustAudience.Personal => "Personal", + TrustAudience.Team => "Team", + TrustAudience.Public => "Public", + _ => audience.ToString() + }; + + private static string AudienceDescription(TrustAudience audience) => audience switch + { + TrustAudience.Personal => "Private operator or owner-only context.", + TrustAudience.Team => "Trusted internal channel.", + TrustAudience.Public => "Untrusted or broad audience with strict controls.", + _ => string.Empty + }; + + private static string AudienceCycle(TrustAudience audience) => $"[◀ {AudienceLabel(audience),-8} ▶]"; + + private static Color ToColor(ConfigStatusTone tone) => tone switch + { + ConfigStatusTone.Success => Color.Green, + ConfigStatusTone.Warning => Color.Yellow, + ConfigStatusTone.Error => Color.Red, + _ => Color.White, + }; + + public override void Dispose() + { + _stepSubs.Dispose(); + base.Dispose(); } } diff --git a/src/Netclaw.Cli/Tui/Config/ChannelsConfigViewModel.cs b/src/Netclaw.Cli/Tui/Config/ChannelsConfigViewModel.cs index 916c912f6..258a33a2d 100644 --- a/src/Netclaw.Cli/Tui/Config/ChannelsConfigViewModel.cs +++ b/src/Netclaw.Cli/Tui/Config/ChannelsConfigViewModel.cs @@ -4,113 +4,625 @@ // </copyright> // ----------------------------------------------------------------------- using System.Text.Json; +using Netclaw.Actors.Channels; +using Netclaw.Channels.Slack; using Netclaw.Cli.Config; +using Netclaw.Cli.Discord; +using Netclaw.Cli.Tui.Sections; +using Netclaw.Cli.Tui.Wizard; +using Netclaw.Cli.Tui.Wizard.Steps; using Netclaw.Configuration; +using Netclaw.Providers; using R3; using Termina.Reactive; namespace Netclaw.Cli.Tui.Config; -public enum ChannelsConfigMode +public sealed class ChannelsConfigViewModel : ReactiveViewModel { - Providers, - Details -} + private readonly NetclawPaths _paths; + private readonly TuiNavigation? _navigation; + private readonly ChannelsConfigPersistenceMapper _mapper = new(); + private readonly WizardContext _context; + private readonly HashSet<ChannelType> _knownProviders; + private readonly Dictionary<ChannelType, Dictionary<string, TrustAudience>> _channelAudiences = []; + private readonly HashSet<ChannelType> _resetProviders = []; + private ChannelType _activeAdapterType = ChannelType.Slack; + private string? _editingAudienceId; + private string? _editingAudienceLabel; + private bool _editingAudienceIsDm; + private int _managementMenuIndex; + private int _channelRowIndex; + private int _audienceSelectionIndex; + private int _directMessagesRowIndex; + private int _resetConfirmIndex; + + public ChannelsConfigViewModel( + NetclawPaths paths, + ISlackProbe slackProbe, + IDiscordProbe discordProbe, + TuiNavigation? navigation = null) + { + _paths = paths; + _navigation = navigation; + Status = new ReactiveProperty<ConfigStatusMessage>(new ConfigStatusMessage(string.Empty, ConfigStatusTone.Neutral)); + Step = new ChannelPickerStepViewModel(slackProbe, discordProbe) + { + DoneActionText = "save channel settings", + PreserveDisabledAdapterDrafts = true + }; + _context = new WizardContext + { + Paths = paths, + Registry = new ProviderDescriptorRegistry([]), + RequestRedraw = RequestRedraw, + ExistingConfig = LoadExistingConfig(paths), + SelectedPosture = LoadDeploymentPosture(paths) + }; -public enum ChannelsConfigProvider -{ - Slack, - Discord, - Mattermost -} + Step.OnEnter(_context, NavigationDirection.Forward); + var draft = _mapper.Load(paths); + _knownProviders = [.. draft.KnownProviders]; + LoadAudienceDrafts(draft); + _mapper.ApplyToStep(Step, draft); + } -public sealed record ChannelsConfigItem( - ChannelsConfigProvider Provider, - string Label, - string Summary, - string Description); + public ChannelPickerStepViewModel Step { get; } + public ChannelPickerStepView StepView { get; } = new(); + public WizardContext Context => _context; + public ReactiveProperty<bool> IsSaved { get; } = new(false); + internal ReactiveProperty<ChannelsConfigScreen> Screen { get; } = new(ChannelsConfigScreen.Picker); + internal ReactiveProperty<ConfigStatusMessage> Status { get; } + public Action? OnStepContentChanged { get; set; } -public sealed record ChannelsConfigDetail(string Label, string Value); + internal bool ShutdownRequestedForTest { get; private set; } -public sealed class ChannelsConfigViewModel : ReactiveViewModel -{ - private static readonly ChannelProviderSpec[] Providers = + internal ChannelType ActiveAdapterType => _activeAdapterType; + internal string ActiveAdapterName => GetAdapterDisplayName(_activeAdapterType); + internal int ManagementMenuIndex => _managementMenuIndex; + internal int ChannelRowIndex => _channelRowIndex; + internal int AudienceSelectionIndex => _audienceSelectionIndex; + internal int DirectMessagesRowIndex => _directMessagesRowIndex; + internal int ResetConfirmIndex => _resetConfirmIndex; + internal string? AddChannelInput { get; set; } + internal string? AllowedUsersInput { get; set; } + internal bool DirectMessagesEnabled { get; set; } + internal string? BotTokenInput { get; set; } + internal string? AppTokenInput { get; set; } + internal string? ServerUrlInput { get; set; } + internal string? CallbackUrlInput { get; set; } + internal int CredentialFieldIndex { get; set; } + + internal static IReadOnlyList<TrustAudience> AudienceOptions { get; } = [ - new( - ChannelsConfigProvider.Slack, - "Slack", - "Socket Mode chat adapter.", - "Slack", - ["Slack.BotToken", "Slack.AppToken"]), - new( - ChannelsConfigProvider.Discord, - "Discord", - "Discord bot adapter.", - "Discord", - ["Discord.BotToken"]), - new( - ChannelsConfigProvider.Mattermost, - "Mattermost", - "Mattermost bot adapter.", - "Mattermost", - ["Mattermost.BotToken"]) + TrustAudience.Personal, + TrustAudience.Team, + TrustAudience.Public ]; - private readonly NetclawPaths _paths; - private readonly TuiNavigation? _navigation; + public void GoNext() + { + if (IsSaved.Value) + { + ReturnToDashboard(); + return; + } + + if (Step.IsInSubFlow) + { + var activeAdapter = Step.ActiveAdapterType; + if (Step.TryAdvance()) + { + if (!Step.IsInSubFlow && activeAdapter is { } completedAdapter) + OpenChannelPermissionsAfterInitialSetup(completedAdapter); + + NotifyContentChanged(); + } - public ChannelsConfigViewModel(NetclawPaths paths, TuiNavigation? navigation = null) + return; + } + + Save(); + } + + public void GoBack() { - _paths = paths; - _navigation = navigation; + if (IsSaved.Value) + { + IsSaved.Value = false; + Status.Value = new ConfigStatusMessage(string.Empty, ConfigStatusTone.Neutral); + NotifyContentChanged(); + return; + } + + if (Screen.Value != ChannelsConfigScreen.Picker) + { + GoBackWithinManagement(); + return; + } + + if (Step.IsInSubFlow && Step.TryGoBack()) + { + Status.Value = new ConfigStatusMessage(string.Empty, ConfigStatusTone.Neutral); + NotifyContentChanged(); + return; + } + + ReturnToDashboard(); } - public ReactiveProperty<ChannelsConfigMode> Mode { get; } = new(ChannelsConfigMode.Providers); - public ReactiveProperty<int> SelectedIndex { get; } = new(0); - public ReactiveProperty<string> StatusMessage { get; } = new(""); + public void Save() + { + var validationMessage = _mapper.Validate(Step); + if (validationMessage is not null) + { + Status.Value = new ConfigStatusMessage(validationMessage, ConfigStatusTone.Error); + RequestRedraw(); + return; + } + + var session = new ConfigEditorSession(_paths); + session.Apply(_mapper.BuildContribution( + Step, + _knownProviders, + _channelAudiences, + _resetProviders, + _context.SelectedPosture ?? DeploymentPosture.Personal)); + session.Save(); + + var savedDraft = _mapper.Load(_paths); + _knownProviders.Clear(); + foreach (var provider in savedDraft.KnownProviders) + _knownProviders.Add(provider); + + _resetProviders.Clear(); + LoadAudienceDrafts(savedDraft); + Step.OnEnter(_context, NavigationDirection.Forward); + _mapper.ApplyToStep(Step, savedDraft); + IsSaved.Value = true; + Screen.Value = ChannelsConfigScreen.Picker; + Status.Value = new ConfigStatusMessage("Channels saved.", ConfigStatusTone.Success); + NotifyContentChanged(); + } - internal bool ShutdownRequestedForTest { get; private set; } + internal bool TryOpenSelectedAdapterManagement() + { + if (!Step.IsInPickerMode) + return false; - public IReadOnlyList<ChannelsConfigItem> Items => BuildItems(); + var type = Step.SelectedAdapterType; + if (!Step.IsAdapterKnown(type)) + return false; - public ChannelsConfigItem SelectedItem => Items[Math.Clamp(SelectedIndex.Value, 0, Items.Count - 1)]; + OpenAdapterManagement(type); + return true; + } - public IReadOnlyList<ChannelsConfigDetail> SelectedDetails => BuildDetails(SelectedItem.Provider); + internal void OpenAdapterManagement(ChannelType type) + { + _activeAdapterType = type; + _managementMenuIndex = 0; + Screen.Value = ChannelsConfigScreen.AdapterMenu; + Status.Value = new ConfigStatusMessage(string.Empty, ConfigStatusTone.Neutral); + NotifyContentChanged(); + } - public void MoveSelection(int delta) + private void OpenChannelPermissionsAfterInitialSetup(ChannelType type) { - var next = Math.Clamp(SelectedIndex.Value + delta, 0, Providers.Length - 1); - if (next != SelectedIndex.Value) - SelectedIndex.Value = next; + _activeAdapterType = type; + _channelRowIndex = 0; + UpdateAdapterPickerSummary(type); + Screen.Value = ChannelsConfigScreen.ChannelPermissions; + Status.Value = new ConfigStatusMessage( + $"Set {GetAdapterDisplayName(type)} channel audiences, then press Esc and d to save.", + ConfigStatusTone.Neutral); } - public void ActivateSelected() + internal IReadOnlyList<ChannelsManagementMenuItem> GetManagementMenuItems() { - if (Mode.Value == ChannelsConfigMode.Providers) - OpenSelectedProvider(); + var enabled = Step.IsAdapterEnabled(_activeAdapterType); + return + [ + new ChannelsManagementMenuItem(ChannelsManagementAction.ManageChannels, "Manage channels and permissions", "Edit allowed channels and audience levels."), + new ChannelsManagementMenuItem(ChannelsManagementAction.AddChannel, $"Add a {ActiveAdapterName} channel", "Add channel ingress without touching credentials."), + new ChannelsManagementMenuItem(ChannelsManagementAction.ManageUsers, "Manage allowed users", "Restrict messages to specific user IDs."), + new ChannelsManagementMenuItem(ChannelsManagementAction.DirectMessages, "Direct messages", "Enable or disable DM ingress and audience."), + new ChannelsManagementMenuItem(ChannelsManagementAction.RotateCredentials, "Rotate credentials", "Replace tokens only when explicitly entered."), + new ChannelsManagementMenuItem(ChannelsManagementAction.ToggleEnabled, enabled ? $"Disable {ActiveAdapterName}" : $"Enable {ActiveAdapterName}", "Preserve saved setup while changing runtime state."), + new ChannelsManagementMenuItem(ChannelsManagementAction.ResetConnection, $"Reset {ActiveAdapterName} connection", "Remove saved config and credentials.") + ]; } - internal void OpenSelectedProvider() + internal void MoveManagementMenu(int delta) { - Mode.Value = ChannelsConfigMode.Details; - StatusMessage.Value = ""; - RequestRedraw(); + _managementMenuIndex = Clamp(_managementMenuIndex + delta, GetManagementMenuItems().Count); + NotifyContentChanged(); } - public void GoBack() + internal void ActivateManagementMenuItem() { - if (Mode.Value == ChannelsConfigMode.Details) + var item = GetManagementMenuItems()[_managementMenuIndex]; + switch (item.Action) { - Mode.Value = ChannelsConfigMode.Providers; - StatusMessage.Value = ""; - RequestRedraw(); + case ChannelsManagementAction.ManageChannels: + _channelRowIndex = 0; + Screen.Value = ChannelsConfigScreen.ChannelPermissions; + break; + case ChannelsManagementAction.AddChannel: + BeginAddChannel(); + break; + case ChannelsManagementAction.ManageUsers: + BeginAllowedUsers(); + break; + case ChannelsManagementAction.DirectMessages: + BeginDirectMessages(); + break; + case ChannelsManagementAction.RotateCredentials: + BeginRotateCredentials(); + break; + case ChannelsManagementAction.ToggleEnabled: + SetActiveAdapterEnabled(!Step.IsAdapterEnabled(_activeAdapterType)); + Screen.Value = ChannelsConfigScreen.Picker; + break; + case ChannelsManagementAction.ResetConnection: + _resetConfirmIndex = 0; + Screen.Value = ChannelsConfigScreen.ResetConfirm; + break; + } + + NotifyContentChanged(); + } + + internal string GetActiveAdapterSummary() + { + var channelCount = GetChannelIds(_activeAdapterType).Count; + var userCount = GetAllowedUserIds(_activeAdapterType).Count; + var credentials = GetCredentialSummary(_activeAdapterType); + var dm = GetAllowDirectMessages(_activeAdapterType) ? "enabled" : "disabled"; + var enabled = Step.IsAdapterEnabled(_activeAdapterType) ? "enabled" : "disabled"; + return $"{enabled} · {credentials} · {Pluralize(channelCount, "channel", "channels")} · {Pluralize(userCount, "user", "users")} · DMs {dm}"; + } + + internal IReadOnlyList<ChannelPermissionRow> GetChannelRows(bool includeAddAction = true) + { + var rows = new List<ChannelPermissionRow>(); + foreach (var channelId in GetChannelIds(_activeAdapterType)) + { + rows.Add(new ChannelPermissionRow( + channelId, + FormatChannelLabel(_activeAdapterType, channelId), + GetChannelAudience(_activeAdapterType, channelId, DefaultChannelAudience()), + IsDirectMessage: false, + IsAddAction: false)); + } + + if (GetAllowDirectMessages(_activeAdapterType)) + { + rows.Add(new ChannelPermissionRow( + "dm", + "Direct messages", + GetChannelAudience(_activeAdapterType, "dm", DefaultDirectMessageAudience()), + IsDirectMessage: true, + IsAddAction: false)); + } + + if (includeAddAction) + { + rows.Add(new ChannelPermissionRow( + string.Empty, + "+ Add channel", + DefaultChannelAudience(), + IsDirectMessage: false, + IsAddAction: true)); + } + + if (_channelRowIndex >= rows.Count) + _channelRowIndex = Math.Max(rows.Count - 1, 0); + + return rows; + } + + internal void MoveChannelRow(int delta) + { + _channelRowIndex = Clamp(_channelRowIndex + delta, GetChannelRows().Count); + NotifyContentChanged(); + } + + internal void OpenSelectedChannelAudience() + { + var rows = GetChannelRows(); + if (rows.Count == 0) + return; + + var row = rows[_channelRowIndex]; + if (row.IsAddAction) + { + BeginAddChannel(); return; } - if (TryGoBack()) + _editingAudienceId = row.Id; + _editingAudienceLabel = row.DisplayName; + _editingAudienceIsDm = row.IsDirectMessage; + _audienceSelectionIndex = AudienceIndex(row.Audience); + Screen.Value = ChannelsConfigScreen.EditAudience; + NotifyContentChanged(); + } + + internal void ChangeSelectedChannelAudience(int delta) + { + var rows = GetChannelRows(); + if (rows.Count == 0) return; - RequestQuit(); + var row = rows[_channelRowIndex]; + if (row.IsAddAction) + return; + + var currentIndex = AudienceIndex(row.Audience); + var next = AudienceOptions[Wrap(currentIndex + delta, AudienceOptions.Count)]; + SetChannelAudience(_activeAdapterType, row.Id, next); + NotifyContentChanged(); + } + + internal void RemoveSelectedChannel() + { + var rows = GetChannelRows(); + if (rows.Count == 0) + return; + + var row = rows[_channelRowIndex]; + if (row.IsAddAction || row.IsDirectMessage) + return; + + var remaining = GetChannelIds(_activeAdapterType) + .Where(id => !string.Equals(id, row.Id, StringComparison.Ordinal)) + .ToArray(); + SetChannelIds(_activeAdapterType, remaining); + if (_channelAudiences.TryGetValue(_activeAdapterType, out var audiences)) + audiences.Remove(row.Id); + + UpdateAdapterPickerSummary(_activeAdapterType); + _channelRowIndex = Clamp(_channelRowIndex, GetChannelRows().Count); + Status.Value = new ConfigStatusMessage($"Removed {row.DisplayName}. Press d to save.", ConfigStatusTone.Neutral); + NotifyContentChanged(); + } + + internal void BeginAddChannel() + { + AddChannelInput = null; + _audienceSelectionIndex = AudienceIndex(DefaultChannelAudience()); + Screen.Value = ChannelsConfigScreen.AddChannel; + Status.Value = new ConfigStatusMessage(string.Empty, ConfigStatusTone.Neutral); + NotifyContentChanged(); + } + + internal void MoveAddChannelAudience(int delta) + { + _audienceSelectionIndex = Wrap(_audienceSelectionIndex + delta, AudienceOptions.Count); + NotifyContentChanged(); + } + + internal void ApplyAddChannel() + { + var channelId = NormalizeChannelId(AddChannelInput); + if (string.IsNullOrWhiteSpace(channelId)) + { + Status.Value = new ConfigStatusMessage("Channel ID is required.", ConfigStatusTone.Error); + NotifyContentChanged(); + return; + } + + var existing = GetChannelIds(_activeAdapterType); + if (existing.Contains(channelId, StringComparer.Ordinal)) + { + Status.Value = new ConfigStatusMessage($"{channelId} is already configured.", ConfigStatusTone.Error); + NotifyContentChanged(); + return; + } + + SetChannelIds(_activeAdapterType, [.. existing, channelId]); + SetChannelAudience(_activeAdapterType, channelId, AudienceOptions[_audienceSelectionIndex]); + UpdateAdapterPickerSummary(_activeAdapterType); + _channelRowIndex = Math.Max(GetChannelRows().Count - 2, 0); + Screen.Value = ChannelsConfigScreen.ChannelPermissions; + Status.Value = new ConfigStatusMessage($"Added {channelId}. Press d to save.", ConfigStatusTone.Neutral); + NotifyContentChanged(); + } + + internal string? EditingAudienceLabel => _editingAudienceLabel; + internal string? EditingAudienceId => _editingAudienceId; + internal bool EditingAudienceIsDm => _editingAudienceIsDm; + + internal void MoveAudienceSelection(int delta) + { + _audienceSelectionIndex = Wrap(_audienceSelectionIndex + delta, AudienceOptions.Count); + NotifyContentChanged(); + } + + internal void ApplyAudienceSelection() + { + if (string.IsNullOrWhiteSpace(_editingAudienceId)) + return; + + SetChannelAudience(_activeAdapterType, _editingAudienceId, AudienceOptions[_audienceSelectionIndex]); + Screen.Value = ChannelsConfigScreen.ChannelPermissions; + Status.Value = new ConfigStatusMessage($"Updated {_editingAudienceLabel} audience. Press d to save.", ConfigStatusTone.Neutral); + NotifyContentChanged(); + } + + internal void BeginAllowedUsers() + { + AllowedUsersInput = JoinOrNull(GetAllowedUserIds(_activeAdapterType)); + Screen.Value = ChannelsConfigScreen.AllowedUsers; + Status.Value = new ConfigStatusMessage(string.Empty, ConfigStatusTone.Neutral); + NotifyContentChanged(); + } + + internal void ApplyAllowedUsers() + { + var userIds = ParseCsv(AllowedUsersInput, trimHash: false); + SetAllowedUserIds(_activeAdapterType, userIds); + UpdateAdapterPickerSummary(_activeAdapterType); + Screen.Value = ChannelsConfigScreen.AdapterMenu; + Status.Value = new ConfigStatusMessage("Allowed users staged. Press d to save.", ConfigStatusTone.Neutral); + NotifyContentChanged(); + } + + internal void BeginDirectMessages() + { + DirectMessagesEnabled = GetAllowDirectMessages(_activeAdapterType); + _directMessagesRowIndex = 0; + _audienceSelectionIndex = AudienceIndex(GetChannelAudience(_activeAdapterType, "dm", DefaultDirectMessageAudience())); + Screen.Value = ChannelsConfigScreen.DirectMessages; + Status.Value = new ConfigStatusMessage(string.Empty, ConfigStatusTone.Neutral); + NotifyContentChanged(); + } + + internal void MoveDirectMessagesRow(int delta) + { + _directMessagesRowIndex = Clamp(_directMessagesRowIndex + delta, 2); + NotifyContentChanged(); + } + + internal void ToggleDirectMessages() + { + DirectMessagesEnabled = !DirectMessagesEnabled; + NotifyContentChanged(); + } + + internal void ChangeDirectMessageAudience(int delta) + { + _audienceSelectionIndex = Wrap(_audienceSelectionIndex + delta, AudienceOptions.Count); + NotifyContentChanged(); + } + + internal void ApplyDirectMessages() + { + SetAllowDirectMessages(_activeAdapterType, DirectMessagesEnabled); + SetChannelAudience(_activeAdapterType, "dm", AudienceOptions[_audienceSelectionIndex]); + UpdateAdapterPickerSummary(_activeAdapterType); + Screen.Value = ChannelsConfigScreen.AdapterMenu; + Status.Value = new ConfigStatusMessage("Direct message settings staged. Press d to save.", ConfigStatusTone.Neutral); + NotifyContentChanged(); + } + + internal void BeginRotateCredentials() + { + BotTokenInput = null; + AppTokenInput = null; + ServerUrlInput = GetServerUrl(_activeAdapterType); + CallbackUrlInput = GetCallbackUrl(_activeAdapterType); + CredentialFieldIndex = 0; + Screen.Value = ChannelsConfigScreen.RotateCredentials; + Status.Value = new ConfigStatusMessage(string.Empty, ConfigStatusTone.Neutral); + NotifyContentChanged(); + } + + internal IReadOnlyList<CredentialFieldSpec> GetCredentialFields() + { + return _activeAdapterType switch + { + ChannelType.Slack => + [ + new CredentialFieldSpec("bot", "Bot token", IsSecret: true, "xoxb-...", GetCredentialPresenceText("bot")), + new CredentialFieldSpec("app", "App token", IsSecret: true, "xapp-...", GetCredentialPresenceText("app")) + ], + ChannelType.Discord => + [ + new CredentialFieldSpec("bot", "Bot token", IsSecret: true, "Discord bot token", GetCredentialPresenceText("bot")) + ], + ChannelType.Mattermost => + [ + new CredentialFieldSpec("server", "Server URL", IsSecret: false, "https://mattermost.example.com", null), + new CredentialFieldSpec("bot", "Bot token", IsSecret: true, "Mattermost bot token", GetCredentialPresenceText("bot")), + new CredentialFieldSpec("callback", "Callback URL", IsSecret: false, "https://netclaw.example.com/api/mattermost/actions", "Optional interactive button callback URL.") + ], + _ => [] + }; + } + + internal string? GetCredentialDraftValue(string key) => key switch + { + "bot" => BotTokenInput, + "app" => AppTokenInput, + "server" => ServerUrlInput, + "callback" => CallbackUrlInput, + _ => null + }; + + internal void StageCredentialDraftValue(string key, string? value) + { + switch (key) + { + case "bot": + BotTokenInput = value; + break; + case "app": + AppTokenInput = value; + break; + case "server": + ServerUrlInput = value; + break; + case "callback": + CallbackUrlInput = value; + break; + } + } + + internal void MoveCredentialField(int delta) + { + CredentialFieldIndex = Clamp(CredentialFieldIndex + delta, GetCredentialFields().Count); + NotifyContentChanged(); + } + + internal void ApplyCredentials() + { + switch (_activeAdapterType) + { + case ChannelType.Slack: + var slack = Step.GetAdapterViewModel<SlackStepViewModel>(ChannelType.Slack); + slack.BotToken = Normalize(BotTokenInput); + slack.AppToken = Normalize(AppTokenInput); + break; + case ChannelType.Discord: + Step.GetAdapterViewModel<DiscordStepViewModel>(ChannelType.Discord).BotToken = Normalize(BotTokenInput); + break; + case ChannelType.Mattermost: + var mattermost = Step.GetAdapterViewModel<MattermostStepViewModel>(ChannelType.Mattermost); + mattermost.ServerUrl = Normalize(ServerUrlInput); + mattermost.BotToken = Normalize(BotTokenInput); + mattermost.CallbackUrl = Normalize(CallbackUrlInput); + break; + } + + Screen.Value = ChannelsConfigScreen.AdapterMenu; + Status.Value = new ConfigStatusMessage("Credential changes staged. Press d to save.", ConfigStatusTone.Neutral); + NotifyContentChanged(); + } + + internal void MoveResetConfirmation(int delta) + { + _resetConfirmIndex = Clamp(_resetConfirmIndex + delta, 2); + NotifyContentChanged(); + } + + internal void ApplyResetConfirmation() + { + if (_resetConfirmIndex == 0) + { + Screen.Value = ChannelsConfigScreen.AdapterMenu; + NotifyContentChanged(); + return; + } + + _resetProviders.Add(_activeAdapterType); + _knownProviders.Remove(_activeAdapterType); + _channelAudiences.Remove(_activeAdapterType); + Step.ResetAdapterState(_activeAdapterType); + Screen.Value = ChannelsConfigScreen.Picker; + Status.Value = new ConfigStatusMessage($"{ActiveAdapterName} reset staged. Press d to save.", ConfigStatusTone.Warning); + NotifyContentChanged(); } public void RequestQuit() @@ -121,12 +633,313 @@ public void RequestQuit() public override void Dispose() { - Mode.Dispose(); - SelectedIndex.Dispose(); - StatusMessage.Dispose(); + IsSaved.Dispose(); + Screen.Dispose(); + Status.Dispose(); + Step.Dispose(); + _context.Dispose(); base.Dispose(); } + private void GoBackWithinManagement() + { + Screen.Value = Screen.Value switch + { + ChannelsConfigScreen.AdapterMenu => ChannelsConfigScreen.Picker, + ChannelsConfigScreen.ChannelPermissions => ChannelsConfigScreen.AdapterMenu, + ChannelsConfigScreen.EditAudience => ChannelsConfigScreen.ChannelPermissions, + ChannelsConfigScreen.AddChannel => ChannelsConfigScreen.ChannelPermissions, + ChannelsConfigScreen.AllowedUsers => ChannelsConfigScreen.AdapterMenu, + ChannelsConfigScreen.DirectMessages => ChannelsConfigScreen.AdapterMenu, + ChannelsConfigScreen.RotateCredentials => ChannelsConfigScreen.AdapterMenu, + ChannelsConfigScreen.ResetConfirm => ChannelsConfigScreen.AdapterMenu, + _ => ChannelsConfigScreen.Picker + }; + + Status.Value = new ConfigStatusMessage(string.Empty, ConfigStatusTone.Neutral); + NotifyContentChanged(); + } + + private void SetActiveAdapterEnabled(bool enabled) + { + var selectedIndex = Step.Adapters + .Select((entry, index) => (entry.Type, index)) + .Single(entry => entry.Type == _activeAdapterType) + .index; + + if (Step.IsAdapterEnabled(_activeAdapterType) != enabled) + Step.ToggleAdapter(selectedIndex); + + UpdateAdapterPickerSummary(_activeAdapterType); + + Status.Value = new ConfigStatusMessage( + $"{ActiveAdapterName} {(enabled ? "enabled" : "disabled")}. Press d to save.", + ConfigStatusTone.Neutral); + } + + private void UpdateAdapterPickerSummary(ChannelType type) + { + if (!Step.IsAdapterEnabled(type)) + { + Step.SetAdapterSummary(type, "disabled, saved setup"); + return; + } + + var channelCount = GetChannelIds(type).Count; + var userCount = GetAllowedUserIds(type).Count; + var parts = new List<string> + { + channelCount > 0 + ? Pluralize(channelCount, "channel", "channels") + : GetAllowDirectMessages(type) ? "DMs only" : "no channels" + }; + + if (userCount > 0) + parts.Add(Pluralize(userCount, "user", "users")); + + Step.SetAdapterSummary(type, string.Join(", ", parts)); + } + + private void LoadAudienceDrafts(ChannelsConfigDraft draft) + { + _channelAudiences.Clear(); + AddAudienceDraft(ChannelType.Slack, draft.Slack.ChannelAudiences); + AddAudienceDraft(ChannelType.Discord, draft.Discord.ChannelAudiences); + AddAudienceDraft(ChannelType.Mattermost, draft.Mattermost.ChannelAudiences); + } + + private void AddAudienceDraft(ChannelType type, IReadOnlyDictionary<string, TrustAudience> audiences) + { + if (audiences.Count == 0) + return; + + _channelAudiences[type] = new Dictionary<string, TrustAudience>(audiences, StringComparer.Ordinal); + } + + private TrustAudience GetChannelAudience(ChannelType type, string channelId, TrustAudience defaultAudience) + => _channelAudiences.TryGetValue(type, out var audiences) && audiences.TryGetValue(channelId, out var audience) + ? audience + : defaultAudience; + + private void SetChannelAudience(ChannelType type, string channelId, TrustAudience audience) + { + if (!_channelAudiences.TryGetValue(type, out var audiences)) + { + audiences = new Dictionary<string, TrustAudience>(StringComparer.Ordinal); + _channelAudiences[type] = audiences; + } + + audiences[channelId] = audience; + } + + private TrustAudience DefaultChannelAudience() + => (_context.SelectedPosture ?? DeploymentPosture.Personal) == DeploymentPosture.Public + ? TrustAudience.Public + : TrustAudience.Team; + + private TrustAudience DefaultDirectMessageAudience() + { + var posture = _context.SelectedPosture ?? DeploymentPosture.Personal; + var allowedUsers = GetAllowedUserIds(_activeAdapterType); + return allowedUsers.Count == 1 + ? TrustAudience.Personal + : posture switch + { + DeploymentPosture.Public => TrustAudience.Public, + DeploymentPosture.Team => TrustAudience.Team, + _ => TrustAudience.Personal + }; + } + + private IReadOnlyList<string> GetChannelIds(ChannelType type) => type switch + { + ChannelType.Slack => ParseCsv(Step.GetAdapterViewModel<SlackStepViewModel>(ChannelType.Slack).ChannelNamesInput, trimHash: true), + ChannelType.Discord => ParseCsv(Step.GetAdapterViewModel<DiscordStepViewModel>(ChannelType.Discord).ChannelIdsInput, trimHash: true), + ChannelType.Mattermost => ParseCsv(Step.GetAdapterViewModel<MattermostStepViewModel>(ChannelType.Mattermost).ChannelIdsInput, trimHash: true), + _ => [] + }; + + private void SetChannelIds(ChannelType type, IReadOnlyList<string> channelIds) + { + var value = JoinOrNull(channelIds); + switch (type) + { + case ChannelType.Slack: + Step.GetAdapterViewModel<SlackStepViewModel>(ChannelType.Slack).ChannelNamesInput = value; + break; + case ChannelType.Discord: + Step.GetAdapterViewModel<DiscordStepViewModel>(ChannelType.Discord).ChannelIdsInput = value; + break; + case ChannelType.Mattermost: + Step.GetAdapterViewModel<MattermostStepViewModel>(ChannelType.Mattermost).ChannelIdsInput = value; + break; + } + } + + private IReadOnlyList<string> GetAllowedUserIds(ChannelType type) => type switch + { + ChannelType.Slack => ParseCsv(Step.GetAdapterViewModel<SlackStepViewModel>(ChannelType.Slack).AllowedUserIdsInput, trimHash: false), + ChannelType.Discord => ParseCsv(Step.GetAdapterViewModel<DiscordStepViewModel>(ChannelType.Discord).AllowedUserIdsInput, trimHash: false), + ChannelType.Mattermost => ParseCsv(Step.GetAdapterViewModel<MattermostStepViewModel>(ChannelType.Mattermost).AllowedUserIdsInput, trimHash: false), + _ => [] + }; + + private void SetAllowedUserIds(ChannelType type, IReadOnlyList<string> userIds) + { + var value = JoinOrNull(userIds); + switch (type) + { + case ChannelType.Slack: + var slack = Step.GetAdapterViewModel<SlackStepViewModel>(ChannelType.Slack); + slack.RestrictToSpecificUsers = userIds.Count > 0; + slack.AllowedUserIdsInput = value; + break; + case ChannelType.Discord: + var discord = Step.GetAdapterViewModel<DiscordStepViewModel>(ChannelType.Discord); + discord.RestrictToSpecificUsers = userIds.Count > 0; + discord.AllowedUserIdsInput = value; + break; + case ChannelType.Mattermost: + var mattermost = Step.GetAdapterViewModel<MattermostStepViewModel>(ChannelType.Mattermost); + mattermost.RestrictToSpecificUsers = userIds.Count > 0; + mattermost.AllowedUserIdsInput = value; + break; + } + } + + private bool GetAllowDirectMessages(ChannelType type) => type switch + { + ChannelType.Slack => Step.GetAdapterViewModel<SlackStepViewModel>(ChannelType.Slack).AllowDirectMessages, + ChannelType.Discord => Step.GetAdapterViewModel<DiscordStepViewModel>(ChannelType.Discord).AllowDirectMessages, + ChannelType.Mattermost => Step.GetAdapterViewModel<MattermostStepViewModel>(ChannelType.Mattermost).AllowDirectMessages, + _ => false + }; + + private void SetAllowDirectMessages(ChannelType type, bool enabled) + { + switch (type) + { + case ChannelType.Slack: + Step.GetAdapterViewModel<SlackStepViewModel>(ChannelType.Slack).AllowDirectMessages = enabled; + break; + case ChannelType.Discord: + Step.GetAdapterViewModel<DiscordStepViewModel>(ChannelType.Discord).AllowDirectMessages = enabled; + break; + case ChannelType.Mattermost: + Step.GetAdapterViewModel<MattermostStepViewModel>(ChannelType.Mattermost).AllowDirectMessages = enabled; + break; + } + } + + private string? GetServerUrl(ChannelType type) + => type == ChannelType.Mattermost + ? Step.GetAdapterViewModel<MattermostStepViewModel>(ChannelType.Mattermost).ServerUrl + : null; + + private string? GetCallbackUrl(ChannelType type) + => type == ChannelType.Mattermost + ? Step.GetAdapterViewModel<MattermostStepViewModel>(ChannelType.Mattermost).CallbackUrl + : null; + + private string? GetCredentialPresenceText(string key) + { + return _activeAdapterType switch + { + ChannelType.Slack when key == "bot" && Step.GetAdapterViewModel<SlackStepViewModel>(ChannelType.Slack).HasPersistedBotToken => + "configured - leave blank to keep", + ChannelType.Slack when key == "app" && Step.GetAdapterViewModel<SlackStepViewModel>(ChannelType.Slack).HasPersistedAppToken => + "configured - leave blank to keep", + ChannelType.Discord when key == "bot" && Step.GetAdapterViewModel<DiscordStepViewModel>(ChannelType.Discord).HasPersistedBotToken => + "configured - leave blank to keep", + ChannelType.Mattermost when key == "bot" && Step.GetAdapterViewModel<MattermostStepViewModel>(ChannelType.Mattermost).HasPersistedBotToken => + "configured - leave blank to keep", + _ => null + }; + } + + private string GetCredentialSummary(ChannelType type) + { + return type switch + { + ChannelType.Slack => + (Step.GetAdapterViewModel<SlackStepViewModel>(ChannelType.Slack).HasPersistedBotToken ? "bot token configured" : "bot token missing") + + " · " + + (Step.GetAdapterViewModel<SlackStepViewModel>(ChannelType.Slack).HasPersistedAppToken ? "app token configured" : "app token missing"), + ChannelType.Discord => Step.GetAdapterViewModel<DiscordStepViewModel>(ChannelType.Discord).HasPersistedBotToken + ? "bot token configured" + : "bot token missing", + ChannelType.Mattermost => Step.GetAdapterViewModel<MattermostStepViewModel>(ChannelType.Mattermost).HasPersistedBotToken + ? "bot token configured" + : "bot token missing", + _ => "credentials unknown" + }; + } + + private static string GetAdapterDisplayName(ChannelType type) => type switch + { + ChannelType.Slack => "Slack", + ChannelType.Discord => "Discord", + ChannelType.Mattermost => "Mattermost", + _ => type.ToString() + }; + + private static string FormatChannelLabel(ChannelType type, string channelId) + => type switch + { + ChannelType.Slack => channelId, + ChannelType.Discord => channelId, + ChannelType.Mattermost => channelId, + _ => channelId + }; + + private static int AudienceIndex(TrustAudience audience) + { + for (var i = 0; i < AudienceOptions.Count; i++) + { + if (AudienceOptions[i] == audience) + return i; + } + + return 0; + } + + private static List<string> ParseCsv(string? input, bool trimHash) + { + if (string.IsNullOrWhiteSpace(input)) + return []; + + return [.. input.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(value => trimHash ? value.Trim().TrimStart('#') : value.Trim()) + .Where(static value => !string.IsNullOrWhiteSpace(value)) + .Distinct(StringComparer.Ordinal)]; + } + + private static string? JoinOrNull(IReadOnlyList<string> values) + => values.Count == 0 ? null : string.Join(", ", values); + + private static string? NormalizeChannelId(string? value) + => string.IsNullOrWhiteSpace(value) ? null : value.Trim().TrimStart('#'); + + private static string? Normalize(string? value) + => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + private static int Clamp(int index, int count) + => count == 0 ? 0 : Math.Clamp(index, 0, count - 1); + + private static int Wrap(int index, int count) + => count == 0 ? 0 : (index % count + count) % count; + + private static string Pluralize(int count, string singular, string plural) + => count == 1 ? $"1 {singular}" : $"{count} {plural}"; + + private void ReturnToDashboard() + { + if (TryGoBack()) + return; + + RequestQuit(); + } + private bool TryGoBack() { if (_navigation is null) @@ -142,104 +955,447 @@ private bool TryGoBack() } } - private IReadOnlyList<ChannelsConfigItem> BuildItems() + private void NotifyContentChanged() { - var config = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath); - return Providers - .Select(provider => new ChannelsConfigItem( - provider.Provider, - provider.Label, - ReadSummary(config, provider), - provider.Description)) - .ToArray(); + OnStepContentChanged?.Invoke(); + RequestRedraw(); } - private IReadOnlyList<ChannelsConfigDetail> BuildDetails(ChannelsConfigProvider providerValue) + private static Dictionary<string, object>? LoadExistingConfig(NetclawPaths paths) { - var provider = Providers.Single(p => p.Provider == providerValue); - var config = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath); - var configured = SectionPresent(config, provider.SectionName) || HasAnySecret(provider.SecretPaths); - var enabled = configured && GetBool(config, $"{provider.SectionName}.Enabled", defaultValue: false); - var channels = ReadConfiguredChannels(config, provider.SectionName); - var users = GetStringArray(config, $"{provider.SectionName}.AllowedUserIds"); - var allowDms = GetBool(config, $"{provider.SectionName}.AllowDirectMessages", defaultValue: false); - var mentionOnly = GetBool(config, $"{provider.SectionName}.MentionOnly", defaultValue: true); - var mentionRequiredInDm = GetBool(config, $"{provider.SectionName}.MentionRequiredInDm", defaultValue: false); - var audienceOverrides = GetDictionaryCount(config, $"{provider.SectionName}.ChannelAudiences"); + if (!File.Exists(paths.NetclawConfigPath)) + return null; + + var config = ConfigFileHelper.LoadJsonDict(paths.NetclawConfigPath); + return config.Count == 0 ? null : config; + } + + private static DeploymentPosture LoadDeploymentPosture(NetclawPaths paths) + { + var config = ConfigFileHelper.LoadJsonDict(paths.NetclawConfigPath); + if (!ConfigFileHelper.TryGetPathValue(config, "Security.DeploymentPosture", out var value)) + return DeploymentPosture.Personal; + + if (Enum.TryParse<DeploymentPosture>(value?.ToString(), ignoreCase: true, out var posture)) + return posture; + + throw new InvalidOperationException($"Configuration value 'Security.DeploymentPosture' is not a valid deployment posture: {value}."); + } +} + +internal enum ChannelsConfigScreen +{ + Picker, + AdapterMenu, + ChannelPermissions, + EditAudience, + AddChannel, + AllowedUsers, + DirectMessages, + RotateCredentials, + ResetConfirm +} + +internal enum ChannelsManagementAction +{ + ManageChannels, + AddChannel, + ManageUsers, + DirectMessages, + RotateCredentials, + ToggleEnabled, + ResetConnection +} + +internal sealed record ChannelsManagementMenuItem( + ChannelsManagementAction Action, + string Label, + string Description); + +internal sealed record ChannelPermissionRow( + string Id, + string DisplayName, + TrustAudience Audience, + bool IsDirectMessage, + bool IsAddAction); + +internal sealed record CredentialFieldSpec( + string Key, + string Label, + bool IsSecret, + string Placeholder, + string? Hint); - var details = new List<ChannelsConfigDetail> +internal sealed class ChannelsConfigPersistenceMapper +{ + internal ChannelsConfigDraft Load(NetclawPaths paths) + { + var config = ConfigFileHelper.LoadJsonDict(paths.NetclawConfigPath); + var secrets = ConfigFileHelper.LoadJsonDict(paths.SecretsPath); + var draft = new ChannelsConfigDraft { - new("Status", enabled ? "enabled" : configured ? "disabled" : "not configured") + Slack = LoadSlack(paths, config, secrets), + Discord = LoadDiscord(paths, config, secrets), + Mattermost = LoadMattermost(paths, config, secrets) }; - AddCredentialDetails(details, provider); + AddKnownProvider(draft.KnownProviders, ChannelType.Slack, draft.Slack.IsKnown); + AddKnownProvider(draft.KnownProviders, ChannelType.Discord, draft.Discord.IsKnown); + AddKnownProvider(draft.KnownProviders, ChannelType.Mattermost, draft.Mattermost.IsKnown); + return draft; + } - if (provider.Provider == ChannelsConfigProvider.Slack) - details.Add(new ChannelsConfigDetail("Socket Mode", GetBool(config, "Slack.SocketMode", defaultValue: true) ? "enabled" : "disabled")); + internal void ApplyToStep(ChannelPickerStepViewModel step, ChannelsConfigDraft draft) + { + step.LoadAdapterState( + ChannelType.Slack, + draft.Slack.Enabled, + BuildSummary(draft.Slack), + vm => ApplySlack((SlackStepViewModel)vm, draft.Slack), + draft.Slack.IsKnown); + + step.LoadAdapterState( + ChannelType.Discord, + draft.Discord.Enabled, + BuildSummary(draft.Discord), + vm => ApplyDiscord((DiscordStepViewModel)vm, draft.Discord), + draft.Discord.IsKnown); + + step.LoadAdapterState( + ChannelType.Mattermost, + draft.Mattermost.Enabled, + BuildSummary(draft.Mattermost), + vm => ApplyMattermost((MattermostStepViewModel)vm, draft.Mattermost), + draft.Mattermost.IsKnown); + } - if (provider.Provider == ChannelsConfigProvider.Mattermost) + internal string? Validate(ChannelPickerStepViewModel step) + { + var slack = step.GetAdapterViewModel<SlackStepViewModel>(ChannelType.Slack); + if (step.IsAdapterEnabled(ChannelType.Slack)) { - details.Add(new ChannelsConfigDetail("Server URL", FormatOptional(GetString(config, "Mattermost.ServerUrl")))); - details.Add(new ChannelsConfigDetail("Callback URL", FormatOptional(GetString(config, "Mattermost.CallbackUrl")))); + if (!HasEffectiveSecret(slack.BotToken, slack.HasPersistedBotToken)) + return "Slack bot token is required."; + if (!HasEffectiveSecret(slack.AppToken, slack.HasPersistedAppToken)) + return "Slack Socket Mode app token is required."; + if (!string.IsNullOrWhiteSpace(slack.BotToken) + && !slack.BotToken.StartsWith("xoxb-", StringComparison.OrdinalIgnoreCase)) + return "Slack bot token must start with xoxb-."; + if (!string.IsNullOrWhiteSpace(slack.AppToken) + && !slack.AppToken.StartsWith("xapp-", StringComparison.OrdinalIgnoreCase)) + return "Slack app token must start with xapp-."; } - details.Add(new ChannelsConfigDetail("Default channel", FormatDefaultChannel(config, provider.SectionName))); - details.Add(new ChannelsConfigDetail("Allowed channels", FormatCount(channels.Count, "configured"))); - details.Add(new ChannelsConfigDetail("Allowed users", FormatCount(users.Count, "configured"))); - details.Add(new ChannelsConfigDetail("DMs", allowDms ? "enabled" : "disabled")); - details.Add(new ChannelsConfigDetail("Channel mentions", mentionOnly ? "required" : "not required")); - details.Add(new ChannelsConfigDetail("DM mentions", allowDms && mentionRequiredInDm ? "required" : "not required")); - details.Add(new ChannelsConfigDetail("Audience overrides", FormatCount(audienceOverrides, "configured"))); + var discord = step.GetAdapterViewModel<DiscordStepViewModel>(ChannelType.Discord); + if (step.IsAdapterEnabled(ChannelType.Discord) + && !HasEffectiveSecret(discord.BotToken, discord.HasPersistedBotToken)) + return "Discord bot token is required."; - return details; + var mattermost = step.GetAdapterViewModel<MattermostStepViewModel>(ChannelType.Mattermost); + if (step.IsAdapterEnabled(ChannelType.Mattermost)) + { + if (string.IsNullOrWhiteSpace(mattermost.ServerUrl)) + return "Mattermost server URL is required."; + if (!Uri.TryCreate(mattermost.ServerUrl, UriKind.Absolute, out _)) + return "Mattermost server URL must be an absolute URL."; + if (!HasEffectiveSecret(mattermost.BotToken, mattermost.HasPersistedBotToken)) + return "Mattermost bot token is required."; + } + + return null; } - private string ReadSummary(Dictionary<string, object> config, ChannelProviderSpec provider) + internal SectionContribution BuildContribution( + ChannelPickerStepViewModel step, + IReadOnlySet<ChannelType> knownProviders, + IReadOnlyDictionary<ChannelType, Dictionary<string, TrustAudience>> channelAudiences, + IReadOnlySet<ChannelType> resetProviders, + DeploymentPosture posture) { - var configured = SectionPresent(config, provider.SectionName) || HasAnySecret(provider.SecretPaths); - if (!configured) - return "not configured"; + var fields = new List<SectionFieldAction>(); + var secrets = new List<SectionSecretAction>(); + + AddSlackContribution( + fields, + secrets, + step.GetAdapterViewModel<SlackStepViewModel>(ChannelType.Slack), + knownProviders.Contains(ChannelType.Slack), + channelAudiences, + resetProviders, + posture); + AddDiscordContribution( + fields, + secrets, + step.GetAdapterViewModel<DiscordStepViewModel>(ChannelType.Discord), + knownProviders.Contains(ChannelType.Discord), + channelAudiences, + resetProviders, + posture); + AddMattermostContribution( + fields, + secrets, + step.GetAdapterViewModel<MattermostStepViewModel>(ChannelType.Mattermost), + knownProviders.Contains(ChannelType.Mattermost), + channelAudiences, + resetProviders, + posture); + + return new SectionContribution(fields, secrets); + } - var enabled = GetBool(config, $"{provider.SectionName}.Enabled", defaultValue: false); - if (!enabled) - return "disabled"; + private static SlackChannelDraft LoadSlack( + NetclawPaths paths, + Dictionary<string, object> config, + Dictionary<string, object> secrets) + { + var hasBotToken = HasSecret(paths, secrets, "Slack.BotToken"); + var hasAppToken = HasSecret(paths, secrets, "Slack.AppToken"); + var sectionPresent = SectionPresent(config, "Slack"); + var channels = ReadConfiguredChannels(config, "Slack"); + var users = GetStringArray(config, "Slack.AllowedUserIds"); + return new SlackChannelDraft + { + IsKnown = sectionPresent || hasBotToken || hasAppToken, + Enabled = sectionPresent && GetBool(config, "Slack.Enabled", defaultValue: false), + HasPersistedBotToken = hasBotToken, + HasPersistedAppToken = hasAppToken, + ChannelIds = channels, + AllowDirectMessages = GetBool(config, "Slack.AllowDirectMessages", defaultValue: false), + AllowedUserIds = users, + ChannelAudiences = GetChannelAudiences(config, "Slack.ChannelAudiences") + }; + } - var channelCount = ReadConfiguredChannels(config, provider.SectionName).Count; - var userCount = GetStringArray(config, $"{provider.SectionName}.AllowedUserIds").Count; - var allowDms = GetBool(config, $"{provider.SectionName}.AllowDirectMessages", defaultValue: false); + private static DiscordChannelDraft LoadDiscord( + NetclawPaths paths, + Dictionary<string, object> config, + Dictionary<string, object> secrets) + { + var hasBotToken = HasSecret(paths, secrets, "Discord.BotToken"); + var sectionPresent = SectionPresent(config, "Discord"); + var channels = ReadConfiguredChannels(config, "Discord"); + var users = GetStringArray(config, "Discord.AllowedUserIds"); + return new DiscordChannelDraft + { + IsKnown = sectionPresent || hasBotToken, + Enabled = sectionPresent && GetBool(config, "Discord.Enabled", defaultValue: false), + HasPersistedBotToken = hasBotToken, + ChannelIds = channels, + AllowDirectMessages = GetBool(config, "Discord.AllowDirectMessages", defaultValue: false), + AllowedUserIds = users, + ChannelAudiences = GetChannelAudiences(config, "Discord.ChannelAudiences") + }; + } - var parts = new List<string> + private static MattermostChannelDraft LoadMattermost( + NetclawPaths paths, + Dictionary<string, object> config, + Dictionary<string, object> secrets) + { + var hasBotToken = HasSecret(paths, secrets, "Mattermost.BotToken"); + var sectionPresent = SectionPresent(config, "Mattermost"); + var channels = ReadConfiguredChannels(config, "Mattermost"); + var users = GetStringArray(config, "Mattermost.AllowedUserIds"); + return new MattermostChannelDraft { - channelCount > 0 - ? Pluralize(channelCount, "channel", "channels") - : allowDms ? "DMs only" : "no channels" + IsKnown = sectionPresent || hasBotToken, + Enabled = sectionPresent && GetBool(config, "Mattermost.Enabled", defaultValue: false), + HasPersistedBotToken = hasBotToken, + ServerUrl = GetString(config, "Mattermost.ServerUrl"), + CallbackUrl = GetString(config, "Mattermost.CallbackUrl"), + ChannelIds = channels, + AllowDirectMessages = GetBool(config, "Mattermost.AllowDirectMessages", defaultValue: false), + AllowedUserIds = users, + ChannelAudiences = GetChannelAudiences(config, "Mattermost.ChannelAudiences") }; + } - if (userCount > 0) - parts.Add(Pluralize(userCount, "user", "users")); + private static void ApplySlack(SlackStepViewModel vm, SlackChannelDraft draft) + { + vm.SlackEnabled = draft.Enabled; + vm.BotToken = null; + vm.AppToken = null; + vm.HasPersistedBotToken = draft.HasPersistedBotToken; + vm.HasPersistedAppToken = draft.HasPersistedAppToken; + vm.ChannelNamesInput = JoinOrNull(draft.ChannelIds); + vm.AllowDirectMessages = draft.AllowDirectMessages; + vm.RestrictToSpecificUsers = draft.AllowedUserIds.Count > 0; + vm.AllowedUserIdsInput = JoinOrNull(draft.AllowedUserIds); + } - return string.Join(", ", parts); + private static void ApplyDiscord(DiscordStepViewModel vm, DiscordChannelDraft draft) + { + vm.DiscordEnabled = draft.Enabled; + vm.BotToken = null; + vm.HasPersistedBotToken = draft.HasPersistedBotToken; + vm.ChannelIdsInput = JoinOrNull(draft.ChannelIds); + vm.AllowDirectMessages = draft.AllowDirectMessages; + vm.RestrictToSpecificUsers = draft.AllowedUserIds.Count > 0; + vm.AllowedUserIdsInput = JoinOrNull(draft.AllowedUserIds); + } + + private static void ApplyMattermost(MattermostStepViewModel vm, MattermostChannelDraft draft) + { + vm.MattermostEnabled = draft.Enabled; + vm.ServerUrl = draft.ServerUrl; + vm.BotToken = null; + vm.HasPersistedBotToken = draft.HasPersistedBotToken; + vm.ChannelIdsInput = JoinOrNull(draft.ChannelIds); + vm.AllowDirectMessages = draft.AllowDirectMessages; + vm.RestrictToSpecificUsers = draft.AllowedUserIds.Count > 0; + vm.AllowedUserIdsInput = JoinOrNull(draft.AllowedUserIds); + vm.CallbackUrl = draft.CallbackUrl; } - private void AddCredentialDetails(List<ChannelsConfigDetail> details, ChannelProviderSpec provider) + private static void AddSlackContribution( + List<SectionFieldAction> fields, + List<SectionSecretAction> secrets, + SlackStepViewModel vm, + bool knownProvider, + IReadOnlyDictionary<ChannelType, Dictionary<string, TrustAudience>> channelAudiences, + IReadOnlySet<ChannelType> resetProviders, + DeploymentPosture posture) { - foreach (var path in provider.SecretPaths) + if (resetProviders.Contains(ChannelType.Slack)) { - var label = path switch - { - "Slack.BotToken" => "Bot token", - "Slack.AppToken" => "App token", - "Discord.BotToken" => "Bot token", - "Mattermost.BotToken" => "Bot token", - _ => path - }; + fields.Add(new SectionFieldAction("Slack", SectionFieldActionKind.Delete)); + secrets.Add(new SectionSecretAction("Slack.BotToken", SectionSecretActionKind.Delete)); + secrets.Add(new SectionSecretAction("Slack.AppToken", SectionSecretActionKind.Delete)); + return; + } + + if (!vm.SlackEnabled) + { + if (knownProvider) + fields.Add(new SectionFieldAction("Slack.Enabled", SectionFieldActionKind.Set, false)); + AddSecretPreserveOrSet(secrets, "Slack.BotToken", vm.BotToken, vm.HasPersistedBotToken); + AddSecretPreserveOrSet(secrets, "Slack.AppToken", vm.AppToken, vm.HasPersistedAppToken); + return; + } + + var channelIds = ParseCsv(vm.ChannelNamesInput, trimHash: true); + var userIds = vm.RestrictToSpecificUsers ? ParseCsv(vm.AllowedUserIdsInput, trimHash: false) : []; + + fields.Add(new SectionFieldAction("Slack.Enabled", SectionFieldActionKind.Set, true)); + fields.Add(new SectionFieldAction("Slack.SocketMode", SectionFieldActionKind.Set, true)); + fields.Add(new SectionFieldAction("Slack.AllowDirectMessages", SectionFieldActionKind.Set, vm.AllowDirectMessages)); + SetArrayOrDelete(fields, "Slack.AllowedChannelIds", channelIds); + SetStringOrDelete(fields, "Slack.DefaultChannelId", channelIds.FirstOrDefault()); + fields.Add(new SectionFieldAction("Slack.DefaultChannelName", SectionFieldActionKind.Delete)); + SetArrayOrDelete(fields, "Slack.AllowedUserIds", userIds); + SetDictionaryOrDelete(fields, "Slack.ChannelAudiences", BuildAudienceMap(ChannelType.Slack, channelIds, userIds, vm.AllowDirectMessages, channelAudiences, posture)); + AddSecretPreserveOrSet(secrets, "Slack.BotToken", vm.BotToken, vm.HasPersistedBotToken); + AddSecretPreserveOrSet(secrets, "Slack.AppToken", vm.AppToken, vm.HasPersistedAppToken); + } + + private static void AddDiscordContribution( + List<SectionFieldAction> fields, + List<SectionSecretAction> secrets, + DiscordStepViewModel vm, + bool knownProvider, + IReadOnlyDictionary<ChannelType, Dictionary<string, TrustAudience>> channelAudiences, + IReadOnlySet<ChannelType> resetProviders, + DeploymentPosture posture) + { + if (resetProviders.Contains(ChannelType.Discord)) + { + fields.Add(new SectionFieldAction("Discord", SectionFieldActionKind.Delete)); + secrets.Add(new SectionSecretAction("Discord.BotToken", SectionSecretActionKind.Delete)); + return; + } + + if (!vm.DiscordEnabled) + { + if (knownProvider) + fields.Add(new SectionFieldAction("Discord.Enabled", SectionFieldActionKind.Set, false)); + AddSecretPreserveOrSet(secrets, "Discord.BotToken", vm.BotToken, vm.HasPersistedBotToken); + return; + } + + var channelIds = ParseCsv(vm.ChannelIdsInput, trimHash: true); + var userIds = vm.RestrictToSpecificUsers ? ParseCsv(vm.AllowedUserIdsInput, trimHash: false) : []; + + fields.Add(new SectionFieldAction("Discord.Enabled", SectionFieldActionKind.Set, true)); + fields.Add(new SectionFieldAction("Discord.AllowDirectMessages", SectionFieldActionKind.Set, vm.AllowDirectMessages)); + SetArrayOrDelete(fields, "Discord.AllowedChannelIds", channelIds); + SetStringOrDelete(fields, "Discord.DefaultChannelId", channelIds.FirstOrDefault()); + SetArrayOrDelete(fields, "Discord.AllowedUserIds", userIds); + SetDictionaryOrDelete(fields, "Discord.ChannelAudiences", BuildAudienceMap(ChannelType.Discord, channelIds, userIds, vm.AllowDirectMessages, channelAudiences, posture)); + AddSecretPreserveOrSet(secrets, "Discord.BotToken", vm.BotToken, vm.HasPersistedBotToken); + } + + private static void AddMattermostContribution( + List<SectionFieldAction> fields, + List<SectionSecretAction> secrets, + MattermostStepViewModel vm, + bool knownProvider, + IReadOnlyDictionary<ChannelType, Dictionary<string, TrustAudience>> channelAudiences, + IReadOnlySet<ChannelType> resetProviders, + DeploymentPosture posture) + { + if (resetProviders.Contains(ChannelType.Mattermost)) + { + fields.Add(new SectionFieldAction("Mattermost", SectionFieldActionKind.Delete)); + secrets.Add(new SectionSecretAction("Mattermost.BotToken", SectionSecretActionKind.Delete)); + return; + } - details.Add(new ChannelsConfigDetail(label, ConfigFileHelper.SecretPresent(_paths, path) ? "configured" : "missing")); + if (!vm.MattermostEnabled) + { + if (knownProvider) + fields.Add(new SectionFieldAction("Mattermost.Enabled", SectionFieldActionKind.Set, false)); + AddSecretPreserveOrSet(secrets, "Mattermost.BotToken", vm.BotToken, vm.HasPersistedBotToken); + return; } + + var channelIds = ParseCsv(vm.ChannelIdsInput, trimHash: true); + var userIds = vm.RestrictToSpecificUsers ? ParseCsv(vm.AllowedUserIdsInput, trimHash: false) : []; + + fields.Add(new SectionFieldAction("Mattermost.Enabled", SectionFieldActionKind.Set, true)); + fields.Add(new SectionFieldAction("Mattermost.AllowDirectMessages", SectionFieldActionKind.Set, vm.AllowDirectMessages)); + SetStringOrDelete(fields, "Mattermost.ServerUrl", Normalize(vm.ServerUrl)); + SetStringOrDelete(fields, "Mattermost.CallbackUrl", Normalize(vm.CallbackUrl)); + SetArrayOrDelete(fields, "Mattermost.AllowedChannelIds", channelIds); + SetStringOrDelete(fields, "Mattermost.DefaultChannelId", channelIds.FirstOrDefault()); + SetArrayOrDelete(fields, "Mattermost.AllowedUserIds", userIds); + SetDictionaryOrDelete(fields, "Mattermost.ChannelAudiences", BuildAudienceMap(ChannelType.Mattermost, channelIds, userIds, vm.AllowDirectMessages, channelAudiences, posture)); + AddSecretPreserveOrSet(secrets, "Mattermost.BotToken", vm.BotToken, vm.HasPersistedBotToken); } - private bool HasAnySecret(IReadOnlyList<string> paths) - => paths.Any(path => ConfigFileHelper.SecretPresent(_paths, path)); + private static void AddSecretPreserveOrSet( + List<SectionSecretAction> secrets, + string path, + string? draftValue, + bool hasPersistedSecret) + { + var normalized = Normalize(draftValue); + if (!string.IsNullOrWhiteSpace(normalized)) + secrets.Add(new SectionSecretAction(path, SectionSecretActionKind.Set, new SensitiveString(normalized))); + else if (hasPersistedSecret) + secrets.Add(new SectionSecretAction(path, SectionSecretActionKind.Preserve)); + } + + private static void SetArrayOrDelete(List<SectionFieldAction> fields, string path, IReadOnlyList<string> values) + { + fields.Add(values.Count > 0 + ? new SectionFieldAction(path, SectionFieldActionKind.Set, values.ToArray()) + : new SectionFieldAction(path, SectionFieldActionKind.Delete)); + } + + private static void SetDictionaryOrDelete(List<SectionFieldAction> fields, string path, IReadOnlyDictionary<string, string> values) + { + fields.Add(values.Count > 0 + ? new SectionFieldAction(path, SectionFieldActionKind.Set, new Dictionary<string, string>(values, StringComparer.Ordinal)) + : new SectionFieldAction(path, SectionFieldActionKind.Delete)); + } + + private static void SetStringOrDelete(List<SectionFieldAction> fields, string path, string? value) + { + var normalized = Normalize(value); + fields.Add(!string.IsNullOrWhiteSpace(normalized) + ? new SectionFieldAction(path, SectionFieldActionKind.Set, normalized) + : new SectionFieldAction(path, SectionFieldActionKind.Delete)); + } private static bool SectionPresent(Dictionary<string, object> config, string sectionName) { @@ -252,6 +1408,49 @@ private static bool SectionPresent(Dictionary<string, object> config, string sec throw new InvalidOperationException($"Configuration section '{sectionName}' must be an object."); } + private static Dictionary<string, string> BuildAudienceMap( + ChannelType type, + IReadOnlyList<string> channelIds, + IReadOnlyList<string> userIds, + bool allowDirectMessages, + IReadOnlyDictionary<ChannelType, Dictionary<string, TrustAudience>> channelAudiences, + DeploymentPosture posture) + { + channelAudiences.TryGetValue(type, out var explicitAudiences); + var map = new Dictionary<string, string>(StringComparer.Ordinal); + foreach (var channelId in channelIds) + { + var audience = explicitAudiences is not null && explicitAudiences.TryGetValue(channelId, out var explicitAudience) + ? explicitAudience + : DefaultChannelAudience(posture); + map[channelId] = audience.ToWireValue(); + } + + if (explicitAudiences is not null && explicitAudiences.TryGetValue("dm", out var explicitDmAudience)) + { + map["dm"] = explicitDmAudience.ToWireValue(); + } + else if (allowDirectMessages) + { + map["dm"] = DefaultDirectMessageAudience(posture, userIds).ToWireValue(); + } + + return map; + } + + private static TrustAudience DefaultChannelAudience(DeploymentPosture posture) + => posture == DeploymentPosture.Public ? TrustAudience.Public : TrustAudience.Team; + + private static TrustAudience DefaultDirectMessageAudience(DeploymentPosture posture, IReadOnlyList<string> userIds) + => userIds.Count == 1 + ? TrustAudience.Personal + : posture switch + { + DeploymentPosture.Public => TrustAudience.Public, + DeploymentPosture.Team => TrustAudience.Team, + _ => TrustAudience.Personal + }; + private static bool GetBool(Dictionary<string, object> config, string path, bool defaultValue) { if (!ConfigFileHelper.TryGetPathValue(config, path, out var value) || value is null) @@ -288,10 +1487,9 @@ private static IReadOnlyList<string> ReadConfiguredChannels(Dictionary<string, o channels.Add(defaultChannelName.StartsWith('#') ? defaultChannelName : $"#{defaultChannelName}"); } - return channels + return [.. channels .Where(static value => !string.IsNullOrWhiteSpace(value)) - .Distinct(StringComparer.Ordinal) - .ToArray(); + .Distinct(StringComparer.Ordinal)]; } private static IReadOnlyList<string> GetStringArray(Dictionary<string, object> config, string path) @@ -301,62 +1499,142 @@ private static IReadOnlyList<string> GetStringArray(Dictionary<string, object> c if (value is object[] objectValues) { - return objectValues + return [.. objectValues .Select(static item => item switch { string stringValue => stringValue, JsonElement { ValueKind: JsonValueKind.String } element => element.GetString()!, _ => throw new InvalidOperationException("Channel list values must be strings.") }) - .Where(static item => !string.IsNullOrWhiteSpace(item)) - .ToArray(); + .Where(static item => !string.IsNullOrWhiteSpace(item))]; } if (value is string[] stringValues) - return stringValues.Where(static item => !string.IsNullOrWhiteSpace(item)).ToArray(); + return [.. stringValues.Where(static item => !string.IsNullOrWhiteSpace(item))]; throw new InvalidOperationException($"Configuration value '{path}' must be an array of strings."); } - private static int GetDictionaryCount(Dictionary<string, object> config, string path) + private static Dictionary<string, TrustAudience> GetChannelAudiences(Dictionary<string, object> config, string path) { if (!ConfigFileHelper.TryGetPathValue(config, path, out var value) || value is null) - return 0; + return []; - return value is Dictionary<string, object> dict - ? dict.Count - : throw new InvalidOperationException($"Configuration value '{path}' must be an object."); + if (value is not Dictionary<string, object> values) + throw new InvalidOperationException($"Configuration value '{path}' must be an object."); + + var audiences = new Dictionary<string, TrustAudience>(StringComparer.Ordinal); + foreach (var (channelId, rawAudience) in values) + { + var wire = rawAudience switch + { + string text => text, + JsonElement { ValueKind: JsonValueKind.String } element => element.GetString(), + _ => throw new InvalidOperationException($"Channel audience '{path}.{channelId}' must be a string.") + }; + + if (!SecurityPolicyDefaults.TryParseAudience(wire, out var audience)) + throw new InvalidOperationException($"Channel audience '{path}.{channelId}' is not valid: {wire}."); + + audiences[channelId] = audience; + } + + return audiences; } - private static string FormatDefaultChannel(Dictionary<string, object> config, string sectionName) + private static bool HasSecret(NetclawPaths paths, Dictionary<string, object> secrets, string path) { - var id = GetString(config, $"{sectionName}.DefaultChannelId"); - if (!string.IsNullOrWhiteSpace(id)) - return id; + if (!ConfigFileHelper.TryGetPathValue(secrets, path, out var value)) + return false; - if (string.Equals(sectionName, "Slack", StringComparison.Ordinal)) + return !string.IsNullOrWhiteSpace(ConfigFileHelper.DecryptIfEncrypted(paths, value?.ToString())); + } + + private static string? BuildSummary(ChannelProviderDraft draft) + { + if (!draft.IsKnown) + return null; + + if (!draft.Enabled) + return "disabled, saved setup"; + + var channelCount = draft.ChannelIds.Count; + var userCount = draft.AllowedUserIds.Count; + var parts = new List<string> { - var name = GetString(config, "Slack.DefaultChannelName"); - if (!string.IsNullOrWhiteSpace(name)) - return name.StartsWith('#') ? name : $"#{name}"; - } + channelCount > 0 + ? Pluralize(channelCount, "channel", "channels") + : draft.AllowDirectMessages ? "DMs only" : "no channels" + }; - return "not set"; + if (userCount > 0) + parts.Add(Pluralize(userCount, "user", "users")); + + return string.Join(", ", parts); + } + + private static bool HasEffectiveSecret(string? draftValue, bool hasPersistedSecret) + => !string.IsNullOrWhiteSpace(draftValue) || hasPersistedSecret; + + private static void AddKnownProvider(HashSet<ChannelType> knownProviders, ChannelType type, bool isKnown) + { + if (isKnown) + knownProviders.Add(type); + } + + private static List<string> ParseCsv(string? input, bool trimHash) + { + if (string.IsNullOrWhiteSpace(input)) + return []; + + return [.. input.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(value => trimHash ? value.Trim().TrimStart('#') : value.Trim()) + .Where(static value => !string.IsNullOrWhiteSpace(value)) + .Distinct(StringComparer.Ordinal)]; } - private static string FormatOptional(string? value) - => string.IsNullOrWhiteSpace(value) ? "not set" : value; + private static string? JoinOrNull(IReadOnlyList<string> values) + => values.Count == 0 ? null : string.Join(", ", values); - private static string FormatCount(int count, string suffix) - => count == 0 ? "none" : $"{count} {suffix}"; + private static string? Normalize(string? value) + => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); private static string Pluralize(int count, string singular, string plural) => count == 1 ? $"1 {singular}" : $"{count} {plural}"; +} + +internal sealed class ChannelsConfigDraft +{ + public required SlackChannelDraft Slack { get; init; } + public required DiscordChannelDraft Discord { get; init; } + public required MattermostChannelDraft Mattermost { get; init; } + public HashSet<ChannelType> KnownProviders { get; } = []; +} + +internal abstract class ChannelProviderDraft +{ + public bool IsKnown { get; init; } + public bool Enabled { get; init; } + public IReadOnlyList<string> ChannelIds { get; init; } = []; + public bool AllowDirectMessages { get; init; } + public IReadOnlyList<string> AllowedUserIds { get; init; } = []; + public IReadOnlyDictionary<string, TrustAudience> ChannelAudiences { get; init; } = new Dictionary<string, TrustAudience>(StringComparer.Ordinal); +} - private sealed record ChannelProviderSpec( - ChannelsConfigProvider Provider, - string Label, - string Description, - string SectionName, - IReadOnlyList<string> SecretPaths); +internal sealed class SlackChannelDraft : ChannelProviderDraft +{ + public bool HasPersistedBotToken { get; init; } + public bool HasPersistedAppToken { get; init; } +} + +internal sealed class DiscordChannelDraft : ChannelProviderDraft +{ + public bool HasPersistedBotToken { get; init; } +} + +internal sealed class MattermostChannelDraft : ChannelProviderDraft +{ + public string? ServerUrl { get; init; } + public bool HasPersistedBotToken { get; init; } + public string? CallbackUrl { get; init; } } diff --git a/src/Netclaw.Cli/Tui/Wizard/Steps/ChannelPickerStepView.cs b/src/Netclaw.Cli/Tui/Wizard/Steps/ChannelPickerStepView.cs index 93c83fe16..5af46f81e 100644 --- a/src/Netclaw.Cli/Tui/Wizard/Steps/ChannelPickerStepView.cs +++ b/src/Netclaw.Cli/Tui/Wizard/Steps/ChannelPickerStepView.cs @@ -79,8 +79,8 @@ private ILayoutNode BuildPickerChecklist() var hasConfigured = _vm.AnyAdapterConfigured; var hintText = hasConfigured - ? " ↑/↓ to navigate, Space to toggle, Enter to configure selected.\n [e] Edit configured channel [d] Done — continue to next step" - : " ↑/↓ to navigate, Space to toggle, Enter to configure selected.\n [d] Done — continue to next step"; + ? $" ↑/↓ to navigate, Space to toggle, Enter to open selected.\n [e] Edit configured channel [d] Done - {_vm.DoneActionText}" + : $" ↑/↓ to navigate, Space to toggle, Enter to configure selected.\n [d] Done - {_vm.DoneActionText}"; layout = layout.WithChild(new TextNode(hintText).WithForeground(Color.BrightBlack)); diff --git a/src/Netclaw.Cli/Tui/Wizard/Steps/ChannelPickerStepViewModel.cs b/src/Netclaw.Cli/Tui/Wizard/Steps/ChannelPickerStepViewModel.cs index f3884e921..2d0867be1 100644 --- a/src/Netclaw.Cli/Tui/Wizard/Steps/ChannelPickerStepViewModel.cs +++ b/src/Netclaw.Cli/Tui/Wizard/Steps/ChannelPickerStepViewModel.cs @@ -31,6 +31,7 @@ private enum Mode { Picker, SubFlow } private readonly List<ChannelAdapterEntry> _adapters; private readonly Dictionary<ChannelType, bool> _enabled = []; private readonly Dictionary<ChannelType, string> _summaries = []; + private readonly HashSet<ChannelType> _knownAdapters = []; public ChannelPickerStepViewModel(ISlackProbe slackProbe, IDiscordProbe discordProbe) { @@ -70,16 +71,72 @@ internal int CursorIndex } internal IWizardStepViewModel? ActiveAdapterVm => _activeAdapter?.Vm; internal IWizardStepView? ActiveAdapterView => _activeAdapter?.View; + internal ChannelType? ActiveAdapterType => _activeAdapter?.Type; + internal ChannelType SelectedAdapterType => _adapters[CursorIndex].Type; + internal string SelectedAdapterDisplayName => _adapters[CursorIndex].DisplayName; + + internal string DoneActionText { get; set; } = "continue to next step"; + internal bool PreserveDisabledAdapterDrafts { get; set; } internal bool IsAdapterEnabled(int index) => index >= 0 && index < _adapters.Count && _enabled[_adapters[index].Type]; + internal bool IsAdapterEnabled(ChannelType type) => + _enabled.TryGetValue(type, out var enabled) && enabled; + + internal bool IsAdapterKnown(ChannelType type) => _knownAdapters.Contains(type); + + internal TAdapter GetAdapterViewModel<TAdapter>(ChannelType type) + where TAdapter : class, IWizardStepViewModel + => _adapters.Single(a => a.Type == type).Vm as TAdapter + ?? throw new InvalidOperationException($"Channel adapter '{type}' is not a {typeof(TAdapter).Name}."); + + internal void LoadAdapterState( + ChannelType type, + bool enabled, + string? summary, + Action<IWizardStepViewModel> configure, + bool isKnown = false) + { + var adapter = _adapters.Single(a => a.Type == type); + _enabled[type] = enabled; + SetChildEnabled(adapter, enabled); + configure(adapter.Vm); + + if (isKnown) + _knownAdapters.Add(type); + else + _knownAdapters.Remove(type); + + if (summary is null) + _summaries.Remove(type); + else + _summaries[type] = summary; + } + + internal void ResetAdapterState(ChannelType type) + { + var adapter = _adapters.Single(a => a.Type == type); + _enabled[type] = false; + _knownAdapters.Remove(type); + _summaries.Remove(type); + ResetChildConfig(adapter); + } + internal string? GetAdapterSummary(int index) => index >= 0 && index < _adapters.Count && _summaries.TryGetValue(_adapters[index].Type, out var summary) ? summary : null; + internal void SetAdapterSummary(ChannelType type, string? summary) + { + if (summary is null) + _summaries.Remove(type); + else + _summaries[type] = summary; + } + internal bool AnyAdapterConfigured => _summaries.Count > 0; internal void ToggleAdapter(int index) @@ -89,17 +146,27 @@ internal void ToggleAdapter(int index) if (_enabled[adapter.Type]) { - // Toggling OFF — clear config + // Config-editor toggles disable without throwing away dormant setup. _enabled[adapter.Type] = false; - _summaries.Remove(adapter.Type); - ResetChildConfig(adapter); + SetChildEnabled(adapter, false); + if (PreserveDisabledAdapterDrafts && _knownAdapters.Contains(adapter.Type)) + { + _summaries[adapter.Type] = "disabled, saved setup"; + } + else + { + _summaries.Remove(adapter.Type); + ResetChildConfig(adapter); + } } else { - // Toggling ON — enter sub-flow _enabled[adapter.Type] = true; SetChildEnabled(adapter, true); - EnterSubFlow(adapter); + if (PreserveDisabledAdapterDrafts && _knownAdapters.Contains(adapter.Type)) + _summaries[adapter.Type] = ComputeSummary(adapter); + else + EnterSubFlow(adapter); } } @@ -223,6 +290,7 @@ private void CompleteSubFlow() { var adapter = _activeAdapter!; _summaries[adapter.Type] = ComputeSummary(adapter); + _knownAdapters.Add(adapter.Type); _mode = Mode.Picker; _activeAdapter = null; } diff --git a/src/Netclaw.Cli/Tui/Wizard/Steps/DiscordStepView.cs b/src/Netclaw.Cli/Tui/Wizard/Steps/DiscordStepView.cs index fd49d8fc8..d71e0bf50 100644 --- a/src/Netclaw.Cli/Tui/Wizard/Steps/DiscordStepView.cs +++ b/src/Netclaw.Cli/Tui/Wizard/Steps/DiscordStepView.cs @@ -86,17 +86,30 @@ private ILayoutNode BuildBotTokenSubStep(DiscordStepViewModel vm, StepViewCallba _lastFocusedList = null; _botTokenInput.Submitted - .Where(text => !string.IsNullOrWhiteSpace(text)) .Subscribe(text => { + if (string.IsNullOrWhiteSpace(text)) + { + if (vm.HasPersistedBotToken || !string.IsNullOrWhiteSpace(vm.BotToken)) + callbacks.AdvanceStep(); + + callbacks.RequestRedraw(); + return; + } + vm.BotToken = text; callbacks.AdvanceStep(); }) .DisposeWith(callbacks.Subscriptions); - return Layouts.Vertical() + var layout = Layouts.Vertical() .WithChild(new TextNode(" Discord Bot Token:").WithForeground(Color.White)) .WithChild(WizardStepHelpers.BuildTextInputPanel(_botTokenInput, "Bot Token")); + + if (vm.HasPersistedBotToken) + layout = layout.WithChild(new TextNode(" (configured - leave blank to keep)").WithForeground(Color.BrightBlack)); + + return layout; } private ILayoutNode BuildChannelIdsSubStep(DiscordStepViewModel vm, StepViewCallbacks callbacks) diff --git a/src/Netclaw.Cli/Tui/Wizard/Steps/DiscordStepViewModel.cs b/src/Netclaw.Cli/Tui/Wizard/Steps/DiscordStepViewModel.cs index ba819be6a..e007826e6 100644 --- a/src/Netclaw.Cli/Tui/Wizard/Steps/DiscordStepViewModel.cs +++ b/src/Netclaw.Cli/Tui/Wizard/Steps/DiscordStepViewModel.cs @@ -41,6 +41,7 @@ bool IChannelAdapterViewModel.AdapterEnabled ParseChannelIds(ChannelIdsInput).Count; public string? BotToken { get; set; } + public bool HasPersistedBotToken { get; set; } public string? ChannelIdsInput { get; set; } public bool AllowDirectMessages { get; set; } public bool RestrictToSpecificUsers { get; set; } diff --git a/src/Netclaw.Cli/Tui/Wizard/Steps/MattermostStepView.cs b/src/Netclaw.Cli/Tui/Wizard/Steps/MattermostStepView.cs index b3dea12ff..9c52e3c12 100644 --- a/src/Netclaw.Cli/Tui/Wizard/Steps/MattermostStepView.cs +++ b/src/Netclaw.Cli/Tui/Wizard/Steps/MattermostStepView.cs @@ -117,17 +117,30 @@ private ILayoutNode BuildBotTokenSubStep(MattermostStepViewModel vm, StepViewCal _lastFocusedList = null; _botTokenInput.Submitted - .Where(text => !string.IsNullOrWhiteSpace(text)) .Subscribe(text => { + if (string.IsNullOrWhiteSpace(text)) + { + if (vm.HasPersistedBotToken || !string.IsNullOrWhiteSpace(vm.BotToken)) + callbacks.AdvanceStep(); + + callbacks.RequestRedraw(); + return; + } + vm.BotToken = text; callbacks.AdvanceStep(); }) .DisposeWith(callbacks.Subscriptions); - return Layouts.Vertical() + var layout = Layouts.Vertical() .WithChild(new TextNode(" Mattermost Bot Token:").WithForeground(Color.White)) .WithChild(WizardStepHelpers.BuildTextInputPanel(_botTokenInput, "Bot Token")); + + if (vm.HasPersistedBotToken) + layout = layout.WithChild(new TextNode(" (configured - leave blank to keep)").WithForeground(Color.BrightBlack)); + + return layout; } private ILayoutNode BuildChannelIdsSubStep(MattermostStepViewModel vm, StepViewCallbacks callbacks) diff --git a/src/Netclaw.Cli/Tui/Wizard/Steps/MattermostStepViewModel.cs b/src/Netclaw.Cli/Tui/Wizard/Steps/MattermostStepViewModel.cs index cbf03b96f..bcae3f67f 100644 --- a/src/Netclaw.Cli/Tui/Wizard/Steps/MattermostStepViewModel.cs +++ b/src/Netclaw.Cli/Tui/Wizard/Steps/MattermostStepViewModel.cs @@ -42,6 +42,7 @@ bool IChannelAdapterViewModel.AdapterEnabled public string? ServerUrl { get; set; } public string? BotToken { get; set; } + public bool HasPersistedBotToken { get; set; } public string? ChannelIdsInput { get; set; } public bool AllowDirectMessages { get; set; } public bool RestrictToSpecificUsers { get; set; } diff --git a/src/Netclaw.Cli/Tui/Wizard/Steps/SlackStepView.cs b/src/Netclaw.Cli/Tui/Wizard/Steps/SlackStepView.cs index 74e2cc4ba..49ae173f4 100644 --- a/src/Netclaw.Cli/Tui/Wizard/Steps/SlackStepView.cs +++ b/src/Netclaw.Cli/Tui/Wizard/Steps/SlackStepView.cs @@ -88,9 +88,17 @@ private ILayoutNode BuildBotTokenSubStep(SlackStepViewModel vm, StepViewCallback _lastFocusedList = null; _botTokenInput.Submitted - .Where(text => !string.IsNullOrWhiteSpace(text)) .Subscribe(text => { + if (string.IsNullOrWhiteSpace(text)) + { + if (vm.HasPersistedBotToken || !string.IsNullOrWhiteSpace(vm.BotToken)) + callbacks.AdvanceStep(); + + callbacks.RequestRedraw(); + return; + } + if (!text.StartsWith("xoxb-", StringComparison.OrdinalIgnoreCase)) { callbacks.RequestRedraw(); @@ -101,9 +109,14 @@ private ILayoutNode BuildBotTokenSubStep(SlackStepViewModel vm, StepViewCallback }) .DisposeWith(callbacks.Subscriptions); - return Layouts.Vertical() + var layout = Layouts.Vertical() .WithChild(new TextNode(" Slack Bot Token:").WithForeground(Color.White)) .WithChild(WizardStepHelpers.BuildTextInputPanel(_botTokenInput, "Bot Token")); + + if (vm.HasPersistedBotToken) + layout = layout.WithChild(new TextNode(" (configured - leave blank to keep)").WithForeground(Color.BrightBlack)); + + return layout; } private ILayoutNode BuildAppTokenSubStep(SlackStepViewModel vm, StepViewCallbacks callbacks) @@ -117,9 +130,17 @@ private ILayoutNode BuildAppTokenSubStep(SlackStepViewModel vm, StepViewCallback _lastFocusedList = null; _appTokenInput.Submitted - .Where(text => !string.IsNullOrWhiteSpace(text)) .Subscribe(text => { + if (string.IsNullOrWhiteSpace(text)) + { + if (vm.HasPersistedAppToken || !string.IsNullOrWhiteSpace(vm.AppToken)) + callbacks.AdvanceStep(); + + callbacks.RequestRedraw(); + return; + } + if (!text.StartsWith("xapp-", StringComparison.OrdinalIgnoreCase)) { callbacks.RequestRedraw(); @@ -130,9 +151,14 @@ private ILayoutNode BuildAppTokenSubStep(SlackStepViewModel vm, StepViewCallback }) .DisposeWith(callbacks.Subscriptions); - return Layouts.Vertical() + var layout = Layouts.Vertical() .WithChild(new TextNode(" Slack App Token (Socket Mode):").WithForeground(Color.White)) .WithChild(WizardStepHelpers.BuildTextInputPanel(_appTokenInput, "App Token")); + + if (vm.HasPersistedAppToken) + layout = layout.WithChild(new TextNode(" (configured - leave blank to keep)").WithForeground(Color.BrightBlack)); + + return layout; } private ILayoutNode BuildChannelNamesSubStep(SlackStepViewModel vm, StepViewCallbacks callbacks) diff --git a/src/Netclaw.Cli/Tui/Wizard/Steps/SlackStepViewModel.cs b/src/Netclaw.Cli/Tui/Wizard/Steps/SlackStepViewModel.cs index 57904dd32..344c6ec22 100644 --- a/src/Netclaw.Cli/Tui/Wizard/Steps/SlackStepViewModel.cs +++ b/src/Netclaw.Cli/Tui/Wizard/Steps/SlackStepViewModel.cs @@ -44,6 +44,8 @@ bool IChannelAdapterViewModel.AdapterEnabled ParseChannelNames(ChannelNamesInput).Count; public string? BotToken { get; set; } public string? AppToken { get; set; } + public bool HasPersistedBotToken { get; set; } + public bool HasPersistedAppToken { get; set; } public string? ChannelNamesInput { get; set; } public bool AllowDirectMessages { get; set; } public bool RestrictToSpecificUsers { get; set; } diff --git a/tests/smoke/assertions/config-channels.sh b/tests/smoke/assertions/config-channels.sh index 1e4164108..d5722eb95 100755 --- a/tests/smoke/assertions/config-channels.sh +++ b/tests/smoke/assertions/config-channels.sh @@ -1,13 +1,14 @@ #!/usr/bin/env bash # config-channels.tape post-tape assertion. # -# Validates the read-only Channels page did not mutate seeded channel config. +# Validates the Channels editor saved Slack management changes while preserving secrets. set -euo pipefail . "$(dirname "$0")/_lib.sh" assert_fail=0 +SECRETS_PATH="${NETCLAW_HOME}/config/secrets.json" echo "config-channels: reading produced config..." if [[ ! -f "$CONFIG_PATH" ]]; then @@ -15,15 +16,32 @@ if [[ ! -f "$CONFIG_PATH" ]]; then exit 1 fi +if [[ ! -f "$SECRETS_PATH" ]]; then + echo "FAIL: ${SECRETS_PATH} does not exist." >&2 + exit 1 +fi + config_json="$(read_config_json)" +secrets_json="$(cat "$SECRETS_PATH")" assert_field '.Slack.Enabled' 'true' "$config_json" || : -assert_field '(.Slack.AllowedChannelIds | length)' '2' "$config_json" || : +assert_field '(.Slack.AllowedChannelIds | length)' '3' "$config_json" || : +assert_field '.Slack.AllowedChannelIds[0]' 'C01' "$config_json" || : +assert_field '.Slack.AllowedChannelIds[1]' 'C02' "$config_json" || : +assert_field '.Slack.AllowedChannelIds[2]' 'C09' "$config_json" || : +assert_field '.Slack.DefaultChannelId' 'C01' "$config_json" || : assert_field '(.Slack.AllowedUserIds | length)' '1' "$config_json" || : -assert_field '.Mattermost.DefaultChannelId' 'town-square' "$config_json" || : +assert_field '.Slack.AllowedUserIds[0]' 'U09' "$config_json" || : +assert_field '.Slack.AllowDirectMessages' 'false' "$config_json" || : +assert_field '.Slack.ChannelAudiences.C01' 'public' "$config_json" || : +assert_field '.Slack.ChannelAudiences.C02' 'team' "$config_json" || : +assert_field '.Slack.ChannelAudiences.C09' 'team' "$config_json" || : +assert_field '.Slack.BotToken' 'xoxb-test' "$secrets_json" || : +assert_field '.Slack.AppToken' 'xapp-test' "$secrets_json" || : if (( assert_fail )); then printf -- '--- netclaw.json contents ---\n%s\n' "$config_json" >&2 + printf -- '--- secrets.json contents ---\n%s\n' "$secrets_json" >&2 exit 1 fi diff --git a/tests/smoke/tapes/config-channels.tape b/tests/smoke/tapes/config-channels.tape index fb445da28..1b8e5de99 100644 --- a/tests/smoke/tapes/config-channels.tape +++ b/tests/smoke/tapes/config-channels.tape @@ -1,17 +1,18 @@ -# config-channels.tape - open Channels from netclaw config. +# config-channels.tape - edit Channels from netclaw config. # # Exercises: -# netclaw config -> Channels -> Slack details -> back to dashboard -# and verifies the read-only channel summary page can render existing config. +# netclaw config -> Channels -> configured Slack management menu +# -> channel permission edit -> add channel -> allowed users -> save. +# Verifies configured Slack does not re-prompt for credentials during re-entry. Output "/tmp/tape-config-channels.gif" -# Seed channel config so `netclaw config` can render useful summaries. +# Seed Slack config so `netclaw config` can render the channel management flow. Type "mkdir -p $NETCLAW_HOME/config" Enter -Type "c1=C01 c2=C02 u1=U01 mm=town-square; jq -n --arg c1 $c1 --arg c2 $c2 --arg u1 $u1 --arg mm $mm '{configVersion:1,Slack:{Enabled:true,AllowedChannelIds:[$c1,$c2],AllowedUserIds:[$u1]},Mattermost:{Enabled:true,DefaultChannelId:$mm}}' > $NETCLAW_HOME/config/netclaw.json" +Type "c1=C01 c2=C02 u1=U01; jq -n --arg c1 $c1 --arg c2 $c2 --arg u1 $u1 --arg aud team '{configVersion:1,Slack:{Enabled:true,SocketMode:true,AllowedChannelIds:[$c1,$c2],AllowedUserIds:[$u1],AllowDirectMessages:false,ChannelAudiences:{($c1):$aud,($c2):$aud}}}' > $NETCLAW_HOME/config/netclaw.json" Enter -Type "bot=xoxb-test app=xapp-test mm_token=mattermost-test; jq -n --arg bot $bot --arg app $app --arg mm_token $mm_token '{configVersion:1,Slack:{BotToken:$bot,AppToken:$app},Mattermost:{BotToken:$mm_token}}' > $NETCLAW_HOME/config/secrets.json" +Type "bot=xoxb-test app=xapp-test; jq -n --arg bot $bot --arg app $app '{configVersion:1,Slack:{BotToken:$bot,AppToken:$app}}' > $NETCLAW_HOME/config/secrets.json" Enter Type "netclaw config" @@ -21,19 +22,52 @@ Wait+Screen@10s /Settings Areas/ # Channels is row 3 in the dashboard list. Down 2 Enter -Wait+Screen@10s /Chat Channels/ +Wait+Screen@10s /Which channels would you like to connect/ Wait+Screen@10s /Slack/ Wait+Screen@10s /2 channels, 1 user/ +# Re-enter configured Slack. This should open management, not token prompts. Enter -Wait+Screen@10s /Slack Channels/ -Wait+Screen@10s /Bot token/ -Wait+Screen@10s /Allowed channels[[:space:]]+2 configured/ +Wait+Screen@10s /Slack is configured/ +Wait+Screen@10s /Manage channels and permissions/ +# Edit the first channel audience from Team to Public. +Enter +Wait+Screen@10s /Channels & Permissions/ +Wait+Screen@10s /C01/ +Right +Wait+Screen@10s /Public/ + +# Add a new channel without touching credentials. +Type "a" +Wait+Screen@10s /Add Channel/ +Type "C09" +Enter +Wait+Screen@10s /Added C09/ +Wait+Screen@10s /C09/ + +# Update allowed users from the management menu. Escape -Wait+Screen@10s /Chat Channels/ +Wait+Screen@10s /What would you like to do/ +Down 2 +Enter +Wait+Screen@10s /User IDs/ +Right 32 +Backspace 32 +Type "U09" +Enter +Wait+Screen@10s /Allowed users staged/ + +# Return to picker and save. Escape +Wait+Screen@10s /Which channels would you like to connect/ +Wait+Screen@10s /3 channels/ + +Type "d" +Wait+Screen@10s /Channel settings saved/ +Enter Wait+Screen@10s /Settings Areas/ + Ctrl+Q Wait+Screen@10s /TAPE\$/ From eb2f9bccc3abcad007a8bb2eb40326e9e4430c7d Mon Sep 17 00:00:00 2001 From: Aaron Stannard <aaron@petabridge.com> Date: Sun, 31 May 2026 15:30:31 +0000 Subject: [PATCH 27/31] fix(config): persist channel connection resets --- docs/ui/TUI-002-netclaw-config-wireframes.md | 5 +- .../Config/ChannelsConfigNavigationTests.cs | 31 +++++++ .../Config/ChannelsConfigViewModelTests.cs | 83 +++++++++++++---- .../Tui/Config/ChannelsConfigPage.cs | 14 ++- .../Tui/Config/ChannelsConfigViewModel.cs | 88 +++++++++++-------- 5 files changed, 156 insertions(+), 65 deletions(-) diff --git a/docs/ui/TUI-002-netclaw-config-wireframes.md b/docs/ui/TUI-002-netclaw-config-wireframes.md index 10eeb8c9e..ca32b9289 100644 --- a/docs/ui/TUI-002-netclaw-config-wireframes.md +++ b/docs/ui/TUI-002-netclaw-config-wireframes.md @@ -420,9 +420,8 @@ this first pass. The same menu is used for Slack, Discord, and Mattermost. Disable/enable only changes `<Adapter>.Enabled`; dormant channel fields and stored credentials are -preserved. Reset stages deletion of the adapter config section and secrets, -then returns to the picker. The deletion is written only when the operator -saves from the picker. +preserved. Reset is immediate: confirming reset deletes the adapter config +section and its secrets before returning to the picker/saved screen. ### 3.3 Channels and permissions diff --git a/src/Netclaw.Cli.Tests/Tui/Config/ChannelsConfigNavigationTests.cs b/src/Netclaw.Cli.Tests/Tui/Config/ChannelsConfigNavigationTests.cs index a338764a6..4af24f621 100644 --- a/src/Netclaw.Cli.Tests/Tui/Config/ChannelsConfigNavigationTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/Config/ChannelsConfigNavigationTests.cs @@ -4,9 +4,11 @@ // </copyright> // ----------------------------------------------------------------------- using Microsoft.Extensions.DependencyInjection; +using Netclaw.Actors.Channels; using Netclaw.Cli.Tests.Tui; using Netclaw.Cli.Tui; using Netclaw.Cli.Tui.Config; +using Netclaw.Cli.Tui.Wizard.Steps; using Netclaw.Configuration; using Netclaw.Tests.Utilities; using Termina; @@ -57,6 +59,35 @@ public async Task Channels_Escape_ReturnsToDashboardUsingTerminaHistory() Assert.Equal("/config", app.CurrentPath); } + [Fact] + public async Task Channels_RotateCredentials_AcceptsTypedSecretInput() + { + var app = CreateHeadlessApp(out var input, out var dashboardVm, out var getChannelsVm); + dashboardVm.SelectedIndex.Value = dashboardVm.Items + .Select((item, index) => (item, index)) + .Single(entry => entry.item.Label == "Channels") + .index; + dashboardVm.ActivateSelected(); + + input.EnqueueKey(ConsoleKey.Enter); // Open configured Slack management. + input.EnqueueKey(ConsoleKey.DownArrow); + input.EnqueueKey(ConsoleKey.DownArrow); + input.EnqueueKey(ConsoleKey.DownArrow); + input.EnqueueKey(ConsoleKey.DownArrow); + input.EnqueueKey(ConsoleKey.Enter); // Rotate credentials. + input.EnqueueString("xoxb-typed-token"); + input.EnqueueKey(ConsoleKey.Enter); + input.EnqueueKey(ConsoleKey.Q, false, false, true); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await app.RunAsync(cts.Token); + + var channelsVm = Assert.IsType<ChannelsConfigViewModel>(getChannelsVm()); + var slack = channelsVm.Step.GetAdapterViewModel<SlackStepViewModel>(ChannelType.Slack); + Assert.Equal("xoxb-typed-token", slack.BotToken); + Assert.Equal("Credential changes staged. Press d to save.", channelsVm.Status.Value.Text); + } + private TerminaApplication CreateHeadlessApp( out VirtualInputSource input, out ConfigDashboardViewModel dashboardVm, diff --git a/src/Netclaw.Cli.Tests/Tui/Config/ChannelsConfigViewModelTests.cs b/src/Netclaw.Cli.Tests/Tui/Config/ChannelsConfigViewModelTests.cs index ae63ad641..9821a5a14 100644 --- a/src/Netclaw.Cli.Tests/Tui/Config/ChannelsConfigViewModelTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/Config/ChannelsConfigViewModelTests.cs @@ -20,6 +20,20 @@ public sealed class ChannelsConfigViewModelTests : IDisposable private readonly DisposableTempDir _dir = new(); private readonly NetclawPaths _paths; + public static TheoryData<ChannelType, string, string[]> ResetConnectionCases { get; } = new() + { + { ChannelType.Slack, "Slack", ["Slack.BotToken", "Slack.AppToken"] }, + { ChannelType.Discord, "Discord", ["Discord.BotToken"] }, + { ChannelType.Mattermost, "Mattermost", ["Mattermost.BotToken"] } + }; + + public static TheoryData<ChannelType> ChannelTypes { get; } = new() + { + ChannelType.Slack, + ChannelType.Discord, + ChannelType.Mattermost + }; + public ChannelsConfigViewModelTests() { _paths = new NetclawPaths(_dir.Path); @@ -291,29 +305,45 @@ public void Rotate_credentials_preserves_blank_secret_and_updates_nonblank_secre Assert.Equal("xapp-test", ConfigFileHelper.DecryptIfEncrypted(_paths, appToken?.ToString())); } - [Fact] - public void Reset_connection_deletes_config_section_and_secrets_on_save() + [Theory] + [MemberData(nameof(ResetConnectionCases))] + public void Reset_connection_deletes_config_section_and_secrets_immediately( + ChannelType type, + string configSection, + string[] secretPaths) { - WriteChannelConfig(); - WriteChannelSecrets(); + WriteAllChannelConfig(); + WriteAllChannelSecrets(); using var vm = CreateViewModel(); - vm.OpenAdapterManagement(ChannelType.Slack); - var resetIndex = vm.GetManagementMenuItems() - .Select((item, index) => (item, index)) - .Single(entry => entry.item.Action == ChannelsManagementAction.ResetConnection) - .index; - vm.MoveManagementMenu(resetIndex); - vm.ActivateManagementMenuItem(); - vm.MoveResetConfirmation(1); - vm.ApplyResetConfirmation(); - vm.Save(); + ConfirmReset(vm, type); var config = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath); - Assert.False(ConfigFileHelper.TryGetPathValue(config, "Slack", out _)); + Assert.False(ConfigFileHelper.TryGetPathValue(config, configSection, out _)); var secrets = ConfigFileHelper.LoadJsonDict(_paths.SecretsPath); - Assert.False(ConfigFileHelper.TryGetPathValue(secrets, "Slack.BotToken", out _)); - Assert.False(ConfigFileHelper.TryGetPathValue(secrets, "Slack.AppToken", out _)); + foreach (var secretPath in secretPaths) + Assert.False(ConfigFileHelper.TryGetPathValue(secrets, secretPath, out _)); + Assert.True(vm.IsSaved.Value); + Assert.Equal($"{type} reset saved.", vm.Status.Value.Text); + } + + [Theory] + [MemberData(nameof(ChannelTypes))] + public void Reset_connection_survives_reopening_channels_editor_without_outer_save( + ChannelType type) + { + WriteAllChannelConfig(); + WriteAllChannelSecrets(); + using (var vm = CreateViewModel()) + { + ConfirmReset(vm, type); + } + + using var reopened = CreateViewModel(); + + Assert.False(reopened.Step.IsAdapterKnown(type)); + Assert.False(reopened.Step.IsAdapterEnabled(type)); + Assert.Null(reopened.Step.GetAdapterSummary(GetAdapterIndex(reopened, type))); } [Theory] @@ -345,6 +375,25 @@ public void Add_channel_management_is_generic_for_discord_and_mattermost( private ChannelsConfigViewModel CreateViewModel() => new(_paths, new FakeSlackProbe(), new FakeDiscordProbe()); + private static void ConfirmReset(ChannelsConfigViewModel vm, ChannelType type) + { + vm.OpenAdapterManagement(type); + var resetIndex = vm.GetManagementMenuItems() + .Select((item, index) => (item, index)) + .Single(entry => entry.item.Action == ChannelsManagementAction.ResetConnection) + .index; + vm.MoveManagementMenu(resetIndex); + vm.ActivateManagementMenuItem(); + vm.MoveResetConfirmation(1); + vm.ApplyResetConfirmation(); + } + + private static int GetAdapterIndex(ChannelsConfigViewModel vm, ChannelType type) + => vm.Step.Adapters + .Select((adapter, index) => (adapter.Type, index)) + .Single(entry => entry.Type == type) + .index; + private static string[] ToStringArray(object? raw) => Assert.IsType<object[]>(raw).Select(static value => value switch { diff --git a/src/Netclaw.Cli/Tui/Config/ChannelsConfigPage.cs b/src/Netclaw.Cli/Tui/Config/ChannelsConfigPage.cs index 9cfd79ae9..0c7026a6f 100644 --- a/src/Netclaw.Cli/Tui/Config/ChannelsConfigPage.cs +++ b/src/Netclaw.Cli/Tui/Config/ChannelsConfigPage.cs @@ -254,7 +254,7 @@ private ILayoutNode BuildRotateCredentials() var field = fields[i]; var input = EnsureCredentialInput(field); if (i == ViewModel.CredentialFieldIndex) - input.OnFocused(); + Focus.SetFocus(input); layout = layout .WithChild(new TextNode($" {field.Label}:").WithForeground(i == ViewModel.CredentialFieldIndex ? Color.Cyan : Color.White)) @@ -273,7 +273,7 @@ private ILayoutNode BuildResetConfirmation() var layout = Layouts.Vertical() .WithChild(Header($" Reset {ViewModel.ActiveAdapterName} connection?")) .WithChild(Hint($" This removes {ViewModel.ActiveAdapterName} credentials, allowed channels, allowed users,")) - .WithChild(Hint(" DM settings, and channel permission mappings after you save.")) + .WithChild(Hint(" DM settings, and channel permission mappings immediately.")) .WithChild(Layouts.Empty().Height(1)); for (var i = 0; i < options.Length; i++) @@ -303,7 +303,7 @@ private LayoutNode BuildHelpText() ChannelsConfigScreen.AllowedUsers => " Use comma-separated user IDs. Blank means unrestricted users in allowed channels.", ChannelsConfigScreen.DirectMessages => " Space toggles DMs. Left/right changes the DM audience.", ChannelsConfigScreen.RotateCredentials => " Blank secret fields preserve existing secrets. Tab switches fields.", - ChannelsConfigScreen.ResetConfirm => " Reset is staged until you save channel settings.", + ChannelsConfigScreen.ResetConfirm => " Reset writes immediately when confirmed.", _ => string.Empty }; return (ILayoutNode)new TextNode(help).WithForeground(Color.Gray); @@ -623,11 +623,9 @@ private void HandleRotateCredentialsKey(ConsoleKeyInfo keyInfo) } var field = fields[ViewModel.CredentialFieldIndex]; - if (_credentialInputs.TryGetValue(field.Key, out var input)) - { - input.HandleInput(keyInfo); - StageCredentialInput(field); - } + var input = EnsureCredentialInput(field); + input.HandleInput(keyInfo); + StageCredentialInput(field); } private void HandleResetConfirmKey(ConsoleKeyInfo keyInfo) diff --git a/src/Netclaw.Cli/Tui/Config/ChannelsConfigViewModel.cs b/src/Netclaw.Cli/Tui/Config/ChannelsConfigViewModel.cs index 258a33a2d..b5aab856e 100644 --- a/src/Netclaw.Cli/Tui/Config/ChannelsConfigViewModel.cs +++ b/src/Netclaw.Cli/Tui/Config/ChannelsConfigViewModel.cs @@ -26,7 +26,6 @@ public sealed class ChannelsConfigViewModel : ReactiveViewModel private readonly WizardContext _context; private readonly HashSet<ChannelType> _knownProviders; private readonly Dictionary<ChannelType, Dictionary<string, TrustAudience>> _channelAudiences = []; - private readonly HashSet<ChannelType> _resetProviders = []; private ChannelType _activeAdapterType = ChannelType.Slack; private string? _editingAudienceId; private string? _editingAudienceLabel; @@ -166,7 +165,6 @@ public void Save() Step, _knownProviders, _channelAudiences, - _resetProviders, _context.SelectedPosture ?? DeploymentPosture.Personal)); session.Save(); @@ -175,7 +173,6 @@ public void Save() foreach (var provider in savedDraft.KnownProviders) _knownProviders.Add(provider); - _resetProviders.Clear(); LoadAudienceDrafts(savedDraft); Step.OnEnter(_context, NavigationDirection.Forward); _mapper.ApplyToStep(Step, savedDraft); @@ -616,12 +613,24 @@ internal void ApplyResetConfirmation() return; } - _resetProviders.Add(_activeAdapterType); - _knownProviders.Remove(_activeAdapterType); - _channelAudiences.Remove(_activeAdapterType); - Step.ResetAdapterState(_activeAdapterType); + var resetType = _activeAdapterType; + var resetName = ActiveAdapterName; + var session = new ConfigEditorSession(_paths); + session.Apply(_mapper.BuildResetContribution(resetType)); + session.Save(); + + var savedDraft = _mapper.Load(_paths); + _knownProviders.Clear(); + foreach (var provider in savedDraft.KnownProviders) + _knownProviders.Add(provider); + + LoadAudienceDrafts(savedDraft); + Step.OnEnter(_context, NavigationDirection.Forward); + _mapper.ApplyToStep(Step, savedDraft); + _activeAdapterType = resetType; Screen.Value = ChannelsConfigScreen.Picker; - Status.Value = new ConfigStatusMessage($"{ActiveAdapterName} reset staged. Press d to save.", ConfigStatusTone.Warning); + IsSaved.Value = true; + Status.Value = new ConfigStatusMessage($"{resetName} reset saved.", ConfigStatusTone.Success); NotifyContentChanged(); } @@ -1026,8 +1035,20 @@ internal sealed record CredentialFieldSpec( string Placeholder, string? Hint); +internal sealed record ChannelPersistenceSpec( + string ConfigSection, + IReadOnlyList<string> SecretPaths); + internal sealed class ChannelsConfigPersistenceMapper { + private static readonly IReadOnlyDictionary<ChannelType, ChannelPersistenceSpec> ChannelSpecs = + new Dictionary<ChannelType, ChannelPersistenceSpec> + { + [ChannelType.Slack] = new ChannelPersistenceSpec("Slack", ["Slack.BotToken", "Slack.AppToken"]), + [ChannelType.Discord] = new ChannelPersistenceSpec("Discord", ["Discord.BotToken"]), + [ChannelType.Mattermost] = new ChannelPersistenceSpec("Mattermost", ["Mattermost.BotToken"]) + }; + internal ChannelsConfigDraft Load(NetclawPaths paths) { var config = ConfigFileHelper.LoadJsonDict(paths.NetclawConfigPath); @@ -1109,7 +1130,6 @@ internal SectionContribution BuildContribution( ChannelPickerStepViewModel step, IReadOnlySet<ChannelType> knownProviders, IReadOnlyDictionary<ChannelType, Dictionary<string, TrustAudience>> channelAudiences, - IReadOnlySet<ChannelType> resetProviders, DeploymentPosture posture) { var fields = new List<SectionFieldAction>(); @@ -1121,7 +1141,6 @@ internal SectionContribution BuildContribution( step.GetAdapterViewModel<SlackStepViewModel>(ChannelType.Slack), knownProviders.Contains(ChannelType.Slack), channelAudiences, - resetProviders, posture); AddDiscordContribution( fields, @@ -1129,7 +1148,6 @@ internal SectionContribution BuildContribution( step.GetAdapterViewModel<DiscordStepViewModel>(ChannelType.Discord), knownProviders.Contains(ChannelType.Discord), channelAudiences, - resetProviders, posture); AddMattermostContribution( fields, @@ -1137,12 +1155,20 @@ internal SectionContribution BuildContribution( step.GetAdapterViewModel<MattermostStepViewModel>(ChannelType.Mattermost), knownProviders.Contains(ChannelType.Mattermost), channelAudiences, - resetProviders, posture); return new SectionContribution(fields, secrets); } + internal SectionContribution BuildResetContribution(ChannelType type) + { + var fields = new List<SectionFieldAction>(); + var secrets = new List<SectionSecretAction>(); + AddResetActions(fields, secrets, type); + + return new SectionContribution(fields, secrets); + } + private static SlackChannelDraft LoadSlack( NetclawPaths paths, Dictionary<string, object> config, @@ -1253,17 +1279,8 @@ private static void AddSlackContribution( SlackStepViewModel vm, bool knownProvider, IReadOnlyDictionary<ChannelType, Dictionary<string, TrustAudience>> channelAudiences, - IReadOnlySet<ChannelType> resetProviders, DeploymentPosture posture) { - if (resetProviders.Contains(ChannelType.Slack)) - { - fields.Add(new SectionFieldAction("Slack", SectionFieldActionKind.Delete)); - secrets.Add(new SectionSecretAction("Slack.BotToken", SectionSecretActionKind.Delete)); - secrets.Add(new SectionSecretAction("Slack.AppToken", SectionSecretActionKind.Delete)); - return; - } - if (!vm.SlackEnabled) { if (knownProvider) @@ -1294,16 +1311,8 @@ private static void AddDiscordContribution( DiscordStepViewModel vm, bool knownProvider, IReadOnlyDictionary<ChannelType, Dictionary<string, TrustAudience>> channelAudiences, - IReadOnlySet<ChannelType> resetProviders, DeploymentPosture posture) { - if (resetProviders.Contains(ChannelType.Discord)) - { - fields.Add(new SectionFieldAction("Discord", SectionFieldActionKind.Delete)); - secrets.Add(new SectionSecretAction("Discord.BotToken", SectionSecretActionKind.Delete)); - return; - } - if (!vm.DiscordEnabled) { if (knownProvider) @@ -1330,16 +1339,8 @@ private static void AddMattermostContribution( MattermostStepViewModel vm, bool knownProvider, IReadOnlyDictionary<ChannelType, Dictionary<string, TrustAudience>> channelAudiences, - IReadOnlySet<ChannelType> resetProviders, DeploymentPosture posture) { - if (resetProviders.Contains(ChannelType.Mattermost)) - { - fields.Add(new SectionFieldAction("Mattermost", SectionFieldActionKind.Delete)); - secrets.Add(new SectionSecretAction("Mattermost.BotToken", SectionSecretActionKind.Delete)); - return; - } - if (!vm.MattermostEnabled) { if (knownProvider) @@ -1375,6 +1376,19 @@ private static void AddSecretPreserveOrSet( secrets.Add(new SectionSecretAction(path, SectionSecretActionKind.Preserve)); } + private static void AddResetActions( + List<SectionFieldAction> fields, + List<SectionSecretAction> secrets, + ChannelType type) + { + if (!ChannelSpecs.TryGetValue(type, out var spec)) + return; + + fields.Add(new SectionFieldAction(spec.ConfigSection, SectionFieldActionKind.Delete)); + foreach (var secretPath in spec.SecretPaths) + secrets.Add(new SectionSecretAction(secretPath, SectionSecretActionKind.Delete)); + } + private static void SetArrayOrDelete(List<SectionFieldAction> fields, string path, IReadOnlyList<string> values) { fields.Add(values.Count > 0 From f37ddcadd706204c79ddadc4968cc704683f4dd9 Mon Sep 17 00:00:00 2001 From: Aaron Stannard <aaron@petabridge.com> Date: Sun, 31 May 2026 17:36:45 +0000 Subject: [PATCH 28/31] refine(config): unify channels validation --- .../Config/ChannelsConfigNavigationTests.cs | 274 +++++++++++++++++- .../Config/ChannelsConfigViewModelTests.cs | 20 ++ .../Tui/Config/ChannelsConfigPage.cs | 5 +- .../Tui/Config/ChannelsConfigViewModel.cs | 119 +++++--- .../Tui/Config/ChannelsEditorModel.cs | 213 ++++++++++++++ src/Netclaw.Cli/Tui/InitWizardPage.cs | 1 + src/Netclaw.Cli/Tui/Wizard/IWizardStepView.cs | 13 + .../Tui/Wizard/Steps/DiscordStepView.cs | 10 +- .../Tui/Wizard/Steps/MattermostStepView.cs | 31 +- .../Tui/Wizard/Steps/SlackStepView.cs | 23 +- tests/smoke/assertions/config-channels.sh | 4 +- tests/smoke/tapes/config-channels.tape | 10 + 12 files changed, 654 insertions(+), 69 deletions(-) create mode 100644 src/Netclaw.Cli/Tui/Config/ChannelsEditorModel.cs diff --git a/src/Netclaw.Cli.Tests/Tui/Config/ChannelsConfigNavigationTests.cs b/src/Netclaw.Cli.Tests/Tui/Config/ChannelsConfigNavigationTests.cs index 4af24f621..e554735fc 100644 --- a/src/Netclaw.Cli.Tests/Tui/Config/ChannelsConfigNavigationTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/Config/ChannelsConfigNavigationTests.cs @@ -32,7 +32,22 @@ public ChannelsConfigNavigationTests() """ { "configVersion": 1, - "Slack": { "Enabled": true, "AllowedChannelIds": ["C01"] } + "Slack": { "Enabled": true, "AllowedChannelIds": ["C01"] }, + "Discord": { "Enabled": true, "AllowedChannelIds": ["123456789"] }, + "Mattermost": { + "Enabled": true, + "ServerUrl": "https://mattermost.example.com", + "AllowedChannelIds": ["town-square"] + } + } + """); + File.WriteAllText(_paths.SecretsPath, + """ + { + "configVersion": 1, + "Slack": { "BotToken": "xoxb-existing", "AppToken": "xapp-existing" }, + "Discord": { "BotToken": "discord-existing" }, + "Mattermost": { "BotToken": "mattermost-existing" } } """); } @@ -59,23 +74,88 @@ public async Task Channels_Escape_ReturnsToDashboardUsingTerminaHistory() Assert.Equal("/config", app.CurrentPath); } + [Theory] + [InlineData(ChannelType.Slack)] + [InlineData(ChannelType.Discord)] + [InlineData(ChannelType.Mattermost)] + public async Task Channels_RotateCredentials_AcceptsTypedCredentialInput(ChannelType channelType) + { + var app = CreateHeadlessApp(out var input, out var dashboardVm, out var getChannelsVm); + OpenChannels(dashboardVm); + MoveToAdapter(input, channelType); + + input.EnqueueKey(ConsoleKey.Enter); // Open configured adapter management. + MoveToRotateCredentials(input); + input.EnqueueKey(ConsoleKey.Enter); // Rotate credentials. + TypeCredentials(input, channelType); + input.EnqueueKey(ConsoleKey.Enter); + input.EnqueueKey(ConsoleKey.Q, false, false, true); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await app.RunAsync(cts.Token); + + var channelsVm = Assert.IsType<ChannelsConfigViewModel>(getChannelsVm()); + AssertTypedCredentials(channelsVm, channelType); + Assert.Equal("Credential changes staged. Press d to save.", channelsVm.Status.Value.Text); + } + + [Theory] + [InlineData(ChannelType.Slack)] + [InlineData(ChannelType.Discord)] + [InlineData(ChannelType.Mattermost)] + public async Task Channels_FirstTimeAdapterSetup_AcceptsTypedCredentialInput(ChannelType channelType) + { + WriteEmptyChannelFiles(); + var app = CreateHeadlessApp(out var input, out var dashboardVm, out var getChannelsVm); + OpenChannels(dashboardVm); + MoveToAdapter(input, channelType); + + input.EnqueueKey(ConsoleKey.Enter); // Enable selected adapter and enter first-time setup. + TypeFirstTimeSetup(input, channelType); + input.EnqueueKey(ConsoleKey.Q, false, false, true); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await app.RunAsync(cts.Token); + + var channelsVm = Assert.IsType<ChannelsConfigViewModel>(getChannelsVm()); + Assert.Equal(ChannelsConfigScreen.ChannelPermissions, channelsVm.Screen.Value); + Assert.Equal(channelType, channelsVm.ActiveAdapterType); + AssertFirstTimeSetup(channelsVm, channelType); + } + [Fact] - public async Task Channels_RotateCredentials_AcceptsTypedSecretInput() + public async Task Channels_FirstTimeSlackBotToken_ShowsValidationError() { + WriteEmptyChannelFiles(); var app = CreateHeadlessApp(out var input, out var dashboardVm, out var getChannelsVm); - dashboardVm.SelectedIndex.Value = dashboardVm.Items - .Select((item, index) => (item, index)) - .Single(entry => entry.item.Label == "Channels") - .index; - dashboardVm.ActivateSelected(); + OpenChannels(dashboardVm); + + input.EnqueueKey(ConsoleKey.Enter); // Enable Slack and enter first-time setup. + input.EnqueueString("not-a-slack-token"); + input.EnqueueKey(ConsoleKey.Enter); + input.EnqueueKey(ConsoleKey.Q, false, false, true); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await app.RunAsync(cts.Token); + + var channelsVm = Assert.IsType<ChannelsConfigViewModel>(getChannelsVm()); + var slack = channelsVm.Step.GetAdapterViewModel<SlackStepViewModel>(ChannelType.Slack); + Assert.Equal(ChannelsEditorValidationMessages.SlackBotTokenPrefix, channelsVm.Status.Value.Text); + Assert.Equal(ConfigStatusTone.Error, channelsVm.Status.Value.Tone); + Assert.Equal(1, slack.CurrentSubStep); + Assert.Null(slack.BotToken); + } + + [Fact] + public async Task Channels_RotateCredentials_InvalidSlackBotToken_ShowsValidationError() + { + var app = CreateHeadlessApp(out var input, out var dashboardVm, out var getChannelsVm); + OpenChannels(dashboardVm); input.EnqueueKey(ConsoleKey.Enter); // Open configured Slack management. - input.EnqueueKey(ConsoleKey.DownArrow); - input.EnqueueKey(ConsoleKey.DownArrow); - input.EnqueueKey(ConsoleKey.DownArrow); - input.EnqueueKey(ConsoleKey.DownArrow); - input.EnqueueKey(ConsoleKey.Enter); // Rotate credentials. - input.EnqueueString("xoxb-typed-token"); + MoveToRotateCredentials(input); + input.EnqueueKey(ConsoleKey.Enter); + input.EnqueueString("not-a-slack-token"); input.EnqueueKey(ConsoleKey.Enter); input.EnqueueKey(ConsoleKey.Q, false, false, true); @@ -84,8 +164,172 @@ public async Task Channels_RotateCredentials_AcceptsTypedSecretInput() var channelsVm = Assert.IsType<ChannelsConfigViewModel>(getChannelsVm()); var slack = channelsVm.Step.GetAdapterViewModel<SlackStepViewModel>(ChannelType.Slack); - Assert.Equal("xoxb-typed-token", slack.BotToken); - Assert.Equal("Credential changes staged. Press d to save.", channelsVm.Status.Value.Text); + Assert.Equal(ChannelsConfigScreen.RotateCredentials, channelsVm.Screen.Value); + Assert.Equal(ChannelsEditorValidationMessages.SlackBotTokenPrefix, channelsVm.Status.Value.Text); + Assert.Equal(ConfigStatusTone.Error, channelsVm.Status.Value.Tone); + Assert.Null(slack.BotToken); + } + + private static void OpenChannels(ConfigDashboardViewModel dashboardVm) + { + dashboardVm.SelectedIndex.Value = dashboardVm.Items + .Select((item, index) => (item, index)) + .Single(entry => entry.item.Label == "Channels") + .index; + dashboardVm.ActivateSelected(); + } + + private static void MoveToAdapter(VirtualInputSource input, ChannelType channelType) + { + var adapterIndex = channelType switch + { + ChannelType.Slack => 0, + ChannelType.Discord => 1, + ChannelType.Mattermost => 2, + _ => throw new ArgumentOutOfRangeException(nameof(channelType), channelType, null) + }; + + for (var i = 0; i < adapterIndex; i++) + input.EnqueueKey(ConsoleKey.DownArrow); + } + + private static void MoveToRotateCredentials(VirtualInputSource input) + { + for (var i = 0; i < 4; i++) + input.EnqueueKey(ConsoleKey.DownArrow); + } + + private static void TypeCredentials(VirtualInputSource input, ChannelType channelType) + { + switch (channelType) + { + case ChannelType.Slack: + input.EnqueueString("xoxb-typed-token"); + input.EnqueueKey(ConsoleKey.Tab); + input.EnqueueString("xapp-typed-token"); + break; + case ChannelType.Discord: + input.EnqueueString("discord-typed-token"); + break; + case ChannelType.Mattermost: + input.EnqueueKey(ConsoleKey.A, false, false, true); + input.EnqueueKey(ConsoleKey.Backspace); + input.EnqueueString("https://typed-mattermost.example.com"); + input.EnqueueKey(ConsoleKey.Tab); + input.EnqueueString("mattermost-typed-token"); + break; + default: + throw new ArgumentOutOfRangeException(nameof(channelType), channelType, null); + } + } + + private static void TypeFirstTimeSetup(VirtualInputSource input, ChannelType channelType) + { + switch (channelType) + { + case ChannelType.Slack: + input.EnqueueString("xoxb-first-time-token"); + input.EnqueueKey(ConsoleKey.Enter); + input.EnqueueString("xapp-first-time-token"); + input.EnqueueKey(ConsoleKey.Enter); + input.EnqueueString("C-first-time"); + input.EnqueueKey(ConsoleKey.Enter); + SelectSecondOption(input); // Disable DMs. + SelectSecondOption(input); // Allow anyone in allowed channels. + break; + case ChannelType.Discord: + input.EnqueueString("discord-first-time-token"); + input.EnqueueKey(ConsoleKey.Enter); + input.EnqueueString("123456789012345678"); + input.EnqueueKey(ConsoleKey.Enter); + SelectSecondOption(input); // Disable DMs. + SelectSecondOption(input); // Allow anyone in allowed channels. + break; + case ChannelType.Mattermost: + input.EnqueueString("https://first-time-mattermost.example.com"); + input.EnqueueKey(ConsoleKey.Enter); + input.EnqueueString("mattermost-first-time-token"); + input.EnqueueKey(ConsoleKey.Enter); + input.EnqueueString("town-square"); + input.EnqueueKey(ConsoleKey.Enter); + SelectSecondOption(input); // Disable DMs. + SelectSecondOption(input); // Allow anyone in allowed channels. + input.EnqueueKey(ConsoleKey.Enter); // Skip optional callback URL. + break; + default: + throw new ArgumentOutOfRangeException(nameof(channelType), channelType, null); + } + } + + private static void SelectSecondOption(VirtualInputSource input) + { + input.EnqueueKey(ConsoleKey.DownArrow); + input.EnqueueKey(ConsoleKey.Enter); + } + + private static void AssertTypedCredentials(ChannelsConfigViewModel vm, ChannelType channelType) + { + switch (channelType) + { + case ChannelType.Slack: + var slack = vm.Step.GetAdapterViewModel<SlackStepViewModel>(ChannelType.Slack); + Assert.Equal("xoxb-typed-token", slack.BotToken); + Assert.Equal("xapp-typed-token", slack.AppToken); + break; + case ChannelType.Discord: + var discord = vm.Step.GetAdapterViewModel<DiscordStepViewModel>(ChannelType.Discord); + Assert.Equal("discord-typed-token", discord.BotToken); + break; + case ChannelType.Mattermost: + var mattermost = vm.Step.GetAdapterViewModel<MattermostStepViewModel>(ChannelType.Mattermost); + Assert.Equal("https://typed-mattermost.example.com", mattermost.ServerUrl); + Assert.Equal("mattermost-typed-token", mattermost.BotToken); + break; + default: + throw new ArgumentOutOfRangeException(nameof(channelType), channelType, null); + } + } + + private static void AssertFirstTimeSetup(ChannelsConfigViewModel vm, ChannelType channelType) + { + switch (channelType) + { + case ChannelType.Slack: + var slack = vm.Step.GetAdapterViewModel<SlackStepViewModel>(ChannelType.Slack); + Assert.Equal("xoxb-first-time-token", slack.BotToken); + Assert.Equal("xapp-first-time-token", slack.AppToken); + Assert.Equal("C-first-time", slack.ChannelNamesInput); + break; + case ChannelType.Discord: + var discord = vm.Step.GetAdapterViewModel<DiscordStepViewModel>(ChannelType.Discord); + Assert.Equal("discord-first-time-token", discord.BotToken); + Assert.Equal("123456789012345678", discord.ChannelIdsInput); + break; + case ChannelType.Mattermost: + var mattermost = vm.Step.GetAdapterViewModel<MattermostStepViewModel>(ChannelType.Mattermost); + Assert.Equal("https://first-time-mattermost.example.com", mattermost.ServerUrl); + Assert.Equal("mattermost-first-time-token", mattermost.BotToken); + Assert.Equal("town-square", mattermost.ChannelIdsInput); + break; + default: + throw new ArgumentOutOfRangeException(nameof(channelType), channelType, null); + } + } + + private void WriteEmptyChannelFiles() + { + File.WriteAllText(_paths.NetclawConfigPath, + """ + { + "configVersion": 1 + } + """); + File.WriteAllText(_paths.SecretsPath, + """ + { + "configVersion": 1 + } + """); } private TerminaApplication CreateHeadlessApp( diff --git a/src/Netclaw.Cli.Tests/Tui/Config/ChannelsConfigViewModelTests.cs b/src/Netclaw.Cli.Tests/Tui/Config/ChannelsConfigViewModelTests.cs index 9821a5a14..80c844073 100644 --- a/src/Netclaw.Cli.Tests/Tui/Config/ChannelsConfigViewModelTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/Config/ChannelsConfigViewModelTests.cs @@ -52,6 +52,26 @@ public void Channels_editor_hosts_original_channel_picker_adapters() Assert.Equal(["Slack", "Discord", "Mattermost"], labels); } + [Fact] + public void Channels_editor_validator_maps_static_errors_to_fields() + { + var model = new ChannelsEditorModel + { + Slack = + { + Enabled = true, + BotTokenDraft = "not-a-slack-token", + HasPersistedAppToken = true, + } + }; + var validator = new ChannelsEditorValidationAdapter(); + + var result = validator.Validate(model); + + var issue = Assert.Single(result.IssuesFor(ChannelsEditorFieldPaths.SlackBotToken)); + Assert.Equal(ChannelsEditorValidationMessages.SlackBotTokenPrefix, issue.Message); + } + [Fact] public void Existing_config_prefills_picker_and_adapter_drafts() { diff --git a/src/Netclaw.Cli/Tui/Config/ChannelsConfigPage.cs b/src/Netclaw.Cli/Tui/Config/ChannelsConfigPage.cs index 0c7026a6f..286ea0c49 100644 --- a/src/Netclaw.Cli/Tui/Config/ChannelsConfigPage.cs +++ b/src/Netclaw.Cli/Tui/Config/ChannelsConfigPage.cs @@ -389,9 +389,7 @@ private bool HandleKeyInfo(ConsoleKeyInfo keyInfo) if (TryOpenConfiguredAdapter(keyInfo)) return true; - if (!ViewModel.IsSaved.Value - && ViewModel.StepView.CapturesInput - && ViewModel.StepView.HandleKeyPress(new KeyPressed(keyInfo))) + if (!ViewModel.IsSaved.Value && ViewModel.StepView.HandleKeyPress(new KeyPressed(keyInfo))) { ViewModel.RequestRedraw(); return true; @@ -652,6 +650,7 @@ private StepViewCallbacks CreateCallbacks() InvalidateHelp = () => _helpTextNode?.Invalidate(), AdvanceStep = ViewModel.GoNext, RequestRedraw = ViewModel.RequestRedraw, + SetStatusMessage = message => ViewModel.Status.Value = new ConfigStatusMessage(message, ConfigStatusTone.Error), }; private void InvalidateAll() diff --git a/src/Netclaw.Cli/Tui/Config/ChannelsConfigViewModel.cs b/src/Netclaw.Cli/Tui/Config/ChannelsConfigViewModel.cs index b5aab856e..1cefaa6bb 100644 --- a/src/Netclaw.Cli/Tui/Config/ChannelsConfigViewModel.cs +++ b/src/Netclaw.Cli/Tui/Config/ChannelsConfigViewModel.cs @@ -23,6 +23,7 @@ public sealed class ChannelsConfigViewModel : ReactiveViewModel private readonly NetclawPaths _paths; private readonly TuiNavigation? _navigation; private readonly ChannelsConfigPersistenceMapper _mapper = new(); + private readonly ChannelsEditorValidationAdapter _validator = new(); private readonly WizardContext _context; private readonly HashSet<ChannelType> _knownProviders; private readonly Dictionary<ChannelType, Dictionary<string, TrustAudience>> _channelAudiences = []; @@ -152,10 +153,10 @@ public void GoBack() public void Save() { - var validationMessage = _mapper.Validate(Step); - if (validationMessage is not null) + var validation = ValidateCurrentStep(); + if (validation.HasErrors) { - Status.Value = new ConfigStatusMessage(validationMessage, ConfigStatusTone.Error); + Status.Value = BuildValidationErrorStatus(validation, "Fix channel validation errors before saving."); RequestRedraw(); return; } @@ -575,6 +576,14 @@ internal void MoveCredentialField(int delta) internal void ApplyCredentials() { + var issue = ValidateCredentialDrafts(); + if (issue is not null) + { + Status.Value = new ConfigStatusMessage(issue.Message, ConfigStatusTone.Error); + NotifyContentChanged(); + return; + } + switch (_activeAdapterType) { case ChannelType.Slack: @@ -598,6 +607,71 @@ internal void ApplyCredentials() NotifyContentChanged(); } + private ChannelsEditorValidationResult ValidateCurrentStep() + => _validator.Validate(ChannelsEditorModel.FromStep(Step)); + + private ChannelsEditorValidationIssue? ValidateCredentialDrafts() + { + var candidate = ChannelsEditorModel.FromStep(Step); + ApplyCredentialDrafts(candidate); + var validation = _validator.Validate(candidate); + var activeFieldPaths = GetCredentialFieldPaths(_activeAdapterType); + return validation.Issues.FirstOrDefault(issue => issue.FieldId is null || activeFieldPaths.Contains(issue.FieldId)); + } + + private void ApplyCredentialDrafts(ChannelsEditorModel model) + { + switch (_activeAdapterType) + { + case ChannelType.Slack: + model.Slack.Enabled = true; + model.Slack.BotTokenDraft = Normalize(BotTokenInput); + model.Slack.AppTokenDraft = Normalize(AppTokenInput); + break; + case ChannelType.Discord: + model.Discord.Enabled = true; + model.Discord.BotTokenDraft = Normalize(BotTokenInput); + break; + case ChannelType.Mattermost: + model.Mattermost.Enabled = true; + model.Mattermost.ServerUrl = Normalize(ServerUrlInput); + model.Mattermost.BotTokenDraft = Normalize(BotTokenInput); + model.Mattermost.CallbackUrl = Normalize(CallbackUrlInput); + break; + } + } + + private static IReadOnlySet<string> GetCredentialFieldPaths(ChannelType type) + => type switch + { + ChannelType.Slack => new HashSet<string>(StringComparer.Ordinal) + { + ChannelsEditorFieldPaths.SlackBotToken, + ChannelsEditorFieldPaths.SlackAppToken, + }, + ChannelType.Discord => new HashSet<string>(StringComparer.Ordinal) + { + ChannelsEditorFieldPaths.DiscordBotToken, + }, + ChannelType.Mattermost => new HashSet<string>(StringComparer.Ordinal) + { + ChannelsEditorFieldPaths.MattermostServerUrl, + ChannelsEditorFieldPaths.MattermostBotToken, + ChannelsEditorFieldPaths.MattermostCallbackUrl, + }, + _ => new HashSet<string>(StringComparer.Ordinal), + }; + + private static ConfigStatusMessage BuildValidationErrorStatus( + ChannelsEditorValidationResult validation, + string fallbackMessage) + { + var issue = validation.Issues.FirstOrDefault(); + return issue is null + ? new ConfigStatusMessage(fallbackMessage, ConfigStatusTone.Error) + : new ConfigStatusMessage(issue.Message, ConfigStatusTone.Error); + } + internal void MoveResetConfirmation(int delta) { _resetConfirmIndex = Clamp(_resetConfirmIndex + delta, 2); @@ -1090,42 +1164,6 @@ internal void ApplyToStep(ChannelPickerStepViewModel step, ChannelsConfigDraft d draft.Mattermost.IsKnown); } - internal string? Validate(ChannelPickerStepViewModel step) - { - var slack = step.GetAdapterViewModel<SlackStepViewModel>(ChannelType.Slack); - if (step.IsAdapterEnabled(ChannelType.Slack)) - { - if (!HasEffectiveSecret(slack.BotToken, slack.HasPersistedBotToken)) - return "Slack bot token is required."; - if (!HasEffectiveSecret(slack.AppToken, slack.HasPersistedAppToken)) - return "Slack Socket Mode app token is required."; - if (!string.IsNullOrWhiteSpace(slack.BotToken) - && !slack.BotToken.StartsWith("xoxb-", StringComparison.OrdinalIgnoreCase)) - return "Slack bot token must start with xoxb-."; - if (!string.IsNullOrWhiteSpace(slack.AppToken) - && !slack.AppToken.StartsWith("xapp-", StringComparison.OrdinalIgnoreCase)) - return "Slack app token must start with xapp-."; - } - - var discord = step.GetAdapterViewModel<DiscordStepViewModel>(ChannelType.Discord); - if (step.IsAdapterEnabled(ChannelType.Discord) - && !HasEffectiveSecret(discord.BotToken, discord.HasPersistedBotToken)) - return "Discord bot token is required."; - - var mattermost = step.GetAdapterViewModel<MattermostStepViewModel>(ChannelType.Mattermost); - if (step.IsAdapterEnabled(ChannelType.Mattermost)) - { - if (string.IsNullOrWhiteSpace(mattermost.ServerUrl)) - return "Mattermost server URL is required."; - if (!Uri.TryCreate(mattermost.ServerUrl, UriKind.Absolute, out _)) - return "Mattermost server URL must be an absolute URL."; - if (!HasEffectiveSecret(mattermost.BotToken, mattermost.HasPersistedBotToken)) - return "Mattermost bot token is required."; - } - - return null; - } - internal SectionContribution BuildContribution( ChannelPickerStepViewModel step, IReadOnlySet<ChannelType> knownProviders, @@ -1587,9 +1625,6 @@ private static bool HasSecret(NetclawPaths paths, Dictionary<string, object> sec return string.Join(", ", parts); } - private static bool HasEffectiveSecret(string? draftValue, bool hasPersistedSecret) - => !string.IsNullOrWhiteSpace(draftValue) || hasPersistedSecret; - private static void AddKnownProvider(HashSet<ChannelType> knownProviders, ChannelType type, bool isKnown) { if (isKnown) diff --git a/src/Netclaw.Cli/Tui/Config/ChannelsEditorModel.cs b/src/Netclaw.Cli/Tui/Config/ChannelsEditorModel.cs new file mode 100644 index 000000000..0f082971f --- /dev/null +++ b/src/Netclaw.Cli/Tui/Config/ChannelsEditorModel.cs @@ -0,0 +1,213 @@ +// ----------------------------------------------------------------------- +// <copyright file="ChannelsEditorModel.cs" company="Petabridge, LLC"> +// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com> +// </copyright> +// ----------------------------------------------------------------------- +using Microsoft.Extensions.Options; +using Netclaw.Actors.Channels; +using Netclaw.Cli.Tui.Wizard.Steps; + +namespace Netclaw.Cli.Tui.Config; + +internal sealed class ChannelsEditorModel +{ + public SlackChannelEditorModel Slack { get; } = new(); + + public DiscordChannelEditorModel Discord { get; } = new(); + + public MattermostChannelEditorModel Mattermost { get; } = new(); + + public static ChannelsEditorModel FromStep(ChannelPickerStepViewModel step) + { + var slack = step.GetAdapterViewModel<SlackStepViewModel>(ChannelType.Slack); + var discord = step.GetAdapterViewModel<DiscordStepViewModel>(ChannelType.Discord); + var mattermost = step.GetAdapterViewModel<MattermostStepViewModel>(ChannelType.Mattermost); + + var model = new ChannelsEditorModel + { + Slack = + { + Enabled = step.IsAdapterEnabled(ChannelType.Slack), + BotTokenDraft = Normalize(slack.BotToken), + HasPersistedBotToken = slack.HasPersistedBotToken, + AppTokenDraft = Normalize(slack.AppToken), + HasPersistedAppToken = slack.HasPersistedAppToken, + }, + Discord = + { + Enabled = step.IsAdapterEnabled(ChannelType.Discord), + BotTokenDraft = Normalize(discord.BotToken), + HasPersistedBotToken = discord.HasPersistedBotToken, + }, + Mattermost = + { + Enabled = step.IsAdapterEnabled(ChannelType.Mattermost), + ServerUrl = Normalize(mattermost.ServerUrl), + BotTokenDraft = Normalize(mattermost.BotToken), + HasPersistedBotToken = mattermost.HasPersistedBotToken, + CallbackUrl = Normalize(mattermost.CallbackUrl), + } + }; + + return model; + } + + private static string? Normalize(string? value) + => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); +} + +internal abstract class ChannelEditorProviderModel +{ + public bool Enabled { get; set; } +} + +internal sealed class SlackChannelEditorModel : ChannelEditorProviderModel +{ + public string? BotTokenDraft { get; set; } + + public bool HasPersistedBotToken { get; set; } + + public string? AppTokenDraft { get; set; } + + public bool HasPersistedAppToken { get; set; } +} + +internal sealed class DiscordChannelEditorModel : ChannelEditorProviderModel +{ + public string? BotTokenDraft { get; set; } + + public bool HasPersistedBotToken { get; set; } +} + +internal sealed class MattermostChannelEditorModel : ChannelEditorProviderModel +{ + public string? ServerUrl { get; set; } + + public string? BotTokenDraft { get; set; } + + public bool HasPersistedBotToken { get; set; } + + public string? CallbackUrl { get; set; } +} + +internal sealed class ChannelsEditorValidator : IValidateOptions<ChannelsEditorModel> +{ + public ValidateOptionsResult Validate(string? name, ChannelsEditorModel options) + { + var errors = new List<string>(); + + if (options.Slack.Enabled) + { + if (!HasEffectiveSecret(options.Slack.BotTokenDraft, options.Slack.HasPersistedBotToken)) + errors.Add(ChannelsEditorValidationMessages.SlackBotTokenRequired); + else if (!string.IsNullOrWhiteSpace(options.Slack.BotTokenDraft) + && !options.Slack.BotTokenDraft.StartsWith("xoxb-", StringComparison.OrdinalIgnoreCase)) + errors.Add(ChannelsEditorValidationMessages.SlackBotTokenPrefix); + + if (!HasEffectiveSecret(options.Slack.AppTokenDraft, options.Slack.HasPersistedAppToken)) + errors.Add(ChannelsEditorValidationMessages.SlackAppTokenRequired); + else if (!string.IsNullOrWhiteSpace(options.Slack.AppTokenDraft) + && !options.Slack.AppTokenDraft.StartsWith("xapp-", StringComparison.OrdinalIgnoreCase)) + errors.Add(ChannelsEditorValidationMessages.SlackAppTokenPrefix); + } + + if (options.Discord.Enabled + && !HasEffectiveSecret(options.Discord.BotTokenDraft, options.Discord.HasPersistedBotToken)) + errors.Add(ChannelsEditorValidationMessages.DiscordBotTokenRequired); + + if (options.Mattermost.Enabled) + { + if (string.IsNullOrWhiteSpace(options.Mattermost.ServerUrl)) + errors.Add(ChannelsEditorValidationMessages.MattermostServerUrlRequired); + else if (!IsHttpUrl(options.Mattermost.ServerUrl)) + errors.Add(ChannelsEditorValidationMessages.MattermostServerUrlAbsoluteHttp); + + if (!HasEffectiveSecret(options.Mattermost.BotTokenDraft, options.Mattermost.HasPersistedBotToken)) + errors.Add(ChannelsEditorValidationMessages.MattermostBotTokenRequired); + + if (!string.IsNullOrWhiteSpace(options.Mattermost.CallbackUrl) + && !IsHttpUrl(options.Mattermost.CallbackUrl)) + errors.Add(ChannelsEditorValidationMessages.MattermostCallbackUrlAbsoluteHttp); + } + + return errors.Count > 0 + ? ValidateOptionsResult.Fail(errors) + : ValidateOptionsResult.Success; + } + + private static bool HasEffectiveSecret(string? draftValue, bool hasPersistedSecret) + => !string.IsNullOrWhiteSpace(draftValue) || hasPersistedSecret; + + internal static bool IsHttpUrl(string value) + => Uri.TryCreate(value, UriKind.Absolute, out var uri) + && uri.Scheme is "http" or "https"; +} + +internal static class ChannelsEditorFieldPaths +{ + internal const string SlackBotToken = "Slack.BotToken"; + internal const string SlackAppToken = "Slack.AppToken"; + internal const string DiscordBotToken = "Discord.BotToken"; + internal const string MattermostServerUrl = "Mattermost.ServerUrl"; + internal const string MattermostBotToken = "Mattermost.BotToken"; + internal const string MattermostCallbackUrl = "Mattermost.CallbackUrl"; +} + +internal static class ChannelsEditorValidationMessages +{ + internal const string SlackBotTokenRequired = "Slack bot token is required."; + internal const string SlackBotTokenPrefix = "Slack bot token must start with xoxb-."; + internal const string SlackAppTokenRequired = "Slack Socket Mode app token is required."; + internal const string SlackAppTokenPrefix = "Slack app token must start with xapp-."; + internal const string DiscordBotTokenRequired = "Discord bot token is required."; + internal const string MattermostServerUrlRequired = "Mattermost server URL is required."; + internal const string MattermostServerUrlAbsoluteHttp = "Mattermost server URL must be an absolute http:// or https:// URL."; + internal const string MattermostBotTokenRequired = "Mattermost bot token is required."; + internal const string MattermostCallbackUrlAbsoluteHttp = "Mattermost callback URL must be an absolute http:// or https:// URL."; +} + +internal sealed record ChannelsEditorValidationIssue(string? FieldId, string Message, ConfigValidationSeverity Severity); + +internal sealed record ChannelsEditorValidationResult(IReadOnlyList<ChannelsEditorValidationIssue> Issues) +{ + public static readonly ChannelsEditorValidationResult Empty = new([]); + + public bool HasErrors => Issues.Any(static issue => issue.Severity == ConfigValidationSeverity.Error); + + public IReadOnlyList<ChannelsEditorValidationIssue> IssuesFor(string fieldId) + => [.. Issues.Where(issue => string.Equals(issue.FieldId, fieldId, StringComparison.Ordinal))]; +} + +internal sealed class ChannelsEditorValidationAdapter +{ + private readonly ChannelsEditorValidator _validator = new(); + + internal ChannelsEditorValidationResult Validate(ChannelsEditorModel model) + { + var result = _validator.Validate(name: null, model); + if (result.Succeeded) + return ChannelsEditorValidationResult.Empty; + + var failures = result.Failures ?? []; + var issues = new List<ChannelsEditorValidationIssue>(); + foreach (var failure in failures) + issues.Add(new ChannelsEditorValidationIssue(FieldForMessage(failure), failure, ConfigValidationSeverity.Error)); + + return new ChannelsEditorValidationResult(issues); + } + + private static string? FieldForMessage(string message) + => message switch + { + ChannelsEditorValidationMessages.SlackBotTokenRequired => ChannelsEditorFieldPaths.SlackBotToken, + ChannelsEditorValidationMessages.SlackBotTokenPrefix => ChannelsEditorFieldPaths.SlackBotToken, + ChannelsEditorValidationMessages.SlackAppTokenRequired => ChannelsEditorFieldPaths.SlackAppToken, + ChannelsEditorValidationMessages.SlackAppTokenPrefix => ChannelsEditorFieldPaths.SlackAppToken, + ChannelsEditorValidationMessages.DiscordBotTokenRequired => ChannelsEditorFieldPaths.DiscordBotToken, + ChannelsEditorValidationMessages.MattermostServerUrlRequired => ChannelsEditorFieldPaths.MattermostServerUrl, + ChannelsEditorValidationMessages.MattermostServerUrlAbsoluteHttp => ChannelsEditorFieldPaths.MattermostServerUrl, + ChannelsEditorValidationMessages.MattermostBotTokenRequired => ChannelsEditorFieldPaths.MattermostBotToken, + ChannelsEditorValidationMessages.MattermostCallbackUrlAbsoluteHttp => ChannelsEditorFieldPaths.MattermostCallbackUrl, + _ => null, + }; +} diff --git a/src/Netclaw.Cli/Tui/InitWizardPage.cs b/src/Netclaw.Cli/Tui/InitWizardPage.cs index 351237ce4..6154c0b20 100644 --- a/src/Netclaw.Cli/Tui/InitWizardPage.cs +++ b/src/Netclaw.Cli/Tui/InitWizardPage.cs @@ -399,6 +399,7 @@ private StepViewCallbacks CreateCallbacks() InvalidateHelp = () => _helpTextNode?.Invalidate(), AdvanceStep = () => ViewModel.GoNext(), RequestRedraw = ViewModel.RequestRedraw, + SetStatusMessage = message => ViewModel.Context.StatusMessage.Value = message, }; } diff --git a/src/Netclaw.Cli/Tui/Wizard/IWizardStepView.cs b/src/Netclaw.Cli/Tui/Wizard/IWizardStepView.cs index d5bb8dd1d..8f28235c8 100644 --- a/src/Netclaw.Cli/Tui/Wizard/IWizardStepView.cs +++ b/src/Netclaw.Cli/Tui/Wizard/IWizardStepView.cs @@ -33,6 +33,9 @@ public sealed class StepViewCallbacks /// <summary>Request a terminal redraw.</summary> public required Action RequestRedraw { get; init; } + /// <summary>Show a step-scoped validation or status message.</summary> + public Action<string>? SetStatusMessage { get; init; } + /// <summary>Invalidate content and help, then request a redraw.</summary> public void InvalidateAndRedraw() { @@ -40,6 +43,16 @@ public void InvalidateAndRedraw() InvalidateHelp(); RequestRedraw(); } + + /// <summary>Show a validation error and redraw without advancing the step.</summary> + public void ShowValidationError(string message) + { + SetStatusMessage?.Invoke(message); + RequestRedraw(); + } + + /// <summary>Clear the step-scoped validation or status message.</summary> + public void ClearStatusMessage() => SetStatusMessage?.Invoke(string.Empty); } /// <summary> diff --git a/src/Netclaw.Cli/Tui/Wizard/Steps/DiscordStepView.cs b/src/Netclaw.Cli/Tui/Wizard/Steps/DiscordStepView.cs index d71e0bf50..d423d0723 100644 --- a/src/Netclaw.Cli/Tui/Wizard/Steps/DiscordStepView.cs +++ b/src/Netclaw.Cli/Tui/Wizard/Steps/DiscordStepView.cs @@ -3,6 +3,7 @@ // Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com> // </copyright> // ----------------------------------------------------------------------- +using Netclaw.Cli.Tui.Config; using R3; using Termina.Extensions; using Termina.Input; @@ -91,13 +92,20 @@ private ILayoutNode BuildBotTokenSubStep(DiscordStepViewModel vm, StepViewCallba if (string.IsNullOrWhiteSpace(text)) { if (vm.HasPersistedBotToken || !string.IsNullOrWhiteSpace(vm.BotToken)) + { + callbacks.ClearStatusMessage(); callbacks.AdvanceStep(); + } + else + { + callbacks.ShowValidationError(ChannelsEditorValidationMessages.DiscordBotTokenRequired); + } - callbacks.RequestRedraw(); return; } vm.BotToken = text; + callbacks.ClearStatusMessage(); callbacks.AdvanceStep(); }) .DisposeWith(callbacks.Subscriptions); diff --git a/src/Netclaw.Cli/Tui/Wizard/Steps/MattermostStepView.cs b/src/Netclaw.Cli/Tui/Wizard/Steps/MattermostStepView.cs index 9c52e3c12..72c2de0e8 100644 --- a/src/Netclaw.Cli/Tui/Wizard/Steps/MattermostStepView.cs +++ b/src/Netclaw.Cli/Tui/Wizard/Steps/MattermostStepView.cs @@ -3,6 +3,7 @@ // Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com> // </copyright> // ----------------------------------------------------------------------- +using Netclaw.Cli.Tui.Config; using R3; using Termina.Extensions; using Termina.Input; @@ -93,10 +94,22 @@ private ILayoutNode BuildServerUrlSubStep(MattermostStepViewModel vm, StepViewCa _lastFocusedList = null; _serverUrlInput.Submitted - .Where(text => !string.IsNullOrWhiteSpace(text)) .Subscribe(text => { + if (string.IsNullOrWhiteSpace(text)) + { + callbacks.ShowValidationError(ChannelsEditorValidationMessages.MattermostServerUrlRequired); + return; + } + + if (!ChannelsEditorValidator.IsHttpUrl(text.Trim())) + { + callbacks.ShowValidationError(ChannelsEditorValidationMessages.MattermostServerUrlAbsoluteHttp); + return; + } + vm.ServerUrl = text.Trim(); + callbacks.ClearStatusMessage(); callbacks.AdvanceStep(); }) .DisposeWith(callbacks.Subscriptions); @@ -122,13 +135,20 @@ private ILayoutNode BuildBotTokenSubStep(MattermostStepViewModel vm, StepViewCal if (string.IsNullOrWhiteSpace(text)) { if (vm.HasPersistedBotToken || !string.IsNullOrWhiteSpace(vm.BotToken)) + { + callbacks.ClearStatusMessage(); callbacks.AdvanceStep(); + } + else + { + callbacks.ShowValidationError(ChannelsEditorValidationMessages.MattermostBotTokenRequired); + } - callbacks.RequestRedraw(); return; } vm.BotToken = text; + callbacks.ClearStatusMessage(); callbacks.AdvanceStep(); }) .DisposeWith(callbacks.Subscriptions); @@ -250,7 +270,14 @@ private ILayoutNode BuildCallbackUrlSubStep(MattermostStepViewModel vm, StepView _callbackUrlInput.Submitted .Subscribe(text => { + if (!string.IsNullOrWhiteSpace(text) && !ChannelsEditorValidator.IsHttpUrl(text.Trim())) + { + callbacks.ShowValidationError(ChannelsEditorValidationMessages.MattermostCallbackUrlAbsoluteHttp); + return; + } + vm.CallbackUrl = string.IsNullOrWhiteSpace(text) ? null : text.Trim(); + callbacks.ClearStatusMessage(); callbacks.AdvanceStep(); }) .DisposeWith(callbacks.Subscriptions); diff --git a/src/Netclaw.Cli/Tui/Wizard/Steps/SlackStepView.cs b/src/Netclaw.Cli/Tui/Wizard/Steps/SlackStepView.cs index 49ae173f4..32ce2c4a2 100644 --- a/src/Netclaw.Cli/Tui/Wizard/Steps/SlackStepView.cs +++ b/src/Netclaw.Cli/Tui/Wizard/Steps/SlackStepView.cs @@ -3,6 +3,7 @@ // Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com> // </copyright> // ----------------------------------------------------------------------- +using Netclaw.Cli.Tui.Config; using R3; using Termina.Extensions; using Termina.Input; @@ -93,18 +94,25 @@ private ILayoutNode BuildBotTokenSubStep(SlackStepViewModel vm, StepViewCallback if (string.IsNullOrWhiteSpace(text)) { if (vm.HasPersistedBotToken || !string.IsNullOrWhiteSpace(vm.BotToken)) + { + callbacks.ClearStatusMessage(); callbacks.AdvanceStep(); + } + else + { + callbacks.ShowValidationError(ChannelsEditorValidationMessages.SlackBotTokenRequired); + } - callbacks.RequestRedraw(); return; } if (!text.StartsWith("xoxb-", StringComparison.OrdinalIgnoreCase)) { - callbacks.RequestRedraw(); + callbacks.ShowValidationError(ChannelsEditorValidationMessages.SlackBotTokenPrefix); return; } vm.BotToken = text; + callbacks.ClearStatusMessage(); callbacks.AdvanceStep(); }) .DisposeWith(callbacks.Subscriptions); @@ -135,18 +143,25 @@ private ILayoutNode BuildAppTokenSubStep(SlackStepViewModel vm, StepViewCallback if (string.IsNullOrWhiteSpace(text)) { if (vm.HasPersistedAppToken || !string.IsNullOrWhiteSpace(vm.AppToken)) + { + callbacks.ClearStatusMessage(); callbacks.AdvanceStep(); + } + else + { + callbacks.ShowValidationError(ChannelsEditorValidationMessages.SlackAppTokenRequired); + } - callbacks.RequestRedraw(); return; } if (!text.StartsWith("xapp-", StringComparison.OrdinalIgnoreCase)) { - callbacks.RequestRedraw(); + callbacks.ShowValidationError(ChannelsEditorValidationMessages.SlackAppTokenPrefix); return; } vm.AppToken = text; + callbacks.ClearStatusMessage(); callbacks.AdvanceStep(); }) .DisposeWith(callbacks.Subscriptions); diff --git a/tests/smoke/assertions/config-channels.sh b/tests/smoke/assertions/config-channels.sh index d5722eb95..cb00ac4b0 100755 --- a/tests/smoke/assertions/config-channels.sh +++ b/tests/smoke/assertions/config-channels.sh @@ -36,8 +36,8 @@ assert_field '.Slack.AllowDirectMessages' 'false' "$config_json" || : assert_field '.Slack.ChannelAudiences.C01' 'public' "$config_json" || : assert_field '.Slack.ChannelAudiences.C02' 'team' "$config_json" || : assert_field '.Slack.ChannelAudiences.C09' 'team' "$config_json" || : -assert_field '.Slack.BotToken' 'xoxb-test' "$secrets_json" || : -assert_field '.Slack.AppToken' 'xapp-test' "$secrets_json" || : +assert_field '(.Slack.BotToken | startswith("ENC:"))' 'true' "$secrets_json" || : +assert_field '(.Slack.AppToken | startswith("ENC:"))' 'true' "$secrets_json" || : if (( assert_fail )); then printf -- '--- netclaw.json contents ---\n%s\n' "$config_json" >&2 diff --git a/tests/smoke/tapes/config-channels.tape b/tests/smoke/tapes/config-channels.tape index 1b8e5de99..af24c449a 100644 --- a/tests/smoke/tapes/config-channels.tape +++ b/tests/smoke/tapes/config-channels.tape @@ -58,6 +58,16 @@ Type "U09" Enter Wait+Screen@10s /Allowed users staged/ +# Rotate credentials using typed input, not paste. +Down 2 +Enter +Wait+Screen@10s /Slack > Credentials/ +Type "xoxb-smoke-typed" +Tab +Type "xapp-smoke-typed" +Enter +Wait+Screen@10s /Credential changes staged/ + # Return to picker and save. Escape Wait+Screen@10s /Which channels would you like to connect/ From 90c4bf14f5dd26e7bc7639a3adbe81bd09768df4 Mon Sep 17 00:00:00 2001 From: Aaron Stannard <aaron@petabridge.com> Date: Sun, 31 May 2026 20:04:05 +0000 Subject: [PATCH 29/31] fix(config): validate channel target contracts --- AGENTS.md | 35 +- BACKLOG_PARKING_LOT.md | 32 + IMPLEMENTATION_PLAN.md | 640 ++++++++++++++++++ docs/ui/TUI-002-netclaw-config-wireframes.md | 8 +- ralph-opencode.sh | 127 ++-- src/Netclaw.Channels.Slack/SlackProbe.cs | 6 +- .../Config/ChannelsConfigNavigationTests.cs | 7 +- .../Config/ChannelsConfigViewModelTests.cs | 123 +++- src/Netclaw.Cli.Tests/Tui/FakeDiscordProbe.cs | 1 + .../Tui/FakeMattermostProbe.cs | 50 ++ src/Netclaw.Cli.Tests/Tui/FakeSlackProbe.cs | 1 + src/Netclaw.Cli/Mattermost/MattermostProbe.cs | 218 ++++++ src/Netclaw.Cli/Program.cs | 5 + .../Tui/Config/ChannelsConfigViewModel.cs | 226 ++++++- .../Tui/Config/ChannelsEditorModel.cs | 3 + 15 files changed, 1429 insertions(+), 53 deletions(-) create mode 100644 BACKLOG_PARKING_LOT.md create mode 100644 IMPLEMENTATION_PLAN.md create mode 100644 src/Netclaw.Cli.Tests/Tui/FakeMattermostProbe.cs create mode 100644 src/Netclaw.Cli/Mattermost/MattermostProbe.cs diff --git a/AGENTS.md b/AGENTS.md index 59c693d94..f8016c996 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,6 +20,7 @@ Read first: - `PROJECT_CONTEXT.md` - `TOOLING.md` +- `IMPLEMENTATION_PLAN.md` - `docs/prd/README.md` - `.opencode/skills/netclaw-*/SKILL.md` - `.claude/skills/ralph-*.md` @@ -88,14 +89,40 @@ task checkboxes in `openspec/changes/*/tasks.md` during RALPH iterations. Before coding a capability, discover in this order: -1. matching PRD in `docs/prd/` -2. matching engineering spec in `docs/spec/` -3. matching OpenSpec capability in `openspec/specs/` -4. active change plan in `openspec/changes/<name>/` +1. active task in `IMPLEMENTATION_PLAN.md` +2. matching PRD in `docs/prd/` +3. matching engineering spec in `docs/spec/` +4. matching OpenSpec capability in `openspec/specs/` +5. active change plan in `openspec/changes/<name>/` If planning and implementation artifacts conflict, fix planning artifacts first. If discovery artifacts conflict with each other, update them before implementing. +## Cross-Boundary Contract Rule + +When a change writes data consumed by another subsystem, identify the consumer +before implementation and verify the producer emits the consumer's canonical +representation. This applies to config editors, persistence records, actor +messages, protocol payloads, tool schemas, and security policy inputs. + +For configuration changes, tests must prove both: + +- invalid or unresolved values are rejected before persistence +- persisted values match what runtime ACL/routing/startup code expects + +Do not treat UI-level save success or schema validity as sufficient when runtime +behavior depends on provider IDs, canonical names, permissions, or security +policy keys. + +## Automation Floor + +Recent regressions define mandatory automated proof classes. TUI text input must +have headless typed-key coverage and native smoke coverage for critical flows. +Dynamic validation must have fake-failure tests proving save is blocked before +persistence. Legacy/new config paradigm changes must have load/round-trip tests +from the old shape to the runtime-consumed shape. Human manual testing is a +last-mile confidence check, not a substitute for these gates. + ## Configuration Schema Sync Rule When adding or changing properties on any `*Config` type in `Netclaw.Configuration`, diff --git a/BACKLOG_PARKING_LOT.md b/BACKLOG_PARKING_LOT.md new file mode 100644 index 000000000..3bb5f6850 --- /dev/null +++ b/BACKLOG_PARKING_LOT.md @@ -0,0 +1,32 @@ +# Backlog Parking Lot + +This file holds non-NOW work so autonomous loops do not accidentally bulldoze +deprioritized tasks. Move items into `IMPLEMENTATION_PLAN.md` only when the user +explicitly changes priority. + +## NEXT Candidates + +- Webhook service identity and inbound webhook hardening. +- Subagent explicit model selection. +- Subagent parent-context alignment. +- GitHub Copilot provider refinements. +- VLLM capability strategy and timing work. +- Fixed-length approval button labels and richer approval UI. +- Config hot-reload beyond startup-time configuration. +- Operator diagnostics refinements beyond current CLI/doctor/status work. + +## LATER Candidates + +- Ambient channel monitoring workflows. +- Delegated coding task orchestration. +- Browser automation as a first-class product feature. +- Split gateway/agent process architecture. +- Hosted/multi-tenant operator console. +- Delivery-policy tuning beyond the first Telemetry & Alerting config pass. + +## Parking Rule + +If a future task is interesting but not necessary for the active milestone, add +it here instead of expanding `NOW`. The implementation plan should stay small +enough that an agent can finish the selected task all the way through runtime +verification. diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md new file mode 100644 index 000000000..ef4f4a278 --- /dev/null +++ b/IMPLEMENTATION_PLAN.md @@ -0,0 +1,640 @@ +# Netclaw Implementation Plan + +Last updated: 2026-05-31 + +This is the execution plan for Netclaw. Autonomous agents and RALPH-style loops +SHALL work from `NOW` by default. `NEXT` and `LATER` work belongs in +`BACKLOG_PARKING_LOT.md` unless the user explicitly reprioritizes it. + +## Operating Principle: Swing Through The Ball + +A task is not done when the local component accepts input, renders a screen, or +writes a file. A task is done when the downstream runtime path consumes the +produced artifact successfully, or bad input is rejected before it crosses the +boundary. + +Examples: + +- A config editor is done only when runtime startup/ACL/routing consumes the + saved shape it emits. +- A TUI flow is done only when typed input, paste input, persisted state, + re-entry, and semantic smoke assertions all agree. +- A tool or adapter is done only when policy denial, invalid credentials, + missing resources, and happy-path dispatch are all covered. +- A planning task is done only when PRD, spec, OpenSpec, tests, docs, and skill + guidance point at the same behavior. + +## Verification Levels + +Use the highest level required by the task. Higher levels include the lower +levels unless explicitly stated otherwise. + +| Level | Name | Required proof | +|-------|------|----------------| +| L0 | Planning-only | PRD/spec/docs updated; no runtime behavior changed. | +| L1 | Unit/contract | Targeted unit or contract tests prove pure behavior, serialization, validation, mapping, or policy decisions. | +| L2 | Integration | Component integration tests prove real persistence, DI, actor lifecycle, config binding, or fake-provider boundaries. | +| L3 | Interactive/smoke | Native smoke tape, CLI/TUI smoke, or equivalent real binary exercise proves the user-visible path. | +| L4 | Live/demo/e2e | Aspire demo, live provider, Docker image, or full runtime flow proves external/runtime wiring. | + +## Non-Negotiable Quality Gates + +These gates apply to every `NOW` task unless the task explicitly says why a gate +does not apply. + +- [ ] **Discovery gate:** Read the matching PRD, spec, OpenSpec capability, and + active change plan before coding. +- [ ] **Consumer gate:** Name the downstream consumer of any config, event, + actor message, persisted record, tool schema, or protocol payload the task + writes. +- [ ] **Canonical representation gate:** Prove the producer emits the exact + representation expected by the consumer, not merely a schema-valid value. +- [ ] **Negative-path gate:** Add at least one invalid/unresolved/denied test for + every security-relevant or routing-relevant input. +- [ ] **No silent fallback gate:** Misconfiguration fails visibly; fallback is + allowed only when partial failure is normal runtime behavior. +- [ ] **Schema gate:** Any `*Config` property change updates + `src/Netclaw.Configuration/Schemas/netclaw-config.v1.schema.json`. +- [ ] **TUI gate:** Termina/init/config changes run the native smoke harness and + include semantic assertions, not just screen text. +- [ ] **Runtime gate:** If config drives runtime behavior, verify startup, + runtime binding, ACL, routing, or tool execution consumes the saved config. +- [ ] **Docs/spec gate:** Behavior changes update the relevant docs/specs and + any mapped system skill. +- [ ] **Repository gates:** Run `dotnet test`, `dotnet slopwatch analyze`, + `pwsh ./scripts/Add-FileHeaders.ps1 -Verify`, and `git diff --check` unless + the task explicitly scopes to docs-only work. + +## Automation-First QA Floor + +The human bare minimum should be priority calls, secrets/credentials for live +checks, and occasional high-risk UX spot checks. Agents are responsible for the +automatable proof below. + +| Recent bug class | Required automation | Human minimum | +|------------------|---------------------|---------------| +| Typed input does not reach a TUI field | Headless Termina test with `VirtualInputSource` covering typed characters, paste, Tab/focus movement, Enter/submit, Escape/back. Critical flows also need a native VHS smoke tape. | Run or review one live command only when a real terminal/TTY bug is suspected. | +| Dynamic validation does not run | Fake-provider failure test proving save is blocked, persistence is unchanged, and the visible error is shown. Tests must call the same public save path the UI uses. | Provide real provider credentials only for optional live probes. | +| Old config paradigm not ported to new editor | Load/round-trip tests from existing config and secrets into the new editor model, then back to disk. Tests must assert dormant values and secrets are preserved unless reset/delete is explicit. | Confirm whether stale fields should migrate, preserve, or fail. | +| Config shape accepted but runtime cannot consume it | Contract test between editor/init output and runtime options/ACL/routing/startup consumer. Assert canonical IDs/names/permissions, not just schema validity. | Decide behavior for ambiguous external API cases. | +| Smoke passes while semantic behavior is wrong | Smoke assertion script checks canonical persisted values, encrypted secrets, runtime-visible config, and error states. Screen text alone is not enough. | Review smoke artifact only if the assertion fails or UX changed substantially. | +| Async UI action fails silently | Public async method has direct tests; fire-and-forget handlers catch exceptions and surface status errors. Test fake exceptions from validation/save dependencies. | None by default. | +| Secret rotation/reset reintroduces old behavior | Tests cover blank-preserve, nonblank-replace, disable-preserve, reset-delete-immediate, and reopen-after-reset. | Confirm destructive copy in the UI. | + +Minimum automation by surface area: + +| Surface | Minimum gate | +|---------|--------------| +| Config editor | Static validation test, dynamic fake-failure test, existing-config round-trip test, config-to-runtime consumer test, native smoke for visible TUI paths. | +| Init wizard | Headless typed-input test for each prompt kind, native `init-wizard` smoke, existing-install path test, destructive-action double-confirm test. | +| Channel adapter | Options-binding test, ACL allow/deny tests, malformed/missing credential test, reply/routing integration or opt-in live smoke. | +| Tool/MCP | Schema generation test, schema coercion negative test, permission allow/deny/prompt tests, malformed metadata test. | +| Persistence/memory/session | Serialization round-trip, restart/recovery test, corrupt/missing state test, eval suite when prompt/memory behavior changes. | +| Packaging/demo | Install smoke, Docker image binary/version check, health endpoint check, opt-in demo smoke when runtime wiring changes. | + +Manual-only acceptance criteria are not allowed for `NOW` implementation tasks. +If something truly cannot be automated, the task must say why and must provide +the smallest repeatable manual script plus expected output. + +## Current Source Artifacts + +- Product: `PROJECT_CONTEXT.md`, `docs/prd/README.md`, `docs/prd/PRD-001-netclaw-mvp.md` +- CLI/config: `docs/prd/PRD-004-cli-onboarding-and-config.md`, `docs/spec/SPEC-004-cli-contract.md`, `docs/spec/SPEC-007-guided-onboarding.md`, `openspec/specs/netclaw-config-command/spec.md`, `openspec/changes/netclaw-config-command/tasks.md` +- Security/gateway: `docs/prd/PRD-002-gateway-security-envelope.md`, `docs/spec/SPEC-001-runtime-boundaries.md`, `docs/spec/SPEC-003-acl-policy-and-security-controls.md`, `openspec/specs/netclaw-acl/spec.md`, `openspec/specs/netclaw-gateway-security/spec.md` +- Input adapters: `docs/prd/PRD-009-input-adapters-and-unified-input.md`, `openspec/specs/netclaw-input-adapters/spec.md`, `openspec/specs/netclaw-slack-socket/spec.md`, `openspec/specs/netclaw-discord-socket/spec.md`, `openspec/changes/add-mattermost-channel/tasks.md` +- Models/providers: `docs/prd/PRD-005-model-provider-strategy.md`, `docs/spec/SPEC-008-model-provider-abstraction.md`, `openspec/specs/netclaw-model-providers/spec.md` +- MCP/tools: `docs/prd/PRD-006-mcp-tool-integration.md`, `openspec/specs/netclaw-mcp/spec.md`, `openspec/specs/netclaw-tools/spec.md`, `openspec/specs/tool-approval-gates/spec.md` +- Memory/personality: `docs/prd/PRD-007-agent-personality-and-local-memory.md`, `openspec/specs/netclaw-agent-memory/spec.md`, `openspec/specs/project-instructions/spec.md` +- Scheduling: `docs/prd/PRD-008-scheduling-and-periodic-tasks.md`, `openspec/specs/netclaw-scheduling/spec.md`, `openspec/specs/reminder-execution-history/spec.md` +- Testing: `docs/spec/SPEC-010-testing-and-smoke-strategy.md`, `TOOLING.md` + +## NOW + +### Phase 0: Execution Governance + +Purpose: prevent shallow local fixes from being mistaken for runtime-complete +work. + +#### Task 0.1: Enforce the cross-boundary contract rule + +**PRD:** `docs/prd/PRD-001-netclaw-mvp.md` +**Spec:** `docs/spec/SPEC-010-testing-and-smoke-strategy.md` +**Surface area:** cross-cutting +**Verification:** L0 + +Done when: + +- [x] `AGENTS.md` references `IMPLEMENTATION_PLAN.md` as a read-first artifact. +- [x] `AGENTS.md` includes the Cross-Boundary Contract Rule. +- [x] This plan is the default routing artifact for build work. +- [x] `BACKLOG_PARKING_LOT.md` exists for non-now work. + +#### Task 0.2: Add PRD/status traceability to the plan workflow + +**PRD:** `docs/prd/README.md` +**Spec:** `docs/spec/SPEC-010-testing-and-smoke-strategy.md` +**Surface area:** docs +**Verification:** L0 + +Done when: + +- [x] Every `NOW` task has a `PRD` reference. +- [x] Tasks with stale, missing, or conflicting PRD coverage are blocked until + the PRD/spec is updated. +- [x] If a task changes OpenSpec-covered behavior, the corresponding OpenSpec + workflow is used rather than hand-editing change artifacts. + +#### Task 0.3: Add contract-test inventory for critical producer/consumer pairs + +**PRD:** `docs/prd/PRD-001-netclaw-mvp.md` +**Spec:** `docs/spec/SPEC-010-testing-and-smoke-strategy.md` +**Surface area:** cross-cutting +**Verification:** L1 + +Done when: + +- [ ] Document the critical producer/consumer pairs in this plan or a linked + spec, including config editor -> runtime options, channel events -> ACL, + scheduler -> delivery gateway, tool schemas -> model/tool dispatcher, and + memory persistence -> prompt assembly. +- [ ] For each pair, identify the canonical representation and the test file + that proves it. +- [ ] Add missing tests or add explicit `NOW` tasks for gaps. + +#### Task 0.4: Automate recent regression classes + +**PRD:** `docs/prd/PRD-001-netclaw-mvp.md`, `docs/prd/PRD-004-cli-onboarding-and-config.md` +**Spec:** `docs/spec/SPEC-010-testing-and-smoke-strategy.md`, `openspec/specs/netclaw-config-command/spec.md` +**Surface area:** testing, TUI, config +**Verification:** L3 + +Done when: + +- [ ] Every config/TUI task touching text input includes headless typed-input + tests for typed characters, paste, Tab, Enter, Escape, and re-entry when + applicable. +- [ ] Every config leaf with dynamic validation has a fake-failure test proving + validation runs before persistence and leaves files unchanged. +- [ ] Every config leaf ported from init/old editor paths has an existing-config + load/round-trip test covering dormant values and persisted secrets. +- [ ] Every smoke tape with config writes has an assertion script that checks + canonical semantic output, not only screenshots or text. +- [ ] Any async UI save/test action has a direct awaitable test path plus + fire-and-forget exception surfacing. + +#### Task 0.5: Add audit tests for plan-critical config editors + +**PRD:** `docs/prd/PRD-004-cli-onboarding-and-config.md` +**Spec:** `openspec/specs/section-editor-abstraction/spec.md`, `openspec/specs/netclaw-config-command/spec.md` +**Surface area:** testing, config +**Verification:** L1 + +Done when: + +- [ ] A registry/audit test lists config leaf editors and fails when a visible + editor lacks round-trip coverage. +- [ ] The audit requires each visible editor to declare whether it has dynamic + validation and, if yes, the test class that covers fake-failure behavior. +- [ ] The audit requires each editor that writes secrets to have blank-preserve, + nonblank-replace, and explicit-delete coverage. +- [ ] The audit requires each editor that writes runtime-consumed config to name + the runtime consumer and contract test file. + +### Phase 1: Config Command And Channel Runtime Contracts + +Purpose: finish the active config work all the way through runtime semantics. + +#### Task 1.1: Complete Channels provider-backed validation and canonical persistence + +**PRD:** `docs/prd/PRD-004-cli-onboarding-and-config.md` +**Spec:** `openspec/specs/netclaw-config-command/spec.md`, `openspec/specs/channel-audience-tui/spec.md`, `openspec/specs/netclaw-input-adapters/spec.md` +**Surface area:** UI, config, runtime contract +**Verification:** L3 + +Done when: + +- [ ] Slack channel names entered in config are resolved through Slack before + persistence. +- [ ] Slack `AllowedChannelIds` persists canonical Slack channel IDs (`C...` or + `G...`) and never unresolved display names. +- [ ] Slack channel audience keys are remapped to resolved channel IDs. +- [ ] Discord channel IDs are checked through `IDiscordProbe.ResolveChannelIdsAsync` + before save. +- [ ] Mattermost channel IDs are checked through a Mattermost config-time probe + before save. +- [ ] Unresolved Slack, Discord, and Mattermost channel targets block save with + visible errors. +- [ ] Existing configured secrets can be used for validation without prompting + on re-entry. +- [ ] Tests cover Slack name -> ID resolution, Slack unresolved name rejection, + Discord unresolved ID rejection, Mattermost unresolved ID rejection, and secret + preservation. +- [ ] Native smoke `./scripts/smoke/run-smoke.sh config-channels` passes with + semantic assertions on canonical persisted values. +- [ ] Docker POC image is rebuilt and `netclaw-config-poc-local` is relaunched + when this task is used for live verification. + +#### Task 1.2: Finish generalized config leaf validation + +**PRD:** `docs/prd/PRD-004-cli-onboarding-and-config.md` +**Spec:** `openspec/specs/netclaw-config-command/spec.md`, `openspec/specs/section-editor-abstraction/spec.md` +**Surface area:** config, UI, cross-cutting +**Verification:** L3 + +Done when: + +- [ ] Every `netclaw config` leaf has typed structural validation before save. +- [ ] Runtime/probe validation is run where the leaf writes values consumed by + runtime startup, ACL, transport, tools, or daemon exposure. +- [ ] Structurally invalid config is a hard block. +- [ ] `Save anyway` exists only for transient runtime/probe failures, never for + schema violations, missing required security fields, or unresolved canonical + IDs. +- [ ] Tests prove invalid path, URI, auth, binary, local-reference, and + reachability failures where those concepts apply. +- [ ] Smoke assertions check semantic preservation and canonical output, not + byte-identical JSON. + +#### Task 1.3: Complete `Security & Access` config area + +**PRD:** `docs/prd/PRD-004-cli-onboarding-and-config.md`, `docs/prd/PRD-002-gateway-security-envelope.md` +**Spec:** `openspec/specs/netclaw-config-command/spec.md`, `openspec/specs/security-posture-tui/spec.md`, `openspec/specs/netclaw-acl/spec.md` +**Surface area:** UI, config, security +**Verification:** L3 + +Done when: + +- [ ] `Security & Access` contains Security Posture, Enabled Features, Audience + Profiles, and Exposure Mode. +- [ ] Security Posture remains distinct from runtime Enabled Features and + Audience Profiles. +- [ ] Team/Public posture continues into Enabled Features; Personal posture does + not force that continuation. +- [ ] Audience Profiles expose only curated high-level controls: Tool Access + (non-MCP), File Access, Incoming Attachments, Reset to posture default. +- [ ] Reset to posture default resets the full underlying audience profile, + including hidden MCP and approval settings. +- [ ] MCP permissions route to `netclaw mcp permissions`; they are not recreated + in this editor. +- [ ] Tests cover round-trip, hidden-field reset semantics, and ACL consumer + expectations. +- [ ] Native config smoke covers at least one posture change and one audience + profile reset with semantic assertions. + +#### Task 1.4: Complete Exposure Mode config leaf + +**PRD:** `docs/prd/PRD-004-cli-onboarding-and-config.md`, `docs/prd/PRD-002-gateway-security-envelope.md` +**Spec:** `docs/spec/SPEC-006-gateway-exposure-and-remote-access.md`, `openspec/specs/daemon-exposure/spec.md`, `openspec/specs/device-pairing/spec.md` +**Surface area:** UI, config, daemon exposure +**Verification:** L3 + +Done when: + +- [ ] Explicit modes are Local, Reverse Proxy, Tailscale Serve, Tailscale + Funnel, and Cloudflare Tunnel. +- [ ] `Daemon.ExposureMode` is the single active selector; no per-mode active + flags are introduced. +- [ ] Inactive old values are preserved and ignored while inactive. +- [ ] Each non-local mode has a mode-specific dialog; Local requires no extra + setup. +- [ ] First non-local enablement auto-pairs the current configuring client when + no bootstrap/pairing state exists. +- [ ] Orphaned or mismatched bootstrap state blocks with actionable guidance to + `netclaw doctor`, docs, and the tracked issue. +- [ ] Tests prove config merge semantics and daemon exposure consumer binding. +- [ ] Native config smoke covers at least one non-local mode and one return to + Local. + +#### Task 1.5: Complete Skill Sources and Telemetry & Alerting config areas + +**PRD:** `docs/prd/PRD-004-cli-onboarding-and-config.md`, `docs/prd/PRD-006-mcp-tool-integration.md` +**Spec:** `openspec/specs/netclaw-config-command/spec.md`, `openspec/specs/netclaw-mcp/spec.md` +**Surface area:** UI, config, operations +**Verification:** L3 + +Done when: + +- [ ] Skill Sources contains External Skills and Skill Feeds. +- [ ] Skill Source validation covers paths, URIs, auth, and reachability where + relevant. +- [ ] Telemetry & Alerting contains Telemetry and Outbound Webhooks only in this + pass. +- [ ] Delivery-policy tuning stays parked. +- [ ] Tests prove semantic round-trip, secret preservation, invalid URI/path + rejection, and runtime consumer binding where applicable. +- [ ] Smoke tapes exercise both areas or document why an existing smoke covers + the route. + +#### Task 1.6: Close the `netclaw config` OpenSpec change + +**PRD:** `docs/prd/PRD-004-cli-onboarding-and-config.md` +**Spec:** `openspec/changes/netclaw-config-command/tasks.md` +**Surface area:** planning, config +**Verification:** L3 + +Done when: + +- [ ] `openspec/changes/netclaw-config-command/tasks.md` accurately reflects + completed and incomplete implementation work. +- [ ] `openspec validate netclaw-config-command --type change` passes. +- [ ] `./scripts/smoke/run-smoke.sh light` passes on a clean runner or a local + blocker is documented with evidence. +- [ ] `/opsx-verify netclaw-config-command` passes. +- [ ] Spec deltas are synced or the change remains explicitly active with only + real unfinished tasks. + +### Phase 2: Init Bootstrap Split + +Purpose: keep first-run setup simple and move post-install editing to config. + +#### Task 2.1: Simplify first-run `netclaw init` + +**PRD:** `docs/prd/PRD-004-cli-onboarding-and-config.md` +**Spec:** `docs/spec/SPEC-007-guided-onboarding.md`, `openspec/changes/simplify-netclaw-init/tasks.md` +**Surface area:** TUI, config bootstrap +**Verification:** L3 + +Done when: + +- [ ] Planning and code remove all `netclaw init --force` assumptions. +- [ ] First-run init contains bootstrap-owned steps only. +- [ ] Posture values remain `Personal`, `Team`, `Public`. +- [ ] Identity remains init-owned. +- [ ] Post-flight messaging points users to `netclaw chat` and `netclaw config`. +- [ ] Init smoke `./scripts/smoke/run-smoke.sh init-wizard` passes. +- [ ] Full light smoke passes or local blockers are documented with evidence. + +#### Task 2.2: Implement existing-install init menu and destructive reset flow + +**PRD:** `docs/prd/PRD-004-cli-onboarding-and-config.md` +**Spec:** `docs/spec/SPEC-007-guided-onboarding.md`, `openspec/changes/simplify-netclaw-init/tasks.md` +**Surface area:** TUI, config bootstrap, destructive actions +**Verification:** L3 + +Done when: + +- [ ] Existing install shows exactly: `Redo identity setup`, `Open configuration + editor`, `Start over from scratch`, `Cancel`. +- [ ] `Open configuration editor` routes to `netclaw config`. +- [ ] `Redo identity setup` routes only into init-owned identity flow. +- [ ] Start-over dialog shows exactly: `Reset setup only`, `Full reset`, + `Cancel`. +- [ ] Both destructive actions require double confirmation. +- [ ] Tests cover refusal, menu routing, double confirmation, and preserved vs + deleted files. +- [ ] Smoke coverage exercises existing-install menu and start-over cancellation. + +#### Task 2.3: Close the `simplify-netclaw-init` OpenSpec change + +**PRD:** `docs/prd/PRD-004-cli-onboarding-and-config.md` +**Spec:** `openspec/changes/simplify-netclaw-init/tasks.md` +**Surface area:** planning, TUI +**Verification:** L3 + +Done when: + +- [ ] `openspec validate simplify-netclaw-init --type change` passes. +- [ ] `/opsx-verify simplify-netclaw-init` passes. +- [ ] Init smoke and light smoke pass. +- [ ] Docs and skill guidance no longer describe stale init behavior. + +### Phase 3: Runtime Adapter Contract Hardening + +Purpose: prove each channel adapter accepts, denies, responds, and reports health +according to the same security envelope. + +#### Task 3.1: Add adapter config-to-runtime contract tests + +**PRD:** `docs/prd/PRD-009-input-adapters-and-unified-input.md`, `docs/prd/PRD-002-gateway-security-envelope.md` +**Spec:** `openspec/specs/netclaw-input-adapters/spec.md`, `openspec/specs/netclaw-slack-socket/spec.md`, `openspec/specs/netclaw-discord-socket/spec.md`, `openspec/specs/netclaw-acl/spec.md` +**Surface area:** runtime, config, ACL +**Verification:** L2 + +Done when: + +- [ ] Slack, Discord, and Mattermost options bind from the config shape emitted + by init/config editors. +- [ ] Allowed channel IDs and user IDs are consumed by runtime ACL in canonical + provider form. +- [ ] Denied channel, denied user, allowed channel, and DM policy cases are + covered per adapter. +- [ ] Misconfigured required tokens or server URLs fail closed for the affected + channel without enabling permissive ingress. +- [ ] Tests name the producer and consumer for each contract. + +#### Task 3.2: Add runtime reply-path smoke for local/demo adapters + +**PRD:** `docs/prd/PRD-009-input-adapters-and-unified-input.md` +**Spec:** `openspec/specs/netclaw-input-adapters/spec.md`, `openspec/specs/netclaw-testing/spec.md` +**Surface area:** runtime, smoke +**Verification:** L4 + +Done when: + +- [ ] Mattermost demo smoke posts a user message and proves the daemon routes it + to a session and attempts a reply. +- [ ] Discord and Slack live smoke remain opt-in and credential-gated; absence + of credentials skips with clear output, not failure. +- [ ] Runtime logs expose enough detail to diagnose allowed/denied/routed/reply + states without leaking secrets. +- [ ] `TOOLING.md` documents the exact invocation and expected artifacts. + +#### Task 3.3: Normalize channel diagnostics and doctor output + +**PRD:** `docs/prd/PRD-003-operator-ux-ops-console.md`, `docs/prd/PRD-009-input-adapters-and-unified-input.md` +**Spec:** `docs/spec/SPEC-005-operator-ui-contract.md`, `openspec/specs/netclaw-operator-ui/spec.md` +**Surface area:** CLI, daemon diagnostics, operations +**Verification:** L2 + +Done when: + +- [ ] `netclaw status` or doctor output distinguishes disconnected, + misconfigured, denied-by-policy, and healthy per channel. +- [ ] Slack/Discord/Mattermost health outputs use consistent terms. +- [ ] Tests cover status mapping from runtime channel health to CLI/doctor + display. +- [ ] Runbooks mention the deny and misconfiguration diagnostics operators + should look for. + +### Phase 4: Model Provider And Tool Execution Contracts + +Purpose: keep model/provider/tool execution reliable and diagnosable across +provider differences. + +#### Task 4.1: Harden provider/model config-to-runtime binding + +**PRD:** `docs/prd/PRD-005-model-provider-strategy.md`, `docs/prd/PRD-004-cli-onboarding-and-config.md` +**Spec:** `docs/spec/SPEC-008-model-provider-abstraction.md`, `openspec/specs/netclaw-model-providers/spec.md`, `openspec/specs/netclaw-model-capabilities/spec.md` +**Surface area:** config, runtime, providers +**Verification:** L2 + +Done when: + +- [ ] Provider and model editors emit config that runtime provider selection + consumes without hidden defaults. +- [ ] Invalid provider IDs, missing model IDs, unsupported auth modes, and stale + capability metadata fail visibly. +- [ ] Tests cover config editor output -> provider registry/model selection + consumption. +- [ ] Eval suite is run if model/provider defaults or capability logic changes. + +#### Task 4.2: Prove tool schema and permission contracts end-to-end + +**PRD:** `docs/prd/PRD-006-mcp-tool-integration.md`, `docs/prd/PRD-002-gateway-security-envelope.md` +**Spec:** `openspec/specs/netclaw-tools/spec.md`, `openspec/specs/netclaw-mcp/spec.md`, `openspec/specs/tool-call-metadata/spec.md`, `openspec/specs/mcp-schema-coercion/spec.md` +**Surface area:** tools, MCP, security +**Verification:** L2 + +Done when: + +- [ ] Tool schemas generated for models match dispatcher expectations. +- [ ] MCP schema coercion has negative tests for invalid/coercion-impossible + inputs. +- [ ] Tool approval and grant decisions are tested for allow, deny, prompt, and + malformed metadata. +- [ ] No tool can bypass audience/profile policy because a field is missing or + has a stale name. + +#### Task 4.3: Keep streaming/progress execution contract coherent + +**PRD:** `docs/prd/PRD-001-netclaw-mvp.md`, `docs/prd/PRD-006-mcp-tool-integration.md` +**Spec:** `openspec/changes/streaming-tool-call-execution/tasks.md`, `openspec/changes/progress-aware-turn-loop/tasks.md`, `openspec/specs/session-state-machine/spec.md` +**Surface area:** runtime, actors, tools +**Verification:** L2 + +Done when: + +- [ ] Tool-call streaming, progress reporting, session phase transitions, and + persistence snapshots agree on the same state names. +- [ ] Actor tests prove progress events survive normal tool completion, + tool failure, cancellation, and session recovery. +- [ ] No turn loop can report success while a tool result is still pending. +- [ ] Logs/traces correlate model call, tool call, approval, and session turn. + +### Phase 5: Memory, Identity, Scheduling, And Persistence Contracts + +Purpose: ensure autonomous behavior survives restarts and carries the right +identity/context. + +#### Task 5.1: Prove identity file and system prompt assembly contracts + +**PRD:** `docs/prd/PRD-007-agent-personality-and-local-memory.md`, `docs/prd/PRD-004-cli-onboarding-and-config.md` +**Spec:** `openspec/specs/project-instructions/spec.md`, `openspec/specs/netclaw-agent-memory/spec.md` +**Surface area:** identity, prompt assembly, evals +**Verification:** L2 plus eval suite + +Done when: + +- [ ] Init writes identity files in the exact paths prompt assembly reads. +- [ ] Prompt assembly rejects missing or malformed required identity assets + visibly. +- [ ] Tests cover first-run, existing-install identity redo, missing file, and + malformed file cases. +- [ ] Eval suite passes when identity grounding rules change. + +#### Task 5.2: Prove memory recall and compaction persistence contracts + +**PRD:** `docs/prd/PRD-007-agent-personality-and-local-memory.md` +**Spec:** `openspec/specs/netclaw-agent-memory/spec.md`, `openspec/specs/netclaw-session/spec.md`, `openspec/specs/thread-history-backfill/spec.md` +**Surface area:** persistence, memory, session actors +**Verification:** L2 plus eval suite + +Done when: + +- [ ] Memory recall inputs, persisted observations, compaction summaries, and + prompt assembly use compatible serialization-safe types. +- [ ] Tests cover fresh session, resumed session, compacted session, and corrupt + or missing memory state. +- [ ] Eval suite passes for memory pipeline and compaction changes. + +#### Task 5.3: Prove scheduling delivery contracts + +**PRD:** `docs/prd/PRD-008-scheduling-and-periodic-tasks.md`, `docs/prd/PRD-009-input-adapters-and-unified-input.md` +**Spec:** `openspec/specs/netclaw-scheduling/spec.md`, `openspec/specs/reminder-execution-history/spec.md` +**Surface area:** scheduling, actors, channel delivery +**Verification:** L2 + +Done when: + +- [ ] Reminder targets resolve to channel gateways using canonical provider IDs. +- [ ] Current-session delivery routes through the existing session gateway chain + without re-running inbound ACL checks. +- [ ] Future scheduled delivery uses policy appropriate for the stored target. +- [ ] Tests cover immediate reminder, periodic reminder, missed execution, + failed delivery, restart recovery, and invalid target. +- [ ] `TimeProvider` is used for all scheduling time. + +### Phase 6: Release Readiness And Packaging + +Purpose: keep install, Docker, demo, and CI aligned with product behavior. + +#### Task 6.1: Keep Docker image and install artifacts contract-tested + +**PRD:** `docs/prd/PRD-001-netclaw-mvp.md`, `docs/prd/PRD-004-cli-onboarding-and-config.md` +**Spec:** `openspec/specs/daemon-container/spec.md`, `openspec/specs/manifest-signature-verification/spec.md` +**Surface area:** packaging, install, Docker +**Verification:** L3 + +Done when: + +- [ ] Docker image contains matching CLI and daemon binaries from the same + source build. +- [ ] Container default config path, health check, entrypoint, and self-update + behavior match docs. +- [ ] Install smoke passes for Linux/macOS/Windows stand-in archives. +- [ ] Manifest signature verification negative paths are covered. +- [ ] Local POC rebuild instructions are documented and reproducible. + +#### Task 6.2: Maintain demo AppHost as the local end-to-end proof + +**PRD:** `docs/prd/PRD-001-netclaw-mvp.md`, `docs/prd/PRD-009-input-adapters-and-unified-input.md` +**Spec:** `openspec/changes/netclaw-demo-apphost/tasks.md`, `TOOLING.md` +**Surface area:** demo, runtime, smoke +**Verification:** L4 + +Done when: + +- [ ] Demo AppHost boots Mattermost, Ollama, and daemon to healthy. +- [ ] Seeded Mattermost user can post into the configured channel. +- [ ] Daemon logs prove message routing into a session and model invocation. +- [ ] Slow CPU inference remains documented as latency caveat, not hidden as a + failed wiring assertion. +- [ ] Opt-in demo integration test remains skipped by default and passes with + `NETCLAW_RUN_DEMO_SMOKE=1` on a suitable Docker host. + +## NEXT + +NEXT tasks are important but not eligible for autonomous execution unless moved +to `NOW` by the user. + +- Webhook service identity and inbound webhook hardening. +- Subagent explicit model selection and parent-context alignment. +- GitHub Copilot provider refinements and VLLM capability strategy. +- Approval button label refinement and richer interactive approval UX. +- Config hot-reload beyond current startup/configure flows. +- Operator UX/Ops Console beyond CLI/TUI diagnostics. + +## LATER + +LATER tasks are product-direction items and should stay out of execution loops. + +- Ambient monitoring workflows. +- Delegated coding task orchestration. +- Browser automation as a first-class feature. +- Split gateway/agent process architecture. +- Hosted SaaS / multi-tenant operator console. + +## Required Session Closure Checklist + +Before declaring any implementation session done, record the closure state in +the final response and, if a task remains incomplete, leave a concrete follow-up +in this plan. + +- [ ] Which `IMPLEMENTATION_PLAN.md` task was worked. +- [ ] Producer/consumer contract identified. +- [ ] Positive behavior verified. +- [ ] Negative behavior verified. +- [ ] Runtime/smoke/eval validation completed or explicitly blocked. +- [ ] Docs/spec/skill updates completed or explicitly not applicable. +- [ ] Commands run and results reported. +- [ ] Worktree state reported. diff --git a/docs/ui/TUI-002-netclaw-config-wireframes.md b/docs/ui/TUI-002-netclaw-config-wireframes.md index ca32b9289..2bd2adacc 100644 --- a/docs/ui/TUI-002-netclaw-config-wireframes.md +++ b/docs/ui/TUI-002-netclaw-config-wireframes.md @@ -391,9 +391,11 @@ secret; entering a new value replaces it. stored credentials. The daemon ignores those fields while the adapter is disabled. -**Validation:** Save blocks missing required credentials for enabled adapters -and invalid Mattermost server URLs. Connection probes remain doctor-owned in -this first pass. +**Validation:** Save blocks missing required credentials for enabled adapters, +invalid Mattermost server URLs, and unresolved channel targets. Slack channel +names entered as `#name` or `name` are resolved through Slack before save and +persisted as Slack channel IDs. Discord and Mattermost channel IDs are checked +with their provider APIs before the config merge is written. ### 3.2 Adapter management menu diff --git a/ralph-opencode.sh b/ralph-opencode.sh index 9b4677e65..1106e2953 100755 --- a/ralph-opencode.sh +++ b/ralph-opencode.sh @@ -224,6 +224,37 @@ ${prior_reviews:- (none — this is the first review)} return 0 } +verify_signed_iteration_commit() { + local before_commit=$1 + local after_commit + after_commit=$(git rev-parse HEAD) + + if [[ "$after_commit" == "$before_commit" ]]; then + echo "Iteration did not create a commit; skipping signature check." + return 0 + fi + + local signature_status + signature_status=$(git log -1 --format=%G? "$after_commit") + case "$signature_status" in + G|U) + echo "Signed commit verified: $after_commit (status=$signature_status)" + ;; + *) + echo "" + echo "==========================================" + echo " SIGNED COMMIT GATE FAILED" + echo "==========================================" + echo "" + echo "Latest commit is not GPG-signed or has an invalid signature." + echo "Commit: $after_commit" + echo "Signature status: $signature_status" + echo "Do not bypass signing. Fix GPG signing and rerun." + return 1 + ;; + esac +} + # Function to verify L3 evidence if L3 was claimed verify_l3_evidence() { local iter_log=$1 @@ -245,27 +276,22 @@ verify_l3_evidence() { local missing_evidence=() - # Check for application running evidence - if ! grep -qi "aspire run\|dotnet run\|npm start\|yarn dev\|Application Started\|resources healthy\|Server started\|listening on" "$iter_log" 2>/dev/null; then - missing_evidence+=("Application running (start command with evidence)") + # Netclaw L3 means a native smoke/interactive CLI proof, not just web-route checks. + if ! grep -qi "run-smoke.sh\|native smoke\|VHS\|tape:" "$iter_log" 2>/dev/null; then + missing_evidence+=("Native smoke evidence (run-smoke.sh, VHS, or named tape)") fi - # Check for routes checked evidence - if ! grep -qi "Routes Checked\|Routes checked\|Route.*200\|Route.*rendered" "$iter_log" 2>/dev/null; then - missing_evidence+=("Routes navigated (Routes Checked section)") + if ! grep -qi "All smoke checks passed\|assertions passed\|: OK\|smoke.*passed" "$iter_log" 2>/dev/null; then + missing_evidence+=("Smoke result and assertion outcome") fi - # Check for console errors evidence - if ! grep -qi "Console errors: none\|Console errors:.*none\|no console errors" "$iter_log" 2>/dev/null; then - # Also check if they documented errors (which is valid if they then fix them) - if ! grep -qi "Console errors:" "$iter_log" 2>/dev/null; then - missing_evidence+=("Console errors checked (Console errors: none)") - fi + if ! grep -qi "semantic assertion\|assertion script\|canonical\|persisted.*value\|config.*assert" "$iter_log" 2>/dev/null; then + missing_evidence+=("Semantic assertion evidence, not only screen text") fi - # Check for viewport evidence - if ! grep -qi "Viewport.*pass\|viewport check\|1024.*1280.*1920\|1024px\|viewport sanity" "$iter_log" 2>/dev/null; then - missing_evidence+=("Viewport sanity check (1024/1280/1920)") + if grep -q "Level: L4" "$iter_log" 2>/dev/null && \ + ! grep -qi "Aspire\|Docker\|container.*healthy\|resources healthy\|live provider\|demo smoke" "$iter_log" 2>/dev/null; then + missing_evidence+=("L4 runtime evidence (Aspire, Docker, live provider, or demo smoke)") fi if [[ ${#missing_evidence[@]} -gt 0 ]]; then @@ -280,7 +306,7 @@ verify_l3_evidence() { echo " - $item" done echo "" - echo " Per ralph-loop.md L3 Verification Checklist, these are MANDATORY when claiming L3." + echo " Per IMPLEMENTATION_PLAN.md Automation-First QA Floor, these are MANDATORY when claiming L3/L4." echo "" echo " Options:" echo " 1. Fix the iteration to include proper L3 evidence" @@ -315,6 +341,7 @@ for ((i=1; i<=ITERATIONS; i++)); do ITER_PAD=$(printf "%02d" "$i") ITER_LOG="${RUN_DIR}/iter-${ITER_PAD}.md" + ITER_START_COMMIT=$(git rev-parse HEAD) if ! opencode run --model "$MODEL" "You are running RALPH iteration $i. @@ -333,56 +360,71 @@ for ((i=1; i<=ITERATIONS; i++)); do ## Instructions (ONE TASK ONLY) 1) Find the next incomplete task in IMPLEMENTATION_PLAN.md: - - Look for '### Task:' blocks with unchecked 'Done when:' items + - Look for '#### Task N.N:' blocks with unchecked 'Done when:' items - Work on the FIRST incomplete task you find - A task is complete only when ALL its Done-when checkboxes are satisfied 2) Determine MODE from Task Routing in AGENTS.md/CLAUDE.md (engineering/ux/marketing/ops/etc.) -3) Load relevant skills from .claude/skills/: - - REQUIRED for code: testing-strategy.md (if present — integration vs unit; no fakes) - - REQUIRED: ralph-loop.md (process discipline) - - If UI impacted: ui-smoke-validation.md (or follow UI validation policy) +3) Apply IMPLEMENTATION_PLAN.md quality gates: + - Identify the downstream consumer for any config/event/message/tool/schema/persistence output + - Prove canonical representation at the producer/consumer boundary + - Add negative-path coverage for invalid, unresolved, denied, malformed, or missing inputs + - For TUI input: add headless typed-key coverage and native smoke for critical flows + - For dynamic validation: add fake-failure tests proving save is blocked before persistence + - For old config migration/porting: add load/round-trip tests from old shape to runtime-consumed shape + +4) Load relevant skills from .claude/skills/: + - REQUIRED for code: testing-strategy.md (if present) + - REQUIRED: ralph-loop.md (process discipline, if present) + - If UI impacted: ui-smoke-validation.md (or follow IMPLEMENTATION_PLAN.md TUI gate) - If schema/events touched: extend-only-design.md (if present) -4) BEFORE coding: choose Verification Level (L0-L4) and state why: +5) BEFORE coding: choose Verification Level (L0-L4) and state why: - I/O coordination (DB/HTTP/actors/external) => L2+ (integration tests required) - - UI or UI dependency changed => L3+ (UI smoke / Playwright required) + - TUI/config/init changes => L3+ (native smoke required) + - Live provider/demo/container proof => L4 -5) Implement to satisfy ALL unchecked Done-when criteria for the chosen task. +6) Implement to satisfy ALL unchecked Done-when criteria for the chosen task. -6) Verify (must match chosen level): +7) Verify (must match chosen level): - Minimum: build + test (language-appropriate commands) - - If Level >= L3: run UI smoke/Playwright and check for console errors + - If Level >= L3: run native smoke and semantic assertion scripts - Follow any additional quality gates from AGENTS.md/CLAUDE.md -7) FLIGHT RECORDER (MANDATORY): +8) FLIGHT RECORDER (MANDATORY): - Write $ITER_LOG BEFORE committing. - Include: - - Task selected (exact title) - - Surface area classification - - Verification level chosen + reason - - Skills consulted - - Commands run + outcomes - - Deviations/skips + justification - - Follow-ups noticed but deferred + why + - Task selected (exact title) + - Surface area classification + - Verification level chosen + reason + - Producer/consumer contract identified (or why none applies) + - Skills consulted + - Commands run + outcomes + - Positive behavior verified + - Negative behavior verified + - Runtime/smoke/eval evidence or explicit blocker + - Deviations/skips + justification + - Follow-ups noticed but deferred + why - If you claim a command was run, it must appear in the log with outcome. - 'Log or it didn't happen.' -8) If verification passes: - - Commit to the current feature branch with a descriptive message +9) If verification passes: + - Commit to the current feature branch with a descriptive message using git commit -S - Update IMPLEMENTATION_PLAN.md checkboxes in the SAME commit - Update TOOLING.md if you used or discovered a new tool/resource + - Never use --no-gpg-sign -9) Stop at checkpoints (UI approval, architecture decisions, credential setup) and ask the user if needed. +10) Stop at checkpoints (UI approval, architecture decisions, credential setup) and ask the user if needed. -10) Exit - do NOT continue to additional tasks. +11) Exit - do NOT continue to additional tasks. ## Constraints (Constitution) - ONE iteration = ONE task block - Never commit to dev/main/master +- Never create unsigned commits - Follow constraints from AGENTS.md/CLAUDE.md -- Test against real infrastructure (per testing-strategy) +- Automate all proof that can reasonably be automated; manual testing is last-mile only "; then EXIT_CODE=$? echo "" @@ -394,6 +436,11 @@ for ((i=1; i<=ITERATIONS; i++)); do echo "" echo "Iteration $i complete" + if ! verify_signed_iteration_commit "$ITER_START_COMMIT"; then + echo "RALPH loop paused due to signed commit gate failure at iteration $i" + exit 1 + fi + # Run L3 verification gate if L3 was claimed if ! verify_l3_evidence "$ITER_LOG"; then echo "RALPH loop paused due to L3 verification gate failure at iteration $i" @@ -432,7 +479,7 @@ echo "==========================================" echo "" echo "Remaining incomplete tasks:" -grep -B5 '^\- \[ \]' "$PLAN_FILE" | grep '### Task:' | head -5 || echo "(none or legacy format)" +grep -B5 '^\- \[ \]' "$PLAN_FILE" | grep -E '#### Task [0-9]+\.[0-9]+:' | head -5 || echo "(none or legacy format)" echo "" echo "Running postmortem (skill): ralph-after-action" diff --git a/src/Netclaw.Channels.Slack/SlackProbe.cs b/src/Netclaw.Channels.Slack/SlackProbe.cs index 06e60ff67..a34b3cce1 100644 --- a/src/Netclaw.Channels.Slack/SlackProbe.cs +++ b/src/Netclaw.Channels.Slack/SlackProbe.cs @@ -40,7 +40,7 @@ public interface ISlackProbe Task<SlackProbeResult> ProbeAsync(string botToken, CancellationToken ct = default); /// <summary> - /// Resolves user-provided channel names to Slack channel IDs via <c>conversations.list</c>. + /// Resolves user-provided channel names or IDs to Slack channel IDs via <c>conversations.list</c>. /// </summary> Task<SlackChannelResolutionResult> ResolveChannelNamesAsync( string botToken, IReadOnlyList<string> channelNames, CancellationToken ct = default); @@ -161,11 +161,13 @@ public async Task<SlackChannelResolutionResult> ResolveChannelNamesAsync( if (id is null) continue; // Check if this channel matches any remaining name (case-insensitive) + // or an already-resolved channel ID from an existing config. string? matchedInput = null; foreach (var input in remaining) { if (string.Equals(input, name, StringComparison.OrdinalIgnoreCase) || - string.Equals(input, nameNormalized, StringComparison.OrdinalIgnoreCase)) + string.Equals(input, nameNormalized, StringComparison.OrdinalIgnoreCase) || + string.Equals(input, id, StringComparison.Ordinal)) { matchedInput = input; break; diff --git a/src/Netclaw.Cli.Tests/Tui/Config/ChannelsConfigNavigationTests.cs b/src/Netclaw.Cli.Tests/Tui/Config/ChannelsConfigNavigationTests.cs index e554735fc..db8a9a247 100644 --- a/src/Netclaw.Cli.Tests/Tui/Config/ChannelsConfigNavigationTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/Config/ChannelsConfigNavigationTests.cs @@ -365,7 +365,12 @@ private TerminaApplication CreateHeadlessApp( _ => new ChannelsConfigPage(), _ => { - capturedChannelsVm = new ChannelsConfigViewModel(_paths, new FakeSlackProbe(), new FakeDiscordProbe(), tuiNavigation); + capturedChannelsVm = new ChannelsConfigViewModel( + _paths, + new FakeSlackProbe(), + new FakeDiscordProbe(), + new FakeMattermostProbe(), + tuiNavigation); return capturedChannelsVm; }); }); diff --git a/src/Netclaw.Cli.Tests/Tui/Config/ChannelsConfigViewModelTests.cs b/src/Netclaw.Cli.Tests/Tui/Config/ChannelsConfigViewModelTests.cs index 80c844073..b8efbd528 100644 --- a/src/Netclaw.Cli.Tests/Tui/Config/ChannelsConfigViewModelTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/Config/ChannelsConfigViewModelTests.cs @@ -5,7 +5,10 @@ // ----------------------------------------------------------------------- using System.Text.Json; using Netclaw.Actors.Channels; +using Netclaw.Channels.Slack; using Netclaw.Cli.Config; +using Netclaw.Cli.Discord; +using Netclaw.Cli.Mattermost; using Netclaw.Cli.Tests.Tui; using Netclaw.Cli.Tui.Config; using Netclaw.Cli.Tui.Wizard.Steps; @@ -392,8 +395,124 @@ public void Add_channel_management_is_generic_for_discord_and_mattermost( Assert.Equal("team", ToStringDictionary(audiencesRaw)[newChannelId]); } - private ChannelsConfigViewModel CreateViewModel() - => new(_paths, new FakeSlackProbe(), new FakeDiscordProbe()); + [Fact] + public void Save_resolves_slack_channel_names_to_ids_and_remaps_audiences() + { + WriteChannelConfig(); + WriteChannelSecrets(); + var slackProbe = new FakeSlackProbe + { + NextResolutionResult = new SlackChannelResolutionResult( + true, + null, + [new ResolvedSlackChannel("netclaw-support", "C09")], + []) + }; + using var vm = CreateViewModel(slackProbe: slackProbe); + vm.OpenAdapterManagement(ChannelType.Slack); + vm.BeginAddChannel(); + vm.AddChannelInput = "netclaw-support"; + + vm.ApplyAddChannel(); + vm.Save(); + + Assert.Equal(1, slackProbe.ResolveCallCount); + Assert.Equal(["netclaw-support"], slackProbe.LastResolvedNames); + var config = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Slack.AllowedChannelIds", out var channelsRaw)); + Assert.Equal(["C01", "C02", "C03", "C09"], ToStringArray(channelsRaw)); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Slack.ChannelAudiences", out var audiencesRaw)); + var audiences = ToStringDictionary(audiencesRaw); + Assert.Equal("team", audiences["C09"]); + Assert.DoesNotContain("netclaw-support", audiences.Keys); + } + + [Fact] + public void Save_rejects_unresolved_slack_channel_name() + { + WriteChannelConfig(); + WriteChannelSecrets(); + var slackProbe = new FakeSlackProbe + { + NextResolutionResult = new SlackChannelResolutionResult( + false, + null, + [], + ["fart"]) + }; + using var vm = CreateViewModel(slackProbe: slackProbe); + vm.OpenAdapterManagement(ChannelType.Slack); + vm.BeginAddChannel(); + vm.AddChannelInput = "fart"; + + vm.ApplyAddChannel(); + vm.Save(); + + Assert.False(vm.IsSaved.Value); + Assert.Equal("Slack channel not found: #fart", vm.Status.Value.Text); + Assert.Equal(ConfigStatusTone.Error, vm.Status.Value.Tone); + Assert.Equal(1, slackProbe.ResolveCallCount); + } + + [Fact] + public void Save_rejects_unresolved_discord_channel_id() + { + WriteAllChannelConfig(); + WriteAllChannelSecrets(); + var discordProbe = new FakeDiscordProbe + { + NextResolutionResult = new DiscordChannelResolutionResult( + false, + null, + [], + ["987654321"]) + }; + using var vm = CreateViewModel(discordProbe: discordProbe); + vm.Step.GetAdapterViewModel<DiscordStepViewModel>(ChannelType.Discord).ChannelIdsInput = "987654321"; + + vm.Save(); + + Assert.False(vm.IsSaved.Value); + Assert.Equal("Discord channel ID not found: 987654321", vm.Status.Value.Text); + Assert.Equal(ConfigStatusTone.Error, vm.Status.Value.Tone); + Assert.Equal(1, discordProbe.ResolveCallCount); + Assert.Equal("discord-token", discordProbe.LastBotToken); + } + + [Fact] + public void Save_rejects_unresolved_mattermost_channel_id() + { + WriteAllChannelConfig(); + WriteAllChannelSecrets(); + var mattermostProbe = new FakeMattermostProbe + { + NextResolutionResult = new MattermostChannelResolutionResult( + false, + null, + [], + ["bogus"]) + }; + using var vm = CreateViewModel(mattermostProbe: mattermostProbe); + vm.Step.GetAdapterViewModel<MattermostStepViewModel>(ChannelType.Mattermost).ChannelIdsInput = "bogus"; + + vm.Save(); + + Assert.False(vm.IsSaved.Value); + Assert.Equal("Mattermost channel ID not found: bogus", vm.Status.Value.Text); + Assert.Equal(ConfigStatusTone.Error, vm.Status.Value.Tone); + Assert.Equal(1, mattermostProbe.ResolveCallCount); + Assert.Equal("https://mattermost.example.com", mattermostProbe.LastServerUrl); + Assert.Equal("mattermost-token", mattermostProbe.LastBotToken); + } + + private ChannelsConfigViewModel CreateViewModel( + FakeSlackProbe? slackProbe = null, + FakeDiscordProbe? discordProbe = null, + FakeMattermostProbe? mattermostProbe = null) + => new(_paths, + slackProbe ?? new FakeSlackProbe(), + discordProbe ?? new FakeDiscordProbe(), + mattermostProbe ?? new FakeMattermostProbe()); private static void ConfirmReset(ChannelsConfigViewModel vm, ChannelType type) { diff --git a/src/Netclaw.Cli.Tests/Tui/FakeDiscordProbe.cs b/src/Netclaw.Cli.Tests/Tui/FakeDiscordProbe.cs index 48b04a368..253cfe327 100644 --- a/src/Netclaw.Cli.Tests/Tui/FakeDiscordProbe.cs +++ b/src/Netclaw.Cli.Tests/Tui/FakeDiscordProbe.cs @@ -37,6 +37,7 @@ public async Task<DiscordChannelResolutionResult> ResolveChannelIdsAsync( string botToken, IReadOnlyList<string> channelIds, CancellationToken ct = default) { ResolveCallCount++; + LastBotToken = botToken; LastResolvedIds = channelIds; if (DelayBeforeResult.HasValue) await Task.Delay(DelayBeforeResult.Value, ct); diff --git a/src/Netclaw.Cli.Tests/Tui/FakeMattermostProbe.cs b/src/Netclaw.Cli.Tests/Tui/FakeMattermostProbe.cs new file mode 100644 index 000000000..99b0408e6 --- /dev/null +++ b/src/Netclaw.Cli.Tests/Tui/FakeMattermostProbe.cs @@ -0,0 +1,50 @@ +// ----------------------------------------------------------------------- +// <copyright file="FakeMattermostProbe.cs" company="Petabridge, LLC"> +// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com> +// </copyright> +// ----------------------------------------------------------------------- +using Netclaw.Cli.Mattermost; + +namespace Netclaw.Cli.Tests.Tui; + +public sealed class FakeMattermostProbe : IMattermostProbe +{ + public MattermostProbeResult NextProbeResult { get; set; } = new( + true, null, "testbot"); + + public int ProbeCallCount { get; private set; } + + public string? LastServerUrl { get; private set; } + + public string? LastBotToken { get; private set; } + + public MattermostChannelResolutionResult NextResolutionResult { get; set; } = new(true, null, [], []); + + public int ResolveCallCount { get; private set; } + + public IReadOnlyList<string>? LastResolvedIds { get; private set; } + + public TimeSpan? DelayBeforeResult { get; set; } + + public async Task<MattermostProbeResult> ProbeAsync(string serverUrl, string botToken, CancellationToken ct = default) + { + ProbeCallCount++; + LastServerUrl = serverUrl; + LastBotToken = botToken; + if (DelayBeforeResult.HasValue) + await Task.Delay(DelayBeforeResult.Value, ct); + return NextProbeResult; + } + + public async Task<MattermostChannelResolutionResult> ResolveChannelIdsAsync( + string serverUrl, string botToken, IReadOnlyList<string> channelIds, CancellationToken ct = default) + { + ResolveCallCount++; + LastServerUrl = serverUrl; + LastBotToken = botToken; + LastResolvedIds = channelIds; + if (DelayBeforeResult.HasValue) + await Task.Delay(DelayBeforeResult.Value, ct); + return NextResolutionResult; + } +} diff --git a/src/Netclaw.Cli.Tests/Tui/FakeSlackProbe.cs b/src/Netclaw.Cli.Tests/Tui/FakeSlackProbe.cs index f85b111fe..b79d221ea 100644 --- a/src/Netclaw.Cli.Tests/Tui/FakeSlackProbe.cs +++ b/src/Netclaw.Cli.Tests/Tui/FakeSlackProbe.cs @@ -64,6 +64,7 @@ public async Task<SlackChannelResolutionResult> ResolveChannelNamesAsync( string botToken, IReadOnlyList<string> channelNames, CancellationToken ct = default) { ResolveCallCount++; + LastBotToken = botToken; LastResolvedNames = channelNames; if (DelayBeforeResult.HasValue) await Task.Delay(DelayBeforeResult.Value, ct); diff --git a/src/Netclaw.Cli/Mattermost/MattermostProbe.cs b/src/Netclaw.Cli/Mattermost/MattermostProbe.cs new file mode 100644 index 000000000..7aa41913a --- /dev/null +++ b/src/Netclaw.Cli/Mattermost/MattermostProbe.cs @@ -0,0 +1,218 @@ +// ----------------------------------------------------------------------- +// <copyright file="MattermostProbe.cs" company="Petabridge, LLC"> +// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com> +// </copyright> +// ----------------------------------------------------------------------- +using System.Net; +using System.Net.Http.Headers; +using System.Text.Json; + +namespace Netclaw.Cli.Mattermost; + +public sealed record MattermostProbeResult( + bool Success, + string? ErrorMessage, + string? BotUsername); + +public sealed record ResolvedMattermostChannel( + string ChannelId, + string ChannelName, + string DisplayName); + +public sealed record MattermostChannelResolutionResult( + bool Success, + string? ErrorMessage, + IReadOnlyList<ResolvedMattermostChannel> Resolved, + IReadOnlyList<string> Unresolved); + +public interface IMattermostProbe +{ + Task<MattermostProbeResult> ProbeAsync(string serverUrl, string botToken, CancellationToken ct = default); + + Task<MattermostChannelResolutionResult> ResolveChannelIdsAsync( + string serverUrl, string botToken, IReadOnlyList<string> channelIds, CancellationToken ct = default); +} + +public sealed class MattermostProbe : IMattermostProbe +{ + private static readonly TimeSpan ProbeTimeout = TimeSpan.FromSeconds(10); + private static readonly TimeSpan ResolveTimeout = TimeSpan.FromSeconds(30); + + private readonly HttpClient _httpClient; + + public MattermostProbe(HttpClient httpClient) + { + _httpClient = httpClient; + } + + public async Task<MattermostProbeResult> ProbeAsync(string serverUrl, string botToken, CancellationToken ct = default) + { + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeoutCts.CancelAfter(ProbeTimeout); + + try + { + using var request = CreateRequest(HttpMethod.Get, serverUrl, "/api/v4/users/me", botToken); + using var response = await _httpClient.SendAsync(request, timeoutCts.Token); + if (!response.IsSuccessStatusCode) + { + var body = await response.Content.ReadAsStringAsync(timeoutCts.Token); + return new MattermostProbeResult(false, MapHttpError(response.StatusCode, body), null); + } + + var json = await response.Content.ReadAsStringAsync(timeoutCts.Token); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + var username = root.TryGetProperty("username", out var usernameProp) + ? usernameProp.GetString() + : null; + return new MattermostProbeResult(true, null, username); + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + return new MattermostProbeResult(false, + "Connection timed out after 10 seconds. Check your network connection.", null); + } + catch (OperationCanceledException) + { + return new MattermostProbeResult(false, "Validation cancelled.", null); + } + catch (HttpRequestException ex) + { + return new MattermostProbeResult(false, $"Connection failed: {ex.Message}", null); + } + catch (InvalidOperationException ex) + { + return new MattermostProbeResult(false, ex.Message, null); + } + } + + public async Task<MattermostChannelResolutionResult> ResolveChannelIdsAsync( + string serverUrl, string botToken, IReadOnlyList<string> channelIds, CancellationToken ct = default) + { + var normalized = channelIds + .Select(static id => id.Trim()) + .Where(static id => !string.IsNullOrWhiteSpace(id)) + .Distinct(StringComparer.Ordinal) + .ToList(); + + if (normalized.Count == 0) + return new MattermostChannelResolutionResult(true, null, [], []); + + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeoutCts.CancelAfter(ResolveTimeout); + + try + { + var resolved = new List<ResolvedMattermostChannel>(); + var unresolved = new List<string>(); + + foreach (var channelId in normalized) + { + var result = await FetchChannelAsync(serverUrl, botToken, channelId, timeoutCts.Token); + if (result is null) + { + unresolved.Add(channelId); + continue; + } + + resolved.Add(result); + } + + return new MattermostChannelResolutionResult( + unresolved.Count == 0, null, resolved, unresolved); + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + return new MattermostChannelResolutionResult(false, + "Channel resolution timed out after 30 seconds.", [], []); + } + catch (OperationCanceledException) + { + return new MattermostChannelResolutionResult(false, + "Channel resolution cancelled.", [], []); + } + catch (HttpRequestException ex) + { + return new MattermostChannelResolutionResult(false, + $"Channel resolution failed: {ex.Message}", [], []); + } + catch (InvalidOperationException ex) + { + return new MattermostChannelResolutionResult(false, ex.Message, [], []); + } + } + + private async Task<ResolvedMattermostChannel?> FetchChannelAsync( + string serverUrl, string botToken, string channelId, CancellationToken ct) + { + using var request = CreateRequest( + HttpMethod.Get, + serverUrl, + $"/api/v4/channels/{Uri.EscapeDataString(channelId)}", + botToken); + using var response = await _httpClient.SendAsync(request, ct); + + if (response.StatusCode == HttpStatusCode.NotFound) + return null; + + if (!response.IsSuccessStatusCode) + { + var body = await response.Content.ReadAsStringAsync(ct); + throw new HttpRequestException(MapHttpError(response.StatusCode, body)); + } + + var json = await response.Content.ReadAsStringAsync(ct); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + var id = root.TryGetProperty("id", out var idProp) ? idProp.GetString() : null; + var name = root.TryGetProperty("name", out var nameProp) ? nameProp.GetString() : null; + var displayName = root.TryGetProperty("display_name", out var displayNameProp) + ? displayNameProp.GetString() + : null; + + return id is null + ? null + : new ResolvedMattermostChannel(id, name ?? id, displayName ?? name ?? id); + } + + private static HttpRequestMessage CreateRequest(HttpMethod method, string serverUrl, string path, string botToken) + { + if (!Uri.TryCreate(serverUrl.TrimEnd('/'), UriKind.Absolute, out var baseUri) + || baseUri.Scheme is not ("http" or "https")) + throw new InvalidOperationException("Mattermost server URL must be an absolute http:// or https:// URL."); + + var request = new HttpRequestMessage(method, new Uri(baseUri, path)); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", botToken); + return request; + } + + private static string MapHttpError(HttpStatusCode statusCode, string body) + { + var message = TryExtractMattermostMessage(body); + return (statusCode, message) switch + { + (HttpStatusCode.Unauthorized, _) => "Bot token is invalid. Check the Mattermost bot access token.", + (HttpStatusCode.Forbidden, _) => "Access denied. Check bot permissions.", + (HttpStatusCode.NotFound, _) => "Resource not found. Check the ID is correct.", + (HttpStatusCode.TooManyRequests, _) => "Rate limited by Mattermost API. Try again in a few seconds.", + (_, { Length: > 0 }) => $"Mattermost API error: {(int)statusCode} {statusCode}: {message}", + _ => $"Mattermost API error: {(int)statusCode} {statusCode}" + }; + } + + private static string? TryExtractMattermostMessage(string body) + { + try + { + using var doc = JsonDocument.Parse(body); + return doc.RootElement.TryGetProperty("message", out var messageProp) + ? messageProp.GetString() + : null; + } + catch (JsonException) + { + return null; + } + } +} diff --git a/src/Netclaw.Cli/Program.cs b/src/Netclaw.Cli/Program.cs index cd11fb3d4..45af33ea6 100644 --- a/src/Netclaw.Cli/Program.cs +++ b/src/Netclaw.Cli/Program.cs @@ -21,6 +21,7 @@ using Netclaw.Cli.Json; using Netclaw.Cli.Doctor; using Netclaw.Cli.Mcp; +using Netclaw.Cli.Mattermost; using Netclaw.Cli.Reminder; using Netclaw.Cli.Secrets; using Netclaw.Cli.Model; @@ -131,6 +132,7 @@ static async Task RunAsync(string[] args) { builder.Services.AddHttpClient<ISlackProbe, SlackProbe>(); builder.Services.AddHttpClient<IDiscordProbe, DiscordProbe>(); + builder.Services.AddHttpClient<IMattermostProbe, MattermostProbe>(); builder.Services.AddDoctorChecks(); } @@ -159,6 +161,7 @@ static async Task RunAsync(string[] args) builder.Services.AddSingleton<DeviceFlowServiceFactory>(); builder.Services.AddHttpClient<ISlackProbe, SlackProbe>(); builder.Services.AddHttpClient<IDiscordProbe, DiscordProbe>(); + builder.Services.AddHttpClient<IMattermostProbe, MattermostProbe>(); // Init wizard + chat page dependencies (daemon lifecycle + SignalR) var initPaths = new NetclawPaths(); @@ -879,6 +882,7 @@ static async Task RunAsync(string[] args) builder.Services.AddProviderDescriptors(); builder.Services.AddHttpClient<ISlackProbe, SlackProbe>(); builder.Services.AddHttpClient<IDiscordProbe, DiscordProbe>(); + builder.Services.AddHttpClient<IMattermostProbe, MattermostProbe>(); builder.Services.AddHttpClient("OAuthDeviceFlow"); builder.Services.AddSingleton(sp => new OAuthDeviceFlowService( @@ -922,6 +926,7 @@ static async Task RunAsync(string[] args) ConfigureConfigServices(doctorBuilder.Services, doctorBuilder.Configuration); doctorBuilder.Services.AddHttpClient<ISlackProbe, SlackProbe>(); doctorBuilder.Services.AddHttpClient<IDiscordProbe, DiscordProbe>(); + doctorBuilder.Services.AddHttpClient<IMattermostProbe, MattermostProbe>(); doctorBuilder.Services.AddDoctorChecks(); doctorBuilder.Logging.ClearProviders(); doctorBuilder.Logging.SetMinimumLevel(LogLevel.Warning); diff --git a/src/Netclaw.Cli/Tui/Config/ChannelsConfigViewModel.cs b/src/Netclaw.Cli/Tui/Config/ChannelsConfigViewModel.cs index 1cefaa6bb..5f5b4b794 100644 --- a/src/Netclaw.Cli/Tui/Config/ChannelsConfigViewModel.cs +++ b/src/Netclaw.Cli/Tui/Config/ChannelsConfigViewModel.cs @@ -8,6 +8,7 @@ using Netclaw.Channels.Slack; using Netclaw.Cli.Config; using Netclaw.Cli.Discord; +using Netclaw.Cli.Mattermost; using Netclaw.Cli.Tui.Sections; using Netclaw.Cli.Tui.Wizard; using Netclaw.Cli.Tui.Wizard.Steps; @@ -21,6 +22,9 @@ namespace Netclaw.Cli.Tui.Config; public sealed class ChannelsConfigViewModel : ReactiveViewModel { private readonly NetclawPaths _paths; + private readonly ISlackProbe _slackProbe; + private readonly IDiscordProbe _discordProbe; + private readonly IMattermostProbe _mattermostProbe; private readonly TuiNavigation? _navigation; private readonly ChannelsConfigPersistenceMapper _mapper = new(); private readonly ChannelsEditorValidationAdapter _validator = new(); @@ -41,9 +45,13 @@ public ChannelsConfigViewModel( NetclawPaths paths, ISlackProbe slackProbe, IDiscordProbe discordProbe, + IMattermostProbe mattermostProbe, TuiNavigation? navigation = null) { _paths = paths; + _slackProbe = slackProbe; + _discordProbe = discordProbe; + _mattermostProbe = mattermostProbe; _navigation = navigation; Status = new ReactiveProperty<ConfigStatusMessage>(new ConfigStatusMessage(string.Empty, ConfigStatusTone.Neutral)); Step = new ChannelPickerStepViewModel(slackProbe, discordProbe) @@ -122,7 +130,7 @@ public void GoNext() return; } - Save(); + _ = SaveFromInputAsync(); } public void GoBack() @@ -152,6 +160,9 @@ public void GoBack() } public void Save() + => SaveAsync().GetAwaiter().GetResult(); + + public async Task SaveAsync(CancellationToken ct = default) { var validation = ValidateCurrentStep(); if (validation.HasErrors) @@ -161,6 +172,17 @@ public void Save() return; } + Status.Value = new ConfigStatusMessage("Validating channel access...", ConfigStatusTone.Neutral); + RequestRedraw(); + + var dynamicValidation = await ValidateChannelAccessAsync(ct); + if (dynamicValidation.HasErrors) + { + Status.Value = BuildValidationErrorStatus(dynamicValidation, "Fix channel validation errors before saving."); + RequestRedraw(); + return; + } + var session = new ConfigEditorSession(_paths); session.Apply(_mapper.BuildContribution( Step, @@ -183,6 +205,19 @@ public void Save() NotifyContentChanged(); } + private async Task SaveFromInputAsync() + { + try + { + await SaveAsync(); + } + catch (Exception ex) + { + Status.Value = new ConfigStatusMessage($"Channel settings save failed: {ex.Message}", ConfigStatusTone.Error); + RequestRedraw(); + } + } + internal bool TryOpenSelectedAdapterManagement() { if (!Step.IsInPickerMode) @@ -610,6 +645,190 @@ internal void ApplyCredentials() private ChannelsEditorValidationResult ValidateCurrentStep() => _validator.Validate(ChannelsEditorModel.FromStep(Step)); + private async Task<ChannelsEditorValidationResult> ValidateChannelAccessAsync(CancellationToken ct) + { + var issues = new List<ChannelsEditorValidationIssue>(); + + var slackIssue = await ValidateSlackChannelsAsync(ct); + if (slackIssue is not null) + issues.Add(slackIssue); + + var discordIssue = await ValidateDiscordChannelsAsync(ct); + if (discordIssue is not null) + issues.Add(discordIssue); + + var mattermostIssue = await ValidateMattermostChannelsAsync(ct); + if (mattermostIssue is not null) + issues.Add(mattermostIssue); + + return issues.Count == 0 + ? ChannelsEditorValidationResult.Empty + : new ChannelsEditorValidationResult(issues); + } + + private async Task<ChannelsEditorValidationIssue?> ValidateSlackChannelsAsync(CancellationToken ct) + { + if (!Step.IsAdapterEnabled(ChannelType.Slack)) + return null; + + var slack = Step.GetAdapterViewModel<SlackStepViewModel>(ChannelType.Slack); + var configuredChannels = ParseCsv(slack.ChannelNamesInput, trimHash: true); + var namesToResolve = configuredChannels + .Where(static channel => !IsSlackChannelId(channel)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (namesToResolve.Length == 0) + return null; + + var botToken = GetEffectiveSecret("Slack.BotToken", slack.BotToken, slack.HasPersistedBotToken); + if (string.IsNullOrWhiteSpace(botToken)) + return Error(ChannelsEditorFieldPaths.SlackBotToken, ChannelsEditorValidationMessages.SlackBotTokenRequired); + + var result = await _slackProbe.ResolveChannelNamesAsync(botToken, namesToResolve, ct); + slack.LastChannelResolution = result; + + if (!string.IsNullOrWhiteSpace(result.ErrorMessage)) + return Error(ChannelsEditorFieldPaths.SlackAllowedChannelIds, $"Slack channel lookup failed: {result.ErrorMessage}"); + + if (result.Unresolved.Count > 0) + return Error(ChannelsEditorFieldPaths.SlackAllowedChannelIds, $"Slack {FormatNotFound(result.Unresolved, "channel", "channels", prefix: "#")}"); + + if (!result.Success) + return Error(ChannelsEditorFieldPaths.SlackAllowedChannelIds, "Slack channel lookup failed."); + + var resolvedByName = result.Resolved.ToDictionary( + static channel => channel.Name, + static channel => channel.Id, + StringComparer.OrdinalIgnoreCase); + var remap = new Dictionary<string, string>(StringComparer.Ordinal); + var resolvedChannels = new List<string>(); + + foreach (var channel in configuredChannels) + { + if (IsSlackChannelId(channel)) + { + resolvedChannels.Add(channel); + continue; + } + + if (!resolvedByName.TryGetValue(channel, out var channelId)) + return Error(ChannelsEditorFieldPaths.SlackAllowedChannelIds, $"Slack channel not found: #{channel}"); + + resolvedChannels.Add(channelId); + remap[channel] = channelId; + } + + SetChannelIds(ChannelType.Slack, [.. resolvedChannels.Distinct(StringComparer.Ordinal)]); + RemapChannelAudiences(ChannelType.Slack, remap); + UpdateAdapterPickerSummary(ChannelType.Slack); + return null; + } + + private async Task<ChannelsEditorValidationIssue?> ValidateDiscordChannelsAsync(CancellationToken ct) + { + if (!Step.IsAdapterEnabled(ChannelType.Discord)) + return null; + + var discord = Step.GetAdapterViewModel<DiscordStepViewModel>(ChannelType.Discord); + var channelIds = ParseCsv(discord.ChannelIdsInput, trimHash: true); + if (channelIds.Count == 0) + return null; + + var botToken = GetEffectiveSecret("Discord.BotToken", discord.BotToken, discord.HasPersistedBotToken); + if (string.IsNullOrWhiteSpace(botToken)) + return Error(ChannelsEditorFieldPaths.DiscordBotToken, ChannelsEditorValidationMessages.DiscordBotTokenRequired); + + var result = await _discordProbe.ResolveChannelIdsAsync(botToken, channelIds, ct); + discord.LastChannelResolution = result; + + if (!string.IsNullOrWhiteSpace(result.ErrorMessage)) + return Error(ChannelsEditorFieldPaths.DiscordAllowedChannelIds, $"Discord channel lookup failed: {result.ErrorMessage}"); + + if (result.Unresolved.Count > 0) + return Error(ChannelsEditorFieldPaths.DiscordAllowedChannelIds, $"Discord {FormatNotFound(result.Unresolved, "channel ID", "channel IDs")}"); + + if (!result.Success) + return Error(ChannelsEditorFieldPaths.DiscordAllowedChannelIds, "Discord channel lookup failed."); + + return null; + } + + private async Task<ChannelsEditorValidationIssue?> ValidateMattermostChannelsAsync(CancellationToken ct) + { + if (!Step.IsAdapterEnabled(ChannelType.Mattermost)) + return null; + + var mattermost = Step.GetAdapterViewModel<MattermostStepViewModel>(ChannelType.Mattermost); + var channelIds = ParseCsv(mattermost.ChannelIdsInput, trimHash: true); + if (channelIds.Count == 0) + return null; + + var serverUrl = Normalize(mattermost.ServerUrl); + if (string.IsNullOrWhiteSpace(serverUrl)) + return Error(ChannelsEditorFieldPaths.MattermostServerUrl, ChannelsEditorValidationMessages.MattermostServerUrlRequired); + + var botToken = GetEffectiveSecret("Mattermost.BotToken", mattermost.BotToken, mattermost.HasPersistedBotToken); + if (string.IsNullOrWhiteSpace(botToken)) + return Error(ChannelsEditorFieldPaths.MattermostBotToken, ChannelsEditorValidationMessages.MattermostBotTokenRequired); + + var result = await _mattermostProbe.ResolveChannelIdsAsync(serverUrl, botToken, channelIds, ct); + if (!string.IsNullOrWhiteSpace(result.ErrorMessage)) + return Error(ChannelsEditorFieldPaths.MattermostAllowedChannelIds, $"Mattermost channel lookup failed: {result.ErrorMessage}"); + + if (result.Unresolved.Count > 0) + return Error(ChannelsEditorFieldPaths.MattermostAllowedChannelIds, $"Mattermost {FormatNotFound(result.Unresolved, "channel ID", "channel IDs")}"); + + if (!result.Success) + return Error(ChannelsEditorFieldPaths.MattermostAllowedChannelIds, "Mattermost channel lookup failed."); + + return null; + } + + private static ChannelsEditorValidationIssue Error(string fieldId, string message) + => new(fieldId, message, ConfigValidationSeverity.Error); + + private static string FormatNotFound( + IReadOnlyList<string> values, + string singular, + string plural, + string prefix = "") + { + var label = values.Count == 1 ? singular : plural; + var list = string.Join(", ", values.Select(value => $"{prefix}{value}")); + return $"{label} not found: {list}"; + } + + private string? GetEffectiveSecret(string path, string? draftValue, bool hasPersistedSecret) + { + var normalized = Normalize(draftValue); + if (!string.IsNullOrWhiteSpace(normalized)) + return normalized; + + if (!hasPersistedSecret) + return null; + + var secrets = ConfigFileHelper.LoadJsonDict(_paths.SecretsPath); + return ConfigFileHelper.TryGetPathValue(secrets, path, out var value) + ? Normalize(ConfigFileHelper.DecryptIfEncrypted(_paths, value?.ToString())) + : null; + } + + private void RemapChannelAudiences(ChannelType type, IReadOnlyDictionary<string, string> remap) + { + if (remap.Count == 0 || !_channelAudiences.TryGetValue(type, out var audiences)) + return; + + foreach (var (oldId, newId) in remap) + { + if (!audiences.TryGetValue(oldId, out var audience)) + continue; + + audiences.Remove(oldId); + audiences.TryAdd(newId, audience); + } + } + private ChannelsEditorValidationIssue? ValidateCredentialDrafts() { var candidate = ChannelsEditorModel.FromStep(Step); @@ -1003,6 +1222,11 @@ private static List<string> ParseCsv(string? input, bool trimHash) private static string? NormalizeChannelId(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim().TrimStart('#'); + private static bool IsSlackChannelId(string value) + => value.Length > 1 + && value[0] is 'C' or 'G' + && value.Skip(1).All(static ch => char.IsUpper(ch) || char.IsDigit(ch)); + private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); diff --git a/src/Netclaw.Cli/Tui/Config/ChannelsEditorModel.cs b/src/Netclaw.Cli/Tui/Config/ChannelsEditorModel.cs index 0f082971f..94b1232cd 100644 --- a/src/Netclaw.Cli/Tui/Config/ChannelsEditorModel.cs +++ b/src/Netclaw.Cli/Tui/Config/ChannelsEditorModel.cs @@ -147,10 +147,13 @@ internal static class ChannelsEditorFieldPaths { internal const string SlackBotToken = "Slack.BotToken"; internal const string SlackAppToken = "Slack.AppToken"; + internal const string SlackAllowedChannelIds = "Slack.AllowedChannelIds"; internal const string DiscordBotToken = "Discord.BotToken"; + internal const string DiscordAllowedChannelIds = "Discord.AllowedChannelIds"; internal const string MattermostServerUrl = "Mattermost.ServerUrl"; internal const string MattermostBotToken = "Mattermost.BotToken"; internal const string MattermostCallbackUrl = "Mattermost.CallbackUrl"; + internal const string MattermostAllowedChannelIds = "Mattermost.AllowedChannelIds"; } internal static class ChannelsEditorValidationMessages From 21eea87efd897427254a3839bf421d81f33d3482 Mon Sep 17 00:00:00 2001 From: Aaron Stannard <aaron@petabridge.com> Date: Sun, 31 May 2026 20:26:22 +0000 Subject: [PATCH 30/31] chore(opencode): use gpt-5.5 xhigh defaults --- opencode.jsonc | 17 ++++++++++++++++- ralph-opencode.sh | 48 ++++++++++++++++++++++++++++++++++++----------- 2 files changed, 53 insertions(+), 12 deletions(-) diff --git a/opencode.jsonc b/opencode.jsonc index 2f81d7141..883f88554 100644 --- a/opencode.jsonc +++ b/opencode.jsonc @@ -1,5 +1,20 @@ { "$schema": "https://opencode.ai/config.json", + "model": "openai/gpt-5.5", + "agent": { + "build": { + "model": "openai/gpt-5.5", + "variant": "xhigh" + }, + "plan": { + "model": "openai/gpt-5.5", + "variant": "xhigh" + }, + "general": { + "model": "openai/gpt-5.5", + "variant": "xhigh" + } + }, "mcp": { "aspire": { "type": "local", @@ -20,4 +35,4 @@ "enabled": true } } -} \ No newline at end of file +} diff --git a/ralph-opencode.sh b/ralph-opencode.sh index 1106e2953..0df22b972 100755 --- a/ralph-opencode.sh +++ b/ralph-opencode.sh @@ -6,10 +6,11 @@ # ./ralph-opencode.sh 10 # Run 10 iterations # ./ralph-opencode.sh --model <m> # Run with model override # ./ralph-opencode.sh --postmortem-model <m> +# ./ralph-opencode.sh --variant high # Run with model variant override # ./ralph-opencode.sh --review-interval 3 # Run adversarial review every 3 iterations # ./ralph-opencode.sh --model <m> 10 -# RALPH_MODEL=openai/gpt-4.5 ./ralph-opencode.sh -# ./ralph-opencode.sh --model openai/gpt-5.2-codex --postmortem-model github-copilot/claude-opus-4.5 9 +# RALPH_MODEL=openai/gpt-5.5 RALPH_VARIANT=xhigh ./ralph-opencode.sh +# ./ralph-opencode.sh --model openai/gpt-5.5 --variant xhigh --postmortem-model github-copilot/claude-opus-4.5 --postmortem-variant max 9 # Postmortem runs automatically after the loop. # # Each iteration is a FRESH OpenCode context window. @@ -24,38 +25,59 @@ trap 'echo ""; echo "RALPH loop interrupted."; exit 130' INT TERM PLAN_FILE="IMPLEMENTATION_PLAN.md" ITERATIONS=5 -MODEL="${RALPH_MODEL:-github-copilot/claude-opus-4.5}" +MODEL="${RALPH_MODEL:-openai/gpt-5.5}" +VARIANT="${RALPH_VARIANT:-xhigh}" POSTMORTEM_MODEL="${RALPH_POSTMORTEM_MODEL:-$MODEL}" +POSTMORTEM_VARIANT="${RALPH_POSTMORTEM_VARIANT:-$VARIANT}" REVIEW_INTERVAL="${RALPH_REVIEW_INTERVAL:-0}" # 0 = disabled L3_GATE_ENABLED="${RALPH_L3_GATE:-true}" # Block commits without L3 evidence L3_GATE_BYPASS=false -# --- arg parsing (allow: [--model X] [--review-interval N] [iterations]) --- +# --- arg parsing (allow: [--model X] [--variant X] [--review-interval N] [iterations]) --- while [[ $# -gt 0 ]]; do case "$1" in --model|-m) if [[ $# -lt 2 ]]; then echo "Missing value for $1" - echo "Usage: $0 [--model <model>] [--postmortem-model <model>] [--review-interval N] [--skip-l3-gate] [iterations]" + echo "Usage: $0 [--model <model>] [--variant <variant>] [--postmortem-model <model>] [--postmortem-variant <variant>] [--review-interval N] [--skip-l3-gate] [iterations]" exit 1 fi MODEL="$2" POSTMORTEM_MODEL="${RALPH_POSTMORTEM_MODEL:-$MODEL}" shift 2 ;; + --variant) + if [[ $# -lt 2 ]]; then + echo "Missing value for $1" + echo "Usage: $0 [--model <model>] [--variant <variant>] [--postmortem-model <model>] [--postmortem-variant <variant>] [--review-interval N] [--skip-l3-gate] [iterations]" + exit 1 + fi + VARIANT="$2" + POSTMORTEM_VARIANT="${RALPH_POSTMORTEM_VARIANT:-$VARIANT}" + shift 2 + ;; --postmortem-model) if [[ $# -lt 2 ]]; then echo "Missing value for $1" - echo "Usage: $0 [--model <model>] [--postmortem-model <model>] [--review-interval N] [--skip-l3-gate] [iterations]" + echo "Usage: $0 [--model <model>] [--variant <variant>] [--postmortem-model <model>] [--postmortem-variant <variant>] [--review-interval N] [--skip-l3-gate] [iterations]" exit 1 fi POSTMORTEM_MODEL="$2" shift 2 ;; + --postmortem-variant) + if [[ $# -lt 2 ]]; then + echo "Missing value for $1" + echo "Usage: $0 [--model <model>] [--variant <variant>] [--postmortem-model <model>] [--postmortem-variant <variant>] [--review-interval N] [--skip-l3-gate] [iterations]" + exit 1 + fi + POSTMORTEM_VARIANT="$2" + shift 2 + ;; --review-interval) if [[ $# -lt 2 ]]; then echo "Missing value for $1" - echo "Usage: $0 [--model <model>] [--postmortem-model <model>] [--review-interval N] [--skip-l3-gate] [iterations]" + echo "Usage: $0 [--model <model>] [--variant <variant>] [--postmortem-model <model>] [--postmortem-variant <variant>] [--review-interval N] [--skip-l3-gate] [iterations]" exit 1 fi REVIEW_INTERVAL="$2" @@ -72,7 +94,7 @@ while [[ $# -gt 0 ]]; do shift else echo "Unknown arg: $1" - echo "Usage: $0 [--model <model>] [--postmortem-model <model>] [--review-interval N] [--skip-l3-gate] [iterations]" + echo "Usage: $0 [--model <model>] [--variant <variant>] [--postmortem-model <model>] [--postmortem-variant <variant>] [--review-interval N] [--skip-l3-gate] [iterations]" exit 1 fi ;; @@ -93,7 +115,9 @@ mkdir -p "$RUN_DIR" echo "==========================================" echo " RALPH Loop (OpenCode)" echo " Model: $MODEL" +echo " Variant: $VARIANT" echo " Postmortem Model: $POSTMORTEM_MODEL" +echo " Postmortem Variant: $POSTMORTEM_VARIANT" echo " Review Interval: $REVIEW_INTERVAL (0=disabled)" echo " L3 Gate: $L3_GATE_ENABLED (bypass=$L3_GATE_BYPASS)" echo " Iterations: $ITERATIONS" @@ -121,7 +145,9 @@ LAST_REVIEW_COMMIT="$START_COMMIT" echo "Run ID: $RUN_ID" echo "Branch: $BRANCH" echo "Model: $MODEL" + echo "Variant: $VARIANT" echo "Postmortem model: $POSTMORTEM_MODEL" + echo "Postmortem variant: $POSTMORTEM_VARIANT" echo "Review interval: $REVIEW_INTERVAL" echo "Run start commit: $START_COMMIT" echo "Started: $(date)" @@ -149,7 +175,7 @@ run_mid_review() { [[ -f "$f" ]] && prior_reviews="$prior_reviews\n- $f" done - if ! opencode run --model "$POSTMORTEM_MODEL" "Run a full adversarial review using the adversarial review skill. + if ! opencode run --model "$POSTMORTEM_MODEL" --variant "$POSTMORTEM_VARIANT" "Run a full adversarial review using the adversarial review skill. ## Context - RUN_ID: $RUN_ID @@ -343,7 +369,7 @@ for ((i=1; i<=ITERATIONS; i++)); do ITER_LOG="${RUN_DIR}/iter-${ITER_PAD}.md" ITER_START_COMMIT=$(git rev-parse HEAD) - if ! opencode run --model "$MODEL" "You are running RALPH iteration $i. + if ! opencode run --model "$MODEL" --variant "$VARIANT" "You are running RALPH iteration $i. ## Run Metadata (MUST USE) - RUN_ID: $RUN_ID @@ -485,7 +511,7 @@ echo "" echo "Running postmortem (skill): ralph-after-action" # Note: OpenCode doesn't have OpenProse plugin, so we invoke the skill directly. # This runs sequentially. For parallel execution, use ralph.sh with Claude Code. -if ! opencode run --model "$POSTMORTEM_MODEL" "/ralph-after-action RUN_ID=$RUN_ID RUN_DIR=$RUN_DIR branch=$(git branch --show-current)"; then +if ! opencode run --model "$POSTMORTEM_MODEL" --variant "$POSTMORTEM_VARIANT" "/ralph-after-action RUN_ID=$RUN_ID RUN_DIR=$RUN_DIR branch=$(git branch --show-current)"; then POSTMORTEM_EXIT=$? echo "" echo "Postmortem exited with code $POSTMORTEM_EXIT" From a508a6fc7c24995e2fa8f9480f895a42bf47ff6f Mon Sep 17 00:00:00 2001 From: Aaron Stannard <aaron@petabridge.com> Date: Sun, 31 May 2026 20:43:07 +0000 Subject: [PATCH 31/31] fix(config): restore post-rebase imports --- src/Netclaw.Cli/Tui/Config/SecurityAccessViewModel.cs | 1 + src/Netclaw.Cli/Tui/Wizard/WizardConfigBuilder.cs | 1 + 2 files changed, 2 insertions(+) diff --git a/src/Netclaw.Cli/Tui/Config/SecurityAccessViewModel.cs b/src/Netclaw.Cli/Tui/Config/SecurityAccessViewModel.cs index 6679780e1..e3bedab63 100644 --- a/src/Netclaw.Cli/Tui/Config/SecurityAccessViewModel.cs +++ b/src/Netclaw.Cli/Tui/Config/SecurityAccessViewModel.cs @@ -10,6 +10,7 @@ using Netclaw.Cli.Tui.Sections; using Netclaw.Cli.Tui.Wizard.Steps; using Netclaw.Configuration; +using Netclaw.Media; using R3; using Termina.Reactive; diff --git a/src/Netclaw.Cli/Tui/Wizard/WizardConfigBuilder.cs b/src/Netclaw.Cli/Tui/Wizard/WizardConfigBuilder.cs index 03e5dc5a2..af6b68629 100644 --- a/src/Netclaw.Cli/Tui/Wizard/WizardConfigBuilder.cs +++ b/src/Netclaw.Cli/Tui/Wizard/WizardConfigBuilder.cs @@ -6,6 +6,7 @@ using System.Text.Json; using System.Text.Json.Nodes; using Netclaw.Cli.Config; +using Netclaw.Cli.Json; using Netclaw.Cli.Mcp; using Netclaw.Cli.Secrets; using Netclaw.Cli.Tui.Sections;