diff --git a/internal/adapter/cli/run.go b/internal/adapter/cli/run.go index 9ab33cf..54979cf 100644 --- a/internal/adapter/cli/run.go +++ b/internal/adapter/cli/run.go @@ -14,6 +14,7 @@ import ( httpadapter "gitea.maximumdirect.net/eric/scriptorium/internal/adapter/http" artifactadapter "gitea.maximumdirect.net/eric/scriptorium/internal/artifact" + appconfig "gitea.maximumdirect.net/eric/scriptorium/internal/config" "gitea.maximumdirect.net/eric/scriptorium/internal/defaults" "gitea.maximumdirect.net/eric/scriptorium/internal/domain" renderformat "gitea.maximumdirect.net/eric/scriptorium/internal/format" @@ -32,6 +33,8 @@ const ( ) type runConfig struct { + configPath string + promptDir string profileDir string promptID string @@ -48,6 +51,8 @@ type runConfig struct { schemaDir string timeout time.Duration + defaultRenderFormat renderformat.PreparedRunOutputFormat + llmBaseURLSet bool apiKeyEnvSet bool modelSet bool @@ -63,6 +68,8 @@ type renderConfig struct { } type serveConfig struct { + configPath string + addr string promptDir string profileDir string @@ -230,8 +237,9 @@ func parseRunArgs(args []string) (*runConfig, error) { fs := flag.NewFlagSet("run", flag.ContinueOnError) fs.SetOutput(io.Discard) + registerConfigPathFlag(fs, &cfg.configPath) registerExecutionRequestFlags(fs, cfg) - fs.StringVar(&cfg.schemaDir, "schema-dir", defaults.SchemaDirDefault, "base directory for validation schemas") + fs.StringVar(&cfg.schemaDir, "schema-dir", "", "base directory for validation schemas") if err := fs.Parse(args); err != nil { return nil, err @@ -251,10 +259,11 @@ func parseRenderArgs(args []string) (*renderConfig, error) { fs := flag.NewFlagSet("render", flag.ContinueOnError) fs.SetOutput(io.Discard) + registerConfigPathFlag(fs, &cfg.configPath) registerExecutionRequestFlags(fs, &cfg.runConfig) var rawFormat string - fs.StringVar(&rawFormat, "format", string(renderformat.DefaultPreparedRunOutputFormat), "render output format (text|json)") + fs.StringVar(&rawFormat, "format", "", "render output format (text|json)") if err := fs.Parse(args); err != nil { return nil, err @@ -263,11 +272,15 @@ func parseRenderArgs(args []string) (*renderConfig, error) { return nil, err } - format, err := renderformat.ParsePreparedRunOutputFormat(rawFormat) - if err != nil { - return nil, err + if flagWasSet(fs, "format") { + format, err := renderformat.ParsePreparedRunOutputFormat(rawFormat) + if err != nil { + return nil, err + } + cfg.outputFormat = format + } else { + cfg.outputFormat = cfg.defaultRenderFormat } - cfg.outputFormat = format return cfg, nil } @@ -276,10 +289,11 @@ func parseServeArgs(args []string) (*serveConfig, error) { fs := flag.NewFlagSet("serve", flag.ContinueOnError) fs.SetOutput(io.Discard) - fs.StringVar(&cfg.addr, "addr", defaults.HTTPAddrDefault, "HTTP listen address") + registerConfigPathFlag(fs, &cfg.configPath) + fs.StringVar(&cfg.addr, "addr", "", "HTTP listen address") 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.schemaDir, "schema-dir", defaults.SchemaDirDefault, "base directory for validation schemas") + fs.StringVar(&cfg.schemaDir, "schema-dir", "", "base directory for validation schemas") if err := fs.Parse(args); err != nil { return nil, err @@ -288,6 +302,21 @@ func parseServeArgs(args []string) (*serveConfig, error) { return nil, fmt.Errorf("unexpected positional args: %v", fs.Args()) } + settings, err := resolveAppSettings(fs, cfg.configPath, appconfig.CLIOverrides{ + PromptDir: cfg.promptDirIfSet(fs), + ProfileDir: cfg.profileDirIfSet(fs), + SchemaDir: cfg.schemaDirIfSet(fs), + ServerAddr: cfg.addrIfSet(fs), + }) + if err != nil { + return nil, err + } + + 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("--prompt-dir is required") } @@ -325,6 +354,20 @@ 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{ + PromptDir: cfg.promptDirIfSet(fs), + ProfileDir: cfg.profileDirIfSet(fs), + SchemaDir: cfg.schemaDirIfSet(fs), + }) + if err != nil { + return err + } + + cfg.promptDir = settings.PromptDir + cfg.profileDir = settings.ProfileDir + cfg.schemaDir = settings.SchemaDir + cfg.defaultRenderFormat = settings.DefaultRenderFormat + if strings.TrimSpace(cfg.promptDir) == "" { return errors.New("--prompt-dir is required") } @@ -352,6 +395,73 @@ func finalizeExecutionRequestConfig(fs *flag.FlagSet, cfg *runConfig) error { return nil } +func (c *runConfig) promptDirIfSet(fs *flag.FlagSet) string { + if flagWasSet(fs, "prompt-dir") { + return c.promptDir + } + return "" +} + +func (c *runConfig) profileDirIfSet(fs *flag.FlagSet) string { + if flagWasSet(fs, "profile-dir") { + return c.profileDir + } + return "" +} + +func (c *runConfig) schemaDirIfSet(fs *flag.FlagSet) string { + if flagWasSet(fs, "schema-dir") { + return c.schemaDir + } + return "" +} + +func (c *serveConfig) promptDirIfSet(fs *flag.FlagSet) string { + if flagWasSet(fs, "prompt-dir") { + return c.promptDir + } + return "" +} + +func (c *serveConfig) profileDirIfSet(fs *flag.FlagSet) string { + if flagWasSet(fs, "profile-dir") { + return c.profileDir + } + return "" +} + +func (c *serveConfig) schemaDirIfSet(fs *flag.FlagSet) string { + if flagWasSet(fs, "schema-dir") { + return c.schemaDir + } + return "" +} + +func (c *serveConfig) addrIfSet(fs *flag.FlagSet) string { + if flagWasSet(fs, "addr") { + return c.addr + } + return "" +} + +func registerConfigPathFlag(fs *flag.FlagSet, target *string) { + fs.StringVar(target, "config", "", fmt.Sprintf("application config path (default %s)", appconfig.DefaultConfigPath)) +} + +func resolveAppSettings(fs *flag.FlagSet, configPath string, overrides appconfig.CLIOverrides) (appconfig.AppSettings, error) { + settings, err := appconfig.LoadConfig(configPath, flagWasSet(fs, "config")) + if err != nil { + return appconfig.AppSettings{}, fmt.Errorf("application config error: %w", err) + } + + merged, err := appconfig.ApplyCLIOverrides(settings, overrides) + if err != nil { + return appconfig.AppSettings{}, fmt.Errorf("application config error: %w", err) + } + + return merged, nil +} + func buildRunRequestFromConfig(cfg *runConfig) (domain.RunRequest, error) { inputMappings, err := parseMappings(cfg.inputRaw, false) if err != nil { @@ -482,7 +592,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 --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) + 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 15b9659..cfcfb69 100644 --- a/internal/adapter/cli/run_test.go +++ b/internal/adapter/cli/run_test.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "errors" + "flag" "fmt" "io" "net/http" @@ -15,6 +16,7 @@ import ( "testing" "time" + appconfig "gitea.maximumdirect.net/eric/scriptorium/internal/config" "gitea.maximumdirect.net/eric/scriptorium/internal/defaults" "gitea.maximumdirect.net/eric/scriptorium/internal/domain" renderformat "gitea.maximumdirect.net/eric/scriptorium/internal/format" @@ -61,22 +63,24 @@ func TestParseMappingsMalformed(t *testing.T) { } func TestParseRunArgsRequiredFlags(t *testing.T) { - _, err := parseRunArgs([]string{"--profile-dir", "./profiles", "--prompt", "p", "--input", "a=b"}) + configPath := writeAppConfigFile(t, "") + + _, err := parseRunArgs([]string{"--config", configPath, "--profile-dir", "./profiles", "--prompt", "p", "--input", "a=b"}) if err == nil { t.Fatal("expected missing --prompt-dir error") } - _, err = parseRunArgs([]string{"--prompt-dir", "./prompts", "--prompt", "p", "--input", "a=b"}) + _, err = parseRunArgs([]string{"--config", configPath, "--prompt-dir", "./prompts", "--prompt", "p", "--input", "a=b"}) if err == nil { t.Fatal("expected missing --profile-dir error") } - _, err = parseRunArgs([]string{"--prompt-dir", "./prompts", "--profile-dir", "./profiles", "--input", "a=b"}) + _, err = parseRunArgs([]string{"--config", configPath, "--prompt-dir", "./prompts", "--profile-dir", "./profiles", "--input", "a=b"}) if err == nil { t.Fatal("expected missing --prompt error") } - _, err = parseRunArgs([]string{"--prompt-dir", "./prompts", "--profile-dir", "./profiles", "--prompt", "p"}) + _, err = parseRunArgs([]string{"--config", configPath, "--prompt-dir", "./prompts", "--profile-dir", "./profiles", "--prompt", "p"}) if err == nil { t.Fatal("expected missing --input error") } @@ -140,17 +144,19 @@ func TestParseRunArgsRejectsRawLLMAPIKeyFlag(t *testing.T) { } func TestParseServeArgsRequiredFlags(t *testing.T) { - _, err := parseServeArgs([]string{"--profile-dir", "./profiles"}) + configPath := writeAppConfigFile(t, "") + + _, err := parseServeArgs([]string{"--config", configPath, "--profile-dir", "./profiles"}) if err == nil { t.Fatal("expected missing --prompt-dir error") } - _, err = parseServeArgs([]string{"--prompt-dir", "./prompts"}) + _, err = parseServeArgs([]string{"--config", configPath, "--prompt-dir", "./prompts"}) if err == nil { t.Fatal("expected missing --profile-dir error") } - cfg, err := parseServeArgs([]string{"--prompt-dir", "./prompts", "--profile-dir", "./profiles"}) + cfg, err := parseServeArgs([]string{"--config", configPath, "--prompt-dir", "./prompts", "--profile-dir", "./profiles"}) if err != nil { t.Fatalf("expected valid serve args, got %v", err) } @@ -266,6 +272,147 @@ func TestParseRenderArgsExplicitFormatsAndUnknown(t *testing.T) { } } +func TestParseRunArgsWithExplicitConfigLoadsDirectories(t *testing.T) { + configPath := writeAppConfigFile(t, ` +prompt_dir: ./from-config/prompts +profile_dir: ./from-config/profiles +schema_dir: ./from-config/schemas +`) + + cfg, err := parseRunArgs([]string{ + "--config", configPath, + "--prompt", "p", + "--input", "a=b", + }) + 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) + } +} + +func TestParseRunArgsMissingExplicitConfigReturnsError(t *testing.T) { + _, err := parseRunArgs([]string{ + "--config", filepath.Join(t.TempDir(), "missing.yml"), + "--prompt", "p", + "--input", "a=b", + }) + if err == nil { + t.Fatal("expected explicit config missing error") + } +} + +func TestResolveAppSettingsMissingImplicitConfigDoesNotError(t *testing.T) { + fs := flag.NewFlagSet("test", flag.ContinueOnError) + settings, err := resolveAppSettings(fs, filepath.Join(t.TempDir(), "missing.yml"), appconfig.CLIOverrides{}) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if settings.SchemaDir != defaults.SchemaDirDefault { + t.Fatalf("expected built-in schema dir, got %q", settings.SchemaDir) + } + if settings.ServerAddr != defaults.HTTPAddrDefault { + t.Fatalf("expected built-in server addr, got %q", settings.ServerAddr) + } +} + +func TestParseRunArgsCLIOverridesConfigDirectories(t *testing.T) { + configPath := writeAppConfigFile(t, ` +prompt_dir: ./from-config/prompts +profile_dir: ./from-config/profiles +schema_dir: ./from-config/schemas +`) + + cfg, err := parseRunArgs([]string{ + "--config", configPath, + "--prompt-dir", "./from-cli/prompts", + "--profile-dir", "./from-cli/profiles", + "--schema-dir", "./from-cli/schemas", + "--prompt", "p", + "--input", "a=b", + }) + if err != nil { + t.Fatalf("expected valid args, got %v", err) + } + + if cfg.promptDir != filepath.Clean("./from-cli/prompts") { + t.Fatalf("expected CLI prompt dir override, got %q", cfg.promptDir) + } + if cfg.profileDir != filepath.Clean("./from-cli/profiles") { + t.Fatalf("expected CLI profile dir override, got %q", cfg.profileDir) + } + if cfg.schemaDir != filepath.Clean("./from-cli/schemas") { + t.Fatalf("expected CLI schema dir override, got %q", cfg.schemaDir) + } +} + +func TestParseRenderArgsWithExplicitConfigLoadsDirectoriesAndFormat(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", + }) + 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.outputFormat != renderformat.PreparedRunFormatJSON { + t.Fatalf("expected render format from config, got %q", cfg.outputFormat) + } +} + +func TestParseServeArgsWithExplicitConfigLoadsSettingsAndCLIAddrOverrides(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, + "--addr", ":7777", + }) + 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 != ":7777" { + t.Fatalf("expected CLI addr override, got %q", cfg.addr) + } +} + func TestDetermineExitCode(t *testing.T) { if got := determineExitCode(errors.New("boom"), nil); got != ExitRuntimeError { t.Fatalf("expected runtime exit code, got %d", got) @@ -822,3 +969,12 @@ func newTestLLMServer(content string, hitCounter *int32) *httptest.Server { _, _ = w.Write([]byte(fmt.Sprintf(`{"choices":[{"message":{"role":"assistant","content":%q}}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`, content))) })) } + +func writeAppConfigFile(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "config.yml") + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("failed to write app config fixture: %v", err) + } + return path +}