diff --git a/internal/adapter/cli/run.go b/internal/adapter/cli/run.go index 1b860d1..9ab33cf 100644 --- a/internal/adapter/cli/run.go +++ b/internal/adapter/cli/run.go @@ -16,6 +16,7 @@ import ( artifactadapter "gitea.maximumdirect.net/eric/scriptorium/internal/artifact" "gitea.maximumdirect.net/eric/scriptorium/internal/defaults" "gitea.maximumdirect.net/eric/scriptorium/internal/domain" + renderformat "gitea.maximumdirect.net/eric/scriptorium/internal/format" "gitea.maximumdirect.net/eric/scriptorium/internal/llm" "gitea.maximumdirect.net/eric/scriptorium/internal/profile" "gitea.maximumdirect.net/eric/scriptorium/internal/prompt" @@ -56,6 +57,11 @@ type runConfig struct { timeoutSet bool } +type renderConfig struct { + runConfig + outputFormat renderformat.PreparedRunOutputFormat +} + type serveConfig struct { addr string promptDir string @@ -83,6 +89,8 @@ func Run(args []string, stdout, stderr io.Writer) int { switch args[0] { case "run": return runCommand(args[1:], stdout, stderr) + case "render": + return renderCommand(args[1:], stdout, stderr) case "serve": return serveCommand(args[1:], stderr) default: @@ -99,24 +107,11 @@ func runCommand(args []string, stdout, stderr io.Writer) int { return ExitRuntimeError } - inputMappings, err := parseMappings(cfg.inputRaw, false) + req, err := buildRunRequestFromConfig(cfg) if err != nil { - fmt.Fprintf(stderr, "input parse error: %v\n", err) + fmt.Fprintf(stderr, "run parse error: %v\n", err) return ExitRuntimeError } - varMappings := map[string]string{} - if len(cfg.varRaw) > 0 { - 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{ Timeout: defaults.LLMRequestTimeoutDefault, @@ -135,28 +130,7 @@ func runCommand(args []string, stdout, stderr io.Writer) int { validate.NewStandardValidator(cfg.schemaDir), ) - var modelOverride *domain.ExecutionTarget - if cfg.llmBaseURLSet || cfg.modelSet || cfg.temperatureSet || cfg.maxTokensSet || cfg.topPSet || cfg.apiKeyEnvSet || cfg.timeoutSet { - modelOverride = &domain.ExecutionTarget{ - Endpoint: cfg.llmBaseURL, - Model: cfg.model, - Temperature: cfg.temperature, - MaxTokens: cfg.maxTokens, - TopP: cfg.topP, - APIKeyEnv: cfg.apiKeyEnv, - } - if cfg.timeoutSet { - modelOverride.TimeoutSeconds = int(cfg.timeout.Seconds()) - } - } - - res, runErr := runner.Run(context.Background(), domain.RunRequest{ - PromptID: cfg.promptID, - ProfileID: cfg.profileID, - Inputs: inputs, - Vars: varMappings, - Execution: modelOverride, - }) + res, runErr := runner.Run(context.Background(), req) if runErr != nil { fmt.Fprintf(stderr, "run error: %v\n", runErr) return ExitRuntimeError @@ -171,6 +145,47 @@ func runCommand(args []string, stdout, stderr io.Writer) int { return determineExitCode(nil, res) } +func renderCommand(args []string, stdout, stderr io.Writer) int { + cfg, err := parseRenderArgs(args) + if err != nil { + fmt.Fprintf(stderr, "render parse error: %v\n", err) + return ExitRuntimeError + } + + req, err := buildRunRequestFromConfig(&cfg.runConfig) + if err != nil { + fmt.Fprintf(stderr, "render parse error: %v\n", err) + return ExitRuntimeError + } + + runner := usecase.NewRunner( + promptdef.NewFilesystemRepository(cfg.promptDir), + profile.NewFilesystemRepository(cfg.profileDir), + artifactadapter.NewCompositeReader(), + prompt.NewGoRenderer(), + nil, + nil, + ) + + prepared, prepErr := runner.Prepare(context.Background(), req) + if prepErr != nil { + fmt.Fprintf(stderr, "render error: %v\n", prepErr) + return ExitRuntimeError + } + + out, err := renderformat.FormatPreparedRun(prepared, cfg.outputFormat) + if err != nil { + fmt.Fprintf(stderr, "render format error: %v\n", err) + return ExitRuntimeError + } + + if err := writeOutput(stdout, cfg.outputPath, out); err != nil { + fmt.Fprintf(stderr, "output write error: %v\n", err) + return ExitRuntimeError + } + return ExitOK +} + func serveCommand(args []string, stderr io.Writer) int { cfg, err := parseServeArgs(args) if err != nil { @@ -215,60 +230,47 @@ func parseRunArgs(args []string) (*runConfig, error) { fs := flag.NewFlagSet("run", flag.ContinueOnError) fs.SetOutput(io.Discard) - fs.StringVar(&cfg.promptDir, "prompt-dir", "", "directory containing prompt definition YAML files") - fs.StringVar(&cfg.profileDir, "profile-dir", "", "directory containing execution profile YAML files") - fs.StringVar(&cfg.promptID, "prompt", "", "prompt ID to run") - fs.StringVar(&cfg.profileID, "profile", "", "optional execution profile ID; if omitted, prompt default_profile is used") - 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.apiKeyEnv, "api-key-env", "", "environment variable name containing 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.Float64Var(&cfg.topP, "top-p", 0, "optional top_p override") + registerExecutionRequestFlags(fs, cfg) fs.StringVar(&cfg.schemaDir, "schema-dir", defaults.SchemaDirDefault, "base directory for validation schemas") - fs.DurationVar(&cfg.timeout, "timeout", defaults.LLMRequestTimeoutDefault, "LLM request timeout") - fs.StringVar(&cfg.promptID, "prompt-id", "", "deprecated alias for --prompt") - fs.StringVar(&cfg.profileID, "profile-id", "", "deprecated alias for --profile") 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 err := finalizeExecutionRequestConfig(fs, cfg); err != nil { + return nil, err } - - if strings.TrimSpace(cfg.promptDir) == "" { - return nil, errors.New("--prompt-dir is required") - } - if strings.TrimSpace(cfg.profileDir) == "" { - return nil, errors.New("--profile-dir is required") - } - if strings.TrimSpace(cfg.promptID) == "" { - return nil, errors.New("--prompt is required") - } - if len(cfg.inputRaw) == 0 { - return nil, errors.New("at least one --input is required") - } - cfg.promptDir = filepath.Clean(cfg.promptDir) - cfg.profileDir = filepath.Clean(cfg.profileDir) cfg.schemaDir = filepath.Clean(cfg.schemaDir) - if cfg.outputPath != "" { - cfg.outputPath = filepath.Clean(cfg.outputPath) - } - cfg.llmBaseURLSet = flagWasSet(fs, "llm-base-url") - cfg.apiKeyEnvSet = flagWasSet(fs, "api-key-env") - cfg.modelSet = flagWasSet(fs, "model") - cfg.temperatureSet = flagWasSet(fs, "temperature") - cfg.maxTokensSet = flagWasSet(fs, "max-tokens") - cfg.topPSet = flagWasSet(fs, "top-p") - cfg.timeoutSet = flagWasSet(fs, "timeout") return cfg, nil } +func parseRenderArgs(args []string) (*renderConfig, error) { + cfg := &renderConfig{ + outputFormat: renderformat.DefaultPreparedRunOutputFormat, + } + fs := flag.NewFlagSet("render", flag.ContinueOnError) + fs.SetOutput(io.Discard) + + registerExecutionRequestFlags(fs, &cfg.runConfig) + + var rawFormat string + fs.StringVar(&rawFormat, "format", string(renderformat.DefaultPreparedRunOutputFormat), "render output format (text|json)") + + if err := fs.Parse(args); err != nil { + return nil, err + } + if err := finalizeExecutionRequestConfig(fs, &cfg.runConfig); err != nil { + return nil, err + } + + format, err := renderformat.ParsePreparedRunOutputFormat(rawFormat) + if err != nil { + return nil, err + } + cfg.outputFormat = format + return cfg, nil +} + func parseServeArgs(args []string) (*serveConfig, error) { cfg := &serveConfig{} fs := flag.NewFlagSet("serve", flag.ContinueOnError) @@ -299,6 +301,100 @@ func parseServeArgs(args []string) (*serveConfig, error) { return cfg, nil } +func registerExecutionRequestFlags(fs *flag.FlagSet, cfg *runConfig) { + fs.StringVar(&cfg.promptDir, "prompt-dir", "", "directory containing prompt definition YAML files") + fs.StringVar(&cfg.profileDir, "profile-dir", "", "directory containing execution profile YAML files") + fs.StringVar(&cfg.promptID, "prompt", "", "prompt ID to run") + fs.StringVar(&cfg.profileID, "profile", "", "optional execution profile ID; if omitted, prompt default_profile is used") + 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.apiKeyEnv, "api-key-env", "", "environment variable name containing 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.Float64Var(&cfg.topP, "top-p", 0, "optional top_p override") + fs.DurationVar(&cfg.timeout, "timeout", defaults.LLMRequestTimeoutDefault, "LLM request timeout") + fs.StringVar(&cfg.promptID, "prompt-id", "", "deprecated alias for --prompt") + fs.StringVar(&cfg.profileID, "profile-id", "", "deprecated alias for --profile") +} + +func finalizeExecutionRequestConfig(fs *flag.FlagSet, cfg *runConfig) error { + if fs.NArg() > 0 { + return fmt.Errorf("unexpected positional args: %v", fs.Args()) + } + + if strings.TrimSpace(cfg.promptDir) == "" { + return errors.New("--prompt-dir is required") + } + if strings.TrimSpace(cfg.profileDir) == "" { + return errors.New("--profile-dir is required") + } + if strings.TrimSpace(cfg.promptID) == "" { + return errors.New("--prompt is required") + } + if len(cfg.inputRaw) == 0 { + return errors.New("at least one --input is required") + } + cfg.promptDir = filepath.Clean(cfg.promptDir) + cfg.profileDir = filepath.Clean(cfg.profileDir) + if cfg.outputPath != "" { + cfg.outputPath = filepath.Clean(cfg.outputPath) + } + cfg.llmBaseURLSet = flagWasSet(fs, "llm-base-url") + cfg.apiKeyEnvSet = flagWasSet(fs, "api-key-env") + cfg.modelSet = flagWasSet(fs, "model") + cfg.temperatureSet = flagWasSet(fs, "temperature") + cfg.maxTokensSet = flagWasSet(fs, "max-tokens") + cfg.topPSet = flagWasSet(fs, "top-p") + cfg.timeoutSet = flagWasSet(fs, "timeout") + return nil +} + +func buildRunRequestFromConfig(cfg *runConfig) (domain.RunRequest, error) { + inputMappings, err := parseMappings(cfg.inputRaw, false) + if err != nil { + return domain.RunRequest{}, fmt.Errorf("input parse error: %w", err) + } + + varMappings := map[string]string{} + if len(cfg.varRaw) > 0 { + varMappings, err = parseMappings(cfg.varRaw, false) + if err != nil { + return domain.RunRequest{}, fmt.Errorf("var parse error: %w", err) + } + } + + inputs := make(map[string]domain.ArtifactRef, len(inputMappings)) + for name, path := range inputMappings { + inputs[name] = domain.ArtifactRef{Type: domain.ArtifactRefFile, URI: path} + } + + var modelOverride *domain.ExecutionTarget + if cfg.llmBaseURLSet || cfg.modelSet || cfg.temperatureSet || cfg.maxTokensSet || cfg.topPSet || cfg.apiKeyEnvSet || cfg.timeoutSet { + modelOverride = &domain.ExecutionTarget{ + Endpoint: cfg.llmBaseURL, + Model: cfg.model, + Temperature: cfg.temperature, + MaxTokens: cfg.maxTokens, + TopP: cfg.topP, + APIKeyEnv: cfg.apiKeyEnv, + } + if cfg.timeoutSet { + modelOverride.TimeoutSeconds = int(cfg.timeout.Seconds()) + } + } + + return domain.RunRequest{ + PromptID: cfg.promptID, + ProfileID: cfg.profileID, + Inputs: inputs, + Vars: varMappings, + Execution: modelOverride, + }, nil +} + func parseMappings(raw []string, allowEmptyValue bool) (map[string]string, error) { out := make(map[string]string) for _, entry := range raw { @@ -385,7 +481,8 @@ func printSummary(stderr io.Writer, res *domain.RunResult) { } func printUsage(w io.Writer) { - fmt.Fprintln(w, "usage: scriptorium ...") + fmt.Fprintln(w, "usage: scriptorium ...") fmt.Fprintln(w, " run: scriptorium run --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 --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 --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 c3bc925..ad4cdc1 100644 --- a/internal/adapter/cli/run_test.go +++ b/internal/adapter/cli/run_test.go @@ -2,6 +2,7 @@ package cli import ( "bytes" + "encoding/json" "errors" "fmt" "io" @@ -16,6 +17,7 @@ import ( "gitea.maximumdirect.net/eric/scriptorium/internal/defaults" "gitea.maximumdirect.net/eric/scriptorium/internal/domain" + renderformat "gitea.maximumdirect.net/eric/scriptorium/internal/format" ) func TestParseMappingsSingleAndRepeated(t *testing.T) { @@ -205,6 +207,65 @@ func TestParseRunArgsTimeout(t *testing.T) { } } +func TestParseRenderArgsDefaultsAndFormat(t *testing.T) { + cfg, err := parseRenderArgs([]string{ + "--prompt-dir", "./prompts", + "--profile-dir", "./profiles", + "--prompt", "p", + "--input", "a=b", + }) + if err != nil { + t.Fatalf("expected valid render args, got %v", err) + } + if cfg.outputFormat != renderformat.DefaultPreparedRunOutputFormat { + t.Fatalf("expected default render format %q, got %q", renderformat.DefaultPreparedRunOutputFormat, cfg.outputFormat) + } +} + +func TestParseRenderArgsExplicitFormatsAndUnknown(t *testing.T) { + cfg, err := parseRenderArgs([]string{ + "--prompt-dir", "./prompts", + "--profile-dir", "./profiles", + "--prompt", "p", + "--input", "a=b", + "--format", "text", + }) + if err != nil { + t.Fatalf("expected valid text format, got %v", err) + } + if cfg.outputFormat != renderformat.PreparedRunFormatText { + t.Fatalf("expected text format, got %q", cfg.outputFormat) + } + + cfg, err = parseRenderArgs([]string{ + "--prompt-dir", "./prompts", + "--profile-dir", "./profiles", + "--prompt", "p", + "--input", "a=b", + "--format", "json", + }) + if err != nil { + t.Fatalf("expected valid json format, got %v", err) + } + if cfg.outputFormat != renderformat.PreparedRunFormatJSON { + t.Fatalf("expected json format, got %q", cfg.outputFormat) + } + + _, err = parseRenderArgs([]string{ + "--prompt-dir", "./prompts", + "--profile-dir", "./profiles", + "--prompt", "p", + "--input", "a=b", + "--format", "yaml", + }) + if err == nil { + t.Fatal("expected unknown format error") + } + if !strings.Contains(err.Error(), "unknown prepared run format") { + t.Fatalf("expected clear unknown format error, 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) @@ -247,6 +308,220 @@ func TestRunCommandVarsOptional(t *testing.T) { } } +func TestRenderCommandDefaultFormatTextIncludesPreparedDetailsAndNoSecrets(t *testing.T) { + const envName = "SCRIPTORIUM_RENDER_TEST_API_KEY" + const secret = "super-secret-render-key" + t.Setenv(envName, secret) + + 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) + } + + writePromptFileWithTemplate(t, promptDir, "prompt.render", "local-default", "Date {{.session_date}} - Summarize: {{input \"transcript\"}}") + writeProfileFile(t, profileDir, "local-default", "http://127.0.0.1:1/v1", "profile-model") + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := renderCommand([]string{ + "--prompt-dir", promptDir, + "--profile-dir", profileDir, + "--prompt", "prompt.render", + "--profile", "local-default", + "--input", "transcript=" + inputPath, + "--var", "session_date=2026-05-04", + "--llm-base-url", "http://override.local/v1", + "--model", "override-model", + "--temperature", "0.7", + "--max-tokens", "55", + "--top-p", "0.2", + "--timeout", "20s", + "--api-key-env", envName, + }, &stdout, &stderr) + if code != ExitOK { + t.Fatalf("expected ExitOK, got %d stderr=%q", code, stderr.String()) + } + if stderr.Len() != 0 { + t.Fatalf("expected empty stderr on success, got %q", stderr.String()) + } + + out := stdout.String() + for _, want := range []string{ + "prompt: prompt.render", + "selected_profile_id: local-default", + "endpoint: http://override.local/v1", + "model: override-model", + "temperature: 0.7", + "max_tokens: 55", + "top_p: 0.2", + "timeout_seconds: 20", + "api_key_env: " + envName, + "rendered_prompt_hash:", + "messages:", + "Date 2026-05-04", + "Summarize:", + "hello transcript", + } { + if !strings.Contains(out, want) { + t.Fatalf("expected render text output to include %q, got:\n%s", want, out) + } + } + if strings.Contains(out, secret) { + t.Fatalf("render output unexpectedly contained secret api key value: %s", out) + } +} + +func TestRenderCommandExplicitTextFormatWorks(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") + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := renderCommand([]string{ + "--prompt-dir", promptDir, + "--profile-dir", profileDir, + "--prompt", "prompt.render", + "--input", "transcript=" + inputPath, + "--format", "text", + }, &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 text output for explicit --format text, got %q", stdout.String()) + } +} + +func TestRenderCommandExplicitJSONFormatOutputsValidJSON(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") + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := renderCommand([]string{ + "--prompt-dir", promptDir, + "--profile-dir", profileDir, + "--prompt", "prompt.render", + "--input", "transcript=" + inputPath, + "--format", "json", + }, &stdout, &stderr) + if code != ExitOK { + t.Fatalf("expected ExitOK, got %d stderr=%q", code, stderr.String()) + } + + var payload map[string]any + if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil { + t.Fatalf("expected valid json output, got %v\nbody=%s", err, stdout.String()) + } + if payload["prompt_id"] != "prompt.render" { + t.Fatalf("expected prompt_id, got %#v", payload["prompt_id"]) + } + if payload["selected_profile_id"] != "local-default" { + t.Fatalf("expected selected_profile_id, got %#v", payload["selected_profile_id"]) + } + if _, ok := payload["messages"]; !ok { + t.Fatalf("expected messages in render json output, got %#v", payload) + } +} + +func TestRenderCommandUnknownFormatFailsClearly(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + code := renderCommand([]string{ + "--prompt-dir", "./prompts", + "--profile-dir", "./profiles", + "--prompt", "p", + "--input", "transcript=./x.md", + "--format", "yaml", + }, &stdout, &stderr) + if code != ExitRuntimeError { + t.Fatalf("expected ExitRuntimeError, got %d", code) + } + if !strings.Contains(stderr.String(), "render parse error") || !strings.Contains(stderr.String(), "unknown prepared run format") { + t.Fatalf("expected clear unknown-format parse error, got %q", stderr.String()) + } +} + +func TestRenderCommandOutWritesToFile(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) + } + outPath := filepath.Join(tmp, "render.txt") + + writePromptFile(t, promptDir, "prompt.render", "local-default") + writeProfileFile(t, profileDir, "local-default", "http://127.0.0.1:1/v1", "profile-model") + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := renderCommand([]string{ + "--prompt-dir", promptDir, + "--profile-dir", profileDir, + "--prompt", "prompt.render", + "--input", "transcript=" + inputPath, + "--out", outPath, + }, &stdout, &stderr) + if code != ExitOK { + t.Fatalf("expected ExitOK, got %d stderr=%q", code, stderr.String()) + } + if stdout.Len() != 0 { + t.Fatalf("expected empty stdout when --out is set, got %q", stdout.String()) + } + out, err := os.ReadFile(outPath) + if err != nil { + t.Fatalf("failed reading render output file: %v", err) + } + if !strings.Contains(string(out), "prompt: prompt.render") { + t.Fatalf("expected render output in file, got %q", string(out)) + } +} + func TestRunCommandPromptDefaultProfileWorksThroughCLIPath(t *testing.T) { tmp := t.TempDir() promptDir := filepath.Join(tmp, "prompts") @@ -425,6 +700,11 @@ func TestWriteOutputAndSummaryUseSeparateWriters(t *testing.T) { } func writePromptFile(t *testing.T, dir, id, defaultProfile string) { + t.Helper() + writePromptFileWithTemplate(t, dir, id, defaultProfile, "Summarize: {{input \"transcript\"}}") +} + +func writePromptFileWithTemplate(t *testing.T, dir, id, defaultProfile, templateContent string) { t.Helper() data := fmt.Sprintf(`id: %s version: "1.0.0" @@ -434,12 +714,12 @@ inputs: required: true messages: - role: user - content: "Summarize: {{input \"transcript\"}}" + content: %q output: format: text validation_mode: none repair_attempts: 0 -`, id, defaultProfile) +`, id, defaultProfile, templateContent) if err := os.WriteFile(filepath.Join(dir, id+".yaml"), []byte(data), 0o644); err != nil { t.Fatalf("failed to write prompt fixture: %v", err) }