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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ craken workspace list
craken workspace create --name test0
craken channel send --workspace test0 --channel general hello
craken dm send --workspace test0 --target orca hello
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
Expand Down
2 changes: 2 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

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.

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. 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 profile file is intentionally compatible with the prior Node CLI:
Expand Down
38 changes: 35 additions & 3 deletions internal/craken/command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -441,12 +441,12 @@ 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":
case "PATCH /api/workspaces/workspace-id/wiki/pages/Home":
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" {
if body["title"] != "Home" || body["content"] != "# Home\n" || body["baseVersionNumber"] != float64(12) {
t.Fatalf("unexpected wiki body %#v", body)
}
writeJSON(t, w, map[string]any{"page": body})
Expand All @@ -465,7 +465,7 @@ 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", "--title", "Home", "--content-file", contentPath}, strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{})
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)
}
Expand All @@ -478,6 +478,38 @@ func TestWikiSaveAndAgentPlanCheck(t *testing.T) {
}
}

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) {
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 "PATCH /api/workspaces/workspace-id/wiki/pages/Home":
w.WriteHeader(http.StatusConflict)
writeJSON(t, w, map[string]any{
"conflict": map[string]any{
"currentVersionNumber": 13,
"receivedBaseVersionNumber": 12,
"reason": "stale_base_version",
},
"error": "Wiki page update for \"Home\" conflicted: baseVersionNumber 12 does not match current version 13.",
})
default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()

err := Run(context.Background(), "dev", []string{"wiki", "save", "--token", "test-token", "--base-url", server.URL, "--workspace", "test0", "--existing-title", "Home", "--content", "stale", "--base-version", "12"}, strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{})
if err == nil {
t.Fatal("expected stale wiki save to fail")
}
if message := err.Error(); !strings.Contains(message, "failed with 409") || !strings.Contains(message, "stale_base_version") {
t.Fatalf("expected 409 conflict details, got %q", message)
}
}

func TestFileUploadUsesMultipartAndFolderCreate(t *testing.T) {
configDir := t.TempDir()
t.Setenv("CRAKEN_CONFIG_DIR", configDir)
Expand Down
12 changes: 12 additions & 0 deletions internal/craken/util.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,18 @@ 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, "") != ""
}
Expand Down
23 changes: 21 additions & 2 deletions internal/craken/wiki_agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,25 @@ func runWiki(ctx context.Context, client *client, cmd command, stdout io.Writer)
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), compact(map[string]any{"content": content, "title": cmd.string("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", map[string]any{"content": content, "title": title})
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 {
Expand Down Expand Up @@ -89,6 +100,14 @@ func runWiki(ctx context.Context, client *client, cmd command, stdout io.Writer)
}
}

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)
Expand Down