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;
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
37/// Keep default Run history output readable; --limit/--offset expose older records.
38const DEFAULT_RUN_LIST_LIMIT: u32 = 50;
39
40#[derive(Debug, Parser)]
41#[command(
42    name = "artifactize",
43    // Help names the command, not the file run; on Windows that is artifactize.exe.
44    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    /// Repository input path.
50    #[arg(long, global = true, value_name = "PATH")]
51    pub repo: Option<PathBuf>,
52
53    /// State database and Run output directory.
54    #[arg(long, global = true, value_name = "PATH")]
55    pub state_dir: Option<PathBuf>,
56
57    /// Use JSON for command results.
58    #[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    /// Run scoped tools or check declared review tools.
68    #[command(disable_help_subcommand = true, version)]
69    Tools {
70        #[command(subcommand)]
71        command: ToolsCommand,
72    },
73    /// Sign in with Codex (ChatGPT) in the system browser and save artifactize's tokens.
74    Login {
75        #[command(subcommand)]
76        provider: AuthProvider,
77    },
78    /// Revoke and remove artifactize's own Codex tokens.
79    Logout {
80        #[command(subcommand)]
81        provider: AuthProvider,
82    },
83    /// Sign in to, sign out of, or check a shared remote review store.
84    Remote {
85        #[command(subcommand)]
86        command: remote::RemoteCommand,
87    },
88    /// Check local readiness without provider calls or creating a Run.
89    Doctor,
90    /// Remove finished, unowned Run output; preserve database audit and repositories.
91    Prune {
92        /// Minimum age since Run completion (for example 7d, 24h, 30m).
93        #[arg(long, value_name = "DURATION", value_parser = crate::store::prune::parse_duration)]
94        older_than: Option<std::time::Duration>,
95        /// Report eligible paths without deleting anything.
96        #[arg(long)]
97        dry_run: bool,
98    },
99    /// List the models a backend offers to your key or account.
100    Models {
101        #[command(subcommand)]
102        provider: ModelProvider,
103    },
104    /// Execute selected evals in the foreground.
105    #[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        /// Maximum concurrent evals, including fingerprint waiters.
114        #[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        /// Limit executor starts in this Run; cache hits and waiters are free.
122        #[arg(long, value_name = "N")]
123        max_executions: Option<u64>,
124        /// How long to wait for Human results; does not cancel pending requests (default 600000).
125        #[arg(
126            long,
127            value_name = "MS",
128            value_parser = timeout_duration
129        )]
130        timeout_ms: Option<std::time::Duration>,
131        /// Only reuse results for these kinds (comma-separated); an eval with nothing to reuse is
132        /// not executed.
133        #[arg(long, value_name = "KINDS", value_enum, value_delimiter = ',')]
134        reuse_only: Vec<ProfileKind>,
135    },
136    /// Inspect current validation, saved Run audit, and what verify would do.
137    Status {
138        #[command(flatten)]
139        selection: SelectionArgs,
140        #[command(flatten)]
141        policy: PolicyArgs,
142    },
143    /// Moved to "artifactize config graph".
144    #[command(hide = true, disable_help_flag = true)]
145    Graph {
146        // Any former graph arguments, including --help, get the same hint.
147        #[arg(trailing_var_arg = true, allow_hyphen_values = true, hide = true)]
148        args: Vec<String>,
149    },
150    /// Read recorded Runs without discovering or executing project code.
151    Run {
152        #[command(subcommand)]
153        command: RunCommand,
154    },
155    /// Read saved requests or claim, inspect, and submit Human reviews.
156    Request {
157        #[command(subcommand)]
158        command: RequestCommand,
159    },
160    /// Read a saved Agent review conversation or continue it with a follow-up.
161    Session {
162        #[command(subcommand)]
163        command: SessionCommand,
164    },
165    /// Inspect or maintain reusable results by reuse key without a repository.
166    Cache {
167        #[command(subcommand)]
168        command: CacheCommand,
169    },
170    /// Inspect static folder declarations without executing hooks or reviews.
171    Config {
172        #[command(subcommand)]
173        command: ConfigCommand,
174    },
175    /// Browse repositories/worktrees, Runs and eval details; initial selection is the current
176    /// worktree.
177    Monitor {
178        /// Initially select ALL repositories; every scope remains accessible in the UI.
179        #[arg(long)]
180        all: bool,
181    },
182    /// Search saved requests and claim, run tools for and submit Human reviews in a terminal
183    /// UI; the search starts on the waiting Human reviews of every repository.
184    Review {
185        /// Open this request instead of the list.
186        #[arg(value_name = "REQUEST_ID")]
187        request: Option<RequestId>,
188        /// Search every repository (the default); with --repo the search starts on that
189        /// repository's term instead.
190        #[arg(long)]
191        all: bool,
192        /// Reviewer name (defaults to USER).
193        #[arg(long, value_name = "NAME")]
194        reviewer: Option<crate::types::ReviewerId>,
195    },
196    /// Serve or administer a shared remote review store (review-store.sqlite).
197    Server {
198        #[command(subcommand)]
199        command: ServerCommand,
200    },
201}
202
203#[derive(Debug, Args)]
204#[group(multiple = false)]
205pub struct SelectionArgs {
206    /// Select one Artifact.
207    artifact: Option<Selector>,
208    /// Select one qualified Eval ID.
209    #[arg(long, value_name = "ID")]
210    eval: Option<crate::types::EvalId>,
211    /// Select comma-separated qualified Eval IDs.
212    #[arg(long, value_name = "CSV", value_delimiter = ',', num_args = 1)]
213    evals: Option<Vec<crate::types::EvalId>>,
214    /// Select comma-separated Artifact names.
215    #[arg(long, value_name = "CSV", value_delimiter = ',', num_args = 1)]
216    artifacts: Option<Vec<crate::types::ArtifactName>>,
217    /// Read Eval IDs from a JSON array or one ID per line.
218    #[arg(long, value_name = "PATH")]
219    evals_file: Option<PathBuf>,
220    /// Read Artifact names from a JSON array or one ID per line.
221    #[arg(long, value_name = "PATH")]
222    artifacts_file: Option<PathBuf>,
223    /// Select every Artifact.
224    #[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    /// Use a declared profile variant for every included Eval.
258    #[arg(long, value_name = "NAME")]
259    profile: Option<crate::config::ProfileVariantName>,
260    /// Include all Evals in the required dependency scope, including cycle peers.
261    #[arg(long)]
262    recursive: bool,
263    /// Force explicitly selected Evals only; dependency gates still apply.
264    #[arg(long)]
265    force: bool,
266    /// Bypass execution gates, never final validation obligations.
267    #[arg(long)]
268    ignore_gates: bool,
269    /// Fingerprints to compute at once (default: the available CPUs).
270    #[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    /// The ChatGPT/Codex account that the codex backend uses.
290    Codex,
291}
292
293#[derive(Debug, Subcommand)]
294pub enum ModelProvider {
295    /// OpenAI API models (OPENAI_API_KEY).
296    Openai,
297    /// Anthropic API models (ANTHROPIC_API_KEY).
298    Anthropic,
299    /// Codex models your ChatGPT account can use (artifactize login codex).
300    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    /// Validate declarations and unique Artifact/Eval ids.
316    Check,
317    /// Inspect full static definitions, relations, and cycles.
318    Graph {
319        /// Select one Artifact and its required closure; defaults to all.
320        artifact: Option<crate::types::ArtifactName>,
321    },
322}
323
324#[derive(Debug, Subcommand)]
325pub enum RunCommand {
326    /// List saved Runs, newest first; defaults to the current repository.
327    List {
328        /// Explicitly restrict results to --repo (the default).
329        #[arg(long, conflicts_with = "all")]
330        repo_only: bool,
331        /// List Runs from every repository in the shared state.
332        #[arg(long)]
333        all: bool,
334        /// Maximum number of Runs to return.
335        #[arg(long, value_name = "N", default_value_t = DEFAULT_RUN_LIST_LIMIT)]
336        limit: u32,
337        /// Skip this many Runs before returning results.
338        #[arg(long, value_name = "N", default_value = "0")]
339        offset: u32,
340    },
341    /// Read the full saved audit as JSON, even without --json.
342    Show {
343        run_id: RunId,
344        /// Follow a RUNNING Run until it finishes and exit with its outcome code.
345        #[arg(long)]
346        wait: bool,
347        /// Wait timeout; never cancels the Run (default 600000).
348        #[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 the latest record of each reuse key, the one verify reuses.
361    List {
362        /// List every record of every key, latest first per key.
363        #[arg(long)]
364        history: bool,
365    },
366    /// Read a key's latest record (result, profile, options, provenance) as JSON.
367    Show {
368        key: ReuseKey,
369        /// Print every record of the key as a JSON array, latest first.
370        #[arg(long)]
371        history: bool,
372    },
373    /// Remove every record of an unused key, preserving saved Runs and executions.
374    Rm { key: ReuseKey },
375}
376
377#[derive(Debug, Subcommand)]
378pub enum ToolsCommand {
379    /// Static preflight by default; --execute opts in to one tool call.
380    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
411/// Global options may precede or follow the subcommand, but only once in total.
412fn 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
427/// What a screen runs until the reviewer quits; screens need no `Send`, the command runs on a
428/// single thread.
429pub type Screen = std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), String>>>>;
430
431/// The terminal screens `artifactize monitor` and `artifactize review` open. They live in
432/// `artifactize-tui`, which the binary hands to [`run`], so this library draws nothing.
433#[derive(Clone, Copy)]
434pub struct Screens {
435    /// The Monitor over the state directory, on one repository or on every one.
436    pub monitor: fn(PathBuf, Option<PathBuf>, tokio_util::sync::CancellationToken) -> Screen,
437    /// The Human review list, or one request when given.
438    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    // The help tool's trailing arguments belong to the target program, even if they
452    // happen to share names with artifactize's global flags.
453    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    // Run on a thread whose stack is the same size everywhere: Windows gives the main thread
495    // only 1 MiB, which deep review futures outgrow in debug builds.
496    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
526/// The stack of the thread commands run on, and of blocking helper threads: 8 MiB, the
527/// usual Linux and macOS main-thread size, so every system runs the same futures.
528const STACK_BYTES: usize = 8 * 1024 * 1024;
529
530/// Parse protocol milliseconds exactly once, preserving the public signed 32-bit bound.
531fn 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/// The positional selection intentionally accepts either an Artifact or qualified Eval.
542#[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}