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..f4606fc 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,177 @@ 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() { + // 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. + 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() { + // 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. + 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" };