diff --git a/docs/architecture.md b/docs/architecture.md index bc33d69..3c481ed 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -6,7 +6,7 @@ The Worker remains the source of truth for product behavior. Dedicated commands Wiki saves follow the service's optimistic concurrency contract. Creating a missing page can omit `--base-version`; updating an existing page should read the page first, edit against the returned `latestVersionNumber`, and pass that number as `--base-version`. A stale base version returns the server's 409 conflict payload so scripts can re-read, merge, and retry. -The default help fetches `/api/client` once and renders the server-owned command syntax, descriptions, examples, and operation routes available to the selected bearer session. Authentication and generic transport flags stay local bootstrap behavior; the product command surface belongs to the Worker so API changes do not require a CLI release just to discover or invoke new operations. +The default help fetches `/api/client` once and renders the server-owned command syntax, descriptions, examples, and operation routes available to the selected bearer session. Operation help such as `craken channel messages --help` focuses on the matching server command when it is present, then augments it with local option knowledge and any route metadata the catalog exposes. Authentication and generic transport flags stay local bootstrap behavior; the product command surface belongs to the Worker so API changes do not require a CLI release just to discover or invoke new operations. The profile file is intentionally compatible with the prior Node CLI: diff --git a/internal/craken/command.go b/internal/craken/command.go index d246818..c3f7167 100644 --- a/internal/craken/command.go +++ b/internal/craken/command.go @@ -2,6 +2,7 @@ package craken import ( "context" + "encoding/json" "fmt" "io" "strings" @@ -114,6 +115,10 @@ func parseCommand(args []string) (command, error) { } } if cmd.Action == "" { + if cmd.Help { + cmd.Action = "help" + return cmd, nil + } if cmd.Resource == "workspace" { cmd.Action = "list" } else { @@ -176,9 +181,164 @@ func runHelp(ctx context.Context, cmd command, stdout io.Writer) error { if err != nil { return err } + if command, route := focusedHelpTarget(cmd, catalog); command != nil { + return printFocusedCommandHelp(stdout, *command, route) + } return printCatalogHelp(stdout, catalog) } +func focusedHelpTarget(cmd command, catalog clientCatalog) (*cliCommand, *route) { + if cmd.Resource == "" || cmd.Action == "" || cmd.Action == "help" { + return nil, nil + } + id := cmd.Resource + "." + cmd.Action + for i := range catalog.Commands { + if catalog.Commands[i].ID == id { + return &catalog.Commands[i], routeByID(catalog.Routes, catalog.Commands[i].OperationID) + } + } + return nil, nil +} + +func routeByID(routes []route, id string) *route { + if id == "" { + return nil + } + for i := range routes { + if routes[i].ID == id { + return &routes[i] + } + } + return nil +} + +func printFocusedCommandHelp(stdout io.Writer, command cliCommand, route *route) error { + if _, err := fmt.Fprintf(stdout, "Usage:\n %s\n\n%s\n", command.Command, command.Description); err != nil { + return err + } + if len(command.Examples) > 0 { + if _, err := fmt.Fprint(stdout, "\nExamples:\n"); err != nil { + return err + } + for _, example := range command.Examples { + if _, err := fmt.Fprintf(stdout, " e.g. %s\n", example); err != nil { + return err + } + } + } + if options := localCommandHelpOptions(command.ID); len(options) > 0 { + if _, err := fmt.Fprint(stdout, "\nOptions:\n"); err != nil { + return err + } + for _, option := range options { + if _, err := fmt.Fprintf(stdout, " %s\n", option); err != nil { + return err + } + } + } + if route == nil { + return nil + } + if _, err := fmt.Fprintf(stdout, "\nOperation:\n %s\t%s\t%s\n", route.ID, route.Method, route.Path); err != nil { + return err + } + if route.Description != "" && route.Description != command.Description { + if _, err := fmt.Fprintf(stdout, " %s\n", route.Description); err != nil { + return err + } + } + for _, group := range []struct { + title string + fields []catalogField + }{ + {title: "Path parameters", fields: route.PathParameters}, + {title: "Query parameters", fields: route.QueryParameters}, + {title: "Body fields", fields: route.BodyFields}, + } { + if err := printCatalogFieldGroup(stdout, group.title, group.fields); err != nil { + return err + } + } + if route.ResponseExample != nil { + if err := printResponseExample(stdout, route.ResponseExample); err != nil { + return err + } + } + return nil +} + +func printCatalogFieldGroup(stdout io.Writer, title string, fields []catalogField) error { + if len(fields) == 0 { + return nil + } + if _, err := fmt.Fprintf(stdout, "\n%s:\n", title); err != nil { + return err + } + for _, field := range fields { + detail := fieldDetail(field) + if detail == "" { + if _, err := fmt.Fprintf(stdout, " --%s\n", kebabCase(field.Name)); err != nil { + return err + } + continue + } + if _, err := fmt.Fprintf(stdout, " --%s %s\n", kebabCase(field.Name), detail); err != nil { + return err + } + } + return nil +} + +func fieldDetail(field catalogField) string { + parts := []string{} + if field.Type != "" { + parts = append(parts, field.Type) + } + if len(field.Values) > 0 { + parts = append(parts, strings.Join(field.Values, "|")) + } + if field.Required { + parts = append(parts, "required") + } + if field.Description != "" { + parts = append(parts, field.Description) + } + return strings.Join(parts, "; ") +} + +func printResponseExample(stdout io.Writer, value any) error { + bytes, err := json.MarshalIndent(value, " ", "\t") + if err != nil { + return err + } + text := strings.ReplaceAll(string(bytes), "\n", "\n ") + _, err = fmt.Fprintf(stdout, "\nResponse example:\n %s\n", text) + return err +} + +func localCommandHelpOptions(commandID string) []string { + switch commandID { + case "channel.messages", "dm.messages", "dm.list": + return []string{ + "--position latest|start Read the latest page or the beginning of the conversation.", + "--before MESSAGE_ID Read older messages before a response oldestCursor.", + "--after MESSAGE_ID Read newer messages after a response newestCursor.", + "--around MESSAGE_ID Read a page ending at a known message id.", + } + case "wiki.recent": + return []string{"--limit N Limit recent wiki changes."} + case "workspace.activity": + return []string{ + "--anchor-json JSON Activity anchor JSON.", + "--before-sequence N Read activity before a sequence cursor.", + "--limit N Limit returned activity rows.", + "--surfaces LIST Comma-separated activity surfaces.", + } + default: + return nil + } +} + func printCatalogHelp(stdout io.Writer, catalog clientCatalog) error { if _, err := fmt.Fprint(stdout, `Craken CLI diff --git a/internal/craken/command_test.go b/internal/craken/command_test.go index be19c15..cd00018 100644 --- a/internal/craken/command_test.go +++ b/internal/craken/command_test.go @@ -116,6 +116,125 @@ func TestHelpRendersServerCatalogWithOptionalBearer(t *testing.T) { } } +func TestFocusedHelpRendersServerCommandLocalOptionsAndMetadata(t *testing.T) { + configDir := t.TempDir() + t.Setenv("CRAKEN_CONFIG_DIR", configDir) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/client" { + t.Fatalf("unexpected help path %s", r.URL.Path) + } + writeJSON(t, w, map[string]any{ + "commands": []map[string]any{ + { + "command": "craken channel messages --workspace WORKSPACE --channel CHANNEL [--after MESSAGE_ID] [--before MESSAGE_ID] [--around MESSAGE_ID] [--position latest|start]", + "description": "List channel messages with cursor pagination.", + "examples": []string{"craken channel messages --workspace W --channel C --after MESSAGE_ID"}, + "group": "Channel", + "id": "channel.messages", + "operationId": "channels.messages.list", + }, + { + "command": "craken workspace activity --workspace WORKSPACE [--limit N]", + "description": "Read workspace activity.", + "group": "Workspace", + "id": "workspace.activity", + "operationId": "workspaces.activity", + }, + { + "command": "craken wiki recent --workspace WORKSPACE", + "description": "List recent wiki changes.", + "group": "Wiki", + "id": "wiki.recent", + "operationId": "wiki.recent-changes", + }, + }, + "routes": []map[string]any{ + { + "auth": "required", + "description": "List channel messages. Response cursors are message ids.", + "id": "channels.messages.list", + "method": "GET", + "path": "/api/workspaces/{workspaceId}/channels/{channelId}/messages", + "queryParameters": []map[string]any{ + {"description": "Read newer messages after this message id.", "name": "after", "type": "string"}, + {"description": "Read older messages before this message id.", "name": "before", "type": "string"}, + }, + "requestBody": "none", + "responseExample": map[string]any{ + "messages": []map[string]any{{"id": "message-1"}}, + "newestCursor": "message-1", + }, + }, + { + "auth": "required", + "description": "Read workspace activity.", + "id": "workspaces.activity", + "method": "GET", + "path": "/api/workspaces/{workspaceId}/activity", + "requestBody": "none", + "responseExample": map[string]any{ + "activities": []map[string]any{{"sequence": 1}}, + }, + }, + { + "auth": "required", + "description": "List wiki recent changes.", + "id": "wiki.recent-changes", + "method": "GET", + "path": "/api/workspaces/{workspaceId}/wiki/recent-changes", + "requestBody": "none", + }, + }, + "schemaVersion": 1, + }) + })) + defer server.Close() + + var stdout bytes.Buffer + if err := Run(context.Background(), "dev", []string{"channel", "messages", "--base-url", server.URL, "--help"}, strings.NewReader(""), &stdout, &bytes.Buffer{}); err != nil { + t.Fatal(err) + } + channelHelp := stdout.String() + for _, expected := range []string{ + "Usage:", + "craken channel messages --workspace WORKSPACE --channel CHANNEL", + "--after MESSAGE_ID", + "--before MESSAGE_ID", + "--around MESSAGE_ID", + "--position latest|start", + "Query parameters:", + "Response example:", + "newestCursor", + "e.g.", + } { + if !strings.Contains(channelHelp, expected) { + t.Fatalf("expected focused channel help to contain %q, got:\n%s", expected, channelHelp) + } + } + if strings.Contains(channelHelp, "Server commands:") { + t.Fatalf("expected focused help, got global help:\n%s", channelHelp) + } + + stdout.Reset() + if err := Run(context.Background(), "dev", []string{"workspace", "activity", "--base-url", server.URL, "--help"}, strings.NewReader(""), &stdout, &bytes.Buffer{}); err != nil { + t.Fatal(err) + } + workspaceHelp := stdout.String() + for _, expected := range []string{"--anchor-json JSON", "--before-sequence N", "--surfaces LIST", "activities"} { + if !strings.Contains(workspaceHelp, expected) { + t.Fatalf("expected focused workspace help to contain %q, got:\n%s", expected, workspaceHelp) + } + } + + stdout.Reset() + if err := Run(context.Background(), "dev", []string{"wiki", "recent", "--base-url", server.URL, "--help"}, strings.NewReader(""), &stdout, &bytes.Buffer{}); err != nil { + t.Fatal(err) + } + if help := stdout.String(); !strings.Contains(help, "--limit N") { + t.Fatalf("expected focused wiki help to contain --limit N, got:\n%s", help) + } +} + func TestCommandsTextRendersServerCommands(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/client" { diff --git a/internal/craken/generic.go b/internal/craken/generic.go index 1c485c7..8219151 100644 --- a/internal/craken/generic.go +++ b/internal/craken/generic.go @@ -16,14 +16,18 @@ import ( var pathParamPattern = regexp.MustCompile(`\{([^}/]+)\}`) type route struct { - ID string `json:"id"` - Method string `json:"method"` - Path string `json:"path"` - Description string `json:"description"` - Auth string `json:"auth"` - Capability string `json:"capability,omitempty"` - RequestBody string `json:"requestBody"` - Stream string `json:"stream,omitempty"` + ID string `json:"id"` + Method string `json:"method"` + Path string `json:"path"` + Description string `json:"description"` + Auth string `json:"auth"` + Capability string `json:"capability,omitempty"` + PathParameters []catalogField `json:"pathParameters,omitempty"` + QueryParameters []catalogField `json:"queryParameters,omitempty"` + BodyFields []catalogField `json:"bodyFields,omitempty"` + RequestBody string `json:"requestBody"` + ResponseExample any `json:"responseExample,omitempty"` + Stream string `json:"stream,omitempty"` } type clientCatalog struct { @@ -55,6 +59,14 @@ type shortcut struct { Resource string `json:"resource"` } +type catalogField struct { + Name string `json:"name"` + Type string `json:"type,omitempty"` + Description string `json:"description,omitempty"` + Required bool `json:"required,omitempty"` + Values []string `json:"values,omitempty"` +} + func runCommands(ctx context.Context, client *client, cmd command, stdout io.Writer) error { value, err := client.json(ctx, "GET", "/api/client", nil) if err != nil {