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"
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-04 21:08:40 -05:00
"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
}
2026-05-04 21:19:29 -05:00
type serveConfig struct {
addr string
profileDir string
schemaDir string
llmBaseURL string
llmAPIKey string
model string
timeout time . Duration
}
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 )
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
}
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 ) ,
2026-05-04 21:19:29 -05:00
artifactadapter . NewCompositeReader ( ) ,
2026-05-04 21:08:40 -05:00
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 )
}
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 := 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
}
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 )
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
}
2026-05-04 21:19:29 -05:00
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
}
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 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 ) {
2026-05-04 21:19:29 -05:00
fmt . Fprintln ( w , "usage: scriptorium <run|serve> ..." )
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]" )
2026-05-04 21:08:40 -05:00
}