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