Skip to main content

artifactize_core/
cli.rs

1//! Command-line parsing, projections, and exit codes.
2
3mod 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
42/// Keep default Run history output readable; --limit/--offset expose older records.
43const DEFAULT_RUN_LIST_LIMIT: u32 = 50;
44
45/// Shells conventionally report an interrupt as 130; use the same status on every platform.
46const INTERRUPTED_EXIT: u8 = 130;
47
48#[derive(Debug, Parser)]
49#[command(
50    name = "artifactize",
51    // Help names the command, not the file run; on Windows that is artifactize.exe.
52    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    /// Repository input path.
58    #[arg(long, global = true, value_name = "PATH")]
59    pub repo: Option<PathBuf>,
60
61    /// State database and Run output directory.
62    #[arg(long, global = true, value_name = "PATH")]
63    pub state_dir: Option<PathBuf>,
64
65    /// Use JSON for command results.
66    #[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    /// Serve bounded verification and inspection tools over MCP stdio.
76    Mcp,
77    #[command(name = "__verify", hide = true)]
78    VerifyWorker { run_id: RunId },
79    #[command(name = "__execute", hide = true)]
80    VerifyExecution { run_id: RunId },
81    /// Run scoped tools or check declared review tools.
82    #[command(disable_help_subcommand = true, version)]
83    Tools {
84        #[command(subcommand)]
85        command: ToolsCommand,
86    },
87    /// Sign in with Codex (ChatGPT) in the system browser and save artifactize's tokens.
88    Login {
89        #[command(subcommand)]
90        provider: AuthProvider,
91    },
92    /// Revoke and remove artifactize's own Codex tokens.
93    Logout {
94        #[command(subcommand)]
95        provider: AuthProvider,
96    },
97    /// Sign in to, sign out of, or check a shared remote review store.
98    Remote {
99        #[command(subcommand)]
100        command: remote::RemoteCommand,
101    },
102    /// Check local readiness without provider calls or creating a Run.
103    Doctor,
104    /// Remove finished, unowned Run output; preserve database audit and repositories.
105    Prune {
106        /// Minimum age since Run completion (for example 7d, 24h, 30m).
107        #[arg(long, value_name = "DURATION", value_parser = crate::store::prune::parse_duration)]
108        older_than: Option<std::time::Duration>,
109        /// Report eligible paths without deleting anything.
110        #[arg(long)]
111        dry_run: bool,
112    },
113    /// List the models a backend offers to your key or account.
114    Models {
115        #[command(subcommand)]
116        provider: ModelProvider,
117    },
118    /// Submit selected evals; use --wait to observe the validation outcome.
119    #[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        /// Opt-in maximum concurrent evals, including fingerprint waiters; otherwise unlimited.
128        #[arg(
129            long,
130            value_name = "N",
131            value_parser = clap::value_parser!(u32).range(1..)
132        )]
133        jobs: Option<u32>,
134        /// Limit executor starts in this Run; cache hits and waiters are free.
135        #[arg(long, value_name = "N")]
136        max_executions: Option<u64>,
137        /// Observe until the Run ends; interrupting stops watching, not execution.
138        #[arg(long)]
139        wait: bool,
140        /// Retry-safe caller attempt key (1–128 ASCII letters/digits/._:-).
141        #[arg(long, value_name = "KEY")]
142        request_key: Option<crate::types::RequestKey>,
143        /// Only reuse results for these kinds (comma-separated); an eval with nothing to reuse is
144        /// not executed.
145        #[arg(long, value_name = "KINDS", value_enum, value_delimiter = ',')]
146        reuse_only: Vec<ProfileKind>,
147        /// Start reviews before unresolved dependency gates; cancel blocked work.
148        #[arg(long)]
149        speculate: bool,
150        /// Run past pending sign-offs and finish with SIGNOFFS_PENDING (exit 5).
151        #[arg(long)]
152        defer_human: bool,
153    },
154    /// Inspect current validation, saved Run audit, and what verify would do.
155    Status {
156        #[command(flatten)]
157        selection: SelectionArgs,
158        #[command(flatten)]
159        policy: PolicyArgs,
160    },
161    /// Moved to "artifactize config graph".
162    #[command(hide = true, disable_help_flag = true)]
163    Graph {
164        // Any former graph arguments, including --help, get the same hint.
165        #[arg(trailing_var_arg = true, allow_hyphen_values = true, hide = true)]
166        args: Vec<String>,
167    },
168    /// Read recorded Runs without discovering or executing project code.
169    Run {
170        #[command(subcommand)]
171        command: RunCommand,
172    },
173    /// Read saved requests or claim, inspect, and submit Human reviews.
174    Request {
175        #[command(subcommand)]
176        command: RequestCommand,
177    },
178    /// Read a saved Agent review conversation or continue it with a follow-up.
179    Session {
180        #[command(subcommand)]
181        command: SessionCommand,
182    },
183    /// Inspect or maintain reusable results by reuse key without a repository.
184    Cache {
185        #[command(subcommand)]
186        command: CacheCommand,
187    },
188    /// Inspect static folder declarations without executing hooks or reviews.
189    Config {
190        #[command(subcommand)]
191        command: ConfigCommand,
192    },
193    /// Browse Runs; starts on ALL regardless of cwd. Paths select only the initial repository.
194    Monitor {
195        /// Initial repository/worktree; all scopes remain accessible in the UI.
196        #[arg(value_name = "PATH", conflicts_with = "repo")]
197        path: Option<PathBuf>,
198        /// Initially select ALL repositories (the default).
199        #[arg(long, conflicts_with_all = ["path", "repo"])]
200        all: bool,
201    },
202    /// Search saved requests and claim, run tools for and submit Human reviews in a terminal
203    /// UI; the search starts on the waiting Human reviews of every repository.
204    Review {
205        /// Open this request instead of the list.
206        #[arg(value_name = "REQUEST_ID")]
207        request: Option<RequestId>,
208        /// Search every repository (the default); with --repo the search starts on that
209        /// repository's term instead.
210        #[arg(long)]
211        all: bool,
212        /// Reviewer name (defaults to USER).
213        #[arg(long, value_name = "NAME")]
214        reviewer: Option<crate::types::ReviewerId>,
215    },
216    /// Serve or administer a shared remote review store (review-store.sqlite).
217    Server {
218        #[command(subcommand)]
219        command: ServerCommand,
220    },
221}
222
223#[derive(Debug, Args)]
224#[group(multiple = false)]
225pub struct SelectionArgs {
226    /// Select one Artifact.
227    artifact: Option<Selector>,
228    /// Select one qualified Eval ID.
229    #[arg(long, value_name = "ID")]
230    eval: Option<crate::types::EvalId>,
231    /// Select comma-separated qualified Eval IDs.
232    #[arg(long, value_name = "CSV", value_delimiter = ',', num_args = 1)]
233    evals: Option<Vec<crate::types::EvalId>>,
234    /// Select comma-separated Artifact names.
235    #[arg(long, value_name = "CSV", value_delimiter = ',', num_args = 1)]
236    artifacts: Option<Vec<crate::types::ArtifactName>>,
237    /// Read Eval IDs from a JSON array or one ID per line.
238    #[arg(long, value_name = "PATH")]
239    evals_file: Option<PathBuf>,
240    /// Read Artifact names from a JSON array or one ID per line.
241    #[arg(long, value_name = "PATH")]
242    artifacts_file: Option<PathBuf>,
243    /// Select every Artifact.
244    #[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    /// Use a declared profile variant for every included Eval.
278    #[arg(long, value_name = "NAME")]
279    profile: Option<crate::config::ProfileVariantName>,
280    /// Include all Evals in the required dependency scope, including cycle peers.
281    #[arg(long)]
282    recursive: bool,
283    /// Force explicitly selected Evals only; dependency gates still apply.
284    #[arg(long)]
285    force: bool,
286    /// Fingerprints to compute at once (default: the available CPUs).
287    #[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    /// The ChatGPT/Codex account that the codex backend uses.
306    Codex,
307}
308
309#[derive(Debug, Subcommand)]
310pub enum ModelProvider {
311    /// OpenAI API models (OPENAI_API_KEY).
312    Openai,
313    /// Anthropic API models (ANTHROPIC_API_KEY).
314    Anthropic,
315    /// Codex models your ChatGPT account can use (artifactize login codex).
316    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    /// Validate declarations and unique Artifact/Eval ids.
332    Check,
333    /// Inspect full static definitions, relations, and cycles.
334    Graph {
335        /// Select one Artifact and its required closure; defaults to all.
336        artifact: Option<crate::types::ArtifactName>,
337    },
338}
339
340#[derive(Debug, Subcommand)]
341pub enum RunCommand {
342    /// Request cancellation; committed results are kept and cleanup settles asynchronously.
343    Cancel { run_id: RunId },
344    /// List saved Runs, newest first; defaults to the current repository.
345    List {
346        /// Explicitly restrict results to --repo (the default).
347        #[arg(long, conflicts_with = "all")]
348        repo_only: bool,
349        /// List Runs from every repository in the shared state.
350        #[arg(long)]
351        all: bool,
352        /// Maximum number of Runs to return.
353        #[arg(long, value_name = "N", default_value_t = DEFAULT_RUN_LIST_LIMIT)]
354        limit: u32,
355        /// Skip this many Runs before returning results.
356        #[arg(long, value_name = "N", default_value = "0")]
357        offset: u32,
358    },
359    /// Read the full saved audit as JSON, even without --json.
360    Show {
361        run_id: RunId,
362        /// Follow a RUNNING Run until it finishes and exit with its outcome code.
363        #[arg(long)]
364        wait: bool,
365        /// Wait timeout; never cancels the Run (default 600000).
366        #[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 the latest record of each reuse key, the one verify reuses.
379    List {
380        /// List every record of every key, latest first per key.
381        #[arg(long)]
382        history: bool,
383    },
384    /// Read a key's latest record (result, profile, options, provenance) as JSON.
385    Show {
386        key: ReuseKey,
387        /// Print every record of the key as a JSON array, latest first.
388        #[arg(long)]
389        history: bool,
390    },
391    /// Remove every record of an unused key, preserving saved Runs and executions.
392    Rm { key: ReuseKey },
393}
394
395#[derive(Debug, Subcommand)]
396pub enum ToolsCommand {
397    /// Static preflight by default; --execute opts in to one tool call.
398    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
429/// Global options may precede or follow the subcommand, but only once in total.
430fn 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
445/// What a screen runs until the reviewer quits; screens need no `Send`, the command runs on a
446/// single thread.
447pub type Screen = std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), String>>>>;
448
449/// The terminal screens `artifactize monitor` and `artifactize review` open. They live in
450/// `artifactize-tui`, which the binary hands to [`run`], so this library draws nothing.
451#[derive(Clone, Copy)]
452pub struct Screens {
453    /// The Monitor over the state directory, on one repository or on every one.
454    pub monitor: fn(PathBuf, Option<PathBuf>, tokio_util::sync::CancellationToken) -> Screen,
455    /// The Human review list, or one request when given.
456    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    // The help tool's trailing arguments belong to the target program, even if they
470    // happen to share names with artifactize's global flags.
471    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    // Run on a thread whose stack is the same size everywhere: Windows gives the main thread
524    // only 1 MiB, which deep review futures outgrow in debug builds.
525    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
555/// The stack of the thread commands run on, and of blocking helper threads: 8 MiB, the
556/// usual Linux and macOS main-thread size, so every system runs the same futures.
557const STACK_BYTES: usize = 8 * 1024 * 1024;
558
559/// Parse protocol milliseconds exactly once, preserving the public signed 32-bit bound.
560fn 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/// The positional selection intentionally accepts either an Artifact or qualified Eval.
571#[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}