From b8f7ec49f781f43fd64d93764faff557eab2a473 Mon Sep 17 00:00:00 2001 From: wievdndr Date: Thu, 8 Oct 2026 11:07:08 +0200 Subject: [PATCH 1/2] feat: command and flag industry standard refactor --- .github/workflows/build.yml | 8 +- README.md | 167 +++++++++--------- cmd/aibomgen-cli/common.go | 150 +++++++++++++--- cmd/aibomgen-cli/completeness.go | 40 +---- cmd/aibomgen-cli/enrich.go | 94 +++++----- cmd/aibomgen-cli/generate.go | 92 ++++------ cmd/aibomgen-cli/merge.go | 46 ++--- cmd/aibomgen-cli/root.go | 45 ++++- cmd/aibomgen-cli/root_test.go | 121 +++++++++++++ cmd/aibomgen-cli/scan.go | 74 +++----- cmd/aibomgen-cli/validate.go | 41 ++--- cmd/aibomgen-cli/vulnscan.go | 99 +++++++---- config/defaults.yaml | 135 +++++++------- go.mod | 2 +- internal/enricher/enricher.go | 25 +-- internal/enricher/interactive.go | 2 +- internal/enricher/preview.go | 8 +- internal/fetcher/client.go | 20 ++- internal/metadata/apply.go | 18 +- internal/ui/model_selector.go | 12 +- internal/ui/terminal.go | 80 +++++++++ internal/ui/terminal_test.go | 35 ++++ internal/ui/workflow.go | 6 + pkg/aibomgen/completeness/completeness.go | 29 +-- pkg/aibomgen/generator/generator.go | 15 +- pkg/aibomgen/generator/generator_refs_test.go | 2 +- pkg/aibomgen/generator/generator_test.go | 34 ++-- pkg/aibomgen/merger/merger.go | 3 + pkg/aibomgen/scanner/scanner.go | 7 +- pkg/aibomgen/validator/validator.go | 28 +-- pkg/aibomgen/vulnscan/vulnscan.go | 3 + 31 files changed, 885 insertions(+), 556 deletions(-) create mode 100644 internal/ui/terminal.go create mode 100644 internal/ui/terminal_test.go diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1357390..2907a4d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -61,10 +61,12 @@ jobs: # Offline end-to-end smoke test (dummy HF mode, no network). - name: Smoke test (scan → validate → completeness) run: | - ./aibomgen-cli scan --hf-mode dummy -o dist/ --log-level quiet + ./aibomgen-cli scan --hf-mode dummy -o dist/ -q for f in dist/*; do - ./aibomgen-cli validate -i "$f" --strict --min-score 0.1 --log-level quiet - ./aibomgen-cli completeness -i "$f" --plain-summary | tee -a "$GITHUB_STEP_SUMMARY" + ./aibomgen-cli validate "$f" --strict --min-score 0.1 -q + ./aibomgen-cli completeness "$f" --json \ + | jq -r '"Model: \(.modelId) | Score: \(.score * 100 | floor)% | Fields: \(.passed)/\(.total)"' \ + | tee -a "$GITHUB_STEP_SUMMARY" done zizmor: diff --git a/README.md b/README.md index 9d632d0..f431175 100644 --- a/README.md +++ b/README.md @@ -105,30 +105,33 @@ generate: hf-token: "hf_config_value" # 3. Environment variable (if set) export AIBOMGEN_GENERATE_HF_TOKEN="hf_env_value" -aibomgen-cli generate -m gpt2 +aibomgen-cli generate gpt2 # Result: env_value is used # 4. Explicit flag (always wins) -aibomgen-cli generate -m gpt2 --hf-token hf_flag_value +aibomgen-cli generate gpt2 --hf-token hf_flag_value # Result: flag_value is used (env var and config are ignored) ``` -Config keys with dashes are translated to underscores in env var names: +Config keys match the flag names. Dashes and dots become underscores in env var names: - `generate.hf-token` → `AIBOMGEN_GENERATE_HF_TOKEN` -- `scan.hf-mode` → `AIBOMGEN_SCAN_HF_MODE` -- `enrich.log-level` → `AIBOMGEN_ENRICH_LOG_LEVEL` -- `vuln-scan.hf-token` → `AIBOMGEN_VULN_SCAN_HF_TOKEN` +- `vuln-scan.hf-timeout` → `AIBOMGEN_VULN_SCAN_HF_TIMEOUT` +- `verbose` → `AIBOMGEN_VERBOSE` (top-level keys have no command prefix) + +**Hugging Face settings.** `generate`, `scan`, `enrich` and `vuln-scan` share `--hf-token`, `--hf-base-url` and `--hf-timeout` (default `10` seconds). When no token or base URL is configured, the standard Hugging Face env vars `HF_TOKEN` and `HF_ENDPOINT` are used. Prefer `HF_TOKEN` over `--hf-token`: a token on the command line ends up in your shell history and the process list. ## Commands +Commands that read a file or directory take it as an argument; `--input, -i` does the same and is useful in a config file. Passing both is an error. + ### `scan` Walks a directory for AI-related imports across Python, YAML, JSON, Markdown, shell, Dockerfile, and JavaScript/TypeScript files. Writes one AIBOM per detected model. Security scan data from the Hugging Face tree API is embedded in each BOM by default. ```bash -aibomgen-cli scan -i targets/target-2 -aibomgen-cli scan -i targets/target-3 --format xml --hf-mode online -aibomgen-cli scan -i targets/target-1 --no-security-scan +aibomgen-cli scan # current directory +aibomgen-cli scan targets/target-3 --format xml +aibomgen-cli scan targets/target-1 --no-security-scan ``` By default this writes one JSON file per model under `dist/`, e.g.: @@ -138,40 +141,34 @@ By default this writes one JSON file per model under `dist/`, e.g.: File names follow the CycloneDX `*.cdx.json` / `*.cdx.xml` convention: `.aibom.cdx.`, where `` is the requested model ID (plus `_` when one was requested) with every character outside `[A-Za-z0-9._-]` replaced by `_`. Names that collide get a `_2`, `_3`, … suffix. Existing files are overwritten. -Options: +Usage: `aibomgen-cli scan [dir]` -- `--input, -i `: directory to scan (default: current directory; cannot be used with `--hf-mode=dummy`) +- `--input, -i `: directory to scan, instead of the argument (default: current directory) - `--output, -o `: output directory (default: `dist`) - `--format, -f json|xml` (default: `json`) -- `--spec `: CycloneDX spec version for output (e.g., `1.5`, `1.6`, `1.7`; default `1.7`) -- `--hf-mode online|dummy` (default: `online`) -- `--hf-token `: for gated/private models -- `--hf-timeout ` +- `--spec `: CycloneDX spec version for output, e.g. `1.6` (default: latest, `1.7`) - `--no-security-scan`: skip fetching the Hugging Face security scan tree -- `--log-level quiet|standard|debug` +- `--hf-token`, `--hf-base-url`, `--hf-timeout`: see Hugging Face settings above ### `generate` -Generates an AIBOM from one or more Hugging Face model IDs specified directly, or through an interactive model browser. Security scan data is embedded in the BOM by default. Use `scan` instead when you want to detect models from a source directory. +Generates an AIBOM from one or more Hugging Face model IDs, or from models picked in an interactive browser. Security scan data is embedded in the BOM by default. Use `scan` instead when you want to detect models from a source directory. ```bash -aibomgen-cli generate -m google-bert/bert-base-uncased -aibomgen-cli generate -m gpt2 -m meta-llama/Llama-3.1-8B +aibomgen-cli generate google-bert/bert-base-uncased +aibomgen-cli generate gpt2 meta-llama/Llama-3.1-8B@main aibomgen-cli generate --interactive ``` -Options: +Usage: `aibomgen-cli generate [model-id...]` -- `--model-id, -m `: Hugging Face model ID, optionally with a revision as `org/name@revision` (can be specified multiple times or comma-separated) -- `--interactive`: open an interactive model selector (cannot be used with `--model-id`) +- `--model-id, -m `: model ID as `org/name` or `org/name@revision`; repeatable or comma-separated, combined with the arguments +- `--interactive`: pick models in an interactive selector (needs a terminal; cannot be combined with model IDs) - `--output, -o `: output directory (default: `dist`) - `--format, -f json|xml` (default: `json`) -- `--spec `: CycloneDX spec version for output (e.g., `1.5`, `1.6`, `1.7`; default `1.7`) -- `--hf-mode online|dummy` (default: `online`) -- `--hf-token `: for gated/private models -- `--hf-timeout ` +- `--spec `: CycloneDX spec version for output, e.g. `1.6` (default: latest, `1.7`) - `--no-security-scan`: skip fetching the Hugging Face security scan tree -- `--log-level quiet|standard|debug` +- `--hf-token`, `--hf-base-url`, `--hf-timeout`: see Hugging Face settings above ### `validate` @@ -183,17 +180,17 @@ Validates an existing AIBOM file (JSON or XML). - **Vulnerabilities:** reported as warnings; with `--strict`, those rated at or above `--fail-severity` are errors. ```bash -aibomgen-cli validate -i dist/google-bert_bert-base-uncased.aibom.cdx.json -aibomgen-cli validate -i dist/google-bert_bert-base-uncased.aibom.cdx.json --strict --min-score 0.5 +aibomgen-cli validate dist/google-bert_bert-base-uncased.aibom.cdx.json +aibomgen-cli validate dist/google-bert_bert-base-uncased.aibom.cdx.json --strict --min-score 0.5 ``` -Options: +Usage: `aibomgen-cli validate [file]` -- `--input, -i `: path to AIBOM file (required) +- `--input, -i `: AIBOM file, instead of the argument - `--strict`: fail on missing required fields and on vulnerabilities rated at or above `--fail-severity` - `--fail-severity critical|high|medium|low|info`: lowest vulnerability severity that fails `--strict` (default: `medium`). Vulnerabilities below it, or without a rated severity, are reported as warnings. Hugging Face scanner findings are rated `critical` (unsafe), `high` (suspicious) or `medium` (caution). - `--min-score 0.0-1.0`: minimum acceptable completeness score -- `--log-level quiet|standard|debug`: `debug` also lists the missing optional fields +- `--json`: print the result as JSON Exit codes: `0` valid, `1` error (unreadable file, invalid flag, …), `2` invalid BOM. @@ -202,40 +199,35 @@ Exit codes: `0` valid, `1` error (unreadable file, invalid flag, …), `2` inval Computes and prints a completeness score for an existing AIBOM using the metadata field registry. Scores both the model component and any linked dataset components. ```bash -aibomgen-cli completeness -i dist/google-bert_bert-base-uncased.aibom.cdx.json +aibomgen-cli completeness dist/google-bert_bert-base-uncased.aibom.cdx.json +aibomgen-cli completeness dist/google-bert_bert-base-uncased.aibom.cdx.json --json | jq .score ``` -Options: +Usage: `aibomgen-cli completeness [file]` -- `--input, -i `: path to AIBOM file (required) -- `--plain-summary`: print a single-line machine-readable summary (no styling) -- `--log-level quiet|standard|debug` +- `--input, -i `: AIBOM file, instead of the argument +- `--json`: print the result as JSON ### `enrich` -Enriches an existing AIBOM by filling missing metadata fields interactively or from a YAML configuration file. Can optionally refetch the latest metadata from Hugging Face before prompting. +Enriches an existing AIBOM by filling missing metadata fields: interactively, or from a YAML file with `--file` (see [`config/enrichment.yaml`](config/enrichment.yaml) for an example). By default the latest metadata is refetched from Hugging Face first. ```bash -aibomgen-cli enrich -i dist/google-bert_bert-base-uncased.aibom.cdx.json -aibomgen-cli enrich -i dist/google-bert_bert-base-uncased.aibom.cdx.json --strategy interactive -aibomgen-cli enrich -i dist/google-bert_bert-base-uncased.aibom.cdx.json --strategy file --file config/enrichment.yaml +aibomgen-cli enrich dist/google-bert_bert-base-uncased.aibom.cdx.json +aibomgen-cli enrich dist/google-bert_bert-base-uncased.aibom.cdx.json --file config/enrichment.yaml --yes ``` -Options: - -- `--input, -i `: path to existing AIBOM (required) -- `--output, -o `: output file path (default: overwrite input); a `.xml` path writes XML, anything else JSON -- `--spec `: CycloneDX spec version for output -- `--strategy interactive|file` (default: `interactive`) -- `--file `: enrichment config file for file-based enrichment (default: `./config/enrichment.yaml`) -- `--required-only`: only enrich required fields -- `--min-weight `: minimum weight threshold for fields to enrich -- `--refetch`: refetch model metadata from Hugging Face Hub before enrichment -- `--no-preview`: skip preview before saving -- `--hf-token `: Hugging Face API token (for refetch) -- `--hf-base-url `: Hugging Face base URL (for refetch) -- `--hf-timeout `: Hugging Face API timeout (for refetch) -- `--log-level quiet|standard|debug` +Usage: `aibomgen-cli enrich [file]` + +- `--input, -i `: AIBOM file, instead of the argument +- `--output, -o `: output file (default: overwrite the input); a `.xml` path writes XML, anything else JSON +- `--spec `: CycloneDX spec version for output (default: same as input) +- `--file `: YAML file with the values to fill in, instead of prompting (required when not running in a terminal) +- `--required-only`: only fill required fields +- `--min-weight `: only fill fields with at least this weight +- `--refetch`: refetch model metadata from Hugging Face first (default: `true`; disable with `--refetch=false`) +- `-y, --yes`: save without preview or confirmation (required when not running in a terminal) +- `--hf-token`, `--hf-base-url`, `--hf-timeout`: see Hugging Face settings above ### `vuln-scan` @@ -244,23 +236,20 @@ Fetches per-file security scan results from the Hugging Face Hub for every model Optionally re-injects the findings back into the AIBOM as CycloneDX `BOM.Vulnerabilities` using `--enrich`. ```bash -aibomgen-cli vuln-scan -i dist/google-bert_bert-base-uncased.aibom.cdx.json -aibomgen-cli vuln-scan -i dist/google-bert_bert-base-uncased.aibom.cdx.json --enrich -aibomgen-cli vuln-scan -i dist/google-bert_bert-base-uncased.aibom.cdx.json --enrich --no-preview +aibomgen-cli vuln-scan dist/google-bert_bert-base-uncased.aibom.cdx.json +aibomgen-cli vuln-scan dist/google-bert_bert-base-uncased.aibom.cdx.json --enrich +aibomgen-cli vuln-scan dist/google-bert_bert-base-uncased.aibom.cdx.json --enrich --yes ``` -Options: +Usage: `aibomgen-cli vuln-scan [file]` -- `--input, -i `: path to existing AIBOM (required) -- `--output, -o `: output path when `--enrich` is set (default: overwrite input); a `.xml` path writes XML, anything else JSON -- `--spec `: CycloneDX spec version for output +- `--input, -i `: AIBOM file, instead of the argument +- `--output, -o `: output file with `--enrich` (default: overwrite the input); a `.xml` path writes XML, anything else JSON +- `--spec `: CycloneDX spec version for output (default: same as input) - `--enrich`: inject discovered vulnerabilities back into the AIBOM -- `--interactive`: show confirmation prompt before saving (default: `true`, only relevant with `--enrich`) -- `--no-preview`: skip the confirmation prompt (only with `--enrich`) -- `--hf-token `: Hugging Face API token -- `--hf-base-url `: Hugging Face base URL override -- `--hf-timeout ` (default: `15`) -- `--log-level quiet|standard|debug` +- `-y, --yes`: apply without preview or confirmation (only with `--enrich`; required when not running in a terminal) +- `--json`: print the scan results as JSON +- `--hf-token`, `--hf-base-url`, `--hf-timeout`: see Hugging Face settings above ### `merge` @@ -272,35 +261,45 @@ The SBOM's application metadata is preserved as the main component, while AI/ML # 1. Generate SBOM for software dependencies using Syft syft scan . -o cyclonedx-json > sbom.json -# 2. Generate AIBOM for AI/ML components using AIBoMGen -aibomgen-cli scan -i . -o dist +# 2. Generate AIBOMs for AI/ML components using AIBoMGen +aibomgen-cli scan . # 3. Merge them into a comprehensive BOM -aibomgen-cli merge --aibom dist/org_model.aibom.cdx.json --sbom sbom.json -o merged.cdx.json - -# 4. Merge multiple AIBOMs with one SBOM (for projects using multiple models in separate AIBOM files) -aibomgen-cli merge --aibom dist/org_model1.aibom.cdx.json --aibom dist/org_model2.aibom.cdx.json --sbom sbom.json -o merged.cdx.json +aibomgen-cli merge dist/*.aibom.cdx.json --sbom sbom.json -o merged.cdx.json ``` -Options: +Usage: `aibomgen-cli merge [aibom...] --sbom -o ` + +- `--aibom `: AIBOM file; repeatable, combined with the arguments +- `--sbom `: SBOM file to merge into (required) +- `--output, -o `: output file for the merged BOM (required); a `.xml` path writes XML, anything else JSON +- `--no-deduplicate`: keep components with duplicate BOM-refs (by default duplicates are removed) -- `--aibom `: path to AIBOM file (can be specified multiple times, required) -- `--sbom `: path to SBOM file (required) -- `--output, -o `: output path for merged BOM (required); a `.xml` path writes XML, anything else JSON -- `--deduplicate`: remove duplicate components based on BOM-ref (default: `true`) -- `--log-level quiet|standard|debug` +### `version` + +Prints the version: `aibomgen-cli version`. ### Global flags - `--config `: config file to use (default: `$HOME/.aibomgen-cli.yaml` or `./config/defaults.yaml`) +- `-q, --quiet`: only print errors and results (no progress output) +- `-v, --verbose`: log to stderr; `-v` shows info (config file used, scan and vuln-scan summaries, missing optional fields in `validate`), `-vv` adds debug detail (HF requests, scanner hits, metadata fields applied, completeness checks, merge dedup) +- `--no-input`: never prompt; commands that would need input fail with a hint instead + +`-q` and `-v` are mutually exclusive. `-q` silences output, not prompts. + +**Output streams and interactive mode.** Results go to stdout. Logs, prompts and TUIs (the `generate --interactive` model selector, `enrich` forms, and the confirmations of `enrich` and `vuln-scan --enrich`) go to stderr. While a prompt is open, log lines are held back and printed when it closes, so `-v`/`-vv` can be combined with interactive mode. Prompts only run when stdin and stderr are terminals; otherwise (CI, pipes, `--no-input`) the command fails up front with a hint, such as `--file` or `--yes`. Progress spinners are only animated on a terminal without `-v`. + +**Machine-readable output.** `validate`, `completeness` and `vuln-scan` accept `--json` to print their result as JSON on stdout, with no styling or progress output. Exit codes are unchanged. The config file is a YAML file that sets default values for any command flag, so you don't have to repeat them on the command line. Keys are namespaced by command: ```yaml +verbose: 2 # same as -vv, for every command + scan: - hf-token: "hf_..." - hf-mode: "online" - log-level: "debug" + output: "out" + no-security-scan: true validate: strict: true diff --git a/cmd/aibomgen-cli/common.go b/cmd/aibomgen-cli/common.go index e2f6cf5..e3e35c4 100644 --- a/cmd/aibomgen-cli/common.go +++ b/cmd/aibomgen-cli/common.go @@ -1,9 +1,15 @@ package cmd import ( + "cmp" + "encoding/json" + "errors" "fmt" + "io" + "log/slog" "os" "strings" + "time" "github.com/spf13/cobra" "github.com/spf13/pflag" @@ -22,15 +28,56 @@ func bindFlags(cmd *cobra.Command, prefix string) { }) } -// quietFrom validates a log level (quiet|standard|debug, empty = standard) -// and reports whether it is quiet. -func quietFrom(level string) (bool, error) { - level = strings.ToLower(strings.TrimSpace(level)) - switch level { - case "", "quiet", "standard", "debug": - return level == "quiet", nil +// verbosity is the resolved output level for the running command: +// -1 quiet, 0 standard, 1 verbose (-v), 2 debug (-vv). Set in PersistentPreRunE. +var verbosity int + +// resolveVerbosity reads --quiet/--verbose (or their config/env keys). +func resolveVerbosity() (int, error) { + quiet, verbose := viper.GetBool("quiet"), viper.GetInt("verbose") + switch { + case quiet && verbose > 0: + return 0, errors.New("cannot combine --quiet with --verbose") + case quiet: + return -1, nil + } + return min(verbose, 2), nil +} + +// slogLevel maps a verbosity to the minimum level logged to stderr. +func slogLevel(v int) slog.Level { + switch { + case v < 0: + return slog.LevelError + case v == 1: + return slog.LevelInfo + case v >= 2: + return slog.LevelDebug + } + return slog.LevelWarn +} + +// setLogger installs the default slog logger at the level for v. It writes to +// stderr through ui.LogWriter, which holds lines back while a prompt is open. +func setLogger(v int) { + slog.SetDefault(slog.New(slog.NewTextHandler(ui.LogWriter(), &slog.HandlerOptions{Level: slogLevel(v), ReplaceAttr: dropTime}))) +} + +// dropTime removes the timestamp from log lines; it is noise for a CLI. +func dropTime(groups []string, a slog.Attr) slog.Attr { + if len(groups) == 0 && a.Key == slog.TimeKey { + return slog.Attr{} } - return false, fmt.Errorf("invalid --log-level %q (expected quiet|standard|debug)", level) + return a +} + +// newWorkflow returns a progress workflow on stdout. With logs enabled (-v) or +// stdout not a terminal it renders only the final state: no spinner redraws to +// erase log lines or leave escape codes in redirected output. +func newWorkflow() *ui.Workflow { + wf := ui.NewWorkflow(os.Stdout) + wf.Static = verbosity >= 1 || !ui.IsTTY(os.Stdout) + return wf } // trackProgress starts the processing task on wf (nil when quiet) and returns @@ -114,23 +161,17 @@ func trackProgress(wf *ui.Workflow, processIdx, writeIdx, total int, hasToken bo return onProgress, finish } -// outputFormat validates a --format value for directory outputs: json (default) -// or xml. "auto" is accepted as json for backwards compatibility. +// outputFormat validates a --format value for directory outputs: json or xml. func outputFormat(format string) (string, error) { switch format = strings.ToLower(strings.TrimSpace(format)); format { - case "", "auto": - return "json", nil case "json", "xml": return format, nil } - return "", fmt.Errorf("invalid --format %q (expected json|xml)", format) + return "", fmt.Errorf("invalid format %q for --format (expected json|xml)", format) } -// writeDiscovered writes one file per BOM into dir (default "dist") and prints the summary. +// writeDiscovered writes one file per BOM into dir and prints the summary. func writeDiscovered(genUI *ui.GenerateUI, boms []generator.DiscoveredBOM, dir, format, specVersion string) error { - if dir == "" { - dir = "dist" - } if err := os.MkdirAll(dir, 0o755); err != nil { return err } @@ -146,14 +187,71 @@ func writeDiscovered(genUI *ui.GenerateUI, boms []generator.DiscoveredBOM, dir, return nil } -const ( - inputFormatDeprecation = "the input format is now detected from the file content" - outputFormatDeprecation = "the output format now follows the --output extension (.xml writes XML, anything else JSON)" -) +// inputFrom returns the command's single input: the positional argument if +// given, otherwise the .input value (flag, env or config). +func inputFrom(cmd *cobra.Command, args []string, prefix string) (string, error) { + if len(args) == 0 { + return strings.TrimSpace(viper.GetString(prefix + ".input")), nil + } + if cmd.Flags().Changed("input") { + return "", errors.New("pass the input as an argument or with --input, not both") + } + return args[0], nil +} + +// requireInput is inputFrom for commands that cannot default the input. +func requireInput(cmd *cobra.Command, args []string, prefix string) (string, error) { + in, err := inputFrom(cmd, args, prefix) + if err == nil && in == "" { + err = errors.New("no input given: pass a file path or --input") + } + return in, err +} + +// hfSettings are the Hugging Face options shared by generate, scan, enrich and vuln-scan. +type hfSettings struct { + Token string + BaseURL string + Timeout time.Duration +} + +// hfOptions reads the HF flags under prefix, falling back to the standard +// huggingface_hub env vars HF_TOKEN and HF_ENDPOINT. +func hfOptions(prefix string) hfSettings { + return hfSettings{ + Token: cmp.Or(viper.GetString(prefix+".hf-token"), os.Getenv("HF_TOKEN")), + BaseURL: cmp.Or(viper.GetString(prefix+".hf-base-url"), os.Getenv("HF_ENDPOINT")), + Timeout: time.Duration(viper.GetInt(prefix+".hf-timeout")) * time.Second, + } +} + +// addHFFlags registers the shared Hugging Face flags on c. +func addHFFlags(c *cobra.Command) { + c.Flags().String("hf-token", "", "Hugging Face access token (default $HF_TOKEN)") + c.Flags().String("hf-base-url", "", "Hugging Face endpoint (default $HF_ENDPOINT or https://huggingface.co)") + c.Flags().Int("hf-timeout", 10, "Timeout in seconds per Hugging Face API request") +} + +// addHFModeFlag registers the hidden --hf-mode flag: "dummy" builds a fixture +// BOM without network access (CI smoke tests), "online" is normal operation. +func addHFModeFlag(c *cobra.Command) { + c.Flags().String("hf-mode", "online", "Hugging Face metadata mode: online|dummy") + _ = c.Flags().MarkHidden("hf-mode") +} + +// hfMode validates .hf-mode. +func hfMode(prefix string) (string, error) { + switch mode := strings.ToLower(strings.TrimSpace(viper.GetString(prefix + ".hf-mode"))); mode { + case "online", "dummy": + return mode, nil + default: + return "", fmt.Errorf("invalid mode %q for --hf-mode (expected online|dummy)", mode) + } +} -// addDeprecatedFlag keeps a removed string flag parseable (hidden and ignored), so -// existing scripts get a deprecation notice instead of an "unknown flag" error. -func addDeprecatedFlag(c *cobra.Command, name, shorthand, msg string) { - c.Flags().StringP(name, shorthand, "", "") - _ = c.Flags().MarkDeprecated(name, msg) +// writeJSON writes v to w as indented JSON (the --json output of a command). +func writeJSON(w io.Writer, v any) error { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return enc.Encode(v) } diff --git a/cmd/aibomgen-cli/completeness.go b/cmd/aibomgen-cli/completeness.go index ee22d0e..2b71e90 100644 --- a/cmd/aibomgen-cli/completeness.go +++ b/cmd/aibomgen-cli/completeness.go @@ -1,9 +1,6 @@ package cmd import ( - "errors" - "fmt" - "github.com/spf13/cobra" "github.com/spf13/viper" @@ -13,22 +10,17 @@ import ( ) var completenessCmd = &cobra.Command{ - Use: "completeness", + Use: "completeness [file]", Short: "Compute completeness score for an AIBOM", Long: "Reads an existing CycloneDX AIBOM (json/xml) and scores it against the configured field registry.", + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + quiet := verbosity < 0 - // Get log level from viper. - quiet, err := quietFrom(viper.GetString("completeness.log-level")) + inputPath, err := requireInput(cmd, args, "completeness") if err != nil { return err } - - // Get input path from viper. - inputPath := viper.GetString("completeness.input") - if inputPath == "" { - return errors.New("--input is required") - } bom, err := bomio.ReadBOM(inputPath) if err != nil { return err @@ -36,31 +28,17 @@ var completenessCmd = &cobra.Command{ res := completeness.Check(bom) - // If plain-summary requested, print a machine-readable plain summary (no styling). - if viper.GetBool("completeness.plain-summary") { - // Model summary line. - fmt.Printf("Model: %s | Score: %.1f%% | Fields: %d/%d\n", res.ModelID, res.Score*100, res.Passed, res.Total) - // Dataset summary lines (if any). - for dsName, ds := range res.DatasetResults { - fmt.Printf("Dataset: %s | Score: %.1f%% | Fields: %d/%d\n", dsName, ds.Score*100, ds.Passed, ds.Total) - } - return nil + if viper.GetBool("completeness.json") { + return writeJSON(cmd.OutOrStdout(), res) } - - // Use the new UI for rendering if not in quiet mode. - ui := ui.NewCompletenessUI(cmd.OutOrStdout(), quiet) - ui.PrintReport(res) - + ui.NewCompletenessUI(cmd.OutOrStdout(), quiet).PrintReport(res) return nil }, } func init() { - completenessCmd.Flags().StringP("input", "i", "", "Path to existing AIBOM file (required)") - addDeprecatedFlag(completenessCmd, "format", "f", inputFormatDeprecation) - completenessCmd.Flags().String("log-level", "", "Log level: quiet|standard|debug") - completenessCmd.Flags().Bool("plain-summary", false, "Print a single-line plain summary (no styling)") + completenessCmd.Flags().StringP("input", "i", "", "AIBOM file, instead of the argument") + completenessCmd.Flags().Bool("json", false, "Print the result as JSON") - // Bind all flags to viper for config file support. bindFlags(completenessCmd, "completeness") } diff --git a/cmd/aibomgen-cli/enrich.go b/cmd/aibomgen-cli/enrich.go index e1c636e..a1b47f2 100644 --- a/cmd/aibomgen-cli/enrich.go +++ b/cmd/aibomgen-cli/enrich.go @@ -3,7 +3,9 @@ package cmd import ( "errors" "fmt" + "github.com/idlab-discover/aibomgen-cli/internal/apperr" "strings" + "time" "github.com/idlab-discover/aibomgen-cli/internal/enricher" "github.com/idlab-discover/aibomgen-cli/internal/ui" @@ -14,35 +16,36 @@ import ( // enrichCmd represents the enrich command. var enrichCmd = &cobra.Command{ - Use: "enrich", + Use: "enrich [file]", Short: "Enrich an existing AIBOM with additional metadata", - Long: `Enrich an existing AIBOM with additional metadata through interactive prompts -or by loading values from a configuration file. Optionally refetch model metadata -from Hugging Face API and README before enrichment.`, + Long: `Enrich an existing AIBOM with additional metadata, prompting for missing fields +or, with --file, loading the values from a YAML file (see config/enrichment.yaml). +By default the model metadata is first refetched from the Hugging Face API and README.`, + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - // Get strategy from viper (respects config file). - strategy := strings.ToLower(strings.TrimSpace(viper.GetString("enrich.strategy"))) - if strategy == "" { - strategy = "interactive" - } - switch strategy { - case "interactive", "file": - // ok. - default: - return fmt.Errorf("invalid strategy %q (expected interactive|file)", strategy) + quiet := verbosity < 0 + yes := viper.GetBool("enrich.yes") + configFile := strings.TrimSpace(viper.GetString("enrich.file")) + strategy := "interactive" + if configFile != "" { + strategy = "file" } - // Get log level from viper. - quiet, err := quietFrom(viper.GetString("enrich.log-level")) + inputPath, err := requireInput(cmd, args, "enrich") if err != nil { return err } - // Read existing BOM. - inputPath := viper.GetString("enrich.input") - if inputPath == "" { - return errors.New("--input is required") + // Fail before any work if a prompt would be needed but can't be shown. + if !ui.CanPrompt() { + if strategy == "interactive" { + return errors.New("interactive enrichment needs a terminal; pass --file ") + } + if !yes { + return errors.New("confirming the changes needs a terminal; pass --yes to save without asking") + } } + bom, err := bomio.ReadBOM(inputPath) if err != nil { return fmt.Errorf("failed to read input BOM: %w", err) @@ -57,26 +60,22 @@ from Hugging Face API and README before enrichment.`, // Get settings from viper (respects config file). specVersion := strings.TrimSpace(viper.GetString("enrich.spec")) - // Build enricher configuration. + hf := hfOptions("enrich") cfg := enricher.Config{ Strategy: strategy, - ConfigFile: viper.GetString("enrich.file"), + ConfigFile: configFile, RequiredOnly: viper.GetBool("enrich.required-only"), MinWeight: viper.GetFloat64("enrich.min-weight"), Refetch: viper.GetBool("enrich.refetch"), - NoPreview: viper.GetBool("enrich.no-preview"), - HFToken: viper.GetString("enrich.hf-token"), - HFBaseURL: viper.GetString("enrich.hf-base-url"), - HFTimeout: viper.GetInt("enrich.hf-timeout"), + Yes: yes, + HFToken: hf.Token, + HFBaseURL: hf.BaseURL, + HFTimeout: int(hf.Timeout / time.Second), } - // Load config file values if using file strategy. + // Load the enrichment values when a file is given. var configViper *viper.Viper if strategy == "file" { - configFile := cfg.ConfigFile - if configFile == "" { - configFile = "./config/enrichment.yaml" - } configViper, err = loadEnrichmentConfig(configFile) if err != nil { return fmt.Errorf("failed to load config file: %w", err) @@ -84,13 +83,13 @@ from Hugging Face API and README before enrichment.`, } // Create enricher. - e := enricher.New(enricher.Options{ - Writer: cmd.OutOrStdout(), - Config: cfg, - }) + e := enricher.New(enricher.Options{Config: cfg}) // Run enrichment. enriched, err := e.Enrich(bom, configViper) + if errors.Is(err, apperr.ErrCancelled) { + return apperr.ErrCancelled + } if err != nil { return fmt.Errorf("enrichment failed: %w", err) } @@ -110,25 +109,16 @@ from Hugging Face API and README before enrichment.`, } func init() { - enrichCmd.Flags().StringP("input", "i", "", "Path to existing AIBOM (required)") - enrichCmd.Flags().StringP("output", "o", "", "Output file path (default: overwrite input)") - addDeprecatedFlag(enrichCmd, "format", "f", inputFormatDeprecation) - addDeprecatedFlag(enrichCmd, "output-format", "", outputFormatDeprecation) + enrichCmd.Flags().StringP("input", "i", "", "AIBOM file, instead of the argument") + enrichCmd.Flags().StringP("output", "o", "", "Output file (default: overwrite the input); .xml writes XML, anything else JSON") enrichCmd.Flags().String("spec", "", "CycloneDX spec version for output (default: same as input)") + enrichCmd.Flags().String("file", "", "YAML file with the values to fill in, instead of prompting") + enrichCmd.Flags().Bool("required-only", false, "Only fill required fields") + enrichCmd.Flags().Float64("min-weight", 0.0, "Only fill fields with weight >= this value") + enrichCmd.Flags().Bool("refetch", true, "Refetch model metadata from Hugging Face first (uses the --hf-* flags)") + enrichCmd.Flags().BoolP("yes", "y", false, "Save without preview or confirmation") + addHFFlags(enrichCmd) - enrichCmd.Flags().String("strategy", "", "Enrichment strategy: interactive|file") - enrichCmd.Flags().String("file", "", "Path to enrichment config file (YAML)") - enrichCmd.Flags().Bool("required-only", false, "Only prompt for required fields") - enrichCmd.Flags().Float64("min-weight", 0.0, "Only prompt for fields with weight >= this value") - enrichCmd.Flags().Bool("refetch", false, "Refetch model metadata from Hugging Face before enrichment") - enrichCmd.Flags().Bool("no-preview", false, "Skip preview before saving") - - enrichCmd.Flags().String("log-level", "", "Log level: quiet|standard|debug") - enrichCmd.Flags().String("hf-token", "", "Hugging Face API token (for refetch)") - enrichCmd.Flags().String("hf-base-url", "", "Hugging Face base URL (for refetch)") - enrichCmd.Flags().Int("hf-timeout", 0, "Hugging Face API timeout in seconds (for refetch)") - - // Bind all flags to viper for config file support. bindFlags(enrichCmd, "enrich") } diff --git a/cmd/aibomgen-cli/generate.go b/cmd/aibomgen-cli/generate.go index a1cd611..16cf5dd 100644 --- a/cmd/aibomgen-cli/generate.go +++ b/cmd/aibomgen-cli/generate.go @@ -3,9 +3,7 @@ package cmd import ( "errors" "fmt" - "os" "strings" - "time" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -17,39 +15,27 @@ import ( // generateCmd represents the generate command. var generateCmd = &cobra.Command{ - Use: "generate", + Use: "generate [model-id...]", Short: "Generate an AI-aware BOM (AIBOM) from Hugging Face model IDs", - Long: "Generate BOM from Hugging Face model ID(s). Use --model-id to specify models directly or --interactive for a model selector. Use 'scan' command to scan directories for AI imports.", + Long: "Generate BOM from Hugging Face model ID(s), given as arguments or with --model-id, or picked with --interactive. Use 'scan' command to scan directories for AI imports.", + Args: cobra.ArbitraryArgs, RunE: runGenerate, } func runGenerate(cmd *cobra.Command, args []string) error { - // Resolve effective log level (from config, env, or flag). - quiet, err := quietFrom(viper.GetString("generate.log-level")) + quiet := verbosity < 0 + + mode, err := hfMode("generate") if err != nil { return err } - // Resolve effective HF mode (from config, env, or flag). - mode := strings.ToLower(strings.TrimSpace(viper.GetString("generate.hf-mode"))) - if mode == "" { - mode = "online" - } - switch mode { - case "online", "dummy": - // ok. - default: - return fmt.Errorf("invalid --hf-mode %q (expected online|dummy)", mode) - } - // Check if --interactive was explicitly provided. interactiveMode := viper.GetBool("generate.interactive") - // Check if --model-id was explicitly provided on the command line. - modelIDFlagProvided := cmd.Flags().Changed("model-id") - - // Get model IDs from viper (respects config file and CLI flag). - modelIDs := viper.GetStringSlice("generate.model-ids") + // Model IDs come from arguments and --model-id (flag, env or config). + modelIDs := append(viper.GetStringSlice("generate.model-id"), args...) + modelIDFlagProvided := cmd.Flags().Changed("model-id") || len(args) > 0 // Filter out empty strings. var cleanModelIDs []string for _, id := range modelIDs { @@ -65,7 +51,7 @@ func runGenerate(cmd *cobra.Command, args []string) error { // Interactive mode validation. if interactiveMode { if modelIDFlagProvided { - return errors.New("--interactive cannot be used with --model-id") + return errors.New("cannot combine --interactive with model IDs") } } @@ -73,16 +59,16 @@ func runGenerate(cmd *cobra.Command, args []string) error { // Dummy mode uses a built-in fixture (BuildDummyBOM) — allow empty input only. if mode == "dummy" { if modelIDFlagProvided || len(cleanModelIDs) > 0 { - return errors.New("--model-id cannot be used with --hf-mode=dummy") + return errors.New("cannot combine model IDs with --hf-mode=dummy") } if interactiveMode { - return errors.New("--interactive cannot be used with --hf-mode=dummy") + return errors.New("cannot combine --interactive with --hf-mode=dummy") } } // Validate that we have either model IDs or interactive mode for non-dummy modes. if !interactiveMode && len(cleanModelIDs) == 0 && mode != "dummy" { - return errors.New("either --model-id or --interactive is required. Use 'scan' command to scan directories") + return errors.New("no model given: pass model IDs or --interactive (use 'scan' to scan a directory)") } // Validate the output format before doing any work. @@ -92,22 +78,20 @@ func runGenerate(cmd *cobra.Command, args []string) error { } outputDir := viper.GetString("generate.output") - // Get HF settings. - hfToken := viper.GetString("generate.hf-token") - hfTimeout := viper.GetInt("generate.hf-timeout") - if hfTimeout <= 0 { - hfTimeout = 10 - } - timeout := time.Duration(hfTimeout) * time.Second + hf := hfOptions("generate") // Create UI handler. genUI := ui.NewGenerateUI(cmd.OutOrStdout(), quiet) if interactiveMode { + if !ui.CanPrompt() { + return errors.New("interactive model selection needs a terminal; pass model IDs instead") + } // Interactive mode: show model selector. selectedModels, err := ui.RunModelSelector(ui.ModelSelectorConfig{ - HFToken: hfToken, - Timeout: timeout, + HFToken: hf.Token, + BaseURL: hf.BaseURL, + Timeout: hf.Timeout, }) if err != nil { return err @@ -120,15 +104,15 @@ func runGenerate(cmd *cobra.Command, args []string) error { // Generate BOMs from model IDs. var discoveredBOMs []generator.DiscoveredBOM - if err := runModelIDMode(genUI, cleanModelIDs, mode, hfToken, timeout, quiet, &discoveredBOMs); err != nil { + if err := runModelIDMode(genUI, cleanModelIDs, mode, hf, quiet, &discoveredBOMs); err != nil { return err } return writeDiscovered(genUI, discoveredBOMs, outputDir, fmtChosen, viper.GetString("generate.spec")) } -func runModelIDMode(genUI *ui.GenerateUI, modelIDs []string, mode, hfToken string, timeout time.Duration, quiet bool, results *[]generator.DiscoveredBOM) error { - hasToken := strings.TrimSpace(hfToken) != "" +func runModelIDMode(genUI *ui.GenerateUI, modelIDs []string, mode string, hf hfSettings, quiet bool, results *[]generator.DiscoveredBOM) error { + hasToken := strings.TrimSpace(hf.Token) != "" if mode == "dummy" { if !quiet { genUI.LogStep("info", "Using dummy mode (no API calls)") @@ -146,7 +130,7 @@ func runModelIDMode(genUI *ui.GenerateUI, modelIDs []string, mode, hfToken strin var processTaskIdx, writeTaskIdx int if !quiet { - workflow = ui.NewWorkflow(os.Stdout) + workflow = newWorkflow() processTaskIdx = workflow.AddTask("Processing possible models") writeTaskIdx = workflow.AddTask("Writing output") workflow.Start() @@ -155,8 +139,9 @@ func runModelIDMode(genUI *ui.GenerateUI, modelIDs []string, mode, hfToken strin onProgress, finish := trackProgress(workflow, processTaskIdx, writeTaskIdx, len(modelIDs), hasToken) opts := generator.GenerateOptions{ - HFToken: hfToken, - Timeout: timeout, + HFToken: hf.Token, + BaseURL: hf.BaseURL, + Timeout: hf.Timeout, OnProgress: onProgress, SkipSecurityScan: viper.GetBool("generate.no-security-scan"), } @@ -175,21 +160,16 @@ func runModelIDMode(genUI *ui.GenerateUI, modelIDs []string, mode, hfToken strin } func init() { - generateCmd.Flags().StringSliceP("model-id", "m", []string{}, "Hugging Face model ID(s) (e.g., gpt2, org/model-name or org/model-name@revision) - can be used multiple times or comma-separated") - generateCmd.Flags().StringP("output", "o", "", "Output directory (default dist)") - generateCmd.Flags().StringP("format", "f", "", "Output BOM format: json|xml (default json)") - generateCmd.Flags().String("spec", "", "CycloneDX spec version for output (e.g., 1.5, 1.6, 1.7; default 1.7)") - generateCmd.Flags().String("hf-mode", "", "Hugging Face metadata mode: online|dummy") - generateCmd.Flags().Int("hf-timeout", 0, "Timeout in seconds per Hugging Face API request (default 10)") - generateCmd.Flags().String("hf-token", "", "Hugging Face access token") - generateCmd.Flags().String("log-level", "", "Log level: quiet|standard|debug") - generateCmd.Flags().Bool("interactive", false, "Interactive model selector (cannot be used with --model-id)") - generateCmd.Flags().Bool("no-security-scan", false, "Skip fetching the HuggingFace security scan tree") - - // Bind all flags to viper for config file support; the model-id flag - // is (also) exposed under the plural config key. + generateCmd.Flags().StringSliceP("model-id", "m", []string{}, "Hugging Face model ID (org/name or org/name@revision); repeatable, comma-separated, or given as arguments") + generateCmd.Flags().StringP("output", "o", "dist", "Output directory; one file per model") + generateCmd.Flags().StringP("format", "f", "json", "Output BOM format: json|xml") + generateCmd.Flags().String("spec", "", "CycloneDX spec version for output, e.g. 1.6 (default latest)") + generateCmd.Flags().Bool("interactive", false, "Pick models in an interactive selector") + generateCmd.Flags().Bool("no-security-scan", false, "Skip fetching the Hugging Face security scan tree") + addHFFlags(generateCmd) + addHFModeFlag(generateCmd) + bindFlags(generateCmd, "generate") - _ = viper.BindPFlag("generate.model-ids", generateCmd.Flags().Lookup("model-id")) } // datasetResult holds the outcome of fetching a single dataset referenced by a model. diff --git a/cmd/aibomgen-cli/merge.go b/cmd/aibomgen-cli/merge.go index f2bbe2b..2c36e11 100644 --- a/cmd/aibomgen-cli/merge.go +++ b/cmd/aibomgen-cli/merge.go @@ -15,7 +15,7 @@ import ( ) var mergeCmd = &cobra.Command{ - Use: "merge", + Use: "merge [aibom...]", Short: "[BETA] Merge one or more AIBOMs with an existing SBOM", Long: `[BETA] Merges one or more AI Bill of Materials (AIBOMs) with a Software Bill of Materials (SBOM) from a different source. This allows you to combine AI/ML component information with traditional software dependencies into a single comprehensive BOM. @@ -27,42 +27,36 @@ Example: # Generate SBOM with Syft syft scan . -o cyclonedx-json > sbom.json - # Generate AIBOM with AIBoMGen - ./aibomgen-cli generate -i . -o aibom.json + # Generate AIBOMs with AIBoMGen (one file per model in dist/) + aibomgen-cli scan . # Merge them together - ./aibomgen-cli merge --aibom aibom.json --sbom sbom.json -o merged.json - - # Merge multiple AIBOMs with one SBOM - ./aibomgen-cli merge --aibom model1_aibom.json --aibom model2_aibom.json --sbom sbom.json -o merged.json`, + aibomgen-cli merge dist/*.aibom.cdx.json --sbom sbom.json -o merged.json`, + Args: cobra.ArbitraryArgs, RunE: func(cmd *cobra.Command, args []string) error { - // Get inputs from viper (respects config file and CLI flag). - aibomPaths := viper.GetStringSlice("merge.aiboms") + // AIBOMs come from arguments and --aibom (flag, env or config). + aibomPaths := append(viper.GetStringSlice("merge.aibom"), args...) if len(aibomPaths) == 0 { - return errors.New("at least one --aibom is required") + return errors.New("no AIBOM given: pass AIBOM files or --aibom") } sbomPath := viper.GetString("merge.sbom") if sbomPath == "" { - return errors.New("--sbom is required") + return errors.New("no SBOM given: pass --sbom") } outputPath := viper.GetString("merge.output") if outputPath == "" { - return errors.New("--output is required") + return errors.New("no output given: pass --output") } - // Get log level from viper. - quiet, err := quietFrom(viper.GetString("merge.log-level")) - if err != nil { - return err - } + quiet := verbosity < 0 // Initialize UI (workflow is nil when quiet). start := time.Now() var wf *ui.Workflow if !quiet { - wf = ui.NewWorkflow(os.Stdout) + wf = newWorkflow() wf.AddTask("Reading SBOM") wf.AddTask("Reading AIBOM(s)") wf.AddTask("Merging BOMs") @@ -121,7 +115,7 @@ Example: // Prepare merge options. opts := merger.MergeOptions{ - DeduplicateComponents: viper.GetBool("merge.deduplicate"), + DeduplicateComponents: !viper.GetBool("merge.no-deduplicate"), } // Perform merge. @@ -156,14 +150,10 @@ Example: } func init() { - mergeCmd.Flags().StringSlice("aibom", []string{}, "Path to AIBOM file (can be specified multiple times, required)") - mergeCmd.Flags().String("sbom", "", "Path to SBOM file (required)") - mergeCmd.Flags().StringP("output", "o", "", "Output path for merged BOM (required)") - addDeprecatedFlag(mergeCmd, "format", "f", outputFormatDeprecation) - mergeCmd.Flags().Bool("deduplicate", true, "Remove duplicate components based on BOM-ref") - mergeCmd.Flags().String("log-level", "", "Log level: quiet|standard|debug") - - // Bind all flags to viper for config file support. + mergeCmd.Flags().StringSlice("aibom", []string{}, "AIBOM file; repeatable, or given as arguments") + mergeCmd.Flags().String("sbom", "", "SBOM file to merge into (required)") + mergeCmd.Flags().StringP("output", "o", "", "Output file for the merged BOM (required); .xml writes XML, anything else JSON") + mergeCmd.Flags().Bool("no-deduplicate", false, "Keep components with duplicate BOM-refs") + bindFlags(mergeCmd, "merge") - _ = viper.BindPFlag("merge.aiboms", mergeCmd.Flags().Lookup("aibom")) } diff --git a/cmd/aibomgen-cli/root.go b/cmd/aibomgen-cli/root.go index 92d6b17..195ee06 100644 --- a/cmd/aibomgen-cli/root.go +++ b/cmd/aibomgen-cli/root.go @@ -3,6 +3,7 @@ package cmd import ( "errors" "fmt" + "log/slog" "os" "strings" @@ -14,12 +15,25 @@ import ( // RootCmd represents the base command. var RootCmd = &cobra.Command{ Use: "aibomgen-cli", - Short: "BOM Generator for Software Projects using AI {}", + Short: "BOM Generator for Software Projects using AI", Long: longDescription, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { initUIAndBanner(cmd) - return initConfig() + if err := initConfig(); err != nil { + return err + } + v, err := resolveVerbosity() + if err != nil { + return err + } + verbosity = v + setLogger(v) + ui.NoInput = viper.GetBool("no-input") + if f := viper.ConfigFileUsed(); f != "" { + slog.Info("using config file", "path", f) + } + return nil }, // When invoked without a subcommand, show help (with banner) instead of. @@ -39,6 +53,13 @@ func init() { // will be global for your application. RootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.aibomgen-cli.yaml or ./config/defaults.yaml)") + RootCmd.PersistentFlags().BoolP("quiet", "q", false, "Only print errors and results") + RootCmd.PersistentFlags().CountP("verbose", "v", "Verbose logging to stderr (-v info, -vv debug)") + RootCmd.PersistentFlags().Bool("no-input", false, "Never prompt; fail with a hint when input would be needed") + RootCmd.MarkFlagsMutuallyExclusive("quiet", "verbose") + for _, name := range []string{"quiet", "verbose", "no-input"} { + _ = viper.BindPFlag(name, RootCmd.PersistentFlags().Lookup(name)) + } // Ensure `--help` (and help subcommands) show a green banner consistently. defaultHelp := RootCmd.HelpFunc() @@ -52,7 +73,7 @@ func init() { RootCmd.SilenceUsage = true // Add subcommands. - RootCmd.AddCommand(generateCmd, scanCmd, enrichCmd, validateCmd, completenessCmd, mergeCmd, vulnScanCmd) + RootCmd.AddCommand(generateCmd, scanCmd, enrichCmd, validateCmd, completenessCmd, mergeCmd, vulnScanCmd, versionCmd) } func initConfig() error { @@ -89,17 +110,23 @@ func initConfig() error { } } - switch { - case err == nil: - configMsg := ui.Dim.Render("Using config file: ") + ui.Secondary.Render(viper.ConfigFileUsed()) - fmt.Fprintln(os.Stderr, configMsg) - case !errors.As(err, notFound): + // The config file is optional, we shouldn't exit when the config is not found. + if err != nil && !errors.As(err, notFound) { return fmt.Errorf("reading config file: %w", err) } - // The config file is optional, we shouldn't exit when the config is not found. return nil } +// versionCmd prints the version that fang sets on the root command. +var versionCmd = &cobra.Command{ + Use: "version", + Short: "Print the aibomgen-cli version", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + fmt.Fprintln(cmd.OutOrStdout(), cmd.Root().Version) + }, +} + const longDescription = "BOM Generator for Software Projects using AI. Helps PDE manufacturers create accurate Bills of Materials for their AI-based software projects." func initUIAndBanner(cmd *cobra.Command) { diff --git a/cmd/aibomgen-cli/root_test.go b/cmd/aibomgen-cli/root_test.go index 0e2759e..0067a8c 100644 --- a/cmd/aibomgen-cli/root_test.go +++ b/cmd/aibomgen-cli/root_test.go @@ -1,11 +1,20 @@ package cmd import ( + "bytes" + "encoding/json" + "errors" + "log/slog" "os" "path/filepath" "testing" + "github.com/spf13/cobra" "github.com/spf13/viper" + + "github.com/idlab-discover/aibomgen-cli/internal/apperr" + "github.com/idlab-discover/aibomgen-cli/pkg/aibomgen/bomio" + "github.com/idlab-discover/aibomgen-cli/pkg/aibomgen/generator" ) func TestInitConfigMalformedReturnsError(t *testing.T) { @@ -21,3 +30,115 @@ func TestInitConfigMalformedReturnsError(t *testing.T) { t.Fatal("want error for malformed config, got nil") } } + +func TestResolveVerbosity(t *testing.T) { + tests := []struct { + name string + set map[string]any + want int + wantErr bool + }{ + {"default", nil, 0, false}, + {"quiet", map[string]any{"quiet": true}, -1, false}, + {"verbose", map[string]any{"verbose": 1}, 1, false}, + {"debug", map[string]any{"verbose": 2}, 2, false}, + {"capped", map[string]any{"verbose": 5}, 2, false}, + {"both", map[string]any{"quiet": true, "verbose": 1}, 0, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + viper.Reset() + t.Cleanup(viper.Reset) + for k, v := range tt.set { + viper.Set(k, v) + } + got, err := resolveVerbosity() + if (err != nil) != tt.wantErr { + t.Fatalf("err = %v, wantErr %v", err, tt.wantErr) + } + if err == nil && got != tt.want { + t.Fatalf("got %d, want %d", got, tt.want) + } + }) + } +} + +func TestSlogLevel(t *testing.T) { + for v, want := range map[int]slog.Level{-1: slog.LevelError, 0: slog.LevelWarn, 1: slog.LevelInfo, 2: slog.LevelDebug} { + if got := slogLevel(v); got != want { + t.Errorf("slogLevel(%d) = %v, want %v", v, got, want) + } + } +} + +func TestInputFrom(t *testing.T) { + tests := []struct { + name string + args []string + flag string + want string + wantErr bool + }{ + {"argument", []string{"a.json"}, "", "a.json", false}, + {"flag", nil, "b.json", "b.json", false}, + {"neither", nil, "", "", false}, + {"both", []string{"a.json"}, "b.json", "", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + viper.Reset() + t.Cleanup(viper.Reset) + c := &cobra.Command{} + c.Flags().String("input", "", "") + bindFlags(c, "x") + if tt.flag != "" { + _ = c.Flags().Set("input", tt.flag) + } + got, err := inputFrom(c, tt.args, "x") + if (err != nil) != tt.wantErr || got != tt.want { + t.Fatalf("got %q, %v; want %q, wantErr %v", got, err, tt.want, tt.wantErr) + } + }) + } +} + +// TestValidateJSON runs `validate --json ` end to end through RootCmd. +func TestValidateJSON(t *testing.T) { + dir := t.TempDir() + boms, err := generator.BuildDummyBOM() + if err != nil { + t.Fatal(err) + } + bomPath := filepath.Join(dir, "bom.json") + if err := bomio.WriteBOM(boms[0].BOM, bomPath, ""); err != nil { + t.Fatal(err) + } + emptyCfg := filepath.Join(dir, "cfg.yaml") + if err := os.WriteFile(emptyCfg, nil, 0o600); err != nil { + t.Fatal(err) + } + + // Earlier tests reset viper, which drops the flag bindings made in init(). + viper.Reset() + t.Cleanup(viper.Reset) + bindFlags(validateCmd, "validate") + for _, name := range []string{"quiet", "verbose", "no-input"} { + _ = viper.BindPFlag(name, RootCmd.PersistentFlags().Lookup(name)) + } + + var out bytes.Buffer + RootCmd.SetOut(&out) + RootCmd.SetArgs([]string{"--config", emptyCfg, "validate", "--json", bomPath}) + t.Cleanup(func() { RootCmd.SetOut(nil); RootCmd.SetArgs(nil) }) + if err := RootCmd.Execute(); err != nil && !errors.Is(err, apperr.ErrValidation) { + t.Fatal(err) + } + + var got map[string]any + if err := json.Unmarshal(out.Bytes(), &got); err != nil { + t.Fatalf("output is not JSON: %v\n%s", err, out.String()) + } + if _, ok := got["valid"]; !ok { + t.Fatalf("missing \"valid\" key: %s", out.String()) + } +} diff --git a/cmd/aibomgen-cli/scan.go b/cmd/aibomgen-cli/scan.go index 99d1fd2..be84fed 100644 --- a/cmd/aibomgen-cli/scan.go +++ b/cmd/aibomgen-cli/scan.go @@ -6,7 +6,6 @@ import ( "os" "path/filepath" "strings" - "time" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -18,44 +17,33 @@ import ( // scanCmd represents the scan command. var scanCmd = &cobra.Command{ - Use: "scan", + Use: "scan [dir]", Short: "Scan a directory for AI imports and generate AIBOMs", Long: "Scan a directory or repository for AI-related imports (e.g., Hugging Face models) and generate AI-aware BOMs.", + Args: cobra.MaximumNArgs(1), RunE: runScan, } func runScan(cmd *cobra.Command, args []string) error { - // Resolve effective log level (from config, env, or flag). - quiet, err := quietFrom(viper.GetString("scan.log-level")) + quiet := verbosity < 0 + + mode, err := hfMode("scan") if err != nil { return err } - // Resolve effective HF mode (from config, env, or flag). - mode := strings.ToLower(strings.TrimSpace(viper.GetString("scan.hf-mode"))) - if mode == "" { - mode = "online" + inputPath, err := inputFrom(cmd, args, "scan") + if err != nil { + return err } - switch mode { - case "online", "dummy": - // ok. - default: - return fmt.Errorf("invalid --hf-mode %q (expected online|dummy)", mode) + // Dummy mode uses built-in fixture data and does not read the filesystem. + if mode == "dummy" && inputPath != "" { + return errors.New("cannot combine an input directory with --hf-mode=dummy") } - - inputPath := viper.GetString("scan.input") - // Detect whether the user explicitly provided --input on the CLI (vs. using default). - inputPathProvided := cmd.Flags().Changed("input") if inputPath == "" { inputPath = "." } - // Disallow providing an input path when running in dummy HF mode — dummy mode. - // uses built-in fixture data and does not consult the filesystem. - if mode == "dummy" && inputPathProvided { - return errors.New("--input cannot be used with --hf-mode=dummy") - } - // Validate the output format before doing any work. fmtChosen, err := outputFormat(viper.GetString("scan.format")) if err != nil { @@ -63,25 +51,17 @@ func runScan(cmd *cobra.Command, args []string) error { } outputDir := viper.GetString("scan.output") - // Get HF settings. - hfToken := viper.GetString("scan.hf-token") - hfTimeout := viper.GetInt("scan.hf-timeout") - if hfTimeout <= 0 { - hfTimeout = 10 - } - timeout := time.Duration(hfTimeout) * time.Second - // Run the scan. var discoveredBOMs []generator.DiscoveredBOM - if err := runScanDirectory(inputPath, mode, hfToken, timeout, quiet, &discoveredBOMs); err != nil { + if err := runScanDirectory(inputPath, mode, hfOptions("scan"), quiet, &discoveredBOMs); err != nil { return err } return writeDiscovered(ui.NewGenerateUI(cmd.OutOrStdout(), quiet), discoveredBOMs, outputDir, fmtChosen, viper.GetString("scan.spec")) } -func runScanDirectory(inputPath, mode, hfToken string, timeout time.Duration, quiet bool, results *[]generator.DiscoveredBOM) error { - hasToken := strings.TrimSpace(hfToken) != "" +func runScanDirectory(inputPath, mode string, hf hfSettings, quiet bool, results *[]generator.DiscoveredBOM) error { + hasToken := strings.TrimSpace(hf.Token) != "" absTarget, err := filepath.Abs(inputPath) if err != nil { return err @@ -105,7 +85,7 @@ func runScanDirectory(inputPath, mode, hfToken string, timeout time.Duration, qu var scanTaskIdx, processTaskIdx, writeTaskIdx int if !quiet { - workflow = ui.NewWorkflow(os.Stdout) + workflow = newWorkflow() scanTaskIdx = workflow.AddTask("Scanning for possible AI imports") processTaskIdx = workflow.AddTask("Processing possible models") writeTaskIdx = workflow.AddTask("Writing output") @@ -144,8 +124,9 @@ func runScanDirectory(inputPath, mode, hfToken string, timeout time.Duration, qu onProgress, finish := trackProgress(workflow, processTaskIdx, writeTaskIdx, len(discoveries), hasToken) opts := generator.GenerateOptions{ - HFToken: hfToken, - Timeout: timeout, + HFToken: hf.Token, + BaseURL: hf.BaseURL, + Timeout: hf.Timeout, OnProgress: onProgress, SkipSecurityScan: viper.GetBool("scan.no-security-scan"), } @@ -165,16 +146,13 @@ func runScanDirectory(inputPath, mode, hfToken string, timeout time.Duration, qu } func init() { - scanCmd.Flags().StringP("input", "i", "", "Path to scan (defaults to current directory)") - scanCmd.Flags().StringP("output", "o", "", "Output directory (default dist)") - scanCmd.Flags().StringP("format", "f", "", "Output BOM format: json|xml (default json)") - scanCmd.Flags().String("spec", "", "CycloneDX spec version for output (e.g., 1.5, 1.6, 1.7; default 1.7)") - scanCmd.Flags().String("hf-mode", "", "Hugging Face metadata mode: online|dummy") - scanCmd.Flags().Int("hf-timeout", 0, "Timeout in seconds per Hugging Face API request (default 10)") - scanCmd.Flags().String("hf-token", "", "Hugging Face access token") - scanCmd.Flags().String("log-level", "", "Log level: quiet|standard|debug") - scanCmd.Flags().Bool("no-security-scan", false, "Skip fetching the HuggingFace security scan tree") - - // Bind all flags to viper for config file support. + scanCmd.Flags().StringP("input", "i", "", "Directory to scan, instead of the argument (default current directory)") + scanCmd.Flags().StringP("output", "o", "dist", "Output directory; one file per model") + scanCmd.Flags().StringP("format", "f", "json", "Output BOM format: json|xml") + scanCmd.Flags().String("spec", "", "CycloneDX spec version for output, e.g. 1.6 (default latest)") + scanCmd.Flags().Bool("no-security-scan", false, "Skip fetching the Hugging Face security scan tree") + addHFFlags(scanCmd) + addHFModeFlag(scanCmd) + bindFlags(scanCmd, "scan") } diff --git a/cmd/aibomgen-cli/validate.go b/cmd/aibomgen-cli/validate.go index 80eb040..43f054a 100644 --- a/cmd/aibomgen-cli/validate.go +++ b/cmd/aibomgen-cli/validate.go @@ -1,7 +1,6 @@ package cmd import ( - "errors" "fmt" "os" "strings" @@ -16,7 +15,7 @@ import ( ) var validateCmd = &cobra.Command{ - Use: "validate", + Use: "validate [file]", Short: "Validate an existing AIBOM file", Long: `Validates a CycloneDX AIBOM (JSON or XML). @@ -27,28 +26,21 @@ missing required fields and vulnerabilities at or above --fail-severity fail validation. Exit codes: 0 valid, 1 error (e.g. unreadable file, bad flag), 2 invalid BOM.`, + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - // Get input from viper (respects config file and CLI flag). - inputPath := viper.GetString("validate.input") - if inputPath == "" { - return errors.New("--input is required") - } - - // Get log level from viper. - level := viper.GetString("validate.log-level") - quiet, err := quietFrom(level) + inputPath, err := requireInput(cmd, args, "validate") if err != nil { return err } + quiet := verbosity < 0 + failSeverity := cdx.Severity(strings.ToLower(strings.TrimSpace(viper.GetString("validate.fail-severity")))) switch failSeverity { - case "": - failSeverity = cdx.SeverityMedium case cdx.SeverityCritical, cdx.SeverityHigh, cdx.SeverityMedium, cdx.SeverityLow, cdx.SeverityInfo: // ok. default: - return fmt.Errorf("invalid --fail-severity %q (expected critical|high|medium|low|info)", failSeverity) + return fmt.Errorf("invalid severity %q for --fail-severity (expected critical|high|medium|low|info)", failSeverity) } data, err := os.ReadFile(inputPath) @@ -65,9 +57,13 @@ Exit codes: 0 valid, 1 error (e.g. unreadable file, bad flag), 2 invalid BOM.`, result := validator.ValidateData(data, opts) - // Use the new UI for rendering if not in quiet mode. - ui := ui.NewValidationUI(cmd.OutOrStdout(), quiet, strings.EqualFold(strings.TrimSpace(level), "debug")) - ui.PrintReport(result) + if viper.GetBool("validate.json") { + if err := writeJSON(cmd.OutOrStdout(), result); err != nil { + return err + } + } else { + ui.NewValidationUI(cmd.OutOrStdout(), quiet, verbosity >= 1).PrintReport(result) + } if !result.Valid { return apperr.ErrValidation @@ -78,16 +74,11 @@ Exit codes: 0 valid, 1 error (e.g. unreadable file, bad flag), 2 invalid BOM.`, } func init() { - validateCmd.Flags().StringP("input", "i", "", "Path to AIBOM file (required)") - addDeprecatedFlag(validateCmd, "format", "f", inputFormatDeprecation) + validateCmd.Flags().StringP("input", "i", "", "AIBOM file, instead of the argument") validateCmd.Flags().Bool("strict", false, "Strict mode: fail on missing required fields and on vulnerabilities at or above --fail-severity") - validateCmd.Flags().String("fail-severity", "", "Lowest vulnerability severity that fails --strict: critical|high|medium|low|info (default medium)") + validateCmd.Flags().String("fail-severity", "medium", "Lowest vulnerability severity that fails --strict: critical|high|medium|low|info") validateCmd.Flags().Float64("min-score", 0.0, "Minimum completeness score (0.0-1.0), enforced with or without --strict") - validateCmd.Flags().String("log-level", "", "Log level: quiet|standard|debug (debug also lists missing optional fields)") - // Deprecated: completeness already scores model card fields. - validateCmd.Flags().Bool("check-model-card", false, "") - _ = validateCmd.Flags().MarkDeprecated("check-model-card", "model card fields are covered by the completeness score") + validateCmd.Flags().Bool("json", false, "Print the result as JSON") - // Bind all flags to viper for config file support. bindFlags(validateCmd, "validate") } diff --git a/cmd/aibomgen-cli/vulnscan.go b/cmd/aibomgen-cli/vulnscan.go index 9843936..a555902 100644 --- a/cmd/aibomgen-cli/vulnscan.go +++ b/cmd/aibomgen-cli/vulnscan.go @@ -3,9 +3,10 @@ package cmd import ( "errors" "fmt" + cdx "github.com/CycloneDX/cyclonedx-go" "io" + "os" "strings" - "time" "charm.land/huh/v2" "github.com/idlab-discover/aibomgen-cli/internal/apperr" @@ -19,34 +20,32 @@ import ( // vulnScanCmd represents the vuln-scan command. var vulnScanCmd = &cobra.Command{ - Use: "vuln-scan", + Use: "vuln-scan [file]", Short: "Scan an existing AIBOM for model/dataset security vulnerabilities", Long: `Fetch per-file security scan results from Hugging Face for every model and dataset referenced in an existing AIBOM and display a vulnerability report. Optionally enrich the AIBOM in-place with the discovered vulnerabilities using -the --enrich flag. When --interactive is also set (default when --enrich is -active) a preview and confirmation prompt are shown before writing.`, +the --enrich flag. A preview and confirmation prompt are shown before writing, +unless --yes is set.`, + Args: cobra.MaximumNArgs(1), RunE: runVulnScan, } -func runVulnScan(cmd *cobra.Command, _ []string) error { - inputPath := viper.GetString("vuln-scan.input") - if inputPath == "" { - return errors.New("--input is required") - } - - quiet, err := quietFrom(viper.GetString("vuln-scan.log-level")) +func runVulnScan(cmd *cobra.Command, args []string) error { + inputPath, err := requireInput(cmd, args, "vuln-scan") if err != nil { return err } + asJSON := viper.GetBool("vuln-scan.json") + quiet := verbosity < 0 || asJSON // --json: only JSON on stdout + enrich := viper.GetBool("vuln-scan.enrich") - interactive := viper.GetBool("vuln-scan.interactive") - noPreview := viper.GetBool("vuln-scan.no-preview") - timeout := viper.GetInt("vuln-scan.hf-timeout") - if timeout <= 0 { - timeout = 15 + yes := viper.GetBool("vuln-scan.yes") + // Fail before any work if the confirmation can't be shown. + if enrich && !yes && !ui.CanPrompt() { + return errors.New("confirming the enrichment needs a terminal; pass --yes to apply without asking") } // ── Read AIBOM ──────────────────────────────────────────────────────────. @@ -66,7 +65,7 @@ func runVulnScan(cmd *cobra.Command, _ []string) error { // ── Workflow / progress ──────────────────────────────────────────────────. var workflow *ui.Workflow if !quiet { - workflow = ui.NewWorkflow(w) + workflow = newWorkflow() workflow.AddTask("Scanning components") workflow.AddTask("Building report") workflow.Start() @@ -77,10 +76,11 @@ func runVulnScan(cmd *cobra.Command, _ []string) error { workflow.StartTask(0, "") } + hf := hfOptions("vuln-scan") opts := vulnscan.Options{ - HFToken: viper.GetString("vuln-scan.hf-token"), - Timeout: time.Duration(timeout) * time.Second, - BaseURL: viper.GetString("vuln-scan.hf-base-url"), + HFToken: hf.Token, + Timeout: hf.Timeout, + BaseURL: hf.BaseURL, } results := vulnscan.ScanBOM(bom, opts) @@ -94,7 +94,13 @@ func runVulnScan(cmd *cobra.Command, _ []string) error { } // ── Print report ─────────────────────────────────────────────────────────. - printVulnReport(w, results) + if asJSON { + if err := writeJSON(w, vulnJSON(results)); err != nil { + return err + } + } else { + printVulnReport(w, results) + } // ── Optional enrichment ───────────────────────────────────────────────────. if !enrich { @@ -107,8 +113,11 @@ func runVulnScan(cmd *cobra.Command, _ []string) error { total += len(r.Vulnerabilities) } // Interactive confirmation (only when there is something to add). - if total > 0 && interactive && !noPreview { + if total > 0 && !yes { confirmed, err := confirmVulnEnrich(results) + if errors.Is(err, apperr.ErrCancelled) { + return apperr.ErrCancelled + } if err != nil { return fmt.Errorf("confirmation error: %w", err) } @@ -243,7 +252,8 @@ func confirmVulnEnrich(results []vulnscan.ComponentScanResult) (bool, error) { ui.Warning.Render(fmt.Sprintf("%d vulnerability entries", len(r.Vulnerabilities))))) } - fmt.Println(ui.Box.Render(sb.String())) + // The preview is part of the prompt, so it goes to stderr with the form. + fmt.Fprintln(os.Stderr, ui.Box.Render(sb.String())) var confirm bool form := huh.NewForm( @@ -256,28 +266,47 @@ func confirmVulnEnrich(results []vulnscan.ComponentScanResult) (bool, error) { Negative("No"), ), ) - if err := form.Run(); err != nil { + if err := ui.RunForm(form); err != nil { return false, err } return confirm, nil } func init() { - vulnScanCmd.Flags().StringP("input", "i", "", "Path to existing AIBOM (required)") - vulnScanCmd.Flags().StringP("output", "o", "", "Output path when --enrich is set (default: overwrite input)") - addDeprecatedFlag(vulnScanCmd, "format", "f", inputFormatDeprecation) - addDeprecatedFlag(vulnScanCmd, "output-format", "", outputFormatDeprecation) - vulnScanCmd.Flags().String("spec", "", "CycloneDX spec version for output") + vulnScanCmd.Flags().StringP("input", "i", "", "AIBOM file, instead of the argument") + vulnScanCmd.Flags().StringP("output", "o", "", "Output file with --enrich (default: overwrite the input); .xml writes XML, anything else JSON") + vulnScanCmd.Flags().String("spec", "", "CycloneDX spec version for output (default: same as input)") vulnScanCmd.Flags().Bool("enrich", false, "Inject discovered vulnerabilities back into the AIBOM") - vulnScanCmd.Flags().Bool("interactive", true, "Show confirmation prompt before saving (only with --enrich)") - vulnScanCmd.Flags().Bool("no-preview", false, "Skip preview prompt (only with --enrich)") + vulnScanCmd.Flags().BoolP("yes", "y", false, "Apply without preview or confirmation (only with --enrich)") - vulnScanCmd.Flags().String("log-level", "", "Log level: quiet|standard|debug") - vulnScanCmd.Flags().String("hf-token", "", "Hugging Face API token") - vulnScanCmd.Flags().String("hf-base-url", "", "Hugging Face base URL override") - vulnScanCmd.Flags().Int("hf-timeout", 15, "Hugging Face API timeout in seconds") + vulnScanCmd.Flags().Bool("json", false, "Print the scan results as JSON") + addHFFlags(vulnScanCmd) // Bind to viper. bindFlags(vulnScanCmd, "vuln-scan") } + +// vulnResultJSON is the --json view of one scanned component: the raw tree +// entries are left out and the error becomes a string. +type vulnResultJSON struct { + Ref string `json:"ref"` + Model string `json:"model"` + Vulnerabilities []cdx.Vulnerability `json:"vulnerabilities"` + Error string `json:"error,omitempty"` +} + +func vulnJSON(results []vulnscan.ComponentScanResult) []vulnResultJSON { + out := make([]vulnResultJSON, 0, len(results)) + for _, r := range results { + v := vulnResultJSON{Ref: r.ComponentRef, Model: r.ModelID, Vulnerabilities: r.Vulnerabilities} + if v.Vulnerabilities == nil { + v.Vulnerabilities = []cdx.Vulnerability{} + } + if r.Err != nil { + v.Error = r.Err.Error() + } + out = append(out, v) + } + return out +} diff --git a/config/defaults.yaml b/config/defaults.yaml index 61a522f..dbaf5f6 100644 --- a/config/defaults.yaml +++ b/config/defaults.yaml @@ -2,91 +2,92 @@ # Reference of every configurable key with its default. Copy it to # $HOME/.aibomgen-cli.yaml (or pass --config ) and edit as needed. # When run from the repo root without --config, this file is loaded as-is. +# Keys match the flag names; any key can also be set as an AIBOMGEN_* env var +# (e.g. generate.hf-token -> AIBOMGEN_GENERATE_HF_TOKEN). + +# Output verbosity for all commands (same as the -q / -v / -vv flags). +# quiet suppresses progress output; verbose 1 logs info, 2 logs debug, to stderr. +quiet: false +verbose: 0 +# Never prompt; commands that would need input fail with a hint instead. +# Prompts are also skipped automatically when stdin/stderr is not a terminal. +no-input: false + +# Hugging Face settings (hf-token, hf-base-url, hf-timeout) exist on generate, +# scan, enrich and vuln-scan. An empty hf-token falls back to $HF_TOKEN and an +# empty hf-base-url to $HF_ENDPOINT, then https://huggingface.co. # ============================================================================ # Command: generate # ============================================================================ generate: - # Hugging Face model ID(s) to generate AIBOMs for (list) - model-ids: [] - # Interactive model selector (cannot be used with model-ids) + # Hugging Face model IDs (org/name or org/name@revision) to generate AIBOMs for + model-id: [] + # Pick models in an interactive selector (cannot be combined with model-id) interactive: false # Output directory; one .aibom.cdx. file is written per model - output: "./dist" + output: "dist" # Output BOM format: json|xml format: "json" - # CycloneDX spec version for output (e.g., 1.5, 1.6, 1.7) empty is latest (1.7) + # CycloneDX spec version for output, e.g. 1.6; empty is latest spec: "" - # Hugging Face metadata mode: online|dummy - hf-mode: "online" + # Skip fetching the Hugging Face security scan tree + no-security-scan: false + hf-token: "" + hf-base-url: "" # Timeout in seconds per Hugging Face API request hf-timeout: 10 - # Hugging Face access token - hf-token: "" - # Log level: quiet|standard|debug - log-level: "standard" - # Skip fetching the HuggingFace security scan tree - no-security-scan: false # ============================================================================ # Command: scan # ============================================================================ scan: - # Path to scan (defaults to current directory) + # Directory to scan; empty is the current directory input: "" # Output directory; one .aibom.cdx. file is written per model - output: "./dist" + output: "dist" # Output BOM format: json|xml format: "json" - # CycloneDX spec version for output (e.g., 1.5, 1.6, 1.7) empty is latest (1.7) + # CycloneDX spec version for output, e.g. 1.6; empty is latest spec: "" - # Hugging Face metadata mode: online|dummy - hf-mode: "online" + # Skip fetching the Hugging Face security scan tree + no-security-scan: false + hf-token: "" + hf-base-url: "" # Timeout in seconds per Hugging Face API request hf-timeout: 10 - # Hugging Face access token - hf-token: "" - # Log level: quiet|standard|debug - log-level: "standard" - # Skip fetching the HuggingFace security scan tree - no-security-scan: false # ============================================================================ # Command: enrich # ============================================================================ enrich: - # Path to existing AIBOM (required) + # AIBOM file to enrich input: "" - # Output file path (default: overwrite input); .xml writes XML, anything else JSON + # Output file; empty overwrites the input. .xml writes XML, anything else JSON output: "" - # CycloneDX spec version for output (default: same as input) + # CycloneDX spec version for output; empty keeps the input's version spec: "" - # Enrichment strategy: interactive|file - strategy: "interactive" - # Path to enrichment config file (YAML) - file: "./config/enrichment.yaml" - # Only prompt for required fields + # YAML file with the values to fill in (see config/enrichment.yaml); + # empty prompts for them interactively + file: "" + # Only fill required fields required-only: false - # Only prompt for fields with weight >= this value + # Only fill fields with weight >= this value min-weight: 0.0 - # Refetch model metadata from Hugging Face before enrichment + # Refetch model metadata from Hugging Face first refetch: true - # Skip preview before saving - no-preview: false - # Log level: quiet|standard|debug - log-level: "standard" - # Hugging Face API token (for refetch) + # Save without preview or confirmation (required when not in a terminal) + yes: false hf-token: "" - # Hugging Face base URL (for refetch) - hf-base-url: "https://huggingface.co" - # Hugging Face API timeout in seconds (for refetch) + hf-base-url: "" + # Timeout in seconds per Hugging Face API request hf-timeout: 10 # ============================================================================ # Command: validate # ============================================================================ validate: - # Path to AIBOM file (required) + # AIBOM file to validate input: "" # Strict mode: fail on missing required fields and on vulnerabilities >= fail-severity strict: false @@ -94,56 +95,48 @@ validate: fail-severity: "medium" # Minimum completeness score (0.0-1.0), enforced with or without strict min-score: 0.0 - # Log level: quiet|standard|debug (debug also lists missing optional fields) - log-level: "standard" + # Print the result as JSON + json: false # ============================================================================ # Command: completeness # ============================================================================ completeness: - # Path to existing AIBOM file (required) + # AIBOM file to score input: "" - # Log level: quiet|standard|debug - log-level: "standard" - # Print a single-line plain summary (no styling) - plain-summary: false + # Print the result as JSON + json: false # ============================================================================ # Command: merge # ============================================================================ merge: - # Path(s) to AIBOM file(s) (list, required) - aiboms: [] - # Path to SBOM file (required) + # AIBOM files to merge + aibom: [] + # SBOM file to merge into (required) sbom: "" - # Output path for merged BOM (required); .xml writes XML, anything else JSON + # Output file for the merged BOM (required); .xml writes XML, anything else JSON output: "" - # Remove duplicate components based on BOM-ref - deduplicate: true - # Log level: quiet|standard|debug - log-level: "standard" + # Keep components with duplicate BOM-refs + no-deduplicate: false # ============================================================================ # Command: vuln-scan # ============================================================================ vuln-scan: - # Path to existing AIBOM (required) + # AIBOM file to scan input: "" - # Output path when enrich is true (default: overwrite input); .xml writes XML, anything else JSON + # Output file with enrich; empty overwrites the input. .xml writes XML, anything else JSON output: "" - # CycloneDX spec version for output (e.g., 1.5, 1.6, 1.7) empty is latest (1.7) + # CycloneDX spec version for output; empty keeps the input's version spec: "" # Inject discovered vulnerabilities back into the AIBOM enrich: false - # Show confirmation prompt before saving (only with enrich) - interactive: true - # Skip preview prompt (only with enrich) - no-preview: false - # Hugging Face access token + # Apply without preview or confirmation (only with enrich; required when not in a terminal) + yes: false + # Print the scan results as JSON + json: false hf-token: "" - # Hugging Face base URL - hf-base-url: "https://huggingface.co" + hf-base-url: "" # Timeout in seconds per Hugging Face API request - hf-timeout: 15 - # Log level: quiet|standard|debug - log-level: "standard" + hf-timeout: 10 diff --git a/go.mod b/go.mod index 1c11036..8335a51 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( charm.land/huh/v2 v2.0.3 charm.land/lipgloss/v2 v2.0.6 github.com/CycloneDX/cyclonedx-go v0.12.0 + github.com/charmbracelet/x/term v0.2.2 github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 @@ -26,7 +27,6 @@ require ( github.com/charmbracelet/x/exp/charmtone v0.0.0-20260902165432-6f6ad8b37b0a // indirect github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect github.com/charmbracelet/x/exp/strings v0.1.0 // indirect - github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/windows v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect diff --git a/internal/enricher/enricher.go b/internal/enricher/enricher.go index abeaf07..78e9629 100644 --- a/internal/enricher/enricher.go +++ b/internal/enricher/enricher.go @@ -1,8 +1,9 @@ package enricher import ( + "errors" "fmt" - "io" + "log/slog" "slices" "strings" "time" @@ -22,7 +23,7 @@ type Config struct { RequiredOnly bool // only enrich required fields MinWeight float64 // minimum weight threshold Refetch bool // refetch from Hugging Face - NoPreview bool // skip preview + Yes bool // save without preview or confirmation HFToken string // Hugging Face token HFBaseURL string // Hugging Face base URL HFTimeout int // timeout in seconds @@ -30,20 +31,17 @@ type Config struct { // Options for creating an Enricher. type Options struct { - Writer io.Writer Config Config } // Enricher handles AIBOM enrichment. type Enricher struct { - writer io.Writer config Config } // New creates a new Enricher. func New(opts Options) *Enricher { return &Enricher{ - writer: opts.Writer, config: opts.Config, } } @@ -57,7 +55,7 @@ func (e *Enricher) Enrich(bom *cdx.BOM, configViper *viper.Viper) (*cdx.BOM, err // Get model ID from BOM. modelID := extractModelID(bom) if modelID == "" { - fmt.Fprintf(e.writer, "warning: no model ID found in BOM; enrichment will proceed without a model ID\n") + slog.Warn("no model ID found in BOM; enrichment will proceed without a model ID") } // Run initial completeness check. @@ -96,8 +94,11 @@ func (e *Enricher) Enrich(bom *cdx.BOM, configViper *viper.Viper) (*cdx.BOM, err comp := &(*bom.Components)[i] if comp.Type == cdx.ComponentTypeData { dsChanges, err := e.enrichDataset(bom, comp, configViper) + if errors.Is(err, apperr.ErrCancelled) { + return nil, err + } if err != nil { - fmt.Fprintf(e.writer, "warning: failed to enrich dataset %q: %v\n", comp.Name, err) + slog.Warn("failed to enrich dataset", "dataset", comp.Name, "err", err) continue } if len(dsChanges) > 0 { @@ -108,7 +109,7 @@ func (e *Enricher) Enrich(bom *cdx.BOM, configViper *viper.Viper) (*cdx.BOM, err } // Show preview if requested. - if !e.config.NoPreview && (len(modelChanges) > 0 || len(datasetChanges) > 0) { + if !e.config.Yes && (len(modelChanges) > 0 || len(datasetChanges) > 0) { confirm, err := ShowPreviewWithConfirm(initialResult, postRefetchResult, bom, modelChanges, datasetChanges) if err != nil { return nil, fmt.Errorf("preview error: %w", err) @@ -295,7 +296,7 @@ func (e *Enricher) refetchMetadata(modelID string) (*fetcher.ModelAPIResponse, * BaseURL: e.config.HFBaseURL, }).Fetch(modelID) if err != nil { - fmt.Fprintf(e.writer, "warning: failed to fetch model API metadata for %q: %v\n", modelID, err) + slog.Warn("failed to fetch model API metadata", "model", modelID, "err", err) apiResp = nil } @@ -304,7 +305,7 @@ func (e *Enricher) refetchMetadata(modelID string) (*fetcher.ModelAPIResponse, * BaseURL: e.config.HFBaseURL, }).Fetch(modelID) if err != nil { - fmt.Fprintf(e.writer, "warning: failed to fetch model README for %q: %v\n", modelID, err) + slog.Warn("failed to fetch model README", "model", modelID, "err", err) readme = nil } @@ -398,7 +399,7 @@ func (e *Enricher) refetchDatasetMetadata(datasetID string) (*fetcher.DatasetAPI BaseURL: e.config.HFBaseURL, }).Fetch(datasetID) if err != nil { - fmt.Fprintf(e.writer, "warning: failed to fetch dataset API metadata for %q: %v\n", datasetID, err) + slog.Warn("failed to fetch dataset API metadata", "dataset", datasetID, "err", err) apiResp = nil } @@ -407,7 +408,7 @@ func (e *Enricher) refetchDatasetMetadata(datasetID string) (*fetcher.DatasetAPI BaseURL: e.config.HFBaseURL, }).Fetch(datasetID) if err != nil { - fmt.Fprintf(e.writer, "warning: failed to fetch dataset README for %q: %v\n", datasetID, err) + slog.Warn("failed to fetch dataset README", "dataset", datasetID, "err", err) readme = nil } diff --git a/internal/enricher/interactive.go b/internal/enricher/interactive.go index fa2d418..a906440 100644 --- a/internal/enricher/interactive.go +++ b/internal/enricher/interactive.go @@ -11,7 +11,7 @@ import ( ) // runForm runs a form; tests swap it to drive the form non-interactively. -var runForm = func(f *huh.Form) error { return f.Run() } +var runForm = ui.RunForm // formField is the kind-independent view of a model or dataset FieldSpec. type formField struct { diff --git a/internal/enricher/preview.go b/internal/enricher/preview.go index 9403490..d2dafc2 100644 --- a/internal/enricher/preview.go +++ b/internal/enricher/preview.go @@ -2,6 +2,7 @@ package enricher import ( "fmt" + "os" "strings" "charm.land/huh/v2" @@ -101,7 +102,8 @@ func ShowPreviewWithConfirm( // Render preview in a box using UI styles. previewBox := ui.Box.Render(sb.String()) - fmt.Println(previewBox) + // The preview is part of the prompt, so it goes to stderr with the form. + fmt.Fprintln(os.Stderr, previewBox) // Confirmation prompt. var confirm bool @@ -116,11 +118,9 @@ func ShowPreviewWithConfirm( ), ) - err := form.Run() - if err != nil { + if err := ui.RunForm(form); err != nil { return false, err } - return confirm, nil } diff --git a/internal/fetcher/client.go b/internal/fetcher/client.go index c7d322b..cb3bbfa 100644 --- a/internal/fetcher/client.go +++ b/internal/fetcher/client.go @@ -5,12 +5,14 @@ import ( "context" "fmt" "io" + "log/slog" "net/http" "strings" "time" ) -// hfTransport injects a Bearer token into every request when a token is set. +// hfTransport injects a Bearer token into every request when a token is set, +// and logs each request at debug level (never the token). type hfTransport struct { base http.RoundTripper token string @@ -21,19 +23,21 @@ func (t *hfTransport) RoundTrip(req *http.Request) (*http.Response, error) { req = req.Clone(req.Context()) req.Header.Set("Authorization", "Bearer "+t.token) } - return t.base.RoundTrip(req) + start := time.Now() + resp, err := t.base.RoundTrip(req) + if err != nil { + slog.Debug("http request failed", "method", req.Method, "url", req.URL.String(), "duration", time.Since(start), "err", err) + return nil, err + } + slog.Debug("http request", "method", req.Method, "url", req.URL.String(), "status", resp.StatusCode, "duration", time.Since(start)) + return resp, nil } // NewHFClient creates an *http.Client configured for Hugging Face API calls. // timeout is the per-request deadline (0 = no timeout). // token is automatically injected as a Bearer token on every request when non-empty. func NewHFClient(timeout time.Duration, token string) *http.Client { - token = strings.TrimSpace(token) - base := http.DefaultTransport - transport := base - if token != "" { - transport = &hfTransport{base: base, token: token} - } + transport := &hfTransport{base: http.DefaultTransport, token: strings.TrimSpace(token)} return &http.Client{Timeout: timeout, Transport: transport} } diff --git a/internal/metadata/apply.go b/internal/metadata/apply.go index 49856b5..f861ea7 100644 --- a/internal/metadata/apply.go +++ b/internal/metadata/apply.go @@ -2,6 +2,7 @@ package metadata import ( "fmt" + "log/slog" cdx "github.com/CycloneDX/cyclonedx-go" ) @@ -46,18 +47,20 @@ func onModelCard(fn func(*cdx.MLModelCard, applyInput) error) func(Target, apply // applyFirst applies the first source value that apply accepts. // A failed apply (e.g. a placeholder value) falls through to the next source. -func applyFirst[S, T any](sources []func(S) (any, bool), apply func(T, applyInput) error, src S, tgt T) { +func applyFirst[S, T any](key fmt.Stringer, sources []func(S) (any, bool), apply func(T, applyInput) error, src S, tgt T) { if apply == nil { return } - for _, get := range sources { + for i, get := range sources { if get == nil { continue } if value, ok := get(src); ok && apply(tgt, applyInput{Value: value}) == nil { + slog.Debug("metadata: field applied", "field", key.String(), "source", i) return } } + slog.Debug("metadata: field not found", "field", key.String()) } // applyParsed parses a user value and applies it with Force set. @@ -66,15 +69,16 @@ func applyParsed[T any](key fmt.Stringer, parse func(string) (any, error), apply return fmt.Errorf("spec missing Parse/Apply for %s", key) } parsed, err := parse(value) - if err != nil { - return err + if err == nil { + err = apply(tgt, applyInput{Value: parsed, Force: true}) } - return apply(tgt, applyInput{Value: parsed, Force: true}) + slog.Debug("metadata: user value", "field", key.String(), "err", err) + return err } // ApplyFromSources applies the first source value that spec.Apply accepts. func ApplyFromSources(spec FieldSpec, src Source, tgt Target) { - applyFirst(spec.Sources, spec.Apply, src, tgt) + applyFirst(spec.Key, spec.Sources, spec.Apply, src, tgt) } // ApplyUserValue parses and applies a user-provided value using spec.Parse and spec.Apply. @@ -84,7 +88,7 @@ func ApplyUserValue(spec FieldSpec, value string, tgt Target) error { // ApplyDatasetFromSources applies the first dataset source value that spec.Apply accepts. func ApplyDatasetFromSources(spec DatasetFieldSpec, src DatasetSource, tgt DatasetTarget) { - applyFirst(spec.Sources, spec.Apply, src, tgt) + applyFirst(spec.Key, spec.Sources, spec.Apply, src, tgt) } // ApplyDatasetUserValue parses and applies a dataset user value. diff --git a/internal/ui/model_selector.go b/internal/ui/model_selector.go index 126a95a..5b474bf 100644 --- a/internal/ui/model_selector.go +++ b/internal/ui/model_selector.go @@ -2,6 +2,7 @@ package ui import ( "fmt" + "os" "strings" "time" @@ -16,6 +17,7 @@ import ( // ModelSelectorConfig configures the model selector. type ModelSelectorConfig struct { HFToken string + BaseURL string Timeout time.Duration } @@ -82,7 +84,8 @@ func NewModelSelector(config ModelSelectorConfig) *modelSelectorModel { ti.SetWidth(50) searcher := &fetcher.ModelSearcher{ - Client: fetcher.NewHFClient(config.Timeout, config.HFToken), + Client: fetcher.NewHFClient(config.Timeout, config.HFToken), + BaseURL: config.BaseURL, } delegate := list.NewDefaultDelegate() @@ -350,8 +353,13 @@ func (m *modelSelectorModel) WasConfirmed() bool { } // RunModelSelector runs the interactive model selector and returns selected model IDs. +// It renders on stderr and holds logs (its searches log HTTP requests) until it exits. func RunModelSelector(config ModelSelectorConfig) ([]string, error) { - p := tea.NewProgram(NewModelSelector(config)) + if !CanPrompt() { + return nil, ErrNoInput + } + defer HoldLogs()() + p := tea.NewProgram(NewModelSelector(config), tea.WithOutput(os.Stderr)) m, err := p.Run() if err != nil { return nil, err diff --git a/internal/ui/terminal.go b/internal/ui/terminal.go new file mode 100644 index 0000000..21e3585 --- /dev/null +++ b/internal/ui/terminal.go @@ -0,0 +1,80 @@ +package ui + +import ( + "bytes" + "errors" + "io" + "os" + "sync" + + "charm.land/huh/v2" + "github.com/charmbracelet/x/term" + "github.com/idlab-discover/aibomgen-cli/internal/apperr" +) + +// NoInput disables all prompts (set from --no-input). +var NoInput bool + +// ErrNoInput is returned when a prompt is needed but cannot be shown. +var ErrNoInput = errors.New("cannot prompt: not running in a terminal (or --no-input set)") + +// IsTTY reports whether f is a terminal. +func IsTTY(f *os.File) bool { return term.IsTerminal(f.Fd()) } + +// CanPrompt reports whether interactive prompts can be shown: stdin and stderr +// (where prompts render) must be terminals and --no-input must be off. +func CanPrompt() bool { return !NoInput && IsTTY(os.Stdin) && IsTTY(os.Stderr) } + +// logGate is the log destination. While held, writes are buffered so they don't +// corrupt a TUI that owns the terminal; release flushes them in order. +type logGate struct { + mu sync.Mutex + out io.Writer + held bool + buf bytes.Buffer +} + +func (g *logGate) Write(p []byte) (int, error) { + g.mu.Lock() + defer g.mu.Unlock() + if g.held { + return g.buf.Write(p) + } + return g.out.Write(p) +} + +func (g *logGate) hold() (release func()) { + g.mu.Lock() + g.held = true + g.mu.Unlock() + return func() { + g.mu.Lock() + defer g.mu.Unlock() + g.held = false + _, _ = g.buf.WriteTo(g.out) + } +} + +var logs = &logGate{out: os.Stderr} + +// LogWriter returns the writer logs should go to (stderr, gated by HoldLogs). +func LogWriter() io.Writer { return logs } + +// HoldLogs buffers log output until the returned release func is called. +func HoldLogs() (release func()) { return logs.hold() } + +// RunForm runs a huh form with logs held. It returns ErrNoInput when prompting +// is not possible and apperr.ErrCancelled when the user aborts. +func RunForm(f *huh.Form) error { + if !CanPrompt() { + return ErrNoInput + } + defer HoldLogs()() + if err := f.Run(); err != nil { + if errors.Is(err, huh.ErrUserAborted) { + return apperr.ErrCancelled + } + return err + } + return nil +} diff --git a/internal/ui/terminal_test.go b/internal/ui/terminal_test.go new file mode 100644 index 0000000..546f9de --- /dev/null +++ b/internal/ui/terminal_test.go @@ -0,0 +1,35 @@ +package ui + +import ( + "bytes" + "errors" + "testing" + + "charm.land/huh/v2" +) + +func TestLogGateHoldsAndFlushesInOrder(t *testing.T) { + var out bytes.Buffer + g := &logGate{out: &out} + + g.Write([]byte("a\n")) + release := g.hold() + g.Write([]byte("b\n")) + g.Write([]byte("c\n")) + if out.String() != "a\n" { + t.Fatalf("held writes leaked: %q", out.String()) + } + release() + g.Write([]byte("d\n")) + if out.String() != "a\nb\nc\nd\n" { + t.Fatalf("got %q", out.String()) + } +} + +func TestRunFormNoInput(t *testing.T) { + NoInput = true + t.Cleanup(func() { NoInput = false }) + if err := RunForm(huh.NewForm(huh.NewGroup(huh.NewConfirm()))); !errors.Is(err, ErrNoInput) { + t.Fatalf("got %v, want ErrNoInput", err) + } +} diff --git a/internal/ui/workflow.go b/internal/ui/workflow.go index 3ecc64c..4b3cdb9 100644 --- a/internal/ui/workflow.go +++ b/internal/ui/workflow.go @@ -41,6 +41,9 @@ type Workflow struct { stopChan chan struct{} running bool lastRender string + + // Static disables the spinner: only the final state is rendered on Stop. + Static bool } // NewWorkflow creates a new workflow tracker. @@ -128,6 +131,9 @@ func (wf *Workflow) Start() { } wf.running = true wf.mu.Unlock() + if wf.Static { + return + } // Start spinner animation. go func() { diff --git a/pkg/aibomgen/completeness/completeness.go b/pkg/aibomgen/completeness/completeness.go index 953e364..0d60f9d 100644 --- a/pkg/aibomgen/completeness/completeness.go +++ b/pkg/aibomgen/completeness/completeness.go @@ -2,6 +2,7 @@ package completeness import ( "github.com/idlab-discover/aibomgen-cli/internal/metadata" + "log/slog" cdx "github.com/CycloneDX/cyclonedx-go" ) @@ -9,29 +10,29 @@ import ( // Result holds the completeness score for the model component of a BOM and. // all linked dataset components. type Result struct { - ModelID string // Model identifier/name - Score float64 // 0..1 + ModelID string `json:"modelId"` // Model identifier/name + Score float64 `json:"score"` // 0..1 - Passed int - Total int + Passed int `json:"passed"` + Total int `json:"total"` - MissingRequired []metadata.Key - MissingOptional []metadata.Key + MissingRequired []metadata.Key `json:"missingRequired"` + MissingOptional []metadata.Key `json:"missingOptional"` // Dataset-specific tracking. - DatasetResults map[string]DatasetResult // key is dataset name/ref + DatasetResults map[string]DatasetResult `json:"datasets,omitempty"` // key is dataset name/ref } // DatasetResult holds the completeness score for a single dataset component. type DatasetResult struct { - DatasetRef string // Reference to the dataset + DatasetRef string `json:"datasetRef"` // Reference to the dataset - Score float64 // 0..1 - Passed int - Total int + Score float64 `json:"score"` // 0..1 + Passed int `json:"passed"` + Total int `json:"total"` - MissingRequired []metadata.DatasetKey - MissingOptional []metadata.DatasetKey + MissingRequired []metadata.DatasetKey `json:"missingRequired"` + MissingOptional []metadata.DatasetKey `json:"missingOptional"` } // Check checks the completeness of a BOM using the default metadata registry. @@ -65,6 +66,7 @@ func checkWithRegistry(bom *cdx.BOM, modelRegistry []metadata.FieldSpec, dataset if spec.Present != nil && (spec.Key != metadata.ModelCardModelParametersDatasets || datasetsReferenced) { ok = spec.Present(bom) } + slog.Debug("completeness: model field", "field", spec.Key.String(), "present", ok, "required", spec.Required) if ok { passed++ @@ -161,6 +163,7 @@ func checkDatasetWithRegistry(comp *cdx.Component, datasetRegistry []metadata.Da if spec.Present != nil { ok = spec.Present(comp) } + slog.Debug("completeness: dataset field", "dataset", comp.Name, "field", spec.Key.String(), "present", ok, "required", spec.Required) if ok { passed++ diff --git a/pkg/aibomgen/generator/generator.go b/pkg/aibomgen/generator/generator.go index 725e622..5d1b6d1 100644 --- a/pkg/aibomgen/generator/generator.go +++ b/pkg/aibomgen/generator/generator.go @@ -78,13 +78,13 @@ func ParseModelRef(s string) (ModelRef, error) { return ModelRef{ID: id, Revision: rev}, nil } -var newFetcherSet = func(httpClient *http.Client) fetcherSet { +var newFetcherSet = func(httpClient *http.Client, baseURL string) fetcherSet { return fetcherSet{ - modelAPI: &fetcher.ModelAPIFetcher{Client: httpClient}, - modelReadme: &fetcher.ModelReadmeFetcher{Client: httpClient}, - datasetAPI: &fetcher.DatasetAPIFetcher{Client: httpClient}, - datasetReadme: &fetcher.DatasetReadmeFetcher{Client: httpClient}, - modelTree: &fetcher.ModelTreeFetcher{Client: httpClient}, + modelAPI: &fetcher.ModelAPIFetcher{Client: httpClient, BaseURL: baseURL}, + modelReadme: &fetcher.ModelReadmeFetcher{Client: httpClient, BaseURL: baseURL}, + datasetAPI: &fetcher.DatasetAPIFetcher{Client: httpClient, BaseURL: baseURL}, + datasetReadme: &fetcher.DatasetReadmeFetcher{Client: httpClient, BaseURL: baseURL}, + modelTree: &fetcher.ModelTreeFetcher{Client: httpClient, BaseURL: baseURL}, } } @@ -139,6 +139,7 @@ const ( // GenerateOptions configures the generation process. type GenerateOptions struct { HFToken string + BaseURL string // Hugging Face endpoint (empty = https://huggingface.co) Timeout time.Duration OnProgress ProgressCallback SkipSecurityScan bool // when true, the HF tree security scan is not fetched @@ -218,7 +219,7 @@ func BuildPerDiscovery(discoveries []scanner.Discovery, opts GenerateOptions) ([ results := make([]DiscoveredBOM, 0, len(discoveries)) - fetchers := newFetcherSet(newHTTPClient(opts)) + fetchers := newFetcherSet(newHTTPClient(opts), opts.BaseURL) bomBuilder := newBOMBuilder() for i, d := range discoveries { diff --git a/pkg/aibomgen/generator/generator_refs_test.go b/pkg/aibomgen/generator/generator_refs_test.go index 130bdae..f5affb0 100644 --- a/pkg/aibomgen/generator/generator_refs_test.go +++ b/pkg/aibomgen/generator/generator_refs_test.go @@ -36,7 +36,7 @@ func hfLikeFetchers(api *mockModelAPIFetcher) fetcherSet { func withFetchers(t *testing.T, fs fetcherSet) { t.Helper() orig := newFetcherSet - newFetcherSet = func(*http.Client) fetcherSet { return fs } + newFetcherSet = func(*http.Client, string) fetcherSet { return fs } t.Cleanup(func() { newFetcherSet = orig }) } diff --git a/pkg/aibomgen/generator/generator_test.go b/pkg/aibomgen/generator/generator_test.go index e41a54c..fe13f12 100644 --- a/pkg/aibomgen/generator/generator_test.go +++ b/pkg/aibomgen/generator/generator_test.go @@ -307,7 +307,7 @@ func TestBuildPerDiscovery(t *testing.T) { }, } } - newFetcherSet = func(httpClient *http.Client) fetcherSet { + newFetcherSet = func(httpClient *http.Client, _ string) fetcherSet { return successFetcherSet() } }, @@ -350,7 +350,7 @@ func TestBuildPerDiscovery(t *testing.T) { }, } } - newFetcherSet = func(httpClient *http.Client) fetcherSet { + newFetcherSet = func(httpClient *http.Client, _ string) fetcherSet { return successFetcherSet() } }, @@ -377,7 +377,7 @@ func TestBuildPerDiscovery(t *testing.T) { }, } } - newFetcherSet = func(httpClient *http.Client) fetcherSet { + newFetcherSet = func(httpClient *http.Client, _ string) fetcherSet { return successFetcherSet() } }, @@ -407,7 +407,7 @@ func TestBuildPerDiscovery(t *testing.T) { }, } } - newFetcherSet = func(httpClient *http.Client) fetcherSet { + newFetcherSet = func(httpClient *http.Client, _ string) fetcherSet { return successFetcherSet() } }, @@ -464,7 +464,7 @@ func TestBuildPerDiscovery(t *testing.T) { }, } } - newFetcherSet = func(httpClient *http.Client) fetcherSet { + newFetcherSet = func(httpClient *http.Client, _ string) fetcherSet { return fetcherSet{ modelAPI: &mockModelAPIFetcher{ fetchFunc: func(id string) (*fetcher.ModelAPIResponse, error) { @@ -525,7 +525,7 @@ func TestBuildPerDiscovery(t *testing.T) { }, } } - newFetcherSet = func(httpClient *http.Client) fetcherSet { + newFetcherSet = func(httpClient *http.Client, _ string) fetcherSet { return fetcherSet{ modelAPI: &mockModelAPIFetcher{ fetchFunc: func(id string) (*fetcher.ModelAPIResponse, error) { @@ -591,7 +591,7 @@ func TestBuildPerDiscovery(t *testing.T) { }, } } - newFetcherSet = func(httpClient *http.Client) fetcherSet { + newFetcherSet = func(httpClient *http.Client, _ string) fetcherSet { return fetcherSet{ modelAPI: &mockModelAPIFetcher{ fetchFunc: func(id string) (*fetcher.ModelAPIResponse, error) { @@ -637,7 +637,7 @@ func TestBuildPerDiscovery(t *testing.T) { }, } } - newFetcherSet = func(httpClient *http.Client) fetcherSet { + newFetcherSet = func(httpClient *http.Client, _ string) fetcherSet { return fetcherSet{ modelAPI: &mockModelAPIFetcher{ fetchFunc: func(id string) (*fetcher.ModelAPIResponse, error) { @@ -677,7 +677,7 @@ func TestBuildPerDiscovery(t *testing.T) { }, } } - newFetcherSet = func(httpClient *http.Client) fetcherSet { + newFetcherSet = func(httpClient *http.Client, _ string) fetcherSet { return fetcherSet{ modelAPI: &mockModelAPIFetcher{ fetchFunc: func(id string) (*fetcher.ModelAPIResponse, error) { @@ -876,7 +876,7 @@ func TestBuildFromModelIDs(t *testing.T) { }, } } - newFetcherSet = func(httpClient *http.Client) fetcherSet { + newFetcherSet = func(httpClient *http.Client, _ string) fetcherSet { return successFetcherSet() } }, @@ -905,7 +905,7 @@ func TestBuildFromModelIDs(t *testing.T) { }, } } - newFetcherSet = func(httpClient *http.Client) fetcherSet { + newFetcherSet = func(httpClient *http.Client, _ string) fetcherSet { return successFetcherSet() } }, @@ -949,7 +949,7 @@ func TestBuildFromModelIDs(t *testing.T) { }, } } - newFetcherSet = func(httpClient *http.Client) fetcherSet { + newFetcherSet = func(httpClient *http.Client, _ string) fetcherSet { return successFetcherSet() } }, @@ -979,7 +979,7 @@ func TestBuildFromModelIDs(t *testing.T) { }, } } - newFetcherSet = func(httpClient *http.Client) fetcherSet { + newFetcherSet = func(httpClient *http.Client, _ string) fetcherSet { return successFetcherSet() } }, @@ -1011,7 +1011,7 @@ func TestBuildFromModelIDs(t *testing.T) { }, } } - newFetcherSet = func(httpClient *http.Client) fetcherSet { + newFetcherSet = func(httpClient *http.Client, _ string) fetcherSet { return fetcherSet{ modelAPI: &mockModelAPIFetcher{ fetchFunc: func(id string) (*fetcher.ModelAPIResponse, error) { @@ -1070,7 +1070,7 @@ func TestBuildFromModelIDs(t *testing.T) { }, } } - newFetcherSet = func(httpClient *http.Client) fetcherSet { + newFetcherSet = func(httpClient *http.Client, _ string) fetcherSet { return fetcherSet{ modelAPI: &mockModelAPIFetcher{ fetchFunc: func(id string) (*fetcher.ModelAPIResponse, error) { @@ -1118,7 +1118,7 @@ func TestBuildFromModelIDs(t *testing.T) { }, } } - newFetcherSet = func(httpClient *http.Client) fetcherSet { + newFetcherSet = func(httpClient *http.Client, _ string) fetcherSet { return fetcherSet{ modelAPI: &mockModelAPIFetcher{ fetchFunc: func(id string) (*fetcher.ModelAPIResponse, error) { @@ -1152,7 +1152,7 @@ func TestBuildFromModelIDs(t *testing.T) { }, } } - newFetcherSet = func(httpClient *http.Client) fetcherSet { + newFetcherSet = func(httpClient *http.Client, _ string) fetcherSet { return fetcherSet{ modelAPI: &mockModelAPIFetcher{ fetchFunc: func(id string) (*fetcher.ModelAPIResponse, error) { diff --git a/pkg/aibomgen/merger/merger.go b/pkg/aibomgen/merger/merger.go index 87cce70..4be8706 100644 --- a/pkg/aibomgen/merger/merger.go +++ b/pkg/aibomgen/merger/merger.go @@ -2,6 +2,7 @@ package merger import ( "fmt" + "log/slog" "strings" cdx "github.com/CycloneDX/cyclonedx-go" @@ -157,6 +158,7 @@ func MergeAIBOMsWithSBOM(sbom *cdx.BOM, aiboms []*cdx.BOM, opts MergeOptions) (* shouldAdd := true if opts.DeduplicateComponents && bomRef != "" { if _, exists := componentsMap[bomRef]; exists { + slog.Debug("merge: duplicate component dropped", "bom-ref", bomRef) result.DuplicatesRemoved++ shouldAdd = false } else { @@ -186,6 +188,7 @@ func MergeAIBOMsWithSBOM(sbom *cdx.BOM, aiboms []*cdx.BOM, opts MergeOptions) (* if opts.DeduplicateComponents && bomRef != "" { if _, exists := componentsMap[bomRef]; exists { + slog.Debug("merge: duplicate component dropped", "bom-ref", bomRef) result.DuplicatesRemoved++ continue } diff --git a/pkg/aibomgen/scanner/scanner.go b/pkg/aibomgen/scanner/scanner.go index f99f5d6..3b641c8 100644 --- a/pkg/aibomgen/scanner/scanner.go +++ b/pkg/aibomgen/scanner/scanner.go @@ -5,6 +5,7 @@ import ( "cmp" "encoding/json" "io/fs" + "log/slog" "os" "path/filepath" "regexp" @@ -254,7 +255,9 @@ func Scan(root string) ([]Discovery, error) { return compareOccurrences(firstOccurrence(a), firstOccurrence(b)) }) - return dedupe(results), nil + deduped := dedupe(results) + slog.Info("scan complete", "root", root, "files", len(paths), "hits", len(results), "models", len(deduped)) + return deduped, nil } func firstOccurrence(d Discovery) Occurrence { @@ -437,8 +440,10 @@ func applyRules(results []Discovery, rules []detectionRule, text string, lineNum } modelID := text[m[2]:m[3]] if !isPlausibleModelID(modelID) { + slog.Debug("scan: rejected model id", "path", path, "line", lineNum, "rule", rule.method, "id", modelID) continue } + slog.Debug("scan: model reference", "path", path, "line", lineNum, "rule", rule.method, "id", modelID) evidence := rule.method + " at line " + strconv.Itoa(lineNum) + ": " + strings.TrimSpace(text) results = append(results, Discovery{ ID: modelID, diff --git a/pkg/aibomgen/validator/validator.go b/pkg/aibomgen/validator/validator.go index 5465169..94fc532 100644 --- a/pkg/aibomgen/validator/validator.go +++ b/pkg/aibomgen/validator/validator.go @@ -15,29 +15,29 @@ import ( // ValidationResult is returned by [Validate] and summarises the outcome of. // all checks performed on the BOM. type ValidationResult struct { - ModelID string - Valid bool - Errors []string - Warnings []string + ModelID string `json:"modelId"` + Valid bool `json:"valid"` + Errors []string `json:"errors"` + Warnings []string `json:"warnings"` // AIBOM-specific metrics. - CompletenessScore float64 - MissingRequired []metadata.Key - MissingOptional []metadata.Key + CompletenessScore float64 `json:"completenessScore"` + MissingRequired []metadata.Key `json:"missingRequired"` + MissingOptional []metadata.Key `json:"missingOptional"` // Dataset-specific results. - DatasetResults map[string]DatasetValidationResult // key is dataset name + DatasetResults map[string]DatasetValidationResult `json:"datasets,omitempty"` // key is dataset name } // DatasetValidationResult holds validation results for a single dataset. // component within the BOM. type DatasetValidationResult struct { - DatasetRef string - CompletenessScore float64 - MissingRequired []metadata.DatasetKey - MissingOptional []metadata.DatasetKey - Errors []string - Warnings []string + DatasetRef string `json:"datasetRef"` + CompletenessScore float64 `json:"completenessScore"` + MissingRequired []metadata.DatasetKey `json:"missingRequired"` + MissingOptional []metadata.DatasetKey `json:"missingOptional"` + Errors []string `json:"errors"` + Warnings []string `json:"warnings"` } // ValidationOptions configures the behaviour of [Validate]. diff --git a/pkg/aibomgen/vulnscan/vulnscan.go b/pkg/aibomgen/vulnscan/vulnscan.go index c0ad40f..fe43de8 100644 --- a/pkg/aibomgen/vulnscan/vulnscan.go +++ b/pkg/aibomgen/vulnscan/vulnscan.go @@ -2,6 +2,7 @@ package vulnscan import ( "fmt" + "log/slog" "strings" "time" @@ -101,6 +102,7 @@ func scanOne(comp *cdx.Component, modelID string, tf treeFetcherIface) Component entries, err := tf.Fetch(modelID) if err != nil { res.Err = fmt.Errorf("security scan for %q failed: %w", modelID, err) + slog.Info("vuln-scan: component failed", "ref", comp.BOMRef, "model", modelID, "err", err) return res } @@ -113,6 +115,7 @@ func scanOne(comp *cdx.Component, modelID string, tf treeFetcherIface) Component if tmpBOM.Vulnerabilities != nil { res.Vulnerabilities = *tmpBOM.Vulnerabilities } + slog.Info("vuln-scan: component scanned", "ref", comp.BOMRef, "model", modelID, "entries", len(entries), "vulns", len(res.Vulnerabilities)) return res } From 860abcd5878815e10ce685f4c0dc32e2515e1fc3 Mon Sep 17 00:00:00 2001 From: wievdndr Date: Thu, 8 Oct 2026 11:55:19 +0200 Subject: [PATCH 2/2] feat: enhance logging and improve static workflow output --- cmd/aibomgen-cli/common.go | 3 +++ cmd/aibomgen-cli/enrich.go | 2 +- cmd/aibomgen-cli/vulnscan.go | 2 +- internal/ui/terminal_test.go | 33 ++++++++++++++++++++--- internal/ui/workflow.go | 18 +++++++++++-- pkg/aibomgen/completeness/completeness.go | 3 ++- 6 files changed, 52 insertions(+), 9 deletions(-) diff --git a/cmd/aibomgen-cli/common.go b/cmd/aibomgen-cli/common.go index e3e35c4..41eed36 100644 --- a/cmd/aibomgen-cli/common.go +++ b/cmd/aibomgen-cli/common.go @@ -95,6 +95,9 @@ func trackProgress(wf *ui.Workflow, processIdx, writeIdx, total int, hasToken bo } onProgress := func(evt generator.ProgressEvent) { + if evt.Type == generator.EventFetchStart { + slog.Info("processing model", "model", evt.ModelID) + } if wf == nil { return } diff --git a/cmd/aibomgen-cli/enrich.go b/cmd/aibomgen-cli/enrich.go index a1b47f2..4b10e1d 100644 --- a/cmd/aibomgen-cli/enrich.go +++ b/cmd/aibomgen-cli/enrich.go @@ -3,10 +3,10 @@ package cmd import ( "errors" "fmt" - "github.com/idlab-discover/aibomgen-cli/internal/apperr" "strings" "time" + "github.com/idlab-discover/aibomgen-cli/internal/apperr" "github.com/idlab-discover/aibomgen-cli/internal/enricher" "github.com/idlab-discover/aibomgen-cli/internal/ui" "github.com/idlab-discover/aibomgen-cli/pkg/aibomgen/bomio" diff --git a/cmd/aibomgen-cli/vulnscan.go b/cmd/aibomgen-cli/vulnscan.go index a555902..04a5d7d 100644 --- a/cmd/aibomgen-cli/vulnscan.go +++ b/cmd/aibomgen-cli/vulnscan.go @@ -3,12 +3,12 @@ package cmd import ( "errors" "fmt" - cdx "github.com/CycloneDX/cyclonedx-go" "io" "os" "strings" "charm.land/huh/v2" + cdx "github.com/CycloneDX/cyclonedx-go" "github.com/idlab-discover/aibomgen-cli/internal/apperr" "github.com/idlab-discover/aibomgen-cli/internal/metadata" "github.com/idlab-discover/aibomgen-cli/internal/ui" diff --git a/internal/ui/terminal_test.go b/internal/ui/terminal_test.go index 546f9de..24ae01c 100644 --- a/internal/ui/terminal_test.go +++ b/internal/ui/terminal_test.go @@ -11,16 +11,21 @@ import ( func TestLogGateHoldsAndFlushesInOrder(t *testing.T) { var out bytes.Buffer g := &logGate{out: &out} + write := func(s string) { + if _, err := g.Write([]byte(s)); err != nil { + t.Fatal(err) + } + } - g.Write([]byte("a\n")) + write("a\n") release := g.hold() - g.Write([]byte("b\n")) - g.Write([]byte("c\n")) + write("b\n") + write("c\n") if out.String() != "a\n" { t.Fatalf("held writes leaked: %q", out.String()) } release() - g.Write([]byte("d\n")) + write("d\n") if out.String() != "a\nb\nc\nd\n" { t.Fatalf("got %q", out.String()) } @@ -33,3 +38,23 @@ func TestRunFormNoInput(t *testing.T) { t.Fatalf("got %v, want ErrNoInput", err) } } + +func TestStaticWorkflowPrintsEachStateOnce(t *testing.T) { + var out bytes.Buffer + wf := NewWorkflow(&out) + wf.Static = true + i := wf.AddTask("Fetch") + wf.Start() + wf.StartTask(i, "") + if !bytes.Contains(out.Bytes(), []byte("Fetch")) { + t.Fatalf("start not printed: %q", out.String()) + } + wf.CompleteTask(i, "done") + wf.Stop() + if n := bytes.Count(out.Bytes(), []byte("Fetch")); n != 2 { + t.Fatalf("want 2 lines (start, done), got %d: %q", n, out.String()) + } + if bytes.Contains(out.Bytes(), []byte("\033[A")) { + t.Fatalf("static output has cursor escapes: %q", out.String()) + } +} diff --git a/internal/ui/workflow.go b/internal/ui/workflow.go index 4b3cdb9..c3cbe3a 100644 --- a/internal/ui/workflow.go +++ b/internal/ui/workflow.go @@ -42,7 +42,8 @@ type Workflow struct { running bool lastRender string - // Static disables the spinner: only the final state is rendered on Stop. + // Static disables the spinner and redraws: each task state change is printed + // once as a plain line instead (for -v logs and non-terminal output). Static bool } @@ -76,6 +77,7 @@ func (wf *Workflow) StartTask(idx int, message string) { if idx >= 0 && idx < len(wf.tasks) { wf.tasks[idx].Status = TaskRunning wf.tasks[idx].Message = message + wf.emitLocked(wf.tasks[idx]) } } @@ -87,6 +89,7 @@ func (wf *Workflow) CompleteTask(idx int, details string) { if idx >= 0 && idx < len(wf.tasks) { wf.tasks[idx].Status = TaskDone wf.tasks[idx].Details = details + wf.emitLocked(wf.tasks[idx]) } } @@ -98,6 +101,7 @@ func (wf *Workflow) FailTask(idx int, errMsg string) { if idx >= 0 && idx < len(wf.tasks) { wf.tasks[idx].Status = TaskFailed wf.tasks[idx].Message = errMsg + wf.emitLocked(wf.tasks[idx]) } } @@ -109,6 +113,7 @@ func (wf *Workflow) SkipTask(idx int, reason string) { if idx >= 0 && idx < len(wf.tasks) { wf.tasks[idx].Status = TaskSkipped wf.tasks[idx].Message = reason + wf.emitLocked(wf.tasks[idx]) } } @@ -165,7 +170,16 @@ func (wf *Workflow) Stop() { wf.mu.Unlock() close(wf.stopChan) - wf.render(true) + if !wf.Static { + wf.render(true) + } +} + +// emitLocked prints t's current state as one line in Static mode. Callers hold wf.mu. +func (wf *Workflow) emitLocked(t *Task) { + if wf.Static { + fmt.Fprintln(wf.writer, wf.renderTask(t, t.Status != TaskRunning)) + } } // render displays the current state; final renders the end state without diff --git a/pkg/aibomgen/completeness/completeness.go b/pkg/aibomgen/completeness/completeness.go index 0d60f9d..2009a21 100644 --- a/pkg/aibomgen/completeness/completeness.go +++ b/pkg/aibomgen/completeness/completeness.go @@ -1,10 +1,11 @@ package completeness import ( - "github.com/idlab-discover/aibomgen-cli/internal/metadata" "log/slog" cdx "github.com/CycloneDX/cyclonedx-go" + + "github.com/idlab-discover/aibomgen-cli/internal/metadata" ) // Result holds the completeness score for the model component of a BOM and.