pub mod chat;
pub mod commit;
pub mod cooldown;
pub mod cost;
pub mod dashboard;
pub mod doctor;
pub mod events;
pub mod job;
pub mod ndjson;
pub mod notify;
pub mod prune;
pub mod service;
pub mod session;
pub mod stats;
pub mod stream;
pub mod task;
pub mod tree;
pub mod webhook;
use crate::cmd::SyncCommandErrorContext;
use crate::config;
use crate::engine::tasks::TaskManager;
use anyhow::Context;
use std::sync::Arc;
pub fn init(repo: Option<String>) -> anyhow::Result<()> {
let orch_home = crate::home::orch_home()?;
std::fs::create_dir_all(&orch_home)?;
let config_path = orch_home.join("config.yml");
let repo_value = match repo {
Some(r) => {
if let Some((owner, name)) = parse_github_slug(&r) {
format!("{}/{}", owner, name)
} else {
r
}
}
None => {
let git_remote = std::process::Command::new("git")
.args(["remote", "get-url", "origin"])
.output_with_context();
match git_remote {
Ok(o) if o.status.success() => {
let url = String::from_utf8_lossy(&o.stdout).trim().to_string();
if let Some((owner, repo)) = parse_github_slug(&url) {
format!("{}/{}", owner, repo)
} else {
eprintln!("Could not parse repository from git remote: {}", url);
eprintln!("Use --repo OWNER/REPO");
std::process::exit(1);
}
}
_ => {
eprintln!("Could not detect repository. Use --repo OWNER/REPO");
std::process::exit(1);
}
}
}
};
if !config_path.exists() {
let content = "# Orch global configuration\n# See: https://github.com/gabrielkoerich/orch\n\nprojects: []\n\nrouter:\n mode: llm\n agent: claude\n model: haiku\n";
std::fs::write(&config_path, content)?;
}
println!("Initialized orch for {repo_value}");
println!("Global config: {}", config_path.display());
let local_config = std::path::Path::new(".orch.yml");
if !local_config.exists() {
std::fs::write(
local_config,
format!("# Project-specific orch config\ngh:\n repo: \"{repo_value}\"\n"),
)?;
println!("Created .orch.yml");
}
let cwd = std::env::current_dir()?;
let cwd_str = cwd.to_string_lossy().to_string();
let paths = config::get_project_paths().unwrap_or_default();
if !paths.iter().any(|p| p == &cwd_str) {
project_add(".")?;
} else {
println!("Project already registered in global config");
}
install_review_workflow(&cwd)?;
println!();
println!("Next steps:");
println!(" orch project board list — find GitHub Projects V2 boards");
println!(" orch project board link <id> — link a board for status tracking");
Ok(())
}
const ORCH_REVIEW_WORKFLOW: &str = include_str!("../../.github/workflows/orch-review.yml");
fn install_review_workflow(project_dir: &std::path::Path) -> anyhow::Result<()> {
let workflows_dir = project_dir.join(".github").join("workflows");
let workflow_path = workflows_dir.join("orch-review.yml");
if workflow_path.exists() {
println!(
"Review workflow already exists: {}",
workflow_path.display()
);
return Ok(());
}
std::fs::create_dir_all(&workflows_dir)?;
std::fs::write(&workflow_path, ORCH_REVIEW_WORKFLOW)?;
println!(
"Installed review gate workflow: {}",
workflow_path.display()
);
Ok(())
}
pub fn log(lines: &str) -> anyhow::Result<()> {
let state_dir = crate::home::state_dir().unwrap_or_default();
let brew_prefix = std::env::var("HOMEBREW_PREFIX").unwrap_or_else(|_| "/opt/homebrew".into());
let mut log_files = Vec::new();
let candidates = [
state_dir.join("orch.log"),
state_dir.join("orch.error.log"),
std::path::PathBuf::from(&brew_prefix).join("var/log/orch.log"),
std::path::PathBuf::from(&brew_prefix).join("var/log/orch.error.log"),
];
for path in &candidates {
if path.exists()
&& std::fs::metadata(path)
.map(|m| m.len() > 0)
.unwrap_or(false)
{
log_files.push(path.clone());
} else {
for n in 1..=5 {
let rotated = std::path::PathBuf::from(format!("{}.{}", path.display(), n));
if rotated.exists()
&& std::fs::metadata(&rotated)
.map(|m| m.len() > 0)
.unwrap_or(false)
{
log_files.push(rotated);
break;
}
}
}
}
if log_files.is_empty() {
println!("No log files found");
return Ok(());
}
if lines == "watch" {
let args: Vec<String> = std::iter::once("-f".to_string())
.chain(log_files.iter().map(|p| p.to_string_lossy().to_string()))
.collect();
let status = std::process::Command::new("tail")
.args(&args)
.status_with_context()?;
std::process::exit(status.code().unwrap_or(1));
} else {
let n = lines.parse::<usize>().unwrap_or(50);
for path in &log_files {
let filename = path.file_name().unwrap_or_default().to_string_lossy();
println!("=== {filename} ===");
let content = std::fs::read_to_string(path)?;
let all_lines: Vec<&str> = content.lines().collect();
let start = if all_lines.len() > n {
all_lines.len() - n
} else {
0
};
for line in &all_lines[start..] {
println!("{line}");
}
println!();
}
}
Ok(())
}
pub fn agents() {
let agents = crate::engine::configured_agents();
println!("{:<12} {:<10} PATH", "AGENT", "STATUS");
println!("{}", "-".repeat(60));
for agent in &agents {
match which::which(agent) {
Ok(path) => {
let version = std::process::Command::new(agent)
.arg("--version")
.output_with_context()
.ok()
.and_then(|o| {
if o.status.success() {
Some(
String::from_utf8_lossy(&o.stdout)
.lines()
.next()
.unwrap_or("")
.trim()
.to_string(),
)
} else {
None
}
})
.unwrap_or_default();
let info = if version.is_empty() {
path.display().to_string()
} else {
format!("{} ({})", path.display(), version)
};
println!("{:<12} {:<10} {}", agent, "installed", info);
}
Err(_) => {
println!("{:<12} {:<10} ", agent, "missing");
}
}
}
}
pub async fn metrics(details: bool, since_hours: u32) -> anyhow::Result<()> {
let store = init_store().await?;
let summary = store.get_metrics_summary(since_hours).await?;
let window_label = hours_to_label(since_hours);
println!();
let header = format!("Orch Metrics (Last {})", window_label);
let width = 58usize;
let pad = width.saturating_sub(header.len() + 2);
let left_pad = pad / 2;
let right_pad = pad - left_pad;
println!("╔{}╗", "═".repeat(width));
println!(
"║{}{header}{}║",
" ".repeat(left_pad),
" ".repeat(right_pad)
);
println!("╚{}╝", "═".repeat(width));
println!();
println!(" Tasks:");
println!(" {:>6} completed", summary.tasks_completed_24h);
println!(" {:>6} failed", summary.tasks_failed_24h);
println!();
println!(" Average Duration by Complexity:");
if let Some(d) = summary.avg_duration_simple {
println!(" {:>6.1}s (simple)", d);
} else {
println!(" {:>6} (simple)", "-");
}
if let Some(d) = summary.avg_duration_medium {
println!(" {:>6.1}s (medium)", d);
} else {
println!(" {:>6} (medium)", "-");
}
if let Some(d) = summary.avg_duration_complex {
println!(" {:>6.1}s (complex)", d);
} else {
println!(" {:>6} (complex)", "-");
}
println!();
if !summary.agent_stats.is_empty() {
println!(" Agent Success Rates:");
for stat in &summary.agent_stats {
println!(
" {:<12} {:>4} runs, {:>5.1}% success",
stat.agent, stat.total_runs, stat.success_rate
);
}
println!();
}
println!(" Rate Limit Events: {:>6}", summary.rate_limits_24h);
println!();
if details {
let slow = store.get_slow_tasks(since_hours).await?;
if !slow.is_empty() {
println!(" Slowest Tasks (Last {}):", window_label);
println!(
" {:<20} {:<12} {:<10} DURATION",
"TASK ID", "AGENT", "COMPLEXITY"
);
println!(" {}", "-".repeat(56));
for t in &slow {
println!(
" {:<20} {:<12} {:<10} {:.1}s",
t.task_id,
t.agent,
t.complexity.as_deref().unwrap_or("-"),
t.duration_seconds
);
}
println!();
}
let errors = store.get_error_distribution(since_hours).await?;
if !errors.is_empty() {
println!(" Error Distribution (Last {}):", window_label);
println!(" {:<30} {:>6}", "ERROR TYPE", "COUNT");
println!(" {}", "-".repeat(38));
for e in &errors {
println!(
" {:<30} {:>6}",
e.error_type.as_deref().unwrap_or("unknown"),
e.count
);
}
println!();
} else {
println!(" Error Distribution (Last {}): none", window_label);
println!();
}
let review_loops = store.get_high_review_cycle_tasks(since_hours).await?;
if !review_loops.is_empty() {
println!(" Tasks with Repeated Review Loops (Last {}):", window_label);
println!(" {:<10} {:<12} TITLE", "CYCLES", "AGENT");
println!(" {}", "-".repeat(60));
for t in &review_loops {
println!(
" {:<10} {:<12} {}",
t.review_cycles,
t.agent.as_deref().unwrap_or("-"),
t.title,
);
}
println!();
}
}
Ok(())
}
pub fn hours_to_label(hours: u32) -> String {
if hours.is_multiple_of(24) {
format!("{}d", hours / 24)
} else {
format!("{hours}h")
}
}
pub fn parse_since(s: &str) -> Option<u32> {
if let Some(d) = s.strip_suffix('d') {
d.parse::<u32>().ok().map(|n| n * 24)
} else if let Some(h) = s.strip_suffix('h') {
h.parse::<u32>().ok()
} else {
s.parse::<u32>().ok()
}
}
pub async fn board_list() -> anyhow::Result<()> {
use crate::github::projects::ProjectSync;
let projects = ProjectSync::list_projects().await?;
if projects.is_empty() {
println!("No GitHub Projects V2 boards found");
return Ok(());
}
println!("{:<50} {:<8} ID", "TITLE", "NUMBER");
println!("{}", "-".repeat(80));
for p in &projects {
println!("{:<50} #{:<7} {}", p.title, p.number, p.id);
}
Ok(())
}
pub async fn board_link(project_id: &str) -> anyhow::Result<()> {
use crate::github::projects::{write_project_config, ProjectSync};
println!("Discovering board fields...");
let sync = ProjectSync::discover_fields(project_id).await?;
write_project_config(&sync).await?;
println!("Linked board: {}", project_id);
println!("Status field: {}", sync.status_field_id());
println!("Column mappings:");
for (col, opt_id) in sync.status_map() {
println!(" {col}: {opt_id}");
}
Ok(())
}
pub async fn board_sync() -> anyhow::Result<()> {
use crate::github::projects::{write_project_config, ProjectSync};
let project_id = config::get("gh.project_id").map_err(|_| {
anyhow::anyhow!("no board configured — run `orch project board link <id>` first")
})?;
if project_id.is_empty() {
anyhow::bail!("no board configured — run `orch project board link <id>` first");
}
println!("Syncing board fields for {}...", project_id);
let sync = ProjectSync::discover_fields(&project_id).await?;
write_project_config(&sync).await?;
println!("Updated config:");
println!(" Status field: {}", sync.status_field_id());
for (col, opt_id) in sync.status_map() {
println!(" {col}: {opt_id}");
}
Ok(())
}
pub fn board_info() -> anyhow::Result<()> {
let project_id = config::get("gh.project_id").unwrap_or_default();
if project_id.is_empty() {
println!("No board configured");
println!(" Run `orch project board list` to see available boards");
println!(" Run `orch project board link <id>` to link one");
return Ok(());
}
println!("Board ID: {}", project_id);
if let Ok(field_id) = config::get("gh.project_status_field_id") {
println!("Status field: {}", field_id);
}
for col in &["backlog", "in_progress", "review", "done"] {
if let Ok(opt_id) = config::get(&format!("gh.project_status_map.{col}")) {
println!(" {col}: {opt_id}");
}
}
Ok(())
}
fn parse_github_slug(input: &str) -> Option<(String, String)> {
if input.starts_with("git@github.com:") {
let path = input
.trim_start_matches("git@github.com:")
.trim_end_matches('/')
.trim_end_matches(".git");
let parts: Vec<&str> = path.splitn(3, '/').collect();
if parts.len() >= 2 && !parts[0].is_empty() && !parts[1].is_empty() {
return Some((parts[0].to_string(), parts[1].to_string()));
}
return None;
}
if input.starts_with("https://github.com/") || input.starts_with("http://github.com/") {
let path = input
.trim_start_matches("https://github.com/")
.trim_start_matches("http://github.com/")
.trim_end_matches('/')
.trim_end_matches(".git");
let parts: Vec<&str> = path.splitn(3, '/').collect();
if parts.len() >= 2 && !parts[0].is_empty() && !parts[1].is_empty() {
return Some((parts[0].to_string(), parts[1].to_string()));
}
return None;
}
if input.starts_with('/')
|| input.starts_with('.')
|| input.starts_with('~')
|| (input.contains(std::path::MAIN_SEPARATOR) && std::path::MAIN_SEPARATOR != '/')
{
return None;
}
let parts: Vec<&str> = input.splitn(3, '/').collect();
if parts.len() == 2 && !parts[0].is_empty() && !parts[1].is_empty() {
let owner = parts[0];
let repo = parts[1].trim_end_matches(".git");
if !owner.contains(' ') && !repo.contains(' ') && !repo.contains('/') {
return Some((owner.to_string(), repo.to_string()));
}
}
None
}
pub fn project_add(input: &str) -> anyhow::Result<()> {
if let Some((owner, repo)) = parse_github_slug(input) {
return project_add_github(&owner, &repo);
}
project_add_local(input)
}
fn project_add_local(path: &str) -> anyhow::Result<()> {
let abs_path = if path == "." {
std::env::current_dir()?
} else {
std::path::PathBuf::from(path).canonicalize()?
};
let orch_yml = abs_path.join(".orch.yml");
if !orch_yml.exists() {
let legacy = abs_path.join(".orchestrator.yml");
if legacy.exists() {
println!("Found legacy .orchestrator.yml — consider renaming to .orch.yml");
} else {
anyhow::bail!(
"no .orch.yml found in {} — run `orch init` in the project first",
abs_path.display()
);
}
}
let path_str = abs_path.to_string_lossy().to_string();
register_project_path(&path_str)?;
if orch_yml.exists() {
let project_content = std::fs::read_to_string(&orch_yml)?;
let project_doc: serde_norway::Value = serde_norway::from_str(&project_content)?;
if let Some(repo) = project_doc
.get("gh")
.and_then(|gh| gh.get("repo"))
.and_then(|r| r.as_str())
{
println!(" repo: {}", repo);
}
}
Ok(())
}
fn project_add_github(owner: &str, repo: &str) -> anyhow::Result<()> {
let projects_dir = crate::home::projects_dir()?;
let bare_path = projects_dir.join(owner).join(format!("{repo}.git"));
let slug = format!("{owner}/{repo}");
if bare_path.exists() {
println!("Bare clone already exists: {}", bare_path.display());
} else {
let parent = bare_path
.parent()
.ok_or_else(|| anyhow::anyhow!("bare path has no parent: {}", bare_path.display()))?;
std::fs::create_dir_all(parent)?;
println!("Cloning {slug} as bare repo...");
let status = std::process::Command::new("gh")
.args([
"repo",
"clone",
&slug,
&bare_path.to_string_lossy(),
"--",
"--bare",
])
.status_with_context()?;
if !status.success() {
anyhow::bail!("gh repo clone failed for {slug}");
}
println!("Cloned to {}", bare_path.display());
}
let orch_yml = bare_path.join(".orch.yml");
if !orch_yml.exists() {
let content = format!("# Project-specific orch config\ngh:\n repo: \"{slug}\"\n");
std::fs::write(&orch_yml, content)?;
println!("Created .orch.yml with gh.repo: {slug}");
}
let path_str = bare_path.to_string_lossy().to_string();
register_project_path(&path_str)?;
println!(" repo: {slug}");
Ok(())
}
fn register_project_path(path_str: &str) -> anyhow::Result<()> {
let config_path = crate::home::config_path()?;
let content = if config_path.exists() {
std::fs::read_to_string(&config_path)?
} else {
String::new()
};
let mut doc: serde_norway::Value = if content.is_empty() {
serde_norway::Value::Mapping(serde_norway::Mapping::new())
} else {
serde_norway::from_str(&content)?
};
let root = doc
.as_mapping_mut()
.ok_or_else(|| anyhow::anyhow!("config is not a YAML mapping"))?;
let projects_key = serde_norway::Value::String("projects".to_string());
if !root.contains_key(&projects_key) {
root.insert(
projects_key.clone(),
serde_norway::Value::Sequence(Vec::new()),
);
}
let projects = root
.get_mut(&projects_key)
.and_then(|v| v.as_sequence_mut())
.ok_or_else(|| anyhow::anyhow!("projects is not a list"))?;
let already_exists = projects
.iter()
.any(|p| p.as_str().map(|s| s == path_str).unwrap_or(false));
if already_exists {
println!("Project already registered: {}", path_str);
return Ok(());
}
projects.push(serde_norway::Value::String(path_str.to_string()));
std::fs::write(&config_path, serde_norway::to_string(&doc)?)?;
println!("Added project: {}", path_str);
Ok(())
}
pub fn project_remove(path: &str) -> anyhow::Result<()> {
let abs_path = std::path::PathBuf::from(path)
.canonicalize()
.unwrap_or_else(|_| std::path::PathBuf::from(path));
let path_str = abs_path.to_string_lossy().to_string();
let config_path = crate::home::config_path()?;
if !config_path.exists() {
anyhow::bail!("no global config found");
}
let content = std::fs::read_to_string(&config_path)?;
let mut doc: serde_norway::Value = serde_norway::from_str(&content)?;
let root = doc
.as_mapping_mut()
.ok_or_else(|| anyhow::anyhow!("config is not a YAML mapping"))?;
let projects_key = serde_norway::Value::String("projects".to_string());
let projects = root
.get_mut(&projects_key)
.and_then(|v| v.as_sequence_mut())
.ok_or_else(|| anyhow::anyhow!("no projects list in config"))?;
let before_len = projects.len();
projects.retain(|p| {
p.as_str()
.map(|s| s != path_str && s != path)
.unwrap_or(true)
});
if projects.len() == before_len {
println!("Project not found: {}", path);
return Ok(());
}
std::fs::write(&config_path, serde_norway::to_string(&doc)?)?;
println!("Removed project: {}", path_str);
Ok(())
}
pub fn project_list() -> anyhow::Result<()> {
let projects = config::get_project_paths()?;
if projects.is_empty() {
println!("No projects registered");
println!(" Run `orch project add <path>` to register a project");
return Ok(());
}
println!("{:<50} REPO", "PATH");
println!("{}", "-".repeat(80));
for path_str in &projects {
let path = std::path::Path::new(path_str);
let repo = read_project_repo(path).unwrap_or_else(|| "(.orch.yml not found)".to_string());
let status = if path.exists() { "" } else { " (missing)" };
println!("{:<50} {}{}", path_str, repo, status);
}
Ok(())
}
fn read_project_repo(project_path: &std::path::Path) -> Option<String> {
let orch_yml = project_path.join(".orch.yml");
let content = std::fs::read_to_string(&orch_yml).ok()?;
let doc: serde_norway::Value = serde_norway::from_str(&content).ok()?;
doc.get("gh")
.and_then(|gh| gh.get("repo"))
.and_then(|r| r.as_str())
.map(String::from)
}
pub async fn init_task_manager() -> anyhow::Result<TaskManager> {
use crate::backends::github::GitHubBackend;
use crate::backends::ExternalBackend;
use crate::store::TaskStore;
let repo = config::get_current_repo()
.context("'repo' not set — run `orch init` or set gh.repo in .orch.yml")?;
let backend: Arc<dyn ExternalBackend> = Arc::new(GitHubBackend::new(repo.clone())?);
let store = Arc::new(TaskStore::open(&crate::store::default_db_path().await?).await?);
Ok(TaskManager::with_store(backend, store, repo))
}
pub async fn init_store() -> anyhow::Result<crate::store::TaskStore> {
crate::store::TaskStore::open(&crate::store::default_db_path().await?).await
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hours_to_label_prefers_days_for_full_day_windows() {
assert_eq!(hours_to_label(24), "1d");
assert_eq!(hours_to_label(168), "7d");
assert_eq!(hours_to_label(30), "30h");
}
#[test]
fn parse_since_supports_days_hours_and_bare_numbers() {
assert_eq!(parse_since("7d"), Some(168));
assert_eq!(parse_since("24h"), Some(24));
assert_eq!(parse_since("36"), Some(36));
assert_eq!(parse_since("bad"), None);
}
#[test]
fn parse_slug_owner_repo() {
let result = parse_github_slug("gabrielkoerich/orch");
assert_eq!(
result,
Some(("gabrielkoerich".to_string(), "orch".to_string()))
);
}
#[test]
fn parse_slug_https_url() {
let result = parse_github_slug("https://github.com/gabrielkoerich/orch");
assert_eq!(
result,
Some(("gabrielkoerich".to_string(), "orch".to_string()))
);
}
#[test]
fn parse_slug_https_url_with_git_suffix() {
let result = parse_github_slug("https://github.com/gabrielkoerich/orch.git");
assert_eq!(
result,
Some(("gabrielkoerich".to_string(), "orch".to_string()))
);
}
#[test]
fn parse_slug_https_url_trailing_slash() {
let result = parse_github_slug("https://github.com/gabrielkoerich/orch/");
assert_eq!(
result,
Some(("gabrielkoerich".to_string(), "orch".to_string()))
);
}
#[test]
fn parse_slug_absolute_path_returns_none() {
assert_eq!(parse_github_slug("/Users/gb/Projects/my-app"), None);
}
#[test]
fn parse_slug_relative_path_returns_none() {
assert_eq!(parse_github_slug("./my-app"), None);
}
#[test]
fn parse_slug_dot_returns_none() {
assert_eq!(parse_github_slug("."), None);
}
#[test]
fn parse_slug_tilde_path_returns_none() {
assert_eq!(parse_github_slug("~/Projects/my-app"), None);
}
#[test]
fn parse_slug_single_word_returns_none() {
assert_eq!(parse_github_slug("my-app"), None);
}
#[test]
fn parse_slug_three_segments_returns_none() {
assert_eq!(parse_github_slug("a/b/c"), None);
}
#[test]
fn parse_slug_ssh_url() {
let result = parse_github_slug("git@github.com:gabrielkoerich/bean.git");
assert_eq!(
result,
Some(("gabrielkoerich".to_string(), "bean".to_string()))
);
}
#[test]
fn parse_slug_ssh_url_no_git_suffix() {
let result = parse_github_slug("git@github.com:gabrielkoerich/bean");
assert_eq!(
result,
Some(("gabrielkoerich".to_string(), "bean".to_string()))
);
}
}