feat: Grok Build (grok) agent support — detection, extensions, config scan - #125
Conversation
Native Skills, MCP, command hooks, plugins, and project scope, with personal MCP disable that never rewrites shared project files. Closes RealZST#124
Replace the homemade spark with the official grok.svg path, scan project .grok/plugins, keep SubagentEnd spec names unique, and only treat $GROK_HOME/hooks as global.
Match xai-grok-hooks build_specs exactly, verified against xai-org/grok-build@c2ad97f: - [i] is keyed per event ENUM VARIANT, not per display token. SubagentStop and SubagentEnd are separate variants whose spec names collide on the shared subagent_stop display token (upstream discovery.rs asserts exactly this); the previous merged sequence emitted subagent_stop[1], a name Grok never generates, so disable was a silent no-op. - Dedup specs on (canonical event, raw command, matcher), first wins, with SubagentEnd folding into SubagentStop — a hook hedged under both spellings runs once upstream and is now one row here. - Handler errors are split the way upstream splits them. A missing `type` (or a wrong JSON type on any field) is a serde error on RawHandler's plain `String` / `Option<String>` fields, and GroupErrorPolicy::Fail then yields zero hooks for the whole file. An unknown `type` deserializes fine and errors inside build_specs' loop (HookError::UnsupportedHandlerType), as does a `command` handler with no `command` (HookError::InvalidConfig) — the file's other hooks still register, and the rejected handler still consumes its `[j]` index, so its siblings keep the spec names Grok generates. Treating every bad handler as a whole-file failure would have hidden hooks Grok runs. Known limitation, deliberately not modeled: when one file spells a single event under two alias keys (Cursor's beforeShellExecution and beforeReadFile both map to PreToolUse), upstream merges the groups in std-HashMap order, so our index may address the sibling entry. The shape is rare, upstream itself is unstable there, and HK reads the real state back from disabled-hooks on every scan.
…ugin ids Upstream hashes the dunce-canonical plugin root (plugins/discovery.rs) and bans std::fs::canonicalize repo-wide via clippy disallowed-methods, because std returns \\?\ verbatim paths on Windows — those hash to a different hex8 than the id Grok writes to its [plugins] lists, so every toggle would write an id Grok does not recognize. dunce delegates directly to std on Unix, so ids on macOS/Linux are unchanged. The crate is already in the lockfile (via tauri) at the same version upstream pins; the canonicalize-failure fallback stays, as upstream only hits it in a delete race and skipping would hide the plugin entirely.
…ugin identity, settings, agent-memory Four corrections, each source-verified against xai-org/grok-build@c2ad97f: - MCP transport: SSE is type = "sse" (ASCII case-insensitive) OR a byte-exact "/sse" url suffix, and the suffix wins even over an explicit type = "http" (config-types mcp.rs to_acp_mcp_server). - Plugins: convention components also include commands/ and .lsp.json; dirnames sanitize like upstream name_from_dirname (lowercase, other chars to '-', trim, >64 rejects); a manifest that fails to parse or carries an invalid name rejects the directory outright — Grok never falls back to the dirname there. The three old helpers collapse into one grok_plugin_identity entry point. - Settings: add sandbox.toml and lsp.json (real user-tier config). managed_config.toml and requirements.toml stay excluded on purpose: both are server-synced artifacts, atomically overwritten per fetch and deleted on logout — listing them invites edits that vanish. - Memory: <project>/.grok/memory does not exist upstream; project memory is per-subagent .grok/agent-memory{,-local}/<agent>/MEMORY.md. The old concrete pattern also made the Kit memory sync write into a directory Grok never reads; all-glob patterns mean no write target, which is correct — HK cannot invent a subagent name. Global side keeps memory/MEMORY.md and adds agent-memory/*/MEMORY.md; the flat memory/*.md glob is gone (Grok never reads those at startup). Also in this pass, since it is the same question of what HK models about Grok's files: `scan_agent_configs` now keeps one row per (real path, lowercased filename) within a category. Grok lists both AGENTS.md and Agents.md — one file under two spellings on a case-insensitive filesystem — which surfaced as a duplicate row. Pairing the canonical path with the spelling confines the suppression to that artifact: CLAUDE.md symlinked to AGENTS.md is two names the user deliberately has (the recommended AGENTS.md migration) and keeps both rows, matching how the rest of HK surfaces symlinks rather than merging them away.
Grok finds SKILL.md up to six segments below each skills root (walk_for_skill_md, depth cap 5), including skills nested inside other skills — team-folder layouts like skills/team/infra/SKILL.md are real skills that the flat scanner missed entirely. New AgentAdapter::expand_skill_roots hook (default: identity, so the other twelve adapters are unchanged) returns the root plus every nested parent directory that directly holds skill subdirs; the scanner's skill scan and lookup paths (scan, find-by-id, locations, content) expand through it so nested skills are not just listed but also toggleable and deletable. Install targets still resolve through the canonical first root only. Recursion alone would be wrong, though: scan_skill_dir also treats any loose *.md at the top of a scanned root as a standalone skill, and expanding roots aims that rule at directories chosen precisely because they hold other things. AgentAdapter::standalone_md_skills (default true, so the other twelve adapters keep today's behaviour) turns it off for Grok, which is what upstream does anyway — walk_for_skill_md filters read_dir to directories before it looks at a filename, and the only name it accepts is SKILL.md inside one, verified against grok 1.0.5. A bare notes.md, a grouping folder's README.md and a skill's own reference.md are therefore payload, not skills; listing one would offer a row Grok cannot load and whose Delete removes an ordinary file. The one place a loose .md is a first-class Grok entity is commands/, a different root that HK already lists as a config file. The rule is applied on the lookup paths too — find_skill_by_id (which delete_extension remove_file()s the result of) and find_skill_content classify entries with their own copy of it, so gating only the scan would have left the same file reachable through a different door. scan_skill_dir also gains a deterministic sort — read_dir order is filesystem-dependent, so same-name collisions now resolve stably. Both roots, not one: `~/.agents/skills` joins `~/.grok/skills` in skill_dirs. Upstream's CompatConfig::skill_config_dirs hard-codes `.grok` and `.agents` and gates only `.claude`/`.cursor` behind their compat cells, and the global pass adds `~/.agents` unconditionally beside grok_home — so HK was modelling one of the two roots Grok always reads, and a skill installed there showed as belonging to Codex/Cursor/OpenCode/dsh but not to Grok, which runs it. The recursive walk applies to both, as upstream's does. `.claude/skills` and `.cursor/skills` stay with their own adapters, following the rule omp already states for the same shared dirs; the project side already declared `.agents/skills` as a read dir, only the global list was short.
…es on deploy Upstream's StreamableHttp.url accepts urlTemplate / url_template as deserialize-only aliases (config-types mcp.rs). HK read only `url`, so an alias-keyed remote server scanned as stdio with an empty command — and redeploying that reading through install-to-agent wrote `command = ""` beside the template, which upstream drops as a blank transport: the remote server was destroyed. The read side now accepts all three spellings (two at once is a duplicate-field error upstream, so there is no precedence to model) and classifies command-first, matching the untagged enum's Stdio-first order. The deploy path now removes urlTemplate/url_template in both branches: writing `url` beside a surviving alias would be that same duplicate- field error and Grok would drop the whole entry.
Two halves of the same question — is this plugin actually on? — each source-verified against xai-org/grok-build@c2ad97f. Project tier (config/mod.rs resolve_effective_plugins_config): upstream merges a project config's [plugins].disabled (always) and .paths (only once the folder is trusted), never its .enabled. A repo cannot self-enable its own plugins — deliberate, so a malicious repo can't bypass the project-plugin auto-disable. HK merged the project enabled list too, showing a plugin as enabled that Grok keeps disabled. Now only the user tier's enabled list counts (id-or-name matching is upstream's own rule and stays; the default-disabled rule itself lives in plugins/registry.rs). Deleting a project plugin now also cleans the project .grok/config.toml lists: the project disabled list merges ungated, so a stale entry would silently disable a future same-named plugin. Name-based removal stays — grok plugin enable/disable writes bare names, so they are live entries. Claude compat (merge_claude_enabled_plugins): unless /import-claude has set [claude_compat] imported in config.toml, Grok live-merges ~/.claude/settings.json enabledPlugins into its plugin lists BEFORE the scope auto-disable, so a Claude-enabled name switches a plugin on — user and project scope alike. HK computed enablement from Grok-native lists only, showing such plugins as disabled while Grok runs them. Semantics mirrored exactly: name@marketplace suffix stripped, false wins per name within the file, and a name already in either native list is never moved to the opposite one — native config wins, so a natively- enabled name survives a Claude false. User-tier settings.json only: upstream deliberately refuses project .claude/settings.json (a repo must not self-enable hooks) and simply never reads settings.local.json for this merge. The lists resolve once per scanned plugin directory, not per plugin (read_plugins walks two global dirs, so a full global scan resolves them twice). Known boundaries, unchanged: HK does not scan ~/.claude/plugins dirs for grok (shared-directory ownership stays with the Claude adapter), and plugins reached via [plugins].paths outside ~/.grok/plugins (ConfigPath scope, auto-enabled upstream) are not discovered either.
Grok loads project-level hooks, MCP/LSP servers, plugins, and permission rules only after the folder is trusted inside Grok (first- open prompt or /hooks-trust; grant keyed on the repository root, inherited by subdirectories). HK shows those rows as active regardless, so an install that silently does nothing looks like a success. Surface the gate as an inline note (en/zh/zh-TW), following the kiro/dsh note pattern. Skills are deliberately excluded — they are not trust-gated upstream.
Grok's saved Rhai workflows ($GROK_HOME/workflows, .grok/workflows —
siblings of commands/ in upstream's watcher), personas, and roles
(SubagentPersona/SubagentRole TOML, one per file) are user-editable
config Grok loads; HK's agent dashboard listed only commands/*.md and
agents/*.md. The config-file pipeline is format-agnostic (stat + raw
30-line preview; Codex subagents are already TOML there), so these are
pure listing additions. bundled/{workflows,personas,roles} stays
excluded as vendor content rather than user config: it is the
GCS-synced builtin bundle, and while a hand edit there does survive
(the bundle is checksum-tracked and never overwrites a modified file),
a user or project file still cannot shadow a compiled-in name. Workflow RUN state lives under the session dir
and is untouched by these non-recursive globs.
|
Thanks for adding Grok! I checked against the upstream mirror (xai-org/grok-build@c2ad97f), tested on a real grok 1.0.5 install, and pushed eight commits on top of yours:
Thanks again! Merged |
* chore: drop the Grok PR screenshots from media/ The three PNGs under media/pr-grok-build/ only ever illustrated PR #125. Nothing in the repo references them, and that PR's body points at raw.githubusercontent.com on the contributor's fork rather than at this repository, so removing them here leaves the thread's images intact. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015weU8hXkgz73A96UcHL7oS * chore(mascot): remove the dead cursor shadow CSS .shadow-el has no element behind it — cursor-mascot.tsx renders only .rocker and .icon-svg. The four rules and the two keyframe blocks that drove it were left behind when the shadow was dropped from the markup, so they have been styling nothing since. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015weU8hXkgz73A96UcHL7oS * feat(mascot): rework grok's hover and click motion Hover no longer scales the whole mark. The glyph reads as a star, so it holds still while a planet crosses it on a drawn elliptical trajectory whose dashes flow along the path. The planet is a grey four-point flare inside a halo, scaled and faded along the orbit so the near point is large and bright and the far one small and dim — grey rather than the mark's own colour so it reads as a second, lesser body rather than a chip off the star. Click reads the same glyph the other way, as the spinning top it also looks like. It crouches, hops, and is stood upright 45deg into the ascent — the angle the line through its two spikes already sits at — then turns about the vertical axis as it lands and runs down to a stop before unwinding back to brand angle. The three tracks sit on three nested elements because a child's transform composes before its parent's, and that is the only arrangement which spins an upright mark rather than tilting the axis it spins about. CLICK_DURATIONS.grok goes from 900ms to 2100ms. The old value pulled is-clicked while the mark was still held at 45deg, cutting the unwind. Everything the orbit adds is transparent at rest, so every surface that draws the mascot without hover — the extension tables, the marketplace, the kit drawer — is unchanged. Only agent-card.tsx passes `animated`, and it already leaves grok on overflow-visible for the orbit to overhang. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015weU8hXkgz73A96UcHL7oS * chore: clear the outstanding biome findings `npm run lint` has been reporting on three files that nothing in this branch touched: an unsorted import in agent-mascot.tsx, a stale format in agent-card.tsx, and a non-null assertion in the memory-grouping test. CI runs test and build but not lint, which is why they went unnoticed. The first two are biome's own fixes. The third replaces the assertion with an explicit throw, so a missing group fails with a message naming the group rather than with a null dereference three lines later. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015weU8hXkgz73A96UcHL7oS --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds Grok Build (
grok) as the 13th supported agent. Closes #124.Source-backed against xai-org/grok-build
@ c2ad97f. Home is$GROK_HOMEverbatim when non-empty, otherwise~/.grok. Detection never creates the directory.What's included
Detection & layout
$GROK_HOME/~/.grok.grok.grok/skills,.grok/config.toml,.grok/hooks/,.grok/plugins/).agents/skillsis a shared read-only alias only — Codex/Gemini keep ownership.claude/.cursorcontents are not attributed to Grokauth.jsonandmcp_credentials.jsonare never scannedSkills / MCP / Hooks / Plugins
$GROK_HOME/skillsand project.grok/skills[mcp_servers.<name>]withheaders(not Codexhttp_headers);type = "sse"is SSE, otherwise URL is HTTPdisabled_mcp_servers(+ user entryenabledwhen present) and does not rewrite the shared project file. Enable clears the user list and unsticks a winning projectenabled = falseglobal/session-start:pre_tool_use[0].hooks[1])$GROK_HOME/disabled-hooksusing the real spec.name$GROK_HOME/hooksisglobal/; a project under a custom$GROK_HOMEstaysproject/SubagentEnduses upstream'ssubagent_stopdisplay token and shares one group-index sequence$GROK_HOME/plugins,installed-plugins, and project.grok/plugins; stable id{scope}/{hex8}/{name}; toggle via[plugins].enabled/[plugins].disabled(user + project lists merged, disabled wins); default disabled; delete removes the directory and both lists. Project plugin toggle writes the user lists onlyProject scope & configs
config.toml,pager.tomlonly$GROK_HOME/memoryplus project.grok/memory/*.md; nestedmemory/index/is not claimedFrontend & docs
AGENT_ORDER/ display name Grok Build, filter color, onboarding scatter, reduced-motion mascotgrok.svgmark (currentColor+--mascot-icon-color)Known limitation
HTTP hooks are outside HarnessKit's current
HookEntrycommand model. This PR only scans and toggles command hooks. HTTP handlers are skipped as entries but still counted in Grok's hook index so disable names stay correct.Verification
npm test: 307 passednpm run build: tsc + vite succeededcargo test --workspace: passed (after frontenddist/build, matching CI)cargo test -p hk-core --lib grok(25) andcargo test -p hk-core --test toggle_integration grok(5) passed$GROK_HOMEsmoke (grok1.0.5 +hk):hk status→1 detected (grok)hk list --agent grok→ skill / stdio+HTTP MCP / plugin / command hookhk disable/hk enableecho → userdisabled_mcp_serversround-trip;themeand unrelated MCPheaderskeptuser/<hex8>/demo-plugingrok mcp listandgrok inspectread the same home (stdioecho, HTTPlinear, command hook, demo skill). Auth files were not copied or printed.Screenshots