Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,14 +231,16 @@ Generated CLIs are designed so an agent does not have to guess.
| `<cli> commands show <path...> --json` | Inspect the source of truth for one command before execution: flags, body, auth, HTTP method/path, and output hints. |
| `<cli> commands schema --json` | Check the catalog schema version before durable machine parsing. |
| `<cli> auth status --hostname <host>` | Confirm credentials before running a command whose detail says `auth.required=true`. |
| `<cli> <command> --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 "<intent>" --json` to find candidates.
2. Use `commands show <path...> --json` for the selected command.
3. If `auth.required=true`, run `auth status --hostname <host>` 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

Expand Down
2 changes: 2 additions & 0 deletions docs/cli-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand All @@ -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 <host>` 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.
Expand Down
81 changes: 79 additions & 2 deletions pkg/runtime/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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"
Expand All @@ -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)
Expand Down
110 changes: 110 additions & 0 deletions pkg/runtime/build_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package runtime

import (
"bytes"
"encoding/json"
"io"
"net/http"
Expand Down Expand Up @@ -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"})
Expand Down
40 changes: 30 additions & 10 deletions pkg/runtime/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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) {
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
Loading