Skip to main content

illumitech_forge_cli/
lib.rs

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