From deee9580767d10819d852df68635c58e474045ae Mon Sep 17 00:00:00 2001 From: samzong Date: Wed, 1 Jul 2026 13:57:59 -0400 Subject: [PATCH] feat(runtime): add --dry-run to print resolved request JSON Add a --dry-run flag to generated commands that prints the resolved HTTP request (method, URL, redacted headers/body, auth, output hints) without sending it. Skip auth refresh during dry-run. Extract resolveRequest for shared request building between DoRawFull and dry-run output. Signed-off-by: samzong --- README.md | 6 ++- docs/cli-usage.md | 2 + pkg/runtime/build.go | 81 +++++++++++++++++++++++++++- pkg/runtime/build_test.go | 110 ++++++++++++++++++++++++++++++++++++++ pkg/runtime/client.go | 40 ++++++++++---- pkg/runtime/ctx.go | 26 ++++++--- pkg/runtime/debug.go | 31 ++++++++++- 7 files changed, 273 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 6ee3c98..63a56db 100644 --- a/README.md +++ b/README.md @@ -231,14 +231,16 @@ Generated CLIs are designed so an agent does not have to guess. | ` commands show --json` | Inspect the source of truth for one command before execution: flags, body, auth, HTTP method/path, and output hints. | | ` commands schema --json` | Check the catalog schema version before durable machine parsing. | | ` auth status --hostname ` | Confirm credentials before running a command whose detail says `auth.required=true`. | +| ` --dry-run` | Print the resolved request JSON, including URL, redacted headers, redacted body, auth, and output hints, without sending the request. | Recommended agent loop: 1. Use `search "" --json` to find candidates. 2. Use `commands show --json` for the selected command. 3. If `auth.required=true`, run `auth status --hostname ` and stop if the user is not logged in. -4. Execute only after flags, body requirements, auth, HTTP path, and output hints are clear. -5. Prefer `-o json` for machine-readable command output unless the user asked for a human-readable table. +4. Run the command with `--dry-run` when the resolved URL, headers, or body need confirmation. +5. Execute only after flags, body requirements, auth, HTTP path, and output hints are clear. +6. Prefer `-o json` for machine-readable command output unless the user asked for a human-readable table. ## Generated Skill Directory diff --git a/docs/cli-usage.md b/docs/cli-usage.md index 78f6742..67db937 100644 --- a/docs/cli-usage.md +++ b/docs/cli-usage.md @@ -329,6 +329,7 @@ bin/acmectl search "create user" --json bin/acmectl commands show users users create --json bin/acmectl commands schema --json bin/acmectl auth status --hostname api.acme.com +bin/acmectl users users create --set email=alice@example.com --dry-run bin/acmectl users users create --set email=alice@example.com -o json ``` @@ -338,6 +339,7 @@ Rules: - Inspect exact command details with `commands show` before execution. - Use `examples` from command detail when overlays provide runnable command metadata. - Run `auth status --hostname ` before authenticated commands. +- Use `--dry-run` to inspect the resolved request JSON before sending it. - Prefer `-o json` for agent-readable output. - Use `--file`, `--set`, or `--set-str` according to the command detail body contract. diff --git a/pkg/runtime/build.go b/pkg/runtime/build.go index d4d8b56..1f94141 100644 --- a/pkg/runtime/build.go +++ b/pkg/runtime/build.go @@ -91,6 +91,7 @@ func buildCmd(s CommandSpec) *cobra.Command { var paginateAll bool var maxPages int var waitPoll bool + var dryRun bool cmd := &cobra.Command{ Use: s.Use, @@ -109,10 +110,11 @@ func buildCmd(s CommandSpec) *cobra.Command { var hostname string var clientOpts ClientOptions var err error + refreshAuth := !dryRun if s.Security != nil && s.Security.Public { - hostname, clientOpts, err = tryLoadHostOptions(cmd, s.DefaultHostname) + hostname, clientOpts, err = tryLoadHostOptionsMaybeRefresh(cmd, s.DefaultHostname, refreshAuth) } else { - hostname, clientOpts, err = loadHostOptions(cmd, s.DefaultHostname) + hostname, clientOpts, err = loadHostOptionsMaybeRefresh(cmd, s.DefaultHostname, refreshAuth) } if err != nil { return err @@ -267,6 +269,9 @@ func buildCmd(s CommandSpec) *cobra.Command { if s.Output.ResponseMediaType != "" { clientOpts.Accept = s.Output.ResponseMediaType } + if dryRun { + return writeDryRun(cmd, s, hostname, path, body, clientOpts) + } var data []byte if paginateAll && s.Output.Pagination != nil { data, err = PaginateAll(cmd.Context(), hostname, s.Method, path, body, clientOpts, *s.Output.Pagination, s.Output.ListPath, maxPages) @@ -388,6 +393,7 @@ func buildCmd(s CommandSpec) *cobra.Command { if s.Method == "POST" || s.Method == "PUT" || s.Method == "DELETE" || s.Method == "PATCH" { cmd.Flags().BoolVar(&waitPoll, "wait", false, "poll until long-running operation completes") } + cmd.Flags().BoolVar(&dryRun, "dry-run", false, "print resolved request JSON without sending it") cmd.Hidden = s.Hidden if s.Deprecated { cmd.Deprecated = "this command is deprecated" @@ -398,6 +404,77 @@ func buildCmd(s CommandSpec) *cobra.Command { return cmd } +type dryRunRequest struct { + Method string `json:"method"` + URL string `json:"url"` + Headers map[string]string `json:"headers"` + Body any `json:"body"` + Auth dryRunAuth `json:"auth"` + Output CatalogOutput `json:"output"` +} + +type dryRunAuth struct { + Required bool `json:"required"` + Public bool `json:"public"` + Scopes []string `json:"scopes,omitempty"` +} + +func writeDryRun(cmd *cobra.Command, s CommandSpec, hostname, path string, body any, opts ClientOptions) error { + req, bodyBytes, _, err := resolveRequest(cmd.Context(), hostname, s.Method, path, body, opts) + if err != nil { + return err + } + out := dryRunRequest{ + Method: req.Method, + URL: req.URL.String(), + Headers: redactedDryRunHeaders(req.Header), + Body: redactedDryRunBody(req.Header.Get("Content-Type"), bodyBytes), + Auth: dryRunAuthForSpec(s), + Output: CatalogOutput{ + ListPath: s.Output.ListPath, + DefaultColumns: append([]string(nil), s.Output.DefaultColumns...), + ResponseMediaType: s.Output.ResponseMediaType, + Pagination: catalogPagination(s.Output.Pagination), + Streaming: catalogStreaming(s.Output.Streaming), + }, + } + enc := json.NewEncoder(cmd.OutOrStdout()) + enc.SetIndent("", " ") + return enc.Encode(out) +} + +func redactedDryRunHeaders(headers map[string][]string) map[string]string { + out := make(map[string]string, len(headers)) + for k, vs := range headers { + out[k] = redactDebugHeader(k, strings.Join(vs, ", ")) + } + return out +} + +func redactedDryRunBody(contentType string, body []byte) any { + if len(body) == 0 { + return nil + } + redacted := redactDebugBody(contentType, body) + if strings.HasPrefix(contentType, "application/json") { + var v any + if err := json.Unmarshal(redacted, &v); err == nil { + return v + } + } + return string(redacted) +} + +func dryRunAuthForSpec(s CommandSpec) dryRunAuth { + out := dryRunAuth{Required: true} + if s.Security != nil { + out.Required = !s.Security.Public + out.Public = s.Security.Public + out.Scopes = append([]string(nil), s.Security.Scopes...) + } + return out +} + func supportsJSONBodyBuilder(mediaType string) bool { mt, _, _ := strings.Cut(strings.ToLower(strings.TrimSpace(mediaType)), ";") mt = strings.TrimSpace(mt) diff --git a/pkg/runtime/build_test.go b/pkg/runtime/build_test.go index be89a74..e98bd54 100644 --- a/pkg/runtime/build_test.go +++ b/pkg/runtime/build_test.go @@ -1,6 +1,7 @@ package runtime import ( + "bytes" "encoding/json" "io" "net/http" @@ -309,6 +310,115 @@ func TestBuild_NonJSONRequestBodyRequiresFile(t *testing.T) { } } +func TestBuild_DryRunPrintsResolvedRequestWithoutSending(t *testing.T) { + bindTestManifest(t, "myctl", "MYCTL_HOST") + t.Setenv("MYCTL_CONFIG_DIR", t.TempDir()) + + hits := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits++ + t.Fatalf("dry-run sent request: %s %s", r.Method, r.URL.String()) + })) + defer srv.Close() + + root := newRootWithModuleGroup() + var stdout bytes.Buffer + root.SetOut(&stdout) + root.SetErr(io.Discard) + root.PersistentFlags().String("hostname", "", "") + root.PersistentFlags().StringP("output", "o", "raw", "") + Build(root, "demo", []CommandSpec{{ + Group: "Users", + Use: "create-user", + Method: "POST", + PathTpl: "/users/{id}", + Params: []ParamSpec{ + {Name: "id", Flag: "id", In: InPath, GoType: "string", Required: true}, + {Name: "limit", Flag: "limit", In: InQuery, GoType: "int64"}, + {Name: "Authorization", Flag: "authorization", In: InHeader, GoType: "string"}, + }, + RequestBody: &RequestBody{Required: true, MediaType: "application/json"}, + Output: OutputHints{ + ListPath: "data.items", + DefaultColumns: []string{"id", "name"}, + ResponseMediaType: "application/vnd.demo+json", + }, + Security: &SecurityHint{Public: true}, + }}) + root.SetArgs([]string{ + "demo", "users", "create-user", + "--hostname", srv.URL, + "--id", "u 1", + "--limit", "5", + "--authorization", "Bearer secret", + "--set", "name=alice", + "--set", "password=hunter2", + "--set", "envVars[0].key=MANUAL_DRY_RUN", + "--set", "envVars[0].value=some-secret", + "--dry-run", + }) + if err := root.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + if hits != 0 { + t.Fatalf("dry-run sent %d requests", hits) + } + + var out struct { + Method string `json:"method"` + URL string `json:"url"` + Headers map[string]string `json:"headers"` + Body map[string]any `json:"body"` + Auth struct { + Required bool `json:"required"` + Public bool `json:"public"` + } `json:"auth"` + Output struct { + ListPath string `json:"list_path"` + DefaultColumns []string `json:"default_columns"` + ResponseMediaType string `json:"response_media_type"` + } `json:"output"` + } + if err := json.Unmarshal(stdout.Bytes(), &out); err != nil { + t.Fatalf("dry-run JSON: %v\n%s", err, stdout.String()) + } + if out.Method != "POST" { + t.Fatalf("method = %q, want POST", out.Method) + } + if out.URL != srv.URL+"/users/u%201?limit=5" { + t.Fatalf("url = %q", out.URL) + } + if out.Headers["Authorization"] != "***" { + t.Fatalf("authorization header = %q", out.Headers["Authorization"]) + } + if out.Headers["Content-Type"] != "application/json" { + t.Fatalf("content-type = %q", out.Headers["Content-Type"]) + } + if out.Headers["Accept"] != "application/vnd.demo+json" { + t.Fatalf("accept = %q", out.Headers["Accept"]) + } + if out.Body["name"] != "alice" || out.Body["password"] != "***" { + t.Fatalf("body = %#v", out.Body) + } + envVars, ok := out.Body["envVars"].([]any) + if !ok || len(envVars) != 1 { + t.Fatalf("envVars = %#v", out.Body["envVars"]) + } + envVar, ok := envVars[0].(map[string]any) + if !ok { + t.Fatalf("envVar = %#v", envVars[0]) + } + if envVar["key"] != "MANUAL_DRY_RUN" || envVar["value"] != "***" { + t.Fatalf("envVar = %#v", envVar) + } + if out.Auth.Required || !out.Auth.Public { + t.Fatalf("auth = %+v", out.Auth) + } + if out.Output.ListPath != "data.items" || out.Output.ResponseMediaType != "application/vnd.demo+json" || !reflect.DeepEqual(out.Output.DefaultColumns, []string{"id", "name"}) { + t.Fatalf("output = %+v", out.Output) + } +} + func TestBuild_VariableFlagsMergeIntoEnvelope(t *testing.T) { root, url, recorded := newRecordingGraphQLRoot(t, createAppSpec()) root.SetArgs([]string{"--hostname", url, "demo", "apps", "create-app", "--input-name", "demo"}) diff --git a/pkg/runtime/client.go b/pkg/runtime/client.go index 7b44730..06f2b4f 100644 --- a/pkg/runtime/client.go +++ b/pkg/runtime/client.go @@ -79,18 +79,13 @@ type RawResult struct { } func DoRawFull(ctx context.Context, hostname, method, path string, body any, opts ClientOptions) (*RawResult, error) { - base, err := BaseURL(hostname) - if err != nil { - return nil, err - } - u := base + path - - bodyBytes, contentType, err := encodeRequestBody(body) + req, bodyBytes, contentType, err := resolveRequest(ctx, hostname, method, path, body, opts) if err != nil { return nil, err } + u := req.URL.String() - result, err := doRawFullOnce(ctx, method, u, bodyBytes, contentType, opts) + result, err := doRawFullOnce(req, opts) if err == nil { return result, nil } @@ -104,7 +99,11 @@ func DoRawFull(ctx context.Context, hostname, method, path string, body any, opt } opts.Auth = auth opts.RefreshAuth = nil - return doRawFullOnce(ctx, method, u, bodyBytes, contentType, opts) + req, err = newRequest(ctx, method, u, bodyBytes, contentType, opts) + if err != nil { + return nil, err + } + return doRawFullOnce(req, opts) } func encodeRequestBody(body any) ([]byte, string, error) { @@ -125,7 +124,23 @@ func encodeRequestBody(body any) ([]byte, string, error) { return nil, "", nil } -func doRawFullOnce(ctx context.Context, method, u string, body []byte, contentType string, opts ClientOptions) (*RawResult, error) { +func resolveRequest(ctx context.Context, hostname, method, path string, body any, opts ClientOptions) (*http.Request, []byte, string, error) { + base, err := BaseURL(hostname) + if err != nil { + return nil, nil, "", err + } + bodyBytes, contentType, err := encodeRequestBody(body) + if err != nil { + return nil, nil, "", err + } + req, err := newRequest(ctx, method, base+path, bodyBytes, contentType, opts) + if err != nil { + return nil, nil, "", err + } + return req, bodyBytes, contentType, nil +} + +func newRequest(ctx context.Context, method, u string, body []byte, contentType string, opts ClientOptions) (*http.Request, error) { var reader io.Reader if body != nil { reader = bytes.NewReader(body) @@ -153,7 +168,12 @@ func doRawFullOnce(ctx context.Context, method, u string, body []byte, contentTy for k, v := range opts.Headers { req.Header.Set(k, v) } + return req, nil +} +func doRawFullOnce(req *http.Request, opts ClientOptions) (*RawResult, error) { + method := req.Method + u := req.URL.String() resp, err := HTTPClient(opts).Do(req) if err != nil { return nil, fmt.Errorf("%s %s: %w", method, u, err) diff --git a/pkg/runtime/ctx.go b/pkg/runtime/ctx.go index 87e3827..60cfa16 100644 --- a/pkg/runtime/ctx.go +++ b/pkg/runtime/ctx.go @@ -63,6 +63,10 @@ func LoadHostOptions(cmd *cobra.Command) (string, ClientOptions, error) { } func loadHostOptions(cmd *cobra.Command, defaultHostname string) (string, ClientOptions, error) { + return loadHostOptionsMaybeRefresh(cmd, defaultHostname, true) +} + +func loadHostOptionsMaybeRefresh(cmd *cobra.Command, defaultHostname string, refresh bool) (string, ClientOptions, error) { hostname, err := resolveHost(cmd, defaultHostname) if err != nil { return "", ClientOptions{}, err @@ -79,9 +83,11 @@ func loadHostOptions(cmd *cobra.Command, defaultHostname string) (string, Client if v, err := cmd.Root().PersistentFlags().GetBool("insecure"); err == nil && v { insecure = true } - e, err = refreshHostAuthIfNeeded(cmd.Context(), hostname, hosts, e, insecure) - if err != nil { - return "", ClientOptions{}, err + if refresh { + e, err = refreshHostAuthIfNeeded(cmd.Context(), hostname, hosts, e, insecure) + if err != nil { + return "", ClientOptions{}, err + } } auth, err := NewAuthFromHost(e) if err != nil { @@ -91,7 +97,7 @@ func loadHostOptions(cmd *cobra.Command, defaultHostname string) (string, Client Auth: auth, Insecure: insecure, } - if canRefreshHostAuth(e) { + if refresh && canRefreshHostAuth(e) { opts.RefreshAuth = refreshAuthFunc(hostname, insecure, e.OAuthToken) } return hostname, opts, nil @@ -102,6 +108,10 @@ func TryLoadHostOptions(cmd *cobra.Command) (string, ClientOptions, error) { } func tryLoadHostOptions(cmd *cobra.Command, defaultHostname string) (string, ClientOptions, error) { + return tryLoadHostOptionsMaybeRefresh(cmd, defaultHostname, true) +} + +func tryLoadHostOptionsMaybeRefresh(cmd *cobra.Command, defaultHostname string, refresh bool) (string, ClientOptions, error) { hostname, err := resolveHost(cmd, defaultHostname) if err != nil { return "", ClientOptions{}, err @@ -122,8 +132,10 @@ func tryLoadHostOptions(cmd *cobra.Command, defaultHostname string) (string, Cli if v, err := cmd.Root().PersistentFlags().GetBool("insecure"); err == nil && v { insecure = true } - if refreshed, err := refreshHostAuthIfNeeded(cmd.Context(), hostname, hosts, e, insecure); err == nil { - e = refreshed + if refresh { + if refreshed, err := refreshHostAuthIfNeeded(cmd.Context(), hostname, hosts, e, insecure); err == nil { + e = refreshed + } } auth, err := NewAuthFromHost(e) if err != nil { @@ -133,7 +145,7 @@ func tryLoadHostOptions(cmd *cobra.Command, defaultHostname string) (string, Cli Auth: auth, Insecure: insecure, } - if canRefreshHostAuth(e) { + if refresh && canRefreshHostAuth(e) { opts.RefreshAuth = refreshAuthFunc(hostname, insecure, e.OAuthToken) } return hostname, opts, nil diff --git a/pkg/runtime/debug.go b/pkg/runtime/debug.go index 0f41517..0940451 100644 --- a/pkg/runtime/debug.go +++ b/pkg/runtime/debug.go @@ -116,16 +116,29 @@ func redactDebugBody(contentType string, body []byte) []byte { } func redactDebugJSON(v any) bool { + return redactDebugJSONAt(v, nil) +} + +func redactDebugJSONAt(v any, path []string) bool { switch tv := v.(type) { case map[string]any: changed := false + envVarPair := isDebugEnvVarPair(path, tv) for k, child := range tv { + if envVarPair && strings.EqualFold(k, "value") { + tv[k] = "***" + changed = true + continue + } + if envVarPair && strings.EqualFold(k, "key") { + continue + } if isSensitiveDebugName(k) { tv[k] = "***" changed = true continue } - if redactDebugJSON(child) { + if redactDebugJSONAt(child, append(path, k)) { changed = true } } @@ -133,7 +146,7 @@ func redactDebugJSON(v any) bool { case []any: changed := false for _, child := range tv { - if redactDebugJSON(child) { + if redactDebugJSONAt(child, path) { changed = true } } @@ -143,6 +156,20 @@ func redactDebugJSON(v any) bool { } } +func isDebugEnvVarPair(path []string, v map[string]any) bool { + if len(path) == 0 || !isDebugEnvVarContainer(path[len(path)-1]) { + return false + } + _, hasKey := v["key"] + _, hasValue := v["value"] + return hasKey && hasValue +} + +func isDebugEnvVarContainer(name string) bool { + n := strings.NewReplacer("_", "", "-", "").Replace(strings.ToLower(name)) + return n == "env" || n == "envvars" || n == "environmentvariables" +} + func redactDebugText(s string) string { fields := strings.FieldsFunc(s, func(r rune) bool { return r == '&' || r == ';' || r == '\n' || r == '\r'