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
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
160 changes: 160 additions & 0 deletions internal/craken/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package craken

import (
"context"
"encoding/json"
"fmt"
"io"
"strings"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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

Expand Down
119 changes: 119 additions & 0 deletions internal/craken/command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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" {
Expand Down
28 changes: 20 additions & 8 deletions internal/craken/generic.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down