Skip to main content

illumitech_forge_cli/
lib.rs

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