Skip to content
Open
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
14 changes: 11 additions & 3 deletions internal/flagutil/metadata.go
Original file line number Diff line number Diff line change
Expand Up @@ -247,8 +247,11 @@ func BuildRequest[T any](cmd *cobra.Command, meta []FlagMeta, bodyFieldPath stri
}
}

// Priority 2: stdin
if !bodyPrePopulated && HasStdinInput(cmd) {
// Priority 2: stdin. Params-only commands (no body field and no --body
// flag) have no body for stdin to fill — consuming piped JSON there would
// both surprise pipelines and re-relax required path/query params via the
// bodyPrePopulated relaxation below.
if !bodyPrePopulated && (bodyFieldPath != "" || bodyFlagName != "") && HasStdinInput(cmd) {
stdinData, err := io.ReadAll(cmd.InOrStdin())
if err != nil {
return nil, fmt.Errorf("failed to read stdin: %w", err)
Expand Down Expand Up @@ -282,7 +285,12 @@ func BuildRequest[T any](cmd *cobra.Command, meta []FlagMeta, bodyFieldPath stri
// provided via --body/stdin, check if any individual flags were changed.
// If not, the user didn't attempt to provide a body at all — relax required
// checks so nullable/optional bodies work without erroring on inner required fields.
if !bodyPrePopulated && bodyFieldPath == "" {
// Only when the command actually has a body flag (bodyFlagName != ""): a
// params-only command (get/delete — no --body registered) has no body to
// relax for, and relaxing would let requests leave with required path or
// query params empty, failing server-side with field paths no CLI user
// can map back to a flag.
if !bodyPrePopulated && bodyFieldPath == "" && bodyFlagName != "" {
anyChanged := false
for _, m := range meta {
if FlagChanged(cmd, m.FlagName) {
Expand Down
4 changes: 4 additions & 0 deletions internal/output/agentmode.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ var (

// agentEnvVars lists environment variables set by known AI coding agents.
var agentEnvVars = []string{
// Claude Code actually sets CLAUDECODE=1 (no underscore) — verified in a
// live session. CLAUDE_CODE is kept for compatibility with the generated
// list, but without CLAUDECODE the flagship agent never triggers agent mode.
"CLAUDECODE",
"CLAUDE_CODE",
"CURSOR_AGENT",
"CODEX",
Expand Down
4 changes: 4 additions & 0 deletions internal/sdk/sdkinternal/hooks/registration.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,8 @@ package hooks
// Speakeasy — add any additional hooks here.
func initHooks(h *Hooks) {
h.registerBeforeRequestHook(&clientTypeHook{})

teach := &teachHook{}
h.registerBeforeRequestHook(teach)
h.registerAfterErrorHook(teach)
}
292 changes: 292 additions & 0 deletions internal/sdk/sdkinternal/hooks/teach.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,292 @@
// This file is not generated by Speakeasy — it implements "errors that teach":
// every failure the CLI surfaces should name its own fix, because for both
// humans and AI coding agents the error text is the documentation they read.
//
// Three behaviors, all request-lifecycle hooks (see registration.go):
//
// - No credential configured -> fail before the network call with setup
// guidance (the one moment that genuinely needs a human: creating a token).
// - Malformed credential -> warn once on stderr (legacy Dialog Manager
// keys, stray whitespace) but still send the request.
// - 4XX/5XX responses -> inject actionable "hints" and a "docs_url"
// into the JSON error body. internal/output preserves body-provided keys
// in both agent-mode envelopes and pretty output, and server-provided
// hints always win — injection only fills keys the body does not have.

package hooks

import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"sync"
)

const (
authDocsURL = "https://www.voiceflow.com/docs/api-reference/authentication"
docsCommandBase = "https://www.voiceflow.com/docs/cli/commands"
tokenEnvVar = "VF_TOKEN"
)

type teachHook struct {
warnOnce sync.Once
}

var _ beforeRequestHook = (*teachHook)(nil)
var _ afterErrorHook = (*teachHook)(nil)

// authSetupError carries a structured JSON body so internal/output renders it
// like an API error envelope (Body is extracted via reflection there).
type authSetupError struct {
Body string
message string
}

func (e *authSetupError) Error() string { return e.message }

// BeforeRequest fails fast when no access token is configured, and warns once
// when the configured token is recognizably malformed. Runs after the SDK
// populates security, so the Authorization header reflects every source
// (--token flag, VF_TOKEN env, keyring, config file).
func (h *teachHook) BeforeRequest(_ BeforeRequestContext, req *http.Request) (*http.Request, error) {
authorization := req.Header.Get("Authorization")
// With no token configured the SDK still writes "Authorization: Bearer "
// (empty credential) — strip the scheme to see whether a credential exists.
credential := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(authorization), "Bearer"))

if credential == "" {
// --dry-run must keep working without credentials: it is a
// request-inspection tool and never contacts the API.
if dryRunRequested() {
return req, nil
}
body, _ := json.Marshal(map[string]interface{}{
"error": "no access token configured",
"error_type": "authentication_error",
"message": "No access token configured — the request was not sent.",
"hints": []string{
"Create a personal access token in Voiceflow under Settings → Access tokens (tokens start with vfp_)",
fmt.Sprintf("Then run: export %s=vfp_... (every command also accepts --token)", tokenEnvVar),
"Verify what is configured, without a network call: vf whoami",
},
"docs_url": authDocsURL,
})
return req, &authSetupError{
Body: string(body),
message: fmt.Sprintf("no access token configured — create one under Settings → Access tokens, then export %s=vfp_... (%s)", tokenEnvVar, authDocsURL),
}
}

token := strings.TrimPrefix(authorization, "Bearer ")
switch {
case strings.HasPrefix(credential, "VF.DM."):
h.warnOnce.Do(func() {
fmt.Fprintf(os.Stderr, "Warning: the configured token looks like a legacy Dialog Manager API key (VF.DM....). The CLI needs a personal access token (vfp_...) — create one under Settings → Access tokens: %s\n", authDocsURL)
})
case strings.TrimSpace(token) != token:
h.warnOnce.Do(func() {
fmt.Fprintln(os.Stderr, "Warning: the configured token has leading or trailing whitespace — the API will likely reject it.")
})
}

return req, nil
}

// AfterError injects hints and a docs_url into JSON error bodies. Keys already
// present in the body (for example server-provided hints) are never replaced.
func (h *teachHook) AfterError(hookCtx AfterErrorContext, res *http.Response, err error) (*http.Response, error) {
if res == nil || res.Body == nil || res.StatusCode < 400 {
return res, err
}

raw, readErr := io.ReadAll(res.Body)
res.Body.Close()
restore := func(body []byte) {
res.Body = io.NopCloser(bytes.NewReader(body))
res.ContentLength = int64(len(body))
res.Header.Set("Content-Length", strconv.Itoa(len(body)))
}
if readErr != nil {
// io.ReadAll can return bytes alongside an error. Restore whatever
// arrived rather than blanking the body — a truncated error message
// still tells the user more than an empty one.
restore(raw)
return res, err
}

var parsed map[string]interface{}
if json.Unmarshal(raw, &parsed) != nil || parsed == nil {
restore(raw)
return res, err
}

changed := false
if _, has := parsed["hints"]; !has {
if hints := hintsForStatus(res.StatusCode, res.Header, hookCtx.OperationID); len(hints) > 0 {
parsed["hints"] = hints
changed = true
}
}
if _, has := parsed["docs_url"]; !has {
if docsURL := docsURLForOperation(hookCtx.OperationID); docsURL != "" {
parsed["docs_url"] = docsURL
changed = true
}
}

if !changed {
restore(raw)
return res, err
}
if enriched, marshalErr := json.Marshal(parsed); marshalErr == nil {
restore(enriched)
} else {
restore(raw)
}
return res, err
}

// dryRunRequested reports whether --dry-run is on the command line. The hook
// has no cobra command reference, and --dry-run is flag-only (no env or config
// source), so the process arguments are its single source of truth.
func dryRunRequested() bool {
for _, arg := range os.Args[1:] {
if arg == "--dry-run" {
return true
}
if value, found := strings.CutPrefix(arg, "--dry-run="); found {
// Accept every spelling cobra's bool parsing accepts (True, T, 1, ...).
if parsed, err := strconv.ParseBool(value); err == nil {
return parsed
}
}
}
return false
}

// hintsForStatus returns fix-naming hints for a failed response. Empty for
// statuses where the generic enrichment in internal/output already says
// everything useful (5XX).
func hintsForStatus(statusCode int, headers http.Header, operationID string) []string {
switch statusCode {
case 401:
return []string{
"Access tokens expire (default 30 days) — this one may have lapsed or been revoked",
fmt.Sprintf("Create a new token in Voiceflow under Settings → Access tokens, then run: export %s=vfp_...", tokenEnvVar),
"See which credential source is being used: vf whoami",
}
case 403:
hints := []string{
"Your token carries your own account's access — this resource belongs to a workspace or organization your account cannot act on",
}
// Always name a command that exists: workspace list is the widest thing
// any account can run, and it is what reveals the reachable scope.
hints = append(hints, "List what your account can reach: vf workspace list")
return hints
case 404:
hints := []string{"Verify the resource identifier — it may be from another workspace or environment"}
if group := commandGroupForOperation(operationID); hasListCommand(group) {
hints = append(hints, fmt.Sprintf("List available resources: vf %s list", group))
}
return hints
case 429:
if retryAfter := headers.Get("Retry-After"); retryAfter != "" {
// Retry-After may be delta-seconds or an HTTP-date; echo it verbatim.
return []string{fmt.Sprintf("Rate limited — Retry-After: %s", retryAfter)}
}
return []string{"Rate limited — retry after a short delay"}
case 400, 422:
return []string{
"Preview the exact request without sending it: add --dry-run",
"Field paths in details[] refer to the API request body — the matching CLI flag usually shares the last path segment (run the command with --help)",
}
default:
return nil
}
}

// docsGroupPages is the set of command-group pages that exist under
// https://www.voiceflow.com/docs/cli/commands/ (verified against the docs
// site's index). Operations whose group is not listed fall back to overview.
var docsGroupPages = map[string]bool{
"agent": true, "analytics": true, "api-tool": true, "conversation": true,
"document": true, "environment": true, "evaluation": true, "function": true,
"knowledge-base": true, "mcp-server": true, "mcp-tool": true, "playbook": true,
"project": true, "tool": true, "transcript": true, "variable": true, "workspace": true,
}

// listCommandGroups is the set of top-level command groups that actually have
// a `list` subcommand — hints must only name commands that exist.
var listCommandGroups = map[string]bool{
"api-tool": true, "document": true, "environment": true, "evaluation": true,
"function": true, "mcp-server": true, "mcp-tool": true, "playbook": true,
"project": true, "test": true, "tool": true, "variable": true, "workspace": true,
}

func hasListCommand(group string) bool { return listCommandGroups[group] }

// commandGroupForOperation derives the CLI command group from an operation ID
// like "StableAPIToolController_list" -> "api-tool". Sub-resource controllers
// (e.g. StableFunctionPathController) resolve to their parent group by
// trimming trailing segments until a known group matches.
func commandGroupForOperation(operationID string) string {
name, _, found := strings.Cut(operationID, "Controller")
if !found {
return ""
}
name = strings.TrimPrefix(name, "Stable")
kebab := camelToKebab(name)

for kebab != "" {
if docsGroupPages[kebab] || listCommandGroups[kebab] {
return kebab
}
lastDash := strings.LastIndex(kebab, "-")
if lastDash < 0 {
return ""
}
kebab = kebab[:lastDash]
}
return ""
}

// docsURLForOperation maps an operation to its command group's documentation
// page, falling back to the commands overview.
func docsURLForOperation(operationID string) string {
if group := commandGroupForOperation(operationID); group != "" && docsGroupPages[group] {
return docsCommandBase + "/" + group
}
return docsCommandBase + "/overview"
}

// camelToKebab converts CamelCase with initialisms to kebab-case:
// "APITool" -> "api-tool", "MCPServer" -> "mcp-server", "Workspace" -> "workspace".
func camelToKebab(name string) string {
var out strings.Builder
runes := []rune(name)
for i, r := range runes {
if i > 0 && isUpper(r) {
prevLower := !isUpper(runes[i-1])
nextLower := i+1 < len(runes) && !isUpper(runes[i+1])
if prevLower || nextLower {
out.WriteByte('-')
}
}
out.WriteRune(toLower(r))
}
return out.String()
}

func isUpper(r rune) bool { return r >= 'A' && r <= 'Z' }

func toLower(r rune) rune {
if isUpper(r) {
return r + ('a' - 'A')
}
return r
}
Loading