Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
776da67
feat(entity): add hidden _id column to wrapped entities
moshloop Apr 30, 2026
f44c71c
feat(api): add stack trace parsing and rendering with source resolution
moshloop May 1, 2026
1b9788d
feat(examples): Add syntax highlighting and stack trace rendering sup…
moshloop May 4, 2026
7db2f25
feat(mcp): Add fluent Builder API and discover-tools MCP tool
moshloop May 4, 2026
4fece21
feat(mcp): add SSE transport, tools command, and multi-client install…
moshloop May 5, 2026
a0e00b6
chore(deps): clean up unused dependencies in go.sum
moshloop May 5, 2026
37cc4d7
feat(api,flags): add unified diff rendering and grouped flag help output
moshloop May 8, 2026
f68560c
build(makefile): add git commit and build date to binary via ldflags
moshloop May 11, 2026
dfebb7d
refactor(api,mcp,rpc): extract helper functions and improve code orga…
moshloop May 11, 2026
275d813
feat(entity): add WithOptionalID for actions with flag-supplied targets
moshloop May 18, 2026
8065786
feat(rpc,cobra): Support rich error rendering and configurable conver…
moshloop May 26, 2026
b7f8c4a
fix(task): treat StatusWarning as terminal state to freeze endTime
moshloop May 26, 2026
4dcafc7
chore(deps): update dependencies to latest versions
moshloop May 26, 2026
b9f1a94
chore(deps): update dependencies to latest versions
moshloop May 26, 2026
6986348
docs(readme): simplify and restructure README for clarity and brevity
moshloop May 27, 2026
419452b
chore(deps): update Go dependencies in examples module
moshloop May 27, 2026
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
1,213 changes: 187 additions & 1,026 deletions README.md

Large diffs are not rendered by default.

31 changes: 30 additions & 1 deletion cobra_command.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
package clicky

import (
"errors"
"fmt"
"os"
"reflect"
"strings"
"sync"

"github.com/flanksource/clicky/api"
"github.com/flanksource/clicky/flags"
"github.com/flanksource/commons/logger"
"github.com/samber/lo"
Expand Down Expand Up @@ -243,7 +245,19 @@ func AddNamedCommand[T any, R any](name string, parent *cobra.Command, opts T, f
// Call the function
result, err := fn(optsValue.Interface().(T))
if err != nil {
logger.GetSlogLogger().WithSkipReportLevel(2).Errorf("Command %s failed: %w", name, err)
// An error that carries a clicky rendering interface
// (Pretty/Textable/Tree*) is rendered through the same format
// pipeline as a success result — honouring --format — instead of
// being collapsed to its Error() line. The error is still
// returned so cobra exits non-zero.
if rich, ok := renderableError(err); ok {
if specErr := Flags.ParseFormatSpec(); specErr != nil {
return specErr
}
PrintAndWriteSinks(rich, Flags.FormatOptions)
} else {
logger.GetSlogLogger().WithSkipReportLevel(2).Errorf("Command %s failed: %v", name, err)
}
return err
}

Expand All @@ -267,6 +281,21 @@ func isStdinAvailable() bool {
return (stat.Mode() & os.ModeCharDevice) == 0
}

// renderableError reports whether err — or any error in its Unwrap chain —
// carries a clicky rendering interface (Pretty / Textable / Tree*), so the
// command runner can render it through the format pipeline instead of just
// logging Error(). The chain is walked so a fmt-wrapped rich error still
// renders. The returned value is the matched error, ready to hand to
// PrintAndWriteSinks.
func renderableError(err error) (any, bool) {
for e := err; e != nil; e = errors.Unwrap(e) {
if api.TryTypedValue(e) != nil {
return e, true
Comment on lines +291 to +293

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

In Go (1.20+), does errors.Unwrap traverse errors that implement Unwrap() []error (e.g., errors.Join or fmt.Errorf with multiple %w), or only Unwrap() error?

💡 Result:

In Go (1.20+), the errors.Unwrap function only traverses errors that implement the Unwrap error method [1][2]. It does not traverse errors that implement Unwrap []error (such as those created by errors.Join) [1][3]. If you call errors.Unwrap on an error that implements only Unwrap []error, it will return nil [1][2]. While the errors.Is and errors.As functions are designed to handle both Unwrap error and Unwrap []error (performing a depth-first search through the error tree for the latter) [4][2][5], the errors.Unwrap function remains restricted to the standard linked-list style of unwrapping [1][3]. If you need to access multiple errors joined together, you must perform a type assertion to the interface that provides the Unwrap []error method [6]: if uw, ok:= err.(interface{ Unwrap []error }); ok { errs:= uw.Unwrap // process the slice of errors }

Citations:


Handle joined/multi-error unwrapping in renderableError
renderableError walks only errors.Unwrap(e) (single Unwrap() error chain), so it will miss renderable causes reachable via Unwrap() []error (e.g., errors.Join(...) or fmt.Errorf with multiple %w). Switch to an errors.As-based approach for api.TryTypedValue’s target type, or explicitly traverse interface{ Unwrap() []error } in addition to Unwrap() error to keep rich error formatting consistent.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cobra_command.go` around lines 291 - 293, The renderableError routine
currently only follows the single-error chain via errors.Unwrap(e) and thus
misses causes inside multi-error joins; update renderableError to detect and
traverse both single Unwrap() error and multi Unwrap() []error cases (or replace
the loop with an errors.As-style search) so api.TryTypedValue targets are found
in joined errors—specifically, modify the loop that checks api.TryTypedValue(e)
to also, when present, call Unwrap() []error and recursively inspect each child
(or use errors.As against the concrete type that TryTypedValue expects) to
ensure all renderable causes are discovered.

}
}
return nil, false
}

type Name interface {
GetName() string
}
48 changes: 48 additions & 0 deletions cobra_command_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package clicky

import (
"errors"
"fmt"
"testing"

"github.com/flanksource/clicky/api"
)

// prettyErr is a test error that carries a clicky rendering interface —
// the shape renderableError must detect so the command runner renders it
// through the format pipeline instead of collapsing it to Error().
type prettyErr struct{ msg string }

func (e *prettyErr) Error() string { return e.msg }
func (e *prettyErr) Pretty() api.Text { return api.Text{Content: "rendered: " + e.msg} }

func TestRenderableError_DetectsErrorImplementingPretty(t *testing.T) {
rich, ok := renderableError(&prettyErr{msg: "boom"})
if !ok {
t.Fatalf("renderableError must detect an error implementing Pretty()")
}
if api.TryTypedValue(rich) == nil {
t.Fatalf("the returned value must be renderable by the format pipeline")
}
}

func TestRenderableError_WalksWrappedChain(t *testing.T) {
// A fmt-wrapped rich error must still be detected — clicky walks the
// Unwrap chain so a renderable cause survives an outer fmt.Errorf.
wrapped := fmt.Errorf("dispatching: %w", &prettyErr{msg: "boom"})
rich, ok := renderableError(wrapped)
if !ok {
t.Fatalf("renderableError must walk the Unwrap chain to find a renderable cause")
}
if _, isPretty := rich.(*prettyErr); !isPretty {
t.Fatalf("renderableError must return the matched *prettyErr, got %T", rich)
}
}

func TestRenderableError_PlainErrorIsNotRenderable(t *testing.T) {
// A plain error carries no rendering interface — the command runner
// must fall back to logging Error(), not the render pipeline.
if _, ok := renderableError(errors.New("plain failure")); ok {
t.Fatalf("a plain error must not be reported as renderable")
}
}
44 changes: 34 additions & 10 deletions entity.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,11 @@ type ActionInfo struct {
// and populated into the flag map passed to DataFunc.
FlagsType reflect.Type
ResponseType reflect.Type
// OptionalID, when true, makes the positional <id> argument optional on
// the generated action command — the action is invokable with no
// argument (and the `id` passed to the run func is empty). Use for
// actions whose target is supplied entirely through flags.
OptionalID bool
}

// BulkActionInfo is the type-erased representation of a bulk action.
Expand Down Expand Up @@ -135,11 +140,12 @@ type EntityAction interface {
}

type actionSpec[R any] struct {
name string
short string
method string
run func(id string, flags map[string]string) (R, error)
flags ActionFlags
name string
short string
method string
run func(id string, flags map[string]string) (R, error)
flags ActionFlags
optionalID bool
}

// Action creates a typed custom operation on a single entity by ID.
Expand Down Expand Up @@ -169,19 +175,28 @@ func (a *actionSpec[R]) WithMethod(method string) *actionSpec[R] {
return a
}

// WithOptionalID makes the positional <id> argument optional on the
// generated action command. Use for actions whose target is supplied
// entirely through flags; the `id` passed to the run func is then empty.
func (a *actionSpec[R]) WithOptionalID() *actionSpec[R] {
a.optionalID = true
return a
}

func (a *actionSpec[R]) actionInfo() ActionInfo {
return ActionInfo{
Name: a.name,
Short: a.short,
Method: a.method,
FlagsType: actionFlagsType(a.flags),
ResponseType: responseTypeOf[R](),
OptionalID: a.optionalID,
DataFunc: func(flagMap map[string]string, args []string) (any, error) {
id := flagMap["id"]
if id == "" && len(args) > 0 {
id = args[0]
}
if id == "" {
if id == "" && !a.optionalID {
return nil, fmt.Errorf("id is required")
}
return a.run(id, flagMap)
Expand Down Expand Up @@ -621,7 +636,7 @@ func generateEntityCLI(parent *cobra.Command, entity EntityInfo) {
DataFunc: action.DataFunc,
FlagsType: action.FlagsType,
ResponseType: action.ResponseType,
}, entity.ValidArgs, "action", "", "entity", action.Name, "id", false, false)
}, entity.ValidArgs, "action", "", "entity", action.Name, "id", false, false, action.OptionalID)
}

for _, ba := range entity.BulkActions {
Expand All @@ -647,6 +662,7 @@ func generateEntitySubcommand(parent *cobra.Command, entity EntityInfo, op Entit
"id",
false,
false,
false,
)
case "create":
generateBodyCommand(parent, "create", fmt.Sprintf("Create a %s", entity.Name), op)
Expand All @@ -666,6 +682,7 @@ func generateEntitySubcommand(parent *cobra.Command, entity EntityInfo, op Entit
"id",
false,
false,
false,
)
}
}
Expand Down Expand Up @@ -720,16 +737,23 @@ func generateIDCommand(
idParam string,
supportsLookup bool,
supportsFilterMode bool,
optionalID bool,
) {
hasFlags := op.FlagsType != nil
use := fmt.Sprintf("%s <id>", verb)
idToken := "<id>"
args := cobra.ExactArgs(1)
if optionalID {
idToken = "[id]"
args = cobra.MaximumNArgs(1)
}
use := fmt.Sprintf("%s %s", verb, idToken)
if hasFlags {
use = fmt.Sprintf("%s <id> [flags]", verb)
use = fmt.Sprintf("%s %s [flags]", verb, idToken)
}
cmd := &cobra.Command{
Use: use,
Short: short,
Args: cobra.ExactArgs(1),
Args: args,
RunE: func(c *cobra.Command, args []string) error {
var flagMap map[string]string
if hasFlags {
Expand Down
73 changes: 73 additions & 0 deletions entity_filters_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -624,3 +624,76 @@ func TestEntityValidArgsPropagateToGeneratedIDCommands(t *testing.T) {
}
}
}

func TestActionWithOptionalIDAcceptsNoPositionalArg(t *testing.T) {
resetEntityRegistry(t)
defer resetEntityRegistry(t)

RegisterEntity(Entity[entityFilterTestEntity, entityFilterTestOpts, any]{
Name: "optional-id-entity",
Get: func(id string) (any, error) { return id, nil },
Actions: []EntityAction{
Action("scan", func(id string, _ map[string]string) (any, error) {
return id, nil
}).WithShort("Scan with no id").WithOptionalID(),
Action("restart", func(id string, _ map[string]string) (any, error) {
return id, nil
}).WithShort("Restart one entity"),
},
})

root := &cobra.Command{Use: "root"}
GenerateCLI(root)

scan, _, err := root.Find([]string{"optional-id-entity", "scan"})
if err != nil || scan == nil {
t.Fatalf("expected to find scan command, got err=%v", err)
}
// The optional-id action accepts zero args but still rejects more than one.
if err := scan.Args(scan, nil); err != nil {
t.Fatalf("optional-id action must accept zero args, got: %v", err)
}
if err := scan.Args(scan, []string{"x", "y"}); err == nil {
t.Fatalf("optional-id action must still reject more than one arg")
}
if scan.Use != "scan [id]" {
t.Fatalf("optional-id action use should show [id], got %q", scan.Use)
}

// A normal action still forces exactly one positional arg.
restart, _, err := root.Find([]string{"optional-id-entity", "restart"})
if err != nil || restart == nil {
t.Fatalf("expected to find restart command, got err=%v", err)
}
if err := restart.Args(restart, nil); err == nil {
t.Fatalf("a normal action must still require its id arg")
}
if restart.Use != "restart <id>" {
t.Fatalf("normal action use should show <id>, got %q", restart.Use)
}
}

func TestActionInfoOptionalIDSkipsIDRequiredCheck(t *testing.T) {
var gotID = "sentinel"
spec := Action("scan", func(id string, _ map[string]string) (any, error) {
gotID = id
return id, nil
}).WithOptionalID()

// The DataFunc must not error on a missing id when OptionalID is set;
// the run func receives an empty id.
if _, err := spec.actionInfo().DataFunc(map[string]string{}, nil); err != nil {
t.Fatalf("optional-id DataFunc must not require an id: %v", err)
}
if gotID != "" {
t.Fatalf("expected empty id passed to run func, got %q", gotID)
}

// Without WithOptionalID the missing-id check still fires.
plain := Action("restart", func(id string, _ map[string]string) (any, error) {
return id, nil
})
if _, err := plain.actionInfo().DataFunc(map[string]string{}, nil); err == nil {
t.Fatalf("a normal action must still reject a missing id")
}
}
Loading
Loading