use std::fs::{self, OpenOptions};
use std::path::PathBuf;
use std::process::ExitCode;
use std::sync::{Arc, Mutex, OnceLock};
use clap::{ColorChoice, CommandFactory, Parser, Subcommand};
use serde::Serialize;
use tracing::info;
use tracing_appender::non_blocking::{ErrorCounter, NonBlockingBuilder, WorkerGuard};
use tracing_subscriber::{fmt, prelude::*, EnvFilter};
use crate::error::{CliError, ExitKind};
use crate::output::{self, OutputFormat, OutputSpec};
const GIT_COMMIT: &str = env!("ORCHESTRATECTL_GIT_COMMIT");
const CARGO_VERSION: &str = env!("CARGO_PKG_VERSION");
#[derive(Parser, Debug)]
#[command(
name = "orchestratectl",
version = CARGO_VERSION,
about = "Orchestrate AI-agent workflows: worktrees, fan-out, orchestrate, llm-skills.",
disable_help_subcommand = true,
disable_version_flag = true,
color = ColorChoice::Never,
)]
struct Cli {
#[arg(
long,
global = true,
default_value = "jsonl",
value_name = "FMT|PATH",
value_parser = parse_output_arg,
)]
output: OutputSpec,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand, Debug)]
enum Command {
Version,
Skill {
#[command(subcommand)]
action: SkillAction,
},
Run {
#[command(subcommand)]
action: crate::run::RunAction,
},
Event {
#[command(subcommand)]
action: crate::event::EventAction,
},
Node {
#[command(subcommand)]
action: crate::node::NodeAction,
},
Discussion {
#[command(subcommand)]
action: crate::discussion::DiscussionAction,
},
Spinoff {
#[command(subcommand)]
action: crate::spinoff::SpinoffAction,
},
Supervise(crate::supervise::SuperviseArgs),
Doctor(crate::doctor::DoctorArgs),
Harness {
#[command(subcommand)]
action: HarnessAction,
},
Pipeline {
#[command(subcommand)]
action: PipelineAction,
},
}
#[derive(Subcommand, Debug)]
enum PipelineAction {
Run(PipelineRunArgs),
}
#[derive(clap::Args, Debug)]
struct PipelineRunArgs {
#[arg(long, value_name = "STR|FILE")]
intent: String,
#[arg(long, value_name = "BRANCH")]
source_branch: String,
#[arg(long, value_name = "PATH")]
files: Vec<PathBuf>,
#[arg(long, value_name = "SLUG")]
slug: Option<String>,
#[arg(long, value_name = "PATH")]
repo: Option<PathBuf>,
#[arg(long, value_name = "CMD")]
test_cmd: Option<String>,
#[arg(long, value_name = "CMD")]
clippy_cmd: Option<String>,
#[arg(long, value_name = "PATH")]
workdir: Option<PathBuf>,
#[arg(long, value_name = "N", default_value_t = 0)]
file_scope_slack: usize,
#[arg(long)]
keep: bool,
#[arg(long, value_name = "SECONDS")]
chunk_timeout: Option<u64>,
#[arg(long, value_name = "N")]
max_recode_per_chunk: Option<u32>,
#[arg(long, value_name = "N")]
max_fix_iterations: Option<u32>,
#[arg(long, value_name = "N")]
max_respec: Option<u32>,
#[arg(long, value_name = "N")]
max_promotions: Option<u32>,
#[arg(long, value_name = "USD")]
max_cost_usd: Option<f64>,
#[arg(long, value_name = "N")]
max_total_tokens: Option<u64>,
#[arg(long, value_name = "SECONDS")]
max_wall_time: Option<u64>,
#[arg(long, value_name = "N")]
max_processes: Option<u32>,
#[arg(long, value_name = "MB")]
max_storage_mb: Option<u64>,
#[arg(long, value_name = "N")]
max_identical_failures: Option<u32>,
}
#[derive(Subcommand, Debug)]
enum HarnessAction {
Bakeoff(BakeoffArgs),
}
#[derive(clap::Args, Debug)]
struct BakeoffArgs {
#[arg(long, value_name = "FILE")]
brief: PathBuf,
#[arg(long, value_name = "PATH")]
files: Vec<PathBuf>,
#[arg(long, value_name = "NAMES", value_delimiter = ',')]
only: Vec<String>,
#[arg(long, value_name = "SECONDS")]
timeout: Option<u64>,
}
#[derive(Subcommand, Debug)]
enum SkillAction {
List,
Show {
name: String,
},
Print {
name: String,
},
Install {
name: Option<String>,
#[arg(long, value_enum, default_value_t = SkillAgentArg::Claude)]
agent: SkillAgentArg,
#[arg(long)]
dest: Option<PathBuf>,
#[arg(long)]
force: bool,
},
}
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
enum SkillAgentArg {
Claude,
Codex,
All,
}
impl From<SkillAgentArg> for crate::skill::AgentTarget {
fn from(v: SkillAgentArg) -> Self {
match v {
SkillAgentArg::Claude => Self::Claude,
SkillAgentArg::Codex => Self::Codex,
SkillAgentArg::All => Self::All,
}
}
}
pub fn run() -> ExitCode {
let raw_args: Vec<String> = std::env::args().skip(1).collect();
match crate::help::resolve_help_request(&Cli::command(), &raw_args) {
crate::help::HelpRequest::None => {}
crate::help::HelpRequest::Render { spec, path, depth } => {
return emit_json_help(&path, depth, &spec);
}
crate::help::HelpRequest::UnknownSubcommand { token } => {
let err = CliError::user(
"unknown_subcommand",
format!("unknown subcommand '{token}'"),
);
err.emit();
return ExitCode::from(ExitKind::User as u8);
}
crate::help::HelpRequest::InvalidDepth { value } => {
let err = CliError::user(
"invalid_arguments",
format!("--depth expects a positive integer or 'tree'/'full'; got '{value}'"),
)
.with_invalid_value(value);
err.emit();
return ExitCode::from(ExitKind::User as u8);
}
}
let LoggingInit {
warnings: logging_warnings,
guard: _log_guard,
} = init_logging();
let cli = match Cli::try_parse() {
Ok(cli) => cli,
Err(e) => return handle_clap_error(e, &logging_warnings),
};
info!(
target: "orchestratectl::cli",
output_format = ?cli.output.format,
output_file = ?cli.output.file,
command = ?cli.command,
"command dispatched"
);
let output = &cli.output;
let result = match cli.command {
Command::Version => cmd_version(output, &logging_warnings),
Command::Skill { action } => match action {
SkillAction::List => crate::skill::cmd_list(output, &logging_warnings),
SkillAction::Show { name } => crate::skill::cmd_show(&name, output, &logging_warnings),
SkillAction::Print { name } => {
crate::skill::cmd_print(&name, output, &logging_warnings)
}
SkillAction::Install {
name,
agent,
dest,
force,
} => crate::skill::cmd_install(
name.as_deref(),
agent.into(),
dest,
force,
output,
&logging_warnings,
),
},
Command::Run { action } => crate::run::dispatch(action, output, &logging_warnings),
Command::Event { action } => crate::event::dispatch(action, output, &logging_warnings),
Command::Node { action } => crate::node::dispatch(action, output, &logging_warnings),
Command::Discussion { action } => {
crate::discussion::dispatch(action, output, &logging_warnings)
}
Command::Spinoff { action } => crate::spinoff::dispatch(action, output, &logging_warnings),
Command::Supervise(args) => crate::supervise::dispatch(args, output, &logging_warnings),
Command::Doctor(args) => return crate::doctor::run(&args, output, &logging_warnings),
Command::Harness { action } => match action {
HarnessAction::Bakeoff(args) => {
let cfg = crate::harness::bakeoff::BakeoffConfig {
brief: args.brief,
files: args.files,
only: args.only,
timeout: std::time::Duration::from_secs(
args.timeout
.unwrap_or(crate::harness::bakeoff::DEFAULT_TIMEOUT_SECS),
),
};
crate::harness::bakeoff::run(&cfg, output, &logging_warnings)
}
},
Command::Pipeline { action } => match action {
PipelineAction::Run(args) => {
let cfg = crate::pipeline::live::PipelineRunConfig {
intent: args.intent,
source_branch: args.source_branch,
files: args.files,
slug: args.slug,
repo: args.repo,
test_cmd: args.test_cmd,
clippy_cmd: args.clippy_cmd,
workdir: args.workdir,
file_scope_slack: args.file_scope_slack,
keep: args.keep,
chunk_timeout_secs: args.chunk_timeout,
max_recode_per_chunk: args.max_recode_per_chunk,
max_fix_iterations: args.max_fix_iterations,
max_respec: args.max_respec,
max_promotions: args.max_promotions,
max_cost_usd: args.max_cost_usd,
max_total_tokens: args.max_total_tokens,
max_wall_time_secs: args.max_wall_time,
max_processes: args.max_processes,
max_storage_mb: args.max_storage_mb,
max_identical_failures: args.max_identical_failures,
};
crate::pipeline::live::cmd_run(&cfg, output, &logging_warnings)
}
},
};
match result {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
e.emit();
ExitCode::from(e.kind as u8)
}
}
}
fn emit_json_help(
subcommand_path: &[String],
depth: crate::help::HelpDepth,
spec: &OutputSpec,
) -> ExitCode {
let mut root = Cli::command();
root.build();
let (target, path) = crate::help::navigate_path(&root, subcommand_path);
let data = crate::help::build_help(target, &path, depth);
match output::emit_envelope(&data, spec, &[]) {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
e.emit();
ExitCode::from(e.kind as u8)
}
}
}
fn handle_clap_error(e: clap::Error, logging_warnings: &[String]) -> ExitCode {
use clap::error::ErrorKind;
if matches!(
e.kind(),
ErrorKind::DisplayHelp | ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
) {
let _ = e.print();
return ExitCode::SUCCESS;
}
let message = e
.to_string()
.lines()
.filter(|l| !l.trim_start().starts_with("For more information"))
.collect::<Vec<_>>()
.join(" ")
.trim()
.to_string();
let message = if message.is_empty() {
"invalid arguments".to_string()
} else {
message
};
let code = match e.kind() {
ErrorKind::InvalidSubcommand | ErrorKind::UnknownArgument => "unknown_subcommand_or_flag",
ErrorKind::MissingRequiredArgument | ErrorKind::MissingSubcommand => "missing_argument",
ErrorKind::InvalidValue => "invalid_value",
_ => "invalid_arguments",
};
let err = CliError {
kind: ExitKind::User,
code: code.to_string(),
message,
invalid_value: None,
expected: None,
};
err.emit();
crate::output::emit_text_warnings(logging_warnings);
ExitCode::from(ExitKind::User as u8)
}
#[derive(Debug, Serialize)]
struct VersionPayload {
version: &'static str,
commit: &'static str,
skills: Vec<crate::skill::SkillCatalogEntry>,
schema_version: u32,
supported_schemas: &'static [u32],
state_schema_version: u32,
supported_state_schemas: &'static [u32],
}
fn cmd_version(spec: &OutputSpec, warnings: &[String]) -> Result<(), CliError> {
let payload = VersionPayload {
version: CARGO_VERSION,
commit: GIT_COMMIT,
skills: crate::skill::catalog(),
schema_version: octl_core::SCHEMA_VERSION,
supported_schemas: &[octl_core::SCHEMA_VERSION],
state_schema_version: octl_core::STATE_SCHEMA_VERSION,
supported_state_schemas: octl_core::SUPPORTED_STATE_SCHEMAS,
};
match spec.format {
OutputFormat::Json | OutputFormat::Jsonl => {
output::emit_envelope(&payload, spec, warnings)?;
}
OutputFormat::Text => {
println!("orchestratectl {}", payload.version);
println!("commit: {}", payload.commit);
println!("envelope schema: {}", payload.schema_version);
println!(
"supported envelopes: {}",
format_u32_list(payload.supported_schemas)
);
println!("state schema version: {}", payload.state_schema_version);
println!(
"supported state schemas: {}",
format_u32_list(payload.supported_state_schemas)
);
output::emit_text_warnings(warnings);
}
}
Ok(())
}
fn parse_output_arg(s: &str) -> Result<OutputSpec, String> {
output::parse_output_value(s)
}
fn format_u32_list(values: &[u32]) -> String {
values
.iter()
.map(u32::to_string)
.collect::<Vec<_>>()
.join(", ")
}
const LOG_BUFFERED_LINES: usize = 128_000;
type LogCell = Arc<Mutex<Option<WorkerGuard>>>;
static LOG_FLUSH: OnceLock<LogCell> = OnceLock::new();
static LOG_DROPPED: OnceLock<ErrorCounter> = OnceLock::new();
pub(crate) fn dropped_log_events() -> u64 {
LOG_DROPPED.get().map_or(0, |c| c.dropped_lines() as u64)
}
fn drain_cell(cell: &Mutex<Option<WorkerGuard>>) {
let taken = match cell.lock() {
Ok(mut g) => g.take(),
Err(poisoned) => poisoned.into_inner().take(),
};
drop(taken);
}
pub(crate) fn flush_logs() {
if let Some(cell) = LOG_FLUSH.get() {
drain_cell(cell);
}
}
#[must_use = "the log writer thread is shut down when the guard is dropped — bind it for the process lifetime"]
struct LogGuard {
cell: LogCell,
}
impl Drop for LogGuard {
fn drop(&mut self) {
drain_cell(&self.cell);
}
}
#[must_use = "the log writer thread is shut down when the guard is dropped — bind it for the process lifetime"]
struct LoggingInit {
warnings: Vec<String>,
guard: LogGuard,
}
fn finish_logging(
warnings: Vec<String>,
guard: Option<WorkerGuard>,
dropped: Option<ErrorCounter>,
) -> LoggingInit {
if let Some(counter) = dropped {
let _ = LOG_DROPPED.set(counter);
}
let cell = LOG_FLUSH.get_or_init(|| Arc::new(Mutex::new(None))).clone();
{
let mut slot = match cell.lock() {
Ok(g) => g,
Err(poisoned) => poisoned.into_inner(),
};
if slot.is_none() {
*slot = guard;
}
}
LoggingInit {
warnings,
guard: LogGuard { cell },
}
}
struct SlowLogWriter<W: std::io::Write> {
inner: W,
delay: std::time::Duration,
}
impl<W: std::io::Write> std::io::Write for SlowLogWriter<W> {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
std::thread::sleep(self.delay);
self.inner.write_all(buf)?;
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
self.inner.flush()
}
}
#[cfg(debug_assertions)]
fn slow_log_write_delay() -> Option<std::time::Duration> {
std::env::var("OCTL_TEST_SLOW_LOG_WRITES")
.ok()?
.parse::<u64>()
.ok()
.map(std::time::Duration::from_millis)
}
#[cfg(not(debug_assertions))]
fn slow_log_write_delay() -> Option<std::time::Duration> {
None
}
fn init_logging() -> LoggingInit {
let mut warnings = Vec::new();
let log_path = if let Some(p) = log_path() {
p
} else {
warnings.push("log path unavailable: HOME and ORCHESTRATECTL_HOME both unset".to_string());
return finish_logging(warnings, None, None);
};
if let Some(parent) = log_path.parent() {
if let Err(e) = fs::create_dir_all(parent) {
warnings.push(format!(
"could not create log directory {}: {}",
parent.display(),
e
));
return finish_logging(warnings, None, None);
}
}
let file = match OpenOptions::new().create(true).append(true).open(&log_path) {
Ok(f) => f,
Err(e) => {
warnings.push(format!(
"could not open log file {}: {}",
log_path.display(),
e
));
return finish_logging(warnings, None, None);
}
};
let filter =
EnvFilter::try_from_env("ORCHESTRATECTL_LOG").unwrap_or_else(|_| EnvFilter::new("info"));
let builder = NonBlockingBuilder::default()
.lossy(true)
.buffered_lines_limit(LOG_BUFFERED_LINES);
let (writer, guard) = match slow_log_write_delay() {
Some(delay) => builder.finish(SlowLogWriter { inner: file, delay }),
None => builder.finish(file),
};
let dropped = writer.error_counter();
let layer = fmt::layer()
.json()
.with_current_span(false)
.with_span_list(false)
.with_writer(writer);
if let Err(e) = tracing_subscriber::registry()
.with(filter)
.with(layer)
.try_init()
{
warnings.push(format!("tracing subscriber not installed: {e}"));
return finish_logging(warnings, None, None);
}
finish_logging(warnings, Some(guard), Some(dropped))
}
fn log_path() -> Option<PathBuf> {
let root = if let Ok(custom) = std::env::var("ORCHESTRATECTL_HOME") {
PathBuf::from(custom)
} else {
let home = std::env::var("HOME").ok()?;
PathBuf::from(home).join(".orchestratectl")
};
Some(root.join("logs").join("orchestratectl.log.jsonl"))
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use std::time::Duration;
#[test]
fn output_arg_id_matches_real_cli_tree() {
let cmd = Cli::command();
let output = cmd
.get_arguments()
.find(|a| a.get_id().as_str() == crate::help::OUTPUT_ARG_ID)
.expect("an arg with the OUTPUT_ARG_ID id exists on the root");
assert_eq!(output.get_long(), Some("output"));
assert!(output.is_global_set(), "--output must be global");
}
struct SlowSink {
out: Arc<Mutex<Vec<u8>>>,
}
impl Write for SlowSink {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
std::thread::sleep(Duration::from_millis(200));
self.out.lock().unwrap().extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
#[test]
fn drain_cell_blocks_until_buffered_line_is_written() {
let out = Arc::new(Mutex::new(Vec::new()));
let (mut writer, guard) = NonBlockingBuilder::default()
.lossy(true)
.finish(SlowSink { out: out.clone() });
writer.write_all(b"buffered-line\n").unwrap();
assert!(
out.lock().unwrap().is_empty(),
"line reached the sink before the flush — the sink wasn't slow enough"
);
let cell: LogCell = Arc::new(Mutex::new(Some(guard)));
drain_cell(&cell);
assert_eq!(
&*out.lock().unwrap(),
b"buffered-line\n",
"flush did not drain the buffered line to disk"
);
drain_cell(&cell);
assert_eq!(&*out.lock().unwrap(), b"buffered-line\n");
}
#[test]
fn drain_cell_on_absent_guard_is_noop() {
let cell: LogCell = Arc::new(Mutex::new(None));
drain_cell(&cell); }
#[test]
fn log_guard_drop_drains_buffered_line() {
let out = Arc::new(Mutex::new(Vec::new()));
let (mut writer, guard) = NonBlockingBuilder::default()
.lossy(true)
.finish(SlowSink { out: out.clone() });
writer.write_all(b"on-drop-line\n").unwrap();
let cell: LogCell = Arc::new(Mutex::new(Some(guard)));
let log_guard = LogGuard { cell: cell.clone() };
assert!(
out.lock().unwrap().is_empty(),
"line reached the sink before drop — the sink wasn't slow enough"
);
drop(log_guard);
assert_eq!(
&*out.lock().unwrap(),
b"on-drop-line\n",
"LogGuard::drop did not drain the buffered line"
);
assert!(cell.lock().unwrap().is_none());
}
#[test]
fn lossy_appender_counts_dropped_events_on_overflow() {
let out = Arc::new(Mutex::new(Vec::new()));
let (mut writer, _guard) = NonBlockingBuilder::default()
.lossy(true)
.buffered_lines_limit(1)
.finish(SlowSink { out });
let counter = writer.error_counter();
assert_eq!(counter.dropped_lines(), 0, "no drops before the burst");
for _ in 0..50 {
let _ = writer.write_all(b"overflow-line\n");
}
assert!(
counter.dropped_lines() >= 40,
"lossy overflow must drop the bulk of the burst, got {}",
counter.dropped_lines()
);
}
}