From 6ad723cde1841ec8d48dbd9752808b7027e0370b Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Wed, 7 Oct 2026 14:26:32 +0000 Subject: [PATCH] Add PromptKit configuration scope --- README.md | 5 +- docs/cli.md | 5 + docs/development.md | 4 +- docs/roadmap/implementation.md | 8 +- go.mod | 8 ++ go.sum | 5 +- promptkit_scope.go | 54 ++++++++ promptkit_scope_test.go | 220 +++++++++++++++++++++++++++++++++ 8 files changed, 300 insertions(+), 9 deletions(-) create mode 100644 promptkit_scope.go create mode 100644 promptkit_scope_test.go diff --git a/README.md b/README.md index b9db8a2..2c5d0b2 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/cli.md b/docs/cli.md index d0b8a4d..3b98a6b 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -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 diff --git a/docs/development.md b/docs/development.md index 8349ac6..8b9a349 100644 --- a/docs/development.md +++ b/docs/development.md @@ -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. diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index cd41cf2..c9e1e5d 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -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. diff --git a/go.mod b/go.mod index 593d033..d33d2cc 100644 --- a/go.mod +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum index a64233a..7716e2a 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/promptkit_scope.go b/promptkit_scope.go new file mode 100644 index 0000000..9fceb3f --- /dev/null +++ b/promptkit_scope.go @@ -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 +} diff --git a/promptkit_scope_test.go b/promptkit_scope_test.go new file mode 100644 index 0000000..6f318ad --- /dev/null +++ b/promptkit_scope_test.go @@ -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) + } +}