Skip to main content

packc/cli/
mod.rs

1#![forbid(unsafe_code)]
2
3use std::{convert::TryFrom, ffi::OsString, path::PathBuf};
4
5use anyhow::Result;
6use clap::{Parser, Subcommand};
7use greentic_types::{EnvId, TenantCtx, TenantId};
8use tokio::runtime::Runtime;
9
10pub mod add_extension;
11pub mod components;
12pub mod config;
13pub(crate) mod doctor_dw;
14pub mod ext_resolver;
15pub mod extensions_lock;
16pub(crate) mod flow_doctor;
17pub mod gui;
18pub mod info;
19pub mod info_cmd;
20pub mod input;
21pub mod inspect;
22pub mod inspect_lock;
23pub mod lint;
24pub mod plan;
25pub mod providers;
26pub mod publish_agent;
27pub mod qa;
28pub mod resolve;
29pub mod sign;
30pub mod update;
31pub mod verify;
32pub mod wizard;
33mod wizard_catalog;
34mod wizard_i18n;
35mod wizard_ui;
36
37use crate::telemetry::set_current_tenant_ctx;
38use crate::{build, new, runtime};
39
40#[derive(Debug, Parser)]
41#[command(name = "greentic-pack", about = "Greentic pack CLI", version)]
42pub struct Cli {
43    /// Logging filter (overrides PACKC_LOG)
44    #[arg(long = "log", default_value = "info", global = true)]
45    pub verbosity: String,
46
47    /// Force offline mode (disables any network activity)
48    #[arg(long, global = true)]
49    pub offline: bool,
50
51    /// Override cache directory (defaults to pack_dir/.packc or GREENTIC_PACK_CACHE_DIR)
52    #[arg(long = "cache-dir", global = true)]
53    pub cache_dir: Option<PathBuf>,
54
55    /// Optional config overrides in TOML/JSON (greentic-config layer)
56    #[arg(long = "config-override", value_name = "FILE", global = true)]
57    pub config_override: Option<PathBuf>,
58
59    /// Emit machine-readable JSON output where applicable
60    #[arg(long, global = true)]
61    pub json: bool,
62
63    /// Locale used for CLI messages (fallback: LC_ALL/LC_MESSAGES/LANG/system/en)
64    #[arg(long, global = true)]
65    pub locale: Option<String>,
66
67    #[command(subcommand)]
68    pub command: Command,
69}
70
71#[allow(clippy::large_enum_variant)]
72#[derive(Debug, Subcommand)]
73pub enum Command {
74    /// Build a pack component and supporting artifacts
75    Build(BuildArgs),
76    /// Lint a pack manifest, flows, and templates
77    Lint(self::lint::LintArgs),
78    /// Sync pack.yaml components with files under components/
79    Components(self::components::ComponentsArgs),
80    /// Sync pack.yaml components and flows with files under the pack root
81    Update(self::update::UpdateArgs),
82    /// Scaffold a new pack directory
83    New(new::NewArgs),
84    /// Sign a pack manifest using an Ed25519 private key
85    Sign(self::sign::SignArgs),
86    /// Verify a pack's manifest signature
87    Verify(self::verify::VerifyArgs),
88    /// GUI-related tooling
89    #[command(subcommand)]
90    Gui(self::gui::GuiCommand),
91    /// Diagnose a pack archive (.gtpack) or source directory (runs validation)
92    Doctor(self::inspect::InspectArgs),
93    /// Describe a .gtpack: show name, version, components, and metadata.
94    Info {
95        /// Path to a .gtpack file.
96        #[arg(value_name = "PATH")]
97        path: std::path::PathBuf,
98        /// Output format.
99        #[arg(long, value_enum, default_value_t = self::inspect::InspectFormat::Human)]
100        format: self::inspect::InspectFormat,
101        /// Fail if unsigned or signature invalid.
102        #[arg(long, default_value_t = false)]
103        strict: bool,
104    },
105    /// Deprecated alias for `doctor`
106    Inspect(self::inspect::InspectArgs),
107    /// Inspect pack.lock.cbor (stable JSON to stdout)
108    InspectLock(self::inspect_lock::InspectLockArgs),
109    /// Run component QA and store answers.
110    Qa(self::qa::QaArgs),
111    /// Inspect resolved configuration (provenance and warnings)
112    Config(self::config::ConfigArgs),
113    /// Generate a DeploymentPlan from a pack archive or source directory.
114    Plan(self::plan::PlanArgs),
115    /// Legacy provider-extension helpers (schema-core path).
116    #[command(subcommand)]
117    Providers(self::providers::ProvidersCommand),
118    /// Add data to pack extensions (provider extension path is legacy/schema-core).
119    #[command(subcommand)]
120    AddExtension(self::add_extension::AddExtensionCommand),
121    /// Resolve extension dependency refs into pack.extensions.lock.json
122    ExtensionsLock(self::extensions_lock::ExtensionsLockArgs),
123    /// Interactive pack wizard.
124    Wizard(self::wizard::WizardArgs),
125    /// Resolve component references and write pack.lock.cbor
126    Resolve(self::resolve::ResolveArgs),
127    /// Publish a built dw-application pack to the store as an AgenticWorker.
128    PublishAgent(self::publish_agent::PublishAgentArgs),
129}
130
131#[derive(Debug, Clone, Parser)]
132pub struct BuildArgs {
133    /// Root directory of the pack (must contain pack.yaml)
134    #[arg(long = "in", value_name = "DIR")]
135    pub input: PathBuf,
136
137    /// Skip running `packc update` before building (default: update first)
138    #[arg(long = "no-update", default_value_t = false)]
139    pub no_update: bool,
140
141    /// Output path for the built Wasm component (legacy; writes a stub)
142    #[arg(long = "out", value_name = "FILE")]
143    pub component_out: Option<PathBuf>,
144
145    /// Output path for the generated manifest (CBOR); defaults to dist/manifest.cbor
146    #[arg(long, value_name = "FILE")]
147    pub manifest: Option<PathBuf>,
148
149    /// Output path for the generated SBOM (legacy; writes a stub JSON)
150    #[arg(long, value_name = "FILE")]
151    pub sbom: Option<PathBuf>,
152
153    /// Output path for the generated & canonical .gtpack archive (default: dist/<pack_dir>.gtpack)
154    #[arg(long = "gtpack-out", value_name = "FILE")]
155    pub gtpack_out: Option<PathBuf>,
156
157    /// Optional path to pack.lock.cbor (default: <pack_dir>/pack.lock.cbor)
158    #[arg(long = "lock", value_name = "FILE")]
159    pub lock: Option<PathBuf>,
160
161    /// Bundle strategy for component artifacts (cache=embed wasm, none=refs only)
162    #[arg(long = "bundle", value_enum, default_value = "cache")]
163    pub bundle: crate::build::BundleMode,
164
165    /// When set, the command validates input without writing artifacts
166    #[arg(long)]
167    pub dry_run: bool,
168
169    /// Optional JSON file with additional secret requirements (migration bridge)
170    #[arg(long = "secrets-req", value_name = "FILE")]
171    pub secrets_req: Option<PathBuf>,
172
173    /// Default secret scope to apply when missing (dev-only), format: env/tenant[/team]
174    #[arg(long = "default-secret-scope", value_name = "ENV/TENANT[/TEAM]")]
175    pub default_secret_scope: Option<String>,
176
177    /// Allow OCI component refs in extensions to be tag-based (default requires sha256 digest)
178    #[arg(long = "allow-oci-tags", default_value_t = false)]
179    pub allow_oci_tags: bool,
180
181    /// Require manifest metadata for flow-referenced components
182    #[arg(long, default_value_t = false)]
183    pub require_component_manifests: bool,
184
185    /// Skip auto-including extra directories (e.g. schemas/, templates/)
186    #[arg(long = "no-extra-dirs", default_value_t = false)]
187    pub no_extra_dirs: bool,
188
189    /// Include source files (pack.yaml, flows) inside the generated .gtpack for debugging
190    #[arg(long = "dev", default_value_t = false)]
191    pub dev: bool,
192
193    /// Migration-only escape hatch: allow deriving component manifest/schema from pack.yaml.
194    #[arg(long = "allow-pack-schema", default_value_t = false)]
195    pub allow_pack_schema: bool,
196}
197
198pub fn run() -> Result<()> {
199    let cli = parse_cli_from_env();
200    Runtime::new()?.block_on(run_with_cli(cli, false))
201}
202
203pub fn parse_cli_from_env() -> Cli {
204    let args: Vec<OsString> = std::env::args_os().collect();
205    parse_cli_from_args(args)
206}
207
208pub fn parse_cli_from_args(args: Vec<OsString>) -> Cli {
209    let (rewritten, wizard_schema_requested) = rewrite_wizard_schema_flags(args);
210    self::wizard::set_forced_schema_flag(wizard_schema_requested);
211    Cli::parse_from(rewritten)
212}
213
214fn rewrite_wizard_schema_flags(args: Vec<OsString>) -> (Vec<OsString>, bool) {
215    let mut saw_wizard = false;
216    let mut schema_requested = false;
217    let mut rewritten = Vec::with_capacity(args.len());
218
219    for arg in args {
220        if arg == "wizard" {
221            saw_wizard = true;
222            rewritten.push(arg);
223            continue;
224        }
225        if saw_wizard && arg == "--schema" {
226            schema_requested = true;
227            continue;
228        }
229        rewritten.push(arg);
230    }
231
232    (rewritten, schema_requested)
233}
234
235pub fn print_top_level_help() {
236    println!("{}", crate::cli_i18n::t("cli.help.title"));
237    println!();
238    println!("{}", crate::cli_i18n::t("cli.help.usage"));
239    println!();
240    println!("{}", crate::cli_i18n::t("cli.help.commands_header"));
241    println!("{}", crate::cli_i18n::t("cli.help.command.build"));
242    println!("{}", crate::cli_i18n::t("cli.help.command.lint"));
243    println!("{}", crate::cli_i18n::t("cli.help.command.components"));
244    println!("{}", crate::cli_i18n::t("cli.help.command.update"));
245    println!("{}", crate::cli_i18n::t("cli.help.command.new"));
246    println!("{}", crate::cli_i18n::t("cli.help.command.sign"));
247    println!("{}", crate::cli_i18n::t("cli.help.command.verify"));
248    println!("{}", crate::cli_i18n::t("cli.help.command.gui"));
249    println!("{}", crate::cli_i18n::t("cli.help.command.doctor"));
250    println!("{}", crate::cli_i18n::t("cli.help.command.inspect"));
251    println!("{}", crate::cli_i18n::t("cli.help.command.inspect_lock"));
252    println!("{}", crate::cli_i18n::t("cli.help.command.qa"));
253    println!("{}", crate::cli_i18n::t("cli.help.command.config"));
254    println!("{}", crate::cli_i18n::t("cli.help.command.plan"));
255    println!("{}", crate::cli_i18n::t("cli.help.command.providers"));
256    println!("{}", crate::cli_i18n::t("cli.help.command.add_extension"));
257    println!("{}", crate::cli_i18n::t("cli.help.command.extensions_lock"));
258    println!("{}", crate::cli_i18n::t("cli.help.command.wizard"));
259    println!("{}", crate::cli_i18n::t("cli.help.command.resolve"));
260    println!("{}", crate::cli_i18n::t("cli.help.command.help"));
261    println!();
262    println!("{}", crate::cli_i18n::t("cli.help.options_header"));
263    println!("{}", crate::cli_i18n::t("cli.help.option.log"));
264    println!("{}", crate::cli_i18n::t("cli.help.option.offline"));
265    println!("{}", crate::cli_i18n::t("cli.help.option.cache_dir"));
266    println!("{}", crate::cli_i18n::t("cli.help.option.config_override"));
267    println!("{}", crate::cli_i18n::t("cli.help.option.json"));
268    println!("{}", crate::cli_i18n::t("cli.help.option.locale"));
269    println!("{}", crate::cli_i18n::t("cli.help.option.help"));
270    println!("{}", crate::cli_i18n::t("cli.help.option.version"));
271}
272
273pub fn print_help_for_path(path: &[String]) -> bool {
274    let key = match path {
275        [] => "cli.help.page.root",
276        [a] if a == "build" => "cli.help.page.build",
277        [a] if a == "lint" => "cli.help.page.lint",
278        [a] if a == "components" => "cli.help.page.components",
279        [a] if a == "update" => "cli.help.page.update",
280        [a] if a == "new" => "cli.help.page.new",
281        [a] if a == "sign" => "cli.help.page.sign",
282        [a] if a == "verify" => "cli.help.page.verify",
283        [a] if a == "gui" => "cli.help.page.gui",
284        [a] if a == "doctor" => "cli.help.page.doctor",
285        [a] if a == "inspect" => "cli.help.page.inspect",
286        [a] if a == "inspect-lock" => "cli.help.page.inspect_lock",
287        [a] if a == "qa" => "cli.help.page.qa",
288        [a] if a == "config" => "cli.help.page.config",
289        [a] if a == "plan" => "cli.help.page.plan",
290        [a] if a == "providers" => "cli.help.page.providers",
291        [a] if a == "add-extension" => "cli.help.page.add_extension",
292        [a] if a == "extensions-lock" => "cli.help.page.extensions_lock",
293        [a] if a == "wizard" => "cli.help.page.wizard",
294        [a, b] if a == "wizard" && b == "run" => "cli.help.page.wizard_run",
295        [a, b] if a == "wizard" && b == "validate" => "cli.help.page.wizard_validate",
296        [a, b] if a == "wizard" && b == "apply" => "cli.help.page.wizard_apply",
297        [a] if a == "resolve" => "cli.help.page.resolve",
298        [a, b] if a == "gui" && b == "loveable-convert" => "cli.help.page.gui_loveable_convert",
299        [a, b] if a == "providers" && b == "list" => "cli.help.page.providers_list",
300        [a, b] if a == "providers" && b == "info" => "cli.help.page.providers_info",
301        [a, b] if a == "providers" && b == "validate" => "cli.help.page.providers_validate",
302        [a, b] if a == "add-extension" && b == "provider" => "cli.help.page.add_extension_provider",
303        [a, b] if a == "add-extension" && b == "capability" => {
304            "cli.help.page.add_extension_capability"
305        }
306        [a, b] if a == "add-extension" && b == "deployer" => "cli.help.page.add_extension_deployer",
307        [a, b] if a == "add-extension" && b == "dependency" => {
308            "cli.help.page.add_extension_dependency"
309        }
310        _ => return false,
311    };
312
313    if !crate::cli_i18n::has(key) {
314        return false;
315    }
316    println!("{}", crate::cli_i18n::t(key));
317    true
318}
319
320/// Resolve the logging filter to use for telemetry initialisation.
321pub fn resolve_env_filter(cli: &Cli) -> String {
322    std::env::var("PACKC_LOG").unwrap_or_else(|_| cli.verbosity.clone())
323}
324
325/// Execute the CLI using a pre-parsed argument set.
326pub async fn run_with_cli(cli: Cli, warn_inspect_alias: bool) -> Result<()> {
327    let wizard_locale = cli.locale.clone();
328    crate::cli_i18n::init_locale(cli.locale.as_deref());
329
330    let runtime = runtime::resolve_runtime(
331        Some(std::env::current_dir()?.as_path()),
332        cli.cache_dir.as_deref(),
333        cli.offline,
334        cli.config_override.as_deref(),
335    )?;
336
337    // Install telemetry according to resolved config.
338    crate::telemetry::install_with_config("packc", &runtime.resolved.config.telemetry)?;
339
340    set_current_tenant_ctx(&TenantCtx::new(
341        EnvId::try_from("local").expect("static env id"),
342        TenantId::try_from("packc").expect("static tenant id"),
343    ));
344
345    match cli.command {
346        Command::Build(args) => {
347            build::run(&build::BuildOptions::from_args(args, &runtime)?).await?
348        }
349        Command::Lint(args) => self::lint::handle(args, cli.json)?,
350        Command::Components(args) => self::components::handle(args, cli.json)?,
351        Command::Update(args) => self::update::handle(args, cli.json)?,
352        Command::New(args) => new::handle(args, cli.json, &runtime).await?,
353        Command::Sign(args) => self::sign::handle(args, cli.json)?,
354        Command::Verify(args) => self::verify::handle(args, cli.json)?,
355        Command::Gui(cmd) => self::gui::handle(cmd, cli.json, &runtime).await?,
356        Command::Inspect(args) | Command::Doctor(args) => {
357            if warn_inspect_alias {
358                eprintln!("{}", crate::cli_i18n::t("cli.warn.inspect_deprecated"));
359            }
360            self::inspect::handle(args, cli.json, &runtime).await?
361        }
362        Command::Info {
363            path,
364            format,
365            strict,
366        } => {
367            // Honour the global `--json` flag as a shortcut for `--format json`.
368            let effective_format = if cli.json {
369                self::inspect::InspectFormat::Json
370            } else {
371                format
372            };
373            match self::info_cmd::handle(&path, effective_format, strict) {
374                Ok(()) => {}
375                Err(err) => {
376                    let msg = err.to_string();
377                    let code = if msg.starts_with(self::info_cmd::ERR_NOT_A_PACK) {
378                        2
379                    } else if msg.starts_with(self::info_cmd::ERR_STRICT_UNSIGNED) {
380                        3
381                    } else {
382                        1
383                    };
384                    eprintln!("{msg}");
385                    std::process::exit(code);
386                }
387            }
388        }
389        Command::InspectLock(args) => self::inspect_lock::handle(args)?,
390        Command::Qa(args) => self::qa::handle(args, &runtime)?,
391        Command::Config(args) => self::config::handle(args, cli.json, &runtime)?,
392        Command::Plan(args) => self::plan::handle(&args)?,
393        Command::Providers(cmd) => self::providers::run(cmd)?,
394        Command::AddExtension(cmd) => self::add_extension::handle(cmd)?,
395        Command::ExtensionsLock(args) => {
396            self::extensions_lock::handle(args, &runtime, true).await?
397        }
398        Command::Wizard(args) => self::wizard::handle(args, &runtime, wizard_locale.as_deref())?,
399        Command::Resolve(args) => self::resolve::handle(args, &runtime, true).await?,
400        Command::PublishAgent(args) => self::publish_agent::run(args).await?,
401    }
402
403    Ok(())
404}
405
406#[cfg(test)]
407mod tests {
408    use super::*;
409
410    #[test]
411    fn cli_parse_build_populates_defaults() {
412        let cli = Cli::parse_from(["greentic-pack", "build", "--in", "demo-pack"]);
413        assert_eq!(cli.verbosity, "info");
414        assert!(!cli.offline);
415        assert!(!cli.json);
416        assert!(matches!(
417            cli.command,
418            Command::Build(BuildArgs {
419                input,
420                no_update: false,
421                dry_run: false,
422                allow_oci_tags: false,
423                require_component_manifests: false,
424                no_extra_dirs: false,
425                dev: false,
426                allow_pack_schema: false,
427                ..
428            }) if input.as_path() == std::path::Path::new("demo-pack")
429        ));
430    }
431
432    #[test]
433    fn cli_parse_nested_subcommands_and_globals() {
434        let cli = Cli::parse_from([
435            "greentic-pack",
436            "--json",
437            "--offline",
438            "--locale",
439            "nl",
440            "providers",
441            "validate",
442        ]);
443        assert!(cli.json);
444        assert!(cli.offline);
445        assert_eq!(cli.locale.as_deref(), Some("nl"));
446        assert!(matches!(
447            cli.command,
448            Command::Providers(self::providers::ProvidersCommand::Validate(_))
449        ));
450    }
451
452    #[test]
453    fn print_help_for_known_paths_returns_true() {
454        crate::cli_i18n::init_locale(Some("en"));
455
456        assert!(print_help_for_path(&[]));
457        assert!(print_help_for_path(&["build".to_string()]));
458        assert!(print_help_for_path(&[
459            "wizard".to_string(),
460            "run".to_string()
461        ]));
462        assert!(print_help_for_path(&[
463            "providers".to_string(),
464            "validate".to_string()
465        ]));
466        assert!(print_help_for_path(&[
467            "add-extension".to_string(),
468            "dependency".to_string()
469        ]));
470    }
471
472    #[test]
473    fn print_help_for_unknown_paths_returns_false() {
474        crate::cli_i18n::init_locale(Some("en"));
475        assert!(!print_help_for_path(&["does-not-exist".to_string()]));
476        assert!(!print_help_for_path(&[
477            "wizard".to_string(),
478            "missing".to_string()
479        ]));
480    }
481
482    #[test]
483    fn localized_wizard_help_mentions_schema_option() {
484        let en_catalog: serde_json::Value =
485            serde_json::from_str(include_str!("../../i18n/en.json")).expect("valid English i18n");
486        let en_wizard = en_catalog["cli.help.page.wizard"]
487            .as_str()
488            .expect("English wizard help string");
489        let en_run = en_catalog["cli.help.page.wizard_run"]
490            .as_str()
491            .expect("English wizard run help string");
492        assert!(en_wizard.contains("--schema"));
493        assert!(en_run.contains("--schema"));
494
495        let nl_catalog: serde_json::Value =
496            serde_json::from_str(include_str!("../../i18n/nl.json")).expect("valid Dutch i18n");
497        let nl_wizard = nl_catalog["cli.help.page.wizard"]
498            .as_str()
499            .expect("Dutch wizard help string");
500        let nl_run = nl_catalog["cli.help.page.wizard_run"]
501            .as_str()
502            .expect("Dutch wizard run help string");
503        assert!(nl_wizard.contains("--schema"));
504        assert!(nl_run.contains("--schema"));
505        assert!(nl_wizard.contains("AnswerDocument-schema"));
506        assert!(nl_run.contains("AnswerDocument-schema"));
507    }
508
509    #[test]
510    fn print_top_level_help_does_not_panic() {
511        crate::cli_i18n::init_locale(Some("en"));
512        print_top_level_help();
513    }
514
515    #[test]
516    fn resolve_env_filter_uses_cli_verbosity_when_env_missing() {
517        let cli = Cli::parse_from(["greentic-pack", "--log", "debug", "build", "--in", "demo"]);
518        assert_eq!(resolve_env_filter(&cli), "debug");
519    }
520
521    #[test]
522    fn rewrite_wizard_schema_flags_strips_schema_after_wizard() {
523        let (rewritten, schema_requested) = rewrite_wizard_schema_flags(vec![
524            "greentic-pack".into(),
525            "--locale".into(),
526            "nl".into(),
527            "wizard".into(),
528            "run".into(),
529            "--schema".into(),
530            "--answers".into(),
531            "answers.json".into(),
532        ]);
533
534        assert!(schema_requested);
535        assert_eq!(
536            rewritten,
537            vec![
538                OsString::from("greentic-pack"),
539                OsString::from("--locale"),
540                OsString::from("nl"),
541                OsString::from("wizard"),
542                OsString::from("run"),
543                OsString::from("--answers"),
544                OsString::from("answers.json"),
545            ]
546        );
547    }
548
549    #[test]
550    fn rewrite_wizard_schema_flags_leaves_other_schema_flags_alone() {
551        let (rewritten, schema_requested) = rewrite_wizard_schema_flags(vec![
552            "greentic-pack".into(),
553            "build".into(),
554            "--schema".into(),
555        ]);
556
557        assert!(!schema_requested);
558        assert_eq!(
559            rewritten,
560            vec![
561                OsString::from("greentic-pack"),
562                OsString::from("build"),
563                OsString::from("--schema"),
564            ]
565        );
566    }
567}