Add a minimal CLI
This commit is contained in:
16
README.md
16
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
|
||||
```
|
||||
|
||||
11
cmd/scriptorium/main.go
Normal file
11
cmd/scriptorium/main.go
Normal file
@@ -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))
|
||||
}
|
||||
262
internal/adapter/cli/run.go
Normal file
262
internal/adapter/cli/run.go
Normal file
@@ -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]")
|
||||
}
|
||||
90
internal/adapter/cli/run_test.go
Normal file
90
internal/adapter/cli/run_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user