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

723 lines
21 KiB
Go
Raw Normal View History

2026-05-04 21:08:40 -05:00
package cli
import (
"context"
"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"
"gitea.maximumdirect.net/eric/scriptorium"
2026-05-04 21:19:29 -05:00
httpadapter "gitea.maximumdirect.net/eric/scriptorium/internal/adapter/http"
artifactadapter "gitea.maximumdirect.net/eric/scriptorium/internal/artifact"
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-05-04 21:08:40 -05:00
"gitea.maximumdirect.net/eric/scriptorium/internal/llm"
2026-07-04 16:48:18 +00:00
"gitea.maximumdirect.net/eric/scriptorium/internal/profile/builtin"
2026-05-04 21:08:40 -05:00
"gitea.maximumdirect.net/eric/scriptorium/internal/prompt"
"gitea.maximumdirect.net/eric/scriptorium/internal/promptdef"
2026-05-04 21:08:40 -05:00
"gitea.maximumdirect.net/eric/scriptorium/internal/usecase"
"gitea.maximumdirect.net/eric/scriptorium/internal/validate"
)
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
2026-05-04 21:08:40 -05:00
profileDir string
promptID string
2026-05-04 21:08:40 -05:00
profileID string
inputRaw listFlag
varRaw listFlag
outputPath string
llmBaseURL string
apiKeyEnv string
2026-05-04 21:08:40 -05:00
model string
temperature float64
maxTokens int
topP float64
2026-05-04 21:08:40 -05:00
schemaDir string
2026-05-04 22:38:20 -05:00
timeout time.Duration
2026-05-06 16:46:33 +00:00
defaultRenderFormat renderformat.PreparedRunOutputFormat
llmBaseURLSet bool
apiKeyEnvSet bool
modelSet bool
temperatureSet bool
maxTokensSet bool
topPSet bool
timeoutSet 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-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-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-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 {
fmt.Fprintf(stderr, "run parse error: %v\n", err)
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
}
engine, err := newEngine(cfg)
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
}
res, runErr := engine.Run(context.Background(), req)
2026-05-04 21:08:40 -05:00
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
}
engine, err := newEngine(&cfg.runConfig)
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 {
fmt.Fprintf(stderr, "serve parse error: %v\n", err)
return ExitRuntimeError
}
llmClient, err := newOpenAIClient()
2026-05-04 21:19:29 -05:00
if err != nil {
fmt.Fprintf(stderr, "llm client error: %v\n", err)
return ExitRuntimeError
}
2026-07-05 00:17:07 +00:00
artifactReader, err := artifactadapter.NewRestrictedCompositeReaderWithLimit(cfg.artifactRoot, cfg.maxArtifactBytes)
2026-07-04 23:34:44 +00:00
if err != nil {
fmt.Fprintf(stderr, "artifact root error: %v\n", err)
return ExitRuntimeError
}
runner := newRunnerWithArtifactReader(cfg.promptDir, cfg.profileDir, cfg.schemaDir, llmClient, artifactReader)
2026-05-04 21:19:29 -05:00
2026-07-05 00:17:07 +00:00
h := httpadapter.NewHandlerWithOptions(runner, httpadapter.HandlerOptions{
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)
2026-05-06 16:46:33 +00:00
registerConfigPathFlag(fs, &cfg.configPath)
registerExecutionRequestFlags(fs, cfg)
2026-05-06 16:46:33 +00:00
fs.StringVar(&cfg.schemaDir, "schema-dir", "", "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)
2026-05-06 16:46:33 +00:00
registerConfigPathFlag(fs, &cfg.configPath)
registerExecutionRequestFlags(fs, &cfg.runConfig)
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)
2026-05-06 16:46:33 +00:00
registerConfigPathFlag(fs, &cfg.configPath)
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, appconfig.CLIOverrides{
2026-07-05 00:17:07 +00:00
PromptDir: cfg.promptDirIfSet(fs),
ProfileDir: cfg.profileDirIfSet(fs),
SchemaDir: cfg.schemaDirIfSet(fs),
ServerAddr: cfg.addrIfSet(fs),
ArtifactRoot: cfg.artifactRootIfSet(fs),
MaxRequestBytes: cfg.maxRequestBytesIfSet(fs),
MaxArtifactBytes: cfg.maxArtifactBytesIfSet(fs),
MaxResponseBytes: cfg.maxResponseBytesIfSet(fs),
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-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
}
cfg.promptDir = filepath.Clean(cfg.promptDir)
2026-07-04 16:48:18 +00:00
if strings.TrimSpace(cfg.profileDir) != "" {
cfg.profileDir = filepath.Clean(cfg.profileDir)
}
cfg.schemaDir = filepath.Clean(cfg.schemaDir)
2026-07-04 23:34:44 +00:00
if strings.TrimSpace(cfg.artifactRoot) != "" {
cfg.artifactRoot = filepath.Clean(cfg.artifactRoot)
}
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")
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")
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")
}
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, appconfig.CLIOverrides{
2026-05-06 16:46:33 +00:00
PromptDir: cfg.promptDirIfSet(fs),
ProfileDir: cfg.profileDirIfSet(fs),
SchemaDir: cfg.schemaDirIfSet(fs),
})
if err != nil {
return err
}
cfg.promptDir = settings.promptDir
cfg.profileDir = settings.profileDir
cfg.schemaDir = settings.schemaDir
cfg.defaultRenderFormat = settings.defaultRenderFormat
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 len(cfg.inputRaw) == 0 {
return errors.New("at least one --input is required")
2026-05-04 21:08:40 -05:00
}
cfg.promptDir = filepath.Clean(cfg.promptDir)
2026-07-04 16:48:18 +00:00
if strings.TrimSpace(cfg.profileDir) != "" {
cfg.profileDir = filepath.Clean(cfg.profileDir)
}
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")
cfg.timeoutSet = flagWasSet(fs, "timeout")
return nil
2026-05-04 21:08:40 -05:00
}
2026-05-06 16:46:33 +00:00
func (c *runConfig) promptDirIfSet(fs *flag.FlagSet) string {
if flagWasSet(fs, "prompt-dir") {
return c.promptDir
}
return ""
}
func (c *runConfig) profileDirIfSet(fs *flag.FlagSet) string {
if flagWasSet(fs, "profile-dir") {
return c.profileDir
}
return ""
}
func (c *runConfig) schemaDirIfSet(fs *flag.FlagSet) string {
if flagWasSet(fs, "schema-dir") {
return c.schemaDir
}
return ""
}
func (c *serveConfig) promptDirIfSet(fs *flag.FlagSet) string {
if flagWasSet(fs, "prompt-dir") {
return c.promptDir
}
return ""
}
func (c *serveConfig) profileDirIfSet(fs *flag.FlagSet) string {
if flagWasSet(fs, "profile-dir") {
return c.profileDir
}
return ""
}
func (c *serveConfig) schemaDirIfSet(fs *flag.FlagSet) string {
if flagWasSet(fs, "schema-dir") {
return c.schemaDir
}
return ""
}
func (c *serveConfig) addrIfSet(fs *flag.FlagSet) string {
if flagWasSet(fs, "addr") {
return c.addr
}
return ""
}
2026-07-04 23:34:44 +00:00
func (c *serveConfig) artifactRootIfSet(fs *flag.FlagSet) string {
if flagWasSet(fs, "artifact-root") {
return c.artifactRoot
}
return ""
}
2026-07-05 00:17:07 +00:00
func (c *serveConfig) maxRequestBytesIfSet(fs *flag.FlagSet) *int64 {
if flagWasSet(fs, "max-request-bytes") {
return &c.maxRequestBytes
}
return nil
}
func (c *serveConfig) maxArtifactBytesIfSet(fs *flag.FlagSet) *int64 {
if flagWasSet(fs, "max-artifact-bytes") {
return &c.maxArtifactBytes
}
return nil
}
func (c *serveConfig) maxResponseBytesIfSet(fs *flag.FlagSet) *int64 {
if flagWasSet(fs, "max-response-bytes") {
return &c.maxResponseBytes
}
return nil
}
2026-05-06 16:46:33 +00:00
func registerConfigPathFlag(fs *flag.FlagSet, target *string) {
fs.StringVar(
target,
"config",
"",
fmt.Sprintf(
"application config path (default search: %s, then %s)",
appconfig.DefaultConfigPathLocal,
appconfig.DefaultConfigPath,
),
)
2026-05-06 16:46:33 +00:00
}
func resolveAppSettings(fs *flag.FlagSet, configPath string, overrides appconfig.CLIOverrides) (appconfig.AppSettings, error) {
settings, err := appconfig.LoadConfig(configPath, flagWasSet(fs, "config"))
if err != nil {
return appconfig.AppSettings{}, fmt.Errorf("application config error: %w", err)
}
merged, err := appconfig.ApplyCLIOverrides(settings, overrides)
if err != nil {
return appconfig.AppSettings{}, fmt.Errorf("application config error: %w", err)
}
return merged, nil
}
func resolveCommonSettings(fs *flag.FlagSet, configPath string, overrides appconfig.CLIOverrides) (commonCommandSettings, error) {
settings, err := resolveAppSettings(fs, configPath, overrides)
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,
}, nil
}
2026-07-04 16:48:18 +00:00
func validateRequiredLibraryDirs(promptDir string) error {
if strings.TrimSpace(promptDir) == "" {
return errors.New(errPromptDirRequired)
}
return nil
}
func newRunner(promptDir, profileDir, schemaDir string, llmClient llm.Client) *usecase.Runner {
2026-07-04 23:34:44 +00:00
return newRunnerWithArtifactReader(promptDir, profileDir, schemaDir, llmClient, artifactadapter.NewCompositeReader())
}
func newRunnerWithArtifactReader(promptDir, profileDir, schemaDir string, llmClient llm.Client, artifactReader artifactadapter.Reader) *usecase.Runner {
if artifactReader == nil {
artifactReader = artifactadapter.NewCompositeReader()
}
return usecase.NewRunner(
promptdef.NewFilesystemRepository(promptDir),
2026-07-04 16:48:18 +00:00
builtin.NewRepositoryWithDirectory(profileDir),
2026-07-04 23:34:44 +00:00
artifactReader,
prompt.NewGoRenderer(),
llmClient,
validate.NewStandardValidator(schemaDir),
)
}
func newOpenAIClient() (*llm.OpenAICompatibleClient, error) {
return llm.NewOpenAICompatibleClient(llm.OpenAICompatibleConfig{
Timeout: defaults.LLMRequestTimeoutDefault,
})
}
func newEngine(cfg *runConfig, options ...scriptorium.Option) (*scriptorium.Engine, error) {
return scriptorium.NewEngine(scriptorium.Config{
PromptDir: cfg.promptDir,
ProfileDir: cfg.profileDir,
SchemaDir: cfg.schemaDir,
}, options...)
}
func buildRunRequestFromConfig(cfg *runConfig) (scriptorium.RunRequest, error) {
inputMappings, err := parseMappings(cfg.inputRaw, false)
if err != nil {
return scriptorium.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 scriptorium.RunRequest{}, fmt.Errorf("var parse error: %w", err)
}
2026-05-04 21:19:29 -05:00
}
inputs := make(map[string]scriptorium.ArtifactRef, len(inputMappings))
for name, path := range inputMappings {
inputs[name] = scriptorium.File(path)
}
var modelOverride *scriptorium.ExecutionTargetOverride
if cfg.llmBaseURLSet || cfg.modelSet || cfg.temperatureSet || cfg.maxTokensSet || cfg.topPSet || cfg.apiKeyEnvSet || cfg.timeoutSet {
modelOverride = &scriptorium.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
}
if cfg.timeoutSet {
timeoutSeconds := int(cfg.timeout.Seconds())
modelOverride.TimeoutSeconds = &timeoutSeconds
}
2026-05-04 21:19:29 -05:00
}
return scriptorium.RunRequest{
PromptID: cfg.promptID,
ProfileID: cfg.profileID,
Inputs: inputs,
Vars: varMappings,
Execution: modelOverride,
}, 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 *scriptorium.RunResult) int {
2026-05-04 21:08:40 -05:00
if runErr != nil {
return ExitRuntimeError
}
if result != nil && result.Validation.Status == scriptorium.ValidationFailed {
2026-05-04 21:08:40 -05:00
return ExitValidationFailed
}
return ExitOK
}
func printSummary(stderr io.Writer, res *scriptorium.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)
}
fmt.Fprintln(stderr)
2026-05-04 21:08:40 -05:00
}
func printUsage(w io.Writer) {
fmt.Fprintln(w, "usage: scriptorium <run|render|serve> ...")
2026-05-06 16:48:35 +00:00
fmt.Fprintln(w, " run: scriptorium run [--config PATH] [--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 [--config PATH] [--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]")
2026-07-05 00:24:17 +00:00
fmt.Fprintf(w, " serve: scriptorium serve [--config PATH] [--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)
2026-05-04 21:08:40 -05:00
}