diff --git a/internal/adapter/cli/run.go b/internal/adapter/cli/run.go index 519ffe7..e04f130 100644 --- a/internal/adapter/cli/run.go +++ b/internal/adapter/cli/run.go @@ -81,6 +81,14 @@ type serveConfig struct { schemaDir string } +type commonCommandSettings struct { + promptDir string + profileDir string + schemaDir string + serverAddr string + defaultRenderFormat renderformat.PreparedRunOutputFormat +} + type listFlag []string func (l *listFlag) String() string { @@ -125,22 +133,13 @@ func runCommand(args []string, stdout, stderr io.Writer) int { return ExitRuntimeError } - llmClient, err := llm.NewOpenAICompatibleClient(llm.OpenAICompatibleConfig{ - Timeout: defaults.LLMRequestTimeoutDefault, - }) + llmClient, err := newOpenAIClient() if err != nil { fmt.Fprintf(stderr, "llm client error: %v\n", err) return ExitRuntimeError } - runner := usecase.NewRunner( - promptdef.NewFilesystemRepository(cfg.promptDir), - profile.NewFilesystemRepository(cfg.profileDir), - artifactadapter.NewCompositeReader(), - prompt.NewGoRenderer(), - llmClient, - validate.NewStandardValidator(cfg.schemaDir), - ) + runner := newRunner(cfg.promptDir, cfg.profileDir, cfg.schemaDir, llmClient) res, runErr := runner.Run(context.Background(), req) if runErr != nil { @@ -170,14 +169,7 @@ func renderCommand(args []string, stdout, stderr io.Writer) int { return ExitRuntimeError } - runner := usecase.NewRunner( - promptdef.NewFilesystemRepository(cfg.promptDir), - profile.NewFilesystemRepository(cfg.profileDir), - artifactadapter.NewCompositeReader(), - prompt.NewGoRenderer(), - nil, - validate.NewStandardValidator(cfg.schemaDir), - ) + runner := newRunner(cfg.promptDir, cfg.profileDir, cfg.schemaDir, nil) prepared, prepErr := runner.Prepare(context.Background(), req) if prepErr != nil { @@ -205,22 +197,13 @@ func serveCommand(args []string, stderr io.Writer) int { return ExitRuntimeError } - llmClient, err := llm.NewOpenAICompatibleClient(llm.OpenAICompatibleConfig{ - Timeout: defaults.LLMRequestTimeoutDefault, - }) + llmClient, err := newOpenAIClient() if err != nil { fmt.Fprintf(stderr, "llm client error: %v\n", err) return ExitRuntimeError } - runner := usecase.NewRunner( - promptdef.NewFilesystemRepository(cfg.promptDir), - profile.NewFilesystemRepository(cfg.profileDir), - artifactadapter.NewCompositeReader(), - prompt.NewGoRenderer(), - llmClient, - validate.NewStandardValidator(cfg.schemaDir), - ) + runner := newRunner(cfg.promptDir, cfg.profileDir, cfg.schemaDir, llmClient) h := httpadapter.NewHandler(runner) srv := &http.Server{ @@ -307,7 +290,7 @@ func parseServeArgs(args []string) (*serveConfig, error) { return nil, fmt.Errorf("unexpected positional args: %v", fs.Args()) } - settings, err := resolveAppSettings(fs, cfg.configPath, appconfig.CLIOverrides{ + settings, err := resolveCommonSettings(fs, cfg.configPath, appconfig.CLIOverrides{ PromptDir: cfg.promptDirIfSet(fs), ProfileDir: cfg.profileDirIfSet(fs), SchemaDir: cfg.schemaDirIfSet(fs), @@ -317,16 +300,13 @@ func parseServeArgs(args []string) (*serveConfig, error) { return nil, err } - cfg.promptDir = settings.PromptDir - cfg.profileDir = settings.ProfileDir - cfg.schemaDir = settings.SchemaDir - cfg.addr = settings.ServerAddr + cfg.promptDir = settings.promptDir + cfg.profileDir = settings.profileDir + cfg.schemaDir = settings.schemaDir + cfg.addr = settings.serverAddr - if strings.TrimSpace(cfg.promptDir) == "" { - return nil, errors.New(errPromptDirRequired) - } - if strings.TrimSpace(cfg.profileDir) == "" { - return nil, errors.New(errProfileDirRequired) + if err := validateRequiredLibraryDirs(cfg.promptDir, cfg.profileDir); err != nil { + return nil, err } cfg.promptDir = filepath.Clean(cfg.promptDir) @@ -359,7 +339,7 @@ func finalizeExecutionRequestConfig(fs *flag.FlagSet, cfg *runConfig) error { return fmt.Errorf("unexpected positional args: %v", fs.Args()) } - settings, err := resolveAppSettings(fs, cfg.configPath, appconfig.CLIOverrides{ + settings, err := resolveCommonSettings(fs, cfg.configPath, appconfig.CLIOverrides{ PromptDir: cfg.promptDirIfSet(fs), ProfileDir: cfg.profileDirIfSet(fs), SchemaDir: cfg.schemaDirIfSet(fs), @@ -368,16 +348,13 @@ func finalizeExecutionRequestConfig(fs *flag.FlagSet, cfg *runConfig) error { return err } - cfg.promptDir = settings.PromptDir - cfg.profileDir = settings.ProfileDir - cfg.schemaDir = settings.SchemaDir - cfg.defaultRenderFormat = settings.DefaultRenderFormat + cfg.promptDir = settings.promptDir + cfg.profileDir = settings.profileDir + cfg.schemaDir = settings.schemaDir + cfg.defaultRenderFormat = settings.defaultRenderFormat - if strings.TrimSpace(cfg.promptDir) == "" { - return errors.New(errPromptDirRequired) - } - if strings.TrimSpace(cfg.profileDir) == "" { - return errors.New(errProfileDirRequired) + if err := validateRequiredLibraryDirs(cfg.promptDir, cfg.profileDir); err != nil { + return err } if strings.TrimSpace(cfg.promptID) == "" { return errors.New("--prompt is required") @@ -476,6 +453,47 @@ func resolveAppSettings(fs *flag.FlagSet, configPath string, overrides appconfig 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, + defaultRenderFormat: settings.DefaultRenderFormat, + }, nil +} + +func validateRequiredLibraryDirs(promptDir, profileDir string) error { + if strings.TrimSpace(promptDir) == "" { + return errors.New(errPromptDirRequired) + } + if strings.TrimSpace(profileDir) == "" { + return errors.New(errProfileDirRequired) + } + return nil +} + +func newRunner(promptDir, profileDir, schemaDir string, llmClient llm.Client) *usecase.Runner { + return usecase.NewRunner( + promptdef.NewFilesystemRepository(promptDir), + profile.NewFilesystemRepository(profileDir), + artifactadapter.NewCompositeReader(), + prompt.NewGoRenderer(), + llmClient, + validate.NewStandardValidator(schemaDir), + ) +} + +func newOpenAIClient() (*llm.OpenAICompatibleClient, error) { + return llm.NewOpenAICompatibleClient(llm.OpenAICompatibleConfig{ + Timeout: defaults.LLMRequestTimeoutDefault, + }) +} + func buildRunRequestFromConfig(cfg *runConfig) (domain.RunRequest, error) { inputMappings, err := parseMappings(cfg.inputRaw, false) if err != nil { diff --git a/internal/adapter/cli/run_test.go b/internal/adapter/cli/run_test.go index 8af17d6..7b06d8c 100644 --- a/internal/adapter/cli/run_test.go +++ b/internal/adapter/cli/run_test.go @@ -11,6 +11,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "reflect" "strings" "sync/atomic" "testing" @@ -410,6 +411,28 @@ defaults: } } +func TestParseRenderArgsExplicitFormatOverridesConfigDefaultFormat(t *testing.T) { + configPath := writeAppConfigFile(t, ` +prompt_dir: ./from-config/prompts +profile_dir: ./from-config/profiles +defaults: + render_format: json +`) + + cfg, err := parseRenderArgs([]string{ + "--config", configPath, + "--prompt", "p", + "--input", "a=b", + "--format", "text", + }) + if err != nil { + t.Fatalf("expected valid args, got %v", err) + } + if cfg.outputFormat != renderformat.PreparedRunFormatText { + t.Fatalf("expected explicit --format text to override config default, got %q", cfg.outputFormat) + } +} + func TestParseServeArgsWithExplicitConfigLoadsSettingsAndCLIAddrOverrides(t *testing.T) { configPath := writeAppConfigFile(t, ` prompt_dir: ./from-config/prompts @@ -471,6 +494,59 @@ server: } } +func TestRunAndRenderBuildEquivalentRuntimeOverrideRequestsForSharedFlags(t *testing.T) { + runCfg, err := parseRunArgs([]string{ + "--prompt-dir", "./prompts", + "--profile-dir", "./profiles", + "--prompt", "prompt-1", + "--profile", "profile-1", + "--input", "transcript=./transcript.md", + "--var", "session_date=2026-05-01", + "--llm-base-url", "http://localhost:8000/v1", + "--model", "model-x", + "--temperature", "0.8", + "--max-tokens", "123", + "--top-p", "0.6", + "--timeout", "90s", + "--api-key-env", "SCRIPTORIUM_API_KEY", + }) + if err != nil { + t.Fatalf("expected valid run args, got %v", err) + } + + renderCfg, err := parseRenderArgs([]string{ + "--prompt-dir", "./prompts", + "--profile-dir", "./profiles", + "--prompt", "prompt-1", + "--profile", "profile-1", + "--input", "transcript=./transcript.md", + "--var", "session_date=2026-05-01", + "--llm-base-url", "http://localhost:8000/v1", + "--model", "model-x", + "--temperature", "0.8", + "--max-tokens", "123", + "--top-p", "0.6", + "--timeout", "90s", + "--api-key-env", "SCRIPTORIUM_API_KEY", + }) + if err != nil { + t.Fatalf("expected valid render args, got %v", err) + } + + runReq, err := buildRunRequestFromConfig(runCfg) + if err != nil { + t.Fatalf("expected run request build success, got %v", err) + } + renderReq, err := buildRunRequestFromConfig(&renderCfg.runConfig) + if err != nil { + t.Fatalf("expected render request build success, got %v", err) + } + + if !reflect.DeepEqual(runReq, renderReq) { + t.Fatalf("expected run/render shared flag requests to match.\nrun=%#v\nrender=%#v", runReq, renderReq) + } +} + func TestParseRunArgsFailsClearlyWhenNoEffectivePromptDir(t *testing.T) { configPath := writeAppConfigFile(t, ` profile_dir: ./profiles