Skip to content
Merged
13 changes: 6 additions & 7 deletions crates/hk-core/src/adapter/antigravity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,11 @@ impl AgentAdapter for AntigravityAdapter {
dirs
}
fn project_skill_dirs(&self) -> Vec<String> {
// Antigravity 1.18.4+ migrated from `.agent/` (singular) to `.agents/`
// (plural). Both still load; `.agents/` is canonical going forward.
// Source: https://discuss.ai.google.dev/t/new-folder-for-rules/126165
// `.agents/skills/` (plural) is the current canonical project path;
// `.agent/skills/` (singular) is still live for the Antigravity CLI,
// not merely legacy — both load.
// Source: https://codelabs.developers.google.com/getting-started-with-antigravity-skills
// (folder rename context: https://discuss.ai.google.dev/t/new-folder-for-rules/126165)
vec![".agents/skills".into(), ".agent/skills".into()]
}
fn mcp_config_path(&self) -> PathBuf {
Expand Down Expand Up @@ -126,10 +128,7 @@ impl AgentAdapter for AntigravityAdapter {
fn project_rules_patterns(&self) -> Vec<String> {
// `.agents/` is canonical (1.18.4+); `.agent/` kept for backward compat.
// Source: https://discuss.ai.google.dev/t/new-folder-for-rules/126165
vec![
".agents/rules/*.md".into(),
".agent/rules/*.md".into(),
]
vec![".agents/rules/*.md".into(), ".agent/rules/*.md".into()]
}

fn project_settings_patterns(&self) -> Vec<String> {
Expand Down
2 changes: 1 addition & 1 deletion crates/hk-core/src/adapter/cursor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ impl AgentAdapter for CursorAdapter {
}

fn project_skill_dirs(&self) -> Vec<String> {
// Cursor 2.4+ project skills.
// Cursor project skills (no documented minimum version).
// Source: https://cursor.com/docs/skills
vec![".cursor/skills".into()]
}
Expand Down
64 changes: 64 additions & 0 deletions crates/hk-core/src/adapter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,29 @@ pub trait AgentAdapter: Send + Sync {
}
}

impl crate::models::AgentCapabilities {
/// Derive install capabilities purely from the adapter's own
/// declarations — no per-agent special cases. Whatever an adapter
/// declares here is exactly what `service::install_to_agent` can
/// resolve a write path for, so frontend gating and backend behavior
/// cannot drift.
pub fn from_adapter(a: &dyn AgentAdapter) -> Self {
let skill = !a.project_skill_dirs().is_empty();
Self {
project_install: crate::models::KindFlags {
skill,
mcp: a.project_mcp_config_relpath().is_some(),
hook: a.project_hook_config_relpath().is_some(),
// A CLI install deploys the companion skill into the skill
// dir (the binary itself is global), so CLI follows skill.
cli: skill,
},
hooks_supported: a.hook_format() != HookFormat::None,
global_hook_install: a.supports_global_hook_install(),
}
}
}

fn pick_unique_concrete(patterns: Vec<String>) -> Option<String> {
let mut concrete = patterns
.into_iter()
Expand Down Expand Up @@ -538,6 +561,47 @@ mod tests {
}
}

/// The full per-agent capability matrix, every value verified against
/// official docs 2026-07 (see docs/superpowers/specs/
/// 2026-07-11-cross-agent-project-install-plan.md for citations).
/// If an adapter declaration changes, this test forces the change to be
/// deliberate — it is the single source the frontend gates against.
#[test]
fn test_agent_capabilities_matrix() {
use crate::models::AgentCapabilities;

// (name, skill, mcp, hook, hooks_supported, global_hook_install);
// cli always equals skill by construction.
let expected = [
("claude", true, true, true, true, true),
("codex", true, true, true, true, true),
("cursor", true, true, true, true, true),
("windsurf", true, false, true, true, true), // MCP global-only upstream
("gemini", true, true, false, true, true), // project hooks deferred
("copilot", true, true, false, true, true), // project hooks deferred
("antigravity", true, false, false, false, true), // no MCP/project, no hooks
("opencode", true, true, false, false, true), // hooks are JS plugins
("kiro", true, true, true, true, false), // kirodotdev/Kiro#5440
("hermes", false, false, false, true, true), // global-only (hermes-agent#4667)
];

let adapters = all_adapters();
assert_eq!(adapters.len(), expected.len(), "agent count drifted");
for (name, skill, mcp, hook, hooks_supported, global_hook) in expected {
let a = adapters
.iter()
.find(|a| a.name() == name)
.unwrap_or_else(|| panic!("adapter {name} missing"));
let caps = AgentCapabilities::from_adapter(a.as_ref());
assert_eq!(caps.project_install.skill, skill, "{name} project skill");
assert_eq!(caps.project_install.mcp, mcp, "{name} project mcp");
assert_eq!(caps.project_install.hook, hook, "{name} project hook");
assert_eq!(caps.project_install.cli, skill, "{name} project cli follows skill");
assert_eq!(caps.hooks_supported, hooks_supported, "{name} hooks_supported");
assert_eq!(caps.global_hook_install, global_hook, "{name} global_hook_install");
}
}

#[test]
fn test_skill_dir_for_category_default_is_none_except_hermes() {
// The install handlers rely on this contract: only category-aware
Expand Down
41 changes: 33 additions & 8 deletions crates/hk-core/src/adapter/windsurf.rs
Original file line number Diff line number Diff line change
Expand Up @@ -207,18 +207,25 @@ impl AgentAdapter for WindsurfAdapter {
}

fn project_settings_patterns(&self) -> Vec<String> {
vec![
".windsurf/mcp_config.json".into(),
".windsurf/hooks.json".into(),
]
// No `.windsurf/mcp_config.json` here: Windsurf MCP is global-only
// (see `project_mcp_config_relpath`), so a workspace copy would be a
// file Windsurf never reads.
vec![".windsurf/hooks.json".into()]
}

fn project_ignore_patterns(&self) -> Vec<String> {
vec![".codeiumignore".into()]
}

fn project_mcp_config_relpath(&self) -> Option<String> {
Some(".windsurf/mcp_config.json".into())
// Windsurf MCP is global-only: the official MCP doc documents a
// single config at `~/.codeium/windsurf/mcp_config.json` and never
// mentions a workspace path — unlike the skills and hooks docs on
// the same site, which explicitly scope `.windsurf/skills/` and
// `.windsurf/hooks.json` to the workspace. Third-party guides
// confirm ("Windsurf doesn't load a project-scoped copy").
// Source: https://docs.windsurf.com/windsurf/cascade/mcp
None
}

fn project_hook_config_relpath(&self) -> Option<String> {
Expand Down Expand Up @@ -295,8 +302,7 @@ mod tests {
hook.event == "pre_user_prompt" && hook.command == "python3 /tmp/check.py"
}));
assert!(hooks.iter().any(|hook| {
hook.event == "post_cascade_response"
&& hook.command == "python C:\\hooks\\log.py"
hook.event == "post_cascade_response" && hook.command == "python C:\\hooks\\log.py"
}));
}

Expand Down Expand Up @@ -339,7 +345,11 @@ mod tests {
fn global_settings_files_excludes_workflows() {
let adapter = WindsurfAdapter::with_home(tempfile::tempdir().unwrap().path().to_path_buf());
let files = adapter.global_settings_files();
assert!(!files.iter().any(|p| p.to_string_lossy().contains("global_workflows")));
assert!(
!files
.iter()
.any(|p| p.to_string_lossy().contains("global_workflows"))
);
}

#[test]
Expand All @@ -355,4 +365,19 @@ mod tests {
let patterns = adapter.project_settings_patterns();
assert!(!patterns.iter().any(|p| p.contains("workflows")));
}

#[test]
fn project_mcp_is_none_global_only() {
// Windsurf MCP has no workspace config (official docs document only
// `~/.codeium/windsurf/mcp_config.json`); pin the deliberate None so
// it isn't "fixed" back by symmetry with other adapters.
let adapter = WindsurfAdapter::with_home(tempfile::tempdir().unwrap().path().to_path_buf());
assert!(adapter.project_mcp_config_relpath().is_none());
assert!(
!adapter
.project_settings_patterns()
.iter()
.any(|p| p.contains("mcp_config"))
);
}
}
122 changes: 6 additions & 116 deletions crates/hk-core/src/deployer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1484,51 +1484,12 @@ pub fn restore_plugin_entry(
})
}

/// Ensure Codex hooks feature is enabled in config.toml.
///
/// Codex requires `[features] hooks = true` to activate hook support. The
/// flag was originally named `codex_hooks` and was renamed to `hooks` in a
/// recent release; Codex still honors the old name with a deprecation warning,
/// so we don't editorialize and accept either form as "already enabled".
///
/// Parse-modify-serialize (rather than string append) is required so that a
/// pre-existing `[features]` table gets the new key inserted in-place,
/// instead of producing a duplicate section that TOML rejects on re-parse.
pub fn ensure_codex_hooks_enabled(codex_base_dir: &Path) -> Result<(), HkError> {
// No flock (cf. `set_codex_plugin_enabled`): single-caller deploy path.
let config_toml = codex_base_dir.join("config.toml");
if let Some(parent) = config_toml.parent() {
std::fs::create_dir_all(parent)?;
}
let content = if config_toml.exists() {
std::fs::read_to_string(&config_toml)?
} else {
String::new()
};
let mut doc: toml::Table = if content.is_empty() {
toml::Table::new()
} else {
content
.parse::<toml::Table>()
.map_err(|e| HkError::ConfigCorrupted(e.to_string()))?
};

let features = doc
.entry("features")
.or_insert_with(|| toml::Value::Table(toml::Table::new()))
.as_table_mut()
.ok_or_else(|| HkError::ConfigCorrupted("features is not a table".into()))?;
// Codex honors either `hooks` (canonical) or `codex_hooks` (deprecated);
// skip rewriting in either case.
if features.contains_key("hooks") || features.contains_key("codex_hooks") {
return Ok(());
}
features.insert("hooks".into(), toml::Value::Boolean(true));

let output = toml::to_string_pretty(&doc).map_err(|e| HkError::Internal(e.to_string()))?;
atomic_write(&config_toml, &output)?;
Ok(())
}
// NOTE: `ensure_codex_hooks_enabled` used to live here, writing
// `[features] hooks = true` into ~/.codex/config.toml on hook deploy. It was
// removed: Codex hooks are enabled by default (the flag is a DISABLE switch,
// per https://developers.openai.com/codex/hooks), so the write was redundant
// and would trample an explicit user `hooks = false` opt-out. Hook execution
// is gated by project trust + per-hook `/hooks` review on the Codex side.

/// Read an MCP server entry's full JSON value from a config file.
pub fn read_mcp_server_config(
Expand Down Expand Up @@ -3496,77 +3457,6 @@ mod tests {
assert!(plugins.contains_key("pluginB@marketplace"));
}

/// Helper: read config.toml, return the `[features]` table (panics if missing/wrong type).
fn read_features(config_path: &Path) -> toml::Table {
let parsed: toml::Table = std::fs::read_to_string(config_path)
.unwrap()
.parse()
.unwrap();
parsed["features"].as_table().unwrap().clone()
}

#[test]
fn ensure_codex_hooks_appends_when_missing() {
let dir = TempDir::new().unwrap();
ensure_codex_hooks_enabled(dir.path()).unwrap();
let features = read_features(&dir.path().join("config.toml"));
assert_eq!(features["hooks"].as_bool(), Some(true));
assert!(!features.contains_key("codex_hooks"));
}

#[test]
fn ensure_codex_hooks_skips_when_canonical_flag_present() {
let dir = TempDir::new().unwrap();
let config = dir.path().join("config.toml");
std::fs::write(&config, "[features]\nhooks = true\n").unwrap();
let before = std::fs::read_to_string(&config).unwrap();
ensure_codex_hooks_enabled(dir.path()).unwrap();
let after = std::fs::read_to_string(&config).unwrap();
assert_eq!(
before, after,
"config must not be rewritten when hooks=true"
);
}

#[test]
fn ensure_codex_hooks_skips_when_deprecated_flag_present() {
let dir = TempDir::new().unwrap();
let config = dir.path().join("config.toml");
std::fs::write(&config, "[features]\ncodex_hooks = true\n").unwrap();
let before = std::fs::read_to_string(&config).unwrap();
ensure_codex_hooks_enabled(dir.path()).unwrap();
let after = std::fs::read_to_string(&config).unwrap();
assert_eq!(
before, after,
"deprecated codex_hooks=true must still count as enabled"
);
}

#[test]
fn ensure_codex_hooks_inserts_into_existing_features_table() {
// Regression: previously we appended a duplicate `[features]` section,
// which TOML rejects on re-parse.
let dir = TempDir::new().unwrap();
let config = dir.path().join("config.toml");
std::fs::write(&config, "[features]\nmemories = true\n").unwrap();
ensure_codex_hooks_enabled(dir.path()).unwrap();
let features = read_features(&config);
assert_eq!(features["memories"].as_bool(), Some(true));
assert_eq!(features["hooks"].as_bool(), Some(true));
// Re-parse round-trip: file must be valid TOML (no duplicate section).
let raw = std::fs::read_to_string(&config).unwrap();
assert!(raw.parse::<toml::Table>().is_ok());
}

#[test]
fn ensure_codex_hooks_errors_on_corrupted_toml() {
let dir = TempDir::new().unwrap();
let config = dir.path().join("config.toml");
std::fs::write(&config, "this is not valid TOML [[[").unwrap();
let err = ensure_codex_hooks_enabled(dir.path()).unwrap_err();
assert!(matches!(err, HkError::ConfigCorrupted(_)));
}

#[test]
fn test_remove_vscode_plugin_entry() {
let dir = TempDir::new().unwrap();
Expand Down
26 changes: 26 additions & 0 deletions crates/hk-core/src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,32 @@ pub struct AgentInfo {
pub extension_count: usize,
pub path: String,
pub enabled: bool,
pub capabilities: AgentCapabilities,
}

/// Per-extension-kind boolean flags.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KindFlags {
pub skill: bool,
pub mcp: bool,
pub hook: bool,
pub cli: bool,
}

/// Install-capability facts derived from an agent's adapter declarations
/// (see `AgentCapabilities::from_adapter` in adapter/mod.rs). Shipped inside
/// every `AgentInfo` so the frontend gates install targets from the same
/// source of truth the backend deploys with — no hand-maintained TS table.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentCapabilities {
/// Which kinds can be installed at PROJECT scope for this agent.
pub project_install: KindFlags,
/// Whether the agent has a declarative hook system at all
/// (`hook_format() != HookFormat::None`).
pub hooks_supported: bool,
/// Whether user-level (global) hook install works upstream
/// (false only for Kiro today, kirodotdev/Kiro#5440).
pub global_hook_install: bool,
}

// --- Dashboard Stats ---
Expand Down
Loading
Loading