From 97055e27b1e22e1e4f6c26766844801a85b20190 Mon Sep 17 00:00:00 2001 From: akcorca Date: Fri, 22 May 2026 01:45:42 +0900 Subject: [PATCH] Add compact projection output modes --- README.md | 4 + docs/architecture.md | 2 + internal/craken/command.go | 13 +- internal/craken/command_test.go | 158 ++++++++++++++++++++- internal/craken/output.go | 235 ++++++++++++++++++++++++++++++++ internal/craken/resources.go | 11 +- internal/craken/wiki_agent.go | 13 +- 7 files changed, 428 insertions(+), 8 deletions(-) create mode 100644 internal/craken/output.go diff --git a/README.md b/README.md index 43b0a17..92b7f17 100644 --- a/README.md +++ b/README.md @@ -32,9 +32,11 @@ craken workspace list craken workspace create --name test0 craken channel send --workspace test0 --channel general hello craken channel messages --workspace test0 --channel general --after MESSAGE_ID --limit 10 +craken channel messages --workspace test0 --channel general --compact craken channel wait --workspace test0 --channel general --after MESSAGE_ID --timeout-ms 60000 craken dm send --workspace test0 --target orca hello craken dm messages --workspace test0 --target orca --limit 10 +craken dm messages --workspace test0 --target orca --fields messages.id,messages.createdAt,messages.sender.name,messages.body craken wiki save --workspace test0 --existing-title Home --content-file ./home.md --base-version 12 craken workspace tail --workspace test0 --pretty craken commands --format text @@ -42,3 +44,5 @@ craken do workspaces.list ``` Profiles live in `${CRAKEN_CONFIG_DIR:-~/.config/craken}/config.json`. Use `--profile NAME` or `CRAKEN_PROFILE` only when you need more than one profile. + +Dedicated message and wiki-history commands accept `--compact` for tab-separated summaries. They also accept `--fields LIST`, where `LIST` is a comma-separated set of dotted JSON paths such as `messages.id,messages.sender.name,messages.body`. diff --git a/docs/architecture.md b/docs/architecture.md index e563dde..7b6bf85 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -10,6 +10,8 @@ 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. + The profile file is intentionally compatible with the prior Node CLI: ```text diff --git a/internal/craken/command.go b/internal/craken/command.go index 8dd879c..d68b1eb 100644 --- a/internal/craken/command.go +++ b/internal/craken/command.go @@ -325,6 +325,8 @@ func localCommandHelpOptions(commandID string) []string { "--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{ @@ -332,7 +334,16 @@ func localCommandHelpOptions(commandID string) []string { "--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."} + 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.", + } + 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.", + } case "workspace.activity": return []string{ "--anchor-json JSON Activity anchor JSON.", diff --git a/internal/craken/command_test.go b/internal/craken/command_test.go index b3a26a6..6001b92 100644 --- a/internal/craken/command_test.go +++ b/internal/craken/command_test.go @@ -203,6 +203,8 @@ func TestFocusedHelpRendersServerCommandLocalOptionsAndMetadata(t *testing.T) { "--around MESSAGE_ID", "--position latest|start", "--limit N", + "--compact", + "--fields LIST", "Query parameters:", "Response example:", "newestCursor", @@ -231,8 +233,8 @@ 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") { - t.Fatalf("expected focused wiki help to contain --limit N, got:\n%s", help) + if help := stdout.String(); !strings.Contains(help, "--limit N") || !strings.Contains(help, "--compact") || !strings.Contains(help, "--fields LIST") { + t.Fatalf("expected focused wiki help to contain output options, got:\n%s", help) } } @@ -656,6 +658,158 @@ func TestMessageCommandsSendLimitQuery(t *testing.T) { } } +func TestChannelMessagesCompactOutputOmitsPictures(t *testing.T) { + configDir := t.TempDir() + t.Setenv("CRAKEN_CONFIG_DIR", configDir) + picture := strings.Repeat("picture-data", 100) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + 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 "GET /api/workspaces/workspace-id": + writeJSON(t, w, map[string]any{ + "channels": []map[string]any{{"id": "channel-id", "name": "general"}}, + "members": []map[string]any{}, + }) + case "GET /api/workspaces/workspace-id/channels/channel-id/messages": + writeJSON(t, w, map[string]any{ + "messages": []map[string]any{{ + "body": "hello\tthere\nnext", + "createdAt": "2026-05-21T12:00:00.000Z", + "id": "message-1", + "sender": map[string]any{ + "id": "user:ada", + "name": "Ada", + "picture": picture, + }, + }}, + }) + default: + t.Fatalf("unexpected request %s %s", r.Method, r.URL.RequestURI()) + } + })) + defer server.Close() + + var stdout bytes.Buffer + if err := Run(context.Background(), "dev", []string{ + "channel", "messages", + "--token", "test-token", + "--base-url", server.URL, + "--workspace", "test0", + "--channel", "general", + "--compact", + }, strings.NewReader(""), &stdout, &bytes.Buffer{}); err != nil { + t.Fatal(err) + } + if got, want := stdout.String(), "2026-05-21T12:00:00.000Z\tAda\thello\\tthere\\nnext\n"; got != want { + t.Fatalf("unexpected compact output %q", got) + } + if strings.Contains(stdout.String(), "picture-data") { + t.Fatalf("compact output leaked picture data: %s", stdout.String()) + } +} + +func TestDMMessagesFieldsProjectNestedJSON(t *testing.T) { + configDir := t.TempDir() + t.Setenv("CRAKEN_CONFIG_DIR", configDir) + picture := strings.Repeat("picture-data", 100) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + 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 "GET /api/workspaces/workspace-id": + writeJSON(t, w, map[string]any{ + "channels": []map[string]any{}, + "members": []map[string]any{{"id": "participant-id", "kind": "agent", "name": "orca"}}, + }) + case "GET /api/workspaces/workspace-id/direct-messages/participant-id/messages": + writeJSON(t, w, map[string]any{ + "messages": []map[string]any{{ + "body": "ready", + "createdAt": "2026-05-21T12:00:00.000Z", + "id": "message-1", + "sender": map[string]any{ + "id": "agent:orca", + "name": "Orca", + "picture": picture, + }, + }}, + "newestCursor": "message-1", + }) + default: + t.Fatalf("unexpected request %s %s", r.Method, r.URL.RequestURI()) + } + })) + defer server.Close() + + var stdout bytes.Buffer + if err := Run(context.Background(), "dev", []string{ + "dm", "messages", + "--token", "test-token", + "--base-url", server.URL, + "--workspace", "test0", + "--target", "orca", + "--fields", "messages.id,messages.createdAt,messages.sender.name,messages.body", + }, strings.NewReader(""), &stdout, &bytes.Buffer{}); err != nil { + t.Fatal(err) + } + if strings.Contains(stdout.String(), "picture-data") || strings.Contains(stdout.String(), "newestCursor") { + t.Fatalf("projected output leaked omitted fields: %s", stdout.String()) + } + + var projected map[string]any + if err := json.Unmarshal(stdout.Bytes(), &projected); err != nil { + t.Fatal(err) + } + messages, _ := projected["messages"].([]any) + first, _ := messages[0].(map[string]any) + sender, _ := first["sender"].(map[string]any) + if first["id"] != "message-1" || first["createdAt"] != "2026-05-21T12:00:00.000Z" || first["body"] != "ready" || sender["name"] != "Orca" { + t.Fatalf("unexpected projected output %#v", projected) + } +} + +func TestWikiRecentCompactOutputOmitsAuthorPictures(t *testing.T) { + configDir := t.TempDir() + t.Setenv("CRAKEN_CONFIG_DIR", configDir) + picture := strings.Repeat("picture-data", 100) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + 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 "GET /api/workspaces/workspace-id/wiki/recent-changes": + writeJSON(t, w, map[string]any{ + "changes": []map[string]any{{ + "createdAt": "2026-05-21T12:00:00.000Z", + "createdBy": map[string]any{"id": "user:ada", "name": "Ada", "picture": picture}, + "page": map[string]any{"title": "Home"}, + "versionNumber": 3, + }}, + }) + default: + t.Fatalf("unexpected request %s %s", r.Method, r.URL.RequestURI()) + } + })) + defer server.Close() + + var stdout bytes.Buffer + if err := Run(context.Background(), "dev", []string{ + "wiki", "recent", + "--token", "test-token", + "--base-url", server.URL, + "--workspace", "test0", + "--compact", + }, strings.NewReader(""), &stdout, &bytes.Buffer{}); err != nil { + t.Fatal(err) + } + if got, want := stdout.String(), "2026-05-21T12:00:00.000Z\tAda\tHome\t3\n"; got != want { + t.Fatalf("unexpected compact wiki output %q", got) + } + if strings.Contains(stdout.String(), "picture-data") { + t.Fatalf("compact wiki output leaked picture data: %s", stdout.String()) + } +} + func TestMessageLimitServiceValidationIsPreserved(t *testing.T) { configDir := t.TempDir() t.Setenv("CRAKEN_CONFIG_DIR", configDir) diff --git a/internal/craken/output.go b/internal/craken/output.go new file mode 100644 index 0000000..3e9c3cf --- /dev/null +++ b/internal/craken/output.go @@ -0,0 +1,235 @@ +package craken + +import ( + "context" + "fmt" + "io" + "strings" +) + +type commandOutputKind string + +const ( + outputMessages commandOutputKind = "messages" + outputWikiRecent commandOutputKind = "wiki-recent" + outputWikiVersion commandOutputKind = "wiki-version" + outputWikiVersions commandOutputKind = "wiki-versions" +) + +func printClientCommandOutput( + ctx context.Context, + client *client, + stdout io.Writer, + cmd command, + kind commandOutputKind, + path string, +) error { + parsed, err := client.json(ctx, "GET", path, nil) + if err != nil { + return err + } + return printCommandOutput(stdout, parsed, cmd, kind) +} + +func printCommandOutput(stdout io.Writer, value any, cmd command, kind commandOutputKind) error { + fields := cmd.string("fields", "") + if boolOption(cmd, "compact") && fields != "" { + return fmt.Errorf("use either --compact or --fields, not both") + } + if fields != "" { + projected, err := projectFields(value, fields) + if err != nil { + return err + } + return printJSON(stdout, projected) + } + 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: + return fmt.Errorf("--compact is not supported for this command") + } +} + +func projectFields(value any, fields string) (any, error) { + var projected any = map[string]any{} + for _, field := range strings.Split(fields, ",") { + parts := fieldPathParts(field) + if len(parts) == 0 { + return nil, fmt.Errorf("empty field in --fields") + } + partial, ok := projectFieldPath(value, parts) + if !ok { + continue + } + projected = mergeProjectedValue(projected, partial) + } + return projected, nil +} + +func fieldPathParts(field string) []string { + raw := strings.Split(field, ".") + parts := make([]string, 0, len(raw)) + for _, part := range raw { + part = strings.TrimSpace(part) + if part != "" { + parts = append(parts, part) + } + } + return parts +} + +func projectFieldPath(value any, parts []string) (any, bool) { + if len(parts) == 0 { + return value, true + } + switch typed := value.(type) { + case map[string]any: + child, ok := typed[parts[0]] + if !ok { + return nil, false + } + projected, ok := projectFieldPath(child, parts[1:]) + if !ok { + return nil, false + } + return map[string]any{parts[0]: projected}, true + case []any: + out := make([]any, len(typed)) + anyProjected := false + for index, item := range typed { + projected, ok := projectFieldPath(item, parts) + if ok { + out[index] = projected + anyProjected = true + } else { + out[index] = map[string]any{} + } + } + return out, anyProjected + default: + return nil, false + } +} + +func mergeProjectedValue(left any, right any) any { + leftMap, leftIsMap := left.(map[string]any) + rightMap, rightIsMap := right.(map[string]any) + if leftIsMap && rightIsMap { + for key, rightValue := range rightMap { + leftMap[key] = mergeProjectedValue(leftMap[key], rightValue) + } + return leftMap + } + + leftSlice, leftIsSlice := left.([]any) + rightSlice, rightIsSlice := right.([]any) + if leftIsSlice && rightIsSlice { + merged := make([]any, len(rightSlice)) + copy(merged, rightSlice) + for index, leftValue := range leftSlice { + if index >= len(merged) { + merged = append(merged, leftValue) + continue + } + merged[index] = mergeProjectedValue(leftValue, merged[index]) + } + return merged + } + + if left == nil { + return right + } + 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"])), + } + 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 + } + } + 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 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 != "" { + return text + } + } + return "" +} + +func stringValue(value any) string { + text, _ := value.(string) + return text +} + +func compactScalar(value any) string { + if value == nil { + return "" + } + return fmt.Sprint(value) +} + +func compactCell(value string) string { + return strings.NewReplacer("\t", `\t`, "\r", `\r`, "\n", `\n`).Replace(value) +} diff --git a/internal/craken/resources.go b/internal/craken/resources.go index 42ae482..c926799 100644 --- a/internal/craken/resources.go +++ b/internal/craken/resources.go @@ -125,7 +125,14 @@ func runChannel(ctx context.Context, client *client, cmd command, stdout io.Writ } return printClientJSON(ctx, client, stdout, "POST", workspacePath(workspaceID)+"/channels/"+url.PathEscape(channelID)+"/members", map[string]any{"participantId": participantID}) case "messages": - return printClientJSON(ctx, client, stdout, "GET", workspacePath(workspaceID)+"/channels/"+url.PathEscape(channelID)+"/messages"+messagePageQuery(cmd), nil) + 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 { @@ -164,7 +171,7 @@ func runDM(ctx context.Context, client *client, cmd command, stdout io.Writer) e } return printClientJSON(ctx, client, stdout, "POST", path, localSenderBody(cmd, body)) case "messages", "list": - return printClientJSON(ctx, client, stdout, "GET", path+messagePageQuery(cmd), nil) + return printClientCommandOutput(ctx, client, stdout, cmd, outputMessages, path+messagePageQuery(cmd)) default: return fmt.Errorf("unknown dm action: %s", cmd.Action) } diff --git a/internal/craken/wiki_agent.go b/internal/craken/wiki_agent.go index f7f4c52..b565406 100644 --- a/internal/craken/wiki_agent.go +++ b/internal/craken/wiki_agent.go @@ -23,7 +23,7 @@ func runWiki(ctx context.Context, client *client, cmd command, stdout io.Writer) if limit := cmd.string("limit", ""); limit != "" { path += "?limit=" + url.QueryEscape(limit) } - return printClientJSON(ctx, client, stdout, "GET", path, nil) + return printClientCommandOutput(ctx, client, stdout, cmd, outputWikiRecent, path) case "get": title, err := cmd.required("title", "page") if err != nil { @@ -71,7 +71,7 @@ func runWiki(ctx context.Context, client *client, cmd command, stdout io.Writer) if err != nil { return err } - return printClientJSON(ctx, client, stdout, "GET", wikiPagePath(workspaceID, title)+"/versions", nil) + return printClientCommandOutput(ctx, client, stdout, cmd, outputWikiVersions, wikiPagePath(workspaceID, title)+"/versions") case "version": title, err := cmd.required("title", "page") if err != nil { @@ -81,7 +81,14 @@ func runWiki(ctx context.Context, client *client, cmd command, stdout io.Writer) if err != nil { return err } - return printClientJSON(ctx, client, stdout, "GET", wikiPagePath(workspaceID, title)+"/versions/"+url.PathEscape(version), nil) + 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 {