diff --git a/README.md b/README.md index 7687e3f2b0..75debda6c8 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ [![platforms](https://img.shields.io/badge/platform-macOS%20%7C%20Linux%20%7C%20Windows-lightgrey)](#requirements) [![built with](https://img.shields.io/badge/.NET%2010-NativeAOT-512bd4?logo=dotnet&logoColor=white)](#) -**Kurrent Capacitor** (`kcap`) records your coding-agent sessions and forwards them to a Capacitor server, where a real-time dashboard and PR-review tools surface the context no diff can give you: *why* code changed, what alternatives were weighed, and how it was actually built. It works across ten agents — Claude Code, Codex, Cursor, GitHub Copilot, Gemini, Kiro, Pi, OpenCode, Antigravity, and Mistral Vibe — capturing the full picture: session lifecycle, transcripts, subagent trees, tool calls, and token usage. +**Kurrent Capacitor** (`kcap`) records your coding-agent sessions and forwards them to a Capacitor server, where a real-time dashboard and PR-review tools surface the context no diff can give you: *why* code changed, what alternatives were weighed, and how it was actually built. It works across eleven agents — Claude Code, Codex, Cursor, GitHub Copilot, Gemini, Kiro, Pi, OpenCode, Antigravity, Mistral Vibe, and Grok — capturing the full picture: session lifecycle, transcripts, subagent trees, tool calls, and token usage. ## Contents @@ -34,13 +34,13 @@ - **Real-time visibility.** Transcript data streams live over SignalR to a dashboard that shows repositories, sessions, and agents as they happen. - **PR review with real context.** `kcap review` launches a reviewer equipped with MCP tools that query the actual implementation transcripts — ask *why* something changed, not just *what*. - **Recall past work from your agent.** MCP servers let agents search and reuse prior sessions, durable team memories, and structured review flows without leaving the chat. -- **Works with your agents.** One install covers Claude Code, Codex, Cursor, GitHub Copilot, Gemini, Kiro, Pi, OpenCode, Antigravity, and Mistral Vibe. +- **Works with your agents.** One install covers Claude Code, Codex, Cursor, GitHub Copilot, Gemini, Kiro, Pi, OpenCode, Antigravity, Mistral Vibe, and Grok. ## Requirements - **A Capacitor workspace.** `kcap setup` signs you in and finds yours, or offers to create one; a self-hosted server takes its URL instead. - **Nothing else to install.** `kcap` is a self-contained NativeAOT executable with no runtime dependency. Node.js and npm are needed only if you install it [through npm](#other-ways-to-install). -- **At least one supported coding agent** so there's something to record: Claude Code, Codex, Cursor, GitHub Copilot, Gemini, Kiro, Pi, OpenCode, Antigravity, or Mistral Vibe. +- **At least one supported coding agent** so there's something to record: Claude Code, Codex, Cursor, GitHub Copilot, Gemini, Kiro, Pi, OpenCode, Antigravity, Mistral Vibe, or Grok. - **GitHub CLI (`gh`), optional** — the desktop app reads a linked pull request (description, checks, reviews, inline threads, conversation) through your own `gh` sign-in when a `gh` recent enough for `gh auth status --json` is installed and signed in, including GitHub Enterprise hosts. Without it the card says what to install; a tenant with the server-side GitHub App enabled reads without it. - **A supported platform:** @@ -254,7 +254,7 @@ The setup wizard walks you through the steps below. Re-run it any time to update **The browser can ask this machine to do two named things while you wait.** If your login shell cannot find `kcap`, the Agents screen offers to link it, and pressing that button runs the same `kcap daemon shim ensure` you could run yourself — so on macOS **you will be asked for your admin password** while you are still looking at the browser. kcap prints a line naming what asked, before the dialog appears. The Done screen separately offers to run the agent daemon as a service, so this machine stays reachable when you are not at the keyboard: that runs the same `kcap daemon service ensure`, needs no password (all three service managers are per-user), and **happens as the rest of setup finishes** — the service unit bakes the profile, the server URL and the daemon name, so it waits until the steps that choose those have written them, and the screen says so while you wait. Each screen reports what happened either way; nothing is attempted unless you press the button, and **only a named capability can be requested** — what crosses is a token from a closed set, never a path or a command, so the browser cannot ask this machine to run anything else. 3. **Default visibility** — choose how your sessions are visible to others. Answered on the browser's Agents screen, this step reports that choice rather than prompting -4. **Coding-agent hooks** — detects Claude Code and Codex CLI on `PATH`, Cursor by user-dir presence (`~/.cursor/`), GitHub Copilot CLI by `~/.copilot/` or `copilot` on `PATH`, Google Gemini CLI by `~/.gemini/` or `gemini` on `PATH`, AWS Kiro CLI by `~/.kiro/` or `kiro`/`kiro-cli` on `PATH`, Pi by `~/.pi/` or `pi` on `PATH`, SST OpenCode by `~/.config/opencode/` (or `~/.local/share/opencode/`) or `opencode` on `PATH`, and Google Antigravity by `~/.gemini/antigravity/` (GUI) or `~/.gemini/antigravity-cli/` (the `agy` CLI) or `antigravity`/`agy` on `PATH`, and Mistral Vibe by `~/.vibe/` or `vibe` on `PATH`, lists what it found, then asks **one** yes/no prompt to install kcap for every detected agent (hooks — or, for Pi/OpenCode/Antigravity, the live-ingest plugin — plus skills, instructions, and MCP) — plus a single shared set of agent skills under `~/.agents/skills/`, installed once when any of Codex, Cursor, Copilot, Gemini, Pi, or OpenCode is detected (Claude gets its skills from the bundled plugin; AWS Kiro and Google Antigravity read their own skills dirs — `~/.kiro/skills` and `~/.gemini/skills` respectively — so each gets its own copy there instead of the shared tree) — all user-wide. For Codex it also offers to enable **sandbox network access** for kcap (see [Unattended setup](#unattended-setup)) — Codex blocks sandbox network by default, so the kcap skills can't reach the server without it. Each agent's own config-relocation environment variable is honored when set: `CLAUDE_CONFIG_DIR` (Claude), `CODEX_HOME` (Codex), `GEMINI_CLI_HOME` (Gemini — names the parent of `.gemini`), `KIRO_HOME` (Kiro), `COPILOT_HOME` (Copilot), `OPENCODE_CONFIG_DIR` (OpenCode), `PI_CODING_AGENT_DIR` (Pi), and `VIBE_HOME` (Vibe — replaces `~/.vibe`). Cursor's hooks path is fixed at `~/.cursor/hooks.json` and is not relocated. Setup also offers any other Claude Code or Codex config directories it finds, so each is recorded as an account (see [Accounts](#accounts)); with `--no-prompt`, or on a console that cannot prompt, it prints the `kcap accounts add` command instead. +4. **Coding-agent hooks** — detects Claude Code and Codex CLI on `PATH`, Cursor by user-dir presence (`~/.cursor/`), GitHub Copilot CLI by `~/.copilot/` or `copilot` on `PATH`, Google Gemini CLI by `~/.gemini/` or `gemini` on `PATH`, AWS Kiro CLI by `~/.kiro/` or `kiro`/`kiro-cli` on `PATH`, Pi by `~/.pi/` or `pi` on `PATH`, SST OpenCode by `~/.config/opencode/` (or `~/.local/share/opencode/`) or `opencode` on `PATH`, and Google Antigravity by `~/.gemini/antigravity/` (GUI) or `~/.gemini/antigravity-cli/` (the `agy` CLI) or `antigravity`/`agy` on `PATH`, Mistral Vibe by `~/.vibe/` or `vibe` on `PATH`, and Grok by `~/.grok/` or `grok` on `PATH`, lists what it found, then asks **one** yes/no prompt to install kcap for every detected agent (hooks — or, for Pi/OpenCode/Antigravity, the live-ingest plugin — plus skills, instructions, and MCP) — plus a single shared set of agent skills under `~/.agents/skills/`, installed once when any of Codex, Cursor, Copilot, Gemini, Pi, OpenCode, Mistral Vibe, or Grok is detected (Claude gets its skills from the bundled plugin; AWS Kiro and Google Antigravity read their own skills dirs — `~/.kiro/skills` and `~/.gemini/skills` respectively — so each gets its own copy there instead of the shared tree) — all user-wide. For Codex it also offers to enable **sandbox network access** for kcap (see [Unattended setup](#unattended-setup)) — Codex blocks sandbox network by default, so the kcap skills can't reach the server without it. Each agent's own config-relocation environment variable is honored when set: `CLAUDE_CONFIG_DIR` (Claude), `CODEX_HOME` (Codex), `GEMINI_CLI_HOME` (Gemini — names the parent of `.gemini`), `KIRO_HOME` (Kiro), `COPILOT_HOME` (Copilot), `OPENCODE_CONFIG_DIR` (OpenCode), `PI_CODING_AGENT_DIR` (Pi), `VIBE_HOME` (Vibe — replaces `~/.vibe`), and `GROK_HOME` (Grok — replaces `~/.grok`). Cursor's hooks path is fixed at `~/.cursor/hooks.json` and is not relocated. Setup also offers any other Claude Code or Codex config directories it finds, so each is recorded as an account (see [Accounts](#accounts)); with `--no-prompt`, or on a console that cannot prompt, it prints the `kcap accounts add` command instead. When it installs any agent's hooks it also adds one entry to `~/.gitconfig`, a git 2.54+ config hook named `kcap` (`kcap plugin install` does the same). After every commit git runs `kcap git-hook`, which files the commit under the session of the agent process above it, so a commit made from a subfolder, a nested repo, a script or any agent lands in the right repository and pull request. Antigravity, Cursor and OpenCode are the exception: one of their processes runs several sessions and your own terminals, so their commits are still read from the agent's shell commands. It runs beside hooks you already have (husky, pre-commit) and never replaces them. `git hook list post-commit` shows it, `git config --global hook.kcap.enabled false` switches it off, and on an older git the server keeps reading commits from the agent's shell commands — setup adds the hook entry, then checks `git --version` and warns when git is older than 2.54 (or when it cannot read the version). When it installs Codex hooks, setup also reminds you that Codex records nothing until you trust them. 5. **Daemon** — configure the daemon name for remote agent execution. If a daemon on another machine is already connected to your account under that name, setup asks for another (suggesting one suffixed with this machine's name); under `--no-prompt` the name is `--daemon-name`, else `KCAP_DAEMON_NAME`, else your user name, and a name held elsewhere exits 1, so pass a different `--daemon-name` (the daemon verb is `kcap daemon`; `kcap agent` is a separate group that runs coding agents — see [Local agents](#local-agents-kcap-agent)). **After a browser answer this step does not prompt:** it takes `--daemon-name` if given, else `KCAP_DAEMON_NAME`, else the profile's daemon name (your user name on a first run); if another machine holds it, setup tries the machine-suffixed name and then numbered variants of it, and ends with an error naming `--daemon-name` if all are taken. The provider API key question (asked when `ANTHROPIC_API_KEY` or `OPENAI_API_KEY` is set) is skipped too and the stored setting kept — change it with `kcap config set use_provider_api_key true|false`. When the browser's Done screen ran the daemon as a service, setup closes by saying so rather than suggesting `kcap daemon start -d` 6. **Import past sessions** — scans every detected agent's history on this machine and prints how many repositories and sessions it found and how many sessions matched no repository, then asks (default yes) `Import past sessions from this machine?` — equivalent to `kcap import --all` under your profile's default visibility (shared only where the repository owner is your workspace's org). The most recent sessions are prioritized: about five import while you watch, and the rest continue in a detached background process that logs to `~/.config/kcap/import-.log`. Setup then offers to open one of your detected coding agents with the prompt `Follow my kcap import` so you can watch the import and its evals from there (or prints the prompt to paste), and finishes when you close it. Your authentication requirements must be satisfied — which includes no-auth servers (auth provider `None`, no token needed); otherwise the step is skipped with the usual `kcap import` hint. Opt out with `--skip-import`; an import chosen on the browser's Import screen runs regardless. **Answered in the browser, this step reports rather than prompts** — the screen there chooses repositories and a history window this prompt cannot express, so re-asking would offer to redo part of what already ran. A browser flow that finished without an import answer skips this step too and names `kcap import --all` for later; handing back to the terminal with **t** keeps the prompt. @@ -291,7 +291,7 @@ kcap setup --server-url https://my-tenant.kcap.ai --default-visibility org_publi kcap setup --org "Acme" --slug acme --tier team --default-visibility org_public --no-prompt ``` -In `--no-prompt` mode, the wizard installs hooks for every detected agent by default. Opt out per agent with `--skip-claude-hooks`, `--skip-codex-hooks`, `--skip-cursor-hooks`, `--skip-copilot-hooks`, `--skip-gemini-hooks`, `--skip-kiro-hooks`, `--skip-pi-hooks`, `--skip-opencode-hooks`, and/or `--skip-antigravity-hooks`. When Codex hooks are installed, the wizard also enables Codex sandbox network access for your server(s) by default; pass `--skip-codex-network-access` to leave `~/.codex/config.toml` untouched. +In `--no-prompt` mode, the wizard installs hooks for every detected agent by default. Opt out per agent with `--skip-claude-hooks`, `--skip-codex-hooks`, `--skip-cursor-hooks`, `--skip-copilot-hooks`, `--skip-gemini-hooks`, `--skip-kiro-hooks`, `--skip-pi-hooks`, `--skip-opencode-hooks`, `--skip-antigravity-hooks`, and/or `--skip-grok-hooks`. When Codex hooks are installed, the wizard also enables Codex sandbox network access for your server(s) by default; pass `--skip-codex-network-access` to leave `~/.codex/config.toml` untouched. To record only one agent, skip the others: @@ -306,10 +306,10 @@ kcap setup --server-url --no-prompt --skip-claude-hooks --skip-codex-hooks #### After setup > **Need hooks for an agent installed after setup, or scoped to a single repo?** -> Run `kcap plugin install [--codex|--cursor|--copilot|--gemini|--kiro|--pi|--opencode|--antigravity|--mistral-vibe]` (omit the flag for the Claude Code plugin), or pair Codex with `--project` for a per-repo install. Every per-vendor install also writes the agent skills to `~/.agents/skills/` (Kiro and Antigravity get their own copies under `~/.kiro/skills` and `~/.gemini/skills`), so `--skills` is only needed to install or refresh them on their own — for instance for an agent kcap has no integration for. Cursor uses user-scope only — `--project` has no effect with `--cursor`. After installing Codex hooks, the next `codex` launch prompts to trust the new hooks — accept once to trust them all (run `/hooks` inside Codex if you'd rather trust each entry individually). If you only use the Codex desktop app, it never prompts — trust the kcap hooks under Settings → Hooks in the app. After a `--project` install, also run `codex` once in the repo and accept the workspace trust prompt. Re-running after a kcap upgrade is rarely needed for user-scope installs — the npm postinstall hook auto-refreshes them on every `npm install -g @kurrent/kcap`, and `kcap update` refreshes them too (npm 11+ blocks install scripts by default — `kcap update` works regardless, or add `allow-scripts[]=@kurrent/kcap` to `~/.npmrc` to opt the postinstall in once). +> Run `kcap plugin install [--codex|--cursor|--copilot|--gemini|--kiro|--pi|--opencode|--antigravity|--mistral-vibe|--grok]` (omit the flag for the Claude Code plugin), or pair Codex with `--project` for a per-repo install. Every per-vendor install also writes the agent skills to `~/.agents/skills/` (Kiro and Antigravity get their own copies under `~/.kiro/skills` and `~/.gemini/skills`), so `--skills` is only needed to install or refresh them on their own — for instance for an agent kcap has no integration for. Cursor uses user-scope only — `--project` has no effect with `--cursor`. After installing Codex hooks, the next `codex` launch prompts to trust the new hooks — accept once to trust them all (run `/hooks` inside Codex if you'd rather trust each entry individually). If you only use the Codex desktop app, it never prompts — trust the kcap hooks under Settings → Hooks in the app. After a `--project` install, also run `codex` once in the repo and accept the workspace trust prompt. Re-running after a kcap upgrade is rarely needed for user-scope installs — the npm postinstall hook auto-refreshes them on every `npm install -g @kurrent/kcap`, and `kcap update` refreshes them too (npm 11+ blocks install scripts by default — `kcap update` works regardless, or add `allow-scripts[]=@kurrent/kcap` to `~/.npmrc` to opt the postinstall in once). > **Want an agent's kcap tools without recording its sessions?** -> Add `--tools-only` to `kcap plugin install --cursor|--copilot|--gemini|--kiro|--pi|--opencode|--antigravity|--mistral-vibe`. It writes that agent's MCP servers, skills and steering instructions (the `--skip--mcp`, `--skip--skills` and `--skip--instructions` flags still apply; Mistral Vibe has no instructions file, so only its MCP servers and skills are written) and nothing that records: no hooks, no Pi or OpenCode live-ingest file, no Antigravity capture plugin, no Kiro agent clone, default-agent switch or Kiro Crew hook, and no git hook. Only Pi's MCP bridge still needs `kcap` on `PATH`. Claude Code and Codex ship their tools with capture, so `--tools-only` is refused for them (exit code 1). The upgrade-time `--if-installed` refresh keeps a tools-only install tools-only: it refreshes the tools and never adds capture. `--tools-only --if-installed` refreshes only the tools of any install. +> Add `--tools-only` to `kcap plugin install --cursor|--copilot|--gemini|--kiro|--pi|--opencode|--antigravity|--mistral-vibe`. It writes that agent's MCP servers, skills and steering instructions (the `--skip--mcp`, `--skip--skills` and `--skip--instructions` flags still apply; Mistral Vibe has no instructions file, so only its MCP servers and skills are written) and nothing that records: no hooks, no Pi or OpenCode live-ingest file, no Antigravity capture plugin, no Kiro agent clone, default-agent switch or Kiro Crew hook, and no git hook. Only Pi's MCP bridge still needs `kcap` on `PATH`. Claude Code and Codex ship their tools with capture, and kcap installs no tools for Grok, so `--tools-only` is refused for those three (exit code 1). The upgrade-time `--if-installed` refresh keeps a tools-only install tools-only: it refreshes the tools and never adds capture. `--tools-only --if-installed` refreshes only the tools of any install. Legacy `--plugin-scope ` is retained for backwards compatibility: @@ -922,6 +922,7 @@ kcap import --kiro --all # only Kiro — every session log u kcap import --pi --all # only Pi — every discovered session kcap import --opencode --all # only OpenCode — every session in opencode.db kcap import --antigravity --all # only Antigravity — GUI and agy CLI brains +kcap import --grok --all # only Grok Build — sessions and their subagents ``` Sessions import most-recent-first, so your latest work reaches the dashboard first. Every form is idempotent — safe to run multiple times. Each run ends with `N imported · N skipped · N failed`, then a breakdown of why each session was skipped. Failures never abort the run or change the exit code: everything that could be imported still is, and because the run is idempotent, re-running retries the failures without re-sending anything already on the server. @@ -1673,6 +1674,17 @@ kcap plugin remove --mistral-vibe # remove kcap hooks + MCP from ~/.vi Vibe has **no session-start or session-end hook** — only the three tool/turn hooks — so kcap treats the first hook it sees for a session as the start and posts session-start; the session ends when the `vibe` process exits. In Vibe's default unified-harness mode a hook's `transcript_path` is the session's store directory (`~/.vibe/logs/session/unified//`), so kcap keeps an append-only JSONL copy of its finished entries under its config directory and the watcher (`vendor=mistral-vibe`) tails that copy, deleting it when the session ends. Subagents run in their own `child-` stores with no hooks of their own; the watcher records each as a sub-session of its parent. Token usage comes from Vibe's running session total, and the model from `active_model` in `~/.vibe/config.toml` when a `[[models]]` entry defines it. The hook always exits 0 with empty stdout, a passthrough under Vibe's hook contract, so recording never gates a tool call. Historical `kcap import --mistral-vibe` reads both stores under `~/.vibe/logs/session/`: the unified-harness store and the legacy `session_/messages.jsonl`. +#### Grok hooks + +Grok Build is detected via `~/.grok/` or `grok` on `PATH`, honouring `GROK_HOME`. `install --grok` (and `kcap setup`) writes kcap's own `~/.grok/hooks/kcap.json`, which Grok loads alongside any other `*.json` in that directory, and installs the kcap skills to `~/.agents/skills` (`kcap plugin install --grok --skip-grok-skills` opts out). Each hook runs the bare `kcap hook --grok`, so kcap must be on `PATH`. User-level hooks need no `/hooks-trust`. Grok loads hooks at startup — restart any running `grok` session after install. + +```bash +kcap plugin install --grok # write ~/.grok/hooks/kcap.json + agent skills +kcap plugin remove --grok # delete ~/.grok/hooks/kcap.json +``` + +kcap hooks Grok's `SessionStart` and `Stop`. Session start posts the session and starts the watcher (`vendor=grok`) on the session's `~/.grok/sessions///updates.jsonl`; `Stop` restarts a watcher that has gone. The session ends when the `grok` process exits, after the watcher's final drain. Historical `kcap import --grok` reads `~/.grok/sessions/` and imports each subagent through its parent. The hook always exits 0 with empty stdout. Grok also runs the hooks Claude Code's and Cursor's configs declare; a `kcap hook --claude` or `--cursor` that Grok runs with its own payload does nothing, so a machine set up for those agents records Grok sessions once, as Grok. Grok has no tools of its own from kcap: it loads the MCP servers set up for Claude Code. + Cursor uses a single user-scope `hooks.json`; there is no project-scope variant. `kcap setup` writes all 8 supported Cursor hook entries. Use `--skip-cursor-hooks` to opt out during setup: @@ -2350,7 +2362,7 @@ kcap uninstall --keep-config # remove integrations, keep ~/.config/kcap `--project` additionally cleans up `/.claude/settings.local.json` and `/.codex/hooks.json` in the current git working tree (errors if you're not inside one). Cursor only has a user-scope `hooks.json`, so `--project` does not affect it. Project-scope hooks in other repos are not touched — re-run from each repo that has them. -Use `--keep-config` to preserve profiles, tokens, and ignore lists when you plan to reinstall. Per-agent selective cleanup is not exposed here — use `kcap plugin remove [--codex|--cursor|--copilot|--gemini|--kiro|--pi|--opencode|--antigravity|--mistral-vibe|--skills]` for finer-grained removal. +Use `--keep-config` to preserve profiles, tokens, and ignore lists when you plan to reinstall. Per-agent selective cleanup is not exposed here — use `kcap plugin remove [--codex|--cursor|--copilot|--gemini|--kiro|--pi|--opencode|--antigravity|--mistral-vibe|--grok|--skills]` for finer-grained removal. ### New-harness detection @@ -2563,7 +2575,7 @@ allow-scripts[]=@kurrent/kcap Without either, upgrade with **`kcap update`** instead of `npm install -g` — it runs the global npm upgrade and then refreshes your agent plugins itself, so it works regardless of the install-script gate. (You can also re-run `kcap plugin -install [--codex|--cursor|--copilot|--gemini|--kiro|--pi|--opencode|--antigravity|--mistral-vibe|--skills] --if-installed` manually.) +install [--codex|--cursor|--copilot|--gemini|--kiro|--pi|--opencode|--antigravity|--mistral-vibe|--grok|--skills] --if-installed` manually.) **Internal-tenant testers:** opt into pre-release builds with `kcap update --beta`; everyone else should stay on the default stable channel. See diff --git a/src/Capacitor.App/Services/HostedHarnessCatalog.cs b/src/Capacitor.App/Services/HostedHarnessCatalog.cs index 17c1307b63..a965437d61 100644 --- a/src/Capacitor.App/Services/HostedHarnessCatalog.cs +++ b/src/Capacitor.App/Services/HostedHarnessCatalog.cs @@ -32,6 +32,7 @@ public static class HostedHarnessCatalog { { "antigravity", "rpc" }, { "pi", "rpc" }, { "mistral-vibe", "pty" }, + { "grok", "acp" }, }; /// The vendors with an EXPLICIT family above — what the guard test reads, since Build's @@ -40,7 +41,7 @@ public static class HostedHarnessCatalog { /// Harnesses kcap records but no daemon hosts: listed only when a daemon advertises one, since /// the server refuses their launch and an older daemon's silence must not offer them. - static readonly HashSet RecordingOnly = new(StringComparer.OrdinalIgnoreCase) { "mistral-vibe" }; + static readonly HashSet RecordingOnly = new(StringComparer.OrdinalIgnoreCase) { "mistral-vibe", "grok" }; public static IReadOnlyList Build(string[]? supportedVendors) { // null = an older daemon that never sent the field: unknown, not empty. @@ -176,6 +177,7 @@ public static bool SupportsCodexPosture(string vendor) => ["antigravity"] = ("An", "#F4B860"), ["pi"] = ("π", "#A994FF"), ["mistral-vibe"] = ("Mv", "#FA520F"), + ["grok"] = ("Gk", "#F1F3F7"), }; /// Glyph + tint for a vendor token; an unmapped token gets its first letter in neutral grey diff --git a/src/Capacitor.Cli.Core/Harness/Grok/GrokHarness.cs b/src/Capacitor.Cli.Core/Harness/Grok/GrokHarness.cs new file mode 100644 index 0000000000..32e5d0e2a6 --- /dev/null +++ b/src/Capacitor.Cli.Core/Harness/Grok/GrokHarness.cs @@ -0,0 +1,24 @@ +namespace Capacitor.Cli.Core.Harness.Grok; + +/// Grok Build as this process sees it. +public sealed class GrokHarness : IHarness { + GrokHarness(GrokPaths paths) => Paths = paths; + + /// Resolves Grok's one override, GROK_HOME, which replaces ~/.grok. + public static GrokHarness FromEnvironment(UserHome home) => + Over(new(home, Environment.GetEnvironmentVariable("GROK_HOME"))); + + public static GrokHarness Over(GrokPaths paths) => new(paths); + + public static HarnessId Id => HarnessId.Grok; + public static string Label => "Grok"; + public static string CliBinary => "grok"; + + public GrokPaths Paths { get; } + + public HarnessSignals Signals => new() { + LaunchSignal = probe => probe.Finds(CliBinary), + UserDataSignal = Paths.HasUserData, + Wired = () => GrokHooksInstaller.IsInstalled(Paths.KcapHooksJson), + }; +} diff --git a/src/Capacitor.Cli.Core/Harness/Grok/GrokHooksInstaller.cs b/src/Capacitor.Cli.Core/Harness/Grok/GrokHooksInstaller.cs new file mode 100644 index 0000000000..cf3b80f3a8 --- /dev/null +++ b/src/Capacitor.Cli.Core/Harness/Grok/GrokHooksInstaller.cs @@ -0,0 +1,70 @@ +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Capacitor.Cli.Core.Harness.Grok; + +/// +/// kcap's user-level Grok hook file. User-level hooks need no project trust. A handler may carry only +/// Grok's own fields: it refuses a file with Claude-only ones. +/// +public static class GrokHooksInstaller { + public const string HookCommand = "kcap hook --grok"; + public const string MarkerFileName = ".kcap-hooks-version"; + + /// Start records the session and starts the watcher; Stop restarts a watcher that died. The + /// watcher ends the session once Grok exits, which a 1.5 s session-end hook could not wait for. + public static readonly string[] Events = ["SessionStart", "Stop"]; + + /// Seconds; Grok's default of 5 is too short for the session-start post. + const int TimeoutSeconds = 30; + + static readonly JsonSerializerOptions WriteOptions = new() { WriteIndented = true }; + + public static bool Install(string hooksPath) { + try { + var hooks = new JsonObject(); + + foreach (var evt in Events) { + hooks[evt] = new JsonArray(new JsonObject { + ["hooks"] = new JsonArray(new JsonObject { + ["type"] = "command", + ["command"] = HookCommand, + ["timeout"] = TimeoutSeconds, + }), + }); + } + + Directory.CreateDirectory(Path.GetDirectoryName(hooksPath)!); + File.WriteAllText(hooksPath, new JsonObject { ["hooks"] = hooks }.ToJsonString(WriteOptions)); + File.WriteAllText(MarkerPath(hooksPath), CapacitorVersion.Current()); + + return true; + } catch (Exception e) when (e is IOException or UnauthorizedAccessException) { + return false; + } + } + + /// True when the file existed. kcap owns it whole, so nothing in it is the user's. + public static bool Remove(string hooksPath) { + var existed = File.Exists(hooksPath); + + if (existed) File.Delete(hooksPath); + if (File.Exists(MarkerPath(hooksPath))) File.Delete(MarkerPath(hooksPath)); + + return existed; + } + + public static bool IsInstalled(string hooksPath) { + if (!File.Exists(hooksPath)) return false; + + try { + return JsonNode.Parse(File.ReadAllText(hooksPath)) is JsonObject { } root + && root["hooks"] is JsonObject hooks + && hooks.Any(h => h.Value?.ToJsonString().Contains(HookCommand, StringComparison.Ordinal) == true); + } catch (Exception e) when (e is JsonException or IOException or UnauthorizedAccessException) { + return false; + } + } + + static string MarkerPath(string hooksPath) => Path.Combine(Path.GetDirectoryName(hooksPath)!, MarkerFileName); +} diff --git a/src/Capacitor.Cli.Core/Harness/Grok/GrokPaths.cs b/src/Capacitor.Cli.Core/Harness/Grok/GrokPaths.cs new file mode 100644 index 0000000000..f6a5db6686 --- /dev/null +++ b/src/Capacitor.Cli.Core/Harness/Grok/GrokPaths.cs @@ -0,0 +1,60 @@ +using System.Text; + +namespace Capacitor.Cli.Core.Harness.Grok; + +/// +/// Filesystem layout for Grok Build: everything under $GROK_HOME when set, otherwise +/// ~/.grok. Grok merges every *.json in , so kcap owns +/// outright. +/// +public sealed class GrokPaths { + /// Longest directory name Grok writes before swapping the encoded cwd for a slug. + const int MaxDirNameBytes = 255; + + public GrokPaths(UserHome home, string? grokHome) => + Home = !string.IsNullOrWhiteSpace(grokHome) ? grokHome : Path.Combine(home.Path, ".grok"); + + public string Home { get; } + + public string HooksDir => Path.Combine(Home, "hooks"); + + public string KcapHooksJson => Path.Combine(HooksDir, "kcap.json"); + + public string ConfigToml => Path.Combine(Home, "config.toml"); + + /// One directory per cwd, holding one directory per session. + public string SessionsDir => Path.Combine(Home, "sessions"); + + /// + /// The session's updates.jsonl, or null when its directory cannot be named. The directory + /// is the cwd percent-encoded; past Grok uses a slug instead and + /// records the real cwd in that directory's .cwd file. + /// + public string? UpdatesJsonl(string cwd, string sessionId) { + var encoded = Uri.EscapeDataString(cwd); + + if (Encoding.UTF8.GetByteCount(encoded) <= MaxDirNameBytes) + return Path.Combine(SessionsDir, encoded, sessionId, "updates.jsonl"); + + return SlugDirFor(cwd) is { } dir ? Path.Combine(dir, sessionId, "updates.jsonl") : null; + } + + string? SlugDirFor(string cwd) { + if (!Directory.Exists(SessionsDir)) return null; + + foreach (var dir in Directory.EnumerateDirectories(SessionsDir)) { + var marker = Path.Combine(dir, ".cwd"); + + try { + if (File.Exists(marker) && File.ReadAllText(marker).TrimEnd('\n', '\r') == cwd) return dir; + } catch (IOException) { } catch (UnauthorizedAccessException) { } + } + + return null; + } + + /// Whether Grok has run here. kcap can create first, so the root + /// alone does not count. + public bool HasUserData() => + Directory.Exists(Home) && (File.Exists(ConfigToml) || Directory.Exists(SessionsDir)); +} diff --git a/src/Capacitor.Cli.Core/Harness/Grok/GrokSubagentEvent.cs b/src/Capacitor.Cli.Core/Harness/Grok/GrokSubagentEvent.cs new file mode 100644 index 0000000000..bbe1b5fba5 --- /dev/null +++ b/src/Capacitor.Cli.Core/Harness/Grok/GrokSubagentEvent.cs @@ -0,0 +1,4 @@ +namespace Capacitor.Cli.Core.Harness.Grok; + +/// A parent's record of one of its subagents starting or finishing. +public sealed record GrokSubagentEvent(string ChildSessionId, string? SubagentType, bool Finished); diff --git a/src/Capacitor.Cli.Core/Harness/Grok/GrokSubagents.cs b/src/Capacitor.Cli.Core/Harness/Grok/GrokSubagents.cs new file mode 100644 index 0000000000..0ff9062dd5 --- /dev/null +++ b/src/Capacitor.Cli.Core/Harness/Grok/GrokSubagents.cs @@ -0,0 +1,40 @@ +using System.Text.Json; + +namespace Capacitor.Cli.Core.Harness.Grok; + +/// +/// Grok fires no hook for a subagent. It runs as a session of its own beside its parent's, and the +/// parent's updates.jsonl is the only record of it starting and finishing. +/// +public static class GrokSubagents { + public static IEnumerable Read(IEnumerable parentLines) { + foreach (var line in parentLines) { + if (!line.Contains("subagent_", StringComparison.Ordinal)) continue; + + GrokSubagentEvent? found = null; + try { + using var doc = JsonDocument.Parse(line); + var root = doc.RootElement; + + if (root.Str("method") == "_x.ai/session/update" + && root.Obj("params")?.Obj("update") is { } update + // The id names a sibling directory, so a non-GUID one could point outside the sessions dir. + && update.Str("child_session_id") is { Length: > 0 } child + && Guid.TryParse(child, out _)) { + found = update.Str("sessionUpdate") switch { + "subagent_spawned" => new(child, update.Str("subagent_type"), Finished: false), + "subagent_finished" => new(child, update.Str("subagent_type"), Finished: true), + _ => null + }; + } + } catch (JsonException) { } + + if (found is not null) yield return found; + } + } + + public static string ChildTranscript(string parentTranscript, string childSessionId) => + Path.Combine(Path.GetDirectoryName(Path.GetDirectoryName(parentTranscript)!)!, childSessionId, "updates.jsonl"); + + public static string AgentId(string childSessionId) => childSessionId.Replace("-", ""); +} diff --git a/src/Capacitor.Cli.Core/Harness/HarnessId.cs b/src/Capacitor.Cli.Core/Harness/HarnessId.cs index 57bdedb732..44049dfbae 100644 --- a/src/Capacitor.Cli.Core/Harness/HarnessId.cs +++ b/src/Capacitor.Cli.Core/Harness/HarnessId.cs @@ -17,4 +17,5 @@ public enum HarnessId { OpenCode, Antigravity, MistralVibe, + Grok, } diff --git a/src/Capacitor.Cli.Core/Harness/HarnessNames.cs b/src/Capacitor.Cli.Core/Harness/HarnessNames.cs index abdb5dff78..7f54ebfd5c 100644 --- a/src/Capacitor.Cli.Core/Harness/HarnessNames.cs +++ b/src/Capacitor.Cli.Core/Harness/HarnessNames.cs @@ -20,6 +20,7 @@ public static class HarnessNames { HarnessId.OpenCode => "opencode", HarnessId.Antigravity => "antigravity", HarnessId.MistralVibe => "mistral-vibe", + HarnessId.Grok => "grok", }; /// The flag that selects this one harness on our commands. @@ -45,6 +46,7 @@ public static class HarnessNames { "opencode" => HarnessId.OpenCode, "antigravity" => HarnessId.Antigravity, "mistral-vibe" => HarnessId.MistralVibe, + "grok" => HarnessId.Grok, _ => null, }; diff --git a/src/Capacitor.Cli.Core/Harness/HarnessOverrides.cs b/src/Capacitor.Cli.Core/Harness/HarnessOverrides.cs index d18922471b..5aaaba75cf 100644 --- a/src/Capacitor.Cli.Core/Harness/HarnessOverrides.cs +++ b/src/Capacitor.Cli.Core/Harness/HarnessOverrides.cs @@ -26,6 +26,7 @@ public static class HarnessOverrides { HarnessId.OpenCode => "KCAP_OPENCODE_PATH", HarnessId.Antigravity => "KCAP_ANTIGRAVITY_PATH", HarnessId.MistralVibe => "KCAP_MISTRAL_VIBE_PATH", + HarnessId.Grok => "KCAP_GROK_PATH", }; /// Overrides the model a hosted agent of this vendor runs. Null where we hand the @@ -38,7 +39,7 @@ public static class HarnessOverrides { HarnessId.Antigravity => "KCAP_ANTIGRAVITY_MODEL", HarnessId.Claude or HarnessId.Codex or HarnessId.Copilot or HarnessId.Gemini - or HarnessId.MistralVibe => null, + or HarnessId.MistralVibe or HarnessId.Grok => null, }; } } diff --git a/src/Capacitor.Cli.Core/Harness/HarnessRegistry.cs b/src/Capacitor.Cli.Core/Harness/HarnessRegistry.cs index b248b662ed..2e6395aad9 100644 --- a/src/Capacitor.Cli.Core/Harness/HarnessRegistry.cs +++ b/src/Capacitor.Cli.Core/Harness/HarnessRegistry.cs @@ -5,6 +5,7 @@ using Capacitor.Cli.Core.Harness.Copilot; using Capacitor.Cli.Core.Harness.Cursor; using Capacitor.Cli.Core.Harness.Gemini; +using Capacitor.Cli.Core.Harness.Grok; using Capacitor.Cli.Core.Harness.Kiro; using Capacitor.Cli.Core.Harness.MistralVibe; using Capacitor.Cli.Core.Harness.OpenCode; @@ -50,6 +51,7 @@ public sealed class HarnessRegistry : IReadOnlyList, IHarnessDetection IdentityOf(), IdentityOf(), IdentityOf(), + IdentityOf(), ]; static HarnessIdentity IdentityOf() where TSelf : IHarness => new(TSelf.Id, TSelf.Label); @@ -76,6 +78,7 @@ public static HarnessRegistry FromEnvironment(UserHome home, BinaryProbe binarie OpenCodeHarness.FromEnvironment(home), AntigravityHarness.Over(gemini), MistralVibeHarness.FromEnvironment(home), + GrokHarness.FromEnvironment(home), ], binaries); } diff --git a/src/Capacitor.Cli.Core/Resources/help-hook.txt b/src/Capacitor.Cli.Core/Resources/help-hook.txt index edab93ecf1..555b99d4f1 100644 --- a/src/Capacitor.Cli.Core/Resources/help-hook.txt +++ b/src/Capacitor.Cli.Core/Resources/help-hook.txt @@ -12,7 +12,7 @@ Description: dispatch is argument-driven (no stdin payload) — see --pi / --opencode below. The command is registered via 'kcap setup' or - 'kcap plugin install [--claude|--codex|--cursor|--copilot|--gemini|--kiro|--pi|--opencode|--antigravity|--mistral-vibe]', + 'kcap plugin install [--claude|--codex|--cursor|--copilot|--gemini|--kiro|--pi|--opencode|--antigravity|--mistral-vibe|--grok]', which writes the appropriate hook configuration (or, for Pi/OpenCode/Antigravity, the plugin/hooks-block) for the vendor. @@ -60,6 +60,12 @@ Options: (vendor=mistral-vibe) and POSTs session-start; later hooks are no-ops. Always exits 0 with empty stdout (a passthrough under Vibe's hook contract). + --grok + Dispatch a Grok Build hook event (session_start / stop, from the + payload's hookEventName). session_start POSTs session-start and + spawns the watcher on the session's updates.jsonl (vendor=grok); + stop restarts a watcher that has gone. Always exits 0 with empty + stdout. Exit codes: 0 Normal exit (including budget expiry, malformed payload, and diff --git a/src/Capacitor.Cli.Core/Resources/help-import.txt b/src/Capacitor.Cli.Core/Resources/help-import.txt index 26ce1022f4..757e68dc49 100644 --- a/src/Capacitor.Cli.Core/Resources/help-import.txt +++ b/src/Capacitor.Cli.Core/Resources/help-import.txt @@ -1,7 +1,7 @@ kcap import — Import local sessions from every detected coding agent Discovers Claude, Codex, Cursor, Copilot, Gemini, Kiro, Pi, OpenCode, Antigravity, -and Mistral Vibe sessions on this machine and imports them in a single run. Most agents +Mistral Vibe, and Grok sessions on this machine and imports them in a single run. Most agents ship a per-session .jsonl transcript (OpenCode is a SQLite db): Claude — ~/.claude/projects//.jsonl Codex — ~/.codex/sessions/.../rollout-.jsonl @@ -15,7 +15,8 @@ ship a per-session .jsonl transcript (OpenCode is a SQLite db): Antigravity — ~/.gemini/antigravity/brain//.system_generated/logs/transcript_full.jsonl Vibe — ~/.vibe/logs/session/unified// (current store) and the legacy ~/.vibe/logs/session/session_/messages.jsonl (honours $VIBE_HOME) -kcap walks all ten the same way. + Grok — ~/.grok/sessions///updates.jsonl (honours $GROK_HOME) +kcap walks all eleven the same way. Usage: kcap import [vendor-filters] [scope] [options] @@ -34,6 +35,7 @@ Vendor filters (additive — pass one or more to restrict the run): --opencode Import SST OpenCode sessions (~/.local/share/opencode/opencode.db) --antigravity Import Google Antigravity conversations (~/.gemini/antigravity/brain) --mistral-vibe Import Mistral Vibe sessions (~/.vibe/logs/session) + --grok Import Grok Build sessions and their subagents (~/.grok/sessions) Scope (one of, required for non-interactive use): --all Import every discovered session diff --git a/src/Capacitor.Cli.Core/Resources/help-plugin.txt b/src/Capacitor.Cli.Core/Resources/help-plugin.txt index 8dd4d1d676..72727aaa3c 100644 --- a/src/Capacitor.Cli.Core/Resources/help-plugin.txt +++ b/src/Capacitor.Cli.Core/Resources/help-plugin.txt @@ -152,6 +152,13 @@ Options: --skip-mistral-vibe-skills With --mistral-vibe: skip installing the kcap skills into ~/.agents/skills. + --grok Target Grok Build: write kcap's own ~/.grok/hooks/kcap.json + (honours $GROK_HOME) for SessionStart/Stop and install the + agent skills. Writes the bare `kcap hook --grok` command, so + kcap must be on PATH. Grok loads hooks at startup — restart + any running `grok` session. + --skip-grok-skills With --grok: skip installing the kcap skills into + ~/.agents/skills. --tools-only With --cursor, --copilot, --gemini, --kiro, --pi, --opencode, --antigravity or --mistral-vibe: install only the tools (MCP servers, skills, steering instructions; the @@ -299,6 +306,7 @@ Examples: kcap plugin install --opencode # Install OpenCode plugin (~/.config/opencode/plugins/kcap.ts) kcap plugin install --antigravity # Install Antigravity capture plugin (~/.gemini/config/plugins/kcap/) kcap plugin install --mistral-vibe # Install Mistral Vibe hooks (merge into ~/.vibe/hooks.toml) + MCP + kcap plugin install --grok # Write ~/.grok/hooks/kcap.json + agent skills kcap plugin install --cursor --tools-only # Cursor MCP servers + agent skills, no hooks kcap plugin install --kiro --tools-only # Kiro MCP servers + skills, default agent unchanged kcap plugin install --skills --if-installed # Refresh skills if previously installed (used by npm postinstall) @@ -312,4 +320,5 @@ Examples: kcap plugin remove --opencode # Delete ~/.config/opencode/plugins/kcap.ts kcap plugin remove --antigravity # Remove the kcap plugin from ~/.gemini/config/plugins/kcap/ kcap plugin remove --mistral-vibe # Remove kcap hooks + MCP from ~/.vibe/hooks.toml and config.toml + kcap plugin remove --grok # Delete ~/.grok/hooks/kcap.json kcap plugin remove --skills # Remove agent skills + legacy ~/.codex/skills diff --git a/src/Capacitor.Cli.Core/Resources/help-setup.txt b/src/Capacitor.Cli.Core/Resources/help-setup.txt index 28b48abc2a..2c57c303c9 100644 --- a/src/Capacitor.Cli.Core/Resources/help-setup.txt +++ b/src/Capacitor.Cli.Core/Resources/help-setup.txt @@ -121,6 +121,8 @@ Options: Antigravity (the shared ~/.gemini/GEMINI.md). --skip-antigravity-skills Skip copying the kcap skills into ~/.gemini/skills (where Antigravity reads them, not ~/.agents/skills). + --skip-grok-hooks Don't install the Grok hooks even if Grok is + detected (~/.grok/ or grok on PATH). --use-provider-api-key In --no-prompt mode, set whether kcap keeps ANTHROPIC_API_KEY / OPENAI_API_KEY in headless agent spawns. Accepts true/1/yes/on or diff --git a/src/Capacitor.Cli.Core/Resources/help-usage.txt b/src/Capacitor.Cli.Core/Resources/help-usage.txt index d61f539e3f..24d5abf037 100644 --- a/src/Capacitor.Cli.Core/Resources/help-usage.txt +++ b/src/Capacitor.Cli.Core/Resources/help-usage.txt @@ -95,8 +95,8 @@ MCP servers (stdio): mcp knowledge Read facts and skills and fine-tune skills from agents Plugin: - plugin install [--project] [--codex|--cursor|--copilot|--gemini|--kiro|--pi|--opencode|--antigravity|--mistral-vibe|--skills] Register hooks/skills (Claude default; flag picks the vendor) - plugin remove [--project] [--codex|--cursor|--copilot|--gemini|--kiro|--pi|--opencode|--antigravity|--mistral-vibe|--skills] Remove hooks/skills (Claude default; flag picks the vendor) + plugin install [--project] [--codex|--cursor|--copilot|--gemini|--kiro|--pi|--opencode|--antigravity|--mistral-vibe|--grok|--skills] Register hooks/skills (Claude default; flag picks the vendor) + plugin remove [--project] [--codex|--cursor|--copilot|--gemini|--kiro|--pi|--opencode|--antigravity|--mistral-vibe|--grok|--skills] Remove hooks/skills (Claude default; flag picks the vendor) Maintenance: update Check for and install updates diff --git a/src/Capacitor.Cli.Daemon/DaemonConfig.cs b/src/Capacitor.Cli.Daemon/DaemonConfig.cs index af27a2dae2..c6c3f1ba95 100644 --- a/src/Capacitor.Cli.Daemon/DaemonConfig.cs +++ b/src/Capacitor.Cli.Daemon/DaemonConfig.cs @@ -417,6 +417,10 @@ public BinaryProbe Binaries { /// kcap config a slot for the binary path; it is not read by any launcher. public string MistralVibePath { get; set; } = Core.Harness.MistralVibe.MistralVibeHarness.CliBinary; + /// Path or bare command for Grok Build. Overridable via KCAP_GROK_PATH. Grok is + /// recording-only until the daemon hosts it, so no launcher reads this yet. + public string GrokPath { get; set; } = Core.Harness.Grok.GrokHarness.CliBinary; + /// /// Opt-in, off-by-default ACP wire/content debug logging (KCAP_ACP_DEBUG_FRAMES). When /// (the default), the ACP layers (AcpEventTranslator, diff --git a/src/Capacitor.Cli.Daemon/VendorConfigSlots.cs b/src/Capacitor.Cli.Daemon/VendorConfigSlots.cs index e2528255dd..b4b1ab4f11 100644 --- a/src/Capacitor.Cli.Daemon/VendorConfigSlots.cs +++ b/src/Capacitor.Cli.Daemon/VendorConfigSlots.cs @@ -23,6 +23,7 @@ internal static class VendorConfigSlots { HarnessId.OpenCode => new(() => config.OpenCodePath, v => config.OpenCodePath = v), HarnessId.Antigravity => new(() => config.AntigravityPath, v => config.AntigravityPath = v), HarnessId.MistralVibe => new(() => config.MistralVibePath, v => config.MistralVibePath = v), + HarnessId.Grok => new(() => config.GrokPath, v => config.GrokPath = v), }; /// Null for the vendors we hand no model at all, which then pick their own. Exactly @@ -36,7 +37,7 @@ internal static class VendorConfigSlots { HarnessId.Antigravity => new(() => config.AntigravityModel, v => config.AntigravityModel = v), HarnessId.Claude or HarnessId.Codex or HarnessId.Copilot or HarnessId.Gemini - or HarnessId.MistralVibe => null, + or HarnessId.MistralVibe or HarnessId.Grok => null, }; } } diff --git a/src/Capacitor.Cli/Commands/CodingAgentsStep.cs b/src/Capacitor.Cli/Commands/CodingAgentsStep.cs index 3d1285c18d..716ec5b828 100644 --- a/src/Capacitor.Cli/Commands/CodingAgentsStep.cs +++ b/src/Capacitor.Cli/Commands/CodingAgentsStep.cs @@ -30,9 +30,9 @@ internal record Options(bool SkipClaude, bool SkipCodex, bool SkipCursor, bool S // `--no-prompt` gives them no consent moment in which to be told otherwise. Turning a // skip flag into something that writes MCP config would be a silent reversal. bool ToolsIndependentOfCapture = false, - bool SkipMistralVibe = false, bool SkipMistralVibeMcp = false); + bool SkipMistralVibe = false, bool SkipMistralVibeMcp = false, bool SkipGrok = false); - internal record DetectedAgents(bool Claude, bool Codex, bool Cursor, bool Copilot, bool Gemini = false, bool Kiro = false, bool Pi = false, bool OpenCode = false, bool Antigravity = false, bool Vibe = false); + internal record DetectedAgents(bool Claude, bool Codex, bool Cursor, bool Copilot, bool Gemini = false, bool Kiro = false, bool Pi = false, bool OpenCode = false, bool Antigravity = false, bool Vibe = false, bool Grok = false); internal record Paths( string ClaudeSettingsPath, @@ -65,7 +65,8 @@ internal record Paths( string PiMcpExtensionPath = "", string PiAgentsMdPath = "", string VibeHooksPath = "", - string VibeConfigPath = "" + string VibeConfigPath = "", + string GrokHooksPath = "" ); internal record Installers( @@ -98,7 +99,8 @@ internal record Installers( Func? InstallPiInstructions = null, Func? InstallKiroCrewHook = null, Func? InstallVibeHooks = null, - Func? RegisterVibeMcp = null + Func? RegisterVibeMcp = null, + Func? InstallGrokHooks = null ); internal record Result( @@ -131,7 +133,8 @@ internal record Result( bool KiroCrewHookInstalled = false, bool KiroCrewSkillsInstalled = false, bool VibeHooksInstalled = false, - bool VibeMcpRegistered = false + bool VibeMcpRegistered = false, + bool GrokHooksInstalled = false ) { /// /// True when at least one agent's hooks were installed — i.e. there's a @@ -141,7 +144,7 @@ internal record Result( /// as agents are added (consumers like SetupCommand's restart tip key off this). /// internal bool AnyHooksInstalled => - ClaudeInstalled || CodexHooksInstalled || CursorHooksInstalled || CopilotHooksInstalled || GeminiHooksInstalled || KiroHooksInstalled || KiroCrewHookInstalled || PiExtensionInstalled || OpenCodeExtensionInstalled || AntigravityHooksInstalled || VibeHooksInstalled; + ClaudeInstalled || CodexHooksInstalled || CursorHooksInstalled || CopilotHooksInstalled || GeminiHooksInstalled || KiroHooksInstalled || KiroCrewHookInstalled || PiExtensionInstalled || OpenCodeExtensionInstalled || AntigravityHooksInstalled || VibeHooksInstalled || GrokHooksInstalled; } /// @@ -188,6 +191,7 @@ void Add(bool done, string path) { Add(r.AntigravitySkillsInstalled, p.AntigravitySkillsDir); Add(r.VibeHooksInstalled, p.VibeHooksPath); Add(r.VibeMcpRegistered, p.VibeConfigPath); + Add(r.GrokHooksInstalled, p.GrokHooksPath); return paths; } @@ -208,8 +212,8 @@ Action writeLine // Ordered early-returns — BEFORE any Handle*/HandleAgentSkills/selected work — so a // decline (or a no-agents machine) guarantees zero artifact mutations. See // SetupDecisions.DecideInstallAgents for how SetupCommand derives InstallAgents. - if (detected is { Claude: false, Codex: false, Cursor: false, Copilot: false, Gemini: false, Kiro: false, Pi: false, OpenCode: false, Antigravity: false, Vibe: false }) { - writeLine(" [yellow]⚠ No supported agent CLI detected.[/] Install Claude Code, Codex CLI, Cursor, Copilot CLI, Gemini CLI, Kiro CLI, Pi, OpenCode, Antigravity, or Mistral Vibe to start capturing sessions."); + if (detected is { Claude: false, Codex: false, Cursor: false, Copilot: false, Gemini: false, Kiro: false, Pi: false, OpenCode: false, Antigravity: false, Vibe: false, Grok: false }) { + writeLine(" [yellow]⚠ No supported agent CLI detected.[/] Install Claude Code, Codex CLI, Cursor, Copilot CLI, Gemini CLI, Kiro CLI, Pi, OpenCode, Antigravity, Mistral Vibe, or Grok to start capturing sessions."); return Task.FromResult(new Result(false, false, false, false, false)); } @@ -263,6 +267,7 @@ Action writeLine var antigravitySkillsInstalled = HandleAntigravitySkills(options, paths, installers, writeLine, antigravitySelected); var vibeHooksInstalled = HandleVibeHooks(options, detected, paths, installers, writeLine, out var vibeTools); var vibeMcpRegistered = HandleVibeMcp(options, paths, installers, writeLine, vibeTools); + var grokHooksInstalled = HandleGrokHooks(options, detected, paths, installers, writeLine); // the shared ~/.agents/skills/ install is decoupled from Codex: run it // once when any non-Claude agent is detected, independent of that agent's hook @@ -308,7 +313,8 @@ Action writeLine KiroCrewHookInstalled: kiroCrewHookInstalled, KiroCrewSkillsInstalled: kiroCrewSkillsInstalled, VibeHooksInstalled: vibeHooksInstalled, - VibeMcpRegistered: vibeMcpRegistered + VibeMcpRegistered: vibeMcpRegistered, + GrokHooksInstalled: grokHooksInstalled ) ); } @@ -709,6 +715,47 @@ out bool toolsEligible return true; } + static bool HandleGrokHooks( + Options options, + DetectedAgents detected, + Paths paths, + Installers installers, + Action writeLine + ) { + if (!detected.Grok) { + writeLine(" [dim]· Grok not detected — skipping[/]"); + + return false; + } + + writeLine(" [green]✓[/] Grok detected"); + + if (options.SkipGrok) { + writeLine(" [dim]· Grok hooks skipped by flag[/]"); + + return false; + } + + // The hook file names the bare "kcap hook --grok" command, so Grok must find kcap on PATH. + if (!installers.CapacitorOnPath()) { + writeLine(" [yellow]⚠[/] Grok hooks not installed — 'kcap' is not on PATH."); + writeLine(" [dim]Re-install kcap: [/][cyan]" + InstallProvenance.ReinstallCommand() + "[/]"); + + return false; + } + + if (installers.InstallGrokHooks is null || !installers.InstallGrokHooks(paths.GrokHooksPath)) { + writeLine($" [yellow]⚠[/] Could not write Grok hooks to {Markup.Escape(paths.GrokHooksPath)}."); + + return false; + } + + writeLine($" [green]✓[/] Grok hooks installed ({Markup.Escape(paths.GrokHooksPath)})"); + writeLine(" [dim] Note: Grok loads hooks at startup — restart any running grok session to pick them up.[/]"); + + return true; + } + static bool HandleVibeMcp( Options options, Paths paths, @@ -1135,7 +1182,8 @@ static DetectedAgents SkillsAudience(Options options, DetectedAgents detected) { Gemini = Wanted(detected.Gemini, options.SkipGemini, options.SkipGeminiMcp), Pi = Wanted(detected.Pi, options.SkipPi, options.SkipPiMcp), OpenCode = Wanted(detected.OpenCode, options.SkipOpenCode, options.SkipOpenCodeMcp), - Vibe = Wanted(detected.Vibe, options.SkipMistralVibe, options.SkipMistralVibeMcp) + Vibe = Wanted(detected.Vibe, options.SkipMistralVibe, options.SkipMistralVibeMcp), + Grok = Wanted(detected.Grok, options.SkipGrok, options.SkipGrok) }; } @@ -1151,7 +1199,7 @@ Action writeLine // install they can't see. var anyNonClaudeDetected = detected.Codex || detected.Cursor || detected.Copilot || detected.Gemini - || detected.Pi || detected.OpenCode || detected.Vibe; + || detected.Pi || detected.OpenCode || detected.Vibe || detected.Grok; // Nothing that reads ~/.agents/skills/ is present (Claude-only or nothing) — the // Claude plugin install handles Claude's skills, so there's nothing to do here. diff --git a/src/Capacitor.Cli/Commands/CommandServices.cs b/src/Capacitor.Cli/Commands/CommandServices.cs index 6ad65c6789..c867701cc4 100644 --- a/src/Capacitor.Cli/Commands/CommandServices.cs +++ b/src/Capacitor.Cli/Commands/CommandServices.cs @@ -160,6 +160,7 @@ public static IServiceCollection AddCapacitorCommands(this IServiceCollection se services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient(); diff --git a/src/Capacitor.Cli/Commands/HandoffLaunchRecipe.cs b/src/Capacitor.Cli/Commands/HandoffLaunchRecipe.cs index 0156b8330e..914171f162 100644 --- a/src/Capacitor.Cli/Commands/HandoffLaunchRecipe.cs +++ b/src/Capacitor.Cli/Commands/HandoffLaunchRecipe.cs @@ -17,6 +17,7 @@ internal sealed record HandoffLaunchRecipe(HarnessId Vendor, IReadOnlyList Argv(string prompt) => [.. LeadingArgs, prompt]; diff --git a/src/Capacitor.Cli/Commands/Harness/GrokHookCommand.cs b/src/Capacitor.Cli/Commands/Harness/GrokHookCommand.cs new file mode 100644 index 0000000000..2158caef47 --- /dev/null +++ b/src/Capacitor.Cli/Commands/Harness/GrokHookCommand.cs @@ -0,0 +1,112 @@ +using System.Globalization; +using System.Text.Json.Nodes; +using Capacitor.Cli.Core; +using Capacitor.Cli.Core.Config; +using Capacitor.Cli.Core.Harness; +using Capacitor.Cli.Core.Harness.Grok; +using Capacitor.Cli.Core.Http; +using Capacitor.Cli.PrDetection; + +namespace Capacitor.Cli.Commands.Harness; + +/// +/// kcap hook --grok, run by Grok Build for the events registers. +/// Session start posts the session and starts the watcher on the session's updates.jsonl; Stop +/// restarts a watcher that is gone. The watcher ends the session itself once Grok exits. +/// +/// +/// Grok reads a passive hook's exit code and nothing else, and a non-zero exit from Stop could hold the +/// turn open, so this always exits 0 and writes nothing to stdout. +/// +sealed class GrokHookCommand( + ConfigRoot config, ProfileContext profiles, HookClock clock, UserHome home, + HarnessRegistry harnesses, ICapacitorHttpClient http, WatcherManager watchers, GitProviderRouter router) { + readonly AgentHookPoster _poster = new(config, profiles, http, watchers, clock.Time); + + string Url => profiles.Resolution.ServerUrl!; + + public async Task Handle(TextReader stdin) { + var body = await stdin.ReadToEndAsync(); + + JsonNode? node; + try { node = JsonNode.Parse(body); } catch { return 0; } + if (node is null) return 0; + + try { + await DispatchAsync(node); + } catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException) { + await Console.Error.WriteLineAsync($"[kcap] grok hook failed: {ex.Message}"); + } + + return 0; + } + + async Task DispatchAsync(JsonNode node) { + var dashedSessionId = Str(node, "sessionId"); + if (string.IsNullOrEmpty(dashedSessionId) || !Guid.TryParse(dashedSessionId, out _)) return; + + var sessionId = dashedSessionId.Replace("-", ""); + if (DisabledSessions.IsDisabled(sessionId, config)) return; + + var cwd = Str(node, "cwd"); + var activeProfile = profiles.Effective; + + if (PathExclusion.IsOutOfScope(cwd, activeProfile?.AllowedPaths, activeProfile?.ExcludedPaths, home)) return; + + switch (Str(node, "hookEventName")) { + // Session start carries no transcript path, and a cwd too long to encode resolves only once + // Grok writes its .cwd file, so the start is posted whether or not the file can be found yet. + case "session_start": + var derived = cwd is null ? null : harnesses.Of().Paths.UpdatesJsonl(cwd, dashedSessionId); + await StartRecording(node, sessionId, cwd, activeProfile, derived); + break; + case "stop" when Str(node, "transcriptPath") is { } named && !watchers.IsWatcherAlive(sessionId): + await EnsureWatcher(sessionId, named, cwd, skipTitle: true); + break; + } + } + + async Task StartRecording(JsonNode node, string sessionId, string? cwd, Profile? activeProfile, string? transcriptPath) { + var forwarded = new JsonObject { + ["hook_event_name"] = "SessionStart", + ["session_id"] = sessionId, + ["cwd"] = cwd ?? "", + ["home_dir"] = home.Path, + }; + + if ((Str(node, "workspaceRoot") ?? (cwd is null ? null : GitRepository.FindRoot(cwd))) is { } root) forwarded["workspace_root"] = root; + if (Timestamp(node) is { } startedAt) forwarded["started_at"] = startedAt.ToString("O"); + if (activeProfile?.DefaultVisibility is { } visibility) forwarded["default_visibility"] = visibility; + + var enriched = await RepositoryDetection.EnrichWithRepositoryInfo(router, config, forwarded.ToJsonString(), clock.Time); + + if (await RepoExclusion.IsOutOfScopeAsync(router, config, enriched, + activeProfile?.AllowedRepos, activeProfile?.ExcludedRepos, clock.Time)) { + DisabledSessions.Mark(sessionId, config); + return; + } + + var outcome = await _poster.PostOrSpoolAsync("session-start/grok", enriched, "grok-hook", + new HookSpool(config, clock.Time), sessionId, route: "session-start/grok"); + + // A refused start leaves the session unrecorded, so a later Stop must not start capturing it. + if (outcome == HookPostOutcome.Failed) DisabledSessions.Mark(sessionId, config); + if (!AgentHookPoster.ShouldSpawnAfter(outcome, Url) || transcriptPath is null) return; + + // A resumed session already has its title. + await EnsureWatcher(sessionId, transcriptPath, cwd, skipTitle: Str(node, "source") is "load" or "resume"); + } + + Task EnsureWatcher(string sessionId, string transcriptPath, string? cwd, bool skipTitle) => + watchers.EnsureWatcherRunning(sessionId, transcriptPath, + agentId: null, sessionIdOverride: null, cwd: cwd, skipTitle: skipTitle, vendor: "grok"); + + static DateTimeOffset? Timestamp(JsonNode node) => + Str(node, "timestamp") is { } s + && DateTimeOffset.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var ts) + ? ts + : null; + + static string? Str(JsonNode node, string field) => + node[field] is JsonValue v && v.TryGetValue(out var s) && s.Length > 0 ? s : null; +} diff --git a/src/Capacitor.Cli/Commands/PluginCommand.cs b/src/Capacitor.Cli/Commands/PluginCommand.cs index 32164b06fc..ce33922feb 100644 --- a/src/Capacitor.Cli/Commands/PluginCommand.cs +++ b/src/Capacitor.Cli/Commands/PluginCommand.cs @@ -10,6 +10,7 @@ using Capacitor.Cli.Core.Harness.Copilot; using Capacitor.Cli.Core.Harness.Cursor; using Capacitor.Cli.Core.Harness.Gemini; +using Capacitor.Cli.Core.Harness.Grok; using Capacitor.Cli.Core.Harness.Kiro; using Capacitor.Cli.Core.Harness.MistralVibe; using Capacitor.Cli.Core.Harness.OpenCode; @@ -47,10 +48,10 @@ public async Task HandleAsync(string[] args) { }; } - static readonly string[] ExclusiveTargetFlags = ["--codex", "--cursor", "--copilot", "--gemini", "--kiro", "--pi", "--opencode", "--antigravity", "--mistral-vibe", "--skills"]; + static readonly string[] ExclusiveTargetFlags = ["--codex", "--cursor", "--copilot", "--gemini", "--kiro", "--pi", "--opencode", "--antigravity", "--mistral-vibe", "--grok", "--skills"]; const string MutuallyExclusiveMsg = - "--cursor, --codex, --copilot, --gemini, --kiro, --pi, --opencode, --antigravity, --mistral-vibe, and --skills are mutually exclusive."; + "--cursor, --codex, --copilot, --gemini, --kiro, --pi, --opencode, --antigravity, --mistral-vibe, --grok, and --skills are mutually exclusive."; static bool HasConflictingTargets(string[] args) => ExclusiveTargetFlags.Count(args.Contains) > 1; @@ -84,6 +85,9 @@ Task InstallHarness(string[] args) { if (args.Contains("--codex")) return RefuseToolsOnlyAsync("Codex", "its hooks and skills install as one unit"); + if (args.Contains("--grok")) + return RefuseToolsOnlyAsync("Grok", "kcap installs no tools for it; Grok loads the ones set up for Claude Code"); + if (!ExclusiveTargetFlags.Any(args.Contains)) return RefuseToolsOnlyAsync("Claude Code", "its MCP servers and skills ship inside the plugin that records sessions"); } @@ -97,6 +101,7 @@ Task InstallHarness(string[] args) { if (args.Contains("--opencode")) return InstallOpenCode(args); if (args.Contains("--antigravity")) return InstallAntigravity(args); if (args.Contains("--mistral-vibe")) return InstallVibe(args); + if (args.Contains("--grok")) return InstallGrok(args); return InstallClaude(args); } @@ -135,6 +140,7 @@ async Task Remove(string[] args) { if (args.Contains("--opencode")) return await RemoveOpenCode(args); if (args.Contains("--antigravity")) return await RemoveAntigravity(args); if (args.Contains("--mistral-vibe")) return await RemoveVibe(args); + if (args.Contains("--grok")) return await RemoveGrok(args); return await RemoveClaude(args); } @@ -2365,6 +2371,46 @@ await env.Stdout.WriteLineAsync( return hooksFailed || mcpFailed || instrFailed ? 1 : 0; } + async Task InstallGrok(string[] args) { + var hooksPath = GetArg(args, "--grok-hooks-path") ?? env.Harnesses.Of().Paths.KcapHooksJson; + var refreshOnly = args.Contains("--if-installed"); + + if (refreshOnly && !GrokHooksInstaller.IsInstalled(hooksPath)) return 0; + + // The hook file names the bare `kcap hook --grok` command, so Grok must find kcap on PATH. + if (!refreshOnly && !KcapOnPath) { + await env.Stderr.WriteLineAsync( + "Cannot install Grok hooks: 'kcap' is not on PATH. Re-install kcap: " + InstallProvenance.ReinstallCommand()); + return 1; + } + + if (!GrokHooksInstaller.Install(hooksPath)) { + await env.Stderr.WriteLineAsync($"Could not write Grok hooks to {hooksPath}."); + return refreshOnly ? 0 : 1; + } + + await env.Stdout.WriteLineAsync(refreshOnly ? $"Grok hooks refreshed ({hooksPath})" : $"Grok hooks installed ({hooksPath})"); + + if (!args.Contains("--skip-grok-skills")) + await InstallVendorSkillsAsync(env.Agents.UserSkillsDir, "Agent", refreshOnly); + + return 0; + } + + async Task RemoveGrok(string[] args) { + var hooksPath = GetArg(args, "--grok-hooks-path") ?? env.Harnesses.Of().Paths.KcapHooksJson; + + try { + await env.Stdout.WriteLineAsync(GrokHooksInstaller.Remove(hooksPath) + ? $"Grok hooks removed ({hooksPath})" + : "Grok hooks were not installed."); + return 0; + } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { + await env.Stderr.WriteLineAsync($"Could not remove Grok hooks at {hooksPath}: {ex.Message}"); + return 1; + } + } + async Task InstallVibe(string[] args) { var vibe = env.Harnesses.Of().Paths; var hooksPath = GetArg(args, "--mistral-vibe-hooks-path") ?? vibe.HooksToml; diff --git a/src/Capacitor.Cli/Commands/RefreshCommand.cs b/src/Capacitor.Cli/Commands/RefreshCommand.cs index 1ba498e4d7..d42a8f134d 100644 --- a/src/Capacitor.Cli/Commands/RefreshCommand.cs +++ b/src/Capacitor.Cli/Commands/RefreshCommand.cs @@ -26,6 +26,7 @@ public RefreshCommand(TimeProvider time) : this(new ProcessRunner(time)) { } ["plugin", "install", "--gemini", "--if-installed"], ["plugin", "install", "--kiro", "--if-installed"], ["plugin", "install", "--antigravity", "--if-installed"], + ["plugin", "install", "--grok", "--if-installed"], ["plugin", "install", "--if-installed"], ["daemon", "service", "refresh"], ]; diff --git a/src/Capacitor.Cli/Commands/SetupCommand.cs b/src/Capacitor.Cli/Commands/SetupCommand.cs index 151fc1f3a2..ee96c16848 100644 --- a/src/Capacitor.Cli/Commands/SetupCommand.cs +++ b/src/Capacitor.Cli/Commands/SetupCommand.cs @@ -12,6 +12,7 @@ using Capacitor.Cli.Core.Harness.Cursor; using Capacitor.Cli.Core.Harness.Gemini; using Capacitor.Cli.Core.Harness.Kiro; +using Capacitor.Cli.Core.Harness.Grok; using Capacitor.Cli.Core.Harness.MistralVibe; using Capacitor.Cli.Core.Harness.OpenCode; using Capacitor.Cli.Core.Harness.Pi; @@ -27,6 +28,7 @@ using Capacitor.Cli.Harness.Cursor; using Capacitor.Cli.Harness.Gemini; using Capacitor.Cli.Harness.Kiro; +using Capacitor.Cli.Harness.Grok; using Capacitor.Cli.Harness.MistralVibe; using Capacitor.Cli.Harness.OpenCode; using Capacitor.Cli.Harness.Pi; @@ -590,6 +592,7 @@ public async Task HandleAsync(string[] args) { var skipAntigravitySkillsFlag = args.Contains("--skip-antigravity-skills"); var skipVibeFlag = args.Contains("--skip-mistral-vibe-hooks"); var skipVibeMcpFlag = args.Contains("--skip-mistral-vibe-mcp"); + var skipGrokFlag = args.Contains("--skip-grok-hooks"); var skipImport = args.Contains("--skip-import"); var legacyPluginScope = GetArg(args, "--plugin-scope"); // "user" | "project" | "skip" | null var skipClaude = skipClaudeFlag || legacyPluginScope == "skip"; @@ -722,6 +725,7 @@ await Console.Error.WriteLineAsync( var gemini = harnesses.Of().Paths; var agy = harnesses.Of().Paths; var vibe = harnesses.Of().Paths; + var grok = harnesses.Of().Paths; var stepPaths = new CodingAgentsStep.Paths( ClaudeSettingsPath: claudeSettingsPath, @@ -754,7 +758,8 @@ await Console.Error.WriteLineAsync( PiMcpExtensionPath: pi.KcapMcpExtension, PiAgentsMdPath: pi.AgentsMd, VibeHooksPath: vibe.HooksToml, - VibeConfigPath: vibe.ConfigToml); + VibeConfigPath: vibe.ConfigToml, + GrokHooksPath: grok.KcapHooksJson); // The browser leg, where the tenant serves one. Unnumbered because it is not a step: the // steps below run either way, and on every tenant that has not turned the flow on this @@ -830,7 +835,8 @@ await Console.Error.WriteLineAsync( Pi: harnesses.Detected(HarnessId.Pi), OpenCode: harnesses.Detected(HarnessId.OpenCode), Antigravity: harnesses.Detected(HarnessId.Antigravity), - Vibe: harnesses.Detected(HarnessId.MistralVibe)); + Vibe: harnesses.Detected(HarnessId.MistralVibe), + Grok: harnesses.Detected(HarnessId.Grok)); bool PromptYesNo(string text) => AnsiConsole.Prompt(new ConfirmationPrompt(text) { DefaultValue = true }); @@ -888,7 +894,8 @@ bool PromptYesNo(string text) => SkipPiInstructions: skipPiInstructionsFlag, InstallAgents: installAgents, SkipMistralVibe: skipVibeFlag, - SkipMistralVibeMcp: skipVibeMcpFlag); + SkipMistralVibeMcp: skipVibeMcpFlag, + SkipGrok: skipGrokFlag); stepOptions = SetupDecisions.WithBrowserAnswer(stepOptions, browserAgents); @@ -953,7 +960,8 @@ browserAgents is null pi.AgentsMd, KcapAgentInstructions.Body), // Vibe's hooks.toml and MCP config.toml are TOML (not the JSON writer / Codex map). InstallVibeHooks: p => MistralVibeHooksInstaller.Install(p) != TomlConfigFile.Outcome.Failed, - RegisterVibeMcp: () => MistralVibeConfigToml.RegisterKcapMcpServers(vibe.ConfigToml)); + RegisterVibeMcp: () => MistralVibeConfigToml.RegisterKcapMcpServers(vibe.ConfigToml), + InstallGrokHooks: GrokHooksInstaller.Install); void WriteLine(string line) => AnsiConsole.MarkupLine(line); @@ -995,6 +1003,7 @@ void ListChangedBeforeExit() { OfferedIf(detected.OpenCode, skipOpenCodeFlag, HarnessId.OpenCode); OfferedIf(detected.Antigravity, skipAntigravityFlag, HarnessId.Antigravity); OfferedIf(detected.Vibe, skipVibeFlag, HarnessId.MistralVibe); + OfferedIf(detected.Grok, skipGrokFlag, HarnessId.Grok); new HarnessOfferStore(config, time).StampOffered(offeredNow, time.GetUtcNow()); // Provider API key handling. kcap scrubs ANTHROPIC_API_KEY / OPENAI_API_KEY @@ -1319,7 +1328,7 @@ await ConfigMutator.MutateAsync(config, c => { installResult.ClaudeInstalled, installResult.CodexHooksInstalled, installResult.CursorHooksInstalled, installResult.CopilotHooksInstalled, installResult.GeminiHooksInstalled, installResult.KiroHooksInstalled, installResult.PiExtensionInstalled, installResult.OpenCodeExtensionInstalled, installResult.AntigravityHooksInstalled, - installResult.VibeHooksInstalled, + installResult.VibeHooksInstalled, installResult.GrokHooksInstalled, }.Count(installed => installed); telemetry.Funnel.Succeeded(agentsConfigured); @@ -1811,7 +1820,8 @@ .. codexRoots.Select(r => (IImportSource)new CodexImportSource(config, r, router Path.Combine(opencode.DataDir, "opencode.db"), opencode.ImportLedgerJson, time), new AntigravityImportSource(harnesses.Of().Paths, time), - new MistralVibeImportSource(harnesses.Of().Paths, time) + new MistralVibeImportSource(harnesses.Of().Paths, time), + new GrokImportSource(harnesses.Of().Paths, time) ]; if (vendors is null) return all; diff --git a/src/Capacitor.Cli/Commands/SetupDecisions.cs b/src/Capacitor.Cli/Commands/SetupDecisions.cs index de404a0d0e..d0f20f683c 100644 --- a/src/Capacitor.Cli/Commands/SetupDecisions.cs +++ b/src/Capacitor.Cli/Commands/SetupDecisions.cs @@ -28,6 +28,7 @@ internal static class SetupDecisions { if (d.OpenCode) names.Add("OpenCode"); if (d.Antigravity) names.Add("Antigravity"); if (d.Vibe) names.Add("Mistral Vibe"); + if (d.Grok) names.Add("Grok"); return names.Count == 0 ? null : string.Join(", ", names); } @@ -77,6 +78,7 @@ public static CodingAgentsStep.Options WithBrowserAnswer( SkipOpenCode = Skip(HarnessId.OpenCode, options.SkipOpenCode), SkipAntigravity = Skip(HarnessId.Antigravity, options.SkipAntigravity), SkipMistralVibe = Skip(HarnessId.MistralVibe, options.SkipMistralVibe), + SkipGrok = Skip(HarnessId.Grok, options.SkipGrok), // The hooks flag counts against TOOLS as well. `--skip--hooks` is a whole-vendor // opt-out, and the browser's separate tools axis must not re-enable the half of it the diff --git a/src/Capacitor.Cli/Commands/UninstallCommand.cs b/src/Capacitor.Cli/Commands/UninstallCommand.cs index b01c5f68c1..f0428a7527 100644 --- a/src/Capacitor.Cli/Commands/UninstallCommand.cs +++ b/src/Capacitor.Cli/Commands/UninstallCommand.cs @@ -146,6 +146,7 @@ await Console.Out.WriteLineAsync( if (await pluginCommand.HandleAsync(["plugin", "remove", "--pi"]) != 0) hadFailures = true; // Pi extension (~/.pi/agent/extensions/kcap.ts) if (await pluginCommand.HandleAsync(["plugin", "remove", "--opencode"]) != 0) hadFailures = true; // OpenCode plugin (~/.config/opencode/plugins/kcap.ts) if (await pluginCommand.HandleAsync(["plugin", "remove", "--antigravity"]) != 0) hadFailures = true; // Antigravity kcap plugin (~/.gemini/config/plugins/kcap/) + if (await pluginCommand.HandleAsync(["plugin", "remove", "--grok"]) != 0) hadFailures = true; // ~/.grok/hooks/kcap.json // Skills are removed by --codex above, but call --skills explicitly in // case the user only ever installed Cursor / agent-agnostic skills and diff --git a/src/Capacitor.Cli/Commands/VendorSelection.cs b/src/Capacitor.Cli/Commands/VendorSelection.cs index af01c00706..ceb4150e15 100644 --- a/src/Capacitor.Cli/Commands/VendorSelection.cs +++ b/src/Capacitor.Cli/Commands/VendorSelection.cs @@ -17,7 +17,7 @@ public sealed record Result(IReadOnlySet Vendors, string? Error) { // internal, not private: the driver-schema conformance suite pins its hand-written harness table // against this list, so adding a tenth installable target fails there instead of silently leaving // it uncovered. No enumeration of supported harnesses exists in production code otherwise. - internal static readonly string[] KnownVendorFlags = ["--claude", "--codex", "--cursor", "--copilot", "--gemini", "--kiro", "--pi", "--opencode", "--antigravity", "--mistral-vibe"]; + internal static readonly string[] KnownVendorFlags = ["--claude", "--codex", "--cursor", "--copilot", "--gemini", "--kiro", "--pi", "--opencode", "--antigravity", "--mistral-vibe", "--grok"]; public static Result Parse(string[] args) { var vendors = new HashSet(); @@ -34,6 +34,7 @@ public static Result Parse(string[] args) { case "--opencode": vendors.Add(HarnessId.OpenCode); break; case "--antigravity": vendors.Add(HarnessId.Antigravity); break; case "--mistral-vibe": vendors.Add(HarnessId.MistralVibe); break; + case "--grok": vendors.Add(HarnessId.Grok); break; } } @@ -45,7 +46,7 @@ public static Result Parse(string[] args) { if (!a.StartsWith("--")) continue; if (Array.IndexOf(KnownVendorFlags, a) >= 0) continue; - if (a.StartsWith("--cursor-") || a.StartsWith("--claude-") || a.StartsWith("--codex-") || a.StartsWith("--copilot-") || a.StartsWith("--gemini-") || a.StartsWith("--kiro-") || a.StartsWith("--pi-") || a.StartsWith("--opencode-") || a.StartsWith("--antigravity-") || a.StartsWith("--mistral-vibe-")) { + if (a.StartsWith("--cursor-") || a.StartsWith("--claude-") || a.StartsWith("--codex-") || a.StartsWith("--copilot-") || a.StartsWith("--gemini-") || a.StartsWith("--kiro-") || a.StartsWith("--pi-") || a.StartsWith("--opencode-") || a.StartsWith("--antigravity-") || a.StartsWith("--mistral-vibe-") || a.StartsWith("--grok-")) { return new(vendors, $"Unknown source option: {a}."); } } @@ -54,7 +55,7 @@ public static Result Parse(string[] args) { foreach (var a in args) { if (!a.StartsWith("--")) continue; if (Array.IndexOf(KnownVendorFlags, a) >= 0) continue; - if (a.StartsWith("--cursor-") || a.StartsWith("--claude-") || a.StartsWith("--codex-") || a.StartsWith("--copilot-") || a.StartsWith("--gemini-") || a.StartsWith("--kiro-") || a.StartsWith("--pi-") || a.StartsWith("--opencode-") || a.StartsWith("--antigravity-") || a.StartsWith("--mistral-vibe-")) continue; + if (a.StartsWith("--cursor-") || a.StartsWith("--claude-") || a.StartsWith("--codex-") || a.StartsWith("--copilot-") || a.StartsWith("--gemini-") || a.StartsWith("--kiro-") || a.StartsWith("--pi-") || a.StartsWith("--opencode-") || a.StartsWith("--antigravity-") || a.StartsWith("--mistral-vibe-") || a.StartsWith("--grok-")) continue; string? hint = null; var bestDist = int.MaxValue; diff --git a/src/Capacitor.Cli/Commands/WatchCommand.cs b/src/Capacitor.Cli/Commands/WatchCommand.cs index 986b6e863f..056e569121 100644 --- a/src/Capacitor.Cli/Commands/WatchCommand.cs +++ b/src/Capacitor.Cli/Commands/WatchCommand.cs @@ -1488,7 +1488,7 @@ internal static async Task BackfillCodexWatcherStateAsync( /// Used to reject unexpected --vendor input before interpolating into the URL /// path (defence-in-depth against path traversal even though the CLI runs locally). /// - static readonly HashSet KnownVendors = new(StringComparer.Ordinal) { "claude", "codex", "copilot", "gemini", "kiro", "pi", "opencode", "antigravity", "cursor", "mistral-vibe" }; + static readonly HashSet KnownVendors = new(StringComparer.Ordinal) { "claude", "codex", "copilot", "gemini", "kiro", "pi", "opencode", "antigravity", "cursor", "mistral-vibe", "grok" }; /// /// Total time budget for the parent-exit session-end POST. Covers /auth/config @@ -1684,7 +1684,7 @@ internal static bool ShouldEndOnIdle( /// ). Pure so it's unit-testable; see /// 's call site. /// - internal static bool SkipsThresholdBuffering(string vendor) => vendor is "antigravity" or "cursor" or "mistral-vibe"; + internal static bool SkipsThresholdBuffering(string vendor) => vendor is "antigravity" or "cursor" or "mistral-vibe" or "grok"; /// /// the idle clock measures against for @@ -2740,6 +2740,7 @@ static void SetFirstUserText(WatchState state, string userText, TimeProvider tim "pi" => TryExtractPiAssistantText(line), "opencode" => TryExtractOpenCodeText(line, "assistant"), "antigravity" => TryExtractAntigravityText(line, "assistant"), + "grok" => TryExtractGrokText(line, "agent_message_chunk"), _ => TryExtractClaudeAssistantText(line) }; @@ -2874,6 +2875,9 @@ internal static bool IsEvent(string line, string vendor = "claude") { }; } + if (vendor == "grok") return TryExtractGrokText(line, "user_message_chunk") is not null + || TryExtractGrokText(line, "agent_message_chunk") is not null; + return root.Str("type") is "user" or "assistant"; } catch { return false; @@ -2888,9 +2892,25 @@ internal static bool IsEvent(string line, string vendor = "claude") { "pi" => TryExtractPiUserText(line), "opencode" => TryExtractOpenCodeText(line, "user"), "antigravity" => TryExtractAntigravityText(line, "user"), + "grok" => TryExtractGrokText(line, "user_message_chunk"), _ => TryExtractClaudeUserText(line, time) }; + // Grok's updates.jsonl lines are ACP session/update notifications, each text run whole. + static string? TryExtractGrokText(string line, string sessionUpdate) { + try { + using var doc = JsonDocument.Parse(line); + var root = doc.RootElement; + + if (root.Str("method") != "session/update") return null; + if (root.Obj("params")?.Obj("update") is not { } update || update.Str("sessionUpdate") != sessionUpdate) return null; + + return update.Obj("content")?.Str("text")?.Trim() is { Length: > 0 } text ? text : null; + } catch { + return null; + } + } + // ── OpenCode extractors ─────────────────────────────────────────── // // OpenCode transcript lines are {info,parts} with info.role user/assistant. Title diff --git a/src/Capacitor.Cli/Harness/Grok/GrokImportSource.cs b/src/Capacitor.Cli/Harness/Grok/GrokImportSource.cs new file mode 100644 index 0000000000..11fca17d46 --- /dev/null +++ b/src/Capacitor.Cli/Harness/Grok/GrokImportSource.cs @@ -0,0 +1,295 @@ +using System.Globalization; +using System.Net; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using Capacitor.Cli.Commands; +using Capacitor.Cli.Core; +using Capacitor.Cli.Core.Harness; +using Capacitor.Cli.Core.Harness.Gemini; +using Capacitor.Cli.Core.Harness.Grok; + +namespace Capacitor.Cli.Harness.Grok; + +/// +/// Discover, classify and import past Grok Build sessions from ~/.grok/sessions/<cwd>/<id>/. +/// Each session's updates.jsonl is sent as it is, with vendor: grok, and the server +/// normalizes it. A subagent is its own session directory beside its parent's and is imported through +/// the parent, as a subagent of it, from the parent's subagent_spawned lines. +/// +internal sealed class GrokImportSource(GrokPaths paths, TimeProvider time) : IImportSource { + const string DirKey = "Dir"; + + public HarnessId Vendor => HarnessId.Grok; + public bool IsAvailable => Directory.Exists(paths.SessionsDir); + public bool SupportsTitleGeneration => false; + + // The live watcher records only the parent, so an already-loaded parent still imports its subagents. + public bool AttachesChildContentOnReplay => true; + + static StringComparison PathComparison => + OperatingSystem.IsWindows() || OperatingSystem.IsMacOS() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + + static string NormalizeForComparison(string path) { + try { return Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); } + catch { return path.TrimEnd('/', '\\'); } + } + + public Task> DiscoverAsync(DiscoveryFilters filters, CancellationToken ct) { + var sessionFilter = filters.FilterSession is { } sf ? ImportCommand.NormalizeGuid(sf) : null; + var normalizedCwd = filters.FilterCwd is { } fc ? NormalizeForComparison(fc) : null; + var sinceUtc = filters.Since is { } since + ? new DateTimeOffset(since.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc), TimeSpan.Zero) + : (DateTimeOffset?)null; + + var result = new List(); + if (!Directory.Exists(paths.SessionsDir)) return Task.FromResult>(result); + + foreach (var cwdDir in Directory.EnumerateDirectories(paths.SessionsDir)) { + foreach (var dir in Directory.EnumerateDirectories(cwdDir)) { + ct.ThrowIfCancellationRequested(); + try { + var id = Path.GetFileName(dir); + if (!Guid.TryParse(id, out _) || !File.Exists(Path.Combine(dir, "updates.jsonl"))) continue; + + var summary = ReadSummary(dir); + if (summary.IsSubagent) continue; + + var dashless = id.Replace("-", ""); + if (sessionFilter is not null && !string.Equals(dashless, sessionFilter, StringComparison.Ordinal)) continue; + + var cwd = summary.Cwd ?? CwdOf(cwdDir); + if (normalizedCwd is not null + && (cwd is null || !NormalizeForComparison(cwd).Equals(normalizedCwd, PathComparison))) continue; + + if (sinceUtc is { } cutoff && summary.CreatedAt is { } ts && ts < cutoff) continue; + + result.Add(new DiscoveredSession(dashless, Vendor, cwd, summary.CreatedAt, + new Dictionary { [DirKey] = dir })); + } catch (Exception e) when (e is IOException or UnauthorizedAccessException) { + // An unreadable session directory must not abort the scan. + } + } + } + + return Task.FromResult>(result); + } + + public async Task> ClassifyAsync( + IReadOnlyList sessions, ClassifyContext ctx, CancellationToken ct) { + var results = new List(sessions.Count); + + foreach (var s in sessions) { + var dir = (string)s.SourceMeta![DirKey]!; + var meta = new SessionMetadata { SessionId = s.SessionId, Cwd = s.Cwd, FirstTimestamp = s.FirstTimestamp, LastTimestamp = ReadSummary(dir).UpdatedAt }; + + IReadOnlyList lines; + try { lines = Lines(dir); } + catch { results.Add(Classify(s, meta, ImportCommand.ClassificationStatus.ProbeError, 0, "transcript read failed")); continue; } + + var nonBlank = lines.Count(l => !string.IsNullOrWhiteSpace(l)); + if (nonBlank == 0) { results.Add(Classify(s, meta, ImportCommand.ClassificationStatus.ProbeError, 0, "empty transcript")); continue; } + if (nonBlank < ctx.MinLines) { results.Add(Classify(s, meta, ImportCommand.ClassificationStatus.TooShort, nonBlank)); continue; } + + int? serverLastLine; + try { serverLastLine = await LastLineAsync(ctx.HttpClient, ctx.BaseUrl, s.SessionId, agentId: null, ct); } + catch { results.Add(Classify(s, meta, ImportCommand.ClassificationStatus.ProbeError, nonBlank, "watermark probe failed")); continue; } + + var status = ImportCommand.ClassificationStatus.New; + var resumeFromLn = 0; + + if (serverLastLine is { } srv) { + if (srv >= LastImportableIndex(lines)) status = ImportCommand.ClassificationStatus.AlreadyLoaded; + else { status = ImportCommand.ClassificationStatus.Partial; resumeFromLn = srv + 1; } + } + + results.Add(new ImportCommand.SessionClassification { + SessionId = s.SessionId, FilePath = "", EncodedCwd = "", Meta = meta, + Status = status, Vendor = Vendor, ResumeFromLine = resumeFromLn, TotalLines = nonBlank, SourceMeta = s.SourceMeta, + }); + } + + return results; + } + + public async Task ImportSessionAsync( + ImportCommand.SessionClassification classification, ImportContext ctx, CancellationToken ct) { + var dir = (string)classification.SourceMeta![DirKey]!; + var transcript = Path.Combine(dir, "updates.jsonl"); + var sid = classification.SessionId; + + // Lifecycle before content: a transcript past a failed start would strand the session without one. + // Both are replayed for an already-loaded session, whose end may be the post a prior run lost. + var start = new JsonObject { + ["hook_event_name"] = "SessionStart", + ["session_id"] = sid, + ["cwd"] = classification.Meta.Cwd ?? "", + ["origin"] = ImportOrigins.Historical, + }; + if (classification.Meta.Cwd is { } cwd && GitRepository.FindRoot(cwd) is { } root) start["workspace_root"] = root; + if (classification.Meta.FirstTimestamp is { } started) start["started_at"] = started.ToString("O"); + if (ctx.VisibilityStampFor(classification.Status) is { } visibility) start["default_visibility"] = visibility; + if (!await PostAsync(ctx.HttpClient, ctx.BaseUrl, "session-start/grok", start, ct)) return ImportOutcome.Failed; + + var alreadyLoaded = classification.Status == ImportCommand.ClassificationStatus.AlreadyLoaded; + var startLine = classification.Status == ImportCommand.ClassificationStatus.Partial ? classification.ResumeFromLine : 0; + var sent = 0; + bool sentChildContent; + try { + // Strict, so a batch landing past a refused one cannot move the watermark beyond lines the server never took. + if (!alreadyLoaded) + sent = await SessionImporter.SendTranscriptBatches( + httpClient: ctx.HttpClient, baseUrl: ctx.BaseUrl, sessionId: sid, + filePath: transcript, agentId: null, startLine: startLine, time: time, vendor: Vendor, + failOnError: true, progress: ctx.Progress); + + sentChildContent = await ImportSubagentsAsync(ctx.HttpClient, ctx.BaseUrl, sid, transcript, ctx.Progress, ct); + } catch (OperationCanceledException) when (ct.IsCancellationRequested) { + throw; + } catch { + return ImportOutcome.Failed; + } + + var end = new JsonObject { + ["hook_event_name"] = "SessionEnd", + ["session_id"] = sid, + ["reason"] = "import", + ["cwd"] = classification.Meta.Cwd ?? "", + ["origin"] = ImportOrigins.Historical, + }; + if (classification.Meta.LastTimestamp is { } ended) end["ended_at"] = ended.ToString("O"); + if (!await PostAsync(ctx.HttpClient, ctx.BaseUrl, "session-end/grok", end, ct)) return ImportOutcome.Failed; + + var outcome = alreadyLoaded ? ImportOutcome.Skipped : startLine > 0 ? ImportOutcome.Resumed : sent == 0 ? ImportOutcome.Skipped : ImportOutcome.Loaded; + + return new ImportSessionResult(outcome, sentChildContent); + } + + /// Each spawned subagent whose transcript is on disk, registered first (fail-closed) and + /// resumed above what the server already holds for it. True when any of them sent content. + async Task ImportSubagentsAsync(HttpClient client, string baseUrl, string sid, string parentTranscript, IProgress? progress, CancellationToken ct) { + var spawned = GrokSubagents.Read(File.ReadLinesShared(parentTranscript)) + .Where(e => !e.Finished) + .DistinctBy(e => e.ChildSessionId); + + var sentAny = false; + + foreach (var child in spawned) { + ct.ThrowIfCancellationRequested(); + + var transcript = GrokSubagents.ChildTranscript(parentTranscript, child.ChildSessionId); + if (!File.Exists(transcript)) continue; + + var agentId = GrokSubagents.AgentId(child.ChildSessionId); + var agentType = child.SubagentType ?? "subagent"; + + if (!await PostAsync(client, baseUrl, "subagent-start", GeminiSubagentDiscovery.BuildStartPayload(sid, agentId, agentType, transcript), ct)) + throw new HttpRequestException($"subagent-start failed for {child.ChildSessionId}"); + + var watermark = await LastLineAsync(client, baseUrl, sid, agentId, ct); + sentAny |= await SessionImporter.SendTranscriptBatches( + httpClient: client, baseUrl: baseUrl, sessionId: sid, + filePath: transcript, agentId: agentId, startLine: watermark is { } w ? w + 1 : 0, + time: time, vendor: Vendor, failOnError: true, progress: progress) > 0; + + if (!await PostAsync(client, baseUrl, "subagent-stop", GeminiSubagentDiscovery.BuildStopPayload(sid, agentId, agentType, transcript), ct)) + throw new HttpRequestException($"subagent-stop failed for {child.ChildSessionId}"); + } + + return sentAny; + } + + static IReadOnlyList Lines(string dir) => File.ReadLinesShared(Path.Combine(dir, "updates.jsonl")).ToList(); + + // Grok names a session's parent directory after its percent-encoded cwd, or a slug with the real + // cwd in its .cwd file when the encoded form is too long. + static string? CwdOf(string cwdDir) { + var marker = Path.Combine(cwdDir, ".cwd"); + if (File.Exists(marker)) return File.ReadAllTextShared(marker).TrimEnd('\r', '\n'); + + var name = Path.GetFileName(cwdDir); + return name.Contains('%') ? Uri.UnescapeDataString(name) : null; + } + + readonly record struct Summary(string? Cwd, DateTimeOffset? CreatedAt, DateTimeOffset? UpdatedAt, bool IsSubagent); + + static Summary ReadSummary(string dir) { + var path = Path.Combine(dir, "summary.json"); + if (!File.Exists(path)) return default; + + try { + using var doc = JsonDocument.Parse(File.ReadAllTextShared(path)); + var root = doc.RootElement; + + return new(root.Obj("info")?.Str("cwd"), Time(root.Str("created_at")), Time(root.Str("updated_at")), + root.Str("session_kind") == "subagent"); + } catch (JsonException) { + return default; + } + } + + static DateTimeOffset? Time(string? s) => + s is not null && DateTimeOffset.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var ts) ? ts : null; + + // The server's watermark is the last line it mapped to an event, so this mirrors when the server's + // Grok normalizer emits one; a trailing line it maps to nothing must not read as unsent. + static int LastImportableIndex(IReadOnlyList lines) { + for (var i = lines.Count - 1; i >= 0; i--) + if (IsImportable(lines[i])) return i; + return 0; + } + + static bool IsImportable(string line) { + if (string.IsNullOrWhiteSpace(line)) return false; + + try { + using var doc = JsonDocument.Parse(line); + if (doc.RootElement.ValueKind != JsonValueKind.Object || doc.RootElement.Obj("params")?.Obj("update") is not { } update) return false; + + return (doc.RootElement.Str("method"), update.Str("sessionUpdate")) switch { + ("session/update", "user_message_chunk" or "agent_message_chunk" or "agent_thought_chunk") + => update.Obj("content")?.Str("text") is { Length: > 0 }, + ("session/update", "tool_call") => update.Str("toolCallId") is { Length: > 0 }, + ("session/update", "tool_call_update") + => update.Str("status") is "completed" or "failed" && update.Str("toolCallId") is { Length: > 0 }, + ("_x.ai/session/update", "turn_completed") + => update.Obj("usage")?.Obj("modelUsage")?.EnumerateObject().Any(HasUsage) == true, + _ => false, + }; + } catch (JsonException) { + return false; + } + } + + static readonly string[] TokenBuckets = ["inputTokens", "cachedReadTokens", "outputTokens", "reasoningTokens"]; + + // The server drops a model whose every token bucket is zero. + static bool HasUsage(JsonProperty model) => + model.Name.Length > 0 && model.Value.IsObject && TokenBuckets.Any(k => model.Value.Num(k) > 0); + + async Task PostAsync(HttpClient client, string baseUrl, string route, JsonObject payload, CancellationToken ct) { + try { + using var content = new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"); + using var resp = await client.PostWithRetryAsync($"{baseUrl}/hooks/{route}", content, time, ct: ct); + return resp.IsSuccessStatusCode; + } catch { + return false; + } + } + + async Task LastLineAsync(HttpClient http, string baseUrl, string sessionId, string? agentId, CancellationToken ct) { + var url = $"{baseUrl}/api/sessions/{sessionId}/last-line" + (agentId is not null ? $"?agentId={agentId}" : ""); + using var resp = await http.GetWithRetryAsync(url, time, ct: ct); + if (resp.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.NoContent) return null; + if (!resp.IsSuccessStatusCode) throw new HttpRequestException($"watermark probe returned {(int)resp.StatusCode}"); + + using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync(ct)); + return doc.RootElement.TryGetProperty("last_line_number", out var ln) && ln.ValueKind == JsonValueKind.Number ? ln.GetInt32() : null; + } + + ImportCommand.SessionClassification Classify( + DiscoveredSession s, SessionMetadata meta, ImportCommand.ClassificationStatus status, int totalLines, string? probeErrorReason = null) => new() { + SessionId = s.SessionId, FilePath = "", EncodedCwd = "", Meta = meta, Status = status, + Vendor = Vendor, ProbeErrorReason = probeErrorReason, TotalLines = totalLines, SourceMeta = s.SourceMeta, + }; +} diff --git a/src/Capacitor.Cli/Program.cs b/src/Capacitor.Cli/Program.cs index 6fed056f3c..d1c30acad9 100644 --- a/src/Capacitor.Cli/Program.cs +++ b/src/Capacitor.Cli/Program.cs @@ -93,7 +93,7 @@ // nested headless invocation. if (Environment.GetEnvironmentVariable("KCAP_SKIP") is "1" && command == "hook" - && (args.Contains("--claude") || args.Contains("--cursor") || args.Contains("--copilot") || args.Contains("--gemini") || args.Contains("--kiro") || args.Contains("--pi") || args.Contains("--opencode") || args.Contains("--antigravity") || args.Contains("--mistral-vibe"))) { + && (args.Contains("--claude") || args.Contains("--cursor") || args.Contains("--copilot") || args.Contains("--gemini") || args.Contains("--kiro") || args.Contains("--pi") || args.Contains("--opencode") || args.Contains("--antigravity") || args.Contains("--mistral-vibe") || args.Contains("--grok"))) { return 0; } @@ -980,8 +980,11 @@ await poster.DrainSpoolsAsync( if (args.Contains("--mistral-vibe")) { return await Run().Handle(Console.In); } + if (args.Contains("--grok")) { + return await Run().Handle(Console.In); + } Console.Error.WriteLine("kcap hook requires a vendor flag (for example --claude)"); - Console.Error.WriteLine("Supported vendors: --claude, --codex, --cursor, --copilot, --gemini, --kiro, --pi, --opencode, --antigravity, --mistral-vibe"); + Console.Error.WriteLine("Supported vendors: --claude, --codex, --cursor, --copilot, --gemini, --kiro, --pi, --opencode, --antigravity, --mistral-vibe, --grok"); return 1; } case "cursor": diff --git a/test/Capacitor.Cli.Core.Tests.Unit/Harness/Grok/GrokHooksInstallerTests.cs b/test/Capacitor.Cli.Core.Tests.Unit/Harness/Grok/GrokHooksInstallerTests.cs new file mode 100644 index 0000000000..5384fa72b6 --- /dev/null +++ b/test/Capacitor.Cli.Core.Tests.Unit/Harness/Grok/GrokHooksInstallerTests.cs @@ -0,0 +1,65 @@ +using System.Text.Json.Nodes; +using Capacitor.Cli.Core.Harness.Grok; + +namespace Capacitor.Cli.Core.Tests.Unit.Harness.Grok; + +public class GrokHooksInstallerTests { + [TempDir] public required TempDir Tmp { get; init; } + + string HooksPath => Tmp.PathTo(".grok", "hooks", "kcap.json"); + + /// Grok reads only type, command, url, timeout and + /// env on a handler and refuses a file carrying Claude-only fields, so nothing else is written. + [Test] + public async Task Install_creates_the_hooks_directory_and_writes_only_grok_s_handler_fields() { + await Assert.That(GrokHooksInstaller.Install(HooksPath)).IsTrue(); + + var hooks = (JsonNode.Parse(File.ReadAllText(HooksPath))!["hooks"] as JsonObject)!; + await Assert.That(hooks.Select(h => h.Key)).IsEquivalentTo(GrokHooksInstaller.Events); + + foreach (var (_, groups) in hooks) { + var handler = (groups!.AsArray().Single()!["hooks"]!.AsArray().Single() as JsonObject)!; + await Assert.That(handler.Select(f => f.Key)).IsEquivalentTo(new[] { "type", "command", "timeout" }); + await Assert.That(handler["command"]!.GetValue()).IsEqualTo(GrokHooksInstaller.HookCommand); + } + + await Assert.That(GrokHooksInstaller.IsInstalled(HooksPath)).IsTrue(); + } + + [Test] + public async Task Install_is_idempotent() { + GrokHooksInstaller.Install(HooksPath); + var first = File.ReadAllText(HooksPath); + + GrokHooksInstaller.Install(HooksPath); + + await Assert.That(File.ReadAllText(HooksPath)).IsEqualTo(first); + } + + [Test] + public async Task Remove_deletes_only_kcap_s_file_and_marker() { + GrokHooksInstaller.Install(HooksPath); + var userHook = Tmp.CreateFile(Path.Combine(".grok", "hooks", "mine.json"), """{"hooks":{}}"""); + + await Assert.That(GrokHooksInstaller.Remove(HooksPath)).IsTrue(); + + await Assert.That(File.Exists(HooksPath)).IsFalse(); + await Assert.That(File.Exists(userHook)).IsTrue(); + await Assert.That(GrokHooksInstaller.IsInstalled(HooksPath)).IsFalse(); + } + + [Test] + public async Task Not_installed_when_nothing_was_written() { + await Assert.That(GrokHooksInstaller.IsInstalled(HooksPath)).IsFalse(); + } + + [Test] + [Arguments("[]")] + [Arguments("{\"hooks\":[]}")] + [Arguments("not json")] + public async Task A_file_of_the_wrong_shape_is_not_installed(string content) { + Tmp.CreateFile(Path.Combine(".grok", "hooks", "kcap.json"), content); + + await Assert.That(GrokHooksInstaller.IsInstalled(HooksPath)).IsFalse(); + } +} diff --git a/test/Capacitor.Cli.Core.Tests.Unit/Harness/Grok/GrokPathsTests.cs b/test/Capacitor.Cli.Core.Tests.Unit/Harness/Grok/GrokPathsTests.cs new file mode 100644 index 0000000000..b0a0b91f4c --- /dev/null +++ b/test/Capacitor.Cli.Core.Tests.Unit/Harness/Grok/GrokPathsTests.cs @@ -0,0 +1,79 @@ +using Capacitor.Cli.Core.Harness.Grok; + +namespace Capacitor.Cli.Core.Tests.Unit.Harness.Grok; + +public class GrokPathsTests { + [TempDir] public required TempDir Tmp { get; init; } + + [Test] + public async Task Defaults_under_dot_grok() { + var home = Path.Combine("x", "home"); + var p = new GrokPaths(new UserHome(home), null); + + await Assert.That(p.Home).IsEqualTo(Path.Combine(home, ".grok")); + await Assert.That(p.KcapHooksJson).IsEqualTo(Path.Combine(home, ".grok", "hooks", "kcap.json")); + await Assert.That(p.SessionsDir).IsEqualTo(Path.Combine(home, ".grok", "sessions")); + } + + [Test] + public async Task Grok_home_replaces_the_root() { + var custom = Path.Combine("custom", "g"); + + await Assert.That(new GrokPaths(new UserHome("h"), custom).Home).IsEqualTo(custom); + } + + /// The directory name is the cwd percent-encoded the way Grok writes it: / as + /// %2F, - left alone. + [Test] + public async Task A_session_s_transcript_sits_under_the_percent_encoded_cwd() { + var p = new GrokPaths(new UserHome(Tmp.Path), null); + + var path = p.UpdatesJsonl("/Users/me/dev/field-technical-services", "01a1359d-bed2-7ce1-bad9-a99e91d9994e"); + + await Assert.That(path).IsEqualTo(Path.Combine( + p.SessionsDir, "%2FUsers%2Fme%2Fdev%2Ffield-technical-services", "01a1359d-bed2-7ce1-bad9-a99e91d9994e", "updates.jsonl")); + } + + /// Grok swaps a name over 255 bytes for a slug and records the real cwd in a .cwd + /// file beside the session directories. + [Test] + public async Task A_cwd_too_long_to_encode_resolves_through_its_dot_cwd_file() { + var p = new GrokPaths(new UserHome(Tmp.Path), null); + var cwd = "/" + string.Join("/", Enumerable.Repeat("a-very-long-directory-name", 12)); + var dir = Tmp.CreateDir(".grok", "sessions", "a-very-long-slug-3f2a9c"); + Tmp.CreateFile(Path.Combine(".grok", "sessions", "a-very-long-slug-3f2a9c", ".cwd"), cwd + "\n"); + + await Assert.That(p.UpdatesJsonl(cwd, "s1")).IsEqualTo(Path.Combine(dir.Path, "s1", "updates.jsonl")); + } + + /// A cwd may end in a space; only the file's line ending is not part of it. + [Test] + public async Task A_long_cwd_ending_in_a_space_still_matches_its_dot_cwd_file() { + var p = new GrokPaths(new UserHome(Tmp.Path), null); + var cwd = "/" + string.Join("/", Enumerable.Repeat("a-very-long-directory-name", 12)) + " "; + var dir = Tmp.CreateDir(".grok", "sessions", "slug-with-space"); + Tmp.CreateFile(Path.Combine(".grok", "sessions", "slug-with-space", ".cwd"), cwd + "\n"); + + await Assert.That(p.UpdatesJsonl(cwd, "s1")).IsEqualTo(Path.Combine(dir.Path, "s1", "updates.jsonl")); + } + + [Test] + public async Task A_long_cwd_with_no_matching_dot_cwd_file_has_no_transcript_path() { + var p = new GrokPaths(new UserHome(Tmp.Path), null); + var cwd = "/" + string.Join("/", Enumerable.Repeat("a-very-long-directory-name", 12)); + + await Assert.That(p.UpdatesJsonl(cwd, "s1")).IsNull(); + } + + [Test] + public async Task Has_user_data_needs_a_session_or_grok_s_own_config() { + var p = new GrokPaths(new UserHome(Tmp.Path), null); + await Assert.That(p.HasUserData()).IsFalse(); + + Tmp.CreateDir(".grok", "hooks"); + await Assert.That(p.HasUserData()).IsFalse(); // kcap can create hooks/ before Grok runs + + Tmp.CreateFile(Path.Combine(".grok", "config.toml"), ""); + await Assert.That(p.HasUserData()).IsTrue(); + } +} diff --git a/test/Capacitor.Cli.Core.Tests.Unit/Harness/Grok/GrokSubagentsTests.cs b/test/Capacitor.Cli.Core.Tests.Unit/Harness/Grok/GrokSubagentsTests.cs new file mode 100644 index 0000000000..6b49043928 --- /dev/null +++ b/test/Capacitor.Cli.Core.Tests.Unit/Harness/Grok/GrokSubagentsTests.cs @@ -0,0 +1,44 @@ +using Capacitor.Cli.Core.Harness.Grok; + +namespace Capacitor.Cli.Core.Tests.Unit.Harness.Grok; + +public class GrokSubagentsTests { + const string Spawned = + """{"timestamp":1,"method":"_x.ai/session/update","params":{"sessionId":"p","update":{"sessionUpdate":"subagent_spawned","subagent_id":"01a135a0-64af-7661-94e6-282ec3ef065c","parent_session_id":"p","child_session_id":"01a135a0-64af-7661-94e6-282ec3ef065c","subagent_type":"general-purpose"},"_meta":{"eventId":"p-1"}}}"""; + const string Finished = + """{"timestamp":2,"method":"_x.ai/session/update","params":{"sessionId":"p","update":{"sessionUpdate":"subagent_finished","subagent_id":"01a135a0-64af-7661-94e6-282ec3ef065c","child_session_id":"01a135a0-64af-7661-94e6-282ec3ef065c","status":"completed"},"_meta":{"eventId":"p-2"}}}"""; + const string Chunk = + """{"timestamp":1,"method":"session/update","params":{"sessionId":"p","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"subagent_spawned"}},"_meta":{"eventId":"p-3"}}}"""; + + [Test] + public async Task Spawns_and_finishes_are_read_from_the_parent_s_lines() { + var events = GrokSubagents.Read([Spawned, Chunk, Finished, "not json"]).ToArray(); + + await Assert.That(events.Length).IsEqualTo(2); + await Assert.That(events[0]).IsEqualTo(new GrokSubagentEvent("01a135a0-64af-7661-94e6-282ec3ef065c", "general-purpose", Finished: false)); + await Assert.That(events[1].Finished).IsTrue(); + } + + /// The child id becomes a path segment, so only a session id Grok could have written is + /// accepted; anything else would point the import at a file outside the sessions directory. + [Test] + [Arguments("../../../.ssh/id_rsa")] + [Arguments("/etc/passwd")] + [Arguments("child-id")] + public async Task A_child_id_that_is_not_a_session_id_is_ignored(string child) { + await Assert.That(GrokSubagents.Read([Spawned.Replace("01a135a0-64af-7661-94e6-282ec3ef065c", child)]).ToArray()).IsEmpty(); + } + + [Test] + public async Task A_child_s_transcript_is_its_sibling_session_directory() { + var parent = Path.Combine("home", ".grok", "sessions", "%2Frepo", "parent-id", "updates.jsonl"); + + await Assert.That(GrokSubagents.ChildTranscript(parent, "child-id")) + .IsEqualTo(Path.Combine("home", ".grok", "sessions", "%2Frepo", "child-id", "updates.jsonl")); + } + + [Test] + public async Task A_child_s_agent_id_is_its_session_id_without_dashes() { + await Assert.That(GrokSubagents.AgentId("01a135a0-64af-7661-94e6-282ec3ef065c")).IsEqualTo("01a135a064af766194e6282ec3ef065c"); + } +} diff --git a/test/Capacitor.Cli.Tests.Integration/GrokHookCommandTests.cs b/test/Capacitor.Cli.Tests.Integration/GrokHookCommandTests.cs new file mode 100644 index 0000000000..9dc7a8d18b --- /dev/null +++ b/test/Capacitor.Cli.Tests.Integration/GrokHookCommandTests.cs @@ -0,0 +1,141 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Capacitor.Cli.Commands; +using Capacitor.Cli.Commands.Harness; +using Capacitor.Cli.Core.Config; +using Capacitor.Cli.PrDetection; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; + +namespace Capacitor.Cli.Tests.Integration; + +/// +/// kcap hook --grok against a stub server, with payloads shaped as Grok hands them to its hooks: +/// camelCase keys, snake_case event values, and a transcript path on every event but session start. +/// +public class GrokHookCommandTests : IDisposable { + [TempConfigRoot] public required TempConfigRoot Config { get; init; } + [TempHome] public required TempHome Home { get; init; } + + readonly WireMockServer _server = WireMockServer.Start(); + readonly FakeWatcherSpawner _spawner = new(); + + const string DashedSessionId = "01a136cc-c631-7762-9e1a-e6b2dbe41f55"; + const string SessionId = "01a136ccc63177629e1ae6b2dbe41f55"; + + public void Dispose() => _server.Stop(); + + async Task RunAsync(JsonObject payload) { + await ConfigMutator.MutateAsync(Config.Root, _ => new ProfileConfig { + ActiveProfile = "work", + Profiles = new() { ["work"] = new Profile { ServerUrl = _server.Url } } + }); + + var profiles = Resolutions.At(_server.Url!, Config.Root); + var http = new FixedCapacitorHttpClient(); + + return await new GrokHookCommand(Config.Root, profiles, new HookClock(TimeProvider.System), Home, + TestHarnesses.Under(Home), http, TestWatchers.For(Config.Root, profiles, http, _spawner), new GitProviderRouter()) + .Handle(new StringReader(payload.ToJsonString())); + } + + static JsonObject Payload(string hookEventName, string cwd, string? transcriptPath = null) { + var payload = new JsonObject { + ["hookEventName"] = hookEventName, + ["sessionId"] = DashedSessionId, + ["cwd"] = cwd, + ["workspaceRoot"] = cwd, + ["timestamp"] = "2026-10-13T19:45:52.612399+00:00", + ["source"] = "new", + ["hook_event_name"] = "SessionStart", + ["session_id"] = DashedSessionId, + }; + if (transcriptPath is not null) payload["transcriptPath"] = transcriptPath; + + return payload; + } + + [Test, NotInParallel] + public async Task Session_start_posts_the_session_and_watches_its_derived_updates_file() { + _server.Given(Request.Create().WithPath("/hooks/session-start/grok").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(200)); + var cwd = "/repo/demo"; + + var exit = await RunAsync(Payload("session_start", cwd)); + + await Assert.That(exit).IsEqualTo(0); + + var post = _server.FindLogEntries(Request.Create().WithPath("/hooks/session-start/grok").UsingPost()).Single(); + using var body = JsonDocument.Parse(post.RequestMessage.Body!); + await Assert.That(body.RootElement.GetProperty("session_id").GetString()).IsEqualTo(SessionId); + await Assert.That(body.RootElement.GetProperty("cwd").GetString()).IsEqualTo(cwd); + await Assert.That(body.RootElement.GetProperty("workspace_root").GetString()).IsEqualTo(cwd); + await Assert.That(body.RootElement.GetProperty("started_at").GetString()).StartsWith("2026-10-13T19:45:52"); + + var spawned = _spawner.Spawned.Single(); + await Assert.That(spawned.Vendor).IsEqualTo("grok"); + await Assert.That(spawned.TranscriptPath).IsEqualTo( + Path.Combine(Home.Path, ".grok", "sessions", "%2Frepo%2Fdemo", DashedSessionId, "updates.jsonl")); + } + + /// A permanent rejection must not leave a watcher shipping lines for a session the server + /// refused — and must still not fail the Grok hook. + [Test, NotInParallel] + public async Task A_rejected_session_start_starts_no_watcher_and_still_exits_zero() { + _server.Given(Request.Create().WithPath("/hooks/session-start/grok").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(400)); + + var exit = await RunAsync(Payload("session_start", "/repo/demo")); + + await Assert.That(exit).IsEqualTo(0); + await Assert.That(_spawner.Spawned).IsEmpty(); + } + + /// A Stop after a refused start must not record the session the server refused. + [Test, NotInParallel] + public async Task A_stop_after_a_rejected_session_start_starts_no_watcher() { + _server.Given(Request.Create().WithPath("/hooks/session-start/grok").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(400)); + await RunAsync(Payload("session_start", "/repo/demo")); + + await RunAsync(Payload("stop", "/repo/demo", Path.Combine(Home.Path, "elsewhere", "updates.jsonl"))); + + await Assert.That(_spawner.Spawned).IsEmpty(); + } + + /// A cwd too long to encode resolves only once Grok has written its .cwd file, which + /// may be after session start; the start must still be recorded, and a later Stop names the file. + [Test, NotInParallel] + public async Task A_session_start_whose_transcript_cannot_be_located_is_still_posted() { + _server.Given(Request.Create().WithPath("/hooks/session-start/grok").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(200)); + var longCwd = "/" + string.Join("/", Enumerable.Repeat("a-very-long-directory-name", 12)); + + var exit = await RunAsync(Payload("session_start", longCwd)); + + await Assert.That(exit).IsEqualTo(0); + await Assert.That(_server.FindLogEntries(Request.Create().WithPath("/hooks/session-start/grok").UsingPost())).Count().IsEqualTo(1); + await Assert.That(_spawner.Spawned).IsEmpty(); + } + + [Test, NotInParallel] + public async Task Stop_restarts_a_missing_watcher_on_the_named_transcript_without_posting() { + var transcript = Path.Combine(Home.Path, "elsewhere", "updates.jsonl"); + + var exit = await RunAsync(Payload("stop", "/repo/demo", transcript)); + + await Assert.That(exit).IsEqualTo(0); + await Assert.That(_server.LogEntries).IsEmpty(); + await Assert.That(_spawner.Spawned.Single().TranscriptPath).IsEqualTo(transcript); + } + + [Test, NotInParallel] + public async Task Events_it_does_not_handle_do_nothing() { + var exit = await RunAsync(Payload("user_prompt_submit", "/repo/demo")); + + await Assert.That(exit).IsEqualTo(0); + await Assert.That(_server.LogEntries).IsEmpty(); + await Assert.That(_spawner.Spawned).IsEmpty(); + } +} diff --git a/test/Capacitor.Cli.Tests.Integration/GrokImportTests.cs b/test/Capacitor.Cli.Tests.Integration/GrokImportTests.cs new file mode 100644 index 0000000000..ac8a0f9fba --- /dev/null +++ b/test/Capacitor.Cli.Tests.Integration/GrokImportTests.cs @@ -0,0 +1,163 @@ +using System.Text.Json; +using Capacitor.Cli.Commands; +using Capacitor.Cli.Core.Harness.Grok; +using Capacitor.Cli.Harness.Grok; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; + +namespace Capacitor.Cli.Tests.Integration; + +/// +/// kcap import --grok against a stub server: a parent session and the subagent its transcript +/// names, laid out as Grok writes them, sibling session directories under the encoded cwd. +/// +public class GrokImportTests : IDisposable { + [TempHome] public required TempHome Home { get; init; } + + readonly WireMockServer _server = WireMockServer.Start(); + + const string Parent = "01a1359d-bed2-7ce1-bad9-a99e91d9994e"; + const string Child = "01a135a0-64af-7661-94e6-282ec3ef065c"; + + public void Dispose() => _server.Stop(); + + static string Line(string sid, string kind, string extra, string method = "session/update") => + "{\"timestamp\":1,\"method\":\"" + method + "\",\"params\":{\"sessionId\":\"" + sid + + "\",\"update\":{\"sessionUpdate\":\"" + kind + "\"," + extra + "},\"_meta\":{\"eventId\":\"" + sid + "-" + kind + "\"}}}"; + + void WriteFixture() { + var cwdDir = Path.Combine(Home.Path, ".grok", "sessions", "%2Frepo%2Fdemo"); + + Directory.CreateDirectory(Path.Combine(cwdDir, Parent)); + File.WriteAllLines(Path.Combine(cwdDir, Parent, "updates.jsonl"), [ + Line(Parent, "user_message_chunk", "\"content\":{\"type\":\"text\",\"text\":\"check the tests\"}"), + Line(Parent, "subagent_spawned", $"\"child_session_id\":\"{Child}\",\"subagent_type\":\"general-purpose\"", "_x.ai/session/update"), + Line(Parent, "subagent_finished", $"\"child_session_id\":\"{Child}\",\"status\":\"completed\"", "_x.ai/session/update"), + ]); + File.WriteAllText(Path.Combine(cwdDir, Parent, "summary.json"), """{"info":{"cwd":"/repo/demo"},"created_at":"2026-10-13T14:15:41Z"}"""); + + Directory.CreateDirectory(Path.Combine(cwdDir, Child)); + File.WriteAllLines(Path.Combine(cwdDir, Child, "updates.jsonl"), [ + Line(Child, "agent_message_chunk", "\"content\":{\"type\":\"text\",\"text\":\"three test projects\"}"), + ]); + File.WriteAllText(Path.Combine(cwdDir, Child, "summary.json"), """{"info":{"cwd":"/repo/demo"},"session_kind":"subagent"}"""); + } + + [Test] + public async Task A_session_imports_with_its_subagent_under_the_grok_routes() { + WriteFixture(); + _server.Given(Request.Create().WithPath("/api/sessions/*/last-line").UsingGet()).RespondWith(Response.Create().WithStatusCode(404)); + foreach (var route in new[] { "/hooks/session-start/grok", "/hooks/transcript", "/hooks/subagent-start", "/hooks/subagent-stop", "/hooks/session-end/grok" }) + _server.Given(Request.Create().WithPath(route).UsingPost()).RespondWith(Response.Create().WithStatusCode(200)); + + using var client = new HttpClient(); + var source = new GrokImportSource(new GrokPaths(Home, null), TimeProvider.System); + + var discovered = await source.DiscoverAsync(new DiscoveryFilters(null, null, null, 0), CancellationToken.None); + await Assert.That(discovered.Select(d => d.SessionId)).IsEquivalentTo(new[] { Parent.Replace("-", "") }); + + var classified = await source.ClassifyAsync(discovered, new ClassifyContext(client, _server.Url!, MinLines: 0, Home: Home), CancellationToken.None); + var result = await source.ImportSessionAsync(classified.Single(), new ImportContext(client, _server.Url!, ForcePrivate: false), CancellationToken.None); + + await Assert.That(result.Outcome).IsEqualTo(ImportOutcome.Loaded); + await Assert.That(result.SentChildContent).IsTrue(); + + var posts = _server.LogEntries.Where(e => e.RequestMessage.Method == "POST").Select(e => e.RequestMessage.Path).ToList(); + await Assert.That(posts).IsEquivalentTo(new[] { + "/hooks/session-start/grok", "/hooks/transcript", "/hooks/subagent-start", + "/hooks/transcript", "/hooks/subagent-stop", "/hooks/session-end/grok" + }, TUnit.Assertions.Enums.CollectionOrdering.Matching); + + using var start = JsonDocument.Parse(_server.LogEntries.Single(e => e.RequestMessage.Path == "/hooks/session-start/grok").RequestMessage.Body!); + await Assert.That(start.RootElement.GetProperty("origin").GetString()).IsEqualTo(ImportOrigins.Historical); + + using var sub = JsonDocument.Parse(_server.LogEntries.Single(e => e.RequestMessage.Path == "/hooks/subagent-start").RequestMessage.Body!); + await Assert.That(sub.RootElement.GetProperty("agent_id").GetString()).IsEqualTo(Child.Replace("-", "")); + await Assert.That(sub.RootElement.GetProperty("agent_type").GetString()).IsEqualTo("general-purpose"); + + var batches = _server.LogEntries.Where(e => e.RequestMessage.Path == "/hooks/transcript") + .Select(e => JsonDocument.Parse(e.RequestMessage.Body!).RootElement).ToList(); + await Assert.That(batches.All(b => b.GetProperty("vendor").GetString() == "grok")).IsTrue(); + await Assert.That(batches[1].GetProperty("agent_id").GetString()).IsEqualTo(Child.Replace("-", "")); + } + + /// The live watcher records only the parent, so re-importing a fully watched session is + /// what attaches its subagents. The parent's last mapped line is its first: the subagent lines + /// after it map to nothing, and must not keep the session from reading as already loaded. + [Test] + public async Task An_already_loaded_parent_still_imports_its_subagents() { + WriteFixture(); + _server.Given(Request.Create().WithPath("/api/sessions/*/last-line").WithParam("agentId").UsingGet()).AtPriority(1) + .RespondWith(Response.Create().WithStatusCode(404)); + _server.Given(Request.Create().WithPath("/api/sessions/*/last-line").UsingGet()).AtPriority(2) + .RespondWith(Response.Create().WithStatusCode(200).WithBody("""{"last_line_number":0}""")); + foreach (var route in new[] { "/hooks/session-start/grok", "/hooks/transcript", "/hooks/subagent-start", "/hooks/subagent-stop", "/hooks/session-end/grok" }) + _server.Given(Request.Create().WithPath(route).UsingPost()).RespondWith(Response.Create().WithStatusCode(200)); + + using var client = new HttpClient(); + var source = new GrokImportSource(new GrokPaths(Home, null), TimeProvider.System); + + var discovered = await source.DiscoverAsync(new DiscoveryFilters(null, null, null, 0), CancellationToken.None); + var classified = (await source.ClassifyAsync(discovered, new ClassifyContext(client, _server.Url!, MinLines: 0, Home: Home), CancellationToken.None)).Single(); + await Assert.That(classified.Status).IsEqualTo(ImportCommand.ClassificationStatus.AlreadyLoaded); + + var result = await source.ImportSessionAsync(classified, new ImportContext(client, _server.Url!, ForcePrivate: false), CancellationToken.None); + + await Assert.That(result.Outcome).IsEqualTo(ImportOutcome.Skipped); + await Assert.That(result.SentChildContent).IsTrue(); + + var batches = _server.LogEntries.Where(e => e.RequestMessage.Path == "/hooks/transcript") + .Select(e => JsonDocument.Parse(e.RequestMessage.Body!).RootElement).ToList(); + await Assert.That(batches.Single().GetProperty("agent_id").GetString()).IsEqualTo(Child.Replace("-", "")); + } + + /// Each trailing line is one the server parses but maps to no event, so it never moves the + /// watermark past the first line. + [Test] + [Arguments("agent_message_chunk", "\"content\":{\"type\":\"text\",\"text\":\"\"}", "session/update")] + [Arguments("tool_call", "\"title\":\"run\"", "session/update")] + [Arguments("tool_call_update", "\"toolCallId\":\"c1\",\"status\":\"in_progress\"", "session/update")] + [Arguments("turn_completed", "\"usage\":{}", "_x.ai/session/update")] + [Arguments("turn_completed", "\"usage\":{\"modelUsage\":{\"grok-4.5-build\":{\"inputTokens\":0,\"outputTokens\":0}}}", "_x.ai/session/update")] + public async Task A_trailing_line_the_server_maps_to_nothing_leaves_the_session_loaded(string kind, string extra, string method) { + var dir = Path.Combine(Home.Path, ".grok", "sessions", "%2Frepo%2Fdemo", Parent); + Directory.CreateDirectory(dir); + File.WriteAllLines(Path.Combine(dir, "updates.jsonl"), [ + Line(Parent, "user_message_chunk", "\"content\":{\"type\":\"text\",\"text\":\"hi\"}"), + Line(Parent, kind, extra, method), + ]); + _server.Given(Request.Create().WithPath("/api/sessions/*/last-line").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(200).WithBody("""{"last_line_number":0}""")); + + using var client = new HttpClient(); + var source = new GrokImportSource(new GrokPaths(Home, null), TimeProvider.System); + + var discovered = await source.DiscoverAsync(new DiscoveryFilters(null, null, null, 0), CancellationToken.None); + var classified = await source.ClassifyAsync(discovered, new ClassifyContext(client, _server.Url!, MinLines: 0, Home: Home), CancellationToken.None); + + await Assert.That(classified.Single().Status).IsEqualTo(ImportCommand.ClassificationStatus.AlreadyLoaded); + } + + /// A later batch landing past a refused one would move the server's watermark beyond the + /// lines it never took, and a re-run would resume after them. + [Test] + public async Task A_refused_parent_batch_fails_the_session() { + var dir = Path.Combine(Home.Path, ".grok", "sessions", "%2Frepo%2Fdemo", Parent); + Directory.CreateDirectory(dir); + File.WriteAllLines(Path.Combine(dir, "updates.jsonl"), [Line(Parent, "user_message_chunk", "\"content\":{\"type\":\"text\",\"text\":\"hi\"}")]); + _server.Given(Request.Create().WithPath("/api/sessions/*/last-line").UsingGet()).RespondWith(Response.Create().WithStatusCode(404)); + _server.Given(Request.Create().WithPath("/hooks/transcript").UsingPost()).RespondWith(Response.Create().WithStatusCode(400)); + foreach (var route in new[] { "/hooks/session-start/grok", "/hooks/session-end/grok" }) + _server.Given(Request.Create().WithPath(route).UsingPost()).RespondWith(Response.Create().WithStatusCode(200)); + + using var client = new HttpClient(); + var source = new GrokImportSource(new GrokPaths(Home, null), TimeProvider.System); + + var discovered = await source.DiscoverAsync(new DiscoveryFilters(null, null, null, 0), CancellationToken.None); + var classified = await source.ClassifyAsync(discovered, new ClassifyContext(client, _server.Url!, MinLines: 0, Home: Home), CancellationToken.None); + var result = await source.ImportSessionAsync(classified.Single(), new ImportContext(client, _server.Url!, ForcePrivate: false), CancellationToken.None); + + await Assert.That(result.Outcome).IsEqualTo(ImportOutcome.Failed); + } +} diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/FlowsDriverSchemaConformanceTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/FlowsDriverSchemaConformanceTests.cs index e866a2773a..018f9763a7 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/FlowsDriverSchemaConformanceTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/FlowsDriverSchemaConformanceTests.cs @@ -671,6 +671,7 @@ public async Task The_projection_table_covers_every_harness_the_cli_claims_to_su var covered = Arms.Select(a => a.Flag) .Append("--claude") // bundled kcap/.mcp.json, covered by the static-config test .Append("--pi") // no MCP config at all, covered by the bridge test + .Append("--grok") // writes no MCP config: Grok loads Claude's, covered by the static-config test .ToHashSet(StringComparer.Ordinal); foreach (var flag in VendorSelection.KnownVendorFlags) diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/RefreshCommandTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/RefreshCommandTests.cs index cd69a1a4f5..523dd66c6e 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/RefreshCommandTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/RefreshCommandTests.cs @@ -45,7 +45,7 @@ public async Task The_steps_cover_every_vendor_and_the_daemon() { var vendors = RefreshCommand.Steps.Where(s => s[0] == "plugin").Select(s => s.Length == 4 ? s[2] : "claude").ToArray(); await Assert.That(vendors).IsEquivalentTo( - ["--skills", "--codex", "--cursor", "--copilot", "--pi", "--opencode", "--gemini", "--kiro", "--antigravity", "claude"]); + ["--skills", "--codex", "--cursor", "--copilot", "--pi", "--opencode", "--gemini", "--kiro", "--antigravity", "--grok", "claude"]); await Assert.That(RefreshCommand.Steps.Where(s => s[0] == "plugin").All(s => s[^1] == "--if-installed")).IsTrue(); } diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/ReplayChildContentCapabilityTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/ReplayChildContentCapabilityTests.cs index 96e99bccd2..1e13c1a1d6 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/ReplayChildContentCapabilityTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/ReplayChildContentCapabilityTests.cs @@ -3,6 +3,7 @@ using Capacitor.Cli.Core.Harness.Copilot; using Capacitor.Cli.Core; using Capacitor.Cli.Core.Harness.Kiro; +using Capacitor.Cli.Core.Harness.Grok; using Capacitor.Cli.Core.Harness.MistralVibe; using Capacitor.Cli.Core.Harness.Pi; using Capacitor.Cli.Harness.Antigravity; @@ -12,6 +13,7 @@ using Capacitor.Cli.Harness.Cursor; using Capacitor.Cli.Harness.Gemini; using Capacitor.Cli.Harness.Kiro; +using Capacitor.Cli.Harness.Grok; using Capacitor.Cli.Harness.MistralVibe; using Capacitor.Cli.Harness.OpenCode; using Capacitor.Cli.Harness.Pi; @@ -40,13 +42,14 @@ public class ReplayChildContentCapabilityTests { [TempConfigRoot] public required TempConfigRoot Config { get; init; } /// - /// The three sources whose child-import pass is reachable when the root is already fully + /// The sources whose child-import pass is reachable when the root is already fully /// ingested — the shape that can add content to a session that already exists. /// [Test] [Arguments("cursor")] [Arguments("antigravity")] [Arguments("gemini")] + [Arguments("grok")] public async Task sources_whose_replay_can_attach_child_content_declare_the_capability(string vendor) { await Assert.That(MakeSource(vendor).AttachesChildContentOnReplay).IsTrue(); } @@ -79,7 +82,7 @@ public async Task sources_whose_replay_cannot_attach_child_content_do_not_declar public async Task every_import_source_is_covered_by_this_table() { var declared = new[] { HarnessId.Cursor, HarnessId.Antigravity, HarnessId.Gemini, HarnessId.Claude, HarnessId.Codex, - HarnessId.Copilot, HarnessId.Kiro, HarnessId.Pi, HarnessId.OpenCode, HarnessId.MistralVibe, + HarnessId.Copilot, HarnessId.Kiro, HarnessId.Pi, HarnessId.OpenCode, HarnessId.MistralVibe, HarnessId.Grok, }; var actual = typeof(IImportSource).Assembly.GetTypes() @@ -110,6 +113,7 @@ IImportSource MakeSource(string vendor) { "opencode" => new OpenCodeImportSource(Path.Combine(scratch, "db"), Path.Combine(scratch, "ledger"), TimeProvider.System), "antigravity" => new AntigravityImportSource(new(new(scratch), ""), TimeProvider.System), "mistralvibe" => new MistralVibeImportSource(new MistralVibePaths(new UserHome(scratch), null), TimeProvider.System), + "grok" => new GrokImportSource(new GrokPaths(new UserHome(scratch), null), TimeProvider.System), _ => throw new ArgumentOutOfRangeException(nameof(vendor), vendor, "unclassified import source"), }; } diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/WatchCommandTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/WatchCommandTests.cs index b9d90d8687..08e1298751 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/WatchCommandTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/WatchCommandTests.cs @@ -781,6 +781,7 @@ public async Task ResolveCursorIdleCeiling_parses_env_with_default(string? env, [Arguments("antigravity", true)] [Arguments("cursor", true)] [Arguments("mistral-vibe", true)] + [Arguments("grok", true)] [Arguments("codex", false)] [Arguments("claude", false)] [Arguments("gemini", false)] diff --git a/test/Capacitor.Cli.Tests.Unit/Harness/Grok/GrokImportSourceTests.cs b/test/Capacitor.Cli.Tests.Unit/Harness/Grok/GrokImportSourceTests.cs new file mode 100644 index 0000000000..51e215106d --- /dev/null +++ b/test/Capacitor.Cli.Tests.Unit/Harness/Grok/GrokImportSourceTests.cs @@ -0,0 +1,78 @@ +using Capacitor.Cli.Commands; +using Capacitor.Cli.Core; +using Capacitor.Cli.Core.Harness; +using Capacitor.Cli.Core.Harness.Grok; +using Capacitor.Cli.Harness.Grok; + +namespace Capacitor.Cli.Tests.Unit.Harness.Grok; + +public class GrokImportSourceTests { + [TempDir] public required TempDir Tmp { get; init; } + + const string ParentId = "01a1359d-bed2-7ce1-bad9-a99e91d9994e"; + const string ChildId = "01a135a0-64af-7661-94e6-282ec3ef065c"; + + GrokImportSource Source() => new(new GrokPaths(new UserHome(Tmp.Path), null), TimeProvider.System); + + static DiscoveryFilters NoFilters => new(FilterCwd: null, FilterSession: null, Since: null, MinLines: 1); + + void Session(string id, string cwd, string? sessionKind = null) { + var dir = Path.Combine(".grok", "sessions", Uri.EscapeDataString(cwd), id); + Tmp.CreateFile(Path.Combine(dir, "updates.jsonl"), """{"timestamp":1,"method":"session/update","params":{"sessionId":"x","update":{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"hi"}},"_meta":{"eventId":"x-1","agentTimestampMs":1791900941391}}}""" + "\n"); + var kind = sessionKind is null ? "" : $$""","session_kind":"{{sessionKind}}" """; + Tmp.CreateFile(Path.Combine(dir, "summary.json"), $$"""{"info":{"id":"{{id}}","cwd":"{{cwd}}"},"created_at":"2026-10-13T14:15:41Z","updated_at":"2026-10-13T14:36:24Z"{{kind}}}"""); + } + + [Test] + public async Task Discovers_top_level_sessions_with_their_cwd_and_start() { + Session(ParentId, "/repo/demo"); + + var found = (await Source().DiscoverAsync(NoFilters, CancellationToken.None)).Single(); + + await Assert.That(found.SessionId).IsEqualTo(ParentId.Replace("-", "")); + await Assert.That(found.Vendor).IsEqualTo(HarnessId.Grok); + await Assert.That(found.Cwd).IsEqualTo("/repo/demo"); + await Assert.That(found.FirstTimestamp).IsEqualTo(new DateTimeOffset(2026, 10, 13, 14, 15, 41, TimeSpan.Zero)); + } + + /// A subagent is imported through its parent, so it is not a session of its own. + [Test] + public async Task Skips_subagent_sessions() { + Session(ParentId, "/repo/demo"); + Session(ChildId, "/repo/demo", sessionKind: "subagent"); + + var found = await Source().DiscoverAsync(NoFilters, CancellationToken.None); + + await Assert.That(found.Select(f => f.SessionId)).IsEquivalentTo(new[] { ParentId.Replace("-", "") }); + } + + [Test] + public async Task A_cwd_filter_narrows_discovery() { + Session(ParentId, "/repo/demo"); + Session("01a1359d-bed2-7ce1-bad9-a99e91d99999", "/other"); + + var found = await Source().DiscoverAsync(NoFilters with { FilterCwd = "/other" }, CancellationToken.None); + + await Assert.That(found.Single().Cwd).IsEqualTo("/other"); + } + + /// A long cwd's directory is a slug with the cwd in its .cwd file; a cwd may end in + /// a space, so only the line ending is stripped. + [Test] + public async Task A_slug_directory_s_cwd_keeps_its_trailing_space() { + var dir = Path.Combine(".grok", "sessions", "long-slug", ParentId); + Tmp.CreateFile(Path.Combine(dir, "updates.jsonl"), "{}\n"); + Tmp.CreateFile(Path.Combine(".grok", "sessions", "long-slug", ".cwd"), "/repo/demo \n"); + + var found = (await Source().DiscoverAsync(NoFilters, CancellationToken.None)).Single(); + + await Assert.That(found.Cwd).IsEqualTo("/repo/demo "); + } + + [Test] + public async Task Is_available_only_when_the_sessions_dir_exists() { + await Assert.That(Source().IsAvailable).IsFalse(); + Session(ParentId, "/repo/demo"); + await Assert.That(Source().IsAvailable).IsTrue(); + } +} diff --git a/test/Capacitor.Cli.Tests.Unit/Harness/Grok/GrokWatchExtractorTests.cs b/test/Capacitor.Cli.Tests.Unit/Harness/Grok/GrokWatchExtractorTests.cs new file mode 100644 index 0000000000..565d623fe9 --- /dev/null +++ b/test/Capacitor.Cli.Tests.Unit/Harness/Grok/GrokWatchExtractorTests.cs @@ -0,0 +1,47 @@ +using System.Text.Json.Nodes; +using Capacitor.Cli.Commands; + +namespace Capacitor.Cli.Tests.Unit.Harness.Grok; + +/// +/// The watcher reads Grok's updates.jsonl (ACP session/update lines) for its title and +/// event counts, rather than falling through to Claude's type:"user"|"assistant" shape. +/// +public class GrokWatchExtractorTests { + static string Update(string kind, string text, string method = "session/update") => new JsonObject { + ["timestamp"] = 1, + ["method"] = method, + ["params"] = new JsonObject { + ["sessionId"] = "s", + ["update"] = new JsonObject { + ["sessionUpdate"] = kind, + ["content"] = new JsonObject { ["type"] = "text", ["text"] = text }, + }, + ["_meta"] = new JsonObject { ["eventId"] = "s-1" }, + }, + }.ToJsonString(); + + const string ToolCall = + """{"timestamp":1,"method":"session/update","params":{"sessionId":"s","update":{"sessionUpdate":"tool_call","toolCallId":"c1","title":"list_dir","rawInput":{}},"_meta":{"eventId":"s-2"}}}"""; + + [Test] + public async Task The_user_s_prompt_is_title_text() { + await Assert.That(WatchCommand.TryExtractUserText(Update("user_message_chunk", "summarize the repo"), TimeProvider.System, "grok")) + .IsEqualTo("summarize the repo"); + await Assert.That(WatchCommand.TryExtractUserText(Update("agent_message_chunk", "here it is"), TimeProvider.System, "grok")).IsNull(); + } + + [Test] + public async Task The_agent_s_message_is_assistant_text_but_its_thinking_is_not() { + await Assert.That(WatchCommand.TryExtractAssistantText(Update("agent_message_chunk", "here it is"), "grok")).IsEqualTo("here it is"); + await Assert.That(WatchCommand.TryExtractAssistantText(Update("agent_thought_chunk", "hmm"), "grok")).IsNull(); + } + + [Test] + public async Task Only_conversational_lines_count_as_events() { + await Assert.That(WatchCommand.IsEvent(Update("user_message_chunk", "go"), "grok")).IsTrue(); + await Assert.That(WatchCommand.IsEvent(Update("agent_message_chunk", "ok"), "grok")).IsTrue(); + await Assert.That(WatchCommand.IsEvent(ToolCall, "grok")).IsFalse(); + await Assert.That(WatchCommand.IsEvent(Update("agent_message_chunk", "ok", "_x.ai/session/update"), "grok")).IsFalse(); + } +} diff --git a/test/Capacitor.Cli.Tests.Unit/SessionStartMemory/SessionNudgesCallSiteTests.cs b/test/Capacitor.Cli.Tests.Unit/SessionStartMemory/SessionNudgesCallSiteTests.cs index a9f63b1cb8..25d5e05907 100644 --- a/test/Capacitor.Cli.Tests.Unit/SessionStartMemory/SessionNudgesCallSiteTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/SessionStartMemory/SessionNudgesCallSiteTests.cs @@ -7,8 +7,9 @@ namespace Capacitor.Cli.Tests.Unit.SessionStartMemory; /// its output is valid either way. public class SessionNudgesCallSiteTests { /// Mistral Vibe's hooks have no session-start event and inject context only into a - /// tool result, so there is nowhere for a session nudge to go. - static readonly HashSet NoSessionStartChannel = [HarnessId.MistralVibe]; + /// tool result; Grok reads only a user-facing systemMessage from its session-start hook. Neither + /// has anywhere for a session nudge to go. + static readonly HashSet NoSessionStartChannel = [HarnessId.MistralVibe, HarnessId.Grok]; public static IEnumerable> Harnesses() { foreach (var harness in Enum.GetValues().Where(h => !NoSessionStartChannel.Contains(h))) diff --git a/test/Capacitor.Tests.Helpers/TestHarnesses.cs b/test/Capacitor.Tests.Helpers/TestHarnesses.cs index 017c80b12e..91fc580ffc 100644 --- a/test/Capacitor.Tests.Helpers/TestHarnesses.cs +++ b/test/Capacitor.Tests.Helpers/TestHarnesses.cs @@ -6,6 +6,7 @@ using Capacitor.Cli.Core.Harness.Copilot; using Capacitor.Cli.Core.Harness.Cursor; using Capacitor.Cli.Core.Harness.Gemini; +using Capacitor.Cli.Core.Harness.Grok; using Capacitor.Cli.Core.Harness.Kiro; using Capacitor.Cli.Core.Harness.MistralVibe; using Capacitor.Cli.Core.Harness.OpenCode; @@ -59,7 +60,8 @@ public static HarnessRegistry Under(UserHome home, BinaryProbe binaries) { PiHarness.Over(new PiPaths(home, null)), OpenCodeHarness.Over(new OpenCodePaths(home, null, null, null)), AntigravityHarness.Over(gemini), - MistralVibeHarness.Over(new MistralVibePaths(home, null))); + MistralVibeHarness.Over(new MistralVibePaths(home, null)), + GrokHarness.Over(new GrokPaths(home, null))); } /// One harness that answers from the search path alone — no run signal, whatever names