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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,10 @@ The login command prints a short code and opens the normal Craken browser sessio
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 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 wiki save --workspace test0 --existing-title Home --content-file ./home.md --base-version 12
craken workspace tail --workspace test0 --pretty
craken commands --format text
Expand Down
2 changes: 2 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ Wiki saves follow the service's optimistic concurrency contract. Creating a miss

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.

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.

The profile file is intentionally compatible with the prior Node CLI:

```text
Expand Down
1 change: 1 addition & 0 deletions internal/craken/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,7 @@ func localCommandHelpOptions(commandID string) []string {
"--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.",
}
case "channel.wait":
return []string{
Expand Down
102 changes: 102 additions & 0 deletions internal/craken/command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@ func TestFocusedHelpRendersServerCommandLocalOptionsAndMetadata(t *testing.T) {
"--before MESSAGE_ID",
"--around MESSAGE_ID",
"--position latest|start",
"--limit N",
"Query parameters:",
"Response example:",
"newestCursor",
Expand Down Expand Up @@ -594,6 +595,107 @@ func TestChannelSendResolvesWorkspaceChannelAndSender(t *testing.T) {
}
}

func TestMessageCommandsSendLimitQuery(t *testing.T) {
configDir := t.TempDir()
t.Setenv("CRAKEN_CONFIG_DIR", configDir)
seenChannel := false
seenDM := false
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{{"id": "participant-id", "kind": "agent", "name": "orca"}},
})
case "GET /api/workspaces/workspace-id/channels/channel-id/messages":
seenChannel = true
if got := r.URL.Query().Get("after"); got != "message-1" {
t.Fatalf("unexpected channel after query %q", got)
}
if got := r.URL.Query().Get("limit"); got != "10" {
t.Fatalf("unexpected channel limit query %q", got)
}
writeJSON(t, w, map[string]any{"messages": []any{}})
case "GET /api/workspaces/workspace-id/direct-messages/participant-id/messages":
seenDM = true
if got := r.URL.Query().Get("limit"); got != "10" {
t.Fatalf("unexpected dm limit query %q", got)
}
writeJSON(t, w, map[string]any{"messages": []any{}})
default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.RequestURI())
}
}))
defer server.Close()

if err := Run(context.Background(), "dev", []string{
"channel", "messages",
"--token", "test-token",
"--base-url", server.URL,
"--workspace", "test0",
"--channel", "general",
"--after", "message-1",
"--limit", "10",
}, strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{}); err != nil {
t.Fatal(err)
}
if err := Run(context.Background(), "dev", []string{
"dm", "messages",
"--token", "test-token",
"--base-url", server.URL,
"--workspace", "test0",
"--target", "orca",
"--limit", "10",
}, strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{}); err != nil {
t.Fatal(err)
}
if !seenChannel || !seenDM {
t.Fatalf("expected both message routes, saw channel=%t dm=%t", seenChannel, seenDM)
}
}

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) {
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":
if got := r.URL.Query().Get("limit"); got != "0" {
t.Fatalf("unexpected limit query %q", got)
}
w.WriteHeader(http.StatusBadRequest)
writeJSON(t, w, map[string]any{"error": "limit must be a positive integer"})
default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.RequestURI())
}
}))
defer server.Close()

err := Run(context.Background(), "dev", []string{
"channel", "messages",
"--token", "test-token",
"--base-url", server.URL,
"--workspace", "test0",
"--channel", "general",
"--limit", "0",
}, strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{})
if err == nil {
t.Fatal("expected service validation error")
}
if message := err.Error(); !strings.Contains(message, "failed with 400") || !strings.Contains(message, "limit must be a positive integer") {
t.Fatalf("expected service validation message, got %q", message)
}
}

func TestChannelWaitResolvesWorkspaceChannelAndPrintsWaitResponse(t *testing.T) {
configDir := t.TempDir()
t.Setenv("CRAKEN_CONFIG_DIR", configDir)
Expand Down
2 changes: 1 addition & 1 deletion internal/craken/resources.go
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,7 @@ func localSenderBody(cmd command, body string) map[string]any {

func messagePageQuery(cmd command) string {
params := url.Values{}
for _, key := range []string{"position", "before", "after", "around"} {
for _, key := range []string{"position", "before", "after", "around", "limit"} {
if value := cmd.string(key, ""); value != "" {
params.Set(key, value)
}
Expand Down