diff --git a/CLAUDE.md b/CLAUDE.md index c0ad47f..75fa57c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,20 +29,20 @@ cf pages get --id 12345 cf search search-content --cql "space = DEV AND type = page" --jq '.results[] | {id, title}' # Create page (content in Confluence storage format — XHTML) -cf pages create --spaceId 123456 --title "New Page" --body "

Content here

" +cf pages create --space-id 123456 --title "New Page" --body "

Content here

" -# Update page (requires current version number) -cf pages update --id 12345 --version-number 3 --title "Updated" --body "

New content

" +# Update page (auto-increments version number) +cf pages update --id 12345 --title "Updated" --body "

New content

" # Delete page cf pages delete --id 12345 # List spaces -cf spaces list --jq '.results[] | {id, key: .key, name: .name}' +cf spaces get --jq '.results[] | {id, key: .key, name: .name}' # Blog posts -cf blogposts create --spaceId 123456 --title "Sprint Recap" --body "

What we shipped

" -cf blogposts list --jq '.results[] | {id, title}' +cf blogposts create-blog-post --space-id 123456 --title "Sprint Recap" --body "

What we shipped

" +cf blogposts get-blog-posts --jq '.results[] | {id, title}' # Comments cf workflow comment --id 12345 --body "Reviewed and approved" @@ -71,10 +71,10 @@ cf export --id 12345 # single page body cf export --id 12345 --tree # page + all descendants cf export --id 12345 --format storage # raw storage format -# Raw API call (method is positional, not a flag; POST/PUT/PATCH require --body) -cf raw GET /wiki/api/v2/pages/12345 -cf raw POST /wiki/api/v2/pages --body '{"spaceId":"123","title":"New"}' -echo '{"spaceId":"123"}' | cf raw POST /wiki/api/v2/pages --body - # stdin +# Raw API call (path is relative to base URL; POST/PUT/PATCH require --body) +cf raw GET /pages/12345 +cf raw POST /pages --body '{"spaceId":"123","title":"New"}' +echo '{"spaceId":"123"}' | cf raw POST /pages --body - # stdin # Batch operations echo '[{"command":"pages get","args":{"id":"12345"},"jq":".title"},{"command":"pages get","args":{"id":"67890"},"jq":".title"}]' | cf batch diff --git a/README.md b/README.md index a996a80..5ec83c5 100644 --- a/README.md +++ b/README.md @@ -204,8 +204,8 @@ cf pages get --id 12345 --preset agent |---|---| | Read a page, only what the model needs | `cf pages get --id 12345 --preset agent` | | Find recently-updated pages in a space | `cf search search-content --cql "space = DEV AND lastModified > now('-7d')" --jq '.results[] \| {id, title}'` | -| Create a page from XHTML body | `cf pages create --spaceId 123456 --title "Runbook" --body "

Steps...

"` | -| Update a page (version-checked) | `cf pages update --id 12345 --version-number 3 --title "Runbook v2" --body "

...

"` | +| Create a page from XHTML body | `cf pages create --space-id 123456 --title "Runbook" --body "

Steps...

"` | +| Update a page (auto version increment) | `cf pages update --id 12345 --title "Runbook v2" --body "

...

"` | | Diff what changed in the last 2 hours | `cf diff --id 12345 --since 2h` | | Export a page tree as JSON | `cf export --id 12345 --tree` | | Watch a space for changes | `cf watch --cql "space = DEV" --interval 30s --max-events 50` | @@ -213,7 +213,7 @@ cf pages get --id 12345 --preset agent | Add and remove labels | `cf labels add --page-id 12345 --name reviewed` | | Upload an attachment | `cf attachments upload --page-id 12345 --file ./diagram.png` | | Run multiple ops in one process | `echo '[...]' \| cf batch` | -| Hit any v2 endpoint directly | `cf raw GET /wiki/api/v2/pages/12345` | +| Hit any v2 endpoint directly | `cf raw GET /pages/12345` |
Streaming output sample (cf watch) diff --git a/cmd/batch_custom_commands_test.go b/cmd/batch_custom_commands_test.go new file mode 100644 index 0000000..ea7898d --- /dev/null +++ b/cmd/batch_custom_commands_test.go @@ -0,0 +1,193 @@ +package cmd_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/sofq/confluence-cli/cmd" + "github.com/sofq/confluence-cli/internal/client" + "github.com/sofq/confluence-cli/internal/config" +) + +// TestBatch_DiffPathSubstitution verifies that batch correctly substitutes +// {id} in the diff command's path template (/pages/{id}/versions). +func TestBatch_DiffPathSubstitution(t *testing.T) { + var gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"results":[{"number":2,"authorId":"a","createdAt":"2026-01-01T00:00:00Z","message":""}],"_links":{}}`)) + })) + defer srv.Close() + + c := &client.Client{ + BaseURL: srv.URL, + Auth: config.AuthConfig{Type: "bearer", Token: "test-token"}, + HTTPClient: srv.Client(), + Stdout: &strings.Builder{}, + Stderr: &strings.Builder{}, + } + + ops := []cmd.BatchOp{ + {Command: "diff diff", Args: map[string]string{"id": "12345"}}, + } + results := cmd.ExecuteBatchOps(c, ops) + + if len(results) != 1 { + t.Fatalf("expected 1 result, got %d", len(results)) + } + + // The path should have 12345 substituted, not the literal {id}. + if strings.Contains(gotPath, "{id}") { + t.Errorf("path contains unsubstituted {id}: %s", gotPath) + } + if !strings.Contains(gotPath, "/pages/12345/versions") { + t.Errorf("expected path to contain /pages/12345/versions, got: %s", gotPath) + } +} + +// TestBatch_ExportPathSubstitution verifies that batch correctly substitutes +// {id} in the export command's path template (/pages/{id}). +func TestBatch_ExportPathSubstitution(t *testing.T) { + var gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"67890","title":"Test","body":{"storage":{"value":"

Hello

"}}}`)) + })) + defer srv.Close() + + c := &client.Client{ + BaseURL: srv.URL, + Auth: config.AuthConfig{Type: "bearer", Token: "test-token"}, + HTTPClient: srv.Client(), + Stdout: &strings.Builder{}, + Stderr: &strings.Builder{}, + } + + ops := []cmd.BatchOp{ + {Command: "export export", Args: map[string]string{"id": "67890"}}, + } + results := cmd.ExecuteBatchOps(c, ops) + + if len(results) != 1 { + t.Fatalf("expected 1 result, got %d", len(results)) + } + if strings.Contains(gotPath, "{id}") { + t.Errorf("path contains unsubstituted {id}: %s", gotPath) + } + if !strings.Contains(gotPath, "/pages/67890") { + t.Errorf("expected path to contain /pages/67890, got: %s", gotPath) + } +} + +// TestBatch_WorkflowCommentPathSubstitution verifies that batch correctly +// substitutes {id} in workflow comment's path (/pages/{id}/footer-comments). +func TestBatch_WorkflowCommentPathSubstitution(t *testing.T) { + var gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"99","status":"current"}`)) + })) + defer srv.Close() + + c := &client.Client{ + BaseURL: srv.URL, + Auth: config.AuthConfig{Type: "bearer", Token: "test-token"}, + HTTPClient: srv.Client(), + Stdout: &strings.Builder{}, + Stderr: &strings.Builder{}, + } + + ops := []cmd.BatchOp{ + {Command: "workflow comment", Args: map[string]string{"id": "55555", "body": "test comment"}}, + } + results := cmd.ExecuteBatchOps(c, ops) + + if len(results) != 1 { + t.Fatalf("expected 1 result, got %d", len(results)) + } + if strings.Contains(gotPath, "{id}") { + t.Errorf("path contains unsubstituted {id}: %s", gotPath) + } + if !strings.Contains(gotPath, "/pages/55555/footer-comments") { + t.Errorf("expected path to contain /pages/55555/footer-comments, got: %s", gotPath) + } +} + +// TestBatch_WorkflowMovePathSubstitution verifies batch substitution of both +// {id} and {targetId} in the move command's path. +func TestBatch_WorkflowMovePathSubstitution(t *testing.T) { + var gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"111","title":"Moved"}`)) + })) + defer srv.Close() + + c := &client.Client{ + BaseURL: srv.URL, + Auth: config.AuthConfig{Type: "bearer", Token: "test-token"}, + HTTPClient: srv.Client(), + Stdout: &strings.Builder{}, + Stderr: &strings.Builder{}, + } + + ops := []cmd.BatchOp{ + {Command: "workflow move", Args: map[string]string{"id": "111", "target-id": "222"}}, + } + results := cmd.ExecuteBatchOps(c, ops) + + if len(results) != 1 { + t.Fatalf("expected 1 result, got %d", len(results)) + } + if strings.Contains(gotPath, "{id}") || strings.Contains(gotPath, "{targetId}") { + t.Errorf("path contains unsubstituted placeholders: %s", gotPath) + } +} + +// TestBatch_CustomCommandMissingRequiredPathParam verifies that batch returns +// a validation error when a required path parameter is missing. +func TestBatch_CustomCommandMissingRequiredPathParam(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Error("unexpected HTTP request — should fail validation before making a request") + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + c := &client.Client{ + BaseURL: srv.URL, + Auth: config.AuthConfig{Type: "bearer", Token: "test-token"}, + HTTPClient: srv.Client(), + Stdout: &strings.Builder{}, + Stderr: &strings.Builder{}, + } + + ops := []cmd.BatchOp{ + {Command: "diff diff", Args: map[string]string{}}, // missing required "id" + } + results := cmd.ExecuteBatchOps(c, ops) + + if len(results) != 1 { + t.Fatalf("expected 1 result, got %d", len(results)) + } + if results[0].ExitCode == 0 { + t.Error("expected non-zero exit code for missing required path param") + } + if results[0].Error == nil { + t.Error("expected error in result for missing required path param") + } + + // Verify error message mentions the missing parameter. + var errObj map[string]string + if err := json.Unmarshal(results[0].Error, &errObj); err == nil { + if !strings.Contains(errObj["message"], "id") { + t.Errorf("expected error to mention 'id', got: %s", errObj["message"]) + } + } +} diff --git a/cmd/diff_schema.go b/cmd/diff_schema.go index 97a415e..04c6f8a 100644 --- a/cmd/diff_schema.go +++ b/cmd/diff_schema.go @@ -13,7 +13,7 @@ func DiffSchemaOps() []generated.SchemaOp { Summary: "Compare page versions and show structured diff", HasBody: false, Flags: []generated.SchemaFlag{ - {Name: "id", Required: true, Type: "string", Description: "page ID to compare versions (required)", In: "custom"}, + {Name: "id", Required: true, Type: "string", Description: "page ID to compare versions (required)", In: "path"}, {Name: "since", Required: false, Type: "string", Description: "filter changes since duration (e.g. 2h, 1d) or ISO date (e.g. 2026-01-01)", In: "custom"}, {Name: "from", Required: false, Type: "integer", Description: "start version number for explicit comparison", In: "custom"}, {Name: "to", Required: false, Type: "integer", Description: "end version number for explicit comparison", In: "custom"}, diff --git a/cmd/export_schema.go b/cmd/export_schema.go index 4f2c6e3..ce78502 100644 --- a/cmd/export_schema.go +++ b/cmd/export_schema.go @@ -13,7 +13,7 @@ func ExportSchemaOps() []generated.SchemaOp { Summary: "Export page body in requested format", HasBody: false, Flags: []generated.SchemaFlag{ - {Name: "id", Required: true, Type: "string", Description: "page ID to export (required)", In: "custom"}, + {Name: "id", Required: true, Type: "string", Description: "page ID to export (required)", In: "path"}, {Name: "format", Required: false, Type: "string", Description: "body format: storage, atlas_doc_format, view", In: "custom"}, {Name: "tree", Required: false, Type: "boolean", Description: "recursively export page tree as NDJSON", In: "custom"}, {Name: "depth", Required: false, Type: "integer", Description: "maximum tree depth (0 = unlimited)", In: "custom"}, diff --git a/cmd/export_test.go b/cmd/export_test.go index 81bfaaf..fe05ab9 100644 --- a/cmd/export_test.go +++ b/cmd/export_test.go @@ -132,6 +132,10 @@ func SearchV1Domain(baseURL string) string { // op using a background context and the provided client. func ExecuteBatchOps(c *client.Client, ops []BatchOp) []BatchResult { allOps := generated.AllSchemaOps() + allOps = append(allOps, DiffSchemaOps()...) + allOps = append(allOps, WorkflowSchemaOps()...) + allOps = append(allOps, ExportSchemaOps()...) + allOps = append(allOps, PresetSchemaOps()...) opMap := make(map[string]generated.SchemaOp, len(allOps)) for _, op := range allOps { key := op.Resource + " " + op.Verb diff --git a/cmd/preset_single_item_test.go b/cmd/preset_single_item_test.go new file mode 100644 index 0000000..e1756ba --- /dev/null +++ b/cmd/preset_single_item_test.go @@ -0,0 +1,241 @@ +package cmd_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// TestPreset_AgentOnSingleItem verifies that the built-in "agent" preset +// works on single-item responses (e.g., pages get-by-id) where there is no +// .results[] wrapper. +func TestPreset_AgentOnSingleItem(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "42", + "title": "Test Page", + "status": "current", + "spaceId": "100", + "version": map[string]any{"number": 3}, + "_links": map[string]any{"webui": "/pages/42"}, + }) + })) + defer srv.Close() + setupEnvForServer(t, srv.URL) + + stdout, stderr, err := captureCommand(t, []string{ + "pages", "get-by-id", "--id", "42", "--preset", "agent", + }) + if err != nil { + t.Fatalf("unexpected error: %v\nstderr: %s", err, stderr) + } + + // Should produce a valid JSON object with the preset fields. + var result map[string]any + if err := json.Unmarshal([]byte(stdout), &result); err != nil { + t.Fatalf("expected valid JSON, got parse error: %v\nstdout: %s", err, stdout) + } + + // Verify key fields are present. + if result["id"] != "42" { + t.Errorf("expected id=42, got %v", result["id"]) + } + if result["title"] != "Test Page" { + t.Errorf("expected title=Test Page, got %v", result["title"]) + } + if result["status"] != "current" { + t.Errorf("expected status=current, got %v", result["status"]) + } +} + +// TestPreset_AgentOnListResponse verifies that the "agent" preset still works +// on list responses (e.g., pages get) that have a .results[] wrapper. +func TestPreset_AgentOnListResponse(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "results": []map[string]any{ + { + "id": "1", + "title": "Page One", + "status": "current", + "spaceId": "100", + "version": map[string]any{"number": 1}, + "_links": map[string]any{"webui": "/pages/1"}, + }, + { + "id": "2", + "title": "Page Two", + "status": "current", + "spaceId": "100", + "version": map[string]any{"number": 2}, + "_links": map[string]any{"webui": "/pages/2"}, + }, + }, + "_links": map[string]any{}, + }) + })) + defer srv.Close() + setupEnvForServer(t, srv.URL) + + stdout, stderr, err := captureCommand(t, []string{ + "pages", "get", "--preset", "agent", + }) + if err != nil { + t.Fatalf("unexpected error: %v\nstderr: %s", err, stderr) + } + + // The agent preset on a list should produce a JSON array. + var results []map[string]any + if err := json.Unmarshal([]byte(stdout), &results); err != nil { + t.Fatalf("expected valid JSON array, got parse error: %v\nstdout: %s", err, stdout) + } + if len(results) != 2 { + t.Fatalf("expected 2 results, got %d", len(results)) + } + if results[0]["title"] != "Page One" { + t.Errorf("expected first title=Page One, got %v", results[0]["title"]) + } +} + +// TestPreset_BriefOnSingleItem verifies the "brief" preset on single-item responses. +func TestPreset_BriefOnSingleItem(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "42", + "title": "Test Page", + "status": "current", + "spaceId": "100", + "version": map[string]any{"number": 3}, + "body": map[string]any{"storage": map[string]any{"value": "

lots of content

"}}, + }) + })) + defer srv.Close() + setupEnvForServer(t, srv.URL) + + stdout, stderr, err := captureCommand(t, []string{ + "pages", "get-by-id", "--id", "42", "--preset", "brief", + }) + if err != nil { + t.Fatalf("unexpected error: %v\nstderr: %s", err, stderr) + } + + var result map[string]any + if err := json.Unmarshal([]byte(stdout), &result); err != nil { + t.Fatalf("expected valid JSON, got parse error: %v\nstdout: %s", err, stdout) + } + + // Brief should include id, title, status but NOT body or version. + if result["id"] != "42" { + t.Errorf("expected id=42, got %v", result["id"]) + } + if result["title"] != "Test Page" { + t.Errorf("expected title=Test Page, got %v", result["title"]) + } + if _, hasBody := result["body"]; hasBody { + t.Error("brief preset should not include body") + } +} + +// TestPreset_MetaOnSingleItem verifies the "meta" preset on single-item responses. +func TestPreset_MetaOnSingleItem(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "42", + "title": "Test Page", + "status": "current", + "spaceId": "100", + "authorId": "user-abc", + "createdAt": "2026-01-01T00:00:00Z", + "version": map[string]any{"number": 3}, + }) + })) + defer srv.Close() + setupEnvForServer(t, srv.URL) + + stdout, stderr, err := captureCommand(t, []string{ + "pages", "get-by-id", "--id", "42", "--preset", "meta", + }) + if err != nil { + t.Fatalf("unexpected error: %v\nstderr: %s", err, stderr) + } + + var result map[string]any + if err := json.Unmarshal([]byte(stdout), &result); err != nil { + t.Fatalf("expected valid JSON, got parse error: %v\nstdout: %s", err, stdout) + } + + if result["authorId"] != "user-abc" { + t.Errorf("expected authorId=user-abc, got %v", result["authorId"]) + } + if result["spaceId"] != "100" { + t.Errorf("expected spaceId=100, got %v", result["spaceId"]) + } +} + +// TestPreset_TitlesOnSingleItem verifies the "titles" preset on a single-item response. +func TestPreset_TitlesOnSingleItem(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "42", + "title": "My Page Title", + }) + })) + defer srv.Close() + setupEnvForServer(t, srv.URL) + + stdout, stderr, err := captureCommand(t, []string{ + "pages", "get-by-id", "--id", "42", "--preset", "titles", + }) + if err != nil { + t.Fatalf("unexpected error: %v\nstderr: %s", err, stderr) + } + + stdout = strings.TrimSpace(stdout) + if !strings.Contains(stdout, "My Page Title") { + t.Errorf("expected output to contain title, got: %s", stdout) + } +} + +// TestPreset_StatusIsStringNotObject verifies that presets work when +// the Confluence v2 API returns status as a plain string ("current") +// rather than an object ({current: "current"}). +func TestPreset_StatusIsStringNotObject(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + // Confluence v2 API returns status as a plain string. + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "42", + "title": "Test", + "status": "current", // string, not object + }) + })) + defer srv.Close() + setupEnvForServer(t, srv.URL) + + stdout, stderr, err := captureCommand(t, []string{ + "pages", "get-by-id", "--id", "42", "--preset", "brief", + }) + if err != nil { + t.Fatalf("unexpected error: %v\nstderr: %s", err, stderr) + } + + // Should not produce a jq error. + if strings.Contains(stderr, "jq_error") { + t.Errorf("preset should handle string status without jq error, stderr: %s", stderr) + } + + var result map[string]any + if err := json.Unmarshal([]byte(stdout), &result); err != nil { + t.Fatalf("expected valid JSON, got: %v\nstdout: %s", err, stdout) + } + if result["status"] != "current" { + t.Errorf("expected status=current, got %v", result["status"]) + } +} diff --git a/cmd/workflow_schema.go b/cmd/workflow_schema.go index c329700..7c396f8 100644 --- a/cmd/workflow_schema.go +++ b/cmd/workflow_schema.go @@ -9,12 +9,12 @@ func WorkflowSchemaOps() []generated.SchemaOp { Resource: "workflow", Verb: "move", Method: "PUT", - Path: "/wiki/rest/api/content/{id}/move/append/{targetId}", + Path: "/wiki/rest/api/content/{id}/move/append/{target-id}", Summary: "Move a page to a different parent", HasBody: false, Flags: []generated.SchemaFlag{ - {Name: "id", Required: true, Type: "string", Description: "page ID to move (required)", In: "custom"}, - {Name: "target-id", Required: true, Type: "string", Description: "target parent page ID (required)", In: "custom"}, + {Name: "id", Required: true, Type: "string", Description: "page ID to move (required)", In: "path"}, + {Name: "target-id", Required: true, Type: "string", Description: "target parent page ID (required)", In: "path"}, }, }, { @@ -25,7 +25,7 @@ func WorkflowSchemaOps() []generated.SchemaOp { Summary: "Copy a page to a target parent", HasBody: true, Flags: []generated.SchemaFlag{ - {Name: "id", Required: true, Type: "string", Description: "page ID to copy (required)", In: "custom"}, + {Name: "id", Required: true, Type: "string", Description: "page ID to copy (required)", In: "path"}, {Name: "target-id", Required: true, Type: "string", Description: "target parent page ID (required)", In: "custom"}, {Name: "title", Required: false, Type: "string", Description: "title for the copied page", In: "custom"}, {Name: "copy-attachments", Required: false, Type: "boolean", Description: "include attachments in copy", In: "custom"}, @@ -43,7 +43,7 @@ func WorkflowSchemaOps() []generated.SchemaOp { Summary: "Publish a draft page", HasBody: true, Flags: []generated.SchemaFlag{ - {Name: "id", Required: true, Type: "string", Description: "page ID to publish (required)", In: "custom"}, + {Name: "id", Required: true, Type: "string", Description: "page ID to publish (required)", In: "path"}, }, }, { @@ -54,7 +54,7 @@ func WorkflowSchemaOps() []generated.SchemaOp { Summary: "Add a plain-text comment to a page", HasBody: true, Flags: []generated.SchemaFlag{ - {Name: "id", Required: true, Type: "string", Description: "page ID to comment on (required)", In: "custom"}, + {Name: "id", Required: true, Type: "string", Description: "page ID to comment on (required)", In: "path"}, {Name: "body", Required: true, Type: "string", Description: "comment text (required)", In: "custom"}, }, }, @@ -66,7 +66,7 @@ func WorkflowSchemaOps() []generated.SchemaOp { Summary: "View, add, or remove page restrictions", HasBody: false, Flags: []generated.SchemaFlag{ - {Name: "id", Required: true, Type: "string", Description: "page ID to manage restrictions (required)", In: "custom"}, + {Name: "id", Required: true, Type: "string", Description: "page ID to manage restrictions (required)", In: "path"}, {Name: "add", Required: false, Type: "boolean", Description: "add a restriction", In: "custom"}, {Name: "remove", Required: false, Type: "boolean", Description: "remove a restriction", In: "custom"}, {Name: "operation", Required: false, Type: "string", Description: "restriction operation: read or update", In: "custom"}, diff --git a/internal/preset/preset.go b/internal/preset/preset.go index 8315d5a..90c0b9e 100644 --- a/internal/preset/preset.go +++ b/internal/preset/preset.go @@ -12,11 +12,11 @@ import ( // builtinPresets contains the default presets shipped with cf. // Values are JQ expression strings applied to Confluence v2 API JSON responses. var builtinPresets = map[string]string{ - "brief": `.results[] | {id, title, status: .status.current}`, - "titles": `.results[] | .title`, - "agent": `.results[] | {id, title, status: .status.current, spaceId, version: .version.number, _links}`, - "tree": `.results[] | {id, title, parentId, childPosition: .position}`, - "meta": `. | {id, title, status: .status.current, version: .version, createdAt, authorId: .authorId, spaceId}`, + "brief": `if has("results") then .results[] | {id, title, status} else {id, title, status} end`, + "titles": `if has("results") then .results[] | .title else .title end`, + "agent": `if has("results") then .results[] | {id, title, status, spaceId, version: .version.number, _links} else {id, title, status, spaceId, version: .version.number, _links} end`, + "tree": `if has("results") then .results[] | {id, title, parentId, childPosition: .position} else {id, title, parentId, childPosition: .position} end`, + "meta": `. | {id, title, status, version: .version, createdAt, authorId: .authorId, spaceId}`, "search": `.results[] | {content: .content.id, title: .content.title, excerpt: .excerpt, url: .url}`, "diff": `. | {id, title, version: .version.number, body}`, } diff --git a/skill/confluence-cli/SKILL.md b/skill/confluence-cli/SKILL.md index d25ec70..8bd7cdf 100644 --- a/skill/confluence-cli/SKILL.md +++ b/skill/confluence-cli/SKILL.md @@ -65,9 +65,9 @@ Always use `cf schema` to discover the exact command name and flags before runni ### Pages ```bash cf pages get --id 12345 -cf pages create --spaceId 123456 --title "Deploy Runbook" \ +cf pages create --space-id 123456 --title "Deploy Runbook" \ --body "

Steps

Follow these steps...

" -cf pages update --id 12345 --version-number 3 \ +cf pages update --id 12345 \ --title "Deploy Runbook v2" --body "

Updated Steps

" cf pages delete --id 12345 ``` @@ -83,13 +83,13 @@ cf search search-content \ ### Spaces ```bash -cf spaces list --jq '.results[] | {id, key: .key, name: .name}' +cf spaces get --jq '.results[] | {id, key: .key, name: .name}' ``` ### Blog posts ```bash -cf blogposts create --spaceId 123456 --title "Sprint Recap" --body "

What we shipped...

" -cf blogposts list --jq '.results[] | {id, title}' +cf blogposts create-blog-post --space-id 123456 --title "Sprint Recap" --body "

What we shipped...

" +cf blogposts get-blog-posts --jq '.results[] | {id, title}' ``` ### Comments, Labels, Attachments @@ -133,9 +133,9 @@ Events: `initial`, `created`, `updated`, `removed`. Always use `--max-polls` in ### Raw API call (escape hatch) ```bash -cf raw GET /wiki/api/v2/pages/12345 -cf raw POST /wiki/api/v2/pages --body '{"spaceId":"123","title":"New Page"}' -echo '{"spaceId":"123"}' | cf raw POST /wiki/api/v2/pages --body - +cf raw GET /pages/12345 +cf raw POST /pages --body '{"spaceId":"123","title":"New Page"}' +echo '{"spaceId":"123"}' | cf raw POST /pages --body - ``` POST/PUT/PATCH require `--body`. Without it, `cf raw` will error instead of hanging on stdin. @@ -159,7 +159,7 @@ cf pages get --id 12345 --jq '{id: .id, title: .title}' cf pages get --id 12345 --fields id,title --jq '{id: .id, title: .title}' # Cache read-heavy data -cf spaces list --cache 5m --jq '[.results[].key]' +cf spaces get --cache 5m --jq '[.results[].key]' ``` Always use `--preset` or `--fields` + `--jq`. Run `cf preset list` for available presets. See `references/presets.md` for the full preset table. @@ -172,7 +172,7 @@ Run multiple Confluence calls in a single process: echo '[ {"command": "pages get", "args": {"id": "12345"}, "jq": ".title"}, {"command": "pages get", "args": {"id": "67890"}, "jq": ".title"}, - {"command": "spaces list", "args": {}, "jq": "[.results[].key]"} + {"command": "spaces get", "args": {}, "jq": "[.results[].key]"} ]' | cf batch ``` @@ -237,17 +237,17 @@ echo '[ ### Create page with children ```bash # Step 1: Create parent -PARENT=$(cf pages create --spaceId 123456 --title "Project Docs" \ +PARENT=$(cf pages create --space-id 123456 --title "Project Docs" \ --body "

Root

" --jq '.id') # Step 2: Create children via batch echo "[ - {\"command\": \"pages create\", \"args\": {\"spaceId\": \"123456\", \"parentId\": \"$PARENT\", \"title\": \"Getting Started\", \"body\": \"

Setup

\"}}, - {\"command\": \"pages create\", \"args\": {\"spaceId\": \"123456\", \"parentId\": \"$PARENT\", \"title\": \"Architecture\", \"body\": \"

Design

\"}} + {\"command\": \"pages create\", \"args\": {\"space-id\": \"123456\", \"parent-id\": \"$PARENT\", \"title\": \"Getting Started\", \"body\": \"

Setup

\"}}, + {\"command\": \"pages create\", \"args\": {\"space-id\": \"123456\", \"parent-id\": \"$PARENT\", \"title\": \"Architecture\", \"body\": \"

Design

\"}} ]" | cf batch ``` ### Validate before executing ```bash -cf pages create --spaceId 123456 --title "Test" --body "

test

" --dry-run +cf pages create --space-id 123456 --title "Test" --body "

test

" --dry-run ``` diff --git a/website/guide/agent-integration.md b/website/guide/agent-integration.md index 6da7637..1e1e431 100644 --- a/website/guide/agent-integration.md +++ b/website/guide/agent-integration.md @@ -119,7 +119,7 @@ cf pages get --id 12345 --preset agent cf pages get --id 12345 --preset brief # Titles only: just page titles -cf spaces list --preset titles +cf spaces get --preset titles # List all available presets cf preset list @@ -154,7 +154,7 @@ Use `--cache` to avoid redundant API calls for stable data: ```bash # Cache space list for 5 minutes -cf spaces list --cache 5m --jq '[.results[].key]' +cf spaces get --cache 5m --jq '[.results[].key]' ``` ## Batch Operations @@ -165,7 +165,7 @@ When an agent needs multiple Confluence calls, use `cf batch` to run them in a s echo '[ {"command": "pages get", "args": {"id": "12345"}, "jq": ".title"}, {"command": "pages get", "args": {"id": "67890"}, "jq": ".title"}, - {"command": "spaces list", "args": {}, "jq": "[.results[].key]"} + {"command": "spaces get", "args": {}, "jq": "[.results[].key]"} ]' | cf batch ``` @@ -205,7 +205,7 @@ Example error response: "error_type": "not_found", "status": 404, "message": "Page Does Not Exist", - "request": {"method": "GET", "path": "/wiki/api/v2/pages/99999"} + "request": {"method": "GET", "path": "/pages/99999"} } ``` @@ -218,7 +218,7 @@ Error JSON may also include `hint` (actionable recovery text) and `retry_after` "message": "Rate limit exceeded", "hint": "You are being rate limited. Wait before retrying.", "retry_after": 30, - "request": {"method": "GET", "path": "/wiki/api/v2/pages"} + "request": {"method": "GET", "path": "/pages"} } ``` @@ -275,5 +275,5 @@ Always use `--preset` or `--fields` + `--jq` to minimize output. A single unfilt Use `--dry-run` to preview what `cf` will send without making the API call: ```bash -cf pages create --spaceId 123456 --title "Test" --body "

Hello

" --dry-run +cf pages create --space-id 123456 --title "Test" --body "

Hello

" --dry-run ``` diff --git a/website/guide/filtering.md b/website/guide/filtering.md index 8b93a0a..622bbd4 100644 --- a/website/guide/filtering.md +++ b/website/guide/filtering.md @@ -57,5 +57,5 @@ Always use `--fields` and `--jq` together. `--fields` reduces what Confluence se For data that changes infrequently (like space lists), use `--cache` to avoid redundant API calls: ```bash -cf spaces list --cache 5m --jq '[.results[].key]' +cf spaces get --cache 5m --jq '[.results[].key]' ``` diff --git a/website/guide/getting-started.md b/website/guide/getting-started.md index 3185be1..96a3495 100644 --- a/website/guide/getting-started.md +++ b/website/guide/getting-started.md @@ -185,7 +185,7 @@ Command names are auto-generated from Confluence's OpenAPI spec, so they can be ### List spaces ```bash -cf spaces list +cf spaces get ``` ## Workflow commands diff --git a/website/guide/global-flags.md b/website/guide/global-flags.md index 0031c95..146df37 100644 --- a/website/guide/global-flags.md +++ b/website/guide/global-flags.md @@ -71,7 +71,7 @@ Override the authentication type for a single command. Accepted values: `basic`, ::: ```bash -cf raw GET /wiki/api/v2/pages --auth-type bearer --auth-token MY_PAT +cf raw GET /pages --auth-type bearer --auth-token MY_PAT ``` --- @@ -97,7 +97,7 @@ cf pages get --id 12345 --auth-user other@company.com --auth-token TOKEN Override the API token or bearer token for a single command. ```bash -cf raw GET /wiki/api/v2/pages --auth-token NEW_TOKEN +cf raw GET /pages --auth-token NEW_TOKEN ``` ::: warning @@ -198,7 +198,7 @@ Only applies to GET requests. ```bash # Cache space list for 5 minutes -cf spaces list --cache 5m --jq '[.results[].key]' +cf spaces get --cache 5m --jq '[.results[].key]' # Cache for 1 hour cf pages get --id 12345 --cache 1h --fields id,title @@ -232,7 +232,7 @@ Disable automatic pagination. By default, `cf` fetches all pages for paginated e ```bash # Only get the first page of results -cf spaces list --no-paginate +cf spaces get --no-paginate ``` --- @@ -260,7 +260,7 @@ cf pages get --id 12345 --verbose Print the HTTP request that would be made as JSON, without actually executing it. Useful for debugging request payloads, especially for POST/PUT operations. ```bash -cf pages create --spaceId 123456 --title "Test" --body "

Hello

" --dry-run +cf pages create --space-id 123456 --title "Test" --body "

Hello

" --dry-run # Outputs the request details without calling Confluence ``` @@ -280,7 +280,7 @@ cf search search-content \ --timeout 2m # Short timeout for quick checks -cf raw GET /wiki/api/v2/spaces --timeout 5s +cf raw GET /spaces --timeout 5s ``` ---