Add PromptKit configuration scope

This commit is contained in:
2026-10-07 14:26:32 +00:00
parent c0be9ffadc
commit 6ad723cde1
8 changed files with 300 additions and 9 deletions

View File

@@ -5,9 +5,10 @@ and shared command infrastructure across related applications.
The current Go library provides an instance-owned command registry, typed flag
and operand bindings, Cobra-backed routing, invocation outcomes, generated help,
application or CommandKit version reporting, configuration scope discovery, and
application or CommandKit version reporting, configuration scope discovery,
ConfigKit-backed validation, native effective YAML display, source inventory,
and path explanation reports. PromptKit commands remain planned.
and path explanation reports. Applications can register a PromptKit engine
configuration scope independently of PromptKit definition commands.
## Documentation

View File

@@ -102,6 +102,11 @@ runtime context or acquire configuration. It reports the intersection between
declared scope capabilities and installed handlers, so an operation is listed
only when the corresponding command is available.
Applications can register the `promptkit` scope with `NewPromptKitScope` to
expose PromptKit engine configuration through these handlers. This configuration
scope can be installed without registering PromptKit prompt or profile
definition commands.
### Validate configuration
`config validate` loads and validates the selected scope. The default scope is

View File

@@ -6,8 +6,8 @@ declarations, an instance-owned registry, a Cobra-backed routing and parsing
adapter, public `App.Execute` coordination, and generated help/version commands.
Configuration suite registration, scope discovery, ConfigKit integration,
validation, native effective YAML display, source inventory, and path
explanation reports are implemented; other configuration and PromptKit
operations remain pending. The
explanation reports, and PromptKit engine configuration inspection are
implemented; named prompt/profile inspection remains pending. The
[README](../README.md) provides project orientation; the
[documentation policy](policy/documentation.md#canonical-ownership) assigns
canonical documentation owners.

View File

@@ -1,6 +1,6 @@
# Initial CommandKit implementation plan
Status: implementation underway; Stages 1-11 complete and Stages 12-14 pending.
Status: implementation underway; Stages 1-12 complete and Stages 13-14 pending.
Date: 2026-10-07.
@@ -716,7 +716,7 @@ and CLI reference describe the feature.
## Stage 12: integrate the PromptKit configuration scope
**Status:** pending. **Prerequisite:** Stage 11.
**Status:** complete. **Prerequisite:** Stage 11.
```go
func NewPromptKitScope[C any](
@@ -744,6 +744,10 @@ all display with app first/promptkit second, configuration-only installation,
caller context/selection propagation, and metadata discovery without engine load.
Fixtures do not depend on provider services or sibling repository runtime files.
**Completion evidence:** `promptkit_scope_test.go` exercises a real in-memory
PromptKit engine, native reports, aggregate order, lazy discovery, and preserved
PromptKit/ConfigKit error identities. `go test ./...` passes.
## Stage 13: implement named prompt/profile inspection
**Status:** pending. **Prerequisite:** Stage 12.

8
go.mod
View File

@@ -9,3 +9,11 @@ require (
github.com/spf13/pflag v1.0.9
go.yaml.in/yaml/v4 v4.0.0-rc.6
)
require (
gitea.maximumdirect.net/eric/promptkit-backend-openrouter/v3 v3.20260930.0 // indirect
gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/v3 v3.20260930.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
golang.org/x/text v0.14.0 // indirect
)

5
go.sum
View File

@@ -6,11 +6,11 @@ gitea.maximumdirect.net/eric/promptkit-backend-openrouter/v3 v3.20260930.0 h1:lz
gitea.maximumdirect.net/eric/promptkit-backend-openrouter/v3 v3.20260930.0/go.mod h1:HjS0f+3q0IdHCTEz+VVAEgIwIWJFAZUjuKdAtvtqKdg=
gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/v3 v3.20260930.0 h1:h12ZTRldAmpLAomj0oQRGteFxHLN1LPE6XDl9zJNbm8=
gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/v3 v3.20260930.0/go.mod h1:OEFBeKdjTsLzqJwLmXI2O5gG3r64FktcVJVb3mBrRfg=
github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
@@ -22,7 +22,6 @@ go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=
go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

54
promptkit_scope.go Normal file
View File

@@ -0,0 +1,54 @@
package commandkit
import (
"context"
"errors"
"fmt"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
"gitea.maximumdirect.net/eric/promptkit"
)
// NewPromptKitScope adapts PromptKit's captured configuration inspection to
// CommandKit's configuration commands.
func NewPromptKitScope[C any](
load func(context.Context, Selection, C) (*promptkit.Engine, error),
) (ConfigScope[C], error) {
if load == nil {
return ConfigScope[C]{}, fmt.Errorf("PromptKit configuration loader is nil")
}
return ConfigScope[C]{
Name: "promptkit",
Summary: "PromptKit engine configuration.",
Operations: []ConfigOperation{ConfigValidate, ConfigShow, ConfigSources, ConfigExplain},
Acquire: func(ctx context.Context, request ConfigRequest, appContext C) (Acquired[ConfigView], error) {
engine, err := load(ctx, request.Selection, appContext)
if err != nil {
var loadErr *configkit.LoadError
if errors.As(err, &loadErr) {
return Acquired[ConfigView]{Diagnostics: ConfigKitDiagnostics("promptkit", loadErr.Sources(), loadErr.Diagnostics())}, err
}
return Acquired[ConfigView]{}, err
}
if engine == nil {
return Acquired[ConfigView]{}, fmt.Errorf("PromptKit configuration loader returned a nil engine")
}
inspection, err := engine.InspectConfiguration()
if err != nil {
return Acquired[ConfigView]{}, err
}
sources := inspection.Sources()
diagnostics := ConfigKitDiagnostics("promptkit", sources, inspection.Diagnostics())
return Acquired[ConfigView]{
Value: ConfigView{
Effective: inspection,
Sources: inspection,
Explanation: inspection,
},
Diagnostics: diagnostics,
}, nil
},
}, nil
}

220
promptkit_scope_test.go Normal file
View File

@@ -0,0 +1,220 @@
package commandkit_test
import (
"bytes"
"context"
"errors"
"io"
"strings"
"testing"
commandkit "gitea.maximumdirect.net/eric/CommandKit"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
"gitea.maximumdirect.net/eric/promptkit"
)
func TestPromptKitScopeUsesCapturedInspectionAndSelectedInputs(t *testing.T) {
type appContext struct{ marker string }
type contextKey struct{}
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
Main: &configkit.Input{Source: configkit.Mapping("local fixture", map[string]any{
"backends": map[string]any{"local": map[string]any{"endpoint": "https://example.invalid/v1"}},
"profiles": map[string]any{"base.v1": map[string]any{"backend": "local", "model": "fixture-model"}},
})},
}})
if err != nil {
t.Fatalf("construct PromptKit engine: %v", err)
}
inspection, err := engine.InspectConfiguration()
if err != nil {
t.Fatalf("inspect PromptKit engine: %v", err)
}
ctx := context.WithValue(context.Background(), contextKey{}, "caller-context")
selection := commandkit.Selection{Config: commandkit.Supplied[string]{Value: "main.yml", Set: true}, Overlay: commandkit.Supplied[string]{Value: "dev", Set: true}}
loaderCalls := 0
var receivedSelections []commandkit.Selection
scope, err := commandkit.NewPromptKitScope(func(gotContext context.Context, gotSelection commandkit.Selection, gotAppContext appContext) (*promptkit.Engine, error) {
loaderCalls++
receivedSelections = append(receivedSelections, gotSelection)
if gotContext != ctx || gotContext.Value(contextKey{}) != "caller-context" {
t.Fatal("PromptKit loader did not receive the caller context")
}
if gotAppContext.marker != "app-state" {
t.Fatalf("loader app context = %+v", gotAppContext)
}
return engine, nil
})
if err != nil {
t.Fatal(err)
}
if scope.Name != "promptkit" || len(scope.Operations) != 4 {
t.Fatalf("scope declaration = %+v", scope)
}
acquired, err := scope.Acquire(ctx, commandkit.ConfigRequest{Operation: commandkit.ConfigShow, Selection: selection}, appContext{marker: "app-state"})
if err != nil {
t.Fatalf("acquire PromptKit scope: %v", err)
}
var gotEffective, wantEffective bytes.Buffer
if err := acquired.Value.Effective.WriteEffective(&gotEffective); err != nil {
t.Fatal(err)
}
if err := inspection.WriteEffective(&wantEffective); err != nil || gotEffective.String() != wantEffective.String() {
t.Fatalf("native effective output = (%q, %v), want %q", gotEffective.String(), err, wantEffective.String())
}
if len(acquired.Value.Sources.Sources()) != len(inspection.Sources()) {
t.Fatalf("source inventory = %#v, want %#v", acquired.Value.Sources.Sources(), inspection.Sources())
}
path := configkit.Keys("profiles", "base.v1", "model")
var gotExplanation, wantExplanation bytes.Buffer
if err := acquired.Value.Explanation.WriteExplanation(&gotExplanation, path); err != nil {
t.Fatal(err)
}
if err := inspection.WriteExplanation(&wantExplanation, path); err != nil || gotExplanation.String() != wantExplanation.String() {
t.Fatalf("native explanation = (%q, %v), want %q", gotExplanation.String(), err, wantExplanation.String())
}
if loaderCalls != 1 {
t.Fatalf("loader called %d times, want once", loaderCalls)
}
if len(receivedSelections) != 1 || receivedSelections[0] != selection {
t.Fatalf("loader selection = %#v, want %#v", receivedSelections, selection)
}
app, err := commandkit.New(commandkit.Identity{Name: "example"})
if err != nil {
t.Fatal(err)
}
if err := commandkit.RegisterConfig(app, commandkit.ConfigSuite[appContext]{
NewContext: func() appContext { return appContext{marker: "app-state"} },
Scopes: []commandkit.ConfigScope[appContext]{scope},
}); err != nil {
t.Fatal(err)
}
var show bytes.Buffer
outcome := app.Execute(ctx, []string{"config", "show", "--scope", "promptkit", "--config", "main.yml", "--overlay", "dev"}, commandkit.Streams{Out: &show, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess || show.String() != wantEffective.String() {
t.Fatalf("config show = (%#v, %q), want native output %q", outcome, show.String(), wantEffective.String())
}
var sources bytes.Buffer
outcome = app.Execute(ctx, []string{"config", "sources", "--scope", "promptkit", "--format", "yaml"}, commandkit.Streams{Out: &sources, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(sources.String(), "label: local fixture") {
t.Fatalf("config sources = (%#v, %q), want captured PromptKit source", outcome, sources.String())
}
var explanation bytes.Buffer
outcome = app.Execute(ctx, []string{"config", "explain", "--scope", "promptkit", "--path", path.String()}, commandkit.Streams{Out: &explanation, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess || explanation.String() != wantExplanation.String() {
t.Fatalf("config explain = (%#v, %q), want native output %q", outcome, explanation.String(), wantExplanation.String())
}
callsBeforeDiscovery := loaderCalls
outcome = app.Execute(ctx, []string{"config", "scopes"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess || loaderCalls != callsBeforeDiscovery {
t.Fatalf("metadata discovery outcome=%#v loader calls=%d", outcome, loaderCalls)
}
}
func TestPromptKitScopeAggregateOrderAndConfigurationOnlyRegistration(t *testing.T) {
engine, err := promptkit.NewEngine(promptkit.Config{})
if err != nil {
t.Fatal(err)
}
scope, err := commandkit.NewPromptKitScope(func(context.Context, commandkit.Selection, struct{}) (*promptkit.Engine, error) {
return engine, nil
})
if err != nil {
t.Fatal(err)
}
app, err := commandkit.New(commandkit.Identity{Name: "example"})
if err != nil {
t.Fatal(err)
}
if err := commandkit.RegisterConfig(app, commandkit.ConfigSuite[struct{}]{Scopes: []commandkit.ConfigScope[struct{}]{
{Name: "app", Operations: []commandkit.ConfigOperation{commandkit.ConfigShow}, Acquire: func(context.Context, commandkit.ConfigRequest, struct{}) (commandkit.Acquired[commandkit.ConfigView], error) {
return commandkit.Acquired[commandkit.ConfigView]{Value: commandkit.ConfigView{Effective: effectiveYAML("app: true\n")}}, nil
}}, scope,
}}); err != nil {
t.Fatal(err)
}
var output bytes.Buffer
outcome := app.Execute(context.Background(), []string{"config", "show", "--scope", "all"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess {
t.Fatalf("aggregate show outcome = %#v", outcome)
}
appIndex, promptkitIndex := strings.Index(output.String(), "# scope: app"), strings.Index(output.String(), "# scope: promptkit")
if appIndex < 0 || promptkitIndex <= appIndex {
t.Fatalf("aggregate order = %q", output.String())
}
var scopes bytes.Buffer
outcome = app.Execute(context.Background(), []string{"config", "scopes"}, commandkit.Streams{Out: &scopes, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(scopes.String(), "promptkit: validate, show, sources, explain") {
t.Fatalf("configuration scope discovery = (%#v, %q)", outcome, scopes.String())
}
outcome = app.Execute(context.Background(), []string{"help", "promptkit"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeUsage {
t.Fatalf("configuration-only install exposed promptkit definition commands: %#v", outcome)
}
}
func TestPromptKitScopeErrorsPreserveIdentityAndRejectNilInputs(t *testing.T) {
if _, err := commandkit.NewPromptKitScope[struct{}](nil); err == nil {
t.Fatal("nil loader was accepted")
}
var nilEngine *promptkit.Engine
if _, err := nilEngine.InspectConfiguration(); !errors.Is(err, promptkit.ErrInvalidConfig) {
t.Fatalf("PromptKit nil engine identity = %v", err)
}
loadErr := errors.Join(promptkit.ErrInvalidConfig, errors.New("loader context"))
scope, err := commandkit.NewPromptKitScope(func(context.Context, commandkit.Selection, struct{}) (*promptkit.Engine, error) {
return nil, loadErr
})
if err != nil {
t.Fatal(err)
}
_, err = scope.Acquire(context.Background(), commandkit.ConfigRequest{}, struct{}{})
if err != loadErr || !errors.Is(err, promptkit.ErrInvalidConfig) {
t.Fatalf("loader error = %v, want original error identity %v", err, loadErr)
}
nilScope, err := commandkit.NewPromptKitScope(func(context.Context, commandkit.Selection, struct{}) (*promptkit.Engine, error) {
return nil, nil
})
if err != nil {
t.Fatal(err)
}
if _, err := nilScope.Acquire(context.Background(), commandkit.ConfigRequest{}, struct{}{}); err == nil || !strings.Contains(err.Error(), "nil engine") {
t.Fatalf("nil engine acquisition error = %v", err)
}
negativeRetries := -1
invalidEngine, err := promptkit.NewEngine(promptkit.Config{TransientRetries: &negativeRetries})
if invalidEngine != nil || !errors.Is(err, promptkit.ErrInvalidConfig) {
t.Fatalf("invalid PromptKit construction = (%v, %v)", invalidEngine, err)
}
invalidEngine, err = promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Main: &configkit.Input{
Source: configkit.Mapping("invalid inheritance", map[string]any{
"profiles": map[string]any{"child": map[string]any{"extends": "missing"}},
}),
}}})
if invalidEngine != nil || !errors.Is(err, promptkit.ErrInvalidConfig) {
t.Fatalf("invalid PromptKit profile construction = (%v, %v)", invalidEngine, err)
}
var configLoadErr *configkit.LoadError
if !errors.As(err, &configLoadErr) {
t.Fatalf("invalid PromptKit construction did not preserve ConfigKit LoadError: %v", err)
}
failedScope, scopeErr := commandkit.NewPromptKitScope(func(context.Context, commandkit.Selection, struct{}) (*promptkit.Engine, error) {
return nil, err
})
if scopeErr != nil {
t.Fatal(scopeErr)
}
failed, scopeErr := failedScope.Acquire(context.Background(), commandkit.ConfigRequest{}, struct{}{})
var acquiredLoadErr *configkit.LoadError
if scopeErr != err || !errors.As(scopeErr, &acquiredLoadErr) || acquiredLoadErr != configLoadErr || len(failed.Diagnostics) == 0 {
t.Fatalf("scope load error = (%v, %+v), want original LoadError and diagnostics", scopeErr, failed)
}
}