Enable explicit configuration imports and overlays

This commit is contained in:
2026-09-26 18:12:27 +00:00
parent 36e7e857f5
commit 9ac7006a79
15 changed files with 204 additions and 30 deletions

View File

@@ -26,7 +26,11 @@ go run ./cmd/scriptorium render \
- `scriptorium inspect prompt`: inspect one prompt definition without model execution. - `scriptorium inspect prompt`: inspect one prompt definition without model execution.
- `scriptorium inspect profile`: inspect one effective profile without model execution. - `scriptorium inspect profile`: inspect one effective profile without model execution.
All commands accept `--config <path>` and reject positional arguments. `run`, All commands accept `--config <path>` and `--config-overlay <name>` and reject
positional arguments. `--config-overlay` selects one application configuration
overlay; it is separate from `--profile`, which selects a Promptkit execution
profile. See [configuration composition](config.md#explicit-imports-and-selected-overlays)
for name, root, and no-base behavior. `run`,
`render`, `serve`, and `inspect prompt` require an effective `prompt_dir`; `render`, `serve`, and `inspect prompt` require an effective `prompt_dir`;
`inspect profile` does not. Supply the directory through the configuration `inspect profile` does not. Supply the directory through the configuration
contract or a supported `--prompt-dir` flag. Explicit application flags, contract or a supported `--prompt-dir` flag. Explicit application flags,
@@ -51,6 +55,7 @@ Optional flags:
| Flag | Meaning | | Flag | Meaning |
| --- | --- | | --- | --- |
| `--config <path>` | Application configuration file. | | `--config <path>` | Application configuration file. |
| `--config-overlay <name>` | Selected application configuration overlay. |
| `--prompt-dir <dir>` | Prompt-definition directory override. | | `--prompt-dir <dir>` | Prompt-definition directory override. |
| `--profile-dir <dir>` | Custom profile-directory override. | | `--profile-dir <dir>` | Custom profile-directory override. |
| `--schema-dir <dir>` | Schema base-directory override. | | `--schema-dir <dir>` | Schema base-directory override. |
@@ -148,7 +153,7 @@ scriptorium render [flags]
``` ```
`--prompt <id>` is required. The following optional flags are supported: `--prompt <id>` is required. The following optional flags are supported:
`--config`, `--prompt-dir`, `--profile-dir`, `--schema-dir`, `--prompt-version`, `--profile`, `--config`, `--config-overlay`, `--prompt-dir`, `--profile-dir`, `--schema-dir`, `--prompt-version`, `--profile`,
`--input`, `--var`, `--out`, `--llm-base-url`, `--input`, `--var`, `--out`, `--llm-base-url`,
`--model`, `--api-key-env`, `--temperature`, `--max-tokens`, `--top-p`, `--model`, `--api-key-env`, `--temperature`, `--max-tokens`, `--top-p`,
`--reasoning-effort`, `--session-id`, `--timeout`, `--transient-retries`, and `--format text|json`. Their meanings match the corresponding `--reasoning-effort`, `--session-id`, `--timeout`, `--transient-retries`, and `--format text|json`. Their meanings match the corresponding
@@ -174,6 +179,7 @@ Optional flags:
| Flag | Meaning | | Flag | Meaning |
| --- | --- | | --- | --- |
| `--config <path>` | Application configuration file. | | `--config <path>` | Application configuration file. |
| `--config-overlay <name>` | Selected application configuration overlay. |
| `--addr <listen-address>` | HTTP listen-address override. | | `--addr <listen-address>` | HTTP listen-address override. |
| `--prompt-dir <dir>` | Prompt-definition directory override. | | `--prompt-dir <dir>` | Prompt-definition directory override. |
| `--profile-dir <dir>` | Custom profile-directory override. | | `--profile-dir <dir>` | Custom profile-directory override. |
@@ -190,7 +196,7 @@ schemas, and error codes are defined in the [HTTP API reference](api.md).
```text ```text
scriptorium inspect prompt --prompt ID [--prompt-version VERSION] scriptorium inspect prompt --prompt ID [--prompt-version VERSION]
[--config PATH] [--prompt-dir DIR] [--format text|json] [--out PATH] [--config PATH] [--config-overlay NAME] [--prompt-dir DIR] [--format text|json] [--out PATH]
``` ```
`--prompt` is required. Inspection uses normal configuration discovery and a `--prompt` is required. Inspection uses normal configuration discovery and a
@@ -203,7 +209,7 @@ template, reserve backend capacity, or call a model.
```text ```text
scriptorium inspect profile --profile ID scriptorium inspect profile --profile ID
[--config PATH] [--profile-dir DIR] [--format text|json] [--out PATH] [--config PATH] [--config-overlay NAME] [--profile-dir DIR] [--format text|json] [--out PATH]
``` ```
`--profile` is required; built-in profiles need no prompt directory. Inspection `--profile` is required; built-in profiles need no prompt directory. Inspection

View File

@@ -20,8 +20,9 @@ When `--config` is omitted, Scriptorium searches
If neither exists, it uses built-in defaults. An explicit `--config` path must If neither exists, it uses built-in defaults. An explicit `--config` path must
exist and decode successfully. exist and decode successfully.
The maintained [minimal configuration](../examples/config.yml) and The maintained [minimal configuration](../examples/config.yml),
[complete configuration](../examples/config.full.yml) are copyable examples. [complete configuration](../examples/config.full.yml), and
[composed configuration](../examples/config.composed.yml) are copyable examples.
## Application Configuration File ## Application Configuration File
@@ -31,8 +32,7 @@ An explicit empty string is a value: it clears a file-provided optional path
or, for required `prompt_dir`, fails the command's prerequisite. A scalar or, for required `prompt_dir`, fails the command's prerequisite. A scalar
`null` is not an empty string and is rejected for scalar fields; nullable `null` is not an empty string and is rejected for scalar fields; nullable
fields such as `transient_retries` retain their documented meaning. Raw fields such as `transient_retries` retain their documented meaning. Raw
API-key fields are not accepted. Imports and selected overlays are not yet API-key fields are not accepted.
enabled for application configuration.
ConfigKit applies defaults, the selected file, then only explicitly supplied ConfigKit applies defaults, the selected file, then only explicitly supplied
application CLI flags before validating the result. Paths in the application application CLI flags before validating the result. Paths in the application
@@ -40,6 +40,43 @@ document remain relative to the process working directory, not to the file's
directory. Load diagnostics are printed safely on stderr; invalid application directory. Load diagnostics are printed safely on stderr; invalid application
configuration exits with status 1. configuration exits with status 1.
### Explicit imports and selected overlays
A file-backed configuration may declare `configkit.imports` with relative
`path` or `glob` entries. Each entry is explicit; Scriptorium does not scan
for fragments on its own. ConfigKit composes the selected file and imports by
conflict, so two fragments cannot define the same field merely to establish
precedence. A required missing import fails; an entry may use
`optional: true` to suppress target nonexistence. Imports are resolved from
the declaring file's canonical directory and remain within the selected
file's canonical directory by default. Scriptorium does not widen that root.
`--config-overlay NAME` selects one file at `overlays/NAME.yml` below that
root. The name must use letters, digits, `_`, or `-`; it is not a path. Empty
and exact lowercase `default` select no overlay. There is no fallback,
case folding, inherited stack, or automatic selection; a missing selected
overlay fails. ConfigKit merges mapping fields and replaces other values,
including sequences and explicit nulls, before applying explicit CLI flags.
An active overlay needs a selected base file. No-source defaults-only loads
cannot use imports or an active overlay.
The [composed example](../examples/config.composed.yml) imports two fragments
and has a selectable [local overlay](../examples/overlays/local.yml). From the
repository root:
```bash
go run ./cmd/scriptorium render \
--config ./examples/config.composed.yml --config-overlay local \
--prompt generic.markdown_summary \
--input transcript=./examples/fixtures/transcript.md \
--input glossary=./examples/fixtures/glossary.yml
```
Import and overlay file locations are constrained by ConfigKit;
*application path values* such as `prompt_dir` remain relative
to the process working directory even when authored in a fragment or overlay.
These source constraints are separate from HTTP artifact containment.
| Field | Default | Meaning | | Field | Default | Meaning |
| --- | --- | --- | | --- | --- | --- |
| `prompt_dir` | unset | Promptkit prompt-definition source directory. `run`, `render`, `serve`, and `inspect prompt` require an effective value. | | `prompt_dir` | unset | Promptkit prompt-definition source directory. `run`, `render`, `serve`, and `inspect prompt` require an effective value. |

View File

@@ -40,8 +40,10 @@ implicit discovery. One ConfigKit load applies explicit application overrides
before validation and returns a result containing the resolved document and before validation and returns a result containing the resolved document and
its frozen inspection records. `Settings` converts that document into the its frozen inspection records. `Settings` converts that document into the
existing runtime settings with backend IDs sorted; it does not read files or existing runtime settings with backend IDs sorted; it does not read files or
apply another merge. Imports and selected overlays remain disabled until their apply another merge. File-backed loads enable explicit ConfigKit imports and
own user-facing integration is ready. select one named overlay when requested. ConfigKit uses the selected file's
canonical directory as the source root; the application does not widen it.
Application path values retain process-working-directory-relative meaning.
Every configuration-loading command uses this boundary. The CLI maps only Every configuration-loading command uses this boundary. The CLI maps only
explicitly visited application flags to ConfigKit overrides and prints explicitly visited application flags to ConfigKit overrides and prints

View File

@@ -44,6 +44,14 @@ schema sources that follow the tagged framework formats. For an HTTP deployment
that accepts file artifacts, use a dedicated, narrow artifact directory rather that accepts file artifacts, use a dedicated, narrow artifact directory rather
than a general-purpose or sensitive filesystem tree. than a general-purpose or sensitive filesystem tree.
If application configuration uses explicit imports or a selected overlay,
deploy those files beneath the selected configuration file's canonical
directory. ConfigKit keeps imported and overlay files within that root;
Scriptorium does not widen it. Application path values inside those files still
resolve from the process working directory, so set that directory deliberately
in the service manager. This configuration-source boundary is distinct from
the HTTP artifact root and does not constrain prompt or schema directories.
When upgrading existing definitions, review selected profile IDs. When upgrading existing definitions, review selected profile IDs.
`deepseek-3-2`, `deepseek-4-flash`, and `deepseek-4-pro` were removed from the `deepseek-3-2`, `deepseek-4-flash`, and `deepseek-4-pro` were removed from the
selected catalog. Choose an available catalog profile, one of Promptkit's selected catalog. Choose an available catalog profile, one of Promptkit's

View File

@@ -277,7 +277,11 @@ not yet advertised.
## Stage 4: Enable Explicit Imports And Selected Configuration Overlays ## Stage 4: Enable Explicit Imports And Selected Configuration Overlays
**Status: Not started.** **Status: Complete.** File-backed loads support explicit imports and one
selected overlay under the selected file's canonical directory. All five
existing command modes accept `--config-overlay`; real-file composition and a
render workflow are tested. The composed example and contracts are updated;
full test, race, vet, build, and diff checks passed.
Primary files: shared loader, CLI source-selection wiring, configuration and Primary files: shared loader, CLI source-selection wiring, configuration and
CLI tests, maintained configuration examples. CLI tests, maintained configuration examples.

View File

@@ -0,0 +1,4 @@
backends:
local-gpu:
endpoint: http://localhost:11434/v1
api_key_env: LOCAL_GPU_API_KEY

View File

@@ -0,0 +1,3 @@
prompt_dir: ./examples/prompts
profile_dir: ./examples/profiles
schema_dir: ./examples/schemas

View File

@@ -0,0 +1,8 @@
# Select from the repository root; application paths remain working-directory-relative.
configkit:
imports:
- path: config-fragments/sources.yml
- path: config-fragments/backend.yml
server:
addr: :8080

View File

@@ -0,0 +1,4 @@
defaults:
render_format: json
server:
addr: 127.0.0.1:8080

View File

@@ -42,9 +42,9 @@ func applicationOverrides(fs *flag.FlagSet) []configkit.Override {
return overrides return overrides
} }
func loadApplicationSettings(configPath string, explicit bool, overrides []configkit.Override) (appconfig.AppSettings, []configkit.Diagnostic, error) { func loadApplicationSettings(configPath, overlay string, explicit bool, overrides []configkit.Override) (appconfig.AppSettings, []configkit.Diagnostic, error) {
result, err := appconfig.Load(appconfig.LoadOptions{ result, err := appconfig.Load(appconfig.LoadOptions{
Path: configPath, Explicit: explicit, Overrides: overrides, Path: configPath, Overlay: overlay, Explicit: explicit, Overrides: overrides,
}) })
if err != nil { if err != nil {
return appconfig.AppSettings{}, nil, err return appconfig.AppSettings{}, nil, err

View File

@@ -17,6 +17,7 @@ import (
type promptInspectionConfig struct { type promptInspectionConfig struct {
configPath, promptDir, promptID, promptVersion, outputPath string configPath, promptDir, promptID, promptVersion, outputPath string
configOverlay string
configExplicit bool configExplicit bool
outputFormat appformat.OutputFormat outputFormat appformat.OutputFormat
overrides []configkit.Override overrides []configkit.Override
@@ -25,6 +26,7 @@ type promptInspectionConfig struct {
type profileInspectionConfig struct { type profileInspectionConfig struct {
configPath, profileDir, profileID, outputPath string configPath, profileDir, profileID, outputPath string
configOverlay string
configExplicit bool configExplicit bool
outputFormat appformat.OutputFormat outputFormat appformat.OutputFormat
overrides []configkit.Override overrides []configkit.Override
@@ -127,7 +129,7 @@ func parseProfileInspectionArgs(args []string) (*profileInspectionConfig, error)
cfg := &profileInspectionConfig{outputFormat: appformat.DefaultOutputFormat} cfg := &profileInspectionConfig{outputFormat: appformat.DefaultOutputFormat}
fs := flag.NewFlagSet("inspect profile", flag.ContinueOnError) fs := flag.NewFlagSet("inspect profile", flag.ContinueOnError)
fs.SetOutput(io.Discard) fs.SetOutput(io.Discard)
registerConfigPathFlag(fs, &cfg.configPath) registerConfigSelectionFlags(fs, &cfg.configPath, &cfg.configOverlay)
fs.StringVar(&cfg.profileDir, "profile-dir", "", "directory containing execution profiles") fs.StringVar(&cfg.profileDir, "profile-dir", "", "directory containing execution profiles")
fs.StringVar(&cfg.profileID, "profile", "", "profile ID to inspect") fs.StringVar(&cfg.profileID, "profile", "", "profile ID to inspect")
rawFormat := "" rawFormat := ""
@@ -156,7 +158,7 @@ func parseProfileInspectionArgs(args []string) (*profileInspectionConfig, error)
} }
func resolveAppSettingsForProfileInspection(cfg *profileInspectionConfig) (engineSettings, error) { func resolveAppSettingsForProfileInspection(cfg *profileInspectionConfig) (engineSettings, error) {
settings, diagnostics, err := loadApplicationSettings(cfg.configPath, cfg.configExplicit, cfg.overrides) settings, diagnostics, err := loadApplicationSettings(cfg.configPath, cfg.configOverlay, cfg.configExplicit, cfg.overrides)
if err != nil { if err != nil {
return engineSettings{}, err return engineSettings{}, err
} }
@@ -168,7 +170,7 @@ func parsePromptInspectionArgs(args []string) (*promptInspectionConfig, error) {
cfg := &promptInspectionConfig{outputFormat: appformat.DefaultOutputFormat} cfg := &promptInspectionConfig{outputFormat: appformat.DefaultOutputFormat}
fs := flag.NewFlagSet("inspect prompt", flag.ContinueOnError) fs := flag.NewFlagSet("inspect prompt", flag.ContinueOnError)
fs.SetOutput(io.Discard) fs.SetOutput(io.Discard)
registerConfigPathFlag(fs, &cfg.configPath) registerConfigSelectionFlags(fs, &cfg.configPath, &cfg.configOverlay)
fs.StringVar(&cfg.promptDir, "prompt-dir", "", "directory containing prompt definitions") fs.StringVar(&cfg.promptDir, "prompt-dir", "", "directory containing prompt definitions")
fs.StringVar(&cfg.promptID, "prompt", "", "prompt ID to inspect") fs.StringVar(&cfg.promptID, "prompt", "", "prompt ID to inspect")
fs.StringVar(&cfg.promptVersion, "prompt-version", "", "optional prompt version") fs.StringVar(&cfg.promptVersion, "prompt-version", "", "optional prompt version")
@@ -198,7 +200,7 @@ func parsePromptInspectionArgs(args []string) (*promptInspectionConfig, error) {
} }
func resolveAppSettingsForPromptInspection(cfg *promptInspectionConfig) (engineSettings, error) { func resolveAppSettingsForPromptInspection(cfg *promptInspectionConfig) (engineSettings, error) {
settings, diagnostics, err := loadApplicationSettings(cfg.configPath, cfg.configExplicit, cfg.overrides) settings, diagnostics, err := loadApplicationSettings(cfg.configPath, cfg.configOverlay, cfg.configExplicit, cfg.overrides)
if err != nil { if err != nil {
return engineSettings{}, err return engineSettings{}, err
} }

View File

@@ -59,6 +59,7 @@ type runConfig struct {
transientRetries int transientRetries int
configuredTransientRetries *int configuredTransientRetries *int
diagnostics []configkit.Diagnostic diagnostics []configkit.Diagnostic
configOverlay string
defaultRenderFormat renderformat.PreparedRunOutputFormat defaultRenderFormat renderformat.PreparedRunOutputFormat
@@ -92,6 +93,7 @@ type serveConfig struct {
backends []appconfig.BackendSettings backends []appconfig.BackendSettings
transientRetries *int transientRetries *int
diagnostics []configkit.Diagnostic diagnostics []configkit.Diagnostic
configOverlay string
} }
type commonCommandSettings struct { type commonCommandSettings struct {
@@ -306,7 +308,7 @@ func parseRunArgs(args []string) (*runConfig, error) {
fs := flag.NewFlagSet("run", flag.ContinueOnError) fs := flag.NewFlagSet("run", flag.ContinueOnError)
fs.SetOutput(io.Discard) fs.SetOutput(io.Discard)
registerConfigPathFlag(fs, &cfg.configPath) registerConfigSelectionFlags(fs, &cfg.configPath, &cfg.configOverlay)
registerExecutionRequestFlags(fs, cfg) registerExecutionRequestFlags(fs, cfg)
fs.StringVar(&cfg.schemaDir, "schema-dir", "", "base directory for validation schemas") fs.StringVar(&cfg.schemaDir, "schema-dir", "", "base directory for validation schemas")
fs.DurationVar(&cfg.runTimeout, "run-timeout", 0, "whole-run execution deadline") fs.DurationVar(&cfg.runTimeout, "run-timeout", 0, "whole-run execution deadline")
@@ -363,7 +365,7 @@ func parseRenderArgs(args []string) (*renderConfig, error) {
fs := flag.NewFlagSet("render", flag.ContinueOnError) fs := flag.NewFlagSet("render", flag.ContinueOnError)
fs.SetOutput(io.Discard) fs.SetOutput(io.Discard)
registerConfigPathFlag(fs, &cfg.configPath) registerConfigSelectionFlags(fs, &cfg.configPath, &cfg.configOverlay)
registerExecutionRequestFlags(fs, &cfg.runConfig) registerExecutionRequestFlags(fs, &cfg.runConfig)
fs.StringVar(&cfg.schemaDir, "schema-dir", "", "base directory for validation schemas") fs.StringVar(&cfg.schemaDir, "schema-dir", "", "base directory for validation schemas")
@@ -394,7 +396,7 @@ func parseServeArgs(args []string) (*serveConfig, error) {
fs := flag.NewFlagSet("serve", flag.ContinueOnError) fs := flag.NewFlagSet("serve", flag.ContinueOnError)
fs.SetOutput(io.Discard) fs.SetOutput(io.Discard)
registerConfigPathFlag(fs, &cfg.configPath) registerConfigSelectionFlags(fs, &cfg.configPath, &cfg.configOverlay)
fs.StringVar(&cfg.addr, "addr", "", "HTTP listen address") fs.StringVar(&cfg.addr, "addr", "", "HTTP listen address")
fs.StringVar(&cfg.promptDir, "prompt-dir", "", "directory containing prompt definition YAML files") 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.profileDir, "profile-dir", "", "directory containing execution profile YAML files")
@@ -411,7 +413,7 @@ func parseServeArgs(args []string) (*serveConfig, error) {
return nil, fmt.Errorf("unexpected positional args: %v", fs.Args()) return nil, fmt.Errorf("unexpected positional args: %v", fs.Args())
} }
settings, err := resolveCommonSettings(fs, cfg.configPath) settings, err := resolveCommonSettings(fs, cfg.configPath, cfg.configOverlay)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -463,7 +465,7 @@ func finalizeExecutionRequestConfig(fs *flag.FlagSet, cfg *runConfig) error {
return fmt.Errorf("unexpected positional args: %v", fs.Args()) return fmt.Errorf("unexpected positional args: %v", fs.Args())
} }
settings, err := resolveCommonSettings(fs, cfg.configPath) settings, err := resolveCommonSettings(fs, cfg.configPath, cfg.configOverlay)
if err != nil { if err != nil {
return err return err
} }
@@ -500,7 +502,7 @@ func finalizeExecutionRequestConfig(fs *flag.FlagSet, cfg *runConfig) error {
return nil return nil
} }
func registerConfigPathFlag(fs *flag.FlagSet, target *string) { func registerConfigSelectionFlags(fs *flag.FlagSet, target, overlay *string) {
fs.StringVar( fs.StringVar(
target, target,
"config", "config",
@@ -511,10 +513,11 @@ func registerConfigPathFlag(fs *flag.FlagSet, target *string) {
appconfig.DefaultConfigPath, appconfig.DefaultConfigPath,
), ),
) )
fs.StringVar(overlay, "config-overlay", "", "selected application configuration overlay")
} }
func resolveCommonSettings(fs *flag.FlagSet, configPath string) (commonCommandSettings, error) { func resolveCommonSettings(fs *flag.FlagSet, configPath, overlay string) (commonCommandSettings, error) {
settings, diagnostics, err := loadApplicationSettings(configPath, flagWasSet(fs, "config"), applicationOverrides(fs)) settings, diagnostics, err := loadApplicationSettings(configPath, overlay, flagWasSet(fs, "config"), applicationOverrides(fs))
if err != nil { if err != nil {
return commonCommandSettings{}, err return commonCommandSettings{}, err
} }
@@ -750,9 +753,9 @@ func runErrorMessage(err error) string {
func printUsage(w io.Writer) { func printUsage(w io.Writer) {
fmt.Fprintln(w, "usage: scriptorium <run|render|serve|inspect> ...") fmt.Fprintln(w, "usage: scriptorium <run|render|serve|inspect> ...")
fmt.Fprintln(w, " run: scriptorium run [--config PATH] [--prompt-dir DIR] [--profile-dir DIR] --prompt ID [--prompt-version VERSION] [--input name=path] [--profile ID] [--session-id ID] [--llm-base-url URL] [--model NAME] [--api-key-env ENV] [--temperature N] [--max-tokens N] [--top-p N] [--reasoning-effort VALUE] [--var k=v] [--out path] [--execution-report path] [--timeout 10m] [--run-timeout 10m] [--transient-retries N]") fmt.Fprintln(w, " run: scriptorium run [--config PATH] [--config-overlay NAME] [--prompt-dir DIR] [--profile-dir DIR] [--schema-dir DIR] --prompt ID [--prompt-version VERSION] [--input name=path] [--profile ID] [--session-id ID] [--llm-base-url URL] [--model NAME] [--api-key-env ENV] [--temperature N] [--max-tokens N] [--top-p N] [--reasoning-effort VALUE] [--var k=v] [--out path] [--execution-report path] [--timeout 10m] [--run-timeout 10m] [--transient-retries N]")
fmt.Fprintln(w, " render: scriptorium render [--config PATH] [--prompt-dir DIR] [--profile-dir DIR] --prompt ID [--prompt-version VERSION] [--input name=path] [--profile ID] [--session-id ID] [--llm-base-url URL] [--model NAME] [--api-key-env ENV] [--temperature N] [--max-tokens N] [--top-p N] [--reasoning-effort VALUE] [--var k=v] [--format text|json] [--out path] [--timeout 10m] [--transient-retries N]") fmt.Fprintln(w, " render: scriptorium render [--config PATH] [--config-overlay NAME] [--prompt-dir DIR] [--profile-dir DIR] [--schema-dir DIR] --prompt ID [--prompt-version VERSION] [--input name=path] [--profile ID] [--session-id ID] [--llm-base-url URL] [--model NAME] [--api-key-env ENV] [--temperature N] [--max-tokens N] [--top-p N] [--reasoning-effort VALUE] [--var k=v] [--format text|json] [--out path] [--timeout 10m] [--transient-retries N]")
fmt.Fprintf(w, " serve: scriptorium serve [--config PATH] [--addr %s] [--prompt-dir DIR] [--profile-dir DIR] [--schema-dir DIR] [--artifact-root DIR] [--max-request-bytes N] [--max-artifact-bytes N] [--max-response-bytes N]\n", defaults.HTTPAddrDefault) fmt.Fprintf(w, " serve: scriptorium serve [--config PATH] [--config-overlay NAME] [--addr %s] [--prompt-dir DIR] [--profile-dir DIR] [--schema-dir DIR] [--artifact-root DIR] [--max-request-bytes N] [--max-artifact-bytes N] [--max-response-bytes N]\n", defaults.HTTPAddrDefault)
fmt.Fprintln(w, " inspect prompt: scriptorium inspect prompt --prompt ID [--prompt-version VERSION] [--config PATH] [--prompt-dir DIR] [--format text|json] [--out path]") fmt.Fprintln(w, " inspect prompt: scriptorium inspect prompt --prompt ID [--prompt-version VERSION] [--config PATH] [--config-overlay NAME] [--prompt-dir DIR] [--format text|json] [--out path]")
fmt.Fprintln(w, " inspect profile: scriptorium inspect profile --profile ID [--config PATH] [--profile-dir DIR] [--format text|json] [--out path]") fmt.Fprintln(w, " inspect profile: scriptorium inspect profile --profile ID [--config PATH] [--config-overlay NAME] [--profile-dir DIR] [--format text|json] [--out path]")
} }

View File

@@ -1071,6 +1071,41 @@ profile_dir: %s
} }
} }
func TestRenderCommandLoadsImportsAndOverlayWithoutChangingExecutionProfile(t *testing.T) {
lib := newCLITestLibrary(t)
inputPath := lib.writeInputFile(t, "transcript.md", "hello transcript")
writePromptFile(t, lib.promptDir, "prompt.render", "local-default")
writeProfileFile(t, lib.profileDir, "local-default", "http://127.0.0.1:1/v1", "profile-model")
if err := os.MkdirAll(filepath.Join(lib.rootDir, "fragments"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(lib.rootDir, "overlays"), 0o755); err != nil {
t.Fatal(err)
}
base := filepath.Join(lib.rootDir, "config.yml")
for path, body := range map[string]string{
base: "configkit:\n imports:\n - path: fragments/sources.yml\n",
filepath.Join(lib.rootDir, "fragments", "sources.yml"): fmt.Sprintf("prompt_dir: %q\nprofile_dir: %q\n", lib.promptDir, lib.profileDir),
filepath.Join(lib.rootDir, "overlays", "local.yml"): "defaults:\n render_format: json\n",
} {
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
args := []string{"--config", base, "--config-overlay", "local", "--profile", "local-default", "--prompt", "prompt.render", "--input", "transcript=" + inputPath}
cfg, err := parseRenderArgs(args)
if err != nil {
t.Fatal(err)
}
if cfg.profileID != "local-default" || cfg.outputFormat != renderformat.PreparedRunFormatJSON {
t.Fatalf("overlay changed execution profile or output selection: %+v", cfg)
}
code, stdout, stderr := runCLICommand(t, renderCommand, args)
if code != ExitOK || !strings.Contains(stdout, `"selected_profile_id": "local-default"`) {
t.Fatalf("render did not use composed config and independent execution profile: code=%d out=%q err=%q", code, stdout, stderr)
}
}
func TestRenderCommandUsesConfiguredCustomBackend(t *testing.T) { func TestRenderCommandUsesConfiguredCustomBackend(t *testing.T) {
lib := newCLITestLibrary(t) lib := newCLITestLibrary(t)
writePromptDefinition(t, lib.promptDir, "custom.yaml", `id: custom writePromptDefinition(t, lib.promptDir, "custom.yaml", `id: custom

View File

@@ -21,8 +21,10 @@ type LoadOptions struct {
// Load resolves one ConfigKit document and retains its inspection snapshot. // Load resolves one ConfigKit document and retains its inspection snapshot.
func Load(options LoadOptions) (*configkit.Result[Document], error) { func Load(options LoadOptions) (*configkit.Result[Document], error) {
var source configkit.Source var source configkit.Source
fileBacked := false
if options.Explicit { if options.Explicit {
source = configkit.File(options.Path) source = configkit.File(options.Path)
fileBacked = true
} else { } else {
selected := "" selected := ""
for _, candidate := range defaultConfigSearchPaths { for _, candidate := range defaultConfigSearchPaths {
@@ -41,11 +43,14 @@ func Load(options LoadOptions) (*configkit.Result[Document], error) {
source = configkit.NoSource() source = configkit.NoSource()
} else { } else {
source = configkit.File(selected) source = configkit.File(selected)
fileBacked = true
} }
} }
result, err := configkit.Load(source, configkit.Options[Document]{ result, err := configkit.Load(source, configkit.Options[Document]{
Definition: documentDefinition(), Definition: documentDefinition(),
Imports: fileBacked,
Overlay: options.Overlay,
Overrides: options.Overrides, Overrides: options.Overrides,
}) })
if err != nil { if err != nil {

View File

@@ -174,3 +174,56 @@ func TestLoadDiscoveryErrorQuotesPath(t *testing.T) {
t.Fatalf("discovery error did not quote path safely: %v", err) t.Fatalf("discovery error did not quote path safely: %v", err)
} }
} }
func TestLoadComposesExplicitImportsAndSelectedOverlay(t *testing.T) {
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, "fragments"), 0o700); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(dir, "overlays"), 0o700); err != nil {
t.Fatal(err)
}
base := filepath.Join(dir, "config.yml")
writeApplicationFile(t, base, "configkit:\n imports:\n - glob: fragments/*.yml\nprompt_dir: ./base-prompts\nserver:\n addr: :8081\n")
writeApplicationFile(t, filepath.Join(dir, "fragments", "a.yml"), "backends:\n alpha:\n endpoint: http://alpha.invalid/v1\n")
writeApplicationFile(t, filepath.Join(dir, "fragments", "b.yml"), "backends:\n beta:\n endpoint: http://beta.invalid/v1\n")
writeApplicationFile(t, filepath.Join(dir, "overlays", "local.yml"), "prompt_dir: ./overlay-prompts\nserver:\n addr: :9090\n")
result, err := Load(LoadOptions{Path: base, Explicit: true})
if err != nil || result.Config.PromptDir != "base-prompts" || len(result.Config.Backends) != 2 || len(result.Sources()) != 3 {
t.Fatalf("disjoint imports did not compose: result=%+v err=%v", result, err)
}
result, err = Load(LoadOptions{Path: base, Explicit: true, Overlay: "local", Overrides: []configkit.Override{{
Path: configkit.Keys("server", "addr"), Value: ":7070", Flag: "--addr",
}}})
if err != nil || result.Config.PromptDir != "overlay-prompts" || result.Config.Server.Addr != ":7070" || len(result.Sources()) != 5 {
t.Fatalf("overlay or explicit override changed: result=%+v err=%v", result, err)
}
if _, err := Load(LoadOptions{Path: base, Explicit: true, Overlay: "missing"}); err == nil {
t.Fatal("missing selected overlay accepted")
}
writeApplicationFile(t, filepath.Join(dir, "fragments", "b.yml"), "backends:\n alpha:\n endpoint: http://conflict.invalid/v1\n")
if _, err := Load(LoadOptions{Path: base, Explicit: true}); err == nil {
t.Fatal("conflicting import accepted")
} else {
var loadError *configkit.LoadError
if !errors.As(err, &loadError) {
t.Fatalf("conflict lost structured diagnostic: %v", err)
}
}
}
func TestLoadRejectsOverlayWithoutBase(t *testing.T) {
useConfigSearchPaths(t, filepath.Join(t.TempDir(), "absent.yml"))
if _, err := Load(LoadOptions{Overlay: "local"}); err == nil {
t.Fatal("overlay without base accepted")
} else {
var loadError *configkit.LoadError
if !errors.As(err, &loadError) {
t.Fatalf("no-base overlay lost structured diagnostic: %v", err)
}
}
if _, err := Load(LoadOptions{Overlay: "default"}); err != nil {
t.Fatalf("default must select no overlay: %v", err)
}
}