package cli import ( "context" "errors" "flag" "fmt" "io" "net/http" "os" "path/filepath" "strings" "time" httpadapter "gitea.maximumdirect.net/eric/scriptorium/internal/adapter/http" artifactadapter "gitea.maximumdirect.net/eric/scriptorium/internal/artifact" "gitea.maximumdirect.net/eric/scriptorium/internal/domain" "gitea.maximumdirect.net/eric/scriptorium/internal/llm" "gitea.maximumdirect.net/eric/scriptorium/internal/profile" "gitea.maximumdirect.net/eric/scriptorium/internal/prompt" "gitea.maximumdirect.net/eric/scriptorium/internal/usecase" "gitea.maximumdirect.net/eric/scriptorium/internal/validate" ) const ( ExitOK = 0 ExitRuntimeError = 1 ExitValidationFailed = 2 ) type runConfig struct { profileDir string profileID string inputRaw listFlag varRaw listFlag outputPath string llmBaseURL string llmAPIKey string model string temperature float64 maxTokens int schemaDir string } type serveConfig struct { addr string profileDir string schemaDir string llmBaseURL string llmAPIKey string model string timeout time.Duration } type listFlag []string func (l *listFlag) String() string { return strings.Join(*l, ",") } func (l *listFlag) Set(v string) error { *l = append(*l, v) return nil } func Run(args []string, stdout, stderr io.Writer) int { if len(args) == 0 { printUsage(stderr) return ExitRuntimeError } switch args[0] { case "run": return runCommand(args[1:], stdout, stderr) case "serve": return serveCommand(args[1:], stderr) default: fmt.Fprintf(stderr, "unknown command %q\n", args[0]) printUsage(stderr) return ExitRuntimeError } } func runCommand(args []string, stdout, stderr io.Writer) int { cfg, err := parseRunArgs(args) if err != nil { fmt.Fprintf(stderr, "run parse error: %v\n", err) return ExitRuntimeError } inputMappings, err := parseMappings(cfg.inputRaw, false) if err != nil { fmt.Fprintf(stderr, "input parse error: %v\n", err) return ExitRuntimeError } varMappings, err := parseMappings(cfg.varRaw, false) if err != nil { fmt.Fprintf(stderr, "var parse error: %v\n", err) return ExitRuntimeError } inputs := make(map[string]domain.ArtifactRef, len(inputMappings)) for name, path := range inputMappings { inputs[name] = domain.ArtifactRef{Type: domain.ArtifactRefFile, URI: path} } llmClient, err := llm.NewOpenAICompatibleClient(llm.OpenAICompatibleConfig{ BaseURL: cfg.llmBaseURL, APIKey: cfg.llmAPIKey, Model: cfg.model, Timeout: 60 * time.Second, }) if err != nil { fmt.Fprintf(stderr, "llm client error: %v\n", err) return ExitRuntimeError } runner := usecase.NewRunner( profile.NewFilesystemRepository(cfg.profileDir), artifactadapter.NewCompositeReader(), prompt.NewGoRenderer(), llmClient, validate.NewStandardValidator(cfg.schemaDir), ) res, runErr := runner.Run(context.Background(), domain.RunRequest{ ProfileID: cfg.profileID, Inputs: inputs, Vars: varMappings, Model: &domain.ModelTarget{ Endpoint: cfg.llmBaseURL, Model: cfg.model, Temperature: cfg.temperature, MaxTokens: cfg.maxTokens, }, }) if runErr != nil { fmt.Fprintf(stderr, "run error: %v\n", runErr) return ExitRuntimeError } if err := writeOutput(stdout, cfg.outputPath, res.Artifact.Body); err != nil { fmt.Fprintf(stderr, "output write error: %v\n", err) return ExitRuntimeError } printSummary(stderr, res) return determineExitCode(nil, res) } func serveCommand(args []string, stderr io.Writer) int { cfg, err := parseServeArgs(args) if err != nil { fmt.Fprintf(stderr, "serve parse error: %v\n", err) return ExitRuntimeError } llmClient, err := llm.NewOpenAICompatibleClient(llm.OpenAICompatibleConfig{ BaseURL: cfg.llmBaseURL, APIKey: cfg.llmAPIKey, Model: cfg.model, Timeout: cfg.timeout, }) if err != nil { fmt.Fprintf(stderr, "llm client error: %v\n", err) return ExitRuntimeError } runner := usecase.NewRunner( profile.NewFilesystemRepository(cfg.profileDir), artifactadapter.NewCompositeReader(), prompt.NewGoRenderer(), llmClient, validate.NewStandardValidator(cfg.schemaDir), ) h := httpadapter.NewHandler(runner) srv := &http.Server{ Addr: cfg.addr, Handler: h, ReadHeaderTimeout: 10 * time.Second, } fmt.Fprintf(stderr, "serving on %s\n", cfg.addr) if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { fmt.Fprintf(stderr, "server error: %v\n", err) return ExitRuntimeError } return ExitOK } func parseRunArgs(args []string) (*runConfig, error) { cfg := &runConfig{} fs := flag.NewFlagSet("run", flag.ContinueOnError) fs.SetOutput(io.Discard) fs.StringVar(&cfg.profileDir, "profile-dir", "", "directory containing prompt profile YAML files") fs.StringVar(&cfg.profileID, "profile-id", "", "profile ID to run") fs.Var(&cfg.inputRaw, "input", "input mapping(s): name=path (repeatable, comma-separated)") fs.Var(&cfg.varRaw, "var", "variable mapping(s): name=value (repeatable, comma-separated)") fs.StringVar(&cfg.outputPath, "out", "", "optional output file path") fs.StringVar(&cfg.llmBaseURL, "llm-base-url", "", "OpenAI-compatible base URL including /v1") fs.StringVar(&cfg.llmAPIKey, "llm-api-key", "", "optional API key") fs.StringVar(&cfg.model, "model", "", "model name") fs.Float64Var(&cfg.temperature, "temperature", 0, "optional temperature override") fs.IntVar(&cfg.maxTokens, "max-tokens", 0, "optional max tokens override") fs.StringVar(&cfg.schemaDir, "schema-dir", ".", "base directory for validation schemas") if err := fs.Parse(args); err != nil { return nil, err } if fs.NArg() > 0 { return nil, fmt.Errorf("unexpected positional args: %v", fs.Args()) } if strings.TrimSpace(cfg.profileDir) == "" { return nil, errors.New("--profile-dir is required") } if strings.TrimSpace(cfg.profileID) == "" { return nil, errors.New("--profile-id is required") } if len(cfg.inputRaw) == 0 { return nil, errors.New("at least one --input is required") } if strings.TrimSpace(cfg.llmBaseURL) == "" { return nil, errors.New("--llm-base-url is required") } if strings.TrimSpace(cfg.model) == "" { return nil, errors.New("--model is required") } cfg.profileDir = filepath.Clean(cfg.profileDir) cfg.schemaDir = filepath.Clean(cfg.schemaDir) if cfg.outputPath != "" { cfg.outputPath = filepath.Clean(cfg.outputPath) } return cfg, nil } func parseServeArgs(args []string) (*serveConfig, error) { cfg := &serveConfig{} fs := flag.NewFlagSet("serve", flag.ContinueOnError) fs.SetOutput(io.Discard) fs.StringVar(&cfg.addr, "addr", ":8080", "HTTP listen address") fs.StringVar(&cfg.profileDir, "profile-dir", "", "directory containing prompt profile YAML files") fs.StringVar(&cfg.schemaDir, "schema-dir", ".", "base directory for validation schemas") fs.StringVar(&cfg.llmBaseURL, "llm-base-url", "", "OpenAI-compatible base URL including /v1") fs.StringVar(&cfg.llmAPIKey, "llm-api-key", "", "optional API key") fs.StringVar(&cfg.model, "model", "", "optional default model") fs.DurationVar(&cfg.timeout, "timeout", 60*time.Second, "LLM request timeout") if err := fs.Parse(args); err != nil { return nil, err } if fs.NArg() > 0 { return nil, fmt.Errorf("unexpected positional args: %v", fs.Args()) } if strings.TrimSpace(cfg.profileDir) == "" { return nil, errors.New("--profile-dir is required") } if strings.TrimSpace(cfg.llmBaseURL) == "" { return nil, errors.New("--llm-base-url is required") } cfg.profileDir = filepath.Clean(cfg.profileDir) cfg.schemaDir = filepath.Clean(cfg.schemaDir) return cfg, nil } func parseMappings(raw []string, allowEmptyValue bool) (map[string]string, error) { out := make(map[string]string) for _, entry := range raw { for _, piece := range strings.Split(entry, ",") { piece = strings.TrimSpace(piece) if piece == "" { continue } key, value, err := parseMapping(piece) if err != nil { return nil, err } if !allowEmptyValue && strings.TrimSpace(value) == "" { return nil, fmt.Errorf("mapping %q has empty value", piece) } out[key] = value } } if len(out) == 0 { return nil, errors.New("no valid mappings provided") } return out, nil } func parseMapping(value string) (string, string, error) { idx := strings.IndexRune(value, '=') if idx <= 0 { return "", "", fmt.Errorf("invalid mapping %q, expected name=value", value) } key := strings.TrimSpace(value[:idx]) val := strings.TrimSpace(value[idx+1:]) if key == "" { return "", "", fmt.Errorf("invalid mapping %q, empty name", value) } return key, val, nil } func writeOutput(stdout io.Writer, outputPath string, body []byte) error { if outputPath == "" { _, err := stdout.Write(body) return err } return os.WriteFile(outputPath, body, 0644) } func determineExitCode(runErr error, result *domain.RunResult) int { if runErr != nil { return ExitRuntimeError } if result != nil && result.Validation.Status == domain.ValidationFailed { return ExitValidationFailed } return ExitOK } func printSummary(stderr io.Writer, res *domain.RunResult) { if res == nil { return } fmt.Fprintf(stderr, "profile=%s@%s model=%s validation=%s mode=%s validation_errors=%d prompt_hash=%s inputs=%d usage=%d/%d/%d\n", res.ProfileID, res.ProfileVersion, res.ModelName, res.Validation.Status, res.Validation.Mode, len(res.Validation.Errors), res.PromptHash, len(res.InputHashes), res.Usage.PromptTokens, res.Usage.CompletionTokens, res.Usage.TotalTokens, ) } func printUsage(w io.Writer) { fmt.Fprintln(w, "usage: scriptorium ...") fmt.Fprintln(w, " run: scriptorium run --profile-dir DIR --profile-id ID --input name=path [--input ...] --llm-base-url URL --model NAME [--var k=v] [--out path]") fmt.Fprintln(w, " serve: scriptorium serve --addr :8080 --profile-dir DIR --llm-base-url URL [--schema-dir DIR] [--llm-api-key KEY] [--model NAME]") }