From 7be543a541ed9f846b7517fc728583a4f56c4f2f Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Wed, 6 May 2026 16:48:35 +0000 Subject: [PATCH] Allow CLI paths to come from config --- internal/adapter/cli/run.go | 19 +-- internal/adapter/cli/run_test.go | 191 +++++++++++++++++++++++++++++++ 2 files changed, 203 insertions(+), 7 deletions(-) diff --git a/internal/adapter/cli/run.go b/internal/adapter/cli/run.go index 54979cf..5ef9f65 100644 --- a/internal/adapter/cli/run.go +++ b/internal/adapter/cli/run.go @@ -32,6 +32,11 @@ const ( ExitValidationFailed = 2 ) +const ( + errPromptDirRequired = "prompt directory is required; provide --prompt-dir or config.yml prompt_dir" + errProfileDirRequired = "profile directory is required; provide --profile-dir or config.yml profile_dir" +) + type runConfig struct { configPath string @@ -318,10 +323,10 @@ func parseServeArgs(args []string) (*serveConfig, error) { cfg.addr = settings.ServerAddr if strings.TrimSpace(cfg.promptDir) == "" { - return nil, errors.New("--prompt-dir is required") + return nil, errors.New(errPromptDirRequired) } if strings.TrimSpace(cfg.profileDir) == "" { - return nil, errors.New("--profile-dir is required") + return nil, errors.New(errProfileDirRequired) } cfg.promptDir = filepath.Clean(cfg.promptDir) @@ -369,10 +374,10 @@ func finalizeExecutionRequestConfig(fs *flag.FlagSet, cfg *runConfig) error { cfg.defaultRenderFormat = settings.DefaultRenderFormat if strings.TrimSpace(cfg.promptDir) == "" { - return errors.New("--prompt-dir is required") + return errors.New(errPromptDirRequired) } if strings.TrimSpace(cfg.profileDir) == "" { - return errors.New("--profile-dir is required") + return errors.New(errProfileDirRequired) } if strings.TrimSpace(cfg.promptID) == "" { return errors.New("--prompt is required") @@ -592,7 +597,7 @@ func printSummary(stderr io.Writer, res *domain.RunResult) { func printUsage(w io.Writer) { fmt.Fprintln(w, "usage: scriptorium ...") - 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]") - fmt.Fprintf(w, " serve: scriptorium serve [--config PATH] --addr %s --prompt-dir DIR --profile-dir DIR [--schema-dir DIR]\n", defaults.HTTPAddrDefault) + 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]") + fmt.Fprintf(w, " serve: scriptorium serve [--config PATH] [--addr %s] [--prompt-dir DIR] [--profile-dir DIR] [--schema-dir DIR]\n", defaults.HTTPAddrDefault) } diff --git a/internal/adapter/cli/run_test.go b/internal/adapter/cli/run_test.go index cfcfb69..9e08566 100644 --- a/internal/adapter/cli/run_test.go +++ b/internal/adapter/cli/run_test.go @@ -69,11 +69,17 @@ func TestParseRunArgsRequiredFlags(t *testing.T) { if err == nil { t.Fatal("expected missing --prompt-dir error") } + if !strings.Contains(err.Error(), "prompt directory is required") { + t.Fatalf("expected clear prompt-dir guidance, got %v", err) + } _, err = parseRunArgs([]string{"--config", configPath, "--prompt-dir", "./prompts", "--prompt", "p", "--input", "a=b"}) if err == nil { t.Fatal("expected missing --profile-dir error") } + if !strings.Contains(err.Error(), "profile directory is required") { + t.Fatalf("expected clear profile-dir guidance, got %v", err) + } _, err = parseRunArgs([]string{"--config", configPath, "--prompt-dir", "./prompts", "--profile-dir", "./profiles", "--input", "a=b"}) if err == nil { @@ -150,11 +156,17 @@ func TestParseServeArgsRequiredFlags(t *testing.T) { if err == nil { t.Fatal("expected missing --prompt-dir error") } + if !strings.Contains(err.Error(), "prompt directory is required") { + t.Fatalf("expected clear prompt-dir guidance, got %v", err) + } _, err = parseServeArgs([]string{"--config", configPath, "--prompt-dir", "./prompts"}) if err == nil { t.Fatal("expected missing --profile-dir error") } + if !strings.Contains(err.Error(), "profile directory is required") { + t.Fatalf("expected clear profile-dir guidance, got %v", err) + } cfg, err := parseServeArgs([]string{"--config", configPath, "--prompt-dir", "./prompts", "--profile-dir", "./profiles"}) if err != nil { @@ -413,6 +425,108 @@ server: } } +func TestParseServeArgsWithConfigProvidesRequiredDirectoriesAndAddr(t *testing.T) { + configPath := writeAppConfigFile(t, ` +prompt_dir: ./from-config/prompts +profile_dir: ./from-config/profiles +schema_dir: ./from-config/schemas +server: + addr: 127.0.0.1:9000 +`) + + cfg, err := parseServeArgs([]string{ + "--config", configPath, + }) + if err != nil { + t.Fatalf("expected valid args, got %v", err) + } + + if cfg.promptDir != filepath.Clean("./from-config/prompts") { + t.Fatalf("expected prompt dir from config, got %q", cfg.promptDir) + } + if cfg.profileDir != filepath.Clean("./from-config/profiles") { + t.Fatalf("expected profile dir from config, got %q", cfg.profileDir) + } + if cfg.schemaDir != filepath.Clean("./from-config/schemas") { + t.Fatalf("expected schema dir from config, got %q", cfg.schemaDir) + } + if cfg.addr != "127.0.0.1:9000" { + t.Fatalf("expected addr from config, got %q", cfg.addr) + } +} + +func TestParseRunArgsFailsClearlyWhenNoEffectivePromptDir(t *testing.T) { + configPath := writeAppConfigFile(t, ` +profile_dir: ./profiles +`) + + _, err := parseRunArgs([]string{ + "--config", configPath, + "--prompt", "p", + "--input", "a=b", + }) + if err == nil { + t.Fatal("expected missing prompt_dir error") + } + if !strings.Contains(err.Error(), "prompt directory is required") || !strings.Contains(err.Error(), "config.yml prompt_dir") { + t.Fatalf("expected clear prompt_dir guidance, got %v", err) + } +} + +func TestParseRunArgsFailsClearlyWhenNoEffectiveProfileDir(t *testing.T) { + configPath := writeAppConfigFile(t, ` +prompt_dir: ./prompts +`) + + _, err := parseRunArgs([]string{ + "--config", configPath, + "--prompt", "p", + "--input", "a=b", + }) + if err == nil { + t.Fatal("expected missing profile_dir error") + } + if !strings.Contains(err.Error(), "profile directory is required") || !strings.Contains(err.Error(), "config.yml profile_dir") { + t.Fatalf("expected clear profile_dir guidance, got %v", err) + } +} + +func TestParseRenderArgsFailsClearlyWhenNoEffectivePromptDir(t *testing.T) { + configPath := writeAppConfigFile(t, ` +profile_dir: ./profiles +`) + + _, err := parseRenderArgs([]string{ + "--config", configPath, + "--prompt", "p", + "--input", "a=b", + }) + if err == nil { + t.Fatal("expected missing prompt_dir error") + } + if !strings.Contains(err.Error(), "prompt directory is required") || !strings.Contains(err.Error(), "config.yml prompt_dir") { + t.Fatalf("expected clear prompt_dir guidance, got %v", err) + } +} + +func TestParseRenderArgsFailsClearlyWhenNoEffectiveProfileDir(t *testing.T) { + configPath := writeAppConfigFile(t, ` +prompt_dir: ./prompts +`) + + _, err := parseRenderArgs([]string{ + "--config", configPath, + "--prompt", "p", + "--input", "a=b", + }) + if err == nil { + t.Fatal("expected missing profile_dir error") + } + if !strings.Contains(err.Error(), "profile directory is required") || !strings.Contains(err.Error(), "config.yml profile_dir") { + t.Fatalf("expected clear profile_dir guidance, got %v", err) + } +} + func TestDetermineExitCode(t *testing.T) { if got := determineExitCode(errors.New("boom"), nil); got != ExitRuntimeError { t.Fatalf("expected runtime exit code, got %d", got) @@ -455,6 +569,46 @@ func TestRunCommandVarsOptional(t *testing.T) { } } +func TestRunCommandSucceedsWithPromptAndProfileDirsFromConfig(t *testing.T) { + tmp := t.TempDir() + promptDir := filepath.Join(tmp, "prompts") + profileDir := filepath.Join(tmp, "profiles") + if err := os.MkdirAll(promptDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(profileDir, 0o755); err != nil { + t.Fatal(err) + } + inputPath := filepath.Join(tmp, "transcript.md") + if err := os.WriteFile(inputPath, []byte("hello"), 0o644); err != nil { + t.Fatal(err) + } + + ts := newTestLLMServer("from-config-dirs", nil) + defer ts.Close() + + writePromptFile(t, promptDir, "prompt.default", "local-default") + writeProfileFile(t, profileDir, "local-default", ts.URL+"/v1", "profile-model") + configPath := writeAppConfigFile(t, fmt.Sprintf(` +prompt_dir: %s +profile_dir: %s +`, promptDir, profileDir)) + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := runCommand([]string{ + "--config", configPath, + "--prompt", "prompt.default", + "--input", "transcript=" + inputPath, + }, &stdout, &stderr) + if code != ExitOK { + t.Fatalf("expected ExitOK, got %d stderr=%q", code, stderr.String()) + } + if stdout.String() != "from-config-dirs" { + t.Fatalf("unexpected stdout output: %q", stdout.String()) + } +} + func TestRenderCommandDefaultFormatTextIncludesPreparedDetailsAndNoSecrets(t *testing.T) { const envName = "SCRIPTORIUM_RENDER_TEST_API_KEY" const secret = "super-secret-render-key" @@ -527,6 +681,43 @@ func TestRenderCommandDefaultFormatTextIncludesPreparedDetailsAndNoSecrets(t *te } } +func TestRenderCommandSucceedsWithPromptAndProfileDirsFromConfig(t *testing.T) { + tmp := t.TempDir() + promptDir := filepath.Join(tmp, "prompts") + profileDir := filepath.Join(tmp, "profiles") + if err := os.MkdirAll(promptDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(profileDir, 0o755); err != nil { + t.Fatal(err) + } + inputPath := filepath.Join(tmp, "transcript.md") + if err := os.WriteFile(inputPath, []byte("hello transcript"), 0o644); err != nil { + t.Fatal(err) + } + + writePromptFile(t, promptDir, "prompt.render", "local-default") + writeProfileFile(t, profileDir, "local-default", "http://127.0.0.1:1/v1", "profile-model") + configPath := writeAppConfigFile(t, fmt.Sprintf(` +prompt_dir: %s +profile_dir: %s +`, promptDir, profileDir)) + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := renderCommand([]string{ + "--config", configPath, + "--prompt", "prompt.render", + "--input", "transcript=" + inputPath, + }, &stdout, &stderr) + if code != ExitOK { + t.Fatalf("expected ExitOK, got %d stderr=%q", code, stderr.String()) + } + if !strings.Contains(stdout.String(), "prompt: prompt.render") { + t.Fatalf("expected rendered output, got %q", stdout.String()) + } +} + func TestRenderCommandExplicitTextFormatWorks(t *testing.T) { tmp := t.TempDir() promptDir := filepath.Join(tmp, "prompts")