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