Replace the xAI OAuth stack with the Kimi device authorization grant:
- kimi_oauth.rs wire layer (device_authorization + token poll + refresh
against kigi_env::oauth_host(); client_id per PRD; retryable statuses
429/5xx with backoff; expired_token restarts authorization)
- X-Msh-Device-{Name,Model,Id} headers; device_id minted uuid4-hex at
~/.kigi/device_id (0600)
- Storage: system keyring service `kigi`, entry `oauth/kimi-code`
(macOS/Windows native backends), atomic-file fallback under ~/.kigi;
official client's keyring/~/.kimi never touched
- Refresh manager: 60s tick, threshold max(300, expires_in*0.5),
401-tombstone keyed by rejected refresh token with 300s cooldown and
rotation auto-clear, cross-process lock with sibling-adoption
triple-check, sleep/wake forced refresh
- Deleted xAI machinery: enterprise OIDC (PKCE/JWKS/teams), devbox login,
external auth provider, JWT tier gating + subscription paywall stack,
X-XAI-Token-Auth marker headers, ZDR gates, /user enrichment
- kigi login / TUI /login both drive the device flow; login-host display
now derives from kigi_env::oauth_host()
- 264 auth unit/wiremock tests; live contract probe of
auth.kimi.com/api/oauth/device_authorization matches the wire shapes
Gates: check/clippy --all-targets clean, fmt, deny ok, kigi-shell lib
5131 tests green.
1901 lines
66 KiB
Rust
1901 lines
66 KiB
Rust
//! `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<String>,
|
|
/// 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<InstructionFile>,
|
|
pub permissions: PermissionsReport,
|
|
pub hooks: Vec<HookEntry>,
|
|
pub skills: Vec<SkillEntry>,
|
|
pub agents: Vec<AgentEntry>,
|
|
pub plugins: Vec<PluginEntry>,
|
|
pub marketplaces: Vec<MarketplaceEntry>,
|
|
pub mcp_servers: Vec<McpServerEntry>,
|
|
pub lsp_servers: Vec<LspServerEntry>,
|
|
pub config_sources: ConfigSources,
|
|
pub external_compat: ExternalCompatReport,
|
|
/// Warnings from `[model.*]` parsing.
|
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
|
pub model_override_warnings:
|
|
Vec<crate::agent::config_model_override_parse::ModelOverrideWarning>,
|
|
}
|
|
|
|
#[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<String>,
|
|
/// 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<CompatEntryStatus>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct PermissionsReport {
|
|
pub sources: Vec<String>,
|
|
pub loaded: usize,
|
|
pub skipped: Vec<SkippedRule>,
|
|
pub mcp_server_allowlist: Vec<String>,
|
|
pub marketplace_allowlist: Vec<String>,
|
|
/// Platform path for managed-settings.json vendor policy (None on unsupported OS).
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub managed_settings_path: Option<String>,
|
|
/// 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<EnforcedPolicy>,
|
|
}
|
|
|
|
/// 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<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub vendor: Option<String>,
|
|
/// 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<CompatEntryStatus>,
|
|
}
|
|
|
|
#[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<String>,
|
|
/// 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<CompatEntryStatus>,
|
|
}
|
|
|
|
#[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<CompatEntryStatus>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub disabled_reason: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub vendor: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct LspServerEntry {
|
|
pub name: String,
|
|
pub command: String,
|
|
pub args: Vec<String>,
|
|
pub source: ConfigSource,
|
|
pub extensions: Vec<String>,
|
|
/// 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<ConfigLayer>,
|
|
}
|
|
|
|
/// 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<String>,
|
|
}
|
|
|
|
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<String> {
|
|
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<InstructionFile> {
|
|
// 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<std::path::PathBuf> = 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<String> = 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<String> = 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<HookEntry> {
|
|
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<HookEntry> = 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<SkillEntry> {
|
|
// 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
|
|
/// `<kigi_home>/skills/<name>/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<AgentEntry> {
|
|
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<PluginEntry> {
|
|
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<MarketplaceEntry> {
|
|
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<McpServerEntry> {
|
|
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<LspServerEntry> {
|
|
let trusted: Vec<_> = discovered_plugins.iter().filter(|p| p.trusted).collect();
|
|
let plugin_lsp_paths: Vec<std::path::PathBuf> = 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<ConfigLayer> = 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<String>)> {
|
|
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<String>)> {
|
|
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<T>(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<T>(
|
|
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<String> = 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} {:<pad$} {}", n, label(item));
|
|
}
|
|
}
|
|
|
|
/// Human label for an enforced setting. Uses product vocabulary, not the
|
|
/// internal field names (no `ui.yolo` / `--yolo` / `permission_mode`).
|
|
fn enforced_label(p: &EnforcedPolicy) -> 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<CompatEntryStatus>,
|
|
) -> &'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 `<kigi_home>/skills/<name>/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 <kigi_home>/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 <kigi_home>/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 <kigi_home>/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"
|
|
);
|
|
}
|
|
}
|