diff --git a/Cargo.lock b/Cargo.lock index 39167e8..9e7e82a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1946,6 +1946,7 @@ dependencies = [ "anyhow", "chrono", "dirs", + "dunce", "fs2", "glob", "jsonc-parser", diff --git a/README.md b/README.md index 438ae3f..36fefa5 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ HarnessKit manages **all five extension types** from a unified interface — **S | **Kiro** | ✓ | ✓ | — | ✓ | ✓ | | **Oh My Pi** | ✓ | ✓ | ✓ | — | ✓ | | **DeepSeek Harness** | ✓ | ✓ | ✓ | — | ✓ | +| **Grok Build** | ✓ | ✓ | ✓ | ✓ | ✓ | * "—" indicates the agent currently does not support this extension type. Devin Desktop support keeps legacy Windsurf paths compatible. @@ -89,7 +90,7 @@ HarnessKit manages **all five extension types** from a unified interface — **S ### 🤖 Agent Configs, Memory & Rules -HarnessKit manages every agent's **Configs**, **Memory**, **Rules**, **Subagents**, and **Ignore** files from one place. Currently supporting **12 agents**: **Claude Code**, **Codex**, **Gemini CLI**, **Cursor**, **Antigravity**, **Copilot**, **Devin Desktop**, **OpenCode**, **Hermes**, **Kiro**, **Oh My Pi**, and **DeepSeek Harness**. +HarnessKit manages every agent's **Configs**, **Memory**, **Rules**, **Subagents**, and **Ignore** files from one place. Currently supporting **13 agents**: **Claude Code**, **Codex**, **Gemini CLI**, **Cursor**, **Antigravity**, **Copilot**, **Devin Desktop**, **OpenCode**, **Hermes**, **Kiro**, **Oh My Pi**, **DeepSeek Harness**, and **Grok Build**. - **Config file tracking** — Automatically discovers every agent's config files — both global and per-project. Add your project directories or custom paths and HarnessKit picks them up alongside the global ones. - **Per-agent dashboard** — Each agent gets its own page with all files organized by category, showing scope, path, file size, and a summary of installed extensions. Expand any file to preview its content right in the app. @@ -177,10 +178,11 @@ HarnessKit ships a standalone command-line interface (`hk`) for terminal-first w ```shell $ hk status - Agents 12 detected (claude · codex · gemini · cursor · antigravity · copilot · windsurf · opencode · hermes · kiro · omp · dsh) + Agents 13 detected (claude · codex · gemini · cursor · antigravity · copilot · windsurf · opencode · hermes · kiro · omp · dsh · grok) Extensions 136 total (124 skills · 2 mcp · 8 plugins · 1 hooks · 1 clis) $ hk list --kind skill --agent claude # filter by type and agent +$ hk list --kind mcp --agent grok # Grok Build MCP servers $ hk audit # security audit with trust scores $ hk enable my-skill # enable by name $ hk disable --pack owner/repo # batch disable by source diff --git a/README.zh-CN.md b/README.zh-CN.md index 4948230..9ac7dc5 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -70,6 +70,7 @@ HarnessKit 通过统一界面管理 **全部五种扩展类型** —— **Skill* | **Kiro** | ✓ | ✓ | — | ✓ | ✓ | | **Oh My Pi** | ✓ | ✓ | ✓ | — | ✓ | | **DeepSeek Harness** | ✓ | ✓ | ✓ | — | ✓ | +| **Grok Build** | ✓ | ✓ | ✓ | ✓ | ✓ | * "—" 表示该 Agent 目前不支持此扩展类型。Devin Desktop 支持会继续兼容旧 Windsurf 路径。 @@ -89,7 +90,7 @@ HarnessKit 通过统一界面管理 **全部五种扩展类型** —— **Skill* ### 🤖 Agent 配置、记忆与规则 -HarnessKit 统一管理每个 Agent 的 **配置**、**记忆**、**规则**、**子 Agent** 与 **忽略**(Ignore)文件。目前支持 **12 个 Agent**:**Claude Code**、**Codex**、**Gemini CLI**、**Cursor**、**Antigravity**、**Copilot**、**Devin Desktop**、**OpenCode**、**Hermes**、**Kiro**、**Oh My Pi** 与 **DeepSeek Harness**。 +HarnessKit 统一管理每个 Agent 的 **配置**、**记忆**、**规则**、**子 Agent** 与 **忽略**(Ignore)文件。目前支持 **13 个 Agent**:**Claude Code**、**Codex**、**Gemini CLI**、**Cursor**、**Antigravity**、**Copilot**、**Devin Desktop**、**OpenCode**、**Hermes**、**Kiro**、**Oh My Pi**、**DeepSeek Harness** 与 **Grok Build**。 - **配置文件跟踪** —— 自动发现每个 Agent 的全局与项目级配置文件。添加项目目录或自定义路径后,HarnessKit 会将它们与全局配置一同纳入管理。 - **Agent 专属面板** —— 每个 Agent 拥有独立页面,文件按类别组织,列出范围、路径、文件大小以及已安装扩展的概览。展开任意文件即可在应用内预览。 @@ -177,10 +178,11 @@ HarnessKit 提供独立命令行工具(`hk`),面向偏好终端的工作 ```shell $ hk status - Agents 12 detected (claude · codex · gemini · cursor · antigravity · copilot · windsurf · opencode · hermes · kiro · omp · dsh) + Agents 13 detected (claude · codex · gemini · cursor · antigravity · copilot · windsurf · opencode · hermes · kiro · omp · dsh · grok) Extensions 136 total (124 skills · 2 mcp · 8 plugins · 1 hooks · 1 clis) $ hk list --kind skill --agent claude # 按类型与 Agent 筛选 +$ hk list --kind mcp --agent grok # Grok Build MCP servers $ hk audit # 带信任评分的安全审计 $ hk enable my-skill # 按名称启用 $ hk disable --pack owner/repo # 按来源批量禁用 diff --git a/crates/hk-core/Cargo.toml b/crates/hk-core/Cargo.toml index 37f8b62..8425c59 100644 --- a/crates/hk-core/Cargo.toml +++ b/crates/hk-core/Cargo.toml @@ -14,6 +14,10 @@ rusqlite = { version = "0.32", features = ["bundled"] } regex = "1" toml = "0.8" dirs = "6" +# Same canonicalizer as grok-build (its clippy.toml bans std::fs::canonicalize): +# Grok plugin ids hash the dunce-canonical root, and \\?\ verbatim Windows +# paths would hash to a different id. Already in the lockfile via tauri. +dunce = "1" glob = "0.3" walkdir = "2" jsonc-parser = { version = "0.32", features = ["serde", "serde_json", "cst"] } diff --git a/crates/hk-core/src/adapter/grok.rs b/crates/hk-core/src/adapter/grok.rs new file mode 100644 index 0000000..3c173b3 --- /dev/null +++ b/crates/hk-core/src/adapter/grok.rs @@ -0,0 +1,1790 @@ +// Grok Build (xAI) config references — verified against +// github.com/xai-org/grok-build @ c2ad97f: +// - Home: crates/codegen/xai-grok-home — `$GROK_HOME` verbatim when +// non-empty, else `/.grok`. Detection must not create the dir. +// - Skills: `$GROK_HOME/skills` and `~/.agents/skills`, project +// `.grok/skills` plus `.agents/skills` (SKILL.md in a directory, found +// recursively). `.agents` is claimed because upstream always reads it +// (`CompatConfig::skill_config_dirs` hard-codes `.grok` and `.agents`); +// the `.claude` / `.cursor` trees it gates behind compat cells stay with +// their own adapters. +// - MCP: `[mcp_servers.]` in `$GROK_HOME/config.toml` and +// `.grok/config.toml`. Remote key is `headers`; `type = "sse"` is SSE. +// Personal disable: user `disabled_mcp_servers` + per-entry `enabled`. +// - Hooks: `$GROK_HOME/hooks/*.json` and `.grok/hooks/*.json` (Claude-like). +// Disable file: `$GROK_HOME/disabled-hooks` (one spec.name per line). +// Spec name: `{global|project}/:[i].hooks[j]`. The +// format is an undocumented internal identifier (only written down as a +// doc comment on upstream's wire type, xai-hooks-plugins-types) — we +// mirror `xai-grok-hooks/src/config.rs::build_specs` exactly: +// [i] is keyed per event ENUM VARIANT (SubagentStop and SubagentEnd are +// separate variants whose names collide on the shared `subagent_stop` +// display token), and specs dedup on (canonical event, raw command, +// matcher) with SubagentEnd folding into SubagentStop. When one file +// spells one event under 2+ alias keys, upstream merges the groups in +// std-HashMap iteration order — nondeterministic run to run — so the +// index we compute may address the sibling entry. Not modeled: the +// shape is rare, upstream itself is unstable there, and HK reads the +// real state back from `disabled-hooks` on every scan. +// Cross-source dedup is NOT modeled: upstream also dedups identical +// hooks across files (first source wins, config-layer `[hooks]` entries +// ahead of every file), so a row shadowed by a twin elsewhere is absent +// from Grok's registry and its disable line would be a no-op. +// HTTP handlers are skipped — HK's HookEntry is command-only. +// - Plugins: `$GROK_HOME/plugins`, `$GROK_HOME/installed-plugins`, +// project `.grok/plugins`. Stable id `{scope}/{hex8}/{name}`; hex8 = +// first 8 hex chars of SHA-256 of the canonical plugin root. Enable +// lists: `[plugins].enabled` / `[plugins].disabled`. A project file +// contributes `disabled` (always) and `paths` (only once the folder is +// trusted) — never `enabled`, so a repo cannot self-enable its own +// plugins. User/project plugins default to disabled. +// - Do not surface `auth.json` or `mcp_credentials.json`. + +use super::{ + files_with_ext, AgentAdapter, HookEntry, HookFormat, McpFormat, McpServerEntry, McpTransport, + PluginEntry, ProjectMarker, RemoteMcpSchema, +}; +use crate::models::ConfigScope; +use sha2::{Digest, Sha256}; +use std::collections::{HashMap, HashSet}; +use std::ffi::OsString; +use std::path::{Path, PathBuf}; + +/// Grok hook events in upstream `HookEventName` table order (drives spec +/// name indices). `aliases` are accepted JSON keys; `display` is the +/// snake_case token Grok writes into spec names; `canonical` is the +/// PascalCase name HK stores on `HookEntry`. +struct GrokEvent { + canonical: &'static str, + display: &'static str, + aliases: &'static [&'static str], +} + +const GROK_EVENTS: &[GrokEvent] = &[ + GrokEvent { + canonical: "SessionStart", + display: "session_start", + aliases: &["SessionStart", "session_start", "sessionStart"], + }, + GrokEvent { + canonical: "UserPromptSubmit", + display: "user_prompt_submit", + aliases: &["UserPromptSubmit", "user_prompt_submit", "beforeSubmitPrompt"], + }, + GrokEvent { + canonical: "PreToolUse", + display: "pre_tool_use", + aliases: &[ + "PreToolUse", + "pre_tool_use", + "preToolUse", + "beforeShellExecution", + "beforeMCPExecution", + "beforeReadFile", + ], + }, + GrokEvent { + canonical: "PostToolUse", + display: "post_tool_use", + aliases: &[ + "PostToolUse", + "post_tool_use", + "postToolUse", + "afterShellExecution", + "afterMCPExecution", + "afterFileEdit", + "afterAgentResponse", + "afterAgentThought", + ], + }, + GrokEvent { + canonical: "PostToolUseFailure", + display: "post_tool_use_failure", + aliases: &[ + "PostToolUseFailure", + "post_tool_use_failure", + "postToolUseFailure", + ], + }, + GrokEvent { + canonical: "PermissionDenied", + display: "permission_denied", + aliases: &["PermissionDenied", "permission_denied", "permissionDenied"], + }, + GrokEvent { + canonical: "Stop", + display: "stop", + aliases: &["Stop", "stop"], + }, + GrokEvent { + canonical: "StopFailure", + display: "stop_failure", + aliases: &["StopFailure", "stop_failure", "stopFailure"], + }, + GrokEvent { + canonical: "StopCancelled", + display: "stop_cancelled", + aliases: &["StopCancelled", "stop_cancelled", "stopCancelled"], + }, + GrokEvent { + canonical: "Notification", + display: "notification", + aliases: &["Notification", "notification"], + }, + GrokEvent { + canonical: "SubagentStart", + display: "subagent_start", + aliases: &["SubagentStart", "subagent_start", "subagentStart"], + }, + GrokEvent { + canonical: "SubagentStop", + display: "subagent_stop", + aliases: &["SubagentStop", "subagent_stop", "subagentStop"], + }, + GrokEvent { + canonical: "SubagentEnd", + display: "subagent_stop", + aliases: &["SubagentEnd", "subagent_end", "subagentEnd"], + }, + GrokEvent { + canonical: "PreCompact", + display: "pre_compact", + aliases: &["PreCompact", "pre_compact", "preCompact"], + }, + GrokEvent { + canonical: "PostCompact", + display: "post_compact", + aliases: &["PostCompact", "post_compact", "postCompact"], + }, + GrokEvent { + canonical: "SessionEnd", + display: "session_end", + aliases: &["SessionEnd", "session_end", "sessionEnd"], + }, +]; + +/// `$GROK_HOME` verbatim when non-empty, else `/.grok`. Pure so tests +/// can cover the override without mutating process env (racy under cargo test). +fn resolve_grok_home(grok_home_env: Option, os_home: Option<&Path>) -> Option { + if let Some(env) = grok_home_env.filter(|v| !v.is_empty()) { + return Some(PathBuf::from(env)); + } + os_home.map(|home| home.join(".grok")) +} + +fn toml_string_map( + table: Option<&toml::Table>, + key: &str, +) -> std::collections::HashMap { + table + .and_then(|t| t.get(key)) + .and_then(|v| v.as_table()) + .map(|obj| { + obj.iter() + .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string()))) + .collect() + }) + .unwrap_or_default() +} + +fn toml_string_list(doc: &toml::Table, key: &str) -> Vec { + doc.get(key) + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default() +} + +/// Stable Grok plugin id: `{scope}/{hex8}/{name}`. +/// `hex8` is the first 8 hex chars of SHA-256 of the canonical root path +/// (`xai-grok-agent/src/plugins/discovery.rs` `PluginId::new`). +/// Must be `dunce::canonicalize`, not `std::fs::canonicalize` — upstream +/// bans the latter repo-wide (grok-build clippy.toml disallowed-methods) +/// because it returns `\\?\` verbatim paths on Windows, which would hash +/// to a different hex8 than the id Grok writes to its plugin lists. +pub fn grok_plugin_id(scope: &str, root: &Path, name: &str) -> String { + // On canonicalize failure upstream skips the plugin; we keep the row + // with a raw-path id instead — the failure only occurs in a + // permission/delete race, and an inspection tool showing the plugin + // beats hiding it (the id just won't match Grok's in that window). + let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf()); + let mut hasher = Sha256::new(); + hasher.update(canonical.to_string_lossy().as_bytes()); + let hash = hasher.finalize(); + format!( + "{scope}/{:02x}{:02x}{:02x}{:02x}/{name}", + hash[0], hash[1], hash[2], hash[3] + ) +} + +fn grok_event_by_alias(key: &str) -> Option<&'static GrokEvent> { + GROK_EVENTS.iter().find(|e| e.aliases.contains(&key)) +} + +/// Upstream `MAX_SKILL_WALK_DEPTH`: a skill directory sits at most six +/// path segments below a skills root (skills/discovery.rs:19). +const MAX_SKILL_WALK_DEPTH: usize = 5; + +/// Collect every directory under `dir` (children visited at `depth`, capped +/// like upstream) whose direct children include a skill dir; returns whether +/// `dir` itself directly holds one, so each directory is read exactly once. +/// Sorted for stable output; note upstream's walk is an interleaved DFS +/// (first-seen wins) while HK scans each root's children before descending +/// and upserts last-write-wins — so on a frontmatter-name collision where +/// the top-level skill sorts before the nested dir holding its twin, HK +/// keeps the nested copy and Grok keeps the top-level one. Skills nested INSIDE another +/// skill are legal and both are emitted (upstream test +/// `find_skill_paths_parent_and_child_both_have_skill_md`). Symlinked dirs +/// are followed — the depth cap is the only cycle protection, deliberately +/// matching upstream. +fn collect_nested_skill_parents(dir: &Path, depth: usize, out: &mut Vec) -> bool { + let Ok(entries) = std::fs::read_dir(dir) else { + return false; + }; + let mut children: Vec = entries + .flatten() + .map(|e| e.path()) + .filter(|p| p.is_dir()) + .collect(); + children.sort(); + let mut holds_skill = false; + for child in children { + if child.join("SKILL.md").is_file() || child.join("SKILL.md.disabled").is_file() { + holds_skill = true; + } + if depth <= MAX_SKILL_WALK_DEPTH { + let mut nested = Vec::new(); + if collect_nested_skill_parents(&child, depth + 1, &mut nested) { + out.push(child.clone()); + } + out.append(&mut nested); + } + } + holds_skill +} + +/// `.grok/plugins` → the sibling `.grok/config.toml` holding the project +/// `[plugins]` lists. Single source of truth for the scan +/// (`read_plugins_from`) and the delete cleanup in service.rs, so the +/// derivation cannot drift. +pub fn project_config_beside_plugins_dir(plugins_dir: &Path) -> Option { + plugins_dir.parent().map(|grok_dir| grok_dir.join("config.toml")) +} + +/// The `[plugins].` string list from a parsed config (empty when absent). +fn plugin_list_in(doc: Option<&toml::Table>, key: &str) -> HashSet { + doc.and_then(|d| d.get("plugins")) + .and_then(|v| v.as_table()) + .and_then(|plugins| plugins.get(key)) + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default() +} + +/// Per-name enable state from Claude's `enabledPlugins` map +/// (`{"name@marketplace": bool}`): the `@marketplace` suffix is stripped +/// and `false` wins per name within the file — mirrors upstream +/// `parse_enabled_disabled_plugins` (plugins/marketplace.rs). Missing or +/// malformed file yields an empty map, like upstream. +fn claude_plugin_states(settings: &Path) -> HashMap { + let Ok(content) = std::fs::read_to_string(settings) else { + return HashMap::new(); + }; + let Ok(json) = serde_json::from_str::(&content) else { + return HashMap::new(); + }; + let Some(obj) = json.get("enabledPlugins").and_then(|v| v.as_object()) else { + return HashMap::new(); + }; + let mut state: HashMap = HashMap::new(); + for (key, val) in obj { + let name = key.split_once('@').map_or(key.as_str(), |(n, _)| n); + let Some(value) = val.as_bool() else { continue }; + if name.is_empty() { + continue; + } + let entry = state.entry(name.to_string()).or_insert(value); + if !value { + *entry = false; + } + } + state +} + +/// Upstream folds the legacy SubagentEnd variant into SubagentStop for +/// dispatch and dedup (`HookEventName::canonical`). +fn dedup_event(canonical: &str) -> &str { + if canonical == "SubagentEnd" { + "SubagentStop" + } else { + canonical + } +} + +/// Names listed in `$GROK_HOME/disabled-hooks` (comments and blanks skipped). +pub fn read_disabled_hook_names(path: &Path) -> HashSet { + let Ok(content) = std::fs::read_to_string(path) else { + return HashSet::new(); + }; + content + .lines() + .map(str::trim) + .filter(|l| !l.is_empty() && !l.starts_with('#')) + .map(String::from) + .collect() +} + +/// One handler's outcome, split the way upstream's two failure modes are. +/// `Err(())` is a whole-file failure: `RawHandler::handler_type` is a plain +/// `String` and `command`/`url` are `Option`, so a missing `type` or +/// a wrong JSON type anywhere is a serde error, and `GroupErrorPolicy::Fail` +/// then yields zero hooks for the file. `Ok(None)` is a per-handler error — +/// an unknown `type` (`HookError::UnsupportedHandlerType`) or a `command` +/// handler with no `command` (`HookError::InvalidConfig`) — which upstream +/// collects inside the loop, so the file's other hooks still register. +/// `http` handlers are well-formed but carry no command, and HK's HookEntry +/// is command-only, so they take the same `Ok(None)` path. +fn handler_command(hook: &serde_json::Value) -> Result, ()> { + let Some(handler_type) = hook.get("type").and_then(|v| v.as_str()) else { + return Err(()); + }; + let optional_str = |key: &str| match hook.get(key) { + None | Some(serde_json::Value::Null) => Ok(None), + Some(serde_json::Value::String(s)) => Ok(Some(s.clone())), + Some(_) => Err(()), + }; + match handler_type { + "command" => optional_str("command"), + "http" => optional_str("url").map(|_| None), + _ => Ok(None), + } +} + +fn plugin_manifest_path(dir: &Path) -> Option { + for rel in ["plugin.json", ".grok-plugin/plugin.json", ".claude-plugin/plugin.json"] + { + let path = dir.join(rel); + if path.is_file() { + return Some(path); + } + } + None +} + +/// Upstream plugin names are validated, never sanitized: lowercase ASCII, +/// digits, hyphens; no leading/trailing hyphen; 1..=64 chars +/// (`plugins/manifest.rs::is_valid_plugin_name`). +fn is_valid_grok_plugin_name(name: &str) -> bool { + !name.is_empty() + && name.len() <= 64 + && !name.starts_with('-') + && !name.ends_with('-') + && name + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') +} + +/// Upstream `name_from_dirname`: ASCII-lowercase, every other char becomes +/// '-' (consecutive hyphens NOT collapsed), hyphens trimmed at both ends, +/// then reject empty or >64 — over-length rejects, never truncates. +fn grok_name_from_dirname(dir: &Path) -> Option { + let dirname = dir.file_name()?.to_str()?; + let sanitized: String = dirname + .to_ascii_lowercase() + .chars() + .map(|c| { + if c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' { + c + } else { + '-' + } + }) + .collect(); + let trimmed = sanitized.trim_matches('-'); + is_valid_grok_plugin_name(trimmed).then(|| trimmed.to_string()) +} + +fn is_convention_plugin(dir: &Path) -> bool { + dir.join("skills").is_dir() + || dir.join("commands").is_dir() + || dir.join("agents").is_dir() + || dir.join(".mcp.json").is_file() + || dir.join(".lsp.json").is_file() + || dir.join("hooks").join("hooks.json").is_file() +} + +/// The plugin name when `dir` is a plugin Grok would load, else None. +/// Mirrors upstream `collect_plugin`: a manifest that fails to parse or +/// carries an invalid name rejects the directory outright (no dirname +/// fallback); without a manifest the sanitized dirname must survive AND +/// at least one plugin component must exist (name check first, like +/// upstream). +fn grok_plugin_identity(dir: &Path) -> Option { + if let Some(manifest) = plugin_manifest_path(dir) { + let content = std::fs::read_to_string(manifest).ok()?; + let v: serde_json::Value = serde_json::from_str(&content).ok()?; + let name = v.get("name")?.as_str()?; + return is_valid_grok_plugin_name(name).then(|| name.to_string()); + } + let name = grok_name_from_dirname(dir)?; + is_convention_plugin(dir).then_some(name) +} + +pub struct GrokAdapter { + grok_home: PathBuf, + /// `~/.agents` — the vendor-neutral config dir Grok always reads skills + /// from. Independent of `$GROK_HOME` (upstream uses `dirs::home_dir`). + agents_home: PathBuf, + /// `~/.claude` — unless the `/import-claude` marker is set, Grok + /// live-reads Claude's settings.json for the enabledPlugins compat + /// merge. Independent of `$GROK_HOME` (upstream uses `dirs::home_dir`). + claude_home: PathBuf, +} + +impl Default for GrokAdapter { + fn default() -> Self { + Self::new() + } +} + +impl GrokAdapter { + pub fn new() -> Self { + let home = dirs::home_dir().unwrap_or_default(); + Self { + grok_home: resolve_grok_home(std::env::var_os("GROK_HOME"), Some(&home)) + .unwrap_or_else(|| home.join(".grok")), + agents_home: home.join(".agents"), + claude_home: home.join(".claude"), + } + } + + /// Test/deployer constructor: `/.grok` (and `/.agents`, + /// `/.claude`, so tests stay hermetic). Does not read `$GROK_HOME`. + pub fn with_home(home: PathBuf) -> Self { + Self { + grok_home: home.join(".grok"), + agents_home: home.join(".agents"), + claude_home: home.join(".claude"), + } + } + + /// Verbatim `$GROK_HOME` override — used by the manager's hook-toggle + /// path (which never evaluates plugin state) and by tests. The `.agents` + /// and `.claude` homes bind the REAL ones, like upstream — `$GROK_HOME` + /// moves neither — so don't scan skills or evaluate plugin enablement + /// through an adapter built this way in tests. + pub fn with_grok_home(grok_home: PathBuf) -> Self { + let home = dirs::home_dir().unwrap_or_default(); + Self { + grok_home, + agents_home: home.join(".agents"), + claude_home: home.join(".claude"), + } + } + + fn parse_json(path: &Path) -> Option { + let content = std::fs::read_to_string(path).ok()?; + serde_json::from_str(&content).ok() + } + + fn read_toml(path: &Path) -> Option { + std::fs::read_to_string(path) + .ok() + .and_then(|c| c.parse().ok()) + } + + fn disabled_mcp_names(&self) -> HashSet { + Self::read_toml(&self.mcp_config_path()) + .map(|doc| toml_string_list(&doc, "disabled_mcp_servers").into_iter().collect()) + .unwrap_or_default() + } + + /// The effective enable/disable sets for one scanned plugin directory, + /// resolved once for the whole directory rather than per plugin. + /// (`read_plugins` walks two global dirs, so a full global scan resolves + /// them twice.) Mirrors upstream + /// resolve_effective_plugins_config in order: + /// - user `[plugins]` lists — `enabled` is user-tier ONLY: upstream + /// merges nothing but `disabled` from a project config, so a repo + /// cannot self-enable its own plugins (deliberate: a malicious repo + /// must not bypass the project-plugin auto-disable); + /// - then the live Claude-compat merge (merge_claude_enabled_plugins, + /// which runs BEFORE the scope auto-disable, so Claude-enabled names + /// switch project plugins on too): unless `/import-claude` wrote + /// `[claude_compat] imported = true` into config.toml, names from + /// ~/.claude/settings.json enabledPlugins join the lists. A name + /// already in either native list is skipped — native config wins, so + /// a natively-enabled name is NOT killed by a Claude `false`. + /// (Upstream merges enabled then disabled with per-list skips; with + /// one state per name that collapses to the single guard below.) + fn effective_plugin_lists( + &self, + extra_config: Option<&Path>, + ) -> (HashSet, HashSet) { + let user = Self::read_toml(&self.plugin_config_path()); + let mut enabled = plugin_list_in(user.as_ref(), "enabled"); + let mut disabled = plugin_list_in(user.as_ref(), "disabled"); + if let Some(extra) = extra_config { + disabled.extend(plugin_list_in(Self::read_toml(extra).as_ref(), "disabled")); + } + let import_marked = user + .as_ref() + .and_then(|doc| doc.get("claude_compat")) + .and_then(|v| v.get("imported")) + .and_then(|v| v.as_bool()) + .unwrap_or(false); + if !import_marked { + for (name, on) in claude_plugin_states(&self.claude_home.join("settings.json")) { + if enabled.contains(&name) || disabled.contains(&name) { + continue; + } + if on { + enabled.insert(name); + } else { + disabled.insert(name); + } + } + } + (enabled, disabled) + } + + fn parse_mcp_entry( + name: &str, + val: &toml::Value, + disabled: &HashSet, + ) -> McpServerEntry { + let table = val.as_table(); + let canonical_name = table + .and_then(|t| t.get("_hk_name")) + .and_then(|v| v.as_str()) + .map(String::from) + .unwrap_or_else(|| name.to_string()); + // Upstream's StreamableHttp.url carries + // `#[serde(alias = "urlTemplate", alias = "url_template")]` — all + // three spellings are the same field (two at once is a duplicate- + // field error that makes Grok drop the entry, so no precedence + // exists to mirror). `urlTemplate` values are `{{var}}` templates + // resolved from mcp_preferences.json, which we cannot expand — + // shown verbatim. + let url = table.and_then(|t| { + ["url", "urlTemplate", "url_template"] + .iter() + .find_map(|k| t.get(*k)) + .and_then(|v| v.as_str()) + .map(String::from) + }); + // Upstream's untagged transport enum tries Stdio first, so a table + // with BOTH `command` and a url runs as stdio — command wins. + // + // For remote entries, mirror to_acp_mcp_server (config-types + // mcp.rs:468-481): `type = "sse"` (ASCII case-insensitive) OR a + // byte-exact "/sse" url suffix means SSE, joined by `||` — the + // suffix wins even over an explicit `type = "http"`. "/sse/", + // "/sse?x=1" and "/SSE" stay HTTP; do not "fix" any of this, it + // must match Grok byte-for-byte. + let has_command = table + .and_then(|t| t.get("command")) + .and_then(|v| v.as_str()) + .is_some(); + let transport = match (has_command, url.as_deref()) { + (false, Some(url_str)) => { + let ty = table.and_then(|t| t.get("type")).and_then(|v| v.as_str()); + if ty.is_some_and(|t| t.eq_ignore_ascii_case("sse")) || url_str.ends_with("/sse") { + McpTransport::Sse + } else { + McpTransport::Http + } + } + _ => McpTransport::Stdio, + }; + let native_enabled = table + .and_then(|t| t.get("enabled")) + .and_then(|v| v.as_bool()) + .unwrap_or(true); + McpServerEntry { + command: table + .and_then(|t| t.get("command")) + .and_then(|v| v.as_str()) + .unwrap_or("") + .into(), + args: table + .and_then(|t| t.get("args")) + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(), + env: toml_string_map(table, "env"), + transport, + url, + headers: toml_string_map(table, "headers"), + enabled: native_enabled + && !disabled.contains(name) + && !disabled.contains(&canonical_name), + name: canonical_name, + } + } + + fn hook_prefix_for(&self, path: &Path) -> &'static str { + // Only `$GROK_HOME/hooks` is global. A project living under a custom + // `$GROK_HOME` (e.g. `/work/repo` when `GROK_HOME=/work`) must stay + // `project/` — `starts_with($GROK_HOME)` would mislabel it. + if path.starts_with(self.grok_home.join("hooks")) { + "global/" + } else { + "project/" + } + } + + fn disabled_hooks_path(&self) -> PathBuf { + self.grok_home.join("disabled-hooks") + } + + /// Recompute Grok's real spec.name for a command hook so toggle can write + /// `$GROK_HOME/disabled-hooks` without extending `HookEntry`. + pub fn hook_spec_name_for( + &self, + source_path: &Path, + event: &str, + matcher: Option<&str>, + command: &str, + ) -> Option { + self.command_hook_specs(source_path) + .into_iter() + .find(|(_, hook)| { + hook.event == event + && hook.matcher.as_deref() == matcher + && hook.command == command + }) + .map(|(name, _)| name) + } + + fn command_hook_specs(&self, path: &Path) -> Vec<(String, HookEntry)> { + let Some(mut config) = Self::parse_json(path) else { + return vec![]; + }; + let Some(serde_json::Value::Object(hooks)) = + config.as_object_mut().and_then(|o| o.remove("hooks")) + else { + return vec![]; + }; + let prefix = self.hook_prefix_for(path); + let stem = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("unknown"); + let disabled = read_disabled_hook_names(&self.disabled_hooks_path()); + + // Bucket matcher groups by event VARIANT — upstream keys its map by + // enum variant (xai-grok-hooks/src/config.rs:31), so SubagentStop and + // SubagentEnd carry independent [i] counters even though both render + // the `subagent_stop` display token. + let mut by_variant: HashMap<&'static str, Vec> = HashMap::new(); + for (key, val) in hooks { + let Some(meta) = grok_event_by_alias(&key) else { + continue; + }; + if let serde_json::Value::Array(groups) = val + && !groups.is_empty() + { + by_variant.entry(meta.canonical).or_default().extend(groups); + } + } + + let mut out: Vec<(String, HookEntry)> = Vec::new(); + // Upstream iterates variants in enum-declaration order + // (`events.sort_by_key` on the derived Ord); GROK_EVENTS mirrors + // that table order, which also makes the dedup below keep the + // SubagentStop copy over the SubagentEnd one, like upstream. + for event in GROK_EVENTS { + let Some(groups) = by_variant.get(event.canonical) else { + continue; + }; + for (group_idx, group) in groups.iter().enumerate() { + let matcher = group + .get("matcher") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(String::from); + // `MatcherGroup::hooks` is a plain Vec: a group without a + // `hooks` array fails upstream's deserialize, and + // GroupErrorPolicy::Fail drops the whole file's hooks. + let Some(handlers) = group.get("hooks").and_then(|v| v.as_array()) else { + return vec![]; + }; + // `hook_idx` enumerates every handler, valid or not: upstream + // pushes a per-handler error and moves on, so a rejected + // handler still consumes its index and its siblings keep the + // spec names Grok generates for them. + for (hook_idx, handler) in handlers.iter().enumerate() { + let command = match handler_command(handler) { + Err(()) => return vec![], + Ok(None) => continue, + Ok(Some(command)) => command, + }; + let spec = format!( + "{prefix}{stem}:{}[{group_idx}].hooks[{hook_idx}]", + event.display + ); + let enabled = !disabled.contains(&spec); + out.push(( + spec, + HookEntry { + event: event.canonical.to_string(), + matcher: matcher.clone(), + command, + enabled, + }, + )); + } + } + } + // Upstream dedups specs on (canonical event, raw command, matcher), + // first wins (discovery.rs:218-231), with SubagentEnd folding into + // SubagentStop — a hook hedged under both spellings runs once, so + // it must be one row here too. Cross-file dedup (same content in + // two files → upstream keeps only the first source) is not modeled. + let mut seen: HashSet<(String, String, Option)> = HashSet::new(); + out.retain(|(_, hook)| { + seen.insert(( + dedup_event(&hook.event).to_string(), + hook.command.clone(), + hook.matcher.clone(), + )) + }); + out + } + + fn scan_plugin_dir( + &self, + dir: &Path, + scope: &str, + source: &str, + extra_config: Option<&Path>, + ) -> Vec { + let Ok(entries) = std::fs::read_dir(dir) else { + return vec![]; + }; + let (enabled_set, disabled_set) = self.effective_plugin_lists(extra_config); + // Matching by id OR name mirrors registry.rs is_disabled / + // explicitly_enabled; must-be-listed-to-be-on encodes upstream's + // default-disabled for user and project scope alike. + let listed = |set: &HashSet, id: &str, name: &str| { + set.contains(id) || set.contains(name) + }; + let mut plugins = Vec::new(); + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + let Some(name) = grok_plugin_identity(&path) else { + continue; + }; + let id = grok_plugin_id(scope, &path, &name); + let enabled = listed(&enabled_set, &id, &name) && !listed(&disabled_set, &id, &name); + plugins.push(PluginEntry { + name, + source: source.to_string(), + enabled, + path: Some(path), + source_url: None, + uri: Some(id), + installed_at: None, + updated_at: None, + base_layers: vec![], + pack: None, + }); + } + plugins + } +} + +impl AgentAdapter for GrokAdapter { + fn name(&self) -> &str { + "grok" + } + + fn base_dir(&self) -> PathBuf { + self.grok_home.clone() + } + + fn detect(&self) -> bool { + self.grok_home.exists() + } + + fn skill_dirs(&self) -> Vec { + // `.grok` and `.agents` are the two config dirs Grok always reads + // skills from — `CompatConfig::skill_config_dirs` hard-codes both and + // gates only `.claude`/`.cursor` behind their compat cells, and the + // global pass adds `~/.agents` unconditionally beside grok_home + // (compat.rs skill_config_dirs, prompt/skills.rs). The vendor dirs + // stay with the claude/cursor adapters. Own dir first: it is the + // install target via `skill_dir_for`. + vec![ + self.grok_home.join("skills"), + self.agents_home.join("skills"), + ] + } + + fn expand_skill_roots(&self, root: &Path) -> Vec { + // Grok discovers skills RECURSIVELY under each skills root + // (walk_for_skill_md, depth cap 5), so nested layouts like + // skills/team/infra/SKILL.md are real skills. Surface them by + // returning every nested parent dir for the flat scanner. + let mut roots = vec![root.to_path_buf()]; + collect_nested_skill_parents(root, 1, &mut roots); + roots + } + + fn standalone_md_skills(&self) -> bool { + // `walk_for_skill_md` filters `read_dir` to directories before it ever + // looks at a filename, and the only name it accepts is `SKILL.md` + // inside one (skills/discovery.rs:127-146). A bare `notes.md` under a + // skills root is invisible to Grok — the one place a loose `.md` + // becomes a Grok entity is `commands/`, a different root that HK + // already lists as a config file. Verified against grok 1.0.5. + false + } + + fn mcp_config_path(&self) -> PathBuf { + self.grok_home.join("config.toml") + } + + fn hook_config_path(&self) -> PathBuf { + self.grok_home.join("hooks").join("harnesskit.json") + } + + fn plugin_dirs(&self) -> Vec { + vec![ + self.grok_home.join("plugins"), + self.grok_home.join("installed-plugins"), + ] + } + + fn plugin_config_path(&self) -> PathBuf { + self.grok_home.join("config.toml") + } + + fn mcp_format(&self) -> McpFormat { + McpFormat::GrokToml + } + + fn remote_mcp_schema(&self) -> RemoteMcpSchema { + RemoteMcpSchema::GrokToml + } + + fn supports_native_mcp_toggle(&self) -> bool { + true + } + + fn hook_format(&self) -> HookFormat { + HookFormat::ClaudeLike + } + + fn translate_hook_event(&self, event: &str) -> Option { + super::hook_events::to_grok(event) + } + + fn hook_config_paths_for(&self, scope: &ConfigScope) -> Vec { + let dir = match scope { + ConfigScope::Global => self.grok_home.join("hooks"), + ConfigScope::Project { path, .. } => Path::new(path).join(".grok").join("hooks"), + }; + // Upstream's is_direct_hook_json_name skips dotfiles. + files_with_ext(&dir, "json") + .filter(|p| { + p.is_file() + && p.file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| !n.starts_with('.')) + }) + .collect() + } + + fn read_mcp_servers(&self) -> Vec { + self.read_mcp_servers_from(&self.mcp_config_path()) + } + + fn read_mcp_servers_from(&self, path: &Path) -> Vec { + let Some(doc) = Self::read_toml(path) else { + return vec![]; + }; + let Some(servers) = doc.get("mcp_servers").and_then(|v| v.as_table()) else { + return vec![]; + }; + // Personal disable lives on the user file and applies to project + // entries too — Grok's `disabled_mcp_servers` is user-tier. + let disabled = self.disabled_mcp_names(); + servers + .iter() + .map(|(name, val)| Self::parse_mcp_entry(name, val, &disabled)) + .collect() + } + + fn read_hooks(&self) -> Vec { + self.hook_config_paths_for(&ConfigScope::Global) + .into_iter() + .flat_map(|path| self.read_hooks_from(&path)) + .collect() + } + + fn read_hooks_from(&self, path: &Path) -> Vec { + self.command_hook_specs(path) + .into_iter() + .map(|(_, hook)| hook) + .collect() + } + + fn read_plugins(&self) -> Vec { + // Both dirs are user scope; the source label distinguishes a + // hand-placed plugin from a marketplace install. + self.plugin_dirs() + .iter() + .zip(["user", "installed"]) + .flat_map(|(dir, source)| self.scan_plugin_dir(dir, "user", source, None)) + .collect() + } + + fn read_plugins_from(&self, dir: &Path) -> Vec { + let extra = project_config_beside_plugins_dir(dir); + self.scan_plugin_dir(dir, "project", "project", extra.as_deref()) + } + + fn global_rules_files(&self) -> Vec { + let mut files = vec![self.grok_home.join("AGENTS.md")]; + files.extend(files_with_ext(&self.grok_home.join("rules"), "md")); + files + } + + fn global_settings_files(&self) -> Vec { + // User-editable config only. Deliberately excluded: + // managed_config.toml + requirements.toml (server-synced, atomically + // overwritten per fetch and deleted on logout — surfacing them + // invites edits that silently vanish), /etc/grok/* (machine-admin), + // and state/cache/secret files at the root (auth.json, + // mcp_credentials.json, mcp_preferences.json, trusted_folders.toml, + // campaigns_state.json, managed_config_cache.json, *.sig.json). + vec![ + self.grok_home.join("config.toml"), + self.grok_home.join("pager.toml"), + self.grok_home.join("sandbox.toml"), + self.grok_home.join("lsp.json"), + ] + } + + fn global_subagent_files(&self) -> Vec { + // agents/*.md plus personas/*.toml and roles/*.toml — SubagentPersona + // and SubagentRole definitions (config/mod.rs discover_personas_in_dir + // / discover_roles_in_dir), user-editable one-per-file TOML. + // bundled/{agents,personas,roles} is the GCS-synced builtin bundle, + // excluded as vendor content rather than user config. (Hand edits + // there do survive — the bundle is checksum-tracked and never + // overwrites a modified file — but a project/user file still cannot + // shadow a compiled-in name, so these are not editable config.) + files_with_ext(&self.grok_home.join("agents"), "md") + .chain(files_with_ext(&self.grok_home.join("personas"), "toml")) + .chain(files_with_ext(&self.grok_home.join("roles"), "toml")) + .collect() + } + + fn global_memory_files(&self) -> Vec { + // The global MEMORY.md plus each subagent's MEMORY.md. Grok never + // reads flat non-MEMORY *.md at the memory/ top level, and the + // {slug}-{hash8}/ workspace subdirs hold session transcripts and + // index.sqlite — state, not user-editable memory. + let mut files = vec![self.grok_home.join("memory").join("MEMORY.md")]; + if let Ok(entries) = std::fs::read_dir(self.grok_home.join("agent-memory")) { + for entry in entries.flatten() { + let memory_md = entry.path().join("MEMORY.md"); + if memory_md.is_file() { + files.push(memory_md); + } + } + } + files + } + + fn global_workflow_files(&self) -> Vec { + // commands/*.md (slash commands) plus workflows/*.rhai (saved Rhai + // workflows, session/workflow/registry.rs). Workflow RUN state lives + // under the session dir, not here. bundled/workflows is the + // GCS-synced builtin bundle — excluded. + files_with_ext(&self.grok_home.join("commands"), "md") + .chain(files_with_ext(&self.grok_home.join("workflows"), "rhai")) + .collect() + } + + fn project_markers(&self) -> Vec { + vec![ProjectMarker::Dir(".grok")] + } + + fn project_skill_dirs(&self) -> Vec { + vec![".grok/skills".into()] + } + + fn project_skill_read_dirs(&self) -> Vec { + // Grok always reads `.agents/skills`; write stays on `.grok/skills` + // so Codex/Gemini keep ownership of the shared alias. + vec![".agents/skills".into()] + } + + fn project_mcp_config_relpath(&self) -> Option { + Some(".grok/config.toml".into()) + } + + fn project_hook_config_relpath(&self) -> Option { + Some(".grok/hooks/harnesskit.json".into()) + } + + fn project_plugin_dirs(&self) -> Vec { + vec![".grok/plugins".into()] + } + + fn project_rules_patterns(&self) -> Vec { + vec![ + "AGENTS.md".into(), + "Agents.md".into(), + "AGENT.md".into(), + ".grok/rules/*.md".into(), + ] + } + + fn project_memory_patterns(&self) -> Vec { + // Grok has no /.grok/memory — project memory is per-subagent + // (MemoryScope in xai-grok-agent config.rs). agent-memory-local is + // the personal/uncommitted variant. Only MEMORY.md is the prompt + // contract; sessions/*.md and index.sqlite are state. All-glob on + // purpose: no unique concrete pattern means no Kit memory write + // target, since HK cannot invent a subagent name. + vec![ + ".grok/agent-memory/*/MEMORY.md".into(), + ".grok/agent-memory-local/*/MEMORY.md".into(), + ] + } + + fn project_settings_patterns(&self) -> Vec { + // Project-scope counterparts of the global list: sandbox profiles + // (profiles.rs also reads .grok/sandbox.toml) and LSP config. + vec![ + ".grok/config.toml".into(), + ".grok/sandbox.toml".into(), + ".grok/lsp.json".into(), + ] + } + + fn project_subagent_patterns(&self) -> Vec { + vec![ + ".grok/agents/*.md".into(), + ".grok/personas/*.toml".into(), + ".grok/roles/*.toml".into(), + ] + } + + fn project_workflow_patterns(&self) -> Vec { + vec![ + ".grok/commands/*.md".into(), + ".grok/workflows/*.rhai".into(), + ] + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + fn write(path: &Path, content: &str) { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(path, content).unwrap(); + } + + #[test] + fn resolve_grok_home_env_wins_verbatim() { + let resolved = resolve_grok_home( + Some(OsString::from("/custom/grok")), + Some(Path::new("/home/u")), + ); + assert_eq!(resolved, Some(PathBuf::from("/custom/grok"))); + } + + #[test] + fn resolve_grok_home_empty_env_falls_through() { + let resolved = resolve_grok_home(Some(OsString::new()), Some(Path::new("/home/u"))); + assert_eq!(resolved, Some(PathBuf::from("/home/u/.grok"))); + } + + #[test] + fn with_home_does_not_follow_process_grok_home() { + let tmp = TempDir::new().unwrap(); + let adapter = GrokAdapter::with_home(tmp.path().to_path_buf()); + assert_eq!(adapter.base_dir(), tmp.path().join(".grok")); + assert!(!adapter.detect()); + fs::create_dir_all(adapter.base_dir()).unwrap(); + assert!(adapter.detect()); + } + + #[test] + fn with_grok_home_uses_override_root() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path().join("override"); + fs::create_dir_all(&root).unwrap(); + let adapter = GrokAdapter::with_grok_home(root.clone()); + assert_eq!(adapter.base_dir(), root); + assert!(adapter.detect()); + assert_eq!(adapter.mcp_config_path(), root.join("config.toml")); + assert_eq!( + adapter.hook_config_path(), + root.join("hooks/harnesskit.json") + ); + } + + #[test] + fn skill_and_project_paths() { + let adapter = GrokAdapter::with_home(PathBuf::from("/tmp/hk-grok")); + assert_eq!( + adapter.skill_dirs(), + vec![ + PathBuf::from("/tmp/hk-grok/.grok/skills"), + PathBuf::from("/tmp/hk-grok/.agents/skills"), + ], + "Grok always reads both config dirs; own dir first is the install target" + ); + assert_eq!(adapter.project_skill_dirs(), vec![".grok/skills".to_string()]); + assert_eq!( + adapter.project_skill_read_dirs(), + vec![".agents/skills".to_string()] + ); + assert_eq!( + adapter.project_mcp_config_relpath().as_deref(), + Some(".grok/config.toml") + ); + assert_eq!( + adapter.project_hook_config_relpath().as_deref(), + Some(".grok/hooks/harnesskit.json") + ); + assert_eq!(adapter.project_plugin_dirs(), vec![".grok/plugins".to_string()]); + } + + #[test] + fn reads_stdio_http_and_sse_mcp() { + let tmp = TempDir::new().unwrap(); + let adapter = GrokAdapter::with_home(tmp.path().to_path_buf()); + write( + &adapter.mcp_config_path(), + r#" +[mcp_servers.local] +command = "npx" +args = ["-y", "srv"] +cwd = "/tmp/work" + +[mcp_servers.http] +url = "https://example.com/mcp" +headers = { Authorization = "Bearer t" } +startup_timeout_sec = 30 + +[mcp_servers.sse] +url = "https://example.com/sse" +type = "sse" +enabled = false +"#, + ); + let servers = adapter.read_mcp_servers(); + assert_eq!(servers.len(), 3); + let local = servers.iter().find(|s| s.name == "local").unwrap(); + assert_eq!(local.transport, McpTransport::Stdio); + assert!(local.enabled); + let http = servers.iter().find(|s| s.name == "http").unwrap(); + assert_eq!(http.transport, McpTransport::Http); + assert_eq!(http.headers["Authorization"], "Bearer t"); + let sse = servers.iter().find(|s| s.name == "sse").unwrap(); + assert_eq!(sse.transport, McpTransport::Sse); + assert!(!sse.enabled); + } + + #[test] + fn mcp_disabled_list_applies_to_user_and_project() { + let tmp = TempDir::new().unwrap(); + let adapter = GrokAdapter::with_home(tmp.path().to_path_buf()); + write( + &adapter.mcp_config_path(), + r#" +disabled_mcp_servers = ["shared"] + +[mcp_servers.shared] +command = "echo" +"#, + ); + let project = tmp.path().join("proj/.grok/config.toml"); + write( + &project, + r#" +[mcp_servers.shared] +command = "echo" +enabled = true +"#, + ); + assert!(!adapter.read_mcp_servers()[0].enabled); + assert!(!adapter.read_mcp_servers_from(&project)[0].enabled); + } + + #[test] + fn hooks_scan_command_only_and_compute_spec_name() { + let tmp = TempDir::new().unwrap(); + let adapter = GrokAdapter::with_home(tmp.path().to_path_buf()); + let hook_file = adapter.base_dir().join("hooks/session-start.json"); + write( + &hook_file, + r#"{ + "hooks": { + "PreToolUse": [{ + "matcher": "Bash", + "hooks": [ + {"type": "http", "url": "https://example.com/hook"}, + {"type": "command", "command": "echo hi"} + ] + }] + } + }"#, + ); + write( + &adapter.disabled_hooks_path(), + "# comment\nglobal/session-start:pre_tool_use[0].hooks[1]\n", + ); + let hooks = adapter.read_hooks_from(&hook_file); + assert_eq!(hooks.len(), 1, "HTTP handlers must be skipped"); + assert_eq!(hooks[0].event, "PreToolUse"); + assert_eq!(hooks[0].matcher.as_deref(), Some("Bash")); + assert_eq!(hooks[0].command, "echo hi"); + assert!(!hooks[0].enabled); + assert_eq!( + adapter + .hook_spec_name_for(&hook_file, "PreToolUse", Some("Bash"), "echo hi") + .as_deref(), + Some("global/session-start:pre_tool_use[0].hooks[1]") + ); + } + + #[test] + fn hook_prefix_does_not_treat_nested_project_as_global() { + let tmp = TempDir::new().unwrap(); + let grok_home = tmp.path().join("work"); + let adapter = GrokAdapter::with_grok_home(grok_home.clone()); + let global = grok_home.join("hooks/session-start.json"); + write( + &global, + r#"{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"echo global"}]}]}}"#, + ); + let nested = grok_home.join("repo/.grok/hooks/safety.json"); + write( + &nested, + r#"{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"echo project"}]}]}}"#, + ); + assert_eq!( + adapter + .hook_spec_name_for(&global, "Stop", None, "echo global") + .as_deref(), + Some("global/session-start:stop[0].hooks[0]") + ); + assert_eq!( + adapter + .hook_spec_name_for(&nested, "Stop", None, "echo project") + .as_deref(), + Some("project/safety:stop[0].hooks[0]") + ); + } + + #[test] + fn subagent_variants_keep_independent_indices() { + // Upstream keys spec indices by enum variant (config.rs:31), and both + // variants declare display "subagent_stop" in the hook_events! table, + // so each starts at [0] — the names collide by design upstream, where + // one disabled-hooks line then covers both. + let tmp = TempDir::new().unwrap(); + let adapter = GrokAdapter::with_home(tmp.path().to_path_buf()); + let hook_file = adapter.base_dir().join("hooks/agents.json"); + write( + &hook_file, + r#"{ + "hooks": { + "SubagentStop": [{"hooks":[{"type":"command","command":"echo stop"}]}], + "SubagentEnd": [{"hooks":[{"type":"command","command":"echo end"}]}] + } + }"#, + ); + let hooks = adapter.read_hooks_from(&hook_file); + assert_eq!(hooks.len(), 2, "different commands are distinct hooks"); + assert_eq!( + adapter + .hook_spec_name_for(&hook_file, "SubagentStop", None, "echo stop") + .as_deref(), + Some("global/agents:subagent_stop[0].hooks[0]"), + "each variant counts from [0], like upstream" + ); + assert_eq!( + adapter + .hook_spec_name_for(&hook_file, "SubagentEnd", None, "echo end") + .as_deref(), + Some("global/agents:subagent_stop[0].hooks[0]") + ); + } + + #[test] + fn identical_hook_under_both_subagent_spellings_dedups_to_one_row() { + // Upstream dedups on (canonical event, raw command, matcher) with + // SubagentEnd folding into SubagentStop (discovery.rs test + // `deduplicates_hooks_across_alias_spellings`), so a hook hedged + // under both spellings runs once and must be one row here. + let tmp = TempDir::new().unwrap(); + let adapter = GrokAdapter::with_home(tmp.path().to_path_buf()); + let hook_file = adapter.base_dir().join("hooks/agents.json"); + write( + &hook_file, + r#"{ + "hooks": { + "SubagentStop": [{"hooks":[{"type":"command","command":"notify.sh"}]}], + "SubagentEnd": [{"hooks":[{"type":"command","command":"notify.sh"}]}] + } + }"#, + ); + let hooks = adapter.read_hooks_from(&hook_file); + assert_eq!(hooks.len(), 1, "alias hedge must not double-register"); + assert_eq!(hooks[0].event, "SubagentStop", "first (table-order) copy wins"); + assert_eq!( + adapter + .hook_spec_name_for(&hook_file, "SubagentStop", None, "notify.sh") + .as_deref(), + Some("global/agents:subagent_stop[0].hooks[0]") + ); + } + + #[test] + fn handler_errors_are_per_handler_but_type_errors_take_the_file() { + let tmp = TempDir::new().unwrap(); + let adapter = GrokAdapter::with_home(tmp.path().to_path_buf()); + + // An unknown `type` deserializes fine and errors inside upstream's + // loop (HookError::UnsupportedHandlerType), as does a `command` + // handler with no `command` (HookError::InvalidConfig) — the file's + // other hooks still register, and the rejected handlers keep their + // index so the survivor's spec name is the one Grok generates. + let per_handler = adapter.base_dir().join("hooks/per-handler.json"); + write( + &per_handler, + r#"{ + "hooks": { + "Stop": [{"hooks":[ + {"type":"webhook","command":"nope.sh"}, + {"type":"command"}, + {"type":"command","command":"ok.sh"} + ]}] + } + }"#, + ); + let commands: Vec = adapter + .read_hooks_from(&per_handler) + .into_iter() + .map(|h| h.command) + .collect(); + assert_eq!(commands, vec!["ok.sh"]); + assert_eq!( + adapter.hook_spec_name_for(&per_handler, "Stop", None, "ok.sh"), + Some("global/per-handler:stop[0].hooks[2]".to_string()), + "a rejected handler still consumes its index" + ); + + // A missing `type` is a serde error on RawHandler's plain String + // field, and GroupErrorPolicy::Fail then yields zero hooks for the + // whole file — listing the valid sibling would be a phantom row. + let file_level = adapter.base_dir().join("hooks/file-level.json"); + write( + &file_level, + r#"{ + "hooks": { + "Stop": [{"hooks":[ + {"type":"command","command":"ok.sh"}, + {"command":"no-type.sh"} + ]}] + } + }"#, + ); + assert!(adapter.read_hooks_from(&file_level).is_empty()); + } + + #[test] + fn cursor_style_alias_keys_merge_into_one_event() { + // Cursor's per-operation names map onto PreToolUse (docs 10-hooks.md + // "Cursor Hook Compatibility"), so both land in one index space — + // upstream merges them in HashMap order, which is why the index for + // such a file is best-effort (see the module header). + let tmp = TempDir::new().unwrap(); + let adapter = GrokAdapter::with_home(tmp.path().to_path_buf()); + let hook_file = adapter.base_dir().join("hooks/cursor-import.json"); + write( + &hook_file, + r#"{ + "hooks": { + "beforeShellExecution": [{"hooks":[{"type":"command","command":"guard.sh"}]}], + "beforeReadFile": [{"hooks":[{"type":"command","command":"scan.sh"}]}], + "Stop": [{"hooks":[{"type":"command","command":"done.sh"}]}] + } + }"#, + ); + let hooks = adapter.read_hooks_from(&hook_file); + assert_eq!(hooks.len(), 3); + assert_eq!( + hooks.iter().filter(|h| h.event == "PreToolUse").count(), + 2, + "both Cursor spellings resolve to PreToolUse" + ); + assert_eq!( + adapter + .hook_spec_name_for(&hook_file, "Stop", None, "done.sh") + .as_deref(), + Some("global/cursor-import:stop[0].hooks[0]") + ); + } + + #[test] + fn project_hook_spec_uses_project_prefix() { + let tmp = TempDir::new().unwrap(); + let adapter = GrokAdapter::with_home(tmp.path().to_path_buf()); + let hook_file = tmp.path().join("repo/.grok/hooks/safety.json"); + write( + &hook_file, + r#"{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"notify"}]}]}}"#, + ); + assert_eq!( + adapter + .hook_spec_name_for(&hook_file, "Stop", None, "notify") + .as_deref(), + Some("project/safety:stop[0].hooks[0]") + ); + } + + #[test] + fn plugin_stable_id_and_default_disabled() { + let tmp = TempDir::new().unwrap(); + let adapter = GrokAdapter::with_home(tmp.path().to_path_buf()); + let plugin = adapter.base_dir().join("plugins/my-tool"); + write(&plugin.join("plugin.json"), r#"{"name":"my-tool"}"#); + let plugins = adapter.read_plugins(); + assert_eq!(plugins.len(), 1); + assert!(!plugins[0].enabled, "user plugins default to disabled"); + let id = plugins[0].uri.as_deref().unwrap(); + assert!(id.starts_with("user/")); + assert!(id.ends_with("/my-tool")); + assert_eq!(id, grok_plugin_id("user", &plugin, "my-tool")); + } + + #[test] + fn plugin_enabled_list_and_disabled_wins() { + let tmp = TempDir::new().unwrap(); + let adapter = GrokAdapter::with_home(tmp.path().to_path_buf()); + let plugin = adapter.base_dir().join("plugins/my-tool"); + write(&plugin.join("skills/.keep"), ""); + let id = grok_plugin_id("user", &plugin, "my-tool"); + write( + &adapter.plugin_config_path(), + &format!( + "[plugins]\nenabled = [\"{id}\"]\ndisabled = [\"{id}\"]\n" + ), + ); + let plugins = adapter.read_plugins(); + assert!(!plugins[0].enabled, "disabled list takes precedence"); + + write( + &adapter.plugin_config_path(), + &format!("[plugins]\nenabled = [\"{id}\"]\n"), + ); + let plugins = adapter.read_plugins(); + assert!(plugins[0].enabled); + } + + #[test] + fn claude_enabled_plugins_merge_mirrors_upstream() { + let tmp = TempDir::new().unwrap(); + let adapter = GrokAdapter::with_home(tmp.path().to_path_buf()); + let plugin = adapter.base_dir().join("plugins/my-tool"); + write(&plugin.join("plugin.json"), r#"{"name":"my-tool"}"#); + + // Enabled only via Claude's settings (name@marketplace, suffix stripped). + write( + &tmp.path().join(".claude/settings.json"), + r#"{"enabledPlugins":{"my-tool@mk":true}}"#, + ); + assert!(adapter.read_plugins()[0].enabled, "Claude-enabled name counts"); + + // false wins per name within the file. + write( + &tmp.path().join(".claude/settings.json"), + r#"{"enabledPlugins":{"my-tool@a":true,"my-tool@b":false}}"#, + ); + assert!(!adapter.read_plugins()[0].enabled); + + // The /import-claude marker turns the live merge off entirely. + write( + &tmp.path().join(".claude/settings.json"), + r#"{"enabledPlugins":{"my-tool@mk":true}}"#, + ); + write( + &adapter.plugin_config_path(), + "[claude_compat]\nimported = true\n", + ); + assert!(!adapter.read_plugins()[0].enabled, "marker disables the merge"); + } + + #[test] + fn native_enabled_name_survives_claude_false() { + // Upstream's skip rule: a name already in a native list is never + // pushed to the opposite list, so native config wins. + let tmp = TempDir::new().unwrap(); + let adapter = GrokAdapter::with_home(tmp.path().to_path_buf()); + let plugin = adapter.base_dir().join("plugins/my-tool"); + write(&plugin.join("plugin.json"), r#"{"name":"my-tool"}"#); + write( + &adapter.plugin_config_path(), + "[plugins]\nenabled = [\"my-tool\"]\n", + ); + write( + &tmp.path().join(".claude/settings.json"), + r#"{"enabledPlugins":{"my-tool@mk":false}}"#, + ); + assert!(adapter.read_plugins()[0].enabled); + } + + #[test] + fn project_enabled_list_cannot_self_enable() { + // Upstream merges only `disabled` from project configs — a repo + // listing its own plugin in `[plugins].enabled` stays disabled. + let tmp = TempDir::new().unwrap(); + let adapter = GrokAdapter::with_home(tmp.path().to_path_buf()); + let plugin = tmp.path().join("repo/.grok/plugins/team-tool"); + write(&plugin.join("plugin.json"), r#"{"name":"team-tool"}"#); + let id = grok_plugin_id("project", &plugin, "team-tool"); + write( + &tmp.path().join("repo/.grok/config.toml"), + &format!("[plugins]\nenabled = [\"{id}\"]\n"), + ); + let plugins = adapter.read_plugins_from(plugin.parent().unwrap()); + assert!(!plugins[0].enabled, "project enabled list must be ignored"); + } + + #[test] + fn project_plugin_is_discovered_with_project_id() { + let tmp = TempDir::new().unwrap(); + let adapter = GrokAdapter::with_home(tmp.path().to_path_buf()); + let plugin = tmp.path().join("repo/.grok/plugins/team-tool"); + write(&plugin.join("plugin.json"), r#"{"name":"team-tool"}"#); + let plugins = adapter.read_plugins_from(plugin.parent().unwrap()); + assert_eq!(plugins.len(), 1); + assert!(!plugins[0].enabled, "project plugins default to disabled"); + let id = plugins[0].uri.as_deref().unwrap(); + assert!(id.starts_with("project/")); + assert!(id.ends_with("/team-tool")); + assert!(adapter.read_plugins().is_empty()); + } + + #[test] + fn settings_do_not_include_auth_files() { + let adapter = GrokAdapter::with_home(PathBuf::from("/tmp/hk-grok")); + let settings = adapter.global_settings_files(); + assert!(settings.iter().all(|p| { + let name = p.file_name().unwrap(); + name != "auth.json" && name != "mcp_credentials.json" + })); + assert!(settings.iter().any(|p| p.ends_with("config.toml"))); + assert!(settings.iter().any(|p| p.ends_with("pager.toml"))); + assert!(settings.iter().any(|p| p.ends_with("sandbox.toml"))); + assert!(settings.iter().any(|p| p.ends_with("lsp.json"))); + assert!( + !settings + .iter() + .any(|p| p.ends_with("managed_config.toml") || p.ends_with("requirements.toml")), + "server-synced artifacts are overwritten per fetch and must not be listed" + ); + } + + #[test] + fn mcp_sse_inferred_from_url_suffix_byte_exact() { + let tmp = TempDir::new().unwrap(); + let adapter = GrokAdapter::with_home(tmp.path().to_path_buf()); + write( + &adapter.mcp_config_path(), + r#" +[mcp_servers.suffix] +url = "https://example.com/mcp/sse" +type = "http" + +[mcp_servers.upper] +url = "https://example.com/SSE" + +[mcp_servers.slash] +url = "https://example.com/sse/" + +[mcp_servers.ty] +url = "https://example.com/x" +type = "SSE" +"#, + ); + let servers = adapter.read_mcp_servers(); + let transport = + |name: &str| servers.iter().find(|s| s.name == name).unwrap().transport; + assert_eq!(transport("suffix"), McpTransport::Sse, "suffix wins over type=http"); + assert_eq!(transport("upper"), McpTransport::Http, "suffix is case-sensitive"); + assert_eq!(transport("slash"), McpTransport::Http, "trailing slash defeats it"); + assert_eq!(transport("ty"), McpTransport::Sse, "type match ignores case"); + } + + #[test] + fn expand_skill_roots_walks_nested_and_caps_depth() { + let tmp = TempDir::new().unwrap(); + let adapter = GrokAdapter::with_home(tmp.path().to_path_buf()); + let root = adapter.base_dir().join("skills"); + write(&root.join("flat/SKILL.md"), "flat"); + write(&root.join("team/infra/SKILL.md"), "nested"); + // A skill nested INSIDE another skill — both are real upstream. + write(&root.join("team/infra/child/SKILL.md"), "inner"); + // Exactly at the cap: parent at segment 5 still qualifies… + write(&root.join("a/b/c/d/e/at-cap/SKILL.md"), "edge"); + // …one deeper (parent at segment 6) does not. + write(&root.join("a/b/c/d/e/f/too-deep/SKILL.md"), "deep"); + + let roots = adapter.expand_skill_roots(&root); + assert_eq!(roots[0], root, "canonical root stays first"); + assert!(roots.contains(&root.join("team")), "nested parent found"); + assert!( + roots.contains(&root.join("team/infra")), + "skill dirs can hold nested skills" + ); + assert!( + roots.contains(&root.join("a/b/c/d/e")), + "a parent at the depth cap (segment 5) still counts" + ); + assert!( + !roots.iter().any(|r| r.ends_with("f")), + "depth cap mirrors upstream MAX_SKILL_WALK_DEPTH" + ); + } + + #[test] + fn mcp_url_aliases_and_command_precedence() { + let tmp = TempDir::new().unwrap(); + let adapter = GrokAdapter::with_home(tmp.path().to_path_buf()); + write( + &adapter.mcp_config_path(), + r#" +[mcp_servers.tpl] +urlTemplate = "https://example.com/mcp" + +[mcp_servers.tpl-sse] +url_template = "https://example.com/sse" + +[mcp_servers.both] +command = "echo" +url = "https://example.com/x" +"#, + ); + let servers = adapter.read_mcp_servers(); + let by = |name: &str| servers.iter().find(|s| s.name == name).unwrap(); + assert_eq!(by("tpl").transport, McpTransport::Http); + assert_eq!(by("tpl").url.as_deref(), Some("https://example.com/mcp")); + assert_eq!(by("tpl-sse").transport, McpTransport::Sse); + assert_eq!( + by("both").transport, + McpTransport::Stdio, + "untagged enum tries Stdio first — command wins over url" + ); + } + + #[test] + fn plugin_identity_mirrors_upstream_rules() { + let tmp = TempDir::new().unwrap(); + let adapter = GrokAdapter::with_home(tmp.path().to_path_buf()); + let plugins_dir = adapter.base_dir().join("plugins"); + + // Convention plugin via commands/ only, dirname sanitized like + // upstream (consecutive hyphens are NOT collapsed). + write(&plugins_dir.join("My__Tool/commands/run.md"), "cmd"); + // Convention plugin via .lsp.json only. + write(&plugins_dir.join("lsp-only/.lsp.json"), "{}"); + // Broken manifest rejects the dir outright — no dirname fallback. + write(&plugins_dir.join("broken/plugin.json"), "{not json"); + // Manifest with an invalid (uppercase) name is rejected, not sanitized. + write(&plugins_dir.join("badname/plugin.json"), r#"{"name":"BadName"}"#); + // Unsalvageable dirname is rejected even with components present. + write(&plugins_dir.join("---/skills/.keep"), ""); + + let mut names: Vec = + adapter.read_plugins().into_iter().map(|p| p.name).collect(); + names.sort(); + assert_eq!(names, vec!["lsp-only", "my--tool"]); + } + + #[test] + fn rules_subagents_memory_and_commands() { + let tmp = TempDir::new().unwrap(); + let adapter = GrokAdapter::with_home(tmp.path().to_path_buf()); + write(&adapter.base_dir().join("AGENTS.md"), "hi"); + write(&adapter.base_dir().join("rules/style.md"), "style"); + write(&adapter.base_dir().join("agents/reviewer.md"), "agent"); + write(&adapter.base_dir().join("memory/MEMORY.md"), "mem"); + write(&adapter.base_dir().join("memory/notes.md"), "note"); + write(&adapter.base_dir().join("agent-memory/reviewer/MEMORY.md"), "am"); + write(&adapter.base_dir().join("commands/ship.md"), "cmd"); + write(&adapter.base_dir().join("workflows/deploy.rhai"), "wf"); + write(&adapter.base_dir().join("personas/reviewer.toml"), "p"); + write(&adapter.base_dir().join("roles/ops.toml"), "r"); + write(&adapter.base_dir().join("bundled/workflows/sync.rhai"), "no"); + write(&adapter.base_dir().join("bundled/personas/sync.toml"), "no"); + + assert!(adapter + .global_rules_files() + .iter() + .any(|p| p.ends_with("AGENTS.md"))); + // agents/*.md + personas/*.toml + roles/*.toml; bundled/ excluded. + assert_eq!(adapter.global_subagent_files().len(), 3); + let memory = adapter.global_memory_files(); + assert!(memory.iter().any(|p| p.ends_with("memory/MEMORY.md"))); + assert!( + memory + .iter() + .any(|p| p.ends_with("agent-memory/reviewer/MEMORY.md")), + "subagent memory files are claimed" + ); + assert!( + !memory.iter().any(|p| p.ends_with("notes.md")), + "Grok never reads flat non-MEMORY *.md at the memory root" + ); + // commands/*.md + workflows/*.rhai; bundled/workflows excluded. + assert_eq!(adapter.global_workflow_files().len(), 2); + assert!(!adapter + .project_rules_patterns() + .iter() + .any(|p| p.contains("CLAUDE.md"))); + } + + #[test] + fn adapter_declarations() { + let adapter = GrokAdapter::with_home(PathBuf::from("/tmp/hk-grok")); + assert_eq!(adapter.name(), "grok"); + assert_eq!(adapter.mcp_format(), McpFormat::GrokToml); + assert_eq!(adapter.remote_mcp_schema(), RemoteMcpSchema::GrokToml); + assert!(adapter.supports_native_mcp_toggle()); + assert_eq!(adapter.hook_format(), HookFormat::ClaudeLike); + assert!(!adapter.needs_path_injection()); + assert!(adapter.supports_global_hook_install()); + } +} diff --git a/crates/hk-core/src/adapter/hook_events.rs b/crates/hk-core/src/adapter/hook_events.rs index 5cea1bc..26adf95 100644 --- a/crates/hk-core/src/adapter/hook_events.rs +++ b/crates/hk-core/src/adapter/hook_events.rs @@ -384,6 +384,76 @@ const KIRO_EVENTS: &[EventMapping] = &[ }, ]; +/// Grok Build: Claude PascalCase plus Grok-only events. Those extras have no +/// cross-agent equivalent (`canonical == agent`) so they passthrough to Grok +/// and translate to None elsewhere. +const GROK_EVENTS: &[EventMapping] = &[ + EventMapping { + canonical: "Stop", + agent: "Stop", + }, + EventMapping { + canonical: "PreToolUse", + agent: "PreToolUse", + }, + EventMapping { + canonical: "PostToolUse", + agent: "PostToolUse", + }, + EventMapping { + canonical: "PostToolUseFailure", + agent: "PostToolUseFailure", + }, + EventMapping { + canonical: "UserPromptSubmit", + agent: "UserPromptSubmit", + }, + EventMapping { + canonical: "SessionStart", + agent: "SessionStart", + }, + EventMapping { + canonical: "SessionEnd", + agent: "SessionEnd", + }, + EventMapping { + canonical: "Notification", + agent: "Notification", + }, + EventMapping { + canonical: "PreCompact", + agent: "PreCompact", + }, + EventMapping { + canonical: "PostCompact", + agent: "PostCompact", + }, + EventMapping { + canonical: "SubagentStart", + agent: "SubagentStart", + }, + EventMapping { + canonical: "SubagentStop", + agent: "SubagentStop", + }, + EventMapping { + canonical: "PermissionDenied", + agent: "PermissionDenied", + }, + EventMapping { + canonical: "StopFailure", + agent: "StopFailure", + }, + EventMapping { + canonical: "StopCancelled", + agent: "StopCancelled", + }, + EventMapping { + canonical: "SubagentEnd", + agent: "SubagentEnd", + }, +]; + /// Translate an event name from any agent's convention to the target agent's convention. /// Returns None if the event has no equivalent in the target agent. fn translate( @@ -419,6 +489,7 @@ pub fn to_claude(event: &str) -> Option { .or_else(|| translate(event, WINDSURF_EVENTS, CLAUDE_EVENTS)) .or_else(|| translate(event, HERMES_EVENTS, CLAUDE_EVENTS)) .or_else(|| translate(event, KIRO_EVENTS, CLAUDE_EVENTS)) + .or_else(|| translate(event, GROK_EVENTS, CLAUDE_EVENTS)) } /// Translate an event name to Gemini convention. @@ -430,6 +501,7 @@ pub fn to_gemini(event: &str) -> Option { .or_else(|| translate(event, WINDSURF_EVENTS, GEMINI_EVENTS)) .or_else(|| translate(event, HERMES_EVENTS, GEMINI_EVENTS)) .or_else(|| translate(event, KIRO_EVENTS, GEMINI_EVENTS)) + .or_else(|| translate(event, GROK_EVENTS, GEMINI_EVENTS)) } /// Translate an event name to Cursor convention. @@ -441,6 +513,7 @@ pub fn to_cursor(event: &str) -> Option { .or_else(|| translate(event, WINDSURF_EVENTS, CURSOR_EVENTS)) .or_else(|| translate(event, HERMES_EVENTS, CURSOR_EVENTS)) .or_else(|| translate(event, KIRO_EVENTS, CURSOR_EVENTS)) + .or_else(|| translate(event, GROK_EVENTS, CURSOR_EVENTS)) } /// Translate an event name to Copilot convention. @@ -452,6 +525,7 @@ pub fn to_copilot(event: &str) -> Option { .or_else(|| translate(event, WINDSURF_EVENTS, COPILOT_EVENTS)) .or_else(|| translate(event, HERMES_EVENTS, COPILOT_EVENTS)) .or_else(|| translate(event, KIRO_EVENTS, COPILOT_EVENTS)) + .or_else(|| translate(event, GROK_EVENTS, COPILOT_EVENTS)) } /// Translate an event name to Windsurf convention. @@ -463,6 +537,7 @@ pub fn to_windsurf(event: &str) -> Option { .or_else(|| translate(event, COPILOT_EVENTS, WINDSURF_EVENTS)) .or_else(|| translate(event, HERMES_EVENTS, WINDSURF_EVENTS)) .or_else(|| translate(event, KIRO_EVENTS, WINDSURF_EVENTS)) + .or_else(|| translate(event, GROK_EVENTS, WINDSURF_EVENTS)) } /// Translate an event name to Hermes convention. @@ -474,6 +549,7 @@ pub fn to_hermes(event: &str) -> Option { .or_else(|| translate(event, COPILOT_EVENTS, HERMES_EVENTS)) .or_else(|| translate(event, WINDSURF_EVENTS, HERMES_EVENTS)) .or_else(|| translate(event, KIRO_EVENTS, HERMES_EVENTS)) + .or_else(|| translate(event, GROK_EVENTS, HERMES_EVENTS)) } /// Translate an event name to Kiro IDE convention. @@ -485,6 +561,20 @@ pub fn to_kiro(event: &str) -> Option { .or_else(|| translate(event, COPILOT_EVENTS, KIRO_EVENTS)) .or_else(|| translate(event, WINDSURF_EVENTS, KIRO_EVENTS)) .or_else(|| translate(event, HERMES_EVENTS, KIRO_EVENTS)) + .or_else(|| translate(event, GROK_EVENTS, KIRO_EVENTS)) +} + +/// Translate an event name to Grok Build convention (Claude PascalCase +/// plus Grok-only events). +pub fn to_grok(event: &str) -> Option { + translate(event, GROK_EVENTS, GROK_EVENTS) + .or_else(|| translate(event, CLAUDE_EVENTS, GROK_EVENTS)) + .or_else(|| translate(event, GEMINI_EVENTS, GROK_EVENTS)) + .or_else(|| translate(event, CURSOR_EVENTS, GROK_EVENTS)) + .or_else(|| translate(event, COPILOT_EVENTS, GROK_EVENTS)) + .or_else(|| translate(event, WINDSURF_EVENTS, GROK_EVENTS)) + .or_else(|| translate(event, HERMES_EVENTS, GROK_EVENTS)) + .or_else(|| translate(event, KIRO_EVENTS, GROK_EVENTS)) } #[cfg(test)] @@ -647,4 +737,14 @@ mod tests { // but passes through to itself assert_eq!(to_hermes("pre_llm_call").as_deref(), Some("pre_llm_call")); } + + #[test] + fn grok_events_translate_and_passthrough() { + assert_eq!(to_grok("PreToolUse"), Some("PreToolUse".into())); + assert_eq!(to_grok("BeforeTool"), Some("PreToolUse".into())); + assert_eq!(to_grok("PermissionDenied"), Some("PermissionDenied".into())); + assert_eq!(to_grok("StopFailure"), Some("StopFailure".into())); + assert_eq!(to_claude("PermissionDenied"), None); + assert_eq!(to_grok("pre_run_command"), None); + } } diff --git a/crates/hk-core/src/adapter/mod.rs b/crates/hk-core/src/adapter/mod.rs index 780531f..489c654 100644 --- a/crates/hk-core/src/adapter/mod.rs +++ b/crates/hk-core/src/adapter/mod.rs @@ -5,6 +5,7 @@ pub mod copilot; pub mod cursor; pub mod dsh; pub mod gemini; +pub mod grok; pub mod hermes; pub mod hook_events; pub mod kiro; @@ -376,6 +377,12 @@ pub enum McpFormat { /// every deployer arm for this variant errors; toggling goes through the /// native in-place path (`set_dsh_mcp_enabled`). DshCordis, + /// Grok Build TOML `[mcp_servers.]` — same table shape as Codex + /// but remote headers are `headers` (not `http_headers`) and + /// `type = "sse"` is a first-class transport. Native toggle uses user + /// `disabled_mcp_servers` plus per-entry `enabled`; never reuse the + /// Codex writer (`deploy_mcp_server_toml`). + GrokToml, } /// How an agent's config spells a remote (HTTP/SSE) MCP entry. @@ -384,7 +391,7 @@ pub enum McpFormat { /// receive": the deployer's JSON writer dispatches on the four JSON-family /// variants, and `AgentCapabilities::from_adapter` derives UI install-gating /// from it (`Toml` and `DshTransport` are the HTTP-only variants — Codex -/// and dsh have no SSE support; +/// and dsh have no SSE support; `GrokToml` accepts both; /// `Unsupported` receives no remote entries at all). #[derive(Debug, Clone, Copy, PartialEq)] pub enum RemoteMcpSchema { @@ -407,6 +414,10 @@ pub enum RemoteMcpSchema { /// insert row. Streamable HTTP only — dsh ships no SSE transport /// (source-verified: packages/mcp/mcp-client/src/index.ts). DshTransport, + /// Grok Build TOML: `url` + `headers` + optional `type = "sse"`. + /// Streamable HTTP and SSE. Distinct from Codex `Toml` (`http_headers`, + /// HTTP-only) so the Codex writer is never reused. + GrokToml, /// Agent has no remote MCP concept; deploying a remote entry is an error. Unsupported, } @@ -416,6 +427,23 @@ pub trait AgentAdapter: Send + Sync { fn base_dir(&self) -> PathBuf; fn detect(&self) -> bool; fn skill_dirs(&self) -> Vec; + /// Directories to actually scan for skills under one skill root (global + /// or project). Default is the root itself; an adapter whose agent + /// discovers skills recursively (Grok walks nested dirs) overrides this + /// to return the root plus every nested parent directory that directly + /// holds skill subdirs. Install targets are unaffected — they keep + /// resolving through `skill_dir_for` / the canonical first root. + fn expand_skill_roots(&self, root: &std::path::Path) -> Vec { + vec![root.to_path_buf()] + } + /// Whether a loose `*.md` file sitting directly in a skills root is a + /// skill in its own right. True for most agents. Grok's discovery filters + /// `read_dir` to directories before it looks for `SKILL.md`, so a bare + /// `.md` there is never a skill — listing one would offer a row Grok + /// cannot load and whose Delete removes an ordinary file. + fn standalone_md_skills(&self) -> bool { + true + } fn mcp_config_path(&self) -> PathBuf; fn hook_config_path(&self) -> PathBuf; fn plugin_dirs(&self) -> Vec; @@ -453,6 +481,11 @@ pub trait AgentAdapter: Send + Sync { fn read_plugins(&self) -> Vec { vec![] } + /// Parse plugins from a specific directory (e.g. a project's `.grok/plugins`). + /// Default returns empty — only adapters that support project-level plugins override. + fn read_plugins_from(&self, _dir: &std::path::Path) -> Vec { + vec![] + } /// VS Code user data directory for agents that store state in state.vscdb. /// Only Copilot overrides this; others return None. fn vscode_user_dir(&self) -> Option { @@ -736,7 +769,8 @@ impl crate::models::AgentCapabilities { global_hook_install: a.supports_global_hook_install(), vendor_baseline_packs: a.vendor_baseline_packs(), // Codex (`Toml`) and dsh (`DshTransport`) speak Streamable HTTP - // but not SSE; every other non-Unsupported schema takes both. + // but not SSE; GrokToml and every other non-Unsupported schema + // take both. mcp_remote: crate::models::RemoteTransportFlags { http: remote_schema != RemoteMcpSchema::Unsupported, sse: !matches!( @@ -777,6 +811,7 @@ pub fn all_adapters() -> Vec> { Box::new(kiro::KiroAdapter::new()), Box::new(omp::OmpAdapter::new()), Box::new(dsh::DshAdapter::new()), + Box::new(grok::GrokAdapter::new()), ] } @@ -837,22 +872,28 @@ mod tests { } #[test] - fn test_all_adapters_returns_twelve() { + fn test_all_adapters_returns_thirteen() { let adapters = all_adapters(); - assert_eq!(adapters.len(), 12); + assert_eq!(adapters.len(), 13); let names: Vec<&str> = adapters.iter().map(|a| a.name()).collect(); - assert!(names.contains(&"claude")); - assert!(names.contains(&"cursor")); - assert!(names.contains(&"codex")); - assert!(names.contains(&"gemini")); - assert!(names.contains(&"antigravity")); - assert!(names.contains(&"copilot")); - assert!(names.contains(&"windsurf")); - assert!(names.contains(&"opencode")); - assert!(names.contains(&"hermes")); - assert!(names.contains(&"kiro")); - assert!(names.contains(&"omp")); - assert!(names.contains(&"dsh")); + assert_eq!( + names, + vec![ + "claude", + "codex", + "gemini", + "cursor", + "antigravity", + "copilot", + "windsurf", + "opencode", + "hermes", + "kiro", + "omp", + "dsh", + "grok", + ] + ); } #[test] @@ -874,7 +915,7 @@ mod tests { // hurting cross-machine portability. for name in [ "claude", "codex", "gemini", "cursor", "copilot", "opencode", "hermes", "kiro", "omp", - "dsh", + "dsh", "grok", ] { assert!( !by_name[name].needs_path_injection(), @@ -889,7 +930,7 @@ mod tests { // manager.rs::toggle_mcp — the trailing else there errors out. let adapters = all_adapters(); for a in &adapters { - let expected = matches!(a.name(), "hermes" | "kiro" | "omp" | "dsh"); + let expected = matches!(a.name(), "hermes" | "kiro" | "omp" | "dsh" | "grok"); assert_eq!( a.supports_native_mcp_toggle(), expected, @@ -940,6 +981,7 @@ mod tests { ("omp", true, true, false, false, true), // hooks are JS/TS modules ("hermes", false, false, false, true, true), // global-only (hermes-agent#4667) ("dsh", true, false, false, false, true), // MCP is cordis-layer only; no own hook format + ("grok", true, true, true, true, true), ]; let adapters = all_adapters(); @@ -1081,6 +1123,7 @@ mod tests { ("kiro", ".kiro/skills"), ("omp", ".omp/skills"), ("dsh", ".dsh/skills"), + ("grok", ".grok/skills"), // hermes is global-only — no project skill dir (hermes-agent#4667). ] .into_iter() diff --git a/crates/hk-core/src/deployer.rs b/crates/hk-core/src/deployer.rs index d30f095..63b9b35 100644 --- a/crates/hk-core/src/deployer.rs +++ b/crates/hk-core/src/deployer.rs @@ -165,6 +165,9 @@ fn json_top_key(format: McpFormat) -> &'static str { toggling uses the native patch-layer path (set_dsh_mcp_enabled)" ) } + McpFormat::GrokToml => { + unreachable!("GrokToml format uses a separate TOML code path") + } } } @@ -193,6 +196,7 @@ pub fn deploy_mcp_server( McpFormat::Opencode => deploy_mcp_server_opencode(config_path, entry), McpFormat::HermesYaml => deploy_mcp_server_hermes_yaml(config_path, entry), McpFormat::DshCordis => deploy_mcp_server_dsh_cordis(config_path, entry), + McpFormat::GrokToml => deploy_mcp_server_grok_toml(config_path, entry), } } @@ -269,6 +273,7 @@ fn build_mcp_json_value( | RemoteMcpSchema::OpencodeRemote | RemoteMcpSchema::HermesUrl | RemoteMcpSchema::DshTransport + | RemoteMcpSchema::GrokToml | RemoteMcpSchema::Unsupported => { return Err(HkError::Internal(format!( "remote JSON value requested for non-JSON schema {remote:?}" @@ -356,6 +361,86 @@ fn deploy_mcp_server_toml(config_path: &Path, entry: &McpServerEntry) -> Result< upsert_mcp_server_toml(config_path, &entry.name, toml::Value::Table(server_table)) } +/// Grok Build MCP deploy: `[mcp_servers.]` with `headers` (not Codex +/// `http_headers`) and optional `type = "sse"`. Extra Grok keys on an +/// existing entry (`cwd`, timeouts, oauth, …) are kept. A newly written +/// server is removed from `disabled_mcp_servers` so it starts enabled. +fn deploy_mcp_server_grok_toml(config_path: &Path, entry: &McpServerEntry) -> Result<(), HkError> { + let existing = read_toml_table(config_path)?; + let safe_name = sanitize_mcp_name(&entry.name); + let mut server_table = existing + .get("mcp_servers") + .and_then(|v| v.as_table()) + .and_then(|t| t.get(&safe_name).or_else(|| t.get(&entry.name))) + .and_then(|v| v.as_table()) + .cloned() + .unwrap_or_default(); + + if entry.transport != McpTransport::Stdio { + let url = entry.url.clone().unwrap_or_default(); + server_table.insert("url".into(), toml::Value::String(url)); + // `urlTemplate`/`url_template` are deserialize-only aliases of `url` + // upstream; leaving one beside the `url` we just wrote would be a + // duplicate-field error that makes Grok drop the whole entry. + server_table.remove("urlTemplate"); + server_table.remove("url_template"); + server_table.remove("command"); + server_table.remove("args"); + server_table.remove("env"); + if entry.transport == McpTransport::Sse { + server_table.insert("type".into(), toml::Value::String("sse".into())); + } else { + server_table.remove("type"); + } + if entry.headers.is_empty() { + server_table.remove("headers"); + } else { + let mut headers_table = toml::Table::new(); + for (k, v) in &entry.headers { + headers_table.insert(k.clone(), toml::Value::String(v.clone())); + } + server_table.insert("headers".into(), toml::Value::Table(headers_table)); + } + } else { + server_table.remove("url"); + server_table.remove("urlTemplate"); + server_table.remove("url_template"); + server_table.remove("type"); + server_table.remove("headers"); + server_table.insert("command".into(), toml::Value::String(entry.command.clone())); + if entry.args.is_empty() { + server_table.remove("args"); + } else { + server_table.insert( + "args".into(), + toml::Value::Array( + entry + .args + .iter() + .map(|a| toml::Value::String(a.clone())) + .collect(), + ), + ); + } + if entry.env.is_empty() { + server_table.remove("env"); + } else { + let mut env_table = toml::Table::new(); + for (k, v) in &entry.env { + env_table.insert(k.clone(), toml::Value::String(v.clone())); + } + server_table.insert("env".into(), toml::Value::Table(env_table)); + } + } + server_table.insert("enabled".into(), toml::Value::Boolean(true)); + upsert_mcp_server_toml(config_path, &entry.name, toml::Value::Table(server_table))?; + modify_toml_table(config_path, |table| { + remove_string_from_array(table, "disabled_mcp_servers", &entry.name); + remove_string_from_array(table, "disabled_mcp_servers", &safe_name); + Ok(()) + }) +} + /// Insert/replace `[mcp_servers.]` in a TOML config, preserving the /// rest of the file. Shared by deploy (freshly built table) and restore /// (snapshot transcoded wholesale). @@ -587,6 +672,292 @@ pub fn set_hermes_mcp_enabled( }) } +fn read_toml_table(path: &Path) -> Result { + if !path.exists() { + return Ok(toml::Table::new()); + } + let content = std::fs::read_to_string(path)?; + if content.trim().is_empty() { + return Ok(toml::Table::new()); + } + content + .parse::() + .map_err(|e| HkError::ConfigCorrupted(format!("Failed to parse TOML config: {e}"))) +} + +fn modify_toml_table( + path: &Path, + f: impl FnOnce(&mut toml::Table) -> Result<(), HkError>, +) -> Result<(), HkError> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let mut doc = read_toml_table(path)?; + f(&mut doc)?; + atomic_write( + path, + &toml::to_string_pretty(&doc).map_err(|e| HkError::Internal(e.to_string()))?, + ) +} + +fn toml_string_array(table: &toml::Table, key: &str) -> Vec { + table + .get(key) + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default() +} + +fn set_string_array(table: &mut toml::Table, key: &str, values: &[String]) { + if values.is_empty() { + table.remove(key); + } else { + table.insert( + key.into(), + toml::Value::Array(values.iter().map(|s| toml::Value::String(s.clone())).collect()), + ); + } +} + +fn remove_string_from_array(table: &mut toml::Table, key: &str, value: &str) { + let mut values = toml_string_array(table, key); + values.retain(|v| v != value); + set_string_array(table, key, &values); +} + +fn apply_grok_user_mcp_enabled(table: &mut toml::Table, server_name: &str, enabled: bool) { + let safe = sanitize_mcp_name(server_name); + let mut disabled = toml_string_array(table, "disabled_mcp_servers"); + if enabled { + disabled.retain(|n| n != server_name && n != &safe); + } else if !disabled.iter().any(|n| n == server_name || n == &safe) { + disabled.push(server_name.to_string()); + } + set_string_array(table, "disabled_mcp_servers", &disabled); + + if let Some(servers) = table.get_mut("mcp_servers").and_then(|v| v.as_table_mut()) { + let key = if servers.contains_key(server_name) { + server_name.to_string() + } else { + safe + }; + if let Some(entry) = servers.get_mut(&key).and_then(|v| v.as_table_mut()) { + entry.insert("enabled".into(), toml::Value::Boolean(enabled)); + } + } +} + +/// Grok personal MCP toggle. Disable writes user `disabled_mcp_servers` and +/// `enabled = false` on a user entry if present — never the project file. +/// Enable clears the user list and unsticks a winning project `enabled = false`. +pub fn set_grok_mcp_enabled( + user_config: &Path, + project_config: Option<&Path>, + server_name: &str, + enabled: bool, +) -> Result<(), HkError> { + modify_toml_table(user_config, |table| { + apply_grok_user_mcp_enabled(table, server_name, enabled); + Ok(()) + })?; + if enabled && let Some(project) = project_config.filter(|p| p.exists() && *p != user_config) { + unstick_grok_project_mcp(project, server_name)?; + } + Ok(()) +} + +fn unstick_grok_project_mcp(path: &Path, server_name: &str) -> Result<(), HkError> { + let doc = read_toml_table(path)?; + let safe = sanitize_mcp_name(server_name); + let sticky = doc + .get("mcp_servers") + .and_then(|v| v.as_table()) + .and_then(|t| t.get(server_name).or_else(|| t.get(&safe))) + .and_then(|v| v.as_table()) + .and_then(|t| t.get("enabled")) + .and_then(|v| v.as_bool()) + == Some(false); + if !sticky { + return Ok(()); + } + modify_toml_table(path, |table| { + if let Some(servers) = table.get_mut("mcp_servers").and_then(|v| v.as_table_mut()) { + let key = if servers.contains_key(server_name) { + server_name.to_string() + } else { + safe + }; + if let Some(entry) = servers.get_mut(&key).and_then(|v| v.as_table_mut()) { + entry.insert("enabled".into(), toml::Value::Boolean(true)); + } + } + Ok(()) + }) +} + +/// Toggle a Grok hook via `$GROK_HOME/disabled-hooks` using the real spec.name. +/// Comments and unrelated lines are preserved. +pub fn set_grok_hook_enabled( + disabled_hooks_path: &Path, + spec_name: &str, + enabled: bool, +) -> Result<(), HkError> { + if enabled { + if !disabled_hooks_path.exists() { + return Ok(()); + } + let content = std::fs::read_to_string(disabled_hooks_path)?; + let mut found = false; + let kept: Vec<&str> = content + .lines() + .filter(|line| { + let trimmed = line.trim(); + if !trimmed.is_empty() && !trimmed.starts_with('#') && trimmed == spec_name { + found = true; + false + } else { + true + } + }) + .collect(); + if !found { + return Ok(()); + } + if let Some(parent) = disabled_hooks_path.parent() { + std::fs::create_dir_all(parent)?; + } + let mut out = kept.join("\n"); + if !out.is_empty() { + out.push('\n'); + } + atomic_write(disabled_hooks_path, &out) + } else { + let existing = crate::adapter::grok::read_disabled_hook_names(disabled_hooks_path); + if existing.contains(spec_name) { + return Ok(()); + } + if let Some(parent) = disabled_hooks_path.parent() { + std::fs::create_dir_all(parent)?; + } + let mut content = std::fs::read_to_string(disabled_hooks_path).unwrap_or_default(); + if !content.is_empty() && !content.ends_with('\n') { + content.push('\n'); + } + content.push_str(spec_name); + content.push('\n'); + atomic_write(disabled_hooks_path, &content) + } +} + +fn grok_plugin_table(root: &mut toml::Table) -> Result<&mut toml::Table, HkError> { + let plugins = root + .entry("plugins") + .or_insert_with(|| toml::Value::Table(toml::Table::new())) + .as_table_mut() + .ok_or_else(|| HkError::ConfigCorrupted("[plugins] is not a table".into()))?; + Ok(plugins) +} + +/// Enable/disable a Grok plugin by stable id in `[plugins].enabled` / +/// `[plugins].disabled`. Disabled wins on conflict; enable adds to enabled +/// and removes from disabled. +pub fn set_grok_plugin_enabled( + config_path: &Path, + plugin_id: &str, + enabled: bool, +) -> Result<(), HkError> { + modify_toml_table(config_path, |table| { + let plugins = grok_plugin_table(table)?; + let mut enabled_list = toml_string_array(plugins, "enabled"); + let mut disabled_list = toml_string_array(plugins, "disabled"); + if enabled { + disabled_list.retain(|v| v != plugin_id); + if !enabled_list.iter().any(|v| v == plugin_id) { + enabled_list.push(plugin_id.to_string()); + } + } else { + enabled_list.retain(|v| v != plugin_id); + if !disabled_list.iter().any(|v| v == plugin_id) { + disabled_list.push(plugin_id.to_string()); + } + } + set_string_array(plugins, "enabled", &enabled_list); + set_string_array(plugins, "disabled", &disabled_list); + if plugins.is_empty() { + table.remove("plugins"); + } + Ok(()) + }) +} + +fn remove_mcp_server_grok_toml(config_path: &Path, server_name: &str) -> Result<(), HkError> { + modify_toml_table(config_path, |table| { + let safe = sanitize_mcp_name(server_name); + if let Some(servers) = table.get_mut("mcp_servers").and_then(|v| v.as_table_mut()) { + if servers.remove(server_name).is_none() { + servers.remove(&safe); + } + if servers.is_empty() { + table.remove("mcp_servers"); + } + } + remove_string_from_array(table, "disabled_mcp_servers", server_name); + remove_string_from_array(table, "disabled_mcp_servers", &safe); + if let Some(tools) = table + .get_mut("disabled_mcp_tools") + .and_then(|v| v.as_table_mut()) + { + tools.remove(server_name); + tools.remove(&safe); + if tools.is_empty() { + table.remove("disabled_mcp_tools"); + } + } + Ok(()) + }) +} + +/// Drop a plugin id (and optional display name) from both Grok plugin lists +/// after the directory is deleted. +pub fn remove_grok_plugin_lists( + config_path: &Path, + plugin_id: &str, + plugin_name: Option<&str>, +) -> Result<(), HkError> { + // Read once, then bail before writing unless a list actually referenced + // the plugin: rewriting discards comments and key order, which must never + // happen to an untouched shared project config just because a plugin + // elsewhere was deleted. (A missing file reads as an empty table and + // returns right here.) + let mut doc = read_toml_table(config_path)?; + let keep = |v: &String| v != plugin_id && plugin_name.is_none_or(|n| v != n); + let Some(plugins) = doc.get_mut("plugins").and_then(|v| v.as_table_mut()) else { + return Ok(()); + }; + let references_plugin = ["enabled", "disabled"] + .iter() + .any(|key| toml_string_array(plugins, key).iter().any(|v| !keep(v))); + if !references_plugin { + return Ok(()); + } + for key in ["enabled", "disabled"] { + let mut list = toml_string_array(plugins, key); + list.retain(keep); + set_string_array(plugins, key, &list); + } + if plugins.is_empty() { + doc.remove("plugins"); + } + atomic_write( + config_path, + &toml::to_string_pretty(&doc).map_err(|e| HkError::Internal(e.to_string()))?, + ) +} + /// Flip a Kiro MCP server's native `disabled` flag in place. pub fn set_kiro_mcp_enabled( config_path: &Path, @@ -1725,6 +2096,7 @@ pub fn remove_mcp_server( Ok(()) }), McpFormat::DshCordis => remove_mcp_server_dsh_cordis(config_path, server_name), + McpFormat::GrokToml => remove_mcp_server_grok_toml(config_path, server_name), _ => locked_modify_json(config_path, |config| { let key = json_top_key(format); if let Some(servers) = config.get_mut(key).and_then(|v| v.as_object_mut()) { @@ -1898,6 +2270,10 @@ pub fn restore_mcp_server( "dsh MCP uses native in-place enable/disable (set_dsh_mcp_enabled); \ the remove+snapshot+restore path is never reached for dsh" ), + McpFormat::GrokToml => unreachable!( + "Grok MCP uses native in-place enable/disable (set_grok_mcp_enabled); \ + the remove+snapshot+restore path is never reached for grok" + ), _ => { let key = json_top_key(format); locked_modify_json(config_path, |config| { @@ -2384,6 +2760,10 @@ pub fn read_mcp_server_config( "dsh MCP uses native in-place enable/disable (set_dsh_mcp_enabled); \ the read-config-for-snapshot path is never reached for dsh" ), + McpFormat::GrokToml => unreachable!( + "Grok MCP uses native in-place enable/disable (set_grok_mcp_enabled); \ + the read-config-for-snapshot path is never reached for grok" + ), _ => { let config = read_or_create_json(config_path)?; let key = json_top_key(format); @@ -2691,6 +3071,7 @@ mod tests { McpFormat::Opencode => Box::new(opencode::OpencodeAdapter::with_home(home)), McpFormat::HermesYaml => Box::new(hermes::HermesAdapter::with_home(home)), McpFormat::DshCordis => Box::new(dsh::DshAdapter::with_home(home)), + McpFormat::GrokToml => Box::new(grok::GrokAdapter::with_home(home)), } } @@ -2841,6 +3222,75 @@ mod tests { assert!(server.get("command").is_none()); } + #[test] + fn deploy_remote_mcp_grok_strips_url_aliases() { + // A redeploy over a urlTemplate-keyed entry must leave exactly one + // url spelling — url + urlTemplate together is a duplicate-field + // error that makes Grok drop the whole entry. + let dir = TempDir::new().unwrap(); + let config = dir.path().join("config.toml"); + std::fs::write( + &config, + "[mcp_servers.linear]\nurlTemplate = \"https://old.example\"\n", + ) + .unwrap(); + let entry = remote_entry(McpTransport::Http); + deploy_mcp_server(&config, &entry, &*test_adapter(McpFormat::GrokToml)).unwrap(); + let doc: toml::Value = std::fs::read_to_string(&config).unwrap().parse().unwrap(); + let server = doc["mcp_servers"]["linear"].as_table().unwrap(); + assert_eq!(server["url"].as_str(), Some("https://mcp.linear.app/mcp")); + assert!(!server.contains_key("urlTemplate"), "{server:?}"); + + // Same strip on the stdio branch: a remote→stdio redeploy over a + // urlTemplate-keyed entry must not leave the alias behind. + let stdio = McpServerEntry { + name: "linear".into(), + command: "npx".into(), + args: vec![], + env: Default::default(), + transport: McpTransport::Stdio, + url: None, + headers: Default::default(), + enabled: true, + }; + std::fs::write( + &config, + "[mcp_servers.linear]\nurl_template = \"https://old.example\"\n", + ) + .unwrap(); + deploy_mcp_server(&config, &stdio, &*test_adapter(McpFormat::GrokToml)).unwrap(); + let doc: toml::Value = std::fs::read_to_string(&config).unwrap().parse().unwrap(); + let server = doc["mcp_servers"]["linear"].as_table().unwrap(); + assert_eq!(server["command"].as_str(), Some("npx")); + assert!(!server.contains_key("url_template"), "{server:?}"); + } + + #[test] + fn deploy_remote_mcp_grok_writes_headers_and_sse_type() { + let dir = TempDir::new().unwrap(); + let config = dir.path().join("config.toml"); + std::fs::write( + &config, + "theme = \"dark\"\n\n[mcp_servers.linear]\ncwd = \"/keep\"\nurl = \"https://old.example\"\n", + ) + .unwrap(); + let entry = remote_entry(McpTransport::Sse); + deploy_mcp_server(&config, &entry, &*test_adapter(McpFormat::GrokToml)).unwrap(); + + let doc: toml::Value = std::fs::read_to_string(&config).unwrap().parse().unwrap(); + let server = &doc["mcp_servers"]["linear"]; + assert_eq!(server["url"].as_str(), Some("https://mcp.linear.app/mcp")); + assert_eq!(server["type"].as_str(), Some("sse")); + assert_eq!( + server["headers"]["Authorization"].as_str(), + Some("Bearer tok") + ); + assert_eq!(server["cwd"].as_str(), Some("/keep")); + assert_eq!(doc["theme"].as_str(), Some("dark")); + assert!(server.get("http_headers").is_none()); + assert!(server.get("command").is_none()); + } + #[test] fn toml_disable_enable_roundtrip_preserves_remote_fields() { // The old restore path narrowed snapshots to command/args/env, diff --git a/crates/hk-core/src/kits/install_plan.rs b/crates/hk-core/src/kits/install_plan.rs index 26a0b45..0a62bc1 100644 --- a/crates/hk-core/src/kits/install_plan.rs +++ b/crates/hk-core/src/kits/install_plan.rs @@ -26,16 +26,17 @@ fn mcp_entry_exists(config_path: &Path, name: &str, format: McpFormat) -> bool { }; v.get(key).and_then(|m| m.get(name)).is_some() } - McpFormat::Toml => { + McpFormat::Toml | McpFormat::GrokToml => { let Ok(s) = std::fs::read_to_string(config_path) else { return false; }; let Ok(doc) = s.parse::() else { return false; }; + let safe = crate::deployer::sanitize_mcp_name(name); doc.get("mcp_servers") .and_then(|v| v.as_table()) - .and_then(|t| t.get(name)) + .and_then(|t| t.get(name).or_else(|| t.get(&safe))) .is_some() } McpFormat::Opencode => { @@ -291,3 +292,47 @@ mod dsh_conflict_tests { assert!(mcp_entry_exists(&path, "github", McpFormat::DshCordis)); } } + +#[cfg(test)] +mod grok_conflict_tests { + use super::*; + + #[test] + fn grok_toml_conflict_detects_original_and_sanitized_names() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("config.toml"); + std::fs::write( + &path, + "[mcp_servers.my-server]\ncommand = \"echo\"\n", + ) + .unwrap(); + assert!(mcp_entry_exists(&path, "my-server", McpFormat::GrokToml)); + assert!(mcp_entry_exists(&path, "my/server", McpFormat::GrokToml)); + assert!(!mcp_entry_exists(&path, "other", McpFormat::GrokToml)); + assert!(!mcp_entry_exists( + &tmp.path().join("absent.toml"), + "my-server", + McpFormat::GrokToml + )); + } + + #[test] + fn grok_toml_conflict_sees_rows_the_real_writer_installed() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(tmp.path().join(".grok")).unwrap(); + let adapter = crate::adapter::grok::GrokAdapter::with_home(tmp.path().to_path_buf()); + let path = adapter.mcp_config_path(); + let entry = crate::adapter::McpServerEntry { + name: "linear".into(), + command: String::new(), + args: vec![], + env: Default::default(), + transport: crate::adapter::McpTransport::Http, + url: Some("https://mcp.linear.app/mcp".into()), + headers: [("Authorization".into(), "Bearer t".into())].into(), + enabled: true, + }; + crate::deployer::deploy_mcp_server(&path, &entry, &adapter).unwrap(); + assert!(mcp_entry_exists(&path, "linear", McpFormat::GrokToml)); + } +} diff --git a/crates/hk-core/src/manager.rs b/crates/hk-core/src/manager.rs index 1b007f8..f5ce1ad 100644 --- a/crates/hk-core/src/manager.rs +++ b/crates/hk-core/src/manager.rs @@ -209,6 +209,21 @@ fn toggle_mcp( } else if a.name() == "hermes" { // Per-server `enabled` field flipped in place in config.yaml. deployer::set_hermes_mcp_enabled(&config_path, &ext.name, enabled)?; + } else if a.name() == "grok" { + // Personal disable writes user `disabled_mcp_servers` only. + // Enable also unsticks a sticky project `enabled = false`. + let user_config = a.mcp_config_path(); + let project_config = if config_path != user_config { + Some(config_path.as_path()) + } else { + None + }; + deployer::set_grok_mcp_enabled( + &user_config, + project_config, + &ext.name, + enabled, + )?; } else { // Every native-toggle agent needs its own writer; a missing // branch must fail loudly instead of falling through to some @@ -353,6 +368,29 @@ fn toggle_hook( .as_ref() .map(|p| vec![PathBuf::from(p)]) .unwrap_or_else(|| a.hook_config_paths_for(&ext.scope)); + // Grok's native toggle writes the hook's recomputed spec name to + // $GROK_HOME/disabled-hooks; the hook file itself stays untouched + // and no DB snapshot is taken (read_hooks reads the state back). + if a.name() == "grok" { + let source_path = ext.source_path.as_ref().map(PathBuf::from).or_else(|| { + config_paths.into_iter().next() + }); + let Some(source_path) = source_path else { + return Err(HkError::NotFound(format!( + "No hook config path for '{}'", + ext.name + ))); + }; + let grok = adapter::grok::GrokAdapter::with_grok_home(a.base_dir()); + let spec = grok + .hook_spec_name_for(&source_path, event, matcher, command) + .ok_or_else(|| { + HkError::NotFound(format!("Hook '{}' not found in config", ext.name)) + })?; + deployer::set_grok_hook_enabled(&a.base_dir().join("disabled-hooks"), &spec, enabled)?; + store.set_disabled_config(&ext.id, None)?; + continue; + } // Kiro hooks have a native per-hook `enabled` flag ("skip without // deleting" — https://kiro.dev/docs/hooks/). Flip it IN PLACE, keeping // the entry, and take NO DB snapshot: the on-disk state is read back by @@ -514,9 +552,9 @@ fn find_plugin_for_ext<'a>( ext: &Extension, agent: &str, ) -> Option<&'a adapter::PluginEntry> { - plugins - .iter() - .find(|p| scanner::plugin_extension_id(&p.name, &p.source, agent) == ext.id) + plugins.iter().find(|p| { + scanner::plugin_extension_id_for_scope(&p.name, &p.source, agent, &ext.scope) == ext.id + }) } fn toggle_plugin( @@ -565,6 +603,23 @@ fn toggle_plugin( // Copilot CLI plugin — reuse cached plugins to avoid second scan toggle_plugin_manifest(ext, enabled, store, a.as_ref(), Some(plugins))?; } + } else if a.name() == "grok" { + // Native `[plugins].enabled` / `[plugins].disabled` lists keyed by + // Grok's stable plugin id. Must run before the generic + // manifest-rename fallback, which would rename plugin.json and + // hide the plugin from Grok's loader. + let plugins = scanner::read_plugins_for_scope(a.as_ref(), &ext.scope); + let plugin = find_plugin_for_ext(&plugins, ext, a.name()).ok_or_else(|| { + HkError::NotFound(format!("Grok plugin '{}' not found on disk", ext.name)) + })?; + let plugin_id = plugin.uri.as_deref().ok_or_else(|| { + HkError::Validation(format!( + "Grok plugin '{}' has no stable plugin id", + plugin.name + )) + })?; + deployer::set_grok_plugin_enabled(&a.plugin_config_path(), plugin_id, enabled)?; + store.set_disabled_config(&ext.id, None)?; } else if a.name() == "dsh" { // Explicit branch BEFORE the generic manifest-rename fallback — // mandatory ordering, not style: the fallback probes package dirs diff --git a/crates/hk-core/src/scanner.rs b/crates/hk-core/src/scanner.rs index 4d3b795..fbbb701 100644 --- a/crates/hk-core/src/scanner.rs +++ b/crates/hk-core/src/scanner.rs @@ -145,7 +145,11 @@ fn cli_stable_id(binary_name: &str) -> String { } /// Scan a skill directory and return Extension entries. -pub fn scan_skill_dir(dir: &Path, agent_name: &str) -> Vec { +/// +/// `standalone_md` mirrors `AgentAdapter::standalone_md_skills`: when false a +/// loose `*.md` in `dir` is ordinary payload (a skill's own `reference.md`, a +/// folder `README.md`) rather than a skill of its own. +pub fn scan_skill_dir(dir: &Path, agent_name: &str, standalone_md: bool) -> Vec { let mut extensions = Vec::new(); let Ok(entries) = std::fs::read_dir(dir) else { return extensions; @@ -155,8 +159,11 @@ pub fn scan_skill_dir(dir: &Path, agent_name: &str) -> Vec { // once per scanned directory (one lockfile serves many skills). let mut lock_cache: HashMap>> = HashMap::new(); - for entry in entries.flatten() { - let path = entry.path(); + // Deterministic order so same-name collisions resolve stably across scans + // (read_dir order is filesystem-dependent). + let mut paths: Vec = entries.flatten().map(|e| e.path()).collect(); + paths.sort(); + for path in paths { // Skills can be either: a directory containing SKILL.md (or SKILL.md.disabled), or a standalone .md file let (skill_file, is_disabled) = if path.is_dir() { let enabled_file = path.join("SKILL.md"); @@ -168,7 +175,7 @@ pub fn scan_skill_dir(dir: &Path, agent_name: &str) -> Vec { } else { continue; } - } else if path.extension().is_some_and(|ext| ext == "md") { + } else if standalone_md && path.extension().is_some_and(|ext| ext == "md") { (path.clone(), false) } else { continue; @@ -515,94 +522,133 @@ pub fn scan_hooks(adapter: &dyn AgentAdapter) -> Vec { .collect() } +/// Plugins discovered for `scope`. Global uses `read_plugins`; project uses +/// `project_plugin_dirs` + `read_plugins_from` so repo-local plugins are +/// visible without asking every adapter to know the project list. +pub fn read_plugins_for_scope( + adapter: &dyn AgentAdapter, + scope: &ConfigScope, +) -> Vec { + match scope { + ConfigScope::Global => adapter.read_plugins(), + ConfigScope::Project { path, .. } => adapter + .project_plugin_dirs() + .into_iter() + .flat_map(|rel| adapter.read_plugins_from(&Path::new(path).join(rel))) + .collect(), + } +} + +/// The extension id of a plugin at `scope`. Global keeps the legacy +/// `plugin_extension_id` key; project appends the project path so a +/// same-named user plugin is a different row. +pub fn plugin_extension_id_for_scope( + name: &str, + source: &str, + agent: &str, + scope: &ConfigScope, +) -> String { + match scope { + ConfigScope::Global => plugin_extension_id(name, source, agent), + ConfigScope::Project { .. } => { + stable_id_with_scope(&format!("{name}:{source}"), "plugin", agent, scope) + } + } +} + +fn plugin_to_extension( + adapter: &dyn AgentAdapter, + plugin: crate::adapter::PluginEntry, + scope: ConfigScope, +) -> Extension { + let description = if plugin.source.is_empty() { + format!("Plugin for {}", adapter.name()) + } else { + format!("Plugin from {}", plugin.source) + }; + // Plugins run code; infer real permissions from directory contents + let permissions = plugin + .path + .as_ref() + .map(|p| infer_plugin_permissions(p)) + .unwrap_or_else(|| { + vec![ + Permission::Shell { commands: vec![] }, + Permission::FileSystem { paths: vec![] }, + ] + }); + + let (installed_at, updated_at) = match (plugin.installed_at, plugin.updated_at) { + (Some(i), Some(u)) => (i, u), + _ => plugin + .path + .as_ref() + .map(|p| (file_created_time(p), file_modified_time(p))) + .unwrap_or_else(|| (Utc::now(), Utc::now())), + }; + + // Prefer the agent manifest's authoritative source (e.g. Claude's + // marketplace → repo mapping); fall back to detecting a `.git` from + // the plugin path (e.g. VS Code agent-plugins that are git clones). + let source = match plugin.source_url { + Some(url) => Source { + origin: SourceOrigin::Git, + url: Some(url), + version: None, + commit_hash: None, + from_manifest: true, + }, + None => plugin + .path + .as_ref() + .map(|p| detect_source(p, true)) + .unwrap_or(Source { + origin: SourceOrigin::Agent, + url: None, + version: None, + commit_hash: None, + from_manifest: false, + }), + }; + // An adapter that knows its provider wins; otherwise fall back to + // the git-URL derivation, which only fires for git-checkout plugins. + let pack = plugin + .pack + .clone() + .or_else(|| source.url.as_deref().and_then(extract_pack_from_url)); + + Extension { + id: plugin_extension_id_for_scope(&plugin.name, &plugin.source, adapter.name(), &scope), + kind: ExtensionKind::Plugin, + name: plugin.name, + description, + source, + agents: vec![adapter.name().to_string()], + tags: vec![], + pack, + permissions, + enabled: plugin.enabled, + trust_score: None, + installed_at, + updated_at, + source_path: plugin + .path + .as_ref() + .map(|p| p.to_string_lossy().to_string()), + cli_parent_id: None, + cli_meta: None, + install_meta: None, + scope, + mcp_transport: None, + } +} + /// Scan plugins from an agent adapter pub fn scan_plugins(adapter: &dyn AgentAdapter) -> Vec { adapter .read_plugins() .into_iter() - .map(|plugin| { - let description = if plugin.source.is_empty() { - format!("Plugin for {}", adapter.name()) - } else { - format!("Plugin from {}", plugin.source) - }; - // Plugins run code; infer real permissions from directory contents - let permissions = plugin - .path - .as_ref() - .map(|p| infer_plugin_permissions(p)) - .unwrap_or_else(|| { - vec![ - Permission::Shell { commands: vec![] }, - Permission::FileSystem { paths: vec![] }, - ] - }); - - let (installed_at, updated_at) = match (plugin.installed_at, plugin.updated_at) { - (Some(i), Some(u)) => (i, u), - _ => plugin - .path - .as_ref() - .map(|p| (file_created_time(p), file_modified_time(p))) - .unwrap_or_else(|| (Utc::now(), Utc::now())), - }; - - // Prefer the agent manifest's authoritative source (e.g. Claude's - // marketplace → repo mapping); fall back to detecting a `.git` from - // the plugin path (e.g. VS Code agent-plugins that are git clones). - let source = match plugin.source_url { - Some(url) => Source { - origin: SourceOrigin::Git, - url: Some(url), - version: None, - commit_hash: None, - from_manifest: true, - }, - None => plugin - .path - .as_ref() - .map(|p| detect_source(p, true)) - .unwrap_or(Source { - origin: SourceOrigin::Agent, - url: None, - version: None, - commit_hash: None, - from_manifest: false, - }), - }; - // An adapter that knows its provider wins; otherwise fall back to - // the git-URL derivation, which only fires for git-checkout plugins. - let pack = plugin - .pack - .clone() - .or_else(|| source.url.as_deref().and_then(extract_pack_from_url)); - - Extension { - id: plugin_extension_id(&plugin.name, &plugin.source, adapter.name()), - kind: ExtensionKind::Plugin, - name: plugin.name, - description, - source, - agents: vec![adapter.name().to_string()], - tags: vec![], - pack, - permissions, - enabled: plugin.enabled, - trust_score: None, - installed_at, - updated_at, - - source_path: plugin - .path - .as_ref() - .map(|p| p.to_string_lossy().to_string()), - cli_parent_id: None, - cli_meta: None, - install_meta: None, - scope: ConfigScope::Global, - mcp_transport: None, - } - }) + .map(|plugin| plugin_to_extension(adapter, plugin, ConfigScope::Global)) .collect() } @@ -1005,8 +1051,8 @@ fn scan_cli_binaries( /// Scan all extension kinds for a specific adapter. pub fn scan_adapter(adapter: &dyn crate::adapter::AgentAdapter) -> Vec { let mut all = Vec::new(); - for skill_dir in adapter.skill_dirs() { - all.extend(scan_skill_dir(&skill_dir, adapter.name())); + for skill_dir in adapter.skill_dirs().iter().flat_map(|d| adapter.expand_skill_roots(d)) { + all.extend(scan_skill_dir(&skill_dir, adapter.name(), adapter.standalone_md_skills())); } all.extend(scan_mcp_servers(adapter)); all.extend(scan_hooks(adapter)); @@ -1017,13 +1063,13 @@ pub fn scan_adapter(adapter: &dyn crate::adapter::AgentAdapter) -> Vec Vec { let mut exts = Vec::new(); - for skill_dir in adapter.skill_dirs() { - exts.extend(scan_skill_dir(&skill_dir, adapter.name())); + for skill_dir in adapter.skill_dirs().iter().flat_map(|d| adapter.expand_skill_roots(d)) { + exts.extend(scan_skill_dir(&skill_dir, adapter.name(), adapter.standalone_md_skills())); } exts } -/// Scan all project-scoped extensions (skills, MCP, hooks) for one adapter and one project. +/// Scan all project-scoped extensions (skills, MCP, hooks, plugins) for one adapter and one project. /// Returns extensions tagged with `ConfigScope::Project { name, path }` and IDs that /// include the project path so they don't collide with same-named global extensions. pub fn scan_project_extensions( @@ -1042,14 +1088,16 @@ pub fn scan_project_extensions( // --- Project-scoped skills --- for rel_dir in adapter.project_skill_dirs() { - let dir = project_path.join(&rel_dir); - let mut skills = scan_skill_dir(&dir, adapter.name()); - for skill in &mut skills { - // Re-tag with project scope and recompute the ID so it's unique vs. global. - skill.scope = scope.clone(); - skill.id = stable_id_with_scope(&skill.name, "skill", adapter.name(), &scope); + let base = project_path.join(&rel_dir); + for dir in adapter.expand_skill_roots(&base) { + let mut skills = scan_skill_dir(&dir, adapter.name(), adapter.standalone_md_skills()); + for skill in &mut skills { + // Re-tag with project scope and recompute the ID so it's unique vs. global. + skill.scope = scope.clone(); + skill.id = stable_id_with_scope(&skill.name, "skill", adapter.name(), &scope); + } + all.extend(skills); } - all.extend(skills); } // --- Project-scoped MCP servers --- @@ -1157,6 +1205,14 @@ pub fn scan_project_extensions( } } + // --- Project-scoped plugins --- + for rel_dir in adapter.project_plugin_dirs() { + let dir = project_path.join(&rel_dir); + for plugin in adapter.read_plugins_from(&dir) { + all.push(plugin_to_extension(adapter, plugin, scope.clone())); + } + } + all } @@ -1173,8 +1229,12 @@ pub fn scan_all( if !adapter.detect() { continue; } - for skill_dir in adapter.skill_dirs() { - all.extend(scan_skill_dir(&skill_dir, adapter.name())); + for skill_dir in adapter + .skill_dirs() + .iter() + .flat_map(|d| adapter.expand_skill_roots(d)) + { + all.extend(scan_skill_dir(&skill_dir, adapter.name(), adapter.standalone_md_skills())); } all.extend(scan_mcp_servers(adapter.as_ref())); all.extend(scan_hooks(adapter.as_ref())); @@ -1270,7 +1330,8 @@ pub fn find_skill_by_id( // joined with each known project. let mut candidates: Vec<(std::path::PathBuf, ConfigScope)> = a .skill_dirs() - .into_iter() + .iter() + .flat_map(|d| a.expand_skill_roots(d)) .map(|d| (d, ConfigScope::Global)) .collect(); for (project_name, project_path) in projects { @@ -1279,13 +1340,15 @@ pub fn find_skill_by_id( continue; } for rel in a.project_skill_dirs() { - candidates.push(( - project_root.join(&rel), - ConfigScope::Project { - name: project_name.clone(), - path: project_path.clone(), - }, - )); + for dir in a.expand_skill_roots(&project_root.join(&rel)) { + candidates.push(( + dir, + ConfigScope::Project { + name: project_name.clone(), + path: project_path.clone(), + }, + )); + } } } @@ -1302,9 +1365,10 @@ pub fn find_skill_by_id( } else { path.join("SKILL.md.disabled") } - } else if path - .extension() - .is_some_and(|e| e == "md" || e == "disabled") + } else if a.standalone_md_skills() + && path + .extension() + .is_some_and(|e| e == "md" || e == "disabled") { path.clone() } else { @@ -1403,7 +1467,11 @@ pub fn skill_locations( continue; } if want_global { - for skill_dir in adapter.skill_dirs() { + for skill_dir in adapter + .skill_dirs() + .iter() + .flat_map(|d| adapter.expand_skill_roots(d)) + { probe(adapter.name(), &skill_dir); } } @@ -1421,7 +1489,9 @@ pub fn skill_locations( continue; } for rel in adapter.project_skill_dirs() { - probe(adapter.name(), &project_root.join(&rel)); + for dir in adapter.expand_skill_roots(&project_root.join(&rel)) { + probe(adapter.name(), &dir); + } } } } @@ -2087,8 +2157,31 @@ pub fn scan_agent_configs( (ConfigCategory::Workflow, adapter.global_workflow_files()), ]; + // An adapter may name one file twice — grok lists both `AGENTS.md` and + // `Agents.md`, which are the same file on a case-insensitive filesystem — + // so a category keeps only the first of the two spellings. + // + // The key deliberately pairs the real path with the lowercased filename, + // so it suppresses ONLY that spelling artifact. Two different names that + // resolve to one file (`CLAUDE.md` symlinked to `AGENTS.md`, the + // recommended AGENTS.md migration) are files the user meant to have under + // both names, and keep their own rows — matching how the rest of HK + // treats symlinks: shown per path, never merged away. + let first_time_seen = |seen: &mut HashSet<(PathBuf, String)>, path: &Path| { + let real = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); + let spelling = path + .file_name() + .map(|n| n.to_string_lossy().to_ascii_lowercase()) + .unwrap_or_default(); + seen.insert((real, spelling)) + }; + for (category, paths) in &global_groups { + let mut seen = HashSet::new(); for path in paths { + if !first_time_seen(&mut seen, path) { + continue; + } if let Some(cf) = stat_config_file(path, adapter.name(), *category, ConfigScope::Global) { configs.push(cf); @@ -2127,9 +2220,12 @@ pub fn scan_agent_configs( }; for (category, patterns) in &project_groups { + let mut seen = HashSet::new(); for pattern in patterns { - let resolved = resolve_pattern(project_root, pattern); - for path in resolved { + for path in resolve_pattern(project_root, pattern) { + if !first_time_seen(&mut seen, &path) { + continue; + } if let Some(cf) = stat_config_file(&path, adapter.name(), *category, scope.clone()) { @@ -2230,7 +2326,7 @@ mod tests { let dir = TempDir::new().unwrap(); setup_claude_skills(&dir); let skills_dir = dir.path().join(".claude").join("skills"); - let extensions = scan_skill_dir(&skills_dir, "claude"); + let extensions = scan_skill_dir(&skills_dir, "claude", true); assert_eq!(extensions.len(), 1); assert_eq!(extensions[0].name, "eslint-skill"); assert_eq!(extensions[0].kind, ExtensionKind::Skill); @@ -2258,7 +2354,7 @@ mod tests { .unwrap(); symlink(&real, claude_skills.join("tdd")).unwrap(); - let exts = scan_skill_dir(&claude_skills, "claude"); + let exts = scan_skill_dir(&claude_skills, "claude", true); assert_eq!(exts.len(), 1); assert_eq!(exts[0].name, "tdd"); assert_ne!( @@ -2302,7 +2398,7 @@ mod tests { ) .unwrap(); - let exts = scan_skill_dir(&skills, "claude"); + let exts = scan_skill_dir(&skills, "claude", true); let tdd = exts .iter() .find(|e| e.name == "test-driven-development") @@ -2764,7 +2860,7 @@ mod tests { ) .unwrap(); - let extensions = super::scan_skill_dir(dir.path(), "claude"); + let extensions = super::scan_skill_dir(dir.path(), "claude", true); assert_eq!(extensions.len(), 1); assert_eq!(extensions[0].name, "my-skill"); assert!( @@ -2795,12 +2891,12 @@ mod tests { // Shape 1 — shared root (e.g. ~/.agents/skills): only dsh drops the // skill; every other agent still lists it, so a shared skill merely // loses dsh from its agent list. - let dsh: Vec = super::scan_skill_dir(dir.path(), "dsh") + let dsh: Vec = super::scan_skill_dir(dir.path(), "dsh", true) .into_iter() .map(|e| e.name) .collect(); assert_eq!(dsh, vec!["clean-skill".to_string()]); - let mut claude: Vec = super::scan_skill_dir(dir.path(), "claude") + let mut claude: Vec = super::scan_skill_dir(dir.path(), "claude", true) .into_iter() .map(|e| e.name) .collect(); @@ -2816,7 +2912,7 @@ mod tests { "---\nname: legacy-skill\ndisableModelInvocation: true\n---\nbody\n", ) .unwrap(); - assert!(super::scan_skill_dir(only.path(), "dsh").is_empty()); + assert!(super::scan_skill_dir(only.path(), "dsh", true).is_empty()); // A DISABLED dropped skill is skipped too — dsh would not load it // even if it were re-enabled. @@ -2827,8 +2923,8 @@ mod tests { "---\nname: off-skill\nuserInvocable: true\n---\nbody\n", ) .unwrap(); - assert!(super::scan_skill_dir(only.path(), "dsh").is_empty()); - assert_eq!(super::scan_skill_dir(only.path(), "claude").len(), 2); + assert!(super::scan_skill_dir(only.path(), "dsh", true).is_empty()); + assert_eq!(super::scan_skill_dir(only.path(), "claude", true).len(), 2); } #[test] @@ -2839,7 +2935,7 @@ mod tests { // Scan as enabled std::fs::write(skill_dir.join("SKILL.md"), "---\nname: my-skill\n---\n").unwrap(); - let enabled_exts = super::scan_skill_dir(dir.path(), "claude"); + let enabled_exts = super::scan_skill_dir(dir.path(), "claude", true); let enabled_id = enabled_exts[0].id.clone(); // Rename to disabled @@ -2848,7 +2944,7 @@ mod tests { skill_dir.join("SKILL.md.disabled"), ) .unwrap(); - let disabled_exts = super::scan_skill_dir(dir.path(), "claude"); + let disabled_exts = super::scan_skill_dir(dir.path(), "claude", true); let disabled_id = disabled_exts[0].id.clone(); assert_eq!( @@ -2868,7 +2964,7 @@ mod tests { ) .unwrap(); - let extensions = super::scan_skill_dir(dir.path(), "claude"); + let extensions = super::scan_skill_dir(dir.path(), "claude", true); assert_eq!(extensions.len(), 1); let source_path = extensions[0].source_path.as_ref().unwrap(); assert!( @@ -3041,6 +3137,206 @@ mod project_extension_tests { assert!(matches!(hook.scope, ConfigScope::Project { .. })); assert_eq!(hook.agents, vec!["codex"]); } + + #[test] + fn grok_global_nested_skill_inside_skill_yields_two_rows() { + use crate::adapter::grok::GrokAdapter; + + let tmp = tempfile::tempdir().unwrap(); + let adapter = GrokAdapter::with_home(tmp.path().to_path_buf()); + let infra = tmp.path().join(".grok/skills/team/infra"); + fs::create_dir_all(infra.join("child")).unwrap(); + fs::write( + infra.join("SKILL.md"), + "---\nname: infra\ndescription: parent skill\n---\nbody", + ) + .unwrap(); + fs::write( + infra.join("child/SKILL.md"), + "---\nname: child\ndescription: nested inside a skill\n---\nbody", + ) + .unwrap(); + + let mut names: Vec = scan_skills_for(&adapter) + .into_iter() + .filter(|e| e.kind == ExtensionKind::Skill) + .map(|e| e.name) + .collect(); + names.sort(); + assert_eq!( + names, + vec!["child", "infra"], + "parent and nested child are both rows, neither double-counted" + ); + } + + #[test] + fn grok_loose_md_beside_skills_is_never_a_skill() { + use crate::adapter::grok::GrokAdapter; + + let tmp = tempfile::tempdir().unwrap(); + let adapter = GrokAdapter::with_home(tmp.path().to_path_buf()); + let skills = tmp.path().join(".grok/skills"); + fs::create_dir_all(skills.join("team/infra")).unwrap(); + fs::write( + skills.join("team/infra/SKILL.md"), + "---\nname: infra\ndescription: real skill\n---\nbody", + ) + .unwrap(); + // Ordinary payload at three levels: beside the root, inside a grouping + // folder, and inside the skill itself. Grok reads none of them as a + // skill, and a phantom row's Delete would remove the real file. + fs::write(skills.join("notes.md"), "loose note").unwrap(); + fs::write(skills.join("team/README.md"), "what lives here").unwrap(); + fs::write(skills.join("team/infra/reference.md"), "helper doc").unwrap(); + + let names: Vec = scan_skills_for(&adapter) + .into_iter() + .filter(|e| e.kind == ExtensionKind::Skill) + .map(|e| e.name) + .collect(); + assert_eq!(names, vec!["infra"], "only the SKILL.md dir is a skill"); + } + + #[test] + fn grok_lookup_paths_never_resolve_to_a_loose_md() { + use crate::adapter::claude::ClaudeAdapter; + use crate::adapter::grok::GrokAdapter; + + // find_skill_by_id feeds delete_extension, which remove_file()s + // whatever it returns — so the standalone-md rule has to hold on the + // lookup path too, not just on the scan that builds the rows. + let tmp = tempfile::tempdir().unwrap(); + let write = |p: std::path::PathBuf| { + fs::create_dir_all(p.parent().unwrap()).unwrap(); + fs::write(p, "---\nname: notes\ndescription: an ordinary file\n---\nbody").unwrap(); + }; + write(tmp.path().join(".grok/skills/notes.md")); + write(tmp.path().join(".claude/skills/notes.md")); + + let id_for = |agent: &str| stable_id_with_scope("notes", "skill", agent, &ConfigScope::Global); + let grok: Vec> = + vec![Box::new(GrokAdapter::with_home(tmp.path().to_path_buf()))]; + let claude: Vec> = + vec![Box::new(ClaudeAdapter::with_home(tmp.path().to_path_buf()))]; + + assert!( + find_skill_by_id(&grok, &id_for("grok"), &["grok".to_string()], &[]).is_none(), + "a loose .md is not a Grok skill, so nothing may resolve to it" + ); + assert!( + find_skill_by_id(&claude, &id_for("claude"), &["claude".to_string()], &[]).is_some(), + "agents that do have standalone-md skills still resolve theirs" + ); + } + + #[test] + fn standalone_md_skills_still_hold_for_other_agents() { + // The Grok rule must not leak: agents that do load a bare `.md` as a + // skill keep doing so. (The `false` side is covered end-to-end by + // grok_loose_md_beside_skills_is_never_a_skill.) + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path(); + fs::write( + dir.join("loose.md"), + "---\nname: loose\ndescription: standalone\n---\nbody", + ) + .unwrap(); + + let names: Vec = scan_skill_dir(dir, "claude", true) + .into_iter() + .map(|e| e.name) + .collect(); + assert_eq!(names, vec!["loose"]); + } + + #[test] + fn grok_project_skill_mcp_and_hook_are_discovered() { + use crate::adapter::grok::GrokAdapter; + use crate::adapter::McpTransport; + + let tmp = tempfile::tempdir().unwrap(); + let project = tmp.path().join("myrepo"); + let skill_dir = project.join(".grok/skills/proj-skill"); + fs::create_dir_all(&skill_dir).unwrap(); + fs::write( + skill_dir.join("SKILL.md"), + "---\nname: proj-skill\ndescription: grok project skill\n---\nbody", + ) + .unwrap(); + let nested_dir = project.join(".grok/skills/team/nested-skill"); + fs::create_dir_all(&nested_dir).unwrap(); + fs::write( + nested_dir.join("SKILL.md"), + "---\nname: nested-skill\ndescription: nested grok skill\n---\nbody", + ) + .unwrap(); + fs::create_dir_all(project.join(".grok/hooks")).unwrap(); + fs::write( + project.join(".grok/config.toml"), + r#" +[mcp_servers.http] +url = "https://example.com/mcp" +headers = { Authorization = "Bearer t" } + +[mcp_servers.sse] +url = "https://example.com/sse" +type = "sse" +"#, + ) + .unwrap(); + fs::write( + project.join(".grok/hooks/safety.json"), + r#"{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"echo grok-hook"}]}]}}"#, + ) + .unwrap(); + let plugin_dir = project.join(".grok/plugins/team-tool"); + fs::create_dir_all(&plugin_dir).unwrap(); + fs::write(plugin_dir.join("plugin.json"), r#"{"name":"team-tool"}"#).unwrap(); + + let adapter = GrokAdapter::with_home(tmp.path().to_path_buf()); + let exts = scan_project_extensions(&adapter, "myrepo", &project); + + let skill = exts + .iter() + .find(|e| e.kind == ExtensionKind::Skill && e.name == "proj-skill") + .expect("project Grok skill"); + assert!(matches!(skill.scope, ConfigScope::Project { .. })); + assert_eq!(skill.agents, vec!["grok"]); + // Grok discovers skills recursively — a team-folder layout is real. + let nested = exts + .iter() + .find(|e| e.kind == ExtensionKind::Skill && e.name == "nested-skill") + .expect("nested project Grok skill"); + assert!(matches!(nested.scope, ConfigScope::Project { .. })); + + let http = exts + .iter() + .find(|e| e.kind == ExtensionKind::Mcp && e.name == "http") + .expect("project Grok HTTP MCP"); + assert_eq!(http.mcp_transport, Some(McpTransport::Http)); + let sse = exts + .iter() + .find(|e| e.kind == ExtensionKind::Mcp && e.name == "sse") + .expect("project Grok SSE MCP"); + assert_eq!(sse.mcp_transport, Some(McpTransport::Sse)); + + let hook = exts + .iter() + .find(|e| e.kind == ExtensionKind::Hook) + .expect("project Grok hook"); + assert!(hook.name.contains("PreToolUse")); + assert!(hook.name.contains("echo grok-hook")); + assert_eq!(hook.agents, vec!["grok"]); + + let plugin = exts + .iter() + .find(|e| e.kind == ExtensionKind::Plugin && e.name == "team-tool") + .expect("project Grok plugin"); + assert!(matches!(plugin.scope, ConfigScope::Project { .. })); + assert!(!plugin.enabled); + assert_eq!(plugin.agents, vec!["grok"]); + } } #[cfg(test)] @@ -3243,6 +3539,70 @@ mod config_tests { assert!(anti.iter().any(|p| p.ends_with("frontend/ag-deep.md"))); } + /// One adapter naming a file under two spellings must collapse to one + /// row; two names the user symlinked together must not. Both halves are + /// asserted here because the fix for the first broke the second once. + #[test] + fn test_scan_agent_configs_dedups_case_spellings_but_keeps_symlinks() { + use crate::adapter::copilot::CopilotAdapter; + use crate::adapter::dsh::DshAdapter; + use crate::adapter::grok::GrokAdapter; + + let tmp = tempfile::tempdir().unwrap(); + let home = tmp.path(); + let project = home.join("myproject"); + fs::create_dir_all(project.join(".github")).unwrap(); + fs::write(project.join("AGENTS.md"), "# shared instructions").unwrap(); + let rule_names = |adapter: &dyn crate::adapter::AgentAdapter| -> Vec { + let projects = vec![( + "myproject".to_string(), + project.to_string_lossy().to_string(), + )]; + let mut names: Vec = scan_agent_configs(adapter, &projects) + .into_iter() + .filter(|c| c.category == ConfigCategory::Rules) + .map(|c| c.file_name) + .collect(); + names.sort(); + names + }; + + // Grok lists AGENTS.md and Agents.md so it covers both spellings on + // case-sensitive filesystems; on macOS/Windows they are one file and + // must not produce two identical-looking rows. + assert_eq!( + rule_names(&GrokAdapter::with_home(home.to_path_buf())), + vec!["AGENTS.md"], + "one file listed under two spellings is one row" + ); + + // `ln -s AGENTS.md CLAUDE.md` is the recommended AGENTS.md migration, + // and dsh lists both names in the same category. Same inode, two + // deliberate names — two rows. + #[cfg(unix)] + { + std::os::unix::fs::symlink("AGENTS.md", project.join("CLAUDE.md")).unwrap(); + assert_eq!( + rule_names(&DshAdapter::with_home(home.to_path_buf())), + vec!["AGENTS.md", "CLAUDE.md"], + "a symlinked twin keeps its own row" + ); + + // Copilot's pattern order puts the link first, so a path-only key + // would have dropped the real file rather than the alias. + std::os::unix::fs::symlink( + "../AGENTS.md", + project.join(".github/copilot-instructions.md"), + ) + .unwrap(); + assert_eq!( + rule_names(&CopilotAdapter::with_home(home.to_path_buf())), + vec!["AGENTS.md", "copilot-instructions.md"], + "the real file survives even when the alias is listed first" + ); + } + } + #[test] fn test_scan_agent_configs_skips_missing_files() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/hk-core/src/service.rs b/crates/hk-core/src/service.rs index 4fe9d5d..3d5d44d 100644 --- a/crates/hk-core/src/service.rs +++ b/crates/hk-core/src/service.rs @@ -799,7 +799,7 @@ fn find_skill_content( if !agent_filter.contains(&a.name().to_string()) { continue; } - for skill_dir in a.skill_dirs() { + for skill_dir in a.skill_dirs().iter().flat_map(|d| a.expand_skill_roots(d)) { let Ok(entries) = std::fs::read_dir(&skill_dir) else { continue; }; @@ -812,9 +812,10 @@ fn find_skill_content( } else { path.join("SKILL.md.disabled") } - } else if path - .extension() - .is_some_and(|e| e == "md" || e == "disabled") + } else if a.standalone_md_skills() + && path + .extension() + .is_some_and(|e| e == "md" || e == "disabled") { path.clone() } else { @@ -1004,9 +1005,13 @@ pub fn delete_extension( if !ext.agents.contains(&adapter.name().to_string()) { continue; } - for plugin in adapter.read_plugins() { - if scanner::plugin_extension_id(&plugin.name, &plugin.source, adapter.name()) - != id + for plugin in scanner::read_plugins_for_scope(adapter.as_ref(), &ext.scope) { + if scanner::plugin_extension_id_for_scope( + &plugin.name, + &plugin.source, + adapter.name(), + &ext.scope, + ) != id { continue; } @@ -1077,6 +1082,35 @@ pub fn delete_extension( &plugin.name, false, )?; + } else if adapter.name() == "grok" { + if let Some(ref path) = plugin.path { + remove_path(path)?; + } + if let Some(id) = plugin.uri.as_deref() { + deployer::remove_grok_plugin_lists( + &adapter.plugin_config_path(), + id, + Some(&plugin.name), + )?; + // Also clean the project lists: upstream merges a + // project `[plugins].disabled` ungated, so a stale + // entry there would silently disable a future + // same-named plugin. (Grok itself never cleans + // lists on uninstall; we do better.) + if matches!(ext.scope, ConfigScope::Project { .. }) + && let Some(project_cfg) = plugin + .path + .as_ref() + .and_then(|p| p.parent()) + .and_then(crate::adapter::grok::project_config_beside_plugins_dir) + { + deployer::remove_grok_plugin_lists( + &project_cfg, + id, + Some(&plugin.name), + )?; + } + } } else { // Everyone else answers through the adapter, so an // agent whose plugins are not simply directories is @@ -1206,7 +1240,12 @@ pub fn get_extension_content( &ext.scope, ); if candidate == id { - let mut lines = if let Some(url) = &server.url { + // Branch on transport, not url-presence: a Grok table + // with both `command` and a url is stdio (command + // wins) and must render its command, not the url. + let mut lines = if server.transport != crate::adapter::McpTransport::Stdio + && let Some(url) = &server.url + { let mut lines = vec![ format!("Transport: {}", server.transport.as_str()), format!("URL: {}", url), @@ -1296,9 +1335,13 @@ pub fn get_extension_content( if !ext.agents.contains(&adapter.name().to_string()) { continue; } - for plugin in adapter.read_plugins() { - if scanner::plugin_extension_id(&plugin.name, &plugin.source, adapter.name()) - == id + for plugin in scanner::read_plugins_for_scope(adapter.as_ref(), &ext.scope) { + if scanner::plugin_extension_id_for_scope( + &plugin.name, + &plugin.source, + adapter.name(), + &ext.scope, + ) == id { let path_str = plugin .path @@ -2887,6 +2930,99 @@ mod tests { ); } + #[test] + fn test_delete_extension_removes_grok_plugin_dir_and_lists() { + use crate::adapter; + use crate::adapter::grok::grok_plugin_id; + + let dir = TempDir::new().unwrap(); + let home = dir.path(); + let plugin_dir = home.join(".grok").join("plugins").join("weather"); + std::fs::create_dir_all(&plugin_dir).unwrap(); + std::fs::write(plugin_dir.join("plugin.json"), r#"{"name":"weather"}"#).unwrap(); + let id = grok_plugin_id("user", &plugin_dir, "weather"); + let config_path = home.join(".grok").join("config.toml"); + std::fs::write( + &config_path, + format!("[plugins]\nenabled = [\"{id}\"]\ndisabled = [\"other\"]\n"), + ) + .unwrap(); + + let store = Mutex::new(Store::open(&home.join("test.db")).unwrap()); + let adapters: Vec> = vec![Box::new( + adapter::grok::GrokAdapter::with_home(home.to_path_buf()), + )]; + let exts = scanner::scan_all(&adapters, &[]); + store.lock().sync_extensions(&exts).unwrap(); + let plugin = store + .lock() + .list_extensions(None, None) + .unwrap() + .into_iter() + .find(|e| e.kind == ExtensionKind::Plugin && e.name == "weather") + .expect("scanned grok plugin"); + delete_extension(&store, &adapters, &plugin.id).unwrap(); + assert!(!plugin_dir.exists()); + let post = std::fs::read_to_string(&config_path).unwrap(); + assert!(!post.contains(&id), "stable id cleaned from lists: {post}"); + assert!(post.contains("other"), "unrelated list entries kept: {post}"); + } + + #[test] + fn test_delete_project_grok_plugin_cleans_project_lists_only_when_referenced() { + use crate::adapter; + use crate::adapter::grok::grok_plugin_id; + + let dir = TempDir::new().unwrap(); + let home = dir.path(); + std::fs::create_dir_all(home.join(".grok")).unwrap(); + let project = home.join("repo"); + let plugin_dir = project.join(".grok/plugins/team-tool"); + std::fs::create_dir_all(&plugin_dir).unwrap(); + std::fs::write(plugin_dir.join("plugin.json"), r#"{"name":"team-tool"}"#).unwrap(); + let id = grok_plugin_id("project", &plugin_dir, "team-tool"); + let project_cfg = project.join(".grok/config.toml"); + std::fs::write( + &project_cfg, + format!("# team config\ntheme = \"dark\"\n\n[plugins]\ndisabled = [\"{id}\"]\n"), + ) + .unwrap(); + // User config never referenced this plugin — it must not be rewritten. + let user_cfg = home.join(".grok/config.toml"); + let user_original = "# hand-written\ntheme = \"light\"\n"; + std::fs::write(&user_cfg, user_original).unwrap(); + + let store = Mutex::new(Store::open(&home.join("test.db")).unwrap()); + store.lock().register_project_by_path(&project.to_string_lossy()); + let adapters: Vec> = vec![Box::new( + adapter::grok::GrokAdapter::with_home(home.to_path_buf()), + )]; + let exts = scanner::scan_project_extensions( + &*adapters[0], + "repo", + &project, + ); + let plugin = exts + .into_iter() + .find(|e| e.kind == ExtensionKind::Plugin && e.name == "team-tool") + .expect("project grok plugin"); + store.lock().sync_extensions(std::slice::from_ref(&plugin)).unwrap(); + + delete_extension(&store, &adapters, &plugin.id).unwrap(); + assert!(!plugin_dir.exists()); + let project_after = std::fs::read_to_string(&project_cfg).unwrap(); + assert!(!project_after.contains(&id), "project list cleaned: {project_after}"); + assert!( + project_after.contains("theme"), + "unrelated project keys survive: {project_after}" + ); + assert_eq!( + std::fs::read_to_string(&user_cfg).unwrap(), + user_original, + "unreferencing user config must not be rewritten" + ); + } + #[test] fn run_agent_uninstall_reports_a_missing_binary_without_deleting_anything() { // The whole point of delegating is that the agent's own uninstaller @@ -3317,4 +3453,119 @@ mod tests { ); assert!(written.contains("echo hi")); } + + #[test] + fn test_install_to_agent_remote_mcp_claude_to_grok() { + use crate::adapter; + + let dir = TempDir::new().unwrap(); + let home = dir.path(); + let store = Mutex::new(Store::open(&home.join("test.db")).unwrap()); + std::fs::create_dir_all(home.join(".grok")).unwrap(); + std::fs::write( + home.join(".claude.json"), + r#"{"mcpServers":{ + "linear":{"type":"http","url":"https://mcp.linear.app/mcp", + "headers":{"Authorization":"Bearer tok"}}, + "events":{"type":"sse","url":"https://example.com/sse"} + }}"#, + ) + .unwrap(); + let adapters: Vec> = vec![ + Box::new(adapter::claude::ClaudeAdapter::with_home( + home.to_path_buf(), + )), + Box::new(adapter::grok::GrokAdapter::with_home(home.to_path_buf())), + ]; + let seed = |name: &str| { + let id = scanner::stable_id_for(name, "mcp", "claude"); + let mut ext = make_skill(ConfigScope::Global, None); + ext.id = id.clone(); + ext.kind = ExtensionKind::Mcp; + ext.name = name.into(); + ext.source_path = None; + store.lock().insert_extension(&ext).unwrap(); + id + }; + + let linear_id = seed("linear"); + install_to_agent( + &store, + &adapters, + &linear_id, + "grok", + None, + &ConfigScope::Global, + ) + .unwrap(); + let toml_str = std::fs::read_to_string(home.join(".grok").join("config.toml")).unwrap(); + let doc: toml::Table = toml_str.parse().unwrap(); + let entry = doc["mcp_servers"]["linear"].as_table().unwrap(); + assert_eq!(entry["url"].as_str(), Some("https://mcp.linear.app/mcp")); + assert_eq!( + entry["headers"]["Authorization"].as_str(), + Some("Bearer tok") + ); + assert!( + !entry.contains_key("http_headers"), + "Grok remote MCP uses headers, not Codex http_headers: {toml_str}" + ); + + let sse_id = seed("events"); + install_to_agent( + &store, + &adapters, + &sse_id, + "grok", + None, + &ConfigScope::Global, + ) + .unwrap(); + let toml_str = std::fs::read_to_string(home.join(".grok").join("config.toml")).unwrap(); + let doc: toml::Table = toml_str.parse().unwrap(); + let sse = doc["mcp_servers"]["events"].as_table().unwrap(); + assert_eq!(sse["url"].as_str(), Some("https://example.com/sse")); + assert_eq!(sse["type"].as_str(), Some("sse")); + } + + #[test] + fn test_install_to_agent_grok_project_skill_and_mcp() { + use crate::adapter; + + let dir = TempDir::new().unwrap(); + let home = dir.path(); + let store = Mutex::new(Store::open(&home.join("test.db")).unwrap()); + let project_dir = home.join("proj"); + std::fs::create_dir_all(&project_dir).unwrap(); + let scope = register_test_project(&store, "proj", &project_dir); + let source_id = seed_claude_skill(&store, home, "foo"); + + std::fs::write( + home.join(".claude.json"), + r#"{"mcpServers":{"srv":{"command":"npx","args":["-y","srv"]}}}"#, + ) + .unwrap(); + let mcp_id = scanner::stable_id_for("srv", "mcp", "claude"); + let mut mcp = make_skill(ConfigScope::Global, None); + mcp.id = mcp_id.clone(); + mcp.kind = ExtensionKind::Mcp; + mcp.name = "srv".into(); + mcp.source_path = None; + store.lock().insert_extension(&mcp).unwrap(); + + let adapters: Vec> = vec![ + Box::new(adapter::claude::ClaudeAdapter::with_home( + home.to_path_buf(), + )), + Box::new(adapter::grok::GrokAdapter::with_home(home.to_path_buf())), + ]; + + install_to_agent(&store, &adapters, &source_id, "grok", None, &scope).unwrap(); + assert!(project_dir.join(".grok/skills/foo/SKILL.md").exists()); + + install_to_agent(&store, &adapters, &mcp_id, "grok", None, &scope).unwrap(); + let deployed = std::fs::read_to_string(project_dir.join(".grok/config.toml")).unwrap(); + assert!(deployed.contains("[mcp_servers.srv]"), "{deployed}"); + assert!(deployed.contains("npx"), "{deployed}"); + } } diff --git a/crates/hk-core/tests/toggle_integration.rs b/crates/hk-core/tests/toggle_integration.rs index 0969c41..98a28ff 100644 --- a/crates/hk-core/tests/toggle_integration.rs +++ b/crates/hk-core/tests/toggle_integration.rs @@ -22,7 +22,7 @@ fn test_skill_disable_enable_roundtrip() { .unwrap(); // Phase 1: Initial scan — skill is enabled - let exts = scan_skill_dir(&skill_dir, "claude"); + let exts = scan_skill_dir(&skill_dir, "claude", true); assert_eq!(exts.len(), 1); assert!(exts[0].enabled); store.sync_extensions(&exts).unwrap(); @@ -41,7 +41,7 @@ fn test_skill_disable_enable_roundtrip() { store.set_enabled(&ext_id, false).unwrap(); // Phase 3: Re-scan — disabled skill should be found with enabled=false - let exts = scan_skill_dir(&skill_dir, "claude"); + let exts = scan_skill_dir(&skill_dir, "claude", true); assert_eq!(exts.len(), 1, "Scanner should find disabled skill"); assert!(!exts[0].enabled, "Disabled skill should have enabled=false"); assert_eq!( @@ -65,7 +65,7 @@ fn test_skill_disable_enable_roundtrip() { store.set_enabled(&ext_id, true).unwrap(); // Phase 5: Re-scan — should be enabled again - let exts = scan_skill_dir(&skill_dir, "claude"); + let exts = scan_skill_dir(&skill_dir, "claude", true); assert_eq!(exts.len(), 1); assert!(exts[0].enabled); store.sync_extensions(&exts).unwrap(); @@ -622,3 +622,225 @@ fn test_dsh_bundle_row_toggle_writes_home_patch_and_never_the_bundle_patch() { assert!(home_text.contains("- id: hmr\n disabled: false"), "{home_text}"); bundle_patches_untouched(); } + +#[test] +fn test_grok_mcp_native_toggle_roundtrip_and_preserves_unrelated() { + use hk_core::adapter::grok::GrokAdapter; + use hk_core::adapter::AgentAdapter; + + let dir = TempDir::new().unwrap(); + let store = Store::open(&dir.path().join("test.db")).unwrap(); + let adapter = || GrokAdapter::with_home(dir.path().to_path_buf()); + std::fs::create_dir_all(dir.path().join(".grok")).unwrap(); + std::fs::write( + dir.path().join(".grok/config.toml"), + r#"theme = "dark" +startup_timeout_sec = 12 + +[mcp_servers.github] +command = "npx" +args = ["-y", "@mcp/github"] +cwd = "/tmp/work" +"#, + ) + .unwrap(); + + let exts = hk_core::scanner::scan_mcp_servers(&adapter()); + assert_eq!(exts.len(), 1); + assert!(exts[0].enabled); + store.sync_extensions(&exts).unwrap(); + let ext_id = store.list_extensions(None, None).unwrap()[0].id.clone(); + let adapters: Vec> = vec![Box::new(adapter())]; + + hk_core::manager::toggle_extension_with_adapters(&store, &adapters, &ext_id, false).unwrap(); + let servers = adapter().read_mcp_servers(); + assert!(!servers[0].enabled); + assert!(store.get_disabled_config(&ext_id).unwrap().is_none()); + let user = std::fs::read_to_string(dir.path().join(".grok/config.toml")).unwrap(); + assert!(user.contains("disabled_mcp_servers")); + assert!(user.contains("github")); + assert!(user.contains("theme")); + assert!(user.contains("startup_timeout_sec")); + assert!(user.contains("cwd")); + + hk_core::manager::toggle_extension_with_adapters(&store, &adapters, &ext_id, false).unwrap(); + let again = adapter().read_mcp_servers(); + assert!(!again[0].enabled, "disable is idempotent"); + + hk_core::manager::toggle_extension_with_adapters(&store, &adapters, &ext_id, true).unwrap(); + assert!(adapter().read_mcp_servers()[0].enabled); + let user = std::fs::read_to_string(dir.path().join(".grok/config.toml")).unwrap(); + assert!( + !user.contains("disabled_mcp_servers"), + "empty disable list is removed: {user}" + ); +} + +#[test] +fn test_grok_project_mcp_disable_does_not_rewrite_project_file() { + use hk_core::adapter::grok::GrokAdapter; + use hk_core::adapter::AgentAdapter; + + let dir = TempDir::new().unwrap(); + let store = Store::open(&dir.path().join("test.db")).unwrap(); + let adapter = || GrokAdapter::with_home(dir.path().to_path_buf()); + std::fs::create_dir_all(dir.path().join(".grok")).unwrap(); + std::fs::write(dir.path().join(".grok/config.toml"), "theme = \"dark\"\n").unwrap(); + + let project = dir.path().join("repo"); + let project_cfg = project.join(".grok/config.toml"); + std::fs::create_dir_all(project_cfg.parent().unwrap()).unwrap(); + let original = r#"# keep this comment if we can +[mcp_servers.shared] +command = "echo" +cwd = "/shared" +enabled = true +"#; + std::fs::write(&project_cfg, original).unwrap(); + + let exts = hk_core::scanner::scan_project_extensions(&adapter(), "repo", &project); + let mcp = exts + .into_iter() + .find(|e| e.kind == hk_core::models::ExtensionKind::Mcp && e.name == "shared") + .expect("project MCP"); + assert!(mcp.enabled); + store.register_project_by_path(&project.to_string_lossy()); + store.sync_extensions(std::slice::from_ref(&mcp)).unwrap(); + let adapters: Vec> = vec![Box::new(adapter())]; + + hk_core::manager::toggle_extension_with_adapters(&store, &adapters, &mcp.id, false).unwrap(); + let after_disable = std::fs::read_to_string(&project_cfg).unwrap(); + assert_eq!( + after_disable, original, + "personal disable must not rewrite the shared project file" + ); + let user = std::fs::read_to_string(dir.path().join(".grok/config.toml")).unwrap(); + assert!(user.contains("disabled_mcp_servers")); + assert!(user.contains("theme")); + assert!(!adapter().read_mcp_servers_from(&project_cfg)[0].enabled); + + // A later sticky `enabled = false` on the shared file is unstuck only + // when the user re-enables (store still has enabled=false here). + std::fs::write( + &project_cfg, + r#" +[mcp_servers.shared] +command = "echo" +cwd = "/shared" +enabled = false +other = "keep-me" +"#, + ) + .unwrap(); + hk_core::manager::toggle_extension_with_adapters(&store, &adapters, &mcp.id, true).unwrap(); + let unstuck = std::fs::read_to_string(&project_cfg).unwrap(); + assert!(unstuck.contains("keep-me"), "unrelated keys survive unstick: {unstuck}"); + assert!(adapter().read_mcp_servers_from(&project_cfg)[0].enabled); +} + +#[test] +fn test_grok_hook_native_toggle_roundtrip() { + use hk_core::adapter::grok::GrokAdapter; + use hk_core::adapter::AgentAdapter; + + let dir = TempDir::new().unwrap(); + let store = Store::open(&dir.path().join("test.db")).unwrap(); + let adapter = || GrokAdapter::with_home(dir.path().to_path_buf()); + let hook_file = dir.path().join(".grok/hooks/session-start.json"); + std::fs::create_dir_all(hook_file.parent().unwrap()).unwrap(); + std::fs::write( + &hook_file, + r#"{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"echo hi"}]}]}}"#, + ) + .unwrap(); + + let exts = hk_core::scanner::scan_hooks(&adapter()); + assert_eq!(exts.len(), 1); + assert!(exts[0].enabled); + store.sync_extensions(&exts).unwrap(); + let ext_id = store.list_extensions(None, None).unwrap()[0].id.clone(); + let adapters: Vec> = vec![Box::new(adapter())]; + + hk_core::manager::toggle_extension_with_adapters(&store, &adapters, &ext_id, false).unwrap(); + let hooks = adapter().read_hooks(); + assert_eq!(hooks.len(), 1); + assert!(!hooks[0].enabled); + let disabled = std::fs::read_to_string(dir.path().join(".grok/disabled-hooks")).unwrap(); + assert!(disabled.contains("global/session-start:pre_tool_use[0].hooks[0]")); + assert!(store.get_disabled_config(&ext_id).unwrap().is_none()); + + hk_core::manager::toggle_extension_with_adapters(&store, &adapters, &ext_id, true).unwrap(); + assert!(adapter().read_hooks()[0].enabled); +} + +#[test] +fn test_grok_plugin_native_toggle_roundtrip() { + use hk_core::adapter::grok::{grok_plugin_id, GrokAdapter}; + use hk_core::adapter::AgentAdapter; + + let dir = TempDir::new().unwrap(); + let store = Store::open(&dir.path().join("test.db")).unwrap(); + let adapter = || GrokAdapter::with_home(dir.path().to_path_buf()); + let plugin = dir.path().join(".grok/plugins/my-tool"); + std::fs::create_dir_all(&plugin).unwrap(); + std::fs::write(plugin.join("plugin.json"), r#"{"name":"my-tool"}"#).unwrap(); + let id = grok_plugin_id("user", &plugin, "my-tool"); + std::fs::write( + dir.path().join(".grok/config.toml"), + format!("[plugins]\nenabled = [\"{id}\"]\n"), + ) + .unwrap(); + + let exts = hk_core::scanner::scan_plugins(&adapter()); + let row = exts.into_iter().find(|e| e.name == "my-tool").unwrap(); + assert!(row.enabled); + store.sync_extensions(std::slice::from_ref(&row)).unwrap(); + let adapters: Vec> = vec![Box::new(adapter())]; + + hk_core::manager::toggle_extension_with_adapters(&store, &adapters, &row.id, false).unwrap(); + assert!(!adapter().read_plugins()[0].enabled); + let cfg = std::fs::read_to_string(dir.path().join(".grok/config.toml")).unwrap(); + assert!(cfg.contains("disabled")); + assert!(store.get_disabled_config(&row.id).unwrap().is_none()); + + hk_core::manager::toggle_extension_with_adapters(&store, &adapters, &row.id, true).unwrap(); + assert!(adapter().read_plugins()[0].enabled); +} + +#[test] +fn test_grok_project_plugin_toggle_writes_user_lists_only() { + use hk_core::adapter::grok::GrokAdapter; + use hk_core::adapter::AgentAdapter; + + let dir = TempDir::new().unwrap(); + let store = Store::open(&dir.path().join("test.db")).unwrap(); + let adapter = || GrokAdapter::with_home(dir.path().to_path_buf()); + let project = dir.path().join("repo"); + let plugin = project.join(".grok/plugins/team-tool"); + std::fs::create_dir_all(&plugin).unwrap(); + std::fs::write(plugin.join("plugin.json"), r#"{"name":"team-tool"}"#).unwrap(); + let project_cfg = project.join(".grok/config.toml"); + let original = "theme = \"dark\"\n"; + std::fs::write(&project_cfg, original).unwrap(); + + let exts = hk_core::scanner::scan_project_extensions(&adapter(), "repo", &project); + let row = exts + .into_iter() + .find(|e| e.kind == hk_core::models::ExtensionKind::Plugin && e.name == "team-tool") + .expect("project plugin"); + assert!(!row.enabled); + store.register_project_by_path(&project.to_string_lossy()); + store.sync_extensions(std::slice::from_ref(&row)).unwrap(); + let adapters: Vec> = vec![Box::new(adapter())]; + + hk_core::manager::toggle_extension_with_adapters(&store, &adapters, &row.id, true).unwrap(); + assert_eq!(std::fs::read_to_string(&project_cfg).unwrap(), original); + let user = std::fs::read_to_string(dir.path().join(".grok/config.toml")).unwrap(); + assert!(user.contains("enabled")); + assert!(user.contains("team-tool")); + let enabled = hk_core::scanner::scan_project_extensions(&adapter(), "repo", &project) + .into_iter() + .find(|e| e.name == "team-tool") + .unwrap(); + assert!(enabled.enabled); +} diff --git a/media/pr-grok-build/agent-detail.png b/media/pr-grok-build/agent-detail.png new file mode 100644 index 0000000..077e386 Binary files /dev/null and b/media/pr-grok-build/agent-detail.png differ diff --git a/media/pr-grok-build/extensions-filter.png b/media/pr-grok-build/extensions-filter.png new file mode 100644 index 0000000..36f9955 Binary files /dev/null and b/media/pr-grok-build/extensions-filter.png differ diff --git a/media/pr-grok-build/overview.png b/media/pr-grok-build/overview.png new file mode 100644 index 0000000..92e3668 Binary files /dev/null and b/media/pr-grok-build/overview.png differ diff --git a/src/components/extensions/extension-detail.tsx b/src/components/extensions/extension-detail.tsx index 0a9e2ac..bbb2850 100644 --- a/src/components/extensions/extension-detail.tsx +++ b/src/components/extensions/extension-detail.tsx @@ -343,6 +343,22 @@ export function ExtensionDetail() { )} + {/* Grok folder-trust gate — project-level hooks, MCP/LSP servers, + * plugins, and permission rules load only after the folder is + * trusted inside Grok (source-verified: folder_trust.rs decide()). + * Skills are NOT trust-gated, so this stays off skill rows. Keyed + * on the instances so it also shows under the "All" scope filter. */} + {["hook", "mcp", "plugin"].includes(group.kind) && + group.agents.includes("grok") && + group.instances.some( + (i) => i.agents.includes("grok") && i.scope.type === "project", + ) && ( +
+ + {t("detail.grokFolderTrustNote")} +
+ )} + {/* Silent skill-drop warning — the `skill-invocation-key-case` audit * rule means DeepSeek Harness discards this ENTIRE skill (functional * breakage, not just a score deduction), so it warrants an inline diff --git a/src/components/extensions/extension-filters.tsx b/src/components/extensions/extension-filters.tsx index 9a6974d..8a1b991 100644 --- a/src/components/extensions/extension-filters.tsx +++ b/src/components/extensions/extension-filters.tsx @@ -47,6 +47,7 @@ const AGENT_FILTER_COLORS: Record = { kiro: "bg-agent-kiro/10 text-agent-kiro border-agent-kiro/30", omp: "bg-agent-omp/10 text-agent-omp border-agent-omp/30", dsh: "bg-agent-dsh/10 text-agent-dsh border-agent-dsh/30", + grok: "bg-agent-grok/10 text-agent-grok border-agent-grok/30", }; export function ExtensionFilters() { diff --git a/src/components/onboarding/onboarding.tsx b/src/components/onboarding/onboarding.tsx index c61b726..3aa3c7d 100644 --- a/src/components/onboarding/onboarding.tsx +++ b/src/components/onboarding/onboarding.tsx @@ -267,6 +267,7 @@ const FLOAT_DELAYS: Record<(typeof AGENT_ORDER)[number], number> = { kiro: 1.4, omp: 1.0, dsh: 1.7, + grok: 2.1, }; const SCATTER_POSITIONS: Record< (typeof AGENT_ORDER)[number], @@ -284,6 +285,7 @@ const SCATTER_POSITIONS: Record< kiro: { x: 40, y: -126, r: -10 }, omp: { x: -200, y: 60, r: 22 }, dsh: { x: 90, y: 135, r: 7 }, + grok: { x: -30, y: 145, r: -12 }, }; function HandAnnotation({ diff --git a/src/components/shared/agent-card.tsx b/src/components/shared/agent-card.tsx index 1b6d082..c6a12f6 100644 --- a/src/components/shared/agent-card.tsx +++ b/src/components/shared/agent-card.tsx @@ -14,6 +14,7 @@ const CLICK_DURATIONS: Partial> = { kiro: 1100, omp: 900, dsh: 1200, + grok: 900, }; export function AgentCard({ agent }: AgentCardProps) { diff --git a/src/components/shared/agent-mascot/agent-mascot.tsx b/src/components/shared/agent-mascot/agent-mascot.tsx index 8685431..f36ab4d 100644 --- a/src/components/shared/agent-mascot/agent-mascot.tsx +++ b/src/components/shared/agent-mascot/agent-mascot.tsx @@ -7,6 +7,7 @@ import { CursorMascot } from "./cursor-mascot"; import { DevinMascot } from "./devin-mascot"; import { DshMascot } from "./dsh-mascot"; import { FallbackMascot } from "./fallback-mascot"; +import { GrokMascot } from "./grok-mascot"; import { GeminiMascot } from "./gemini-mascot"; import { HermesMascot } from "./hermes-mascot"; import { KiroMascot } from "./kiro-mascot"; @@ -82,6 +83,11 @@ const MASCOT_MAP: Record< className: "mascot-dsh", scale: 0.95, }, + grok: { + component: GrokMascot, + className: "mascot-grok", + scale: 0.95, + }, }; export function AgentMascot({ diff --git a/src/components/shared/agent-mascot/grok-mascot.tsx b/src/components/shared/agent-mascot/grok-mascot.tsx new file mode 100644 index 0000000..c308225 --- /dev/null +++ b/src/components/shared/agent-mascot/grok-mascot.tsx @@ -0,0 +1,30 @@ +// Grok Build mascot — LobeHub Icons `grok` mark +// https://unpkg.com/@lobehub/icons-static-svg@latest/icons/grok.svg +// Brand color on icons.lobehub.com is #000; we paint with --mascot-icon-color +// so the silhouette stays readable on light and dark cards. +// Motion lives on the .mascot-grok wrapper in mascot.css. + +interface MascotSvgProps { + size: number; +} + +const GROK_MARK_D = + "M9.27 15.29l7.978-5.897c.391-.29.95-.177 1.137.272.98 2.369.542 5.215-1.41 7.169-1.951 1.954-4.667 2.382-7.149 1.406l-2.711 1.257c3.889 2.661 8.611 2.003 11.562-.953 2.341-2.344 3.066-5.539 2.388-8.42l.006.007c-.983-4.232.242-5.924 2.75-9.383.06-.082.12-.164.179-.248l-3.301 3.305v-.01L9.267 15.292M7.623 16.723c-2.792-2.67-2.31-6.801.071-9.184 1.761-1.763 4.647-2.483 7.166-1.425l2.705-1.25a7.808 7.808 0 00-1.829-1A8.975 8.975 0 005.984 5.83c-2.533 2.536-3.33 6.436-1.962 9.764 1.022 2.487-.653 4.246-2.34 6.022-.599.63-1.199 1.259-1.682 1.925l7.62-6.815"; + +export function GrokMascot({ size }: MascotSvgProps) { + return ( + + ); +} diff --git a/src/components/shared/agent-mascot/mascot.css b/src/components/shared/agent-mascot/mascot.css index 14861c8..7fb8550 100644 --- a/src/components/shared/agent-mascot/mascot.css +++ b/src/components/shared/agent-mascot/mascot.css @@ -1460,6 +1460,28 @@ .mascot-dsh.is-clicked .dsh-drop-2 { --fling-x: 0px; } .mascot-dsh.is-clicked .dsh-drop-3 { --fling-x: 3.5px; animation-delay: 0.3s; } +/* Grok: LobeHub mark. Quiet hover pulse; click is a brief scale flash. */ +.mascot-grok .grok-mark { + transform-box: fill-box; + transform-origin: center; +} +.mascot-grok.is-animated .grok-mark { + animation: grok-pulse 2.4s ease-in-out infinite; +} +.mascot-grok.is-clicked .grok-svg { + animation: grok-flash 0.9s ease-out; +} + +@keyframes grok-pulse { + 0%, 100% { transform: scale(1); opacity: 0.92; } + 50% { transform: scale(1.06); opacity: 1; } +} +@keyframes grok-flash { + 0% { transform: scale(1); opacity: 0.9; } + 35% { transform: scale(1.16); opacity: 1; } + 100% { transform: scale(1); opacity: 0.92; } +} + /* Sine-like bob: per-segment timing functions so velocity peaks crossing the midline and only reaches zero at the extremes — a single easing for the whole animation would stall at every keyframe boundary instead. */ @@ -1807,6 +1829,8 @@ .mascot-omp.is-animated *, .mascot-dsh.is-animated, .mascot-dsh.is-animated *, + .mascot-grok.is-animated, + .mascot-grok.is-animated *, .mascot-fallback.is-animated *, .mascot-claude.is-clicked, .mascot-claude.is-clicked *, @@ -1830,6 +1854,8 @@ .mascot-omp.is-clicked *, .mascot-dsh.is-clicked, .mascot-dsh.is-clicked *, + .mascot-grok.is-clicked, + .mascot-grok.is-clicked *, .mascot-fallback.is-clicked * { animation: none; transition: none; diff --git a/src/index.css b/src/index.css index d1784e6..3b8d4be 100644 --- a/src/index.css +++ b/src/index.css @@ -100,6 +100,7 @@ --agent-kiro: oklch(0.58 0.15 305); --agent-omp: oklch(0.58 0.13 160); --agent-dsh: oklch(0.55 0.15 270); + --agent-grok: oklch(0.38 0.04 75); --toast-success-bg: oklch(0.94 0.06 155); --toast-success-border: oklch(0.72 0.16 155); --toast-success-text: oklch(0.4 0.1 155); @@ -206,6 +207,7 @@ --agent-kiro: oklch(0.78 0.13 305); --agent-omp: oklch(0.78 0.11 160); --agent-dsh: oklch(0.75 0.13 270); + --agent-grok: oklch(0.86 0.04 75); --toast-success-bg: oklch(0.23 0.04 155); --toast-success-border: oklch(0.55 0.14 155); --toast-success-text: oklch(0.78 0.12 155); @@ -306,6 +308,7 @@ --agent-kiro: oklch(0.58 0.15 305); --agent-omp: oklch(0.58 0.13 160); --agent-dsh: oklch(0.55 0.15 270); + --agent-grok: oklch(0.38 0.04 75); --toast-success-bg: oklch(0.94 0.06 155); --toast-success-border: oklch(0.65 0.15 155); --toast-success-text: oklch(0.4 0.1 155); @@ -400,6 +403,7 @@ --agent-kiro: oklch(0.78 0.13 305); --agent-omp: oklch(0.78 0.11 160); --agent-dsh: oklch(0.75 0.13 270); + --agent-grok: oklch(0.86 0.04 75); --toast-success-bg: oklch(0.26 0.04 155); --toast-success-border: oklch(0.55 0.14 155); --toast-success-text: oklch(0.78 0.12 155); @@ -506,6 +510,7 @@ --agent-kiro: oklch(0.58 0.15 305); --agent-omp: oklch(0.58 0.13 160); --agent-dsh: oklch(0.55 0.15 270); + --agent-grok: oklch(0.38 0.04 75); --toast-success-bg: oklch(0.94 0.06 155); --toast-success-border: oklch(0.72 0.16 155); --toast-success-text: oklch(0.4 0.1 155); @@ -606,6 +611,7 @@ --agent-kiro: oklch(0.78 0.13 305); --agent-omp: oklch(0.78 0.11 160); --agent-dsh: oklch(0.75 0.13 270); + --agent-grok: oklch(0.86 0.04 75); --toast-success-bg: oklch(0.23 0.04 155); --toast-success-border: oklch(0.55 0.14 155); --toast-success-text: oklch(0.78 0.12 155); @@ -684,6 +690,7 @@ html.dark[data-theme="tiesen"][data-web="true"] { --color-agent-kiro: var(--agent-kiro); --color-agent-omp: var(--agent-omp); --color-agent-dsh: var(--agent-dsh); + --color-agent-grok: var(--agent-grok); --color-sidebar: var(--sidebar); --color-sidebar-foreground: var(--sidebar-foreground); --color-sidebar-primary: var(--sidebar-primary); diff --git a/src/lib/__tests__/agent-capabilities.test.ts b/src/lib/__tests__/agent-capabilities.test.ts index b004da3..8782aeb 100644 --- a/src/lib/__tests__/agent-capabilities.test.ts +++ b/src/lib/__tests__/agent-capabilities.test.ts @@ -42,6 +42,12 @@ const WINDSURF = agent("windsurf", { hooks_supported: true, global_hook_install: true, }); +const GROK = agent("grok", { + project_install: { skill: true, mcp: true, hook: true, cli: true }, + hooks_supported: true, + global_hook_install: true, + mcp_remote: { http: true, sse: true }, +}); describe("canInstallAtScope", () => { it("returns true for any agent/kind at global and all scopes", () => { @@ -58,6 +64,9 @@ describe("canInstallAtScope", () => { expect(canInstallAtScope(WINDSURF, "skill", PROJECT)).toBe(true); // Windsurf MCP is global-only upstream. expect(canInstallAtScope(WINDSURF, "mcp", PROJECT)).toBe(false); + expect(canInstallAtScope(GROK, "skill", PROJECT)).toBe(true); + expect(canInstallAtScope(GROK, "mcp", PROJECT)).toBe(true); + expect(canInstallAtScope(GROK, "hook", PROJECT)).toBe(true); }); it("returns false at project scope for Hermes (global-only, hermes-agent#4667)", () => { @@ -98,6 +107,8 @@ describe("canReceiveMcpTransport", () => { // Codex speaks Streamable HTTP only. expect(canReceiveMcpTransport(codex, "http")).toBe(true); expect(canReceiveMcpTransport(codex, "sse")).toBe(false); + expect(canReceiveMcpTransport(GROK, "http")).toBe(true); + expect(canReceiveMcpTransport(GROK, "sse")).toBe(true); }); it("gates remote transports off when capabilities are absent (old backend / unknown agent)", () => { diff --git a/src/lib/__tests__/types.test.ts b/src/lib/__tests__/types.test.ts index fcb1ef7..7b746d1 100644 --- a/src/lib/__tests__/types.test.ts +++ b/src/lib/__tests__/types.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import type { Extension, GroupedExtension } from "../types"; import { + AGENT_ORDER, agentDisplayName, extensionGroupKey, formatRelativeTime, @@ -332,6 +333,13 @@ describe("sortAgentNames", () => { }); }); +describe("AGENT_ORDER", () => { + it("lists 13 agents with grok last", () => { + expect(AGENT_ORDER).toHaveLength(13); + expect(AGENT_ORDER[AGENT_ORDER.length - 1]).toBe("grok"); + }); +}); + describe("agentDisplayName", () => { it("returns display name for known agents", () => { expect(agentDisplayName("claude")).toBe("Claude Code"); @@ -342,6 +350,7 @@ describe("agentDisplayName", () => { expect(agentDisplayName("kiro")).toBe("Kiro"); expect(agentDisplayName("omp")).toBe("Oh My Pi"); expect(agentDisplayName("dsh")).toBe("DeepSeek"); + expect(agentDisplayName("grok")).toBe("Grok Build"); }); it("capitalizes first letter for unknown agents", () => { diff --git a/src/lib/i18n/locales/en/extensions.json b/src/lib/i18n/locales/en/extensions.json index fbfbdaf..feae85c 100644 --- a/src/lib/i18n/locales/en/extensions.json +++ b/src/lib/i18n/locales/en/extensions.json @@ -73,6 +73,7 @@ "partOfSkillPrefix": "This skill is part of ", "codexHooksWarning": "Codex hooks must be trusted via `codex /hooks` before they run.", "dshMcpPluginNote": "In DeepSeek Harness this server appears in Settings → Plugins as an 'mcp-client' plugin card (search this server's name there to locate it).", + "grokFolderTrustNote": "To activate: open a terminal in this project folder, run `grok`, and answer yes when it asks whether you trust the folder.", "skillInvocationKeyWarning": "This skill's frontmatter uses a camelCase invocation key — DeepSeek Harness silently drops the whole skill. Rename to the kebab-case form (see Audit for details); re-run the audit after fixing and this warning disappears.", "enabled": "Enabled", "disabled": "Disabled", diff --git a/src/lib/i18n/locales/zh-TW/extensions.json b/src/lib/i18n/locales/zh-TW/extensions.json index b6a93d3..87fc36b 100644 --- a/src/lib/i18n/locales/zh-TW/extensions.json +++ b/src/lib/i18n/locales/zh-TW/extensions.json @@ -73,6 +73,7 @@ "partOfSkillPrefix": "這個 skill 屬於 ", "codexHooksWarning": "Codex hook 必須先透過 `codex /hooks` 信任後才能執行。", "dshMcpPluginNote": "在 DeepSeek Harness 中,此 server 顯示在 Settings → Plugins 裡,是名為 mcp-client 的 plugin 卡片(在其中搜尋此 server 名稱可定位)。", + "grokFolderTrustNote": "啟用方法:在該專案目錄下開啟終端機執行 `grok`,當它詢問是否信任該資料夾時選擇「是」。", "skillInvocationKeyWarning": "此 skill 的 frontmatter 使用了 camelCase 呼叫鍵——DeepSeek Harness 會靜默捨棄整個 skill。請改為 kebab-case 寫法(詳見稽核);修正後重新執行稽核,此提示會消失。", "enabled": "已啟用", "disabled": "已停用", diff --git a/src/lib/i18n/locales/zh/extensions.json b/src/lib/i18n/locales/zh/extensions.json index aa2432a..93b5d22 100644 --- a/src/lib/i18n/locales/zh/extensions.json +++ b/src/lib/i18n/locales/zh/extensions.json @@ -73,6 +73,7 @@ "partOfSkillPrefix": "此 skill 属于 ", "codexHooksWarning": "Codex hook 必须先通过 `codex /hooks` 信任后才能运行。", "dshMcpPluginNote": "在 DeepSeek Harness 中,此 server 显示在 Settings → Plugins 里,是名为 mcp-client 的插件卡片(在其中搜索此 server 名称可定位)。", + "grokFolderTrustNote": "启用方法:在该项目目录下打开终端运行 `grok`,当它询问是否信任该文件夹时选择「是」。", "skillInvocationKeyWarning": "此 skill 的 frontmatter 使用了 camelCase 调用键——DeepSeek Harness 会静默丢弃整个 skill。请改为 kebab-case 写法(详见审计);修复后重新运行审计,此提示会消失。", "enabled": "已启用", "disabled": "已禁用", diff --git a/src/lib/types.ts b/src/lib/types.ts index 302ad72..f75635d 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -429,6 +429,7 @@ export const AGENT_ORDER = [ "kiro", "omp", "dsh", + "grok", ] as const; /** Sort an array of agents (or agent-like objects with a `name` field) by a given order. */ @@ -456,6 +457,7 @@ const AGENT_DISPLAY_NAMES: Record = { kiro: "Kiro", omp: "Oh My Pi", dsh: "DeepSeek", + grok: "Grok Build", }; /** Get the display name for an agent (e.g. "claude" → "Claude Code"). */