From 7d21aaab9254e85f867dcacd3a4ede862094036a Mon Sep 17 00:00:00 2001 From: iliya Date: Sun, 6 Sep 2026 16:56:23 +0000 Subject: [PATCH 1/4] feat(authoring): compose private release CLI roots --- cli/plugin-kit-ai/cmd/agentplugins/main.go | 12 +- .../cmd/agentplugins/release_root.go | 91 +++++ .../cmd/agentplugins/release_root_test.go | 100 ++++++ cli/plugin-kit-ai/cmd/plugin-kit-ai/main.go | 8 +- .../cmd/plugin-kit-ai/release_compat.go | 242 +++++++++++++ .../cmd/plugin-kit-ai/release_compat_test.go | 320 ++++++++++++++++++ .../internal/authoring/commands/commands.go | 6 +- .../authoring/commands/public_contract.go | 75 +++- .../authoring/commands/release_parity_test.go | 127 +++++++ .../internal/authoring/commands/version.go | 96 ++++++ .../internal/authoringcli/release.go | 37 ++ .../scripts/dual-authoring-candidate.js | 20 +- .../scripts/stage-dual-authoring-candidate.js | 34 +- .../dual-authoring-candidate-marker.test.js | 28 +- .../dual-authoring-candidate-native.test.js | 72 +++- .../test/dual-authoring-candidate.test.js | 23 ++ 16 files changed, 1246 insertions(+), 45 deletions(-) create mode 100644 cli/plugin-kit-ai/cmd/agentplugins/release_root.go create mode 100644 cli/plugin-kit-ai/cmd/agentplugins/release_root_test.go create mode 100644 cli/plugin-kit-ai/cmd/plugin-kit-ai/release_compat.go create mode 100644 cli/plugin-kit-ai/cmd/plugin-kit-ai/release_compat_test.go create mode 100644 cli/plugin-kit-ai/internal/authoring/commands/release_parity_test.go create mode 100644 cli/plugin-kit-ai/internal/authoring/commands/version.go create mode 100644 cli/plugin-kit-ai/internal/authoringcli/release.go diff --git a/cli/plugin-kit-ai/cmd/agentplugins/main.go b/cli/plugin-kit-ai/cmd/agentplugins/main.go index 43a29fc5..06b6dc08 100644 --- a/cli/plugin-kit-ai/cmd/agentplugins/main.go +++ b/cli/plugin-kit-ai/cmd/agentplugins/main.go @@ -60,14 +60,22 @@ var ( ) func main() { - if commands.IsEnabled() && commands.IsAuthorInvocation(os.Args[1:], agentpluginscli.NewRoot(agentpluginscli.App{})) { + if commands.IsRelease() { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + if err := executeRelease(ctx, os.Args[1:], authoringcli.Streams{In: os.Stdin, Out: os.Stdout, Err: os.Stderr}, run); err != nil { + os.Exit(exitx.Code(err)) + } + return + } + if commands.IsEnabled() && commands.IsAuthorInvocation(os.Args[1:], agentpluginscli.NewRoot(agentpluginscli.App{Version: version})) { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() app := commands.App{Projects: project.Service{Scratch: os.TempDir()}, Revision: commands.Revision} err := app.Execute(ctx, os.Args[1:], authoringcli.Streams{In: os.Stdin, Out: os.Stdout, Err: os.Stderr}, func(factories ...authoringcli.Factory) (*cobra.Command, error) { // Construct the ENTIRE root and installer options on every invocation. // Installer dependencies are deliberately unconfigured on this author route. - root := agentpluginscli.NewRoot(agentpluginscli.App{}) + root := agentpluginscli.NewRoot(agentpluginscli.App{Version: version}) author, err := authoringcli.NewAuthorCommand(factories...) if err != nil { return nil, err diff --git a/cli/plugin-kit-ai/cmd/agentplugins/release_root.go b/cli/plugin-kit-ai/cmd/agentplugins/release_root.go new file mode 100644 index 00000000..5d416023 --- /dev/null +++ b/cli/plugin-kit-ai/cmd/agentplugins/release_root.go @@ -0,0 +1,91 @@ +package main + +import ( + "bytes" + "context" + "errors" + "io" + "os" + + "github.com/777genius/plugin-kit-ai/cli/internal/agentpluginscli" + "github.com/777genius/plugin-kit-ai/cli/internal/authoring/commands" + "github.com/777genius/plugin-kit-ai/cli/internal/authoring/project" + "github.com/777genius/plugin-kit-ai/cli/internal/authoringcli" + "github.com/777genius/plugin-kit-ai/cli/internal/exitx" + "github.com/777genius/plugin-kit-ai/cli/internal/outputjson" + "github.com/spf13/cobra" +) + +func newReleaseRoot(factories ...authoringcli.Factory) (*cobra.Command, error) { + root := agentpluginscli.NewRoot(agentpluginscli.App{Version: version}) + author, err := authoringcli.NewReleaseAuthorCommand(factories...) + if err != nil { + return nil, err + } + root.AddCommand(author) + return root, nil +} + +// The installer callback is the sole entry to home, feeds, security and client +// setup. Tests replace it with a trap; selection only constructs fresh commands. +func executeRelease(ctx context.Context, args []string, streams authoringcli.Streams, installer func() error) error { + a := commands.App{Projects: project.Service{Scratch: os.TempDir()}, Revision: commands.Revision, PublicContract: true, + Release: &commands.ReleaseOptions{Product: "agentplugins", Version: version}} + root, author, noEffect, err := a.ReleaseSelection(args, newReleaseRoot) + if err != nil { + return err + } + if author { + return a.Execute(ctx, args, streams, newReleaseRoot) + } + if !noEffect { + return installer() + } + var out bytes.Buffer + root.SetOut(&out) + for _, c := range root.Commands() { + if c.Name() == "completion" { + root.RemoveCommand(c) + } + } + authoringcli.PrepareReleaseUtilities(root, true) + helpRendered := false + root.SetHelpFunc(func(c *cobra.Command, _ []string) { + helpRendered = true + format, _ := c.Flags().GetString("format") + if format == "json" { + var visible []string + for _, child := range c.Commands() { + if !child.Hidden { + visible = append(visible, child.Name()) + } + } + err = outputjson.Write(&out, "help", outputjson.Success, map[string]any{"use": c.CommandPath(), "commands": visible}) + } else { + err = c.Usage() + } + }) + runErr := authoringcli.Factory(func() (*cobra.Command, error) { return root, nil }).Execute(ctx, args, authoringcli.Streams{In: streams.In, Out: &out, Err: io.Discard}) + if runErr != nil || err != nil { + format := commands.OutputFormat(root, args) + if format == "json" { + err = outputjson.Write(streams.Out, "arguments", outputjson.Failure, map[string]string{"code": "arguments_invalid"}) + } else { + _, err = io.WriteString(streams.Out, "Use agentplugins --help for implemented commands.\n") + } + if err != nil { + return exitx.Wrap(errors.New("output failed"), 1) + } + return exitx.Wrap(errors.New("arguments invalid"), 2) + } + if !helpRendered && commands.CompletionInvocation(root, args) && commands.OutputFormat(root, args) == "json" { + if err = outputjson.Write(streams.Out, "completion", outputjson.Success, map[string]string{"script": out.String()}); err != nil { + return exitx.Wrap(errors.New("output failed"), 1) + } + return nil + } + if _, err = streams.Out.Write(out.Bytes()); err != nil { + return exitx.Wrap(errors.New("output failed"), 1) + } + return nil +} diff --git a/cli/plugin-kit-ai/cmd/agentplugins/release_root_test.go b/cli/plugin-kit-ai/cmd/agentplugins/release_root_test.go new file mode 100644 index 00000000..c2e01000 --- /dev/null +++ b/cli/plugin-kit-ai/cmd/agentplugins/release_root_test.go @@ -0,0 +1,100 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "strings" + "testing" + + "github.com/777genius/plugin-kit-ai/cli/internal/authoringcli" + "github.com/777genius/plugin-kit-ai/cli/internal/exitx" +) + +func TestReleaseRoutesBeforeInstallerSetup(t *testing.T) { + for _, args := range [][]string{ + nil, {"--help"}, {"help", "author"}, {"help", "author", "skills", "init"}, + {"author"}, {"author", "--help"}, {"author", "version"}, {"version"}, + {"author", "skills", "--help"}, {"author", "inspect", "--help"}, + {"--scope=user", "author", "--help"}, {"author", "--accept-security-risk=false", "--help"}, + {"author", "init", "--unknown=credential-fixture"}, + {"completion", "bash"}, {"completion", "zsh"}, {"completion", "fish"}, {"completion", "powershell"}, + {"help", "add"}, {"add", "--help"}, {"author", "skills", "--security-details=false"}, + } { + t.Run(strings.Join(args, " "), func(t *testing.T) { + var out, errout bytes.Buffer + calls := 0 + err := executeRelease(context.Background(), args, authoringcli.Streams{Out: &out, Err: &errout}, func() error { calls++; return errors.New("installer trap") }) + if calls != 0 || errout.Len() != 0 || strings.Contains(out.String(), "credential-fixture") { + t.Fatalf("route reached setup or leaked input: calls=%d", calls) + } + if len(args) == 0 && (err != nil || !strings.Contains(out.String(), "author")) { + t.Fatal("author missing from root") + } + if strings.HasPrefix(strings.Join(args, " "), "completion") && (err != nil || out.Len() == 0) { + t.Fatal("completion failed", err) + } + }) + } + for _, args := range [][]string{{"add", "author"}, {"--target", "author", "add", "source"}, {"--target", "cursor", "add", "author"}, {"add", "--", "author"}, {"validate", "author"}} { + calls := 0 + _ = executeRelease(context.Background(), args, authoringcli.Streams{Out: io.Discard, Err: io.Discard}, func() error { calls++; return nil }) + if calls != 1 { + t.Fatalf("installer placement/source changed: %v", args) + } + } +} + +func TestReleaseVersionAndHelpJSON(t *testing.T) { + old := version + version = "0.1.91" + defer func() { version = old }() + for _, args := range [][]string{{"version"}, {"author", "version"}, {"--help"}, {"help", "author", "skills", "init"}, {"completion", "bash"}} { + var out bytes.Buffer + err := executeRelease(context.Background(), append(args, "--format=json"), authoringcli.Streams{Out: &out, Err: io.Discard}, func() error { t.Fatal("installer initialized"); return nil }) + if err != nil { + t.Fatal(err, out.String()) + } + var e struct { + Command, Result string + Data map[string]any + } + if err = json.Unmarshal(out.Bytes(), &e); err != nil { + t.Fatal(err, out.String()) + } + if e.Result != "success" { + t.Fatal(e) + } + switch strings.Join(args, " ") { + case "completion bash": + if e.Command != "completion" || !strings.Contains(e.Data["script"].(string), "agentplugins") { + t.Fatal(e) + } + case "version": + if e.Command != "version" || e.Data["version"] != version { + t.Fatal(e) + } + case "author version": + if e.Command != "author.version" || e.Data["product_version"] != version || e.Data["engine_version"] != "standard-first-slice/1" { + t.Fatal(e) + } + case "help author skills init": + if !strings.HasPrefix(e.Data["help"].(map[string]any)["use"].(string), "agentplugins author skills init ") { + t.Fatal(e) + } + } + } +} +func TestReleaseInstallerFlagsRejectBeforeHelp(t *testing.T) { + for _, f := range []string{"--scope=user", "--security-details=false", "--accept-security-risk=false", "--dry-run=false"} { + for _, args := range [][]string{{f, "author", "init", "--help"}, {"author", "init", f, "--help"}} { + var out bytes.Buffer + err := executeRelease(context.Background(), append(args, "--format=json"), authoringcli.Streams{Out: &out, Err: io.Discard}, func() error { t.Fatal("installer initialized"); return nil }) + if exitx.Code(err) != 2 || !strings.Contains(out.String(), `"attempted":false`) || !strings.Contains(out.String(), `"result":"failure"`) { + t.Fatal(err, out.String()) + } + } + } +} diff --git a/cli/plugin-kit-ai/cmd/plugin-kit-ai/main.go b/cli/plugin-kit-ai/cmd/plugin-kit-ai/main.go index e30754f2..9b6b2b62 100644 --- a/cli/plugin-kit-ai/cmd/plugin-kit-ai/main.go +++ b/cli/plugin-kit-ai/cmd/plugin-kit-ai/main.go @@ -17,7 +17,13 @@ func main() { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() app := commands.App{Projects: project.Service{Scratch: os.TempDir()}, Revision: commands.Revision} - if err := app.Execute(ctx, os.Args[1:], authoringcli.Streams{In: os.Stdin, Out: os.Stdout, Err: os.Stderr}, authoringcli.NewPluginKitRoot); err != nil { + build := commands.RootBuilder(authoringcli.NewPluginKitRoot) + if commands.IsRelease() { + app.PublicContract = true + app.Release = &commands.ReleaseOptions{Product: "plugin-kit-ai", Version: version, Reject: rejectV1} + build = newReleaseRoot + } + if err := app.Execute(ctx, os.Args[1:], authoringcli.Streams{In: os.Stdin, Out: os.Stdout, Err: os.Stderr}, build); err != nil { os.Exit(exitx.Code(err)) } return diff --git a/cli/plugin-kit-ai/cmd/plugin-kit-ai/release_compat.go b/cli/plugin-kit-ai/cmd/plugin-kit-ai/release_compat.go new file mode 100644 index 00000000..3b032f95 --- /dev/null +++ b/cli/plugin-kit-ai/cmd/plugin-kit-ai/release_compat.go @@ -0,0 +1,242 @@ +package main + +import ( + "errors" + "strings" + + "github.com/777genius/plugin-kit-ai/cli/internal/authoring/commands" + "github.com/777genius/plugin-kit-ai/cli/internal/authoringcli" + "github.com/spf13/cobra" +) + +// This finite inventory owns retirement only. ! denotes a boolean and / a +// shorthand. All successful parsing and services remain in the shared engine. +type v1Disposition struct { + path, flags string + retained bool +} + +var v1Dispositions = []v1Disposition{ + {"init", "template platform runtime typescript! runtime-package! runtime-package-version output/o force!/f extras! claude-extended-hooks!", true}, + {"validate", "platform strict! format", true}, + {"inspect", "target format authoring!", true}, + {"compat", "target format from include-user-scope!", true}, + {"doctor", "", true}, + {"test", "platform event all! fixture golden-dir update-golden! format", true}, + {"capabilities", "platform format mode", true}, + {"skills", "", true}, + {"skills init", "output/o description template command force!/f", true}, + {"skills validate", "", true}, + {"skills generate", "target", false}, + {"skills install", "global!/g agent/a skill/s list!/l yes!/y copy! all! full-depth! skills-cli-version", false}, + {"skills add", "global!/g agent/a skill/s list!/l yes!/y copy! all! full-depth! skills-cli-version", false}, + {"skills list", "global!/g agent/a json! skills-cli-version", false}, + {"skills ls", "global!/g agent/a json! skills-cli-version", false}, + {"skills update", "global!/g project!/p yes!/y skills-cli-version", false}, + {"skills upgrade", "global!/g project!/p yes!/y skills-cli-version", false}, + {"skills remove", "global!/g agent/a skill/s yes!/y all! skills-cli-version", false}, + {"skills rm", "global!/g agent/a skill/s yes!/y all! skills-cli-version", false}, + {"bootstrap", "", false}, + {"dev", "platform event all! fixture golden-dir once! interval", false}, + {"generate", "target check!", false}, + {"normalize", "force!/f", false}, + {"import", "source from force!/f include-user-scope!", false}, + {"export", "platform output", false}, + {"bundle", "", false}, + {"bundle fetch", "url dest sha256 asset-name platform runtime tag latest! github-token github-api-base force!/f", false}, + {"bundle install", "dest force!/f", false}, + {"bundle publish", "platform repo tag draft! github-token github-api-base force!/f", false}, + {"publish", "channel all! dest package-root dry-run! format", false}, + {"publication", "target format", false}, + {"publication doctor", "target format dest package-root", false}, + {"publication materialize", "target dest package-root dry-run!", false}, + {"publication remove", "target dest package-root dry-run!", false}, + {"install", "tag latest! dir force!/f pre! output-name github-token goos goarch github-api-base", false}, + {"integrations", "", false}, + {"integrations add", "target scope auto-update! adopt-new-targets pre! dry-run!", false}, + {"add", "target scope auto-update! adopt-new-targets pre! dry-run!", false}, + {"integrations update", "dry-run! all!", false}, + {"update", "dry-run! all!", false}, + {"integrations remove", "dry-run!", false}, + {"remove", "dry-run!", false}, + {"integrations repair", "dry-run! target", false}, + {"repair", "dry-run! target", false}, + {"integrations list", "", false}, + {"integrations doctor", "", false}, + {"integrations sync", "dry-run!", false}, + {"integrations enable", "dry-run! target", false}, + {"integrations disable", "dry-run! target", false}, + {"version", "", true}, + {"__docs", "", false}, + {"__docs export-cli", "out-dir manifest-path", false}, + {"__docs export-support", "events-path targets-path capabilities-path", false}, +} + +func flagParts(spec string) (name, short string, boolean bool) { + name, short, _ = strings.Cut(spec, "/") + boolean = strings.HasSuffix(name, "!") + name = strings.TrimSuffix(name, "!") + return +} +func newReleaseRoot(factories ...authoringcli.Factory) (*cobra.Command, error) { + root, err := authoringcli.NewReleasePluginKitRoot(factories...) + if err != nil { + return nil, err + } + for _, row := range v1Dispositions { + c := root + for _, part := range strings.Fields(row.path) { + var next *cobra.Command + for _, child := range c.Commands() { + if child.Name() == part { + next = child + break + } + } + if next == nil { + next = &cobra.Command{Use: part, Hidden: true, Annotations: map[string]string{authoringcli.RejectionKey: row.path}, + RunE: func(*cobra.Command, []string) error { return errors.New("v1 operation unavailable") }} + c.AddCommand(next) + } + c = next + } + for _, spec := range strings.Fields(row.flags) { + name, short, boolean := flagParts(spec) + if c.Flags().Lookup(name) != nil || root.PersistentFlags().Lookup(name) != nil { + continue + } + if boolean { + c.Flags().BoolP(name, short, false, "") + } else { + c.Flags().StringP(name, short, "", "") + } + _ = c.Flags().MarkHidden(name) + } + } + return root, nil +} + +func legacyGuidance(verb string) string { + return "This v1 operation is unavailable in v2. Use plugin-kit-ai 1.2.4 with `plugin-kit-ai " + verb + "` for the legacy workflow. Project migration is unavailable in v2." +} + +const absentDestination = "Use positional destination: plugin-kit-ai init --name --template --description ." + +func rejectV1(in commands.Invocation) error { + verb := strings.TrimPrefix(in.Command.CommandPath(), "plugin-kit-ai ") + has := func(name string) bool { return len(in.Values[name]) > 0 } + last := func(name string) string { + v := in.Values[name] + if len(v) > 0 { + return v[len(v)-1] + } + return "" + } + reject := func(v string, extra string) error { return errors.New(legacyGuidance(v) + " " + extra) } + if in.Command.Annotations[authoringcli.RejectionKey] != "" { + extra := "" + switch verb { + case "install": + extra = "The retired operation is a third-party binary downloader." + case "integrations": + extra = "Use agentplugins --help for manager commands." + case "integrations list", "integrations doctor": + extra = "Manager state/health: agentplugins " + strings.TrimPrefix(verb, "integrations ") + "; report schemas and legacy-state policy differ." + case "add", "integrations add", "update", "integrations update", "remove", "integrations remove", "repair", "integrations repair": + supported := !has("auto-update") && !has("adopt-new-targets") && !has("pre") && (!has("scope") || last("scope") == "user") + for _, value := range in.Values["target"] { + for _, target := range strings.Split(value, ",") { + switch target { + case "claude", "codex", "gemini", "opencode", "cursor": + default: + supported = false + } + } + } + if supported { + job := strings.TrimPrefix(verb, "integrations ") + switch job { + case "add": + extra = "For a supported standard/local/exact source, use agentplugins add --target --scope user." + case "update": + if last("all") == "true" { + extra = "Use agentplugins update --all." + } else { + extra = "With an explicit name, use agentplugins update ; omitted name does not imply --all." + } + case "remove": + extra = "Use agentplugins remove ." + case "repair": + extra = "For a supported selected manager binding, use agentplugins repair --target ." + } + if last("dry-run") == "true" || !has("dry-run") && strings.HasPrefix(verb, "integrations ") { + extra += " Preserve plan intent by adding --dry-run." + } + } + } + if strings.HasPrefix(verb, "__docs") { + return errors.New("Internal v1 documentation tooling is unavailable in this private release tree.") + } + return reject(verb, extra) + } + for _, row := range v1Dispositions { + if row.path != verb || !row.retained { + continue + } + for _, spec := range strings.Fields(row.flags) { + name, _, _ := flagParts(spec) + if !has(name) { + continue + } + switch name { + case "format": + if last(name) == "text" || last(name) == "table" { + return errors.New("Use --format human instead; --format json is retained.") + } + case "description": // skills init retains exactly this meaning. + case "template": + if verb == "init" { + switch last(name) { + case "skill", "mcp-remote", "mcp-stdio", "hybrid": + continue + } + } + return reject(verb, "") + case "runtime": + if last(name) == "node" && (last("template") == "mcp-stdio" || last("template") == "hybrid" && last("mcp-template") == "mcp-stdio") { + continue + } + return reject("init --runtime", "") + case "target": + for _, value := range in.Values[name] { + for _, target := range strings.Split(value, ",") { + switch target { + case "all": + return errors.New("Select explicit comma-separated clients, for example --target claude,codex.") + case "codex-package", "codex-runtime", "cursor-workspace": + return reject(verb, "Standard static compatibility can separately use --target codex or --target cursor; legacy runtime/workspace semantics are unavailable.") + } + } + } + case "output": + if verb == "init" { + return errors.New(absentDestination) + } + return errors.New("Use positional package root: plugin-kit-ai skills init [package-path] --description .") + case "force": + if verb == "init" { + return reject("init --force", "init requires an absent destination; overwriting is unavailable. "+absentDestination) + } + return reject(verb, "Use plugin-kit-ai skills init [package-path] --description only for a new standard Skill.") + case "authoring": + return errors.New("Standard inspection is inherent; omit --authoring.") + default: + if verb == "validate" { + return reject(verb+" --"+name, "") + } + return reject(verb, "") + } + } + } + return nil +} diff --git a/cli/plugin-kit-ai/cmd/plugin-kit-ai/release_compat_test.go b/cli/plugin-kit-ai/cmd/plugin-kit-ai/release_compat_test.go new file mode 100644 index 00000000..bfbca05d --- /dev/null +++ b/cli/plugin-kit-ai/cmd/plugin-kit-ai/release_compat_test.go @@ -0,0 +1,320 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "strings" + "testing" + + "github.com/777genius/plugin-kit-ai/cli/internal/authoring/commands" + "github.com/777genius/plugin-kit-ai/cli/internal/authoring/report" + "github.com/777genius/plugin-kit-ai/cli/internal/authoringcli" + "github.com/777genius/plugin-kit-ai/cli/internal/exitx" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +func releaseProbe(t *testing.T, args []string, want int) (report.Public, string) { + t.Helper() + a := commands.App{Revision: "fixture-revision", PublicContract: true, Release: &commands.ReleaseOptions{Product: "plugin-kit-ai", Version: "2.0.0", Reject: rejectV1}} + var out, stderr bytes.Buffer + calls := 0 + err := a.Execute(context.Background(), args, authoringcli.Streams{Out: &out, Err: &stderr}, func(fs ...authoringcli.Factory) (*cobra.Command, error) { + root, err := newReleaseRoot(fs...) + if err != nil { + return nil, err + } + var trap func(*cobra.Command) + trap = func(c *cobra.Command) { + if c.RunE != nil && c.Name() != "version" { + c.RunE = func(*cobra.Command, []string) error { calls++; return errors.New("service trap") } + } + for _, child := range c.Commands() { + trap(child) + } + } + trap(root) + return root, nil + }) + code := 0 + if err != nil { + code = exitx.Code(err) + } + if code != want || calls != 0 || stderr.Len() != 0 { + t.Fatalf("status=%d want=%d services=%d stderr=%d output=%s", code, want, calls, stderr.Len(), out.String()) + } + if strings.Contains(out.String(), "ghp_") || strings.Contains(out.String(), "credential-fixture") { + t.Fatal("unsafe argv projection") + } + var env struct { + Schema int `json:"schema_version"` + Command, Result string + Data report.Public + } + if strings.HasPrefix(out.String(), "{") { + d := json.NewDecoder(&out) + if err := d.Decode(&env); err != nil { + t.Fatal(err) + } + var extra any + if d.Decode(&extra) != io.EOF { + t.Fatal("multiple documents") + } + result := "success" + if want != 0 { + result = "failure" + } + if env.Schema != 1 || env.Result != result || env.Data.Effects.Attempted || env.Data.Effects.Committed || len(env.Data.Paths) != 0 { + t.Fatalf("effect/envelope: %+v", env) + } + if env.Data.Requested.Operation != env.Command || env.Data.Conformance.Status != "not_evaluated" { + t.Fatalf("operation/policy: %+v", env) + } + return env.Data, "" + } + return env.Data, out.String() +} + +// Compare EVERY real registration, including aliases and hidden auxiliary init +// registrations, to the bounded disposition table. A new v1 flag fails here. +func TestReleaseV1InventoryCoverage(t *testing.T) { + expected := map[string]v1Disposition{} + for _, row := range v1Dispositions { + expected[row.path] = row + } + seen := map[string]bool{} + var walk func(*cobra.Command, string) + walk = func(c *cobra.Command, prefix string) { + if c != rootCmd { + path := strings.TrimSpace(prefix + " " + c.Name()) + names := append([]string{c.Name()}, c.Aliases...) + for _, name := range names { + key := strings.TrimSpace(prefix + " " + name) + row, ok := expected[key] + if !ok { + t.Errorf("unclassified command/alias %s", key) + continue + } + seen[key] = true + flags := map[string]string{} + for _, spec := range strings.Fields(row.flags) { + n, s, b := flagParts(spec) + flags[n] = s + if b { + flags[n] += "!" + } + } + c.LocalNonPersistentFlags().VisitAll(func(f *pflag.Flag) { + if f.Name == "help" { + return + } + got := f.Shorthand + if f.NoOptDefVal != "" { + got += "!" + } + want, ok := flags[f.Name] + if !ok || got != want { + t.Errorf("unclassified/changed %s --%s shorthand/arity=%q want=%q", key, f.Name, got, want) + } + delete(flags, f.Name) + }) + if len(flags) > 0 { + t.Errorf("stale flags %s: %v", key, flags) + } + } + prefix = path + } + for _, child := range c.Commands() { + if child.Name() == "help" || child.Name() == "completion" || strings.HasPrefix(child.Name(), "__complete") { + continue + } + walk(child, prefix) + } + } + walk(rootCmd, "") + for name := range expected { + if !seen[name] { + t.Errorf("stale disposition %s", name) + } + } +} + +func TestReleaseEveryRemovedVerbAliasFlag(t *testing.T) { + for _, row := range v1Dispositions { + if row.retained { + continue + } + t.Run(row.path, func(t *testing.T) { + base := strings.Fields(row.path) + variants := [][]string{nil, {"--help"}, {"-h"}, {"arbitrary", "--unknown=credential-fixture"}, {"--unknown=credential-fixture", "--help"}, {"--", "--format=human", "credential-fixture"}} + for _, spec := range strings.Fields(row.flags) { + name, short, boolean := flagParts(spec) + value := "credential-fixture" + if boolean { + value = "false" + } + variants = append(variants, []string{"--" + name + "=" + value}, []string{"--" + name + "=" + value, "--help"}) + if !boolean { + variants = append(variants, []string{"--" + name, value}) + } else { + variants = append(variants, []string{"--" + name}) + } + if short != "" { + variants = append(variants, []string{"-" + short + "=" + value}, []string{"-h" + short + "=" + value}) + } + } + for _, tail := range variants { + args := append([]string{"--format=json"}, base...) + args = append(args, tail...) + p, human := releaseProbe(t, args, 2) + if !strings.HasPrefix(row.path, "__docs") && !strings.Contains(human, legacyGuidance(row.path)) && (p.Error == nil || !strings.Contains(p.Error.Action, legacyGuidance(row.path))) { + t.Fatalf("missing exact guidance for %s: %+v", row.path, p.Error) + } + } + }) + } +} +func TestReleaseSupportedNameLegacyFlags(t *testing.T) { + retained := map[string]string{"init": "template runtime", "validate": "format", "inspect": "target format", "compat": "target format", "test": "format", "capabilities": "format", "skills init": "description"} + for _, row := range v1Dispositions { + if !row.retained { + continue + } + for _, spec := range strings.Fields(row.flags) { + name, short, boolean := flagParts(spec) + if strings.Contains(" "+retained[row.path]+" ", " "+name+" ") { + continue + } + value := "credential-fixture" + if boolean { + value = "false" + } + variants := [][]string{{"--" + name + "=" + value}, {"--unknown=credential-fixture", "--" + name + "=" + value, "--help"}} + if short != "" { + variants = append(variants, []string{"-h" + short + "=" + value}) + } + for _, tail := range variants { + args := append(strings.Fields(row.path), "--format=json") + releaseProbe(t, append(args, tail...), 2) + } + } + } + for _, args := range [][]string{ + {"init", "--template=online-service"}, {"init", "--template=local-tool"}, {"init", "--template=custom-logic"}, + {"init", "--runtime=go"}, {"init", "--runtime=python"}, {"init", "--runtime=shell"}, {"init", "--runtime=node", "--template=skill"}, + {"inspect", "--target=all"}, {"compat", "--target=codex-package"}, {"inspect", "--target=codex-runtime"}, {"compat", "--target=cursor-workspace"}, + {"validate", "--format=text"}, {"capabilities", "--format=table"}, + } { + releaseProbe(t, append(args, "--help"), 2) + } +} +func TestReleaseJSONSelectionAndClosure(t *testing.T) { + for _, args := range [][]string{ + {"skills", "list", "--json"}, {"skills", "ls", "--json=true"}, + {"init", "--force=false", "--format=human", "--format", "json"}, + {"init", "--unknown=credential-fixture", "-hf=false", "--format=json"}, + {"init", "-zhf=false", "--format=json"}, + {"init", "--format=json", "--output"}, + {"skills", "install", "-gly", "--format=json"}, + {"bundle", "fetch", "--github-token", "--format=human", "--format=json"}, + } { + p, _ := releaseProbe(t, args, 2) + if p.Error == nil { + t.Fatal("JSON not selected") + } + } + for _, args := range [][]string{ + {"skills", "list", "--json=false"}, {"skills", "list", "--", "--json"}, + {"init", "--force", "--format=json", "--format=human"}, + {"bundle", "fetch", "--github-token", "--format=json"}, + } { + _, human := releaseProbe(t, args, 2) + if human == "" { + t.Fatal("flag value/delimiter became JSON") + } + } + for _, args := range [][]string{nil, {"--help"}, {"help", "skills", "init"}, {"version"}, {"skills"}} { + releaseProbe(t, append(args, "--format=json"), 0) + } + for _, args := range [][]string{{"credential-fixture"}, {"init", "--unknown=credential-fixture"}, {"skills", "init", "--description"}, {"--", "init"}} { + releaseProbe(t, append(args, "--format=json"), 2) + } +} + +func TestReleaseCompletionUsesVisibleTrees(t *testing.T) { + for _, shell := range []string{"bash", "zsh", "fish", "powershell"} { + _, out := releaseProbe(t, []string{"completion", shell}, 0) + for _, forbidden := range []string{"bootstrap", "integrations", "publication", "__docs", "skills-cli-version", "runtime-package"} { + if strings.Contains(out, forbidden) { + t.Fatalf("%s leaked %s", shell, forbidden) + } + } + if !strings.Contains(out, "plugin-kit-ai") { + t.Fatal("missing executable") + } + } + p, _ := releaseProbe(t, []string{"--help", "--format=json"}, 0) + if len(p.Surface) != 10 || p.Help.Use != "plugin-kit-ai " { + t.Fatal(p) + } + for _, args := range [][]string{{"help", "bundle"}, {"help", "skills", "add"}, {"bundle", "inspect", "--help"}} { + releaseProbe(t, append(args, "--format=json"), 2) + } +} + +func TestReleaseActualLegacyFlagDefaults(t *testing.T) { + for _, row := range v1Dispositions { + c, _, err := rootCmd.Find(strings.Fields(row.path)) + if err != nil { + t.Fatal(err) + } + c.LocalNonPersistentFlags().VisitAll(func(f *pflag.Flag) { + if f.Name == "help" { + return + } + if row.retained { + // These have a retained standard meaning; their values are validated by + // the shared factory. The other legacy defaults must still reject. + switch f.Name { + case "format", "target", "description": + return + } + } + args := append(strings.Fields(row.path), "--"+f.Name+"="+f.DefValue, "--help", "--format=json") + releaseProbe(t, args, 2) + }) + } +} +func TestReleaseManagerGuidancePreservesIntent(t *testing.T) { + for _, tc := range []struct { + args []string + want, absent string + }{ + {[]string{"integrations", "add"}, "adding --dry-run", ""}, + {[]string{"add"}, "agentplugins add ", "adding --dry-run"}, + {[]string{"integrations", "add", "--dry-run=false"}, "agentplugins add ", "adding --dry-run"}, + {[]string{"add", "--auto-update=false"}, legacyGuidance("add"), "agentplugins add "}, + {[]string{"add", "--scope=project"}, legacyGuidance("add"), "agentplugins add "}, + {[]string{"repair", "--target=codex-runtime"}, legacyGuidance("repair"), "agentplugins repair"}, + {[]string{"update"}, "omitted name does not imply --all", "Use agentplugins update --all"}, + {[]string{"update", "--all=true", "--dry-run=true"}, "Use agentplugins update --all. Preserve plan intent by adding --dry-run.", ""}, + } { + p, _ := releaseProbe(t, append(tc.args, "--format=json"), 2) + if p.Error == nil || !strings.Contains(p.Error.Action, tc.want) || tc.absent != "" && strings.Contains(p.Error.Action, tc.absent) { + t.Fatal(tc.args, p.Error) + } + } +} + +func TestReleaseCompletionHelpAncestry(t *testing.T) { + p, _ := releaseProbe(t, []string{"completion", "bash", "--help", "--format=json"}, 0) + if p.Help == nil || p.Help.Use != "plugin-kit-ai completion bash" { + t.Fatal(p.Help) + } + for _, shell := range []string{"bash", "zsh", "fish", "powershell"} { + releaseProbe(t, []string{"completion", shell, "--no-descriptions"}, 0) + } +} diff --git a/cli/plugin-kit-ai/internal/authoring/commands/commands.go b/cli/plugin-kit-ai/internal/authoring/commands/commands.go index 7d4a69a4..edbc4163 100644 --- a/cli/plugin-kit-ai/internal/authoring/commands/commands.go +++ b/cli/plugin-kit-ai/internal/authoring/commands/commands.go @@ -29,7 +29,10 @@ import ( var Enabled string var Revision = "unversioned" -func IsEnabled() bool { return Enabled == "vertical-slice-v1" } +const ReleaseMode = "release-cli-contract-v1" + +func IsEnabled() bool { return Enabled == "vertical-slice-v1" || IsRelease() } +func IsRelease() bool { return Enabled == ReleaseMode } type RootBuilder func(...authoringcli.Factory) (*cobra.Command, error) type App struct { @@ -37,6 +40,7 @@ type App struct { Revision string // PublicContract opts into the private Phase 6 contract; mains retain their existing mode. PublicContract bool + Release *ReleaseOptions } func commandNames() []string { diff --git a/cli/plugin-kit-ai/internal/authoring/commands/public_contract.go b/cli/plugin-kit-ai/internal/authoring/commands/public_contract.go index 10122b88..cae817b4 100644 --- a/cli/plugin-kit-ai/internal/authoring/commands/public_contract.go +++ b/cli/plugin-kit-ai/internal/authoring/commands/public_contract.go @@ -1,6 +1,7 @@ package commands import ( + "bytes" "context" "errors" "fmt" @@ -56,6 +57,7 @@ type selection struct { operation, mode, format string help, invalid bool flags []string + values map[string][]string } // selectPublic observes definitions on the fresh tree without executing a @@ -64,7 +66,7 @@ type selection struct { // command; its unknown arity can never promote a credential value into a verb. // Format selection continues after errors, with pflag's final-value semantics. func selectPublic(root *cobra.Command, args []string) selection { - s := selection{command: root, operation: "author", mode: "read", format: "human"} + s := selection{command: root, operation: "author", mode: "read", format: "human", values: map[string][]string{}} positional, helpCommand := false, false lookup := func(name string, short bool) *pflag.Flag { for c := s.command; c != nil; c = c.Parent() { @@ -83,6 +85,7 @@ func selectPublic(root *cobra.Command, args []string) selection { return nil } flag := func(f *pflag.Flag, value string) { + s.values[f.Name] = append(s.values[f.Name], value) if f.Name == "format" { s.format = value } @@ -122,6 +125,7 @@ func selectPublic(root *cobra.Command, args []string) selection { i++ value = args[i] } else { + s.values[f.Name] = append(s.values[f.Name], "") s.invalid = true continue } @@ -138,7 +142,7 @@ func selectPublic(root *cobra.Command, args []string) selection { if f == nil { s.invalid = true positional = true - break + continue } value := f.NoOptDefVal if strings.HasPrefix(cluster, "=") { @@ -170,7 +174,7 @@ func selectPublic(root *cobra.Command, args []string) selection { } var next *cobra.Command for _, c := range s.command.Commands() { - if c.Name() == token && (!c.Hidden || c.Name() == "author") { + if c.Name() == token && (!c.Hidden || c.Name() == "author" || c.Annotations[authoringcli.RejectionKey] != "") { next = c break } @@ -183,11 +187,14 @@ func selectPublic(root *cobra.Command, args []string) selection { continue } s.command = next + if id := next.Annotations[operationKey]; id != "" { + s.operation = id + } } s.help = s.help || helpCommand if id := s.command.Annotations[operationKey]; id != "" { s.operation = id - } else { + } else if s.command.Annotations[authoringcli.RejectionKey] == "" && !isCompletion(s.command) { s.invalid = true } if s.operation == "author.init" || s.operation == "author.skills.init" { @@ -218,6 +225,8 @@ func publicInputError(err error) error { func (a App) executePublic(ctx context.Context, args []string, streams authoringcli.Streams, build RootBuilder) error { var captured *report.Report + var utility bytes.Buffer + utilitySelected := false var selected = selection{operation: "author", mode: "read", format: "human"} var surface []string factory := authoringcli.Factory(func() (*cobra.Command, error) { @@ -231,6 +240,9 @@ func (a App) executePublic(ctx context.Context, args []string, streams authoring return c, err }) } + if a.Release != nil { + factories = append(factories, func() (*cobra.Command, error) { return NewVersionCommand() }) + } root, err := build(factories...) if err != nil { return nil, err @@ -238,7 +250,11 @@ func (a App) executePublic(ctx context.Context, args []string, streams authoring if root == nil { return nil, errors.New("missing authoring root") } - root.CompletionOptions.DisableDefaultCmd = true + root.CompletionOptions.DisableDefaultCmd = a.Release == nil + if a.Release != nil { + root.SetOut(&utility) + authoringcli.PrepareReleaseUtilities(root, false) + } if root.Name() == "plugin-kit-ai" { root.Annotations = map[string]string{operationKey: "author"} } @@ -262,6 +278,24 @@ func (a App) executePublic(ctx context.Context, args []string, streams authoring prepare(root) surface = implementedLeaves(root) selected = selectPublic(root, args) + if a.Release != nil { + if a.Release.Reject != nil { + if e := a.Release.Reject(Invocation{Command: selected.command, Values: selected.values}); e != nil { + if v := selected.values["json"]; len(v) > 0 && v[len(v)-1] == "true" && len(selected.values["format"]) == 0 { + selected.format = "json" + } + return nil, &inputError{"v1_operation_unavailable", e.Error()} + } + } + utilitySelected = isCompletion(selected.command) + if len(args) > 0 && (args[0] == "__complete" || args[0] == "__completeNoDesc") { + utilitySelected = true + selected.invalid = false + } + if utilitySelected { + authoringcli.PrepareReleaseUtilities(root, true) + } + } if selected.invalid { return nil, &inputError{"arguments_invalid", publicArguments} } @@ -282,10 +316,16 @@ func (a App) executePublic(ctx context.Context, args []string, streams authoring return root, nil }) - err := factory.Execute(ctx, args, authoringcli.Streams{In: streams.In, Out: io.Discard, Err: io.Discard}) + err := factory.Execute(ctx, args, authoringcli.Streams{In: streams.In, Out: &utility, Err: io.Discard}) if errors.Is(err, errPublicHelp) { err = nil } + if utilitySelected && err == nil && !selected.help && selected.format == "human" { + if _, e := streams.Out.Write(utility.Bytes()); e != nil { + return exitx.Wrap(errors.New("authoring output failed"), 1) + } + return nil + } attempted := captured != nil code := 0 if err != nil { @@ -320,7 +360,8 @@ func (a App) executePublic(ctx context.Context, args []string, streams authoring commands = surface } p := captured.PublicResult(selected.operation, selected.mode, attempted, commands) - if !attempted && err == nil { + versionResult := a.Release != nil && selected.operation == "author.version" && !selected.help && err == nil + if !attempted && err == nil && !versionResult && (!utilitySelected || selected.help) { p.Help = publicHelp(selected) } result := outputjson.Success @@ -329,9 +370,23 @@ func (a App) executePublic(ctx context.Context, args []string, streams authoring } var outputErr error if selected.format == "json" { - outputErr = outputjson.Write(streams.Out, selected.operation, result, p) + var payload any = p + if utilitySelected && err == nil && !selected.help { + payload = struct { + report.Public + Script string `json:"script"` + }{p, utility.String()} + } + if versionResult { + payload = versionPayload{Public: p, Product: a.Release.Product, ProductVersion: a.Release.Version} + } + outputErr = outputjson.Write(streams.Out, selected.operation, result, payload) } else { - outputErr = writePublicHuman(streams.Out, p, result) + if versionResult { + _, outputErr = fmt.Fprintf(streams.Out, "%s %s\nauthoring engine %s; revision %s\n", a.Release.Product, a.Release.Version, p.EngineVersion, p.Revision) + } else { + outputErr = writePublicHuman(streams.Out, p, result) + } } if outputErr != nil { return exitx.Wrap(errors.New("authoring output failed"), 1) @@ -389,7 +444,7 @@ func publicHelp(s selection) *report.CommandHelp { // argv and mutable flag values never contribute to usage text. use := s.command.CommandPath() + strings.TrimPrefix(s.command.Use, s.command.Name()) h := &report.CommandHelp{Use: use, Flags: []string{}, Guidance: publicArguments} - if s.operation == "author" { + if s.operation == "author" && (s.command.Name() == "author" || s.command.Name() == "plugin-kit-ai") { h.Use += " " } seen := map[string]bool{} diff --git a/cli/plugin-kit-ai/internal/authoring/commands/release_parity_test.go b/cli/plugin-kit-ai/internal/authoring/commands/release_parity_test.go new file mode 100644 index 00000000..489ca4ab --- /dev/null +++ b/cli/plugin-kit-ai/internal/authoring/commands/release_parity_test.go @@ -0,0 +1,127 @@ +package commands_test + +import ( + "bytes" + "context" + "encoding/json" + "io" + "strings" + "testing" + + "github.com/777genius/plugin-kit-ai/cli/internal/agentpluginscli" + "github.com/777genius/plugin-kit-ai/cli/internal/authoring/commands" + "github.com/777genius/plugin-kit-ai/cli/internal/authoringcli" + "github.com/777genius/plugin-kit-ai/cli/internal/exitx" + "github.com/spf13/cobra" +) + +func releaseExecute(t *testing.T, args []string, mount bool) ([]byte, int) { + t.Helper() + a := publicApp(t) + product, version := "plugin-kit-ai", "2.0.0" + build := commands.RootBuilder(authoringcli.NewReleasePluginKitRoot) + if mount { + product, version = "agentplugins", "0.1.91" + args = append([]string{"author"}, args...) + build = func(fs ...authoringcli.Factory) (*cobra.Command, error) { + root := agentpluginscli.NewRoot(agentpluginscli.App{Version: version}) + c, err := authoringcli.NewReleaseAuthorCommand(fs...) + if err != nil { + return nil, err + } + root.AddCommand(c) + return root, nil + } + } + a.Release = &commands.ReleaseOptions{Product: product, Version: version} + var out, errout bytes.Buffer + err := a.Execute(context.Background(), args, authoringcli.Streams{Out: &out, Err: &errout}, build) + if errout.Len() > 0 { + t.Fatal("unexpected stderr") + } + code := 0 + if err != nil { + code = exitx.Code(err) + } + return out.Bytes(), code +} +func TestReleaseVersionParityAndFreshHelp(t *testing.T) { + for _, args := range [][]string{{"version"}, {"--help"}, {"help", "skills", "init"}, {"skills"}, {"version", "--help"}, {"capabilities"}} { + var pair [2]map[string]any + for i := range pair { + raw, code := releaseExecute(t, append(args, "--format=json"), i == 1) + if code != 0 { + t.Fatal(code, string(raw)) + } + d := json.NewDecoder(bytes.NewReader(raw)) + if err := d.Decode(&pair[i]); err != nil { + t.Fatal(err) + } + var extra any + if d.Decode(&extra) != io.EOF { + t.Fatal("multiple documents") + } + data := pair[i]["data"].(map[string]any) + if data["engine_version"] != "standard-first-slice/1" || data["revision"] != publicRevision { + t.Fatal(data) + } + if product, ok := data["product"]; ok { + wantProduct, wantVersion := "plugin-kit-ai", "2.0.0" + if i == 1 { + wantProduct, wantVersion = "agentplugins", "0.1.91" + } + if product != wantProduct || data["product_version"] != wantVersion || pair[i]["command"] != "author.version" { + t.Fatal(data) + } + delete(data, "product") + delete(data, "product_version") + } + if help, ok := data["help"].(map[string]any); ok { + prefix := "plugin-kit-ai" + if i == 1 { + prefix = "agentplugins author" + } + if !strings.HasPrefix(help["use"].(string), prefix) { + t.Fatal(help) + } + help["use"] = strings.Replace(help["use"].(string), prefix, "", 1) + } + } + a, _ := json.Marshal(pair[0]) + b, _ := json.Marshal(pair[1]) + if !bytes.Equal(a, b) { + t.Fatalf("parity:\n%s\n%s", a, b) + } + } +} +func TestReleaseParserAndProtocolClosure(t *testing.T) { + for _, args := range [][]string{ + {"version", "--target=cursor"}, {"version", "--unknown=credential-fixture"}, {"version", "extra"}, + {"init", "--name=inspect", "--template=skill", "--unknown=credential-fixture"}, + {"skills", "init", "--description=version"}, {"--format=json", "--", "version"}, + {"completion", "bash", "--unknown=credential-fixture"}, + } { + for _, mount := range []bool{false, true} { + raw, code := releaseExecute(t, append(args, "--format=json"), mount) + if code != 2 || strings.Contains(string(raw), "credential-fixture") { + t.Fatal(code, string(raw)) + } + } + } + for _, args := range [][]string{{"__complete", ""}, {"__completeNoDesc", "skills", ""}} { + raw, code := releaseExecute(t, args, false) + if code != 0 || !strings.Contains(string(raw), ":") { + t.Fatal(code, string(raw)) + } + } +} +func TestReleaseBuildModeClosed(t *testing.T) { + old := commands.Enabled + defer func() { commands.Enabled = old }() + for _, value := range []string{"", "vertical-slice-v1", commands.ReleaseMode, "true", "enabled"} { + commands.Enabled = value + if commands.IsRelease() != (value == commands.ReleaseMode) || commands.IsEnabled() != (value == commands.ReleaseMode || value == "vertical-slice-v1") { + t.Fatal(value) + } + } +} diff --git a/cli/plugin-kit-ai/internal/authoring/commands/version.go b/cli/plugin-kit-ai/internal/authoring/commands/version.go new file mode 100644 index 00000000..183ade3c --- /dev/null +++ b/cli/plugin-kit-ai/internal/authoring/commands/version.go @@ -0,0 +1,96 @@ +package commands + +import ( + "github.com/777genius/plugin-kit-ai/cli/internal/authoring/report" + "github.com/777genius/plugin-kit-ai/cli/internal/authoringcli" + "github.com/spf13/cobra" + "io" +) + +// ReleaseOptions is explicit private composition, never environment discovery. +// Reject receives observations from the shared parser and may only reject. +type ReleaseOptions struct { + Product, Version string + Reject func(Invocation) error +} +type Invocation struct { + Command *cobra.Command + Values map[string][]string +} +type versionPayload struct { + report.Public + Product string `json:"product"` + ProductVersion string `json:"product_version"` +} + +func NewVersionCommand() (*cobra.Command, error) { + c := &cobra.Command{Use: "version", Short: "Print product version and shared authoring engine revision", Args: cobra.NoArgs, + RunE: func(c *cobra.Command, _ []string) error { + _, err := authoringcli.AdaptFlags(c, authoringcli.Support{Format: true, NoColor: true}) + return err + }} + tagOperations(c, "author.version") + return c, nil +} + +func isCompletion(c *cobra.Command) bool { + for ; c != nil; c = c.Parent() { + switch c.Name() { + case "completion", "__complete", "__completeNoDesc": + return true + } + } + return false +} + +// ReleaseSelection builds the same complete tree used by Execute, with inert +// captures. Installer construction is dependency-free until a genuine job wins. +func (a App) ReleaseSelection(args []string, build RootBuilder) (root *cobra.Command, author, noEffect bool, err error) { + root, err = a.releaseTree(build) + if err != nil { + return + } + s := selectPublic(root, args) + author = s.command.Annotations[operationKey] != "" + noEffect = len(args) > 0 && (args[0] == "__complete" || args[0] == "__completeNoDesc") || author || s.help || s.command == root || s.command.Name() == "version" || isCompletion(s.command) + return +} + +func (a App) releaseTree(build RootBuilder) (*cobra.Command, error) { + var factories []authoringcli.Factory + for _, name := range commandNames() { + factories = append(factories, func() (*cobra.Command, error) { + c, e := a.command(name, func(report.Report) {}) + if e == nil { + tagOperations(c, "author."+name) + } + return c, e + }) + } + factories = append(factories, NewVersionCommand) + root, e := build(factories...) + if e != nil { + return nil, e + } + var prepare func(*cobra.Command) + prepare = func(c *cobra.Command) { + c.InitDefaultHelpFlag() + if c.Name() == "author" { + c.Annotations = map[string]string{operationKey: "author"} + } + for _, child := range c.Commands() { + prepare(child) + } + } + root.SetOut(io.Discard) + authoringcli.PrepareReleaseUtilities(root, false) + prepare(root) + return root, nil +} + +// OutputFormat observes only the real tree's flag arities, including error paths. +func OutputFormat(root *cobra.Command, args []string) string { return selectPublic(root, args).format } + +func CompletionInvocation(root *cobra.Command, args []string) bool { + return isCompletion(selectPublic(root, args).command) +} diff --git a/cli/plugin-kit-ai/internal/authoringcli/release.go b/cli/plugin-kit-ai/internal/authoringcli/release.go new file mode 100644 index 00000000..73a7b9b6 --- /dev/null +++ b/cli/plugin-kit-ai/internal/authoringcli/release.go @@ -0,0 +1,37 @@ +package authoringcli + +import "github.com/spf13/cobra" + +// RejectionKey identifies private, terminal compatibility errors, never jobs. +const RejectionKey = "authoringcli.v1-rejection" + +func NewReleaseAuthorCommand(factories ...Factory) (*cobra.Command, error) { + c, err := NewAuthorCommand(factories...) + if err == nil { + c.Hidden = false + } + return c, err +} + +func NewReleasePluginKitRoot(factories ...Factory) (*cobra.Command, error) { + return NewPluginKitRoot(factories...) +} + +// PrepareReleaseUtilities uses Cobra's real tree. Hidden retirement shims are +// removed before generating scripts, including generators that walk all nodes. +func PrepareReleaseUtilities(root *cobra.Command, completion bool) { + if completion { + var prune func(*cobra.Command) + prune = func(c *cobra.Command) { + for _, child := range c.Commands() { + if child.Hidden { + c.RemoveCommand(child) + } else { + prune(child) + } + } + } + prune(root) + } + root.InitDefaultCompletionCmd() +} diff --git a/npm/agentplugins/scripts/dual-authoring-candidate.js b/npm/agentplugins/scripts/dual-authoring-candidate.js index f6ca8299..2bc373dd 100644 --- a/npm/agentplugins/scripts/dual-authoring-candidate.js +++ b/npm/agentplugins/scripts/dual-authoring-candidate.js @@ -56,8 +56,13 @@ function executableName(product, target) { return product + (target.startsWith("windows-") ? ".exe" : ""); } -function linkerFlags(product, id) { - return `-X main.version=${id.versions[product]} -X ${COMMANDS}.Enabled=vertical-slice-v1 -X ${COMMANDS}.Revision=${id.commit}`; +function authoringMode(value = "vertical-slice-v1") { + if (!["vertical-slice-v1", "release-cli-contract-v1"].includes(value)) throw new Error("unknown private authoring mode"); + return value; +} + +function linkerFlags(product, id, mode) { + return `-X main.version=${id.versions[product]} -X ${COMMANDS}.Enabled=${authoringMode(mode)} -X ${COMMANDS}.Revision=${id.commit}`; } // Walk every component, including ancestors, before resolving paths. This is a @@ -177,7 +182,8 @@ function checkMetadata(value, body, label) { value.sha256 !== digest(body) || value.size !== body.length) throw new Error(`${label}: digest or size mismatch`); } -function manifestShape(manifest, expected, expectedScope) { +function manifestShape(manifest, expected, expectedScope, expectedMode) { + const mode = authoringMode(expectedMode); identity(expected); const targets = scopeTargets(expectedScope); keys(manifest, ["schema", "status", "identity", "asset_scope", "build", "products", "release_eligible"], "candidate"); @@ -187,7 +193,7 @@ function manifestShape(manifest, expected, expectedScope) { PRODUCTS.some((p) => manifest.identity.versions[p] !== expected.versions[p])) throw new Error("candidate identity/schema mismatch"); keys(manifest.build, ["method", "go_version", "go_sha256", "source_archive_sha256", "authoring_mode"], "build"); if (manifest.build.method !== "controlled-git-archive-go-build/v1" || manifest.build.go_version !== "go1.25.13" || - manifest.build.authoring_mode !== "vertical-slice-v1" || + manifest.build.authoring_mode !== mode || typeof manifest.build.go_sha256 !== "string" || typeof manifest.build.source_archive_sha256 !== "string" || !/^[0-9a-f]{64}$/.test(manifest.build.go_sha256) || !/^[0-9a-f]{64}$/.test(manifest.build.source_archive_sha256)) { throw new Error("candidate controlled build description is invalid"); @@ -212,7 +218,7 @@ function manifestShape(manifest, expected, expectedScope) { // This function validates STRUCTURE/BYTES ONLY. The public verifier additionally // checks embedded product/build settings with the trusted Go tool. Neither is a // platform gate, and neither establishes provenance from untrusted metadata. -function frozenCandidate(root, expected, manifestDigest, expectedScope) { +function frozenCandidate(root, expected, manifestDigest, expectedScope, expectedMode) { safeDirectory(root); if (typeof manifestDigest !== "string" || !/^[0-9a-f]{64}$/.test(manifestDigest)) throw new Error("independent manifest digest is required"); const body = readFile(path.join(root, "candidate.json"), 1024 * 1024); @@ -220,7 +226,7 @@ function frozenCandidate(root, expected, manifestDigest, expectedScope) { const manifest = JSON.parse(body); // Require canonical encoding: duplicate JSON keys and ambiguous encodings fail. if (!body.equals(encode(manifest))) throw new Error("noncanonical candidate JSON"); - manifestShape(manifest, expected, expectedScope); + manifestShape(manifest, expected, expectedScope, expectedMode); const expectedFiles = ["candidate.json"]; const binaries = []; const seen = new Set(); @@ -243,6 +249,6 @@ function frozenCandidate(root, expected, manifestDigest, expectedScope) { module.exports = { REPOSITORY, SCHEMA, PRODUCTS, TARGETS, COMMANDS, digest, encode, keys, identity, - assetName, executableName, linkerFlags, safeDirectory, outputPlacement, readFile, + assetName, executableName, authoringMode, linkerFlags, safeDirectory, outputPlacement, readFile, archive, unpack, metadata, scopeTargets, manifestShape, frozenCandidate }; diff --git a/npm/agentplugins/scripts/stage-dual-authoring-candidate.js b/npm/agentplugins/scripts/stage-dual-authoring-candidate.js index 1adef441..7b84f9fb 100644 --- a/npm/agentplugins/scripts/stage-dual-authoring-candidate.js +++ b/npm/agentplugins/scripts/stage-dual-authoring-candidate.js @@ -17,7 +17,8 @@ function run(command, args, options = {}) { } function optIn(options, fields) { - c.keys(options, ["candidate", ...fields], "options"); + c.keys(options, ["candidate", ...fields, ...("authoringMode" in options ? ["authoringMode"] : [])], "options"); + c.authoringMode(options.authoringMode); if (options.candidate !== true) throw new Error("explicit candidate opt-in required"); c.identity(options.identity); } @@ -56,7 +57,7 @@ function toolchain(go, context) { return hash; } -function buildInfo(info, product, target, id) { +function buildInfo(info, product, target, id, mode) { if (!info || info.GoVersion !== GO_VERSION || info.Path !== `github.com/777genius/plugin-kit-ai/cli/cmd/${product}` || !Array.isArray(info.Settings)) throw new Error("embedded Go product/toolchain identity mismatch"); const settings = new Map(); @@ -67,16 +68,16 @@ function buildInfo(info, product, target, id) { const [os, arch] = target.split("-"); const required = { GOOS: os, GOARCH: arch, CGO_ENABLED: "0", "-buildmode": "exe", "-compiler": "gc", - "-ldflags": c.linkerFlags(product, id) + "-ldflags": c.linkerFlags(product, id, mode) }; for (const [key, value] of Object.entries(required)) { if (settings.get(key) !== value) throw new Error(`embedded Go build setting mismatch: ${product}/${target}/${key}`); } } -function inspectBinary(go, file, product, target, id, env) { +function inspectBinary(go, file, product, target, id, env, mode) { // `go version` reads build info; it never starts the subject executable. - buildInfo(JSON.parse(run(go, ["version", "-m", "-json", file], { env })), product, target, id); + buildInfo(JSON.parse(run(go, ["version", "-m", "-json", file], { env })), product, target, id, mode); } function sourceSnapshot(repo, commit, context) { @@ -117,6 +118,7 @@ function writeExclusive(file, body, mode = 0o444) { function stageCandidate(options) { optIn(options, ["repo", "output", "workParent", "go", "modCache", "identity", "assetScope"]); const targets = c.scopeTargets(options.assetScope); + const mode = c.authoringMode(options.authoringMode); const id = structuredClone(options.identity); c.safeDirectory(options.modCache); c.safeDirectory(options.workParent); @@ -124,11 +126,19 @@ function stageCandidate(options) { const context = privateContext(options.workParent); const goHash = toolchain(options.go, context); const sourceHash = sourceSnapshot(options.repo, id.commit, context); + // Old revisions accept arbitrary -X strings even when their routing does not + // implement that mode. Refuse that source before compiling or labelling bytes. + if (mode === "release-cli-contract-v1") { + const selection = c.readFile(path.join(context.root, "source", "cli/plugin-kit-ai/internal/authoring/commands/commands.go")).toString(); + if (!/^const ReleaseMode = "release-cli-contract-v1"$/m.test(selection)) { + throw new Error("frozen source does not declare the requested release authoring mode"); + } + } const env = { ...context.env, GOMODCACHE: options.modCache, GOWORK: path.join(context.root, "source", "go.work") }; const manifest = { schema: c.SCHEMA, status: "CANDIDATE", identity: id, asset_scope: options.assetScope, build: { method: "controlled-git-archive-go-build/v1", go_version: GO_VERSION, go_sha256: goHash, - source_archive_sha256: sourceHash, authoring_mode: "vertical-slice-v1" }, + source_archive_sha256: sourceHash, authoring_mode: mode }, products: {}, release_eligible: false }; // Reserve an absent destination exclusively. Partial output has no candidate @@ -148,10 +158,10 @@ function stageCandidate(options) { // No trimpath: Go intentionally omits -ldflags from build info with // trimpath. Preserve the exact embedded version/engine linker settings // for byte inspection. Reproducibility is a later, separate gate. - const args = ["build", "-p", "2", "-buildvcs=false", "-mod=readonly", "-ldflags", c.linkerFlags(product, id), + const args = ["build", "-p", "2", "-buildvcs=false", "-mod=readonly", "-ldflags", c.linkerFlags(product, id, mode), "-o", binaryPath, `./cli/plugin-kit-ai/cmd/${product}`]; run(options.go, args, { cwd: path.join(context.root, "source"), env: { ...env, GOOS: os, GOARCH: arch } }); - inspectBinary(options.go, binaryPath, product, target, id, env); + inspectBinary(options.go, binaryPath, product, target, id, env, mode); const binary = c.readFile(binaryPath); const file = c.assetName(product, id.versions[product], target); const binaryName = c.executableName(product, target); @@ -164,7 +174,7 @@ function stageCandidate(options) { fs.writeFileSync(path.join(context.root, "build-log.json"), c.encode(log)); } } - c.manifestShape(manifest, id, options.assetScope); + c.manifestShape(manifest, id, options.assetScope, mode); const body = c.encode(manifest); const manifestHash = c.digest(body); // Validate the complete set in a separate private root before publishing the @@ -174,7 +184,7 @@ function stageCandidate(options) { for (const file of fs.readdirSync(options.output)) writeExclusive(path.join(checkRoot, file), c.readFile(path.join(options.output, file))); writeExclusive(path.join(checkRoot, "candidate.json"), body); verifyCandidate({ candidate: true, root: checkRoot, identity: id, manifestDigest: manifestHash, - go: options.go, workParent: options.workParent, assetScope: options.assetScope }); + go: options.go, workParent: options.workParent, assetScope: options.assetScope, authoringMode: mode }); // Recheck final output bytes immediately before committing the marker. for (const product of c.PRODUCTS) for (const asset of Object.values(manifest.products[product].assets)) { if (c.digest(c.readFile(path.join(options.output, asset.file))) !== asset.sha256) throw new Error("staged bytes changed"); @@ -225,14 +235,14 @@ function verifyCandidate(options) { if (options.workParent === options.root || options.workParent.startsWith(options.root + path.sep)) { throw new Error("verification scratch must be outside candidate output"); } - const frozen = c.frozenCandidate(options.root, options.identity, options.manifestDigest, options.assetScope); + const frozen = c.frozenCandidate(options.root, options.identity, options.manifestDigest, options.assetScope, options.authoringMode); const context = privateContext(options.workParent); const goHash = toolchain(options.go, context); if (goHash !== frozen.manifest.build.go_sha256) throw new Error("trusted Go tool digest mismatch"); for (const { product, target, binary } of frozen.binaries) { const file = path.join(context.root, "bin", `${product}-${target}`); writeExclusive(file, binary); // no executable bit; no execution during verify - inspectBinary(options.go, file, product, target, options.identity, context.env); + inspectBinary(options.go, file, product, target, options.identity, context.env, options.authoringMode); } return { status: "CANDIDATE", manifest_sha256: frozen.manifest_sha256, consistency_verified: true, release_eligible: false, platform_acceptance: false, attested: false }; diff --git a/npm/agentplugins/test/dual-authoring-candidate-marker.test.js b/npm/agentplugins/test/dual-authoring-candidate-marker.test.js index 7912f709..c9386393 100644 --- a/npm/agentplugins/test/dual-authoring-candidate-marker.test.js +++ b/npm/agentplugins/test/dual-authoring-candidate-marker.test.js @@ -12,7 +12,7 @@ const producer = require("../scripts/stage-dual-authoring-candidate"); // Structural controlled-builder fixtures only: Go env/build/inspection are // stubbed. Git snapshot, exclusive creation, writes and cleanup are real. // Actual Linux bytes and native journeys are separate opt-in evidence. -function fixture(t) { +function fixture(t, mode) { const root = fs.mkdtempSync(path.join(os.tmpdir(), "candidate-marker-")); const repo = path.resolve(__dirname, "../../.."); const workParent = path.join(root, "work"); @@ -27,6 +27,17 @@ function fixture(t) { const output = path.join(root, "candidate"); const marker = path.join(output, "candidate.json"); const options = { candidate: true, repo, workParent, modCache, go, output, identity, assetScope: "linux-amd64-pair" }; + if (mode) { + options.authoringMode = mode; + // Structural source-capability response, like the fake compiler below; + // this is not a real source or native candidate proof. + const read = c.readFile; + t.mock.method(c, "readFile", function(file, ...rest) { + if (file.endsWith("/source/cli/plugin-kit-ai/internal/authoring/commands/commands.go")) + return Buffer.from('const ReleaseMode = "release-cli-contract-v1"\n'); + return read(file, ...rest); + }); + } // Keep the trusted tool directory disjoint from candidate output. const tools = path.join(root, "tools"); fs.mkdirSync(tools); fs.renameSync(go, path.join(tools, "go")); options.go = path.join(tools, "go"); @@ -36,6 +47,7 @@ function fixture(t) { if (args[0] === "env") return JSON.stringify({ GOVERSION: "go1.25.13", GOHOSTOS: "linux", GOHOSTARCH: "amd64" }); if (args[0] === "build") { const product = args.at(-1).split("/").at(-1); + assert.equal(args[args.indexOf("-ldflags") + 1], c.linkerFlags(product, identity, mode)); fs.writeFileSync(args[args.indexOf("-o") + 1], `STRUCTURAL ONLY: ${product}`); return Buffer.alloc(0); } @@ -44,7 +56,7 @@ function fixture(t) { assert.ok(c.PRODUCTS.includes(product)); return JSON.stringify({ GoVersion: "go1.25.13", Path: `github.com/777genius/plugin-kit-ai/cli/cmd/${product}`, Settings: Object.entries({ GOOS: "linux", GOARCH: "amd64", CGO_ENABLED: "0", "-buildmode": "exe", "-compiler": "gc", - "-ldflags": c.linkerFlags(product, identity) }).map(([Key, Value]) => ({ Key, Value })) }); + "-ldflags": c.linkerFlags(product, identity, mode) }).map(([Key, Value]) => ({ Key, Value })) }); }); return { options, marker, output }; } @@ -155,3 +167,15 @@ test("successful structural candidate still finalizes and verifies", { manifestDigest: result.manifest_sha256, go: options.go, workParent: options.workParent, assetScope: options.assetScope }).consistency_verified, true); }); + + +test("release mode producer and verifier require matching explicit intent", (t) => { + const mode = "release-cli-contract-v1"; + const { options, output, marker } = fixture(t, mode); + const staged = producer.stageCandidate(options); + assert.equal(JSON.parse(fs.readFileSync(marker)).build.authoring_mode, mode); + const verify = { candidate: true, root: output, identity: options.identity, + manifestDigest: staged.manifest_sha256, go: options.go, workParent: options.workParent, assetScope: options.assetScope }; + assert.throws(() => producer.verifyCandidate(verify), /build description/); + assert.equal(producer.verifyCandidate({ ...verify, authoringMode: mode }).consistency_verified, true); +}); diff --git a/npm/agentplugins/test/dual-authoring-candidate-native.test.js b/npm/agentplugins/test/dual-authoring-candidate-native.test.js index ee082d1e..2f1d9d8e 100644 --- a/npm/agentplugins/test/dual-authoring-candidate-native.test.js +++ b/npm/agentplugins/test/dual-authoring-candidate-native.test.js @@ -33,7 +33,8 @@ test("actual controlled Linux pair: offline verification, engine reports, frozen status: "CANDIDATE", manifest_sha256: options.manifestDigest, consistency_verified: true, release_eligible: false, platform_acceptance: false, attested: false }); - const frozen = c.frozenCandidate(options.root, options.identity, options.manifestDigest, options.assetScope); + const frozen = c.frozenCandidate(options.root, options.identity, options.manifestDigest, options.assetScope, options.authoringMode); + const release = options.authoringMode === "release-cli-contract-v1"; const before = new Map(fs.readdirSync(options.root).map((name) => [name, c.digest(c.readFile(path.join(options.root, name)))])); assert.equal(fs.statSync(options.root).mode & 0o222, 0); const contexts = c.PRODUCTS.map(() => producer.privateContext(options.workParent)); @@ -47,32 +48,65 @@ test("actual controlled Linux pair: offline verification, engine reports, frozen assert.equal(c.digest(c.readFile(executable)), frozen.manifest.products[product].assets["linux-amd64"].binary.sha256); const productReports = []; const productTrees = []; - const invoke = (...args) => { + const invokeCode = (expectedCode, ...args) => { const argv = [...(product === "agentplugins" ? ["author"] : []), ...args, "--format=json"]; const result = cp.spawnSync(executable, argv, { cwd: context.root, env: { ...context.env, PATH: path.join(context.root, "bin") }, timeout: 30000, encoding: "utf8" }); - assert.equal(result.status, 0, `${product}: ${result.stdout}\n${result.stderr}`); + assert.equal(result.status, expectedCode, `${product}: ${result.stdout}\n${result.stderr}`); assert.equal(result.stderr, ""); - const report = JSON.parse(result.stdout); + let report = JSON.parse(result.stdout); + if (release) { + assert.equal(report.schema_version, 1); assert.equal(report.result, expectedCode === 0 ? "success" : "failure"); + assert.ok(report.command.startsWith("author")); report = report.data; + assert.equal(report.authoring_schema_version, 1); + assert.equal(report.engine_version, "standard-first-slice/1"); + if (report.help) report.help.use = report.help.use.replace(/^agentplugins author|^plugin-kit-ai/, ""); + if (report.product) { + assert.equal(report.product, product); assert.equal(report.product_version, options.identity.versions[product]); + delete report.product; delete report.product_version; + } + } assert.equal(report.revision, options.identity.commit); assert.equal(report.engine, "standard-first-slice/1"); assert.equal(result.stdout.includes(context.root), false); productReports.push({ argv, report }); return report; }; + const invoke = (...args) => invokeCode(0, ...args); + if (release) { + invoke("version"); invoke("--help"); invoke("help", "skills", "init"); + const rootArgs = ["version", "--format=json"]; + const rootResult = cp.spawnSync(executable, rootArgs, { cwd: context.root, env: context.env, encoding: "utf8", timeout: 30000 }); + assert.equal(rootResult.status, 0); const v = JSON.parse(rootResult.stdout); + assert.equal(v.data[product === "agentplugins" ? "version" : "product_version"], options.identity.versions[product]); + for (const args of product === "agentplugins" ? [["author", "init", "--scope=user", "--help"], ["author", "unknown"]] : + [["init", "--force=false", "--help"], ["skills", "ls", "--json"], ["bundle", "fetch", "--github-token=credential-fixture"], ["integrations", "sync", "--dry-run=false"]]) { + const r = cp.spawnSync(executable, [...args, "--format=json"], { cwd: context.root, env: context.env, encoding: "utf8", timeout: 30000 }); + assert.equal(r.status, 2); assert.equal(r.stderr, ""); assert.equal(r.stdout.includes("credential-fixture"), false); + const e = JSON.parse(r.stdout); assert.equal(e.result, "failure"); assert.deepEqual(e.data.effects, { attempted: false, committed: false }); + } + } invoke("capabilities"); - for (const template of ["skill", "mcp-remote"]) { - const project = path.join(context.root, template); - const extra = template === "mcp-remote" ? ["--url=https://example.invalid/mcp"] : []; - const initialized = invoke("init", project, `--template=${template}`, "--name=demo", "--description=Disposable candidate fixture.", ...extra); + for (const lane of (release ? ["skill", "mcp-remote", "mcp-stdio", "hybrid-remote", "hybrid-stdio"] : ["skill", "mcp-remote"])) { + const template = lane.startsWith("hybrid") ? "hybrid" : lane; + const project = path.join(context.root, lane); + const extra = lane.endsWith("remote") ? ["--url=https://docs.example.com/mcp"] : lane.endsWith("stdio") ? ["--runtime=node"] : []; + if (template === "hybrid") extra.push("--mcp-template=mcp-" + lane.split("-")[1]); + const initialized = release ? invoke("init", lane, `--template=${template}`, ...extra) : invoke("init", project, `--template=${template}`, "--name=demo", "--description=Disposable candidate fixture.", ...extra); assert.equal(initialized.committed, true); for (const command of ["validate", "inspect", "test"]) { const report = invoke(command, project); assert.equal(report.runtime_evidence.status, "not_evaluated"); assert.ok(report.identity.tree_digest); } + if (release) { + invoke("compat", project, "--target=claude,codex"); invokeCode(lane === "skill" ? 0 : 1, "doctor", project); + invoke("skills", "validate", project); + assert.equal(invoke("skills", "init", "extra-skill", project, "--description=Disposable fixture.").committed, true); + invoke("skills", "validate", project); + } const tree = []; const walk = (relative) => { for (const name of fs.readdirSync(path.join(project, relative)).sort()) { @@ -108,14 +142,32 @@ test("actual controlled Linux pair: offline verification, engine reports, frozen } const badBody = c.encode(bad); fs.writeFileSync(path.join(badRoot, "candidate.json"), badBody); const badOptions = { ...options, root: badRoot, manifestDigest: c.digest(badBody) }; - assert.equal(c.frozenCandidate(badRoot, options.identity, badOptions.manifestDigest, options.assetScope).binaries.length, 2); + assert.equal(c.frozenCandidate(badRoot, options.identity, badOptions.manifestDigest, options.assetScope, options.authoringMode).binaries.length, 2); assert.throws(() => producer.verifyCandidate(badOptions), /embedded Go product/); + // Repinned real bytes must still reject changed version, engine or known mode. + for (const change of ["version", "revision", "mode"]) { + const root = path.join(contexts[0].root, "repinned-" + change); fs.mkdirSync(root); + const m = structuredClone(frozen.manifest); + if (change === "version") { m.identity.versions["plugin-kit-ai"] = "2.0.91"; m.products["plugin-kit-ai"].version = "2.0.91"; } + if (change === "revision") m.identity.commit = m.identity.engine_revision = "b".repeat(40); + if (change === "mode") m.build.authoring_mode = release ? "vertical-slice-v1" : "release-cli-contract-v1"; + for (const product of c.PRODUCTS) { + const asset = m.products[product].assets["linux-amd64"]; + const original = frozen.manifest.products[product].assets["linux-amd64"].file; + asset.file = c.assetName(product, m.identity.versions[product], "linux-amd64"); + fs.writeFileSync(path.join(root, asset.file), c.readFile(path.join(options.root, original))); + } + const body = c.encode(m); fs.writeFileSync(path.join(root, "candidate.json"), body); + const repinned = { ...options, root, identity: m.identity, manifestDigest: c.digest(body), authoringMode: m.build.authoring_mode }; + c.frozenCandidate(root, repinned.identity, repinned.manifestDigest, options.assetScope, repinned.authoringMode); + assert.throws(() => producer.verifyCandidate(repinned), /embedded Go build setting mismatch/); + } // A build failure cannot produce a success marker or overwrite existing output. const emptyModules = path.join(contexts[0].root, "empty-modules"); fs.mkdirSync(emptyModules); const failureRoot = fs.mkdtempSync(path.join(path.dirname(options.workParent), "candidate-failure-")); const failedOutput = path.join(failureRoot, "failed-build"); const stageOptions = { candidate: true, repo: sourceRepo, output: failedOutput, - modCache: emptyModules, workParent: options.workParent, go: options.go, identity: options.identity, assetScope: options.assetScope }; + modCache: emptyModules, workParent: options.workParent, go: options.go, identity: options.identity, assetScope: options.assetScope, ...(options.authoringMode ? { authoringMode: options.authoringMode } : {}) }; const wrongCommit = (sourceHead[0] === "0" ? "1" : "0") + sourceHead.slice(1); assert.throws(() => producer.stageCandidate({ ...stageOptions, identity: { ...options.identity, commit: wrongCommit, engine_revision: wrongCommit } diff --git a/npm/agentplugins/test/dual-authoring-candidate.test.js b/npm/agentplugins/test/dual-authoring-candidate.test.js index 241cf5e6..0ce906de 100644 --- a/npm/agentplugins/test/dual-authoring-candidate.test.js +++ b/npm/agentplugins/test/dual-authoring-candidate.test.js @@ -285,3 +285,26 @@ test("offline controlled snapshot matches exact HEAD blobs and rejects a differe assert.equal(fs.existsSync(path.join(context.root, "source", "npm")), false); assert.throws(() => producer.sourceSnapshot(repo, "b".repeat(40), producer.privateContext(temp())), /HEAD/); }); + +test("private mode is explicit, closed and bound to embedded bytes", () => { + const mode = "release-cli-contract-v1"; + assert.equal(c.authoringMode(), "vertical-slice-v1"); + assert.throws(() => c.authoringMode("enabled"), /unknown private/); + assert.notEqual(c.linkerFlags("agentplugins", ID), c.linkerFlags("agentplugins", ID, mode)); + const info = { GoVersion: "go1.25.13", Path: "github.com/777genius/plugin-kit-ai/cli/cmd/agentplugins", + Settings: Object.entries({ GOOS: "linux", GOARCH: "amd64", CGO_ENABLED: "0", "-buildmode": "exe", "-compiler": "gc", + "-ldflags": c.linkerFlags("agentplugins", ID, mode) }).map(([Key, Value]) => ({ Key, Value })) }; + producer.buildInfo(info, "agentplugins", "linux-amd64", ID, mode); + assert.throws(() => producer.buildInfo(info, "agentplugins", "linux-amd64", ID), /build setting mismatch/); + info.Settings.at(-1).Value = c.linkerFlags("agentplugins", ID); + assert.throws(() => producer.buildInfo(info, "agentplugins", "linux-amd64", ID, mode), /build setting mismatch/); +}); + +test("manifest expected mode cannot silently reinterpret historical candidates", () => { + const f = structuralFixture(); + assert.throws(() => c.frozenCandidate(f.root, ID, f.hash, SCOPE, "release-cli-contract-v1"), /build description/); + f.manifest.build.authoring_mode = "release-cli-contract-v1"; + const hash = f.save(); + assert.throws(() => freeze(f.root, ID, hash), /build description/); + assert.equal(c.frozenCandidate(f.root, ID, hash, SCOPE, "release-cli-contract-v1").binaries.length, 12); +}); From 49625d4edf5abdbe57f11e4aa16261c689a29574 Mon Sep 17 00:00:00 2001 From: iliya Date: Sun, 6 Sep 2026 17:51:19 +0000 Subject: [PATCH 2/4] fix(authoring): preserve release routing and completion safety --- .../cmd/agentplugins/release_root.go | 9 +- .../cmd/agentplugins/release_root_test.go | 114 ++++++++++++++++ .../cmd/plugin-kit-ai/release_compat.go | 15 +- .../cmd/plugin-kit-ai/release_compat_test.go | 129 +++++++++++++++++- .../authoring/commands/public_contract.go | 5 +- .../internal/authoring/commands/version.go | 42 ++++++ .../internal/authoringcli/release.go | 76 ++++++++++- 7 files changed, 383 insertions(+), 7 deletions(-) diff --git a/cli/plugin-kit-ai/cmd/agentplugins/release_root.go b/cli/plugin-kit-ai/cmd/agentplugins/release_root.go index 5d416023..2e9a4df7 100644 --- a/cli/plugin-kit-ai/cmd/agentplugins/release_root.go +++ b/cli/plugin-kit-ai/cmd/agentplugins/release_root.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "errors" + "fmt" "io" "os" @@ -39,7 +40,13 @@ func executeRelease(ctx context.Context, args []string, streams authoringcli.Str return a.Execute(ctx, args, streams, newReleaseRoot) } if !noEffect { - return installer() + if err := installer(); err != nil { + // Preserve the ordinary main's installer rendering and exit boundary. + // Author and already-rendered utility failures never enter this branch. + _, _ = fmt.Fprintln(streams.Err, "agentplugins:", err) + return exitx.Wrap(err, 1) + } + return nil } var out bytes.Buffer root.SetOut(&out) diff --git a/cli/plugin-kit-ai/cmd/agentplugins/release_root_test.go b/cli/plugin-kit-ai/cmd/agentplugins/release_root_test.go index c2e01000..193f607d 100644 --- a/cli/plugin-kit-ai/cmd/agentplugins/release_root_test.go +++ b/cli/plugin-kit-ai/cmd/agentplugins/release_root_test.go @@ -6,11 +6,15 @@ import ( "encoding/json" "errors" "io" + "os" + "os/exec" "strings" "testing" + "github.com/777genius/plugin-kit-ai/cli/internal/agentpluginscli" "github.com/777genius/plugin-kit-ai/cli/internal/authoringcli" "github.com/777genius/plugin-kit-ai/cli/internal/exitx" + "github.com/spf13/cobra" ) func TestReleaseRoutesBeforeInstallerSetup(t *testing.T) { @@ -98,3 +102,113 @@ func TestReleaseInstallerFlagsRejectBeforeHelp(t *testing.T) { } } } + +func TestReleaseInstallerAliasAndFlagPlacement(t *testing.T) { + for _, args := range [][]string{ + {"install", "fixture", "--target=cursor"}, {"--target=cursor", "install", "fixture"}, + {"install", "author"}, {"--target=author", "install", "fixture"}, {"install", "--", "author"}, + {"update", "--all"}, {"--all", "update"}, + } { + t.Run(strings.Join(args, " "), func(t *testing.T) { + capture := func() (string, error) { + root := agentpluginscli.NewRoot(agentpluginscli.App{}) + selected := "" + for _, c := range root.Commands() { + c.RunE = func(cmd *cobra.Command, _ []string) error { selected = cmd.Name(); return nil } + } + root.SetOut(io.Discard) + root.SetErr(io.Discard) + root.SetArgs(args) + err := root.Execute() + return selected, err + } + want, baselineErr := capture() + calls, got := 0, "" + err := executeRelease(context.Background(), args, authoringcli.Streams{Out: io.Discard, Err: io.Discard}, func() error { + calls++ + var err error + got, err = capture() + return err + }) + if (err == nil) != (baselineErr == nil) || got != want || baselineErr == nil && calls != 1 { + t.Fatalf("Cobra dispatch changed: baseline=%q/%v release=%q/%v callbacks=%d", want, baselineErr, got, err, calls) + } + }) + } +} + +func TestReleaseInstallerErrorRendering(t *testing.T) { + var out, stderr bytes.Buffer + failure := errors.New("captured installer failure") + err := executeRelease(context.Background(), []string{"list"}, authoringcli.Streams{Out: &out, Err: &stderr}, func() error { return failure }) + if exitx.Code(err) != 1 || !errors.Is(err, failure) || out.Len() != 0 || stderr.String() != "agentplugins: captured installer failure\n" { + t.Fatalf("installer error boundary changed: %v, %q, %q", err, out.String(), stderr.String()) + } + for _, args := range [][]string{{"author", "--unknown=credential-fixture"}, {"--unknown=credential-fixture"}} { + out.Reset() + stderr.Reset() + err = executeRelease(context.Background(), append(args, "--format=json"), authoringcli.Streams{Out: &out, Err: &stderr}, func() error { t.Fatal("installer callback"); return failure }) + if exitx.Code(err) != 2 || stderr.Len() != 0 || strings.Contains(out.String(), "credential-fixture") || strings.Count(out.String(), `"schema_version"`) != 1 { + t.Fatal("duplicated or disclosed author/utility failure") + } + } +} + +// A child process is essential: Cobra CompErrorln bypasses Command.SetErr. +func TestReleaseCompletionProcessStderr(t *testing.T) { + if os.Getenv("UAP_COMPLETION_PROCESS_FIXTURE") == "1" { + args := os.Args + for i, arg := range args { + if arg == "--" { + args = args[i+1:] + break + } + } + streams := authoringcli.Streams{Out: os.Stdout, Err: os.Stderr} + err := executeRelease(context.Background(), args, streams, func() error { panic("installer callback in completion") }) + if err != nil { + os.Exit(exitx.Code(err)) + } + os.Exit(0) + } + exe, err := os.Executable() + if err != nil { + t.Fatal(err) + } + home := t.TempDir() + marker := "glpat-" + strings.Repeat("b", 20) + for _, protocol := range []string{"__complete", "__completeNoDesc"} { + cases := []struct { + args []string + want string + fails bool + }{ + {[]string{protocol, ""}, "author", false}, + {append(append([]string{protocol}, []string{"author", "init"}...), "--desc"), "--description", false}, + {append(append([]string{protocol}, []string{"author", "init"}...), "--description", ""), ":", false}, + {[]string{protocol, "--unknown=" + marker, ""}, "", true}, + {[]string{protocol, "--unknown=" + marker}, "", true}, + {[]string{protocol, "--no-color=" + marker, ""}, "", true}, + {[]string{protocol, "-" + marker, ""}, "", true}, + {[]string{protocol, marker, ""}, "", true}, + {[]string{protocol, "--format", marker, "--unknown", ""}, "", true}, + {[]string{protocol, "--unknown=" + marker, "", "--format=json"}, "", true}, + {[]string{protocol, "--format=json", "--unknown=" + marker, ""}, "", true}, + {[]string{"--format=json", protocol, "--unknown=" + marker, ""}, "", true}, + } + for i, tc := range cases { + child := exec.Command(exe, append([]string{"-test.run=^TestReleaseCompletionProcessStderr$", "--"}, tc.args...)...) + child.Dir = home + child.Env = []string{"UAP_COMPLETION_PROCESS_FIXTURE=1", "HOME=" + home, "TMPDIR=" + home, "XDG_CONFIG_HOME=" + home, "PATH=" + home, "BASH_COMP_DEBUG_FILE=" + home + "/completion-debug", "GOMAXPROCS=2"} + var stdout, stderr bytes.Buffer + child.Stdout, child.Stderr = &stdout, &stderr + err := child.Run() + if _, statErr := os.Stat(home + "/completion-debug"); !os.IsNotExist(statErr) { + t.Fatal("completion wrote a process-global debug file") + } + if (err != nil) != tc.fails || stderr.Len() != 0 || strings.Contains(stdout.String(), marker) || !strings.Contains(stdout.String(), tc.want) { + t.Fatalf("protocol %s case %d: exit=%v stderr bytes=%d; completion/containment failed", protocol, i, err, stderr.Len()) + } + } + } +} diff --git a/cli/plugin-kit-ai/cmd/plugin-kit-ai/release_compat.go b/cli/plugin-kit-ai/cmd/plugin-kit-ai/release_compat.go index 3b032f95..5788f31e 100644 --- a/cli/plugin-kit-ai/cmd/plugin-kit-ai/release_compat.go +++ b/cli/plugin-kit-ai/cmd/plugin-kit-ai/release_compat.go @@ -2,6 +2,7 @@ package main import ( "errors" + "strconv" "strings" "github.com/777genius/plugin-kit-ai/cli/internal/authoring/commands" @@ -133,6 +134,16 @@ func rejectV1(in commands.Invocation) error { return "" } reject := func(v string, extra string) error { return errors.New(legacyGuidance(v) + " " + extra) } + // These booleans select output or replacement intent. Validate every + // occurrence: pflag rejects an earlier invalid value even if a later one is + // valid. Never turn malformed input into an all/plan/JSON choice. + for _, name := range []string{"json", "all", "dry-run"} { + for _, value := range in.Values[name] { + if _, err := strconv.ParseBool(value); err != nil { + return reject(verb, "Invalid boolean flag value; use true or false.") + } + } + } if in.Command.Annotations[authoringcli.RejectionKey] != "" { extra := "" switch verb { @@ -159,7 +170,7 @@ func rejectV1(in commands.Invocation) error { case "add": extra = "For a supported standard/local/exact source, use agentplugins add --target --scope user." case "update": - if last("all") == "true" { + if in.Bool("all") { extra = "Use agentplugins update --all." } else { extra = "With an explicit name, use agentplugins update ; omitted name does not imply --all." @@ -169,7 +180,7 @@ func rejectV1(in commands.Invocation) error { case "repair": extra = "For a supported selected manager binding, use agentplugins repair --target ." } - if last("dry-run") == "true" || !has("dry-run") && strings.HasPrefix(verb, "integrations ") { + if in.Bool("dry-run") || !has("dry-run") && strings.HasPrefix(verb, "integrations ") { extra += " Preserve plan intent by adding --dry-run." } } diff --git a/cli/plugin-kit-ai/cmd/plugin-kit-ai/release_compat_test.go b/cli/plugin-kit-ai/cmd/plugin-kit-ai/release_compat_test.go index bfbca05d..4198f915 100644 --- a/cli/plugin-kit-ai/cmd/plugin-kit-ai/release_compat_test.go +++ b/cli/plugin-kit-ai/cmd/plugin-kit-ai/release_compat_test.go @@ -6,6 +6,8 @@ import ( "encoding/json" "errors" "io" + "os" + "os/exec" "strings" "testing" @@ -128,7 +130,7 @@ func TestReleaseV1InventoryCoverage(t *testing.T) { prefix = path } for _, child := range c.Commands() { - if child.Name() == "help" || child.Name() == "completion" || strings.HasPrefix(child.Name(), "__complete") { + if child.Name() == "help" || child.Name() == "completion" || child.Name() == "__complete" || child.Name() == "__completeNoDesc" { continue } walk(child, prefix) @@ -318,3 +320,128 @@ func TestReleaseCompletionHelpAncestry(t *testing.T) { releaseProbe(t, []string{"completion", shell, "--no-descriptions"}, 0) } } + +func TestReleaseCompatibilityBooleanSpellings(t *testing.T) { + for _, value := range []string{"1", "t", "T", "TRUE", "true", "True", "0", "f", "F", "FALSE", "false", "False", "invalid"} { + t.Run(value, func(t *testing.T) { + truth := value == "1" || value == "t" || value == "T" || value == "TRUE" || value == "true" || value == "True" + p, human := releaseProbe(t, []string{"skills", "ls", "--json=" + value}, 2) + if (p.Error != nil) != truth || (human == "") != truth { + t.Fatal("legacy JSON boolean semantics changed") + } + for _, prefix := range [][]string{{"update"}, {"integrations", "update"}} { + p, _ = releaseProbe(t, append(prefix, "--all="+value, "--dry-run="+value, "--format=json"), 2) + if p.Error == nil || strings.Contains(p.Error.Action, "Use agentplugins update --all.") != truth || strings.Contains(p.Error.Action, "Preserve plan intent") != truth { + t.Fatalf("intent changed: %+v", p.Error) + } + } + // Explicit presence still retires a flag, even when false. Format's + // final value wins regardless of the JSON flag's position or spelling. + for _, args := range [][]string{ + {"init", "--force=" + value, "--help", "--format=json"}, + {"skills", "ls", "--format=human", "--json=" + value, "--format=json"}, + } { + p, _ = releaseProbe(t, args, 2) + if p.Error == nil { + t.Fatal("missing rejection") + } + } + _, human = releaseProbe(t, []string{"skills", "ls", "--format=json", "--json=" + value, "--format=human"}, 2) + if human == "" { + t.Fatal("final human format ignored") + } + }) + } +} + +// A child process is essential: Cobra CompErrorln bypasses Command.SetErr. +func TestReleaseCompletionProcessStderr(t *testing.T) { + if os.Getenv("UAP_COMPLETION_PROCESS_FIXTURE") == "1" { + args := os.Args + for i, arg := range args { + if arg == "--" { + args = args[i+1:] + break + } + } + streams := authoringcli.Streams{Out: os.Stdout, Err: os.Stderr} + a := commands.App{Revision: "process-fixture", PublicContract: true, Release: &commands.ReleaseOptions{Product: "plugin-kit-ai", Version: "2.0.0", Reject: rejectV1}} + err := a.Execute(context.Background(), args, streams, newReleaseRoot) + if err != nil { + os.Exit(exitx.Code(err)) + } + os.Exit(0) + } + exe, err := os.Executable() + if err != nil { + t.Fatal(err) + } + home := t.TempDir() + marker := "glpat-" + strings.Repeat("b", 20) + for _, protocol := range []string{"__complete", "__completeNoDesc"} { + cases := []struct { + args []string + want string + fails bool + }{ + {[]string{protocol, ""}, "init", false}, + {append(append([]string{protocol}, []string{"init"}...), "--desc"), "--description", false}, + {append(append([]string{protocol}, []string{"init"}...), "--description", ""), ":", false}, + {[]string{protocol, "--unknown=" + marker, ""}, "", true}, + {[]string{protocol, "--unknown=" + marker}, "", true}, + {[]string{protocol, "--no-color=" + marker, ""}, "", true}, + {[]string{protocol, "-" + marker, ""}, "", true}, + {[]string{protocol, marker, ""}, "", true}, + {[]string{protocol, "--format", marker, "--unknown", ""}, "", true}, + {[]string{protocol, "--unknown=" + marker, "", "--format=json"}, "", true}, + {[]string{protocol, "--format=json", "--unknown=" + marker, ""}, "", true}, + {[]string{"--format=json", protocol, "--unknown=" + marker, ""}, "", true}, + } + for i, tc := range cases { + child := exec.Command(exe, append([]string{"-test.run=^TestReleaseCompletionProcessStderr$", "--"}, tc.args...)...) + child.Dir = home + child.Env = []string{"UAP_COMPLETION_PROCESS_FIXTURE=1", "HOME=" + home, "TMPDIR=" + home, "XDG_CONFIG_HOME=" + home, "PATH=" + home, "BASH_COMP_DEBUG_FILE=" + home + "/completion-debug", "GOMAXPROCS=2"} + var stdout, stderr bytes.Buffer + child.Stdout, child.Stderr = &stdout, &stderr + err := child.Run() + if _, statErr := os.Stat(home + "/completion-debug"); !os.IsNotExist(statErr) { + t.Fatal("completion wrote a process-global debug file") + } + if (err != nil) != tc.fails || stderr.Len() != 0 || strings.Contains(stdout.String(), marker) || !strings.Contains(stdout.String(), tc.want) { + t.Fatalf("protocol %s case %d: exit=%v stderr bytes=%d; completion/containment failed", protocol, i, err, stderr.Len()) + } + } + } +} + +func TestReleaseV1InventoryMutants(t *testing.T) { + if name := os.Getenv("UAP_INVENTORY_PROCESS_FIXTURE"); name != "" { + c := &cobra.Command{Use: name, Hidden: true} + rootCmd.AddCommand(c) + defer rootCmd.RemoveCommand(c) + TestReleaseV1InventoryCoverage(t) + return + } + exe, err := os.Executable() + if err != nil { + t.Fatal(err) + } + for _, name := range []string{"__complete", "__completeNoDesc", "__completeReviewFixture", "review-unmapped-command"} { + child := exec.Command(exe, "-test.run=^TestReleaseV1InventoryMutants$") + child.Env = []string{"UAP_INVENTORY_PROCESS_FIXTURE=" + name, "GOMAXPROCS=2"} + out, err := child.CombinedOutput() + wantFailure := name == "__completeReviewFixture" || name == "review-unmapped-command" + if (err != nil) != wantFailure || wantFailure && !strings.Contains(string(out), "unclassified command/alias "+name) { + t.Fatalf("inventory mutant %s: %v, %s", name, err, out) + } + } +} + +func TestReleaseInvalidBooleanIsNotIntent(t *testing.T) { + for _, name := range []string{"all", "dry-run"} { + p, _ := releaseProbe(t, []string{"update", "--" + name + "=invalid", "--" + name + "=true", "--format=json"}, 2) + if p.Error == nil || !strings.Contains(p.Error.Action, "Invalid boolean") || strings.Contains(p.Error.Action, "Preserve plan intent") || strings.Contains(p.Error.Action, "Use agentplugins update --all.") { + t.Fatal("invalid value coerced into intent") + } + } +} diff --git a/cli/plugin-kit-ai/internal/authoring/commands/public_contract.go b/cli/plugin-kit-ai/internal/authoring/commands/public_contract.go index cae817b4..a1046125 100644 --- a/cli/plugin-kit-ai/internal/authoring/commands/public_contract.go +++ b/cli/plugin-kit-ai/internal/authoring/commands/public_contract.go @@ -174,7 +174,7 @@ func selectPublic(root *cobra.Command, args []string) selection { } var next *cobra.Command for _, c := range s.command.Commands() { - if c.Name() == token && (!c.Hidden || c.Name() == "author" || c.Annotations[authoringcli.RejectionKey] != "") { + if (c.Name() == token || c.Annotations[operationKey] == "" && c.HasAlias(token)) && (!c.Hidden || c.Name() == "author" || c.Annotations[authoringcli.RejectionKey] != "") { next = c break } @@ -281,7 +281,7 @@ func (a App) executePublic(ctx context.Context, args []string, streams authoring if a.Release != nil { if a.Release.Reject != nil { if e := a.Release.Reject(Invocation{Command: selected.command, Values: selected.values}); e != nil { - if v := selected.values["json"]; len(v) > 0 && v[len(v)-1] == "true" && len(selected.values["format"]) == 0 { + if (Invocation{Values: selected.values}).Bool("json") && len(selected.values["format"]) == 0 { selected.format = "json" } return nil, &inputError{"v1_operation_unavailable", e.Error()} @@ -295,6 +295,7 @@ func (a App) executePublic(ctx context.Context, args []string, streams authoring if utilitySelected { authoringcli.PrepareReleaseUtilities(root, true) } + a.guardReleaseCompletion(root, build) } if selected.invalid { return nil, &inputError{"arguments_invalid", publicArguments} diff --git a/cli/plugin-kit-ai/internal/authoring/commands/version.go b/cli/plugin-kit-ai/internal/authoring/commands/version.go index 183ade3c..51c457a9 100644 --- a/cli/plugin-kit-ai/internal/authoring/commands/version.go +++ b/cli/plugin-kit-ai/internal/authoring/commands/version.go @@ -5,6 +5,7 @@ import ( "github.com/777genius/plugin-kit-ai/cli/internal/authoringcli" "github.com/spf13/cobra" "io" + "strconv" ) // ReleaseOptions is explicit private composition, never environment discovery. @@ -17,6 +18,18 @@ type Invocation struct { Command *cobra.Command Values map[string][]string } + +// Bool observes pflag's final boolean value. Invalid input never implies intent; +// compatibility still rejects the invocation based on explicit flag presence. +func (in Invocation) Bool(name string) bool { + v := in.Values[name] + if len(v) == 0 { + return false + } + b, err := strconv.ParseBool(v[len(v)-1]) + return err == nil && b +} + type versionPayload struct { report.Public Product string `json:"product"` @@ -53,6 +66,7 @@ func (a App) ReleaseSelection(args []string, build RootBuilder) (root *cobra.Com s := selectPublic(root, args) author = s.command.Annotations[operationKey] != "" noEffect = len(args) > 0 && (args[0] == "__complete" || args[0] == "__completeNoDesc") || author || s.help || s.command == root || s.command.Name() == "version" || isCompletion(s.command) + a.guardReleaseCompletion(root, build) return } @@ -94,3 +108,31 @@ func OutputFormat(root *cobra.Command, args []string) string { return selectPubl func CompletionInvocation(root *cobra.Command, args []string) bool { return isCompletion(selectPublic(root, args).command) } + +// Cobra's protocol prints getCompletions errors directly to process stderr. +// Check its parser boundary on an inert fresh tree before that Run is reached. +// Never redirect process globals, and never pre-parse the execution tree (slice +// flags and completion's Changed bookkeeping must remain untouched). +func (a App) guardReleaseCompletion(root *cobra.Command, build RootBuilder) { + previousE, previous := root.PersistentPreRunE, root.PersistentPreRun + root.PersistentPreRunE = func(c *cobra.Command, args []string) error { + if c.Name() == cobra.ShellCompRequestCmd || c.Name() == cobra.ShellCompNoDescRequestCmd { + probe, err := a.releaseTree(build) + if err == nil { + authoringcli.PrepareReleaseUtilities(probe, true) + err = authoringcli.CheckReleaseCompletion(probe, args) + } + if err != nil { + return &inputError{"arguments_invalid", publicArguments} + } + return nil + } + if previousE != nil { + return previousE(c, args) + } + if previous != nil { + previous(c, args) + } + return nil + } +} diff --git a/cli/plugin-kit-ai/internal/authoringcli/release.go b/cli/plugin-kit-ai/internal/authoringcli/release.go index 73a7b9b6..e800213a 100644 --- a/cli/plugin-kit-ai/internal/authoringcli/release.go +++ b/cli/plugin-kit-ai/internal/authoringcli/release.go @@ -1,6 +1,12 @@ package authoringcli -import "github.com/spf13/cobra" +import ( + "errors" + "io" + "strings" + + "github.com/spf13/cobra" +) // RejectionKey identifies private, terminal compatibility errors, never jobs. const RejectionKey = "authoringcli.v1-rejection" @@ -35,3 +41,71 @@ func PrepareReleaseUtilities(root *cobra.Command, completion bool) { } root.InitDefaultCompletionCmd() } + +// CheckReleaseCompletion preflights Cobra 1.10's getCompletions error paths: +// command lookup, complete flag values, and the incomplete flag's identity. +// root is disposable: ParseFlags must never mutate the real completion tree. +// No command/Args/completion callback is executed and no raw error is exposed. +func CheckReleaseCompletion(root *cobra.Command, args []string) error { + invalid := errors.New("invalid completion arguments") + if len(args) == 0 || root.TraverseChildren { + return invalid + } + root.SetOut(io.Discard) + root.SetErr(io.Discard) + root.InitDefaultHelpCmd() + c, flags, err := root.Find(args[:len(args)-1]) + if err != nil { + return invalid + } + c.InitDefaultHelpFlag() + c.InitDefaultVersionFlag() + last := args[len(args)-1] + name, equal := "", false + parsed := flags + // Match Cobra's incomplete-flag treatment, including the last shorthand in + // a cluster. Complete values are validated by the actual pflag definitions. + if strings.HasPrefix(last, "-") { + if i := strings.IndexByte(last, '='); i >= 0 { + equal = true + if strings.HasPrefix(last, "--") { + name = last[2:i] + } else { + name = last[i-1 : i] + } + } + } else if len(flags) > 0 { + prev := flags[len(flags)-1] + if len(prev) > 1 && strings.HasPrefix(prev, "-") && prev != "--" && !strings.Contains(prev, "=") { + if strings.HasPrefix(prev, "--") { + name = prev[2:] + } else { + name = prev[len(prev)-1:] + } + parsed = flags[:len(flags)-1] + } + } + unknown := false + if name != "" { + f := c.Flag(name) + if len(name) == 1 { + f = c.Flags().ShorthandLookup(name) + if f == nil { + f = c.InheritedFlags().ShorthandLookup(name) + } + } + if f == nil { + unknown, parsed = true, flags + } else if !equal && f.NoOptDefVal != "" { + parsed = flags + } + } + // Cobra ignores an unknown incomplete flag after -- (or non-interspersed + // positional arguments). Use its same argument-count test for that rule. + _ = c.ParseFlags(append(append([]string{}, parsed...), "--")) + count := c.Flags().NArg() + if c.ParseFlags(parsed) != nil || unknown && count <= c.Flags().NArg() { + return invalid + } + return nil +} From fbc9dcda9307328805c945caac21f31bef7688f6 Mon Sep 17 00:00:00 2001 From: iliya Date: Sun, 6 Sep 2026 18:25:41 +0000 Subject: [PATCH 3/4] fix(authoring): preserve completion empty-name fallback --- .../cmd/agentplugins/release_root_test.go | 13 +++++++++++++ .../cmd/plugin-kit-ai/release_compat_test.go | 13 +++++++++++++ cli/plugin-kit-ai/internal/authoringcli/release.go | 5 ++++- 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/cli/plugin-kit-ai/cmd/agentplugins/release_root_test.go b/cli/plugin-kit-ai/cmd/agentplugins/release_root_test.go index 193f607d..935e9084 100644 --- a/cli/plugin-kit-ai/cmd/agentplugins/release_root_test.go +++ b/cli/plugin-kit-ai/cmd/agentplugins/release_root_test.go @@ -186,6 +186,16 @@ func TestReleaseCompletionProcessStderr(t *testing.T) { {[]string{protocol, ""}, "author", false}, {append(append([]string{protocol}, []string{"author", "init"}...), "--desc"), "--description", false}, {append(append([]string{protocol}, []string{"author", "init"}...), "--description", ""), ":", false}, + // Empty flag names fall back to the preceding value flag in Cobra. + {[]string{protocol, "author", "init", "--description", "--=x"}, ":0\n", false}, + {[]string{protocol, "author", "skills", "init", "--description", "--=x"}, ":0\n", false}, + {[]string{protocol, "author", "skills", "init", "--format", "--=x"}, ":0\n", false}, + {[]string{protocol, "--format", "--=x"}, ":0\n", false}, + // Ordinary partial flag names must still leave the missing value invalid. + {[]string{protocol, "author", "init", "--description", "--desc"}, "", true}, + {[]string{protocol, "author", "init", "--description", "-"}, "", true}, + {[]string{protocol, "author", "init", "--description", "--"}, "", true}, + {[]string{protocol, "--unknown=" + marker, "--=x"}, "", true}, {[]string{protocol, "--unknown=" + marker, ""}, "", true}, {[]string{protocol, "--unknown=" + marker}, "", true}, {[]string{protocol, "--no-color=" + marker, ""}, "", true}, @@ -206,6 +216,9 @@ func TestReleaseCompletionProcessStderr(t *testing.T) { if _, statErr := os.Stat(home + "/completion-debug"); !os.IsNotExist(statErr) { t.Fatal("completion wrote a process-global debug file") } + if tc.want == ":0\n" && stdout.String() != tc.want { + t.Fatalf("protocol %s case %d: want exact completion %q, got %q", protocol, i, tc.want, stdout.String()) + } if (err != nil) != tc.fails || stderr.Len() != 0 || strings.Contains(stdout.String(), marker) || !strings.Contains(stdout.String(), tc.want) { t.Fatalf("protocol %s case %d: exit=%v stderr bytes=%d; completion/containment failed", protocol, i, err, stderr.Len()) } diff --git a/cli/plugin-kit-ai/cmd/plugin-kit-ai/release_compat_test.go b/cli/plugin-kit-ai/cmd/plugin-kit-ai/release_compat_test.go index 4198f915..eccc6f44 100644 --- a/cli/plugin-kit-ai/cmd/plugin-kit-ai/release_compat_test.go +++ b/cli/plugin-kit-ai/cmd/plugin-kit-ai/release_compat_test.go @@ -387,6 +387,16 @@ func TestReleaseCompletionProcessStderr(t *testing.T) { {[]string{protocol, ""}, "init", false}, {append(append([]string{protocol}, []string{"init"}...), "--desc"), "--description", false}, {append(append([]string{protocol}, []string{"init"}...), "--description", ""), ":", false}, + // Empty flag names fall back to the preceding value flag in Cobra. + {[]string{protocol, "init", "--description", "--=x"}, ":0\n", false}, + {[]string{protocol, "skills", "init", "--description", "--=x"}, ":0\n", false}, + {[]string{protocol, "skills", "init", "--format", "--=x"}, "author: success;", false}, + {[]string{protocol, "--format", "--=x"}, "author: success;", false}, + // Ordinary partial flag names must still leave the missing value invalid. + {[]string{protocol, "init", "--description", "--desc"}, "", true}, + {[]string{protocol, "init", "--description", "-"}, "", true}, + {[]string{protocol, "init", "--description", "--"}, "", true}, + {[]string{protocol, "--unknown=" + marker, "--=x"}, "", true}, {[]string{protocol, "--unknown=" + marker, ""}, "", true}, {[]string{protocol, "--unknown=" + marker}, "", true}, {[]string{protocol, "--no-color=" + marker, ""}, "", true}, @@ -407,6 +417,9 @@ func TestReleaseCompletionProcessStderr(t *testing.T) { if _, statErr := os.Stat(home + "/completion-debug"); !os.IsNotExist(statErr) { t.Fatal("completion wrote a process-global debug file") } + if tc.want == ":0\n" && stdout.String() != tc.want { + t.Fatalf("protocol %s case %d: want exact completion %q, got %q", protocol, i, tc.want, stdout.String()) + } if (err != nil) != tc.fails || stderr.Len() != 0 || strings.Contains(stdout.String(), marker) || !strings.Contains(stdout.String(), tc.want) { t.Fatalf("protocol %s case %d: exit=%v stderr bytes=%d; completion/containment failed", protocol, i, err, stderr.Len()) } diff --git a/cli/plugin-kit-ai/internal/authoringcli/release.go b/cli/plugin-kit-ai/internal/authoringcli/release.go index e800213a..1b4a045c 100644 --- a/cli/plugin-kit-ai/internal/authoringcli/release.go +++ b/cli/plugin-kit-ai/internal/authoringcli/release.go @@ -74,7 +74,10 @@ func CheckReleaseCompletion(root *cobra.Command, args []string) error { name = last[i-1 : i] } } - } else if len(flags) > 0 { + } + // Cobra independently falls back for an empty name (including --=x), but + // returns before that fallback for ordinary partial flag names without =. + if name == "" && (!strings.HasPrefix(last, "-") || equal) && len(flags) > 0 { prev := flags[len(flags)-1] if len(prev) > 1 && strings.HasPrefix(prev, "-") && prev != "--" && !strings.Contains(prev, "=") { if strings.HasPrefix(prev, "--") { From 692cae54b7ba8ea43ed9500087bf98caf0dd92c7 Mon Sep 17 00:00:00 2001 From: iliya Date: Sun, 6 Sep 2026 19:11:32 +0000 Subject: [PATCH 4/4] test(authoring): scope Git producer fixture to repository runs --- .../test/dual-authoring-candidate-marker.test.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/npm/agentplugins/test/dual-authoring-candidate-marker.test.js b/npm/agentplugins/test/dual-authoring-candidate-marker.test.js index c9386393..343ff73a 100644 --- a/npm/agentplugins/test/dual-authoring-candidate-marker.test.js +++ b/npm/agentplugins/test/dual-authoring-candidate-marker.test.js @@ -169,7 +169,10 @@ test("successful structural candidate still finalizes and verifies", { }); -test("release mode producer and verifier require matching explicit intent", (t) => { +test("release mode producer and verifier require matching explicit intent", { + // Like the sibling producer fixtures, this requires the repository Git snapshot. + skip: process.env.AGENTPLUGINS_STAGED_TEST_CHILD === "1" +}, (t) => { const mode = "release-cli-contract-v1"; const { options, output, marker } = fixture(t, mode); const staged = producer.stageCandidate(options);