Files
scriptorium/internal/adapter/cli/run.go

762 lines
25 KiB
Go
Raw Normal View History

2026-05-04 21:08:40 -05:00
package cli
import (
"context"
2026-09-26 15:47:26 +00:00
"encoding/json"
2026-05-04 21:08:40 -05:00
"errors"
"flag"
"fmt"
"io"
2026-05-04 21:19:29 -05:00
"net/http"
2026-05-04 21:08:40 -05:00
"os"
"path/filepath"
"strings"
"time"
2026-09-26 18:08:36 +00:00
configkit "gitea.maximumdirect.net/eric/ConfigKit"
"gitea.maximumdirect.net/eric/promptkit"
2026-05-04 21:19:29 -05:00
httpadapter "gitea.maximumdirect.net/eric/scriptorium/internal/adapter/http"
2026-05-06 16:46:33 +00:00
appconfig "gitea.maximumdirect.net/eric/scriptorium/internal/config"
"gitea.maximumdirect.net/eric/scriptorium/internal/defaults"
renderformat "gitea.maximumdirect.net/eric/scriptorium/internal/format"
2026-09-26 15:47:26 +00:00
executionreport "gitea.maximumdirect.net/eric/scriptorium/internal/report"
2026-05-04 21:08:40 -05:00
)
const (
ExitOK = 0
ExitRuntimeError = 1
ExitValidationFailed = 2
)
2026-05-06 16:48:35 +00:00
const (
2026-07-04 16:48:18 +00:00
errPromptDirRequired = "prompt directory is required; provide --prompt-dir or config.yml prompt_dir"
2026-05-06 16:48:35 +00:00
)
2026-05-04 21:08:40 -05:00
type runConfig struct {
2026-05-06 16:46:33 +00:00
configPath string
promptDir string
profileDir string
promptID string
promptVersion string
profileID string
sessionID string
inputRaw listFlag
varRaw listFlag
outputPath string
2026-09-26 15:47:26 +00:00
executionReportPath string
llmBaseURL string
apiKeyEnv string
model string
temperature float64
maxTokens int
topP float64
reasoningEffort string
schemaDir string
backends []appconfig.BackendSettings
timeout time.Duration
runTimeout time.Duration
transientRetries int
configuredTransientRetries *int
2026-09-26 18:08:36 +00:00
diagnostics []configkit.Diagnostic
configOverlay string
2026-05-06 16:46:33 +00:00
defaultRenderFormat renderformat.PreparedRunOutputFormat
llmBaseURLSet bool
apiKeyEnvSet bool
modelSet bool
temperatureSet bool
maxTokensSet bool
topPSet bool
reasoningEffortSet bool
timeoutSet bool
transientRetriesSet bool
2026-05-04 21:08:40 -05:00
}
type renderConfig struct {
runConfig
outputFormat renderformat.PreparedRunOutputFormat
}
2026-05-04 21:19:29 -05:00
type serveConfig struct {
2026-05-06 16:46:33 +00:00
configPath string
2026-07-05 00:17:07 +00:00
addr string
promptDir string
profileDir string
schemaDir string
artifactRoot string
maxRequestBytes int64
maxArtifactBytes int64
maxResponseBytes int64
2026-08-29 14:18:29 +00:00
backends []appconfig.BackendSettings
transientRetries *int
2026-09-26 18:08:36 +00:00
diagnostics []configkit.Diagnostic
configOverlay string
2026-05-04 21:19:29 -05:00
}
type commonCommandSettings struct {
promptDir string
profileDir string
schemaDir string
serverAddr string
2026-07-04 23:34:44 +00:00
artifactRoot string
2026-07-05 00:17:07 +00:00
maxRequestBytes int64
maxArtifactBytes int64
maxResponseBytes int64
defaultRenderFormat renderformat.PreparedRunOutputFormat
2026-08-29 14:18:29 +00:00
backends []appconfig.BackendSettings
transientRetries *int
2026-09-26 18:08:36 +00:00
diagnostics []configkit.Diagnostic
}
2026-05-04 21:08:40 -05:00
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)
2026-05-04 21:19:29 -05:00
case "serve":
return serveCommand(args[1:], stderr)
2026-08-29 14:29:14 +00:00
case "inspect":
return inspectCommand(args[1:], stdout, stderr)
2026-05-04 21:08:40 -05:00
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 {
2026-09-26 18:08:36 +00:00
writeApplicationError(stderr, "run parse", err)
return ExitRuntimeError
}
if err := writeApplicationWarnings(stderr, cfg.diagnostics); err != nil {
2026-05-04 21:08:40 -05:00
return ExitRuntimeError
}
req, err := buildRunRequestFromConfig(cfg)
2026-05-04 21:08:40 -05:00
if err != nil {
fmt.Fprintf(stderr, "run parse error: %v\n", err)
2026-05-04 21:08:40 -05:00
return ExitRuntimeError
}
2026-08-29 14:18:29 +00:00
engine, err := newEngine(cfg.engineSettings())
2026-05-04 21:08:40 -05:00
if err != nil {
fmt.Fprintf(stderr, "engine error: %v\n", err)
2026-05-04 21:08:40 -05:00
return ExitRuntimeError
}
2026-09-26 15:47:26 +00:00
return executeRun(cfg, req, engine.Run, stdout, stderr)
}
2026-05-04 21:08:40 -05:00
2026-09-26 15:47:26 +00:00
func executeRun(cfg *runConfig, req promptkit.RunRequest, run func(context.Context, promptkit.RunRequest) (*promptkit.RunResult, error), stdout, stderr io.Writer) int {
var collector *executionreport.Collector
if cfg.executionReportPath != "" {
collector = executionreport.NewCollector()
req.Observer = collector.Observe
}
runContext := context.Background()
cancel := func() {}
if cfg.runTimeout > 0 {
runContext, cancel = context.WithTimeout(runContext, cfg.runTimeout)
}
2026-09-26 15:47:26 +00:00
res, runErr := run(runContext, req)
contextErr := runContext.Err()
cancel()
2026-05-04 21:08:40 -05:00
if runErr != nil {
if errors.Is(contextErr, context.DeadlineExceeded) {
fmt.Fprintln(stderr, "run error: execution deadline exceeded")
2026-09-26 15:47:26 +00:00
} else {
fmt.Fprintln(stderr, runErrorMessage(runErr))
}
2026-05-04 21:08:40 -05:00
}
2026-09-26 15:47:26 +00:00
outputFailed := false
if runErr == nil {
if err := writeOutput(stdout, cfg.outputPath, res.Artifact.Body); err != nil {
fmt.Fprintf(stderr, "output write error: %v\n", err)
outputFailed = true
}
}
reportFailed := false
if collector != nil {
if value := collector.Snapshot(); value == nil {
fmt.Fprintln(stderr, "execution report not produced: no terminal observation")
} else if err := writeExecutionReport(cfg.executionReportPath, value); err != nil {
fmt.Fprintf(stderr, "execution report write error: %v\n", err)
reportFailed = true
}
}
if runErr != nil || outputFailed || reportFailed {
2026-05-04 21:08:40 -05:00
return ExitRuntimeError
}
printSummary(stderr, res)
return determineExitCode(nil, res)
}
2026-09-26 15:47:26 +00:00
func writeExecutionReport(path string, value *executionreport.Execution) error {
data, err := json.Marshal(value)
if err != nil {
return err
}
return os.WriteFile(path, append(data, '\n'), 0644)
}
func renderCommand(args []string, stdout, stderr io.Writer) int {
cfg, err := parseRenderArgs(args)
if err != nil {
2026-09-26 18:08:36 +00:00
writeApplicationError(stderr, "render parse", err)
return ExitRuntimeError
}
if err := writeApplicationWarnings(stderr, cfg.diagnostics); err != nil {
return ExitRuntimeError
}
req, err := buildRunRequestFromConfig(&cfg.runConfig)
if err != nil {
fmt.Fprintf(stderr, "render parse error: %v\n", err)
return ExitRuntimeError
}
2026-08-29 14:18:29 +00:00
engine, err := newEngine(cfg.runConfig.engineSettings())
if err != nil {
fmt.Fprintf(stderr, "engine error: %v\n", err)
return ExitRuntimeError
}
prepared, prepErr := engine.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
}
2026-05-04 21:19:29 -05:00
func serveCommand(args []string, stderr io.Writer) int {
cfg, err := parseServeArgs(args)
if err != nil {
2026-09-26 18:08:36 +00:00
writeApplicationError(stderr, "serve parse", err)
return ExitRuntimeError
}
if err := writeApplicationWarnings(stderr, cfg.diagnostics); err != nil {
2026-05-04 21:19:29 -05:00
return ExitRuntimeError
}
2026-07-28 00:52:24 +00:00
artifactReader, err := httpadapter.NewRestrictedArtifactReader(cfg.artifactRoot, cfg.maxArtifactBytes)
2026-05-04 21:19:29 -05:00
if err != nil {
2026-07-28 00:52:24 +00:00
fmt.Fprintf(stderr, "artifact root error: %v\n", err)
2026-05-04 21:19:29 -05:00
return ExitRuntimeError
}
2026-08-29 14:18:29 +00:00
engine, err := newEngine(cfg.engineSettings(), promptkit.WithArtifactReader(artifactReader))
2026-07-04 23:34:44 +00:00
if err != nil {
2026-07-28 00:52:24 +00:00
fmt.Fprintf(stderr, "engine error: %v\n", err)
2026-07-04 23:34:44 +00:00
return ExitRuntimeError
}
2026-07-28 00:52:24 +00:00
h := httpadapter.NewHandlerWithOptions(engine, httpadapter.HandlerOptions{
2026-07-05 00:17:07 +00:00
MaxRequestBytes: cfg.maxRequestBytes,
MaxResponseBytes: cfg.maxResponseBytes,
})
2026-05-04 21:19:29 -05:00
srv := &http.Server{
Addr: cfg.addr,
Handler: h,
ReadHeaderTimeout: defaults.HTTPReadHeaderTimeoutDefault,
2026-05-04 21:19:29 -05:00
}
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
}
2026-05-04 21:08:40 -05:00
func parseRunArgs(args []string) (*runConfig, error) {
cfg := &runConfig{}
fs := flag.NewFlagSet("run", flag.ContinueOnError)
fs.SetOutput(io.Discard)
registerConfigSelectionFlags(fs, &cfg.configPath, &cfg.configOverlay)
registerExecutionRequestFlags(fs, cfg)
2026-05-06 16:46:33 +00:00
fs.StringVar(&cfg.schemaDir, "schema-dir", "", "base directory for validation schemas")
fs.DurationVar(&cfg.runTimeout, "run-timeout", 0, "whole-run execution deadline")
2026-09-26 15:47:26 +00:00
fs.StringVar(&cfg.executionReportPath, "execution-report", "", "JSON execution report file path")
if err := fs.Parse(args); err != nil {
return nil, err
}
if err := finalizeExecutionRequestConfig(fs, cfg); err != nil {
return nil, err
}
if cfg.runTimeout < 0 {
return nil, errors.New("--run-timeout must be greater than or equal to 0")
}
2026-09-26 15:47:26 +00:00
if flagWasSet(fs, "execution-report") {
if cfg.executionReportPath == "" {
return nil, errors.New("--execution-report requires a nonempty path")
}
cfg.executionReportPath = filepath.Clean(cfg.executionReportPath)
if cfg.outputPath != "" {
collision, err := outputPathsCollide(cfg.outputPath, cfg.executionReportPath)
if err != nil {
return nil, err
}
if collision {
return nil, errors.New("--out and --execution-report select the same file")
}
}
}
return cfg, nil
}
2026-09-26 15:47:26 +00:00
func outputPathsCollide(first, second string) (bool, error) {
firstAbs, err := filepath.Abs(first)
if err != nil {
return false, err
}
secondAbs, err := filepath.Abs(second)
if err != nil {
return false, err
}
if firstAbs == secondAbs {
return true, nil
}
firstInfo, firstErr := os.Stat(firstAbs)
secondInfo, secondErr := os.Stat(secondAbs)
return firstErr == nil && secondErr == nil && os.SameFile(firstInfo, secondInfo), nil
}
func parseRenderArgs(args []string) (*renderConfig, error) {
cfg := &renderConfig{
outputFormat: renderformat.DefaultPreparedRunOutputFormat,
}
fs := flag.NewFlagSet("render", flag.ContinueOnError)
fs.SetOutput(io.Discard)
registerConfigSelectionFlags(fs, &cfg.configPath, &cfg.configOverlay)
registerExecutionRequestFlags(fs, &cfg.runConfig)
2026-09-26 18:08:36 +00:00
fs.StringVar(&cfg.schemaDir, "schema-dir", "", "base directory for validation schemas")
var rawFormat string
2026-05-06 16:46:33 +00:00
fs.StringVar(&rawFormat, "format", "", "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
}
2026-05-06 16:46:33 +00:00
if flagWasSet(fs, "format") {
format, err := renderformat.ParsePreparedRunOutputFormat(rawFormat)
if err != nil {
return nil, err
}
cfg.outputFormat = format
} else {
cfg.outputFormat = cfg.defaultRenderFormat
}
return cfg, nil
}
func parseServeArgs(args []string) (*serveConfig, error) {
cfg := &serveConfig{}
fs := flag.NewFlagSet("serve", flag.ContinueOnError)
fs.SetOutput(io.Discard)
registerConfigSelectionFlags(fs, &cfg.configPath, &cfg.configOverlay)
2026-05-06 16:46:33 +00:00
fs.StringVar(&cfg.addr, "addr", "", "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")
2026-05-06 16:46:33 +00:00
fs.StringVar(&cfg.schemaDir, "schema-dir", "", "base directory for validation schemas")
2026-07-04 23:34:44 +00:00
fs.StringVar(&cfg.artifactRoot, "artifact-root", "", "base directory for HTTP file input artifacts")
2026-07-05 00:17:07 +00:00
fs.Int64Var(&cfg.maxRequestBytes, "max-request-bytes", 0, "maximum HTTP request body bytes; 0 disables the limit")
fs.Int64Var(&cfg.maxArtifactBytes, "max-artifact-bytes", 0, "maximum HTTP file artifact bytes; 0 disables the limit")
fs.Int64Var(&cfg.maxResponseBytes, "max-response-bytes", 0, "maximum HTTP response body bytes; 0 disables the limit")
if err := fs.Parse(args); err != nil {
return nil, err
}
if fs.NArg() > 0 {
return nil, fmt.Errorf("unexpected positional args: %v", fs.Args())
}
settings, err := resolveCommonSettings(fs, cfg.configPath, cfg.configOverlay)
2026-05-06 16:46:33 +00:00
if err != nil {
return nil, err
}
cfg.promptDir = settings.promptDir
cfg.profileDir = settings.profileDir
cfg.schemaDir = settings.schemaDir
cfg.addr = settings.serverAddr
2026-07-04 23:34:44 +00:00
cfg.artifactRoot = settings.artifactRoot
2026-07-05 00:17:07 +00:00
cfg.maxRequestBytes = settings.maxRequestBytes
cfg.maxArtifactBytes = settings.maxArtifactBytes
cfg.maxResponseBytes = settings.maxResponseBytes
2026-08-29 14:18:29 +00:00
cfg.backends = settings.backends
cfg.transientRetries = settings.transientRetries
2026-09-26 18:08:36 +00:00
cfg.diagnostics = settings.diagnostics
2026-05-06 16:46:33 +00:00
2026-07-04 16:48:18 +00:00
if err := validateRequiredLibraryDirs(cfg.promptDir); err != nil {
return nil, err
}
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.promptVersion, "prompt-version", "", "optional prompt definition version")
fs.StringVar(&cfg.profileID, "profile", "", "optional execution profile ID; if omitted, prompt default_profile is used")
2026-08-29 14:22:45 +00:00
fs.StringVar(&cfg.sessionID, "session-id", "", "optional session ID")
2026-05-04 21:08:40 -05:00
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")
2026-05-04 21:08:40 -05:00
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")
2026-08-29 14:22:45 +00:00
fs.StringVar(&cfg.reasoningEffort, "reasoning-effort", "", "optional reasoning effort override")
2026-07-28 14:07:59 +00:00
fs.DurationVar(&cfg.timeout, "timeout", 0, "LLM request timeout")
fs.IntVar(&cfg.transientRetries, "transient-retries", 0, "additional transient generation attempts")
fs.StringVar(&cfg.promptID, "prompt-id", "", "deprecated alias for --prompt")
fs.StringVar(&cfg.profileID, "profile-id", "", "deprecated alias for --profile")
}
2026-05-04 21:08:40 -05:00
func finalizeExecutionRequestConfig(fs *flag.FlagSet, cfg *runConfig) error {
2026-05-04 21:08:40 -05:00
if fs.NArg() > 0 {
return fmt.Errorf("unexpected positional args: %v", fs.Args())
2026-05-04 21:08:40 -05:00
}
settings, err := resolveCommonSettings(fs, cfg.configPath, cfg.configOverlay)
2026-05-06 16:46:33 +00:00
if err != nil {
return err
}
cfg.promptDir = settings.promptDir
cfg.profileDir = settings.profileDir
cfg.schemaDir = settings.schemaDir
cfg.defaultRenderFormat = settings.defaultRenderFormat
2026-08-29 14:18:29 +00:00
cfg.backends = settings.backends
cfg.configuredTransientRetries = settings.transientRetries
2026-09-26 18:08:36 +00:00
cfg.diagnostics = settings.diagnostics
2026-05-06 16:46:33 +00:00
2026-07-04 16:48:18 +00:00
if err := validateRequiredLibraryDirs(cfg.promptDir); err != nil {
return err
2026-05-04 21:08:40 -05:00
}
if strings.TrimSpace(cfg.promptID) == "" {
return errors.New("--prompt is required")
2026-05-04 21:08:40 -05:00
}
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")
2026-08-29 14:22:45 +00:00
cfg.reasoningEffortSet = flagWasSet(fs, "reasoning-effort")
cfg.timeoutSet = flagWasSet(fs, "timeout")
cfg.transientRetriesSet = flagWasSet(fs, "transient-retries")
if cfg.transientRetriesSet && cfg.transientRetries < 0 {
return errors.New("--transient-retries must be greater than or equal to 0")
}
return nil
2026-05-04 21:08:40 -05:00
}
func registerConfigSelectionFlags(fs *flag.FlagSet, target, overlay *string) {
fs.StringVar(
target,
"config",
"",
fmt.Sprintf(
"application config path (default search: %s, then %s)",
appconfig.DefaultConfigPathLocal,
appconfig.DefaultConfigPath,
),
)
fs.StringVar(overlay, "config-overlay", "", "selected application configuration overlay")
2026-05-06 16:46:33 +00:00
}
func resolveCommonSettings(fs *flag.FlagSet, configPath, overlay string) (commonCommandSettings, error) {
settings, diagnostics, err := loadApplicationSettings(configPath, overlay, flagWasSet(fs, "config"), applicationOverrides(fs))
if err != nil {
return commonCommandSettings{}, err
}
return commonCommandSettings{
promptDir: settings.PromptDir,
profileDir: settings.ProfileDir,
schemaDir: settings.SchemaDir,
serverAddr: settings.ServerAddr,
2026-07-04 23:34:44 +00:00
artifactRoot: settings.ArtifactRoot,
2026-07-05 00:17:07 +00:00
maxRequestBytes: settings.MaxRequestBytes,
maxArtifactBytes: settings.MaxArtifactBytes,
maxResponseBytes: settings.MaxResponseBytes,
defaultRenderFormat: settings.DefaultRenderFormat,
2026-08-29 14:18:29 +00:00
backends: settings.Backends,
transientRetries: settings.TransientRetries,
2026-09-26 18:08:36 +00:00
diagnostics: diagnostics,
}, nil
}
2026-07-04 16:48:18 +00:00
func validateRequiredLibraryDirs(promptDir string) error {
if strings.TrimSpace(promptDir) == "" {
return errors.New(errPromptDirRequired)
}
return nil
}
2026-08-29 14:18:29 +00:00
type engineSettings struct {
promptDir string
profileDir string
schemaDir string
backends []appconfig.BackendSettings
transientRetries *int
2026-08-29 14:18:29 +00:00
}
func (c runConfig) engineSettings() engineSettings {
return engineSettings{promptDir: c.promptDir, profileDir: c.profileDir, schemaDir: c.schemaDir, backends: c.backends, transientRetries: c.configuredTransientRetries}
2026-08-29 14:18:29 +00:00
}
func (c serveConfig) engineSettings() engineSettings {
return engineSettings{promptDir: c.promptDir, profileDir: c.profileDir, schemaDir: c.schemaDir, backends: c.backends, transientRetries: c.transientRetries}
2026-08-29 14:18:29 +00:00
}
func newEngine(settings engineSettings, options ...promptkit.Option) (*promptkit.Engine, error) {
engineOptions := make([]promptkit.Option, 0, len(settings.backends)+len(options))
for _, configured := range settings.backends {
engineOptions = append(engineOptions, promptkit.WithBackend(promptkit.Backend{
ID: configured.ID,
Endpoint: configured.Endpoint,
APIKeyEnv: configured.APIKeyEnv,
ExtraParams: configured.ExtraParams,
ConcurrencyLimit: configured.ConcurrencyLimit,
QueueCapacity: configured.QueueCapacity,
}))
}
engineOptions = append(engineOptions, options...)
engine, err := promptkit.NewEngine(promptkit.Config{
PromptDir: settings.promptDir,
ProfileDir: settings.profileDir,
SchemaDir: settings.schemaDir,
TransientRetries: settings.transientRetries,
2026-08-29 14:18:29 +00:00
}, engineOptions...)
if err != nil {
return nil, fmt.Errorf("engine initialization from application configuration: %w", err)
}
return engine, nil
}
func buildRunRequestFromConfig(cfg *runConfig) (promptkit.RunRequest, error) {
var inputMappings map[string]string
var err error
if len(cfg.inputRaw) > 0 {
inputMappings, err = parseMappings(cfg.inputRaw, false)
if err != nil {
return promptkit.RunRequest{}, fmt.Errorf("input parse error: %w", err)
}
2026-05-04 21:19:29 -05:00
}
varMappings := map[string]string{}
if len(cfg.varRaw) > 0 {
varMappings, err = parseMappings(cfg.varRaw, false)
if err != nil {
return promptkit.RunRequest{}, fmt.Errorf("var parse error: %w", err)
}
2026-05-04 21:19:29 -05:00
}
var inputs map[string]promptkit.ArtifactRef
if len(inputMappings) > 0 {
inputs = make(map[string]promptkit.ArtifactRef, len(inputMappings))
for name, path := range inputMappings {
inputs[name] = promptkit.File(path)
}
}
var modelOverride *promptkit.ExecutionTargetOverride
2026-08-29 14:22:45 +00:00
if cfg.llmBaseURLSet || cfg.modelSet || cfg.temperatureSet || cfg.maxTokensSet || cfg.topPSet || cfg.reasoningEffortSet || cfg.apiKeyEnvSet || cfg.timeoutSet {
modelOverride = &promptkit.ExecutionTargetOverride{
Endpoint: cfg.llmBaseURL,
Model: cfg.model,
APIKeyEnv: cfg.apiKeyEnv,
}
if cfg.temperatureSet {
modelOverride.Temperature = &cfg.temperature
}
if cfg.maxTokensSet {
modelOverride.MaxTokens = &cfg.maxTokens
}
if cfg.topPSet {
modelOverride.TopP = &cfg.topP
}
2026-08-29 14:22:45 +00:00
if cfg.reasoningEffortSet {
modelOverride.ReasoningEffort = &cfg.reasoningEffort
}
if cfg.timeoutSet {
timeoutSeconds := int(cfg.timeout.Seconds())
modelOverride.TimeoutSeconds = &timeoutSeconds
}
2026-05-04 21:19:29 -05:00
}
var transientRetries *int
if cfg.transientRetriesSet {
transientRetries = &cfg.transientRetries
}
return promptkit.RunRequest{
PromptID: cfg.promptID,
PromptVersion: cfg.promptVersion,
ProfileID: cfg.profileID,
SessionID: cfg.sessionID,
Inputs: inputs,
Vars: varMappings,
Execution: modelOverride,
TransientRetries: transientRetries,
}, nil
2026-05-04 21:19:29 -05:00
}
2026-05-04 21:08:40 -05:00
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
}
2026-05-04 21:08:40 -05:00
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 *promptkit.RunResult) int {
2026-05-04 21:08:40 -05:00
if runErr != nil {
return ExitRuntimeError
}
if result != nil && result.Validation.Status == promptkit.ValidationFailed {
2026-05-04 21:08:40 -05:00
return ExitValidationFailed
}
return ExitOK
}
func printSummary(stderr io.Writer, res *promptkit.RunResult) {
2026-05-04 21:08:40 -05:00
if res == nil {
return
}
2026-07-02 23:07:57 +00:00
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",
res.PromptID,
res.PromptVersion,
res.SelectedProfileID,
2026-05-04 21:08:40 -05:00
res.ModelName,
res.Validation.Status,
res.Validation.Mode,
len(res.Validation.Errors),
res.RenderedPromptHash,
2026-05-04 21:08:40 -05:00
len(res.InputHashes),
res.Usage.PromptTokens,
res.Usage.CompletionTokens,
res.Usage.TotalTokens,
)
2026-07-02 23:07:57 +00:00
if res.Usage.CachedTokens != 0 || res.Usage.CacheWriteTokens != 0 {
fmt.Fprintf(stderr, " cached_tokens=%d cache_write_tokens=%d", res.Usage.CachedTokens, res.Usage.CacheWriteTokens)
}
if res.SelectedBackendID != "" {
fmt.Fprintf(stderr, " backend=%s", res.SelectedBackendID)
}
2026-07-02 23:07:57 +00:00
fmt.Fprintln(stderr)
2026-05-04 21:08:40 -05:00
}
func runErrorMessage(err error) string {
if errors.Is(err, promptkit.ErrCapacityExceeded) {
return "run error: model backend capacity is exhausted"
}
return fmt.Sprintf("run error: %v", err)
}
2026-05-04 21:08:40 -05:00
func printUsage(w io.Writer) {
2026-08-29 14:29:14 +00:00
fmt.Fprintln(w, "usage: scriptorium <run|render|serve|inspect> ...")
fmt.Fprintln(w, " run: scriptorium run [--config PATH] [--config-overlay NAME] [--prompt-dir DIR] [--profile-dir DIR] [--schema-dir DIR] --prompt ID [--prompt-version VERSION] [--input name=path] [--profile ID] [--session-id ID] [--llm-base-url URL] [--model NAME] [--api-key-env ENV] [--temperature N] [--max-tokens N] [--top-p N] [--reasoning-effort VALUE] [--var k=v] [--out path] [--execution-report path] [--timeout 10m] [--run-timeout 10m] [--transient-retries N]")
fmt.Fprintln(w, " render: scriptorium render [--config PATH] [--config-overlay NAME] [--prompt-dir DIR] [--profile-dir DIR] [--schema-dir DIR] --prompt ID [--prompt-version VERSION] [--input name=path] [--profile ID] [--session-id ID] [--llm-base-url URL] [--model NAME] [--api-key-env ENV] [--temperature N] [--max-tokens N] [--top-p N] [--reasoning-effort VALUE] [--var k=v] [--format text|json] [--out path] [--timeout 10m] [--transient-retries N]")
fmt.Fprintf(w, " serve: scriptorium serve [--config PATH] [--config-overlay NAME] [--addr %s] [--prompt-dir DIR] [--profile-dir DIR] [--schema-dir DIR] [--artifact-root DIR] [--max-request-bytes N] [--max-artifact-bytes N] [--max-response-bytes N]\n", defaults.HTTPAddrDefault)
fmt.Fprintln(w, " inspect prompt: scriptorium inspect prompt --prompt ID [--prompt-version VERSION] [--config PATH] [--config-overlay NAME] [--prompt-dir DIR] [--format text|json] [--out path]")
fmt.Fprintln(w, " inspect profile: scriptorium inspect profile --profile ID [--config PATH] [--config-overlay NAME] [--profile-dir DIR] [--format text|json] [--out path]")
2026-05-04 21:08:40 -05:00
}