Skip to content
Closed
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: 5 additions & 1 deletion cmd/vf/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,11 @@ func main() {
}

if err := cli.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
// An empty message means the producer already wrote it to stderr;
// printing again would duplicate it. See internal/output/reported.go.
if msg := err.Error(); msg != "" {
fmt.Fprintln(os.Stderr, msg)
}
os.Exit(1)
}
}
49 changes: 49 additions & 0 deletions internal/cli/flagerrors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// This file is not generated by Speakeasy — it renders flag-parsing failures
// through the same path as every other CLI-level error.
//
// flagutil returns a typed *flagutil.FlagValueError but cannot format it: the
// output package already imports flagutil, so flagutil importing output would be
// an import cycle. cli.Execute is the first point that has both the resolved
// *cobra.Command and permission to import output, so the rendering happens here.
//
// In agent mode this reuses output.AgentModeError, which is already the accepted
// envelope for non-API CLI errors (see configure.go and auth.go) and is already
// asserted by test/errors-teach.test.ts. Human mode keeps the plain multi-line
// form, which reads better in a terminal than JSON.

package cli

import (
"errors"

"github.com/spf13/cobra"
"github.com/voiceflow/cli/internal/flagutil"
"github.com/voiceflow/cli/internal/output"
)

// renderFlagValueError converts a flag-value failure into the structured agent
// envelope. Errors of any other kind are returned untouched, so this is safe to
// wrap around the whole command execution.
//
// The message handed to AgentModeError is deliberately one line: the detail
// belongs in hints[], where a consumer can read it as structured data rather
// than parsing it out of a sentence.
//
// The echoed value goes through DisplayValue, never the raw fve.Value. This
// render path is the one that matters most for that: it feeds an agent's
// context, and an unbounded echo of a malformed blob both floods that context
// and can reproduce a secret that happened to be inside it.
func renderFlagValueError(cmd *cobra.Command, err error) error {
var fve *flagutil.FlagValueError
if !errors.As(err, &fve) || !output.IsAgentMode() {
return err
}

hints := fve.Hints
if v := fve.DisplayValue(); v != "" {
hints = append([]string{"you passed: " + v}, hints...)
}

return output.AgentModeError(cmd, "invalid_flag_value",
"invalid value for --"+fve.Flag+": "+fve.Expected, hints)
}
2 changes: 1 addition & 1 deletion internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ func Execute() error {
return runExplorer(rootCmd)
}

return rootCmd.Execute()
return renderFlagValueError(rootCmd, rootCmd.Execute()) // see flagerrors.go
}

// shouldAutoExplore returns true when the CLI is invoked with no subcommand
Expand Down
37 changes: 37 additions & 0 deletions internal/flagutil/description.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// This file is not generated by Speakeasy — it stops a flag's description from
// renaming the flag in help output.
//
// The problem it solves: pflag's UnquoteUsage treats the first back-quoted word
// in a usage string as the flag's value placeholder, and strips the quotes from
// the prose. That is a deliberate pflag feature, but our descriptions come from
// the OpenAPI spec, where backticks are ordinary prose emphasis. The result:
//
// --instructions Name Markdown text. Backticked Name resolves to a tool...
//
// The flag does not want a name. It wants markdown. Twelve flags are affected
// today. The worst is transcript search's --version-param, whose only legal
// values are draft and published, rendered as `environmentAlias` — an unrelated
// parameter name that appears nowhere in its own list of options.
//
// Backticks are replaced rather than deleted so the emphasis the spec author
// intended survives: "Backticked 'Name' resolves to a tool" still reads as a
// reference to a token, while pflag now falls back to the flag's real type.
//
// Doing this at the single point where descriptions enter cobra fixes all twelve
// and every future one automatically. The alternative — editing twelve
// descriptions across eight generated files — would be twelve standing merge
// conflicts that silently stop covering a thirteenth flag the day the spec adds
// one.

package flagutil

import "strings"

// flagDescription prepares an OpenAPI description for use as a pflag usage
// string, neutralizing backticks so they cannot be read as a value placeholder.
func flagDescription(description string) string {
if !strings.Contains(description, "`") {
return description
}
return strings.ReplaceAll(description, "`", "'")
}
75 changes: 75 additions & 0 deletions internal/flagutil/flagerror.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// This file is not generated by Speakeasy — it gives flag-parsing failures a
// typed shape so they can be rendered with the same care as API errors.
//
// The problem it solves: errors produced while interpreting a flag value never
// reach output.Error. They are returned raw from the generated RunE, and because
// the root command sets SilenceErrors, cmd/vf/main.go prints them with a bare
// Fprintln. So an agent that mistypes a flag gets one unstructured line, while
// an agent that gets a 401 back from the API gets a structured envelope with
// hints and a docs URL. That is backwards: mistyped flags are the more common
// failure, and they are the one the caller can actually act on.
//
// flagutil cannot import output — output already imports flagutil, so that
// direction is an import cycle. This type is therefore pure data. It is carried
// up to cli.Execute, which has both the *cobra.Command and permission to import
// output, and is rendered there.

package flagutil

import (
"fmt"
"strings"
)

// FlagValueError reports a flag whose value could not be interpreted.
//
// It follows the house style already used for date parsing in this package —
// state the expectation, show a worked example, echo what was actually passed —
// so the reader can see the difference between what they wrote and what was
// wanted without consulting documentation.
type FlagValueError struct {
Flag string // flag name, without leading dashes
Value string // what the user actually passed
Expected string // one line: what the flag wanted instead
Hints []string // concrete next actions, most likely fix first
Cause error // underlying parse error, preserved for errors.Is/As
}

func (e *FlagValueError) Error() string {
var b strings.Builder
fmt.Fprintf(&b, "invalid value for --%s: %s", e.Flag, e.Expected)
if e.Value != "" {
fmt.Fprintf(&b, "\n you passed: %s", truncateForError(e.Value))
}
for _, h := range e.Hints {
fmt.Fprintf(&b, "\n %s", h)
}
return b.String()
}

func (e *FlagValueError) Unwrap() error { return e.Cause }

// DisplayValue is the value as it should be shown to a reader: shortened, and
// with newlines flattened. Exported because internal/cli renders these errors
// too and must not echo the raw value — an unbounded echo would flood an agent's
// context and can reproduce secrets that happened to sit inside a malformed
// blob. Every render path must go through this, never through Value directly.
func (e *FlagValueError) DisplayValue() string { return truncateForError(e.Value) }

// truncateForError keeps a long value from burying the explanation. Instructions
// and prompts are routinely thousands of characters; echoing one in full pushes
// the hints off the reader's screen, which defeats the point of the hints. It
// also bounds what a malformed value can spill into a log or an agent's context.
//
// Counting and cutting are done in runes, not bytes. Slicing a byte index splits
// multi-byte characters in half, which writes invalid UTF-8 to stderr and makes
// the reported length wrong for any non-ASCII value.
func truncateForError(s string) string {
const max = 120
s = strings.ReplaceAll(s, "\n", " ")
runes := []rune(s)
if len(runes) <= max {
return s
}
return string(runes[:max]) + "… (" + fmt.Sprint(len(runes)) + " chars)"
}
5 changes: 3 additions & 2 deletions internal/flagutil/metadata.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ type FlagMeta struct {
// credentials) are skipped to prevent local flags from shadowing them.
func RegisterFlags(cmd *cobra.Command, meta []FlagMeta) {
for _, m := range meta {
m.Description = flagDescription(m.Description) // see description.go
// Skip if a flag with this name already exists — either inherited from
// a parent command (persistent globals/security) or already registered
// locally (e.g., operation security flags like username/password).
Expand Down Expand Up @@ -817,13 +818,13 @@ func buildJSONField(cmd *cobra.Command, v reflect.Value, m FlagMeta) error {
holder := reflect.New(reflect.PtrTo(fieldType))
holder.Elem().Set(reflect.New(fieldType))
if err := utils.UnmarshalJsonFromString(val, holder.Interface(), m.Annotations); err != nil {
return fmt.Errorf("invalid value for --%s: %w", m.FlagName, err)
return setJSONFieldAsRawText(field, fieldType, isPtr, val, m, err) // see rawtext.go
}
field.Set(holder.Elem())
} else {
target := reflect.New(fieldType)
if err := utils.UnmarshalJsonFromString(val, target.Interface(), m.Annotations); err != nil {
return fmt.Errorf("invalid value for --%s: %w", m.FlagName, err)
return setJSONFieldAsRawText(field, fieldType, isPtr, val, m, err) // see rawtext.go
}
field.Set(target.Elem())
}
Expand Down
Loading