diff --git a/VERSION b/VERSION index fad30c5..1474d00 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v0.1.7 +v0.2.0 diff --git a/docs/architecture.md b/docs/architecture.md index 7b6bf85..0b712f3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,16 +1,16 @@ # Architecture -`craken` is a small Go CLI that mirrors the Craken product API surface. It keeps machine-local behavior in the binary: profile storage, device-code browser login polling, request formatting, file upload/download, and realtime WebSocket subscriptions. +`craken` is a small Go CLI that executes the server-owned client catalog. It keeps machine-local behavior in the binary: profile storage, device-code browser login polling, request formatting, file upload/download, polling loops, and WebSocket transport. -The Worker remains the source of truth for product behavior. Dedicated commands call stable API routes directly, while `commands`, `do`, and raw HTTP commands discover and invoke the server-owned `/api/client` catalog so new routes and command syntax are usable before a CLI release is needed. +The Worker remains the source of truth for product behavior. Product shortcuts such as `workspace list`, `channel send`, and `wiki save` are discovered from the server-owned `/api/client` catalog and executed from the catalog's command plan. Those plans describe path bindings, lookup resolvers, multipart fields, download behavior, polling termination, WebSocket protocols, and compact table output. The binary keeps only local bootstrap and generic transport primitives. `commands`, `do`, and raw HTTP commands use the same catalog route contract as the ergonomic shortcuts. 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. 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 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 execute new operations. Message page commands pass cursor options and `--limit` through to the Worker so service-side validation, clamping, and cursor semantics remain the source of truth. -Dedicated message and wiki-history reads can format service JSON locally. `--compact` emits stable tab-separated rows for shell loops, while `--fields` keeps JSON output but projects only the requested dotted paths so scripts can omit large fields such as profile pictures without changing the service response contract. +Catalog commands can advertise table output plans. `--compact` emits those server-described columns as tab-separated rows for shell loops, while `--fields` keeps JSON output but projects only the requested dotted paths so scripts can omit large fields such as profile pictures without changing the service response contract. The profile file is intentionally compatible with the prior Node CLI: @@ -22,4 +22,4 @@ Each profile stores `baseUrl` and `token`. `CRAKEN_PROFILE`, `CRAKEN_TOKEN`, and `auth login` starts a server-side device login through `/api/client/device-authorizations`, prints the short code and verification URL, and polls `/api/client/device-token` until the browser approves the code. The CLI never opens a loopback callback listener, so the same command works from local shells, SSH sessions, and containers. -Realtime subscriptions use the Craken bearer WebSocket subprotocol pair: `craken-bearer` plus a base64url JSON payload protocol carrying the selected token. `channel wait` is the channel-scoped alternative for agent loops that need one bounded long-poll response instead of the full workspace stream. +Realtime subscriptions use the WebSocket protocols advertised by the catalog command plan. `channel wait` is the channel-scoped alternative for agent loops that need one bounded long-poll response instead of the full workspace stream. diff --git a/internal/craken/catalog_binding.go b/internal/craken/catalog_binding.go new file mode 100644 index 0000000..254fd49 --- /dev/null +++ b/internal/craken/catalog_binding.go @@ -0,0 +1,320 @@ +package craken + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" +) + +func catalogValues( + ctx context.Context, + client *client, + routes []route, + cmd command, + bindings map[string]commandBinding, + resolved map[string]string, + consumed map[string]bool, +) (map[string]any, error) { + values := map[string]any{} + for name, binding := range bindings { + value, ok, err := catalogBindingValue(ctx, client, routes, cmd, binding, resolved, consumed) + if err != nil { + return nil, err + } + if ok { + values[name] = value + } + } + return values, nil +} + +func catalogBindingString( + ctx context.Context, + client *client, + routes []route, + cmd command, + binding commandBinding, + resolved map[string]string, + consumed map[string]bool, +) (string, error) { + value, ok, err := catalogBindingValue(ctx, client, routes, cmd, binding, resolved, consumed) + if err != nil { + return "", err + } + if !ok { + return "", nil + } + text, ok := value.(string) + if !ok { + return fmt.Sprint(value), nil + } + return text, nil +} + +func catalogBindingValue( + ctx context.Context, + client *client, + routes []route, + cmd command, + binding commandBinding, + resolved map[string]string, + consumed map[string]bool, +) (any, bool, error) { + switch binding.Source { + case commandBindingSourceBearerToken: + return client.token, client.token != "", nil + case commandBindingSourceLiteral: + return binding.Value, true, nil + case commandBindingSourceResolved: + value := resolved[binding.Name] + if value == "" { + if binding.Required { + return nil, false, fmt.Errorf("expected resolved value %s", binding.Name) + } + return nil, false, nil + } + return value, true, nil + case commandBindingSourceFlag: + if binding.Option == "" { + return nil, false, nil + } + consumed[binding.Option] = true + return boolOption(cmd, binding.Option), true, nil + case commandBindingSourceText: + value, ok, err := textBindingValue(cmd, binding) + if err != nil || !ok { + if binding.Required && !ok { + return nil, false, fmt.Errorf("expected --%s", binding.Option) + } + return value, ok, err + } + resolvedValue, err := resolveBoundValue(ctx, client, routes, binding, fmt.Sprint(value), resolved) + return resolvedValue, true, err + case commandBindingSourceOption, "": + value, source, ok := bindingOptionValue(cmd, binding) + if !ok { + if binding.Default != nil { + return binding.Default, true, nil + } + if binding.Required { + return nil, false, fmt.Errorf("expected --%s", binding.Option) + } + return nil, false, nil + } + consumed[source] = true + if binding.Type == commandBindingValueTypeJSON { + parsed, err := jsonBindingValue(value, source) + return parsed, true, err + } + if binding.Type == commandBindingValueTypeInteger { + parsed, err := strconv.Atoi(value) + if err != nil { + return nil, false, fmt.Errorf("expected integer for --%s, got %s", source, value) + } + return parsed, true, nil + } + resolvedValue, err := resolveBoundValue(ctx, client, routes, binding, value, resolved) + return resolvedValue, true, err + default: + return nil, false, fmt.Errorf("unsupported catalog binding source: %s", binding.Source) + } +} + +func bindingOptionValue(cmd command, binding commandBinding) (string, string, bool) { + for _, name := range bindingOptionNames(binding) { + if value, ok := cmd.Options[name]; ok { + return value, name, true + } + if value, ok := cmd.Flags[name]; ok && value { + return "true", name, true + } + } + return "", "", false +} + +func bindingOptionNames(binding commandBinding) []string { + names := []string{} + if binding.Option != "" { + names = append(names, binding.Option) + } + names = append(names, binding.Aliases...) + return names +} + +func textBindingValue(cmd command, binding commandBinding) (string, bool, error) { + value, valueSource, hasValue := bindingOptionValue(cmd, commandBinding{Option: binding.Option}) + file, fileSource, hasFile := bindingOptionValue(cmd, commandBinding{Option: binding.FileOption}) + if hasValue && hasFile { + return "", false, fmt.Errorf("use either --%s or --%s, not both", valueSource, fileSource) + } + if hasFile { + bytes, err := os.ReadFile(filepath.Clean(file)) + return string(bytes), true, err + } + if hasValue { + return value, true, nil + } + if binding.Positionals == commandBindingPositionalsJoin && len(cmd.Positionals) > 0 { + return strings.Join(cmd.Positionals, " "), true, nil + } + return "", false, nil +} + +func jsonBindingValue(value string, source string) (any, error) { + if strings.HasSuffix(source, "-file") { + bytes, err := os.ReadFile(filepath.Clean(value)) + if err != nil { + return nil, err + } + value = string(bytes) + } + var parsed any + if err := json.Unmarshal([]byte(value), &parsed); err != nil { + return nil, err + } + return parsed, nil +} + +func resolveBoundValue( + ctx context.Context, + client *client, + routes []route, + binding commandBinding, + value string, + resolved map[string]string, +) (string, error) { + if binding.Resolver == nil { + return value, nil + } + return resolveCatalogValue(ctx, client, routes, *binding.Resolver, value, resolved) +} + +func resolveCatalogValue( + ctx context.Context, + client *client, + routes []route, + plan commandResolverPlan, + value string, + resolved map[string]string, +) (string, error) { + route := routeByID(routes, plan.OperationID) + if route == nil { + return "", fmt.Errorf("unknown resolver operation: %s", plan.OperationID) + } + path, err := catalogResolverPath(ctx, client, routes, *route, plan.PathParams, resolved) + if err != nil { + return "", err + } + root, err := client.json(ctx, path) + if err != nil { + return "", err + } + items, ok := valueAtPath(root, plan.CollectionPath).([]any) + if !ok { + return "", fmt.Errorf("resolver %s expected array at %s", planLabel(plan), plan.CollectionPath) + } + var matched any + for _, item := range items { + if matchesCatalogResolverItem(item, plan.MatchFields, value) { + if matched != nil { + return "", fmt.Errorf("multiple %s matches for %s", planLabel(plan), value) + } + matched = item + } + } + if matched == nil { + return "", fmt.Errorf("unknown %s: %s", planLabel(plan), value) + } + result := valueString(valueAtPath(matched, plan.ResultPath)) + if plan.RequiredResultPrefix != "" && !strings.HasPrefix(result, plan.RequiredResultPrefix) { + return "", fmt.Errorf("%s must resolve to %s value, got %s", planLabel(plan), plan.RequiredResultPrefix, result) + } + if plan.TrimResultPrefix != "" { + result = strings.TrimPrefix(result, plan.TrimResultPrefix) + } + if result == "" { + return "", fmt.Errorf("resolver %s returned empty %s", planLabel(plan), plan.ResultPath) + } + return result, nil +} + +func catalogResolverPath( + ctx context.Context, + client *client, + routes []route, + route route, + pathParams map[string]commandBinding, + resolved map[string]string, +) (string, error) { + path := pathParamPattern.ReplaceAllStringFunc(route.Path, func(match string) string { + name := match[1 : len(match)-1] + binding := pathParams[name] + if binding.Source == "" { + return "\x00missing:" + name + } + value, err := catalogBindingString(ctx, client, routes, command{}, binding, resolved, map[string]bool{}) + if err != nil { + return "\x00error:" + err.Error() + } + if value == "" { + return "\x00missing:" + name + } + return url.PathEscape(value) + }) + if strings.Contains(path, "\x00error:") { + return "", fmt.Errorf("%s", strings.TrimPrefix(path[strings.Index(path, "\x00error:"):], "\x00error:")) + } + if strings.Contains(path, "\x00missing:") { + name := strings.TrimPrefix(path[strings.Index(path, "\x00missing:"):], "\x00missing:") + return "", fmt.Errorf("resolver %s requires path binding %s", route.ID, name) + } + return path, nil +} + +func matchesCatalogResolverItem(item any, fields []string, value string) bool { + for _, field := range fields { + itemValue := valueString(valueAtPath(item, field)) + if strings.EqualFold(itemValue, value) { + return true + } + } + return false +} + +func valueAtPath(value any, path string) any { + if path == "" { + return value + } + current := value + for _, part := range strings.Split(path, ".") { + object, ok := current.(map[string]any) + if !ok { + return nil + } + current = object[part] + } + return current +} + +func valueString(value any) string { + switch typed := value.(type) { + case string: + return typed + case nil: + return "" + default: + return fmt.Sprint(typed) + } +} + +func planLabel(plan commandResolverPlan) string { + if plan.Label != "" { + return plan.Label + } + return plan.OperationID +} diff --git a/internal/craken/catalog_command.go b/internal/craken/catalog_command.go new file mode 100644 index 0000000..f4d2f1d --- /dev/null +++ b/internal/craken/catalog_command.go @@ -0,0 +1,305 @@ +package craken + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" +) + +func runCatalogCommand(ctx context.Context, client *client, cmd command, stdout io.Writer, stdin io.Reader) error { + catalogValue, err := client.json(ctx, "/api/client") + if err != nil { + return err + } + catalog, err := catalogFromValue(catalogValue) + if err != nil { + return err + } + serverCommand := catalogCommandByID(catalog.Commands, cmd.Resource+"."+cmd.Action) + if serverCommand == nil { + return fmt.Errorf("unknown command: %s %s", cmd.Resource, cmd.Action) + } + plan := selectedExecution(*serverCommand, cmd) + if plan.OperationID == "" { + plan.OperationID = serverCommand.OperationID + } + if plan.Transport == "" { + plan.Transport = commandTransportHTTP + } + selectedRoute := routeByID(catalog.Routes, plan.OperationID) + if selectedRoute == nil { + return fmt.Errorf("unknown operation for %s: %s", serverCommand.ID, plan.OperationID) + } + requestPath, resolved, consumed, err := catalogCommandPath(ctx, client, catalog.Routes, *selectedRoute, plan, cmd) + if err != nil { + return err + } + switch plan.Transport { + case commandTransportHTTP: + return runCatalogHTTPCommand(ctx, client, catalog.Routes, *selectedRoute, plan, cmd, requestPath, resolved, consumed, stdout, stdin) + case commandTransportDownload: + return runCatalogDownloadCommand(ctx, client, *selectedRoute, cmd, requestPath, stdout) + case commandTransportMultipart: + return runCatalogMultipartCommand(ctx, client, catalog.Routes, plan, cmd, requestPath, resolved, consumed, stdout) + case commandTransportWebSocket: + return runCatalogWebSocketCommand(ctx, client, catalog.Routes, plan, cmd, requestPath, resolved, consumed, stdout) + default: + return fmt.Errorf("unsupported catalog command transport: %s", plan.Transport) + } +} + +func catalogCommandByID(commands []cliCommand, id string) *cliCommand { + for i := range commands { + if commands[i].ID == id { + return &commands[i] + } + } + return nil +} + +func selectedExecution(command cliCommand, cmd command) commandExecution { + base := command.Execution + if base.OperationID == "" { + base.OperationID = command.OperationID + } + for _, variant := range command.Execution.Variants { + if variant.When.Option != "" && cmd.string(variant.When.Option, "") == "" && !cmd.Flags[variant.When.Option] { + continue + } + return mergeExecution(base, variant) + } + return base +} + +func mergeExecution(base commandExecution, variant commandExecutionVariant) commandExecution { + if variant.OperationID != "" { + base.OperationID = variant.OperationID + } + if variant.Transport != "" { + base.Transport = variant.Transport + } + if !isEmptyMultipartPlan(variant.Multipart) { + base.Multipart = variant.Multipart + } + if variant.Output != nil { + base.Output = variant.Output + } + if variant.Poll != nil { + base.Poll = variant.Poll + } + if variant.PathParams != nil { + base.PathParams = variant.PathParams + } + if variant.QueryParams != nil { + base.QueryParams = variant.QueryParams + } + if variant.BodyFields != nil { + base.BodyFields = variant.BodyFields + } + if len(variant.WebSocket.Protocols) > 0 { + base.WebSocket = variant.WebSocket + } + return base +} + +func isEmptyMultipartPlan(plan commandMultipartPlan) bool { + return plan.FileOption == "" && plan.FileField == "" && len(plan.Fields) == 0 +} + +func catalogCommandPath(ctx context.Context, client *client, routes []route, route route, plan commandExecution, cmd command) (string, map[string]string, map[string]bool, error) { + consumed := map[string]bool{} + resolved := map[string]string{} + path := pathParamPattern.ReplaceAllStringFunc(route.Path, func(match string) string { + name := match[1 : len(match)-1] + binding := plan.PathParams[name] + if binding.Source == "" { + return "\x00missing:" + name + } + value, err := catalogBindingString(ctx, client, routes, cmd, binding, resolved, consumed) + if err != nil { + return "\x00error:" + err.Error() + } + if value == "" { + return "\x00missing:" + name + } + resolved[name] = value + return url.PathEscape(value) + }) + if strings.Contains(path, "\x00error:") { + return "", nil, nil, errors.New(strings.TrimPrefix(path[strings.Index(path, "\x00error:"):], "\x00error:")) + } + if strings.Contains(path, "\x00missing:") { + name := strings.TrimPrefix(path[strings.Index(path, "\x00missing:"):], "\x00missing:") + return "", nil, nil, fmt.Errorf("expected --%s for %s", kebabCase(name), route.Path) + } + return path, resolved, consumed, nil +} + +func runCatalogHTTPCommand( + ctx context.Context, + client *client, + routes []route, + route route, + plan commandExecution, + cmd command, + path string, + resolved map[string]string, + consumed map[string]bool, + stdout io.Writer, + stdin io.Reader, +) error { + if plan.Poll != nil { + return runCatalogPollCommand(ctx, client, routes, route, plan, cmd, path, resolved, consumed, stdout, stdin) + } + requestPath, spec, err := catalogHTTPRequest(ctx, client, routes, route, plan, cmd, path, resolved, consumed, stdin) + if err != nil { + return err + } + response, err := client.raw(ctx, route.Method, requestPath, spec) + if err != nil { + return err + } + payload, err := readPayload(response, route.Method, requestPath) + if err != nil { + return err + } + return printCatalogCommandPayload(stdout, payload, cmd, plan.Output) +} + +func catalogHTTPRequest( + ctx context.Context, + client *client, + routes []route, + route route, + plan commandExecution, + cmd command, + path string, + resolved map[string]string, + consumed map[string]bool, + stdin io.Reader, +) (string, requestSpec, error) { + spec := requestSpec{Headers: requestHeadersFromOptions(cmd)} + explicitBody, hasExplicitBody, err := jsonBodyFromOptions(cmd, stdin) + if err != nil { + return "", requestSpec{}, err + } + requestBody := route.RequestBody + if requestBody == "" { + requestBody = defaultRequestBody(route.Method) + } + if requestBody == "none" && hasExplicitBody { + return "", requestSpec{}, fmt.Errorf("operation %s does not accept a JSON body", route.ID) + } + if requestBody == "multipart" { + return "", requestSpec{}, fmt.Errorf("operation %s expects multipart request bodies", route.ID) + } + values, err := catalogValues(ctx, client, routes, cmd, plan.QueryParams, resolved, consumed) + if err != nil { + return "", requestSpec{}, err + } + if plan.QueryParams == nil { + values = requestValuesFromOptions(cmd, consumed) + } + if route.Method == http.MethodGet || (route.Method == http.MethodDelete && requestBody == "none") || requestBody == "none" { + return appendQuery(path, values), spec, nil + } + if hasExplicitBody { + spec.JSONBody = explicitBody + return path, spec, nil + } + if plan.BodyFields != nil { + body, err := catalogValues(ctx, client, routes, cmd, plan.BodyFields, resolved, consumed) + if err != nil { + return "", requestSpec{}, err + } + spec.JSONBody = compact(body) + return path, spec, nil + } + spec.JSONBody = compact(requestValuesFromOptions(cmd, consumed)) + return path, spec, nil +} + +func runCatalogDownloadCommand(ctx context.Context, client *client, route route, cmd command, path string, stdout io.Writer) error { + response, err := client.raw(ctx, route.Method, path, requestSpec{}) + if err != nil { + return err + } + defer func() { _ = response.Body.Close() }() + bytes, err := io.ReadAll(response.Body) + if err != nil { + return err + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + return fmt.Errorf("%s %s failed with %d: %s", route.Method, path, response.StatusCode, string(bytes)) + } + if output := cmd.string("output", ""); output != "" { + return os.WriteFile(filepath.Clean(output), bytes, 0o600) + } + _, err = stdout.Write(bytes) + return err +} + +func runCatalogMultipartCommand( + ctx context.Context, + client *client, + routes []route, + plan commandExecution, + cmd command, + path string, + resolved map[string]string, + consumed map[string]bool, + stdout io.Writer, +) error { + multipartPlan := plan.Multipart + if multipartPlan.FileOption == "" || multipartPlan.FileField == "" { + return fmt.Errorf("catalog command %s missing multipart plan", plan.OperationID) + } + filePath, err := cmd.required(multipartPlan.FileOption) + if err != nil { + return err + } + fieldValues, err := catalogValues(ctx, client, routes, cmd, multipartPlan.Fields, resolved, consumed) + if err != nil { + return err + } + fields := map[string]string{} + for name, value := range fieldValues { + fields[name] = fmt.Sprint(value) + } + fileName := "" + if multipartPlan.FileNameOption != "" { + fileName = cmd.string(multipartPlan.FileNameOption, "") + } + if fileName == "" && multipartPlan.FileNameDefault == "basename" { + fileName = filepath.Base(filePath) + } + contentType := multipartPlan.ContentTypeDefault + if multipartPlan.ContentTypeOption != "" { + contentType = cmd.string(multipartPlan.ContentTypeOption, contentType) + } + parsed, err := client.multipart(ctx, http.MethodPost, path, fields, multipartPlan.FileField, filePath, fileName, contentType) + if err != nil { + return err + } + return printJSON(stdout, parsed) +} + +func printCatalogCommandPayload(stdout io.Writer, payload responsePayload, cmd command, output *commandOutputPlan) error { + if output == nil { + return printPayload(stdout, payload, cmd) + } + switch output.Mode { + case commandOutputModeJSON, commandOutputModeBytes: + return printPayload(stdout, payload, cmd) + case commandOutputModeTable: + return printCommandOutput(stdout, payload.Parsed, cmd, *output) + default: + return fmt.Errorf("unsupported catalog command output mode: %s", output.Mode) + } +} diff --git a/internal/craken/catalog_contract.go b/internal/craken/catalog_contract.go new file mode 100644 index 0000000..ec0b5e5 --- /dev/null +++ b/internal/craken/catalog_contract.go @@ -0,0 +1,38 @@ +package craken + +type commandBindingPositionals string +type commandBindingSource string +type commandBindingValueType string +type commandOutputMode string +type commandTransport string + +const ( + commandBindingPositionalsJoin commandBindingPositionals = "join" +) + +const ( + commandBindingSourceBearerToken commandBindingSource = "bearer-token" + commandBindingSourceFlag commandBindingSource = "flag" + commandBindingSourceLiteral commandBindingSource = "literal" + commandBindingSourceOption commandBindingSource = "option" + commandBindingSourceResolved commandBindingSource = "resolved" + commandBindingSourceText commandBindingSource = "text" +) + +const ( + commandBindingValueTypeInteger commandBindingValueType = "integer" + commandBindingValueTypeJSON commandBindingValueType = "json" +) + +const ( + commandOutputModeBytes commandOutputMode = "bytes" + commandOutputModeJSON commandOutputMode = "json" + commandOutputModeTable commandOutputMode = "table" +) + +const ( + commandTransportDownload commandTransport = "download" + commandTransportHTTP commandTransport = "http" + commandTransportMultipart commandTransport = "multipart" + commandTransportWebSocket commandTransport = "websocket" +) diff --git a/internal/craken/catalog_poll.go b/internal/craken/catalog_poll.go new file mode 100644 index 0000000..87db1da --- /dev/null +++ b/internal/craken/catalog_poll.go @@ -0,0 +1,82 @@ +package craken + +import ( + "context" + "encoding/json" + "io" + "time" +) + +func runCatalogPollCommand( + ctx context.Context, + client *client, + routes []route, + route route, + plan commandExecution, + cmd command, + path string, + resolved map[string]string, + consumed map[string]bool, + stdout io.Writer, + stdin io.Reader, +) error { + interval, err := numberOption(cmd, plan.Poll.IntervalOption, plan.Poll.DefaultIntervalSeconds) + if err != nil { + return err + } + maxPolls, err := numberOption(cmd, plan.Poll.MaxPollsOption, plan.Poll.DefaultMaxPolls) + if err != nil { + return err + } + var last string + for index := 0; index < maxPolls; index++ { + requestPath, spec, err := catalogHTTPRequest(ctx, client, routes, route, commandExecution{ + BodyFields: plan.BodyFields, + OperationID: plan.OperationID, + Output: plan.Output, + PathParams: plan.PathParams, + QueryParams: plan.QueryParams, + Transport: plan.Transport, + }, cmd, path, resolved, consumed, stdin) + if err != nil { + return err + } + response, err := client.raw(ctx, route.Method, requestPath, spec) + if err != nil { + return err + } + payload, err := readPayload(response, route.Method, requestPath) + if err != nil { + return err + } + signatureBytes, _ := json.Marshal(payload.Parsed) + signature := string(signatureBytes) + if boolOption(cmd, "verbose") || signature != last { + if err := printCatalogCommandPayload(stdout, payload, cmd, plan.Output); err != nil { + return err + } + } + last = signature + if catalogPollTerminal(plan.Poll, payload.Parsed) || index == maxPolls-1 { + return nil + } + timer := time.NewTimer(time.Duration(interval) * time.Second) + select { + case <-ctx.Done(): + timer.Stop() + return ctx.Err() + case <-timer.C: + } + } + return nil +} + +func catalogPollTerminal(plan *commandPollPlan, payload any) bool { + status := valueString(valueAtPath(payload, plan.StatusPath)) + for _, terminal := range plan.TerminalValues { + if status == terminal { + return true + } + } + return false +} diff --git a/internal/craken/catalog_websocket.go b/internal/craken/catalog_websocket.go new file mode 100644 index 0000000..b6c265a --- /dev/null +++ b/internal/craken/catalog_websocket.go @@ -0,0 +1,134 @@ +package craken + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/gorilla/websocket" +) + +var websocketDialer = websocket.DefaultDialer + +func runCatalogWebSocketCommand( + ctx context.Context, + client *client, + routes []route, + plan commandExecution, + cmd command, + path string, + resolved map[string]string, + consumed map[string]bool, + stdout io.Writer, +) error { + query, err := catalogValues(ctx, client, routes, cmd, plan.QueryParams, resolved, consumed) + if err != nil { + return err + } + if plan.QueryParams != nil { + path = appendQuery(path, query) + } + endpoint, err := client.resolve(path) + if err != nil { + return err + } + switch endpoint.Scheme { + case "https": + endpoint.Scheme = "wss" + case "http": + endpoint.Scheme = "ws" + } + limit, err := numberOption(cmd, "limit", 0) + if err != nil { + return err + } + timeoutMS, err := numberOption(cmd, "timeout-ms", 0) + if err != nil { + return err + } + protocols, err := catalogWebSocketProtocols(ctx, client, routes, plan.WebSocket.Protocols, cmd, resolved, consumed) + if err != nil { + return err + } + dialer := *websocketDialer + dialer.Subprotocols = protocols + connection, _, err := dialer.DialContext(ctx, endpoint.String(), http.Header{}) + if err != nil { + return fmt.Errorf("websocket subscription failed for %s: %w", endpoint.String(), err) + } + defer func() { _ = connection.Close() }() + if timeoutMS > 0 { + _ = connection.SetReadDeadline(time.Now().Add(time.Duration(timeoutMS) * time.Millisecond)) + } + seen := 0 + for { + _, message, err := connection.ReadMessage() + if err != nil { + if timeoutMS > 0 && strings.Contains(strings.ToLower(err.Error()), "timeout") { + return nil + } + if websocket.IsCloseError(err, websocket.CloseNormalClosure) { + return nil + } + return err + } + if err := printWebSocketMessage(stdout, message, cmd); err != nil { + return err + } + seen++ + if limit > 0 && seen >= limit { + _ = connection.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, "limit"), time.Now().Add(time.Second)) + return nil + } + } +} + +func catalogWebSocketProtocols( + ctx context.Context, + client *client, + routes []route, + plans []commandWebSocketProtocol, + cmd command, + resolved map[string]string, + consumed map[string]bool, +) ([]string, error) { + protocols := make([]string, 0, len(plans)) + for _, plan := range plans { + switch plan.Source { + case "literal": + if plan.Value != "" { + protocols = append(protocols, plan.Value) + } + case "json-payload": + values, err := catalogValues(ctx, client, routes, cmd, plan.Payload, resolved, consumed) + if err != nil { + return nil, err + } + bytes, err := json.Marshal(values) + if err != nil { + return nil, err + } + protocols = append(protocols, plan.Prefix+base64.RawURLEncoding.EncodeToString(bytes)) + default: + return nil, fmt.Errorf("unsupported websocket protocol source: %s", plan.Source) + } + } + return protocols, nil +} + +func printWebSocketMessage(stdout io.Writer, message []byte, cmd command) error { + if !boolOption(cmd, "pretty") { + _, err := fmt.Fprintln(stdout, string(message)) + return err + } + var parsed any + if err := json.Unmarshal(message, &parsed); err != nil { + return err + } + return printJSON(stdout, parsed) +} diff --git a/internal/craken/client.go b/internal/craken/client.go index 7fd4be0..425c1a3 100644 --- a/internal/craken/client.go +++ b/internal/craken/client.go @@ -62,8 +62,8 @@ func newClientWithAuthRequirement(cmd command, log *logger, requireToken bool) ( }, nil } -func (c *client) json(ctx context.Context, method string, path string, body any) (any, error) { - response, err := c.raw(ctx, method, path, requestSpec{JSONBody: body}) +func (c *client) json(ctx context.Context, path string) (any, error) { + response, err := c.raw(ctx, http.MethodGet, path, requestSpec{}) if err != nil { return nil, err } @@ -79,7 +79,7 @@ func (c *client) json(ctx context.Context, method string, path string, body any) } } if response.StatusCode < 200 || response.StatusCode >= 300 { - return nil, fmt.Errorf("%s %s failed with %d: %s", method, path, response.StatusCode, string(text)) + return nil, fmt.Errorf("GET %s failed with %d: %s", path, response.StatusCode, string(text)) } return parsed, nil } diff --git a/internal/craken/command.go b/internal/craken/command.go index d68b1eb..5f867d2 100644 --- a/internal/craken/command.go +++ b/internal/craken/command.go @@ -59,24 +59,8 @@ func Run(ctx context.Context, version string, args []string, stdin io.Reader, st return runRawHTTP(ctx, client, cmd.Resource, cmd.Action, cmd, stdout, stdin) case "api": return runRawHTTP(ctx, client, cmd.Action, first(cmd.Positionals, cmd.string("path", "")), cmd.withPositionals(rest(cmd.Positionals)), stdout, stdin) - case "workspace": - return runWorkspace(ctx, client, cmd, stdout) - case "channel": - return runChannel(ctx, client, cmd, stdout) - case "dm": - return runDM(ctx, client, cmd, stdout) - case "file": - return runFile(ctx, client, cmd, stdout) - case "folder": - return runFolder(ctx, client, cmd, stdout) - case "wiki": - return runWiki(ctx, client, cmd, stdout) - case "agent": - return runAgent(ctx, client, cmd, stdout) - case "dream": - return runDream(ctx, client, cmd, stdout) default: - return fmt.Errorf("unknown resource: %s", cmd.Resource) + return runCatalogCommand(ctx, client, cmd, stdout, stdin) } } @@ -173,7 +157,7 @@ func runHelp(ctx context.Context, cmd command, stdout io.Writer) error { if err != nil { return err } - value, err := client.json(ctx, "GET", "/api/client", nil) + value, err := client.json(ctx, "/api/client") if err != nil { return err } @@ -226,7 +210,7 @@ func printFocusedCommandHelp(stdout io.Writer, command cliCommand, route *route) } } } - if options := localCommandHelpOptions(command.ID); len(options) > 0 { + if options := localCommandHelpOptions(command); len(options) > 0 { if _, err := fmt.Fprint(stdout, "\nOptions:\n"); err != nil { return err } @@ -251,8 +235,8 @@ func printFocusedCommandHelp(stdout io.Writer, command cliCommand, route *route) title string fields []catalogField }{ - {title: "Path parameters", fields: route.PathParameters}, - {title: "Query parameters", fields: route.QueryParameters}, + {title: "Path parameters", fields: firstCatalogFields(route.PathParams, route.PathParameters)}, + {title: "Query parameters", fields: firstCatalogFields(route.QueryParams, route.QueryParameters)}, {title: "Body fields", fields: route.BodyFields}, } { if err := printCatalogFieldGroup(stdout, group.title, group.fields); err != nil { @@ -267,6 +251,13 @@ func printFocusedCommandHelp(stdout io.Writer, command cliCommand, route *route) return nil } +func firstCatalogFields(primary []catalogField, fallback []catalogField) []catalogField { + if len(primary) > 0 { + return primary + } + return fallback +} + func printCatalogFieldGroup(stdout io.Writer, title string, fields []catalogField) error { if len(fields) == 0 { return nil @@ -316,44 +307,49 @@ func printResponseExample(stdout io.Writer, value any) error { 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.", - "--limit N Request a smaller message page from the service.", - "--compact Print createdAt, sender, and body as tab-separated text.", - "--fields LIST Print JSON projected to comma-separated dotted fields.", - } - case "channel.wait": - return []string{ - "--after MESSAGE_ID Wait after a previous message id or newestCursor. Omit to wait after the current newest message.", - "--timeout-ms MS Server-side wait timeout. Defaults to 30000 and caps at 60000.", - } - case "wiki.recent": - return []string{ - "--limit N Limit recent wiki changes.", - "--compact Print createdAt, author, page, and version as tab-separated text.", - "--fields LIST Print JSON projected to comma-separated dotted fields.", +func localCommandHelpOptions(command cliCommand) []string { + options := []string{} + options = append(options, bindingHelpOptions(command.Execution.QueryParams)...) + if command.Execution.Output != nil { + options = append(options, "--fields LIST Print JSON projected to comma-separated dotted fields.") + if command.Execution.Output.Mode == commandOutputModeTable { + options = append(options, "--compact Print the catalog table columns as tab-separated text.") + } + } + if command.Execution.Poll != nil { + options = append( + options, + fmt.Sprintf("--%s N Poll interval in seconds.", command.Execution.Poll.IntervalOption), + fmt.Sprintf("--%s N Maximum poll attempts.", command.Execution.Poll.MaxPollsOption), + ) + } + if command.Execution.Transport == commandTransportWebSocket { + options = append( + options, + "--limit N Stop after receiving N WebSocket messages.", + "--timeout-ms MS Stop when no WebSocket message arrives before the timeout.", + "--pretty Pretty-print JSON WebSocket messages.", + ) + } + return options +} + +func bindingHelpOptions(bindings map[string]commandBinding) []string { + options := []string{} + for _, binding := range bindings { + if binding.Source != commandBindingSourceOption || binding.Option == "" { + continue } - case "wiki.versions", "wiki.version": - return []string{ - "--compact Print createdAt, author, and version as tab-separated text.", - "--fields LIST Print JSON projected to comma-separated dotted fields.", + value := "VALUE" + if binding.Type == commandBindingValueTypeInteger { + value = "N" } - 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.", + if binding.Type == commandBindingValueTypeJSON { + value = "JSON" } - default: - return nil + options = append(options, fmt.Sprintf("--%s %s", binding.Option, value)) } + return options } func printCatalogHelp(stdout io.Writer, catalog clientCatalog) error { diff --git a/internal/craken/command_test.go b/internal/craken/command_test.go index 6001b92..43a78e9 100644 --- a/internal/craken/command_test.go +++ b/internal/craken/command_test.go @@ -16,6 +16,9 @@ func TestImportTokenStoresReusableProfile(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 writeTestCatalog(t, w, r) { + return + } if got := r.Header.Get("Authorization"); got != "Bearer stored-token" { t.Fatalf("unexpected auth header %q", got) } @@ -128,6 +131,7 @@ func TestFocusedHelpRendersServerCommandLocalOptionsAndMetadata(t *testing.T) { { "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.", + "execution": map[string]any{"operationId": "channels.messages.list", "output": messagesOutputPlan()}, "examples": []string{"craken channel messages --workspace W --channel C --after MESSAGE_ID"}, "group": "Channel", "id": "channel.messages", @@ -136,6 +140,14 @@ func TestFocusedHelpRendersServerCommandLocalOptionsAndMetadata(t *testing.T) { { "command": "craken workspace activity --workspace WORKSPACE [--limit N]", "description": "Read workspace activity.", + "execution": map[string]any{ + "operationId": "workspaces.activity", + "queryParams": map[string]any{ + "anchor": map[string]any{"source": "option", "option": "anchor-json", "aliases": []string{"anchor"}, "type": "json"}, + "beforeSequence": map[string]any{"source": "option", "option": "before-sequence", "type": "integer"}, + "surfaces": map[string]any{"source": "option", "option": "surfaces"}, + }, + }, "group": "Workspace", "id": "workspace.activity", "operationId": "workspaces.activity", @@ -143,6 +155,7 @@ func TestFocusedHelpRendersServerCommandLocalOptionsAndMetadata(t *testing.T) { { "command": "craken wiki recent --workspace WORKSPACE", "description": "List recent wiki changes.", + "execution": map[string]any{"operationId": "wiki.recent-changes", "output": wikiRecentOutputPlan()}, "group": "Wiki", "id": "wiki.recent", "operationId": "wiki.recent-changes", @@ -158,6 +171,7 @@ func TestFocusedHelpRendersServerCommandLocalOptionsAndMetadata(t *testing.T) { "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"}, + {"description": "Limit returned rows.", "name": "limit", "type": "integer"}, }, "requestBody": "none", "responseExample": map[string]any{ @@ -182,6 +196,9 @@ func TestFocusedHelpRendersServerCommandLocalOptionsAndMetadata(t *testing.T) { "id": "wiki.recent-changes", "method": "GET", "path": "/api/workspaces/{workspaceId}/wiki/recent-changes", + "queryParameters": []map[string]any{ + {"description": "Limit returned rows.", "name": "limit", "type": "integer"}, + }, "requestBody": "none", }, }, @@ -202,7 +219,7 @@ func TestFocusedHelpRendersServerCommandLocalOptionsAndMetadata(t *testing.T) { "--before MESSAGE_ID", "--around MESSAGE_ID", "--position latest|start", - "--limit N", + "--limit integer", "--compact", "--fields LIST", "Query parameters:", @@ -223,7 +240,7 @@ func TestFocusedHelpRendersServerCommandLocalOptionsAndMetadata(t *testing.T) { t.Fatal(err) } workspaceHelp := stdout.String() - for _, expected := range []string{"--anchor-json JSON", "--before-sequence N", "--surfaces LIST", "activities"} { + for _, expected := range []string{"--anchor-json JSON", "--before-sequence N", "--surfaces VALUE", "activities"} { if !strings.Contains(workspaceHelp, expected) { t.Fatalf("expected focused workspace help to contain %q, got:\n%s", expected, workspaceHelp) } @@ -233,7 +250,7 @@ func TestFocusedHelpRendersServerCommandLocalOptionsAndMetadata(t *testing.T) { 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") || !strings.Contains(help, "--compact") || !strings.Contains(help, "--fields LIST") { + if help := stdout.String(); !strings.Contains(help, "--limit integer") || !strings.Contains(help, "--compact") || !strings.Contains(help, "--fields LIST") { t.Fatalf("expected focused wiki help to contain output options, got:\n%s", help) } } @@ -327,6 +344,9 @@ func TestWorkspaceAcceptUsesProfileBearerWhenTokenIsInvitationAlias(t *testing.T var seenAuth string var seenPath string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if writeTestCatalog(t, w, r) { + return + } seenAuth = r.Header.Get("Authorization") seenPath = r.URL.Path writeJSON(t, w, map[string]any{"ok": true}) @@ -561,6 +581,9 @@ func TestChannelSendResolvesWorkspaceChannelAndSender(t *testing.T) { t.Setenv("CRAKEN_CONFIG_DIR", configDir) var posted map[string]any server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if writeTestCatalog(t, w, r) { + return + } switch r.Method + " " + r.URL.Path { case "GET /api/workspaces": writeJSON(t, w, map[string]any{"workspaces": []map[string]any{{"id": "workspace-id", "name": "test0"}}}) @@ -603,6 +626,9 @@ func TestMessageCommandsSendLimitQuery(t *testing.T) { seenChannel := false seenDM := false server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if writeTestCatalog(t, w, r) { + return + } switch r.Method + " " + r.URL.Path { case "GET /api/workspaces": writeJSON(t, w, map[string]any{"workspaces": []map[string]any{{"id": "workspace-id", "name": "test0"}}}) @@ -663,6 +689,9 @@ func TestChannelMessagesCompactOutputOmitsPictures(t *testing.T) { t.Setenv("CRAKEN_CONFIG_DIR", configDir) picture := strings.Repeat("picture-data", 100) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if writeTestCatalog(t, w, r) { + return + } switch r.Method + " " + r.URL.Path { case "GET /api/workspaces": writeJSON(t, w, map[string]any{"workspaces": []map[string]any{{"id": "workspace-id", "name": "test0"}}}) @@ -714,6 +743,9 @@ func TestDMMessagesFieldsProjectNestedJSON(t *testing.T) { t.Setenv("CRAKEN_CONFIG_DIR", configDir) picture := strings.Repeat("picture-data", 100) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if writeTestCatalog(t, w, r) { + return + } switch r.Method + " " + r.URL.Path { case "GET /api/workspaces": writeJSON(t, w, map[string]any{"workspaces": []map[string]any{{"id": "workspace-id", "name": "test0"}}}) @@ -774,6 +806,9 @@ func TestWikiRecentCompactOutputOmitsAuthorPictures(t *testing.T) { t.Setenv("CRAKEN_CONFIG_DIR", configDir) picture := strings.Repeat("picture-data", 100) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if writeTestCatalog(t, w, r) { + return + } switch r.Method + " " + r.URL.Path { case "GET /api/workspaces": writeJSON(t, w, map[string]any{"workspaces": []map[string]any{{"id": "workspace-id", "name": "test0"}}}) @@ -814,6 +849,9 @@ func TestMessageLimitServiceValidationIsPreserved(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 writeTestCatalog(t, w, r) { + return + } switch r.Method + " " + r.URL.Path { case "GET /api/workspaces": writeJSON(t, w, map[string]any{"workspaces": []map[string]any{{"id": "workspace-id", "name": "test0"}}}) @@ -854,6 +892,9 @@ func TestChannelWaitResolvesWorkspaceChannelAndPrintsWaitResponse(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 writeTestCatalog(t, w, r) { + return + } switch r.Method + " " + r.URL.Path { case "GET /api/workspaces": writeJSON(t, w, map[string]any{"workspaces": []map[string]any{{"id": "workspace-id", "name": "test0"}}}) @@ -909,10 +950,22 @@ func TestWikiSaveAndAgentPlanCheck(t *testing.T) { } var seen []string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if writeTestCatalog(t, w, r) { + return + } seen = append(seen, r.Method+" "+r.URL.Path) switch r.Method + " " + r.URL.Path { case "GET /api/workspaces": writeJSON(t, w, map[string]any{"workspaces": []map[string]any{{"id": "workspace-id", "name": "test0"}}}) + case "POST /api/workspaces/workspace-id/wiki/pages": + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body["title"] != "Home" || body["content"] != "# Home\n" || body["baseVersionNumber"] != float64(12) { + t.Fatalf("unexpected wiki create body %#v", body) + } + writeJSON(t, w, map[string]any{"page": body}) case "PATCH /api/workspaces/workspace-id/wiki/pages/Home": var body map[string]any if err := json.NewDecoder(r.Body).Decode(&body); err != nil { @@ -937,7 +990,11 @@ func TestWikiSaveAndAgentPlanCheck(t *testing.T) { })) defer server.Close() - err := Run(context.Background(), "dev", []string{"wiki", "save", "--token", "test-token", "--base-url", server.URL, "--workspace", "test0", "--existing-title", "Home", "--title", "Home", "--content-file", contentPath, "--base-version", "12"}, strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{}) + err := Run(context.Background(), "dev", []string{"wiki", "save", "--token", "test-token", "--base-url", server.URL, "--workspace", "test0", "--title", "Home", "--content-file", contentPath, "--base-version", "12"}, strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{}) + if err != nil { + t.Fatal(err) + } + err = Run(context.Background(), "dev", []string{"wiki", "save", "--token", "test-token", "--base-url", server.URL, "--workspace", "test0", "--existing-title", "Home", "--title", "Home", "--content-file", contentPath, "--base-version", "12"}, strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{}) if err != nil { t.Fatal(err) } @@ -945,7 +1002,7 @@ func TestWikiSaveAndAgentPlanCheck(t *testing.T) { if err != nil { t.Fatal(err) } - if len(seen) != 3 { + if len(seen) != 5 { t.Fatalf("unexpected request count %d: %#v", len(seen), seen) } } @@ -954,6 +1011,9 @@ func TestWikiSaveReportsBaseVersionConflict(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 writeTestCatalog(t, w, r) { + return + } switch r.Method + " " + r.URL.Path { case "GET /api/workspaces": writeJSON(t, w, map[string]any{"workspaces": []map[string]any{{"id": "workspace-id", "name": "test0"}}}) @@ -990,6 +1050,9 @@ func TestFileUploadUsesMultipartAndFolderCreate(t *testing.T) { t.Fatal(err) } server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if writeTestCatalog(t, w, r) { + return + } switch r.Method + " " + r.URL.Path { case "GET /api/workspaces": writeJSON(t, w, map[string]any{"workspaces": []map[string]any{{"id": "workspace-id", "name": "test0"}}}) @@ -1029,19 +1092,22 @@ func TestFileUploadUsesMultipartAndFolderCreate(t *testing.T) { } } -func TestRealtimeFormattingHelpers(t *testing.T) { - items, err := realtimeItems([]byte(`{"activities":[{"event":{"type":"message.created","conversation":{"kind":"channel","channel":{"name":"general"}},"message":{"body":"hi","sender":{"name":"Orca"}}}}]}`)) - if err != nil { - t.Fatal(err) - } +func TestWebSocketProtocolPlanBuildsBearerPayload(t *testing.T) { + client := &client{token: "token-value"} var stdout bytes.Buffer - if err := printRealtimeItem(&stdout, items[0], command{Flags: map[string]bool{"pretty": true}}); err != nil { + if err := printWebSocketMessage(&stdout, []byte(`{"event":{"type":"message.created"}}`), command{Flags: map[string]bool{"pretty": true}}); err != nil { t.Fatal(err) } - if got := stdout.String(); !strings.Contains(got, "message.created #general Orca: hi") { + if got := stdout.String(); !strings.Contains(got, `"message.created"`) { t.Fatalf("unexpected pretty output %q", got) } - protocols := bearerProtocols("token-value") + protocols, err := catalogWebSocketProtocols(context.Background(), client, nil, []commandWebSocketProtocol{ + {Source: "literal", Value: "craken-bearer"}, + {Source: "json-payload", Prefix: "craken-bearer-payload.", Payload: map[string]commandBinding{"token": {Source: commandBindingSourceBearerToken}}}, + }, command{}, map[string]string{}, map[string]bool{}) + if err != nil { + t.Fatal(err) + } if len(protocols) != 2 || protocols[0] != "craken-bearer" || !strings.HasPrefix(protocols[1], "craken-bearer-payload.") { t.Fatalf("unexpected protocols %#v", protocols) } @@ -1055,6 +1121,225 @@ func writeJSON(t *testing.T, w http.ResponseWriter, value any) { } } +func writeTestCatalog(t *testing.T, w http.ResponseWriter, r *http.Request) bool { + t.Helper() + if r.Method != http.MethodGet || r.URL.Path != "/api/client" { + return false + } + writeJSON(t, w, map[string]any{ + "commands": []map[string]any{ + testCommand("workspace.list", "workspaces.list", nil), + testCommand("workspace.accept", "workspace-invitations.accept", map[string]any{ + "pathParams": map[string]any{"token": map[string]any{"source": "option", "option": "invitation-token", "aliases": []string{"token"}, "required": true}}, + }), + testCommand("channel.send", "channels.messages.create", map[string]any{ + "bodyFields": map[string]any{ + "body": map[string]any{"source": "text", "option": "body", "fileOption": "body-file", "positionals": "join", "required": true}, + "senderEmail": map[string]any{"source": "option", "option": "sender-email", "aliases": []string{"sender"}}, + }, + }), + testCommand("channel.messages", "channels.messages.list", map[string]any{"output": messagesOutputPlan()}), + testCommand("channel.wait", "channels.messages.wait", map[string]any{ + "queryParams": map[string]any{ + "after": map[string]any{"source": "option", "option": "after"}, + "timeoutMs": map[string]any{"source": "option", "option": "timeout-ms", "type": "integer"}, + }, + }), + testCommand("dm.messages", "direct-messages.list", map[string]any{"output": messagesOutputPlan()}), + testCommand("wiki.recent", "wiki.recent-changes", map[string]any{"output": wikiRecentOutputPlan()}), + { + "command": "craken wiki save", + "description": "Save wiki page.", + "execution": map[string]any{ + "bodyFields": map[string]any{ + "baseVersionNumber": map[string]any{"source": "option", "option": "base-version", "type": "integer"}, + "content": map[string]any{"source": "text", "option": "content", "fileOption": "content-file", "required": true}, + "title": map[string]any{"source": "option", "option": "title", "required": true}, + }, + "operationId": "wiki.pages.create", + "pathParams": map[string]any{"workspaceId": workspaceOptionBinding()}, + "transport": "http", + "variants": []map[string]any{{ + "bodyFields": map[string]any{ + "baseVersionNumber": map[string]any{"source": "option", "option": "base-version", "type": "integer"}, + "content": map[string]any{"source": "text", "option": "content", "fileOption": "content-file", "required": true}, + "title": map[string]any{"source": "option", "option": "title"}, + }, + "operationId": "wiki.pages.update", + "pathParams": map[string]any{ + "workspaceId": workspaceOptionBinding(), + "pageTitle": map[string]any{"source": "option", "option": "existing-title", "required": true}, + }, + "when": map[string]any{"option": "existing-title"}, + }}, + }, + "group": "Wiki", + "id": "wiki.save", + "operationId": "wiki.pages.update", + }, + testCommand("agent.plan-check", "sysop.agent-job-plan.progression", map[string]any{ + "bodyFields": map[string]any{ + "nextPlan": map[string]any{"source": "option", "option": "next-json", "aliases": []string{"next-file"}, "required": true, "type": "json"}, + "previousPlan": map[string]any{"source": "option", "option": "previous-json", "aliases": []string{"previous-file"}, "type": "json"}, + }, + }), + testCommand("file.upload", "files.create", map[string]any{ + "multipart": map[string]any{ + "contentTypeDefault": "application/octet-stream", + "contentTypeOption": "type", + "fields": map[string]any{ + "folderPath": map[string]any{"source": "option", "option": "folder"}, + "scope": map[string]any{"source": "option", "option": "scope", "default": "workspace"}, + }, + "fileField": "file", + "fileNameDefault": "basename", + "fileNameOption": "name", + "fileOption": "path", + }, + "transport": "multipart", + }), + testCommand("folder.create", "folders.create", map[string]any{ + "bodyFields": map[string]any{ + "name": map[string]any{"source": "option", "option": "name", "required": true}, + "parentPath": map[string]any{"source": "option", "option": "parent"}, + "scope": map[string]any{"source": "option", "option": "scope"}, + }, + }), + }, + "routes": []map[string]any{ + testRoute("workspaces.list", http.MethodGet, "/api/workspaces", "none"), + testRoute("workspaces.get", http.MethodGet, "/api/workspaces/{workspaceId}", "none"), + testRoute("workspace-invitations.accept", http.MethodPost, "/api/workspace-invitations/{token}/accept", "json"), + testRoute("channels.messages.create", http.MethodPost, "/api/workspaces/{workspaceId}/channels/{channelId}/messages", "json"), + testRoute("channels.messages.list", http.MethodGet, "/api/workspaces/{workspaceId}/channels/{channelId}/messages", "none"), + testRoute("channels.messages.wait", http.MethodGet, "/api/workspaces/{workspaceId}/channels/{channelId}/messages/wait", "none"), + testRoute("direct-messages.list", http.MethodGet, "/api/workspaces/{workspaceId}/direct-messages/{participantId}/messages", "none"), + testRoute("wiki.recent-changes", http.MethodGet, "/api/workspaces/{workspaceId}/wiki/recent-changes", "none"), + testRoute("wiki.pages.create", http.MethodPost, "/api/workspaces/{workspaceId}/wiki/pages", "json"), + testRoute("wiki.pages.update", http.MethodPatch, "/api/workspaces/{workspaceId}/wiki/pages/{pageTitle}", "json"), + testRoute("sysop.agent-job-plan.progression", http.MethodPost, "/api/admin/agent-job-plan/progression", "json"), + testRoute("files.create", http.MethodPost, "/api/workspaces/{workspaceId}/files", "multipart"), + testRoute("folders.create", http.MethodPost, "/api/workspaces/{workspaceId}/folders", "json"), + }, + "schemaVersion": 1, + }) + return true +} + +func testCommand(id string, operationID string, execution map[string]any) map[string]any { + if execution == nil { + execution = map[string]any{} + } + execution["operationId"] = operationID + if _, ok := execution["pathParams"]; !ok { + if pathParams := testPathParams(operationID); len(pathParams) > 0 { + execution["pathParams"] = pathParams + } + } + return map[string]any{ + "command": "craken " + strings.Replace(id, ".", " ", 1), + "description": "Test command " + id, + "execution": execution, + "group": "Test", + "id": id, + "operationId": operationID, + } +} + +func testPathParams(operationID string) map[string]any { + switch operationID { + case "workspace-invitations.accept": + return map[string]any{"token": map[string]any{"source": "option", "option": "invitation-token", "aliases": []string{"token"}, "required": true}} + case "workspaces.get", "wiki.recent-changes", "wiki.pages.create", "folders.create", "files.create": + return map[string]any{"workspaceId": workspaceOptionBinding()} + case "channels.messages.create", "channels.messages.list", "channels.messages.wait": + return map[string]any{"workspaceId": workspaceOptionBinding(), "channelId": channelOptionBinding()} + case "direct-messages.list": + return map[string]any{"workspaceId": workspaceOptionBinding(), "participantId": participantOptionBinding()} + case "wiki.pages.update": + return map[string]any{"workspaceId": workspaceOptionBinding(), "pageTitle": map[string]any{"source": "option", "option": "title", "aliases": []string{"page"}, "required": true}} + default: + return nil + } +} + +func workspaceOptionBinding() map[string]any { + return map[string]any{ + "source": "option", "option": "workspace", "required": true, + "resolver": map[string]any{ + "collectionPath": "workspaces", + "label": "workspace", + "matchFields": []string{"id", "name"}, + "operationId": "workspaces.list", + "resultPath": "id", + }, + } +} + +func channelOptionBinding() map[string]any { + return map[string]any{ + "source": "option", "option": "channel", "required": true, + "resolver": map[string]any{ + "collectionPath": "channels", + "label": "channel", + "matchFields": []string{"id", "name"}, + "operationId": "workspaces.get", + "pathParams": map[string]any{"workspaceId": map[string]any{"source": "resolved", "name": "workspaceId", "required": true}}, + "resultPath": "id", + }, + } +} + +func participantOptionBinding() map[string]any { + return map[string]any{ + "source": "option", "option": "target", "aliases": []string{"participant"}, "required": true, + "resolver": map[string]any{ + "collectionPath": "members", + "label": "participant", + "matchFields": []string{"id", "email", "name"}, + "operationId": "workspaces.get", + "pathParams": map[string]any{"workspaceId": map[string]any{"source": "resolved", "name": "workspaceId", "required": true}}, + "resultPath": "id", + }, + } +} + +func messagesOutputPlan() map[string]any { + return map[string]any{ + "columns": []map[string]any{ + {"paths": []string{"createdAt"}}, + {"paths": []string{"sender.name", "sender.email", "sender.id"}}, + {"paths": []string{"body"}}, + }, + "mode": "table", + "rowsPath": "messages", + } +} + +func wikiRecentOutputPlan() map[string]any { + return map[string]any{ + "columns": []map[string]any{ + {"paths": []string{"createdAt"}}, + {"paths": []string{"createdBy.name", "createdBy.email", "createdBy.id"}}, + {"paths": []string{"page.title"}}, + {"paths": []string{"versionNumber"}}, + }, + "mode": "table", + "rowsPath": "changes", + } +} + +func testRoute(id string, method string, path string, requestBody string) map[string]any { + return map[string]any{ + "auth": "required", + "description": "Test route " + id, + "id": id, + "method": method, + "path": path, + "requestBody": requestBody, + } +} + func serverURL(r *http.Request) string { return "http://" + r.Host } diff --git a/internal/craken/generic.go b/internal/craken/generic.go index 8219151..fc33d06 100644 --- a/internal/craken/generic.go +++ b/internal/craken/generic.go @@ -22,7 +22,9 @@ type route struct { Description string `json:"description"` Auth string `json:"auth"` Capability string `json:"capability,omitempty"` + PathParams []catalogField `json:"pathParams,omitempty"` PathParameters []catalogField `json:"pathParameters,omitempty"` + QueryParams []catalogField `json:"queryParams,omitempty"` QueryParameters []catalogField `json:"queryParameters,omitempty"` BodyFields []catalogField `json:"bodyFields,omitempty"` RequestBody string `json:"requestBody"` @@ -40,12 +42,108 @@ type clientCatalog struct { } type cliCommand struct { - Command string `json:"command"` - Description string `json:"description"` - Examples []string `json:"examples,omitempty"` - Group string `json:"group"` - ID string `json:"id"` - OperationID string `json:"operationId,omitempty"` + Command string `json:"command"` + Description string `json:"description"` + Execution commandExecution `json:"execution,omitempty"` + Examples []string `json:"examples,omitempty"` + Group string `json:"group"` + ID string `json:"id"` + OperationID string `json:"operationId,omitempty"` +} + +type commandExecution struct { + BodyFields map[string]commandBinding `json:"bodyFields,omitempty"` + Multipart commandMultipartPlan `json:"multipart,omitempty"` + OperationID string `json:"operationId,omitempty"` + Output *commandOutputPlan `json:"output,omitempty"` + PathParams map[string]commandBinding `json:"pathParams,omitempty"` + Poll *commandPollPlan `json:"poll,omitempty"` + QueryParams map[string]commandBinding `json:"queryParams,omitempty"` + Transport commandTransport `json:"transport,omitempty"` + Variants []commandExecutionVariant `json:"variants,omitempty"` + WebSocket commandWebSocketPlan `json:"websocket,omitempty"` +} + +type commandExecutionVariant struct { + BodyFields map[string]commandBinding `json:"bodyFields,omitempty"` + Multipart commandMultipartPlan `json:"multipart,omitempty"` + OperationID string `json:"operationId,omitempty"` + Output *commandOutputPlan `json:"output,omitempty"` + PathParams map[string]commandBinding `json:"pathParams,omitempty"` + Poll *commandPollPlan `json:"poll,omitempty"` + QueryParams map[string]commandBinding `json:"queryParams,omitempty"` + Transport commandTransport `json:"transport,omitempty"` + When commandCondition `json:"when,omitempty"` + WebSocket commandWebSocketPlan `json:"websocket,omitempty"` +} + +type commandCondition struct { + Option string `json:"option,omitempty"` +} + +type commandBinding struct { + Aliases []string `json:"aliases,omitempty"` + Default any `json:"default,omitempty"` + FileOption string `json:"fileOption,omitempty"` + Name string `json:"name,omitempty"` + Option string `json:"option,omitempty"` + Positionals commandBindingPositionals `json:"positionals,omitempty"` + Required bool `json:"required,omitempty"` + Resolver *commandResolverPlan `json:"resolver,omitempty"` + Source commandBindingSource `json:"source,omitempty"` + Type commandBindingValueType `json:"type,omitempty"` + Value any `json:"value,omitempty"` +} + +type commandResolverPlan struct { + CollectionPath string `json:"collectionPath"` + Label string `json:"label"` + MatchFields []string `json:"matchFields"` + OperationID string `json:"operationId"` + PathParams map[string]commandBinding `json:"pathParams,omitempty"` + RequiredResultPrefix string `json:"requiredResultPrefix,omitempty"` + ResultPath string `json:"resultPath"` + TrimResultPrefix string `json:"trimResultPrefix,omitempty"` +} + +type commandOutputPlan struct { + Columns []commandOutputColumn `json:"columns,omitempty"` + Mode commandOutputMode `json:"mode"` + RowsPath string `json:"rowsPath,omitempty"` +} + +type commandOutputColumn struct { + Paths []string `json:"paths"` +} + +type commandPollPlan struct { + DefaultIntervalSeconds int `json:"defaultIntervalSeconds"` + DefaultMaxPolls int `json:"defaultMaxPolls"` + IntervalOption string `json:"intervalOption"` + MaxPollsOption string `json:"maxPollsOption"` + StatusPath string `json:"statusPath"` + TerminalValues []string `json:"terminalValues"` +} + +type commandMultipartPlan struct { + ContentTypeDefault string `json:"contentTypeDefault,omitempty"` + ContentTypeOption string `json:"contentTypeOption,omitempty"` + Fields map[string]commandBinding `json:"fields,omitempty"` + FileField string `json:"fileField,omitempty"` + FileNameDefault string `json:"fileNameDefault,omitempty"` + FileNameOption string `json:"fileNameOption,omitempty"` + FileOption string `json:"fileOption,omitempty"` +} + +type commandWebSocketPlan struct { + Protocols []commandWebSocketProtocol `json:"protocols,omitempty"` +} + +type commandWebSocketProtocol struct { + Payload map[string]commandBinding `json:"payload,omitempty"` + Prefix string `json:"prefix,omitempty"` + Source string `json:"source"` + Value string `json:"value,omitempty"` } type commandExample struct { @@ -68,7 +166,7 @@ type catalogField struct { } func runCommands(ctx context.Context, client *client, cmd command, stdout io.Writer) error { - value, err := client.json(ctx, "GET", "/api/client", nil) + value, err := client.json(ctx, "/api/client") if err != nil { return err } @@ -95,7 +193,7 @@ func runDo(ctx context.Context, client *client, cmd command, stdout io.Writer, s if operationID == "" || operationID == "help" { return fmt.Errorf("expected operation id") } - catalog, err := client.json(ctx, "GET", "/api/client", nil) + catalog, err := client.json(ctx, "/api/client") if err != nil { return err } @@ -387,6 +485,7 @@ func requestValuesFromOptions(cmd command, consumed map[string]bool) map[string] "accept": true, "base-url": true, "body-file": true, "body-json": true, "format": true, "json": true, "json-file": true, "log-file": true, "profile": true, "save-token-profile": true, "token": true, "bearer-token": true, + "compact": true, "fields": true, "output": true, "pretty": true, } values := map[string]any{} for key, value := range cmd.Options { diff --git a/internal/craken/output.go b/internal/craken/output.go index 3e9c3cf..23ce34b 100644 --- a/internal/craken/output.go +++ b/internal/craken/output.go @@ -1,37 +1,12 @@ package craken import ( - "context" "fmt" "io" "strings" ) -type commandOutputKind string - -const ( - outputMessages commandOutputKind = "messages" - outputWikiRecent commandOutputKind = "wiki-recent" - outputWikiVersion commandOutputKind = "wiki-version" - outputWikiVersions commandOutputKind = "wiki-versions" -) - -func printClientCommandOutput( - ctx context.Context, - client *client, - stdout io.Writer, - cmd command, - kind commandOutputKind, - path string, -) error { - parsed, err := client.json(ctx, "GET", path, nil) - if err != nil { - return err - } - return printCommandOutput(stdout, parsed, cmd, kind) -} - -func printCommandOutput(stdout io.Writer, value any, cmd command, kind commandOutputKind) error { +func printCommandOutput(stdout io.Writer, value any, cmd command, plan commandOutputPlan) error { fields := cmd.string("fields", "") if boolOption(cmd, "compact") && fields != "" { return fmt.Errorf("use either --compact or --fields, not both") @@ -46,19 +21,10 @@ func printCommandOutput(stdout io.Writer, value any, cmd command, kind commandOu if !boolOption(cmd, "compact") { return printJSON(stdout, value) } - - switch kind { - case outputMessages: - return printCompactMessages(stdout, value) - case outputWikiRecent: - return printCompactWikiRows(stdout, collectionFromRoot(value, "changes"), true) - case outputWikiVersions: - return printCompactWikiRows(stdout, collectionFromRoot(value, "versions"), false) - case outputWikiVersion: - return printCompactWikiRows(stdout, []any{objectFromRoot(value, "version")}, false) - default: + if plan.Mode != commandOutputModeTable { return fmt.Errorf("--compact is not supported for this command") } + return printCompactTable(stdout, value, plan) } func projectFields(value any, fields string) (any, error) { @@ -153,40 +119,12 @@ func mergeProjectedValue(left any, right any) any { return right } -func printCompactMessages(stdout io.Writer, value any) error { - for _, raw := range collectionFromRoot(value, "messages") { - message, _ := raw.(map[string]any) - if message == nil { - continue - } - if _, err := fmt.Fprintf( - stdout, - "%s\t%s\t%s\n", - compactCell(stringValue(message["createdAt"])), - compactCell(senderLabel(message["sender"])), - compactCell(stringValue(message["body"])), - ); err != nil { - return err - } - } - return nil -} - -func printCompactWikiRows(stdout io.Writer, rows []any, includePage bool) error { - for _, raw := range rows { - row, _ := raw.(map[string]any) - if row == nil { - continue - } - cells := []string{ - compactCell(stringValue(row["createdAt"])), - compactCell(senderLabel(row["createdBy"])), +func printCompactTable(stdout io.Writer, value any, plan commandOutputPlan) error { + for _, row := range outputRows(value, plan.RowsPath) { + cells := make([]string, 0, len(plan.Columns)) + for _, column := range plan.Columns { + cells = append(cells, compactCell(firstColumnValue(row, column.Paths))) } - if includePage { - page, _ := row["page"].(map[string]any) - cells = append(cells, compactCell(stringValue(page["title"]))) - } - cells = append(cells, compactCell(compactScalar(row["versionNumber"]))) if _, err := fmt.Fprintln(stdout, strings.Join(cells, "\t")); err != nil { return err } @@ -194,35 +132,26 @@ func printCompactWikiRows(stdout io.Writer, rows []any, includePage bool) error return nil } -func collectionFromRoot(value any, key string) []any { - root, _ := value.(map[string]any) - items, _ := root[key].([]any) - return items -} - -func objectFromRoot(value any, key string) any { - root, _ := value.(map[string]any) - return root[key] +func outputRows(value any, rowsPath string) []any { + rows := valueAtPath(value, rowsPath) + if items, ok := rows.([]any); ok { + return items + } + if rows == nil { + return nil + } + return []any{rows} } -func senderLabel(value any) string { - sender, _ := value.(map[string]any) - if sender == nil { - return "" - } - for _, key := range []string{"name", "email", "id"} { - if text := stringValue(sender[key]); text != "" { +func firstColumnValue(row any, paths []string) string { + for _, path := range paths { + if text := compactScalar(valueAtPath(row, path)); text != "" { return text } } return "" } -func stringValue(value any) string { - text, _ := value.(string) - return text -} - func compactScalar(value any) string { if value == nil { return "" diff --git a/internal/craken/realtime.go b/internal/craken/realtime.go deleted file mode 100644 index b152783..0000000 --- a/internal/craken/realtime.go +++ /dev/null @@ -1,187 +0,0 @@ -package craken - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "strings" - "time" - - "github.com/gorilla/websocket" -) - -var websocketDialer = websocket.DefaultDialer - -func tailWorkspace(ctx context.Context, client *client, workspaceID string, cmd command, stdout io.Writer) error { - endpoint, err := client.resolve(workspacePath(workspaceID) + "/realtime") - if err != nil { - return err - } - if after := cmd.string("after", ""); after != "" { - params := endpoint.Query() - params.Set("after", after) - endpoint.RawQuery = params.Encode() - } - switch endpoint.Scheme { - case "https": - endpoint.Scheme = "wss" - case "http": - endpoint.Scheme = "ws" - } - limit, err := numberOption(cmd, "limit", 0) - if err != nil { - return err - } - timeoutMS, err := numberOption(cmd, "timeout-ms", 0) - if err != nil { - return err - } - dialer := *websocketDialer - dialer.Subprotocols = bearerProtocols(client.token) - headers := http.Header{} - connection, _, err := dialer.DialContext(ctx, endpoint.String(), headers) - if err != nil { - return fmt.Errorf("websocket subscription failed for %s: %w", endpoint.String(), err) - } - defer func() { _ = connection.Close() }() - client.logger.line("cli", fmt.Sprintf("subscribed workspace=%s", workspaceID)) - if timeoutMS > 0 { - deadline := time.Now().Add(time.Duration(timeoutMS) * time.Millisecond) - _ = connection.SetReadDeadline(deadline) - } - seen := 0 - for { - _, message, err := connection.ReadMessage() - if err != nil { - if timeoutMS > 0 && strings.Contains(strings.ToLower(err.Error()), "timeout") { - return nil - } - if websocket.IsCloseError(err, websocket.CloseNormalClosure) { - return nil - } - return err - } - items, err := realtimeItems(message) - if err != nil { - return err - } - for _, item := range items { - if err := printRealtimeItem(stdout, item, cmd); err != nil { - return err - } - seen++ - if limit > 0 && seen >= limit { - _ = connection.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, "limit"), time.Now().Add(time.Second)) - return nil - } - } - } -} - -func realtimeItems(message []byte) ([]map[string]any, error) { - var parsed map[string]any - if err := json.Unmarshal(message, &parsed); err != nil { - return nil, err - } - if activities, ok := parsed["activities"].([]any); ok { - items := make([]map[string]any, 0, len(activities)) - for _, item := range activities { - object, _ := item.(map[string]any) - items = append(items, object) - } - return items, nil - } - if activity, ok := parsed["activity"].(map[string]any); ok { - return []map[string]any{activity}, nil - } - return []map[string]any{parsed}, nil -} - -func printRealtimeItem(stdout io.Writer, item map[string]any, cmd command) error { - if !boolOption(cmd, "pretty") { - bytes, err := json.Marshal(item) - if err != nil { - return err - } - _, err = fmt.Fprintln(stdout, string(bytes)) - return err - } - event, _ := item["event"].(map[string]any) - line := prettyRealtimeEventLine(event) - if _, err := fmt.Fprintln(stdout, line); err != nil { - return err - } - if boolOption(cmd, "details") && event != nil { - bytes, err := json.MarshalIndent(event, "", "\t") - if err != nil { - return err - } - _, err = fmt.Fprintln(stdout, indent(string(bytes), " ")) - return err - } - return nil -} - -func prettyRealtimeEventLine(event map[string]any) string { - if event == nil { - return "workspace.activity" - } - eventType := itemString(event, "type") - switch eventType { - case "message.created", "message.updated": - message, _ := event["message"].(map[string]any) - sender, _ := message["sender"].(map[string]any) - return fmt.Sprintf("%s %s %s: %s", eventType, conversationLabel(event["conversation"]), firstNonEmpty(itemString(sender, "name"), itemString(sender, "id"), "unknown"), itemString(message, "body")) - case "wiki.page.updated": - change, _ := event["change"].(map[string]any) - page, _ := change["page"].(map[string]any) - return fmt.Sprintf("wiki.page.updated [[%s]] v%v", firstNonEmpty(itemString(page, "title"), "page"), change["versionNumber"]) - case "wiki.page.deleted": - page, _ := event["page"].(map[string]any) - return fmt.Sprintf("wiki.page.deleted [[%s]]", firstNonEmpty(itemString(page, "title"), "page")) - case "agent.activity.updated": - activity, _ := event["activity"].(map[string]any) - agent, _ := activity["agent"].(map[string]any) - return fmt.Sprintf("agent.activity.%s %s", itemString(event, "status"), firstNonEmpty(itemString(agent, "name"), itemString(activity, "agentId"), "agent")) - case "channel.created", "channel.updated": - channel, _ := event["channel"].(map[string]any) - return fmt.Sprintf("%s #%s", eventType, firstNonEmpty(itemString(channel, "name"), itemString(channel, "id"), "channel")) - case "channel.deleted": - return fmt.Sprintf("channel.deleted %v", event["channelId"]) - case "member.joined": - member, _ := event["member"].(map[string]any) - return fmt.Sprintf("member.joined %s", firstNonEmpty(itemString(member, "name"), itemString(member, "id"), "member")) - case "agent.trace.updated": - trace, _ := event["trace"].(map[string]any) - agent, _ := trace["agent"].(map[string]any) - return fmt.Sprintf("> %s [%s; wake=%s; %s]", itemString(trace, "summary"), itemString(agent, "name"), itemString(trace, "wakeId"), itemString(trace, "kind")) - default: - bytes, _ := json.Marshal(event) - return fmt.Sprintf("%s %s", firstNonEmpty(eventType, "workspace.activity"), string(bytes)) - } -} - -func conversationLabel(value any) string { - conversation, _ := value.(map[string]any) - if conversation == nil { - return "" - } - if itemString(conversation, "kind") == "channel" { - channel, _ := conversation["channel"].(map[string]any) - return "#" + firstNonEmpty(itemString(channel, "name"), itemString(conversation, "channelId"), "channel") - } - return "dm" -} - -func indent(value string, prefix string) string { - lines := strings.Split(value, "\n") - for i := range lines { - lines[i] = prefix + lines[i] - } - return strings.Join(lines, "\n") -} - -var _ = url.URL{} diff --git a/internal/craken/resolve.go b/internal/craken/resolve.go deleted file mode 100644 index 3e84f33..0000000 --- a/internal/craken/resolve.go +++ /dev/null @@ -1,98 +0,0 @@ -package craken - -import ( - "context" - "fmt" - "strings" -) - -func resolveWorkspaceID(ctx context.Context, client *client, value string) (string, error) { - if looksLikeID(value) { - return value, nil - } - parsed, err := client.json(ctx, "GET", "/api/workspaces", nil) - if err != nil { - return "", err - } - root, _ := parsed.(map[string]any) - matches := filterObjects(root["workspaces"], func(item map[string]any) bool { - return itemString(item, "id") == value || itemString(item, "name") == value - }) - match, err := uniqueMatch(matches, value, "workspace") - if err != nil { - return "", err - } - return itemString(match, "id"), nil -} - -func resolveChannelID(ctx context.Context, client *client, workspaceID string, value string) (string, error) { - if looksLikeID(value) { - return value, nil - } - parsed, err := client.json(ctx, "GET", workspacePath(workspaceID), nil) - if err != nil { - return "", err - } - detail, _ := parsed.(map[string]any) - matches := filterObjects(detail["channels"], func(item map[string]any) bool { - return itemString(item, "id") == value || itemString(item, "name") == value - }) - match, err := uniqueMatch(matches, value, "channel") - if err != nil { - return "", err - } - return itemString(match, "id"), nil -} - -func resolveParticipantID(ctx context.Context, client *client, workspaceID string, value string) (string, error) { - if strings.HasPrefix(value, "user:") || strings.HasPrefix(value, "agent:") { - return value, nil - } - lower := strings.ToLower(value) - parsed, err := client.json(ctx, "GET", workspacePath(workspaceID), nil) - if err != nil { - return "", err - } - detail, _ := parsed.(map[string]any) - matches := filterObjects(detail["members"], func(item map[string]any) bool { - if itemString(item, "id") == value { - return true - } - if itemString(item, "kind") == "user" { - return strings.ToLower(itemString(item, "email")) == lower || strings.ToLower(itemString(item, "name")) == lower - } - return strings.ToLower(itemString(item, "name")) == lower - }) - match, err := uniqueMatch(matches, value, "participant") - if err != nil { - return "", err - } - return itemString(match, "id"), nil -} - -func filterObjects(value any, include func(map[string]any) bool) []map[string]any { - items, _ := value.([]any) - var matches []map[string]any - for _, item := range items { - object, ok := item.(map[string]any) - if ok && include(object) { - matches = append(matches, object) - } - } - return matches -} - -func uniqueMatch(matches []map[string]any, value string, label string) (map[string]any, error) { - if len(matches) == 1 { - return matches[0], nil - } - if len(matches) == 0 { - return nil, fmt.Errorf("could not resolve %s: %s", label, value) - } - return nil, fmt.Errorf("ambiguous %s: %s", label, value) -} - -func itemString(item map[string]any, key string) string { - value, _ := item[key].(string) - return value -} diff --git a/internal/craken/resources.go b/internal/craken/resources.go index c926799..80a59d5 100644 --- a/internal/craken/resources.go +++ b/internal/craken/resources.go @@ -1,367 +1,7 @@ package craken -import ( - "context" - "fmt" - "io" - "net/http" - "net/url" - "os" - "path/filepath" - "strings" -) - -func runWorkspace(ctx context.Context, client *client, cmd command, stdout io.Writer) error { - switch cmd.Action { - case "list": - return printClientJSON(ctx, client, stdout, "GET", "/api/workspaces", nil) - case "create": - name, err := cmd.required("name") - if err != nil { - return err - } - return printClientJSON(ctx, client, stdout, "POST", "/api/workspaces", map[string]any{"name": name}) - case "get", "snapshot", "activity", "delete", "invite", "accept", "subs", "tail": - default: - return fmt.Errorf("unknown workspace action: %s", cmd.Action) - } - - if cmd.Action == "accept" { - token, err := cmd.required("invitation-token", "token") - if err != nil { - return err - } - return printClientJSON(ctx, client, stdout, "POST", "/api/workspace-invitations/"+url.PathEscape(token)+"/accept", map[string]any{}) - } - workspace, err := cmd.required("workspace") - if err != nil { - return err - } - workspaceID, err := resolveWorkspaceID(ctx, client, workspace) - if err != nil { - return err - } - switch cmd.Action { - case "get": - return printClientJSON(ctx, client, stdout, "GET", workspacePath(workspaceID), nil) - case "snapshot": - return printClientJSON(ctx, client, stdout, "GET", workspacePath(workspaceID)+"/snapshot", nil) - case "activity": - params := url.Values{} - if value := cmd.string("anchor-json", ""); value != "" { - params.Set("anchor", value) - } - if value := cmd.string("before-sequence", ""); value != "" { - params.Set("beforeSequence", value) - } - if value := cmd.string("limit", ""); value != "" { - params.Set("limit", value) - } - for _, surface := range stringListOption(cmd, "surfaces") { - params.Add("surfaces", surface) - } - path := workspacePath(workspaceID) + "/activity" - if len(params) > 0 { - path += "?" + params.Encode() - } - return printClientJSON(ctx, client, stdout, "GET", path, nil) - case "delete": - body := map[string]any(nil) - if name := cmd.string("name", ""); name != "" { - body = map[string]any{"name": name} - } - return printClientJSON(ctx, client, stdout, "DELETE", workspacePath(workspaceID), body) - case "invite": - email, err := cmd.required("email") - if err != nil { - return err - } - return printClientJSON(ctx, client, stdout, "POST", workspacePath(workspaceID)+"/invitations", map[string]any{"email": email}) - case "subs", "tail": - return tailWorkspace(ctx, client, workspaceID, cmd, stdout) - } - return nil -} - -func runChannel(ctx context.Context, client *client, cmd command, stdout io.Writer) error { - workspaceID, err := workspaceIDFromCommand(ctx, client, cmd) - if err != nil { - return err - } - switch cmd.Action { - case "list": - return printClientJSON(ctx, client, stdout, "GET", workspacePath(workspaceID)+"/channels", nil) - case "create": - name, err := cmd.required("name", "channel") - if err != nil { - return err - } - return printClientJSON(ctx, client, stdout, "POST", workspacePath(workspaceID)+"/channels", map[string]any{"name": name, "purpose": cmd.string("purpose", "")}) - case "update", "delete", "join", "leave", "add-member", "messages", "wait", "send": - default: - return fmt.Errorf("unknown channel action: %s", cmd.Action) - } - channelID, err := channelIDFromCommand(ctx, client, workspaceID, cmd) - if err != nil { - return err - } - switch cmd.Action { - case "update": - return printClientJSON(ctx, client, stdout, "PATCH", workspacePath(workspaceID)+"/channels/"+url.PathEscape(channelID), compact(map[string]any{"name": cmd.string("name", ""), "purpose": cmd.string("purpose", "")})) - case "delete": - return printClientJSON(ctx, client, stdout, "DELETE", workspacePath(workspaceID)+"/channels/"+url.PathEscape(channelID), nil) - case "join": - return printClientJSON(ctx, client, stdout, "POST", workspacePath(workspaceID)+"/channels/"+url.PathEscape(channelID)+"/members/me", map[string]any{}) - case "leave": - return printClientJSON(ctx, client, stdout, "DELETE", workspacePath(workspaceID)+"/channels/"+url.PathEscape(channelID)+"/members/me", nil) - case "add-member": - participant, err := cmd.required("participant", "target") - if err != nil { - return err - } - participantID, err := resolveParticipantID(ctx, client, workspaceID, participant) - if err != nil { - return err - } - return printClientJSON(ctx, client, stdout, "POST", workspacePath(workspaceID)+"/channels/"+url.PathEscape(channelID)+"/members", map[string]any{"participantId": participantID}) - case "messages": - return printClientCommandOutput( - ctx, - client, - stdout, - cmd, - outputMessages, - workspacePath(workspaceID)+"/channels/"+url.PathEscape(channelID)+"/messages"+messagePageQuery(cmd), - ) - case "wait": - query, err := channelWaitQuery(cmd) - if err != nil { - return err - } - return printClientJSON(ctx, client, stdout, "GET", workspacePath(workspaceID)+"/channels/"+url.PathEscape(channelID)+"/messages/wait"+query, nil) - case "send": - body, err := messageBody(cmd) - if err != nil { - return err - } - return printClientJSON(ctx, client, stdout, "POST", workspacePath(workspaceID)+"/channels/"+url.PathEscape(channelID)+"/messages", localSenderBody(cmd, body)) - } - return nil -} - -func runDM(ctx context.Context, client *client, cmd command, stdout io.Writer) error { - workspaceID, err := workspaceIDFromCommand(ctx, client, cmd) - if err != nil { - return err - } - target, err := cmd.required("target", "participant") - if err != nil { - return err - } - participantID, err := resolveParticipantID(ctx, client, workspaceID, target) - if err != nil { - return err - } - path := workspacePath(workspaceID) + "/direct-messages/" + url.PathEscape(participantID) + "/messages" - switch cmd.Action { - case "send": - body, err := messageBody(cmd) - if err != nil { - return err - } - return printClientJSON(ctx, client, stdout, "POST", path, localSenderBody(cmd, body)) - case "messages", "list": - return printClientCommandOutput(ctx, client, stdout, cmd, outputMessages, path+messagePageQuery(cmd)) - default: - return fmt.Errorf("unknown dm action: %s", cmd.Action) - } -} - -func runFile(ctx context.Context, client *client, cmd command, stdout io.Writer) error { - workspaceID, err := workspaceIDFromCommand(ctx, client, cmd) - if err != nil { - return err - } - switch cmd.Action { - case "list": - return printClientJSON(ctx, client, stdout, "GET", workspacePath(workspaceID)+"/files", nil) - case "upload": - path, err := cmd.required("path", "file-path") - if err != nil { - return err - } - name := cmd.string("name", filepath.Base(path)) - parsed, err := client.multipart(ctx, "POST", workspacePath(workspaceID)+"/files", map[string]string{ - "scope": cmd.string("scope", "workspace"), - "folderPath": cmd.string("folder", ""), - }, "file", path, name, cmd.string("type", "application/octet-stream")) - if err != nil { - return err - } - return printJSON(stdout, parsed) - case "get", "download": - fileID, err := cmd.required("file") - if err != nil { - return err - } - route := "content" - if cmd.Action == "download" { - route = "download" - } - response, err := client.raw(ctx, "GET", workspacePath(workspaceID)+"/files/"+url.PathEscape(fileID)+"/"+route, requestSpec{}) - if err != nil { - return err - } - defer func() { _ = response.Body.Close() }() - bytes, err := io.ReadAll(response.Body) - if err != nil { - return err - } - if response.StatusCode < 200 || response.StatusCode >= 300 { - return fmt.Errorf("GET file failed with %d: %s", response.StatusCode, string(bytes)) - } - if output := cmd.string("output", ""); output != "" { - return os.WriteFile(filepath.Clean(output), bytes, 0o600) - } - _, err = stdout.Write(bytes) - return err - case "update-content": - fileID, err := cmd.required("file") - if err != nil { - return err - } - content, err := readTextOption(cmd, "content", "content-file") - if err != nil { - return err - } - return printClientJSON(ctx, client, stdout, "PUT", workspacePath(workspaceID)+"/files/"+url.PathEscape(fileID)+"/content", map[string]any{"content": content}) - case "patch", "move", "rename": - fileID, err := cmd.required("file") - if err != nil { - return err - } - return printClientJSON(ctx, client, stdout, "PATCH", workspacePath(workspaceID)+"/files/"+url.PathEscape(fileID), compact(map[string]any{"folderPath": cmd.string("folder", ""), "name": cmd.string("name", ""), "scope": cmd.string("scope", "")})) - case "delete": - fileID, err := cmd.required("file") - if err != nil { - return err - } - return printClientJSON(ctx, client, stdout, "DELETE", workspacePath(workspaceID)+"/files/"+url.PathEscape(fileID), nil) - default: - return fmt.Errorf("unknown file action: %s", cmd.Action) - } -} - -func runFolder(ctx context.Context, client *client, cmd command, stdout io.Writer) error { - workspaceID, err := workspaceIDFromCommand(ctx, client, cmd) - if err != nil { - return err - } - switch cmd.Action { - case "create": - name, err := cmd.required("name") - if err != nil { - return err - } - return printClientJSON(ctx, client, stdout, "POST", workspacePath(workspaceID)+"/folders", map[string]any{"name": name, "parentPath": cmd.string("parent", ""), "scope": cmd.string("scope", "workspace")}) - case "update", "move", "rename": - folderID, err := cmd.required("folder") - if err != nil { - return err - } - return printClientJSON(ctx, client, stdout, "PATCH", workspacePath(workspaceID)+"/folders/"+url.PathEscape(folderID), compact(map[string]any{"name": cmd.string("name", ""), "parentPath": cmd.string("parent", ""), "scope": cmd.string("scope", "")})) - case "delete": - folderID, err := cmd.required("folder") - if err != nil { - return err - } - return printClientJSON(ctx, client, stdout, "DELETE", workspacePath(workspaceID)+"/folders/"+url.PathEscape(folderID), nil) - default: - return fmt.Errorf("unknown folder action: %s", cmd.Action) - } -} - -func printClientJSON(ctx context.Context, client *client, stdout io.Writer, method string, path string, body any) error { - parsed, err := client.json(ctx, method, path, body) - if err != nil { - return err - } - return printJSON(stdout, parsed) -} - -func workspaceIDFromCommand(ctx context.Context, client *client, cmd command) (string, error) { - workspace, err := cmd.required("workspace") - if err != nil { - return "", err - } - return resolveWorkspaceID(ctx, client, workspace) -} - -func channelIDFromCommand(ctx context.Context, client *client, workspaceID string, cmd command) (string, error) { - channel, err := cmd.required("channel") - if err != nil { - return "", err - } - return resolveChannelID(ctx, client, workspaceID, channel) -} - -func messageBody(cmd command) (string, error) { - if cmd.string("body", "") != "" && cmd.string("body-file", "") != "" { - return "", fmt.Errorf("use either --body or --body-file, not both") - } - if file := cmd.string("body-file", ""); file != "" { - bytes, err := os.ReadFile(filepath.Clean(file)) - return string(bytes), err - } - if body := cmd.string("body", ""); body != "" { - return body, nil - } - if len(cmd.Positionals) > 0 { - return strings.Join(cmd.Positionals, " "), nil - } - return "", fmt.Errorf("expected message body through --body, --body-file, or trailing positional text") -} - -func localSenderBody(cmd command, body string) map[string]any { - return compact(map[string]any{ - "body": body, - "senderEmail": firstNonEmpty(cmd.string("sender", ""), cmd.string("sender-email", "")), - "senderName": cmd.string("sender-name", ""), - }) -} - -func messagePageQuery(cmd command) string { - params := url.Values{} - for _, key := range []string{"position", "before", "after", "around", "limit"} { - if value := cmd.string(key, ""); value != "" { - params.Set(key, value) - } - } - if len(params) == 0 { - return "" - } - return "?" + params.Encode() -} - -func channelWaitQuery(cmd command) (string, error) { - params := url.Values{} - if value := cmd.string("after", ""); value != "" { - params.Set("after", value) - } - if value := cmd.string("timeout-ms", ""); value != "" { - if _, err := numberOption(cmd, "timeout-ms", 0); err != nil { - return "", err - } - params.Set("timeoutMs", value) - } - if len(params) == 0 { - return "", nil - } - return "?" + params.Encode(), nil -} +import "strings" func methodUpper(value string) string { return strings.ToUpper(value) } - -var _ = http.MethodGet diff --git a/internal/craken/util.go b/internal/craken/util.go index b3d03aa..bec6c4c 100644 --- a/internal/craken/util.go +++ b/internal/craken/util.go @@ -1,19 +1,12 @@ package craken import ( - "encoding/base64" "encoding/json" "fmt" - "net/url" - "os" - "path/filepath" - "regexp" "strconv" "strings" ) -var idPattern = regexp.MustCompile(`(?i)^[0-9a-f]{8}-[0-9a-f-]{27,}$`) - func trim(value string) string { return strings.TrimSpace(value) } @@ -59,55 +52,6 @@ func printJSON(stdout interface{ Write([]byte) (int, error) }, value any) error return err } -func readTextOption(cmd command, valueName string, fileName string) (string, error) { - value := cmd.string(valueName, "") - file := cmd.string(fileName, "") - if value != "" && file != "" { - return "", fmt.Errorf("use either --%s or --%s, not both", valueName, fileName) - } - if file != "" { - bytes, err := os.ReadFile(filepath.Clean(file)) - if err != nil { - return "", err - } - return string(bytes), nil - } - if value != "" { - return value, nil - } - return "", fmt.Errorf("expected --%s", valueName) -} - -func jsonOption(cmd command, jsonName string, fileName string, required bool) (any, error) { - value := cmd.string(jsonName, "") - file := cmd.string(fileName, "") - if value != "" && file != "" { - return nil, fmt.Errorf("use either --%s or --%s, not both", jsonName, fileName) - } - if value != "" { - var parsed any - if err := json.Unmarshal([]byte(value), &parsed); err != nil { - return nil, err - } - return parsed, nil - } - if file != "" { - bytes, err := os.ReadFile(filepath.Clean(file)) - if err != nil { - return nil, err - } - var parsed any - if err := json.Unmarshal(bytes, &parsed); err != nil { - return nil, err - } - return parsed, nil - } - if required { - return nil, fmt.Errorf("expected --%s or --%s", jsonName, fileName) - } - return nil, nil -} - func numberOption(cmd command, name string, fallback int) (int, error) { value := cmd.string(name, "") if value == "" { @@ -120,53 +64,6 @@ func numberOption(cmd command, name string, fallback int) (int, error) { return parsed, nil } -func positiveNumberOption(cmd command, name string) (*int, error) { - value := cmd.string(name, "") - if value == "" { - return nil, nil - } - parsed, err := strconv.Atoi(value) - if err != nil || parsed < 1 { - return nil, fmt.Errorf("expected positive integer, got %s", value) - } - return &parsed, nil -} - func boolOption(cmd command, name string) bool { return cmd.Flags[name] || cmd.string(name, "") != "" } - -func stringListOption(cmd command, name string) []string { - value := cmd.string(name, "") - if value == "" { - return nil - } - parts := strings.Split(value, ",") - out := make([]string, 0, len(parts)) - for _, part := range parts { - if part = trim(part); part != "" { - out = append(out, part) - } - } - return out -} - -func looksLikeID(value string) bool { - return idPattern.MatchString(value) -} - -func workspacePath(workspaceID string) string { - return "/api/workspaces/" + url.PathEscape(workspaceID) -} - -func wikiPagePath(workspaceID string, title string) string { - return workspacePath(workspaceID) + "/wiki/pages/" + url.PathEscape(title) -} - -func bearerProtocols(token string) []string { - payload, _ := json.Marshal(map[string]string{"token": token}) - return []string{ - "craken-bearer", - "craken-bearer-payload." + base64.RawURLEncoding.EncodeToString(payload), - } -} diff --git a/internal/craken/wiki_agent.go b/internal/craken/wiki_agent.go deleted file mode 100644 index b565406..0000000 --- a/internal/craken/wiki_agent.go +++ /dev/null @@ -1,281 +0,0 @@ -package craken - -import ( - "context" - "fmt" - "io" - "net/url" - "time" -) - -func runWiki(ctx context.Context, client *client, cmd command, stdout io.Writer) error { - workspaceID, err := workspaceIDFromCommand(ctx, client, cmd) - if err != nil { - return err - } - switch cmd.Action { - case "list": - return printClientJSON(ctx, client, stdout, "GET", workspacePath(workspaceID)+"/wiki/pages", nil) - case "deleted": - return printClientJSON(ctx, client, stdout, "GET", workspacePath(workspaceID)+"/wiki/deleted-pages", nil) - case "recent": - path := workspacePath(workspaceID) + "/wiki/recent-changes" - if limit := cmd.string("limit", ""); limit != "" { - path += "?limit=" + url.QueryEscape(limit) - } - return printClientCommandOutput(ctx, client, stdout, cmd, outputWikiRecent, path) - case "get": - title, err := cmd.required("title", "page") - if err != nil { - return err - } - return printClientJSON(ctx, client, stdout, "GET", wikiPagePath(workspaceID, title), nil) - case "save": - content, err := readTextOption(cmd, "content", "content-file") - if err != nil { - return err - } - baseVersionNumber, err := positiveNumberOption(cmd, "base-version") - if err != nil { - return err - } - if existing := cmd.string("existing-title", ""); existing != "" { - return printClientJSON( - ctx, - client, - stdout, - "PATCH", - wikiPagePath(workspaceID, existing), - wikiSaveBody(content, cmd.string("title", existing), baseVersionNumber), - ) - } - title, err := cmd.required("title") - if err != nil { - return err - } - return printClientJSON(ctx, client, stdout, "POST", workspacePath(workspaceID)+"/wiki/pages", wikiSaveBody(content, title, baseVersionNumber)) - case "delete": - title, err := cmd.required("title", "page") - if err != nil { - return err - } - return printClientJSON(ctx, client, stdout, "DELETE", wikiPagePath(workspaceID, title), nil) - case "restore": - title, err := cmd.required("title", "page") - if err != nil { - return err - } - return printClientJSON(ctx, client, stdout, "POST", wikiPagePath(workspaceID, title)+"/restore", map[string]any{}) - case "versions": - title, err := cmd.required("title", "page") - if err != nil { - return err - } - return printClientCommandOutput(ctx, client, stdout, cmd, outputWikiVersions, wikiPagePath(workspaceID, title)+"/versions") - case "version": - title, err := cmd.required("title", "page") - if err != nil { - return err - } - version, err := cmd.required("version") - if err != nil { - return err - } - return printClientCommandOutput( - ctx, - client, - stdout, - cmd, - outputWikiVersion, - wikiPagePath(workspaceID, title)+"/versions/"+url.PathEscape(version), - ) - case "diff": - title, err := cmd.required("title", "page") - if err != nil { - return err - } - params := url.Values{} - if value := cmd.string("from", ""); value != "" { - params.Set("from", value) - } - if value := cmd.string("to", ""); value != "" { - params.Set("to", value) - } - return printClientJSON(ctx, client, stdout, "GET", wikiPagePath(workspaceID, title)+"/diff?"+params.Encode(), nil) - default: - return fmt.Errorf("unknown wiki action: %s", cmd.Action) - } -} - -func wikiSaveBody(content string, title string, baseVersionNumber *int) map[string]any { - body := map[string]any{"content": content, "title": title} - if baseVersionNumber != nil { - body["baseVersionNumber"] = *baseVersionNumber - } - return body -} - -func runAgent(ctx context.Context, client *client, cmd command, stdout io.Writer) error { - if cmd.Action == "plan-check" { - previous, err := jsonOption(cmd, "previous-json", "previous-file", false) - if err != nil { - return err - } - next, err := jsonOption(cmd, "next-json", "next-file", true) - if err != nil { - return err - } - return printClientJSON(ctx, client, stdout, "POST", "/api/admin/agent-job-plan/progression", map[string]any{"previousPlan": previous, "nextPlan": next}) - } - workspaceID, err := workspaceIDFromCommand(ctx, client, cmd) - if err != nil { - return err - } - switch cmd.Action { - case "create": - name, err := cmd.required("name") - if err != nil { - return err - } - purpose, err := cmd.required("purpose") - if err != nil { - return err - } - return printClientJSON(ctx, client, stdout, "POST", workspacePath(workspaceID)+"/agents", map[string]any{"name": name, "purpose": purpose}) - case "jobs": - params := url.Values{} - if value := cmd.string("limit", ""); value != "" { - params.Set("limit", value) - } - if value := cmd.string("offset", ""); value != "" { - params.Set("offset", value) - } - path := workspacePath(workspaceID) + "/agent-jobs" - if len(params) > 0 { - path += "?" + params.Encode() - } - return printClientJSON(ctx, client, stdout, "GET", path, nil) - case "job": - wake, err := cmd.required("wake", "job") - if err != nil { - return err - } - return printClientJSON(ctx, client, stdout, "GET", workspacePath(workspaceID)+"/agent-jobs/"+url.PathEscape(wake), nil) - case "watch": - return watchAgentJob(ctx, client, workspaceID, cmd, stdout) - case "interrupt", "stop": - wake, err := cmd.required("wake") - if err != nil { - return err - } - return printClientJSON(ctx, client, stdout, "POST", workspacePath(workspaceID)+"/agent-wakes/"+url.PathEscape(wake)+"/interrupt", compact(map[string]any{"reason": cmd.string("reason", "")})) - default: - return fmt.Errorf("unknown agent action: %s", cmd.Action) - } -} - -func watchAgentJob(ctx context.Context, client *client, workspaceID string, cmd command, stdout io.Writer) error { - jobID, err := cmd.required("wake", "job") - if err != nil { - return err - } - interval, err := numberOption(cmd, "interval", 2) - if err != nil { - return err - } - maxPolls, err := numberOption(cmd, "max-polls", 60) - if err != nil { - return err - } - var last string - for index := 0; index < maxPolls; index++ { - parsed, err := client.json(ctx, "GET", workspacePath(workspaceID)+"/agent-jobs/"+url.PathEscape(jobID), nil) - if err != nil { - return err - } - snapshot := agentJobWatchSnapshot(parsed) - signature := fmt.Sprintf("%v", compact(map[string]any{"status": snapshot["status"], "plan": snapshot["plan"], "error": snapshot["error"]})) - if boolOption(cmd, "verbose") || signature != last { - if err := printJSON(stdout, snapshot); err != nil { - return err - } - } - last = signature - if terminalStatus(snapshot["status"]) || index == maxPolls-1 { - return nil - } - timer := time.NewTimer(time.Duration(interval) * time.Second) - select { - case <-ctx.Done(): - timer.Stop() - return ctx.Err() - case <-timer.C: - } - } - return nil -} - -func agentJobWatchSnapshot(parsed any) map[string]any { - root, _ := parsed.(map[string]any) - job, _ := root["job"].(map[string]any) - plan, _ := job["plan"].(map[string]any) - var planSnapshot any - if plan != nil { - itemsRaw, _ := plan["items"].([]any) - items := make([]any, 0, len(itemsRaw)) - completed := 0 - for _, itemRaw := range itemsRaw { - item, _ := itemRaw.(map[string]any) - if itemString(item, "status") == "done" { - completed++ - } - items = append(items, compact(map[string]any{"evidence": item["evidence"], "id": item["id"], "status": item["status"], "title": item["title"]})) - } - planSnapshot = map[string]any{"completedItems": completed, "items": items, "nextAction": plan["nextAction"], "status": plan["status"], "totalItems": len(items)} - } - return map[string]any{ - "error": job["error"], - "executionMode": job["executionMode"], - "id": job["id"], - "observedAt": time.Now().UTC().Format(time.RFC3339Nano), - "plan": planSnapshot, - "sliceCount": job["sliceCount"], - "status": job["status"], - } -} - -func terminalStatus(value any) bool { - status, _ := value.(string) - return status == "cancelled" || status == "completed" || status == "failed" -} - -func runDream(ctx context.Context, client *client, cmd command, stdout io.Writer) error { - workspaceID, err := workspaceIDFromCommand(ctx, client, cmd) - if err != nil { - return err - } - agentName, err := cmd.required("agent") - if err != nil { - return err - } - participantID, err := resolveParticipantID(ctx, client, workspaceID, agentName) - if err != nil { - return err - } - if len(participantID) < len("agent:") || participantID[:len("agent:")] != "agent:" { - return fmt.Errorf("dream agent must resolve to an agent participant, got %s", participantID) - } - agentID := participantID[len("agent:"):] - switch cmd.Action { - case "issue-smoke": - return printClientJSON(ctx, client, stdout, "POST", "/api/admin/dream/issue-smoke", map[string]any{"agentId": agentID, "agentName": agentName, "reason": cmd.string("reason", "manual Dream GitHub issue reporter smoke test"), "workspaceId": workspaceID}) - case "run": - limit, err := numberOption(command{Options: map[string]string{"activity-limit": firstNonEmpty(cmd.string("activity-limit", ""), cmd.string("limit", ""))}}, "activity-limit", 50) - if err != nil { - return err - } - return printClientJSON(ctx, client, stdout, "POST", "/api/admin/dream/run", map[string]any{"activityLimit": limit, "agentId": agentID, "apply": boolOption(cmd, "apply"), "reason": cmd.string("reason", "manual CLI dream run"), "workspaceId": workspaceID}) - default: - return fmt.Errorf("unknown dream action: %s", cmd.Action) - } -}