mod cache;
#[cfg(test)]
mod defaults_tests;
mod dispatch;
#[cfg(test)]
mod identity_tests;
mod interactive;
mod maintenance;
mod remote;
mod render;
mod run;
mod workflow;
use dispatch::Context;
use render::{outcome_code, print_json};
mod request;
mod server;
mod session;
use request::RequestCommand;
use server::ServerCommand;
use session::SessionCommand;
use std::io::{self, Write};
use std::path::PathBuf;
use std::process::ExitCode;
use clap::{Args, Parser, Subcommand, error::ErrorKind};
use serde_json::json;
use crate::{
config::ProfileKind,
project::selection::{ProfileSelection, Selection, read_selection_file},
types::{RequestId, ReuseKey, RunId},
};
const DEFAULT_RUN_LIST_LIMIT: u32 = 50;
#[derive(Debug, Parser)]
#[command(
name = "artifactize",
// Help names the command, not the file run; on Windows that is artifactize.exe.
bin_name = "artifactize",
version,
about = "The AI-native collaboration layer for one-of-a-kind teammates and their agents."
)]
pub struct Cli {
#[arg(long, global = true, value_name = "PATH")]
pub repo: Option<PathBuf>,
#[arg(long, global = true, value_name = "PATH")]
pub state_dir: Option<PathBuf>,
#[arg(long, global = true)]
pub json: bool,
#[command(subcommand)]
pub command: Option<Command>,
}
#[derive(Debug, Subcommand)]
pub enum Command {
#[command(disable_help_subcommand = true, version)]
Tools {
#[command(subcommand)]
command: ToolsCommand,
},
Login {
#[command(subcommand)]
provider: AuthProvider,
},
Logout {
#[command(subcommand)]
provider: AuthProvider,
},
Remote {
#[command(subcommand)]
command: remote::RemoteCommand,
},
Doctor,
Prune {
#[arg(long, value_name = "DURATION", value_parser = crate::store::prune::parse_duration)]
older_than: Option<std::time::Duration>,
#[arg(long)]
dry_run: bool,
},
Models {
#[command(subcommand)]
provider: ModelProvider,
},
#[command(group(clap::ArgGroup::new("required_selection")
.args(["artifact", "eval", "evals", "artifacts", "evals_file", "artifacts_file", "all"])
.required(true)))]
Verify {
#[command(flatten)]
selection: SelectionArgs,
#[command(flatten)]
policy: PolicyArgs,
#[arg(
long,
value_name = "N",
default_value_t = crate::project::DEFAULT_JOBS as u32,
value_parser = clap::value_parser!(u32).range(1..)
)]
jobs: u32,
#[arg(long, value_name = "N")]
max_executions: Option<u64>,
#[arg(
long,
value_name = "MS",
value_parser = timeout_duration
)]
timeout_ms: Option<std::time::Duration>,
#[arg(long, value_name = "KINDS", value_enum, value_delimiter = ',')]
reuse_only: Vec<ProfileKind>,
},
Status {
#[command(flatten)]
selection: SelectionArgs,
#[command(flatten)]
policy: PolicyArgs,
},
#[command(hide = true, disable_help_flag = true)]
Graph {
#[arg(trailing_var_arg = true, allow_hyphen_values = true, hide = true)]
args: Vec<String>,
},
Run {
#[command(subcommand)]
command: RunCommand,
},
Request {
#[command(subcommand)]
command: RequestCommand,
},
Session {
#[command(subcommand)]
command: SessionCommand,
},
Cache {
#[command(subcommand)]
command: CacheCommand,
},
Config {
#[command(subcommand)]
command: ConfigCommand,
},
Monitor {
#[arg(long)]
all: bool,
},
Review {
#[arg(value_name = "REQUEST_ID")]
request: Option<RequestId>,
#[arg(long)]
all: bool,
#[arg(long, value_name = "NAME")]
reviewer: Option<crate::types::ReviewerId>,
},
Server {
#[command(subcommand)]
command: ServerCommand,
},
}
#[derive(Debug, Args)]
#[group(multiple = false)]
pub struct SelectionArgs {
artifact: Option<Selector>,
#[arg(long, value_name = "ID")]
eval: Option<crate::types::EvalId>,
#[arg(long, value_name = "CSV", value_delimiter = ',', num_args = 1)]
evals: Option<Vec<crate::types::EvalId>>,
#[arg(long, value_name = "CSV", value_delimiter = ',', num_args = 1)]
artifacts: Option<Vec<crate::types::ArtifactName>>,
#[arg(long, value_name = "PATH")]
evals_file: Option<PathBuf>,
#[arg(long, value_name = "PATH")]
artifacts_file: Option<PathBuf>,
#[arg(long)]
all: bool,
}
impl SelectionArgs {
fn resolve(self) -> Result<Selection, String> {
if let Some(selector) = self.artifact {
Ok(match selector {
Selector::Artifact(id) => Selection::Artifact { artifact_id: id },
Selector::Eval(id) => Selection::Eval { eval_id: id },
})
} else if let Some(eval_id) = self.eval {
Ok(Selection::Eval { eval_id })
} else if let Some(ids) = self.evals {
Ok(Selection::Evals { eval_ids: ids })
} else if let Some(ids) = self.artifacts {
Ok(Selection::Artifacts { artifact_ids: ids })
} else if let Some(path) = self.evals_file {
Ok(Selection::Evals {
eval_ids: selection_file::<crate::types::EvalId>(&path)?,
})
} else if let Some(path) = self.artifacts_file {
Ok(Selection::Artifacts {
artifact_ids: selection_file::<crate::types::ArtifactName>(&path)?,
})
} else {
Ok(Selection::All)
}
}
}
#[derive(Debug, Args)]
pub struct PolicyArgs {
#[arg(long, value_name = "NAME")]
profile: Option<crate::config::ProfileVariantName>,
#[arg(long)]
recursive: bool,
#[arg(long)]
force: bool,
#[arg(long)]
ignore_gates: bool,
#[arg(long, value_name = "N", value_parser = clap::value_parser!(u32).range(1..))]
fingerprint_jobs: Option<u32>,
}
impl PolicyArgs {
fn options(self) -> crate::project::VerifyOptions {
crate::project::VerifyOptions {
profile: self.profile.map(ProfileSelection::Named),
recursive: self.recursive,
force: self.force,
ignore_gates: self.ignore_gates.then_some(true),
fingerprint_jobs: self.fingerprint_jobs.map(|jobs| jobs as usize),
..Default::default()
}
}
}
#[derive(Debug, Subcommand)]
pub enum AuthProvider {
Codex,
}
#[derive(Debug, Subcommand)]
pub enum ModelProvider {
Openai,
Anthropic,
Codex,
}
impl ModelProvider {
fn backend(self) -> crate::config::Backend {
match self {
Self::Openai => crate::config::Backend::Openai,
Self::Anthropic => crate::config::Backend::Anthropic,
Self::Codex => crate::config::Backend::Codex,
}
}
}
#[derive(Debug, Subcommand)]
pub enum ConfigCommand {
Check,
Graph {
artifact: Option<crate::types::ArtifactName>,
},
}
#[derive(Debug, Subcommand)]
pub enum RunCommand {
List {
#[arg(long, conflicts_with = "all")]
repo_only: bool,
#[arg(long)]
all: bool,
#[arg(long, value_name = "N", default_value_t = DEFAULT_RUN_LIST_LIMIT)]
limit: u32,
#[arg(long, value_name = "N", default_value = "0")]
offset: u32,
},
Show {
run_id: RunId,
#[arg(long)]
wait: bool,
#[arg(
long,
value_name = "MS",
requires = "wait",
value_parser = timeout_duration
)]
timeout_ms: Option<std::time::Duration>,
},
}
#[derive(Debug, Subcommand)]
pub enum CacheCommand {
List {
#[arg(long)]
history: bool,
},
Show {
key: ReuseKey,
#[arg(long)]
history: bool,
},
Rm { key: ReuseKey },
}
#[derive(Debug, Subcommand)]
pub enum ToolsCommand {
Check(crate::diagnostics::ToolCheckOptions),
#[command(flatten)]
Builtin(artifactize_tools::cli::Tool),
}
fn cancellation_listener() -> Result<
(
tokio_util::sync::CancellationToken,
tokio::task::JoinHandle<()>,
),
String,
> {
let cancellation = tokio_util::sync::CancellationToken::new();
let stop = crate::platform::stop_requested().map_err(|e| e.to_string())?;
let token = cancellation.clone();
let listener = tokio::spawn(async move {
stop.await;
token.cancel();
});
Ok((cancellation, listener))
}
fn failure(message: &str, json: bool) -> ExitCode {
if json {
let _ = writeln!(io::stdout().lock(), "{}", json!({ "error": message }));
} else {
let _ = writeln!(io::stderr().lock(), "{message}");
}
ExitCode::from(2)
}
fn repeated_global(options: &[&str]) -> Option<&'static str> {
["--repo", "--state-dir", "--json"]
.into_iter()
.find(|name| {
options
.iter()
.filter(|arg| {
arg.strip_prefix(name)
.is_some_and(|rest| rest.is_empty() || rest.starts_with('='))
})
.count()
> 1
})
}
pub type Screen = std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), String>>>>;
#[derive(Clone, Copy)]
pub struct Screens {
pub monitor: fn(PathBuf, Option<PathBuf>, tokio_util::sync::CancellationToken) -> Screen,
pub review: fn(
PathBuf,
Option<PathBuf>,
crate::types::ReviewerId,
Option<crate::types::RequestId>,
tokio_util::sync::CancellationToken,
) -> Screen,
}
pub fn run(screens: Screens) -> ExitCode {
let args: Vec<_> = std::env::args_os().collect();
let has_args = args.len() > 1;
let parsed = Cli::try_parse_from(&args);
let tool_args = match &parsed {
Ok(Cli {
command:
Some(Command::Tools {
command: ToolsCommand::Builtin(artifactize_tools::cli::Tool::Help { args }),
}),
..
}) => args.len(),
_ => 0,
};
let options: Vec<_> = args
.iter()
.take(args.len() - tool_args)
.skip(1)
.take_while(|arg| *arg != "--")
.filter_map(|arg| arg.to_str())
.collect();
let json = options.contains(&"--json");
if let Some(name) = repeated_global(&options) {
return failure(&format!("{name} cannot be used multiple times."), json);
}
let cli = match parsed {
Ok(cli) => cli,
Err(error) if json && error.use_stderr() => {
let message = error.to_string();
let message = if error.kind() == ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand {
"A subcommand is required; see --help."
} else {
let usage = message.split("\n\n").next().unwrap_or_default();
usage.trim_start_matches("error: ")
};
return failure(message, true);
}
Err(error) => {
return if error.print().is_ok() && !error.use_stderr() {
ExitCode::SUCCESS
} else {
ExitCode::from(2)
};
}
};
let worker = std::thread::Builder::new()
.name("artifactize".into())
.stack_size(STACK_BYTES)
.spawn(move || {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.thread_stack_size(STACK_BYTES)
.build()
.map_err(|error| error.to_string())?;
runtime.block_on(Box::pin(async {
let result = dispatch::execute(cli, screens).await;
crate::changes::drain().await;
result
}))
});
let result = match worker {
Ok(worker) => worker
.join()
.unwrap_or_else(|panic| std::panic::resume_unwind(panic)),
Err(error) => Err(error.to_string()),
};
match result {
Ok(code) => match crate::platform::pause_after_help(has_args) {
Ok(()) => ExitCode::from(code),
Err(error) => failure(&error.to_string(), json),
},
Err(error) => failure(&error, json),
}
}
const STACK_BYTES: usize = 8 * 1024 * 1024;
fn timeout_duration(text: &str) -> Result<std::time::Duration, String> {
let millis: u64 = text
.parse()
.map_err(|_| "timeout must be milliseconds".to_owned())?;
if !(1..=crate::config::validation::MAX_TIMEOUT_MS).contains(&millis) {
return Err("timeoutMs must be between 1 and 2147483647".into());
}
Ok(std::time::Duration::from_millis(millis))
}
#[derive(Debug, Clone)]
enum Selector {
Artifact(crate::types::ArtifactName),
Eval(crate::types::EvalId),
}
impl std::str::FromStr for Selector {
type Err = String;
fn from_str(text: &str) -> Result<Self, Self::Err> {
if text.contains('/') {
text.parse().map(Self::Eval)
} else {
text.parse().map(Self::Artifact)
}
}
}
fn selection_file<T>(path: &std::path::Path) -> Result<Vec<T>, String>
where
T: std::str::FromStr<Err = String>,
{
read_selection_file(path)?
.into_iter()
.map(|text| text.parse::<T>())
.collect()
}