From fd35e688ab6a4a1302d22a842c5d42fd466060d7 Mon Sep 17 00:00:00 2001 From: akcorca Date: Mon, 25 May 2026 20:39:47 +0900 Subject: [PATCH 1/7] Execute shortcuts from server catalog --- docs/architecture.md | 4 +- internal/craken/catalog_command.go | 501 +++++++++++++++++++++++++++++ internal/craken/client.go | 6 +- internal/craken/command.go | 31 +- internal/craken/command_test.go | 129 ++++++++ internal/craken/generic.go | 57 +++- internal/craken/output.go | 16 - internal/craken/resolve.go | 6 +- internal/craken/resources.go | 362 +-------------------- internal/craken/util.go | 82 ----- internal/craken/wiki_agent.go | 199 +----------- 11 files changed, 700 insertions(+), 693 deletions(-) create mode 100644 internal/craken/catalog_command.go diff --git a/docs/architecture.md b/docs/architecture.md index 7b6bf85..e986116 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2,11 +2,11 @@ `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. -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. The binary keeps only local bootstrap and transport primitives: profile storage, device login polling, request construction, file upload/download, realtime WebSocket handling, and output shaping. `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. diff --git a/internal/craken/catalog_command.go b/internal/craken/catalog_command.go new file mode 100644 index 0000000..bc3d87b --- /dev/null +++ b/internal/craken/catalog_command.go @@ -0,0 +1,501 @@ +package craken + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strconv" + "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 = "http" + } + 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, *selectedRoute, plan, cmd) + if err != nil { + return err + } + switch plan.Transport { + case "http": + return runCatalogHTTPCommand(ctx, client, *selectedRoute, plan, cmd, requestPath, resolved, consumed, stdout, stdin) + case "download": + return runCatalogDownloadCommand(ctx, client, *selectedRoute, cmd, requestPath, stdout) + case "multipart": + return runCatalogMultipartCommand(ctx, client, cmd, requestPath, stdout) + case "websocket": + workspaceID := resolved["workspaceId"] + if workspaceID == "" { + return fmt.Errorf("websocket command requires workspaceId") + } + return tailWorkspace(ctx, client, workspaceID, cmd, 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 variant.Output != "" { + base.Output = variant.Output + } + if variant.PathParams != nil { + base.PathParams = variant.PathParams + } + if variant.QueryParams != nil { + base.QueryParams = variant.QueryParams + } + if variant.BodyFields != nil { + base.BodyFields = variant.BodyFields + } + return base +} + +func catalogCommandPath(ctx context.Context, client *client, 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 == "" { + binding = inferredPathBinding(name) + } + value, err := catalogBindingString(ctx, client, 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 inferredPathBinding(name string) commandBinding { + switch name { + case "workspaceId": + return commandBinding{Option: "workspace", Required: true, Resolver: "workspace", Source: "option"} + case "channelId": + return commandBinding{Option: "channel", Required: true, Resolver: "channel", Scope: "workspaceId", Source: "option"} + case "participantId": + return commandBinding{Aliases: []string{"participant"}, Option: "target", Required: true, Resolver: "participant", Scope: "workspaceId", Source: "option"} + case "pageTitle": + return commandBinding{Aliases: []string{"page"}, Option: "title", Required: true, Source: "option"} + case "fileId": + return commandBinding{Option: "file", Required: true, Source: "option"} + case "folderId": + return commandBinding{Option: "folder", Required: true, Source: "option"} + case "jobId": + return commandBinding{Aliases: []string{"wake"}, Option: "job", Required: true, Source: "option"} + case "wakeId": + return commandBinding{Option: "wake", Required: true, Source: "option"} + case "token": + return commandBinding{Aliases: []string{"token"}, Option: "invitation-token", Required: true, Source: "option"} + case "versionNumber": + return commandBinding{Aliases: []string{"version"}, Option: "version-number", Required: true, Source: "option"} + default: + return commandBinding{Option: kebabCase(name), Required: true, Source: "option"} + } +} + +func runCatalogHTTPCommand( + ctx context.Context, + client *client, + 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.Output == "agent-job-watch" { + if optionText(cmd, []string{"job", "wake"}) == "" { + return fmt.Errorf("expected --job") + } + return watchAgentJob(ctx, client, resolved["workspaceId"], cmd, stdout) + } + requestPath, spec, err := catalogHTTPRequest(ctx, client, 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, + 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, 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, 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 catalogValues( + ctx context.Context, + client *client, + 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, 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, + cmd command, + binding commandBinding, + resolved map[string]string, + consumed map[string]bool, +) (string, error) { + value, ok, err := catalogBindingValue(ctx, client, 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, + cmd command, + binding commandBinding, + resolved map[string]string, + consumed map[string]bool, +) (any, bool, error) { + switch binding.Source { + case "literal": + return binding.Value, true, nil + case "flag": + if binding.Option == "" { + return nil, false, nil + } + consumed[binding.Option] = true + return boolOption(cmd, binding.Option), true, nil + case "text": + 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, binding, fmt.Sprint(value), resolved) + return resolvedValue, true, err + case "option", "": + value, source, ok := bindingOptionValue(cmd, binding) + if !ok { + if binding.Required { + return nil, false, fmt.Errorf("expected --%s", binding.Option) + } + return nil, false, nil + } + consumed[source] = true + if binding.Type == "json" { + parsed, err := jsonBindingValue(value, source) + return parsed, true, err + } + if binding.Type == "integer" { + 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, 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 == "join" && 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, + binding commandBinding, + value string, + resolved map[string]string, +) (string, error) { + switch binding.Resolver { + case "": + return value, nil + case "workspace": + return resolveWorkspaceID(ctx, client, value) + case "channel": + workspaceID := resolved[binding.Scope] + if workspaceID == "" { + return "", fmt.Errorf("resolver channel requires scope %s", binding.Scope) + } + return resolveChannelID(ctx, client, workspaceID, value) + case "participant", "agent": + workspaceID := resolved[binding.Scope] + if workspaceID == "" { + return "", fmt.Errorf("resolver %s requires scope %s", binding.Resolver, binding.Scope) + } + participantID, err := resolveParticipantID(ctx, client, workspaceID, value) + if err != nil { + return "", err + } + if binding.Resolver == "agent" { + if !strings.HasPrefix(participantID, "agent:") { + return "", fmt.Errorf("dream agent must resolve to an agent participant, got %s", participantID) + } + return strings.TrimPrefix(participantID, "agent:"), nil + } + return participantID, nil + default: + return "", fmt.Errorf("unsupported catalog resolver: %s", binding.Resolver) + } +} + +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, cmd command, path string, stdout io.Writer) error { + filePath, err := cmd.required("path", "file-path") + if err != nil { + return err + } + parsed, err := client.multipart(ctx, http.MethodPost, path, map[string]string{ + "folderPath": cmd.string("folder", ""), + "scope": cmd.string("scope", "workspace"), + }, "file", filePath, cmd.string("name", filepath.Base(filePath)), cmd.string("type", "application/octet-stream")) + if err != nil { + return err + } + return printJSON(stdout, parsed) +} + +func printCatalogCommandPayload(stdout io.Writer, payload responsePayload, cmd command, output string) error { + switch output { + case "": + return printPayload(stdout, payload, cmd) + case "messages": + return printCommandOutput(stdout, payload.Parsed, cmd, outputMessages) + case "wiki-recent": + return printCommandOutput(stdout, payload.Parsed, cmd, outputWikiRecent) + case "wiki-version": + return printCommandOutput(stdout, payload.Parsed, cmd, outputWikiVersion) + case "wiki-versions": + return printCommandOutput(stdout, payload.Parsed, cmd, outputWikiVersions) + case "json", "bytes": + return printPayload(stdout, payload, cmd) + default: + return fmt.Errorf("unsupported catalog command output: %s", output) + } +} + +func optionText(cmd command, names []string) string { + for _, name := range names { + if value := cmd.string(name, ""); value != "" { + return value + } + } + return "" +} 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..588704d 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 } @@ -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 diff --git a/internal/craken/command_test.go b/internal/craken/command_test.go index 6001b92..b0e9933 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) } @@ -327,6 +330,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 +567,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 +612,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 +675,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 +729,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 +792,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 +835,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 +878,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,6 +936,9 @@ 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": @@ -954,6 +984,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 +1023,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"}}}) @@ -1055,6 +1091,99 @@ 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": "messages"}), + 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": "messages"}), + testCommand("wiki.recent", "wiki.recent-changes", map[string]any{"output": "wiki-recent"}), + testCommand("wiki.save", "wiki.pages.update", 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"}, + }, + "pathParams": map[string]any{"pageTitle": map[string]any{"source": "option", "option": "existing-title", "required": true}}, + }), + 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{"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("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.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 + return map[string]any{ + "command": "craken " + strings.Replace(id, ".", " ", 1), + "description": "Test command " + id, + "execution": execution, + "group": "Test", + "id": id, + "operationId": operationID, + } +} + +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..41be726 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,50 @@ 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"` + OperationID string `json:"operationId,omitempty"` + Output string `json:"output,omitempty"` + PathParams map[string]commandBinding `json:"pathParams,omitempty"` + QueryParams map[string]commandBinding `json:"queryParams,omitempty"` + Transport string `json:"transport,omitempty"` + Variants []commandExecutionVariant `json:"variants,omitempty"` +} + +type commandExecutionVariant struct { + BodyFields map[string]commandBinding `json:"bodyFields,omitempty"` + OperationID string `json:"operationId,omitempty"` + Output string `json:"output,omitempty"` + PathParams map[string]commandBinding `json:"pathParams,omitempty"` + QueryParams map[string]commandBinding `json:"queryParams,omitempty"` + Transport string `json:"transport,omitempty"` + When commandCondition `json:"when,omitempty"` +} + +type commandCondition struct { + Option string `json:"option,omitempty"` +} + +type commandBinding struct { + Aliases []string `json:"aliases,omitempty"` + FileOption string `json:"fileOption,omitempty"` + Option string `json:"option,omitempty"` + Positionals string `json:"positionals,omitempty"` + Required bool `json:"required,omitempty"` + Resolver string `json:"resolver,omitempty"` + Scope string `json:"scope,omitempty"` + Source string `json:"source,omitempty"` + Type string `json:"type,omitempty"` + Value any `json:"value,omitempty"` } type commandExample struct { @@ -68,7 +108,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 +135,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 +427,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..5dcd049 100644 --- a/internal/craken/output.go +++ b/internal/craken/output.go @@ -1,7 +1,6 @@ package craken import ( - "context" "fmt" "io" "strings" @@ -16,21 +15,6 @@ const ( 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 { fields := cmd.string("fields", "") if boolOption(cmd, "compact") && fields != "" { diff --git a/internal/craken/resolve.go b/internal/craken/resolve.go index 3e84f33..308cbe8 100644 --- a/internal/craken/resolve.go +++ b/internal/craken/resolve.go @@ -10,7 +10,7 @@ func resolveWorkspaceID(ctx context.Context, client *client, value string) (stri if looksLikeID(value) { return value, nil } - parsed, err := client.json(ctx, "GET", "/api/workspaces", nil) + parsed, err := client.json(ctx, "/api/workspaces") if err != nil { return "", err } @@ -29,7 +29,7 @@ func resolveChannelID(ctx context.Context, client *client, workspaceID string, v if looksLikeID(value) { return value, nil } - parsed, err := client.json(ctx, "GET", workspacePath(workspaceID), nil) + parsed, err := client.json(ctx, workspacePath(workspaceID)) if err != nil { return "", err } @@ -49,7 +49,7 @@ func resolveParticipantID(ctx context.Context, client *client, workspaceID strin return value, nil } lower := strings.ToLower(value) - parsed, err := client.json(ctx, "GET", workspacePath(workspaceID), nil) + parsed, err := client.json(ctx, workspacePath(workspaceID)) if err != nil { return "", err } 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..1ea7245 100644 --- a/internal/craken/util.go +++ b/internal/craken/util.go @@ -5,8 +5,6 @@ import ( "encoding/json" "fmt" "net/url" - "os" - "path/filepath" "regexp" "strconv" "strings" @@ -59,55 +57,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,37 +69,10 @@ 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) } @@ -159,10 +81,6 @@ 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{ diff --git a/internal/craken/wiki_agent.go b/internal/craken/wiki_agent.go index b565406..7adc9af 100644 --- a/internal/craken/wiki_agent.go +++ b/internal/craken/wiki_agent.go @@ -8,172 +8,6 @@ import ( "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 { @@ -189,7 +23,7 @@ func watchAgentJob(ctx context.Context, client *client, workspaceID string, cmd } var last string for index := 0; index < maxPolls; index++ { - parsed, err := client.json(ctx, "GET", workspacePath(workspaceID)+"/agent-jobs/"+url.PathEscape(jobID), nil) + parsed, err := client.json(ctx, workspacePath(workspaceID)+"/agent-jobs/"+url.PathEscape(jobID)) if err != nil { return err } @@ -248,34 +82,3 @@ 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) - } -} From 99d88b429c5e4b8ee4838e2b844d25f89c6e9482 Mon Sep 17 00:00:00 2001 From: akcorca Date: Mon, 25 May 2026 21:09:15 +0900 Subject: [PATCH 2/7] Refine catalog command structure --- internal/craken/catalog_binding.go | 202 +++++++++++++++++++++++++++++ internal/craken/catalog_command.go | 193 --------------------------- 2 files changed, 202 insertions(+), 193 deletions(-) create mode 100644 internal/craken/catalog_binding.go diff --git a/internal/craken/catalog_binding.go b/internal/craken/catalog_binding.go new file mode 100644 index 0000000..55bdbfd --- /dev/null +++ b/internal/craken/catalog_binding.go @@ -0,0 +1,202 @@ +package craken + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" +) + +func catalogValues( + ctx context.Context, + client *client, + 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, 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, + cmd command, + binding commandBinding, + resolved map[string]string, + consumed map[string]bool, +) (string, error) { + value, ok, err := catalogBindingValue(ctx, client, 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, + cmd command, + binding commandBinding, + resolved map[string]string, + consumed map[string]bool, +) (any, bool, error) { + switch binding.Source { + case "literal": + return binding.Value, true, nil + case "flag": + if binding.Option == "" { + return nil, false, nil + } + consumed[binding.Option] = true + return boolOption(cmd, binding.Option), true, nil + case "text": + 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, binding, fmt.Sprint(value), resolved) + return resolvedValue, true, err + case "option", "": + value, source, ok := bindingOptionValue(cmd, binding) + if !ok { + if binding.Required { + return nil, false, fmt.Errorf("expected --%s", binding.Option) + } + return nil, false, nil + } + consumed[source] = true + if binding.Type == "json" { + parsed, err := jsonBindingValue(value, source) + return parsed, true, err + } + if binding.Type == "integer" { + 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, 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 == "join" && 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, + binding commandBinding, + value string, + resolved map[string]string, +) (string, error) { + switch binding.Resolver { + case "": + return value, nil + case "workspace": + return resolveWorkspaceID(ctx, client, value) + case "channel": + workspaceID := resolved[binding.Scope] + if workspaceID == "" { + return "", fmt.Errorf("resolver channel requires scope %s", binding.Scope) + } + return resolveChannelID(ctx, client, workspaceID, value) + case "participant", "agent": + workspaceID := resolved[binding.Scope] + if workspaceID == "" { + return "", fmt.Errorf("resolver %s requires scope %s", binding.Resolver, binding.Scope) + } + participantID, err := resolveParticipantID(ctx, client, workspaceID, value) + if err != nil { + return "", err + } + if binding.Resolver == "agent" { + if !strings.HasPrefix(participantID, "agent:") { + return "", fmt.Errorf("dream agent must resolve to an agent participant, got %s", participantID) + } + return strings.TrimPrefix(participantID, "agent:"), nil + } + return participantID, nil + default: + return "", fmt.Errorf("unsupported catalog resolver: %s", binding.Resolver) + } +} diff --git a/internal/craken/catalog_command.go b/internal/craken/catalog_command.go index bc3d87b..c65e58f 100644 --- a/internal/craken/catalog_command.go +++ b/internal/craken/catalog_command.go @@ -2,7 +2,6 @@ package craken import ( "context" - "encoding/json" "errors" "fmt" "io" @@ -10,7 +9,6 @@ import ( "net/url" "os" "path/filepath" - "strconv" "strings" ) @@ -246,197 +244,6 @@ func catalogHTTPRequest( return path, spec, nil } -func catalogValues( - ctx context.Context, - client *client, - 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, 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, - cmd command, - binding commandBinding, - resolved map[string]string, - consumed map[string]bool, -) (string, error) { - value, ok, err := catalogBindingValue(ctx, client, 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, - cmd command, - binding commandBinding, - resolved map[string]string, - consumed map[string]bool, -) (any, bool, error) { - switch binding.Source { - case "literal": - return binding.Value, true, nil - case "flag": - if binding.Option == "" { - return nil, false, nil - } - consumed[binding.Option] = true - return boolOption(cmd, binding.Option), true, nil - case "text": - 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, binding, fmt.Sprint(value), resolved) - return resolvedValue, true, err - case "option", "": - value, source, ok := bindingOptionValue(cmd, binding) - if !ok { - if binding.Required { - return nil, false, fmt.Errorf("expected --%s", binding.Option) - } - return nil, false, nil - } - consumed[source] = true - if binding.Type == "json" { - parsed, err := jsonBindingValue(value, source) - return parsed, true, err - } - if binding.Type == "integer" { - 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, 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 == "join" && 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, - binding commandBinding, - value string, - resolved map[string]string, -) (string, error) { - switch binding.Resolver { - case "": - return value, nil - case "workspace": - return resolveWorkspaceID(ctx, client, value) - case "channel": - workspaceID := resolved[binding.Scope] - if workspaceID == "" { - return "", fmt.Errorf("resolver channel requires scope %s", binding.Scope) - } - return resolveChannelID(ctx, client, workspaceID, value) - case "participant", "agent": - workspaceID := resolved[binding.Scope] - if workspaceID == "" { - return "", fmt.Errorf("resolver %s requires scope %s", binding.Resolver, binding.Scope) - } - participantID, err := resolveParticipantID(ctx, client, workspaceID, value) - if err != nil { - return "", err - } - if binding.Resolver == "agent" { - if !strings.HasPrefix(participantID, "agent:") { - return "", fmt.Errorf("dream agent must resolve to an agent participant, got %s", participantID) - } - return strings.TrimPrefix(participantID, "agent:"), nil - } - return participantID, nil - default: - return "", fmt.Errorf("unsupported catalog resolver: %s", binding.Resolver) - } -} - 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 { From 4aa50a649a26ce5a0a31f016273bc054484d5bac Mon Sep 17 00:00:00 2001 From: akcorca Date: Mon, 25 May 2026 21:12:46 +0900 Subject: [PATCH 3/7] Type catalog execution contracts --- internal/craken/catalog_binding.go | 22 ++++++------- internal/craken/catalog_command.go | 46 +++++++++++++-------------- internal/craken/catalog_contract.go | 48 +++++++++++++++++++++++++++++ internal/craken/generic.go | 28 ++++++++--------- 4 files changed, 96 insertions(+), 48 deletions(-) create mode 100644 internal/craken/catalog_contract.go diff --git a/internal/craken/catalog_binding.go b/internal/craken/catalog_binding.go index 55bdbfd..e81c719 100644 --- a/internal/craken/catalog_binding.go +++ b/internal/craken/catalog_binding.go @@ -62,15 +62,15 @@ func catalogBindingValue( consumed map[string]bool, ) (any, bool, error) { switch binding.Source { - case "literal": + case commandBindingSourceLiteral: return binding.Value, true, nil - case "flag": + case commandBindingSourceFlag: if binding.Option == "" { return nil, false, nil } consumed[binding.Option] = true return boolOption(cmd, binding.Option), true, nil - case "text": + case commandBindingSourceText: value, ok, err := textBindingValue(cmd, binding) if err != nil || !ok { if binding.Required && !ok { @@ -80,7 +80,7 @@ func catalogBindingValue( } resolvedValue, err := resolveBoundValue(ctx, client, binding, fmt.Sprint(value), resolved) return resolvedValue, true, err - case "option", "": + case commandBindingSourceOption, "": value, source, ok := bindingOptionValue(cmd, binding) if !ok { if binding.Required { @@ -89,11 +89,11 @@ func catalogBindingValue( return nil, false, nil } consumed[source] = true - if binding.Type == "json" { + if binding.Type == commandBindingValueTypeJSON { parsed, err := jsonBindingValue(value, source) return parsed, true, err } - if binding.Type == "integer" { + 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) @@ -141,7 +141,7 @@ func textBindingValue(cmd command, binding commandBinding) (string, bool, error) if hasValue { return value, true, nil } - if binding.Positionals == "join" && len(cmd.Positionals) > 0 { + if binding.Positionals == commandBindingPositionalsJoin && len(cmd.Positionals) > 0 { return strings.Join(cmd.Positionals, " "), true, nil } return "", false, nil @@ -172,15 +172,15 @@ func resolveBoundValue( switch binding.Resolver { case "": return value, nil - case "workspace": + case commandBindingResolverWorkspace: return resolveWorkspaceID(ctx, client, value) - case "channel": + case commandBindingResolverChannel: workspaceID := resolved[binding.Scope] if workspaceID == "" { return "", fmt.Errorf("resolver channel requires scope %s", binding.Scope) } return resolveChannelID(ctx, client, workspaceID, value) - case "participant", "agent": + case commandBindingResolverParticipant, commandBindingResolverAgent: workspaceID := resolved[binding.Scope] if workspaceID == "" { return "", fmt.Errorf("resolver %s requires scope %s", binding.Resolver, binding.Scope) @@ -189,7 +189,7 @@ func resolveBoundValue( if err != nil { return "", err } - if binding.Resolver == "agent" { + if binding.Resolver == commandBindingResolverAgent { if !strings.HasPrefix(participantID, "agent:") { return "", fmt.Errorf("dream agent must resolve to an agent participant, got %s", participantID) } diff --git a/internal/craken/catalog_command.go b/internal/craken/catalog_command.go index c65e58f..d31b201 100644 --- a/internal/craken/catalog_command.go +++ b/internal/craken/catalog_command.go @@ -30,7 +30,7 @@ func runCatalogCommand(ctx context.Context, client *client, cmd command, stdout plan.OperationID = serverCommand.OperationID } if plan.Transport == "" { - plan.Transport = "http" + plan.Transport = commandTransportHTTP } selectedRoute := routeByID(catalog.Routes, plan.OperationID) if selectedRoute == nil { @@ -41,13 +41,13 @@ func runCatalogCommand(ctx context.Context, client *client, cmd command, stdout return err } switch plan.Transport { - case "http": + case commandTransportHTTP: return runCatalogHTTPCommand(ctx, client, *selectedRoute, plan, cmd, requestPath, resolved, consumed, stdout, stdin) - case "download": + case commandTransportDownload: return runCatalogDownloadCommand(ctx, client, *selectedRoute, cmd, requestPath, stdout) - case "multipart": + case commandTransportMultipart: return runCatalogMultipartCommand(ctx, client, cmd, requestPath, stdout) - case "websocket": + case commandTransportWebSocket: workspaceID := resolved["workspaceId"] if workspaceID == "" { return fmt.Errorf("websocket command requires workspaceId") @@ -135,27 +135,27 @@ func catalogCommandPath(ctx context.Context, client *client, route route, plan c func inferredPathBinding(name string) commandBinding { switch name { case "workspaceId": - return commandBinding{Option: "workspace", Required: true, Resolver: "workspace", Source: "option"} + return commandBinding{Option: "workspace", Required: true, Resolver: commandBindingResolverWorkspace, Source: commandBindingSourceOption} case "channelId": - return commandBinding{Option: "channel", Required: true, Resolver: "channel", Scope: "workspaceId", Source: "option"} + return commandBinding{Option: "channel", Required: true, Resolver: commandBindingResolverChannel, Scope: "workspaceId", Source: commandBindingSourceOption} case "participantId": - return commandBinding{Aliases: []string{"participant"}, Option: "target", Required: true, Resolver: "participant", Scope: "workspaceId", Source: "option"} + return commandBinding{Aliases: []string{"participant"}, Option: "target", Required: true, Resolver: commandBindingResolverParticipant, Scope: "workspaceId", Source: commandBindingSourceOption} case "pageTitle": - return commandBinding{Aliases: []string{"page"}, Option: "title", Required: true, Source: "option"} + return commandBinding{Aliases: []string{"page"}, Option: "title", Required: true, Source: commandBindingSourceOption} case "fileId": - return commandBinding{Option: "file", Required: true, Source: "option"} + return commandBinding{Option: "file", Required: true, Source: commandBindingSourceOption} case "folderId": - return commandBinding{Option: "folder", Required: true, Source: "option"} + return commandBinding{Option: "folder", Required: true, Source: commandBindingSourceOption} case "jobId": - return commandBinding{Aliases: []string{"wake"}, Option: "job", Required: true, Source: "option"} + return commandBinding{Aliases: []string{"wake"}, Option: "job", Required: true, Source: commandBindingSourceOption} case "wakeId": - return commandBinding{Option: "wake", Required: true, Source: "option"} + return commandBinding{Option: "wake", Required: true, Source: commandBindingSourceOption} case "token": - return commandBinding{Aliases: []string{"token"}, Option: "invitation-token", Required: true, Source: "option"} + return commandBinding{Aliases: []string{"token"}, Option: "invitation-token", Required: true, Source: commandBindingSourceOption} case "versionNumber": - return commandBinding{Aliases: []string{"version"}, Option: "version-number", Required: true, Source: "option"} + return commandBinding{Aliases: []string{"version"}, Option: "version-number", Required: true, Source: commandBindingSourceOption} default: - return commandBinding{Option: kebabCase(name), Required: true, Source: "option"} + return commandBinding{Option: kebabCase(name), Required: true, Source: commandBindingSourceOption} } } @@ -171,7 +171,7 @@ func runCatalogHTTPCommand( stdout io.Writer, stdin io.Reader, ) error { - if plan.Output == "agent-job-watch" { + if plan.Output == commandExecutionOutputAgentJobWatch { if optionText(cmd, []string{"job", "wake"}) == "" { return fmt.Errorf("expected --job") } @@ -279,19 +279,19 @@ func runCatalogMultipartCommand(ctx context.Context, client *client, cmd command return printJSON(stdout, parsed) } -func printCatalogCommandPayload(stdout io.Writer, payload responsePayload, cmd command, output string) error { +func printCatalogCommandPayload(stdout io.Writer, payload responsePayload, cmd command, output commandExecutionOutput) error { switch output { case "": return printPayload(stdout, payload, cmd) - case "messages": + case commandExecutionOutputMessages: return printCommandOutput(stdout, payload.Parsed, cmd, outputMessages) - case "wiki-recent": + case commandExecutionOutputWikiRecent: return printCommandOutput(stdout, payload.Parsed, cmd, outputWikiRecent) - case "wiki-version": + case commandExecutionOutputWikiVersion: return printCommandOutput(stdout, payload.Parsed, cmd, outputWikiVersion) - case "wiki-versions": + case commandExecutionOutputWikiVersions: return printCommandOutput(stdout, payload.Parsed, cmd, outputWikiVersions) - case "json", "bytes": + case commandExecutionOutputJSON, commandExecutionOutputBytes: return printPayload(stdout, payload, cmd) default: return fmt.Errorf("unsupported catalog command output: %s", output) diff --git a/internal/craken/catalog_contract.go b/internal/craken/catalog_contract.go new file mode 100644 index 0000000..166842d --- /dev/null +++ b/internal/craken/catalog_contract.go @@ -0,0 +1,48 @@ +package craken + +type commandBindingPositionals string +type commandBindingResolver string +type commandBindingSource string +type commandBindingValueType string +type commandExecutionOutput string +type commandTransport string + +const ( + commandBindingPositionalsJoin commandBindingPositionals = "join" +) + +const ( + commandBindingResolverAgent commandBindingResolver = "agent" + commandBindingResolverChannel commandBindingResolver = "channel" + commandBindingResolverParticipant commandBindingResolver = "participant" + commandBindingResolverWorkspace commandBindingResolver = "workspace" +) + +const ( + commandBindingSourceFlag commandBindingSource = "flag" + commandBindingSourceLiteral commandBindingSource = "literal" + commandBindingSourceOption commandBindingSource = "option" + commandBindingSourceText commandBindingSource = "text" +) + +const ( + commandBindingValueTypeInteger commandBindingValueType = "integer" + commandBindingValueTypeJSON commandBindingValueType = "json" +) + +const ( + commandExecutionOutputAgentJobWatch commandExecutionOutput = "agent-job-watch" + commandExecutionOutputBytes commandExecutionOutput = "bytes" + commandExecutionOutputJSON commandExecutionOutput = "json" + commandExecutionOutputMessages commandExecutionOutput = "messages" + commandExecutionOutputWikiRecent commandExecutionOutput = "wiki-recent" + commandExecutionOutputWikiVersion commandExecutionOutput = "wiki-version" + commandExecutionOutputWikiVersions commandExecutionOutput = "wiki-versions" +) + +const ( + commandTransportDownload commandTransport = "download" + commandTransportHTTP commandTransport = "http" + commandTransportMultipart commandTransport = "multipart" + commandTransportWebSocket commandTransport = "websocket" +) diff --git a/internal/craken/generic.go b/internal/craken/generic.go index 41be726..87b3e8f 100644 --- a/internal/craken/generic.go +++ b/internal/craken/generic.go @@ -54,20 +54,20 @@ type cliCommand struct { type commandExecution struct { BodyFields map[string]commandBinding `json:"bodyFields,omitempty"` OperationID string `json:"operationId,omitempty"` - Output string `json:"output,omitempty"` + Output commandExecutionOutput `json:"output,omitempty"` PathParams map[string]commandBinding `json:"pathParams,omitempty"` QueryParams map[string]commandBinding `json:"queryParams,omitempty"` - Transport string `json:"transport,omitempty"` + Transport commandTransport `json:"transport,omitempty"` Variants []commandExecutionVariant `json:"variants,omitempty"` } type commandExecutionVariant struct { BodyFields map[string]commandBinding `json:"bodyFields,omitempty"` OperationID string `json:"operationId,omitempty"` - Output string `json:"output,omitempty"` + Output commandExecutionOutput `json:"output,omitempty"` PathParams map[string]commandBinding `json:"pathParams,omitempty"` QueryParams map[string]commandBinding `json:"queryParams,omitempty"` - Transport string `json:"transport,omitempty"` + Transport commandTransport `json:"transport,omitempty"` When commandCondition `json:"when,omitempty"` } @@ -76,16 +76,16 @@ type commandCondition struct { } type commandBinding struct { - Aliases []string `json:"aliases,omitempty"` - FileOption string `json:"fileOption,omitempty"` - Option string `json:"option,omitempty"` - Positionals string `json:"positionals,omitempty"` - Required bool `json:"required,omitempty"` - Resolver string `json:"resolver,omitempty"` - Scope string `json:"scope,omitempty"` - Source string `json:"source,omitempty"` - Type string `json:"type,omitempty"` - Value any `json:"value,omitempty"` + Aliases []string `json:"aliases,omitempty"` + FileOption string `json:"fileOption,omitempty"` + Option string `json:"option,omitempty"` + Positionals commandBindingPositionals `json:"positionals,omitempty"` + Required bool `json:"required,omitempty"` + Resolver commandBindingResolver `json:"resolver,omitempty"` + Scope string `json:"scope,omitempty"` + Source commandBindingSource `json:"source,omitempty"` + Type commandBindingValueType `json:"type,omitempty"` + Value any `json:"value,omitempty"` } type commandExample struct { From 37126dfe77cd67fe181eaa6e5243e46e54361dc4 Mon Sep 17 00:00:00 2001 From: akcorca Date: Mon, 25 May 2026 21:15:43 +0900 Subject: [PATCH 4/7] Cover wiki save catalog variants --- internal/craken/command_test.go | 50 +++++++++++++++++++++++++++------ 1 file changed, 41 insertions(+), 9 deletions(-) diff --git a/internal/craken/command_test.go b/internal/craken/command_test.go index b0e9933..a474728 100644 --- a/internal/craken/command_test.go +++ b/internal/craken/command_test.go @@ -943,6 +943,15 @@ func TestWikiSaveAndAgentPlanCheck(t *testing.T) { 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 { @@ -967,7 +976,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) } @@ -975,7 +988,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) } } @@ -1117,14 +1130,32 @@ func writeTestCatalog(t *testing.T, w http.ResponseWriter, r *http.Request) bool }), testCommand("dm.messages", "direct-messages.list", map[string]any{"output": "messages"}), testCommand("wiki.recent", "wiki.recent-changes", map[string]any{"output": "wiki-recent"}), - testCommand("wiki.save", "wiki.pages.update", 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"}, + { + "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", + "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{"pageTitle": map[string]any{"source": "option", "option": "existing-title", "required": true}}, + "when": map[string]any{"option": "existing-title"}, + }}, }, - "pathParams": map[string]any{"pageTitle": map[string]any{"source": "option", "option": "existing-title", "required": true}}, - }), + "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"}, @@ -1148,6 +1179,7 @@ func writeTestCatalog(t *testing.T, w http.ResponseWriter, r *http.Request) bool 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"), From d19edd8a7436309762c59b16c96736cd5e195ba8 Mon Sep 17 00:00:00 2001 From: akcorca Date: Mon, 25 May 2026 21:55:20 +0900 Subject: [PATCH 5/7] Remove product command adapters --- docs/architecture.md | 8 +- internal/craken/catalog_binding.go | 174 +++++++++++++++++++++---- internal/craken/catalog_command.go | 138 ++++++++++---------- internal/craken/catalog_contract.go | 30 ++--- internal/craken/catalog_poll.go | 82 ++++++++++++ internal/craken/catalog_websocket.go | 134 +++++++++++++++++++ internal/craken/command.go | 75 ++++++----- internal/craken/command_test.go | 158 +++++++++++++++++++--- internal/craken/generic.go | 66 +++++++++- internal/craken/output.go | 95 +++----------- internal/craken/realtime.go | 187 --------------------------- internal/craken/resolve.go | 98 -------------- internal/craken/util.go | 21 --- internal/craken/wiki_agent.go | 84 ------------ 14 files changed, 711 insertions(+), 639 deletions(-) create mode 100644 internal/craken/catalog_poll.go create mode 100644 internal/craken/catalog_websocket.go delete mode 100644 internal/craken/realtime.go delete mode 100644 internal/craken/resolve.go delete mode 100644 internal/craken/wiki_agent.go diff --git a/docs/architecture.md b/docs/architecture.md index e986116..0b712f3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,8 +1,8 @@ # 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. 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. The binary keeps only local bootstrap and transport primitives: profile storage, device login polling, request construction, file upload/download, realtime WebSocket handling, and output shaping. `commands`, `do`, and raw HTTP commands use the same catalog route contract as the ergonomic shortcuts. +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. @@ -10,7 +10,7 @@ The default help fetches `/api/client` once and renders the server-owned command 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 index e81c719..254fd49 100644 --- a/internal/craken/catalog_binding.go +++ b/internal/craken/catalog_binding.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "net/url" "os" "path/filepath" "strconv" @@ -13,6 +14,7 @@ import ( func catalogValues( ctx context.Context, client *client, + routes []route, cmd command, bindings map[string]commandBinding, resolved map[string]string, @@ -20,7 +22,7 @@ func catalogValues( ) (map[string]any, error) { values := map[string]any{} for name, binding := range bindings { - value, ok, err := catalogBindingValue(ctx, client, cmd, binding, resolved, consumed) + value, ok, err := catalogBindingValue(ctx, client, routes, cmd, binding, resolved, consumed) if err != nil { return nil, err } @@ -34,12 +36,13 @@ func catalogValues( 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, cmd, binding, resolved, consumed) + value, ok, err := catalogBindingValue(ctx, client, routes, cmd, binding, resolved, consumed) if err != nil { return "", err } @@ -56,14 +59,26 @@ func catalogBindingString( 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 @@ -78,11 +93,14 @@ func catalogBindingValue( } return value, ok, err } - resolvedValue, err := resolveBoundValue(ctx, client, binding, fmt.Sprint(value), resolved) + 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) } @@ -100,7 +118,7 @@ func catalogBindingValue( } return parsed, true, nil } - resolvedValue, err := resolveBoundValue(ctx, client, binding, value, resolved) + 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) @@ -165,38 +183,138 @@ func jsonBindingValue(value string, source string) (any, error) { func resolveBoundValue( ctx context.Context, client *client, + routes []route, binding commandBinding, value string, resolved map[string]string, ) (string, error) { - switch binding.Resolver { - case "": + if binding.Resolver == nil { return value, nil - case commandBindingResolverWorkspace: - return resolveWorkspaceID(ctx, client, value) - case commandBindingResolverChannel: - workspaceID := resolved[binding.Scope] - if workspaceID == "" { - return "", fmt.Errorf("resolver channel requires scope %s", binding.Scope) - } - return resolveChannelID(ctx, client, workspaceID, value) - case commandBindingResolverParticipant, commandBindingResolverAgent: - workspaceID := resolved[binding.Scope] - if workspaceID == "" { - return "", fmt.Errorf("resolver %s requires scope %s", binding.Resolver, binding.Scope) - } - participantID, err := resolveParticipantID(ctx, client, workspaceID, value) + } + 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 "", err + return "\x00error:" + err.Error() } - if binding.Resolver == commandBindingResolverAgent { - if !strings.HasPrefix(participantID, "agent:") { - return "", fmt.Errorf("dream agent must resolve to an agent participant, got %s", participantID) - } - return strings.TrimPrefix(participantID, "agent:"), nil + if value == "" { + return "\x00missing:" + name } - return participantID, nil + 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.Errorf("unsupported catalog resolver: %s", binding.Resolver) + 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 index d31b201..855722c 100644 --- a/internal/craken/catalog_command.go +++ b/internal/craken/catalog_command.go @@ -36,23 +36,19 @@ func runCatalogCommand(ctx context.Context, client *client, cmd command, stdout if selectedRoute == nil { return fmt.Errorf("unknown operation for %s: %s", serverCommand.ID, plan.OperationID) } - requestPath, resolved, consumed, err := catalogCommandPath(ctx, client, *selectedRoute, plan, cmd) + 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, *selectedRoute, plan, cmd, requestPath, resolved, consumed, stdout, stdin) + 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, cmd, requestPath, stdout) + return runCatalogMultipartCommand(ctx, client, catalog.Routes, plan, cmd, requestPath, resolved, consumed, stdout) case commandTransportWebSocket: - workspaceID := resolved["workspaceId"] - if workspaceID == "" { - return fmt.Errorf("websocket command requires workspaceId") - } - return tailWorkspace(ctx, client, workspaceID, cmd, stdout) + return runCatalogWebSocketCommand(ctx, client, catalog.Routes, plan, cmd, requestPath, resolved, consumed, stdout) default: return fmt.Errorf("unsupported catalog command transport: %s", plan.Transport) } @@ -88,9 +84,15 @@ func mergeExecution(base commandExecution, variant commandExecutionVariant) comm if variant.Transport != "" { base.Transport = variant.Transport } - if variant.Output != "" { + 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 } @@ -100,19 +102,26 @@ func mergeExecution(base commandExecution, variant commandExecutionVariant) comm if variant.BodyFields != nil { base.BodyFields = variant.BodyFields } + if len(variant.WebSocket.Protocols) > 0 { + base.WebSocket = variant.WebSocket + } return base } -func catalogCommandPath(ctx context.Context, client *client, route route, plan commandExecution, cmd command) (string, map[string]string, map[string]bool, error) { +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 == "" { - binding = inferredPathBinding(name) + return "\x00missing:" + name } - value, err := catalogBindingString(ctx, client, cmd, binding, resolved, consumed) + value, err := catalogBindingString(ctx, client, routes, cmd, binding, resolved, consumed) if err != nil { return "\x00error:" + err.Error() } @@ -132,36 +141,10 @@ func catalogCommandPath(ctx context.Context, client *client, route route, plan c return path, resolved, consumed, nil } -func inferredPathBinding(name string) commandBinding { - switch name { - case "workspaceId": - return commandBinding{Option: "workspace", Required: true, Resolver: commandBindingResolverWorkspace, Source: commandBindingSourceOption} - case "channelId": - return commandBinding{Option: "channel", Required: true, Resolver: commandBindingResolverChannel, Scope: "workspaceId", Source: commandBindingSourceOption} - case "participantId": - return commandBinding{Aliases: []string{"participant"}, Option: "target", Required: true, Resolver: commandBindingResolverParticipant, Scope: "workspaceId", Source: commandBindingSourceOption} - case "pageTitle": - return commandBinding{Aliases: []string{"page"}, Option: "title", Required: true, Source: commandBindingSourceOption} - case "fileId": - return commandBinding{Option: "file", Required: true, Source: commandBindingSourceOption} - case "folderId": - return commandBinding{Option: "folder", Required: true, Source: commandBindingSourceOption} - case "jobId": - return commandBinding{Aliases: []string{"wake"}, Option: "job", Required: true, Source: commandBindingSourceOption} - case "wakeId": - return commandBinding{Option: "wake", Required: true, Source: commandBindingSourceOption} - case "token": - return commandBinding{Aliases: []string{"token"}, Option: "invitation-token", Required: true, Source: commandBindingSourceOption} - case "versionNumber": - return commandBinding{Aliases: []string{"version"}, Option: "version-number", Required: true, Source: commandBindingSourceOption} - default: - return commandBinding{Option: kebabCase(name), Required: true, Source: commandBindingSourceOption} - } -} - func runCatalogHTTPCommand( ctx context.Context, client *client, + routes []route, route route, plan commandExecution, cmd command, @@ -171,13 +154,10 @@ func runCatalogHTTPCommand( stdout io.Writer, stdin io.Reader, ) error { - if plan.Output == commandExecutionOutputAgentJobWatch { - if optionText(cmd, []string{"job", "wake"}) == "" { - return fmt.Errorf("expected --job") - } - return watchAgentJob(ctx, client, resolved["workspaceId"], cmd, stdout) + if plan.Poll != nil { + return runCatalogPollCommand(ctx, client, routes, route, plan, cmd, path, resolved, consumed, stdout, stdin) } - requestPath, spec, err := catalogHTTPRequest(ctx, client, route, plan, cmd, path, resolved, consumed, stdin) + requestPath, spec, err := catalogHTTPRequest(ctx, client, routes, route, plan, cmd, path, resolved, consumed, stdin) if err != nil { return err } @@ -195,6 +175,7 @@ func runCatalogHTTPCommand( func catalogHTTPRequest( ctx context.Context, client *client, + routes []route, route route, plan commandExecution, cmd command, @@ -218,7 +199,7 @@ func catalogHTTPRequest( if requestBody == "multipart" { return "", requestSpec{}, fmt.Errorf("operation %s expects multipart request bodies", route.ID) } - values, err := catalogValues(ctx, client, cmd, plan.QueryParams, resolved, consumed) + values, err := catalogValues(ctx, client, routes, cmd, plan.QueryParams, resolved, consumed) if err != nil { return "", requestSpec{}, err } @@ -233,7 +214,7 @@ func catalogHTTPRequest( return path, spec, nil } if plan.BodyFields != nil { - body, err := catalogValues(ctx, client, cmd, plan.BodyFields, resolved, consumed) + body, err := catalogValues(ctx, client, routes, cmd, plan.BodyFields, resolved, consumed) if err != nil { return "", requestSpec{}, err } @@ -264,37 +245,62 @@ func runCatalogDownloadCommand(ctx context.Context, client *client, route route, return err } -func runCatalogMultipartCommand(ctx context.Context, client *client, cmd command, path string, stdout io.Writer) error { - filePath, err := cmd.required("path", "file-path") +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 } - parsed, err := client.multipart(ctx, http.MethodPost, path, map[string]string{ - "folderPath": cmd.string("folder", ""), - "scope": cmd.string("scope", "workspace"), - }, "file", filePath, cmd.string("name", filepath.Base(filePath)), cmd.string("type", "application/octet-stream")) + 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 commandExecutionOutput) error { - switch output { - case "": +func printCatalogCommandPayload(stdout io.Writer, payload responsePayload, cmd command, output *commandOutputPlan) error { + if output == nil { return printPayload(stdout, payload, cmd) - case commandExecutionOutputMessages: - return printCommandOutput(stdout, payload.Parsed, cmd, outputMessages) - case commandExecutionOutputWikiRecent: - return printCommandOutput(stdout, payload.Parsed, cmd, outputWikiRecent) - case commandExecutionOutputWikiVersion: - return printCommandOutput(stdout, payload.Parsed, cmd, outputWikiVersion) - case commandExecutionOutputWikiVersions: - return printCommandOutput(stdout, payload.Parsed, cmd, outputWikiVersions) - case commandExecutionOutputJSON, commandExecutionOutputBytes: + } + 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: %s", output) + 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 index 166842d..ec0b5e5 100644 --- a/internal/craken/catalog_contract.go +++ b/internal/craken/catalog_contract.go @@ -1,10 +1,9 @@ package craken type commandBindingPositionals string -type commandBindingResolver string type commandBindingSource string type commandBindingValueType string -type commandExecutionOutput string +type commandOutputMode string type commandTransport string const ( @@ -12,17 +11,12 @@ const ( ) const ( - commandBindingResolverAgent commandBindingResolver = "agent" - commandBindingResolverChannel commandBindingResolver = "channel" - commandBindingResolverParticipant commandBindingResolver = "participant" - commandBindingResolverWorkspace commandBindingResolver = "workspace" -) - -const ( - commandBindingSourceFlag commandBindingSource = "flag" - commandBindingSourceLiteral commandBindingSource = "literal" - commandBindingSourceOption commandBindingSource = "option" - commandBindingSourceText commandBindingSource = "text" + commandBindingSourceBearerToken commandBindingSource = "bearer-token" + commandBindingSourceFlag commandBindingSource = "flag" + commandBindingSourceLiteral commandBindingSource = "literal" + commandBindingSourceOption commandBindingSource = "option" + commandBindingSourceResolved commandBindingSource = "resolved" + commandBindingSourceText commandBindingSource = "text" ) const ( @@ -31,13 +25,9 @@ const ( ) const ( - commandExecutionOutputAgentJobWatch commandExecutionOutput = "agent-job-watch" - commandExecutionOutputBytes commandExecutionOutput = "bytes" - commandExecutionOutputJSON commandExecutionOutput = "json" - commandExecutionOutputMessages commandExecutionOutput = "messages" - commandExecutionOutputWikiRecent commandExecutionOutput = "wiki-recent" - commandExecutionOutputWikiVersion commandExecutionOutput = "wiki-version" - commandExecutionOutputWikiVersions commandExecutionOutput = "wiki-versions" + commandOutputModeBytes commandOutputMode = "bytes" + commandOutputModeJSON commandOutputMode = "json" + commandOutputModeTable commandOutputMode = "table" ) const ( 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/command.go b/internal/craken/command.go index 588704d..5f867d2 100644 --- a/internal/craken/command.go +++ b/internal/craken/command.go @@ -210,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 } @@ -307,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 a474728..43a78e9 100644 --- a/internal/craken/command_test.go +++ b/internal/craken/command_test.go @@ -131,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", @@ -139,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", @@ -146,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", @@ -161,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{ @@ -185,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", }, }, @@ -205,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:", @@ -226,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) } @@ -236,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) } } @@ -1078,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) } @@ -1121,15 +1138,15 @@ func writeTestCatalog(t *testing.T, w http.ResponseWriter, r *http.Request) bool "senderEmail": map[string]any{"source": "option", "option": "sender-email", "aliases": []string{"sender"}}, }, }), - testCommand("channel.messages", "channels.messages.list", map[string]any{"output": "messages"}), + 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": "messages"}), - testCommand("wiki.recent", "wiki.recent-changes", map[string]any{"output": "wiki-recent"}), + 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.", @@ -1140,6 +1157,7 @@ func writeTestCatalog(t *testing.T, w http.ResponseWriter, r *http.Request) bool "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{ @@ -1148,8 +1166,11 @@ func writeTestCatalog(t *testing.T, w http.ResponseWriter, r *http.Request) bool "title": map[string]any{"source": "option", "option": "title"}, }, "operationId": "wiki.pages.update", - "pathParams": map[string]any{"pageTitle": map[string]any{"source": "option", "option": "existing-title", "required": true}}, - "when": map[string]any{"option": "existing-title"}, + "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", @@ -1162,7 +1183,21 @@ func writeTestCatalog(t *testing.T, w http.ResponseWriter, r *http.Request) bool "previousPlan": map[string]any{"source": "option", "option": "previous-json", "aliases": []string{"previous-file"}, "type": "json"}, }, }), - testCommand("file.upload", "files.create", map[string]any{"transport": "multipart"}), + 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}, @@ -1173,6 +1208,7 @@ func writeTestCatalog(t *testing.T, w http.ResponseWriter, r *http.Request) bool }, "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"), @@ -1195,6 +1231,11 @@ func testCommand(id string, operationID string, execution map[string]any) map[st 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, @@ -1205,6 +1246,89 @@ func testCommand(id string, operationID string, execution map[string]any) map[st } } +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", diff --git a/internal/craken/generic.go b/internal/craken/generic.go index 87b3e8f..fc33d06 100644 --- a/internal/craken/generic.go +++ b/internal/craken/generic.go @@ -53,22 +53,28 @@ type cliCommand struct { type commandExecution struct { BodyFields map[string]commandBinding `json:"bodyFields,omitempty"` + Multipart commandMultipartPlan `json:"multipart,omitempty"` OperationID string `json:"operationId,omitempty"` - Output commandExecutionOutput `json:"output,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 commandExecutionOutput `json:"output,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 { @@ -77,17 +83,69 @@ type commandCondition struct { 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 commandBindingResolver `json:"resolver,omitempty"` - Scope string `json:"scope,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 { Command string `json:"command"` Description string `json:"description"` diff --git a/internal/craken/output.go b/internal/craken/output.go index 5dcd049..23ce34b 100644 --- a/internal/craken/output.go +++ b/internal/craken/output.go @@ -6,16 +6,7 @@ import ( "strings" ) -type commandOutputKind string - -const ( - outputMessages commandOutputKind = "messages" - outputWikiRecent commandOutputKind = "wiki-recent" - outputWikiVersion commandOutputKind = "wiki-version" - outputWikiVersions commandOutputKind = "wiki-versions" -) - -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") @@ -30,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) { @@ -137,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 } @@ -178,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 308cbe8..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, "/api/workspaces") - 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, workspacePath(workspaceID)) - 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, workspacePath(workspaceID)) - 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/util.go b/internal/craken/util.go index 1ea7245..bec6c4c 100644 --- a/internal/craken/util.go +++ b/internal/craken/util.go @@ -1,17 +1,12 @@ package craken import ( - "encoding/base64" "encoding/json" "fmt" - "net/url" - "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) } @@ -72,19 +67,3 @@ func numberOption(cmd command, name string, fallback int) (int, error) { func boolOption(cmd command, name string) bool { return cmd.Flags[name] || cmd.string(name, "") != "" } - -func looksLikeID(value string) bool { - return idPattern.MatchString(value) -} - -func workspacePath(workspaceID string) string { - return "/api/workspaces/" + url.PathEscape(workspaceID) -} - -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 7adc9af..0000000 --- a/internal/craken/wiki_agent.go +++ /dev/null @@ -1,84 +0,0 @@ -package craken - -import ( - "context" - "fmt" - "io" - "net/url" - "time" -) - -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, workspacePath(workspaceID)+"/agent-jobs/"+url.PathEscape(jobID)) - 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" -} From 6851aee3bdb0864cae4a83395ed94136b59d3638 Mon Sep 17 00:00:00 2001 From: akcorca Date: Mon, 25 May 2026 21:57:28 +0900 Subject: [PATCH 6/7] Drop unused catalog option helper --- internal/craken/catalog_command.go | 9 --------- 1 file changed, 9 deletions(-) diff --git a/internal/craken/catalog_command.go b/internal/craken/catalog_command.go index 855722c..f4d2f1d 100644 --- a/internal/craken/catalog_command.go +++ b/internal/craken/catalog_command.go @@ -303,12 +303,3 @@ func printCatalogCommandPayload(stdout io.Writer, payload responsePayload, cmd c return fmt.Errorf("unsupported catalog command output mode: %s", output.Mode) } } - -func optionText(cmd command, names []string) string { - for _, name := range names { - if value := cmd.string(name, ""); value != "" { - return value - } - } - return "" -} From dbb34f0740c1574f358765c6465560544c958161 Mon Sep 17 00:00:00 2001 From: akcorca Date: Mon, 25 May 2026 22:24:52 +0900 Subject: [PATCH 7/7] Bump CLI minor version --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index fad30c5..1474d00 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v0.1.7 +v0.2.0