1mod cache;
4#[cfg(test)]
5mod defaults_tests;
6mod dispatch;
7#[cfg(test)]
8mod identity_tests;
9mod interactive;
10mod maintenance;
11mod remote;
12mod render;
13mod run;
14mod workflow;
15use dispatch::Context;
16use render::{outcome_code, print_json};
17mod request;
18mod server;
19mod session;
20use request::RequestCommand;
21use server::ServerCommand;
22use session::SessionCommand;
23
24use std::io::{self, Write};
25use std::path::PathBuf;
26use std::process::ExitCode;
27
28use clap::{Args, Parser, Subcommand, error::ErrorKind};
29use serde_json::json;
30
31use crate::{
32 config::ProfileKind,
33 project::selection::{ProfileSelection, Selection, read_selection_file},
34 types::{RequestId, ReuseKey, RunId},
35};
36
37const DEFAULT_RUN_LIST_LIMIT: u32 = 50;
39
40#[derive(Debug, Parser)]
41#[command(
42 name = "artifactize",
43 bin_name = "artifactize",
45 version,
46 about = "The AI-native collaboration layer for one-of-a-kind teammates and their agents."
47)]
48pub struct Cli {
49 #[arg(long, global = true, value_name = "PATH")]
51 pub repo: Option<PathBuf>,
52
53 #[arg(long, global = true, value_name = "PATH")]
55 pub state_dir: Option<PathBuf>,
56
57 #[arg(long, global = true)]
59 pub json: bool,
60
61 #[command(subcommand)]
62 pub command: Option<Command>,
63}
64
65#[derive(Debug, Subcommand)]
66pub enum Command {
67 #[command(disable_help_subcommand = true, version)]
69 Tools {
70 #[command(subcommand)]
71 command: ToolsCommand,
72 },
73 Login {
75 #[command(subcommand)]
76 provider: AuthProvider,
77 },
78 Logout {
80 #[command(subcommand)]
81 provider: AuthProvider,
82 },
83 Remote {
85 #[command(subcommand)]
86 command: remote::RemoteCommand,
87 },
88 Doctor,
90 Prune {
92 #[arg(long, value_name = "DURATION", value_parser = crate::store::prune::parse_duration)]
94 older_than: Option<std::time::Duration>,
95 #[arg(long)]
97 dry_run: bool,
98 },
99 Models {
101 #[command(subcommand)]
102 provider: ModelProvider,
103 },
104 #[command(group(clap::ArgGroup::new("required_selection")
106 .args(["artifact", "eval", "evals", "artifacts", "evals_file", "artifacts_file", "all"])
107 .required(true)))]
108 Verify {
109 #[command(flatten)]
110 selection: SelectionArgs,
111 #[command(flatten)]
112 policy: PolicyArgs,
113 #[arg(
115 long,
116 value_name = "N",
117 default_value_t = crate::project::DEFAULT_JOBS as u32,
118 value_parser = clap::value_parser!(u32).range(1..)
119 )]
120 jobs: u32,
121 #[arg(long, value_name = "N")]
123 max_executions: Option<u64>,
124 #[arg(
126 long,
127 value_name = "MS",
128 value_parser = timeout_duration
129 )]
130 timeout_ms: Option<std::time::Duration>,
131 #[arg(long, value_name = "KINDS", value_enum, value_delimiter = ',')]
134 reuse_only: Vec<ProfileKind>,
135 },
136 Status {
138 #[command(flatten)]
139 selection: SelectionArgs,
140 #[command(flatten)]
141 policy: PolicyArgs,
142 },
143 #[command(hide = true, disable_help_flag = true)]
145 Graph {
146 #[arg(trailing_var_arg = true, allow_hyphen_values = true, hide = true)]
148 args: Vec<String>,
149 },
150 Run {
152 #[command(subcommand)]
153 command: RunCommand,
154 },
155 Request {
157 #[command(subcommand)]
158 command: RequestCommand,
159 },
160 Session {
162 #[command(subcommand)]
163 command: SessionCommand,
164 },
165 Cache {
167 #[command(subcommand)]
168 command: CacheCommand,
169 },
170 Config {
172 #[command(subcommand)]
173 command: ConfigCommand,
174 },
175 Monitor {
178 #[arg(long)]
180 all: bool,
181 },
182 Review {
185 #[arg(value_name = "REQUEST_ID")]
187 request: Option<RequestId>,
188 #[arg(long)]
191 all: bool,
192 #[arg(long, value_name = "NAME")]
194 reviewer: Option<crate::types::ReviewerId>,
195 },
196 Server {
198 #[command(subcommand)]
199 command: ServerCommand,
200 },
201}
202
203#[derive(Debug, Args)]
204#[group(multiple = false)]
205pub struct SelectionArgs {
206 artifact: Option<Selector>,
208 #[arg(long, value_name = "ID")]
210 eval: Option<crate::types::EvalId>,
211 #[arg(long, value_name = "CSV", value_delimiter = ',', num_args = 1)]
213 evals: Option<Vec<crate::types::EvalId>>,
214 #[arg(long, value_name = "CSV", value_delimiter = ',', num_args = 1)]
216 artifacts: Option<Vec<crate::types::ArtifactName>>,
217 #[arg(long, value_name = "PATH")]
219 evals_file: Option<PathBuf>,
220 #[arg(long, value_name = "PATH")]
222 artifacts_file: Option<PathBuf>,
223 #[arg(long)]
225 all: bool,
226}
227
228impl SelectionArgs {
229 fn resolve(self) -> Result<Selection, String> {
230 if let Some(selector) = self.artifact {
231 Ok(match selector {
232 Selector::Artifact(id) => Selection::Artifact { artifact_id: id },
233 Selector::Eval(id) => Selection::Eval { eval_id: id },
234 })
235 } else if let Some(eval_id) = self.eval {
236 Ok(Selection::Eval { eval_id })
237 } else if let Some(ids) = self.evals {
238 Ok(Selection::Evals { eval_ids: ids })
239 } else if let Some(ids) = self.artifacts {
240 Ok(Selection::Artifacts { artifact_ids: ids })
241 } else if let Some(path) = self.evals_file {
242 Ok(Selection::Evals {
243 eval_ids: selection_file::<crate::types::EvalId>(&path)?,
244 })
245 } else if let Some(path) = self.artifacts_file {
246 Ok(Selection::Artifacts {
247 artifact_ids: selection_file::<crate::types::ArtifactName>(&path)?,
248 })
249 } else {
250 Ok(Selection::All)
251 }
252 }
253}
254
255#[derive(Debug, Args)]
256pub struct PolicyArgs {
257 #[arg(long, value_name = "NAME")]
259 profile: Option<crate::config::ProfileVariantName>,
260 #[arg(long)]
262 recursive: bool,
263 #[arg(long)]
265 force: bool,
266 #[arg(long)]
268 ignore_gates: bool,
269 #[arg(long, value_name = "N", value_parser = clap::value_parser!(u32).range(1..))]
271 fingerprint_jobs: Option<u32>,
272}
273
274impl PolicyArgs {
275 fn options(self) -> crate::project::VerifyOptions {
276 crate::project::VerifyOptions {
277 profile: self.profile.map(ProfileSelection::Named),
278 recursive: self.recursive,
279 force: self.force,
280 ignore_gates: self.ignore_gates.then_some(true),
281 fingerprint_jobs: self.fingerprint_jobs.map(|jobs| jobs as usize),
282 ..Default::default()
283 }
284 }
285}
286
287#[derive(Debug, Subcommand)]
288pub enum AuthProvider {
289 Codex,
291}
292
293#[derive(Debug, Subcommand)]
294pub enum ModelProvider {
295 Openai,
297 Anthropic,
299 Codex,
301}
302
303impl ModelProvider {
304 fn backend(self) -> crate::config::Backend {
305 match self {
306 Self::Openai => crate::config::Backend::Openai,
307 Self::Anthropic => crate::config::Backend::Anthropic,
308 Self::Codex => crate::config::Backend::Codex,
309 }
310 }
311}
312
313#[derive(Debug, Subcommand)]
314pub enum ConfigCommand {
315 Check,
317 Graph {
319 artifact: Option<crate::types::ArtifactName>,
321 },
322}
323
324#[derive(Debug, Subcommand)]
325pub enum RunCommand {
326 List {
328 #[arg(long, conflicts_with = "all")]
330 repo_only: bool,
331 #[arg(long)]
333 all: bool,
334 #[arg(long, value_name = "N", default_value_t = DEFAULT_RUN_LIST_LIMIT)]
336 limit: u32,
337 #[arg(long, value_name = "N", default_value = "0")]
339 offset: u32,
340 },
341 Show {
343 run_id: RunId,
344 #[arg(long)]
346 wait: bool,
347 #[arg(
349 long,
350 value_name = "MS",
351 requires = "wait",
352 value_parser = timeout_duration
353 )]
354 timeout_ms: Option<std::time::Duration>,
355 },
356}
357
358#[derive(Debug, Subcommand)]
359pub enum CacheCommand {
360 List {
362 #[arg(long)]
364 history: bool,
365 },
366 Show {
368 key: ReuseKey,
369 #[arg(long)]
371 history: bool,
372 },
373 Rm { key: ReuseKey },
375}
376
377#[derive(Debug, Subcommand)]
378pub enum ToolsCommand {
379 Check(crate::diagnostics::ToolCheckOptions),
381 #[command(flatten)]
382 Builtin(artifactize_tools::cli::Tool),
383}
384
385fn cancellation_listener() -> Result<
386 (
387 tokio_util::sync::CancellationToken,
388 tokio::task::JoinHandle<()>,
389 ),
390 String,
391> {
392 let cancellation = tokio_util::sync::CancellationToken::new();
393 let stop = crate::platform::stop_requested().map_err(|e| e.to_string())?;
394 let token = cancellation.clone();
395 let listener = tokio::spawn(async move {
396 stop.await;
397 token.cancel();
398 });
399 Ok((cancellation, listener))
400}
401
402fn failure(message: &str, json: bool) -> ExitCode {
403 if json {
404 let _ = writeln!(io::stdout().lock(), "{}", json!({ "error": message }));
405 } else {
406 let _ = writeln!(io::stderr().lock(), "{message}");
407 }
408 ExitCode::from(2)
409}
410
411fn repeated_global(options: &[&str]) -> Option<&'static str> {
413 ["--repo", "--state-dir", "--json"]
414 .into_iter()
415 .find(|name| {
416 options
417 .iter()
418 .filter(|arg| {
419 arg.strip_prefix(name)
420 .is_some_and(|rest| rest.is_empty() || rest.starts_with('='))
421 })
422 .count()
423 > 1
424 })
425}
426
427pub type Screen = std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), String>>>>;
430
431#[derive(Clone, Copy)]
434pub struct Screens {
435 pub monitor: fn(PathBuf, Option<PathBuf>, tokio_util::sync::CancellationToken) -> Screen,
437 pub review: fn(
439 PathBuf,
440 Option<PathBuf>,
441 crate::types::ReviewerId,
442 Option<crate::types::RequestId>,
443 tokio_util::sync::CancellationToken,
444 ) -> Screen,
445}
446
447pub fn run(screens: Screens) -> ExitCode {
448 let args: Vec<_> = std::env::args_os().collect();
449 let has_args = args.len() > 1;
450 let parsed = Cli::try_parse_from(&args);
451 let tool_args = match &parsed {
454 Ok(Cli {
455 command:
456 Some(Command::Tools {
457 command: ToolsCommand::Builtin(artifactize_tools::cli::Tool::Help { args }),
458 }),
459 ..
460 }) => args.len(),
461 _ => 0,
462 };
463 let options: Vec<_> = args
464 .iter()
465 .take(args.len() - tool_args)
466 .skip(1)
467 .take_while(|arg| *arg != "--")
468 .filter_map(|arg| arg.to_str())
469 .collect();
470 let json = options.contains(&"--json");
471 if let Some(name) = repeated_global(&options) {
472 return failure(&format!("{name} cannot be used multiple times."), json);
473 }
474 let cli = match parsed {
475 Ok(cli) => cli,
476 Err(error) if json && error.use_stderr() => {
477 let message = error.to_string();
478 let message = if error.kind() == ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand {
479 "A subcommand is required; see --help."
480 } else {
481 let usage = message.split("\n\n").next().unwrap_or_default();
482 usage.trim_start_matches("error: ")
483 };
484 return failure(message, true);
485 }
486 Err(error) => {
487 return if error.print().is_ok() && !error.use_stderr() {
488 ExitCode::SUCCESS
489 } else {
490 ExitCode::from(2)
491 };
492 }
493 };
494 let worker = std::thread::Builder::new()
497 .name("artifactize".into())
498 .stack_size(STACK_BYTES)
499 .spawn(move || {
500 let runtime = tokio::runtime::Builder::new_current_thread()
501 .enable_all()
502 .thread_stack_size(STACK_BYTES)
503 .build()
504 .map_err(|error| error.to_string())?;
505 runtime.block_on(Box::pin(async {
506 let result = dispatch::execute(cli, screens).await;
507 crate::changes::drain().await;
508 result
509 }))
510 });
511 let result = match worker {
512 Ok(worker) => worker
513 .join()
514 .unwrap_or_else(|panic| std::panic::resume_unwind(panic)),
515 Err(error) => Err(error.to_string()),
516 };
517 match result {
518 Ok(code) => match crate::platform::pause_after_help(has_args) {
519 Ok(()) => ExitCode::from(code),
520 Err(error) => failure(&error.to_string(), json),
521 },
522 Err(error) => failure(&error, json),
523 }
524}
525
526const STACK_BYTES: usize = 8 * 1024 * 1024;
529
530fn timeout_duration(text: &str) -> Result<std::time::Duration, String> {
532 let millis: u64 = text
533 .parse()
534 .map_err(|_| "timeout must be milliseconds".to_owned())?;
535 if !(1..=crate::config::validation::MAX_TIMEOUT_MS).contains(&millis) {
536 return Err("timeoutMs must be between 1 and 2147483647".into());
537 }
538 Ok(std::time::Duration::from_millis(millis))
539}
540
541#[derive(Debug, Clone)]
543enum Selector {
544 Artifact(crate::types::ArtifactName),
545 Eval(crate::types::EvalId),
546}
547impl std::str::FromStr for Selector {
548 type Err = String;
549 fn from_str(text: &str) -> Result<Self, Self::Err> {
550 if text.contains('/') {
551 text.parse().map(Self::Eval)
552 } else {
553 text.parse().map(Self::Artifact)
554 }
555 }
556}
557fn selection_file<T>(path: &std::path::Path) -> Result<Vec<T>, String>
558where
559 T: std::str::FromStr<Err = String>,
560{
561 read_selection_file(path)?
562 .into_iter()
563 .map(|text| text.parse::<T>())
564 .collect()
565}