From 526ee463f256a3b06f4bc8a3c677abb3f6395374 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Mon, 4 May 2026 21:08:40 -0500 Subject: [PATCH] Add a minimal CLI --- README.md | 16 ++ cmd/scriptorium/main.go | 11 ++ internal/adapter/cli/run.go | 262 +++++++++++++++++++++++++++++++ internal/adapter/cli/run_test.go | 90 +++++++++++ 4 files changed, 379 insertions(+) create mode 100644 cmd/scriptorium/main.go create mode 100644 internal/adapter/cli/run.go create mode 100644 internal/adapter/cli/run_test.go diff --git a/README.md b/README.md index e5e43f5..570f775 100644 --- a/README.md +++ b/README.md @@ -7,3 +7,19 @@ Current implementation scope: - YAML-backed prompt profile loading - inline and file artifact reading - provider-neutral prompt rendering + +## Local CLI usage + +Run a profile locally against an OpenAI-compatible endpoint: + +```bash +go run ./cmd/scriptorium run \ + --profile-dir ./profiles \ + --profile-id recap \ + --input transcript=./testdata/transcript.md \ + --input glossary=./testdata/glossary.yml \ + --var session_date=2026-05-04 \ + --llm-base-url http://localhost:8000/v1 \ + --model gpt-4o-mini \ + --out ./out.md +``` diff --git a/cmd/scriptorium/main.go b/cmd/scriptorium/main.go new file mode 100644 index 0000000..bc3e0fd --- /dev/null +++ b/cmd/scriptorium/main.go @@ -0,0 +1,11 @@ +package main + +import ( + "os" + + "gitea.maximumdirect.net/eric/scriptorium/internal/adapter/cli" +) + +func main() { + os.Exit(cli.Run(os.Args[1:], os.Stdout, os.Stderr)) +} diff --git a/internal/adapter/cli/run.go b/internal/adapter/cli/run.go new file mode 100644 index 0000000..b163abb --- /dev/null +++ b/internal/adapter/cli/run.go @@ -0,0 +1,262 @@ +package cli + +import ( + "context" + "errors" + "flag" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "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 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) + 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), + artifact.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 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 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 run --profile-dir DIR --profile-id ID --input name=path [--input ...] --llm-base-url URL --model NAME [--var k=v] [--out path]") +} diff --git a/internal/adapter/cli/run_test.go b/internal/adapter/cli/run_test.go new file mode 100644 index 0000000..cff6378 --- /dev/null +++ b/internal/adapter/cli/run_test.go @@ -0,0 +1,90 @@ +package cli + +import ( + "errors" + "testing" + + "gitea.maximumdirect.net/eric/scriptorium/internal/domain" +) + +func TestParseMappingsSingleAndRepeated(t *testing.T) { + got, err := parseMappings([]string{"transcript=./t.md", "glossary=./g.yml"}, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got["transcript"] != "./t.md" || got["glossary"] != "./g.yml" { + t.Fatalf("unexpected mappings: %#v", got) + } +} + +func TestParseMappingsCommaSeparated(t *testing.T) { + got, err := parseMappings([]string{"transcript=./t.md,glossary=./g.yml"}, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got["transcript"] != "./t.md" || got["glossary"] != "./g.yml" { + t.Fatalf("unexpected mappings: %#v", got) + } +} + +func TestParseMappingsVarWithEqualsInValue(t *testing.T) { + got, err := parseMappings([]string{"session_note=a=b=c"}, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got["session_note"] != "a=b=c" { + t.Fatalf("unexpected variable value: %#v", got) + } +} + +func TestParseMappingsMalformed(t *testing.T) { + tests := []string{"", "novalue", "=emptyname", "name="} + for _, tc := range tests { + _, err := parseMappings([]string{tc}, false) + if err == nil { + t.Fatalf("expected error for %q", tc) + } + } +} + +func TestParseRunArgsRequiredFlags(t *testing.T) { + _, err := parseRunArgs([]string{"--profile-id", "p", "--input", "a=b", "--llm-base-url", "http://x/v1", "--model", "m"}) + if err == nil { + t.Fatal("expected missing --profile-dir error") + } + + _, err = parseRunArgs([]string{"--profile-dir", "./profiles", "--input", "a=b", "--llm-base-url", "http://x/v1", "--model", "m"}) + if err == nil { + t.Fatal("expected missing --profile-id error") + } + + _, err = parseRunArgs([]string{"--profile-dir", "./profiles", "--profile-id", "p", "--llm-base-url", "http://x/v1", "--model", "m"}) + if err == nil { + t.Fatal("expected missing --input error") + } + + _, err = parseRunArgs([]string{"--profile-dir", "./profiles", "--profile-id", "p", "--input", "a=b", "--model", "m"}) + if err == nil { + t.Fatal("expected missing --llm-base-url error") + } + + _, err = parseRunArgs([]string{"--profile-dir", "./profiles", "--profile-id", "p", "--input", "a=b", "--llm-base-url", "http://x/v1"}) + if err == nil { + t.Fatal("expected missing --model error") + } +} + +func TestDetermineExitCode(t *testing.T) { + if got := determineExitCode(errors.New("boom"), nil); got != ExitRuntimeError { + t.Fatalf("expected runtime exit code, got %d", got) + } + if got := determineExitCode(nil, &domain.RunResult{Validation: domain.ValidationResult{Status: domain.ValidationFailed}}); got != ExitValidationFailed { + t.Fatalf("expected validation exit code, got %d", got) + } + if got := determineExitCode(nil, &domain.RunResult{Validation: domain.ValidationResult{Status: domain.ValidationPassed}}); got != ExitOK { + t.Fatalf("expected success exit code for passed validation, got %d", got) + } + if got := determineExitCode(nil, &domain.RunResult{Validation: domain.ValidationResult{Status: domain.ValidationSkipped}}); got != ExitOK { + t.Fatalf("expected success exit code for skipped validation, got %d", got) + } +}