From f49836ca3835d284539eea0a9823d404df786b0d Mon Sep 17 00:00:00 2001 From: Sam Keen Date: Wed, 13 May 2026 12:40:12 -0700 Subject: [PATCH 1/2] Ground inspection in a chosen claude session (#11, #12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pivot the inspector from "knobs.cc's own CWD" to a session it picks from the running process table. Closes the longstanding paradox where the project / project_local rail rows showed knobs.cc's launch directory rather than the user's claude session, and the cli row sat permanently empty because flags couldn't be observed without reading another process's argv. New surface: - `read_runtime_layer` Tauri command enumerates same-UID `claude` / `claude-code` processes via the `sysinfo` crate and returns each one's cwd, argv, and environ. - `read_settings_layers` grows `attached_pid` / `project_root_override` args. The new `ProjectSource` enum routes the three grounding modes (Attached / Picked / CurrentDir-fallback) through one snapshot read. - `cli_layer.rs` parses argv against `catalog/cli-settings-map.json` (5 starter flags: --model, --permission-mode, --effort, --agent, --add-dir) and emits a real `cli` LayerRead. - `env_layer.rs` grows `read_env_layer_attached(environ)`, so the env precedence layer reads the attached claude's environ when attached rather than knobs.cc's own. - `SessionPill` in the topbar drives a four-state picker (loading / 0 / 1 / 2+ claudes, plus unsupported on Windows). Single-process case auto-attaches; multi-process opens an inline picker; zero drops to a native folder picker via the `dialog` plugin. - EnvVarsPanel gains an `attached` column alongside `shell`, a per-row `Δ` badge when the two diverge, and `attached` / `Δ diff` filter chips that appear only when attached. Capability surface gains one grant: `dialog:allow-open` for the project-directory fallback picker. Cross-process reads stay Rust-only (no JS-side process/fs plugin grants). The "no `fs`, `shell`, `process`, `updater`" rule from design-notes.md still holds. Other refinements during smoke-testing: - `MERGED` chip on array-merge paths now only fires when 2+ layers actually contributed elements. Single-contributor array paths render as normal `set` rows with the contributor's badge; the per-element drawer view still renders. - `#[tauri::command(rename_all = "snake_case")]` on `read_settings_layers` — Tauri 2 defaults to camelCase command args while this project's wire format is snake_case throughout; without the override `attached_pid` from JS silently deserialized to `None`. - The repo's "Claude Code reads dotenv files at startup" claim (footnote, four spec spots, #11 body) was wrong. Smoke-tested with `claude -p` reading a .env-only var: nothing. The claim is removed. Docs sweep: user-facing copy (README, HelpView, EnvVarsPanel footnote, waterfall empty-state) describes grounded inspection as the product without narrating the pivot. Spec docs (attach-mode.md, inspector-ui, design-notes, settings-display, roadmap) reference the implementation contract in attach-mode.md and the closed issues. Small code-cleanup items (dedup `set_dotted_path` between cli + env layer, strip pivot/legacy framing from comments, two missing test cases) parked in issue #14. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 6 +- CONTRIBUTING.md | 7 +- README.md | 32 +- catalog/cli-settings-map.json | 32 ++ mocks/README.md | 8 +- package-lock.json | 16 +- package.json | 1 + spec/attach-mode.md | 442 ++++++++++++++++++++ spec/design-notes.md | 9 +- spec/inspector-ui.md | 50 ++- spec/roadmap.md | 84 ++-- spec/settings-display.md | 37 +- src-tauri/Cargo.lock | 102 +++++ src-tauri/Cargo.toml | 2 + src-tauri/capabilities/default.json | 3 +- src-tauri/src/cli_layer.rs | 376 +++++++++++++++++ src-tauri/src/env_layer.rs | 27 +- src-tauri/src/lib.rs | 4 + src-tauri/src/runtime.rs | 334 +++++++++++++++ src-tauri/src/settings.rs | 281 ++++++++++++- src/App.tsx | 87 +++- src/components/inspector/EnvVarsPanel.tsx | 75 +++- src/components/inspector/HelpView.tsx | 11 +- src/components/inspector/InspectorShell.tsx | 29 +- src/components/inspector/KeyDrawer.tsx | 70 ++-- src/components/inspector/PrecedenceRail.tsx | 73 +++- src/components/inspector/SessionPill.tsx | 270 ++++++++++++ src/components/inspector/Topbar.tsx | 25 +- src/lib/envVars.test.ts | 118 ++++++ src/lib/envVars.ts | 98 ++++- src/lib/openPath.ts | 27 ++ src/lib/rows.test.ts | 32 +- src/lib/rows.ts | 26 +- src/lib/runtime.test.ts | 149 +++++++ src/lib/runtime.ts | 101 +++++ src/lib/waterfall.ts | 2 +- src/types.ts | 28 ++ 37 files changed, 2859 insertions(+), 215 deletions(-) create mode 100644 catalog/cli-settings-map.json create mode 100644 spec/attach-mode.md create mode 100644 src-tauri/src/cli_layer.rs create mode 100644 src-tauri/src/runtime.rs create mode 100644 src/components/inspector/SessionPill.tsx create mode 100644 src/lib/runtime.test.ts create mode 100644 src/lib/runtime.ts diff --git a/CLAUDE.md b/CLAUDE.md index 1043124..7e2d8c4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,8 +9,8 @@ knobs.cc is a **Tauri 2 desktop app** (pre-release; no signed installer yet) tha The repo holds three coordinated surfaces: 1. **`spec/`** — design, scope, and roadmap. `inventory.md` catalogs every Claude Code config surface; `settings-display.md` and `inspector-ui.md` describe the app's backend and UI in phases; `catalog-sync.md` describes the harness that keeps catalog files in sync with upstream docs; `design-notes.md` carries open questions; `roadmap.md` is the single source of truth for what's shipped and what's pending. -2. **The Tauri 2 app** (`src/`, `src-tauri/`) — implementation. The Rust backend exposes three commands: `read_settings_layers` (reads the five real layers — managed / env / project_local / project / user — with per-leaf provenance and array-merge for permissions-style fields; `cli` and `default` are synthesized at the UI), `read_catalog` (the upstream-derived reference data), and `read_shell_env_vars` (filters the user's process env down to catalog-listed names for the EnvVarsPanel). Plus a `notify`-based file watcher that emits `settings-changed` so the UI refreshes live (see "Tauri 2 boundaries" — we use `notify` directly, not `tauri-plugin-fs-watch`, to keep the capability surface minimal). The React/Vite frontend is a three-pane DevTools-style Inspector (precedence rail, settings list, key drawer) plus a sibling **EnvVarsPanel** modal — a topbar-pill takeover that's the SSOT for env vars (catalog × shell × settings.json `env` block). Inspector rows skip the `env` subtree by design; the panel owns that surface. -3. **The catalog harness** (`scripts/sync-*.js`, `catalog/*.json`) — pulls upstream JSON Schema and docs into `catalog/{settings,env-vars,hooks,sub-agents,mcp,permissions,keybindings,cli-reference}.json`, which the app reads through `read_catalog`. `catalog/env-settings-map.json` maps env vars to their settings-key equivalents for the env layer. `.github/workflows/catalog-drift.yml` re-runs the sync scripts weekly (and on `workflow_dispatch`), normalises the always-changing `fetchedAt` field out of the comparison, and opens a single rolling `chore/catalog-drift` PR when real content drifts. Don't run the sync scripts and commit by hand unless you have a specific reason — let the workflow drive. +2. **The Tauri 2 app** (`src/`, `src-tauri/`) — implementation. The Rust backend exposes four commands: `read_settings_layers` (reads the six real precedence layers — managed / cli / env / project_local / project / user — with per-leaf provenance and array-merge for permissions-style fields; `default` is synthesized at the UI; accepts `attached_pid` / `project_root_override` args to ground the snapshot in a chosen session), `read_runtime_layer` (enumerates same-UID claude processes via `sysinfo` and returns each one's cwd / argv / environ — see [`spec/attach-mode.md`](spec/attach-mode.md)), `read_catalog` (the upstream-derived reference data), and `read_shell_env_vars` (filters knobs.cc's own process env down to catalog-listed names; complementary to the attached-env column when a claude is attached). Plus a `notify`-based file watcher that emits `settings-changed` so the UI refreshes live (see "Tauri 2 boundaries" — we use `notify` directly, not `tauri-plugin-fs-watch`, to keep the capability surface minimal). The React/Vite frontend is a three-pane DevTools-style Inspector (precedence rail, settings list, key drawer) plus a sibling **EnvVarsPanel** modal — a topbar-pill takeover that's the SSOT for env vars (catalog × attached-environ × shell × settings.json `env` block, with a Δ-diff chip when attached vs shell disagree). Inspector rows skip the `env` subtree by design; the panel owns that surface. A **SessionPill** in the topbar drives the attach mechanism: 0 / 1 / 2+ claude processes get distinct UI states, with a path-picker fallback when none are running. +3. **The catalog harness** (`scripts/sync-*.js`, `catalog/*.json`) — pulls upstream JSON Schema and docs into `catalog/{settings,env-vars,hooks,sub-agents,mcp,permissions,keybindings,cli-reference}.json`, which the app reads through `read_catalog`. `catalog/env-settings-map.json` maps env vars to their settings-key equivalents for the env layer; `catalog/cli-settings-map.json` does the same for argv flags feeding the cli layer (both hand-maintained — upstream JSON Schema doesn't expose these as structured metadata). `.github/workflows/catalog-drift.yml` re-runs the sync scripts weekly (and on `workflow_dispatch`), normalises the always-changing `fetchedAt` field out of the comparison, and opens a single rolling `chore/catalog-drift` PR when real content drifts. Don't run the sync scripts and commit by hand unless you have a specific reason — let the workflow drive. **Outstanding work is tracked in [`spec/roadmap.md`](spec/roadmap.md)**, the single source of truth. When you ship something or discover new work, update there rather than scattering status across the individual specs. @@ -28,7 +28,7 @@ Entries tagged `> [!verify]` haven't been cross-checked against live docs. Clear v1 is locked to read-only inspection. The capability surface is deliberately tiny: -- `src-tauri/capabilities/default.json` grants `core:default`, `opener:default`, and a scoped `opener:allow-open-path` (whitelist covering the user/project `.claude/` dirs — needed for the rail's path-note click-through). Platform-specific managed-tier paths live in sibling files gated with `platforms`: `default-macos.json` (`/Library/...`), `default-linux.json` (`/etc/claude-code/**`), `default-windows.json` (`C:\Program Files\ClaudeCode\**`). Splitting them is load-bearing — Tauri compiles every scope glob on every target, and the Windows backslash pattern fails to compile on macOS/Linux unless gated. **Do not add `fs`, `shell`, `process`, `dialog`, or `updater` plugin permissions.** Adding a path to the `opener:allow-open-path` allowlist is an expansion of the trust surface — keep new entries as tight as the file you actually need to open, not the whole parent dir. +- `src-tauri/capabilities/default.json` grants `core:default`, `opener:default`, a scoped `opener:allow-open-path` (whitelist covering the user/project `.claude/` dirs — needed for the rail's path-note click-through), and `dialog:allow-open` (used for the project-directory picker when no claude is attached; returns a path string but doesn't read files). Platform-specific managed-tier paths live in sibling files gated with `platforms`: `default-macos.json` (`/Library/...`), `default-linux.json` (`/etc/claude-code/**`), `default-windows.json` (`C:\Program Files\ClaudeCode\**`). Splitting them is load-bearing — Tauri compiles every scope glob on every target, and the Windows backslash pattern fails to compile on macOS/Linux unless gated. **Do not add `fs`, `shell`, `process`, or `updater` plugin permissions.** Adding a path to the `opener:allow-open-path` allowlist is an expansion of the trust surface — keep new entries as tight as the file you actually need to open, not the whole parent dir. - File reads happen through explicit `#[tauri::command]` Rust functions registered via `tauri::generate_handler![...]`, **not** by granting the frontend filesystem-plugin permissions. - File **watching** uses the `notify` crate from Rust and pushes `settings-changed` events to the frontend. Don't swap to `tauri-plugin-fs-watch` — that would require granting fs-watch capabilities to JS. - Frontend calls commands via `invoke()` from `@tauri-apps/api/core`. No open-ended plugin APIs from JS. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 860774a..aad7012 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,16 +1,17 @@ # Contributing -knobs.cc is in early development. The Tauri 2 app shell has been scaffolded (React + TypeScript + Vite frontend, Rust backend), but no custom Tauri commands or UI have been implemented yet. The project is still in concept phase — see `spec/` for the working inventory of Claude Code's configuration surface and the catalog-sync harness design. +knobs.cc is in pre-release. The Tauri 2 desktop inspector runs end-to-end via `npm run tauri dev` — settings-precedence rendering, a session picker that grounds the inspector against a running `claude` process (or a picked project directory), per-leaf provenance, per-element waterfall for array-merged paths, and a sibling EnvVarsPanel with attached/shell diff. No signed installer yet. ## What's useful right now - **Corrections to the inventory.** Entries tagged `> [!verify]` are places we're least confident. If you've tested a knob and can confirm the behaviour, file an issue or PR against `spec/inventory.md`. - **Missing knobs.** If you know of a Claude Code configuration surface — env var, setting, hook event, IDE quirk — that's absent from the inventory, file an issue. -- **Design input.** Opinions on app stack, hero flow, how to represent hook graphs, etc. belong in issues tagged `design`. +- **Inspector bugs / UX feedback.** Run `npm run tauri dev`, attach to a session, file what feels off. +- **Design input.** Opinions on hero flow, how to represent hook graphs, goals view, landing page, etc. belong in issues tagged `design`. ## What's not useful yet -Code PRs. The prototype milestone hasn't been reached — the Tauri 2 scaffold is in place but no app-specific functionality has been built. If you're excited to contribute, watch the repo for the prototype milestone. +Substantial code PRs without an issue first — let's discuss the shape before you write it. The roadmap in `spec/roadmap.md` is the source of truth for what's slated and what's deferred. ## Ground rules diff --git a/README.md b/README.md index 26880ef..9e8d28e 100644 --- a/README.md +++ b/README.md @@ -9,15 +9,6 @@ A local desktop inspector for every knob Claude Code gives you — where it live **Pre-release.** The read-only inspector runs end-to-end via `npm run tauri dev`. No signed installer or auto-update yet. -Two known gaps in the precedence rail are tracked openly: the `cli` -slot stays empty because knobs.cc can't read another process's flags -([#11](https://github.com/AlteredCraft/knobs-cc/issues/11)), and the -`project` / `project_local` rows resolve relative to knobs.cc's own -working directory rather than a chosen claude session, so they're -greyed out in the rail -([#12](https://github.com/AlteredCraft/knobs-cc/issues/12)). The -managed / env / user / default layers are unaffected. - ## Premise Claude Code has a sprawling configuration surface: settings files @@ -32,10 +23,16 @@ hard. knobs.cc lays it all out in one place: - What Claude Code **can** be configured with -- What **is** configured in the current environment +- What **is** configured for a specific session - **Where** each value is coming from (user / project / local / managed / env var / CLI flag / default) +The inspector grounds against a running `claude` process you pick +from the topbar: it reads that session's cwd, argv, and environ to +resolve the project, cli, and env layers honestly. If no claude is +running, point the inspector at a project directory and the file +layers resolve against it. + Live updates are wired in: when a watched settings file changes on disk the snapshot refreshes automatically. @@ -59,13 +56,16 @@ disk the snapshot refreshes automatically. Three coordinated surfaces: - **The Tauri 2 app.** `src/` (React/Vite/TypeScript Inspector UI) and - `src-tauri/` (Rust backend with the read-only `read_settings_layers` - and `read_catalog` Tauri commands). Five settings layers (managed / - env / project_local / project / user), per-leaf provenance, and + `src-tauri/` (Rust backend with the read-only `read_settings_layers`, + `read_catalog`, `read_shell_env_vars`, and `read_runtime_layer` + Tauri commands). Seven settings layers (managed / cli / env / + project_local / project / user / default), per-leaf provenance, per-element waterfall for array-merged fields like - `permissions.allow`. The managed tier reads the macOS - `com.anthropic.claudecode` MDM plist when present and falls back to - the file-based source otherwise. + `permissions.allow`, and a session picker that reads a running + claude process's cwd, argv, and environ so the cli + env + project + layers resolve against the same session. The managed tier reads the + macOS `com.anthropic.claudecode` MDM plist when present and falls + back to the file-based source otherwise. - **The specs.** [`spec/roadmap.md`](spec/roadmap.md) is the single source of truth for what's shipped vs pending. Other live specs: [`spec/inventory.md`](spec/inventory.md) (every Claude Code knob), diff --git a/catalog/cli-settings-map.json b/catalog/cli-settings-map.json new file mode 100644 index 0000000..cbd0a47 --- /dev/null +++ b/catalog/cli-settings-map.json @@ -0,0 +1,32 @@ +{ + "source": "Hand-curated from catalog/cli-reference.json — each flag's documented 'Overrides … setting' wording. See spec/attach-mode.md PR 2 'cli row + EnvVarsPanel attached-env column'. Mirrors the env-settings-map.json pattern.", + "fetchedAt": "2026-05-13", + "count": 5, + "mappings": [ + { + "flag": "--model", + "settings": "model", + "kind": "string" + }, + { + "flag": "--permission-mode", + "settings": "permissions.defaultMode", + "kind": "string" + }, + { + "flag": "--effort", + "settings": "effortLevel", + "kind": "string" + }, + { + "flag": "--agent", + "settings": "agent", + "kind": "string" + }, + { + "flag": "--add-dir", + "settings": "permissions.additionalDirectories", + "kind": "stringArrayMulti" + } + ] +} diff --git a/mocks/README.md b/mocks/README.md index 0c26fa7..bf7574c 100644 --- a/mocks/README.md +++ b/mocks/README.md @@ -11,11 +11,17 @@ open mocks/03-goals.html ``` All three render the same realistic snapshot: -- 5 of 7 layers active (managed and cli are absent / not inspectable) +- 5 of 7 layers active (managed and cli are absent in this fixture — + no MDM policy, no attached claude with mapped flags) - 12 set keys, 2 env vars, 3 shadowed values, 4 array-merged fields - The same `model` shadowing example: project (`opus-4-7`) wins over user (`sonnet-4-6`) - The same `permissions.allow` array-merge across user + project + project_local +These are concept-phase mocks; the shipped Inspector resolves the cli + +env + project layers against a session picked via the topbar (see +[`../spec/attach-mode.md`](../spec/attach-mode.md)). The mocks predate +that surface and don't render it. + No build step. No JS framework. Tailwind via CDN. Fonts from Google. --- diff --git a/package-lock.json b/package-lock.json index 36de2a3..7121603 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@tauri-apps/api": "^2", + "@tauri-apps/plugin-dialog": "^2.7.1", "@tauri-apps/plugin-opener": "^2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -1443,9 +1444,9 @@ } }, "node_modules/@tauri-apps/api": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz", - "integrity": "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==", + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.0.tgz", + "integrity": "sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==", "license": "Apache-2.0 OR MIT", "funding": { "type": "opencollective", @@ -1669,6 +1670,15 @@ "node": ">= 10" } }, + "node_modules/@tauri-apps/plugin-dialog": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.1.tgz", + "integrity": "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.11.0" + } + }, "node_modules/@tauri-apps/plugin-opener": { "version": "2.5.3", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.3.tgz", diff --git a/package.json b/package.json index 569cd73..6ee2290 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ }, "dependencies": { "@tauri-apps/api": "^2", + "@tauri-apps/plugin-dialog": "^2.7.1", "@tauri-apps/plugin-opener": "^2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/spec/attach-mode.md b/spec/attach-mode.md new file mode 100644 index 0000000..19cba76 --- /dev/null +++ b/spec/attach-mode.md @@ -0,0 +1,442 @@ +# Attach mode + +Pivot spec: ground the Inspector in a real `claude` process the user +selects, rather than in knobs.cc's own current working directory. +Closes the paradoxes described in +[#11](https://github.com/AlteredCraft/knobs-cc/issues/11) (runtime +introspection — `cli` precedence slot + EnvVarsPanel ground truth) +and [#12](https://github.com/AlteredCraft/knobs-cc/issues/12) +(`project` / `project_local` resolved against knobs.cc's launch dir). + +This file is the implementation contract for the pivot. Visual +reference and rail/list/drawer conventions stay in +[`inspector-ui.md`](./inspector-ui.md); how the precedence layers +merge stays in [`settings-display.md`](./settings-display.md). This +spec only adds the **attach surface** that those two now front-end +against. + +> **Status of attach-mode phases lives in +> [`roadmap.md`](./roadmap.md).** What's shipped vs. pending is +> tracked there. + +## Why + +Today knobs.cc reads file-based settings layers relative to its own +process CWD, reads the user's shell env from its own process env, +and renders the `cli` precedence row as a permanent empty state. +Each of those is a real product gap: + +- `project` / `project_local` reflect whichever directory knobs.cc + was launched from — not the user's claude session. The rail rows + are greyed out today as a stopgap (`PrecedenceRail.tsx`, shipped + 2026-05-10). +- The EnvVarsPanel's shell-env column reflects knobs.cc's process + env, which can differ from the running claude's (Finder/Spotlight + launches via LaunchServices vs. terminal claude with full shell + env). +- The `cli` precedence row has no data source — flags passed to + `claude` (`--model opus`, `--permission-mode auto`, ...) override + settings layers but can't be observed without reading another + process's argv. + +All three gaps are the same shape: knobs.cc needs to *attach to a +running `claude` process* and read its cwd, argv, and environ. With +those three values, every grounded layer becomes correct. + +A separate option ("launch mode" — knobs.cc spawns claude itself) was +considered and rejected for v1: it doesn't help users who launched +claude from a terminal or IDE, which is the dominant workflow. Attach +mode is a *superset* — sessions spawned by knobs.cc would attach the +same way as any other. + +## Pivot framing + +The product reframes around **grounded inspection**: + +- Primary view: the Inspector renders against a *chosen* claude + session. The rail's `project` / `project_local` rows resolve to + that session's cwd; the `cli` row populates from its argv; the + EnvVarsPanel grounds its "shell" column in that session's environ. +- Fallback view: when no claude is detected, the user picks a + project directory (`#12` proposal 3 — "path picker mode"). The + Inspector still works but cli/env stay ungrounded. +- The "no session, knobs.cc's CWD as project" behavior is retired. + That's the paradox; the pivot deletes it. + +## Goals + +- For any local-host claude the user owns, surface a snapshot of + its cwd, argv, and environ that the Inspector can ground itself + against. +- Handle 0 / 1 / many claude processes coherently: 0 → path-picker + fallback; 1 → auto-attach; many → picker. +- Keep the read-only boundary: attaching reads kernel-visible process + state, never writes to the target process or anywhere on disk. +- Mask credential-shaped values in the attached environ by default + (extend the existing EnvVarsPanel mask), with a UI toggle to + reveal. +- Stay inside the Tauri 2 capability posture: no new JS-side plugin + permissions beyond what attach mode strictly requires. + +## Non-goals (v1) + +- **Windows.** `sysinfo::Process::environ()` returns empty on + Windows; cross-process env reading needs `NtQueryInformationProcess` + + `ReadProcessMemory` (the PEB-walking path). Deferred per #11. + Windows users see an "attach mode not available on this platform" + empty state with the path picker still usable. +- **Containerised / sandboxed claude.** Host knobs.cc can't see into + a container's PID namespace. Out of scope; honest empty state + ("no inspectable claude processes"). +- **Remote / SSH claude.** `sysinfo` reads local `/proc` and + `sysctl` only. Headless / remote mode is a separate v-next + conversation; see "Out of scope" below. +- **Root-owned claude.** Same-UID requirement applies. If a claude + process is running as root and the user isn't, we don't try to + read it. +- **Editing the attached claude's state.** Read-only stays the + v1 boundary. No `kill`, no `setenv` injection, no config writes + triggered from the attach UI. +- **Polling beyond user-triggered refresh + window-focus.** The + data we read is frozen at exec time anyway; aggressive polling + buys nothing. + +## Mechanism + +Implementation lives in a new `src-tauri/src/runtime.rs` module. +Backed by the [`sysinfo`](https://docs.rs/sysinfo) crate. + +### Process discovery + +`sysinfo::System::processes()` returns a map of every visible PID. +Filter to processes whose `name()` is `claude` or `claude-code` and +whose `user_id()` matches the calling user. Each surviving entry +becomes a `ClaudeProcess`. + +### Per-process reads + +For each surviving process: + +| Field | macOS source | Linux source | +|--------------|-------------------------------------------|-----------------------------| +| `cwd` | `proc_pidinfo` `PROC_PIDVNODEPATHINFO` | `/proc//cwd` symlink | +| `argv` | `KERN_PROCARGS2` sysctl | `/proc//cmdline` | +| `environ` | `KERN_PROCARGS2` sysctl (env block) | `/proc//environ` | +| `started_at` | `proc_pidinfo` `start_time` | `/proc//stat` | + +All of these are wrapped by `sysinfo::Process::{cwd, cmd, environ, +start_time}`. No additional crates or unsafe code required at the +attach-mode layer. + +### Tauri command + +New command registered alongside the existing handlers: + +```rust +#[tauri::command] +fn read_runtime_layer() -> RuntimeSnapshot; +``` + +Wire shape (snake-case at the IPC boundary, mirroring the existing +`SettingsSnapshot` convention): + +```ts +interface RuntimeSnapshot { + // 0..N processes the user can pick from. + processes: ClaudeProcess[]; + // The platform's capability status. "ok" on supported Unix; "unsupported" + // on Windows; "error" if sysinfo itself failed. + platform_status: "ok" | "unsupported" | "error"; + // Set when platform_status === "error". + error: string | null; +} + +interface ClaudeProcess { + pid: number; + // Seconds since UNIX epoch — the moment exec() ran for this process. + started_at: number; + cwd: string; + // Parsed argv (argv[0] = the binary, argv[1..] = flags + prompt). + argv: string[]; + // Full environ as a flat object. Order is not preserved (rarely matters); + // duplicate keys collapse to the last occurrence. + environ: Record; +} +``` + +`read_runtime_layer` does **not** select a process. Selection is a +frontend-side concern: the UI persists the chosen `pid` in app +state, and `read_settings_layers` accepts that pid (or a fallback +path) as input — see "Wiring into existing commands" below. + +### Wiring into existing commands + +The two existing commands take optional grounding hints so they can +resolve against the chosen session rather than knobs.cc's CWD: + +```rust +#[tauri::command] +fn read_settings_layers( + // The selected attach target. If supplied, the snapshot's + // project/project_local layers read from this process's cwd. + attached_pid: Option, + // Fallback when no claude is running and the user picked a dir. + // Mutually exclusive with attached_pid; if both are set, + // attached_pid wins. + project_root_override: Option, +) -> SettingsSnapshot; +``` + +Backwards compatibility: passing neither argument falls back to the +current behavior (read relative to knobs.cc's CWD), so the existing +test suite continues to pass during the migration. Once attach mode +ships, the production UI never calls it that way — the fallback is +retained only for test convenience and an explicit "browse the +catalog cold" view that may land later. + +`read_shell_env_vars` follows the same pattern — given an +`attached_pid`, it returns that process's environ projected through +the env-var catalog (so the EnvVarsPanel's "shell" column becomes a +"running claude env" column). The pid-less call still returns +knobs.cc's own process env, used by the path-picker fallback. + +### Refresh cadence + +- **On user action.** A refresh button in the topbar + the existing + `R` keybinding (already wired for `read_settings_layers`) call + both commands. +- **On window-focus.** Tauri's `WindowEvent::Focused(true)` fires + the same refresh — common UX expectation when the user + alt-tabs back from a terminal where they just started/stopped + claude. +- **Not periodic polling.** Process env, argv, and cwd are frozen + at exec time. The only thing that changes is the set of running + processes, and the focus-event + manual-refresh combo covers + that without burning CPU/battery. + +When the *attached* process disappears between refreshes (the user +killed claude in their terminal), the snapshot returns it with +`status: "exited"` and the UI surfaces a "session ended" affordance +in the topbar pill — see "UX states" below. + +## UX states + +The Inspector's topbar grows a new **session pill** that's the +primary attach affordance. It's the analogue of the existing +managed-mcp / errors / diagnostics pills, but persistent (not +conditional on data presence) and load-bearing for the inspector's +ground-truth. + +### States + +Four states total: three driven by process count, plus a brief +loading state during the initial runtime-snapshot fetch on app +boot. + +1. **Loading** (initial fetch only). + - Pill: `detecting…` with an empty status dot. + - Renders for the few hundred ms between `App.tsx` mounting and + `read_runtime_layer` returning. The inspector body shows its + existing "reading settings…" spinner state during this window; + the pill picks up its real state on the first successful + `RuntimeSnapshot`. + +2. **Zero claude processes detected.** + - Pill: `no claude · pick a project ▾`. + - On open: path-picker fallback CTA — "pick a project directory + to ground the inspector against, or start a claude session in + a terminal and we'll pick it up automatically." + - Inspector renders against the picked path; `cli` and `env` + rail rows are greyed; project / project_local resolve relative + to the picker dir. + +3. **Exactly one claude process detected.** + - Auto-attach. Pill: `attached · claude PID 4172 · ~/Projects/foo`. + - No picker required; the user can still click the pill to see + the full process list (useful when a second session starts). + - All rail rows render normally; `cli` populates from argv; + EnvVarsPanel grounds its shell column in the attached environ. + +4. **Two or more claude processes detected.** + - No auto-attach. Pill: `pick session ▾ (2 running)`. + - On open: list of detected processes, each row showing pid, + cwd, started-at, and the first ~3 argv entries. Clicking a row + attaches; selection persists for the app's lifetime (no disk + persistence in v1). + - Inspector renders against the chosen process. Rail rows + identical to state 3. + +Plus an **unsupported** state for Windows (until #11 Proposal C +lands): the pill reads "attach unsupported" with the path picker +still usable; cli + env rail rows stay greyed; project files +resolve against the picked directory. + +### Path picker mechanism + +Native folder picker via Tauri's `dialog` plugin. **Adds a new +capability** (`dialog:default` + `dialog:allow-open`) — the only +new JS-side capability the pivot needs. The grant is scoped to the +file-picker open primitive only; nothing else. + +Justification: dragging or hand-typing a project path is meaningfully +worse UX than a native picker for an action this central to the +new flow, and `dialog:allow-open` is a narrow grant (read-only, +returns a path string — doesn't open or read the file/folder). + +Considered alternatives, rejected: +- Plain text input ("paste a path"): no capability change but + worse ergonomic, especially for users with deep monorepos. +- Drag-and-drop only: discoverable for some users, not for others; + needs a fallback anyway. +- Tauri's `fs` plugin: huge scope expansion for one read-only + picker operation. Not worth it. + +### Stale / exited session + +When the attached pid no longer exists in the next refresh: + +- Pill state: `session ended · PID 4172 · re-attach ▾`. +- Inspector renders the *last successful snapshot* in dimmed state + with a "session ended — re-attach to refresh" banner above the + list. Better than wiping the view; the user was just looking at + it. +- Clicking re-attach reopens the picker. + +### Privacy: masking + reveal + +The existing EnvVarsPanel masks values whose key name matches +`/key|token|secret|password/i` to `•••••••• abcd` (last 4 chars). +That mask extends transparently to the attached environ — same +regex, same affordance, same reveal-on-click. + +No explicit "enable runtime introspection" opt-in flow. Rationale: +reading another process's env is a same-UID operation — anything +knobs.cc can read this way, the user can already read with +`ps -E` or `printenv`. The mask-by-default + reveal-toggle pattern +is sufficient privacy hygiene, and aligns with how API-key +dashboards typically present credentials. + +## Capability surface impact + +| Change | Surface | Notes | +|--------------------------------|----------------------|---------------------------------------------------------------------------------------------| +| `sysinfo` crate dep | Rust only | No JS-side cap. Already proposed in #11. | +| `dialog:default` capability | JS | New grant. Scoped to the open-dialog primitive only. | +| `dialog:allow-open` capability | JS | New grant. Returns a path string; does not read files. | +| No `fs` plugin | (no change) | Path reads stay inside `#[tauri::command]` Rust functions. | +| No `shell` / `process` plugin | (no change) | We're *reading* another process, not spawning. Launch mode is not v1. | +| Tauri builder | Rust only | Register `read_runtime_layer`; add the window-focus listener that fires `runtime-changed`. | + +The "no shell, no process, no dialog, no fs" rule from `CLAUDE.md` +and `design-notes.md` is relaxed in exactly one spot — `dialog` for +the path picker — and the rationale is documented above. No other +plugin grants needed for the pivot. + +## What landed + +Both halves of the pivot shipped together on `feat/attach-mode`. +Closes #11 and #12. + +### Grounding mechanism + +- New `runtime.rs` module + `sysinfo` dep. +- New `read_runtime_layer` Tauri command — same-UID claude process + enumeration with cwd / argv / environ per process. Unit tests + cover the filter logic; self-attach exercises the live-process + path. +- `read_settings_layers` accepts `attached_pid` / + `project_root_override`; `#[tauri::command(rename_all = "snake_case")]` + is required because Tauri 2 defaults to camelCase for command + args while the rest of this project's wire format is snake_case. +- Frontend: `SessionPill` topbar UI, three-state picker (0 / 1 / + 2+ claudes), path-picker fallback via the `dialog:allow-open` + capability grant, `R`-key + window-focus refresh wiring. +- Rail: project / project_local rows un-greyed when grounded; + fall back to greyed when neither attached nor picked. + +### `cli` row + env layer attached + EnvVarsPanel attached column + +- New `catalog/cli-settings-map.json` — hand-curated 5 entries + (`--model`, `--permission-mode`, `--effort`, `--agent`, + `--add-dir`). Schema: `flag`, `settings` (dotted path), `kind` + (`string` | `stringArrayMulti`). The `string` kind consumes + the next argv token (supporting both `--flag value` and + `--flag=value` forms); `stringArrayMulti` mirrors clap's + `` variadic — consumes tokens until the next + `-`-prefixed flag. +- `cli_layer.rs` parses argv against the map, emits a synthetic + settings object as the `cli` LayerRead's `raw`. Unmapped flags + are skipped silently — claude has many flags this map doesn't + cover, and erroring on unknown flags would poison the layer. +- `env_layer.rs` grows `read_env_layer_attached(environ)` — + same parser as `read_env_layer()`, but reads the attached + claude's environ instead of knobs.cc's own. `settings.rs::read_snapshot` + routes between the two based on attach state. +- EnvVarsPanel: new `attached` column (green badge) alongside + `shell`. Per-row `Δ` badge when attached and shell values + disagree. Two new filter chips: `attached` and `Δ diff`. + Mask + reveal extended to the new column. + +### Notable refinement during smoke + +The `MERGED` chip on array-merged paths originally fired whenever +the backend emitted `elements`, even when only one layer +contributed. That misleads users into thinking single-source +arrays are multi-sourced. Refined: when an array-merge path has +only one contributor, the row downgrades to `state: "set"` with +that layer as `winner`. `elements` stays populated so the drawer's +per-element list still renders. The `MERGED` chip only fires for +genuine multi-source merges. + +## Out of scope (v-next conversations) + +These are real product surfaces that the pivot exposes but doesn't +solve: + +- **Headless / CLI / server mode.** Inspecting claude inside a + Docker container, on an SSH-mounted server, or in a CI runner + requires knobs.cc-the-Tauri-desktop-app to grow a sibling + knobs.cc-the-data-collector binary that runs in those contexts + and feeds a remote frontend. Real architectural shift; v-next. +- **Persistent attach across sessions.** When the attached claude + exits and the user starts a new one with the same cwd, should + knobs.cc auto-reattach? Probably yes, but the policy + ("same cwd? same argv? same parent shell?") needs design. +- **Multi-claude diff view.** When N claudes are running, the + picker shows them but doesn't let the user compare. Probably + worth a dedicated "compare sessions" surface eventually. +- **Recording snapshots for debug reports.** "Export this snapshot + as JSON" is a natural extension once the snapshot is grounded + in a real session. Out of scope; doesn't change the data model. + +## Open questions (still open) + +- **Picker UI shape if metadata grows.** Topbar dropdown handles + the 2-3-process case fine. If process metadata grows (live + status, last-message timestamp, token usage), the dropdown may + feel cramped — revisit as a modal or cmd+K quick-switcher then. +- **Persistence of the selected project root.** Currently + app-lifetime only (the pid is rightly transient; the path + picker resets on each launch when no claude is running). A + small `~/.config/knobs.cc/state.json` for "remember the last + picked directory" is a v-next decision — flag if users start + asking. +- **Watcher target.** `notify`-based file watcher in `watcher.rs` + still watches `cwd/.claude`, not the attached / picked project + dir. Manual refresh + window-focus cover it; dynamic rebinding + is a focused follow-up if file edits to the attached project + feel laggy in practice. + +## Related + +- [#11](https://github.com/AlteredCraft/knobs-cc/issues/11) — runtime + introspection (argv + env). Closed. +- [#12](https://github.com/AlteredCraft/knobs-cc/issues/12) — project + paths grounded in a real session. Closed. +- [`settings-display.md`](./settings-display.md) — precedence merge + semantics. Unchanged; this spec adds the *grounding input* the + merge runs against. +- [`inspector-ui.md`](./inspector-ui.md) — rail/list/drawer layout. + This spec adds the session pill; otherwise no UI shape changes. +- [`design-notes.md`](./design-notes.md) "Tauri 2 boundaries" — + amended by the `dialog:allow-open` grant for the path picker. diff --git a/spec/design-notes.md b/spec/design-notes.md index c44c1c8..4d11a13 100644 --- a/spec/design-notes.md +++ b/spec/design-notes.md @@ -37,16 +37,17 @@ v1 should keep the desktop security model tight: - No write commands — v1 is read-only by design. - No `fs` plugin — file reads are done via explicit Rust commands, not by granting the frontend filesystem plugin permissions. - No `shell` plugin — no shell execution permitted. +- No `process` plugin — process introspection is done by the `sysinfo` crate inside an explicit `read_runtime_layer` Rust command, not by granting the frontend process-spawning capabilities. See [`attach-mode.md`](./attach-mode.md). - No `updater` plugin until signing and release flow are stable. -- Expose explicit commands (`read_settings_layers`, `read_catalog`, `read_shell_env_vars`) registered via `generate_handler![]` instead of granting generic file access. -- Capability files in `src-tauri/capabilities/` (`default.json` for cross-platform plus per-OS files `default-macos.json` / `default-linux.json` / `default-windows.json` gated via `platforms`) grant only `core:default` + `opener:default` + a tightly-scoped `opener:allow-open-path` — no `fs`, `shell`, or `updater`. The per-OS split is load-bearing: Tauri compiles every glob on every target, and a Windows backslash pattern (`C:\Program Files\…\**`) fails to compile on macOS/Linux unless gated. +- Expose explicit commands (`read_settings_layers`, `read_catalog`, `read_shell_env_vars`, `read_runtime_layer`) registered via `generate_handler![]` instead of granting generic file access. +- Capability files in `src-tauri/capabilities/` (`default.json` for cross-platform plus per-OS files `default-macos.json` / `default-linux.json` / `default-windows.json` gated via `platforms`) grant only `core:default` + `opener:default` + a tightly-scoped `opener:allow-open-path` + `dialog:allow-open` — no `fs`, `shell`, `process`, or `updater`. The `dialog:allow-open` grant powers the path-picker fallback when no claude is attached; it returns a path string but doesn't read files. The per-OS split is load-bearing: Tauri compiles every glob on every target, and a Windows backslash pattern (`C:\Program Files\…\**`) fails to compile on macOS/Linux unless gated. ## Security model (Tauri 2 capabilities) Tauri 2 permissions are managed through capability files (`src-tauri/capabilities/.json`). For v1: -- The default capability file grants `core:default` + `opener:default` plus a scoped `opener:allow-open-path` whitelist for the path-notes click-through. Platform-specific managed-tier paths live in sibling capability files gated by `platforms` (split because Tauri compiles every scope glob on every target — a Windows backslash pattern in a shared file fails to compile on macOS/Linux). -- No `fs`, `shell`, `process`, `dialog`, or `updater` plugin permissions. +- The default capability file grants `core:default` + `opener:default` + a scoped `opener:allow-open-path` whitelist for the path-notes click-through + `dialog:allow-open` for the project-directory picker. Platform-specific managed-tier paths live in sibling capability files gated by `platforms` (split because Tauri compiles every scope glob on every target — a Windows backslash pattern in a shared file fails to compile on macOS/Linux). +- No `fs`, `shell`, `process`, or `updater` plugin permissions. - Our Rust commands (`#[tauri::command]`) are registered in the builder via `invoke_handler(tauri::generate_handler![...])`. - The frontend calls commands through `@tauri-apps/api/core` (`invoke`), not through open-ended plugin APIs. diff --git a/spec/inspector-ui.md b/spec/inspector-ui.md index ceead39..215ac4a 100644 --- a/spec/inspector-ui.md +++ b/spec/inspector-ui.md @@ -122,7 +122,9 @@ across rail, list, drawer, and waterfall: - `PROJ` — amber tint (team-shared) - `USER` — neutral grey - `DEFAULT` — muted, no tint -- `CLI` — never appears in v1 (not inspectable, [#11](https://github.com/AlteredCraft/knobs-cc/issues/11)) +- `CLI` — neutral grey tint; sourced from the attached process's argv, + parsed against `catalog/cli-settings-map.json` per + [`attach-mode.md`](./attach-mode.md). Empty when no claude is attached. ### Waterfall @@ -139,17 +141,20 @@ Some layers are absent or ungrounded for typical users. Copy matters because generic "—" or "missing" is misleading. - `managed` (no MDM): **"no MDM policy detected"** -- `cli` (sibling process can't read): **"not inspectable from sibling proc"** - ([#11](https://github.com/AlteredCraft/knobs-cc/issues/11)) +- `cli` (no attached claude): **"no attached claude (argv unavailable)"** + — rail row greyed when ungrounded; renders normally when attached and + argv contains a mapped flag. See [`attach-mode.md`](./attach-mode.md). +- `cli` (attached but no mapped flags in argv): **"no mapped flags in + argv"** — Ok status, count 0. - `env` (no relevant vars): **"$ANTHROPIC_MODEL not set for this key"** (per-key, not per-layer; applies to the 8 settings keys ENV projects via `catalog/env-settings-map.json` — the rest of the env-vars surface lives in the EnvVarsPanel) -- `project` / `project_local` (scoped to knobs.cc's launch dir, not the - user's claude session): **"knobs.cc's launch dir, not your claude - session"** — rail row is greyed out regardless of whether the file - was read. Tracked at - [#12](https://github.com/AlteredCraft/knobs-cc/issues/12). +- `project` / `project_local` (no claude attached and no project + directory picked): rail row is greyed; detail reads + **"knobs.cc's launch dir, not your claude session"** until the user + picks a session or directory. When grounded (attached or picked), the + rows render normally with the resolved path. - `default` (always present): **"catalog (compiled-in)"** Per `settings-display.md`, a malformed user file does not block reading @@ -195,23 +200,28 @@ Topbar-pill-driven takeover panel; full-pane modal modeled on invisible everywhere else. Shell-set names that aren't in the catalog are deliberately *not* surfaced (a user's shell carries hundreds of unrelated vars: `PATH`, `HOME`, …). -- Each row joins shell-set values (read via `read_shell_env_vars`) - with `settings.json` `env.` contributors per layer, in - precedence order. Shell wins when both routes set the same name. +- Each row joins three sources, in precedence order: the **attached** + claude's environ (when attached — ground truth for what claude + sees), the **shell** values knobs.cc itself was launched with (via + `read_shell_env_vars` — a proxy + diagnostic), and `settings.json` + `env.` contributors per layer. Attached wins over shell; shell + wins over settings.json. - Names matching `/key|token|secret|password/i` mask their value to `•••••••• abcd` until clicked — demo-safe by default. -- Filter chips: `all` / `set` / `shell` / `settings.json` / `unset`; - substring search across name and purpose. +- Filter chips when no claude attached: `all` / `set` / `shell` / + `settings.json` / `unset`. When attached, two more chips appear: + `attached` (vars set in claude's environ) and `Δ diff` (vars where + attached and shell values disagree — the headline diagnostic). Per- + row `Δ` badge highlights the divergence inline. +- Substring search across name and purpose. - Click a row to expand inline — full markdown `purpose` prose, default, contributor list (winner first; shadowed values struck-through with their layer + path). -- Caveat in the panel footnote: knobs.cc reads its own process env, - which usually matches the user's shell but can differ for - Finder/Spotlight launches via LaunchServices. Dotenv files Claude - Code reads at startup are out of scope. Closing this gap (reading - another `claude` process's actual environ) is tracked at - [#11](https://github.com/AlteredCraft/knobs-cc/issues/11) alongside - the related `cli` precedence-slot gap. +- Caveat in the panel footnote: the `shell` column reflects knobs.cc's + own process env, which usually matches the user's terminal shell but + can differ for Finder/Spotlight launches that use LaunchServices' + env. When attached, the `attached` column is ground truth — diff + against `shell` to spot the divergence. The inspector's column-header banner points users at this panel so a filter for `env` in the inspector (which now returns zero rows) diff --git a/spec/roadmap.md b/spec/roadmap.md index cb8bb7a..6ea0d48 100644 --- a/spec/roadmap.md +++ b/spec/roadmap.md @@ -8,15 +8,9 @@ If you ship something, mark it ✅ here and (where relevant) update the corresponding spec section. If you discover new work, add it here, not inline in another spec. -Last reviewed: 2026-05-10 (Phase 2 + read_catalog + Phase 5 + Phase 7 + -path-notes click-through + Phase 6 fully shipped + three-OS CI + -managed-mcp.json topbar pill + catalog-drift cron + sync-sub-agents + -in-app error log + per-OS capability split + sync-mcp + sync-permissions -+ drawer cross-references env-vars catalog + sync-keybindings + -sync-cli-reference + hooks catalog pass #2 + drawer cross-references -permissions.modes as a value-conditional annotation under EFFECTIVE + -issue #7 filed for generalizing the annotation seam + EnvVarsPanel -shipped, closing #6). +Last reviewed: 2026-05-13 (attach mode shipped on `feat/attach-mode`, +closing #11 and #12 — see "Attach mode" section below for the +shipped surface). ## Next-up candidates @@ -51,14 +45,12 @@ here, then jump to the relevant section for shape and rationale. staleness signal. (Cron-driven sync with PR-on-diff shipped 2026-05-06.) -Deferred / open-ended (kept warm, not slated): runtime introspection -(CLI layer + cross-process env reading, -[#11](https://github.com/AlteredCraft/knobs-cc/issues/11)), grounding -`project` / `project_local` in a real claude session -([#12](https://github.com/AlteredCraft/knobs-cc/issues/12); shares -process-discovery plumbing with #11), goals view, cross-cutting -surfaces, landing page, nomenclature. See "Deferred plan" and -"Design surfaces" further down. +**Recently shipped (2026-05-13):** Attach mode pivots the inspector +to grounded inspection — see [`attach-mode.md`](./attach-mode.md) +and the "Attach mode" section below for the shipped surface. +Closes #11 + #12. Other deferred items unchanged: goals view, +cross-cutting surfaces, landing page, nomenclature — see "Design +surfaces" further down. --- @@ -148,25 +140,44 @@ Phase numbering matches the spec. Existing empty states (managed / cli / env / default) continue verbatim from `inspector-ui.md:131-139`. (§ "Phase 7".) -### Deferred plan (kept warm, not slated) +### Attach mode (branch `feat/attach-mode` — ready for merge) -- **Runtime introspection — CLI layer + cross-process env reading.** - Reach into a running `claude` process to read its argv (populating the - empty `cli` precedence slot, parsed against `catalog/cli-reference.json`) - and its environ (grounding the EnvVarsPanel in what claude actually - inherited rather than what knobs.cc inherited). Same OS APIs, same - process-discovery problem — treated as one feature. Unix-first via the - `sysinfo` crate; Windows deferred until Unix proves out. Tracked at - [#11](https://github.com/AlteredCraft/knobs-cc/issues/11) — that issue - is the SSOT for problem statement, limitations, and proposals. - Currently documented as out of v1 in `settings-display.md:253`. -- **Ground `project` / `project_local` in a real claude session.** - Today both layers resolve relative to knobs.cc's own CWD, which is - rarely the user's claude project; the rail rows are greyed out for - now (shipped 2026-05-10). Long-term framings — attach to a running - claude (shares plumbing with #11), launch claude as a harness, or - ship a plain path picker independent of #11 — are scoped in - [#12](https://github.com/AlteredCraft/knobs-cc/issues/12). +✅ shipped on branch 2026-05-13. Grounded inspection is now the +product — [`attach-mode.md`](./attach-mode.md) is the +implementation contract. Closes +[#11](https://github.com/AlteredCraft/knobs-cc/issues/11) and +[#12](https://github.com/AlteredCraft/knobs-cc/issues/12). + +- ✅ `sysinfo` crate dep + `runtime.rs` module + `read_runtime_layer` + Tauri command (cwd / argv / environ for same-UID claude processes). +- ✅ `read_settings_layers` accepts `attached_pid` / + `project_root_override`; `ProjectSource` enum routes the three + grounding modes. `#[tauri::command(rename_all = "snake_case")]` + required so JS snake_case args deserialize to Rust snake_case + params (Tauri 2 defaults to camelCase). +- ✅ Frontend: `SessionPill` topbar UI with 4-state picker + (loading / 0 / 1 / 2+ claudes, plus unsupported on Windows), + session-grounding derivation, window-focus refresh, path-picker + fallback via `tauri-plugin-dialog`. +- ✅ Rail: project / project_local rows grounded against the + chosen session's cwd or picked root; cli row populated from + attached argv via `catalog/cli-settings-map.json` (5 starter + flags); env precedence layer reads attached environ when + available. +- ✅ EnvVarsPanel: `attached` column alongside `shell`, with + `Δ` per-row badge when values diverge. New `attached` and + `Δ diff` filter chips appear when attached. +- ✅ MERGED chip refined to only fire for genuine multi-source + array merges; single-contributor array paths render as + normal `set` rows with the contributor's badge. +- ✅ Capability surface change: `dialog:allow-open` granted for + the path picker. No other new JS-side plugin permissions. + +**Known follow-up** (deferred to a maintenance issue): `watcher.rs` +still watches knobs.cc's own `cwd/.claude` rather than the attached +/ picked project dir. Manual refresh + window-focus cover it +operationally; dynamic watch-target rebinding is a focused +improvement, not a blocker. --- @@ -404,8 +415,7 @@ Shipped: Caveat surfaced in the panel footnote: knobs.cc reads its own process env, which usually matches the user's shell but can differ for Finder/Spotlight launches that use LaunchServices' - env. Dotenv files Claude Code reads at startup are out of scope - for this pass. Both gaps tracked at + env. Tracked at [#11](https://github.com/AlteredCraft/knobs-cc/issues/11) (runtime introspection — reads another `claude` process's environ to ground- truth the panel). diff --git a/spec/settings-display.md b/spec/settings-display.md index 2535104..15d6c32 100644 --- a/spec/settings-display.md +++ b/spec/settings-display.md @@ -33,18 +33,18 @@ Per `spec/inventory.md:55`, plus env vars folded in: | # | Layer | Source | Phase | |---|------------------------|-------------------------------------------------------------------|-------| -| 1 | `managed` | Server-managed > MDM (plist/registry) > file-based > HKCU | 2 / 6 | -| 2 | `cli` | Flags passed to `claude` (out of v1 scope — not inspectable, see [#11](https://github.com/AlteredCraft/knobs-cc/issues/11)) | — | -| 3 | `env` | Process env + dotenv files Claude Code reads | 3 | -| 4 | `project_local` | `/.claude/settings.local.json` *(see [#12](https://github.com/AlteredCraft/knobs-cc/issues/12) — `` is knobs.cc's launch dir today)* | 1 | -| 5 | `project` | `/.claude/settings.json` *(see [#12](https://github.com/AlteredCraft/knobs-cc/issues/12) — `` is knobs.cc's launch dir today)* | 1 | -| 6 | `user` | `~/.claude/settings.json` | 1 | -| 7 | `default` | Claude Code's compiled-in defaults (catalog-derived) | 5 | - -CLI flags are listed for completeness but cannot be inspected from a separate -process. The UI displays that slot with an explanatory empty state, and rows -4–5 are greyed out in the rail until project resolution is grounded in a -real claude session (see [#12](https://github.com/AlteredCraft/knobs-cc/issues/12)). +| 1 | `managed` | Server-managed > MDM (plist/registry) > file-based > HKCU | +| 2 | `cli` | Attached claude's argv, parsed against `catalog/cli-settings-map.json` ([`attach-mode.md`](./attach-mode.md)) | +| 3 | `env` | Process env. Reads the attached claude's environ when attached; falls back to knobs.cc's own process env otherwise. The EnvVarsPanel surfaces both side-by-side. | +| 4 | `project_local` | `/.claude/settings.local.json` — `` resolves to the attached claude's cwd or a user-picked directory ([`attach-mode.md`](./attach-mode.md)) | +| 5 | `project` | `/.claude/settings.json` — same grounding as project_local | +| 6 | `user` | `~/.claude/settings.json` | +| 7 | `default` | Claude Code's compiled-in defaults (catalog-derived) | + +When no claude is attached, the `cli` row is empty and the rail greys +it; when no claude is attached *and* no project directory has been +picked, `project` / `project_local` also grey out (falling back to +reading from knobs.cc's own CWD only to satisfy tests). ## Merge semantics @@ -122,12 +122,13 @@ interface SettingsSnapshot { 1. `/.claude/settings.local.json` (`project_local`) 2. `/.claude/settings.json` (`project`) 3. `~/.claude/settings.json` (`user`) -- Project root for Phase 1 = the Tauri app's current working directory. Walking - up to find the nearest `.claude/` was originally slated as Phase 4 but - never shipped, and the broader limitation (that knobs.cc's CWD isn't - the user's claude session in the first place) is now tracked at - [#12](https://github.com/AlteredCraft/knobs-cc/issues/12). Rows 4–5 of - the precedence rail are greyed out until that lands. +- Project root: post-pivot, resolved per [`attach-mode.md`](./attach-mode.md) + — the attached claude process's cwd, a user-picked directory, or (legacy + fallback only) `std::env::current_dir()`. The `read_settings_layers` + command grows two optional args (`attached_pid`, + `project_root_override`); calling with neither preserves the + pre-pivot CWD behavior for tests and the no-attach-no-picker default. + Closed [#12](https://github.com/AlteredCraft/knobs-cc/issues/12). - Each layer reports `ok` / `missing` / `error` independently — a malformed user file does not block reading the project file. - `effective` is computed last-wins. Array-merge semantics are deferred to diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index b1f485e..6a11012 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1947,8 +1947,10 @@ dependencies = [ "plist", "serde", "serde_json", + "sysinfo", "tauri", "tauri-build", + "tauri-plugin-dialog", "tauri-plugin-opener", "winreg 0.56.0", ] @@ -2248,6 +2250,15 @@ dependencies = [ "bitflags 2.11.1", ] +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + [[package]] name = "num-conv" version = "0.2.1" @@ -2355,10 +2366,21 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.11.1", "block2", + "libc", "objc2", "objc2-core-foundation", ] +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + [[package]] name = "objc2-io-surface" version = "0.3.2" @@ -3086,6 +3108,30 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + [[package]] name = "rustc-hash" version = "2.1.2" @@ -3629,6 +3675,20 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "sysinfo" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252800745060e7b9ffb7b2badbd8b31cfa4aa2e61af879d0a3bf2a317c20217d" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows", +] + [[package]] name = "system-deps" version = "6.2.2" @@ -3828,6 +3888,48 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-dialog" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.18", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371" +dependencies = [ + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.18", + "toml 1.1.2+spec-1.1.0", + "url", +] + [[package]] name = "tauri-plugin-opener" version = "2.5.3" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e4ef6fe..37d7b9a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -20,9 +20,11 @@ tauri-build = { version = "2", features = [] } [dependencies] tauri = { version = "2", features = [] } tauri-plugin-opener = "2" +tauri-plugin-dialog = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" notify = "8" +sysinfo = { version = "0.36", default-features = false, features = ["system"] } [target.'cfg(target_os = "macos")'.dependencies] plist = "1" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index f7fa677..db9329c 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -13,6 +13,7 @@ { "path": "**/.claude/settings.json" }, { "path": "**/.claude/settings.local.json" } ] - } + }, + "dialog:allow-open" ] } diff --git a/src-tauri/src/cli_layer.rs b/src-tauri/src/cli_layer.rs new file mode 100644 index 0000000..91e6abf --- /dev/null +++ b/src-tauri/src/cli_layer.rs @@ -0,0 +1,376 @@ +//! Attach-mode PR 2: synthesize the `cli` precedence layer from the +//! attached claude's argv. +//! +//! Mirrors `env_layer.rs`'s shape: a hand-curated map at +//! `catalog/cli-settings-map.json` translates known flags to settings keys, +//! and `build_raw` walks an argv slice producing a synthetic settings +//! object. The rest of the snapshot's merge machinery treats the result +//! identically to any other layer. +//! +//! Closes the cli-row half of #11. + +use serde::Deserialize; +use serde_json::{Map, Value}; + +use crate::settings::{LayerRead, LayerSource, LayerStatus}; + +const RAW_MAPPING_FILE: &str = include_str!("../../catalog/cli-settings-map.json"); + +#[derive(Debug, Deserialize)] +struct MappingFile { + mappings: Vec, +} + +#[derive(Debug, Deserialize)] +struct CliMapping { + flag: String, + settings: String, + kind: String, +} + +fn load_mappings() -> Vec { + serde_json::from_str::(RAW_MAPPING_FILE) + .expect("catalog/cli-settings-map.json must parse at compile time") + .mappings +} + +/// Set a JSON value at a dotted settings path. Used by the `string` kind to +/// place a scalar; the `stringArrayMulti` kind has its own array-aware +/// variant below. +fn set_dotted_path(root: &mut Value, dotted: &str, value: Value) { + let segments: Vec<&str> = dotted.split('.').collect(); + set_segments(root, &segments, value); +} + +fn set_segments(root: &mut Value, segments: &[&str], value: Value) { + if segments.is_empty() { + *root = value; + return; + } + if !root.is_object() { + *root = Value::Object(Map::new()); + } + let map = root.as_object_mut().expect("ensured object above"); + let head = segments[0]; + let rest = &segments[1..]; + if rest.is_empty() { + map.insert(head.to_string(), value); + return; + } + let child = map + .entry(head.to_string()) + .or_insert_with(|| Value::Object(Map::new())); + set_segments(child, rest, value); +} + +/// Append a list of values to the array at the given dotted path. Creates +/// the array if it doesn't exist. Used by `stringArrayMulti` flags like +/// `--add-dir A B C`. +fn append_to_array_path(root: &mut Value, dotted: &str, values: Vec) { + let segments: Vec<&str> = dotted.split('.').collect(); + if values.is_empty() { + return; + } + // Walk to the parent, then append/create at the leaf. + let mut cur = root; + for seg in &segments[..segments.len() - 1] { + if !cur.is_object() { + *cur = Value::Object(Map::new()); + } + let map = cur.as_object_mut().expect("ensured object above"); + let child = map + .entry(seg.to_string()) + .or_insert_with(|| Value::Object(Map::new())); + cur = child; + } + if !cur.is_object() { + *cur = Value::Object(Map::new()); + } + let leaf_map = cur.as_object_mut().expect("ensured object above"); + let last = segments.last().expect("non-empty path"); + let entry = leaf_map + .entry(last.to_string()) + .or_insert_with(|| Value::Array(Vec::new())); + if let Some(arr) = entry.as_array_mut() { + arr.extend(values); + } else { + // The path already held a non-array value (shouldn't happen for a + // fresh cli layer, but be defensive). Overwrite with the array. + *entry = Value::Array(values); + } +} + +/// Pure parser: walk an argv slice and a mapping table, return the +/// synthesized settings object. argv[0] (the binary path) is ignored. +/// +/// Tokens that don't match a known flag are skipped silently — claude has +/// many flags we don't map yet, and erroring on unrecognised input would +/// poison the layer for any session that uses an unmapped flag (effectively +/// most of them). +/// +fn build_raw(argv: &[String], mappings: &[CliMapping]) -> Value { + use std::collections::HashMap; + let by_flag: HashMap<&str, &CliMapping> = + mappings.iter().map(|m| (m.flag.as_str(), m)).collect(); + + let mut raw = Value::Object(Map::new()); + let mut i = 1; // skip argv[0] + while i < argv.len() { + let tok = &argv[i]; + // Support both `--flag value` and `--flag=value` forms. + let (flag, inline_value): (&str, Option<&str>) = + if let Some(eq) = tok.find('=') { + (&tok[..eq], Some(&tok[eq + 1..])) + } else { + (tok.as_str(), None) + }; + + let Some(mapping) = by_flag.get(flag) else { + i += 1; + continue; + }; + + match mapping.kind.as_str() { + "string" => { + let value = if let Some(v) = inline_value { + v.to_string() + } else { + i += 1; + if i >= argv.len() { + // Flag without value at end of argv — silently + // drop. The cli layer is a best-effort surface, + // not a syntax checker. + break; + } + argv[i].clone() + }; + set_dotted_path(&mut raw, &mapping.settings, Value::String(value)); + } + "stringArrayMulti" => { + // Consume tokens until the next `-`-prefixed token or end. + // Mirrors clap's `` variadic flag style — claude's + // `--add-dir ../apps ../lib` is the canonical example. + let mut values: Vec = Vec::new(); + if let Some(v) = inline_value { + values.push(Value::String(v.to_string())); + } + i += 1; + while i < argv.len() && !argv[i].starts_with('-') { + values.push(Value::String(argv[i].clone())); + i += 1; + } + if !values.is_empty() { + append_to_array_path(&mut raw, &mapping.settings, values); + } + // The outer `i += 1` at the bottom would advance past a + // valid flag token; `continue` so we re-examine argv[i]. + continue; + } + _ => { + // Unknown kind — defensive. Skip silently. + i += 1; + continue; + } + } + i += 1; + } + raw +} + +/// Build the cli LayerRead from an attached claude's argv. Returns `Missing` +/// when argv is empty (degenerate input — process exited or wasn't readable). +pub fn read_cli_layer(argv: &[String]) -> LayerRead { + if argv.is_empty() { + return LayerRead { + source: LayerSource::Cli, + path: None, + status: LayerStatus::Missing, + raw: None, + error: None, + }; + } + let mappings = load_mappings(); + let raw = build_raw(argv, &mappings); + LayerRead { + source: LayerSource::Cli, + path: None, + status: LayerStatus::Ok, + raw: Some(raw), + error: None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn map_string(flag: &str, settings: &str) -> CliMapping { + CliMapping { + flag: flag.into(), + settings: settings.into(), + kind: "string".into(), + } + } + + fn map_array(flag: &str, settings: &str) -> CliMapping { + CliMapping { + flag: flag.into(), + settings: settings.into(), + kind: "stringArrayMulti".into(), + } + } + + fn argv(parts: &[&str]) -> Vec { + parts.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn string_flag_extracts_next_token_as_value() { + let map = vec![map_string("--model", "model")]; + let out = build_raw(&argv(&["claude", "--model", "opus"]), &map); + assert_eq!(out, json!({ "model": "opus" })); + } + + #[test] + fn string_flag_extracts_inline_eq_value() { + let map = vec![map_string("--model", "model")]; + let out = build_raw(&argv(&["claude", "--model=opus"]), &map); + assert_eq!(out, json!({ "model": "opus" })); + } + + #[test] + fn string_flag_uses_dotted_settings_path() { + let map = vec![map_string("--permission-mode", "permissions.defaultMode")]; + let out = build_raw(&argv(&["claude", "--permission-mode", "auto"]), &map); + assert_eq!(out, json!({ "permissions": { "defaultMode": "auto" } })); + } + + #[test] + fn unknown_flag_is_skipped_without_failure() { + let map = vec![map_string("--model", "model")]; + let out = build_raw( + &argv(&["claude", "--unknown-thing", "x", "--model", "opus"]), + &map, + ); + // The unknown flag and its value are both passed over. The model + // mapping still fires. + assert_eq!(out, json!({ "model": "opus" })); + } + + #[test] + fn array_flag_consumes_until_next_flag() { + let map = vec![map_array("--add-dir", "permissions.additionalDirectories")]; + let out = build_raw( + &argv(&["claude", "--add-dir", "../apps", "../lib", "--unknown", "x"]), + &map, + ); + assert_eq!( + out, + json!({ "permissions": { "additionalDirectories": ["../apps", "../lib"] } }), + ); + } + + #[test] + fn array_flag_supports_inline_first_value() { + let map = vec![map_array("--add-dir", "permissions.additionalDirectories")]; + let out = build_raw(&argv(&["claude", "--add-dir=foo", "bar"]), &map); + assert_eq!( + out, + json!({ "permissions": { "additionalDirectories": ["foo", "bar"] } }), + ); + } + + #[test] + fn array_flag_repeated_concatenates() { + // `--add-dir foo --add-dir bar` should yield [foo, bar] rather + // than the second occurrence overwriting the first. + let map = vec![map_array("--add-dir", "permissions.additionalDirectories")]; + let out = build_raw( + &argv(&["claude", "--add-dir", "foo", "--add-dir", "bar"]), + &map, + ); + assert_eq!( + out, + json!({ "permissions": { "additionalDirectories": ["foo", "bar"] } }), + ); + } + + #[test] + fn string_flag_at_end_of_argv_without_value_is_dropped_silently() { + // `claude --model` with no following value — degenerate input from + // an exited or malformed process. Don't crash, don't emit a + // partial entry. + let map = vec![map_string("--model", "model")]; + let out = build_raw(&argv(&["claude", "--model"]), &map); + assert_eq!(out, json!({})); + } + + #[test] + fn empty_argv_or_no_mapped_flags_produces_empty_object() { + let map = vec![map_string("--model", "model")]; + assert_eq!(build_raw(&argv(&["claude"]), &map), json!({})); + assert_eq!( + build_raw(&argv(&["claude", "--no-mcp"]), &map), + json!({}), + ); + } + + #[test] + fn multiple_unrelated_flags_compose() { + let map = vec![ + map_string("--model", "model"), + map_string("--permission-mode", "permissions.defaultMode"), + map_string("--effort", "effortLevel"), + ]; + let out = build_raw( + &argv(&[ + "claude", + "--model", + "opus", + "--permission-mode", + "plan", + "--effort", + "high", + ]), + &map, + ); + assert_eq!( + out, + json!({ + "model": "opus", + "permissions": { "defaultMode": "plan" }, + "effortLevel": "high" + }), + ); + } + + #[test] + fn read_cli_layer_returns_ok_with_synthesized_raw() { + let layer = read_cli_layer(&argv(&["claude", "--model", "opus"])); + assert!(matches!(layer.status, LayerStatus::Ok)); + assert_eq!( + layer.raw.unwrap(), + json!({ "model": "opus" }), + ); + } + + #[test] + fn read_cli_layer_returns_missing_for_empty_argv() { + // Degenerate input — process gone or unreadable. Better to signal + // missing than emit an empty Ok layer that the UI would render as + // a real entry. + let layer = read_cli_layer(&[]); + assert!(matches!(layer.status, LayerStatus::Missing)); + assert!(layer.raw.is_none()); + } + + #[test] + fn mapping_file_parses_and_contains_model() { + // Compile-time-embedded JSON guard, parallel to env_layer. + let mappings = load_mappings(); + assert!(!mappings.is_empty()); + assert!(mappings.iter().any(|m| m.flag == "--model")); + assert!(mappings.iter().any(|m| m.flag == "--add-dir")); + } +} diff --git a/src-tauri/src/env_layer.rs b/src-tauri/src/env_layer.rs index ab0fa0c..58c4ca2 100644 --- a/src-tauri/src/env_layer.rs +++ b/src-tauri/src/env_layer.rs @@ -108,12 +108,31 @@ fn build_raw Option>( raw } -/// Build the env LayerRead from the current process environment. Always -/// returns `Ok` — even when no mapped vars are set, an empty raw object is -/// the right answer (the rail row will just show count 0). +/// Build the env LayerRead from the current process environment. Used when +/// no claude is attached — best-effort proxy for "what claude would inherit +/// if it were launched from the same shell." pub fn read_env_layer() -> LayerRead { + read_env_layer_with(|key| std::env::var(key).ok()) +} + +/// Build the env LayerRead from the *attached* claude's environ. Ground +/// truth (literally what claude has at runtime) rather than the proxy +/// `read_env_layer` returns from knobs.cc's own env. +/// +/// Used by `read_snapshot` when `ProjectSource::Attached(pid)` resolves to +/// a live process. See attach-mode PR 2. +pub fn read_env_layer_attached( + environ: &std::collections::BTreeMap, +) -> LayerRead { + read_env_layer_with(|key| environ.get(key).cloned()) +} + +/// Shared body of the two public entry points. Always returns `Ok` — even +/// when no mapped vars are set, an empty raw object is the right answer +/// (the rail row will just show count 0). +fn read_env_layer_with Option>(read_env: F) -> LayerRead { let mappings = load_mappings(); - let raw = build_raw(&mappings, |key| std::env::var(key).ok()); + let raw = build_raw(&mappings, read_env); LayerRead { source: LayerSource::Env, path: None, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 28f3734..4d55b22 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,7 +1,9 @@ mod catalog; +mod cli_layer; mod env_layer; mod env_vars; mod managed_layer; +mod runtime; mod settings; mod watcher; @@ -11,6 +13,7 @@ use tauri::Manager; pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_opener::init()) + .plugin(tauri_plugin_dialog::init()) .setup(|app| { let handle = app.handle().clone(); match watcher::SettingsWatcher::start(handle) { @@ -25,6 +28,7 @@ pub fn run() { settings::read_settings_layers, catalog::read_catalog, env_vars::read_shell_env_vars, + runtime::read_runtime_layer, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/runtime.rs b/src-tauri/src/runtime.rs new file mode 100644 index 0000000..84e6c74 --- /dev/null +++ b/src-tauri/src/runtime.rs @@ -0,0 +1,334 @@ +//! Attach mode: enumerate running `claude` / `claude-code` processes the +//! current user owns, surface each one's cwd, argv, and environ. +//! +//! Closes #11 (runtime introspection: cli + env) and #12 (project paths +//! grounded in a real session). Spec: `spec/attach-mode.md`. +//! +//! Boundaries: +//! - Only processes belonging to the calling UID. Same-UID is the same +//! trust boundary as `ps -E` / `printenv` in the user's own shell. +//! - Local-host only. Containers / SSH / remote claude are out of scope +//! for v1; honest empty state on those targets. +//! - Read-only. We never write to the target process. +//! +//! Platform support: +//! - macOS / Linux: full (cwd + argv + environ). +//! - Windows: `sysinfo::Process::environ()` returns empty on Windows; we +//! report `platform_status: "unsupported"` and produce an empty +//! process list. PR 2 (or v-next) revisits via `NtQueryInformationProcess` +//! + `ReadProcessMemory`. + +use std::collections::BTreeMap; + +use serde::Serialize; +use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, System, UpdateKind}; + +/// Process names we consider "claude." Matched case-sensitively against +/// `Process::name()`. The CLI ships as `claude` on the official tarball; +/// `claude-code` is the legacy/alternate binary name some distros use. +const CLAUDE_PROCESS_NAMES: &[&str] = &["claude", "claude-code"]; + +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum PlatformStatus { + Ok, + Unsupported, + Error, +} + +#[derive(Debug, Serialize)] +pub struct ClaudeProcess { + pub pid: u32, + /// Seconds since UNIX epoch — the moment exec() ran for this process. + pub started_at: u64, + pub cwd: String, + /// argv as the kernel sees it. argv[0] is the binary path. + pub argv: Vec, + /// Full environ. Order is not preserved; duplicate keys collapse. + /// BTreeMap so the wire output is deterministic for tests / diffing. + pub environ: BTreeMap, +} + +#[derive(Debug, Serialize)] +pub struct RuntimeSnapshot { + pub processes: Vec, + pub platform_status: PlatformStatus, + pub error: Option, +} + +/// Pure predicate: does this binary name match the set we consider "claude"? +/// Extracted so the filter can be unit-tested without spinning up sysinfo. +fn matches_claude_name(name: &str) -> bool { + CLAUDE_PROCESS_NAMES.contains(&name) +} + +/// Build a `System` populated only with the fields we need (cwd, cmd, +/// environ, user, exe). Cheaper than `System::new_all()` and avoids +/// pulling in CPU/memory/disk refresh cost. +fn build_system() -> System { + let mut sys = System::new(); + sys.refresh_processes_specifics( + ProcessesToUpdate::All, + true, + ProcessRefreshKind::nothing() + .with_cmd(UpdateKind::Always) + .with_environ(UpdateKind::Always) + .with_cwd(UpdateKind::Always) + .with_user(UpdateKind::Always) + .with_exe(UpdateKind::Always), + ); + sys +} + +/// Read the calling process's UID via sysinfo (avoids a libc dep). Returns +/// `None` if sysinfo can't see the current process — that would imply +/// something unusual about the runtime environment and we treat it as +/// "no inspectable claudes." +fn current_uid(sys: &System) -> Option { + let pid = sysinfo::get_current_pid().ok()?; + sys.process(pid)?.user_id().cloned() +} + +/// Build a `ClaudeProcess` from a sysinfo Process, *assuming* the caller has +/// already confirmed this is a claude. Skips the name filter — used by +/// `process_for_pid` where the pid is supplied by a caller that's already +/// chosen the process. Still enforces same-UID (a hard trust boundary) and +/// drops processes whose cwd/cmd reads failed. +fn process_to_data( + p: &sysinfo::Process, + pid: Pid, + our_uid: &sysinfo::Uid, +) -> Option { + if p.user_id() != Some(our_uid) { + return None; + } + let cwd = p.cwd()?.to_string_lossy().into_owned(); + let argv: Vec = p + .cmd() + .iter() + .map(|s| s.to_string_lossy().into_owned()) + .collect(); + if argv.is_empty() { + return None; + } + let environ: BTreeMap = p + .environ() + .iter() + .filter_map(|entry| { + let s = entry.to_str()?; + let (k, v) = s.split_once('=')?; + Some((k.to_string(), v.to_string())) + }) + .collect(); + Some(ClaudeProcess { + pid: pid.as_u32(), + started_at: p.start_time(), + cwd, + argv, + environ, + }) +} + +/// Convert a sysinfo `Process` into a `ClaudeProcess` if it qualifies for +/// the discovery list — additionally requires the binary name to match +/// our claude allowlist. Used by `read_snapshot` when enumerating +/// processes; do *not* use to validate a pid the user has already picked. +fn process_to_claude( + p: &sysinfo::Process, + pid: Pid, + our_uid: &sysinfo::Uid, +) -> Option { + let name = p.name().to_str()?; + if !matches_claude_name(name) { + return None; + } + process_to_data(p, pid, our_uid) +} + +pub fn read_snapshot() -> RuntimeSnapshot { + // Windows: `sysinfo::Process::environ()` returns empty. Without environ + // the attach mode can't ground the env layer, and surfacing argv-only + // rows would be misleading. Report unsupported and leave the path-picker + // fallback as the Windows story until #11 Proposal C lands. + if cfg!(target_os = "windows") { + return RuntimeSnapshot { + processes: Vec::new(), + platform_status: PlatformStatus::Unsupported, + error: None, + }; + } + + let sys = build_system(); + let Some(our_uid) = current_uid(&sys) else { + return RuntimeSnapshot { + processes: Vec::new(), + platform_status: PlatformStatus::Error, + error: Some("could not resolve calling process UID".into()), + }; + }; + + let mut processes: Vec = sys + .processes() + .iter() + .filter_map(|(pid, p)| process_to_claude(p, *pid, &our_uid)) + .collect(); + // Stable order: started_at ascending, then pid ascending. Keeps the + // picker UI deterministic across refreshes. + processes.sort_by(|a, b| a.started_at.cmp(&b.started_at).then(a.pid.cmp(&b.pid))); + + RuntimeSnapshot { + processes, + platform_status: PlatformStatus::Ok, + error: None, + } +} + +#[tauri::command] +pub fn read_runtime_layer() -> RuntimeSnapshot { + read_snapshot() +} + +/// Look up an attached process by pid and surface the same fields +/// `read_runtime_layer` would have returned for it — cwd, argv, environ, +/// started_at. Used by `read_settings_layers` to ground the project/cli/env +/// layers in one sysinfo pass per snapshot read. +/// +/// Returns `None` if the process no longer exists or isn't owned by the +/// calling user. Callers are expected to fall back gracefully (the UI +/// surfaces "session ended"). +pub fn process_for_pid(pid: u32) -> Option { + if cfg!(target_os = "windows") { + return None; + } + let sys = build_system(); + let our_uid = current_uid(&sys)?; + let pid_obj = Pid::from_u32(pid); + // process_to_data — not process_to_claude — because by the time the + // frontend hands us a pid it's already picked one from the discovery + // list. The name filter is for discovery, not validation. + process_to_data(sys.process(pid_obj)?, pid_obj, &our_uid) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn matches_claude_name_accepts_known_names() { + assert!(matches_claude_name("claude")); + assert!(matches_claude_name("claude-code")); + } + + #[test] + fn matches_claude_name_rejects_others() { + // Case matters: kernel-level Process::name() is exact. We don't + // case-fold because that would feed "Claude.app"-style matches that + // aren't the CLI. + assert!(!matches_claude_name("Claude")); + assert!(!matches_claude_name("CLAUDE")); + assert!(!matches_claude_name("claude-helper")); + assert!(!matches_claude_name("anthropic-claude")); + assert!(!matches_claude_name("node")); + assert!(!matches_claude_name("")); + } + + #[test] + fn snapshot_has_expected_shape() { + // We can't reliably assert *which* processes will be visible — the + // test runner may or may not have a claude session running — but we + // can assert the contract: on Unix the call returns Ok status and + // never panics. Every returned process passes our same-UID + + // claude-name filter. + let snap = read_snapshot(); + if cfg!(target_os = "windows") { + assert_eq!(snap.platform_status, PlatformStatus::Unsupported); + assert!(snap.processes.is_empty()); + return; + } + assert!(matches!( + snap.platform_status, + PlatformStatus::Ok | PlatformStatus::Error + )); + for p in &snap.processes { + assert!(!p.argv.is_empty(), "argv must not be empty for an attached process"); + assert!(!p.cwd.is_empty(), "cwd must not be empty for an attached process"); + } + } + + #[test] + fn snapshot_processes_sorted_by_start_time_then_pid() { + let snap = read_snapshot(); + let mut prev: Option<(u64, u32)> = None; + for p in &snap.processes { + if let Some((pst, ppid)) = prev { + let cur = (p.started_at, p.pid); + assert!( + cur >= (pst, ppid), + "processes out of order: ({pst}, {ppid}) -> ({}, {})", + p.started_at, + p.pid, + ); + } + prev = Some((p.started_at, p.pid)); + } + } + + #[test] + fn process_for_pid_returns_none_for_unknown_pid() { + // Pid 1 belongs to init/launchd; it's not ours, so the same-UID + // filter rejects it. Returns None. + assert!(process_for_pid(1).is_none()); + } + + #[test] + fn process_for_pid_returns_none_for_nonexistent_pid() { + // Pick a pid that's almost certainly unused. u32::MAX is well above + // any real pid limit on macOS / Linux. + assert!(process_for_pid(u32::MAX).is_none()); + } + + #[test] + fn process_for_pid_returns_data_for_live_owned_process() { + if cfg!(target_os = "windows") { + return; + } + let p = process_for_pid(std::process::id()) + .expect("our own pid should resolve via sysinfo"); + // argv and cwd should be populated; environ may be large. + assert!(!p.argv.is_empty()); + assert!(!p.cwd.is_empty()); + } + + #[test] + fn current_uid_resolves_on_unix() { + if cfg!(target_os = "windows") { + return; + } + let sys = build_system(); + assert!( + current_uid(&sys).is_some(), + "could not resolve our own UID via sysinfo on a Unix host", + ); + } + + /// Manual diagnostic — print whatever attach mode would surface. + /// Run with `cargo test --lib runtime::tests::dump_snapshot -- --ignored --nocapture`. + #[test] + #[ignore] + fn dump_snapshot() { + let snap = read_snapshot(); + eprintln!("platform_status: {:?}", snap.platform_status); + eprintln!("error: {:?}", snap.error); + eprintln!("processes: {} found", snap.processes.len()); + for p in &snap.processes { + eprintln!( + " pid={} started_at={} environ_keys={} argv={:?}", + p.pid, + p.started_at, + p.environ.len(), + p.argv, + ); + eprintln!(" cwd: {}", p.cwd); + } + } +} diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 2c1cda2..5a7e0ce 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -285,19 +285,75 @@ fn home_dir() -> Option { .map(PathBuf::from) } -fn project_dir() -> Option { - std::env::current_dir().ok() -} - fn settings_path(dir: &Path, file: &str) -> PathBuf { dir.join(".claude").join(file) } -pub fn read_snapshot() -> SettingsSnapshot { +/// How the project root was supplied to a snapshot read. Mirrors the +/// attach-mode spec: an attached pid wins over a path override; both win +/// over the legacy "knobs.cc's own CWD" fallback. +pub enum ProjectSource { + /// Pre-pivot fallback: read project/project_local relative to whatever + /// directory knobs.cc itself was launched from. Useful only in tests + /// and the no-attach-no-picker default; production frontends after the + /// pivot will always supply Attached or Picked. + CurrentDir, + /// User attached to a running claude; the snapshot grounds itself in + /// that process's cwd. Carries the pid so we can emit an honest + /// diagnostic if the process is gone. + Attached(u32), + /// User picked a project directory via the path-picker fallback (no + /// claude running, or explicit override). + Picked(PathBuf), +} + +/// One-shot resolution of grounding from a `ProjectSource`. When attached, +/// fetches the full ClaudeProcess (cwd + argv + environ) so the cli + env +/// + project layers can each draw from the same sysinfo snapshot rather +/// than re-querying. +fn resolve_grounding( + source: &ProjectSource, + diagnostics: &mut Vec, +) -> (Option, Option) { + match source { + ProjectSource::CurrentDir => (std::env::current_dir().ok(), None), + ProjectSource::Attached(pid) => match crate::runtime::process_for_pid(*pid) { + Some(p) => { + let cwd = PathBuf::from(&p.cwd); + (Some(cwd), Some(p)) + } + None => { + diagnostics.push(Diagnostic { + level: DiagnosticLevel::Warn, + message: format!( + "attached claude process (pid {pid}) is no longer visible — project / cli / env layers will be empty" + ), + }); + (None, None) + } + }, + ProjectSource::Picked(path) => { + if path.is_dir() { + (Some(path.clone()), None) + } else { + diagnostics.push(Diagnostic { + level: DiagnosticLevel::Warn, + message: format!( + "picked project directory does not exist: {}", + path.display() + ), + }); + (None, None) + } + } + } +} + +pub fn read_snapshot(source: ProjectSource) -> SettingsSnapshot { let mut diagnostics = Vec::new(); - let project = project_dir(); - if project.is_none() { + let (project, attached) = resolve_grounding(&source, &mut diagnostics); + if project.is_none() && matches!(source, ProjectSource::CurrentDir) { diagnostics.push(Diagnostic { level: DiagnosticLevel::Warn, message: "could not resolve current working directory".into(), @@ -312,11 +368,30 @@ pub fn read_snapshot() -> SettingsSnapshot { }); } + // `cli` and `env` layers ground in the attached process's argv / environ + // when available; otherwise they fall back to the legacy behaviors + // (cli: Missing — no argv to read; env: knobs.cc's own process env). + let cli_layer = match attached.as_ref() { + Some(p) => crate::cli_layer::read_cli_layer(&p.argv), + None => LayerRead { + source: LayerSource::Cli, + path: None, + status: LayerStatus::Missing, + raw: None, + error: None, + }, + }; + let env_layer = match attached.as_ref() { + Some(p) => crate::env_layer::read_env_layer_attached(&p.environ), + None => crate::env_layer::read_env_layer(), + }; + // Highest precedence first — matches the public API order. The merge below // walks them in reverse so higher-precedence values win. let layers = vec![ crate::managed_layer::read_managed_layer(), - crate::env_layer::read_env_layer(), + cli_layer, + env_layer, read_layer( LayerSource::ProjectLocal, project @@ -358,9 +433,31 @@ pub fn read_snapshot() -> SettingsSnapshot { } } -#[tauri::command] -pub fn read_settings_layers() -> SettingsSnapshot { - read_snapshot() +/// The Tauri command. Frontend supplies one of: +/// - `attached_pid` — preferred, grounds the snapshot in a running claude. +/// - `project_root_override` — fallback for the no-claude / path-picker flow. +/// - neither — legacy "knobs.cc's own CWD" behavior, kept so existing +/// integration smoke tests don't break during migration. +/// +/// If both are supplied, `attached_pid` wins per the attach-mode spec. +/// +/// `rename_all = "snake_case"` is load-bearing: Tauri 2 defaults to +/// camelCase for JS-side command args, but the rest of this project's +/// wire format is snake_case (matching the SettingsSnapshot return shape +/// via `#[serde(rename_all = "snake_case")]`). Without this attribute, +/// `attached_pid: 75618` from JS silently deserialized to `None` and the +/// snapshot fell back to `ProjectSource::CurrentDir`. +#[tauri::command(rename_all = "snake_case")] +pub fn read_settings_layers( + attached_pid: Option, + project_root_override: Option, +) -> SettingsSnapshot { + let source = match (attached_pid, project_root_override) { + (Some(pid), _) => ProjectSource::Attached(pid), + (None, Some(path)) => ProjectSource::Picked(PathBuf::from(path)), + (None, None) => ProjectSource::CurrentDir, + }; + read_snapshot(source) } #[cfg(test)] @@ -663,4 +760,166 @@ mod tests { assert_eq!(layer.raw.unwrap(), json!({ "model": "opus" })); std::fs::remove_dir_all(&dir).ok(); } + + // ---- Grounding (ProjectSource) ----------------------------------- + + fn temp_project_with_settings(model: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "knobs-cc-grounding-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(), + )); + let claude_dir = dir.join(".claude"); + std::fs::create_dir_all(&claude_dir).unwrap(); + std::fs::write( + claude_dir.join("settings.json"), + format!(r#"{{ "model": "{model}" }}"#), + ) + .unwrap(); + dir + } + + fn find_layer<'a>( + snap: &'a SettingsSnapshot, + source: LayerSource, + ) -> &'a LayerRead { + snap.layers + .iter() + .find(|l| matches!((l.source, source), (a, b) if std::mem::discriminant(&a) == std::mem::discriminant(&b))) + .expect("layer present") + } + + #[test] + fn picked_project_dir_grounds_project_layer() { + let project = temp_project_with_settings("opus"); + let snap = read_snapshot(ProjectSource::Picked(project.clone())); + let layer = find_layer(&snap, LayerSource::Project); + assert!(matches!(layer.status, LayerStatus::Ok)); + assert_eq!(layer.raw.as_ref().unwrap()["model"], json!("opus")); + let p = layer.path.as_ref().unwrap(); + assert!( + p.contains(&project.to_string_lossy().to_string()), + "expected project path to contain the picked dir; got {p}", + ); + assert_eq!(snap.project_root.as_deref(), Some(&*project.to_string_lossy())); + std::fs::remove_dir_all(&project).ok(); + } + + #[test] + fn picked_nonexistent_dir_emits_diagnostic_and_skips_layer() { + let bogus = PathBuf::from("/nonexistent/path/that/will/never/exist"); + let snap = read_snapshot(ProjectSource::Picked(bogus.clone())); + // Project layer falls back to "no path" — read_layer treats that as + // Missing without an error, but we expect a diagnostic naming the + // bogus dir so the user can correct. + assert!( + snap.diagnostics + .iter() + .any(|d| d.message.contains("picked project directory does not exist")), + "expected a diagnostic about the bogus directory; got {:?}", + snap.diagnostics + .iter() + .map(|d| &d.message) + .collect::>(), + ); + let layer = find_layer(&snap, LayerSource::Project); + assert!(matches!(layer.status, LayerStatus::Missing)); + } + + #[test] + fn attached_pid_for_unknown_process_emits_diagnostic() { + // u32::MAX is reliably an unused pid; resolve_project_dir routes + // that through runtime::cwd_for_pid which returns None. + let snap = read_snapshot(ProjectSource::Attached(u32::MAX)); + assert!( + snap.diagnostics + .iter() + .any(|d| d.message.contains("no longer visible")), + "expected a diagnostic about the missing attached process; got {:?}", + snap.diagnostics + .iter() + .map(|d| &d.message) + .collect::>(), + ); + let layer = find_layer(&snap, LayerSource::Project); + assert!(matches!(layer.status, LayerStatus::Missing)); + } + + fn cli_layer<'a>(snap: &'a SettingsSnapshot) -> &'a LayerRead { + snap.layers + .iter() + .find(|l| matches!(l.source, LayerSource::Cli)) + .expect("cli layer slot must be present in every snapshot") + } + + #[test] + fn cli_layer_missing_when_grounding_isnt_attached() { + let snap = read_snapshot(ProjectSource::CurrentDir); + let l = cli_layer(&snap); + assert!( + matches!(l.status, LayerStatus::Missing), + "expected Missing for current-dir grounding; got {:?}", + l.status, + ); + // Picked grounding likewise has no process to read argv from. + let snap = read_snapshot(ProjectSource::Picked(std::env::temp_dir())); + let l = cli_layer(&snap); + assert!(matches!(l.status, LayerStatus::Missing)); + } + + #[test] + fn cli_layer_ok_when_attached_to_live_process() { + // The test runner's argv doesn't contain claude flags, so the cli + // layer will be Ok with an empty `raw` — but the slot must be Ok, + // not Missing, and the rail row must un-grey. + let snap = read_snapshot(ProjectSource::Attached(std::process::id())); + let l = cli_layer(&snap); + assert!( + matches!(l.status, LayerStatus::Ok), + "expected Ok for attached grounding; got {:?}", + l.status, + ); + assert!(l.raw.is_some(), "cli layer raw must be set when Ok"); + } + + #[test] + fn attached_pid_for_live_process_resolves_project_root() { + // The runtime layer's cwd_for_pid filter is "same UID + cwd + // readable" — it doesn't require the target to be named claude + // (that filter runs at discovery time in read_runtime_layer). + // Using our own pid is the cheapest way to exercise the live + // resolution path end-to-end. + let our_pid = std::process::id(); + let snap = read_snapshot(ProjectSource::Attached(our_pid)); + // The test runner's cwd is the project_root we should have read. + let cwd = std::env::current_dir().unwrap(); + assert_eq!( + snap.project_root.as_deref(), + Some(&*cwd.to_string_lossy()), + "attached snapshot should ground in the live process's cwd", + ); + // No "no longer visible" diagnostic — the process is us. + assert!( + !snap.diagnostics.iter().any(|d| d.message.contains("no longer visible")), + "live pid should not produce a missing-process diagnostic", + ); + } + + #[test] + fn current_dir_fallback_matches_legacy_behavior() { + // The pre-pivot behavior: project root resolves to whatever + // std::env::current_dir() returns. We don't assert specific paths + // (tests run in unpredictable cwd), only that the snapshot has a + // project_root set when env::current_dir is resolvable. + let snap = read_snapshot(ProjectSource::CurrentDir); + let cwd = std::env::current_dir().unwrap(); + assert_eq!( + snap.project_root.as_deref(), + Some(&*cwd.to_string_lossy()), + "current-dir grounding should match std::env::current_dir() output", + ); + } } diff --git a/src/App.tsx b/src/App.tsx index b8abf79..6000057 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,10 +1,20 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import { InspectorShell } from "@/components/inspector/InspectorShell"; import { loadCatalog } from "@/lib/catalog"; import { installGlobalHandlers } from "@/lib/errorLog"; -import type { SettingsSnapshot } from "@/types"; +import { pickProjectDirectory } from "@/lib/openPath"; +import { + deriveSessionGrounding, + groundingToInvokeArgs, + readRuntimeLayer, +} from "@/lib/runtime"; +import type { + RuntimeSnapshot, + SessionGrounding, + SettingsSnapshot, +} from "@/types"; // Coalesce window for `settings-changed` bursts. Editors typically write a // settings file as a tempfile rename — that's two events back-to-back, plus @@ -14,17 +24,35 @@ const REFRESH_DEBOUNCE_MS = 250; function App() { const [snapshot, setSnapshot] = useState(null); + const [runtimeSnapshot, setRuntimeSnapshot] = useState( + null, + ); + // Persisted across runtime refreshes so the user's selection survives a + // rescan of the process list. Cleared if the underlying claude exits — + // deriveSessionGrounding handles that transition. + const [selectedPid, setSelectedPid] = useState(null); + const [pickedRoot, setPickedRoot] = useState(null); const [error, setError] = useState(null); + const grounding: SessionGrounding = useMemo(() => { + if (runtimeSnapshot === null) return { kind: "loading" }; + return deriveSessionGrounding(runtimeSnapshot, selectedPid, pickedRoot); + }, [runtimeSnapshot, selectedPid, pickedRoot]); + const refresh = useCallback(async () => { try { // Catalog is idempotent after first load — the await is a no-op on - // refresh. Pair it with the snapshot read so a cold start doesn't - // race the inspector against an unloaded catalog. - const [, next] = await Promise.all([ - loadCatalog(), - invoke("read_settings_layers"), - ]); + // refresh. Pair it with the runtime read so a cold start doesn't + // race the inspector against an unloaded catalog or unknown + // grounding state. + const [, runtime] = await Promise.all([loadCatalog(), readRuntimeLayer()]); + setRuntimeSnapshot(runtime); + // Derive grounding from the fresh runtime to pick the right + // invoke args. Reading prior state here rather than relying on the + // memoized `grounding` avoids one round-trip of stale state. + const g = deriveSessionGrounding(runtime, selectedPid, pickedRoot); + const args = groundingToInvokeArgs(g); + const next = await invoke("read_settings_layers", args); setSnapshot(next); setError(null); } catch (e) { @@ -36,7 +64,7 @@ function App() { return prev; }); } - }, []); + }, [selectedPid, pickedRoot]); useEffect(() => { void refresh(); @@ -64,6 +92,35 @@ function App() { }; }, [refresh]); + // Window-focus refresh per spec/attach-mode.md "Refresh cadence" — common + // UX expectation when the user alt-tabs back from a terminal where they + // just started or stopped claude. Browser-level focus event works in the + // WebView; no Rust-side bridge needed. + useEffect(() => { + const onFocus = () => void refresh(); + window.addEventListener("focus", onFocus); + return () => window.removeEventListener("focus", onFocus); + }, [refresh]); + + const onAttach = useCallback((pid: number) => { + // Attaching supersedes any previously-picked root — the spec's + // precedence: attached_pid wins over project_root_override. + setSelectedPid(pid); + setPickedRoot(null); + }, []); + + const onPickRoot = useCallback(async () => { + const selected = await pickProjectDirectory(); + if (selected !== null) { + setPickedRoot(selected); + setSelectedPid(null); + } + }, []); + + const onClearRoot = useCallback(() => { + setPickedRoot(null); + }, []); + if (error) { return (
@@ -92,7 +149,17 @@ function App() { ); } - return void refresh()} />; + return ( + void refresh()} + /> + ); } export default App; diff --git a/src/components/inspector/EnvVarsPanel.tsx b/src/components/inspector/EnvVarsPanel.tsx index 392d6ff..98a58ff 100644 --- a/src/components/inspector/EnvVarsPanel.tsx +++ b/src/components/inspector/EnvVarsPanel.tsx @@ -25,18 +25,36 @@ import { isRegistryPath } from "./WaterfallRow"; const CHIP_LABELS: Record = { all: "all", set: "set", + attached: "attached", shell: "shell", settings: "settings.json", + diff: "Δ diff", unset: "unset", }; -const CHIPS: EnvVarChip[] = ["all", "set", "shell", "settings", "unset"]; +// Order matters: `attached` and `diff` sit between `set` and `shell` so the +// attach-mode lens is prominent when active. Both stay hidden until a +// claude is attached — there's no value in a "0 attached" chip cluttering +// the toolbar when the user has no claude to compare against. +const BASE_CHIPS: EnvVarChip[] = ["all", "set", "shell", "settings", "unset"]; +const ATTACH_CHIPS: EnvVarChip[] = [ + "all", + "set", + "attached", + "diff", + "shell", + "settings", + "unset", +]; export function EnvVarsPanel({ snapshot, + attachedEnv, onClose, }: { snapshot: SettingsSnapshot; + /** The attached claude's environ, or null when not attached. */ + attachedEnv: Readonly> | null; onClose: () => void; }) { const [shellEnv, setShellEnv] = useState | null>(null); @@ -97,11 +115,15 @@ export function EnvVarsPanel({ }, []); const allRows = useMemo( - () => (shellEnv ? buildEnvVarRows(catalog, snapshot, shellEnv) : []), - [catalog, snapshot, shellEnv], + () => + shellEnv + ? buildEnvVarRows(catalog, snapshot, shellEnv, attachedEnv) + : [], + [catalog, snapshot, shellEnv, attachedEnv], ); const counts = useMemo(() => envVarChipCounts(allRows), [allRows]); + const chips = attachedEnv ? ATTACH_CHIPS : BASE_CHIPS; const visibleRows = useMemo( () => applyEnvVarFilter(applyEnvVarChip(allRows, chip), filter), [allRows, chip, filter], @@ -146,6 +168,7 @@ export function EnvVarsPanel({ chip={chip} onChipChange={setChip} counts={counts} + chips={chips} filterRef={filterRef} /> @@ -176,6 +199,7 @@ function Toolbar({ chip, onChipChange, counts, + chips, filterRef, }: { filter: string; @@ -183,6 +207,7 @@ function Toolbar({ chip: EnvVarChip; onChipChange: (c: EnvVarChip) => void; counts: Record; + chips: EnvVarChip[]; filterRef: React.RefObject; }) { return ( @@ -196,7 +221,7 @@ function Toolbar({ className="w-72 rounded-sm border border-line-strong bg-bg-1 px-2 py-1 font-mono text-[12px] text-fg-1 placeholder:text-fg-4 focus:border-accent focus:outline-none" />
- {CHIPS.map((c) => ( + {chips.map((c) => ( @@ -406,11 +446,25 @@ function ValueChip({ } function SourceTag({ source }: { source: EnvVarSource }) { + if (source === "attached") { + // Greenish accent: this is the ground-truth value (the running claude's + // actual environ). Distinct from `shell` so the diff is legible at a + // glance — if you see `attached` and `shell` side-by-side with + // different values, that's the divergence story attach mode tells. + return ( + + attached + + ); + } if (source === "shell") { return ( shell @@ -562,11 +616,12 @@ function shortenPath(path: string): string { function Footnote() { return (

- Shell values reflect the environment knobs.cc was launched with — - usually the same env Claude Code would inherit from your shell, but - Finder/Spotlight launches use LaunchServices' env, which can differ. - Dotenv files (.env) Claude Code reads at startup are not - shown here. + When attached to a claude session, the attached column is + the literal environ of that process — ground truth for what claude + sees. The shell column is knobs.cc's own process env; + it's a useful proxy when no claude is running and a diagnostic when + both are set (Finder/Spotlight launches use LaunchServices' env, + which can diverge from a terminal shell).

); } diff --git a/src/components/inspector/HelpView.tsx b/src/components/inspector/HelpView.tsx index 4ce9769..7838a11 100644 --- a/src/components/inspector/HelpView.tsx +++ b/src/components/inspector/HelpView.tsx @@ -18,7 +18,7 @@ const SHORTCUTS: ReadonlyArray<{ keys: string[]; action: string; note?: string } const LAYER_DESCRIPTIONS: Record = { managed: "Enterprise / MDM-deployed policy. Highest precedence — designed for admins to pin settings users can't override.", - cli: "Flags on the running `claude` process (e.g. --model, --mcp-config). Not inspectable from a sibling app in v1.", + cli: "Flags on the attached `claude` process's argv (e.g. --model, --permission-mode). Documented mappings live in `catalog/cli-settings-map.json`.", env: "Process environment variables that override settings keys (e.g. ANTHROPIC_MODEL → `model`). Mapping table at `catalog/env-settings-map.json` — env-only vars without a settings equivalent aren't surfaced here.", project_local: "`/.claude/settings.local.json` — your machine's overrides for this project, gitignored by convention.", @@ -234,6 +234,13 @@ function AboutSection() { you've set, and which layer wins. Every row in the centre pane is a key. Every column tells you where its value came from.

+

+ Pick a session from the topbar pill to ground the inspector against + a running claude process — + that resolves the cli, env, and project layers against the session. + If no claude is running, pick a project directory instead and the + file-based layers resolve against that. +

v1 is read-only — there is no write path and no way to edit settings through the app. The full catalog of config surfaces lives in{" "} @@ -263,7 +270,7 @@ function Kbd({ children }: { children: React.ReactNode }) { const SHORT_BADGE_NOTE: Record = { managed: "Enterprise / MDM policy", - cli: "CLI flags · not inspectable in v1", + cli: "CLI flags · from attached process argv", env: "Process env (mapped vars)", project_local: ".claude/settings.local.json", project: ".claude/settings.json", diff --git a/src/components/inspector/InspectorShell.tsx b/src/components/inspector/InspectorShell.tsx index 5753a0e..1bcbae5 100644 --- a/src/components/inspector/InspectorShell.tsx +++ b/src/components/inspector/InspectorShell.tsx @@ -1,5 +1,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import type { SettingsSnapshot } from "@/types"; +import type { + RuntimeSnapshot, + SessionGrounding, + SettingsSnapshot, +} from "@/types"; import { buildRows } from "@/lib/rows"; import { EnvVarsPanel } from "./EnvVarsPanel"; import { ErrorPanel } from "./ErrorPanel"; @@ -18,11 +22,27 @@ function isTextInput(el: Element | null): boolean { export function InspectorShell({ snapshot, + grounding, + runtimeSnapshot, + onAttach, + onPickRoot, + onClearRoot, onRefresh, }: { snapshot: SettingsSnapshot; + grounding: SessionGrounding; + runtimeSnapshot: RuntimeSnapshot | null; + onAttach: (pid: number) => void; + onPickRoot: () => void; + onClearRoot: () => void; onRefresh?: () => void; }) { + // Pulled here (rather than at each consumer) so the EnvVarsPanel and any + // future "running claude env" UI see the same snapshot. + const attachedEnv = useMemo( + () => (grounding.kind === "attached" ? grounding.process.environ : null), + [grounding], + ); const [activeKeyPath, setActiveKeyPath] = useState(null); const [helpOpen, setHelpOpen] = useState(false); const [errorsOpen, setErrorsOpen] = useState(false); @@ -168,6 +188,11 @@ export function InspectorShell({

setHelpOpen(true)} onShowErrors={() => setErrorsOpen(true)} @@ -177,6 +202,7 @@ export function InspectorShell({
setEnvVarsOpen(false)} /> )} diff --git a/src/components/inspector/KeyDrawer.tsx b/src/components/inspector/KeyDrawer.tsx index 274e558..176a7ba 100644 --- a/src/components/inspector/KeyDrawer.tsx +++ b/src/components/inspector/KeyDrawer.tsx @@ -33,7 +33,12 @@ export function KeyDrawer({ }) { const formatted = formatValue(row.value); const description = resolveDescription(row); - const isArrayMerged = row.state === "array-merged"; + // Drive the drawer body decision on element presence, not row state: + // single-contributor array-typed rows are state="set" (so the centre + // list shows a normal source badge instead of MERGED) but still need + // the per-element list rather than a layer waterfall, since each rule + // in the array carries its own provenance. + const hasElements = row.elements !== undefined; // Look up siblings via the catalog and join with current row state so the // section can show set-vs-unset hints. Memoized on snapshot/row so we @@ -54,7 +59,7 @@ export function KeyDrawer({
- {isArrayMerged ? ( + {hasElements ? ( ) : ( @@ -255,34 +260,45 @@ function EffectiveBlock({ return (
Effective -
- - - {formatted.text} - - +
+
+ + + {formatted.text} + {row.winner ? ( - <> + wins - + ) : ( - merged across {row.contributors.length} layers + + merged · {row.contributors.length}{" "} + {row.contributors.length === 1 ? "layer" : "layers"} + )} - +
+ {!row.winner && row.contributors.length > 0 && ( +
+ from + {row.contributors.map((source) => ( + + ))} +
+ )}
{annotation ? (
> = { managed: "no MDM policy detected", - cli: "not inspectable from sibling proc", + // cli's missing-state copy depends on grounding: when not attached, the + // honest message is "no attached claude." When attached but argv had no + // mapped flags, the rail renders the layer as Ok with count 0 (see env's + // empty-raw branch for the precedent). + cli: "no attached claude (argv unavailable)", env: "no mapped env vars set", default: "catalog (compiled-in)", }; @@ -42,33 +47,41 @@ function unreachableLayerDetail(source: LayerSource): string { } } -// Layers we can't faithfully attribute to the user's claude session yet. -// `cli` has no live process to read argv from (tracked in #11); `project` -// and `project_local` resolve relative to knobs.cc's own CWD rather than -// the user's chosen claude project (tracked in #12). Greying these out in -// the rail prevents users from trusting values that came from an -// unrelated dir. -const UNGROUNDED_LAYERS: ReadonlySet = new Set([ - "cli", - "project", - "project_local", -]); +/** Which layers should be greyed in the rail for the current grounding. */ +function ungroundedLayersFor( + grounding: SessionGrounding, +): ReadonlySet { + switch (grounding.kind) { + case "attached": + // Attached → cli, env, project, project_local all grounded against + // the live process. No row needs to be greyed for ungrounding. + return new Set(); + case "no-claude": + if (grounding.pickedRoot) { + // Path picker → project files grounded against the picked dir; + // cli still has no process to read argv from. + return new Set(["cli"]); + } + // No grounding source at all: cli + project files all ungrounded. + return new Set(["cli", "project", "project_local"]); + case "unsupported": + case "loading": + return new Set(["cli", "project", "project_local"]); + } +} function buildRow( source: LayerSource, layer: LayerRead | undefined, defaultCount: number, + ungrounded: ReadonlySet, ): RailRow { - // Tack `disabled: true` onto every row for an ungrounded layer so the - // greyout applies regardless of which status branch the layer hits - // (ok / missing / error / not-read). Wrapping here avoids drift if a - // future branch forgets the field — which is exactly how #12 slipped - // past first review. For project/project_local we also overwrite the - // detail line: the underlying path is knobs.cc's own CWD, so showing - // it suggests a real, authoritative project entry. Cli is unchanged — - // its ABSENT_DETAIL message already reads correctly. + // Tack `disabled: true` onto every ungrounded row so the greyout applies + // regardless of which status branch the layer hits (ok / missing / error + // / not-read). Wrapping here avoids drift if a future branch forgets the + // field — which is exactly how #12 slipped past first review. const row = buildRowCore(source, layer, defaultCount); - if (UNGROUNDED_LAYERS.has(source)) { + if (ungrounded.has(source)) { row.disabled = true; if (source === "project" || source === "project_local") { row.detail = "knobs.cc's launch dir, not your claude session"; @@ -137,6 +150,19 @@ function buildRowCore( }; } + // cli is a real layer when attached. Mirror env's pattern — describe the + // source rather than show "—" for an empty raw. (When unattached the + // layer status is Missing, handled above.) + if (source === "cli") { + const setCount = countTopLevelKeys(layer.raw); + return { + source, + dot: setCount > 0 ? "ok" : "empty", + detail: setCount > 0 ? "process argv" : "no mapped flags in argv", + count: setCount, + }; + } + return { source, dot: "ok", @@ -147,15 +173,18 @@ function buildRowCore( export function PrecedenceRail({ snapshot, + grounding, activeWinner, }: { snapshot: SettingsSnapshot; + grounding: SessionGrounding; activeWinner?: LayerSource | null; }) { const byKey = new Map(snapshot.layers.map((l) => [l.source, l] as const)); const defaultCount = buildRows(snapshot).filter((r) => r.state === "unset").length; + const ungrounded = ungroundedLayersFor(grounding); const rows = LAYERS_IN_PRECEDENCE_ORDER.map((src) => - buildRow(src, byKey.get(src), defaultCount), + buildRow(src, byKey.get(src), defaultCount, ungrounded), ); return ( diff --git a/src/components/inspector/SessionPill.tsx b/src/components/inspector/SessionPill.tsx new file mode 100644 index 0000000..a40e1f2 --- /dev/null +++ b/src/components/inspector/SessionPill.tsx @@ -0,0 +1,270 @@ +/** + * Session pill — the primary attach affordance in the topbar. + * See `spec/attach-mode.md` § "UX states". + * + * Three states by process count: + * - 0 processes → "no claude · pick a project ▾" (path picker fallback) + * - 1 process → auto-attached, pill shows pid + cwd + * - 2+ processes → "pick session ▾" with the picker + * + * Plus two structural states orthogonal to count: + * - unsupported (Windows v1) — pill is informational + path picker open + * - loading — pill renders as a low-key "detecting…" + */ +import { useEffect, useRef, useState } from "react"; +import type { ClaudeProcess, RuntimeSnapshot, SessionGrounding } from "@/types"; +import { shortLabel, tildify } from "@/lib/runtime"; +import { StatusDot } from "./StatusDot"; + +export function SessionPill({ + grounding, + runtimeSnapshot, + onAttach, + onPickRoot, + onClearRoot, +}: { + grounding: SessionGrounding; + runtimeSnapshot: RuntimeSnapshot | null; + onAttach: (pid: number) => void; + /** Opens the native folder picker. */ + onPickRoot: () => void; + /** Clear a previously-picked root and return to "no claude" state. */ + onClearRoot: () => void; +}) { + const [open, setOpen] = useState(false); + const containerRef = useRef(null); + + // Close the dropdown when the user clicks outside. + useEffect(() => { + if (!open) return; + const onDocClick = (e: MouseEvent) => { + if (!containerRef.current?.contains(e.target as Node)) { + setOpen(false); + } + }; + document.addEventListener("mousedown", onDocClick); + return () => document.removeEventListener("mousedown", onDocClick); + }, [open]); + + const processes = runtimeSnapshot?.processes ?? []; + const home = homeFromGrounding(grounding, processes); + + const label = labelFor(grounding, processes.length); + const dotVariant = dotFor(grounding); + + return ( +
+ + + {open && ( +
+ {grounding.kind === "unsupported" && ( +
+ Attach mode isn't supported on this platform yet — Windows + support is deferred until the Unix version proves out. The + path picker below still works. +
+ )} + + {processes.length > 0 && ( +
    + {processes.map((p) => ( + { + onAttach(p.pid); + setOpen(false); + }} + /> + ))} +
+ )} +
+ + {grounding.kind === "no-claude" && grounding.pickedRoot && ( + + )} +
+
+ )} +
+ ); +} + +function PickerHeader({ + processes, + grounding, +}: { + processes: ClaudeProcess[]; + grounding: SessionGrounding; +}) { + let msg: string; + if (grounding.kind === "unsupported") { + msg = "Unsupported platform"; + } else if (processes.length === 0) { + msg = "No claude processes detected"; + } else if (processes.length === 1) { + msg = "1 claude session running"; + } else { + msg = `${processes.length} claude sessions running`; + } + return ( +
+ {msg} +
+ ); +} + +function ProcessRow({ + process, + home, + selected, + onClick, +}: { + process: ClaudeProcess; + home: string | null; + selected: boolean; + onClick: () => void; +}) { + const argSummary = process.argv.length > 1 + ? ` · argv: ${process.argv.slice(1, 4).join(" ")}${process.argv.length > 4 ? " …" : ""}` + : ""; + return ( +
  • + +
  • + ); +} + +/** + * Best-effort HOME extraction. Used for tildifying paths in the pill UI. + * If we have an attached process, prefer its environ.HOME — that's the + * exact value resolved at exec time for *that* session. Otherwise fall + * back to scanning the first available process's environ; if no process + * is available either, return null and let paths render verbatim. + */ +function homeFromGrounding( + grounding: SessionGrounding, + processes: ClaudeProcess[], +): string | null { + if (grounding.kind === "attached" && grounding.process.environ.HOME) { + return grounding.process.environ.HOME; + } + const first = processes[0]; + return first?.environ.HOME ?? null; +} + +function labelFor(grounding: SessionGrounding, count: number): string { + switch (grounding.kind) { + case "loading": + return "detecting…"; + case "unsupported": + return "attach unsupported"; + case "attached": { + const home = grounding.process.environ.HOME ?? null; + return shortLabel(grounding.process, home); + } + case "no-claude": + if (grounding.pickedRoot) { + // Tildify needs HOME; we don't have it without a process to read + // from. Show the raw path — predictable beats half-clever. + return `picked · ${truncatePath(grounding.pickedRoot)}`; + } + if (count === 0) return "no claude · pick a project"; + return `pick session (${count} running)`; + } +} + +function titleFor(grounding: SessionGrounding, count: number): string { + switch (grounding.kind) { + case "loading": + return "Detecting running claude processes…"; + case "unsupported": + return "Attach mode isn't supported on this platform. Use the path picker to ground the inspector against a directory."; + case "attached": + return `Inspecting claude PID ${grounding.pid} (cwd: ${grounding.process.cwd}). Click to switch sessions or pick a directory.`; + case "no-claude": + if (grounding.pickedRoot) { + return `Inspecting ${grounding.pickedRoot}. Click to attach to a running claude or pick a different directory.`; + } + if (count === 0) { + return "No claude processes detected. Click to pick a project directory."; + } + return `${count} claude processes detected. Click to pick one to inspect.`; + } +} + +function dotFor(grounding: SessionGrounding): "ok" | "warn" | "err" | "empty" { + switch (grounding.kind) { + case "attached": + return "ok"; + case "no-claude": + return grounding.pickedRoot ? "ok" : "warn"; + case "unsupported": + return "warn"; + case "loading": + return "empty"; + } +} + +function truncatePath(p: string, max = 40): string { + if (p.length <= max) return p; + const tail = p.slice(p.length - (max - 1)); + return `…${tail}`; +} diff --git a/src/components/inspector/Topbar.tsx b/src/components/inspector/Topbar.tsx index f4ad2a5..37541dd 100644 --- a/src/components/inspector/Topbar.tsx +++ b/src/components/inspector/Topbar.tsx @@ -1,18 +1,34 @@ import { useSyncExternalStore } from "react"; -import { LAYERS_IN_PRECEDENCE_ORDER, type SettingsSnapshot } from "@/types"; +import { + LAYERS_IN_PRECEDENCE_ORDER, + type RuntimeSnapshot, + type SessionGrounding, + type SettingsSnapshot, +} from "@/types"; import { describeMcpPolicy } from "@/lib/managedMcp"; import { openInEditor } from "@/lib/openPath"; import { getEntries, getUnseenCount, subscribe } from "@/lib/errorLog"; +import { SessionPill } from "./SessionPill"; import { StatusDot } from "./StatusDot"; export function Topbar({ snapshot, + grounding, + runtimeSnapshot, + onAttach, + onPickRoot, + onClearRoot, onRefresh, onHelp, onShowErrors, onShowEnvVars, }: { snapshot: SettingsSnapshot; + grounding: SessionGrounding; + runtimeSnapshot: RuntimeSnapshot | null; + onAttach: (pid: number) => void; + onPickRoot: () => void; + onClearRoot: () => void; onRefresh?: () => void; onHelp?: () => void; onShowErrors?: () => void; @@ -52,6 +68,13 @@ export function Topbar({
    + 0 ? "ok" : "empty"} /> {okLayers}/{totalLayers} layers diff --git a/src/lib/envVars.test.ts b/src/lib/envVars.test.ts index a552640..b00a814 100644 --- a/src/lib/envVars.test.ts +++ b/src/lib/envVars.test.ts @@ -226,6 +226,71 @@ describe("buildEnvVarRows", () => { .toEqual({ value: "true", source: "user" }); }); + it("records an attached contributor when supplied", () => { + const rows = buildEnvVarRows(catalog, makeSnapshot([]), {}, { + ANTHROPIC_API_KEY: "from-attached", + }); + const row = rows.find((r) => r.name === "ANTHROPIC_API_KEY")!; + expect(row.contributors).toEqual([ + { source: "attached", value: "from-attached", path: null }, + ]); + expect(row.effective).toEqual({ + value: "from-attached", + source: "attached", + }); + }); + + it("attached wins over shell when both supply a value", () => { + const rows = buildEnvVarRows( + catalog, + makeSnapshot([]), + { ANTHROPIC_API_KEY: "from-shell" }, + { ANTHROPIC_API_KEY: "from-attached" }, + ); + const row = rows.find((r) => r.name === "ANTHROPIC_API_KEY")!; + expect(row.effective).toEqual({ + value: "from-attached", + source: "attached", + }); + expect(row.contributors.map((c) => c.source)).toEqual([ + "attached", + "shell", + ]); + }); + + it("attached, shell, settings layers all surface as separate contributors", () => { + const snap = makeSnapshot([ + { + source: "user", + path: "/u/.claude/settings.json", + status: "ok", + raw: { env: { ANTHROPIC_API_KEY: "from-settings" } }, + error: null, + }, + ]); + const rows = buildEnvVarRows( + catalog, + snap, + { ANTHROPIC_API_KEY: "from-shell" }, + { ANTHROPIC_API_KEY: "from-attached" }, + ); + const row = rows.find((r) => r.name === "ANTHROPIC_API_KEY")!; + expect(row.contributors.map((c) => ({ s: c.source, v: c.value }))).toEqual([ + { s: "attached", v: "from-attached" }, + { s: "shell", v: "from-shell" }, + { s: "user", v: "from-settings" }, + ]); + }); + + it("null attachedEnv leaves the contributor list unchanged from the legacy path", () => { + // The pre-attach-mode signature took 3 args. Passing null for the + // 4th arg should produce identical rows. + const snap = makeSnapshot([]); + const a = buildEnvVarRows(catalog, snap, { ANTHROPIC_API_KEY: "x" }); + const b = buildEnvVarRows(catalog, snap, { ANTHROPIC_API_KEY: "x" }, null); + expect(a).toEqual(b); + }); + it("ignores object/array/null env values (no useful string form)", () => { const snap = makeSnapshot([ { @@ -423,3 +488,56 @@ describe("filtering", () => { expect(applyEnvVarFilter(rows, "")).toHaveLength(catalog.length); }); }); + +describe("attach-mode chips: attached / diff", () => { + // Three vars set in this fixture: + // - ANTHROPIC_API_KEY: same value in shell + attached (no diff) + // - ANTHROPIC_BASE_URL: different value in shell vs attached (diff) + // - CLAUDE_CODE_DEBUG_LOG_LEVEL: only in attached (no shell value) + const fixture = buildEnvVarRows( + catalog, + makeSnapshot([]), + { + ANTHROPIC_API_KEY: "sk-shared", + ANTHROPIC_BASE_URL: "https://shell-proxy", + }, + { + ANTHROPIC_API_KEY: "sk-shared", + ANTHROPIC_BASE_URL: "https://attached-proxy", + CLAUDE_CODE_DEBUG_LOG_LEVEL: "debug", + }, + ); + + it("counts attached separately from shell", () => { + const c = envVarChipCounts(fixture); + expect(c.attached).toBe(3); + expect(c.shell).toBe(2); + expect(c.set).toBe(3); + }); + + it("counts diff only when both sides are set and values differ", () => { + const c = envVarChipCounts(fixture); + // BASE_URL is the only one with diverging shell + attached values. + expect(c.diff).toBe(1); + }); + + it("attached chip filters to rows with an attached contributor", () => { + const filtered = applyEnvVarChip(fixture, "attached"); + expect(filtered.map((r) => r.name).sort()).toEqual([ + "ANTHROPIC_API_KEY", + "ANTHROPIC_BASE_URL", + "CLAUDE_CODE_DEBUG_LOG_LEVEL", + ]); + }); + + it("diff chip filters to rows where shell and attached differ", () => { + const filtered = applyEnvVarChip(fixture, "diff"); + expect(filtered.map((r) => r.name)).toEqual(["ANTHROPIC_BASE_URL"]); + }); + + it("settings chip excludes attached and shell contributors", () => { + // None of the rows in this fixture come from settings.json layers. + const filtered = applyEnvVarChip(fixture, "settings"); + expect(filtered).toHaveLength(0); + }); +}); diff --git a/src/lib/envVars.ts b/src/lib/envVars.ts index d712081..af0bf42 100644 --- a/src/lib/envVars.ts +++ b/src/lib/envVars.ts @@ -14,8 +14,21 @@ import type { EnvVarEntry } from "./catalog"; import type { LayerRead, LayerSource, SettingsSnapshot } from "@/types"; import { LAYERS_IN_PRECEDENCE_ORDER } from "@/types"; -/** Source that contributed a value for a given env var. */ -export type EnvVarSource = "shell" | LayerSource; +/** Source that contributed a value for a given env var. + * + * - "attached": from the running claude's actual environ, read via attach + * mode (`runtime_layer.processes[*].environ`). The literal ground-truth + * for what claude sees right now; only present when knobs.cc is attached. + * - "shell": from knobs.cc's own process env. The shell-launched proxy + * for "what claude would inherit if started from the same shell" — + * useful for diagnosing Finder/Spotlight vs terminal env divergence. + * - Settings layers: from `env.` in a settings.json layer's blob. + * + * Precedence for the *effective* value: attached > shell > settings + * layers in their precedence order. Attached wins when present because + * it's literally claude's process env at this moment. + */ +export type EnvVarSource = "attached" | "shell" | LayerSource; export interface EnvVarContributor { /** "shell" = process env; otherwise a settings layer that has `env.`. */ @@ -104,20 +117,23 @@ function envValueFromLayer(layer: LayerRead, name: string): string | null { /** * Build one EnvVarRow per catalog entry. Caller supplies the catalog - * (so this stays pure / testable without hydrating the global) and the - * snapshot of layers + the shell-env map from `read_shell_env_vars`. + * (so this stays pure / testable without hydrating the global), the + * snapshot of layers + the shell-env map from `read_shell_env_vars`, + * and optionally the attached claude's environ (from attach mode). * - * Precedence for the effective value: shell wins over settings.json. This - * matches Claude Code's documented behavior — vars set in the shell are - * exported into the process before settings.json is read, and settings' - * `env` is *injected into* the launched process, not vice versa. The two - * routes converge on the same process-env, with shell taking priority - * when both are set on the same name. + * Precedence for the effective value: attached > shell > settings.json. + * - Attached wins when present because it's literally claude's process + * env right now — ground truth. + * - Shell wins over settings.json because vars set in the shell are + * exported into the process before settings.json is read, and + * settings' `env` is *injected into* the launched process, not vice + * versa. */ export function buildEnvVarRows( catalog: readonly EnvVarEntry[], snapshot: SettingsSnapshot, shellEnv: Readonly>, + attachedEnv: Readonly> | null = null, ): EnvVarRow[] { const layersByName = new Map( snapshot.layers.map((l) => [l.source, l] as const), @@ -125,6 +141,19 @@ export function buildEnvVarRows( const buildContributors = (name: string): EnvVarContributor[] => { const contributors: EnvVarContributor[] = []; + // Attached env is the highest-fidelity source — what the running + // claude actually has — so it leads the contributor list when + // present. + if (attachedEnv) { + const attachedValue = attachedEnv[name]; + if (attachedValue !== undefined) { + contributors.push({ + source: "attached", + value: attachedValue, + path: null, + }); + } + } const shellValue = shellEnv[name]; if (shellValue !== undefined) { contributors.push({ source: "shell", value: shellValue, path: null }); @@ -203,7 +232,14 @@ export function buildEnvVarRows( // ---- Filter / search -------------------------------------------------------- -export type EnvVarChip = "all" | "set" | "shell" | "settings" | "unset"; +export type EnvVarChip = + | "all" + | "set" + | "attached" + | "shell" + | "settings" + | "diff" + | "unset"; export function applyEnvVarChip( rows: EnvVarRow[], @@ -214,14 +250,34 @@ export function applyEnvVarChip( return rows; case "set": return rows.filter((r) => r.contributors.length > 0); + case "attached": + return rows.filter((r) => + r.contributors.some((c) => c.source === "attached"), + ); case "shell": return rows.filter((r) => r.contributors.some((c) => c.source === "shell"), ); case "settings": return rows.filter((r) => - r.contributors.some((c) => c.source !== "shell"), + r.contributors.some( + (c) => c.source !== "shell" && c.source !== "attached", + ), ); + case "diff": + // Vars where the attached claude's value differs from knobs.cc's + // shell value — the headline use case for attach mode's env + // surface. Requires *both* to be set; one-sided presence is + // surfaced by the attached / shell chips already. + return rows.filter((r) => { + const attached = r.contributors.find((c) => c.source === "attached"); + const shell = r.contributors.find((c) => c.source === "shell"); + return ( + attached !== undefined && + shell !== undefined && + attached.value !== shell.value + ); + }); case "unset": return rows.filter((r) => r.contributors.length === 0); } @@ -245,8 +301,10 @@ export function envVarChipCounts(rows: EnvVarRow[]): Record const counts: Record = { all: rows.length, set: 0, + attached: 0, shell: 0, settings: 0, + diff: 0, unset: 0, }; for (const r of rows) { @@ -255,8 +313,20 @@ export function envVarChipCounts(rows: EnvVarRow[]): Record continue; } counts.set += 1; - if (r.contributors.some((c) => c.source === "shell")) counts.shell += 1; - if (r.contributors.some((c) => c.source !== "shell")) counts.settings += 1; + const attached = r.contributors.find((c) => c.source === "attached"); + const shell = r.contributors.find((c) => c.source === "shell"); + if (attached) counts.attached += 1; + if (shell) counts.shell += 1; + if ( + r.contributors.some( + (c) => c.source !== "shell" && c.source !== "attached", + ) + ) { + counts.settings += 1; + } + if (attached && shell && attached.value !== shell.value) { + counts.diff += 1; + } } return counts; } diff --git a/src/lib/openPath.ts b/src/lib/openPath.ts index 523f30a..62b8298 100644 --- a/src/lib/openPath.ts +++ b/src/lib/openPath.ts @@ -2,8 +2,35 @@ import { openPath as openWithSystem, openUrl as openUrlWithSystem, } from "@tauri-apps/plugin-opener"; +import { open as openSystemDialog } from "@tauri-apps/plugin-dialog"; import { reportError } from "./errorLog"; +/** + * Open the native folder picker. Returns the absolute path of the + * directory the user selected, or null if they cancelled. Routes + * errors to the in-app error log; never throws. + * + * Used by attach mode's path-picker fallback (spec/attach-mode.md). + */ +export async function pickProjectDirectory(): Promise { + try { + const selected = await openSystemDialog({ + directory: true, + multiple: false, + title: "Pick a project directory to ground the inspector", + }); + if (typeof selected === "string") return selected; + return null; + } catch (e) { + reportError({ + message: "Couldn't open the folder picker", + detail: e, + source: "pickProjectDirectory", + }); + return null; + } +} + // Thin wrapper so callers don't import the plugin directly. The opener // plugin uses the OS file association — for `.json` that's whatever the // user has set as default (VS Code, JetBrains, TextEdit, etc.). Line diff --git a/src/lib/rows.test.ts b/src/lib/rows.test.ts index 226b424..d5c77c2 100644 --- a/src/lib/rows.test.ts +++ b/src/lib/rows.test.ts @@ -90,7 +90,7 @@ describe("buildRows — unset rows", () => { }); describe("buildRows — array-merged", () => { - it("emits state=array-merged with null winner and per-element list when the leaf has elements", () => { + it("emits state=array-merged with null winner and per-element list when 2+ layers contribute", () => { const snap = snapshot( [ ok("project", { permissions: { allow: ["b"] } }), @@ -119,6 +119,36 @@ describe("buildRows — array-merged", () => { // Contributors still come from the raw layers, not elements. expect(r?.contributors).toEqual(["project", "user"]); }); + + it("downgrades to state=set with a single winner when only one layer contributes elements", () => { + // permissions.allow is on the array-merged policy list, so the + // backend emits `elements` even when only one layer contributed. + // The UI should NOT show MERGED in that case — it would mislead the + // reader into thinking the row is multi-sourced. + const snap = snapshot( + [ok("project_local", { permissions: { allow: ["a", "b", "c"] } })], + { + permissions: { + allow: { + value: ["a", "b", "c"], + source: null, + elements: [ + { value: "a", source: "project_local" }, + { value: "b", source: "project_local" }, + { value: "c", source: "project_local" }, + ], + }, + }, + }, + ); + const r = buildRows(snap).find((x) => x.keyPath === "permissions.allow"); + expect(r?.state).toBe("set"); + expect(r?.winner).toBe("project_local"); + expect(r?.contributors).toEqual(["project_local"]); + // Elements are preserved so the drawer's per-element list still + // renders — the per-element provenance is the whole point. + expect(r?.elements).toHaveLength(3); + }); }); describe("buildRows — namespace split", () => { diff --git a/src/lib/rows.ts b/src/lib/rows.ts index 841d48a..e01bf21 100644 --- a/src/lib/rows.ts +++ b/src/lib/rows.ts @@ -69,19 +69,31 @@ export function buildRows(snapshot: SettingsSnapshot): Row[] { const setRows: Row[] = leaves.map((leaf) => { const contributors = contributorsForKey(snapshot.layers, leaf.keyPath); const { namespace, leaf: leafName } = splitKey(leaf.keyPath); - const isArrayMerged = leaf.elements !== undefined; + const hasElements = leaf.elements !== undefined; + // The "array-merged" badge only reads as meaningful when multiple + // layers actually contributed. When the backend emitted elements but + // only one layer is in the contributor list, surface it as a normal + // `set` row with that layer's badge — the per-element list still + // renders in the drawer (`elements` is preserved), but the centre + // list and EFFECTIVE block don't pretend a single-source field is + // multi-sourced. + const isMultiSourceMerge = hasElements && contributors.length > 1; + const winner: LayerSource | null = isMultiSourceMerge + ? null + : (leaf.winner ?? contributors[0] ?? null); + const state: RowState = isMultiSourceMerge + ? "array-merged" + : contributors.length > 1 + ? "shadowed" + : "set"; return { keyPath: leaf.keyPath, namespace, leaf: leafName, value: leaf.value, - winner: leaf.winner, + winner, contributors, - state: isArrayMerged - ? "array-merged" - : contributors.length > 1 - ? "shadowed" - : "set", + state, elements: leaf.elements, catalog: findCatalogEntry(leaf.keyPath), }; diff --git a/src/lib/runtime.test.ts b/src/lib/runtime.test.ts new file mode 100644 index 0000000..7dc641b --- /dev/null +++ b/src/lib/runtime.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, test } from "vitest"; + +import type { ClaudeProcess, RuntimeSnapshot, SessionGrounding } from "@/types"; +import { + deriveSessionGrounding, + groundingToInvokeArgs, + shortLabel, + tildify, +} from "./runtime"; + +function mkProc(pid: number, cwd: string, startedAt = 0): ClaudeProcess { + return { + pid, + started_at: startedAt, + cwd, + argv: ["claude"], + environ: {}, + }; +} + +function snap( + processes: ClaudeProcess[], + status: RuntimeSnapshot["platform_status"] = "ok", + error: string | null = null, +): RuntimeSnapshot { + return { processes, platform_status: status, error }; +} + +describe("deriveSessionGrounding", () => { + test("unsupported platform → unsupported state", () => { + const g = deriveSessionGrounding(snap([], "unsupported"), null, null); + expect(g.kind).toBe("unsupported"); + }); + + test("sysinfo error → no-claude state, carrying picked root", () => { + const g = deriveSessionGrounding( + snap([], "error"), + null, + "/Users/sam/Projects/foo", + ); + expect(g).toEqual({ + kind: "no-claude", + pickedRoot: "/Users/sam/Projects/foo", + }); + }); + + test("zero processes → no-claude", () => { + const g = deriveSessionGrounding(snap([]), null, null); + expect(g).toEqual({ kind: "no-claude", pickedRoot: null }); + }); + + test("zero processes carries previous picked root forward", () => { + const g = deriveSessionGrounding(snap([]), null, "/tmp/proj"); + expect(g).toEqual({ kind: "no-claude", pickedRoot: "/tmp/proj" }); + }); + + test("exactly one process → auto-attach", () => { + const p = mkProc(4172, "/Users/sam/Projects/foo"); + const g = deriveSessionGrounding(snap([p]), null, null); + expect(g.kind).toBe("attached"); + if (g.kind === "attached") { + expect(g.pid).toBe(4172); + expect(g.process).toEqual(p); + } + }); + + test("prior pid still present → keep that selection across refreshes", () => { + // Two claudes are running; user previously selected the second. A + // refresh should keep them on the second, not silently switch to the + // first or drop them into the picker. + const a = mkProc(100, "/a", 1); + const b = mkProc(200, "/b", 2); + const g = deriveSessionGrounding(snap([a, b]), 200, null); + expect(g.kind).toBe("attached"); + if (g.kind === "attached") { + expect(g.pid).toBe(200); + } + }); + + test("prior pid gone → fall back to no-claude even when processes exist", () => { + // User's claude died; two other claudes are running. Don't auto-attach + // to one of them — make the user pick explicitly. + const a = mkProc(100, "/a", 1); + const b = mkProc(200, "/b", 2); + const g = deriveSessionGrounding(snap([a, b]), 999, null); + expect(g).toEqual({ kind: "no-claude", pickedRoot: null }); + }); + + test("two+ processes with no prior → no-claude (pick required)", () => { + const a = mkProc(100, "/a", 1); + const b = mkProc(200, "/b", 2); + const g = deriveSessionGrounding(snap([a, b]), null, null); + expect(g).toEqual({ kind: "no-claude", pickedRoot: null }); + }); +}); + +describe("groundingToInvokeArgs", () => { + test("attached → attached_pid", () => { + const g: SessionGrounding = { + kind: "attached", + pid: 42, + process: mkProc(42, "/x"), + }; + expect(groundingToInvokeArgs(g)).toEqual({ attached_pid: 42 }); + }); + + test("no-claude with pickedRoot → project_root_override", () => { + const g: SessionGrounding = { kind: "no-claude", pickedRoot: "/tmp/x" }; + expect(groundingToInvokeArgs(g)).toEqual({ + project_root_override: "/tmp/x", + }); + }); + + test("no-claude with no pickedRoot → empty args (legacy CWD fallback)", () => { + const g: SessionGrounding = { kind: "no-claude", pickedRoot: null }; + expect(groundingToInvokeArgs(g)).toEqual({}); + }); + + test("loading / unsupported → empty args", () => { + expect(groundingToInvokeArgs({ kind: "loading" })).toEqual({}); + expect(groundingToInvokeArgs({ kind: "unsupported" })).toEqual({}); + }); +}); + +describe("tildify", () => { + test("returns plain path when home is null", () => { + expect(tildify("/Users/sam/foo", null)).toBe("/Users/sam/foo"); + }); + + test("collapses home prefix to ~", () => { + expect(tildify("/Users/sam", "/Users/sam")).toBe("~"); + expect(tildify("/Users/sam/foo", "/Users/sam")).toBe("~/foo"); + }); + + test("leaves unrelated paths alone", () => { + expect(tildify("/tmp/x", "/Users/sam")).toBe("/tmp/x"); + // Prefix match that isn't a directory boundary — must not match. + expect(tildify("/Users/samuel/foo", "/Users/sam")).toBe( + "/Users/samuel/foo", + ); + }); +}); + +describe("shortLabel", () => { + test("includes pid + tildified cwd", () => { + const p = mkProc(4172, "/Users/sam/Projects/foo"); + expect(shortLabel(p, "/Users/sam")).toBe("PID 4172 · ~/Projects/foo"); + }); +}); diff --git a/src/lib/runtime.ts b/src/lib/runtime.ts new file mode 100644 index 0000000..a4e3089 --- /dev/null +++ b/src/lib/runtime.ts @@ -0,0 +1,101 @@ +/** + * Attach-mode wire layer. Wraps `invoke("read_runtime_layer")` so callers + * don't import `invoke` directly and so the test seam stays clean. + * + * Spec: `spec/attach-mode.md`. Closes #11 (cli + env runtime introspection) + * and #12 (project grounded in a real session). + */ +import { invoke } from "@tauri-apps/api/core"; +import type { ClaudeProcess, RuntimeSnapshot, SessionGrounding } from "@/types"; + +export async function readRuntimeLayer(): Promise { + return invoke("read_runtime_layer"); +} + +/** + * Decide the grounding state from a fresh runtime snapshot + the + * previously-selected pid (if any) + the previously-picked project root. + * + * Behavior: + * - Platform not supported (Windows v1) → unsupported state; the path + * picker remains available. + * - Zero claude processes → no-claude state; carry the previously-picked + * root forward so the inspector stays grounded after a process exits. + * - Exactly one claude process → auto-attach. + * - The previously-selected pid is still in the list → keep it (don't yank + * the user's attachment when a second claude appears). + * - Otherwise (the prior pid is gone, or none was set and there are 2+) → + * fall back to no-claude state so the user can pick explicitly. Carries + * the prior process's cwd as a hint via the picked root *if* there's a + * sensible one — but the spec leaves this open; we default to null to + * keep behavior predictable. + */ +export function deriveSessionGrounding( + snap: RuntimeSnapshot, + priorPid: number | null, + pickedRoot: string | null, +): SessionGrounding { + if (snap.platform_status === "unsupported") { + return { kind: "unsupported" }; + } + if (snap.platform_status === "error") { + // We treat sysinfo error the same as zero processes: the path picker is + // still useful, and a misleading "attached" state would be worse. + return { kind: "no-claude", pickedRoot }; + } + const procs = snap.processes; + if (procs.length === 0) { + return { kind: "no-claude", pickedRoot }; + } + if (priorPid !== null) { + const stillThere = procs.find((p) => p.pid === priorPid); + if (stillThere) { + return { kind: "attached", pid: stillThere.pid, process: stillThere }; + } + } + if (procs.length === 1) { + return { kind: "attached", pid: procs[0].pid, process: procs[0] }; + } + // 2+ processes and either no prior selection or prior is gone — the user + // needs to pick. We park in no-claude state so the picker UI opens and + // nothing is auto-grounded. (no-claude is a slight misnomer for this + // case; UX copy distinguishes "no claude detected" vs "pick a session + // from N running" — see SessionPill.) + return { kind: "no-claude", pickedRoot }; +} + +/** + * What to pass to `read_settings_layers` given a grounding state. + * Returns the args object so the caller can spread it into invoke(). + */ +export function groundingToInvokeArgs( + grounding: SessionGrounding, +): { attached_pid?: number; project_root_override?: string } { + switch (grounding.kind) { + case "attached": + return { attached_pid: grounding.pid }; + case "no-claude": + if (grounding.pickedRoot) { + return { project_root_override: grounding.pickedRoot }; + } + return {}; + case "loading": + case "unsupported": + return {}; + } +} + +/** Compact `~/Projects/foo` style render for a cwd path. */ +export function tildify(path: string, home: string | null): string { + if (!home) return path; + if (path === home) return "~"; + if (path.startsWith(`${home}/`)) { + return `~/${path.slice(home.length + 1)}`; + } + return path; +} + +/** Short label for a process: "PID 4172 · ~/Projects/foo". */ +export function shortLabel(p: ClaudeProcess, home: string | null): string { + return `PID ${p.pid} · ${tildify(p.cwd, home)}`; +} diff --git a/src/lib/waterfall.ts b/src/lib/waterfall.ts index 3727db6..4ea2ae7 100644 --- a/src/lib/waterfall.ts +++ b/src/lib/waterfall.ts @@ -39,7 +39,7 @@ export interface WaterfallEntry { const ABSENT_PER_LAYER_TEXT: Partial> = { managed: "— no policy —", - cli: "— not inspectable —", + cli: "— no attached claude —", env: "— not set —", default: "— no catalog default —", }; diff --git a/src/types.ts b/src/types.ts index 1c74766..8eea8a1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -51,3 +51,31 @@ export const SOURCE_LABEL: Record = { user: "user", default: "default", }; + +// ---- Attach mode (spec/attach-mode.md) ---------------------------------- + +export type PlatformStatus = "ok" | "unsupported" | "error"; + +export interface ClaudeProcess { + pid: number; + /** Seconds since UNIX epoch — moment exec() ran for this process. */ + started_at: number; + cwd: string; + /** argv as the kernel sees it. argv[0] is the binary path. */ + argv: string[]; + /** Full environ. Order not preserved; duplicate keys collapse. */ + environ: Record; +} + +export interface RuntimeSnapshot { + processes: ClaudeProcess[]; + platform_status: PlatformStatus; + error: string | null; +} + +/** How the inspector is currently grounded. */ +export type SessionGrounding = + | { kind: "loading" } + | { kind: "no-claude"; pickedRoot: string | null } + | { kind: "attached"; pid: number; process: ClaudeProcess } + | { kind: "unsupported" }; From 09cd90d814f3e24dec394dfec123cfd0731dbc1a Mon Sep 17 00:00:00 2001 From: Sam Keen Date: Wed, 13 May 2026 12:51:02 -0700 Subject: [PATCH 2/2] Skip Unix-only attach tests on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new settings tests — cli_layer_ok_when_attached_to_live_process and attached_pid_for_live_process_resolves_project_root — assert that attaching to a live pid resolves to Some(...). But process_for_pid is Unix-only in v1 (sysinfo doesn't expose another process's environ on Windows, so the function early-returns None on that target). Add the same cfg!(target_os = "windows") early-return guard used by current_uid_resolves_on_unix. The Windows code path is still covered by the existing tests that exercise the None branch (attached_pid_for_unknown_process_emits_diagnostic runs on every OS). Co-Authored-By: Claude Opus 4.7 (1M context) --- src-tauri/src/settings.rs | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 5a7e0ce..f4606fc 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -872,6 +872,13 @@ mod tests { #[test] fn cli_layer_ok_when_attached_to_live_process() { + // sysinfo can't read another process's environ on Windows; attach + // mode reports `Unsupported` and `process_for_pid` returns None, + // so an "attached" snapshot has no argv to parse and the cli + // layer stays Missing. The test's premise only holds on Unix. + if cfg!(target_os = "windows") { + return; + } // The test runner's argv doesn't contain claude flags, so the cli // layer will be Ok with an empty `raw` — but the slot must be Ok, // not Missing, and the rail row must un-grey. @@ -887,11 +894,15 @@ mod tests { #[test] fn attached_pid_for_live_process_resolves_project_root() { - // The runtime layer's cwd_for_pid filter is "same UID + cwd - // readable" — it doesn't require the target to be named claude - // (that filter runs at discovery time in read_runtime_layer). - // Using our own pid is the cheapest way to exercise the live - // resolution path end-to-end. + // Same Windows caveat as above — `process_for_pid` is Unix-only + // in v1, so this end-to-end attach test only runs on macOS / Linux. + if cfg!(target_os = "windows") { + return; + } + // process_for_pid is gated on same-UID + a readable cwd; it does + // not require the target to be named claude (that filter runs at + // discovery time in read_runtime_layer). Using our own pid is the + // cheapest way to exercise the live resolution path end-to-end. let our_pid = std::process::id(); let snap = read_snapshot(ProjectSource::Attached(our_pid)); // The test runner's cwd is the project_root we should have read.