package cli import ( "context" "encoding/json" "flag" "fmt" "io" "os" "sort" "strings" "time" "gitea.maximumdirect.net/eric/notarius/internal/core/artifacts" "gitea.maximumdirect.net/eric/notarius/internal/core/config" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" ) const defaultConfigPath = "/usr/local/etc/notarius/config.yml" const usage = `Usage: notarius help notarius run --input path/to/source.json [--config path/to/config.yml] [--only lane-a,lane-b] notarius config validate --config path/to/config.yml [--pipeline pipeline-id] [--only lane-a,lane-b] notarius pipelines list --config path/to/config.yml [--json] ` type Options struct { Catalog pipeline.ModuleCatalog Registries pipeline.Registries LLMClientFactory LLMClientFactory LookupEnv func(string) (string, bool) Now func() time.Time } type LLMClientFactory func(ctx context.Context, cfg config.Config, profileID string) (contracts.StructuredLLMClient, []artifacts.LLMProfileManifest, error) // Run executes the command-line interface and returns a process exit code. func Run(args []string, stdout, stderr io.Writer) int { return RunWithOptions(args, stdout, stderr, Options{}) } func RunWithOptions(args []string, stdout, stderr io.Writer, opts Options) int { opts = normalizeOptions(opts) if len(args) == 0 { writeUsage(stdout) return 0 } switch args[0] { case "help", "--help", "-h": writeUsage(stdout) return 0 case "config": return runConfig(args[1:], stdout, stderr, opts) case "pipelines": return runPipelines(args[1:], stdout, stderr, opts) case "run": return runPipelineCommand(args[1:], stdout, stderr, opts) default: fmt.Fprintf(stderr, "notarius: unknown command %q\n", args[0]) writeUsage(stderr) return 2 } } func writeUsage(w io.Writer) { fmt.Fprint(w, usage) } func normalizeOptions(opts Options) Options { if opts.LookupEnv == nil { opts.LookupEnv = os.LookupEnv } if opts.Now == nil { opts.Now = time.Now } if opts.LLMClientFactory == nil { opts.LLMClientFactory = productionLLMClientFactory } return opts } func runPipelineCommand(args []string, stdout, stderr io.Writer, opts Options) int { fs := flag.NewFlagSet("run", flag.ContinueOnError) fs.SetOutput(io.Discard) configPath := fs.String("config", "", "config file path") inputPath := fs.String("input", "", "source input file path") onlyRaw := fs.String("only", "", "comma-separated artifact lanes") outputDir := fs.String("output-dir", "", "output directory") diagnosticsDir := fs.String("diagnostics-dir", "", "diagnostics directory") llmProfile := fs.String("llm-profile", "", "LLM profile override") if err := fs.Parse(reorderRunArgs(args)); err != nil { fmt.Fprintf(stderr, "notarius: %v\n", err) return 2 } if fs.NArg() == 0 { fmt.Fprintln(stderr, "notarius: run requires a pipeline ID") return 2 } if fs.NArg() > 1 { fmt.Fprintf(stderr, "notarius: unexpected argument %q\n", fs.Arg(1)) return 2 } pipelineID := strings.TrimSpace(fs.Arg(0)) if pipelineID == "" { fmt.Fprintln(stderr, "notarius: run requires a pipeline ID") return 2 } if strings.TrimSpace(*inputPath) == "" { fmt.Fprintln(stderr, "notarius: run requires --input") return 2 } only, err := parseOnly(*onlyRaw) if err != nil { fmt.Fprintf(stderr, "notarius: %v\n", err) return 2 } cfg, _, err := loadConfig(*configPath, opts) if err != nil { fmt.Fprintf(stderr, "notarius: %v\n", err) return 1 } if dir := strings.TrimSpace(*diagnosticsDir); dir != "" { cfg.Diagnostics.WorkDir = dir } catalog, err := effectiveCatalog(opts) if err != nil { fmt.Fprintf(stderr, "notarius: %v\n", err) return 1 } effective, err := cfg.Resolve(config.ResolveInput{ PipelineID: pipelineID, Only: only, Catalog: catalog, LLMProfileOverride: *llmProfile, }) if err != nil { fmt.Fprintf(stderr, "notarius: %v\n", err) return 1 } profileIDs := effectiveLLMProfileIDs(effective.ResolvedPipeline) if len(profileIDs) != 1 { fmt.Fprintf(stderr, "notarius: pipeline %q uses %d distinct LLM profiles; current runs require exactly one: %s\n", pipelineID, len(profileIDs), strings.Join(profileIDs, ", ")) return 1 } rawInput, err := os.ReadFile(strings.TrimSpace(*inputPath)) if err != nil { fmt.Fprintf(stderr, "notarius: read input %q: %v\n", strings.TrimSpace(*inputPath), err) return 1 } registries, err := effectiveRegistries(opts) if err != nil { fmt.Fprintf(stderr, "notarius: %v\n", err) return 1 } ctx := context.Background() llmClient, llmProfiles, err := opts.LLMClientFactory(ctx, effective.Config, profileIDs[0]) if err != nil { fmt.Fprintf(stderr, "notarius: create LLM client for profile %q: %v\n", profileIDs[0], err) return 1 } output, err := pipeline.New(registries).Run(ctx, pipeline.RunInput{ Pipeline: effective.ResolvedPipeline, Path: strings.TrimSpace(*inputPath), RawInput: rawInput, LLMClient: llmClient, StartedAt: opts.Now().UTC(), LLMProfiles: llmProfiles, Metadata: runMetadata(*outputDir, *diagnosticsDir), }) if err != nil { fmt.Fprintf(stderr, "notarius: run pipeline %q: %v\n", pipelineID, err) return 1 } fmt.Fprintf(stdout, "pipeline %q complete: approved=%d rejected=%d\n", effective.PipelineID, len(output.Approved), len(output.Rejected)) if len(output.Warnings) > 0 { fmt.Fprintf(stderr, "notarius: run completed with %d warning(s)\n", len(output.Warnings)) } return 0 } func reorderRunArgs(args []string) []string { var flags []string var positionals []string for i := 0; i < len(args); i++ { arg := args[i] if arg == "--" { positionals = append(positionals, args[i+1:]...) break } if strings.HasPrefix(arg, "-") { flags = append(flags, arg) if runFlagTakesValue(arg) && !strings.Contains(arg, "=") && i+1 < len(args) { i++ flags = append(flags, args[i]) } continue } positionals = append(positionals, arg) } return append(flags, positionals...) } func runFlagTakesValue(arg string) bool { switch arg { case "--config", "--input", "--only", "--output-dir", "--diagnostics-dir", "--llm-profile": return true default: return false } } func effectiveLLMProfileIDs(resolved pipeline.ResolvedPipeline) []string { seen := make(map[string]struct{}) add := func(binding pipeline.ModuleBinding) { id := strings.TrimSpace(binding.LLMProfile) if id != "" { seen[id] = struct{}{} } } add(resolved.Input) add(resolved.Chunk) add(resolved.Output) for _, lane := range resolved.ArtifactLanes { add(lane.Extract) add(lane.Merge) add(lane.Normalize) for _, validator := range lane.Validators { add(validator) } } ids := make([]string, 0, len(seen)) for id := range seen { ids = append(ids, id) } sort.Strings(ids) return ids } func runMetadata(outputDir, diagnosticsDir string) map[string]any { metadata := make(map[string]any) if dir := strings.TrimSpace(outputDir); dir != "" { metadata["output_dir"] = dir } if dir := strings.TrimSpace(diagnosticsDir); dir != "" { metadata["diagnostics_dir"] = dir } if len(metadata) == 0 { return nil } return metadata } func runConfig(args []string, stdout, stderr io.Writer, opts Options) int { if len(args) == 0 { fmt.Fprintln(stderr, "notarius: config requires a subcommand") writeUsage(stderr) return 2 } switch args[0] { case "validate": return runConfigValidate(args[1:], stdout, stderr, opts) default: fmt.Fprintf(stderr, "notarius: unknown config subcommand %q\n", args[0]) writeUsage(stderr) return 2 } } func runConfigValidate(args []string, stdout, stderr io.Writer, opts Options) int { fs := flag.NewFlagSet("config validate", flag.ContinueOnError) fs.SetOutput(io.Discard) configPath := fs.String("config", "", "config file path") pipelineID := fs.String("pipeline", "", "pipeline ID") onlyRaw := fs.String("only", "", "comma-separated artifact lanes") if err := fs.Parse(args); err != nil { fmt.Fprintf(stderr, "notarius: %v\n", err) return 2 } if fs.NArg() != 0 { fmt.Fprintf(stderr, "notarius: unexpected argument %q\n", fs.Arg(0)) return 2 } if strings.TrimSpace(*onlyRaw) != "" && strings.TrimSpace(*pipelineID) == "" { fmt.Fprintln(stderr, "notarius: --only requires --pipeline") return 2 } only, err := parseOnly(*onlyRaw) if err != nil { fmt.Fprintf(stderr, "notarius: %v\n", err) return 2 } cfg, path, err := loadConfig(*configPath, opts) if err != nil { fmt.Fprintf(stderr, "notarius: %v\n", err) return 1 } if strings.TrimSpace(*pipelineID) != "" { catalog, err := effectiveCatalog(opts) if err != nil { fmt.Fprintf(stderr, "notarius: %v\n", err) return 1 } if _, err := cfg.Resolve(config.ResolveInput{ PipelineID: *pipelineID, Only: only, Catalog: catalog, }); err != nil { fmt.Fprintf(stderr, "notarius: %v\n", err) return 1 } fmt.Fprintf(stdout, "config %q is valid for pipeline %q\n", path, strings.TrimSpace(*pipelineID)) return 0 } if err := cfg.Validate(); err != nil { fmt.Fprintf(stderr, "notarius: %v\n", err) return 1 } fmt.Fprintf(stdout, "config %q is valid\n", path) return 0 } func runPipelines(args []string, stdout, stderr io.Writer, opts Options) int { if len(args) == 0 { fmt.Fprintln(stderr, "notarius: pipelines requires a subcommand") writeUsage(stderr) return 2 } switch args[0] { case "list": return runPipelinesList(args[1:], stdout, stderr, opts) default: fmt.Fprintf(stderr, "notarius: unknown pipelines subcommand %q\n", args[0]) writeUsage(stderr) return 2 } } func runPipelinesList(args []string, stdout, stderr io.Writer, opts Options) int { fs := flag.NewFlagSet("pipelines list", flag.ContinueOnError) fs.SetOutput(io.Discard) configPath := fs.String("config", "", "config file path") jsonOutput := fs.Bool("json", false, "write JSON output") if err := fs.Parse(args); err != nil { fmt.Fprintf(stderr, "notarius: %v\n", err) return 2 } if fs.NArg() != 0 { fmt.Fprintf(stderr, "notarius: unexpected argument %q\n", fs.Arg(0)) return 2 } cfg, _, err := loadConfig(*configPath, opts) if err != nil { fmt.Fprintf(stderr, "notarius: %v\n", err) return 1 } if err := cfg.Validate(); err != nil { fmt.Fprintf(stderr, "notarius: %v\n", err) return 1 } ids := sortedPipelineIDs(cfg) if *jsonOutput { payload := struct { Pipelines []string `json:"pipelines"` }{Pipelines: ids} encoded, err := json.Marshal(payload) if err != nil { fmt.Fprintf(stderr, "notarius: marshal pipeline list: %v\n", err) return 1 } fmt.Fprintf(stdout, "%s\n", encoded) return 0 } for _, id := range ids { fmt.Fprintln(stdout, id) } return 0 } func loadConfig(configPath string, opts Options) (config.Config, string, error) { path, err := discoverConfigPath(configPath, opts) if err != nil { return config.Config{}, "", err } fileCfg, err := config.LoadFileConfig(path) if err != nil { return config.Config{}, "", err } cfg := config.Default() if err := cfg.ApplyFileConfigWithLookup(fileCfg, opts.LookupEnv); err != nil { return config.Config{}, "", err } if err := cfg.ApplyEnvOverridesWithLookup(opts.LookupEnv); err != nil { return config.Config{}, "", err } return cfg, path, nil } func discoverConfigPath(configPath string, opts Options) (string, error) { if path := strings.TrimSpace(configPath); path != "" { if err := requireConfigFile(path); err != nil { return "", err } return path, nil } if path, ok := opts.LookupEnv("NOTARIUS_CONFIG"); ok && strings.TrimSpace(path) != "" { path = strings.TrimSpace(path) if err := requireConfigFile(path); err != nil { return "", err } return path, nil } if _, err := os.Stat(defaultConfigPath); err == nil { return defaultConfigPath, nil } else if err != nil && !os.IsNotExist(err) { return "", fmt.Errorf("check default config %q: %w", defaultConfigPath, err) } return "", fmt.Errorf("config file not found; pass --config or set NOTARIUS_CONFIG") } func requireConfigFile(path string) error { info, err := os.Stat(path) if err != nil { return fmt.Errorf("config file %q is not available: %w", path, err) } if info.IsDir() { return fmt.Errorf("config file %q is a directory", path) } return nil } func parseOnly(raw string) ([]string, error) { if strings.TrimSpace(raw) == "" { return nil, nil } parts := strings.Split(raw, ",") result := make([]string, 0, len(parts)) for _, part := range parts { trimmed := strings.TrimSpace(part) if trimmed == "" { return nil, fmt.Errorf("--only must contain comma-separated non-empty artifact lane IDs") } result = append(result, trimmed) } return result, nil } func sortedPipelineIDs(cfg config.Config) []string { ids := make([]string, 0, len(cfg.Pipelines)) for id := range cfg.Pipelines { ids = append(ids, strings.TrimSpace(id)) } sort.Strings(ids) return ids }