Skip to main content

illumitech_forge_cli/
lib.rs

1mod budget;
2mod comparison;
3mod compatibility;
4mod context;
5mod daemon;
6mod dashboard;
7mod demo;
8mod duration;
9mod engine;
10mod findings;
11mod guided;
12mod importing;
13mod installation;
14mod issue;
15mod launch;
16mod native;
17mod observability;
18mod operational;
19mod parallel;
20mod planning;
21mod project;
22mod publication;
23mod review;
24mod service;
25mod snapshots;
26mod templates;
27mod terminal_chat;
28mod validation_cache;
29mod workbench;
30mod workbench_http;
31mod workflow;
32use anyhow::Result;
33use clap::{CommandFactory, FromArgMatches, Parser, Subcommand};
34use forge_adapters::process;
35use serde_json::{Value, json};
36use std::path::{Path, PathBuf};
37use tokio::sync::watch;
38
39#[derive(Parser)]
40#[command(
41    name = "illumitech-forge",
42    version,
43    about = "Releases com evidências verificáveis / Evidence-backed releases"
44)]
45struct Cli {
46    #[arg(long, global = true)]
47    json: bool,
48    #[arg(long, global = true, hide = true)]
49    service_logging: bool,
50    #[arg(long, global = true, default_value = ".forge-state")]
51    state_dir: PathBuf,
52    #[command(subcommand)]
53    command: Command,
54}
55#[derive(Subcommand)]
56enum Command {
57    /// Prepare and execute a reviewed scope; acceptance stays with external gates.
58    Workflow {
59        #[command(subcommand)]
60        command: workflow::Command,
61    },
62    /// Describe versioned public contracts without opening state or a native client.
63    Contracts,
64    /// Diagnose or explicitly update a task-local installation (preview by default).
65    Installation {
66        #[command(subcommand)]
67        command: installation::Command,
68    },
69    /// Prepare a disposable native example; no agent or Python required.
70    Demo {
71        #[command(subcommand)]
72        command: demo::Command,
73    },
74    /// Consistent, verified state snapshots and fresh-target recovery.
75    State {
76        #[command(subcommand)]
77        command: snapshots::Command,
78    },
79    /// Import exactly one selected GitHub issue as a preview-first candidate.
80    Issue {
81        #[command(subcommand)]
82        command: issue::Command,
83    },
84    /// Independent read-only native review; never grants release acceptance.
85    Review {
86        #[command(subcommand)]
87        command: ReviewCommand,
88    },
89    Templates {
90        #[command(subcommand)]
91        command: templates::Command,
92    },
93    Budget {
94        #[command(subcommand)]
95        command: BudgetCommand,
96    },
97    /// Local authenticated UI; Ctrl+C shuts down the native bridge.
98    Workbench {
99        #[arg(long)]
100        root: PathBuf,
101        #[arg(long, default_value_t = 0)]
102        port: u16,
103        #[arg(long)]
104        app_server: Option<PathBuf>,
105        #[arg(long)]
106        manifest: Option<PathBuf>,
107    },
108    /// Interactive Illumitech terminal conversation using native authentication and permissions.
109    Chat {
110        #[arg(long, default_value = ".")]
111        root: PathBuf,
112        #[arg(long)]
113        app_server: Option<PathBuf>,
114    },
115    Feature {
116        #[command(subcommand)]
117        command: FeatureCommand,
118    },
119    Plan {
120        #[command(subcommand)]
121        command: PlanCommand,
122    },
123    /// Internal bounded Windows daemon supervisor, started only by service start.
124    #[command(hide = true)]
125    ServiceHost {
126        #[arg(long)]
127        releases_dir: PathBuf,
128    },
129    /// Preview/install/control a per-user daemon; defaults to preview.
130    Service(service::Options),
131    /// Painel local somente leitura; Ctrl+C encerra o servidor.
132    Dashboard {
133        #[arg(long, default_value_t = 0)]
134        port: u16,
135    },
136    Daemon {
137        #[arg(long)]
138        releases_dir: PathBuf,
139        #[arg(long)]
140        once: bool,
141        #[arg(long, default_value_t = 60)]
142        interval_seconds: u64,
143        #[arg(long)]
144        dry_run: bool,
145    },
146    DaemonControl {
147        #[command(subcommand)]
148        command: DaemonControl,
149    },
150    Init {
151        #[arg(default_value = ".")]
152        path: PathBuf,
153        /// Explicitly create optional ai-memory routing for this workspace.
154        #[arg(long)]
155        memory_workspace: Option<String>,
156    },
157    Doctor,
158    Spec {
159        #[command(subcommand)]
160        command: SpecCommand,
161    },
162    Release {
163        #[command(subcommand)]
164        command: ReleaseCommand,
165    },
166    Status {
167        manifest: PathBuf,
168    },
169    Report {
170        manifest: PathBuf,
171        #[arg(long, default_value_t = 100)]
172        limit: u32,
173        #[arg(long, default_value_t = 0)]
174        offset: u64,
175    },
176    Context {
177        #[command(subcommand)]
178        command: ContextCommand,
179    },
180    Pause {
181        manifest: PathBuf,
182    },
183    Cancel {
184        manifest: PathBuf,
185    },
186    Resume {
187        manifest: PathBuf,
188        #[arg(long)]
189        reconcile: Option<String>,
190    },
191    Memory {
192        #[command(subcommand)]
193        command: MemoryCommand,
194    },
195    Tools {
196        #[command(subcommand)]
197        command: ToolsCommand,
198    },
199    Eval {
200        #[command(subcommand)]
201        command: EvalCommand,
202    },
203}
204#[derive(Subcommand)]
205enum ReviewCommand {
206    Plan {
207        manifest: PathBuf,
208    },
209    Run {
210        manifest: PathBuf,
211        #[arg(long)]
212        app_server: Option<PathBuf>,
213    },
214}
215#[derive(Subcommand)]
216enum BudgetCommand {
217    Inspect { manifest: PathBuf },
218}
219#[derive(Subcommand)]
220enum FeatureCommand {
221    New {
222        #[arg(long)]
223        root: PathBuf,
224        #[arg(long)]
225        id: String,
226        #[arg(long)]
227        title: String,
228        #[arg(long)]
229        ops_root: PathBuf,
230        #[arg(long, conflicts_with = "apply")]
231        dry_run: bool,
232        #[arg(long)]
233        apply: bool,
234    },
235}
236#[derive(Subcommand)]
237enum PlanCommand {
238    Propose {
239        #[arg(long)]
240        root: PathBuf,
241        #[arg(long)]
242        request: String,
243        #[arg(long)]
244        ops_root: PathBuf,
245        #[arg(long)]
246        output: PathBuf,
247        #[arg(long)]
248        dry_run: bool,
249        #[arg(long)]
250        app_server: Option<PathBuf>,
251        #[arg(long)]
252        previous: Option<PathBuf>,
253        /// Attach planning usage to an explicit release budget.
254        #[arg(long)]
255        manifest: Option<PathBuf>,
256    },
257    Validate {
258        path: PathBuf,
259        #[arg(long)]
260        root: PathBuf,
261    },
262}
263#[derive(Subcommand)]
264enum ContextCommand {
265    /// Preview bounded context locally; never invokes a model or writes state.
266    Plan {
267        manifest: PathBuf,
268        #[arg(long)]
269        task: String,
270        #[arg(long)]
271        budget_tokens: u64,
272    },
273}
274#[derive(Subcommand)]
275enum SpecCommand {
276    Init {
277        id: String,
278        title: String,
279        #[arg(long, default_value = ".")]
280        root: PathBuf,
281    },
282    Check {
283        #[arg(default_value = ".")]
284        root: PathBuf,
285    },
286}
287#[derive(Subcommand)]
288enum ReleaseCommand {
289    ImportSpec {
290        #[arg(long)]
291        root: PathBuf,
292        #[arg(long, required = true)]
293        spec: Vec<String>,
294        #[arg(long)]
295        release_id: String,
296        #[arg(long)]
297        output: Option<PathBuf>,
298    },
299    DraftPr {
300        manifest: PathBuf,
301        #[arg(long)]
302        dry_run: bool,
303    },
304    Inspect {
305        manifest: PathBuf,
306    },
307    Run {
308        manifest: PathBuf,
309        #[arg(long)]
310        dry_run: bool,
311    },
312    Approve {
313        manifest: PathBuf,
314        #[arg(long)]
315        task: String,
316        #[arg(long)]
317        gate: String,
318        #[arg(long)]
319        reviewer: String,
320        #[arg(long)]
321        fingerprint: String,
322    },
323    ImportLoop {
324        input: PathBuf,
325        #[arg(long)]
326        output: Option<PathBuf>,
327    },
328    Publish {
329        manifest: PathBuf,
330        /// Check this visibility; never change the remote repository.
331        #[arg(long, value_enum, default_value = "PRIVATE")]
332        expected_visibility: publication::ExpectedVisibility,
333        #[arg(long)]
334        repository: String,
335        #[arg(long)]
336        tag: String,
337        #[arg(long)]
338        assets: PathBuf,
339        #[arg(long)]
340        notes: PathBuf,
341        #[arg(long)]
342        dry_run: bool,
343    },
344}
345#[derive(Subcommand)]
346enum DaemonControl {
347    Status,
348    Pause {
349        #[arg(long)]
350        reason: Option<String>,
351    },
352    Resume,
353    Retry {
354        #[arg(long)]
355        release: String,
356        #[arg(long)]
357        reason: String,
358    },
359}
360#[derive(Subcommand)]
361enum MemoryCommand {
362    Doctor { manifest: PathBuf },
363    Query { manifest: PathBuf, query: String },
364}
365#[derive(Subcommand)]
366enum ToolsCommand {
367    List,
368    Doctor,
369    Plan {
370        #[arg(long, default_value = ".")]
371        root: PathBuf,
372        #[arg(required = true)]
373        recipes: Vec<String>,
374    },
375    Apply {
376        #[arg(long, default_value = ".")]
377        root: PathBuf,
378        #[arg(required = true)]
379        recipes: Vec<String>,
380    },
381}
382#[derive(Subcommand)]
383enum EvalCommand {
384    /// Compare observed consumption and delivery outcomes; never invokes a model.
385    Compare {
386        baseline: PathBuf,
387        candidate: PathBuf,
388    },
389    Run {
390        manifest: PathBuf,
391        #[arg(long)]
392        dry_run: bool,
393    },
394}
395
396#[tokio::main]
397pub async fn run() {
398    let (program, argv) = launch::arguments(std::env::args_os().collect());
399    let mut command = Cli::command();
400    if program == "forge" {
401        command = command.name("forge").after_help("Sem subcomando: abre o chat no projeto atual. Use forge --root PATH para escolher outra pasta.");
402    }
403    let arguments = command.get_matches_from(argv);
404    let mut cli = Cli::from_arg_matches(&arguments).unwrap_or_else(|e| e.exit());
405    let explicit_state =
406        arguments.value_source("state_dir") == Some(clap::parser::ValueSource::CommandLine);
407    let explicit_root = arguments
408        .subcommand_matches("chat")
409        .is_some_and(|a| a.value_source("root") == Some(clap::parser::ValueSource::CommandLine));
410    let prepared = launch::prepare(&mut cli, explicit_state, explicit_root);
411    let logs = if cli.service_logging {
412        match service::Logs::new(&cli.state_dir) {
413            Ok(logs) => Some(logs),
414            Err(e) => {
415                eprintln!("service logging refused: {e}");
416                std::process::exit(2);
417            }
418        }
419    } else {
420        None
421    };
422    let writer = tracing_subscriber::fmt::writer::BoxMakeWriter::new(std::io::stderr);
423    let writer = logs
424        .clone()
425        .map(tracing_subscriber::fmt::writer::BoxMakeWriter::new)
426        .unwrap_or(writer);
427    tracing_subscriber::fmt()
428        .with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
429        .with_ansi(!cli.service_logging)
430        .with_writer(writer)
431        .init();
432    let result = if let Err(error) = prepared {
433        Err(error)
434    } else if matches!(
435        &cli.command,
436        Command::State {
437            command: snapshots::Command::Snapshot { .. }
438        }
439    ) && !explicit_state
440    {
441        Err(anyhow::anyhow!(
442            "state snapshot requires explicit --state-dir"
443        ))
444    } else {
445        dispatch(&cli).await
446    };
447    let (value, code) = match result {
448        Ok(v) => {
449            let status = v["effective_status"]
450                .as_str()
451                .or_else(|| v["run"]["status"].as_str())
452                .or_else(|| v["status"].as_str());
453            let code = if matches!(
454                status,
455                Some("failed" | "blocked" | "awaiting_approval" | "cancelled" | "degraded")
456            ) {
457                2
458            } else {
459                0
460            };
461            (v, code)
462        }
463        Err(e) => (
464            json!({"schema_version":1,"status":"blocked","error_code":"operation_blocked","error":process::redact(&format!("{e:#}"))}),
465            2,
466        ),
467    };
468    let value = compatibility::envelope(value);
469    if let Some(logs) = logs {
470        if logs
471            .write("stdout", serde_json::to_string(&value).unwrap().as_bytes())
472            .is_err()
473        {
474            std::process::exit(2);
475        }
476    } else if cli.json {
477        println!("{}", serde_json::to_string(&value).unwrap());
478    } else {
479        if let Some(summary) = duration::human(&value) {
480            println!("{summary}");
481        }
482        println!("{}", serde_json::to_string_pretty(&value).unwrap());
483    }
484    std::process::exit(code);
485}
486async fn dispatch(cli: &Cli) -> Result<Value> {
487    let ctx = |p: &Path| engine::Context::load(p, &cli.state_dir);
488    match &cli.command {
489        Command::Workflow { command } => workflow::dispatch(command).await,
490        Command::Contracts => Ok(compatibility::contracts()),
491        Command::Installation { command } => installation::dispatch(command).await,
492        Command::Demo { command } => demo::dispatch(command).await,
493        Command::State { command } => snapshots::dispatch(command, &cli.state_dir),
494        Command::Issue { command } => issue::dispatch(command).await,
495        Command::Review { command } => match command {
496            ReviewCommand::Plan { manifest } => review::plan(&ctx(manifest)?),
497            ReviewCommand::Run {
498                manifest,
499                app_server,
500            } => review::run(&ctx(manifest)?, app_server.as_deref()).await,
501        },
502        Command::Templates { command } => templates::dispatch(command),
503        Command::Budget {
504            command: BudgetCommand::Inspect { manifest },
505        } => budget::inspect(&ctx(manifest)?),
506        Command::Workbench {
507            root,
508            port,
509            app_server,
510            manifest,
511        } => {
512            workbench::serve(
513                root,
514                &cli.state_dir,
515                *port,
516                app_server.clone(),
517                manifest.clone(),
518            )
519            .await
520        }
521        Command::Chat { root, app_server } => {
522            anyhow::ensure!(!cli.json, "chat is interactive; omit --json");
523            terminal_chat::run(root, &cli.state_dir, app_server.clone()).await
524        }
525        Command::Feature { command } => match command {
526            FeatureCommand::New {
527                root,
528                id,
529                title,
530                ops_root,
531                apply,
532                ..
533            } => guided::feature(root, id, title, ops_root, *apply),
534        },
535        Command::Plan { command } => match command {
536            PlanCommand::Validate { path, root } => planning::validate_file(path, root),
537            PlanCommand::Propose {
538                root,
539                request,
540                ops_root,
541                output,
542                dry_run,
543                app_server,
544                previous,
545                manifest,
546            } => {
547                let reservation = if let Some(path) = manifest.as_ref().filter(|_| !*dry_run) {
548                    let c = ctx(path)?;
549                    let root = guided::root(root)?;
550                    anyhow::ensure!(
551                        c.roots.values().all(|r| r == &root),
552                        "planning manifest project mismatch"
553                    );
554                    let mut nonce = [0u8; 16];
555                    getrandom::fill(&mut nonce)
556                        .map_err(|e| anyhow::anyhow!("secure operation ID: {e}"))?;
557                    Some(budget::Reservation::begin(
558                        &c,
559                        &format!("planning:{}", forge_domain::digest(&nonce)),
560                        "planning",
561                    )?)
562                } else {
563                    None
564                };
565                planning::propose_reserved(
566                    root,
567                    request,
568                    ops_root,
569                    output,
570                    *dry_run,
571                    app_server.as_deref(),
572                    previous.as_deref(),
573                    reservation,
574                )
575                .await
576            }
577        },
578        Command::ServiceHost { releases_dir } => service::host(releases_dir, &cli.state_dir).await,
579        Command::Service(options) => service::execute(options, &cli.state_dir),
580        Command::Dashboard { port } => dashboard::serve(&cli.state_dir, *port).await,
581        Command::Daemon {
582            releases_dir,
583            once,
584            interval_seconds,
585            dry_run,
586        } => {
587            daemon::run(
588                releases_dir,
589                &cli.state_dir,
590                *once,
591                *interval_seconds,
592                *dry_run,
593            )
594            .await
595        }
596        Command::DaemonControl { command } => match command {
597            DaemonControl::Status => daemon::control(&cli.state_dir, "status", None),
598            DaemonControl::Pause { reason } => {
599                daemon::control(&cli.state_dir, "pause", reason.as_deref())
600            }
601            DaemonControl::Resume => daemon::control(&cli.state_dir, "resume", None),
602            DaemonControl::Retry { release, reason } => {
603                daemon::retry(&cli.state_dir, release, reason)
604            }
605        },
606        Command::Init {
607            path,
608            memory_workspace,
609        } => project::init(path, memory_workspace.as_deref()),
610        Command::Doctor => doctor().await,
611        Command::Spec { command } => match command {
612            SpecCommand::Init { id, title, root } => project::spec_init(root, id, title),
613            SpecCommand::Check { root } => project::spec_check(root),
614        },
615        Command::Release { command } => match command {
616            ReleaseCommand::ImportSpec {
617                root,
618                spec,
619                release_id,
620                output,
621            } => importing::import_spec(root, spec, release_id, output.as_deref()),
622            ReleaseCommand::DraftPr { manifest, dry_run } => {
623                publication::draft_pr(&ctx(manifest)?, *dry_run).await
624            }
625            ReleaseCommand::Inspect { manifest } => ctx(manifest)?.inspect(),
626            ReleaseCommand::Run { manifest, dry_run } => {
627                let c = ctx(manifest)?;
628                if *dry_run {
629                    c.inspect()
630                } else {
631                    engine::execute(c, false, None).await
632                }
633            }
634            ReleaseCommand::Approve {
635                manifest,
636                task,
637                gate,
638                reviewer,
639                fingerprint,
640            } => engine::approve(&ctx(manifest)?, task, gate, reviewer, fingerprint),
641            ReleaseCommand::ImportLoop { input, output } => {
642                importing::import_loop(input, output.as_deref())
643            }
644            ReleaseCommand::Publish {
645                manifest,
646                expected_visibility,
647                repository,
648                tag,
649                assets,
650                notes,
651                dry_run,
652            } => {
653                publication::release(
654                    &ctx(manifest)?,
655                    repository,
656                    tag,
657                    assets,
658                    notes,
659                    *dry_run,
660                    *expected_visibility,
661                )
662                .await
663            }
664        },
665        Command::Status { manifest } => engine::status(&ctx(manifest)?, false),
666        Command::Report {
667            manifest,
668            limit,
669            offset,
670        } => engine::status_window(&ctx(manifest)?, true, *limit, *offset),
671        Command::Context {
672            command:
673                ContextCommand::Plan {
674                    manifest,
675                    task,
676                    budget_tokens,
677                },
678        } => context::plan(&ctx(manifest)?, task, *budget_tokens),
679        Command::Pause { manifest } => engine::control(&ctx(manifest)?, "pause"),
680        Command::Cancel { manifest } => engine::control(&ctx(manifest)?, "cancel"),
681        Command::Resume {
682            manifest,
683            reconcile,
684        } => engine::execute(ctx(manifest)?, true, reconcile.clone()).await,
685        Command::Memory { command } => match command {
686            MemoryCommand::Doctor { manifest } => engine::memory_check(&ctx(manifest)?, None).await,
687            MemoryCommand::Query { manifest, query } => {
688                engine::memory_check(&ctx(manifest)?, Some(query)).await
689            }
690        },
691        Command::Tools { command } => match command {
692            ToolsCommand::List => {
693                Ok(json!({"schema_version":1,"recipes":forge_catalog::recipes()?}))
694            }
695            ToolsCommand::Plan { root, recipes } => {
696                Ok(json!({"schema_version":1,"changes":forge_catalog::plan(root,recipes)?}))
697            }
698            ToolsCommand::Apply { root, recipes } => {
699                Ok(json!({"schema_version":1,"changes":forge_catalog::apply(root,recipes)?}))
700            }
701            ToolsCommand::Doctor => {
702                let mut results = Vec::new();
703                for r in forge_catalog::recipes()? {
704                    let discovered = r.executable.as_deref().and_then(find_executable);
705                    results.push(json!({"id":r.id,"available":true,"installed_recipe":std::env::current_dir()?.join(format!(".forge-toolkit/{}.md",r.id)).is_file(),"discovered":discovered.is_some(),"tested":false,"upstream_revision":r.revision,"last_verified":r.last_verified}));
706                }
707                Ok(json!({"schema_version":1,"tools":results}))
708            }
709        },
710        Command::Eval {
711            command:
712                EvalCommand::Compare {
713                    baseline,
714                    candidate,
715                },
716        } => comparison::files(baseline, candidate),
717        Command::Eval {
718            command: EvalCommand::Run { manifest, dry_run },
719        } => {
720            let c = ctx(manifest)?;
721            if *dry_run {
722                c.inspect()
723            } else {
724                engine::execute(c, false, None).await
725            }
726        }
727    }
728}
729fn find_executable(name: &str) -> Option<PathBuf> {
730    forge_adapters::clients::find_executable(name)
731}
732async fn doctor() -> Result<Value> {
733    let mut checks = Vec::new();
734    for (name, args, features) in [
735        ("git", vec!["--version"], vec![]),
736        ("codex", vec!["exec", "--help"], vec!["--json", "--sandbox"]),
737        (
738            "claude",
739            vec!["--help"],
740            vec!["--print", "--output-format", "--permission-mode"],
741        ),
742    ] {
743        let mut argv = vec![name.to_string()];
744        argv.extend(args.iter().map(|s| s.to_string()));
745        let (_tx, rx) = watch::channel(false);
746        let result = process::run(&argv, &std::env::current_dir()?, 15, rx).await;
747        let passed = result
748            .as_ref()
749            .is_ok_and(|r| r.passed() && features.iter().all(|f| r.stdout.contains(f)));
750        checks.push(json!({"name":name,"discovered":find_executable(name).is_some(),"capabilities_verified":passed,"authentication_tested":false}));
751    }
752    Ok(
753        json!({"schema_version":1,"platform":std::env::consts::OS,"operational_support":cfg!(unix),"windows_candidate":cfg!(windows),"authenticated_windows_acceptance":false,"checks":checks}),
754    )
755}