Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 57 additions & 15 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,31 +54,73 @@ impl Config {
Ok(file_path)
}

/// Where the config lives, creating nothing on the way to it.
fn config_path_readonly() -> Option<PathBuf> {
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 {
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(),
}
}

fn apply_env_overrides(&mut self) {
if let Ok(corgea_debug) = env::var("CORGEA_DEBUG") {
self.debug = corgea_debug.parse::<i8>().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 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<Self> {
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)?;
}

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::<i8>().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)
}
Expand Down
50 changes: 48 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Show,
/// Configure the default agent used when --agent is not provided
SetDefaultAgent {
#[arg(help = "Agent id (e.g. cursor, claude-code, codex).")]
Expand Down Expand Up @@ -509,9 +517,38 @@ 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<Commands>) -> bool {
matches!(
command,
Some(Commands::Skill {
command: SkillCommands::Show | SkillCommands::Install { local: true, .. }
})
)
}

fn main() {
let cli = Cli::parse();
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()
Comment thread
leenk7991 marked this conversation as resolved.
} 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);
}
}
};
init_logging(&corgea_config);
fn verify_token_and_exit_when_fail(config: &Config) {
if config.get_token().is_empty() {
Expand Down Expand Up @@ -867,17 +904,26 @@ 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,
agent.clone(),
scope,
dir.clone(),
*set_default,
*local,
);
}
SkillCommands::Show => {
skill::run_show();
}
SkillCommands::SetDefaultAgent { agent } => {
skill::run_set_default_agent(&mut corgea_config, agent);
}
Expand Down
182 changes: 142 additions & 40 deletions src/skill.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,20 @@
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)`.
Expand Down Expand Up @@ -119,46 +131,32 @@ pub fn run_set_default_agent(config: &mut Config, agent: &str) {
}
}

/// `corgea skill install <name[@version]>`
pub fn run_install(
config: &mut Config,
name_arg: &str,
agent: Option<String>,
scope: &str,
dir: Option<String>,
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 <name>, set a default with \
'corgea skill set-default-agent <name>', 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() {
// 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);
}
}
}

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,
Expand Down Expand Up @@ -231,7 +229,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 <name[@version]>`
pub fn run_install(
config: &mut Config,
name_arg: &str,
agent: Option<String>,
scope: &str,
dir: Option<String>,
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 <name>, set a default with \
'corgea skill set-default-agent <name>', 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,
Expand Down Expand Up @@ -272,9 +351,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
Expand Down Expand Up @@ -364,4 +443,27 @@ mod tests {
let result = resolve_skill_dir("foo", None, "project", None, &cwd, &home);
assert!(result.is_err());
}

#[test]
fn test_embedded_skill_is_present() {
Comment thread
cursor[bot] marked this conversation as resolved.
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
);
}
}
Loading
Loading