Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,17 @@ 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
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`.
2 changes: 2 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 12 additions & 1 deletion internal/craken/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -325,14 +325,25 @@ 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{
"--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."}
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.",
Expand Down
158 changes: 156 additions & 2 deletions internal/craken/command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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)
Expand Down
Loading