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//` +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//` 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/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/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-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 new file mode 100644 index 000000000..2bd2adacc --- /dev/null +++ b/docs/ui/TUI-002-netclaw-config-wireframes.md @@ -0,0 +1,1188 @@ +# 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 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 + +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 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) + └── prints refusal to stderr and exits 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 — Domain dashboard + +``` +╭─ Netclaw Configuration ─────────────────────────────────────╮ +│ │ +│ ▸ 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:** each domain row shows a concise aggregate summary of +the underlying leaf editors or routed command state. + +**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; domain entries plus Quit) +└── TextNode (footer hint line) +``` + +--- + +## 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 + +### 3.1 Channels picker + +``` +╭─ Channels ──────────────────────────────────────────────────╮ +│ │ +│ Which channels would you like to connect? │ +│ │ +│ ▶ [✓] Slack 2 channels, 1 user │ +│ [ ] Discord disabled, saved setup │ +│ [ ] Mattermost │ +│ │ +│ ↑/↓ 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 +`.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, +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 + +``` +╭─ 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 `.Enabled`; dormant channel fields and stored credentials are +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 + +``` +╭─ 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 `.AllowedChannelIds` and +`.ChannelAudiences[channelId]`. The DM row writes +`.AllowDirectMessages` plus `.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 + +### 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.6 — Search + +### 6.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(...)`. + +### 6.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.9.5 — Exposure Mode + +### 9.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 Serve │ +│ Tailscale-served local access. │ +│ │ +│ Tailscale Funnel │ +│ Public Tailscale funnel exposure. │ +│ │ +│ Cloudflare Tunnel │ +│ Cloudflare-managed tunnel access. │ +│ │ +│ ────── │ +│ Daemon host: 127.0.0.1 │ +│ Daemon port: 5199 │ +│ │ +│ [ Configure mode → ] [ Save ] [ Cancel ] │ +│ │ +│ ↑/↓ navigate · Tab to buttons · Enter activate │ +╰─────────────────────────────────────────────────────────────╯ +``` + +**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) + +``` +╭─ Exposure Mode › Reverse Proxy ─────────────────────────────╮ +│ │ +│ Trusted proxies (CIDR list): 2 configured → │ +│ │ +│ [ Apply ] [ Cancel ] │ +│ │ +│ Tab next · Enter activate · Esc cancel │ +╰─────────────────────────────────────────────────────────────╯ +``` + +Trusted proxies row → 9.5.6 list editor. + +### 9.5.3 Tailscale Serve sub-form + +``` +╭─ Exposure Mode › Tailscale Serve ───────────────────────────╮ +│ │ +│ No Netclaw-managed credentials are stored here. │ +│ │ +│ Tunnel process: ▸ Managed on this host │ +│ Managed externally / sidecar │ +│ │ +│ [ Apply ] [ Cancel ] │ +│ │ +╰─────────────────────────────────────────────────────────────╯ +``` + +### 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 ─────────────────────────╮ +│ │ +│ No Netclaw-managed tunnel token is stored here. │ +│ Configure `cloudflared` outside Netclaw, then return for │ +│ validation. │ +│ │ +│ Tunnel process: ▸ Managed on this host │ +│ Managed externally / sidecar │ +│ │ +│ [ Apply ] [ Cancel ] │ +│ │ +╰─────────────────────────────────────────────────────────────╯ +``` + +### 9.5.6 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.9 — Security & Access + +### 9.1 Security & Access page + +``` +╭─ Security & Access ─────────────────────────────────────────╮ +│ │ +│ ▸ Security Posture Team │ +│ Enabled Features 4/6 enabled │ +│ Audience Profiles Customized │ +│ Exposure Mode Cloudflare Tunnel │ +│ │ +│ [ Open / Edit inline ] [ Back ] │ +│ │ +│ ↑/↓ navigate · Enter open/edit · Esc back │ +╰─────────────────────────────────────────────────────────────╯ +``` + +## Config.9.1 — Security Posture + +### 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 & 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. │ +│ [ ] Public Open to untrusted users. Strict defaults │ +│ and access controls. │ +│ │ +│ ↑/↓ navigate · Enter save · Esc back │ +╰─────────────────────────────────────────────────────────────╯ +``` + +### 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. + +``` +╭─ 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.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. + +``` +╭─ Security & Access ─────────────────────────────────────────╮ +│ │ +│ Enabled Features │ +│ Toggle global runtime features. Audience exposure is │ +│ configured separately. │ +│ │ +│ ▶ [✓] memory │ +│ [✓] search │ +│ [✓] skills │ +│ [✓] scheduling │ +│ [✓] sub-agents │ +│ [✓] webhooks │ +│ │ +│ ↑/↓ navigate · Space/Enter toggle + save · Esc back │ +╰─────────────────────────────────────────────────────────────╯ +``` + +--- + +## Config.9.4 — Audience Profiles *(addresses #1150)* + +### 9.4.1 Audience selection + +``` +╭─ Audience Profiles ─────────────────────────────────────────╮ +│ │ +│ System default posture: Team │ +│ Customize audience/channel access when it should differ. │ +│ * global default audience Customized = custom overrides │ +│ │ +│ ▶ 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 Profile: Team ────────────────────────────────────╮ +│ │ +│ System default posture: Team │ +│ Profile: No custom overrides │ +│ │ +│ Tools │ +│ ▶ [✓] File tools │ +│ [✓] Web │ +│ [✓] Skills │ +│ [✓] Scheduling │ +│ [✓] Change workspace │ +│ │ +│ Access │ +│ File scope [◀ Session only ▶] │ +│ Attachments [◀ Common work files ▶] │ +│ MCP grants [Open] netclaw mcp permissions │ +│ │ +│ Actions │ +│ Reset overrides [Reset] │ +│ │ +│ Common work files: images, PDFs, documents, archives, │ +│ and media; excludes unknown file types. │ +│ │ +│ ↑/↓ navigate · ←/→ change · Space/Enter toggle/apply │ +╰─────────────────────────────────────────────────────────────╯ +``` + +**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). +- `←` / `→` 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. + +The `config-audience.tape` smoke tape explicitly exercises `↓`, `Space`, +and `Esc` to lock in the keystroke contract. Regression in arrow nav, +toggle, or return behavior is caught. + +**Doctor checks:** `ConfigSchemaDoctorCheck`, `ToolAudienceProfilesDoctorCheck`. + +--- + +## Config.8 — Telemetry & Alerting + +### 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 ─────────────────────────────────────────╮ +│ │ +│ ▸ 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.3.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.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.4 — 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.5.2 — 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.5.3 — 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.7 — 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`. + +## 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..77c881659 --- /dev/null +++ b/docs/ui/TUI-003-simplified-init-wireframes.md @@ -0,0 +1,295 @@ +# 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 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 explicit: re-running over an existing install +opens a small action menu instead of replaying the full wizard. + +## 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 (workspaces directory, user name, timezone) + ├── Init.3 Security Posture + ├── 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 +``` + +--- + +## 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: 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:** 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 + +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: Identity ──────────────────────────╮ +│ │ +│ Your provider is configured. Now let's set up the agent. │ +│ │ +│ Your name (what the agent calls you): │ +│ ╭────────────────────────────────────────────────────────╮ │ +│ │ │ │ +│ ╰────────────────────────────────────────────────────────╯ │ +│ │ +│ Timezone (IANA name): │ +│ ╭────────────────────────────────────────────────────────╮ │ +│ │ America/Los_Angeles │ │ +│ ╰────────────────────────────────────────────────────────╯ │ +│ │ +│ Workspaces directory: │ +│ ╭────────────────────────────────────────────────────────╮ │ +│ │ ~/.netclaw/workspaces │ │ +│ ╰────────────────────────────────────────────────────────╯ │ +│ │ +│ [ Next ] [ Back ] [ Cancel ] │ +│ │ +│ Tab next · Enter activate · Esc cancel │ +╰─────────────────────────────────────────────────────────────╯ +``` + +**Transitions:** + +- `Next` → Init.3. +- `Back` → Init.1. +- `Cancel` → discard confirm → exit. + +**Validation:** User name required. Timezone validates against +`TimeZoneInfo.FindSystemTimeZoneById`. Workspaces directory must be a +valid local path. + +On completion, the flow can continue into the existing bot-assisted +identity conversation that regenerates `SOUL.md` and `TOOLING.md`. + +--- + +## Init.3 — Security Posture + +Reuses existing `SecurityPostureStepViewModel`. + +``` +╭─ Netclaw Setup — Step 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. │ +│ │ +│ Public │ +│ Open to untrusted users. Strict defaults and access │ +│ controls. │ +│ │ +│ [ 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. +- `Personal` proceeds directly to Init.5. +- `Team` and `Public` proceed to Init.4 (Enabled Features). +- `Back` → Init.2. + +**Shell mode remains global:** the posture step writes the global shell +default. It does not create per-audience shell settings. + +--- + +## Init.4 — Enabled Features + +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.5 — Post-flight + +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: 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 providers, models, │ +│ channels, webhooks, search, security, 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 ]` action instead of `[ Done ]`. The operator +returns to the previous applicable step to fix. + +## Init.E1 — Existing-install menu + +Rendered when `netclaw init` detects an existing install. + +``` +╭─ Existing Netclaw install detected ─────────────────────────╮ +│ │ +│ Choose what to do next. │ +│ │ +│ ▸ Redo identity setup │ +│ Open configuration editor │ +│ Start over from scratch │ +│ Cancel │ +│ │ +│ ↑/↓ navigate · Enter select · Esc cancel │ +╰─────────────────────────────────────────────────────────────╯ +``` + +## Init.E2 — Start-over scope chooser + +Rendered after `Start over from scratch`. + +``` +╭─ Start over from scratch ───────────────────────────────────╮ +│ │ +│ Choose reset scope. │ +│ │ +│ ▸ Reset setup only │ +│ Archive config, secrets, pairing/bootstrap state, and │ +│ identity files. Preserve DB, logs, projects, schedules, │ +│ environment, and skills. │ +│ │ +│ Full reset │ +│ Wipe the full Netclaw home except the binary payload. │ +│ │ +│ Cancel │ +│ │ +│ ↑/↓ navigate · Enter select · Esc cancel │ +╰─────────────────────────────────────────────────────────────╯ +``` + +## Init.E3 / Init.E4 — Double confirmation + +Both reset scopes require two explicit confirmations before mutation. +Default focus stays on the non-destructive option. 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..2eba2eacc --- /dev/null +++ b/docs/ui/TUI-004-search-config-progressive-disclosure-poc.md @@ -0,0 +1,352 @@ +# 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. +5. Keep quiet states quiet. + +## 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 +- 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. + +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. + +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` +- `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 │ +│ No additional setup required. │ +│ │ +│ ▸ 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 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 +╭─ 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 │ +╰─────────────────────────────────────────────────────────────╯ +``` + +If no Brave credential is currently stored, omit the `Existing key is +configured` helper line entirely. + +### 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`. + +### 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 + +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?" 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/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/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..4adbce18b --- /dev/null +++ b/openspec/changes/netclaw-config-command/design.md @@ -0,0 +1,165 @@ +## Context + +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 `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:** + +- 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. 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 + +- 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 + +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. 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 new file mode 100644 index 000000000..c1154a961 --- /dev/null +++ b/openspec/changes/netclaw-config-command/proposal.md @@ -0,0 +1,137 @@ +## Why + +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 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 domain-oriented config + dashboard, routed handoffs, leaf-editor hosting, generalized validation, + missing-install refusal, and coverage expectations. + +### Modified Capabilities + +- `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 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:** + +- 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 new file mode 100644 index 000000000..834b826a8 --- /dev/null +++ b/openspec/changes/netclaw-config-command/specs/feature-selection-wizard/spec.md @@ -0,0 +1,41 @@ +## MODIFIED Requirements + +### Requirement: Post-install runtime feature editing SHALL move to Enabled Features + +Post-install runtime feature editing SHALL move to +`netclaw config -> Security & Access -> Enabled Features`, not to Audience +Profiles. + +**Reason**: Runtime feature enablement is deployment-wide and remains a +separate concept from Security Posture and 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 config Enabled flags + +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: Personal posture default keeps all features enabled + +- **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 new file mode 100644 index 000000000..80e96c8a3 --- /dev/null +++ b/openspec/changes/netclaw-config-command/specs/netclaw-cli/spec.md @@ -0,0 +1,32 @@ +## ADDED Requirements + +### 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: 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 new file mode 100644 index 000000000..d13b43aa5 --- /dev/null +++ b/openspec/changes/netclaw-config-command/specs/netclaw-config-command/spec.md @@ -0,0 +1,205 @@ +## ADDED 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 SHALL be first-class config outcomes + +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: 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 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`. + +#### 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/changes/netclaw-config-command/tasks.md b/openspec/changes/netclaw-config-command/tasks.md new file mode 100644 index 000000000..277c9a8cd --- /dev/null +++ b/openspec/changes/netclaw-config-command/tasks.md @@ -0,0 +1,133 @@ +## 1. OpenSpec planning artifacts and traceability + +- [x] 1.1 Confirm proposal, design, and spec deltas reflect the + domain-oriented config IA and the locked ownership split. +- [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. +- [x] 1.3 Run `openspec validate netclaw-config-command --type change`. + +## 2. Command entry and refusal behavior + +- [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. +- [x] 2.3 Keep `--help` discoverable from `netclaw --help`. + +## 3. Root dashboard IA + +- [x] 3.1 Implement the root dashboard as domain navigation, not a flat + list of every leaf editor. +- [x] 3.2 Add these root entries: Inference Providers, Models, Channels, + Inbound Webhooks, Skill Sources, Search, Browser Automation, + Telemetry & Alerting, Security & Access. +- [x] 3.3 Add Quit and Run Full Doctor affordances at the root. + +## 4. Routed 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 + +- [ ] 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. +- [x] 15.6 `openspec validate netclaw-config-command --type change` + passes. 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..5300a82a1 --- /dev/null +++ b/openspec/changes/section-editor-abstraction/design.md @@ -0,0 +1,108 @@ +## Context + +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` 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:** + +- Defining the `netclaw config` IA. +- Making Identity editable from `netclaw config`. +- Forcing all schema sections into TUI editors. +- Byte-identical JSON preservation. + +## Decisions + +### D1. The abstraction is for leaf editors, not dashboard IA + +`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`. + +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. + +### 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`. + +### D4. Identity is synthetic and permanently init-owned in this branch + +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 + +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. 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 new file mode 100644 index 000000000..1c6ec8455 --- /dev/null +++ b/openspec/changes/section-editor-abstraction/proposal.md @@ -0,0 +1,99 @@ +## Why + +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 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 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 `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`: reusable leaf-editor contract for init and + config, including reentrancy, secret handling, semantic merge-on-save, + and audit obligations. + +### Modified Capabilities + +- `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 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 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 new file mode 100644 index 000000000..e425c0beb --- /dev/null +++ b/openspec/changes/section-editor-abstraction/specs/netclaw-onboarding/spec.md @@ -0,0 +1,43 @@ +## ADDED Requirements + +### Requirement: Init-owned editor re-entry SHALL use existing config state + +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 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/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..8fa3990b6 --- /dev/null +++ b/openspec/changes/section-editor-abstraction/specs/section-editor-abstraction/spec.md @@ -0,0 +1,106 @@ +## ADDED 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 SHALL preserve existing state rules + +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. + +#### 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 diff --git a/openspec/changes/section-editor-abstraction/tasks.md b/openspec/changes/section-editor-abstraction/tasks.md new file mode 100644 index 000000000..fe5e8a070 --- /dev/null +++ b/openspec/changes/section-editor-abstraction/tasks.md @@ -0,0 +1,100 @@ +## 1. OpenSpec planning artifacts and traceability + +- [x] 1.1 Confirm proposal, design, and spec deltas describe a leaf-editor + abstraction rather than a flat dashboard contract. +- [x] 1.2 Confirm the artifacts reflect the locked split: `init` owns + bootstrap and Identity; `config` owns post-install editing. +- [x] 1.3 Run `openspec validate section-editor-abstraction --type change` + and resolve issues. + +## 2. Core abstraction + +- [x] 2.1 Add `ISectionEditor` with `SectionId`, `DisplayName`, + `Category?`, `ShowInMenu`, `GetStatus`, `Summary`, + `RelevantDoctorChecks`, and `CreateEditor`. +- [x] 2.2 Add `SectionStatus`. +- [x] 2.3 Add `SectionContribution` with explicit field and secret + actions. +- [x] 2.4 Add `[NoDoctorChecks]` justification support where truly needed. + +## 3. Registry and exemption list + +- [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. +- [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 + +- [x] 4.1 Add single-step hosting to `WizardOrchestrator`. +- [x] 4.2 Ensure save exits and cancel exits work without linear step-list + navigation. +- [x] 4.3 Add unit tests for single-step save and cancel. + +## 5. Semantic merge-on-save plumbing + +- [x] 5.1 Refactor config writes to load existing config, apply + contributions, and preserve unrelated sections semantically. +- [x] 5.2 Refactor secret writes to preserve blank submissions, replace on + non-blank, and remove only on explicit delete. +- [x] 5.3 Preserve inactive values for exposure-mode and similar editors + when they are not the active leaf being changed. +- [x] 5.4 Add `ConfigFileHelper.SecretPresent(...)` without decrypting + stored values. + +## 6. ExistingConfig population + +- [x] 6.1 Populate `WizardContext.ExistingConfig` from on-disk config when + init enters an editor flow that needs existing state. +- [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 + +- [x] 7.1 Refactor Provider to implement `ISectionEditor` + (`ShowInMenu = false`; owned by init / routed provider command). +- [x] 7.2 Refactor Identity to implement `ISectionEditor` + (`ShowInMenu = false`; synthetic ID; init-owned). +- [x] 7.3 Refactor Security Posture to implement `ISectionEditor` + (`ShowInMenu = true`; reusable under `Security & Access`). +- [x] 7.4 Refactor Enabled Features to implement `ISectionEditor` + (`ShowInMenu = true`; separate from posture and audience profiles). +- [x] 7.5 Ensure each refactored editor declares meaningful validation + checks and produces `SectionContribution` output. + +## 8. Round-trip test harness + +- [x] 8.1 Add `SectionEditorTestBase<TEditor>` with semantic round-trip, + secret-preservation, and targeted update scenarios. +- [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 + +- [x] 9.1 Add `MenuRegistryAuditTests` for registered leaf editors. +- [x] 9.2 Require round-trip tests and validation declarations for every + registered leaf editor. +- [x] 9.3 Exempt `ShowInMenu = false` leaves from config smoke-tape + existence checks. +- [x] 9.4 Document that routed handoff entries are tested separately in the + config command change. + +## 10. Existing test suite preservation + +- [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 + +- [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/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..7185badd2 --- /dev/null +++ b/openspec/changes/simplify-netclaw-init/design.md @@ -0,0 +1,103 @@ +## Context + +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:** + +- 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:** + +- 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. 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 + +- 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 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. 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 new file mode 100644 index 000000000..094603797 --- /dev/null +++ b/openspec/changes/simplify-netclaw-init/proposal.md @@ -0,0 +1,80 @@ +## Why + +`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 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`: bootstrap-only init flow, explicit existing-install + menu, guarded scratch reset, and locked posture/enabled-features split. + +## Impact + +**Affected systems:** + +- CLI init entry handling. +- Init wizard step composition. +- Existing-install branching screens. +- Init smoke tapes and assertions. + +**Security and operational impact:** + +- 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 new file mode 100644 index 000000000..baa945ef0 --- /dev/null +++ b/openspec/changes/simplify-netclaw-init/specs/netclaw-onboarding/spec.md @@ -0,0 +1,103 @@ +## MODIFIED Requirements + +### Requirement: Guided onboarding + +`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 new file mode 100644 index 000000000..768418f5c --- /dev/null +++ b/openspec/changes/simplify-netclaw-init/tasks.md @@ -0,0 +1,63 @@ +## 1. OpenSpec planning artifacts and traceability + +- [ ] 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. 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 diff --git a/ralph-opencode.sh b/ralph-opencode.sh index 9b4677e65..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 @@ -224,6 +250,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 +302,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 +332,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,8 +367,9 @@ 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. + if ! opencode run --model "$MODEL" --variant "$VARIANT" "You are running RALPH iteration $i. ## Run Metadata (MUST USE) - RUN_ID: $RUN_ID @@ -333,56 +386,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 +462,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,13 +505,13 @@ 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" # 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" 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..e18a77d73 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 @@ -53,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 config-exposure config-posture config-features config-audience config-channels provider-add provider-rename config-search tui-cleanup) FULL_TAPES=("${LIGHT_TAPES[@]}") LIGHT_SCENARIOS=( @@ -73,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() { @@ -152,6 +156,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 +273,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 +299,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 +334,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.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.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/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/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..93f096971 100644 --- a/src/Netclaw.Cli.Tests/Mcp/McpToolPermissionsViewModelTests.cs +++ b/src/Netclaw.Cli.Tests/Mcp/McpToolPermissionsViewModelTests.cs @@ -28,59 +28,85 @@ 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 CycleServerDefault_StartingFromAuto_LandsOnDenyAfterTwoCycles() + public void InitializeForTests_AppliesRequestedInitialAudience() { - var vm = CreateVm(); - vm.InitializeForTests(new McpServerName("notion"), new[] { "create-pages", "search" }); - vm.SetSelectedAudienceForTests(TrustAudience.Personal); + var navigationState = new McpToolPermissionsNavigationState(); + navigationState.RequestInitialAudience(TrustAudience.Team); + var vm = CreateVm(navigationState); - vm.CycleServerDefault(); - Assert.Equal(ToolApprovalMode.Approval, vm.GetServerDefault()); - - vm.CycleServerDefault(); - Assert.Equal(ToolApprovalMode.Deny, vm.GetServerDefault()); + vm.InitializeForTests(new McpServerName("notion"), new[] { "create-pages" }); - vm.CycleServerDefault(); - Assert.Equal(ToolApprovalMode.Auto, vm.GetServerDefault()); + Assert.Equal(TrustAudience.Team, vm.SelectedAudience); } [Fact] - public void CycleToolOverride_FromInherit_CyclesThroughAllModes() + public void InitializeForTests_ThrowsForMalformedConfig() { var vm = CreateVm(); - vm.InitializeForTests(new McpServerName("notion"), new[] { "create-pages" }); + File.WriteAllText(_paths.NetclawConfigPath, "{ not json"); + + Assert.ThrowsAny<JsonException>(() => + vm.InitializeForTests(new McpServerName("notion"), new[] { "create-pages" })); + } + + public static TheoryData<bool, ToolApprovalMode[]> ServerDefaultCycles => new() + { + { false, [ToolApprovalMode.Approval, ToolApprovalMode.Deny, ToolApprovalMode.Auto] }, + { true, [ToolApprovalMode.Deny, ToolApprovalMode.Approval, ToolApprovalMode.Auto] } + }; + + public static TheoryData<bool, ToolApprovalMode[]> ToolOverrideCycles => new() + { + { false, [ToolApprovalMode.Auto, ToolApprovalMode.Approval, ToolApprovalMode.Deny] }, + { true, [ToolApprovalMode.Deny, ToolApprovalMode.Approval, ToolApprovalMode.Auto] } + }; + + [Theory] + [MemberData(nameof(ServerDefaultCycles))] + public void CycleServerDefault_CyclesThroughModes(bool reverse, ToolApprovalMode[] expectedModes) + { + var vm = CreateVm(); + 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] @@ -105,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") @@ -156,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() { @@ -254,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/ChannelsConfigNavigationTests.cs b/src/Netclaw.Cli.Tests/Tui/Config/ChannelsConfigNavigationTests.cs new file mode 100644 index 000000000..db8a9a247 --- /dev/null +++ b/src/Netclaw.Cli.Tests/Tui/Config/ChannelsConfigNavigationTests.cs @@ -0,0 +1,386 @@ +// ----------------------------------------------------------------------- +// <copyright file="ChannelsConfigNavigationTests.cs" company="Petabridge, LLC"> +// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com> +// </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; +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"] }, + "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" } + } + """); + } + + 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); + } + + [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_FirstTimeSlackBotToken_ShowsValidationError() + { + WriteEmptyChannelFiles(); + var app = CreateHeadlessApp(out var input, out var dashboardVm, out var getChannelsVm); + 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. + MoveToRotateCredentials(input); + input.EnqueueKey(ConsoleKey.Enter); + 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(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( + 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, + new FakeSlackProbe(), + new FakeDiscordProbe(), + new FakeMattermostProbe(), + 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..b8efbd528 --- /dev/null +++ b/src/Netclaw.Cli.Tests/Tui/Config/ChannelsConfigViewModelTests.cs @@ -0,0 +1,648 @@ +// ----------------------------------------------------------------------- +// <copyright file="ChannelsConfigViewModelTests.cs" company="Petabridge, LLC"> +// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com> +// </copyright> +// ----------------------------------------------------------------------- +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; +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 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); + _paths.EnsureDirectoriesExist(); + } + + public void Dispose() => _dir.Dispose(); + + [Fact] + public void Channels_editor_hosts_original_channel_picker_adapters() + { + using var vm = CreateViewModel(); + + var labels = vm.Step.Adapters.Select(static item => item.DisplayName).ToArray(); + + 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() + { + WriteChannelConfig(); + WriteChannelSecrets(); + using var vm = CreateViewModel(); + + var slack = vm.Step.GetAdapterViewModel<SlackStepViewModel>(ChannelType.Slack); + var mattermost = vm.Step.GetAdapterViewModel<MattermostStepViewModel>(ChannelType.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 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 + } + """); + 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"; + }); + + vm.Save(); + + 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 Save_disabled_existing_provider_preserves_dormant_fields_and_secrets() + { + WriteChannelConfig(); + WriteChannelSecrets(); + 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.Save(); + + Assert.False(vm.IsSaved.Value); + Assert.Equal("Slack bot token is required.", vm.Status.Value.Text); + } + + [Fact] + public void Back_from_saved_returns_to_channel_picker() + { + WriteChannelConfig(); + WriteChannelSecrets(); + using var vm = CreateViewModel(); + vm.Save(); + + vm.GoBack(); + + 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())); + } + + [Theory] + [MemberData(nameof(ResetConnectionCases))] + public void Reset_connection_deletes_config_section_and_secrets_immediately( + ChannelType type, + string configSection, + string[] secretPaths) + { + WriteAllChannelConfig(); + WriteAllChannelSecrets(); + using var vm = CreateViewModel(); + + ConfirmReset(vm, type); + + var config = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath); + Assert.False(ConfigFileHelper.TryGetPathValue(config, configSection, out _)); + var secrets = ConfigFileHelper.LoadJsonDict(_paths.SecretsPath); + 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] + [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]); + } + + [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) + { + 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 + { + 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, + """ + { + "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" + } + } + """); + } + + 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.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/ExposureModeConfigViewModelTests.cs b/src/Netclaw.Cli.Tests/Tui/Config/ExposureModeConfigViewModelTests.cs new file mode 100644 index 000000000..d911cfaa8 --- /dev/null +++ b/src/Netclaw.Cli.Tests/Tui/Config/ExposureModeConfigViewModelTests.cs @@ -0,0 +1,161 @@ +// ----------------------------------------------------------------------- +// <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 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() + { + 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/SearchConfigEditorPageTests.cs b/src/Netclaw.Cli.Tests/Tui/Config/SearchConfigEditorPageTests.cs new file mode 100644 index 000000000..904655d46 --- /dev/null +++ b/src/Netclaw.Cli.Tests/Tui/Config/SearchConfigEditorPageTests.cs @@ -0,0 +1,103 @@ +// ----------------------------------------------------------------------- +// <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_RendersActiveCheckboxAndConfiguredLegend() + { + 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 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}"); + } + + [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.Tests/Tui/Config/SearchConfigEditorViewModelTests.cs b/src/Netclaw.Cli.Tests/Tui/Config/SearchConfigEditorViewModelTests.cs new file mode 100644 index 000000000..bfabf81a3 --- /dev/null +++ b/src/Netclaw.Cli.Tests/Tui/Config/SearchConfigEditorViewModelTests.cs @@ -0,0 +1,327 @@ +// ----------------------------------------------------------------------- +// <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.Cli.Tui.Sections; +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 void Starts_on_provider_selection_screen() + { + using var vm = new SearchConfigEditorViewModel(_paths); + + Assert.Equal(SearchConfigEditorScreen.ProviderSelection, vm.CurrentScreen.Value); + Assert.Equal("duckduckgo", vm.CurrentBackendValue); + Assert.Null(vm.CurrentProviderField); + } + + [Fact] + public void Selecting_brave_moves_to_entry_state() + { + using var vm = new SearchConfigEditorViewModel(_paths); + + vm.BeginBackendSelection(); + vm.SelectBackendForEditing("brave"); + + Assert.Equal(SearchConfigEditorScreen.Entry, vm.CurrentScreen.Value); + Assert.Equal("brave", vm.CurrentBackendValue); + Assert.Equal("Search.BraveApiKey", vm.CurrentProviderField?.Path); + } + + [Fact] + public void Selecting_duckduckgo_enters_zero_config_workflow_state() + { + using var vm = new SearchConfigEditorViewModel(_paths); + + vm.BeginBackendSelection(); + vm.SelectBackendForEditing("duckduckgo"); + + Assert.Equal(SearchConfigEditorScreen.Entry, vm.CurrentScreen.Value); + Assert.Null(vm.CurrentProviderField); + } + + [Fact] + public void Selecting_zero_config_provider_keeps_workflow_clean_when_effective_value_is_unchanged() + { + using var vm = new SearchConfigEditorViewModel(_paths); + + vm.BeginBackendSelection(); + vm.SelectBackendForEditing("brave"); + vm.BeginBackendSelection(); + vm.SelectBackendForEditing("duckduckgo"); + + Assert.Equal(SearchConfigEditorScreen.Entry, vm.CurrentScreen.Value); + Assert.False(vm.IsDirty); + 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() + { + using var vm = new SearchConfigEditorViewModel(_paths, new StubHttpClientFactory(_ => + new HttpResponseMessage(HttpStatusCode.Unauthorized))); + + vm.SelectBackendForEditing("brave"); + vm.StageFieldValue("Search.BraveApiKey", "bad-key"); + + 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); + } + + [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); + 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] + 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 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 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() + { + using var vm = new SearchConfigEditorViewModel(_paths, new StubHttpClientFactory(_ => + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{\"web\":{\"results\":[]}}", Encoding.UTF8, "application/json"), + })); + + vm.SelectBackendForEditing("brave"); + vm.StageFieldValue("Search.BraveApiKey", "good-key"); + + await vm.SubmitCurrentConfigurationAsync(TestContext.Current.CancellationToken); + + Assert.Equal(SearchConfigEditorDialog.None, vm.ActiveDialog.Value); + Assert.Equal(SearchConfigEditorScreen.Saved, vm.CurrentScreen.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.SelectBackendForEditing("searxng"); + vm.StageFieldValue("Search.SearXngEndpoint", "https://search.example.com"); + vm.CommitFieldValue("Search.SearXngEndpoint"); + vm.OnDeactivating(); + vm.OnActivated(); + + Assert.Equal("searxng", vm.FieldValues["Search.Backend"].Value); + 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) + => 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/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/SecurityAccessNavigationTests.cs b/src/Netclaw.Cli.Tests/Tui/Config/SecurityAccessNavigationTests.cs new file mode 100644 index 000000000..79cacda1e --- /dev/null +++ b/src/Netclaw.Cli.Tests/Tui/Config/SecurityAccessNavigationTests.cs @@ -0,0 +1,127 @@ +// ----------------------------------------------------------------------- +// <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); + 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); + } + + private TerminaApplication CreateHeadlessApp( + out VirtualInputSource input, + out SecurityAccessViewModel securityVm, + out Func<McpToolPermissionsViewModel?> getMcpVm) + { + 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; + 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 new file mode 100644 index 000000000..918a24474 --- /dev/null +++ b/src/Netclaw.Cli.Tests/Tui/Config/SecurityAccessViewModelTests.cs @@ -0,0 +1,297 @@ +// ----------------------------------------------------------------------- +// <copyright file="SecurityAccessViewModelTests.cs" company="Petabridge, LLC"> +// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com> +// </copyright> +// ----------------------------------------------------------------------- +using Netclaw.Cli.Config; +using Netclaw.Cli.Mcp; +using Netclaw.Cli.Tui.Config; +using Netclaw.Cli.Tests.Tui.Wizard; +using Netclaw.Configuration; +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 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.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 _)); + Assert.Equal("Customized", vm.AudienceOverrideMarker(TrustAudience.Team)); + Assert.Equal("", vm.AudienceOverrideMarker(TrustAudience.Public)); + 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() + { + 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] + 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() + { + 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() + { + 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 new file mode 100644 index 000000000..0e7b98732 --- /dev/null +++ b/src/Netclaw.Cli.Tests/Tui/ConfigDashboardViewModelTests.cs @@ -0,0 +1,109 @@ +// ----------------------------------------------------------------------- +// <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 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 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() + { + 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); + } + + [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(item => item.Label == label)); + + Assert.Contains("not implemented yet", vm.StatusMessage.Value, StringComparison.OrdinalIgnoreCase); + } +} 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.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.Tests/Tui/Sections/ConfigEditorSessionTests.cs b/src/Netclaw.Cli.Tests/Tui/Sections/ConfigEditorSessionTests.cs new file mode 100644 index 000000000..835f7a773 --- /dev/null +++ b/src/Netclaw.Cli.Tests/Tui/Sections/ConfigEditorSessionTests.cs @@ -0,0 +1,167 @@ +// ----------------------------------------------------------------------- +// <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 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() + { + 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/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..0587b39f3 --- /dev/null +++ b/src/Netclaw.Cli.Tests/Tui/Wizard/MenuRegistryAuditTests.cs @@ -0,0 +1,104 @@ +// ----------------------------------------------------------------------- +// <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(["exposure-mode", "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), + ["exposure-mode"] = nameof(ExposureModeSectionEditorTests) + }; + + 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>() + .AddSectionEditor<ExposureModeStepViewModel>(); + 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..9a0e8eb00 --- /dev/null +++ b/src/Netclaw.Cli.Tests/Tui/Wizard/SectionEditorLeafTests.cs @@ -0,0 +1,163 @@ +// ----------------------------------------------------------------------- +// <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_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() + { + 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"); + } +} + +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_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); + + 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); + 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.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..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"; @@ -159,37 +152,39 @@ public void PersonalPosture_WithIdentityAndWorkspaces_ConfigMatchesChoices() } [Fact] - public void PersonalPosture_ExposureModeLocal_NoDaemonSection() + public void ExistingConfig_SearchEdit_PreservesUnrelatedSections() { - 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"); + 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" } + } + """); - var config = AssembleConfig(steps); + 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 } + }; - var daemon = GetSection(config, "Daemon"); - Assert.Equal("tailscale-funnel", daemon["ExposureMode"]); + using var orchestrator = new WizardOrchestrator(steps, context, singleStepMode: true); + orchestrator.WriteConfig(); - // Webhooks: both the feature gate and the exposure step contribute - AssertSectionEnabled(config, "Webhooks", true); + var config = LoadWrittenConfig(); + Assert.True(config.ContainsKey("Slack")); + Assert.True(config.ContainsKey("Daemon")); + Assert.Equal("brave", GetSection(config, "Search")["Backend"]); } // ── Helpers ── @@ -201,8 +196,7 @@ private static List<IWizardStepViewModel> BuildCoreSteps() new SecurityPostureStepViewModel(), new FeatureSelectionStepViewModel(), new SearchStepViewModel(), - new IdentityStepViewModel(), - new ExposureModeStepViewModel() + new IdentityStepViewModel() ]; } @@ -233,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); @@ -252,6 +239,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..62f4b9f5d 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; @@ -101,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> @@ -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/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/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..864598e67 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(); } @@ -75,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(); @@ -120,6 +141,7 @@ public void SelectServer(McpServerName serverName) /// </summary> internal void InitializeForTests(McpServerName serverName, IEnumerable<string> tools) { + ApplyPendingNavigationState(); SelectedServer = serverName.Value; DiscoveredTools.Clear(); DiscoveredTools.AddRange(tools); @@ -441,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); @@ -466,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) @@ -608,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); } @@ -640,7 +676,20 @@ private static bool IsServerAllowed(McpServerName serverName, ToolAudienceProfil _ => "Personal" }; - public void RequestQuit() => Shutdown(); + private static TrustAudience AudienceFromName(string audienceName) => audienceName switch + { + "Public" => TrustAudience.Public, + "Team" => TrustAudience.Team, + _ => TrustAudience.Personal + }; + + public void RequestQuit() + { + if (_navigation?.TryGoBack() == true) + return; + + Shutdown(); + } public void GoBack() { @@ -664,25 +713,32 @@ private void NotifyStateChanged() RequestRedraw(); } + private void ApplyPendingNavigationState() + { + if (_navigationState?.ConsumeInitialAudience() is { } audience) + 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); - - if (!doc.RootElement.TryGetProperty("Tools", out var toolsSection)) - return new ToolConfig(); + var text = File.ReadAllText(_paths.NetclawConfigPath); + using var doc = JsonDocument.Parse(text); - 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/Program.cs b/src/Netclaw.Cli/Program.cs index 9a22c1c55..45af33ea6 100644 --- a/src/Netclaw.Cli/Program.cs +++ b/src/Netclaw.Cli/Program.cs @@ -15,16 +15,21 @@ 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; using Netclaw.Cli.Doctor; using Netclaw.Cli.Mcp; +using Netclaw.Cli.Mattermost; using Netclaw.Cli.Reminder; using Netclaw.Cli.Secrets; 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; @@ -98,7 +103,10 @@ static async Task RunAsync(string[] args) { if (args.Length > 1 && IsHelpToken(args[1])) { - WriteDoctorHelp(); + if (mode is "init") + WriteInitHelp(); + else + WriteDoctorHelp(); return; } @@ -124,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(); } @@ -152,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(); @@ -159,6 +169,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) @@ -207,7 +222,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; } @@ -438,7 +453,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; } @@ -641,6 +656,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); @@ -648,7 +665,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; } @@ -706,7 +724,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; } @@ -735,7 +754,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; } @@ -764,7 +784,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; } @@ -788,7 +809,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; } @@ -838,10 +860,85 @@ 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.AddSingleton<McpToolPermissionsNavigationState>(); + builder.Services.AddSingleton<TuiNavigation>(); + 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( + 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.Services + .AddSectionEditor<SecurityPostureStepViewModel>() + .AddSectionEditor<FeatureSelectionStepViewModel>() + .AddSectionEditor<ExposureModeStepViewModel>(); + 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<ChannelsConfigPage, ChannelsConfigViewModel>("/channels"); + 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(); + var navigationState = host.Services.GetRequiredService<ConfigDashboardNavigationState>(); + await RunTerminaHostAsync(host); + + 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.AddHttpClient<IMattermostProbe, MattermostProbe>(); + 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; } @@ -1027,7 +1124,7 @@ static async Task RunAsync(string[] args) return; } - var app = webBuilder.Build(); + using var app = webBuilder.Build(); await RunTerminaHostAsync(app); } @@ -1043,7 +1140,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)."); @@ -1053,7 +1154,8 @@ static async Task RunTerminaHostAsync(IHost host) return; } - await host.RunAsync(); + await host.StartAsync(); + await host.WaitForShutdownAsync(); } static void WriteDaemonResult(DaemonResult result) @@ -1092,7 +1194,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(); @@ -1139,6 +1241,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/ChannelsConfigPage.cs b/src/Netclaw.Cli/Tui/Config/ChannelsConfigPage.cs new file mode 100644 index 000000000..286ea0c49 --- /dev/null +++ b/src/Netclaw.Cli/Tui/Config/ChannelsConfigPage.cs @@ -0,0 +1,771 @@ +// ----------------------------------------------------------------------- +// <copyright file="ChannelsConfigPage.cs" company="Petabridge, LLC"> +// 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; +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? _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.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() + => NetclawTuiChrome.BuildPageFrame("Channels", 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) + { + 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 BuildAdapterMenu() + { + var layout = Layouts.Vertical() + .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.GetManagementMenuItems(); + for (var i = 0; i < items.Count; i++) + { + var item = items[i]; + var focused = i == ViewModel.ManagementMenuIndex; + layout = layout.WithChild(Row( + $"{FocusPrefix(focused)}{item.Label,-36} {item.Description}", + focused)); + } + + return layout; + } + + 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 label = ViewModel.EditingAudienceLabel ?? "channel"; + var id = ViewModel.EditingAudienceId ?? string.Empty; + var layout = Layouts.Vertical() + .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)); + + 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++) + { + var audience = ChannelsConfigViewModel.AudienceOptions[i]; + var focused = i == ViewModel.AudienceSelectionIndex; + layout = layout.WithChild(Row( + $"{FocusPrefix(focused)}{AudienceLabel(audience),-10} {AudienceDescription(audience)}", + focused)); + } + + 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(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) + Focus.SetFocus(input); + + 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 immediately.")) + .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 writes immediately when confirmed.", + _ => 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.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(() => + { + 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); + } + + public override bool HandlePageInput(ConsoleKeyInfo keyInfo) + { + if (base.HandlePageInput(keyInfo)) + return true; + + return HandleKeyInfo(keyInfo); + } + + private bool HandleKeyInfo(ConsoleKeyInfo keyInfo) + { + if (keyInfo.Key == ConsoleKey.Q && keyInfo.Modifiers.HasFlag(ConsoleModifiers.Control)) + { + ViewModel.RequestQuit(); + 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.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; + } + + 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 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.MoveAudienceSelection(-1); + break; + case ConsoleKey.DownArrow: + ViewModel.MoveAudienceSelection(1); + break; + case ConsoleKey.Enter: + 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]; + var input = EnsureCredentialInput(field); + 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, + SetStatusMessage = message => ViewModel.Status.Value = new ConfigStatusMessage(message, ConfigStatusTone.Error), + }; + + 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 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); + } + + 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 new file mode 100644 index 000000000..5f5b4b794 --- /dev/null +++ b/src/Netclaw.Cli/Tui/Config/ChannelsConfigViewModel.cs @@ -0,0 +1,1913 @@ +// ----------------------------------------------------------------------- +// <copyright file="ChannelsConfigViewModel.cs" company="Petabridge, LLC"> +// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com> +// </copyright> +// ----------------------------------------------------------------------- +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.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 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(); + private readonly WizardContext _context; + private readonly HashSet<ChannelType> _knownProviders; + private readonly Dictionary<ChannelType, Dictionary<string, TrustAudience>> _channelAudiences = []; + 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, + 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) + { + DoneActionText = "save channel settings", + PreserveDisabledAdapterDrafts = true + }; + _context = new WizardContext + { + Paths = paths, + Registry = new ProviderDescriptorRegistry([]), + RequestRedraw = RequestRedraw, + ExistingConfig = LoadExistingConfig(paths), + SelectedPosture = LoadDeploymentPosture(paths) + }; + + Step.OnEnter(_context, NavigationDirection.Forward); + var draft = _mapper.Load(paths); + _knownProviders = [.. draft.KnownProviders]; + LoadAudienceDrafts(draft); + _mapper.ApplyToStep(Step, draft); + } + + 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; } + + internal bool ShutdownRequestedForTest { get; private set; } + + 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; } = + [ + TrustAudience.Personal, + TrustAudience.Team, + TrustAudience.Public + ]; + + 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(); + } + + return; + } + + _ = SaveFromInputAsync(); + } + + public void GoBack() + { + 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 void Save() + => SaveAsync().GetAwaiter().GetResult(); + + public async Task SaveAsync(CancellationToken ct = default) + { + var validation = ValidateCurrentStep(); + if (validation.HasErrors) + { + Status.Value = BuildValidationErrorStatus(validation, "Fix channel validation errors before saving."); + RequestRedraw(); + 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, + _knownProviders, + _channelAudiences, + _context.SelectedPosture ?? DeploymentPosture.Personal)); + 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); + IsSaved.Value = true; + Screen.Value = ChannelsConfigScreen.Picker; + Status.Value = new ConfigStatusMessage("Channels saved.", ConfigStatusTone.Success); + 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) + return false; + + var type = Step.SelectedAdapterType; + if (!Step.IsAdapterKnown(type)) + return false; + + OpenAdapterManagement(type); + return true; + } + + internal void OpenAdapterManagement(ChannelType type) + { + _activeAdapterType = type; + _managementMenuIndex = 0; + Screen.Value = ChannelsConfigScreen.AdapterMenu; + Status.Value = new ConfigStatusMessage(string.Empty, ConfigStatusTone.Neutral); + NotifyContentChanged(); + } + + private void OpenChannelPermissionsAfterInitialSetup(ChannelType type) + { + _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); + } + + internal IReadOnlyList<ChannelsManagementMenuItem> GetManagementMenuItems() + { + 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 MoveManagementMenu(int delta) + { + _managementMenuIndex = Clamp(_managementMenuIndex + delta, GetManagementMenuItems().Count); + NotifyContentChanged(); + } + + internal void ActivateManagementMenuItem() + { + var item = GetManagementMenuItems()[_managementMenuIndex]; + switch (item.Action) + { + 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; + } + + _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; + + 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() + { + var issue = ValidateCredentialDrafts(); + if (issue is not null) + { + Status.Value = new ConfigStatusMessage(issue.Message, ConfigStatusTone.Error); + NotifyContentChanged(); + return; + } + + 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(); + } + + 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); + 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); + NotifyContentChanged(); + } + + internal void ApplyResetConfirmation() + { + if (_resetConfirmIndex == 0) + { + Screen.Value = ChannelsConfigScreen.AdapterMenu; + NotifyContentChanged(); + return; + } + + 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; + IsSaved.Value = true; + Status.Value = new ConfigStatusMessage($"{resetName} reset saved.", ConfigStatusTone.Success); + NotifyContentChanged(); + } + + public void RequestQuit() + { + ShutdownRequestedForTest = true; + Shutdown(); + } + + public override void 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 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(); + + 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) + return false; + + try + { + return _navigation.TryGoBack(); + } + catch (InvalidOperationException) + { + return false; + } + } + + private void NotifyContentChanged() + { + OnStepContentChanged?.Invoke(); + RequestRedraw(); + } + + 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; + } + + 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); + +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); + var secrets = ConfigFileHelper.LoadJsonDict(paths.SecretsPath); + var draft = new ChannelsConfigDraft + { + Slack = LoadSlack(paths, config, secrets), + Discord = LoadDiscord(paths, config, secrets), + Mattermost = LoadMattermost(paths, config, secrets) + }; + + 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; + } + + 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); + } + + internal SectionContribution BuildContribution( + ChannelPickerStepViewModel step, + IReadOnlySet<ChannelType> knownProviders, + IReadOnlyDictionary<ChannelType, Dictionary<string, TrustAudience>> channelAudiences, + DeploymentPosture posture) + { + var fields = new List<SectionFieldAction>(); + var secrets = new List<SectionSecretAction>(); + + AddSlackContribution( + fields, + secrets, + step.GetAdapterViewModel<SlackStepViewModel>(ChannelType.Slack), + knownProviders.Contains(ChannelType.Slack), + channelAudiences, + posture); + AddDiscordContribution( + fields, + secrets, + step.GetAdapterViewModel<DiscordStepViewModel>(ChannelType.Discord), + knownProviders.Contains(ChannelType.Discord), + channelAudiences, + posture); + AddMattermostContribution( + fields, + secrets, + step.GetAdapterViewModel<MattermostStepViewModel>(ChannelType.Mattermost), + knownProviders.Contains(ChannelType.Mattermost), + channelAudiences, + 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, + 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") + }; + } + + 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") + }; + } + + 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 + { + 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") + }; + } + + 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); + } + + 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 static void AddSlackContribution( + List<SectionFieldAction> fields, + List<SectionSecretAction> secrets, + SlackStepViewModel vm, + bool knownProvider, + IReadOnlyDictionary<ChannelType, Dictionary<string, TrustAudience>> channelAudiences, + DeploymentPosture posture) + { + 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, + DeploymentPosture posture) + { + 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, + DeploymentPosture posture) + { + 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 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 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 + ? 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) + { + 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 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) + 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)]; + } + + 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))]; + } + + if (value is string[] stringValues) + return [.. stringValues.Where(static item => !string.IsNullOrWhiteSpace(item))]; + + throw new InvalidOperationException($"Configuration value '{path}' must be an array of strings."); + } + + private static Dictionary<string, TrustAudience> GetChannelAudiences(Dictionary<string, object> config, string path) + { + if (!ConfigFileHelper.TryGetPathValue(config, path, out var value) || value is null) + return []; + + 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 bool HasSecret(NetclawPaths paths, Dictionary<string, object> secrets, string path) + { + if (!ConfigFileHelper.TryGetPathValue(secrets, path, out var value)) + return false; + + 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> + { + channelCount > 0 + ? Pluralize(channelCount, "channel", "channels") + : draft.AllowDirectMessages ? "DMs only" : "no channels" + }; + + if (userCount > 0) + parts.Add(Pluralize(userCount, "user", "users")); + + return string.Join(", ", parts); + } + + 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? JoinOrNull(IReadOnlyList<string> values) + => values.Count == 0 ? null : string.Join(", ", values); + + 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); +} + +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/Config/ChannelsEditorModel.cs b/src/Netclaw.Cli/Tui/Config/ChannelsEditorModel.cs new file mode 100644 index 000000000..94b1232cd --- /dev/null +++ b/src/Netclaw.Cli/Tui/Config/ChannelsEditorModel.cs @@ -0,0 +1,216 @@ +// ----------------------------------------------------------------------- +// <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 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 +{ + 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/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/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..f31e13438 --- /dev/null +++ b/src/Netclaw.Cli/Tui/Config/SearchConfigEditorPage.cs @@ -0,0 +1,376 @@ +// ----------------------------------------------------------------------- +// <copyright file="SearchConfigEditorPage.cs" company="Petabridge, LLC"> +// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com> +// </copyright> +// ----------------------------------------------------------------------- +using R3; +using Netclaw.Cli.Tui; +using Netclaw.Cli.Tui.Workflow; +using Termina.Extensions; +using Termina.Layout; +using Termina.Reactive; +using Termina.Rendering; +using Termina.Terminal; + +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 ActiveSelectionList<ConfigEnumOption>? _providerList; + private bool _providerSelectionSynced; + + public override void OnNavigatedTo() + { + base.OnNavigatedTo(); + + ViewModel.ActiveDialog.Subscribe(_ => _contentNode?.Invalidate()) + .DisposeWith(Subscriptions); + 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() + => NetclawTuiChrome.BuildPageFrame("Search", BuildInnerLayout()); + + private ILayoutNode BuildInnerLayout() + => Layouts.Vertical() + .WithSpacing(1) + .WithChild(BuildContent()) + .WithChild(BuildStatusBar()) + .WithChild(BuildKeyBindings()); + + private LayoutNode BuildContent() + { + _contentNode = new DynamicLayoutNode(() => + { + _contentSubscriptions.Clear(); + _dialogList = null; + + if (ViewModel.ActiveDialog.Value == SearchConfigEditorDialog.ProbeWarning) + return BuildProbeWarningDialog(); + + return ViewModel.CurrentScreen.Value switch + { + SearchConfigEditorScreen.ProviderSelection => BuildProviderSelectionScreen(), + SearchConfigEditorScreen.Entry => BuildEntryScreen(), + SearchConfigEditorScreen.Validating => BuildValidatingScreen(), + SearchConfigEditorScreen.Saved => BuildSavedScreen(), + _ => Layouts.Empty(), + }; + }); + + return _contentNode; + } + + private ILayoutNode BuildProviderSelectionScreen() + { + if (!_providerSelectionSynced) + { + SyncProviderIndexToCurrentBackend(); + _providerSelectionSynced = true; + } + + return WorkflowViewComponents.BuildSelectionScreen( + heading: "Choose the backend Netclaw uses for web search.", + selector: EnsureProviderList().AsLayout(), + legend: ActiveSelectionList<ConfigEnumOption>.BuildLegend("active backend", "backend has saved setup"), + supportText: ViewModel.GetProviderDescription(EnsureProviderList().FocusedOption.Value)); + } + + private ILayoutNode BuildEntryScreen() + { + var field = ViewModel.CurrentProviderField; + + if (field is null) + { + 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(); + + 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 WorkflowViewComponents.BuildValidatingScreen( + heading: "Validating Search configuration...", + message: $"{frame} {ViewModel.GetValidatingMessage()}", + supportText: "This may take a few seconds."); + } + + private ILayoutNode BuildSavedScreen() + => WorkflowViewComponents.BuildSavedScreen( + successText: ViewModel.GetSavedMessage(), + nextStepText: ViewModel.GetSavedNextStepText()); + + 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() + { + var options = new List<string> + { + "Retry validation", + "Back to edit", + "Save anyway", + }; + + _dialogList = Layouts.SelectionList(options) + .WithMode(SelectionMode.Single) + .WithHighlightColors(Color.Black, Color.Yellow); + _dialogList.OnFocused(); + + _dialogList.SelectionConfirmed + .Subscribe(async selected => + { + if (selected.Count == 0) + return; + + switch (selected[0]) + { + case "Save anyway": + ViewModel.SaveWithoutProbeOverride(); + break; + case "Retry validation": + ViewModel.DismissDialog(); + await ViewModel.SubmitCurrentConfigurationAsync(); + break; + default: + ViewModel.DismissDialog(); + break; + } + }) + .DisposeWith(_contentSubscriptions); + + var message = ViewModel.LastProbeResult?.Message ?? "Search validation failed."; + return NetclawTuiChrome.BuildPanel( + "Search Validation Warning", + Layouts.Vertical() + .WithSpacing(1) + .WithChild(new TextNode(" Netclaw could not complete a live search using this configuration.") + .WithForeground(Color.White)) + .WithChild(new TextNode($" {message}").WithForeground(Color.Yellow)) + .WithChild(_dialogList), + Color.Yellow); + } + + private LayoutNode BuildStatusBar() + => ViewModel.Status + .Select(status => string.IsNullOrWhiteSpace(status.Text) + ? Layouts.Empty() + : NetclawTuiChrome.BuildStatusLine(status.Text, ToColor(status.Tone))) + .AsLayout() + .Height(1); + + private LayoutNode BuildKeyBindings() + { + 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 => " [Enter] Settings Areas [Esc] Review backends [Ctrl+Q] Quit", + _ => " [Ctrl+Q] Quit", + }; + + return NetclawTuiChrome.BuildKeyHintLine(text); + } + + public override bool HandlePageInput(ConsoleKeyInfo keyInfo) + { + if (keyInfo.Key == ConsoleKey.Q && keyInfo.Modifiers.HasFlag(ConsoleModifiers.Control)) + { + ViewModel.RequestQuit(); + return true; + } + + if (keyInfo.Key == ConsoleKey.Escape) + { + if (ViewModel.ActiveDialog.Value != SearchConfigEditorDialog.None) + { + ViewModel.DismissDialog(); + _contentNode?.Invalidate(); + return true; + } + + if (ViewModel.CurrentScreen.Value == SearchConfigEditorScreen.Entry) + { + BeginProviderSelection(); + 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); + return true; + } + + if (ViewModel.CurrentScreen.Value == SearchConfigEditorScreen.ProviderSelection) + { + EnsureProviderList().HandleInput(keyInfo); + return true; + } + + if (ViewModel.CurrentScreen.Value == SearchConfigEditorScreen.Saved) + { + if (keyInfo.Key == ConsoleKey.Enter) + ViewModel.NavigateBack(); + + return true; + } + + if (ViewModel.CurrentScreen.Value == SearchConfigEditorScreen.Entry) + { + if (keyInfo.Key == ConsoleKey.Enter) + { + StageActiveInput(); + _ = ViewModel.SubmitCurrentConfigurationAsync(); + return true; + } + + if (_textInput is not null) + { + _textInput.HandleInput(keyInfo); + ViewModel.StageFieldValue(_textInputFieldPath!, _textInput.Text); + } + + ViewModel.RequestRedraw(); + return true; + } + + 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 + .Select((option, idx) => (option, idx)) + .FirstOrDefault(entry => string.Equals(entry.option.Value, ViewModel.CurrentBackendValue, StringComparison.OrdinalIgnoreCase)) + .idx; + + EnsureProviderList().SetFocusedIndex(index, notify: false); + } + + private void SelectProviderForEditing(ConfigEnumOption option) + { + ViewModel.SelectBackendForEditing(option.Value); + ResetEntryInput(); + _contentNode?.Invalidate(); + ViewModel.RequestRedraw(); + } + + private void ResetEntryInput() + { + _textInput = null; + _textInputFieldPath = null; + } + + 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); + if (!string.IsNullOrEmpty(_textInput.Text)) + _textInput.HandleInput(new ConsoleKeyInfo('\0', ConsoleKey.End, shift: false, alt: false, control: false)); + + return _textInput; + } + + private bool IsConfigured(string backend) + => backend switch + { + "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 + { + 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..3ce92509e --- /dev/null +++ b/src/Netclaw.Cli/Tui/Config/SearchConfigEditorViewModel.cs @@ -0,0 +1,560 @@ +// ----------------------------------------------------------------------- +// <copyright file="SearchConfigEditorViewModel.cs" company="Petabridge, LLC"> +// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com> +// </copyright> +// ----------------------------------------------------------------------- +using System.Net.Http; +using System.Threading; +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 enum SearchConfigEditorScreen +{ + ProviderSelection, + Entry, + Validating, + Saved, +} + +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 SearchSectionSpec _spec; + private readonly NetclawPaths _paths; + private readonly SearchEditorPersistenceMapper _mapper; + private readonly SearchEditorValidationAdapter _validator; + private readonly IHttpClientFactory? _httpClientFactory; + private readonly TimeProvider _timeProvider; + private SearchEditorModel _model; + private SearchEditorValidationResult _validation = SearchEditorValidationResult.Empty; + private SearchProbeResult? _lastProbeResult; + private CancellationTokenSource? _validationSpinnerCts; + + public IReadOnlyList<ProjectedConfigField> Fields => _spec.Fields; + + public Dictionary<string, ReactiveProperty<string>> FieldValues { get; } = new(StringComparer.Ordinal); + + internal Action<string>? RouteRequested { get; set; } + internal bool ShutdownRequestedForTest { get; private set; } + + public SearchConfigEditorViewModel( + NetclawPaths paths, + IHttpClientFactory? httpClientFactory = null, + TimeProvider? timeProvider = null) + { + _spec = new SearchSectionSpec(); + _paths = paths; + _httpClientFactory = httpClientFactory; + _timeProvider = timeProvider ?? TimeProvider.System; + _mapper = new SearchEditorPersistenceMapper(); + _validator = new SearchEditorValidationAdapter(); + _model = _mapper.Load(paths); + + foreach (var field in Fields) + FieldValues[field.Path] = new ReactiveProperty<string>(GetCurrentFieldValue(field.Path)); + + 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.ProviderSelection); + ValidationSpinnerTick = new ReactiveProperty<int>(0); + Revalidate(); + } + + public ReactiveProperty<ConfigStatusMessage> Status { get; } + 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; + public string CurrentBackendValue => _model.Backend.ToWireValue(); + public string CurrentBackendLabel => _spec.GetBackendLabel(_model.Backend); + + public IReadOnlyList<ConfigEnumOption> BackendOptions { get; } = + [ + new("duckduckgo", "DuckDuckGo"), + new("brave", "Brave"), + new("searxng", "SearXng (self-hosted)") + ]; + + public ProjectedConfigField? CurrentProviderField => _spec.GetProviderField(_model); + + public bool IsCurrentBackendConfigured => _model.Backend switch + { + SearchBackend.Brave => HasEffectiveBraveKey(), + SearchBackend.SearXng => !string.IsNullOrWhiteSpace(_model.SearXng.Endpoint), + _ => 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(); + foreach (var value in FieldValues.Values) + value.Dispose(); + + Status.Dispose(); + ValidationSummary.Dispose(); + ActiveDialog.Dispose(); + CurrentScreen.Dispose(); + ValidationSpinnerTick.Dispose(); + base.Dispose(); + } + + public SearchFieldCommitResult CommitField(string path, string? value) + { + StageFieldValue(path, value); + var candidate = CloneModel(_model); + ApplyFieldValue(candidate, path, value); + + var candidateValidation = _validator.Validate(candidate); + var issues = candidateValidation.IssuesFor(path); + + if (issues.Count > 0) + { + Status.Value = new ConfigStatusMessage(issues[0].Message, ConfigStatusTone.Error); + RequestRedraw(); + return SearchFieldCommitResult.Invalid(issues); + } + + _model = candidate; + SyncFieldValue(path); + ClearTransientProbeState(); + Revalidate(); + Status.Value = new ConfigStatusMessage(string.Empty, ConfigStatusTone.Neutral); + RequestRedraw(); + return SearchFieldCommitResult.Ok; + } + + 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 GetEditorSeed(ProjectedConfigField field) + => FieldValues.TryGetValue(field.Path, out var property) + ? property.Value + : GetCurrentFieldValue(field.Path); + + 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() + => _model.Backend switch + { + 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() + => _model.Backend switch + { + SearchBackend.Brave when !HasEffectiveBraveKey() => ConfigStatusTone.Warning, + SearchBackend.SearXng when string.IsNullOrWhiteSpace(_model.SearXng.Endpoint) => ConfigStatusTone.Warning, + _ => ConfigStatusTone.Neutral, + }; + + public string? GetCurrentProviderSupportText() + => _model.Backend switch + { + 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) + => 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() + { + 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); + ActiveDialog.Value = SearchConfigEditorDialog.None; + CurrentScreen.Value = SearchConfigEditorScreen.Entry; + RequestRedraw(); + } + + public void ReturnToSummary() + { + BeginBackendSelection(); + RequestRedraw(); + } + + public void DismissDialog() + { + ActiveDialog.Value = SearchConfigEditorDialog.None; + Status.Value = new ConfigStatusMessage(string.Empty, ConfigStatusTone.Neutral); + RequestRedraw(); + } + + public async Task<bool> SubmitCurrentConfigurationAsync(CancellationToken ct = default) + { + if (CurrentProviderField is { } field) + { + 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; + } + } + + return await RunDynamicValidationAsync(persistOnSuccess: true, ct); + } + + public async Task TestCurrentConfigurationAsync(CancellationToken ct = default) + { + Revalidate(); + if (_validation.HasErrors) + { + Status.Value = BuildValidationErrorStatus( + "Fix structural validation errors before testing this search configuration."); + CurrentScreen.Value = SearchConfigEditorScreen.Entry; + RequestRedraw(); + return; + } + + await RunDynamicValidationAsync(persistOnSuccess: false, ct); + } + + public async Task SaveAsync(CancellationToken ct = default) + => await SubmitCurrentConfigurationAsync(ct); + + public void SaveWithoutProbeOverride() + { + CancelValidationSpinner(); + _mapper.Save(_paths, _model); + ReloadPersistedDraft(); + ActiveDialog.Value = SearchConfigEditorDialog.None; + CurrentScreen.Value = SearchConfigEditorScreen.Saved; + Status.Value = new ConfigStatusMessage("Saved Search settings.", ConfigStatusTone.Success); + RequestRedraw(); + } + + public void ResetDraft() + { + ReloadPersistedDraft(); + Status.Value = new ConfigStatusMessage("Reverted unsaved Search edits.", ConfigStatusTone.Neutral); + RequestRedraw(); + } + + 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"); + } + + public void RequestQuit() + { + CancelValidationSpinner(); + ShutdownRequestedForTest = true; + Shutdown(); + } + + private void Revalidate() + { + _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() + { + _lastProbeResult = null; + Status.Value = new ConfigStatusMessage(string.Empty, ConfigStatusTone.Neutral); + } + + 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); + 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}'."); + } + } + + private string GetCurrentFieldValue(string path) + => path switch + { + "Search.Backend" => _model.Backend.ToWireValue(), + "Search.BraveApiKey" => _model.Brave.ApiKeyDraft ?? string.Empty, + "Search.SearXngEndpoint" => _model.SearXng.Endpoint ?? string.Empty, + _ => string.Empty, + }; + + private void SyncFieldValue(string path) + { + if (FieldValues.TryGetValue(path, out var property)) + property.Value = GetCurrentFieldValue(path); + } + + private void SyncAllFieldValues() + { + foreach (var field in Fields) + 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 + { + ISearchBackend searchBackend = _model.Backend switch + { + SearchBackend.Brave => new BraveSearchBackend( + GetEffectiveBraveApiKey(), + CreateHttpClient(), + _timeProvider), + SearchBackend.SearXng => new SearXngBackend( + _model.SearXng.Endpoint ?? 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(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; + + private bool ComputeIsDirty() + { + 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 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 + { + "brave" => SearchBackend.Brave, + "searxng" => SearchBackend.SearXng, + _ => SearchBackend.DuckDuckGo, + }; + + private static string? Normalize(string? value) + => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + +} diff --git a/src/Netclaw.Cli/Tui/Config/SearchEditorModel.cs b/src/Netclaw.Cli/Tui/Config/SearchEditorModel.cs new file mode 100644 index 000000000..1352c0daa --- /dev/null +++ b/src/Netclaw.Cli/Tui/Config/SearchEditorModel.cs @@ -0,0 +1,182 @@ +// ----------------------------------------------------------------------- +// <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.Cli.Tui.Sections; +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 session = new ConfigEditorSession(paths); + session.Apply(BuildContribution(model)); + session.Save(); + } + + internal SectionContribution BuildContribution(SearchEditorModel model) + { + var fieldActions = new List<SectionFieldAction> + { + new("Search.Backend", SectionFieldActionKind.Set, model.Backend.ToWireValue()) + }; + + var endpoint = Normalize(model.SearXng.Endpoint); + if (!string.IsNullOrWhiteSpace(endpoint)) + fieldActions.Add(new SectionFieldAction("Search.SearXngEndpoint", SectionFieldActionKind.Set, endpoint)); + + 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)); + } + + return new SectionContribution(fieldActions, secretActions); + } + + 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); + } +} diff --git a/src/Netclaw.Cli/Tui/Config/SearchSectionSpec.cs b/src/Netclaw.Cli/Tui/Config/SearchSectionSpec.cs new file mode 100644 index 000000000..3c8f34564 --- /dev/null +++ b/src/Netclaw.Cli/Tui/Config/SearchSectionSpec.cs @@ -0,0 +1,143 @@ +// ----------------------------------------------------------------------- +// <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 GetSavedMessage(SearchEditorModel model) + => $"✔ {GetBackendLabel(model.Backend)} validated and saved."; + + internal string GetSavedNextStepText() + => "Press Enter to return to Settings Areas or Esc to review Search backends."; + + 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..2c09b3d4e --- /dev/null +++ b/src/Netclaw.Cli/Tui/Config/SecurityAccessPage.cs @@ -0,0 +1,404 @@ +// ----------------------------------------------------------------------- +// <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 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); + 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() + => NetclawTuiChrome.BuildPageFrame("Security & Access", 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 + { + SecurityAccessEditorMode.Posture => BuildPostureEditor(), + SecurityAccessEditorMode.PostureCascade => BuildPostureCascade(), + SecurityAccessEditorMode.Features => BuildFeatureToggles(), + SecurityAccessEditorMode.AudienceList => BuildAudienceList(), + SecurityAccessEditorMode.AudienceProfile => BuildAudienceProfile(), + _ => BuildSecurityMenu() + }); + + return _contentNode; + } + + private ILayoutNode BuildSecurityMenu() + { + var layout = Layouts.Vertical() + .WithChild(Header(" Security & Access")); + + var items = ViewModel.Items; + for (var i = 0; i < items.Count; i++) + { + var item = items[i]; + 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; + } + + private ILayoutNode BuildFeatureToggles() + { + var layout = Layouts.Vertical() + .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 focused = i == ViewModel.SelectedFeatureIndex.Value; + var enabled = ViewModel.IsFeatureEnabled(i); + 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($" 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; + for (var i = 0; i < options.Count; i++) + { + var option = options[i]; + var focused = i == ViewModel.SelectedAudienceIndex.Value; + var marker = ViewModel.AudienceOverrideMarker(option.Value); + var defaultMarker = ViewModel.IsSystemDefaultAudience(option.Value) ? "*" : " "; + layout = layout.WithChild(Row( + $"{FocusPrefix(focused)}{defaultMarker} {option.Label,-9} {option.Description,-34} {marker}", + focused)); + } + + return layout; + } + + private ILayoutNode BuildAudienceProfile() + { + var audience = ViewModel.AudienceOptions[ViewModel.SelectedAudienceIndex.Value]; + var layout = Layouts.Vertical() + .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; + for (var i = 0; i < rows.Count; i++) + { + 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,-14} {CycleValue(ViewModel.AudienceValue(row.Kind))}", + AudienceProfileRowKind.McpPermissions => + $"{FocusPrefix(focused)}{row.Label,-14} [Open] {ViewModel.AudienceValue(row.Kind)}", + AudienceProfileRowKind.ResetToDefault => + $"{FocusPrefix(focused)}{row.Label,-14} [Reset]", + _ => + $"{FocusPrefix(focused)}[{Check(ViewModel.IsAudienceToggleEnabled(row.Kind))}] {row.Label}" + }; + + 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)); + } + + var focusedRow = rows[ViewModel.SelectedAudienceRowIndex.Value]; + layout = layout + .WithChild(Layouts.Empty().Height(1)) + .WithChild(Hint($" {ViewModel.AudienceRowHelp(focusedRow.Kind)}")); + + 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 + { + 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 [←/→] Change [Space/Enter] Toggle/Apply [Esc] Audiences [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; + } + + switch (ViewModel.Mode.Value) + { + 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: + ViewModel.MoveSelection(-1); + break; + case ConsoleKey.DownArrow: + ViewModel.MoveSelection(1); + break; + case ConsoleKey.Enter: + ViewModel.ActivateSelected(); + break; + } + } + + 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.LeftArrow: + ViewModel.ChangeSelectedAudienceProfileRow(-1); + break; + case ConsoleKey.RightArrow: + ViewModel.ChangeSelectedAudienceProfileRow(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 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) + { + 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 new file mode 100644 index 000000000..e3bedab63 --- /dev/null +++ b/src/Netclaw.Cli/Tui/Config/SecurityAccessViewModel.cs @@ -0,0 +1,892 @@ +// ----------------------------------------------------------------------- +// <copyright file="SecurityAccessViewModel.cs" company="Petabridge, LLC"> +// 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.Mcp; +using Netclaw.Cli.Tui.Sections; +using Netclaw.Cli.Tui.Wizard.Steps; +using Netclaw.Configuration; +using Netclaw.Media; +using R3; +using Termina.Reactive; + +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 +{ + FileTools, + WebAccess, + Skills, + Scheduling, + ChangeWorkingDirectory, + FileAccess, + IncomingAttachments, + McpPermissions, + ResetToDefault +} + +public sealed class SecurityAccessViewModel : ReactiveViewModel +{ + private static readonly string[] FeatureConfigPaths = + [ + "Memory.Enabled", + "Search.Enabled", + "SkillSync.Enabled", + "Scheduling.Enabled", + "SubAgents.Enabled", + "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.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 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[FeatureConfigPaths.Length]; + private DeploymentPosture? _pendingPosture; + + public SecurityAccessViewModel( + NetclawPaths paths, + McpToolPermissionsNavigationState? mcpNavigationState = null) + { + _paths = paths; + _mcpNavigationState = mcpNavigationState; + LoadEnabledFeatures(); + } + + internal Action<string>? RouteRequested { get; set; } + 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<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 string SelectedAudienceOverrideStatus => AudienceHasOverrides(SelectedAudience) ? "Customized overrides" : "No custom overrides"; + + 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 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, FeatureConfigPaths.Length); + public void MoveAudienceSelection(int delta) => Move(SelectedAudienceIndex, delta, Audiences.Length); + public void MoveAudienceRow(int delta) => Move(SelectedAudienceRowIndex, delta, AudienceRows.Length); + + public void ActivateSelected() + { + switch (Mode.Value) + { + 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; + } + } + + internal void Activate(SecurityAccessItem item) + { + switch (item.Label) + { + case "Security Posture": + OpenPostureEditor(); + return; + case "Enabled Features": + OpenFeatureEditor(); + return; + case "Audience Profiles": + OpenAudienceList(); + return; + } + + 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 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() + { + var posture = Postures[SelectedPostureIndex.Value].Value; + if (posture == CurrentPosture) + { + StatusMessage.Value = $"{posture} posture is already active."; + RequestRedraw(); + return; + } + + _pendingPosture = posture; + if (AudienceProfilesCustomized()) + { + SelectedCascadeIndex.Value = 0; + Mode.Value = SecurityAccessEditorMode.PostureCascade; + StatusMessage.Value = ""; + RequestRedraw(); + return; + } + + 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 OpenFeatureEditor() + { + LoadEnabledFeatures(); + Mode.Value = SecurityAccessEditorMode.Features; + StatusMessage.Value = ""; + RequestRedraw(); + } + + 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 OpenAudienceList() + { + Mode.Value = SecurityAccessEditorMode.AudienceList; + StatusMessage.Value = ""; + RequestRedraw(); + } + + public void OpenSelectedAudienceProfile() + { + SelectedAudienceRowIndex.Value = 0; + Mode.Value = SecurityAccessEditorMode.AudienceProfile; + StatusMessage.Value = ""; + RequestRedraw(); + } + + 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.FileTools => ToolGroupEnabled(profile, FileTools), + 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 => "netclaw mcp permissions", + AudienceProfileRowKind.ResetToDefault => "", + _ => IsAudienceToggleEnabled(kind) ? "Enabled" : "Disabled" + }; + } + + public void ActivateSelectedAudienceProfileRow() + { + var row = AudienceRows[SelectedAudienceRowIndex.Value]; + switch (row.Kind) + { + case AudienceProfileRowKind.FileTools: + ToggleToolGroup(row.Kind, FileTools); + 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(1); + return; + case AudienceProfileRowKind.IncomingAttachments: + CycleIncomingAttachments(1); + return; + case AudienceProfileRowKind.McpPermissions: + _mcpNavigationState?.RequestInitialAudience(SelectedAudience); + RouteRequested?.Invoke("/mcp-tools"); + Navigate?.Invoke("/mcp-tools"); + return; + case AudienceProfileRowKind.ResetToDefault: + ResetSelectedAudienceProfile(); + return; + } + } + + 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)} overrides reset to the {CurrentPosture} posture baseline."; + RequestRedraw(); + } + + public void RequestQuit() + { + ShutdownRequestedForTest = true; + Shutdown(); + } + + public override void Dispose() + { + StatusMessage.Dispose(); + Mode.Dispose(); + SelectedIndex.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(int direction) + { + var profiles = LoadAudienceProfiles(); + var profile = GetProfile(profiles, SelectedAudience); + var next = CycleValue(CurrentFilesystemLevel(profile), FilesystemLevelsFor(SelectedAudience), direction); + + ApplyFilesystemLevel(profile, next); + SaveAudienceProfile(profile); + StatusMessage.Value = $"{AudienceLabel(SelectedAudience)} file access set to {DescribeFilesystem(profile)}. Saved."; + RequestRedraw(); + } + + private void CycleIncomingAttachments(int direction) + { + var profiles = LoadAudienceProfiles(); + var profile = GetProfile(profiles, SelectedAudience); + var next = CycleValue(CurrentAttachmentLevel(profile.ChannelAttachments), AttachmentLevels, direction); + + 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); + 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(FeatureConfigPaths + .Select((path, index) => new SectionFieldAction(path, SectionFieldActionKind.Set, _enabledFeatures[index])) + .ToArray()); + + private IReadOnlyList<SecurityAccessItem> BuildItems() + { + var config = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath); + return + [ + new("Security Posture", ReadPosture(config).ToString(), "Deployment trust stance."), + new("Enabled Features", ReadEnabledFeaturesSummary(config), "Deployment-wide runtime feature gates."), + 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 ReadEnabledFeaturesSummary(Dictionary<string, object> config) + { + var enabled = 0; + foreach (var path in FeatureConfigPaths) + { + var flag = true; + if (ConfigFileHelper.TryGetPathValue(config, path, out var value) && value is bool configuredFlag) + flag = configuredFlag; + + if (flag) + enabled++; + } + + 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 "No overrides"; + + var existing = ConvertConfigObject<ToolAudienceProfiles>(value, "Tools.AudienceProfiles"); + var defaults = BuildPostureProfiles(ReadPosture(config)); + 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) + { + 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; + 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() + }; + } + + 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) + { + [ToolAudienceProfileToolCatalog.ShellExecute] = 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 = [.. ToolAudienceProfileToolCatalog.ProfileManagedTools]; + } + + 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 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) + 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 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); + + 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/src/Netclaw.Cli/Tui/ConfigDashboardPage.cs b/src/Netclaw.Cli/Tui/ConfigDashboardPage.cs new file mode 100644 index 000000000..be677c457 --- /dev/null +++ b/src/Netclaw.Cli/Tui/ConfigDashboardPage.cs @@ -0,0 +1,105 @@ +// ----------------------------------------------------------------------- +// <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 NetclawTuiChrome.BuildPageFrame("Netclaw Config", BuildInnerLayout()); + } + + 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 => NetclawTuiChrome.BuildStatusLine(msg, Color.Yellow)) + .AsLayout() + .Height(1); + } + + private LayoutNode BuildKeyBindings() + { + return NetclawTuiChrome.BuildKeyHintLine(" [↑/↓] Navigate [Enter] Select [Esc] Quit [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.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..1f7a057cd --- /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.", "/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"), + 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.", "/security"), + 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/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/InitWizardViewModel.cs b/src/Netclaw.Cli/Tui/InitWizardViewModel.cs index 779c52db3..e00b70291 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,22 +82,25 @@ 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: - // 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(); @@ -115,7 +122,6 @@ internal InitWizardViewModel( identityStep, externalSkillsStep, skillFeedsStep, - exposureModeStep, _healthCheckStep }; @@ -137,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(), @@ -217,10 +222,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/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/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/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/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/src/Netclaw.Cli/Tui/Sections/ConfigEditorSession.cs b/src/Netclaw.Cli/Tui/Sections/ConfigEditorSession.cs new file mode 100644 index 000000000..ad7ba2861 --- /dev/null +++ b/src/Netclaw.Cli/Tui/Sections/ConfigEditorSession.cs @@ -0,0 +1,196 @@ +// ----------------------------------------------------------------------- +// <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: + SetSecretPathValue(secrets, action.Path, action.Value!); + changed = true; + break; + case SectionSecretActionKind.Delete: + changed |= RemoveSecretPath(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)); + + 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/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 new file mode 100644 index 000000000..ce88947c9 --- /dev/null +++ b/src/Netclaw.Cli/Tui/Sections/SectionEditorInfrastructure.cs @@ -0,0 +1,191 @@ +// ----------------------------------------------------------------------- +// <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; +using Netclaw.Configuration; + +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, + IReadOnlyList<SectionEditorStateAction>? StateActions = null) +{ + 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 +{ + 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, + string Key, + SectionEditorStateActionKind Action, + object? Value = null); + +public enum SectionFieldActionKind +{ + Set, + Delete, +} + +public enum SectionSecretActionKind +{ + Preserve, + Set, + Delete, +} + +public enum SectionEditorStateActionKind +{ + 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/TuiNavigation.cs b/src/Netclaw.Cli/Tui/TuiNavigation.cs new file mode 100644 index 000000000..9e84fd5da --- /dev/null +++ b/src/Netclaw.Cli/Tui/TuiNavigation.cs @@ -0,0 +1,31 @@ +// ----------------------------------------------------------------------- +// <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; + + public void Attach(TerminaApplication application) + { + ArgumentNullException.ThrowIfNull(application); + _application = application; + } + + public bool TryGoBack() + { + if (_application is null) + throw new InvalidOperationException("TUI navigation was requested before TerminaApplication was attached."); + + if (!_application.CanGoBack) + return false; + + _application.GoBack(); + return true; + } +} 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/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..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; @@ -86,17 +87,37 @@ 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.ClearStatusMessage(); + callbacks.AdvanceStep(); + } + else + { + callbacks.ShowValidationError(ChannelsEditorValidationMessages.DiscordBotTokenRequired); + } + + return; + } + vm.BotToken = text; + callbacks.ClearStatusMessage(); 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/ExposureModeStepView.cs b/src/Netclaw.Cli/Tui/Wizard/Steps/ExposureModeStepView.cs index 08efec7ae..a1052e453 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; @@ -24,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; @@ -44,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); @@ -64,50 +77,34 @@ 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); - 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)); + _modeList = modeList; + + return WorkflowViewComponents.BuildSelectionScreen( + heading: "How will this Netclaw daemon be accessed?", + 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); } private ILayoutNode BuildReverseProxyHost(ExposureModeStepViewModel vm, StepViewCallbacks callbacks) @@ -330,16 +327,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 +364,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) @@ -395,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/Wizard/Steps/ExposureModeStepViewModel.cs b/src/Netclaw.Cli/Tui/Wizard/Steps/ExposureModeStepViewModel.cs index f3a07eed9..2763bb978 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,11 +23,14 @@ 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"; + private const string ReverseProxyHostStateKey = "ReverseProxy.Host"; + private const string ReverseProxyTrustedProxiesStateKey = "ReverseProxy.TrustedProxies"; + private static readonly JsonSerializerOptions DevicesJsonOptions = new() { WriteIndented = true, @@ -35,13 +39,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 +101,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 +130,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 +192,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 +210,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 +222,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 +232,7 @@ public void ContributeConfig(WizardConfigBuilder builder) }; } - if (WebhooksEnabled) + if (IncludeWebhookToggle && WebhooksEnabled) { builder.Webhooks = new WebhooksConfigSection { Enabled = true }; } @@ -213,7 +261,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 +279,59 @@ 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()) + }; + var stateActions = new List<SectionEditorStateAction>(); + + if (vm.SelectedMode == ExposureMode.ReverseProxy) + { + 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, + trustedProxies)); + + stateActions.Add(CreateStateAction(ReverseProxyHostStateKey, host, host != DefaultReverseProxyHost)); + stateActions.Add(CreateStateAction(ReverseProxyTrustedProxiesStateKey, trustedProxies, + trustedProxies.Length > 0)); + } + else + { + 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, StateActions: stateActions); + } + /// <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 +370,90 @@ 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); + var editorState = new ConfigEditorStateStore(context.Paths); + + 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 = inactiveHost; + } + + 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) + { + 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/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/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/MattermostStepView.cs b/src/Netclaw.Cli/Tui/Wizard/Steps/MattermostStepView.cs index b3dea12ff..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); @@ -117,17 +130,37 @@ 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.ClearStatusMessage(); + callbacks.AdvanceStep(); + } + else + { + callbacks.ShowValidationError(ChannelsEditorValidationMessages.MattermostBotTokenRequired); + } + + return; + } + vm.BotToken = text; + callbacks.ClearStatusMessage(); 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) @@ -237,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/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/ProviderStepViewModel.cs b/src/Netclaw.Cli/Tui/Wizard/Steps/ProviderStepViewModel.cs index d5eae3f5c..3130ac013 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}.ApiKey", SectionSecretActionKind.Set, + new SensitiveString(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/Steps/SlackStepView.cs b/src/Netclaw.Cli/Tui/Wizard/Steps/SlackStepView.cs index 74e2cc4ba..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; @@ -88,22 +89,42 @@ 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.ClearStatusMessage(); + callbacks.AdvanceStep(); + } + else + { + callbacks.ShowValidationError(ChannelsEditorValidationMessages.SlackBotTokenRequired); + } + + return; + } + if (!text.StartsWith("xoxb-", StringComparison.OrdinalIgnoreCase)) { - callbacks.RequestRedraw(); + callbacks.ShowValidationError(ChannelsEditorValidationMessages.SlackBotTokenPrefix); return; } vm.BotToken = text; + callbacks.ClearStatusMessage(); callbacks.AdvanceStep(); }) .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,22 +138,42 @@ 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.ClearStatusMessage(); + callbacks.AdvanceStep(); + } + else + { + callbacks.ShowValidationError(ChannelsEditorValidationMessages.SlackAppTokenRequired); + } + + return; + } + if (!text.StartsWith("xapp-", StringComparison.OrdinalIgnoreCase)) { - callbacks.RequestRedraw(); + callbacks.ShowValidationError(ChannelsEditorValidationMessages.SlackAppTokenPrefix); return; } vm.AppToken = text; + callbacks.ClearStatusMessage(); callbacks.AdvanceStep(); }) .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/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/src/Netclaw.Cli/Tui/Wizard/WizardConfigBuilder.cs b/src/Netclaw.Cli/Tui/Wizard/WizardConfigBuilder.cs index dd0a74ca0..af6b68629 100644 --- a/src/Netclaw.Cli/Tui/Wizard/WizardConfigBuilder.cs +++ b/src/Netclaw.Cli/Tui/Wizard/WizardConfigBuilder.cs @@ -9,8 +9,8 @@ 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; @@ -21,10 +21,13 @@ namespace Netclaw.Cli.Tui.Wizard; public sealed class WizardConfigBuilder { private readonly NetclawPaths _paths; + private readonly Dictionary<string, object> _existingConfig; + private readonly List<SectionContribution> _sectionContributions = []; public WizardConfigBuilder(NetclawPaths paths) { _paths = paths; + _existingConfig = ConfigFileHelper.LoadJsonDict(paths.NetclawConfigPath); } // ── Typed sections populated by steps ── @@ -54,9 +57,8 @@ public void WriteConfigFile() { _paths.EnsureDirectoriesExist(); var config = BuildConfigDictionary(); - - File.WriteAllText(_paths.NetclawConfigPath, - JsonSerializer.Serialize(config, JsonDefaults.ConfigFile)); + ApplyEditorStateContributions(); + ConfigFileHelper.WriteConfigFile(_paths.NetclawConfigPath, config); } /// <summary> @@ -64,14 +66,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 +83,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 +104,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 +203,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 +242,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 +329,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 +342,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) @@ -359,6 +384,7 @@ internal Dictionary<string, object> BuildConfigDictionary() MergeEnabledFlag(config, "Webhooks", FeatureSelections.WebhooksEnabled); } + ApplySectionContributions(config); return config; } @@ -380,6 +406,23 @@ private static void MergeEnabledFlag(Dictionary<string, object> config, string s }; } } + + internal void ApplyContribution(SectionContribution contribution) + { + _sectionContributions.Add(contribution); + } + + private static void ApplyContribution(Dictionary<string, object> config, SectionContribution contribution) + => ConfigEditorSession.ApplyFieldActions(config, contribution); + + private void ApplySectionContributions(Dictionary<string, object> config) + { + foreach (var contribution in _sectionContributions) + ApplyContribution(config, contribution); + } + + private void ApplyEditorStateContributions() + => ConfigEditorSession.ApplyEditorStateActions(_paths, _sectionContributions); } /// <summary> @@ -389,10 +432,15 @@ 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); } internal NetclawPaths Paths => _paths; @@ -409,34 +457,46 @@ 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; - if (File.Exists(_paths.SecretsPath)) - { - 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 merged = _existingSecrets.Count == 0 + ? new Dictionary<string, object>() + : new Dictionary<string, object>(_existingSecrets, StringComparer.Ordinal); - SecretsFileWriter.Write(_paths.SecretsPath, existingNode.ToJsonString(JsonDefaults.ConfigFile), - protector: SensitiveStringTypeConverter.Protector); - return; - } + 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) + { + 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, _secrets, - options: JsonDefaults.ConfigFile, protector: SensitiveStringTypeConverter.Protector); + if (hasDirectSecrets || contributionChanged && (_secretsFileExists || HasUserSecretData(merged))) + { + 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 ── 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(); diff --git a/src/Netclaw.Cli/Tui/Workflow/ActiveSelectionList.cs b/src/Netclaw.Cli/Tui/Workflow/ActiveSelectionList.cs new file mode 100644 index 000000000..91f741278 --- /dev/null +++ b/src/Netclaw.Cli/Tui/Workflow/ActiveSelectionList.cs @@ -0,0 +1,149 @@ +// ----------------------------------------------------------------------- +// <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 Action<T>? _toggled; + 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, + Action<T>? toggled = null) + { + _options = options; + _labelSelector = labelSelector; + _activeSelector = activeSelector; + _statusSelector = statusSelector; + _confirmed = confirmed; + _changed = changed; + _toggled = toggled; + _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; + case ConsoleKey.Spacebar when _toggled is not null: + _toggled(FocusedOption); + Invalidate(); + 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; + } +} 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.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/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/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(), 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-channels.sh b/tests/smoke/assertions/config-channels.sh new file mode 100755 index 000000000..cb00ac4b0 --- /dev/null +++ b/tests/smoke/assertions/config-channels.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# config-channels.tape post-tape assertion. +# +# 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 + echo "FAIL: ${CONFIG_PATH} does not exist." >&2 + 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)' '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 '.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 | 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 + printf -- '--- secrets.json contents ---\n%s\n' "$secrets_json" >&2 + exit 1 +fi + +echo "config-channels: assertions passed." 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/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/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/assertions/config-search.sh b/tests/smoke/assertions/config-search.sh new file mode 100755 index 000000000..0057c2a23 --- /dev/null +++ b/tests/smoke/assertions/config-search.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# config-search.tape post-tape assertion. +# +# 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 + +. "$(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' '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 + printf -- '--- netclaw.json contents ---\n%s\n' "$config_json" >&2 + exit 1 +fi + +echo "config-search: 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-audience.tape b/tests/smoke/tapes/config-audience.tape new file mode 100644 index 000000000..3567bd78f --- /dev/null +++ b/tests/smoke/tapes/config-audience.tape @@ -0,0 +1,50 @@ +# 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 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" +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 /System default posture: Team/ +Wait+Screen@10s /\* Team/ +Down +Enter +Wait+Screen@10s /Audience Profile: Team/ +Down +Space +Wait+Screen@10s /\[ \] Web/ +Down 4 +Wait+Screen@10s /\[◀ Session only/ +Down +Wait+Screen@10s /Common work files: images/ +Escape +Wait+Screen@10s /System default posture: Team/ +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-channels.tape b/tests/smoke/tapes/config-channels.tape new file mode 100644 index 000000000..af24c449a --- /dev/null +++ b/tests/smoke/tapes/config-channels.tape @@ -0,0 +1,90 @@ +# config-channels.tape - edit Channels from netclaw config. +# +# Exercises: +# 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 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; 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; jq -n --arg bot $bot --arg app $app '{configVersion:1,Slack:{BotToken:$bot,AppToken:$app}}' > $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 /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 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 /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/ + +# 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/ +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\$/ + +Type "echo CONFIG_CHANNELS_EXIT=$?" +Enter +Wait+Screen@5s /CONFIG_CHANNELS_EXIT=0/ + +Type "exit" +Enter diff --git a/tests/smoke/tapes/config-exposure.tape b/tests/smoke/tapes/config-exposure.tape new file mode 100644 index 000000000..1b60e71aa --- /dev/null +++ b/tests/smoke/tapes/config-exposure.tape @@ -0,0 +1,70 @@ +# 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/ + +# 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/ +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/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 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 diff --git a/tests/smoke/tapes/config-search.tape b/tests/smoke/tapes/config-search.tape new file mode 100644 index 000000000..768988c00 --- /dev/null +++ b/tests/smoke/tapes/config-search.tape @@ -0,0 +1,66 @@ +# config-search.tape — drive `netclaw config` into the Search workflow. +# +# Covers: +# - 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. + +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 selection ──────────────────────────────────────────── +Wait+Screen@10s /Search/ +Wait+Screen@5s /DuckDuckGo works without setup/ + +# ─── Select Brave and confirm static validation blocks progression ───────── +Down +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/ +Escape + +# ─── Select SearXNG and complete happy path ─────────────────────────────── +Down +Enter +Wait+Screen@10s /Enter the base URL of your SearXNG instance/ +Type "https://search.test.local" +Enter +Wait+Screen@10s /Validating Search configuration/ +Wait+Screen@10s /Search Validation Warning/ +Down 2 +Enter +Wait+Screen@10s /validated and saved/ +Enter +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/init-wizard-reverse-proxy.tape b/tests/smoke/tapes/init-wizard-reverse-proxy.tape deleted file mode 100644 index dcbe19376..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 is rendered as "Daemon will listen on: http://0.0.0.0:5199" -Wait+Screen@5s /http:\/\/0\.0\.0\.0:5199/ -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 0.0.0.0:5199 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 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__" diff --git a/tests/smoke/tapes/screenshots/config-search.tape b/tests/smoke/tapes/screenshots/config-search.tape new file mode 100644 index 000000000..45049ad2e --- /dev/null +++ b/tests/smoke/tapes/screenshots/config-search.tape @@ -0,0 +1,57 @@ +# config-search.tape (screenshot) — capture the Search workflow screens. +# +# Frames captured: +# shot-config-search-selection +# shot-config-search-brave-entry +# shot-config-search-saved + +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: Provider selection ─────────────────────────────────────────── +Wait+Screen@10s /Choose the backend Netclaw uses for web search/ +Sleep 1s +Screenshot "/tmp/shot-config-search-selection.png" +Sleep 1s + +# ─── Frame 2: Brave entry state ──────────────────────────────────────────── +Down +Enter +Wait+Screen@10s /Brave Search requires an API key/ +Sleep 1s +Screenshot "/tmp/shot-config-search-brave-entry.png" +Sleep 1s + +# ─── Frame 3: Saved state ────────────────────────────────────────────────── +Escape +Down +Enter +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-saved.png" +Sleep 1s + +Ctrl+Q +Wait+Screen@10s /TAPE\$/ + +Type "exit" +Enter