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/defaults" "gitea.maximumdirect.net/eric/scriptorium/internal/domain" renderformat "gitea.maximumdirect.net/eric/scriptorium/internal/format" "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/promptdef" "gitea.maximumdirect.net/eric/scriptorium/internal/usecase" "gitea.maximumdirect.net/eric/scriptorium/internal/validate" ) const ( ExitOK = 0 ExitRuntimeError = 1 ExitValidationFailed = 2 ) type runConfig struct { promptDir string profileDir string promptID string profileID string inputRaw listFlag varRaw listFlag outputPath string llmBaseURL string apiKeyEnv string model string temperature float64 maxTokens int topP float64 schemaDir string timeout time.Duration llmBaseURLSet bool apiKeyEnvSet bool modelSet bool temperatureSet bool maxTokensSet bool topPSet bool timeoutSet bool } type renderConfig struct { runConfig outputFormat renderformat.PreparedRunOutputFormat } type serveConfig struct { addr string promptDir string profileDir string schemaDir string } 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 "render": return renderCommand(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 } req, err := buildRunRequestFromConfig(cfg) if err != nil { fmt.Fprintf(stderr, "run parse error: %v\n", err) return ExitRuntimeError } llmClient, err := llm.NewOpenAICompatibleClient(llm.OpenAICompatibleConfig{ Timeout: defaults.LLMRequestTimeoutDefault, }) if err != nil { fmt.Fprintf(stderr, "llm client error: %v\n", err) return ExitRuntimeError } runner := usecase.NewRunner( promptdef.NewFilesystemRepository(cfg.promptDir), profile.NewFilesystemRepository(cfg.profileDir), artifactadapter.NewCompositeReader(), prompt.NewGoRenderer(), llmClient, validate.NewStandardValidator(cfg.schemaDir), ) res, runErr := runner.Run(context.Background(), req) 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 renderCommand(args []string, stdout, stderr io.Writer) int { cfg, err := parseRenderArgs(args) if err != nil { fmt.Fprintf(stderr, "render parse error: %v\n", err) return ExitRuntimeError } req, err := buildRunRequestFromConfig(&cfg.runConfig) if err != nil { fmt.Fprintf(stderr, "render parse error: %v\n", err) return ExitRuntimeError } runner := usecase.NewRunner( promptdef.NewFilesystemRepository(cfg.promptDir), profile.NewFilesystemRepository(cfg.profileDir), artifactadapter.NewCompositeReader(), prompt.NewGoRenderer(), nil, nil, ) prepared, prepErr := runner.Prepare(context.Background(), req) if prepErr != nil { fmt.Fprintf(stderr, "render error: %v\n", prepErr) return ExitRuntimeError } out, err := renderformat.FormatPreparedRun(prepared, cfg.outputFormat) if err != nil { fmt.Fprintf(stderr, "render format error: %v\n", err) return ExitRuntimeError } if err := writeOutput(stdout, cfg.outputPath, out); err != nil { fmt.Fprintf(stderr, "output write error: %v\n", err) return ExitRuntimeError } return ExitOK } 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{ Timeout: defaults.LLMRequestTimeoutDefault, }) if err != nil { fmt.Fprintf(stderr, "llm client error: %v\n", err) return ExitRuntimeError } runner := usecase.NewRunner( promptdef.NewFilesystemRepository(cfg.promptDir), 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: defaults.HTTPReadHeaderTimeoutDefault, } 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) registerExecutionRequestFlags(fs, cfg) fs.StringVar(&cfg.schemaDir, "schema-dir", defaults.SchemaDirDefault, "base directory for validation schemas") if err := fs.Parse(args); err != nil { return nil, err } if err := finalizeExecutionRequestConfig(fs, cfg); err != nil { return nil, err } cfg.schemaDir = filepath.Clean(cfg.schemaDir) return cfg, nil } func parseRenderArgs(args []string) (*renderConfig, error) { cfg := &renderConfig{ outputFormat: renderformat.DefaultPreparedRunOutputFormat, } fs := flag.NewFlagSet("render", flag.ContinueOnError) fs.SetOutput(io.Discard) registerExecutionRequestFlags(fs, &cfg.runConfig) var rawFormat string fs.StringVar(&rawFormat, "format", string(renderformat.DefaultPreparedRunOutputFormat), "render output format (text|json)") if err := fs.Parse(args); err != nil { return nil, err } if err := finalizeExecutionRequestConfig(fs, &cfg.runConfig); err != nil { return nil, err } format, err := renderformat.ParsePreparedRunOutputFormat(rawFormat) if err != nil { return nil, err } cfg.outputFormat = format 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", defaults.HTTPAddrDefault, "HTTP listen address") fs.StringVar(&cfg.promptDir, "prompt-dir", "", "directory containing prompt definition YAML files") fs.StringVar(&cfg.profileDir, "profile-dir", "", "directory containing execution profile YAML files") fs.StringVar(&cfg.schemaDir, "schema-dir", defaults.SchemaDirDefault, "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.promptDir) == "" { return nil, errors.New("--prompt-dir is required") } if strings.TrimSpace(cfg.profileDir) == "" { return nil, errors.New("--profile-dir is required") } cfg.promptDir = filepath.Clean(cfg.promptDir) cfg.profileDir = filepath.Clean(cfg.profileDir) cfg.schemaDir = filepath.Clean(cfg.schemaDir) return cfg, nil } func registerExecutionRequestFlags(fs *flag.FlagSet, cfg *runConfig) { fs.StringVar(&cfg.promptDir, "prompt-dir", "", "directory containing prompt definition YAML files") fs.StringVar(&cfg.profileDir, "profile-dir", "", "directory containing execution profile YAML files") fs.StringVar(&cfg.promptID, "prompt", "", "prompt ID to run") fs.StringVar(&cfg.profileID, "profile", "", "optional execution profile ID; if omitted, prompt default_profile is used") 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.apiKeyEnv, "api-key-env", "", "environment variable name containing 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.Float64Var(&cfg.topP, "top-p", 0, "optional top_p override") fs.DurationVar(&cfg.timeout, "timeout", defaults.LLMRequestTimeoutDefault, "LLM request timeout") fs.StringVar(&cfg.promptID, "prompt-id", "", "deprecated alias for --prompt") fs.StringVar(&cfg.profileID, "profile-id", "", "deprecated alias for --profile") } func finalizeExecutionRequestConfig(fs *flag.FlagSet, cfg *runConfig) error { if fs.NArg() > 0 { return fmt.Errorf("unexpected positional args: %v", fs.Args()) } if strings.TrimSpace(cfg.promptDir) == "" { return errors.New("--prompt-dir is required") } if strings.TrimSpace(cfg.profileDir) == "" { return errors.New("--profile-dir is required") } if strings.TrimSpace(cfg.promptID) == "" { return errors.New("--prompt is required") } if len(cfg.inputRaw) == 0 { return errors.New("at least one --input is required") } cfg.promptDir = filepath.Clean(cfg.promptDir) cfg.profileDir = filepath.Clean(cfg.profileDir) if cfg.outputPath != "" { cfg.outputPath = filepath.Clean(cfg.outputPath) } cfg.llmBaseURLSet = flagWasSet(fs, "llm-base-url") cfg.apiKeyEnvSet = flagWasSet(fs, "api-key-env") cfg.modelSet = flagWasSet(fs, "model") cfg.temperatureSet = flagWasSet(fs, "temperature") cfg.maxTokensSet = flagWasSet(fs, "max-tokens") cfg.topPSet = flagWasSet(fs, "top-p") cfg.timeoutSet = flagWasSet(fs, "timeout") return nil } func buildRunRequestFromConfig(cfg *runConfig) (domain.RunRequest, error) { inputMappings, err := parseMappings(cfg.inputRaw, false) if err != nil { return domain.RunRequest{}, fmt.Errorf("input parse error: %w", err) } varMappings := map[string]string{} if len(cfg.varRaw) > 0 { varMappings, err = parseMappings(cfg.varRaw, false) if err != nil { return domain.RunRequest{}, fmt.Errorf("var parse error: %w", err) } } inputs := make(map[string]domain.ArtifactRef, len(inputMappings)) for name, path := range inputMappings { inputs[name] = domain.ArtifactRef{Type: domain.ArtifactRefFile, URI: path} } var modelOverride *domain.ExecutionTarget if cfg.llmBaseURLSet || cfg.modelSet || cfg.temperatureSet || cfg.maxTokensSet || cfg.topPSet || cfg.apiKeyEnvSet || cfg.timeoutSet { modelOverride = &domain.ExecutionTarget{ Endpoint: cfg.llmBaseURL, Model: cfg.model, Temperature: cfg.temperature, MaxTokens: cfg.maxTokens, TopP: cfg.topP, APIKeyEnv: cfg.apiKeyEnv, } if cfg.timeoutSet { modelOverride.TimeoutSeconds = int(cfg.timeout.Seconds()) } } return domain.RunRequest{ PromptID: cfg.promptID, ProfileID: cfg.profileID, Inputs: inputs, Vars: varMappings, Execution: modelOverride, }, 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 flagWasSet(fs *flag.FlagSet, name string) bool { set := false fs.Visit(func(f *flag.Flag) { if f.Name == name { set = true } }) return set } 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, "prompt=%s@%s selected_profile=%s model=%s validation=%s mode=%s validation_errors=%d prompt_hash=%s inputs=%d usage=%d/%d/%d\n", res.PromptID, res.PromptVersion, res.SelectedProfileID, res.ModelName, res.Validation.Status, res.Validation.Mode, len(res.Validation.Errors), res.RenderedPromptHash, 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 --prompt-dir DIR --profile-dir DIR --prompt ID --input name=path [--input ...] [--profile ID] [--llm-base-url URL] [--model NAME] [--api-key-env ENV] [--temperature N] [--max-tokens N] [--top-p N] [--var k=v] [--out path] [--timeout 10m]") fmt.Fprintln(w, " render: scriptorium render --prompt-dir DIR --profile-dir DIR --prompt ID --input name=path [--input ...] [--profile ID] [--llm-base-url URL] [--model NAME] [--api-key-env ENV] [--temperature N] [--max-tokens N] [--top-p N] [--var k=v] [--format text|json] [--out path] [--timeout 10m]") fmt.Fprintf(w, " serve: scriptorium serve --addr %s --prompt-dir DIR --profile-dir DIR [--schema-dir DIR]\n", defaults.HTTPAddrDefault) }