mod agent;
mod config;
mod memory;
mod messages;
mod permissions;
mod plugin;
mod project_context;
mod provider;
mod providers;
mod session;
mod tool;
mod tools;
mod ui;
mod skills_data;
mod skills_import;
use std::collections::HashMap;
use std::io::Read;
use std::io::Write;
use std::path::Path;
use std::sync::Arc;
use clap::Parser;
use clap::CommandFactory;
use agent::Agent;
use config::Config;
use memory::store::MemoryStore;
/// Template config written on first run — guides the user through setup.
const FIRST_RUN_CONFIG_TEMPLATE: &str = r##"# Cortex Agent — configuration
# ${ENV_VAR} references are resolved from the environment at runtime.
# --- Active provider & model ---
active_provider: openai # Change this to your preferred provider
active_model: gpt-4o # Set the model you want to use
# --- Providers (OpenAI-compatible API) ---
# Each entry needs an api_key and base_url. Keys can be inline or ${ENV_VAR}.
providers:
openai:
api_key: ${OPENAI_API_KEY}
base_url: https://api.openai.com/v1
openrouter:
api_key: ${OPENROUTER_API_KEY}
base_url: https://openrouter.ai/api/v1
anthropic:
api_key: ${ANTHROPIC_API_KEY}
base_url: https://api.anthropic.com/v1
deepseek:
api_key: ${DEEPSEEK_API_KEY}
base_url: https://api.deepseek.com/v1
groq:
api_key: ${GROQ_API_KEY}
base_url: https://api.groq.com/openai/v1
together:
api_key: ${TOGETHER_API_KEY}
base_url: https://api.together.xyz/v1
mistral:
api_key: ${MISTRAL_API_KEY}
base_url: https://api.mistral.ai/v1
ollama:
api_key: ""
base_url: http://localhost:11434/v1
# --- Agent Behaviour ---
system_prompt: >
You are Cortex, an autonomous agent. Answer from your own knowledge first.
Only use tools when genuinely needed.
CONCISENESS:
- Be BRIEF. Short answers, no fluff.
- Don't introduce yourself unless asked.
- Let the user drive the conversation.
max_tokens: 4096
max_iterations: 10
temperature: 0.7
# --- Memory ---
memory_enabled: true
memory_dir: ~/.cortex/memory
memory_db: cortex.db
# --- Diagnostics ---
verbose: false
# --- Custom Tips ---
# Add your own tips here. They will be shown randomly on startup
# alongside built-in tips, and via the /tip command.
#custom_tips:
# - "Try /persona list to switch between conversation styles"
# - "Use /copy --last to copy the last assistant response"
"##;
/// On first run (no config file found), create `~/.cortex/config.yaml` with a
/// commented template and print setup instructions, then exit cleanly.
fn first_run_setup() {
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
let config_dir = Path::new(&home).join(".cortex");
let config_path = config_dir.join("config.yaml");
// Create the .cortex directory
if let Err(e) = std::fs::create_dir_all(&config_dir) {
eprintln!("Error: Could not create config directory ~/.cortex: {}", e);
std::process::exit(1);
}
// Write the template config
if let Err(e) = std::fs::write(&config_path, FIRST_RUN_CONFIG_TEMPLATE) {
eprintln!("Error: Could not write config file: {}", e);
std::process::exit(1);
}
// Show a friendly setup message on stderr so it doesn't interfere with piping
eprintln!(
"\
╭─────────────────────────────────────────────────────────────╮
│ Welcome to Cortex! │
│ │
│ A template config has been created at: │
│ ~/.cortex/config.yaml │
│ │
│ Next steps: │
│ 1. Set your API keys via environment variables or edit │
│ the config file directly. │
│ 2. Run 'cortex' again to start chatting. │
│ │
│ Supported providers (set the corresponding env var): │
│ openai → OPENAI_API_KEY │
│ openrouter → OPENROUTER_API_KEY │
│ anthropic → ANTHROPIC_API_KEY │
│ deepseek → DEEPSEEK_API_KEY │
│ groq → GROQ_API_KEY │
│ together → TOGETHER_API_KEY │
│ mistral → MISTRAL_API_KEY │
│ │
│ Example: │
│ export OPENAI_API_KEY=\"sk-...\" │
│ cortex │
╰─────────────────────────────────────────────────────────────╯"
);
}
/// Cortex Agent — self-learning agent framework with persistent memory.
#[derive(Parser)]
#[command(
name = "cortex",
version = env!("CARGO_PKG_VERSION"),
about = "Self-learning AI agent with persistent memory, tools, and a beautiful terminal UI",
after_help = "Examples:\n cortex # interactive with memory\n cortex --doctor # run system diagnostics\n cortex eval \"What is Rust?\" # non-interactive question\n echo \"Explain traits\" | cortex # pipe mode\n cortex show # display current config\n cortex get active_provider # get config value\n cortex set max_tokens 8192 # set config value\n cortex completions bash # generate shell completions\n cortex --one-shot \"Remember I use Vim\"\n cortex --config ./config.yaml\n cortex --no-memory"
)]
struct Cli {
/// Run a single question and exit
#[arg(long, value_name = "QUESTION")]
one_shot: Option<String>,
/// Path to YAML config file
#[arg(long)]
config: Option<String>,
/// Print internal reasoning and tool calls
#[arg(short, long)]
verbose: bool,
/// Disable persistent memory for this session
#[arg(long)]
no_memory: bool,
/// Run system diagnostics and exit
#[arg(long)]
doctor: bool,
/// CLI subcommand
#[command(subcommand)]
command: Option<CliCommand>,
}
#[derive(clap::Subcommand)]
enum CliCommand {
/// Evaluate a question non-interactively and print the response
Eval {
/// The question to answer (optional — reads from stdin if omitted)
#[arg(num_args = 0..)]
question: Vec<String>,
},
/// Show current configuration
Show,
/// Get a configuration value (e.g., active_provider)
Get { key: String },
/// Set a configuration value (e.g., active_provider, max_tokens)
Set { key: String, value: String },
/// Validate configuration for errors
Validate,
/// Generate shell completions
Completions { shell: clap_complete::Shell },
/// Show project context (AGENTS.md, CLAUDE.md, etc.)
Context,
/// Initialize .cortex/config.yaml in the current directory
Init,
/// Run system diagnostics (same as --doctor)
Doctor,
/// Generate a commit message from the staged git diff
Commit {
/// Automatically commit with the generated message (no edit prompt)
#[arg(long, short)]
yes: bool,
},
/// Create a git branch from a description
Branch {
/// Description of what the branch is for
description: Vec<String>,
},
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let args = Cli::parse();
// ── Pipe/stdin support: read from stdin if not a TTY and no subcommand ──
let piped_input = if args.command.is_none() && !atty::is(atty::Stream::Stdin) {
let mut input = String::new();
std::io::stdin().read_to_string(&mut input).ok();
let trimmed = input.trim().to_string();
if !trimmed.is_empty() { Some(trimmed) } else { None }
} else {
None
};
// If piped input and no subcommand, run eval with the piped content
if let Some(input) = piped_input {
return run_eval(&input).await;
}
// ── Load config ──
// Security: only auto-discover ~/.cortex/config.yaml.
// Project-specific configs require --config ./config.yaml.
// Do NOT auto-discover ./config.yaml — that could load API keys from
// a cloned repo's committed config file.
let config_path = if let Some(ref p) = args.config {
Some(p.clone())
} else {
let default = dirs::home_dir().map(|h| h.join(".cortex/config.yaml"));
default.as_ref().filter(|p| p.exists()).map(|p| p.to_string_lossy().to_string())
};
// ── First-run setup ──
// If no config file was found anywhere, create a template and guide the user.
if config_path.is_none() {
first_run_setup();
return Ok(());
}
let mut cfg = match config_path {
Some(ref path) => Config::from_yaml(path).unwrap_or_default(),
None => Config::default(),
};
if args.verbose { cfg.verbose = true; }
if args.no_memory { cfg.memory_enabled = false; }
// ── `cortex doctor`: Run system diagnostics ──
if args.doctor {
return run_doctor(&cfg).await;
}
// ── `cortex config`: Config CLI ──
if let Some(ref cmd) = args.command {
// Handle doctor subcommand directly (needs config)
if matches!(cmd, CliCommand::Doctor) {
return run_doctor(&cfg).await;
}
// Handle commit/branch directly (needs git + provider)
if let CliCommand::Commit { yes } = cmd {
return run_commit(&cfg, *yes).await;
}
if let CliCommand::Branch { description } = cmd {
let desc = description.join(" ");
return run_branch(&desc).await;
}
return run_config_cli(cmd, config_path.as_deref()).await;
}
// ── Load session state (last used provider/model) ──
let session_state = session::load_session_state();
if !session_state.last_provider.is_empty() && cfg.providers.contains_key(&session_state.last_provider) {
cfg.active_provider = session_state.last_provider.clone();
cfg.active_model = session_state.last_model.clone();
if cfg.verbose {
eprintln!("[Session: resuming {} / {}]", cfg.active_provider, cfg.active_model);
}
}
// ── Health check ──
let (provider_name, api_key, base_url) = cfg.get_active_provider_config();
let model = if cfg.active_model.is_empty() { &cfg.model } else { &cfg.active_model };
if api_key.is_empty() {
use crate::ui;
let provider_names = cfg.get_provider_names();
let env_var = match provider_name.as_str() {
"openai" => "OPENAI_API_KEY",
"openrouter" => "OPENROUTER_API_KEY",
"anthropic" => "ANTHROPIC_API_KEY",
"deepseek" => "DEEPSEEK_API_KEY",
"groq" => "GROQ_API_KEY",
"together" => "TOGETHER_API_KEY",
"mistral" => "MISTRAL_API_KEY",
"opencode" => "OPENCODE_API_KEY",
_ => "<PROVIDER>_API_KEY",
};
ui::render_action_card(
"🔑",
"API Key Required",
&[
&format!("Your active provider '{}' has no API key configured.", provider_name),
&format!("Set the environment variable: export {}=\"sk-...\"", env_var),
"Or edit ~/.cortex/config.yaml and add your key there.",
"Then run 'cortex' again.",
],
Some(&format!("Available providers: {} (use /switch to change)", provider_names.join(", "))),
);
std::process::exit(1);
}
if cfg.verbose {
let (ok, msg) = session::health_check(&base_url, &api_key).await;
if ok {
eprintln!("[Health: {} - {}]", provider_name, msg);
} else {
eprintln!("[Health: {} - {}]", provider_name, msg);
}
}
let provider = providers::openai_compat::create_provider("openai", model, &api_key, Some(&base_url))
.map_err(|e| anyhow::anyhow!("Failed to create provider: {}", e))?;
// ── Build memory store ──
let memory_store = if cfg.memory_enabled {
let db_dir = cfg.memory_dir_resolved();
let db_name = cfg.memory_db_resolved();
match MemoryStore::new(&db_dir, &db_name) {
Ok(store) => {
if cfg.verbose { eprintln!("[Memory store: {}]", store.db_path); }
Some(store)
}
Err(e) => { eprintln!("Warning: Failed to initialize memory store: {}", e); None }
}
} else { None };
// ── Build tools ──
let memory_arc = memory_store.as_ref().map(|_| {
std::sync::Arc::new(std::sync::Mutex::new(
MemoryStore::new(&cfg.memory_dir_resolved(), &cfg.memory_db_resolved()).unwrap()
))
});
let tools = tools::default_tools(memory_arc);
if cfg.verbose {
let tool_names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
eprintln!("Config: active_provider={}, model={}, memory_enabled={}", cfg.active_provider, cfg.active_model, cfg.memory_enabled);
eprintln!("Tools ({}): {:?}", tool_names.len(), tool_names);
}
// Build provider map for switching
let provider_map: HashMap<String, serde_json::Value> = cfg.providers.iter().map(|(k, v)| {
(k.clone(), serde_json::json!({ "api_key": v.api_key, "base_url": v.base_url }))
}).collect();
let provider_names = cfg.get_provider_names();
// ── Build agent with permission store ──
let perm_store = if cfg.memory_enabled {
let db_dir = cfg.memory_dir_resolved();
Some(Arc::new(permissions::PermissionStore::new(&db_dir)))
} else { None };
let mut agent = Agent::new(
provider,
tools,
memory_store,
&cfg.system_prompt,
cfg.max_iterations,
cfg.max_tokens,
cfg.temperature,
cfg.verbose,
provider_map,
provider_names,
cfg.active_provider.clone(),
cfg.active_model.clone(),
perm_store.clone(),
Some(project_context::load_project_context(&std::env::current_dir().unwrap_or_default())),
);
// ── Run ──
if let Some(question) = args.one_shot {
let response = agent.run(&question, true).await;
println!("{}", response);
} else {
agent.chat().await;
}
Ok(())
}
/// Run system diagnostics: provider connectivity, memory DB, config validity, plugins.
async fn run_doctor(cfg: &Config) -> anyhow::Result<()> {
use console::style;
println!();
println!(" {} {}", style("Cortex Diagnostics").bold().white(), style("v0.3.1").dim());
println!(" {}", style("─".repeat(40)).dim());
println!();
// 1. Config file
println!(" {} {}", style("Config File").bold(), style("~/.cortex/config.yaml").dim());
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
let config_path = format!("{}/.cortex/config.yaml", home);
match std::fs::read_to_string(&config_path) {
Ok(content) => {
let lines = content.lines().count();
println!(" {} Found ({} lines), providers: {}", style("✓").green(), lines, cfg.providers.len());
}
Err(e) => println!(" {} {} (will create on first run)", style("○").yellow(), e),
}
println!();
// 2. Providers
println!(" {} {}", style("Providers").bold(), style(format!("({} configured)", cfg.providers.len())).dim());
for (name, p) in &cfg.providers {
let key_ok = !p.api_key.is_empty() && !p.api_key.contains("${");
let status = if key_ok { style("● key set").green() } else { style("○ key missing").red() };
println!(" {} {} {} {}", style("▸").cyan(), style(name).bold(), status, style(&p.base_url).dim());
}
println!();
// 3. Active provider connectivity (check if key is configured)
let (provider_name, api_key, base_url) = cfg.get_active_provider_config();
println!(" {} {} ({})", style("Active Provider").bold(), style(&provider_name).cyan(), style(&base_url).dim());
if api_key.is_empty() || api_key.contains("${") {
println!(" {} API key not configured", style("○").yellow());
} else {
print!(" {} Testing connection... ", style("⟳").dim());
std::io::stdout().flush().ok();
let (ok, msg) = crate::session::health_check(&base_url, &api_key).await;
if ok {
println!("{}", style("OK").green());
println!(" {} {}", style("↳").dim(), msg);
} else {
println!("{}", style("FAIL").red());
println!(" {} {}", style("↳").dim(), style(msg).red());
}
}
println!();
// 4. Memory DB
println!(" {} {}", style("Memory Store").bold(), style(if cfg.memory_enabled { "enabled" } else { "disabled" }).dim());
if cfg.memory_enabled {
let db_dir = cfg.memory_dir_resolved();
let db_name = cfg.memory_db_resolved();
let db_path = format!("{}/{}", db_dir, db_name);
match std::fs::metadata(&db_path) {
Ok(m) => {
let size_kb = m.len() / 1024;
println!(" {} Found ({})", style("✓").green(), style(format!("{} KB", size_kb)).dim());
// Try opening
match crate::memory::store::MemoryStore::new(&db_dir, &db_name) {
Ok(store) => {
if let Ok(count) = store.count_memories() {
println!(" {} {} memories", style("↳").dim(), count);
}
}
Err(e) => println!(" {} Open failed: {}", style("✗").red(), e),
}
}
Err(_) => println!(" {} Not yet initialized (will create on first use)", style("○").yellow()),
}
}
println!();
// 5. Plugin directory
let plugin_dir = format!("{}/.cortex/plugins", home);
println!(" {} {}", style("Plugins").bold(), style(&plugin_dir).dim());
match std::fs::read_dir(&plugin_dir) {
Ok(entries) => {
let count = entries.filter(|e| e.is_ok()).count();
println!(" {} {} plugin(s) found", style("✓").green(), count);
}
Err(_) => println!(" {} Empty (add .yaml files to use plugins)", style("○").yellow()),
}
println!();
println!(" {}", style("─".repeat(40)).dim());
println!(" {} All checks complete.", style("✓").green());
println!();
Ok(())
}
/// Handle `cortex config` subcommands.
async fn run_config_cli(cmd: &CliCommand, config_path: Option<&str>) -> anyhow::Result<()> {
use console::style;
let default_path = format!("{}/.cortex/config.yaml", std::env::var("HOME").unwrap_or_else(|_| ".".into()));
let path = config_path.unwrap_or(&default_path);
let content = std::fs::read_to_string(path).unwrap_or_default();
match *cmd {
CliCommand::Eval { ref question } => {
let question = if question.is_empty() {
// Try reading from stdin
let mut input = String::new();
std::io::stdin().read_to_string(&mut input).ok();
input.trim().to_string()
} else {
question.join(" ")
};
if question.is_empty() {
println!("Usage: cortex eval <question>");
println!(" or: echo \"question\" | cortex eval");
return Ok(());
}
return run_eval(&question).await;
}
CliCommand::Show => {
let masked = content
.lines()
.map(|l| {
if l.trim_start().starts_with("api_key:") {
let val = l.trim_start_matches("api_key:").trim();
if val.len() > 8 {
format!(" api_key: {}…", &val[..8])
} else {
" api_key: ***".into()
}
} else {
l.to_string()
}
})
.collect::<Vec<_>>()
.join("\n");
println!("{}", masked);
}
CliCommand::Get { ref key } => {
for line in content.lines() {
if let Some(rest) = line.trim().strip_prefix(&format!("{}:", key)) {
println!("{}", rest.trim());
return Ok(());
}
}
println!("Key '{}' not found", key);
}
CliCommand::Set { ref key, ref value } => {
let mut lines: Vec<String> = content.lines().map(|l| l.to_string()).collect();
let mut found = false;
for line in &mut lines {
if line.trim().starts_with(&format!("{}:", key)) {
let indent = &line[..line.len() - line.trim_start().len()];
*line = format!("{}{}: {}", indent, key, value);
found = true;
}
}
if !found {
lines.push(format!("{}: {}", key, value));
}
// Backup config before modifying
let backup_path = format!("{}.bak", path);
if !std::path::Path::new(&backup_path).exists() {
let _ = std::fs::write(&backup_path, &content);
}
std::fs::write(path, lines.join("\n")).map_err(|e| anyhow::anyhow!("Write failed: {}", e))?;
println!(" {} Set {} = {}", style("✓").green(), key, value);
}
CliCommand::Validate => {
let _: serde_yaml::Value = serde_yaml::from_str(&content)
.map_err(|e| anyhow::anyhow!("Invalid YAML: {}", e))?;
println!(" {} Config is valid YAML", style("✓").green());
if !content.contains("active_provider:") {
println!(" {} Missing 'active_provider'", style("○").yellow());
}
if !content.contains("providers:") {
println!(" {} No 'providers' section", style("○").yellow());
}
}
CliCommand::Completions { shell } => {
let mut cmd = <Cli as clap::CommandFactory>::command();
let name = cmd.get_name().to_string();
clap_complete::generate(shell, &mut cmd, name, &mut std::io::stdout());
}
CliCommand::Context => {
let cwd = std::env::current_dir().unwrap_or_default();
let ctx = project_context::load_project_context(&cwd);
println!(" {} Project Context", style("📋").bold());
println!(" {}", style("─".repeat(40)).dim());
if let Some(ref root) = ctx.root {
println!(" Root: {}", style(root.display()).bold());
} else {
println!(" No project context files found (AGENTS.md, CLAUDE.md, .cursorrules)");
println!(" Create one in your project root for Cortex to auto-load.");
}
if let Some(ref content) = ctx.agent_context {
let preview: String = content.lines().take(5).collect::<Vec<_>>().join("\n ");
println!("\n Content:\n {}", style(preview).dim());
let total = content.lines().count();
if total > 5 {
println!(" {} ... ({} more lines)", style("▸").dim(), total - 5);
}
}
if ctx.project_config.is_some() {
println!("\n {} Per-project config active", style("●").green());
}
}
CliCommand::Init => {
let cwd = std::env::current_dir().unwrap_or_default();
let cfg_dir = cwd.join(".cortex");
let cfg_path = cfg_dir.join("config.yaml");
if cfg_path.exists() {
println!(" {} .cortex/config.yaml already exists in {}", style("○").yellow(), cwd.display());
} else {
std::fs::create_dir_all(&cfg_dir).map_err(|e| anyhow::anyhow!("Cannot create .cortex: {}", e))?;
let template = r#"# Cortex per-project configuration
# These settings override ~/.cortex/config.yaml for this project.
system_prompt: >
You are in $PROJECT_NAME project context.
This is a Rust project using cargo.
"#;
// Detect project type
let project_type = if cwd.join("Cargo.toml").exists() { "Rust (cargo)" }
else if cwd.join("package.json").exists() { "Node.js (npm)" }
else if cwd.join("pyproject.toml").exists() || cwd.join("requirements.txt").exists() { "Python" }
else if cwd.join("pom.xml").exists() || cwd.join("build.gradle").exists() { "Java" }
else { "unknown" };
let template = template.replace("$PROJECT_NAME", &cwd.file_name().map(|s| s.to_string_lossy()).unwrap_or_default());
std::fs::write(&cfg_path, &template).map_err(|e| anyhow::anyhow!("Write failed: {}", e))?;
println!(" {} Created .cortex/config.yaml for {} project in", style("✓").green(), project_type);
println!(" {}", style(cfg_path.display()).dim());
println!(" Edit it to add project-specific instructions.");
}
}
CliCommand::Doctor => {
// Handled in main() before dispatch — should not reach here
unreachable!()
}
CliCommand::Commit { .. } | CliCommand::Branch { .. } => {
// Handled in main() before dispatch
unreachable!()
}
}
Ok(())
}
/// Run a single question non-interactively and print the response.
async fn run_eval(question: &str) -> anyhow::Result<()> {
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
let config_path = format!("{}/.cortex/config.yaml", home);
let config_path = if std::path::Path::new(&config_path).exists() {
Some(config_path)
} else {
let local = "config.yaml";
if std::path::Path::new(local).exists() { Some(local.to_string()) } else { None }
};
let cfg = match config_path {
Some(ref path) => Config::from_yaml(path).unwrap_or_default(),
None => {
println!("No config found. Run 'cortex' to set up.");
return Ok(());
}
};
let (provider_name, api_key, base_url) = cfg.get_active_provider_config();
let model = if cfg.active_model.is_empty() { &cfg.model } else { &cfg.active_model };
if api_key.is_empty() || api_key.contains("${") {
println!("API key not configured for '{}'.", provider_name);
return Ok(());
}
let provider = providers::openai_compat::create_provider("openai", model, &api_key, Some(&base_url))
.map_err(|e| anyhow::anyhow!("Provider error: {}", e))?;
let memory_store = if cfg.memory_enabled {
let db_dir = cfg.memory_dir_resolved();
let db_name = cfg.memory_db_resolved();
MemoryStore::new(&db_dir, &db_name).ok()
} else { None };
let memory_arc = memory_store.as_ref().map(|_| {
Arc::new(std::sync::Mutex::new(
MemoryStore::new(&cfg.memory_dir_resolved(), &cfg.memory_db_resolved()).unwrap()
))
});
let tools = tools::default_tools(memory_arc);
let provider_map: HashMap<String, serde_json::Value> = cfg.providers.iter().map(|(k, v)| {
(k.clone(), serde_json::json!({ "api_key": v.api_key, "base_url": v.base_url }))
}).collect();
let provider_names = cfg.get_provider_names();
let agent = Agent::new(
provider, tools, memory_store, &cfg.system_prompt,
cfg.max_iterations, cfg.max_tokens, cfg.temperature,
false, provider_map, provider_names,
cfg.active_provider.clone(), cfg.active_model.clone(),
None, // no permission store for eval
None, // no project context for eval
);
let response = agent.run(question, true).await;
println!("{}", response);
Ok(())
}
/// Generate a structured commit message from the staged git diff.
async fn run_commit(cfg: &Config, auto_commit: bool) -> anyhow::Result<()> {
use console::style;
// Get the staged diff
let diff_output = std::process::Command::new("git")
.args(["diff", "--cached"])
.output();
let diff = match diff_output {
Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout).to_string(),
Ok(out) => {
let stderr = String::from_utf8_lossy(&out.stderr);
// Maybe no staged changes, try unstaged diff
if stderr.contains("ambiguous argument") || out.stdout.is_empty() {
let unstaged = std::process::Command::new("git")
.args(["diff"])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).to_string())
.unwrap_or_default();
if unstaged.is_empty() {
println!(" {} No changes detected. Stage your changes first with `git add`.", style("○").yellow());
return Ok(());
}
unstaged
} else {
println!(" {} Git error: {}", style("✗").red(), stderr);
return Ok(());
}
}
Err(e) => {
println!(" {} Git not available: {}", style("✗").red(), e);
return Ok(());
}
};
let (provider_name, api_key, base_url) = cfg.get_active_provider_config();
let model = if cfg.active_model.is_empty() { &cfg.model } else { &cfg.active_model };
if api_key.is_empty() || api_key.contains("${") {
println!(" {} API key not configured for '{}'. Run `cortex` first to set up.", style("○").yellow(), provider_name);
return Ok(());
}
// Truncate diff if too large
let diff_preview = if diff.len() > 8000 {
format!("{}... (truncated, {} bytes)", &diff[..8000], diff.len())
} else {
diff.clone()
};
let prompt = format!(
"Generate a concise conventional commit message for the following git diff.\n\
Format: type(scope): description\n\n\
Types: feat, fix, chore, docs, style, refactor, perf, test, ci\n\n\
Respond with ONLY the commit message, no explanation.\n\n\
Diff:\n```\n{}\n```",
diff_preview
);
println!(" {} Generating commit message...", style("⟳").dim());
let provider = providers::openai_compat::create_provider("openai", model, &api_key, Some(&base_url))
.map_err(|e| anyhow::anyhow!("Provider error: {}", e))?;
let messages = vec![
messages::Message::new_system("You are an expert developer. Generate clean, informative conventional commit messages."),
messages::Message::new_user(&prompt),
];
match provider.chat_completion(&messages, None, "none", Some(512), 0.3).await {
Ok(response) => {
let msg = response.content.unwrap_or_default().trim().to_string();
if msg.is_empty() {
println!(" {} No commit message generated.", style("✗").red());
return Ok(());
}
println!("\n {} Generated commit message:\n", style("📝").bold());
for line in msg.lines() {
println!(" {}", line);
}
println!();
if auto_commit {
// Commit with the generated message
let mut child = std::process::Command::new("git")
.args(["commit", "-m", &msg])
.spawn()
.map_err(|e| anyhow::anyhow!("Git commit failed: {}", e))?;
let status = child.wait().map_err(|e| anyhow::anyhow!("Git wait: {}", e))?;
if status.success() {
println!(" {} Committed successfully.", style("✓").green());
} else {
println!(" {} Commit failed.", style("✗").red());
}
} else {
println!(" {} Run with `--yes` to commit automatically, or copy the message above.", style("💡").dim());
}
}
Err(e) => {
println!(" {} Failed to generate commit message: {}", style("✗").red(), e);
}
}
Ok(())
}
/// Create a git branch from a description.
async fn run_branch(description: &str) -> anyhow::Result<()> {
use console::style;
if description.is_empty() {
println!(" {} Usage: cortex branch <description>", style("○").yellow());
println!(" {} Example: cortex branch fix-login-bug", style(" ▸").dim());
return Ok(());
}
// Generate branch name from description
let branch_name: String = description
.to_lowercase()
.split_whitespace()
.flat_map(|w| {
let clean: String = w.chars()
.filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
.collect();
if clean.is_empty() { vec![] } else { vec![clean] }
})
.collect::<Vec<_>>()
.join("-")
.trim_matches('-')
.to_string();
// Prepend type prefix based on keywords
let prefix = if description.contains("fix") || description.contains("bug") { "fix/" }
else if description.contains("feat") || description.contains("add") || description.contains("new") { "feat/" }
else if description.contains("docs") || description.contains("readme") { "docs/" }
else if description.contains("refactor") || description.contains("clean") { "refactor/" }
else if description.contains("test") { "test/" }
else if description.contains("chore") || description.contains("bump") || description.contains("dep") { "chore/" }
else { "" };
let full_name = format!("{}{}", prefix, branch_name);
// Check if branch already exists
let check = std::process::Command::new("git")
.args(["rev-parse", "--verify", "--quiet", &full_name])
.status();
if let Ok(status) = check {
if status.success() {
println!(" {} Branch '{}' already exists.", style("○").yellow(), style(&full_name).bold());
return Ok(());
}
}
// Check for unstaged changes
let has_changes = std::process::Command::new("git")
.args(["diff", "--quiet"])
.status()
.map(|s| !s.success())
.unwrap_or(false);
if has_changes {
println!(" {} You have unstaged changes. Stash or commit them first.", style("○").yellow());
return Ok(());
}
// Create the branch
match std::process::Command::new("git")
.args(["checkout", "-b", &full_name])
.status()
{
Ok(status) if status.success() => {
println!(" {} Created and switched to '{}'", style("✓").green(), style(&full_name).bold());
println!(" {} From: {}", style("📍").dim(), description);
}
_ => {
println!(" {} Failed to create branch '{}'", style("✗").red(), &full_name);
println!(" {} Make sure you're in a git repository.", style("💡").dim());
}
}
Ok(())
}