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
20 changes: 10 additions & 10 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<p>Content here</p>"
cf pages create --space-id 123456 --title "New Page" --body "<p>Content here</p>"

# Update page (requires current version number)
cf pages update --id 12345 --version-number 3 --title "Updated" --body "<p>New content</p>"
# Update page (auto-increments version number)
cf pages update --id 12345 --title "Updated" --body "<p>New content</p>"

# 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 "<p>What we shipped</p>"
cf blogposts list --jq '.results[] | {id, title}'
cf blogposts create-blog-post --space-id 123456 --title "Sprint Recap" --body "<p>What we shipped</p>"
cf blogposts get-blog-posts --jq '.results[] | {id, title}'

# Comments
cf workflow comment --id 12345 --body "Reviewed and approved"
Expand Down Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,16 +204,16 @@ 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 "<p>Steps...</p>"` |
| Update a page (version-checked) | `cf pages update --id 12345 --version-number 3 --title "Runbook v2" --body "<p>...</p>"` |
| Create a page from XHTML body | `cf pages create --space-id 123456 --title "Runbook" --body "<p>Steps...</p>"` |
| Update a page (auto version increment) | `cf pages update --id 12345 --title "Runbook v2" --body "<p>...</p>"` |
| 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` |
| Move, copy, archive, comment | `cf workflow move \| copy \| archive \| comment ...` |
| 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` |

<details>
<summary><b>Streaming output sample (<code>cf watch</code>)</b></summary>
Expand Down
193 changes: 193 additions & 0 deletions cmd/batch_custom_commands_test.go
Original file line number Diff line number Diff line change
@@ -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":"<p>Hello</p>"}}}`))
}))
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"])
}
}
}
2 changes: 1 addition & 1 deletion cmd/diff_schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
2 changes: 1 addition & 1 deletion cmd/export_schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
4 changes: 4 additions & 0 deletions cmd/export_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading