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