use clap::{Parser, Subcommand};
use std::collections::HashMap;
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Arc;
use crate::config::{find_repo_root, get_log_file, get_pid_file, global_config_dir, GlobalConfig};
const DEFAULT_ORCHESTRATOR_MD: &str = include_str!("../defaults/orchestrator.md");
const DEFAULT_GLOBAL_PROMPT_MD: &str = include_str!("../defaults/global_prompt.md");
const DEFAULT_VALUE_MD: &str = include_str!("../defaults/value.md");
const DEFAULT_MEMORY_MD: &str = include_str!("../defaults/memory.md");
const DEFAULT_SPAWN_CLAUDE_SH: &str = r#"#!/usr/bin/env bash
# GithubClaw spawn template for Claude Code.
set -euo pipefail
exec claude -p \
--dangerously-skip-permissions \
--allowedTools "${ALLOWED_TOOLS}" \
--disallowedTools "${DISALLOWED_TOOLS}" \
--max-turns "${MAX_TURNS:-200}" \
--append-system-prompt-file "${PROMPT_FILE}" \
"$TASK_PROMPT"
"#;
const DEFAULT_SPAWN_CODEX_SH: &str = r#"#!/usr/bin/env bash
# GithubClaw spawn template for Codex CLI.
set -euo pipefail
cat "${PROMPT_FILE}" | codex exec - \
--dangerously-bypass-approvals-and-sandbox
"#;
const DEFAULT_GITIGNORE: &str = "secrets/\nqueue/\nlogs/\nmemory.md\n";
const DEFAULT_REPO_CONFIG_YAML: &str = "# GithubClaw per-repo configuration.\n# See https://github.com/GithubClaw/githubclaw for options.\n";
const DEFAULT_AGENT_ORCHESTRATOR: &str = include_str!("../defaults/agents/orchestrator.md");
const DEFAULT_AGENT_IMPLEMENTER: &str = include_str!("../defaults/agents/implementer.md");
const DEFAULT_AGENT_VERIFIER: &str = include_str!("../defaults/agents/verifier.md");
const DEFAULT_AGENT_REVIEWER: &str = include_str!("../defaults/agents/reviewer.md");
const DEFAULT_AGENT_VISION_GAP_ANALYST: &str =
include_str!("../defaults/agents/vision_gap_analyst.md");
const DEFAULT_AGENT_BUG_REPRODUCER: &str = include_str!("../defaults/agents/bug_reproducer.md");
const LAUNCHD_LABEL: &str = "com.githubclaw.webhook-server";
const SYSTEMD_UNIT: &str = "githubclaw-webhook-server";
#[derive(Parser)]
#[command(
name = "githubclaw",
version = env!("CARGO_PKG_VERSION"),
about = "Near-autonomous AI agents for open-source project management."
)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Init,
Bootstrap,
Start,
Stop {
#[arg(long, short)]
force: bool,
},
Status,
Logs {
#[arg(long, short)]
follow: bool,
},
Serve {
#[arg(long, default_value = "0.0.0.0")]
host: String,
#[arg(long, default_value_t = 8000)]
port: u16,
},
Dispatch {
agent_type: String,
#[arg(long)]
issue: u64,
#[arg(long)]
prompt: String,
#[arg(long)]
repo: Option<String>,
#[arg(long)]
event_id: Option<String>,
#[arg(long)]
dedupe_key: Option<String>,
},
Release {
#[arg(long)]
repo: Option<String>,
},
Tui,
}
pub fn run() {
let cli = Cli::parse();
match cli.command {
Commands::Init => cmd_init(),
Commands::Bootstrap => cmd_bootstrap(),
Commands::Start => cmd_start(),
Commands::Stop { force } => cmd_stop(force),
Commands::Status => cmd_status(),
Commands::Logs { follow } => cmd_logs(follow),
Commands::Serve { host, port } => cmd_serve(&host, port),
Commands::Dispatch {
agent_type,
issue,
prompt,
repo,
event_id,
dedupe_key,
} => cmd_dispatch(
&agent_type,
issue,
&prompt,
repo.as_deref(),
event_id.as_deref(),
dedupe_key.as_deref(),
),
Commands::Release { repo } => cmd_release(repo.as_deref()),
Commands::Tui => cmd_tui(),
}
}
fn cmd_init() {
let repo_root = match find_repo_root(None) {
Some(r) => r,
None => {
eprintln!("Error: not inside a git repository.");
std::process::exit(1);
}
};
let claw_dir = repo_root.join(".githubclaw");
if claw_dir.exists() {
println!(
"Directory {} already exists. Skipping existing files.",
claw_dir.display()
);
}
let agents_dir = claw_dir.join("agents");
let ai_dir = claw_dir.join("ai_instructions");
let logs_dir = claw_dir.join("logs");
let queue_dir = claw_dir.join("queue").join("dead");
for d in [&agents_dir, &ai_dir, &logs_dir, &queue_dir] {
fs::create_dir_all(d).unwrap_or_else(|e| {
eprintln!("Error creating directory {}: {e}", d.display());
std::process::exit(1);
});
}
let files: Vec<(PathBuf, &str)> = vec![
(claw_dir.join("orchestrator.md"), DEFAULT_ORCHESTRATOR_MD),
(claw_dir.join("global-prompt.md"), DEFAULT_GLOBAL_PROMPT_MD),
(claw_dir.join("VALUE.md"), DEFAULT_VALUE_MD),
(claw_dir.join("memory.md"), DEFAULT_MEMORY_MD),
(claw_dir.join("spawn_claude.sh"), DEFAULT_SPAWN_CLAUDE_SH),
(claw_dir.join("spawn_codex.sh"), DEFAULT_SPAWN_CODEX_SH),
(claw_dir.join(".gitignore"), DEFAULT_GITIGNORE),
(claw_dir.join("config.yaml"), DEFAULT_REPO_CONFIG_YAML),
(
agents_dir.join("orchestrator.md"),
DEFAULT_AGENT_ORCHESTRATOR,
),
(agents_dir.join("implementer.md"), DEFAULT_AGENT_IMPLEMENTER),
(agents_dir.join("verifier.md"), DEFAULT_AGENT_VERIFIER),
(agents_dir.join("reviewer.md"), DEFAULT_AGENT_REVIEWER),
(
agents_dir.join("vision_gap_analyst.md"),
DEFAULT_AGENT_VISION_GAP_ANALYST,
),
(
agents_dir.join("bug_reproducer.md"),
DEFAULT_AGENT_BUG_REPRODUCER,
),
];
let mut created: usize = 0;
let mut skipped: usize = 0;
let mut refreshed: usize = 0;
for (filepath, content) in &files {
let existed = filepath.exists();
let is_spawn_script = filepath
.file_name()
.and_then(|n| n.to_str())
.map(|n| n == "spawn_claude.sh" || n == "spawn_codex.sh")
.unwrap_or(false);
if existed && !is_spawn_script {
skipped += 1;
continue;
}
if let Some(parent) = filepath.parent() {
let _ = fs::create_dir_all(parent);
}
if let Err(e) = fs::write(filepath, content) {
eprintln!("Error writing {}: {e}", filepath.display());
} else {
if existed && is_spawn_script {
refreshed += 1;
} else {
created += 1;
}
}
}
for script_name in &["spawn_claude.sh", "spawn_codex.sh"] {
let script = claw_dir.join(script_name);
if script.exists() {
let _ = fs::set_permissions(&script, fs::Permissions::from_mode(0o755));
}
}
println!("Initialized .githubclaw/ in {}", repo_root.display());
println!(
" Created {created} files, refreshed {refreshed} runtime scripts, skipped {skipped} existing files."
);
println!();
register_repo(&repo_root);
setup_webhook_secret();
detect_backends();
preflight_gh();
println!();
println!("To track agent configs in git:");
println!(
" git add .githubclaw/agents/ .githubclaw/VALUE.md \
.githubclaw/global-prompt.md .githubclaw/orchestrator.md"
);
println!();
println!("Next steps:");
println!(" 1. Edit .githubclaw/VALUE.md with your project mission");
println!(" 2. Create a GitHub App and set webhook URL + secret");
println!(" 3. Set up a tunnel (cloudflare tunnel, ngrok, etc.)");
println!(" 4. githubclaw start");
}
fn cmd_bootstrap() {
use crate::process_manager::ProcessManager;
use crate::scheduler::ScheduledEventManager;
use crate::server::{bootstrap_repo, load_registry, ServerState};
use std::collections::HashSet;
use tokio::sync::{Mutex, RwLock};
let repo_root = match find_repo_root(None) {
Some(r) => r,
None => {
eprintln!("Error: not inside a git repository.");
std::process::exit(1);
}
};
let output = match Command::new("git")
.args(["remote", "get-url", "origin"])
.current_dir(&repo_root)
.output()
{
Ok(o) => o,
Err(e) => {
eprintln!("Error running git remote get-url origin: {e}");
std::process::exit(1);
}
};
if !output.status.success() {
eprintln!("Error: no 'origin' remote found.");
std::process::exit(1);
}
let remote_url = String::from_utf8_lossy(&output.stdout).trim().to_string();
let owner_repo = match parse_github_remote(&remote_url) {
Some(or) => or,
None => {
eprintln!("Error: could not parse GitHub owner/repo from: {remote_url}");
std::process::exit(1);
}
};
let global_dir = global_config_dir();
let registry = load_registry(&global_dir.join("registry.json"));
let entry = match registry.get(&owner_repo).cloned() {
Some(entry) => entry,
None => {
eprintln!("Error: repo {owner_repo} is not registered. Run `githubclaw init` first.");
std::process::exit(1);
}
};
let rt = tokio::runtime::Runtime::new().unwrap_or_else(|e| {
eprintln!("Failed to create tokio runtime: {e}");
std::process::exit(1);
});
rt.block_on(async move {
let scheduler_path = global_dir.join("scheduled_events.json");
let state = Arc::new(ServerState {
webhook_secret: String::new(),
registry: RwLock::new(registry),
started_repos: RwLock::new(HashSet::new()),
queues: Mutex::new(HashMap::new()),
githubclaw_home: global_dir.clone(),
process_manager: Arc::new(ProcessManager::new(1)),
scheduler: Mutex::new(ScheduledEventManager::new(&scheduler_path)),
rate_limiter: Arc::new(crate::rate_limiter::RateLimiter::default()),
shutdown: Arc::new(std::sync::atomic::AtomicBool::new(false)),
issue_router: crate::issue_router::IssueRouter::new(global_dir.join("sessions")),
session_store: crate::session_store::SessionStore::new(),
});
match bootstrap_repo(&state, &owner_repo, &entry, true).await {
Ok(()) => println!("Bootstrapped open issues/PRs for {owner_repo}."),
Err(e) => {
eprintln!("Bootstrap failed for {owner_repo}: {e}");
std::process::exit(1);
}
}
});
}
fn cmd_start() {
if let Some(pid) = read_pid() {
eprintln!("Webhook server already running (PID {pid}).");
std::process::exit(1);
}
let global_dir = global_config_dir();
let _ = fs::create_dir_all(global_dir.join("logs"));
let _ = fs::create_dir_all(global_dir.join("secrets"));
let config = GlobalConfig::load(None).unwrap_or_else(|e| {
eprintln!("Error loading config: {e}");
std::process::exit(1);
});
if !global_dir.join("config.yaml").exists() {
let _ = config.save(None);
}
let log_path = get_log_file();
if let Some(parent) = log_path.parent() {
let _ = fs::create_dir_all(parent);
}
let system = std::env::consts::OS;
match system {
"macos" => {
let plist_path = write_launchd_plist(LAUNCHD_LABEL, config.port, &log_path);
let uid = unsafe { libc::getuid() };
let _ = Command::new("launchctl")
.args([
"bootout",
&format!("gui/{uid}"),
&plist_path.to_string_lossy(),
])
.output();
let result = Command::new("launchctl")
.args([
"bootstrap",
&format!("gui/{uid}"),
&plist_path.to_string_lossy(),
])
.output();
match result {
Ok(output) if !output.status.success() => {
eprintln!(
"Failed to start via launchd: {}",
String::from_utf8_lossy(&output.stderr).trim()
);
std::process::exit(1);
}
Err(e) => {
eprintln!("Failed to run launchctl: {e}");
std::process::exit(1);
}
_ => {}
}
if let Ok(info) = Command::new("launchctl")
.args(["print", &format!("gui/{uid}/{LAUNCHD_LABEL}")])
.output()
{
let stdout = String::from_utf8_lossy(&info.stdout);
for line in stdout.lines() {
let trimmed = line.trim();
if trimmed.starts_with("pid =") {
if let Some(val) = trimmed.split('=').nth(1) {
if let Ok(pid) = val.trim().parse::<u32>() {
let _ = fs::write(get_pid_file(), pid.to_string());
}
}
}
}
}
println!(
"Webhook server started via launchd on port {}.",
config.port
);
println!(" Logs: {}", log_path.display());
println!(" Plist: {}", plist_path.display());
}
"linux" => {
let unit_path = write_systemd_unit(SYSTEMD_UNIT, config.port, &log_path);
let _ = Command::new("systemctl")
.args(["--user", "daemon-reload"])
.output();
let result = Command::new("systemctl")
.args(["--user", "start", SYSTEMD_UNIT])
.output();
match result {
Ok(output) if !output.status.success() => {
eprintln!(
"Failed to start via systemd: {}",
String::from_utf8_lossy(&output.stderr).trim()
);
std::process::exit(1);
}
Err(e) => {
eprintln!("Failed to run systemctl: {e}");
std::process::exit(1);
}
_ => {}
}
if let Ok(pid_output) = Command::new("systemctl")
.args([
"--user",
"show",
SYSTEMD_UNIT,
"--property=MainPID",
"--value",
])
.output()
{
let s = String::from_utf8_lossy(&pid_output.stdout);
if let Ok(pid) = s.trim().parse::<u32>() {
if pid > 0 {
let _ = fs::write(get_pid_file(), pid.to_string());
}
}
}
println!(
"Webhook server started via systemd on port {}.",
config.port
);
println!(" Logs: {}", log_path.display());
println!(" Unit: {}", unit_path.display());
}
_ => {
eprintln!("Unsupported platform: {system}. Only macOS and Linux are supported.");
std::process::exit(1);
}
}
health_check(config.port, &log_path);
}
fn cmd_stop(force: bool) {
let system = std::env::consts::OS;
if force {
stop_force(system);
} else {
stop_graceful(system);
}
}
fn stop_graceful(system: &str) {
let pid = read_pid();
match system {
"macos" => {
let plist_path = home_dir()
.join("Library")
.join("LaunchAgents")
.join(format!("{LAUNCHD_LABEL}.plist"));
if plist_path.exists() {
if let Some(pid) = pid {
unsafe {
libc::kill(pid as i32, libc::SIGTERM);
}
}
let uid = unsafe { libc::getuid() };
let result = Command::new("launchctl")
.args([
"bootout",
&format!("gui/{uid}"),
&plist_path.to_string_lossy(),
])
.output();
let _ = fs::remove_file(get_pid_file());
match result {
Ok(output)
if output.status.success()
|| String::from_utf8_lossy(&output.stderr)
.contains("No such process") =>
{
println!("Webhook server stopped (graceful drain).");
}
Ok(output) => {
println!(
"launchctl bootout warning: {}",
String::from_utf8_lossy(&output.stderr).trim()
);
}
Err(e) => {
eprintln!("Failed to run launchctl: {e}");
}
}
return;
}
}
"linux" => {
let result = Command::new("systemctl")
.args(["--user", "stop", SYSTEMD_UNIT])
.output();
let _ = fs::remove_file(get_pid_file());
match result {
Ok(output) if output.status.success() => {
println!("Webhook server stopped (graceful drain).");
}
Ok(output) => {
println!(
"systemctl stop warning: {}",
String::from_utf8_lossy(&output.stderr).trim()
);
}
Err(e) => {
eprintln!("Failed to run systemctl: {e}");
}
}
return;
}
_ => {}
}
match pid {
Some(pid) => {
unsafe {
libc::kill(pid as i32, libc::SIGTERM);
}
let _ = fs::remove_file(get_pid_file());
println!("Webhook server stopped (graceful drain).");
}
None => {
eprintln!("Webhook server is not running.");
std::process::exit(1);
}
}
}
fn stop_force(system: &str) {
let pid = read_pid();
match system {
"macos" => {
if let Some(pid) = pid {
unsafe {
libc::kill(pid as i32, libc::SIGKILL);
}
}
let plist_path = home_dir()
.join("Library")
.join("LaunchAgents")
.join(format!("{LAUNCHD_LABEL}.plist"));
if plist_path.exists() {
let uid = unsafe { libc::getuid() };
let _ = Command::new("launchctl")
.args([
"bootout",
&format!("gui/{uid}"),
&plist_path.to_string_lossy(),
])
.output();
}
let _ = fs::remove_file(get_pid_file());
println!("Webhook server killed (force).");
}
"linux" => {
let result = Command::new("systemctl")
.args(["--user", "kill", "--signal=KILL", SYSTEMD_UNIT])
.output();
let _ = Command::new("systemctl")
.args(["--user", "stop", SYSTEMD_UNIT])
.output();
let _ = fs::remove_file(get_pid_file());
match result {
Ok(output) if output.status.success() => {
println!("Webhook server killed (force).");
}
Ok(output) => {
println!(
"systemctl kill warning: {}",
String::from_utf8_lossy(&output.stderr).trim()
);
}
Err(e) => {
eprintln!("Failed to run systemctl: {e}");
}
}
}
_ => {
match pid {
Some(pid) => {
unsafe {
libc::kill(pid as i32, libc::SIGKILL);
}
let _ = fs::remove_file(get_pid_file());
println!("Webhook server killed (force).");
}
None => {
eprintln!("Webhook server is not running.");
std::process::exit(1);
}
}
}
}
}
fn cmd_status() {
match read_pid() {
Some(pid) => {
println!("Webhook server is running (PID {pid}).");
if let Ok(config) = GlobalConfig::load(None) {
println!(" Port: {}", config.port);
}
}
None => {
println!("Webhook server is not running.");
}
}
let registry_path = global_config_dir().join("registry.json");
if !registry_path.exists() {
println!();
println!("No repos registered (registry.json not found).");
return;
}
let registry_contents = match fs::read_to_string(®istry_path) {
Ok(c) => c,
Err(_) => {
println!();
println!("Registry file is corrupted.");
return;
}
};
let registry: serde_json::Value = match serde_json::from_str(®istry_contents) {
Ok(v) => v,
Err(_) => {
println!();
println!("Registry file is corrupted.");
return;
}
};
let repos = match registry.get("repos").and_then(|r| r.as_object()) {
Some(r) if !r.is_empty() => r,
_ => {
println!();
println!("No repos registered.");
return;
}
};
println!();
println!("Registered repos ({}):", repos.len());
for (repo_name, info) in repos {
let local_path = info
.get("local_path")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
println!(" {repo_name}");
println!(" Path: {local_path}");
let queue_dir = Path::new(local_path).join(".githubclaw").join("queue");
if queue_dir.exists() {
let queue_count = fs::read_dir(&queue_dir)
.map(|entries| {
entries
.filter_map(|e| e.ok())
.filter(|e| {
e.path().is_file()
&& e.path().extension().is_some_and(|ext| ext == "json")
})
.count()
})
.unwrap_or(0);
println!(" Queue: {queue_count} item(s)");
}
}
}
fn cmd_logs(follow: bool) {
let log_path = get_log_file();
if !log_path.exists() {
println!("No logs found.");
return;
}
if follow {
use std::os::unix::process::CommandExt;
let err = Command::new("tail")
.args(["-f", &log_path.to_string_lossy()])
.exec();
eprintln!("Failed to exec tail: {err}");
std::process::exit(1);
} else {
match fs::read_to_string(&log_path) {
Ok(contents) => {
let lines: Vec<&str> = contents.lines().collect();
let start = if lines.len() > 50 {
lines.len() - 50
} else {
0
};
for line in &lines[start..] {
println!("{line}");
}
}
Err(e) => {
eprintln!("Error reading log file: {e}");
std::process::exit(1);
}
}
}
}
fn cmd_serve(host: &str, port: u16) {
use crate::process_manager::ProcessManager;
use crate::scheduler::ScheduledEventManager;
use crate::server::{
bootstrap_repo, create_router, load_registry, load_webhook_secret, ServerState,
};
use std::collections::HashSet;
use tokio::sync::{Mutex, RwLock};
let rt = tokio::runtime::Runtime::new().unwrap_or_else(|e| {
eprintln!("Failed to create tokio runtime: {e}");
std::process::exit(1);
});
rt.block_on(async {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.init();
let global_dir = global_config_dir();
let config = GlobalConfig::load(None).unwrap_or_else(|e| {
eprintln!("Error loading config: {e}");
std::process::exit(1);
});
let secret_path = global_dir.join("secrets").join("webhook_secret");
let webhook_secret = load_webhook_secret(&secret_path).unwrap_or_else(|e| {
eprintln!("Error loading webhook secret: {e}");
std::process::exit(1);
});
let registry_path = global_dir.join("registry.json");
let registry = load_registry(®istry_path);
if registry.is_empty() {
tracing::warn!("No repos registered. Run `githubclaw init` in a repo first.");
}
let scheduler_path = global_dir.join("scheduled_events.json");
let mut scheduler = ScheduledEventManager::new(&scheduler_path);
if let Err(e) = scheduler.load() {
tracing::warn!("Failed to load scheduled events: {}", e);
}
let state = Arc::new(ServerState {
webhook_secret: webhook_secret.clone(),
registry: RwLock::new(registry.clone()),
started_repos: RwLock::new(HashSet::new()),
queues: Mutex::new(HashMap::new()),
githubclaw_home: global_dir.clone(),
process_manager: Arc::new(ProcessManager::with_limits(
config.max_concurrent_orchestrators,
config.max_concurrent_workers,
)),
scheduler: Mutex::new(scheduler),
rate_limiter: Arc::new(crate::rate_limiter::RateLimiter::default()),
shutdown: Arc::new(std::sync::atomic::AtomicBool::new(false)),
issue_router: crate::issue_router::IssueRouter::new(global_dir.join("sessions")),
session_store: crate::session_store::SessionStore::new(),
});
for (repo_name, entry) in ®istry {
if let Err(e) = bootstrap_repo(&state, repo_name, entry, false).await {
tracing::warn!("Bootstrap failed for {}: {}", repo_name, e);
}
}
let _monitor_handle = state.process_manager.start_monitor();
{
let sched_state = Arc::clone(&state);
tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(
crate::constants::SCHEDULER_CHECK_INTERVAL_SECONDS,
));
loop {
interval.tick().await;
if sched_state
.shutdown
.load(std::sync::atomic::Ordering::Relaxed)
{
break;
}
let mut scheduler = sched_state.scheduler.lock().await;
let sched_state_inner = Arc::clone(&sched_state);
scheduler
.fire_due_events_with_callback(|repo, payload| {
let st = Arc::clone(&sched_state_inner);
async move {
let mut queues = st.queues.lock().await;
let registry = st.registry.read().await;
let queue = crate::server::get_or_create_queue(
&mut queues,
®istry,
&st.githubclaw_home,
&repo,
)
.map_err(|e| e.to_string())?;
queue
.enqueue(
serde_json::json!({
"type": "scheduled_fired",
"scheduled_payload": payload,
}),
"scheduled_fired",
)
.map_err(|e| e.to_string())?;
Ok(())
}
})
.await;
}
});
}
{
let shutdown_flag = state.shutdown.clone();
tokio::spawn(async move {
tokio::signal::ctrl_c().await.ok();
shutdown_flag.store(true, std::sync::atomic::Ordering::Relaxed);
});
}
crate::server::start_event_processing(Arc::clone(&state)).await;
let app = create_router(Arc::clone(&state));
let bind_addr = format!("{host}:{port}");
tracing::info!("GithubClaw webhook server listening on {}", bind_addr);
let listener = tokio::net::TcpListener::bind(&bind_addr)
.await
.unwrap_or_else(|e| {
eprintln!("Failed to bind to {}: {}", bind_addr, e);
std::process::exit(1);
});
let shutdown_signal = async {
let mut sigterm =
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("failed to install SIGTERM handler");
let sigint = tokio::signal::ctrl_c();
tokio::select! {
_ = sigterm.recv() => {
tracing::info!("Received SIGTERM, shutting down...");
}
_ = sigint => {
tracing::info!("Received SIGINT, shutting down...");
}
}
};
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal)
.await
.unwrap_or_else(|e| {
eprintln!("Server error: {e}");
std::process::exit(1);
});
tracing::info!("Server shut down.");
});
}
fn home_dir() -> PathBuf {
std::env::var("HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("/tmp"))
}
fn parse_github_remote(url: &str) -> Option<String> {
let re_ssh = regex::Regex::new(r"^git@github\.com:([^/]+)/([^/]+?)(?:\.git)?$").ok()?;
if let Some(caps) = re_ssh.captures(url) {
return Some(format!("{}/{}", &caps[1], &caps[2]));
}
let re_https = regex::Regex::new(r"^https://github\.com/([^/]+)/([^/]+?)(?:\.git)?$").ok()?;
if let Some(caps) = re_https.captures(url) {
return Some(format!("{}/{}", &caps[1], &caps[2]));
}
None
}
fn register_repo(repo_root: &Path) {
let output = match Command::new("git")
.args(["remote", "get-url", "origin"])
.current_dir(repo_root)
.output()
{
Ok(o) => o,
Err(_) => {
println!(" Warning: could not run git; skipping registry.");
return;
}
};
if !output.status.success() {
println!(" Warning: no 'origin' remote found; skipping registry.");
return;
}
let remote_url = String::from_utf8_lossy(&output.stdout).trim().to_string();
let owner_repo = match parse_github_remote(&remote_url) {
Some(or) => or,
None => {
println!(" Warning: could not parse GitHub owner/repo from: {remote_url}");
return;
}
};
let repo_name = owner_repo.split('/').next_back().unwrap_or(&owner_repo);
let global_dir = global_config_dir();
let _ = fs::create_dir_all(&global_dir);
let registry_path = global_dir.join("registry.json");
let mut registry: serde_json::Value = if registry_path.exists() {
fs::read_to_string(®istry_path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_else(|| serde_json::json!({"repos": {}}))
} else {
serde_json::json!({"repos": {}})
};
if registry.get("repos").is_none() {
registry["repos"] = serde_json::json!({});
}
let resolved = fs::canonicalize(repo_root).unwrap_or_else(|_| repo_root.to_path_buf());
registry["repos"][&owner_repo] = serde_json::json!({
"local_path": resolved.to_string_lossy(),
"socket_path": format!("/tmp/githubclaw-{repo_name}.sock"),
});
let json_str = serde_json::to_string_pretty(®istry).unwrap_or_default();
let _ = fs::write(®istry_path, format!("{json_str}\n"));
println!(" Registered {owner_repo} in ~/.githubclaw/registry.json");
}
fn setup_webhook_secret() {
let secrets_dir = global_config_dir().join("secrets");
let _ = fs::create_dir_all(&secrets_dir);
let secret_path = secrets_dir.join("webhook_secret");
if secret_path.exists() {
println!(" Webhook secret already configured.");
return;
}
use std::io::Read;
let mut buf = [0u8; 32];
if let Ok(mut f) = fs::File::open("/dev/urandom") {
if f.read_exact(&mut buf).is_ok() {
let secret = hex::encode(buf);
if fs::write(&secret_path, &secret).is_ok() {
let _ = fs::set_permissions(&secret_path, fs::Permissions::from_mode(0o600));
println!(" Generated webhook secret at ~/.githubclaw/secrets/webhook_secret");
println!(" Use this secret when creating your GitHub App webhook.");
return;
}
}
}
eprintln!(" Warning: could not generate webhook secret.");
}
fn detect_backends() {
let claude_found = which("claude");
let codex_found = which("codex");
let mut available = Vec::new();
if claude_found {
available.push("claude");
}
if codex_found {
available.push("codex");
}
if available.is_empty() {
println!(
" Warning: Neither claude nor codex CLI found. \
Install one before running agents."
);
} else {
println!(" Available backends: {}", available.join(", "));
}
}
fn preflight_gh() {
if !which("gh") {
println!(" Warning: gh CLI not found. Install it: https://cli.github.com");
return;
}
let result = Command::new("gh").args(["auth", "status"]).output();
match result {
Ok(output) if output.status.success() => {
println!(" gh CLI authenticated.");
}
_ => {
println!(" Warning: gh CLI not authenticated. Run: gh auth login");
}
}
}
fn which(binary: &str) -> bool {
Command::new("which")
.arg(binary)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn read_pid() -> Option<u32> {
let pid_file = get_pid_file();
if !pid_file.exists() {
return None;
}
let contents = fs::read_to_string(&pid_file).ok()?;
let pid: u32 = contents.trim().parse().ok()?;
let alive = unsafe { libc::kill(pid as i32, 0) == 0 };
if alive {
Some(pid)
} else {
let _ = fs::remove_file(&pid_file);
None
}
}
fn write_launchd_plist(label: &str, port: u16, log_path: &Path) -> PathBuf {
let plist_dir = home_dir().join("Library").join("LaunchAgents");
let _ = fs::create_dir_all(&plist_dir);
let plist_path = plist_dir.join(format!("{label}.plist"));
let exe_path = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("githubclaw"));
let path_env = std::env::var("PATH").unwrap_or_else(|_| "/usr/local/bin:/usr/bin:/bin".into());
let plist_content = format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>{label}</string>
<key>ProgramArguments</key>
<array>
<string>{exe}</string>
<string>serve</string>
<string>--host</string>
<string>0.0.0.0</string>
<string>--port</string>
<string>{port}</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>{log}</string>
<key>StandardErrorPath</key>
<string>{log}</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>{path}</string>
</dict>
</dict>
</plist>
"#,
exe = exe_path.display(),
log = log_path.display(),
path = path_env,
);
let _ = fs::write(&plist_path, plist_content);
plist_path
}
fn write_systemd_unit(unit_name: &str, port: u16, log_path: &Path) -> PathBuf {
let unit_dir = home_dir().join(".config").join("systemd").join("user");
let _ = fs::create_dir_all(&unit_dir);
let unit_path = unit_dir.join(format!("{unit_name}.service"));
let exe_path = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("githubclaw"));
let path_env = std::env::var("PATH").unwrap_or_else(|_| "/usr/local/bin:/usr/bin:/bin".into());
let unit_content = format!(
r#"[Unit]
Description=GithubClaw Webhook Server
After=network.target
[Service]
Type=simple
ExecStart={exe} serve --host 0.0.0.0 --port {port}
Restart=on-failure
RestartSec=5
StandardOutput=append:{log}
StandardError=append:{log}
Environment=PATH={path}
[Install]
WantedBy=default.target
"#,
exe = exe_path.display(),
log = log_path.display(),
path = path_env,
);
let _ = fs::write(&unit_path, unit_content);
unit_path
}
fn health_check(port: u16, log_path: &Path) {
std::thread::sleep(std::time::Duration::from_secs(2));
for attempt in 0..3 {
let result = Command::new("curl")
.args([
"-sf",
"--max-time",
"5",
&format!("http://127.0.0.1:{port}/health"),
])
.output();
if let Ok(output) = result {
if output.status.success() {
println!(" Server is healthy.");
return;
}
}
if attempt < 2 {
std::thread::sleep(std::time::Duration::from_secs(1));
}
}
println!(
" Warning: Server may not have started correctly. Check logs: {}",
log_path.display()
);
}
fn detect_github_remote(repo_root: &Path) -> Option<String> {
let output = Command::new("git")
.args(["remote", "get-url", "origin"])
.current_dir(repo_root)
.output()
.ok()?;
if !output.status.success() {
return None;
}
let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
parse_github_remote(&url)
}
fn runtime_timestamp() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs().to_string())
.unwrap_or_else(|_| "0".to_string())
}
fn cmd_dispatch(
agent_type: &str,
issue: u64,
prompt: &str,
repo: Option<&str>,
event_id_arg: Option<&str>,
dedupe_key_arg: Option<&str>,
) {
use crate::constants::AGENT_TYPES;
use crate::dispatch_receipts::DispatchReceiptStore;
if !AGENT_TYPES.contains(&agent_type) {
eprintln!(
"Error: unknown agent type '{}'. Valid types: {}",
agent_type,
AGENT_TYPES.join(", ")
);
std::process::exit(1);
}
let repo_name = match repo {
Some(r) => r.to_string(),
None => {
let repo_root = find_repo_root(None).unwrap_or_else(|| {
eprintln!("Error: not inside a git repository. Use --repo flag.");
std::process::exit(1);
});
detect_github_remote(&repo_root).unwrap_or_else(|| {
eprintln!("Error: cannot detect GitHub remote. Use --repo flag.");
std::process::exit(1);
})
}
};
let repo_root = find_repo_root(None).unwrap_or_else(|| {
eprintln!("Error: not inside a git repository.");
std::process::exit(1);
});
let receipt_store = DispatchReceiptStore::new(&repo_root);
let dispatch_event_id = event_id_arg.map(ToString::to_string).or_else(|| {
std::env::var("GITHUBCLAW_EVENT_ID")
.ok()
.filter(|value| !value.trim().is_empty())
});
let dispatch_dedupe_suffix = dedupe_key_arg.map(ToString::to_string).or_else(|| {
std::env::var("GITHUBCLAW_DISPATCH_DEDUPE_KEY")
.ok()
.filter(|value| !value.trim().is_empty())
});
let dispatch_receipt_key = dispatch_event_id.as_ref().map(|event_id| {
DispatchReceiptStore::key_for(
event_id,
agent_type,
issue,
prompt,
dispatch_dedupe_suffix.as_deref(),
)
});
if let Some(ref receipt_key) = dispatch_receipt_key {
if receipt_store.has_receipt(receipt_key) {
println!(
"Skipping duplicate {} dispatch for {}#{} (receipt {}).",
agent_type, repo_name, issue, receipt_key
);
let started_at = runtime_timestamp();
let session_store = crate::session_store::SessionStore::new();
let mut runtime_snapshot = session_store
.load_runtime_snapshot(&repo_name, issue)
.unwrap_or(None)
.unwrap_or_else(|| {
crate::runtime_state::IssueRuntimeSnapshot::new(&repo_name, issue)
});
runtime_snapshot.note_agent_finished(
agent_type,
&started_at,
true,
format!(
"Skipped duplicate dispatch for {}#{} (event {})",
repo_name,
issue,
dispatch_event_id.as_deref().unwrap_or_default()
),
);
let _ = session_store.save_runtime_snapshot(&repo_name, &runtime_snapshot);
return;
}
}
println!(
"Dispatching {} agent for {}#{} ...",
agent_type, repo_name, issue
);
let started_at = runtime_timestamp();
let session_store = crate::session_store::SessionStore::new();
let mut runtime_snapshot = session_store
.load_runtime_snapshot(&repo_name, issue)
.unwrap_or(None)
.unwrap_or_else(|| crate::runtime_state::IssueRuntimeSnapshot::new(&repo_name, issue));
runtime_snapshot.note_agent_started(
agent_type,
&started_at,
format!("Dispatch started for {}#{}", repo_name, issue),
);
let _ = session_store.save_runtime_snapshot(&repo_name, &runtime_snapshot);
let mut extra_env = HashMap::new();
if issue > 0 {
extra_env.insert("GITHUBCLAW_ROOT_ISSUE".into(), issue.to_string());
}
extra_env.insert("GITHUBCLAW_REPO".into(), repo_name.clone());
let agent_def_content = load_agent_definition(agent_type, &repo_root);
let tmp_dir = std::env::temp_dir().join("githubclaw-dispatch");
fs::create_dir_all(&tmp_dir).unwrap_or_default();
let agent_file = tmp_dir.join(format!("{}.md", agent_type));
fs::write(&agent_file, &agent_def_content).unwrap_or_else(|e| {
eprintln!("Error writing temp agent file: {}", e);
std::process::exit(1);
});
let agent_def = match crate::agents::parser::parse_agent_file(&agent_file) {
Ok(def) => def,
Err(e) => {
eprintln!("Error parsing agent definition for '{}': {}", agent_type, e);
std::process::exit(1);
}
};
let mut prompt_assembler = crate::agents::prompt_assembler::PromptAssembler::new(&repo_root);
let prompt_file = match prompt_assembler.assemble(&agent_def, prompt) {
Ok(path) => path,
Err(e) => {
eprintln!("Error assembling prompt: {}", e);
std::process::exit(1);
}
};
let spawner = crate::agents::spawner::AgentSpawner::new(
&repo_root,
crate::constants::DEFAULT_AGENT_MAX_TURNS,
);
let env = spawner.build_env(&agent_def, &prompt_file, prompt, Some(&extra_env));
let cmd = match spawner.build_command(&agent_def, &prompt_file, prompt) {
Ok(c) => c,
Err(e) => {
eprintln!("Error building command: {}", e);
std::process::exit(1);
}
};
let program = &cmd[0];
let args = &cmd[1..];
let status = Command::new(program)
.args(args)
.envs(&env)
.current_dir(&repo_root)
.status();
match status {
Ok(s) => {
let code = s.code().unwrap_or(-1);
let mut runtime_snapshot = session_store
.load_runtime_snapshot(&repo_name, issue)
.unwrap_or(None)
.unwrap_or_else(|| {
crate::runtime_state::IssueRuntimeSnapshot::new(&repo_name, issue)
});
let detail = if code == 0 {
format!("Dispatch completed for {}#{}", repo_name, issue)
} else {
format!(
"Dispatch exited with code {} for {}#{}",
code, repo_name, issue
)
};
runtime_snapshot.note_agent_finished(agent_type, &started_at, code == 0, detail);
let _ = session_store.save_runtime_snapshot(&repo_name, &runtime_snapshot);
if code == 0 {
if let Some(event_id) = dispatch_event_id.as_deref() {
if let Err(err) = receipt_store.record_success(
event_id,
agent_type,
issue,
prompt,
dispatch_dedupe_suffix.as_deref(),
) {
eprintln!(
"Warning: failed to persist dispatch receipt for '{}': {}",
agent_type, err
);
}
}
println!("Agent '{}' completed successfully.", agent_type);
} else {
eprintln!("Agent '{}' exited with code {}.", agent_type, code);
std::process::exit(code);
}
}
Err(e) => {
let mut runtime_snapshot = session_store
.load_runtime_snapshot(&repo_name, issue)
.unwrap_or(None)
.unwrap_or_else(|| {
crate::runtime_state::IssueRuntimeSnapshot::new(&repo_name, issue)
});
runtime_snapshot.note_agent_finished(
agent_type,
&started_at,
false,
format!(
"Dispatch failed to spawn for {}#{}: {}",
repo_name, issue, e
),
);
let _ = session_store.save_runtime_snapshot(&repo_name, &runtime_snapshot);
eprintln!("Error spawning agent '{}': {}", agent_type, e);
std::process::exit(1);
}
}
}
fn load_agent_definition(agent_type: &str, repo_root: &Path) -> String {
let local_path = repo_root
.join(".githubclaw")
.join("agents")
.join(format!("{}.md", agent_type));
if local_path.exists() {
return fs::read_to_string(&local_path).unwrap_or_default();
}
match agent_type {
"orchestrator" => DEFAULT_AGENT_ORCHESTRATOR.to_string(),
"implementer" => DEFAULT_AGENT_IMPLEMENTER.to_string(),
"verifier" => DEFAULT_AGENT_VERIFIER.to_string(),
"reviewer" => DEFAULT_AGENT_REVIEWER.to_string(),
"vision-gap-analyst" => DEFAULT_AGENT_VISION_GAP_ANALYST.to_string(),
"bug-reproducer" => DEFAULT_AGENT_BUG_REPRODUCER.to_string(),
_ => {
eprintln!(
"Error: no embedded definition for agent type '{}'",
agent_type
);
std::process::exit(1);
}
}
}
fn cmd_release(repo: Option<&str>) {
let repo_root = find_repo_root(None).unwrap_or_else(|| {
eprintln!("Error: not inside a git repository.");
std::process::exit(1);
});
let repo_name = match repo {
Some(r) => r.to_string(),
None => detect_github_remote(&repo_root).unwrap_or_else(|| {
eprintln!("Error: cannot detect GitHub remote. Use --repo flag.");
std::process::exit(1);
}),
};
println!("Starting release pipeline for {} ...", repo_name);
let output = Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.current_dir(&repo_root)
.output();
let current_branch = match output {
Ok(o) => String::from_utf8_lossy(&o.stdout).trim().to_string(),
Err(e) => {
eprintln!("Error getting current branch: {}", e);
std::process::exit(1);
}
};
if current_branch != "dev" {
eprintln!(
"Error: release must be started from 'dev' branch (currently on '{}')",
current_branch
);
std::process::exit(1);
}
let timestamp = chrono::Utc::now().format("%Y%m%d-%H%M%S");
let release_branch = format!("release/{}", timestamp);
let status = Command::new("git")
.args(["checkout", "-b", &release_branch])
.current_dir(&repo_root)
.status();
if let Err(e) = status {
eprintln!("Error creating release branch: {}", e);
std::process::exit(1);
}
let status = Command::new("git")
.args(["push", "-u", "origin", &release_branch])
.current_dir(&repo_root)
.status();
if let Err(e) = status {
eprintln!("Error pushing release branch: {}", e);
std::process::exit(1);
}
println!("Creating release PR...");
let pr_body = format!(
"## Release from `{}`\n\n\
Automated release PR. Review changes and complete dogfooding checklist before merging.\n\n\
---\n_Generated by `githubclaw release`_",
release_branch
);
let status = Command::new("gh")
.args([
"pr",
"create",
"--base",
"main",
"--head",
&release_branch,
"--title",
&format!("Release {}", release_branch.replace("release/", "")),
"--body",
&pr_body,
])
.current_dir(&repo_root)
.status();
match status {
Ok(s) if s.success() => {
println!("Release PR created successfully.");
println!("Complete the dogfooding checklist, then merge via GitHub web UI.");
}
Ok(s) => {
eprintln!("gh pr create exited with code {:?}", s.code());
std::process::exit(1);
}
Err(e) => {
eprintln!("Failed to create release PR: {}", e);
std::process::exit(1);
}
}
}
fn cmd_tui() {
crate::tui::startup::run_tui_startup_checks();
let repo_root = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
crate::tui::ui::run(&repo_root).unwrap_or_else(|err| {
eprintln!("Error: failed to run TUI: {}", err);
std::process::exit(1);
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_github_remote_ssh() {
let result = parse_github_remote("git@github.com:octocat/Hello-World.git");
assert_eq!(result, Some("octocat/Hello-World".to_string()));
}
#[test]
fn test_parse_github_remote_https() {
let result = parse_github_remote("https://github.com/octocat/Hello-World.git");
assert_eq!(result, Some("octocat/Hello-World".to_string()));
}
#[test]
fn test_parse_github_remote_https_no_git_suffix() {
let result = parse_github_remote("https://github.com/octocat/Hello-World");
assert_eq!(result, Some("octocat/Hello-World".to_string()));
}
#[test]
fn test_parse_github_remote_invalid() {
assert_eq!(parse_github_remote("not-a-url"), None);
assert_eq!(parse_github_remote("https://gitlab.com/owner/repo"), None);
assert_eq!(parse_github_remote(""), None);
}
#[test]
fn test_dispatch_command_accepts_dedupe_flags() {
let cli = Cli::try_parse_from([
"githubclaw",
"dispatch",
"implementer",
"--issue",
"42",
"--prompt",
"Fix bug",
"--event-id",
"evt-123",
"--dedupe-key",
"second-pass",
])
.unwrap();
match cli.command {
Commands::Dispatch {
agent_type,
issue,
prompt,
event_id,
dedupe_key,
..
} => {
assert_eq!(agent_type, "implementer");
assert_eq!(issue, 42);
assert_eq!(prompt, "Fix bug");
assert_eq!(event_id.as_deref(), Some("evt-123"));
assert_eq!(dedupe_key.as_deref(), Some("second-pass"));
}
_ => panic!("expected dispatch command"),
}
}
}