From f904cfad85a870e4097d34035255e69529ab8e09 Mon Sep 17 00:00:00 2001 From: Leen Kilani Date: Mon, 10 Aug 2026 15:40:57 +0300 Subject: [PATCH 1/6] Embed the corgea skill in the binary and expose it An agent reading the skill from a branch or a registry can be told about flags the installed CLI does not accept. Compiling skills/corgea/SKILL.md into the binary makes the reference pinned to the version being driven, by construction. corgea skill show prints it verbatim. It is dispatched without the token check that guards install, because reading a compiled-in string cannot need a login, and the point is that it still answers when the registry does not. install --local writes the same content, refusing a name other than corgea or an explicit version since neither is something the binary can honour. The registry path is unchanged and still serves company-authored skills. Co-authored-by: Cursor --- src/main.rs | 19 +++++- src/skill.rs | 171 ++++++++++++++++++++++++++++++++++++++------------- 2 files changed, 147 insertions(+), 43 deletions(-) diff --git a/src/main.rs b/src/main.rs index 57cac78..f851af7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -457,7 +457,15 @@ enum SkillCommands { help = "Persist the provided --agent as the default for future installs." )] set_default: bool, + + #[arg( + long, + help = "Install the 'corgea' skill built into this binary instead of fetching it from the registry. Works offline and without a token." + )] + local: bool, }, + /// Print the 'corgea' skill built into this binary, pinned to its version + Show, /// Configure the default agent used when --agent is not provided SetDefaultAgent { #[arg(help = "Agent id (e.g. cursor, claude-code, codex).")] @@ -867,8 +875,13 @@ fn main() { scope, dir, set_default, + local, } => { - verify_token_and_exit_when_fail(&corgea_config); + // --local reads a string compiled into this binary, so it must + // not require a login the way the registry path does. + if !*local { + verify_token_and_exit_when_fail(&corgea_config); + } skill::run_install( &mut corgea_config, name, @@ -876,8 +889,12 @@ fn main() { scope, dir.clone(), *set_default, + *local, ); } + SkillCommands::Show => { + skill::run_show(); + } SkillCommands::SetDefaultAgent { agent } => { skill::run_set_default_agent(&mut corgea_config, agent); } diff --git a/src/skill.rs b/src/skill.rs index be035f7..f1de748 100644 --- a/src/skill.rs +++ b/src/skill.rs @@ -3,6 +3,17 @@ use crate::utils; use crate::utils::terminal::{set_text_color, TerminalColor}; use std::path::{Path, PathBuf}; +/// The skill this binary was built from, so the reference an agent reads always +/// matches the CLI it is driving. Kept out of the registry path deliberately: +/// reading it must work offline and without a token. +pub const EMBEDDED_SKILL: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/skills/corgea/SKILL.md" +)); + +/// The name `--local` installs under, and the only name it accepts. +pub const EMBEDDED_SKILL_NAME: &str = "corgea"; + /// Supported agents and where their skills are installed. /// /// Tuple layout: `(agent_id, project_relative_dir, user_relative_dir)`. @@ -119,46 +130,23 @@ pub fn run_set_default_agent(config: &mut Config, agent: &str) { } } -/// `corgea skill install ` -pub fn run_install( - config: &mut Config, - name_arg: &str, - agent: Option, - scope: &str, - dir: Option, - set_default: bool, -) { - let (skill_name, version) = parse_skill_arg(name_arg); - - if !["project", "user"].contains(&scope) { - eprintln!("Invalid scope '{}'. Expected 'project' or 'user'.", scope); - std::process::exit(1); - } - - // Resolve the agent (flag > configured default) unless a custom dir is set. - let resolved_agent = agent.clone().or_else(|| config.get_default_agent()); - if dir.is_none() && resolved_agent.is_none() { - eprintln!( - "No agent specified. Pass --agent , set a default with \ - 'corgea skill set-default-agent ', or use --dir.\nSupported agents: {}", - supported_agent_ids() - ); - std::process::exit(1); - } - if dir.is_none() { - if let Some(ref a) = resolved_agent { - if !is_supported_agent(a) { - eprintln!( - "Unsupported agent '{}'. Supported agents: {}", - a, - supported_agent_ids() - ); - std::process::exit(1); - } - } - } +/// `corgea skill show` +/// +/// Writes the embedded skill to stdout verbatim so it can be piped or read by +/// an agent. No token, no network, no formatting. +pub fn run_show() { + print!("{}", EMBEDDED_SKILL); +} - let result = utils::api::get_skill(config.get_url().as_str(), &skill_name, version.as_deref()); +/// Fetch an approved skill from the Corgea registry, exiting on any failure. +/// +/// Returns the skill body and a display label for the resolved version. +fn fetch_registry_skill( + config: &Config, + skill_name: &str, + version: Option<&str>, +) -> (String, String) { + let result = utils::api::get_skill(config.get_url().as_str(), skill_name, version); let response = match result { Ok(Some(resp)) => resp, @@ -231,7 +219,88 @@ pub fn run_install( std::process::exit(1); } - let content = version_info.content.unwrap_or_default(); + let label = format!("v{}", version_info.version); + (version_info.content.unwrap_or_default(), label) +} + +/// Resolve the embedded skill for `--local`, exiting if the request does not +/// match what this binary carries. +fn embedded_skill_or_exit(skill_name: &str, version: Option<&str>) -> (String, String) { + if skill_name != EMBEDDED_SKILL_NAME { + eprintln!( + "{}", + set_text_color( + &format!( + "--local can only install '{}', the skill built into this binary. \ + Drop --local to fetch '{}' from the registry.", + EMBEDDED_SKILL_NAME, skill_name + ), + TerminalColor::Red + ) + ); + std::process::exit(1); + } + if version.is_some() { + eprintln!( + "{}", + set_text_color( + "--local installs the skill pinned to this binary, so a version cannot be \ + requested. Drop the version, or drop --local to pick one from the registry.", + TerminalColor::Red + ) + ); + std::process::exit(1); + } + + let label = format!("v{}, embedded", env!("CARGO_PKG_VERSION")); + (EMBEDDED_SKILL.to_string(), label) +} + +/// `corgea skill install ` +pub fn run_install( + config: &mut Config, + name_arg: &str, + agent: Option, + scope: &str, + dir: Option, + set_default: bool, + local: bool, +) { + let (skill_name, version) = parse_skill_arg(name_arg); + + if !["project", "user"].contains(&scope) { + eprintln!("Invalid scope '{}'. Expected 'project' or 'user'.", scope); + std::process::exit(1); + } + + // Resolve the agent (flag > configured default) unless a custom dir is set. + let resolved_agent = agent.clone().or_else(|| config.get_default_agent()); + if dir.is_none() && resolved_agent.is_none() { + eprintln!( + "No agent specified. Pass --agent , set a default with \ + 'corgea skill set-default-agent ', or use --dir.\nSupported agents: {}", + supported_agent_ids() + ); + std::process::exit(1); + } + if dir.is_none() { + if let Some(ref a) = resolved_agent { + if !is_supported_agent(a) { + eprintln!( + "Unsupported agent '{}'. Supported agents: {}", + a, + supported_agent_ids() + ); + std::process::exit(1); + } + } + } + + let (content, version_label) = if local { + embedded_skill_or_exit(&skill_name, version.as_deref()) + } else { + fetch_registry_skill(config, &skill_name, version.as_deref()) + }; let cwd = match std::env::current_dir() { Ok(p) => p, @@ -272,9 +341,9 @@ pub fn run_install( "{}", set_text_color( &format!( - "Installed skill '{}' (v{}) to {}", + "Installed skill '{}' ({}) to {}", skill_name, - version_info.version, + version_label, skill_file.display() ), TerminalColor::Green @@ -364,4 +433,22 @@ mod tests { let result = resolve_skill_dir("foo", None, "project", None, &cwd, &home); assert!(result.is_err()); } + + #[test] + fn test_embedded_skill_is_present() { + assert!(!EMBEDDED_SKILL.trim().is_empty()); + } + + #[test] + fn test_embedded_skill_has_frontmatter_naming_itself() { + assert!( + EMBEDDED_SKILL.starts_with("---\n"), + "embedded skill must open with YAML frontmatter" + ); + assert!( + EMBEDDED_SKILL.contains(&format!("name: {}", EMBEDDED_SKILL_NAME)), + "frontmatter name must stay '{}', which is what --local installs under", + EMBEDDED_SKILL_NAME + ); + } } From ac6f48fabdceb5305d5414cb3537328c35ec2a2c Mon Sep 17 00:00:00 2001 From: Leen Kilani Date: Mon, 10 Aug 2026 16:04:42 +0300 Subject: [PATCH 2/6] Make skill show survive a read-only HOME and a closed pipe Config::load() creates ~/.corgea/config.toml and panics when it cannot, so `corgea skill show` exited 101 with a backtrace in a sandbox with a read-only home. That is the environment the command is most useful in, and printing a string compiled into the binary should not depend on the filesystem, so it is now dispatched before the config loads. Writing via write_all also lets `corgea skill show | head` exit quietly. Rust ignores SIGPIPE, so print! would have panicked once the skill grew past the pipe buffer. Also anchors the frontmatter test to the frontmatter block rather than matching anywhere in the file. Co-authored-by: Cursor --- src/main.rs | 16 +++++++++++++++- src/skill.rs | 23 +++++++++++++++++++---- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/src/main.rs b/src/main.rs index f851af7..9feab5a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -464,7 +464,7 @@ enum SkillCommands { )] local: bool, }, - /// Print the 'corgea' skill built into this binary, pinned to its version + /// Print the 'corgea' skill built into this binary Show, /// Configure the default agent used when --agent is not provided SetDefaultAgent { @@ -519,6 +519,19 @@ fn default_log_level(debug_flag: i8) -> &'static str { fn main() { let cli = Cli::parse(); + + // Dispatched before the config loads because that call creates + // ~/.corgea/config.toml and panics when it cannot. Printing a string + // compiled into the binary has no business failing in a read-only sandbox, + // which is exactly where an agent is most likely to run it. + if let Some(Commands::Skill { + command: SkillCommands::Show, + }) = &cli.command + { + skill::run_show(); + return; + } + let mut corgea_config = Config::load().expect("Failed to load config"); init_logging(&corgea_config); fn verify_token_and_exit_when_fail(config: &Config) { @@ -892,6 +905,7 @@ fn main() { *local, ); } + // Normally handled before the config loads, above. SkillCommands::Show => { skill::run_show(); } diff --git a/src/skill.rs b/src/skill.rs index f1de748..8552bd9 100644 --- a/src/skill.rs +++ b/src/skill.rs @@ -1,6 +1,7 @@ use crate::config::Config; use crate::utils; use crate::utils::terminal::{set_text_color, TerminalColor}; +use std::io::Write; use std::path::{Path, PathBuf}; /// The skill this binary was built from, so the reference an agent reads always @@ -135,7 +136,16 @@ pub fn run_set_default_agent(config: &mut Config, agent: &str) { /// Writes the embedded skill to stdout verbatim so it can be piped or read by /// an agent. No token, no network, no formatting. pub fn run_show() { - print!("{}", EMBEDDED_SKILL); + // Rust ignores SIGPIPE, so `corgea skill show | head` would otherwise panic + // once the skill outgrows the pipe buffer. A closed reader is a normal exit. + match std::io::stdout().write_all(EMBEDDED_SKILL.as_bytes()) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => {} + Err(e) => { + eprintln!("Failed to write skill: {}", e); + std::process::exit(1); + } + } } /// Fetch an approved skill from the Corgea registry, exiting on any failure. @@ -441,12 +451,17 @@ mod tests { #[test] fn test_embedded_skill_has_frontmatter_naming_itself() { - assert!( - EMBEDDED_SKILL.starts_with("---\n"), + let mut lines = EMBEDDED_SKILL.lines(); + assert_eq!( + lines.next().map(str::trim), + Some("---"), "embedded skill must open with YAML frontmatter" ); + let frontmatter: Vec<&str> = lines.take_while(|l| l.trim() != "---").collect(); assert!( - EMBEDDED_SKILL.contains(&format!("name: {}", EMBEDDED_SKILL_NAME)), + frontmatter + .iter() + .any(|l| l.trim() == format!("name: {}", EMBEDDED_SKILL_NAME)), "frontmatter name must stay '{}', which is what --local installs under", EMBEDDED_SKILL_NAME ); From 982932ad26f30771d2573965babd56d6f6b989a5 Mon Sep 17 00:00:00 2001 From: Leen Kilani Date: Mon, 10 Aug 2026 17:32:11 +0300 Subject: [PATCH 3/6] Let --local install run without a writable HOME, and test it The early return added for `skill show` only covered that one command, so `skill install corgea --local --dir ` still reached the unconditional `Config::load()` and panicked creating ~/.corgea/config.toml. The embedded path needs neither a registry nor a token, so the local installer was unusable in the same sandbox `skill show` was fixed for. Replace the special case with one rule: `Config::load_or_defaults()` falls back to in-memory defaults, and `tolerates_unusable_home` decides which commands get it. Only the two that serve the compiled-in skill qualify; everything else still fails loudly, because it needs a token or somewhere to save one. The existing tests only read the embedded constant, so they survived every regression this PR guards against. Add binary-level tests that drive the real executable with no token under an unusable HOME and compare the bytes it prints and writes against skills/corgea/SKILL.md. Reverting the config guard fails four of them; removing the --local auth bypass fails five. --- src/config.rs | 34 ++++-- src/main.rs | 35 +++--- tests/cli_skill_offline.rs | 227 +++++++++++++++++++++++++++++++++++++ 3 files changed, 272 insertions(+), 24 deletions(-) create mode 100644 tests/cli_skill_offline.rs diff --git a/src/config.rs b/src/config.rs index 5e4986c..60fb01b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -54,20 +54,34 @@ impl Config { Ok(file_path) } + /// The settings a fresh install starts from, before anything is persisted. + fn defaults() -> Self { + Self { + url: "https://www.corgea.app".to_string(), + debug: 0, + token: "".to_string(), + default_agent: None, + recency_gate: default_recency_gate(), + recency_threshold_days: default_recency_threshold_days(), + } + } + + /// Load without requiring a usable home directory. + /// + /// `load` creates `~/.corgea/config.toml` and fails when it cannot, which is + /// right for commands that authenticate or persist something. Commands that + /// only read a compiled-in string, or write to a path given explicitly, need + /// neither, and must still work in a sandbox with a read-only home. The + /// environment overrides in the getters apply to the defaults unchanged. + pub fn load_or_defaults() -> Self { + Self::load().unwrap_or_else(|_| Self::defaults()) + } + pub fn load() -> io::Result { let file_path = Self::config_path()?; if !file_path.exists() { - let config = Self { - url: "https://www.corgea.app".to_string(), - debug: 0, - token: "".to_string(), - default_agent: None, - recency_gate: default_recency_gate(), - recency_threshold_days: default_recency_threshold_days(), - }; - - let toml = toml::to_string(&config).expect("Failed to serialize config"); + let toml = toml::to_string(&Self::defaults()).expect("Failed to serialize config"); fs::write(&file_path, toml)?; } diff --git a/src/main.rs b/src/main.rs index 9feab5a..b4f5311 100644 --- a/src/main.rs +++ b/src/main.rs @@ -517,22 +517,30 @@ fn default_log_level(debug_flag: i8) -> &'static str { } } +/// Whether this command has to keep working when `~/.corgea` cannot be created. +/// +/// Both of these serve the skill compiled into the binary: one prints it, the +/// other writes it where the caller asked. Neither authenticates nor persists +/// anything, so a read-only home — the sandbox an agent is most likely to run +/// in — must not stop them. Every other command still fails loudly, because it +/// needs a token or somewhere to save one. +fn tolerates_unusable_home(command: &Option) -> bool { + matches!( + command, + Some(Commands::Skill { + command: SkillCommands::Show | SkillCommands::Install { local: true, .. } + }) + ) +} + fn main() { let cli = Cli::parse(); - // Dispatched before the config loads because that call creates - // ~/.corgea/config.toml and panics when it cannot. Printing a string - // compiled into the binary has no business failing in a read-only sandbox, - // which is exactly where an agent is most likely to run it. - if let Some(Commands::Skill { - command: SkillCommands::Show, - }) = &cli.command - { - skill::run_show(); - return; - } - - let mut corgea_config = Config::load().expect("Failed to load config"); + let mut corgea_config = if tolerates_unusable_home(&cli.command) { + Config::load_or_defaults() + } else { + Config::load().expect("Failed to load config") + }; init_logging(&corgea_config); fn verify_token_and_exit_when_fail(config: &Config) { if config.get_token().is_empty() { @@ -905,7 +913,6 @@ fn main() { *local, ); } - // Normally handled before the config loads, above. SkillCommands::Show => { skill::run_show(); } diff --git a/tests/cli_skill_offline.rs b/tests/cli_skill_offline.rs new file mode 100644 index 0000000..88d5491 --- /dev/null +++ b/tests/cli_skill_offline.rs @@ -0,0 +1,227 @@ +//! Binary-level cover for the offline skill path: `corgea skill show` and +//! `corgea skill install --local` must work with no token and no usable home, +//! because that is the sandbox an agent drives the CLI from. +//! +//! These assert on the process, not on the embedded constant. A unit test that +//! reads `EMBEDDED_SKILL` keeps passing when the dispatch or the auth bypass in +//! `main` regresses; these do not. + +mod common; + +use std::fs; +use std::path::{Path, PathBuf}; + +use tempfile::TempDir; + +/// A home directory that cannot be created: `/dev/null` is not a directory, so +/// `create_dir_all` under it fails with ENOTDIR. Stands in for the read-only +/// home of a sandboxed agent without needing root or a mount. +#[cfg(unix)] +const UNUSABLE_HOME: &str = "/dev/null/corgea-no-home"; + +/// The skill file compiled into the binary under test. +fn skill_source() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("skills") + .join("corgea") + .join("SKILL.md") +} + +fn skill_bytes() -> Vec { + fs::read(skill_source()).expect("skills/corgea/SKILL.md should be readable") +} + +/// Where `--dir ` puts the skill. +fn installed_skill(base: &Path) -> PathBuf { + base.join("corgea").join("SKILL.md") +} + +#[test] +fn skill_show_prints_the_skill_byte_for_byte() { + let (mut cmd, _home) = common::corgea_isolated(); + let out = cmd.args(["skill", "show"]).output().expect("run corgea"); + + assert!( + out.status.success(), + "skill show failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + assert_eq!( + out.stdout, + skill_bytes(), + "stdout must match skills/corgea/SKILL.md exactly" + ); +} + +#[cfg(unix)] +#[test] +fn skill_show_survives_a_home_that_cannot_be_created() { + let (mut cmd, _home) = common::corgea_isolated(); + let out = cmd + .env("HOME", UNUSABLE_HOME) + .env("USERPROFILE", UNUSABLE_HOME) + .args(["skill", "show"]) + .output() + .expect("run corgea"); + + assert!( + out.status.success(), + "skill show must not need a writable home: {}", + String::from_utf8_lossy(&out.stderr) + ); + assert_eq!(out.stdout, skill_bytes()); +} + +#[cfg(unix)] +#[test] +fn local_install_writes_the_skill_without_a_token_or_a_home() { + let dest = TempDir::new().expect("temp dest"); + let (mut cmd, _home) = common::corgea_isolated(); + let out = cmd + .env("HOME", UNUSABLE_HOME) + .env("USERPROFILE", UNUSABLE_HOME) + .args(["skill", "install", "corgea", "--local", "--dir"]) + .arg(dest.path()) + .output() + .expect("run corgea"); + + assert!( + out.status.success(), + "--local must not need a writable home when --dir is given: {}", + String::from_utf8_lossy(&out.stderr) + ); + assert_eq!( + fs::read(installed_skill(dest.path())).expect("skill should have been written"), + skill_bytes() + ); +} + +/// Resolving the destination from `--agent`/`--scope` rather than `--dir` still +/// only touches the working directory, so it is no more dependent on a home. +#[cfg(unix)] +#[test] +fn local_install_resolves_a_project_agent_dir_without_a_home() { + let project = TempDir::new().expect("temp project"); + let (mut cmd, _home) = common::corgea_isolated(); + let out = cmd + .env("HOME", UNUSABLE_HOME) + .env("USERPROFILE", UNUSABLE_HOME) + .current_dir(project.path()) + .args([ + "skill", "install", "corgea", "--local", "--agent", "cursor", "--scope", "project", + ]) + .output() + .expect("run corgea"); + + assert!( + out.status.success(), + "project-scoped --local failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + assert_eq!( + fs::read(project.path().join(".cursor/skills/corgea/SKILL.md")) + .expect("skill should have been written into the project"), + skill_bytes() + ); +} + +/// Persisting the default agent is the one part of `--local` that wants a +/// writable home. It must stay best-effort: the install already succeeded. +#[cfg(unix)] +#[test] +fn local_install_with_set_default_still_succeeds_without_a_home() { + let dest = TempDir::new().expect("temp dest"); + let (mut cmd, _home) = common::corgea_isolated(); + let out = cmd + .env("HOME", UNUSABLE_HOME) + .env("USERPROFILE", UNUSABLE_HOME) + .args([ + "skill", + "install", + "corgea", + "--local", + "--agent", + "cursor", + "--set-default", + "--dir", + ]) + .arg(dest.path()) + .output() + .expect("run corgea"); + + assert!( + out.status.success(), + "an unsaveable default agent must not fail the install: {}", + String::from_utf8_lossy(&out.stderr) + ); + assert_eq!( + fs::read(installed_skill(dest.path())).expect("skill should have been written"), + skill_bytes() + ); +} + +#[test] +fn registry_install_without_a_token_stops_at_the_auth_gate() { + let dest = TempDir::new().expect("temp dest"); + let (mut cmd, _home) = common::corgea_isolated(); + let out = cmd + // Nothing listens here, so if the token gate ever stopped + // short-circuiting, this fails locally instead of reaching a real host. + .env("CORGEA_URL", "http://127.0.0.1:1") + .args(["skill", "install", "corgea", "--dir"]) + .arg(dest.path()) + .output() + .expect("run corgea"); + + assert!( + !out.status.success(), + "a tokenless registry install must fail" + ); + assert!( + String::from_utf8_lossy(&out.stderr).contains("No token set"), + "it must fail at the auth gate, before any request: {}", + String::from_utf8_lossy(&out.stderr) + ); + assert!( + !installed_skill(dest.path()).exists(), + "nothing should be written when auth fails" + ); +} + +#[test] +fn local_install_refuses_a_skill_this_binary_does_not_carry() { + let dest = TempDir::new().expect("temp dest"); + let (mut cmd, _home) = common::corgea_isolated(); + let out = cmd + .args(["skill", "install", "sighthound", "--local", "--dir"]) + .arg(dest.path()) + .output() + .expect("run corgea"); + + assert!(!out.status.success()); + assert!( + String::from_utf8_lossy(&out.stderr).contains("--local can only install"), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + assert!(!dest.path().join("sighthound").exists()); +} + +#[test] +fn local_install_refuses_a_pinned_version() { + let dest = TempDir::new().expect("temp dest"); + let (mut cmd, _home) = common::corgea_isolated(); + let out = cmd + .args(["skill", "install", "corgea@1.0.0", "--local", "--dir"]) + .arg(dest.path()) + .output() + .expect("run corgea"); + + assert!(!out.status.success()); + assert!( + String::from_utf8_lossy(&out.stderr).contains("version cannot be"), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + assert!(!installed_skill(dest.path()).exists()); +} From f70d754a445d57f36c5fe9dceb097e1809c8a6e5 Mon Sep 17 00:00:00 2001 From: Leen Kilani Date: Mon, 10 Aug 2026 18:24:00 +0300 Subject: [PATCH 4/6] Read the config for offline commands without writing or panicking `load_or_defaults` called `load()`, which creates ~/.corgea and writes config.toml as a side effect of resolving its path. So `skill show` began persisting state on a writable home -- a regression against the early return it replaced, and a contradiction of its own comment. It also inherited the `.expect()` on the TOML parse, so a config the user had merely mistyped took down the commands that exist to work when nothing else does. Read the file directly instead, through a `config_path_readonly` that creates nothing, falling back to the defaults when it is absent, unreadable or unparseable. `load()` now returns a parse failure as an `io::Error` naming the file rather than panicking, and `main` prints that and exits 1: a file the user can edit is their problem to fix, not a crash with a backtrace note. The tolerance is scoped to the embedded path. A command that needs the config still refuses to run on one it cannot parse. --- src/config.rs | 48 +++++++++++++---- src/main.rs | 10 +++- tests/cli_skill_offline.rs | 107 +++++++++++++++++++++++++++++++++++++ 3 files changed, 154 insertions(+), 11 deletions(-) diff --git a/src/config.rs b/src/config.rs index 60fb01b..d9c2243 100644 --- a/src/config.rs +++ b/src/config.rs @@ -54,6 +54,14 @@ impl Config { Ok(file_path) } + /// Where the config lives, creating nothing on the way to it. + fn config_path_readonly() -> Option { + let mut path = dirs::home_dir()?; + path.push(".corgea"); + path.push("config.toml"); + Some(path) + } + /// The settings a fresh install starts from, before anything is persisted. fn defaults() -> Self { Self { @@ -66,15 +74,29 @@ impl Config { } } - /// Load without requiring a usable home directory. + fn apply_env_overrides(&mut self) { + if let Ok(corgea_debug) = env::var("CORGEA_DEBUG") { + self.debug = corgea_debug.parse::().unwrap_or(0); + } + } + + /// Read the persisted settings, touching nothing. /// /// `load` creates `~/.corgea/config.toml` and fails when it cannot, which is /// right for commands that authenticate or persist something. Commands that - /// only read a compiled-in string, or write to a path given explicitly, need - /// neither, and must still work in a sandbox with a read-only home. The - /// environment overrides in the getters apply to the defaults unchanged. + /// only serve the skill compiled into the binary need neither: they have to + /// run against a read-only home, and must not leave state behind on a + /// writable one. A home that is absent, unreadable or malformed therefore + /// yields the defaults rather than an error or a newly written file. pub fn load_or_defaults() -> Self { - Self::load().unwrap_or_else(|_| Self::defaults()) + let mut config = Self::config_path_readonly() + .and_then(|path| fs::read_to_string(path).ok()) + .and_then(|contents| toml::from_str(&contents).ok()) + .unwrap_or_else(Self::defaults); + + config.apply_env_overrides(); + + config } pub fn load() -> io::Result { @@ -88,11 +110,17 @@ impl Config { let contents = fs::read_to_string(&file_path)?; - let mut config: Self = toml::from_str(&contents).expect("Failed to deserialize config"); - - if let Ok(corgea_debug) = env::var("CORGEA_DEBUG") { - config.debug = corgea_debug.parse::().unwrap_or(0); - } + // An unparseable config is a normal error, not a bug: it is a file the + // user can edit. Returning it lets callers that tolerate a bad config + // fall back, and gives the rest a message naming the file. + let mut config: Self = toml::from_str(&contents).map_err(|e| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("Failed to parse {}: {}", file_path.display(), e), + ) + })?; + + config.apply_env_overrides(); Ok(config) } diff --git a/src/main.rs b/src/main.rs index b4f5311..08c201d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -539,7 +539,15 @@ fn main() { let mut corgea_config = if tolerates_unusable_home(&cli.command) { Config::load_or_defaults() } else { - Config::load().expect("Failed to load config") + match Config::load() { + Ok(config) => config, + // `config.toml` is a file the user can edit, so a bad one is their + // problem to fix, not a Rust panic with a backtrace note. + Err(e) => { + eprintln!("Failed to load config: {}", e); + std::process::exit(1); + } + } }; init_logging(&corgea_config); fn verify_token_and_exit_when_fail(config: &Config) { diff --git a/tests/cli_skill_offline.rs b/tests/cli_skill_offline.rs index 88d5491..22fbf8b 100644 --- a/tests/cli_skill_offline.rs +++ b/tests/cli_skill_offline.rs @@ -160,6 +160,113 @@ fn local_install_with_set_default_still_succeeds_without_a_home() { ); } +/// Serving the embedded skill is a read, so it must not leave `~/.corgea` +/// behind on a home it *could* have written to. +#[test] +fn skill_show_creates_no_config() { + let (mut cmd, home) = common::corgea_isolated(); + let out = cmd.args(["skill", "show"]).output().expect("run corgea"); + + assert!(out.status.success()); + assert!( + !home.path().join(".corgea").exists(), + "skill show must not create ~/.corgea" + ); +} + +#[test] +fn local_install_creates_no_config() { + let dest = TempDir::new().expect("temp dest"); + let (mut cmd, home) = common::corgea_isolated(); + let out = cmd + .args(["skill", "install", "corgea", "--local", "--dir"]) + .arg(dest.path()) + .output() + .expect("run corgea"); + + assert!(out.status.success()); + assert!( + !home.path().join(".corgea").exists(), + "--local must not create ~/.corgea" + ); +} + +/// `config.toml` is a file the user can edit, so a broken one must not take the +/// offline commands down with it. +#[test] +fn offline_commands_survive_a_malformed_config() { + let dest = TempDir::new().expect("temp dest"); + let (mut show, home) = common::corgea_isolated(); + let config_dir = home.path().join(".corgea"); + fs::create_dir_all(&config_dir).expect("create config dir"); + fs::write( + config_dir.join("config.toml"), + "this is not = valid = toml\n", + ) + .expect("write malformed config"); + + let shown = show.args(["skill", "show"]).output().expect("run corgea"); + assert!( + shown.status.success(), + "skill show must survive a malformed config: {}", + String::from_utf8_lossy(&shown.stderr) + ); + assert_eq!(shown.stdout, skill_bytes()); + + let (mut install, _unused_home) = common::corgea_isolated(); + let installed = install + .env("HOME", home.path()) + .env("USERPROFILE", home.path()) + .args(["skill", "install", "corgea", "--local", "--dir"]) + .arg(dest.path()) + .output() + .expect("run corgea"); + assert!( + installed.status.success(), + "--local must survive a malformed config: {}", + String::from_utf8_lossy(&installed.stderr) + ); + assert_eq!( + fs::read(installed_skill(dest.path())).expect("skill should have been written"), + skill_bytes() + ); +} + +/// The tolerance above is scoped to the embedded path. A command that needs the +/// config must still refuse to run on one it cannot parse. +#[test] +fn a_malformed_config_still_stops_commands_that_need_it() { + let dest = TempDir::new().expect("temp dest"); + let (mut cmd, home) = common::corgea_isolated(); + let config_dir = home.path().join(".corgea"); + fs::create_dir_all(&config_dir).expect("create config dir"); + fs::write( + config_dir.join("config.toml"), + "this is not = valid = toml\n", + ) + .expect("write malformed config"); + + let out = cmd + .args(["skill", "install", "corgea", "--dir"]) + .arg(dest.path()) + .output() + .expect("run corgea"); + + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + !out.status.success(), + "a registry install must not silently ignore a broken config" + ); + assert!( + stderr.contains("config.toml"), + "the error should name the file to fix: {stderr}" + ); + assert!( + !stderr.contains("panicked"), + "an editable file being wrong is not a crash: {stderr}" + ); +} + #[test] fn registry_install_without_a_token_stops_at_the_auth_gate() { let dest = TempDir::new().expect("temp dest"); From 510e69b9d8f474819d04d06f3a3e4507db51aaef Mon Sep 17 00:00:00 2001 From: Leen Kilani Date: Tue, 11 Aug 2026 10:39:08 +0300 Subject: [PATCH 5/6] Point the skill at --help instead of embedding it in the binary Dropped the embedded skill, `corgea skill show` and `--local`. The problem they solved is real but unreported: no client has hit version skew, and `corgea --help` is already a version-pinned reference compiled into the binary by clap, for free. The skill now says so. It opens with `corgea --version`, names `--help` as authoritative wherever the two disagree, and tells the agent to report an outdated CLI with the upgrade for how it was installed rather than upgrading unprompted, since CI runners and self-hosted installs are often pinned. It also names the case a "command not found" rule misses: a changed default or output shape does not error, so a surprising result on an older CLI is a version difference before it is a bug. Kept from the earlier approach: a malformed `config.toml` used to panic with a backtrace note, because `load` called `.expect()` on the parse and `main` called `.expect()` on the result. It is a file the user can edit, so it now returns an `io::Error` naming the file and exits 1. Co-authored-by: Cursor --- skills/corgea/SKILL.md | 19 +++ src/config.rs | 31 +--- src/main.rs | 54 +----- src/skill.rs | 182 +++++--------------- tests/cli_config_errors.rs | 51 ++++++ tests/cli_skill_offline.rs | 334 ------------------------------------- 6 files changed, 120 insertions(+), 551 deletions(-) create mode 100644 tests/cli_config_errors.rs delete mode 100644 tests/cli_skill_offline.rs diff --git a/skills/corgea/SKILL.md b/skills/corgea/SKILL.md index bbf8d9e..bfd8f02 100644 --- a/skills/corgea/SKILL.md +++ b/skills/corgea/SKILL.md @@ -8,6 +8,25 @@ allowed-tools: Shell, Read, Grep, Glob, StrReplace Find and fix security vulnerabilities using AI-powered scanning (BLAST), third-party scanners, and AI-generated fixes. +## Check the installed version first + +This file describes a CLI version, not the one on the machine. Run `corgea --version` before relying on anything below. + +```bash +corgea --version +``` + +`corgea --help` and `corgea --help` come from the installed binary, so they are authoritative about which commands and flags exist. **Where this file and `--help` disagree, `--help` is right.** Confirm any command from here that you have not seen in `--help` before running it. + +If a command or flag is missing, the CLI is likely older than this reference. Report that to the user with the upgrade for how it was installed, rather than upgrading unprompted — CI runners and self-hosted installs are often pinned deliberately. + +```bash +pip install --upgrade corgea-cli # installed with pip +npm install -g @corgea/cli # installed with npm +``` + +A missing flag is the visible case. A flag whose default or output shape changed will not error, so treat a surprising result on an older CLI as a version difference before treating it as a bug. + ## Commands ### Scan — `corgea scan [scanner]` diff --git a/src/config.rs b/src/config.rs index d9c2243..42f6365 100644 --- a/src/config.rs +++ b/src/config.rs @@ -54,14 +54,6 @@ impl Config { Ok(file_path) } - /// Where the config lives, creating nothing on the way to it. - fn config_path_readonly() -> Option { - let mut path = dirs::home_dir()?; - path.push(".corgea"); - path.push("config.toml"); - Some(path) - } - /// The settings a fresh install starts from, before anything is persisted. fn defaults() -> Self { Self { @@ -80,25 +72,6 @@ impl Config { } } - /// Read the persisted settings, touching nothing. - /// - /// `load` creates `~/.corgea/config.toml` and fails when it cannot, which is - /// right for commands that authenticate or persist something. Commands that - /// only serve the skill compiled into the binary need neither: they have to - /// run against a read-only home, and must not leave state behind on a - /// writable one. A home that is absent, unreadable or malformed therefore - /// yields the defaults rather than an error or a newly written file. - pub fn load_or_defaults() -> Self { - let mut config = Self::config_path_readonly() - .and_then(|path| fs::read_to_string(path).ok()) - .and_then(|contents| toml::from_str(&contents).ok()) - .unwrap_or_else(Self::defaults); - - config.apply_env_overrides(); - - config - } - pub fn load() -> io::Result { let file_path = Self::config_path()?; @@ -111,8 +84,8 @@ impl Config { let contents = fs::read_to_string(&file_path)?; // An unparseable config is a normal error, not a bug: it is a file the - // user can edit. Returning it lets callers that tolerate a bad config - // fall back, and gives the rest a message naming the file. + // user can edit. Returning it rather than panicking lets the caller + // report it as what it is, naming the file to fix. let mut config: Self = toml::from_str(&contents).map_err(|e| { io::Error::new( io::ErrorKind::InvalidData, diff --git a/src/main.rs b/src/main.rs index 08c201d..4712f5b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -457,15 +457,7 @@ enum SkillCommands { help = "Persist the provided --agent as the default for future installs." )] set_default: bool, - - #[arg( - long, - help = "Install the 'corgea' skill built into this binary instead of fetching it from the registry. Works offline and without a token." - )] - local: bool, }, - /// Print the 'corgea' skill built into this binary - Show, /// Configure the default agent used when --agent is not provided SetDefaultAgent { #[arg(help = "Agent id (e.g. cursor, claude-code, codex).")] @@ -517,36 +509,15 @@ fn default_log_level(debug_flag: i8) -> &'static str { } } -/// Whether this command has to keep working when `~/.corgea` cannot be created. -/// -/// Both of these serve the skill compiled into the binary: one prints it, the -/// other writes it where the caller asked. Neither authenticates nor persists -/// anything, so a read-only home — the sandbox an agent is most likely to run -/// in — must not stop them. Every other command still fails loudly, because it -/// needs a token or somewhere to save one. -fn tolerates_unusable_home(command: &Option) -> bool { - matches!( - command, - Some(Commands::Skill { - command: SkillCommands::Show | SkillCommands::Install { local: true, .. } - }) - ) -} - fn main() { let cli = Cli::parse(); - - let mut corgea_config = if tolerates_unusable_home(&cli.command) { - Config::load_or_defaults() - } else { - match Config::load() { - Ok(config) => config, - // `config.toml` is a file the user can edit, so a bad one is their - // problem to fix, not a Rust panic with a backtrace note. - Err(e) => { - eprintln!("Failed to load config: {}", e); - std::process::exit(1); - } + let mut corgea_config = match Config::load() { + Ok(config) => config, + // `config.toml` is a file the user can edit, so a bad one is theirs to + // fix, not a Rust panic with a backtrace note. + Err(e) => { + eprintln!("Failed to load config: {}", e); + std::process::exit(1); } }; init_logging(&corgea_config); @@ -904,13 +875,8 @@ fn main() { scope, dir, set_default, - local, } => { - // --local reads a string compiled into this binary, so it must - // not require a login the way the registry path does. - if !*local { - verify_token_and_exit_when_fail(&corgea_config); - } + verify_token_and_exit_when_fail(&corgea_config); skill::run_install( &mut corgea_config, name, @@ -918,12 +884,8 @@ fn main() { scope, dir.clone(), *set_default, - *local, ); } - SkillCommands::Show => { - skill::run_show(); - } SkillCommands::SetDefaultAgent { agent } => { skill::run_set_default_agent(&mut corgea_config, agent); } diff --git a/src/skill.rs b/src/skill.rs index 8552bd9..be035f7 100644 --- a/src/skill.rs +++ b/src/skill.rs @@ -1,20 +1,8 @@ use crate::config::Config; use crate::utils; use crate::utils::terminal::{set_text_color, TerminalColor}; -use std::io::Write; use std::path::{Path, PathBuf}; -/// The skill this binary was built from, so the reference an agent reads always -/// matches the CLI it is driving. Kept out of the registry path deliberately: -/// reading it must work offline and without a token. -pub const EMBEDDED_SKILL: &str = include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/skills/corgea/SKILL.md" -)); - -/// The name `--local` installs under, and the only name it accepts. -pub const EMBEDDED_SKILL_NAME: &str = "corgea"; - /// Supported agents and where their skills are installed. /// /// Tuple layout: `(agent_id, project_relative_dir, user_relative_dir)`. @@ -131,32 +119,46 @@ pub fn run_set_default_agent(config: &mut Config, agent: &str) { } } -/// `corgea skill show` -/// -/// Writes the embedded skill to stdout verbatim so it can be piped or read by -/// an agent. No token, no network, no formatting. -pub fn run_show() { - // Rust ignores SIGPIPE, so `corgea skill show | head` would otherwise panic - // once the skill outgrows the pipe buffer. A closed reader is a normal exit. - match std::io::stdout().write_all(EMBEDDED_SKILL.as_bytes()) { - Ok(()) => {} - Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => {} - Err(e) => { - eprintln!("Failed to write skill: {}", e); - std::process::exit(1); +/// `corgea skill install ` +pub fn run_install( + config: &mut Config, + name_arg: &str, + agent: Option, + scope: &str, + dir: Option, + set_default: bool, +) { + let (skill_name, version) = parse_skill_arg(name_arg); + + if !["project", "user"].contains(&scope) { + eprintln!("Invalid scope '{}'. Expected 'project' or 'user'.", scope); + std::process::exit(1); + } + + // Resolve the agent (flag > configured default) unless a custom dir is set. + let resolved_agent = agent.clone().or_else(|| config.get_default_agent()); + if dir.is_none() && resolved_agent.is_none() { + eprintln!( + "No agent specified. Pass --agent , set a default with \ + 'corgea skill set-default-agent ', or use --dir.\nSupported agents: {}", + supported_agent_ids() + ); + std::process::exit(1); + } + if dir.is_none() { + if let Some(ref a) = resolved_agent { + if !is_supported_agent(a) { + eprintln!( + "Unsupported agent '{}'. Supported agents: {}", + a, + supported_agent_ids() + ); + std::process::exit(1); + } } } -} -/// Fetch an approved skill from the Corgea registry, exiting on any failure. -/// -/// Returns the skill body and a display label for the resolved version. -fn fetch_registry_skill( - config: &Config, - skill_name: &str, - version: Option<&str>, -) -> (String, String) { - let result = utils::api::get_skill(config.get_url().as_str(), skill_name, version); + let result = utils::api::get_skill(config.get_url().as_str(), &skill_name, version.as_deref()); let response = match result { Ok(Some(resp)) => resp, @@ -229,88 +231,7 @@ fn fetch_registry_skill( std::process::exit(1); } - let label = format!("v{}", version_info.version); - (version_info.content.unwrap_or_default(), label) -} - -/// Resolve the embedded skill for `--local`, exiting if the request does not -/// match what this binary carries. -fn embedded_skill_or_exit(skill_name: &str, version: Option<&str>) -> (String, String) { - if skill_name != EMBEDDED_SKILL_NAME { - eprintln!( - "{}", - set_text_color( - &format!( - "--local can only install '{}', the skill built into this binary. \ - Drop --local to fetch '{}' from the registry.", - EMBEDDED_SKILL_NAME, skill_name - ), - TerminalColor::Red - ) - ); - std::process::exit(1); - } - if version.is_some() { - eprintln!( - "{}", - set_text_color( - "--local installs the skill pinned to this binary, so a version cannot be \ - requested. Drop the version, or drop --local to pick one from the registry.", - TerminalColor::Red - ) - ); - std::process::exit(1); - } - - let label = format!("v{}, embedded", env!("CARGO_PKG_VERSION")); - (EMBEDDED_SKILL.to_string(), label) -} - -/// `corgea skill install ` -pub fn run_install( - config: &mut Config, - name_arg: &str, - agent: Option, - scope: &str, - dir: Option, - set_default: bool, - local: bool, -) { - let (skill_name, version) = parse_skill_arg(name_arg); - - if !["project", "user"].contains(&scope) { - eprintln!("Invalid scope '{}'. Expected 'project' or 'user'.", scope); - std::process::exit(1); - } - - // Resolve the agent (flag > configured default) unless a custom dir is set. - let resolved_agent = agent.clone().or_else(|| config.get_default_agent()); - if dir.is_none() && resolved_agent.is_none() { - eprintln!( - "No agent specified. Pass --agent , set a default with \ - 'corgea skill set-default-agent ', or use --dir.\nSupported agents: {}", - supported_agent_ids() - ); - std::process::exit(1); - } - if dir.is_none() { - if let Some(ref a) = resolved_agent { - if !is_supported_agent(a) { - eprintln!( - "Unsupported agent '{}'. Supported agents: {}", - a, - supported_agent_ids() - ); - std::process::exit(1); - } - } - } - - let (content, version_label) = if local { - embedded_skill_or_exit(&skill_name, version.as_deref()) - } else { - fetch_registry_skill(config, &skill_name, version.as_deref()) - }; + let content = version_info.content.unwrap_or_default(); let cwd = match std::env::current_dir() { Ok(p) => p, @@ -351,9 +272,9 @@ pub fn run_install( "{}", set_text_color( &format!( - "Installed skill '{}' ({}) to {}", + "Installed skill '{}' (v{}) to {}", skill_name, - version_label, + version_info.version, skill_file.display() ), TerminalColor::Green @@ -443,27 +364,4 @@ mod tests { let result = resolve_skill_dir("foo", None, "project", None, &cwd, &home); assert!(result.is_err()); } - - #[test] - fn test_embedded_skill_is_present() { - assert!(!EMBEDDED_SKILL.trim().is_empty()); - } - - #[test] - fn test_embedded_skill_has_frontmatter_naming_itself() { - let mut lines = EMBEDDED_SKILL.lines(); - assert_eq!( - lines.next().map(str::trim), - Some("---"), - "embedded skill must open with YAML frontmatter" - ); - let frontmatter: Vec<&str> = lines.take_while(|l| l.trim() != "---").collect(); - assert!( - frontmatter - .iter() - .any(|l| l.trim() == format!("name: {}", EMBEDDED_SKILL_NAME)), - "frontmatter name must stay '{}', which is what --local installs under", - EMBEDDED_SKILL_NAME - ); - } } diff --git a/tests/cli_config_errors.rs b/tests/cli_config_errors.rs new file mode 100644 index 0000000..8053fd8 --- /dev/null +++ b/tests/cli_config_errors.rs @@ -0,0 +1,51 @@ +//! `config.toml` is a file the user can edit, so a bad one must read as an +//! error naming the file, not as a Rust panic. + +mod common; + +use std::fs; + +fn write_malformed_config(home: &std::path::Path) { + let dir = home.join(".corgea"); + fs::create_dir_all(&dir).expect("create config dir"); + fs::write(dir.join("config.toml"), "this is not = valid = toml\n").expect("write config"); +} + +#[test] +fn a_malformed_config_fails_cleanly_instead_of_panicking() { + let (mut cmd, home) = common::corgea_isolated(); + write_malformed_config(home.path()); + + let out = cmd.args(["ls"]).output().expect("run corgea"); + let stderr = String::from_utf8_lossy(&out.stderr); + + assert!(!out.status.success(), "a broken config must stop the run"); + assert!( + stderr.contains("config.toml"), + "the error should name the file to fix: {stderr}" + ); + assert!( + !stderr.contains("panicked"), + "an editable file being wrong is not a crash: {stderr}" + ); +} + +#[test] +fn a_valid_config_is_still_read() { + let (mut cmd, home) = common::corgea_isolated(); + let dir = home.path().join(".corgea"); + fs::create_dir_all(&dir).expect("create config dir"); + fs::write( + dir.join("config.toml"), + "url = \"https://example.invalid\"\ndebug = 0\ntoken = \"\"\n", + ) + .expect("write config"); + + let out = cmd.args(["--help"]).output().expect("run corgea"); + + assert!( + out.status.success(), + "a parseable config must not stop the run: {}", + String::from_utf8_lossy(&out.stderr) + ); +} diff --git a/tests/cli_skill_offline.rs b/tests/cli_skill_offline.rs deleted file mode 100644 index 22fbf8b..0000000 --- a/tests/cli_skill_offline.rs +++ /dev/null @@ -1,334 +0,0 @@ -//! Binary-level cover for the offline skill path: `corgea skill show` and -//! `corgea skill install --local` must work with no token and no usable home, -//! because that is the sandbox an agent drives the CLI from. -//! -//! These assert on the process, not on the embedded constant. A unit test that -//! reads `EMBEDDED_SKILL` keeps passing when the dispatch or the auth bypass in -//! `main` regresses; these do not. - -mod common; - -use std::fs; -use std::path::{Path, PathBuf}; - -use tempfile::TempDir; - -/// A home directory that cannot be created: `/dev/null` is not a directory, so -/// `create_dir_all` under it fails with ENOTDIR. Stands in for the read-only -/// home of a sandboxed agent without needing root or a mount. -#[cfg(unix)] -const UNUSABLE_HOME: &str = "/dev/null/corgea-no-home"; - -/// The skill file compiled into the binary under test. -fn skill_source() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .join("skills") - .join("corgea") - .join("SKILL.md") -} - -fn skill_bytes() -> Vec { - fs::read(skill_source()).expect("skills/corgea/SKILL.md should be readable") -} - -/// Where `--dir ` puts the skill. -fn installed_skill(base: &Path) -> PathBuf { - base.join("corgea").join("SKILL.md") -} - -#[test] -fn skill_show_prints_the_skill_byte_for_byte() { - let (mut cmd, _home) = common::corgea_isolated(); - let out = cmd.args(["skill", "show"]).output().expect("run corgea"); - - assert!( - out.status.success(), - "skill show failed: {}", - String::from_utf8_lossy(&out.stderr) - ); - assert_eq!( - out.stdout, - skill_bytes(), - "stdout must match skills/corgea/SKILL.md exactly" - ); -} - -#[cfg(unix)] -#[test] -fn skill_show_survives_a_home_that_cannot_be_created() { - let (mut cmd, _home) = common::corgea_isolated(); - let out = cmd - .env("HOME", UNUSABLE_HOME) - .env("USERPROFILE", UNUSABLE_HOME) - .args(["skill", "show"]) - .output() - .expect("run corgea"); - - assert!( - out.status.success(), - "skill show must not need a writable home: {}", - String::from_utf8_lossy(&out.stderr) - ); - assert_eq!(out.stdout, skill_bytes()); -} - -#[cfg(unix)] -#[test] -fn local_install_writes_the_skill_without_a_token_or_a_home() { - let dest = TempDir::new().expect("temp dest"); - let (mut cmd, _home) = common::corgea_isolated(); - let out = cmd - .env("HOME", UNUSABLE_HOME) - .env("USERPROFILE", UNUSABLE_HOME) - .args(["skill", "install", "corgea", "--local", "--dir"]) - .arg(dest.path()) - .output() - .expect("run corgea"); - - assert!( - out.status.success(), - "--local must not need a writable home when --dir is given: {}", - String::from_utf8_lossy(&out.stderr) - ); - assert_eq!( - fs::read(installed_skill(dest.path())).expect("skill should have been written"), - skill_bytes() - ); -} - -/// Resolving the destination from `--agent`/`--scope` rather than `--dir` still -/// only touches the working directory, so it is no more dependent on a home. -#[cfg(unix)] -#[test] -fn local_install_resolves_a_project_agent_dir_without_a_home() { - let project = TempDir::new().expect("temp project"); - let (mut cmd, _home) = common::corgea_isolated(); - let out = cmd - .env("HOME", UNUSABLE_HOME) - .env("USERPROFILE", UNUSABLE_HOME) - .current_dir(project.path()) - .args([ - "skill", "install", "corgea", "--local", "--agent", "cursor", "--scope", "project", - ]) - .output() - .expect("run corgea"); - - assert!( - out.status.success(), - "project-scoped --local failed: {}", - String::from_utf8_lossy(&out.stderr) - ); - assert_eq!( - fs::read(project.path().join(".cursor/skills/corgea/SKILL.md")) - .expect("skill should have been written into the project"), - skill_bytes() - ); -} - -/// Persisting the default agent is the one part of `--local` that wants a -/// writable home. It must stay best-effort: the install already succeeded. -#[cfg(unix)] -#[test] -fn local_install_with_set_default_still_succeeds_without_a_home() { - let dest = TempDir::new().expect("temp dest"); - let (mut cmd, _home) = common::corgea_isolated(); - let out = cmd - .env("HOME", UNUSABLE_HOME) - .env("USERPROFILE", UNUSABLE_HOME) - .args([ - "skill", - "install", - "corgea", - "--local", - "--agent", - "cursor", - "--set-default", - "--dir", - ]) - .arg(dest.path()) - .output() - .expect("run corgea"); - - assert!( - out.status.success(), - "an unsaveable default agent must not fail the install: {}", - String::from_utf8_lossy(&out.stderr) - ); - assert_eq!( - fs::read(installed_skill(dest.path())).expect("skill should have been written"), - skill_bytes() - ); -} - -/// Serving the embedded skill is a read, so it must not leave `~/.corgea` -/// behind on a home it *could* have written to. -#[test] -fn skill_show_creates_no_config() { - let (mut cmd, home) = common::corgea_isolated(); - let out = cmd.args(["skill", "show"]).output().expect("run corgea"); - - assert!(out.status.success()); - assert!( - !home.path().join(".corgea").exists(), - "skill show must not create ~/.corgea" - ); -} - -#[test] -fn local_install_creates_no_config() { - let dest = TempDir::new().expect("temp dest"); - let (mut cmd, home) = common::corgea_isolated(); - let out = cmd - .args(["skill", "install", "corgea", "--local", "--dir"]) - .arg(dest.path()) - .output() - .expect("run corgea"); - - assert!(out.status.success()); - assert!( - !home.path().join(".corgea").exists(), - "--local must not create ~/.corgea" - ); -} - -/// `config.toml` is a file the user can edit, so a broken one must not take the -/// offline commands down with it. -#[test] -fn offline_commands_survive_a_malformed_config() { - let dest = TempDir::new().expect("temp dest"); - let (mut show, home) = common::corgea_isolated(); - let config_dir = home.path().join(".corgea"); - fs::create_dir_all(&config_dir).expect("create config dir"); - fs::write( - config_dir.join("config.toml"), - "this is not = valid = toml\n", - ) - .expect("write malformed config"); - - let shown = show.args(["skill", "show"]).output().expect("run corgea"); - assert!( - shown.status.success(), - "skill show must survive a malformed config: {}", - String::from_utf8_lossy(&shown.stderr) - ); - assert_eq!(shown.stdout, skill_bytes()); - - let (mut install, _unused_home) = common::corgea_isolated(); - let installed = install - .env("HOME", home.path()) - .env("USERPROFILE", home.path()) - .args(["skill", "install", "corgea", "--local", "--dir"]) - .arg(dest.path()) - .output() - .expect("run corgea"); - assert!( - installed.status.success(), - "--local must survive a malformed config: {}", - String::from_utf8_lossy(&installed.stderr) - ); - assert_eq!( - fs::read(installed_skill(dest.path())).expect("skill should have been written"), - skill_bytes() - ); -} - -/// The tolerance above is scoped to the embedded path. A command that needs the -/// config must still refuse to run on one it cannot parse. -#[test] -fn a_malformed_config_still_stops_commands_that_need_it() { - let dest = TempDir::new().expect("temp dest"); - let (mut cmd, home) = common::corgea_isolated(); - let config_dir = home.path().join(".corgea"); - fs::create_dir_all(&config_dir).expect("create config dir"); - fs::write( - config_dir.join("config.toml"), - "this is not = valid = toml\n", - ) - .expect("write malformed config"); - - let out = cmd - .args(["skill", "install", "corgea", "--dir"]) - .arg(dest.path()) - .output() - .expect("run corgea"); - - let stderr = String::from_utf8_lossy(&out.stderr); - assert!( - !out.status.success(), - "a registry install must not silently ignore a broken config" - ); - assert!( - stderr.contains("config.toml"), - "the error should name the file to fix: {stderr}" - ); - assert!( - !stderr.contains("panicked"), - "an editable file being wrong is not a crash: {stderr}" - ); -} - -#[test] -fn registry_install_without_a_token_stops_at_the_auth_gate() { - let dest = TempDir::new().expect("temp dest"); - let (mut cmd, _home) = common::corgea_isolated(); - let out = cmd - // Nothing listens here, so if the token gate ever stopped - // short-circuiting, this fails locally instead of reaching a real host. - .env("CORGEA_URL", "http://127.0.0.1:1") - .args(["skill", "install", "corgea", "--dir"]) - .arg(dest.path()) - .output() - .expect("run corgea"); - - assert!( - !out.status.success(), - "a tokenless registry install must fail" - ); - assert!( - String::from_utf8_lossy(&out.stderr).contains("No token set"), - "it must fail at the auth gate, before any request: {}", - String::from_utf8_lossy(&out.stderr) - ); - assert!( - !installed_skill(dest.path()).exists(), - "nothing should be written when auth fails" - ); -} - -#[test] -fn local_install_refuses_a_skill_this_binary_does_not_carry() { - let dest = TempDir::new().expect("temp dest"); - let (mut cmd, _home) = common::corgea_isolated(); - let out = cmd - .args(["skill", "install", "sighthound", "--local", "--dir"]) - .arg(dest.path()) - .output() - .expect("run corgea"); - - assert!(!out.status.success()); - assert!( - String::from_utf8_lossy(&out.stderr).contains("--local can only install"), - "stderr: {}", - String::from_utf8_lossy(&out.stderr) - ); - assert!(!dest.path().join("sighthound").exists()); -} - -#[test] -fn local_install_refuses_a_pinned_version() { - let dest = TempDir::new().expect("temp dest"); - let (mut cmd, _home) = common::corgea_isolated(); - let out = cmd - .args(["skill", "install", "corgea@1.0.0", "--local", "--dir"]) - .arg(dest.path()) - .output() - .expect("run corgea"); - - assert!(!out.status.success()); - assert!( - String::from_utf8_lossy(&out.stderr).contains("version cannot be"), - "stderr: {}", - String::from_utf8_lossy(&out.stderr) - ); - assert!(!installed_skill(dest.path()).exists()); -} From ac77ae9eee923bf4a3b72b61fbfb5a63ede4ffd7 Mon Sep 17 00:00:00 2001 From: Leen Kilani Date: Tue, 11 Aug 2026 17:05:28 +0300 Subject: [PATCH 6/6] Test the valid-config path with a command that loads the config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--help` exits during clap parsing, so the assertion held whether or not `Config::load` ever ran. Use `ls`, the same command the malformed-config test uses, so the two differ only in whether the file parses, and assert on reaching the auth gate — which is only possible once the config has been read. Co-authored-by: Cursor --- tests/cli_config_errors.rs | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/tests/cli_config_errors.rs b/tests/cli_config_errors.rs index 8053fd8..00e547b 100644 --- a/tests/cli_config_errors.rs +++ b/tests/cli_config_errors.rs @@ -30,6 +30,9 @@ fn a_malformed_config_fails_cleanly_instead_of_panicking() { ); } +/// Uses the same command as the test above, so the two differ only in whether +/// the config parses. `--help` would not do: clap exits during parsing, before +/// `Config::load` is ever reached. #[test] fn a_valid_config_is_still_read() { let (mut cmd, home) = common::corgea_isolated(); @@ -41,11 +44,17 @@ fn a_valid_config_is_still_read() { ) .expect("write config"); - let out = cmd.args(["--help"]).output().expect("run corgea"); + let out = cmd.args(["ls"]).output().expect("run corgea"); + let stderr = String::from_utf8_lossy(&out.stderr); assert!( - out.status.success(), - "a parseable config must not stop the run: {}", - String::from_utf8_lossy(&out.stderr) + !stderr.contains("Failed to load config"), + "a parseable config must load: {stderr}" + ); + // Reaching the auth gate is what proves the config was read: the token it + // found was the empty one written above. + assert!( + stderr.contains("No token set"), + "expected the run to get as far as the auth gate: {stderr}" ); }