//! `grok inspect` — configuration introspection. //! //! Shows everything Grok discovers in the current directory: project //! instructions, permissions, hooks, skills, agents, plugins, MCP servers, //! LSP config, and config.toml sources. Supports `--json` for machine output. mod compat; pub use compat::{CompatEntryStatus, CompatSource, ExternalCompatEntry, ExternalCompatReport}; use compat::{ derive_vendor, instruction_compat_status, resolve_inspect_compat, vendor_compat_status, vendor_tag, }; use std::collections::HashMap; use std::path::{Path, PathBuf}; use serde::Serialize; use kigi_tools::types::config_source::ConfigSource; use kigi_tools::util::truncate::estimate_tokens; const TREE: &str = "\u{2514}"; /// Coarse scope label for project instructions and plugin entries. #[derive(Debug, Clone, Copy, Serialize)] #[serde(rename_all = "lowercase")] pub enum Scope { Project, User, Global, Plugin, Builtin, Cli, Config, } impl std::fmt::Display for Scope { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let s = match self { Self::Project => "project", Self::User => "user", Self::Global => "global", Self::Plugin => "plugin", Self::Builtin => "builtin", Self::Cli => "cli", Self::Config => "config", }; f.write_str(s) } } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct InspectReport { pub grok_version: String, pub channel: String, pub cwd: String, pub project_root: Option, /// Folder-trust verdict for `cwd`: when false, repo-local project hooks, /// plugins, and MCP/LSP entries are gated out of the listings below. pub project_trusted: bool, pub project_instructions: Vec, pub permissions: PermissionsReport, pub hooks: Vec, pub skills: Vec, pub agents: Vec, pub plugins: Vec, pub marketplaces: Vec, pub mcp_servers: Vec, pub lsp_servers: Vec, pub config_sources: ConfigSources, pub external_compat: ExternalCompatReport, /// Warnings from `[model.*]` parsing. #[serde(skip_serializing_if = "Vec::is_empty")] pub model_override_warnings: Vec, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct InstructionFile { pub path: String, pub scope: Scope, pub file_type: String, pub size_bytes: usize, /// Estimated token count (chars / 4). pub approx_tokens: usize, #[serde(skip_serializing_if = "Option::is_none")] pub vendor: Option, /// True when this entry's vendor surface is disabled by compat config. #[serde(skip_serializing_if = "std::ops::Not::not")] pub disabled: bool, #[serde(skip_serializing_if = "Option::is_none")] pub compatibility_status: Option, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct PermissionsReport { pub sources: Vec, pub loaded: usize, pub skipped: Vec, pub mcp_server_allowlist: Vec, pub marketplace_allowlist: Vec, /// Platform path for managed-settings.json vendor policy (None on unsupported OS). #[serde(skip_serializing_if = "Option::is_none")] pub managed_settings_path: Option, /// Whether that file exists on disk. Always emitted, so a JSON consumer /// can distinguish "absent" from "present" without string-matching. pub managed_settings_exists: bool, /// Whether the runtime actually loaded that file into policy (`exists` can /// be true while this is false for an unreadable/malformed file). Always emitted. pub managed_settings_active: bool, /// Settings forced by a policy layer. #[serde(skip_serializing_if = "Vec::is_empty")] pub enforced: Vec, } /// One policy-enforced setting. Structured for `--json`; the human view /// derives its line from these fields (see `enforced_label`). #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct EnforcedPolicy { /// Stable key: "alwaysApprove" | "telemetry" | "feedback". pub setting: String, /// The enforced value. pub enabled: bool, /// Originating file, e.g. "managed-settings.json". pub source: String, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct SkippedRule { pub rule: String, pub reason: String, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct HookEntry { pub event: String, pub hook_type: String, pub target: String, pub source: ConfigSource, pub matcher: Option, #[serde(skip_serializing_if = "Option::is_none")] pub vendor: Option, /// True when this entry's vendor surface is disabled by compat config. #[serde(skip_serializing_if = "std::ops::Not::not")] pub disabled: bool, #[serde(skip_serializing_if = "Option::is_none")] pub compatibility_status: Option, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct SkillEntry { pub name: String, pub description: String, pub source: ConfigSource, pub user_invocable: bool, #[serde(skip_serializing_if = "Option::is_none")] pub vendor: Option, /// True when disabled by `[skills].disabled` config or when this entry's /// vendor surface is disabled by compat config. #[serde(skip_serializing_if = "std::ops::Not::not")] pub disabled: bool, #[serde(skip_serializing_if = "Option::is_none")] pub compatibility_status: Option, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct AgentEntry { pub name: String, pub description: String, pub source: ConfigSource, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct PluginEntry { pub name: String, pub scope: Scope, pub path: String, pub enabled: bool, pub provides: PluginProvides, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct PluginProvides { pub skills: usize, pub agents: usize, pub hooks: bool, pub mcp_servers: usize, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct MarketplaceEntry { pub name: String, pub path: String, pub enabled_plugins: usize, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct McpServerEntry { pub name: String, pub transport: String, pub target: String, pub source: ConfigSource, /// True when this entry's vendor surface is disabled by compat config. #[serde(skip_serializing_if = "std::ops::Not::not")] pub disabled: bool, #[serde(skip_serializing_if = "Option::is_none")] pub compatibility_status: Option, #[serde(skip_serializing_if = "Option::is_none")] pub disabled_reason: Option, #[serde(skip_serializing_if = "Option::is_none")] pub vendor: Option, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct LspServerEntry { pub name: String, pub command: String, pub args: Vec, pub source: ConfigSource, pub extensions: Vec, /// True when this project-scoped server would be skipped (untrusted folder). #[serde(skip_serializing_if = "std::ops::Not::not")] pub untrusted: bool, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct ConfigSources { /// Config layers (system + user managed, user + system requirements, user /// config.toml, the macOS MDM managed-preferences layer, and project /// .kigi/config.toml files). Driven from the same resolvers used at runtime /// (`ConfigLayers`, `requirements_layers`) so system + MDM layers and /// precedence are included, and emptiness reflects real contribution after /// stripping (version_overrides, fail_closed, etc). pub layers: Vec, } /// A single config layer entry for `grok inspect`. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct ConfigLayer { /// Logical role of the layer: "system-managed", "managed", "user", /// "system-requirements", "requirements", "mdm", or "project". pub role: String, pub path: String, /// "empty" or "parse error" when the on-disk file does not contribute /// effective config (after the real loader's processing). Omitted when /// the layer is present and contributes. #[serde(skip_serializing_if = "Option::is_none")] pub note: Option, } pub async fn inspect(cwd: &Path, json: bool) -> anyhow::Result<()> { let report = build_report(cwd).await; if json { println!("{}", serde_json::to_string_pretty(&report)?); } else { print_human(&report); } Ok(()) } async fn build_report(cwd: &Path) -> InspectReport { let effective_config_result = crate::config::load_effective_config(); let effective_config = effective_config_result .as_ref() .cloned() .unwrap_or_else(|_| toml::Value::Table(toml::map::Map::new())); // Parse compatibility separately so malformed cells cannot block unrelated sections. let mut config_without_compat = effective_config.clone(); if let Some(table) = config_without_compat.as_table_mut() { table.remove("compat"); } let parsed_config = crate::agent::config::Config::new_from_toml_cfg(&config_without_compat).ok(); let git_root = git2::Repository::discover(cwd) .ok() .and_then(|r| r.workdir().map(|p| p.to_path_buf())); // Route through the live folder-trust gate rather than a raw store read; no // session resolve has run for a one-shot `inspect`. The single verdict drives // the top-level flag and gates the hooks, plugins, and MCP/LSP listings so // they reflect runtime gating. `remote = None`: env/user/managed opt-out is // honored, but a remote kill-switch is not consulted on this report-only path. crate::agent::folder_trust::resolve_and_record(cwd, None, false); let project_trusted = crate::agent::folder_trust::project_scope_allowed(cwd); let trust_store = kigi_agent::plugins::TrustStore::load(); let mut plugins_cfg: crate::agent::config::PluginsConfig = effective_config .get("plugins") .and_then(|v| v.clone().try_into().ok()) .unwrap_or_default(); plugins_cfg.merge_claude_enabled_plugins(Some(cwd)); let mut plugin_config = plugins_cfg.to_discovery_config(); // Project plugins gate on the same folder-trust verdict as hooks and the live // session/doctor sites, so the listing's `enabled` flags match runtime gating. let discovered_plugins = kigi_agent::plugins::discover_plugins( Some(cwd), &plugin_config, &trust_store, project_trusted, ); plugin_config.populate_plugin_lists(&discovered_plugins); let plugin_registry = kigi_agent::plugins::PluginRegistry::from_discovered( discovered_plugins.clone(), &plugin_config.disabled, &plugin_config.enabled, ); let external_compat = resolve_inspect_compat(effective_config_result.as_ref().map_err(|_| ())); // Same `[skills]` table the runtime loads, so `paths` skills appear, // `ignore`d ones are hidden, and `disabled` ones surface as disabled. let skills_config = crate::config::parse_skills_config(&effective_config); // Discover with all vendors ON so inspect shows the full set on disk. let (mut instructions, permissions, mut skills) = tokio::join!( list_instructions(cwd), list_permissions(cwd), list_skills(cwd, &plugin_registry, &skills_config), ); // Attach local compatibility status to each discovered vendor entry. for entry in &mut instructions { entry.compatibility_status = instruction_compat_status(&entry.vendor, &entry.file_type, &external_compat); entry.disabled |= entry.compatibility_status == Some(CompatEntryStatus::Disabled); } for entry in &mut skills { entry.compatibility_status = vendor_compat_status(&entry.vendor, "skills", &external_compat); entry.disabled |= entry.compatibility_status == Some(CompatEntryStatus::Disabled); } let mut hooks = list_hooks(git_root.as_deref(), project_trusted, &discovered_plugins); for entry in &mut hooks { entry.compatibility_status = vendor_compat_status(&entry.vendor, "hooks", &external_compat); entry.disabled |= entry.compatibility_status == Some(CompatEntryStatus::Disabled); } let agents = list_agents(cwd, &plugin_registry); let plugins = list_plugins(&discovered_plugins); let marketplaces = list_marketplaces(git_root.as_deref()); let mut mcp = list_mcp_servers(cwd, &plugin_registry); for entry in &mut mcp { entry.compatibility_status = vendor_compat_status(&entry.vendor, "mcps", &external_compat); entry.disabled |= entry.compatibility_status == Some(CompatEntryStatus::Disabled); } let lsp = list_lsp_servers(cwd, &discovered_plugins); let configs = list_config_sources(cwd); let model_override_warnings = parsed_config .as_ref() .map(|c| c.model_override_warnings.clone()) .unwrap_or_default(); InspectReport { grok_version: kigi_version::VERSION.to_string(), channel: crate::util::config::channel_name_from_cache() .unwrap_or("unknown") .to_string(), cwd: cwd.display().to_string(), project_root: git_root.map(|p| p.display().to_string()), project_trusted, project_instructions: instructions, permissions, hooks, skills, agents, plugins, marketplaces, mcp_servers: mcp, lsp_servers: lsp, config_sources: configs, external_compat, model_override_warnings, } } /// Read `[paths] extra_rule_dirs` from the effective config. Returns empty /// on any read/parse failure so misconfiguration never breaks classification. fn extra_rule_dirs_from_config() -> Vec { let Ok(root) = crate::config::load_effective_config() else { return Vec::new(); }; root.get("paths") .and_then(|v| v.get("extra_rule_dirs")) .and_then(|v| v.as_array()) .map(|arr| { arr.iter() .filter_map(|v| v.as_str().map(|s| s.to_string())) .collect() }) .unwrap_or_default() } fn has_rules_directory(file_path: &str, config_dir: &str) -> bool { let mut previous = None; for component in file_path .split(['/', '\\']) .filter(|component| !component.is_empty()) { if previous == Some(config_dir) && component == "rules" { return true; } previous = Some(component); } false } fn instruction_file_type( file_path: &str, claude_imported: bool, extra_rule_prefixes: &[PathBuf], ) -> &'static str { let path = Path::new(file_path); if has_rules_directory(file_path, ".kigi") || has_rules_directory(file_path, ".cursor") || (!claude_imported && has_rules_directory(file_path, ".claude")) || extra_rule_prefixes .iter() .any(|prefix| path.starts_with(prefix)) { "rules" } else { "agents_md" } } /// Wraps the production instruction discovery (`agents_md::read_agents_config_with_paths`). async fn list_instructions(cwd: &Path) -> Vec { // Discover with all vendors ON so inspect shows the full set. let configs = kigi_agent::prompt::agents_md::read_agents_config_with_paths( &cwd.display().to_string(), kigi_agent::prompt::skills::CompatConfig::default(), ) .await; let kigi_home = Some(crate::util::kigi_home::kigi_home()); // Phase 2 cutoff: when imported, stop classifying `.claude/rules/` paths // as rules. Equivalent dirs come in via `[paths] extra_rule_dirs`. let imported = crate::claude_import::is_claude_import_marked(); let extra_rule_dirs = extra_rule_dirs_from_config(); // Pre-expand `~/` and resolve once, so the per-config-file matching loop // can use a clean prefix check. Empty/invalid paths fall // through to a no-op match. // // TODO(phase-3): `extra_rule_dirs` only re-classifies files that // `kigi_agent::prompt::agents_md::read_agents_config_with_paths` // has already discovered. Plumbing `extra_rule_dirs` through to that // discovery (so files in arbitrary user-configured dirs are surfaced as // rules instead of being missed entirely) is out of scope for this stack // (intentional wontfix for now). // Skills (`extensions/skills.rs`) take the typed-scan path so they don't // have this limitation; rules need the same treatment in a follow-up. let extra_rule_prefixes: Vec = extra_rule_dirs .iter() .map(|d| crate::claude_import::expand_home(d)) .collect(); configs .into_iter() .map(|c| { let file_type = instruction_file_type(&c.file_path, imported, &extra_rule_prefixes); let scope = if kigi_home .as_deref() .is_some_and(|home| Path::new(&c.file_path).starts_with(home)) { Scope::Global } else { Scope::Project }; let size = c.content.len(); let vendor = derive_vendor(&c.file_path).map(String::from); InstructionFile { size_bytes: size, approx_tokens: estimate_tokens(&c.content), path: c.file_path, scope, file_type: file_type.to_string(), vendor, disabled: false, compatibility_status: None, } }) .collect() } /// Calls the production permission resolver (`resolve_permissions_with_provenance`) /// which handles both Grok TOML and vendor settings fallback in one codepath. async fn list_permissions(cwd: &Path) -> PermissionsReport { use kigi_workspace::permission::resolution; let ms = resolution::managed_settings(); let format_entry = |e: &resolution::AllowedMcpServer| match e { resolution::AllowedMcpServer::Http { url_pattern } => url_pattern.clone(), resolution::AllowedMcpServer::Stdio { command } => format!("command:{command}"), resolution::AllowedMcpServer::Name { name } => format!("name:{name}"), }; let mcp_server_allowlist: Vec = ms .mcp_allowlist .entries .iter() .map(format_entry) .chain( ms.mcp_allowlist .deny_entries .iter() .map(|e| format!("deny:{}", format_entry(e))), ) .collect(); let marketplace_allowlist = ms.marketplace_allowlist.allowed_urls.clone(); // Managed settings presence + enforced policy computed unconditionally (before // the early return) so that a managed-settings.json containing *only* e.g. // disableBypassPermissionsMode still surfaces its path and effects. let managed_settings_path = crate::config::claude_managed_settings_probe_path().map(|p| p.display().to_string()); let managed_settings_exists = crate::config::claude_managed_settings_probe_path().is_some_and(|p| p.exists()); // `source_path` is set only on the successful read+parse path, so it is the // signal for "actually loaded" (vs present-but-broken). let managed_settings_active = ms.features.source_path.is_some(); let mut enforced = Vec::new(); if let Some(src) = &ms.features.source_path { let source = src .file_name() .map(|n| n.to_string_lossy().into_owned()) .unwrap_or_else(|| "managed-settings.json".to_string()); for (flag, setting) in [ (ms.features.disable_yolo, "alwaysApprove"), (ms.features.disable_telemetry, "telemetry"), (ms.features.disable_feedback, "feedback"), ] { if flag == Some(true) { enforced.push(EnforcedPolicy { setting: setting.to_string(), enabled: false, source: source.clone(), }); } } } let Some(resolved) = resolution::resolve_permissions_with_provenance(cwd).await else { return PermissionsReport { sources: vec![], loaded: 0, skipped: vec![], mcp_server_allowlist, marketplace_allowlist, managed_settings_path: managed_settings_path.clone(), managed_settings_exists, managed_settings_active, enforced: enforced.clone(), }; }; let mut sources: Vec = resolved.sources.iter().map(|s| s.to_string()).collect(); sources.dedup(); let skipped = resolved .skipped .into_iter() .map(|s| SkippedRule { rule: s.rule, reason: s.reason, }) .collect(); PermissionsReport { sources, loaded: resolved.config.rules.len(), skipped, mcp_server_allowlist, marketplace_allowlist, managed_settings_path, managed_settings_exists, managed_settings_active, enforced, } } /// Discovers hooks with every vendor enabled so compatibility can be annotated later. fn list_hooks( git_root: Option<&Path>, project_trusted: bool, discovered_plugins: &[kigi_agent::plugins::DiscoveredPlugin], ) -> Vec { let all_on = kigi_tools::types::compat::CompatConfig::default(); let source_paths = crate::util::hooks::discover_hook_source_paths(git_root, &all_on); let (global_sources, project_sources) = source_paths.as_sources(project_trusted); let (registry, _errors) = kigi_hooks::discovery::load_hooks_from_sources(&global_sources, &project_sources); let home_dir = dirs::home_dir(); let kigi_home = kigi_config::kigi_home(); let mut entries: Vec = registry .all_hooks() .into_iter() .map(|h| { let is_user_scope = h.source_dir.starts_with(&kigi_home) || home_dir.as_deref().is_some_and(|home| { h.source_dir.starts_with(home.join(".cursor")) || h.source_dir.starts_with(home.join(".claude")) }); let source = if is_user_scope { ConfigSource::User { path: h.source_dir.clone(), } } else { ConfigSource::Project { path: h.source_dir.clone(), } }; let vendor = derive_vendor(&h.source_dir.display().to_string()).map(String::from); HookEntry { event: format!("{:?}", h.event), hook_type: h.handler_type.clone(), target: h .command .as_ref() .map(|p| p.display().to_string()) .or_else(|| h.url.clone()) .unwrap_or_default(), source, matcher: h.configured_matcher.clone(), vendor, disabled: false, compatibility_status: None, } }) .collect(); // Plugin hooks for p in discovered_plugins { if !p.trusted { continue; } let source = ConfigSource::Plugin { plugin_name: p.manifest.name.clone(), path: p.root.clone(), }; if let Some(ref hooks_path) = p.hooks_path { entries.push(HookEntry { event: "(plugin)".to_string(), hook_type: "file".to_string(), target: hooks_path.display().to_string(), source, matcher: None, vendor: None, disabled: false, compatibility_status: None, }); } else if p.manifest.inline_hooks().is_some() { entries.push(HookEntry { event: "(plugin)".to_string(), hook_type: "inline".to_string(), target: String::new(), source, matcher: None, vendor: None, disabled: false, compatibility_status: None, }); } } entries } async fn list_skills( cwd: &Path, plugin_registry: &kigi_agent::plugins::PluginRegistry, skills_config: &kigi_agent::prompt::skills::SkillsConfig, ) -> Vec { // Discover with all vendors ON so inspect shows the full set. let skills = kigi_agent::prompt::skills::list_skills_with_plugins( Some(&cwd.display().to_string()), skills_config, Some(plugin_registry), kigi_agent::prompt::skills::CompatConfig::default(), ) .await; let kigi_home = crate::util::kigi_home::kigi_home(); skills .into_iter() .map(|s| { let source = skill_entry_source(&s, &kigi_home); let vendor = derive_vendor(&s.path).map(String::from); SkillEntry { name: s.label().to_string(), description: s.description, source, user_invocable: s.user_invocable, vendor, // Preserve `[skills].disabled`; compatibility is applied later. disabled: !s.enabled, compatibility_status: None, } }) .collect() } /// Resolve the inspect-facing source for a discovered skill. /// /// Prefers the discovery-stamped `config_source` (plugin skills, /// `[skills].paths` entries), then falls back to a scope mapping. One /// display-only fixup: bundled skills are extracted to /// `/skills//SKILL.md` and discovered as user skills, so a /// skill at exactly that path with a bundled name is re-labeled `Bundled` /// (`builtin::is_extracted_bundled_skill`) — a same-named skill anywhere else /// stays non-bundled. Runtime discovery scopes/precedence are untouched. /// /// `Bundled`/`Server` sources are constructed only here, never by runtime /// discovery: deployed pagers parse `x.ai/skills/list` into a typed /// `ConfigSource` and reject unknown tags, so runtime stamping must wait /// until clients without these variants have aged out. Until then this /// mapping is the single owner of the scope→source translation. fn skill_entry_source(s: &kigi_agent::prompt::skills::SkillInfo, kigi_home: &Path) -> ConfigSource { use kigi_tools::implementations::skills::types::SkillScope; if let Some(source) = s.config_source.clone() { return source; } let path = PathBuf::from(&s.path); match s.scope { SkillScope::Local | SkillScope::Repo => ConfigSource::Project { path }, SkillScope::User => { if crate::builtin::is_extracted_bundled_skill(&s.name, &path, kigi_home) { ConfigSource::Bundled { path } } else { ConfigSource::User { path } } } SkillScope::Server => ConfigSource::Server { path }, SkillScope::Bundled => ConfigSource::Bundled { path }, SkillScope::Plugin => ConfigSource::Plugin { plugin_name: String::new(), path, }, } } fn list_agents( cwd: &Path, plugin_registry: &kigi_agent::plugins::PluginRegistry, ) -> Vec { let agents = kigi_agent::discovery::all_subagents_with_plugins( cwd, &HashMap::new(), Some(plugin_registry), ); agents .into_iter() .map(|a| AgentEntry { name: a.name, description: a.description, source: a.config_source, }) .collect() } /// Maps pre-discovered plugins (from `discover_plugins`) to inspect entries. fn list_plugins(discovered: &[kigi_agent::plugins::DiscoveredPlugin]) -> Vec { discovered .iter() .map(|p| { let scope = match p.scope { kigi_agent::plugins::PluginScope::CliOverride => Scope::Cli, kigi_agent::plugins::PluginScope::Project => Scope::Project, kigi_agent::plugins::PluginScope::User => Scope::User, kigi_agent::plugins::PluginScope::ConfigPath => Scope::Config, }; PluginEntry { name: p.manifest.name.clone(), scope, path: p.root.display().to_string(), enabled: p.trusted, provides: PluginProvides { // Count actual SKILL.md files discovered (root-level or in // subdirs), not the number of configured skill dirs, so the // reported count matches what the skills registry loads. skills: kigi_agent::plugins::registry::skill_md_paths(&p.skill_dirs).len(), agents: p.agent_dirs.len(), hooks: p.hooks_path.is_some(), mcp_servers: if p.mcp_config_path.is_some() { 1 } else { 0 }, }, } }) .collect() } /// Wraps the production marketplace resolver (`marketplace::resolve`). fn list_marketplaces(git_root: Option<&Path>) -> Vec { let Some(root) = git_root else { return vec![]; }; kigi_agent::plugins::marketplace::resolve(root) .into_iter() .map(|m| MarketplaceEntry { name: m.name, path: m.path.display().to_string(), enabled_plugins: m.plugin_dirs.len(), }) .collect() } /// Discovers MCPs with every vendor enabled so compatibility can be annotated later. fn list_mcp_servers( cwd: &Path, plugin_registry: &kigi_agent::plugins::PluginRegistry, ) -> Vec { use kigi_workspace::permission::resolution; let all_on = kigi_tools::types::compat::CompatConfig::default(); let sourced = crate::session::managed_mcp::merge_managed_mcp_servers_sourced( cwd, Some(plugin_registry), &all_on, ); let allowlist = &resolution::managed_settings().mcp_allowlist; sourced .into_iter() .map(|(server, source)| { let (name, transport, target) = match &server { agent_client_protocol::McpServer::Stdio( agent_client_protocol::McpServerStdio { name, command, .. }, ) => (name.clone(), "stdio", command.display().to_string()), agent_client_protocol::McpServer::Http( agent_client_protocol::McpServerHttp { name, url, .. }, ) => (name.clone(), "http", url.clone()), agent_client_protocol::McpServer::Sse( agent_client_protocol::McpServerSse { name, url, .. }, ) => (name.clone(), "sse", url.clone()), // TODO(acp-0.10): `McpServer` is #[non_exhaustive]. _ => ("unknown".to_string(), "unknown", String::new()), }; let disabled_reason = (!allowlist.is_server_allowed(&server)).then(|| { crate::session::managed_mcp::McpDisabledReason::for_blocked_server( allowlist, &server, ) .to_string() }); let vendor = match &source { ConfigSource::ClaudeJson { .. } => Some("claude".to_owned()), ConfigSource::McpJson { path } => { derive_vendor(&path.display().to_string()).map(String::from) } _ => None, }; McpServerEntry { name, transport: transport.to_string(), target, source, disabled: false, compatibility_status: None, disabled_reason, vendor, } }) .collect() } /// Wraps the production LSP loader (`load_servers_with_plugins_sourced`). fn list_lsp_servers( cwd: &Path, discovered_plugins: &[kigi_agent::plugins::DiscoveredPlugin], ) -> Vec { let trusted: Vec<_> = discovered_plugins.iter().filter(|p| p.trusted).collect(); let plugin_lsp_paths: Vec = trusted .iter() .filter_map(|p| p.lsp_config_path.clone()) .collect(); let plugin_names: Vec<&str> = trusted .iter() .filter(|p| p.lsp_config_path.is_some()) .map(|p| p.manifest.name.as_str()) .collect(); let plugin_inline_lsp: Vec<(&serde_json::Value, &str)> = trusted .iter() .filter_map(|p| { p.manifest .inline_lsp_servers() .map(|v| (v, p.manifest.name.as_str())) }) .collect(); let inline_values: Vec<&serde_json::Value> = plugin_inline_lsp.iter().map(|(v, _)| *v).collect(); let inline_names: Vec<&str> = plugin_inline_lsp.iter().map(|(_, n)| *n).collect(); let servers = kigi_tools::implementations::lsp::config::load_servers_with_plugins_sourced( cwd, &plugin_lsp_paths, &inline_values, &plugin_names, &inline_names, ); // Folder-trust gate (display-only): inspect never spawns servers, but mark the // repo-local (project-scoped) entries a session would skip in an untrusted // clone so the listing matches the live gate. `remote = None` mirrors // `grok mcp doctor` (no loaded RemoteSettings in a standalone command). crate::agent::folder_trust::resolve_and_record(cwd, None, false); let project_allowed = crate::agent::folder_trust::project_scope_allowed(cwd); servers .into_iter() .map(|(name, (cfg, source))| { let untrusted = !project_allowed && matches!(source, ConfigSource::Project { .. }); LspServerEntry { name, command: cfg.command, args: cfg.args, source, extensions: cfg.extensions.keys().cloned().collect(), untrusted, } }) .collect() } /// Locates the config files that contribute to the effective config by /// probing the canonical locations used by `ConfigLayers::load` and /// `requirements_layers`: system + user `managed_config.toml`, user /// `config.toml`, user + system `requirements.toml`, and project /// `.kigi/config.toml` files (via `find_project_configs`). The macOS MDM /// managed-preferences layer has no file on disk, so it is sourced directly /// from `requirements_layers()` rather than a path probe. /// /// Only on-disk files (plus the synthetic MDM layer) are emitted, except the /// primary user `config.toml` which always gets a "User: (none)" line in the /// human view when absent. /// `note` distinguishes files that exist but contribute nothing after the /// real loader's processing (stripping, version overrides, fail_closed, etc). /// Parse errors are reported distinctly rather than as "empty". fn list_config_sources(cwd: &Path) -> ConfigSources { let mut layers: Vec = vec![]; // System managed (comes first in merge precedence) if let Some(dir) = crate::config::system_config_dir() { let p = dir.join("managed_config.toml"); if let Some((path_s, note)) = describe_config_file(&p) { layers.push(ConfigLayer { role: "system-managed".to_string(), path: path_s, note, }); } } // User managed if let Some(home) = crate::config::user_kigi_home() { let p = home.join("managed_config.toml"); if let Some((path_s, note)) = describe_config_file(&p) { layers.push(ConfigLayer { role: "managed".to_string(), path: path_s, note, }); } } // User config.toml (primary user layer; shown as (none) when absent) if let Some(home) = crate::config::user_kigi_home() { let p = home.join("config.toml"); if let Some((path_s, note)) = describe_config_file(&p) { layers.push(ConfigLayer { role: "user".to_string(), path: path_s, note, }); } } // Requirements: user then system (order they appear in requirements_layers) if let Some(home) = crate::config::user_kigi_home() { let p = home.join("requirements.toml"); if let Some((path_s, note)) = describe_requirements_file(&p) { layers.push(ConfigLayer { role: "requirements".to_string(), path: path_s, note, }); } } if let Some(dir) = crate::config::system_config_dir() { let p = dir.join("requirements.toml"); if let Some((path_s, note)) = describe_requirements_file(&p) { layers.push(ConfigLayer { role: "system-requirements".to_string(), path: path_s, note, }); } } // macOS MDM managed preferences: a synthetic, admin-forced requirements layer // with no file on disk, so it's sourced from requirements_layers() (keyed on // the synthetic label) with contribution decided from the in-memory value // rather than a path probe. Absent on non-macOS or when no profile is forced. let rt_layers = crate::config::requirements_layers(); if let Some(mdm) = rt_layers .iter() .find(|l| matches!(l.source, crate::config::RequirementsSource::Mdm)) { let path_s = mdm.source.label().into_owned(); let note = if requirements_layer_contributes(&rt_layers, &path_s) { None } else { Some("empty".to_string()) }; layers.push(ConfigLayer { role: "mdm".to_string(), path: path_s, note, }); } // Project configs (from git root up); each is its own "project" role entry for p in crate::config::find_project_configs(cwd) { if p.exists() && let Some((path_s, note)) = describe_config_file(&p) { layers.push(ConfigLayer { role: "project".to_string(), path: path_s, note, }); } } ConfigSources { layers } } /// For managed / user / project config files: use `load_config_file` (the /// production path for those layers) so `note` reflects post-processing /// (version overrides stripped) and distinguishes parse failure. fn describe_config_file(path: &Path) -> Option<(String, Option)> { if !path.exists() { return None; } let path_s = path.display().to_string(); match crate::config::load_config_file(path) { Ok(v) => { let empty = v.as_table().is_none_or(|t| t.is_empty()); Some(( path_s, if empty { Some("empty".to_string()) } else { None }, )) } Err(_) => Some((path_s, Some("parse error".to_string()))), } } /// Classify a requirements file against the real loader. `load_config_file` /// catches both syntax errors and invalid `[[version_overrides]]` (the loader /// rejects the latter too), so those read "(parse error)"; contribution is /// then sourced from `requirements_layers()` via `requirements_layer_contributes`. fn describe_requirements_file(path: &Path) -> Option<(String, Option)> { if !path.exists() { return None; } let path_s = path.display().to_string(); if crate::config::load_config_file(path).is_err() { return Some((path_s, Some("parse error".to_string()))); } if requirements_layer_contributes(&crate::config::requirements_layers(), &path_s) { Some((path_s, None)) } else { Some((path_s, Some("empty".to_string()))) } } /// Whether the loader keeps `path_s` *and* its post-load table is non-empty. /// The non-empty guard runs before `fail_closed` is stripped, so a /// `fail_closed`-only file is retained with an empty table yet contributes nothing. fn requirements_layer_contributes( layers: &[crate::config::RequirementsLayer], path_s: &str, ) -> bool { layers.iter().any(|l| { l.source.label().as_ref() == path_s && l.value.as_table().is_some_and(|t| !t.is_empty()) }) } fn print_section(title: &str, items: &[T], format_item: impl Fn(&T) -> String) { println!(); println!(" {} ({})", title, items.len()); if items.is_empty() { println!(" {TREE} (none)"); } for item in items { println!(" {TREE} {}", format_item(item)); } } /// Print items in a two-column layout: name on the left, source label on the right. fn print_columns( title: &str, items: &[T], name: impl Fn(&T) -> String, label: impl Fn(&T) -> String, ) { println!(); println!(" {} ({})", title, items.len()); if items.is_empty() { println!(" {TREE} (none)"); return; } let names: Vec = items.iter().map(&name).collect(); let pad = names.iter().map(|n| n.len()).max().unwrap_or(0).min(50); for (item, n) in items.iter().zip(&names) { println!(" {TREE} {: String { let name = match p.setting.as_str() { "alwaysApprove" => "Permissions mode: always-approve", "telemetry" => "Telemetry", "feedback" => "Feedback", other => other, }; let state = if p.enabled { "enabled" } else { "disabled" }; format!("{name} {state}") } fn disabled_compat_tags( disabled: bool, compatibility_status: Option, ) -> &'static str { if disabled || compatibility_status == Some(CompatEntryStatus::Disabled) { " [disabled]" } else { "" } } /// Renders the "Model Overrides" section of the human report; empty when /// there are no warnings. fn render_model_override_warnings( warnings: &[crate::agent::config_model_override_parse::ModelOverrideWarning], ) -> String { use std::fmt::Write as _; if warnings.is_empty() { return String::new(); } let mut out = String::from("\n Model Overrides\n"); let _ = writeln!( out, " {TREE} {} warning(s) (models with invalid fields kept in catalog)", warnings.len() ); for w in warnings { let target = match w.model_key.as_deref() { Some(key) => format!("[model.\"{key}\"]"), None => "[model]".to_owned(), }; match w.field.as_deref() { Some(field) => { let _ = writeln!(out, " {TREE} {target} {field} — {}", w.reason); } None => { let _ = writeln!(out, " {TREE} {target} — {}", w.reason); } } } out } fn render_harness_compatibility(report: &ExternalCompatReport) -> String { use std::fmt::Write as _; let mut out = String::from("\n Harness Compatibility\n"); let mut current_vendor = ""; for cell in &report.cells { if cell.vendor != current_vendor { current_vendor = &cell.vendor; let _ = writeln!(out, " {TREE} {current_vendor}"); } let status = if cell.enabled { "on" } else { "OFF" }; let _ = writeln!( out, " {TREE} {:<10} {:<3} ({})", cell.surface, status, cell.source ); } out.push('\n'); out } fn print_human(r: &InspectReport) { println!(); println!(" Environment"); println!(" {TREE} Version: {} [{}]", r.grok_version, r.channel); println!(" {TREE} CWD: {}", r.cwd); if let Some(ref root) = r.project_root { println!(" {TREE} Git root: {}", root); } println!( " {TREE} Project trusted: {}", if r.project_trusted { "yes" } else { "no" } ); print_section("Project Instructions", &r.project_instructions, |f| { let status = disabled_compat_tags(f.disabled, f.compatibility_status); format!( "{} ({}, ~{} tokens){}{}", f.path, f.scope, f.approx_tokens, vendor_tag(&f.vendor), status, ) }); println!(); println!(" Permissions"); if r.permissions.managed_settings_exists && let Some(ref p) = r.permissions.managed_settings_path { let status = if r.permissions.managed_settings_active { "active" } else { "not loaded" }; println!(" {TREE} Managed settings: {p} ({status})"); } if r.permissions.sources.is_empty() { println!(" {TREE} Source: (none)"); } else { for src in &r.permissions.sources { println!(" {TREE} Source: {src}"); } } println!( " {TREE} {} loaded, {} skipped", r.permissions.loaded, r.permissions.skipped.len() ); for s in &r.permissions.skipped { println!(" {TREE} {} -- {}", s.rule, s.reason); } if !r.permissions.enforced.is_empty() { println!(" {TREE} Enforced by policy"); for e in &r.permissions.enforced { println!(" {TREE} {} ({})", enforced_label(e), e.source); } } if !r.permissions.mcp_server_allowlist.is_empty() { println!( " {TREE} MCP server allowlist ({} patterns)", r.permissions.mcp_server_allowlist.len() ); for pat in &r.permissions.mcp_server_allowlist { println!(" {TREE} {}", pat); } } if !r.permissions.marketplace_allowlist.is_empty() { println!( " {TREE} Marketplace allowlist ({} sources)", r.permissions.marketplace_allowlist.len() ); for url in &r.permissions.marketplace_allowlist { println!(" {TREE} {}", url); } } print_columns( "Skills", &r.skills, |s| s.name.clone(), |s| { let status = disabled_compat_tags(s.disabled, s.compatibility_status); format!( "{}{}{}", s.source.display_label(), vendor_tag(&s.vendor), status, ) }, ); print_columns( "Agents", &r.agents, |a| a.name.clone(), |a| a.source.display_label(), ); print_columns( "Plugins", &r.plugins, |p| { let status = if p.enabled { "enabled" } else { "disabled" }; format!("{} ({}, {})", p.name, p.scope, status) }, |p| { let mut parts = Vec::new(); if p.provides.skills > 0 { parts.push(format!("{} skills", p.provides.skills)); } if p.provides.agents > 0 { parts.push(format!("{} agents", p.provides.agents)); } if p.provides.hooks { parts.push("hooks".into()); } if p.provides.mcp_servers > 0 { parts.push(format!("{} MCPs", p.provides.mcp_servers)); } if parts.is_empty() { "-".into() } else { parts.join(", ") } }, ); print_section("Marketplaces", &r.marketplaces, |m| { format!( "{} ({}, {} enabled plugins)", m.name, m.path, m.enabled_plugins ) }); if r.mcp_servers.is_empty() { println!(); println!(" MCP Servers (0)"); println!(" {TREE} (none) \u{2014} see `grok mcp add --help`"); } else { print_columns( "MCP Servers", &r.mcp_servers, |m| { if let Some(ref reason) = m.disabled_reason { format!("{} ({}) [BLOCKED: {}]", m.name, m.transport, reason) } else { format!("{} ({})", m.name, m.transport) } }, |m| { let status = disabled_compat_tags(m.disabled, m.compatibility_status); format!( "{}{}{}", m.source.display_label(), vendor_tag(&m.vendor), status, ) }, ); } print_columns( "LSP Servers", &r.lsp_servers, |l| format!("{} ({} {})", l.name, l.command, l.args.join(" ")), |l| { let untrusted = if l.untrusted { " [untrusted]" } else { "" }; format!("{}{}", l.source.display_label(), untrusted) }, ); print_columns( "Hooks", &r.hooks, |h| { let matcher = h .matcher .as_ref() .map(|m| format!(" matcher={}", m)) .unwrap_or_default(); format!("{}{}", h.hook_type, matcher) }, |h| { let status = disabled_compat_tags(h.disabled, h.compatibility_status); format!( "{}{}{}", h.source.display_label(), vendor_tag(&h.vendor), status, ) }, ); println!(); println!(" Config Sources"); // User is always emitted (with (none) when absent) for the primary user config. if let Some(user_l) = r.config_sources.layers.iter().find(|l| l.role == "user") { let tag = match user_l.note.as_deref() { Some("empty") => " (empty)", Some("parse error") => " (parse error)", _ => "", }; println!(" {TREE} User: {}{}", user_l.path, tag); } else { println!(" {TREE} User: (none)"); } for layer in &r.config_sources.layers { if layer.role == "user" { continue; } let tag = match layer.note.as_deref() { Some("empty") => " (empty)", Some("parse error") => " (parse error)", _ => "", }; let label = match layer.role.as_str() { "system-managed" => "System Managed", "managed" => "Managed", "system-requirements" => "System Requirements", "requirements" => "Requirements", "mdm" => "MDM Requirements", "project" => "Project", other => other, }; println!(" {TREE} {}: {}{}", label, layer.path, tag); } if !r.config_sources.layers.iter().any(|l| l.role == "project") { println!(" {TREE} Project: (none)"); } print!( "{}", render_model_override_warnings(&r.model_override_warnings) ); print!("{}", render_harness_compatibility(&r.external_compat)); } #[cfg(test)] mod tests { use super::*; use kigi_agent::prompt::skills::{SkillInfo, SkillsConfig}; use kigi_tools::implementations::skills::types::SkillScope; #[test] fn harness_compatibility_human_output_stays_compact() { let effective_config: toml::Value = toml::from_str("[compat.cursor]\nrules = false").unwrap(); let report = compat::resolve_inspect_compat_with_env(Ok(&effective_config), |_| None); let human = render_harness_compatibility(&report); assert!(human.contains("skills on (default)"), "{human}"); assert!(human.contains("rules OFF (config)"), "{human}"); assert!( !human.contains("Defaults shown; remote may override."), "{human}" ); assert!(!human.contains("resolved at session start"), "{human}"); assert!(!human.contains("unresolved"), "{human}"); assert!(!human.contains("?"), "{human}"); } #[test] fn disabled_entry_status_serializes_and_renders_consistently() { let entry = InstructionFile { path: "/repo/.cursor/AGENTS.md".to_owned(), scope: Scope::Project, file_type: "agents_md".to_owned(), size_bytes: 10, approx_tokens: 3, vendor: Some("cursor".to_owned()), disabled: false, compatibility_status: Some(CompatEntryStatus::Disabled), }; assert_eq!( serde_json::to_value(&entry).unwrap(), serde_json::json!({ "path": "/repo/.cursor/AGENTS.md", "scope": "project", "fileType": "agents_md", "sizeBytes": 10, "approxTokens": 3, "vendor": "cursor", "compatibilityStatus": "disabled" }) ); assert_eq!( disabled_compat_tags(false, entry.compatibility_status), " [disabled]" ); } #[test] fn vendor_rule_paths_select_rules_compatibility_cells() { let cell = |vendor: &str, surface: &str, enabled: bool| ExternalCompatEntry { vendor: vendor.to_owned(), surface: surface.to_owned(), enabled, source: CompatSource::Config, }; let report = ExternalCompatReport { remote_settings_loaded: false, cells: vec![ cell("cursor", "rules", false), cell("cursor", "agents", true), cell("claude", "rules", false), cell("claude", "agents", true), ], }; for (vendor, path) in [ ("cursor", "/repo/.cursor/rules/team.md"), ("cursor", r"C:\repo\.cursor\rules\team.md"), ("claude", "/repo/.claude/rules/team.md"), ("claude", r"C:\repo\.claude\rules\team.md"), ] { let file_type = instruction_file_type(path, false, &[]); assert_eq!(file_type, "rules"); assert_eq!( instruction_compat_status(&Some(vendor.to_owned()), file_type, &report), Some(CompatEntryStatus::Disabled) ); } for path in ["/repo/.kigi/rules/team.md", r"C:\repo\.kigi\rules\team.md"] { assert_eq!(instruction_file_type(path, false, &[]), "rules"); } for path in [ "/repo/.cursor/rules/team.md", r"C:\repo\.cursor\rules\team.md", ] { assert_eq!(instruction_file_type(path, true, &[]), "rules"); } for path in [ "/repo/.claude/rules/team.md", r"C:\repo\.claude\rules\team.md", ] { let file_type = instruction_file_type(path, true, &[]); assert_eq!(file_type, "agents_md"); assert_eq!( instruction_compat_status(&Some("claude".to_owned()), file_type, &report), Some(CompatEntryStatus::Enabled) ); } for path in [ "/repo/not.cursor/rules/team.md", r"C:\repo\.cursor\ruleset\team.md", ] { assert_eq!(instruction_file_type(path, false, &[]), "agents_md"); } } #[test] fn describe_config_file_flags_empty_and_parse_error() { let dir = tempfile::tempdir().unwrap(); // Missing file: describe returns None (no layer entry). let missing = dir.path().join("missing.toml"); assert!(describe_config_file(&missing).is_none()); // Comment-only and whitespace-only files parse to an empty table after load. let comment_only = dir.path().join("comment.toml"); std::fs::write(&comment_only, "# nothing enforced here\n").unwrap(); let (_, note) = describe_config_file(&comment_only).unwrap(); assert_eq!(note.as_deref(), Some("empty")); let blank = dir.path().join("blank.toml"); std::fs::write(&blank, "\n\n").unwrap(); let (_, note) = describe_config_file(&blank).unwrap(); assert_eq!(note.as_deref(), Some("empty")); // A file with real content contributes config and has no note. let with_content = dir.path().join("content.toml"); std::fs::write(&with_content, "[telemetry]\nmode = \"disabled\"\n").unwrap(); let (_, note) = describe_config_file(&with_content).unwrap(); assert!(note.is_none()); // Malformed TOML is flagged as parse error (distinct from empty). let bad = dir.path().join("bad.toml"); std::fs::write(&bad, "[[[ this is not valid toml").unwrap(); let (_, note) = describe_config_file(&bad).unwrap(); assert_eq!(note.as_deref(), Some("parse error")); } #[test] fn describe_requirements_file_flags_invalid_version_overrides_as_parse_error() { // Valid TOML but invalid `[[version_overrides]]` is rejected by the real // loader, so it must read "parse error", not "empty". let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("requirements.toml"); std::fs::write(&path, "[[version_overrides]]\nminimum_version = \"nope\"\n").unwrap(); let (_, note) = describe_requirements_file(&path).unwrap(); assert_eq!(note.as_deref(), Some("parse error")); } #[test] fn requirements_layer_contributes_requires_non_empty_post_strip_table() { // A `fail_closed`-only file is kept by the loader but with an empty // post-strip table, so it must not count as contributing. let path = "/home/u/.kigi/requirements.toml"; let layer = |v| crate::config::RequirementsLayer { value: v, source: crate::config::RequirementsSource::File(std::path::PathBuf::from(path)), is_system: false, }; let empty = layer(toml::Value::Table(toml::map::Map::new())); assert!(!requirements_layer_contributes( std::slice::from_ref(&empty), path )); let mut tbl = toml::map::Map::new(); tbl.insert("telemetry".into(), toml::Value::Boolean(true)); let full = layer(toml::Value::Table(tbl)); assert!(requirements_layer_contributes( std::slice::from_ref(&full), path )); } #[test] fn enforced_label_uses_product_vocabulary() { let p = EnforcedPolicy { setting: "alwaysApprove".into(), enabled: false, source: "managed-settings.json".into(), }; assert_eq!( enforced_label(&p), "Permissions mode: always-approve disabled" ); assert!(!enforced_label(&p).contains("yolo")); } /// Model-override warnings flow from an effective config through `Config` /// to the human renderer and the JSON report. #[test] fn model_override_warnings_inspect_smoke() { let effective: toml::Value = toml::from_str( r#" [model."grok-4.5"] model = "grok-4.5" env_key = "ANTHROPIC_AUTH_TOKEN" compactions_remaining = 1 send_compactions_remaining = true reasoning_effort = "not-a-level" "#, ) .unwrap(); let cfg = crate::agent::config::Config::new_from_toml_cfg(&effective).unwrap(); let warnings = cfg.model_override_warnings; assert!( warnings .iter() .any(|w| w.field.as_deref() == Some("send_compactions_remaining")), "duplicate alias should warn: {warnings:?}" ); assert!( warnings .iter() .any(|w| w.field.as_deref() == Some("reasoning_effort")), "invalid enum should warn: {warnings:?}" ); assert!(cfg.config_models.contains_key("grok-4.5")); let human = render_model_override_warnings(&warnings); assert!(human.contains("Model Overrides"), "{human}"); assert!( human.contains("[model.\"grok-4.5\"] send_compactions_remaining"), "{human}" ); assert!( human.contains("[model.\"grok-4.5\"] reasoning_effort"), "{human}" ); assert_eq!(render_model_override_warnings(&[]), ""); let json = serde_json::to_value(&warnings).unwrap(); let alias_warning = json .as_array() .unwrap() .iter() .find(|w| w["field"] == "send_compactions_remaining") .expect("alias warning present in JSON"); assert_eq!(alias_warning["modelKey"], "grok-4.5"); assert_eq!(alias_warning["kind"], "duplicate-alias"); assert!( alias_warning["reason"] .as_str() .is_some_and(|r| !r.is_empty()) ); } // ── skill source mapping (skill_entry_source) ───────────────────────── fn skill_fixture(name: &str, path: &str, scope: SkillScope) -> SkillInfo { SkillInfo { name: name.to_string(), description: format!("desc for {name}"), path: path.to_string(), scope, ..SkillInfo::default() } } #[test] fn skill_entry_source_maps_scopes() { let home = Path::new("/home/u/.kigi"); let s = skill_fixture("a", "/repo/.kigi/skills/a/SKILL.md", SkillScope::Local); assert!(matches!( skill_entry_source(&s, home), ConfigSource::Project { .. } )); let s = skill_fixture("b", "/repo/.kigi/skills/b/SKILL.md", SkillScope::Repo); assert!(matches!( skill_entry_source(&s, home), ConfigSource::Project { .. } )); let s = skill_fixture("c", "/home/u/.kigi/skills/c/SKILL.md", SkillScope::User); assert!(matches!( skill_entry_source(&s, home), ConfigSource::User { .. } )); let s = skill_fixture( "d", "/home/u/.kigi/server-skills/d/SKILL.md", SkillScope::Server, ); assert!(matches!( skill_entry_source(&s, home), ConfigSource::Server { .. } )); let s = skill_fixture("e", "/home/u/.kigi/bundled/e/SKILL.md", SkillScope::Bundled); assert!(matches!( skill_entry_source(&s, home), ConfigSource::Bundled { .. } )); } /// Bundled skills are re-labeled `Bundled` only at their exact extraction /// path `/skills//SKILL.md`; a same-named skill anywhere /// else keeps its real source. #[test] fn skill_entry_source_relabels_extracted_bundled_skills() { let home = Path::new("/home/u/.kigi"); let s = skill_fixture( "help", "/home/u/.kigi/skills/help/SKILL.md", SkillScope::User, ); assert!(matches!( skill_entry_source(&s, home), ConfigSource::Bundled { .. } )); // Bundled name in a project dir: stays project. let s = skill_fixture("help", "/repo/.kigi/skills/help/SKILL.md", SkillScope::Repo); assert!(matches!( skill_entry_source(&s, home), ConfigSource::Project { .. } )); // Bundled name in a user dir outside /skills: stays user. let s = skill_fixture( "help", "/home/u/other-skills/help/SKILL.md", SkillScope::User, ); assert!(matches!( skill_entry_source(&s, home), ConfigSource::User { .. } )); // Bundled frontmatter name in a different dir under /skills: // not the extracted copy — stays user. let s = skill_fixture( "help", "/home/u/.kigi/skills/my-tools/SKILL.md", SkillScope::User, ); assert!(matches!( skill_entry_source(&s, home), ConfigSource::User { .. } )); // Non-bundled name under /skills: stays user. let s = skill_fixture( "my-skill", "/home/u/.kigi/skills/my-skill/SKILL.md", SkillScope::User, ); assert!(matches!( skill_entry_source(&s, home), ConfigSource::User { .. } )); } /// A discovery-stamped `config_source` (plugins, `[skills].paths`) wins /// over the scope fallback. #[test] fn skill_entry_source_prefers_stamped_config_source() { let home = Path::new("/home/u/.kigi"); let mut s = skill_fixture("cfg", "/team/skills/cfg/SKILL.md", SkillScope::User); s.config_source = Some(ConfigSource::ConfigToml { path: PathBuf::from("/team/skills/cfg/SKILL.md"), }); assert!(matches!( skill_entry_source(&s, home), ConfigSource::ConfigToml { .. } )); } /// `list_skills` must honor the `[skills]` table like the runtime does: /// `paths` skills appear (with a `configToml` source), `ignore`d skills /// are hidden, and `disabled` skills stay listed but flagged. #[tokio::test] async fn list_skills_honors_skills_config() { let write = |dir: &Path, name: &str| { std::fs::create_dir_all(dir).unwrap(); std::fs::write( dir.join("SKILL.md"), format!("---\nname: {name}\ndescription: test skill {name}\n---\n\nBody.\n"), ) .unwrap(); }; // Test-unique names: discovery also reads this machine's real ~/.kigi dirs. let extra = tempfile::tempdir().unwrap(); write(&extra.path().join("inspect-cfg-extra"), "inspect-cfg-extra"); write( &extra.path().join("inspect-cfg-ignored"), "inspect-cfg-ignored", ); let cwd = tempfile::tempdir().unwrap(); let config = SkillsConfig { paths: vec![extra.path().to_string_lossy().into_owned()], ignore: vec![ extra .path() .join("inspect-cfg-ignored") .to_string_lossy() .into_owned(), ], disabled: vec!["inspect-cfg-extra".to_string()], ..Default::default() }; let registry = kigi_agent::plugins::PluginRegistry::from_discovered(vec![], &[], &[]); let entries = list_skills(cwd.path(), ®istry, &config).await; let extra_entry = entries .iter() .find(|e| e.name == "inspect-cfg-extra") .expect("[skills].paths skill should be listed"); assert!( matches!(extra_entry.source, ConfigSource::ConfigToml { .. }), "unexpected source: {:?}", extra_entry.source ); assert!( extra_entry.disabled, "[skills].disabled must flag the entry" ); assert!( !entries.iter().any(|e| e.name == "inspect-cfg-ignored"), "[skills].ignore must hide the skill" ); } }