diff --git a/Makefile b/Makefile index 46b3ef5..fcc5e0c 100644 --- a/Makefile +++ b/Makefile @@ -18,11 +18,13 @@ help: @echo " BASECAMP_TEST_PROJECT_ID Project ID to test against" @echo " BASECAMP_TEST_BOARD_ID Board ID for card tests" @echo " BASECAMP_TEST_CARD_ID Card ID for detail/move tests" + @echo " BASECAMP_TEST_TODOLIST_ID Todolist ID for todo tests" @echo "" @echo "Example:" @echo " export BASECAMP_TEST_PROJECT_ID=12345678" @echo " export BASECAMP_TEST_BOARD_ID=87654321" @echo " export BASECAMP_TEST_CARD_ID=44444444" + @echo " export BASECAMP_TEST_TODOLIST_ID=99999999" @echo " make test-e2e" build: diff --git a/README.md b/README.md index 63155ae..2d30cf5 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,25 @@ A command-line interface for Basecamp written in Go with zero external dependenc ## Installation +### Homebrew + +```bash +brew install rzolkos/tap/basecamp +``` + +### AUR (Arch Linux) + +```bash +yay -S basecamp-cli +``` + +### Go + ```bash go install github.com/rzolkos/basecamp-cli/cmd/basecamp@latest ``` -Or build from source: +### Build from source ```bash make build @@ -26,6 +40,8 @@ Configuration files (XDG Base Directory): ## Usage +### Card Tables + ```bash # List all projects basecamp projects @@ -33,6 +49,9 @@ basecamp projects # List card tables in a project basecamp boards +# List columns in a board +basecamp columns + # List cards in a board basecamp cards @@ -45,10 +64,274 @@ basecamp card # View card with comments basecamp card --comments +# Create a card +basecamp card-create --column --title "Card title" + +# Update a card +basecamp card-update --title "New title" --content "Description" + # Move a card to a different column basecamp move --to "Done" ``` +### Card Steps + +```bash +# Steps are shown when viewing a card +basecamp card + +# Create a step on a card +basecamp step-create --title "Step description" + +# Create a step with due date and assignees +basecamp step-create --title "Task" --due 2026-02-01 --assignees "123,456" + +# Update a step +basecamp step-update --title "Updated title" + +# Complete a step +basecamp step-complete + +# Uncomplete a step +basecamp step-uncomplete + +# Reposition a step (0-indexed) +basecamp step-reposition --position 0 +``` + +### Todos + +```bash +# List todo lists in a project +basecamp todolists + +# List todos in a todo list +basecamp todos + +# List completed todos +basecamp todos --completed + +# View a todo +basecamp todo + +# Create a todo +basecamp todo-create --content "Task description" + +# Complete a todo +basecamp todo-complete + +# Uncomplete a todo +basecamp todo-uncomplete + +# Reposition a todo within its list (1-indexed) +basecamp todo-reposition --position 1 +``` + +### Todo Groups + +```bash +# List groups within a todolist +basecamp todolist-groups + +# View a group +basecamp todolist-group + +# Create a group within a todolist +basecamp todolist-group-create --name "Group Name" + +# Create a group with color +basecamp todolist-group-create --name "Group Name" --color green +``` + +### Messages + +```bash +# List messages in a project +basecamp messages + +# View a message +basecamp message + +# View a message with comments +basecamp message --comments + +# Create a message +basecamp message-create --subject "Subject" --content "Body" +``` + +### Comments + +```bash +# Add a comment to any recording (card, message, todo, etc.) +basecamp comment-add --content "Comment text" +``` + +### Documents + +```bash +# List documents in a project +basecamp docs + +# View a document +basecamp doc + +# View a document with comments +basecamp doc --comments + +# Create a document +basecamp doc-create --title "Title" --content "Content" +``` + +### Schedule + +```bash +# List schedule entries +basecamp schedule + +# View an event +basecamp event + +# View an event with comments +basecamp event --comments + +# Create an event +basecamp event-create --summary "Meeting" --starts-at "2026-02-01T10:00:00Z" --ends-at "2026-02-01T11:00:00Z" + +# Create an all-day event +basecamp event-create --summary "Holiday" --starts-at "2026-02-01" --ends-at "2026-02-01" --all-day +``` + +### Campfire + +```bash +# List campfire messages +basecamp campfire + +# Post to campfire +basecamp campfire-post --content "Hello team!" +``` + +### Search + +```bash +# Search across all projects +basecamp search "query" + +# Search with type filter +basecamp search "query" --type Todo + +# Search within a project +basecamp search "query" --project +``` + +### People + +```bash +# List all people +basecamp people + +# View a person's details +basecamp person + +# List pingable people +basecamp people-pingable + +# List people on a project +basecamp people-project + +# View your own profile +basecamp my-profile + +# Update project access (grant/revoke people) +basecamp project-access --grant "123,456" --revoke "789" +``` + +### Automatic Check-ins (Questions) + +```bash +# Get questionnaire info for a project +basecamp questionnaire + +# List check-in questions +basecamp questions + +# View a question +basecamp question + +# View a question with comments +basecamp question --comments + +# List answers to a question +basecamp question-answers + +# View an answer +basecamp question-answer + +# View an answer with comments +basecamp question-answer --comments +``` + +### Uploads + +```bash +# Upload a file (returns attachable_sgid for use in other API calls) +basecamp upload /path/to/file.pdf + +# List uploads in a vault (get vault_id from 'docs' command) +basecamp uploads + +# View an upload +basecamp upload-view + +# View an upload with comments +basecamp upload-view --comments +``` + +### Recordings Management + +```bash +# Archive any recording (todo, message, card, etc.) +basecamp archive + +# Unarchive a recording (set back to active) +basecamp unarchive + +# Trash a recording +basecamp trash +``` + +### Message Types + +```bash +# List message types (categories) +basecamp message-types + +# View a message type +basecamp message-type + +# Create a message type +basecamp message-type-create --name "Announcement" --icon "๐Ÿ“ข" + +# Update a message type +basecamp message-type-update --name "Update" --icon "โœ…" + +# Delete a message type +basecamp message-type-delete +``` + +### Activity Events + +```bash +# List all events (across all projects) +basecamp events + +# List events for a project +basecamp events-project + +# List events for a specific recording +basecamp events-recording +``` + ## Project-specific config Create `.basecamp.yml` in your project directory to set a default project_id: diff --git a/e2e/harness/harness.go b/e2e/harness/harness.go index fe79747..53be205 100644 --- a/e2e/harness/harness.go +++ b/e2e/harness/harness.go @@ -26,6 +26,9 @@ type Harness struct { // CardID is a test card ID for read operations CardID string + // TodolistID is a test todolist ID + TodolistID string + // t is the testing context t *testing.T } @@ -57,6 +60,7 @@ type Config struct { ProjectID string BoardID string CardID string + TodolistID string } // Exit codes @@ -84,6 +88,7 @@ func LoadConfig() *Config { ProjectID: os.Getenv("BASECAMP_TEST_PROJECT_ID"), BoardID: os.Getenv("BASECAMP_TEST_BOARD_ID"), CardID: os.Getenv("BASECAMP_TEST_CARD_ID"), + TodolistID: os.Getenv("BASECAMP_TEST_TODOLIST_ID"), } } @@ -109,6 +114,7 @@ func New(t *testing.T) *Harness { ProjectID: cfg.ProjectID, BoardID: cfg.BoardID, CardID: cfg.CardID, + TodolistID: cfg.TodolistID, t: t, } } diff --git a/e2e/tests/campfire_test.go b/e2e/tests/campfire_test.go new file mode 100644 index 0000000..b96e61b --- /dev/null +++ b/e2e/tests/campfire_test.go @@ -0,0 +1,69 @@ +package tests + +import ( + "fmt" + "testing" + "time" + + "github.com/rzolkos/basecamp-cli/e2e/harness" +) + +func TestCampfire(t *testing.T) { + h := harness.New(t) + + t.Run("list campfire messages", func(t *testing.T) { + result := h.Run("campfire", h.ProjectID) + + if !result.Success() { + t.Fatalf("expected success, got exit code %d\nstderr: %s", result.ExitCode, result.Stderr) + } + + if result.JSON == nil { + t.Fatalf("expected JSON object, got: %s", result.Stdout) + } + + if result.GetInt("project_id") == 0 { + t.Error("expected project_id in response") + } + if result.GetInt("campfire_id") == 0 { + t.Error("expected campfire_id in response") + } + }) +} + +func TestCampfirePost(t *testing.T) { + h := harness.New(t) + + t.Run("post to campfire", func(t *testing.T) { + content := fmt.Sprintf("E2E Test Message %d", time.Now().UnixNano()) + result := h.Run("campfire-post", h.ProjectID, "--content", content) + + if !result.Success() { + t.Fatalf("expected success, got exit code %d\nstderr: %s", result.ExitCode, result.Stderr) + } + + if result.JSON == nil { + t.Fatalf("expected JSON object, got: %s", result.Stdout) + } + + if result.GetString("status") != "ok" { + t.Error("expected status=ok") + } + + if result.GetInt("id") == 0 { + t.Error("expected id in response") + } + }) + + t.Run("missing content flag", func(t *testing.T) { + result := h.Run("campfire-post", h.ProjectID) + + if result.Success() { + t.Error("expected failure without --content flag") + } + + if result.ErrorMessage() != "--content required" { + t.Errorf("expected '--content required' error, got: %s", result.ErrorMessage()) + } + }) +} diff --git a/e2e/tests/card_enhancement_test.go b/e2e/tests/card_enhancement_test.go new file mode 100644 index 0000000..3d0d039 --- /dev/null +++ b/e2e/tests/card_enhancement_test.go @@ -0,0 +1,113 @@ +package tests + +import ( + "fmt" + "testing" + "time" + + "github.com/rzolkos/basecamp-cli/e2e/harness" +) + +func TestColumns(t *testing.T) { + h := harness.New(t) + + if h.BoardID == "" { + t.Skip("BASECAMP_TEST_BOARD_ID not set") + } + + t.Run("list columns", func(t *testing.T) { + result := h.Run("columns", h.ProjectID, h.BoardID) + + if !result.Success() { + t.Fatalf("expected success, got exit code %d\nstderr: %s", result.ExitCode, result.Stderr) + } + + if result.JSON == nil { + t.Fatalf("expected JSON object, got: %s", result.Stdout) + } + + if result.GetInt("board_id") == 0 { + t.Error("expected board_id in response") + } + + columns, ok := result.JSON["columns"].([]any) + if !ok || len(columns) == 0 { + t.Error("expected at least one column") + } + }) +} + +func TestCardCreate(t *testing.T) { + h := harness.New(t) + + if h.BoardID == "" { + t.Skip("BASECAMP_TEST_BOARD_ID not set") + } + + // First get a column ID + columnsResult := h.Run("columns", h.ProjectID, h.BoardID) + if !columnsResult.Success() { + t.Fatalf("failed to get columns: %s", columnsResult.Stderr) + } + + columns, ok := columnsResult.JSON["columns"].([]any) + if !ok || len(columns) == 0 { + t.Fatal("no columns found") + } + + firstColumn := columns[0].(map[string]any) + columnID := fmt.Sprintf("%.0f", firstColumn["id"].(float64)) + + var cardID string + cardTitle := fmt.Sprintf("E2E Test Card %d", time.Now().UnixNano()) + + t.Run("create card", func(t *testing.T) { + result := h.Run("card-create", h.ProjectID, h.BoardID, "--column", columnID, "--title", cardTitle) + + if !result.Success() { + t.Fatalf("expected success, got exit code %d\nstderr: %s", result.ExitCode, result.Stderr) + } + + if result.JSON == nil { + t.Fatalf("expected JSON object, got: %s", result.Stdout) + } + + if result.GetString("status") != "ok" { + t.Error("expected status=ok") + } + + cardID = fmt.Sprintf("%d", result.GetInt("id")) + if cardID == "0" { + t.Fatal("expected card id in response") + } + }) + + t.Run("update card", func(t *testing.T) { + if cardID == "" { + t.Skip("no card created") + } + + newTitle := fmt.Sprintf("Updated Card %d", time.Now().UnixNano()) + result := h.Run("card-update", h.ProjectID, cardID, "--title", newTitle) + + if !result.Success() { + t.Fatalf("expected success, got exit code %d\nstderr: %s", result.ExitCode, result.Stderr) + } + + if result.JSON == nil { + t.Fatalf("expected JSON object, got: %s", result.Stdout) + } + + if result.GetString("status") != "ok" { + t.Error("expected status=ok") + } + }) + + t.Run("missing required flags", func(t *testing.T) { + result := h.Run("card-create", h.ProjectID, h.BoardID) + + if result.Success() { + t.Error("expected failure without required flags") + } + }) +} diff --git a/e2e/tests/comment_test.go b/e2e/tests/comment_test.go new file mode 100644 index 0000000..b474a18 --- /dev/null +++ b/e2e/tests/comment_test.go @@ -0,0 +1,67 @@ +package tests + +import ( + "fmt" + "testing" + "time" + + "github.com/rzolkos/basecamp-cli/e2e/harness" +) + +func TestCommentAdd(t *testing.T) { + h := harness.New(t) + + if h.TodolistID == "" { + t.Skip("BASECAMP_TEST_TODOLIST_ID not set") + } + + // First create a todo to comment on + todoContent := fmt.Sprintf("E2E Comment Test Todo %d", time.Now().UnixNano()) + createResult := h.Run("todo-create", h.ProjectID, h.TodolistID, "--content", todoContent) + + if !createResult.Success() { + t.Fatalf("failed to create test todo: %s", createResult.Stderr) + } + + todoID := fmt.Sprintf("%d", createResult.GetInt("id")) + if todoID == "0" { + t.Fatal("failed to get todo ID") + } + + t.Run("add comment to todo", func(t *testing.T) { + commentContent := fmt.Sprintf("E2E Test Comment %d", time.Now().UnixNano()) + result := h.Run("comment-add", h.ProjectID, todoID, "--content", commentContent) + + if !result.Success() { + t.Fatalf("expected success, got exit code %d\nstderr: %s", result.ExitCode, result.Stderr) + } + + if result.JSON == nil { + t.Fatalf("expected JSON object, got: %s", result.Stdout) + } + + if result.GetString("status") != "ok" { + t.Error("expected status=ok") + } + + if result.GetInt("id") == 0 { + t.Error("expected comment id in response") + } + + if result.GetString("recording_id") != todoID { + t.Errorf("expected recording_id=%s, got %s", todoID, result.GetString("recording_id")) + } + }) + + t.Run("missing content flag", func(t *testing.T) { + result := h.Run("comment-add", h.ProjectID, todoID) + + if result.Success() { + t.Error("expected failure without --content flag") + } + + if result.ErrorMessage() != "--content required" { + t.Errorf("expected '--content required' error, got: %s", result.ErrorMessage()) + } + }) +} diff --git a/e2e/tests/document_test.go b/e2e/tests/document_test.go new file mode 100644 index 0000000..27be94b --- /dev/null +++ b/e2e/tests/document_test.go @@ -0,0 +1,115 @@ +package tests + +import ( + "fmt" + "testing" + "time" + + "github.com/rzolkos/basecamp-cli/e2e/harness" +) + +func TestDocs(t *testing.T) { + h := harness.New(t) + + t.Run("list documents", func(t *testing.T) { + result := h.Run("docs", h.ProjectID) + + if !result.Success() { + t.Fatalf("expected success, got exit code %d\nstderr: %s", result.ExitCode, result.Stderr) + } + + if result.JSON == nil { + t.Fatalf("expected JSON object, got: %s", result.Stdout) + } + + if result.GetInt("project_id") == 0 { + t.Error("expected project_id in response") + } + if result.GetInt("vault_id") == 0 { + t.Error("expected vault_id in response") + } + }) +} + +func TestDocCRUD(t *testing.T) { + h := harness.New(t) + + var docID string + docTitle := fmt.Sprintf("E2E Test Document %d", time.Now().UnixNano()) + + t.Run("create document", func(t *testing.T) { + result := h.Run("doc-create", h.ProjectID, "--title", docTitle, "--content", "Test document content from e2e tests") + + if !result.Success() { + t.Fatalf("expected success, got exit code %d\nstderr: %s", result.ExitCode, result.Stderr) + } + + if result.JSON == nil { + t.Fatalf("expected JSON object, got: %s", result.Stdout) + } + + if result.GetString("status") != "ok" { + t.Error("expected status=ok") + } + + docID = fmt.Sprintf("%d", result.GetInt("id")) + if docID == "0" { + t.Fatal("expected document id in response") + } + }) + + t.Run("view document", func(t *testing.T) { + if docID == "" { + t.Skip("no document created") + } + + result := h.Run("doc", h.ProjectID, docID) + + if !result.Success() { + t.Fatalf("expected success, got exit code %d\nstderr: %s", result.ExitCode, result.Stderr) + } + + if result.JSON == nil { + t.Fatalf("expected JSON object, got: %s", result.Stdout) + } + + if result.GetInt("id") == 0 { + t.Error("expected id in response") + } + if result.GetString("title") == "" { + t.Error("expected title in response") + } + }) + + t.Run("view document with comments flag", func(t *testing.T) { + if docID == "" { + t.Skip("no document created") + } + + result := h.Run("doc", h.ProjectID, docID, "--comments") + + if !result.Success() { + t.Fatalf("expected success, got exit code %d\nstderr: %s", result.ExitCode, result.Stderr) + } + + if result.JSON == nil { + t.Fatalf("expected JSON object, got: %s", result.Stdout) + } + + if result.GetInt("id") == 0 { + t.Error("expected id in response") + } + }) + + t.Run("missing title flag", func(t *testing.T) { + result := h.Run("doc-create", h.ProjectID) + + if result.Success() { + t.Error("expected failure without --title flag") + } + + if result.ErrorMessage() != "--title required" { + t.Errorf("expected '--title required' error, got: %s", result.ErrorMessage()) + } + }) +} diff --git a/e2e/tests/events_test.go b/e2e/tests/events_test.go new file mode 100644 index 0000000..c4c949d --- /dev/null +++ b/e2e/tests/events_test.go @@ -0,0 +1,105 @@ +package tests + +import ( + "testing" + + "github.com/rzolkos/basecamp-cli/e2e/harness" +) + +func TestEvents(t *testing.T) { + h := harness.New(t) + + t.Run("list all events", func(t *testing.T) { + result := h.Run("events") + + // This endpoint may not be available in all accounts + if !result.Success() { + errMsg := result.ErrorMessage() + if errMsg != "" { + t.Skip("events endpoint not available") + } + t.Fatalf("expected success, got exit code %d\nstderr: %s", result.ExitCode, result.Stderr) + } + + if result.JSON == nil { + t.Fatalf("expected JSON object, got: %s", result.Stdout) + } + + // count should exist + if _, ok := result.JSON["count"]; !ok { + t.Error("expected count in response") + } + + // events array should exist + if _, ok := result.JSON["events"]; !ok { + t.Error("expected events array in response") + } + }) +} + +func TestEventsProject(t *testing.T) { + h := harness.New(t) + + t.Run("list project events", func(t *testing.T) { + result := h.Run("events-project", h.ProjectID) + + // This endpoint may not be available in all accounts + if !result.Success() { + errMsg := result.ErrorMessage() + if errMsg != "" { + t.Skip("project events endpoint not available") + } + t.Fatalf("expected success, got exit code %d\nstderr: %s", result.ExitCode, result.Stderr) + } + + if result.JSON == nil { + t.Fatalf("expected JSON object, got: %s", result.Stdout) + } + + if result.GetInt("project_id") == 0 { + t.Error("expected project_id in response") + } + + // events array should exist + if _, ok := result.JSON["events"]; !ok { + t.Error("expected events array in response") + } + }) +} + +func TestEventsRecording(t *testing.T) { + h := harness.New(t) + + t.Run("list recording events", func(t *testing.T) { + // Use the test card as the recording + result := h.Run("events-recording", h.ProjectID, h.CardID) + + if !result.Success() { + t.Fatalf("expected success, got exit code %d\nstderr: %s", result.ExitCode, result.Stderr) + } + + if result.JSON == nil { + t.Fatalf("expected JSON object, got: %s", result.Stdout) + } + + if result.GetInt("project_id") == 0 { + t.Error("expected project_id in response") + } + if result.GetInt("recording_id") == 0 { + t.Error("expected recording_id in response") + } + + // events array should exist + if _, ok := result.JSON["events"]; !ok { + t.Error("expected events array in response") + } + }) + + t.Run("missing recording_id", func(t *testing.T) { + result := h.Run("events-recording", h.ProjectID) + + if result.Success() { + t.Error("expected failure without recording_id") + } + }) +} diff --git a/e2e/tests/message_test.go b/e2e/tests/message_test.go new file mode 100644 index 0000000..45c2eed --- /dev/null +++ b/e2e/tests/message_test.go @@ -0,0 +1,103 @@ +package tests + +import ( + "fmt" + "testing" + "time" + + "github.com/rzolkos/basecamp-cli/e2e/harness" +) + +func TestMessages(t *testing.T) { + h := harness.New(t) + + t.Run("list messages", func(t *testing.T) { + result := h.Run("messages", h.ProjectID) + + if !result.Success() { + t.Fatalf("expected success, got exit code %d\nstderr: %s", result.ExitCode, result.Stderr) + } + + if result.JSON == nil { + t.Fatalf("expected JSON object, got: %s", result.Stdout) + } + + if result.GetInt("project_id") == 0 { + t.Error("expected project_id in response") + } + if result.GetInt("message_board_id") == 0 { + t.Error("expected message_board_id in response") + } + }) +} + +func TestMessageCRUD(t *testing.T) { + h := harness.New(t) + + var messageID string + messageSubject := fmt.Sprintf("E2E Test Message %d", time.Now().UnixNano()) + + t.Run("create message", func(t *testing.T) { + result := h.Run("message-create", h.ProjectID, "--subject", messageSubject, "--content", "Test message content from e2e tests") + + if !result.Success() { + t.Fatalf("expected success, got exit code %d\nstderr: %s", result.ExitCode, result.Stderr) + } + + if result.JSON == nil { + t.Fatalf("expected JSON object, got: %s", result.Stdout) + } + + if result.GetString("status") != "ok" { + t.Error("expected status=ok") + } + + messageID = fmt.Sprintf("%d", result.GetInt("id")) + if messageID == "0" { + t.Fatal("expected message id in response") + } + }) + + t.Run("view message", func(t *testing.T) { + if messageID == "" { + t.Skip("no message created") + } + + result := h.Run("message", h.ProjectID, messageID) + + if !result.Success() { + t.Fatalf("expected success, got exit code %d\nstderr: %s", result.ExitCode, result.Stderr) + } + + if result.JSON == nil { + t.Fatalf("expected JSON object, got: %s", result.Stdout) + } + + if result.GetInt("id") == 0 { + t.Error("expected id in response") + } + if result.GetString("subject") == "" { + t.Error("expected subject in response") + } + }) + + t.Run("view message with comments flag", func(t *testing.T) { + if messageID == "" { + t.Skip("no message created") + } + + result := h.Run("message", h.ProjectID, messageID, "--comments") + + if !result.Success() { + t.Fatalf("expected success, got exit code %d\nstderr: %s", result.ExitCode, result.Stderr) + } + + if result.JSON == nil { + t.Fatalf("expected JSON object, got: %s", result.Stdout) + } + + if result.GetInt("id") == 0 { + t.Error("expected id in response") + } + }) +} diff --git a/e2e/tests/message_type_test.go b/e2e/tests/message_type_test.go new file mode 100644 index 0000000..289cae3 --- /dev/null +++ b/e2e/tests/message_type_test.go @@ -0,0 +1,145 @@ +package tests + +import ( + "fmt" + "testing" + + "github.com/rzolkos/basecamp-cli/e2e/harness" +) + +func TestMessageTypes(t *testing.T) { + h := harness.New(t) + + t.Run("list message types", func(t *testing.T) { + result := h.Run("message-types", h.ProjectID) + + if !result.Success() { + t.Fatalf("expected success, got exit code %d\nstderr: %s", result.ExitCode, result.Stderr) + } + + if result.JSON == nil { + t.Fatalf("expected JSON object, got: %s", result.Stdout) + } + + if result.GetInt("project_id") == 0 { + t.Error("expected project_id in response") + } + + // message_types array should exist + if _, ok := result.JSON["message_types"]; !ok { + t.Error("expected message_types array in response") + } + }) +} + +func TestMessageTypeCRUD(t *testing.T) { + h := harness.New(t) + + var typeID string + + t.Run("create message type", func(t *testing.T) { + result := h.Run("message-type-create", h.ProjectID, "--name", "Test Type", "--icon", "๐Ÿงช") + + if !result.Success() { + t.Fatalf("expected success, got exit code %d\nstderr: %s", result.ExitCode, result.Stderr) + } + + if result.JSON == nil { + t.Fatalf("expected JSON object, got: %s", result.Stdout) + } + + if result.GetString("status") != "ok" { + t.Error("expected status ok") + } + if result.GetInt("id") == 0 { + t.Error("expected id in response") + } + if result.GetString("name") != "Test Type" { + t.Error("expected name to match") + } + + typeID = fmt.Sprintf("%d", result.GetInt("id")) + }) + + t.Run("view message type", func(t *testing.T) { + if typeID == "" { + t.Skip("no type created") + } + + result := h.Run("message-type", h.ProjectID, typeID) + + if !result.Success() { + t.Fatalf("expected success, got exit code %d\nstderr: %s", result.ExitCode, result.Stderr) + } + + if result.JSON == nil { + t.Fatalf("expected JSON object, got: %s", result.Stdout) + } + + if result.GetInt("id") == 0 { + t.Error("expected id in response") + } + if result.GetString("name") == "" { + t.Error("expected name in response") + } + }) + + t.Run("update message type", func(t *testing.T) { + if typeID == "" { + t.Skip("no type created") + } + + result := h.Run("message-type-update", h.ProjectID, typeID, "--name", "Updated Type", "--icon", "โœ…") + + if !result.Success() { + t.Fatalf("expected success, got exit code %d\nstderr: %s", result.ExitCode, result.Stderr) + } + + if result.JSON == nil { + t.Fatalf("expected JSON object, got: %s", result.Stdout) + } + + if result.GetString("status") != "ok" { + t.Error("expected status ok") + } + if result.GetString("name") != "Updated Type" { + t.Error("expected name to be updated") + } + }) + + t.Run("delete message type", func(t *testing.T) { + if typeID == "" { + t.Skip("no type created") + } + + result := h.Run("message-type-delete", h.ProjectID, typeID) + + if !result.Success() { + t.Fatalf("expected success, got exit code %d\nstderr: %s", result.ExitCode, result.Stderr) + } + + if result.JSON == nil { + t.Fatalf("expected JSON object, got: %s", result.Stdout) + } + + if result.GetString("status") != "ok" { + t.Error("expected status ok") + } + }) + + t.Run("missing name and icon for create", func(t *testing.T) { + result := h.Run("message-type-create", h.ProjectID) + + if result.Success() { + t.Error("expected failure without --name and --icon") + } + }) + + t.Run("missing type_id for view", func(t *testing.T) { + result := h.Run("message-type", h.ProjectID) + + if result.Success() { + t.Error("expected failure without type_id") + } + }) +} diff --git a/e2e/tests/people_test.go b/e2e/tests/people_test.go new file mode 100644 index 0000000..620625c --- /dev/null +++ b/e2e/tests/people_test.go @@ -0,0 +1,168 @@ +package tests + +import ( + "fmt" + "testing" + + "github.com/rzolkos/basecamp-cli/e2e/harness" +) + +func TestPeople(t *testing.T) { + h := harness.New(t) + + t.Run("list all people", func(t *testing.T) { + result := h.Run("people") + + if !result.Success() { + t.Fatalf("expected success, got exit code %d\nstderr: %s", result.ExitCode, result.Stderr) + } + + if result.JSON == nil { + t.Fatalf("expected JSON object, got: %s", result.Stdout) + } + + count := result.GetInt("count") + if count == 0 { + t.Error("expected at least one person") + } + + people, ok := result.JSON["people"].([]any) + if !ok { + t.Fatal("expected people array") + } + + if len(people) != count { + t.Errorf("expected %d people, got %d", count, len(people)) + } + + // Check first person has required fields + if len(people) > 0 { + first := people[0].(map[string]any) + if _, ok := first["id"]; !ok { + t.Error("expected id in person") + } + if _, ok := first["name"]; !ok { + t.Error("expected name in person") + } + } + }) +} + +func TestPeoplePingable(t *testing.T) { + h := harness.New(t) + + t.Run("list pingable people", func(t *testing.T) { + result := h.Run("people-pingable") + + if !result.Success() { + t.Fatalf("expected success, got exit code %d\nstderr: %s", result.ExitCode, result.Stderr) + } + + if result.JSON == nil { + t.Fatalf("expected JSON object, got: %s", result.Stdout) + } + + // Should have count and people array + if _, ok := result.JSON["count"]; !ok { + t.Error("expected count in response") + } + if _, ok := result.JSON["people"]; !ok { + t.Error("expected people array in response") + } + }) +} + +func TestPeopleProject(t *testing.T) { + h := harness.New(t) + + t.Run("list people on project", func(t *testing.T) { + result := h.Run("people-project", h.ProjectID) + + if !result.Success() { + t.Fatalf("expected success, got exit code %d\nstderr: %s", result.ExitCode, result.Stderr) + } + + if result.JSON == nil { + t.Fatalf("expected JSON object, got: %s", result.Stdout) + } + + if result.GetInt("project_id") == 0 { + t.Error("expected project_id in response") + } + + if _, ok := result.JSON["people"]; !ok { + t.Error("expected people array in response") + } + }) +} + +func TestMyProfile(t *testing.T) { + h := harness.New(t) + + t.Run("view my profile", func(t *testing.T) { + result := h.Run("my-profile") + + if !result.Success() { + t.Fatalf("expected success, got exit code %d\nstderr: %s", result.ExitCode, result.Stderr) + } + + if result.JSON == nil { + t.Fatalf("expected JSON object, got: %s", result.Stdout) + } + + if result.GetInt("id") == 0 { + t.Error("expected id in response") + } + if result.GetString("name") == "" { + t.Error("expected name in response") + } + if result.GetString("email") == "" { + t.Error("expected email in response") + } + }) +} + +func TestPerson(t *testing.T) { + h := harness.New(t) + + // First get a person ID from the people list + peopleResult := h.Run("people") + if !peopleResult.Success() { + t.Fatalf("failed to get people: %s", peopleResult.Stderr) + } + + people, ok := peopleResult.JSON["people"].([]any) + if !ok || len(people) == 0 { + t.Skip("no people found") + } + + firstPerson := people[0].(map[string]any) + personID := fmt.Sprintf("%.0f", firstPerson["id"].(float64)) + + t.Run("view person details", func(t *testing.T) { + result := h.Run("person", personID) + + if !result.Success() { + t.Fatalf("expected success, got exit code %d\nstderr: %s", result.ExitCode, result.Stderr) + } + + if result.JSON == nil { + t.Fatalf("expected JSON object, got: %s", result.Stdout) + } + + if result.GetInt("id") == 0 { + t.Error("expected id in response") + } + if result.GetString("name") == "" { + t.Error("expected name in response") + } + }) + + t.Run("missing person_id", func(t *testing.T) { + result := h.Run("person") + + if result.Success() { + t.Error("expected failure without person_id") + } + }) +} diff --git a/e2e/tests/questionnaire_test.go b/e2e/tests/questionnaire_test.go new file mode 100644 index 0000000..befe7f5 --- /dev/null +++ b/e2e/tests/questionnaire_test.go @@ -0,0 +1,262 @@ +package tests + +import ( + "fmt" + "testing" + + "github.com/rzolkos/basecamp-cli/e2e/harness" +) + +func TestQuestionnaire(t *testing.T) { + h := harness.New(t) + + t.Run("get questionnaire", func(t *testing.T) { + result := h.Run("questionnaire", h.ProjectID) + + if !result.Success() { + // Questionnaire may not exist in project - that's ok + errMsg := result.ErrorMessage() + if errMsg == "no questionnaire found in this project" { + t.Skip("no questionnaire in test project") + } + t.Fatalf("expected success, got exit code %d\nstderr: %s", result.ExitCode, result.Stderr) + } + + if result.JSON == nil { + t.Fatalf("expected JSON object, got: %s", result.Stdout) + } + + if result.GetInt("project_id") == 0 { + t.Error("expected project_id in response") + } + if result.GetInt("questionnaire_id") == 0 { + t.Error("expected questionnaire_id in response") + } + }) +} + +func TestQuestions(t *testing.T) { + h := harness.New(t) + + t.Run("list questions", func(t *testing.T) { + result := h.Run("questions", h.ProjectID) + + if !result.Success() { + // Questionnaire may not exist in project - that's ok + errMsg := result.ErrorMessage() + if errMsg == "no questionnaire found in this project" { + t.Skip("no questionnaire in test project") + } + t.Fatalf("expected success, got exit code %d\nstderr: %s", result.ExitCode, result.Stderr) + } + + if result.JSON == nil { + t.Fatalf("expected JSON object, got: %s", result.Stdout) + } + + if result.GetInt("project_id") == 0 { + t.Error("expected project_id in response") + } + if result.GetInt("questionnaire_id") == 0 { + t.Error("expected questionnaire_id in response") + } + + // Questions array should exist + if _, ok := result.JSON["questions"]; !ok { + t.Error("expected questions array in response") + } + }) +} + +func TestQuestion(t *testing.T) { + h := harness.New(t) + + // First get questions list + questionsResult := h.Run("questions", h.ProjectID) + if !questionsResult.Success() { + errMsg := questionsResult.ErrorMessage() + if errMsg == "no questionnaire found in this project" { + t.Skip("no questionnaire in test project") + } + t.Fatalf("failed to get questions: %s", questionsResult.Stderr) + } + + questions, ok := questionsResult.JSON["questions"].([]any) + if !ok || len(questions) == 0 { + t.Skip("no questions found") + } + + firstQuestion := questions[0].(map[string]any) + questionID := fmt.Sprintf("%.0f", firstQuestion["id"].(float64)) + + t.Run("view question", func(t *testing.T) { + result := h.Run("question", h.ProjectID, questionID) + + if !result.Success() { + t.Fatalf("expected success, got exit code %d\nstderr: %s", result.ExitCode, result.Stderr) + } + + if result.JSON == nil { + t.Fatalf("expected JSON object, got: %s", result.Stdout) + } + + if result.GetInt("id") == 0 { + t.Error("expected id in response") + } + if result.GetString("title") == "" { + t.Error("expected title in response") + } + }) + + t.Run("view question with comments", func(t *testing.T) { + result := h.Run("question", h.ProjectID, questionID, "--comments") + + if !result.Success() { + t.Fatalf("expected success, got exit code %d\nstderr: %s", result.ExitCode, result.Stderr) + } + + // Should have comments field (even if empty) + if result.JSON != nil { + // Comments may or may not be present based on whether there are any + if result.GetInt("id") == 0 { + t.Error("expected id in response") + } + } + }) + + t.Run("missing question_id", func(t *testing.T) { + result := h.Run("question", h.ProjectID) + + if result.Success() { + t.Error("expected failure without question_id") + } + }) +} + +func TestQuestionAnswers(t *testing.T) { + h := harness.New(t) + + // First get questions list + questionsResult := h.Run("questions", h.ProjectID) + if !questionsResult.Success() { + errMsg := questionsResult.ErrorMessage() + if errMsg == "no questionnaire found in this project" { + t.Skip("no questionnaire in test project") + } + t.Fatalf("failed to get questions: %s", questionsResult.Stderr) + } + + questions, ok := questionsResult.JSON["questions"].([]any) + if !ok || len(questions) == 0 { + t.Skip("no questions found") + } + + firstQuestion := questions[0].(map[string]any) + questionID := fmt.Sprintf("%.0f", firstQuestion["id"].(float64)) + + t.Run("list answers", func(t *testing.T) { + result := h.Run("question-answers", h.ProjectID, questionID) + + if !result.Success() { + t.Fatalf("expected success, got exit code %d\nstderr: %s", result.ExitCode, result.Stderr) + } + + if result.JSON == nil { + t.Fatalf("expected JSON object, got: %s", result.Stdout) + } + + if result.GetInt("question_id") == 0 { + t.Error("expected question_id in response") + } + + // Answers array should exist + if _, ok := result.JSON["answers"]; !ok { + t.Error("expected answers array in response") + } + }) + + t.Run("missing question_id", func(t *testing.T) { + result := h.Run("question-answers", h.ProjectID) + + if result.Success() { + t.Error("expected failure without question_id") + } + }) +} + +func TestQuestionAnswer(t *testing.T) { + h := harness.New(t) + + // First get questions list + questionsResult := h.Run("questions", h.ProjectID) + if !questionsResult.Success() { + errMsg := questionsResult.ErrorMessage() + if errMsg == "no questionnaire found in this project" { + t.Skip("no questionnaire in test project") + } + t.Fatalf("failed to get questions: %s", questionsResult.Stderr) + } + + questions, ok := questionsResult.JSON["questions"].([]any) + if !ok || len(questions) == 0 { + t.Skip("no questions found") + } + + firstQuestion := questions[0].(map[string]any) + questionID := fmt.Sprintf("%.0f", firstQuestion["id"].(float64)) + + // Get answers + answersResult := h.Run("question-answers", h.ProjectID, questionID) + if !answersResult.Success() { + t.Fatalf("failed to get answers: %s", answersResult.Stderr) + } + + answers, ok := answersResult.JSON["answers"].([]any) + if !ok || len(answers) == 0 { + t.Skip("no answers found") + } + + firstAnswer := answers[0].(map[string]any) + answerID := fmt.Sprintf("%.0f", firstAnswer["id"].(float64)) + + t.Run("view answer", func(t *testing.T) { + result := h.Run("question-answer", h.ProjectID, answerID) + + if !result.Success() { + t.Fatalf("expected success, got exit code %d\nstderr: %s", result.ExitCode, result.Stderr) + } + + if result.JSON == nil { + t.Fatalf("expected JSON object, got: %s", result.Stdout) + } + + if result.GetInt("id") == 0 { + t.Error("expected id in response") + } + if result.GetString("content") == "" { + t.Error("expected content in response") + } + }) + + t.Run("view answer with comments", func(t *testing.T) { + result := h.Run("question-answer", h.ProjectID, answerID, "--comments") + + if !result.Success() { + t.Fatalf("expected success, got exit code %d\nstderr: %s", result.ExitCode, result.Stderr) + } + + if result.JSON != nil { + if result.GetInt("id") == 0 { + t.Error("expected id in response") + } + } + }) + + t.Run("missing answer_id", func(t *testing.T) { + result := h.Run("question-answer", h.ProjectID) + + if result.Success() { + t.Error("expected failure without answer_id") + } + }) +} diff --git a/e2e/tests/recording_test.go b/e2e/tests/recording_test.go new file mode 100644 index 0000000..ccb1dc1 --- /dev/null +++ b/e2e/tests/recording_test.go @@ -0,0 +1,95 @@ +package tests + +import ( + "fmt" + "testing" + + "github.com/rzolkos/basecamp-cli/e2e/harness" +) + +func TestRecordingStatus(t *testing.T) { + h := harness.New(t) + + // Create a todo to use as a test recording + createResult := h.Run("todo-create", h.ProjectID, h.TodolistID, "--content", "Recording status test todo") + if !createResult.Success() { + t.Fatalf("failed to create todo: %s", createResult.Stderr) + } + + recordingID := fmt.Sprintf("%d", createResult.GetInt("id")) + + t.Run("archive recording", func(t *testing.T) { + result := h.Run("archive", h.ProjectID, recordingID) + + if !result.Success() { + t.Fatalf("expected success, got exit code %d\nstderr: %s", result.ExitCode, result.Stderr) + } + + if result.JSON == nil { + t.Fatalf("expected JSON object, got: %s", result.Stdout) + } + + if result.GetString("status") != "ok" { + t.Error("expected status ok") + } + if result.GetString("recording_id") != recordingID { + t.Error("expected recording_id to match") + } + }) + + t.Run("unarchive recording", func(t *testing.T) { + result := h.Run("unarchive", h.ProjectID, recordingID) + + if !result.Success() { + t.Fatalf("expected success, got exit code %d\nstderr: %s", result.ExitCode, result.Stderr) + } + + if result.JSON == nil { + t.Fatalf("expected JSON object, got: %s", result.Stdout) + } + + if result.GetString("status") != "ok" { + t.Error("expected status ok") + } + }) + + t.Run("trash recording", func(t *testing.T) { + result := h.Run("trash", h.ProjectID, recordingID) + + if !result.Success() { + t.Fatalf("expected success, got exit code %d\nstderr: %s", result.ExitCode, result.Stderr) + } + + if result.JSON == nil { + t.Fatalf("expected JSON object, got: %s", result.Stdout) + } + + if result.GetString("status") != "ok" { + t.Error("expected status ok") + } + }) + + t.Run("missing recording_id for archive", func(t *testing.T) { + result := h.Run("archive", h.ProjectID) + + if result.Success() { + t.Error("expected failure without recording_id") + } + }) + + t.Run("missing recording_id for unarchive", func(t *testing.T) { + result := h.Run("unarchive", h.ProjectID) + + if result.Success() { + t.Error("expected failure without recording_id") + } + }) + + t.Run("missing recording_id for trash", func(t *testing.T) { + result := h.Run("trash", h.ProjectID) + + if result.Success() { + t.Error("expected failure without recording_id") + } + }) +} diff --git a/e2e/tests/schedule_test.go b/e2e/tests/schedule_test.go new file mode 100644 index 0000000..97eea25 --- /dev/null +++ b/e2e/tests/schedule_test.go @@ -0,0 +1,120 @@ +package tests + +import ( + "fmt" + "testing" + "time" + + "github.com/rzolkos/basecamp-cli/e2e/harness" +) + +func TestSchedule(t *testing.T) { + h := harness.New(t) + + t.Run("list schedule entries", func(t *testing.T) { + result := h.Run("schedule", h.ProjectID) + + if !result.Success() { + t.Fatalf("expected success, got exit code %d\nstderr: %s", result.ExitCode, result.Stderr) + } + + if result.JSON == nil { + t.Fatalf("expected JSON object, got: %s", result.Stdout) + } + + if result.GetInt("project_id") == 0 { + t.Error("expected project_id in response") + } + if result.GetInt("schedule_id") == 0 { + t.Error("expected schedule_id in response") + } + }) +} + +func TestEventCRUD(t *testing.T) { + h := harness.New(t) + + var eventID string + eventSummary := fmt.Sprintf("E2E Test Event %d", time.Now().UnixNano()) + startsAt := time.Now().Add(24 * time.Hour).Format(time.RFC3339) + endsAt := time.Now().Add(25 * time.Hour).Format(time.RFC3339) + + t.Run("create event", func(t *testing.T) { + result := h.Run("event-create", h.ProjectID, + "--summary", eventSummary, + "--starts-at", startsAt, + "--ends-at", endsAt) + + if !result.Success() { + t.Fatalf("expected success, got exit code %d\nstderr: %s", result.ExitCode, result.Stderr) + } + + if result.JSON == nil { + t.Fatalf("expected JSON object, got: %s", result.Stdout) + } + + if result.GetString("status") != "ok" { + t.Error("expected status=ok") + } + + eventID = fmt.Sprintf("%d", result.GetInt("id")) + if eventID == "0" { + t.Fatal("expected event id in response") + } + }) + + t.Run("view event", func(t *testing.T) { + if eventID == "" { + t.Skip("no event created") + } + + result := h.Run("event", h.ProjectID, eventID) + + if !result.Success() { + t.Fatalf("expected success, got exit code %d\nstderr: %s", result.ExitCode, result.Stderr) + } + + if result.JSON == nil { + t.Fatalf("expected JSON object, got: %s", result.Stdout) + } + + if result.GetInt("id") == 0 { + t.Error("expected id in response") + } + if result.GetString("summary") == "" { + t.Error("expected summary in response") + } + }) + + t.Run("view event with comments flag", func(t *testing.T) { + if eventID == "" { + t.Skip("no event created") + } + + result := h.Run("event", h.ProjectID, eventID, "--comments") + + if !result.Success() { + t.Fatalf("expected success, got exit code %d\nstderr: %s", result.ExitCode, result.Stderr) + } + + if result.JSON == nil { + t.Fatalf("expected JSON object, got: %s", result.Stdout) + } + + if result.GetInt("id") == 0 { + t.Error("expected id in response") + } + }) + + t.Run("missing required flags", func(t *testing.T) { + result := h.Run("event-create", h.ProjectID) + + if result.Success() { + t.Error("expected failure without required flags") + } + + if result.ErrorMessage() != "--summary required" { + t.Errorf("expected '--summary required' error, got: %s", result.ErrorMessage()) + } + }) +} diff --git a/e2e/tests/search_test.go b/e2e/tests/search_test.go new file mode 100644 index 0000000..14e294f --- /dev/null +++ b/e2e/tests/search_test.go @@ -0,0 +1,74 @@ +package tests + +import ( + "testing" + + "github.com/rzolkos/basecamp-cli/e2e/harness" +) + +func TestSearch(t *testing.T) { + h := harness.New(t) + + t.Run("search with query", func(t *testing.T) { + result := h.Run("search", "test") + + if !result.Success() { + t.Fatalf("expected success, got exit code %d\nstderr: %s", result.ExitCode, result.Stderr) + } + + if result.JSON == nil { + t.Fatalf("expected JSON object, got: %s", result.Stdout) + } + + if result.GetString("query") != "test" { + t.Error("expected query in response") + } + }) + + t.Run("search with type filter", func(t *testing.T) { + result := h.Run("search", "test", "--type", "Todo") + + if !result.Success() { + t.Fatalf("expected success, got exit code %d\nstderr: %s", result.ExitCode, result.Stderr) + } + + if result.JSON == nil { + t.Fatalf("expected JSON object, got: %s", result.Stdout) + } + + // All results should be todos + results, ok := result.JSON["results"].([]any) + if ok { + for _, r := range results { + rMap := r.(map[string]any) + if rMap["type"] != "Todo" { + t.Errorf("expected all results to be Todo, got %s", rMap["type"]) + } + } + } + }) + + t.Run("search with project filter", func(t *testing.T) { + result := h.Run("search", "test", "--project", h.ProjectID) + + if !result.Success() { + t.Fatalf("expected success, got exit code %d\nstderr: %s", result.ExitCode, result.Stderr) + } + + if result.JSON == nil { + t.Fatalf("expected JSON object, got: %s", result.Stdout) + } + }) + + t.Run("missing query", func(t *testing.T) { + result := h.Run("search") + + if result.Success() { + t.Error("expected failure without query") + } + + if result.ErrorMessage() != "search query required" { + t.Errorf("expected 'search query required' error, got: %s", result.ErrorMessage()) + } + }) +} diff --git a/e2e/tests/step_test.go b/e2e/tests/step_test.go new file mode 100644 index 0000000..b8fc912 --- /dev/null +++ b/e2e/tests/step_test.go @@ -0,0 +1,168 @@ +package tests + +import ( + "fmt" + "testing" + "time" + + "github.com/rzolkos/basecamp-cli/e2e/harness" +) + +func TestStepCRUD(t *testing.T) { + h := harness.New(t) + + if h.CardID == "" { + t.Skip("BASECAMP_TEST_CARD_ID not set") + } + + var stepID string + stepTitle := fmt.Sprintf("E2E Test Step %d", time.Now().UnixNano()) + + t.Run("create step", func(t *testing.T) { + result := h.Run("step-create", h.ProjectID, h.CardID, "--title", stepTitle) + + if !result.Success() { + t.Fatalf("expected success, got exit code %d\nstderr: %s", result.ExitCode, result.Stderr) + } + + if result.JSON == nil { + t.Fatalf("expected JSON object, got: %s", result.Stdout) + } + + if result.GetString("status") != "ok" { + t.Error("expected status=ok") + } + + stepID = fmt.Sprintf("%d", result.GetInt("id")) + if stepID == "0" { + t.Fatal("expected step id in response") + } + }) + + t.Run("view card shows step", func(t *testing.T) { + if stepID == "" { + t.Skip("no step created") + } + + result := h.Run("card", h.ProjectID, h.CardID) + + if !result.Success() { + t.Fatalf("expected success, got exit code %d\nstderr: %s", result.ExitCode, result.Stderr) + } + + steps, ok := result.JSON["steps"].([]any) + if !ok || len(steps) == 0 { + t.Error("expected steps in card output") + } + }) + + t.Run("update step", func(t *testing.T) { + if stepID == "" { + t.Skip("no step created") + } + + newTitle := fmt.Sprintf("Updated Step %d", time.Now().UnixNano()) + result := h.Run("step-update", h.ProjectID, stepID, "--title", newTitle) + + if !result.Success() { + t.Fatalf("expected success, got exit code %d\nstderr: %s", result.ExitCode, result.Stderr) + } + + if result.GetString("status") != "ok" { + t.Error("expected status=ok") + } + }) + + t.Run("complete step", func(t *testing.T) { + if stepID == "" { + t.Skip("no step created") + } + + result := h.Run("step-complete", h.ProjectID, stepID) + + if !result.Success() { + t.Fatalf("expected success, got exit code %d\nstderr: %s", result.ExitCode, result.Stderr) + } + + if result.GetString("status") != "ok" { + t.Error("expected status=ok") + } + }) + + t.Run("uncomplete step", func(t *testing.T) { + if stepID == "" { + t.Skip("no step created") + } + + result := h.Run("step-uncomplete", h.ProjectID, stepID) + + if !result.Success() { + t.Fatalf("expected success, got exit code %d\nstderr: %s", result.ExitCode, result.Stderr) + } + + if result.GetString("status") != "ok" { + t.Error("expected status=ok") + } + }) + + t.Run("missing title flag", func(t *testing.T) { + result := h.Run("step-create", h.ProjectID, h.CardID) + + if result.Success() { + t.Error("expected failure without --title flag") + } + + if result.ErrorMessage() != "--title required" { + t.Errorf("expected '--title required' error, got: %s", result.ErrorMessage()) + } + }) +} + +func TestStepReposition(t *testing.T) { + h := harness.New(t) + + if h.CardID == "" { + t.Skip("BASECAMP_TEST_CARD_ID not set") + } + + // Create two steps to test repositioning + step1Title := fmt.Sprintf("E2E Reposition Step 1 %d", time.Now().UnixNano()) + step2Title := fmt.Sprintf("E2E Reposition Step 2 %d", time.Now().UnixNano()) + + result1 := h.Run("step-create", h.ProjectID, h.CardID, "--title", step1Title) + if !result1.Success() { + t.Fatalf("failed to create step 1: %s", result1.Stderr) + } + step1ID := fmt.Sprintf("%d", result1.GetInt("id")) + + result2 := h.Run("step-create", h.ProjectID, h.CardID, "--title", step2Title) + if !result2.Success() { + t.Fatalf("failed to create step 2: %s", result2.Stderr) + } + step2ID := fmt.Sprintf("%d", result2.GetInt("id")) + + t.Run("reposition step", func(t *testing.T) { + // Move step2 to position 0 (first) + result := h.Run("step-reposition", h.ProjectID, h.CardID, step2ID, "--position", "0") + + if !result.Success() { + t.Fatalf("expected success, got exit code %d\nstderr: %s", result.ExitCode, result.Stderr) + } + + if result.GetString("status") != "ok" { + t.Error("expected status=ok") + } + }) + + t.Run("missing position flag", func(t *testing.T) { + result := h.Run("step-reposition", h.ProjectID, h.CardID, step1ID) + + if result.Success() { + t.Error("expected failure without --position flag") + } + + if result.ErrorMessage() != "--position required" { + t.Errorf("expected '--position required' error, got: %s", result.ErrorMessage()) + } + }) +} diff --git a/e2e/tests/todo_test.go b/e2e/tests/todo_test.go new file mode 100644 index 0000000..6a3eed7 --- /dev/null +++ b/e2e/tests/todo_test.go @@ -0,0 +1,181 @@ +package tests + +import ( + "fmt" + "testing" + "time" + + "github.com/rzolkos/basecamp-cli/e2e/harness" +) + +func TestTodolists(t *testing.T) { + h := harness.New(t) + + t.Run("list todolists", func(t *testing.T) { + result := h.Run("todolists", h.ProjectID) + + if !result.Success() { + t.Fatalf("expected success, got exit code %d\nstderr: %s", result.ExitCode, result.Stderr) + } + + if result.JSON == nil { + t.Fatalf("expected JSON object, got: %s", result.Stdout) + } + + if result.GetInt("project_id") == 0 { + t.Error("expected project_id in response") + } + if result.GetInt("todoset_id") == 0 { + t.Error("expected todoset_id in response") + } + }) +} + +func TestTodos(t *testing.T) { + h := harness.New(t) + + if h.TodolistID == "" { + t.Skip("BASECAMP_TEST_TODOLIST_ID not set") + } + + t.Run("list todos", func(t *testing.T) { + result := h.Run("todos", h.ProjectID, h.TodolistID) + + if !result.Success() { + t.Fatalf("expected success, got exit code %d\nstderr: %s", result.ExitCode, result.Stderr) + } + + if result.JSON == nil { + t.Fatalf("expected JSON object, got: %s", result.Stdout) + } + + if result.GetInt("todolist_id") == 0 { + t.Error("expected todolist_id in response") + } + }) +} + +func TestTodoCRUD(t *testing.T) { + h := harness.New(t) + + if h.TodolistID == "" { + t.Skip("BASECAMP_TEST_TODOLIST_ID not set") + } + + var todoID string + todoContent := fmt.Sprintf("E2E Test Todo %d", time.Now().UnixNano()) + + t.Run("create todo", func(t *testing.T) { + result := h.Run("todo-create", h.ProjectID, h.TodolistID, "--content", todoContent) + + if !result.Success() { + t.Fatalf("expected success, got exit code %d\nstderr: %s", result.ExitCode, result.Stderr) + } + + if result.JSON == nil { + t.Fatalf("expected JSON object, got: %s", result.Stdout) + } + + if result.GetString("status") != "ok" { + t.Error("expected status=ok") + } + + todoID = fmt.Sprintf("%d", result.GetInt("id")) + if todoID == "0" { + t.Fatal("expected todo id in response") + } + }) + + t.Run("view todo", func(t *testing.T) { + if todoID == "" { + t.Skip("no todo created") + } + + result := h.Run("todo", h.ProjectID, todoID) + + if !result.Success() { + t.Fatalf("expected success, got exit code %d\nstderr: %s", result.ExitCode, result.Stderr) + } + + if result.JSON == nil { + t.Fatalf("expected JSON object, got: %s", result.Stdout) + } + + if result.GetInt("id") == 0 { + t.Error("expected id in response") + } + if result.GetString("content") == "" { + t.Error("expected content in response") + } + }) + + t.Run("complete todo", func(t *testing.T) { + if todoID == "" { + t.Skip("no todo created") + } + + result := h.Run("todo-complete", h.ProjectID, todoID) + + if !result.Success() { + t.Fatalf("expected success, got exit code %d\nstderr: %s", result.ExitCode, result.Stderr) + } + + if result.GetString("status") != "ok" { + t.Error("expected status=ok") + } + }) + + t.Run("verify todo is completed", func(t *testing.T) { + if todoID == "" { + t.Skip("no todo created") + } + + result := h.Run("todo", h.ProjectID, todoID) + + if !result.Success() { + t.Fatalf("expected success, got exit code %d\nstderr: %s", result.ExitCode, result.Stderr) + } + + completed, ok := result.JSON["completed"].(bool) + if !ok || !completed { + t.Error("expected todo to be completed") + } + }) + + t.Run("uncomplete todo", func(t *testing.T) { + if todoID == "" { + t.Skip("no todo created") + } + + result := h.Run("todo-uncomplete", h.ProjectID, todoID) + + if !result.Success() { + t.Fatalf("expected success, got exit code %d\nstderr: %s", result.ExitCode, result.Stderr) + } + + if result.GetString("status") != "ok" { + t.Error("expected status=ok") + } + }) + + t.Run("verify todo is uncompleted", func(t *testing.T) { + if todoID == "" { + t.Skip("no todo created") + } + + result := h.Run("todo", h.ProjectID, todoID) + + if !result.Success() { + t.Fatalf("expected success, got exit code %d\nstderr: %s", result.ExitCode, result.Stderr) + } + + completed, ok := result.JSON["completed"].(bool) + if !ok || completed { + t.Error("expected todo to be uncompleted") + } + }) + + // Note: Basecamp API doesn't have a delete endpoint for todos, + // they can only be trashed via the recordings API which is out of scope. + // The test todo will remain in the todolist. +} diff --git a/e2e/tests/todolist_group_test.go b/e2e/tests/todolist_group_test.go new file mode 100644 index 0000000..67c0d02 --- /dev/null +++ b/e2e/tests/todolist_group_test.go @@ -0,0 +1,168 @@ +package tests + +import ( + "fmt" + "testing" + + "github.com/rzolkos/basecamp-cli/e2e/harness" +) + +func TestTodolistGroups(t *testing.T) { + h := harness.New(t) + + t.Run("list todolist groups", func(t *testing.T) { + result := h.Run("todolist-groups", h.ProjectID, h.TodolistID) + + if !result.Success() { + t.Fatalf("expected success, got exit code %d\nstderr: %s", result.ExitCode, result.Stderr) + } + + if result.JSON == nil { + t.Fatalf("expected JSON object, got: %s", result.Stdout) + } + + if result.GetInt("project_id") == 0 { + t.Error("expected project_id in response") + } + if result.GetInt("todolist_id") == 0 { + t.Error("expected todolist_id in response") + } + + // Groups array should exist (may be empty) + if _, ok := result.JSON["groups"]; !ok { + t.Error("expected groups array in response") + } + }) + + t.Run("missing todolist_id", func(t *testing.T) { + result := h.Run("todolist-groups", h.ProjectID) + + if result.Success() { + t.Error("expected failure without todolist_id") + } + }) +} + +func TestTodolistGroupCRUD(t *testing.T) { + h := harness.New(t) + + var groupID string + + t.Run("create group", func(t *testing.T) { + result := h.Run("todolist-group-create", h.ProjectID, h.TodolistID, "--name", "Test Group") + + if !result.Success() { + t.Fatalf("expected success, got exit code %d\nstderr: %s", result.ExitCode, result.Stderr) + } + + if result.JSON == nil { + t.Fatalf("expected JSON object, got: %s", result.Stdout) + } + + if result.GetString("status") != "ok" { + t.Error("expected status ok") + } + if result.GetInt("id") == 0 { + t.Error("expected id in response") + } + if result.GetString("name") != "Test Group" { + t.Error("expected name to match") + } + + groupID = fmt.Sprintf("%d", result.GetInt("id")) + }) + + t.Run("view group", func(t *testing.T) { + if groupID == "" { + t.Skip("no group created") + } + + result := h.Run("todolist-group", h.ProjectID, groupID) + + if !result.Success() { + t.Fatalf("expected success, got exit code %d\nstderr: %s", result.ExitCode, result.Stderr) + } + + if result.JSON == nil { + t.Fatalf("expected JSON object, got: %s", result.Stdout) + } + + if result.GetInt("id") == 0 { + t.Error("expected id in response") + } + if result.GetString("name") == "" { + t.Error("expected name in response") + } + }) + + t.Run("missing todolist_id", func(t *testing.T) { + result := h.Run("todolist-group-create", h.ProjectID) + + if result.Success() { + t.Error("expected failure without todolist_id") + } + }) + + t.Run("missing name flag", func(t *testing.T) { + result := h.Run("todolist-group-create", h.ProjectID, h.TodolistID) + + if result.Success() { + t.Error("expected failure without --name") + } + }) + + t.Run("missing group_id", func(t *testing.T) { + result := h.Run("todolist-group", h.ProjectID) + + if result.Success() { + t.Error("expected failure without group_id") + } + }) +} + +func TestTodoReposition(t *testing.T) { + h := harness.New(t) + + // First create a todo to reposition + createResult := h.Run("todo-create", h.ProjectID, h.TodolistID, "--content", "Reposition test todo") + if !createResult.Success() { + t.Fatalf("failed to create todo: %s", createResult.Stderr) + } + + todoID := fmt.Sprintf("%d", createResult.GetInt("id")) + + t.Run("reposition todo", func(t *testing.T) { + result := h.Run("todo-reposition", h.ProjectID, todoID, "--position", "1") + + if !result.Success() { + t.Fatalf("expected success, got exit code %d\nstderr: %s", result.ExitCode, result.Stderr) + } + + if result.JSON == nil { + t.Fatalf("expected JSON object, got: %s", result.Stdout) + } + + if result.GetString("status") != "ok" { + t.Error("expected status ok") + } + if result.GetString("position") != "1" { + t.Error("expected position 1") + } + }) + + t.Run("missing position flag", func(t *testing.T) { + result := h.Run("todo-reposition", h.ProjectID, todoID) + + if result.Success() { + t.Error("expected failure without --position") + } + }) + + t.Run("missing todo_id", func(t *testing.T) { + result := h.Run("todo-reposition", h.ProjectID) + + if result.Success() { + t.Error("expected failure without todo_id") + } + }) +} diff --git a/e2e/tests/upload_test.go b/e2e/tests/upload_test.go new file mode 100644 index 0000000..b4e1852 --- /dev/null +++ b/e2e/tests/upload_test.go @@ -0,0 +1,103 @@ +package tests + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/rzolkos/basecamp-cli/e2e/harness" +) + +func TestUpload(t *testing.T) { + h := harness.New(t) + + // Create a temporary test file + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test-upload.txt") + if err := os.WriteFile(testFile, []byte("Test file content for upload"), 0644); err != nil { + t.Fatalf("failed to create test file: %v", err) + } + + t.Run("upload file", func(t *testing.T) { + result := h.Run("upload", testFile) + + if !result.Success() { + t.Fatalf("expected success, got exit code %d\nstderr: %s", result.ExitCode, result.Stderr) + } + + if result.JSON == nil { + t.Fatalf("expected JSON object, got: %s", result.Stdout) + } + + if result.GetString("status") != "ok" { + t.Error("expected status ok") + } + if result.GetString("attachable_sgid") == "" { + t.Error("expected attachable_sgid in response") + } + }) + + t.Run("missing file path", func(t *testing.T) { + result := h.Run("upload") + + if result.Success() { + t.Error("expected failure without file path") + } + }) + + t.Run("nonexistent file", func(t *testing.T) { + result := h.Run("upload", "/nonexistent/file.txt") + + if result.Success() { + t.Error("expected failure with nonexistent file") + } + }) +} + +func TestUploads(t *testing.T) { + h := harness.New(t) + + // Get vault ID from docs command + docsResult := h.Run("docs", h.ProjectID) + if !docsResult.Success() { + t.Fatalf("failed to get docs: %s", docsResult.Stderr) + } + + vaultID := docsResult.GetInt("vault_id") + if vaultID == 0 { + t.Skip("no vault found") + } + + t.Run("list uploads", func(t *testing.T) { + result := h.Run("uploads", h.ProjectID, fmt.Sprintf("%d", vaultID)) + + // Note: This test may fail if there are no uploads in the vault + // That's acceptable - we're testing the command structure + if result.JSON != nil { + if _, ok := result.JSON["uploads"]; !ok { + t.Error("expected uploads array in response") + } + } + }) + + t.Run("missing vault_id", func(t *testing.T) { + result := h.Run("uploads", h.ProjectID) + + if result.Success() { + t.Error("expected failure without vault_id") + } + }) +} + +func TestUploadView(t *testing.T) { + h := harness.New(t) + + t.Run("missing upload_id", func(t *testing.T) { + result := h.Run("upload-view", h.ProjectID) + + if result.Success() { + t.Error("expected failure without upload_id") + } + }) +} diff --git a/internal/client/client.go b/internal/client/client.go index 68289fe..6f4adec 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -58,6 +58,37 @@ func (c *Client) Put(path string, data any) (json.RawMessage, error) { return c.request(context.Background(), http.MethodPut, url, data) } +func (c *Client) Delete(path string) (json.RawMessage, error) { + url := c.resolveURL(path) + return c.request(context.Background(), http.MethodDelete, url, nil) +} + +// UploadFile uploads raw binary data to the given path +func (c *Client) UploadFile(path string, data []byte, contentType string, size int64) (json.RawMessage, error) { + url := c.resolveURL(path) + return c.uploadRequest(context.Background(), url, data, contentType, size) +} + +func (c *Client) uploadRequest(ctx context.Context, url string, data []byte, contentType string, size int64) (json.RawMessage, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(data)) + if err != nil { + return nil, err + } + + req.Header.Set("Authorization", "Bearer "+c.token) + req.Header.Set("User-Agent", UserAgent) + req.Header.Set("Content-Type", contentType) + req.Header.Set("Content-Length", fmt.Sprintf("%d", size)) + + resp, err := c.http.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + return c.handleResponse(resp) +} + // GetAll fetches all pages of a paginated endpoint and returns combined results func (c *Client) GetAll(path string) ([]json.RawMessage, error) { var results []json.RawMessage diff --git a/internal/commands/boards.go b/internal/commands/boards.go index d962402..273d19b 100644 --- a/internal/commands/boards.go +++ b/internal/commands/boards.go @@ -49,27 +49,13 @@ func (c *BoardsCmd) Run(args []string) error { return err } - // Get project details - projectData, err := cl.Get("/projects/" + projectID + ".json") + project, err := fetchProject(cl, projectID) if err != nil { return err } - var project ProjectDetail - if err := json.Unmarshal(projectData, &project); err != nil { - return err - } - - // Find card table dock - var cardTableURL string - for _, dock := range project.Dock { - if dock.Name == "kanban_board" { - cardTableURL = dock.URL - break - } - } - - if cardTableURL == "" { + cardTableURL, err := getDockURL(project, "kanban_board") + if err != nil { return PrintJSON(map[string]any{ "project_id": project.ID, "project_name": project.Name, diff --git a/internal/commands/campfires.go b/internal/commands/campfires.go new file mode 100644 index 0000000..655800c --- /dev/null +++ b/internal/commands/campfires.go @@ -0,0 +1,178 @@ +package commands + +import ( + "encoding/json" + "errors" + "strings" + + "github.com/rzolkos/basecamp-cli/internal/client" +) + +// fetchCampfire gets the campfire for a project +func fetchCampfire(cl *client.Client, projectID string) (ProjectDetail, Campfire, error) { + project, err := fetchProject(cl, projectID) + if err != nil { + return ProjectDetail{}, Campfire{}, err + } + + campfireURL, err := getDockURL(project, "chat") + if err != nil { + return ProjectDetail{}, Campfire{}, err + } + + campfireData, err := cl.Get(campfireURL) + if err != nil { + return ProjectDetail{}, Campfire{}, err + } + + var campfire Campfire + if err := json.Unmarshal(campfireData, &campfire); err != nil { + return ProjectDetail{}, Campfire{}, err + } + + return project, campfire, nil +} + +type CampfireCmd struct{} + +type Campfire struct { + ID int `json:"id"` + Title string `json:"title"` + LinesURL string `json:"lines_url"` +} + +type CampfireLine struct { + ID int `json:"id"` + Content string `json:"content"` + CreatedAt string `json:"created_at"` + Creator Creator `json:"creator"` +} + +type CampfireListOutput struct { + ProjectID int `json:"project_id"` + CampfireID int `json:"campfire_id"` + Lines []CampfireLineBrief `json:"lines"` +} + +type CampfireLineBrief struct { + ID int `json:"id"` + Content string `json:"content"` + Creator string `json:"creator"` + CreatedAt string `json:"created_at"` +} + +func (c *CampfireCmd) Run(args []string) error { + projectID, _, err := getProjectID(args) + if err != nil { + return err + } + + cl, err := client.New() + if err != nil { + return err + } + + project, campfire, err := fetchCampfire(cl, projectID) + if err != nil { + return err + } + + // Get lines + linesData, err := cl.Get(campfire.LinesURL) + if err != nil { + return err + } + + var lines []CampfireLine + if err := json.Unmarshal(linesData, &lines); err != nil { + return err + } + + output := CampfireListOutput{ + ProjectID: project.ID, + CampfireID: campfire.ID, + Lines: make([]CampfireLineBrief, len(lines)), + } + + for i, line := range lines { + output.Lines[i] = CampfireLineBrief{ + ID: line.ID, + Content: line.Content, + Creator: line.Creator.Name, + CreatedAt: line.CreatedAt, + } + } + + return PrintJSON(output) +} + +type CampfirePostCmd struct{} + +type CampfirePostOutput struct { + Status string `json:"status"` + ID int `json:"id"` + Content string `json:"content"` + Message string `json:"message"` +} + +func (c *CampfirePostCmd) Run(args []string) error { + projectID, remaining, err := getProjectID(args) + if err != nil { + return err + } + + // Parse --content flag + var content string + + for i := 0; i < len(remaining); i++ { + if remaining[i] == "--content" { + if i+1 < len(remaining) { + content = remaining[i+1] + i++ + } + } + } + + if content == "" { + return errors.New("--content required") + } + + cl, err := client.New() + if err != nil { + return err + } + + _, campfire, err := fetchCampfire(cl, projectID) + if err != nil { + return err + } + + // Post line + payload := map[string]string{ + "content": content, + } + + // POST to lines URL + linesURL := campfire.LinesURL + // Convert from full URL to path + if idx := strings.Index(linesURL, "/buckets/"); idx != -1 { + linesURL = linesURL[idx:] + } + + responseData, err := cl.Post(linesURL, payload) + if err != nil { + return err + } + + var created CampfireLine + if err := json.Unmarshal(responseData, &created); err != nil { + return err + } + + return PrintJSON(CampfirePostOutput{ + Status: "ok", + ID: created.ID, + Content: created.Content, + Message: "Message posted to campfire", + }) +} diff --git a/internal/commands/card.go b/internal/commands/card.go index 0575342..d32c0f5 100644 --- a/internal/commands/card.go +++ b/internal/commands/card.go @@ -28,6 +28,7 @@ type CardDetail struct { CommentsURL string `json:"comments_url"` Creator Creator `json:"creator"` Assignees []Assignee `json:"assignees"` + Steps []Step `json:"steps"` } type Comment struct { @@ -53,6 +54,7 @@ type CardDetailOutput struct { URL string `json:"url"` Assignees []string `json:"assignees,omitempty"` Description string `json:"description"` + Steps []StepOutput `json:"steps,omitempty"` Comments []CommentOutput `json:"comments,omitempty"` } @@ -108,29 +110,30 @@ func (c *CardCmd) Run(args []string) error { } } + if len(card.Steps) > 0 { + output.Steps = make([]StepOutput, len(card.Steps)) + for i, s := range card.Steps { + var assignees []string + for _, a := range s.Assignees { + assignees = append(assignees, a.Name) + } + output.Steps[i] = StepOutput{ + ID: s.ID, + Title: s.Title, + Completed: s.Completed, + DueOn: s.DueOn, + Position: s.Position, + Assignees: assignees, + } + } + } + if showComments && card.CommentsCount > 0 { - commentsData, err := cl.GetAll(card.CommentsURL) + comments, err := fetchComments(cl, card.CommentsURL) if err != nil { return err } - - output.Comments = make([]CommentOutput, len(commentsData)) - for i, commentJSON := range commentsData { - var comment Comment - if err := json.Unmarshal(commentJSON, &comment); err != nil { - return err - } - author := "Unknown" - if comment.Creator.Name != "" { - author = comment.Creator.Name - } - output.Comments[i] = CommentOutput{ - ID: comment.ID, - Author: author, - Content: stripHTML(comment.Content), - CreatedAt: comment.CreatedAt, - } - } + output.Comments = comments } return PrintJSON(output) diff --git a/internal/commands/cards.go b/internal/commands/cards.go index 3c69dab..e153df8 100644 --- a/internal/commands/cards.go +++ b/internal/commands/cards.go @@ -3,6 +3,7 @@ package commands import ( "encoding/json" "errors" + "fmt" "strings" "github.com/rzolkos/basecamp-cli/internal/client" @@ -13,7 +14,9 @@ var errBoardIDRequired = errors.New("usage: basecamp cards [project_id] ") + } + boardID := remaining[0] + + cl, err := client.New() + if err != nil { + return err + } + + // Get the card table + data, err := cl.Get("/buckets/" + projectID + "/card_tables/" + boardID + ".json") + if err != nil { + return err + } + + var cardTable CardTableDetail + if err := json.Unmarshal(data, &cardTable); err != nil { + return err + } + + output := ColumnsOutput{ + BoardID: cardTable.ID, + BoardTitle: cardTable.Title, + Columns: make([]ColumnOutputBrief, len(cardTable.Lists)), + } + + for i, col := range cardTable.Lists { + output.Columns[i] = ColumnOutputBrief{ + ID: col.ID, + Title: col.Title, + Color: col.Color, + CardsCount: col.CardsCount, + } + } + + return PrintJSON(output) +} + +// CardCreateCmd creates a new card in a column +type CardCreateCmd struct{} + +type CardCreateOutput struct { + Status string `json:"status"` + ID int `json:"id"` + Title string `json:"title"` + Message string `json:"message"` +} + +func (c *CardCreateCmd) Run(args []string) error { + projectID, remaining, err := getProjectID(args) + if err != nil { + return err + } + + // Parse board_id, column_id, and flags + var boardID, columnID, title, content, dueOn string + + for i := 0; i < len(remaining); i++ { + switch remaining[i] { + case "--column": + if i+1 < len(remaining) { + columnID = remaining[i+1] + i++ + } + case "--title": + if i+1 < len(remaining) { + title = remaining[i+1] + i++ + } + case "--content": + if i+1 < len(remaining) { + content = remaining[i+1] + i++ + } + case "--due": + if i+1 < len(remaining) { + dueOn = remaining[i+1] + i++ + } + default: + if boardID == "" { + boardID = remaining[i] + } + } + } + + if boardID == "" { + return errors.New("board_id required") + } + if columnID == "" { + return errors.New("--column required (column ID)") + } + if title == "" { + return errors.New("--title required") + } + + cl, err := client.New() + if err != nil { + return err + } + + // Create card + payload := map[string]any{ + "title": title, + } + if content != "" { + payload["content"] = content + } + if dueOn != "" { + payload["due_on"] = dueOn + } + + path := fmt.Sprintf("/buckets/%s/card_tables/lists/%s/cards.json", projectID, columnID) + responseData, err := cl.Post(path, payload) + if err != nil { + return err + } + + var created CardSummary + if err := json.Unmarshal(responseData, &created); err != nil { + return err + } + + return PrintJSON(CardCreateOutput{ + Status: "ok", + ID: created.ID, + Title: created.Title, + Message: fmt.Sprintf("Card '%s' created", created.Title), + }) +} + +// CardUpdateCmd updates an existing card +type CardUpdateCmd struct{} + +type CardUpdateOutput struct { + Status string `json:"status"` + ID int `json:"id"` + Title string `json:"title"` + Message string `json:"message"` +} + +func (c *CardUpdateCmd) Run(args []string) error { + projectID, remaining, err := getProjectID(args) + if err != nil { + return err + } + + // Parse card_id and flags + var cardID, title, content, dueOn string + + for i := 0; i < len(remaining); i++ { + switch remaining[i] { + case "--title": + if i+1 < len(remaining) { + title = remaining[i+1] + i++ + } + case "--content": + if i+1 < len(remaining) { + content = remaining[i+1] + i++ + } + case "--due": + if i+1 < len(remaining) { + dueOn = remaining[i+1] + i++ + } + default: + if cardID == "" { + cardID = remaining[i] + } + } + } + + if cardID == "" { + return errors.New("card_id required") + } + if title == "" && content == "" && dueOn == "" { + return errors.New("at least one of --title, --content, or --due required") + } + + cl, err := client.New() + if err != nil { + return err + } + + // Update card + payload := map[string]any{} + if title != "" { + payload["title"] = title + } + if content != "" { + payload["content"] = content + } + if dueOn != "" { + payload["due_on"] = dueOn + } + + path := fmt.Sprintf("/buckets/%s/card_tables/cards/%s.json", projectID, cardID) + responseData, err := cl.Put(path, payload) + if err != nil { + return err + } + + var updated CardSummary + if err := json.Unmarshal(responseData, &updated); err != nil { + return err + } + + return PrintJSON(CardUpdateOutput{ + Status: "ok", + ID: updated.ID, + Title: updated.Title, + Message: fmt.Sprintf("Card '%s' updated", updated.Title), + }) +} diff --git a/internal/commands/comments.go b/internal/commands/comments.go new file mode 100644 index 0000000..aa5febe --- /dev/null +++ b/internal/commands/comments.go @@ -0,0 +1,77 @@ +package commands + +import ( + "encoding/json" + "errors" + "fmt" + + "github.com/rzolkos/basecamp-cli/internal/client" +) + +type CommentAddCmd struct{} + +type CommentAddOutput struct { + Status string `json:"status"` + ID int `json:"id"` + RecordingID string `json:"recording_id"` + Message string `json:"message"` +} + +func (c *CommentAddCmd) Run(args []string) error { + projectID, remaining, err := getProjectID(args) + if err != nil { + return err + } + + // Parse recording_id and --content flag + var recordingID, content string + + for i := 0; i < len(remaining); i++ { + switch remaining[i] { + case "--content": + if i+1 < len(remaining) { + content = remaining[i+1] + i++ + } + default: + if recordingID == "" { + recordingID = remaining[i] + } + } + } + + if recordingID == "" { + return errors.New("recording_id required") + } + if content == "" { + return errors.New("--content required") + } + + cl, err := client.New() + if err != nil { + return err + } + + // Create comment + payload := map[string]string{ + "content": content, + } + + path := fmt.Sprintf("/buckets/%s/recordings/%s/comments.json", projectID, recordingID) + responseData, err := cl.Post(path, payload) + if err != nil { + return err + } + + var created Comment + if err := json.Unmarshal(responseData, &created); err != nil { + return err + } + + return PrintJSON(CommentAddOutput{ + Status: "ok", + ID: created.ID, + RecordingID: recordingID, + Message: fmt.Sprintf("Comment added to recording %s", recordingID), + }) +} diff --git a/internal/commands/documents.go b/internal/commands/documents.go new file mode 100644 index 0000000..f2abcfc --- /dev/null +++ b/internal/commands/documents.go @@ -0,0 +1,268 @@ +package commands + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/rzolkos/basecamp-cli/internal/client" +) + +// fetchVault gets the vault for a project +func fetchVault(cl *client.Client, projectID string) (ProjectDetail, Vault, error) { + project, err := fetchProject(cl, projectID) + if err != nil { + return ProjectDetail{}, Vault{}, err + } + + vaultURL, err := getDockURL(project, "vault") + if err != nil { + return ProjectDetail{}, Vault{}, err + } + + vaultData, err := cl.Get(vaultURL) + if err != nil { + return ProjectDetail{}, Vault{}, err + } + + var vault Vault + if err := json.Unmarshal(vaultData, &vault); err != nil { + return ProjectDetail{}, Vault{}, err + } + + return project, vault, nil +} + +type DocsCmd struct{} + +type Vault struct { + ID int `json:"id"` + Title string `json:"title"` + DocumentsURL string `json:"documents_url"` +} + +type Document struct { + ID int `json:"id"` + Title string `json:"title"` + Content string `json:"content"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + CommentsCount int `json:"comments_count"` + CommentsURL string `json:"comments_url"` + URL string `json:"app_url"` + Creator Creator `json:"creator"` +} + +type DocsListOutput struct { + ProjectID int `json:"project_id"` + VaultID int `json:"vault_id"` + Documents []DocOutputBrief `json:"documents"` +} + +type DocOutputBrief struct { + ID int `json:"id"` + Title string `json:"title"` + Creator string `json:"creator"` + UpdatedAt string `json:"updated_at"` +} + +func (c *DocsCmd) Run(args []string) error { + projectID, _, err := getProjectID(args) + if err != nil { + return err + } + + cl, err := client.New() + if err != nil { + return err + } + + project, vault, err := fetchVault(cl, projectID) + if err != nil { + return err + } + + // Get documents + docsData, err := cl.Get(vault.DocumentsURL) + if err != nil { + return err + } + + var documents []Document + if err := json.Unmarshal(docsData, &documents); err != nil { + return err + } + + output := DocsListOutput{ + ProjectID: project.ID, + VaultID: vault.ID, + Documents: make([]DocOutputBrief, len(documents)), + } + + for i, d := range documents { + output.Documents[i] = DocOutputBrief{ + ID: d.ID, + Title: d.Title, + Creator: d.Creator.Name, + UpdatedAt: d.UpdatedAt, + } + } + + return PrintJSON(output) +} + +type DocCmd struct{} + +type DocDetailOutput struct { + ID int `json:"id"` + Title string `json:"title"` + Content string `json:"content"` + Creator string `json:"creator"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + CommentsCount int `json:"comments_count"` + URL string `json:"url"` + Comments []CommentOutput `json:"comments,omitempty"` +} + +func (c *DocCmd) Run(args []string) error { + projectID, remaining, err := getProjectID(args) + if err != nil { + return err + } + + // Parse doc_id and flags + var docID string + showComments := false + + for i := 0; i < len(remaining); i++ { + if remaining[i] == "--comments" { + showComments = true + } else if docID == "" { + docID = remaining[i] + } + } + + if docID == "" { + return errors.New("document_id required") + } + + cl, err := client.New() + if err != nil { + return err + } + + // Get document directly + docData, err := cl.Get("/buckets/" + projectID + "/documents/" + docID + ".json") + if err != nil { + return err + } + + var doc Document + if err := json.Unmarshal(docData, &doc); err != nil { + return err + } + + output := DocDetailOutput{ + ID: doc.ID, + Title: doc.Title, + Content: stripHTML(doc.Content), + Creator: doc.Creator.Name, + CreatedAt: doc.CreatedAt, + UpdatedAt: doc.UpdatedAt, + CommentsCount: doc.CommentsCount, + URL: doc.URL, + } + + if showComments && doc.CommentsURL != "" { + comments, err := fetchComments(cl, doc.CommentsURL) + if err != nil { + return err + } + output.Comments = comments + } + + return PrintJSON(output) +} + +type DocCreateCmd struct{} + +type DocCreateOutput struct { + Status string `json:"status"` + ID int `json:"id"` + Title string `json:"title"` + Message string `json:"message"` +} + +func (c *DocCreateCmd) Run(args []string) error { + projectID, remaining, err := getProjectID(args) + if err != nil { + return err + } + + // Parse flags + var title, content string + + for i := 0; i < len(remaining); i++ { + switch remaining[i] { + case "--title": + if i+1 < len(remaining) { + title = remaining[i+1] + i++ + } + case "--content": + if i+1 < len(remaining) { + content = remaining[i+1] + i++ + } + } + } + + if title == "" { + return errors.New("--title required") + } + + cl, err := client.New() + if err != nil { + return err + } + + _, vault, err := fetchVault(cl, projectID) + if err != nil { + return err + } + + // Create document + payload := map[string]any{ + "title": title, + "status": "active", + } + if content != "" { + payload["content"] = content + } + + // POST to documents URL + docsURL := vault.DocumentsURL + // Convert from full URL to path + if idx := strings.Index(docsURL, "/buckets/"); idx != -1 { + docsURL = docsURL[idx:] + } + + responseData, err := cl.Post(docsURL, payload) + if err != nil { + return err + } + + var created Document + if err := json.Unmarshal(responseData, &created); err != nil { + return err + } + + return PrintJSON(DocCreateOutput{ + Status: "ok", + ID: created.ID, + Title: created.Title, + Message: fmt.Sprintf("Document '%s' created", created.Title), + }) +} diff --git a/internal/commands/events.go b/internal/commands/events.go new file mode 100644 index 0000000..1ea6419 --- /dev/null +++ b/internal/commands/events.go @@ -0,0 +1,206 @@ +package commands + +import ( + "encoding/json" + "errors" + "fmt" + + "github.com/rzolkos/basecamp-cli/internal/client" +) + +// Event represents a Basecamp activity event +type Event struct { + ID int `json:"id"` + Action string `json:"action"` + CreatedAt string `json:"created_at"` + RecordingType string `json:"recording_type"` + Creator Creator `json:"creator"` + Recording struct { + ID int `json:"id"` + Title string `json:"title"` + Type string `json:"type"` + } `json:"recording"` + Bucket struct { + ID int `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + } `json:"bucket"` +} + +// EventsCmd lists all events across all projects +type EventsCmd struct{} + +type EventsOutput struct { + Count int `json:"count"` + Events []EventBrief `json:"events"` +} + +type EventBrief struct { + ID int `json:"id"` + Action string `json:"action"` + RecordingType string `json:"recording_type"` + RecordingID int `json:"recording_id"` + RecordingTitle string `json:"recording_title"` + ProjectID int `json:"project_id"` + ProjectName string `json:"project_name"` + Creator string `json:"creator"` + CreatedAt string `json:"created_at"` +} + +func (c *EventsCmd) Run(args []string) error { + cl, err := client.New() + if err != nil { + return err + } + + data, err := cl.Get("/events.json") + if err != nil { + return err + } + + var events []Event + if err := json.Unmarshal(data, &events); err != nil { + return err + } + + output := EventsOutput{ + Count: len(events), + Events: make([]EventBrief, len(events)), + } + + for i, e := range events { + output.Events[i] = EventBrief{ + ID: e.ID, + Action: e.Action, + RecordingType: e.RecordingType, + RecordingID: e.Recording.ID, + RecordingTitle: e.Recording.Title, + ProjectID: e.Bucket.ID, + ProjectName: e.Bucket.Name, + Creator: e.Creator.Name, + CreatedAt: e.CreatedAt, + } + } + + return PrintJSON(output) +} + +// EventsProjectCmd lists events for a specific project +type EventsProjectCmd struct{} + +type EventsProjectOutput struct { + ProjectID int `json:"project_id"` + Count int `json:"count"` + Events []EventBrief `json:"events"` +} + +func (c *EventsProjectCmd) Run(args []string) error { + projectID, _, err := getProjectID(args) + if err != nil { + return err + } + + cl, err := client.New() + if err != nil { + return err + } + + data, err := cl.Get("/buckets/" + projectID + "/events.json") + if err != nil { + return err + } + + var events []Event + if err := json.Unmarshal(data, &events); err != nil { + return err + } + + var pID int + fmt.Sscanf(projectID, "%d", &pID) + + output := EventsProjectOutput{ + ProjectID: pID, + Count: len(events), + Events: make([]EventBrief, len(events)), + } + + for i, e := range events { + output.Events[i] = EventBrief{ + ID: e.ID, + Action: e.Action, + RecordingType: e.RecordingType, + RecordingID: e.Recording.ID, + RecordingTitle: e.Recording.Title, + ProjectID: e.Bucket.ID, + ProjectName: e.Bucket.Name, + Creator: e.Creator.Name, + CreatedAt: e.CreatedAt, + } + } + + return PrintJSON(output) +} + +// EventsRecordingCmd lists events for a specific recording +type EventsRecordingCmd struct{} + +type EventsRecordingOutput struct { + ProjectID int `json:"project_id"` + RecordingID int `json:"recording_id"` + Count int `json:"count"` + Events []EventBrief `json:"events"` +} + +func (c *EventsRecordingCmd) Run(args []string) error { + projectID, remaining, err := getProjectID(args) + if err != nil { + return err + } + + if len(remaining) < 1 { + return errors.New("recording_id required") + } + recordingID := remaining[0] + + cl, err := client.New() + if err != nil { + return err + } + + data, err := cl.Get("/buckets/" + projectID + "/recordings/" + recordingID + "/events.json") + if err != nil { + return err + } + + var events []Event + if err := json.Unmarshal(data, &events); err != nil { + return err + } + + var pID, rID int + fmt.Sscanf(projectID, "%d", &pID) + fmt.Sscanf(recordingID, "%d", &rID) + + output := EventsRecordingOutput{ + ProjectID: pID, + RecordingID: rID, + Count: len(events), + Events: make([]EventBrief, len(events)), + } + + for i, e := range events { + output.Events[i] = EventBrief{ + ID: e.ID, + Action: e.Action, + RecordingType: e.RecordingType, + RecordingID: e.Recording.ID, + RecordingTitle: e.Recording.Title, + ProjectID: e.Bucket.ID, + ProjectName: e.Bucket.Name, + Creator: e.Creator.Name, + CreatedAt: e.CreatedAt, + } + } + + return PrintJSON(output) +} diff --git a/internal/commands/helpers.go b/internal/commands/helpers.go new file mode 100644 index 0000000..dadce96 --- /dev/null +++ b/internal/commands/helpers.go @@ -0,0 +1,59 @@ +package commands + +import ( + "encoding/json" + "fmt" + + "github.com/rzolkos/basecamp-cli/internal/client" +) + +// getDockURL finds a dock item URL by name from a project +func getDockURL(project ProjectDetail, dockName string) (string, error) { + for _, dock := range project.Dock { + if dock.Name == dockName { + return dock.URL, nil + } + } + return "", fmt.Errorf("no %s found in this project", dockName) +} + +// fetchProject gets a project by ID and returns the parsed ProjectDetail +func fetchProject(cl *client.Client, projectID string) (ProjectDetail, error) { + data, err := cl.Get("/projects/" + projectID + ".json") + if err != nil { + return ProjectDetail{}, err + } + + var project ProjectDetail + if err := json.Unmarshal(data, &project); err != nil { + return ProjectDetail{}, err + } + return project, nil +} + +// fetchComments fetches and parses comments from a comments URL +func fetchComments(cl *client.Client, commentsURL string) ([]CommentOutput, error) { + commentsData, err := cl.GetAll(commentsURL) + if err != nil { + return nil, err + } + + comments := make([]CommentOutput, len(commentsData)) + for i, commentJSON := range commentsData { + var comment Comment + if err := json.Unmarshal(commentJSON, &comment); err != nil { + return nil, err + } + author := "Unknown" + if comment.Creator.Name != "" { + author = comment.Creator.Name + } + comments[i] = CommentOutput{ + ID: comment.ID, + Author: author, + Content: stripHTML(comment.Content), + CreatedAt: comment.CreatedAt, + } + } + return comments, nil +} diff --git a/internal/commands/message_types.go b/internal/commands/message_types.go new file mode 100644 index 0000000..380513f --- /dev/null +++ b/internal/commands/message_types.go @@ -0,0 +1,302 @@ +package commands + +import ( + "encoding/json" + "errors" + "fmt" + + "github.com/rzolkos/basecamp-cli/internal/client" +) + +// MessageType represents a message board category +type MessageType struct { + ID int `json:"id"` + Name string `json:"name"` + Icon string `json:"icon"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// MessageTypesCmd lists message types +type MessageTypesCmd struct{} + +type MessageTypesOutput struct { + ProjectID int `json:"project_id"` + MessageTypes []MessageTypeBrief `json:"message_types"` +} + +type MessageTypeBrief struct { + ID int `json:"id"` + Name string `json:"name"` + Icon string `json:"icon"` +} + +func (c *MessageTypesCmd) Run(args []string) error { + projectID, _, err := getProjectID(args) + if err != nil { + return err + } + + cl, err := client.New() + if err != nil { + return err + } + + data, err := cl.Get("/buckets/" + projectID + "/categories.json") + if err != nil { + return err + } + + var types []MessageType + if err := json.Unmarshal(data, &types); err != nil { + return err + } + + var pID int + fmt.Sscanf(projectID, "%d", &pID) + + output := MessageTypesOutput{ + ProjectID: pID, + MessageTypes: make([]MessageTypeBrief, len(types)), + } + + for i, t := range types { + output.MessageTypes[i] = MessageTypeBrief{ + ID: t.ID, + Name: t.Name, + Icon: t.Icon, + } + } + + return PrintJSON(output) +} + +// MessageTypeCmd views a single message type +type MessageTypeCmd struct{} + +type MessageTypeDetailOutput struct { + ID int `json:"id"` + Name string `json:"name"` + Icon string `json:"icon"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +func (c *MessageTypeCmd) Run(args []string) error { + projectID, remaining, err := getProjectID(args) + if err != nil { + return err + } + + if len(remaining) < 1 { + return errors.New("message_type_id required") + } + typeID := remaining[0] + + cl, err := client.New() + if err != nil { + return err + } + + data, err := cl.Get("/buckets/" + projectID + "/categories/" + typeID + ".json") + if err != nil { + return err + } + + var msgType MessageType + if err := json.Unmarshal(data, &msgType); err != nil { + return err + } + + return PrintJSON(MessageTypeDetailOutput{ + ID: msgType.ID, + Name: msgType.Name, + Icon: msgType.Icon, + CreatedAt: msgType.CreatedAt, + UpdatedAt: msgType.UpdatedAt, + }) +} + +// MessageTypeCreateCmd creates a new message type +type MessageTypeCreateCmd struct{} + +type MessageTypeCreateOutput struct { + Status string `json:"status"` + ID int `json:"id"` + Name string `json:"name"` + Icon string `json:"icon"` + Message string `json:"message"` +} + +func (c *MessageTypeCreateCmd) Run(args []string) error { + projectID, remaining, err := getProjectID(args) + if err != nil { + return err + } + + // Parse flags + var name, icon string + + for i := 0; i < len(remaining); i++ { + switch remaining[i] { + case "--name": + if i+1 < len(remaining) { + name = remaining[i+1] + i++ + } + case "--icon": + if i+1 < len(remaining) { + icon = remaining[i+1] + i++ + } + } + } + + if name == "" { + return errors.New("--name required") + } + if icon == "" { + return errors.New("--icon required (emoji)") + } + + cl, err := client.New() + if err != nil { + return err + } + + payload := map[string]any{ + "name": name, + "icon": icon, + } + + data, err := cl.Post("/buckets/"+projectID+"/categories.json", payload) + if err != nil { + return err + } + + var msgType MessageType + if err := json.Unmarshal(data, &msgType); err != nil { + return err + } + + return PrintJSON(MessageTypeCreateOutput{ + Status: "ok", + ID: msgType.ID, + Name: msgType.Name, + Icon: msgType.Icon, + Message: fmt.Sprintf("Message type '%s' created", msgType.Name), + }) +} + +// MessageTypeUpdateCmd updates a message type +type MessageTypeUpdateCmd struct{} + +type MessageTypeUpdateOutput struct { + Status string `json:"status"` + ID int `json:"id"` + Name string `json:"name"` + Icon string `json:"icon"` + Message string `json:"message"` +} + +func (c *MessageTypeUpdateCmd) Run(args []string) error { + projectID, remaining, err := getProjectID(args) + if err != nil { + return err + } + + if len(remaining) < 1 { + return errors.New("message_type_id required") + } + typeID := remaining[0] + + // Parse flags + var name, icon string + + for i := 1; i < len(remaining); i++ { + switch remaining[i] { + case "--name": + if i+1 < len(remaining) { + name = remaining[i+1] + i++ + } + case "--icon": + if i+1 < len(remaining) { + icon = remaining[i+1] + i++ + } + } + } + + if name == "" && icon == "" { + return errors.New("at least one of --name or --icon required") + } + + cl, err := client.New() + if err != nil { + return err + } + + payload := map[string]any{} + if name != "" { + payload["name"] = name + } + if icon != "" { + payload["icon"] = icon + } + + data, err := cl.Put("/buckets/"+projectID+"/categories/"+typeID+".json", payload) + if err != nil { + return err + } + + var msgType MessageType + if err := json.Unmarshal(data, &msgType); err != nil { + return err + } + + return PrintJSON(MessageTypeUpdateOutput{ + Status: "ok", + ID: msgType.ID, + Name: msgType.Name, + Icon: msgType.Icon, + Message: fmt.Sprintf("Message type '%s' updated", msgType.Name), + }) +} + +// MessageTypeDeleteCmd deletes a message type +type MessageTypeDeleteCmd struct{} + +type MessageTypeDeleteOutput struct { + Status string `json:"status"` + TypeID string `json:"type_id"` + Message string `json:"message"` +} + +func (c *MessageTypeDeleteCmd) Run(args []string) error { + projectID, remaining, err := getProjectID(args) + if err != nil { + return err + } + + if len(remaining) < 1 { + return errors.New("message_type_id required") + } + typeID := remaining[0] + + cl, err := client.New() + if err != nil { + return err + } + + _, err = cl.Delete("/buckets/" + projectID + "/categories/" + typeID + ".json") + if err != nil { + return err + } + + return PrintJSON(MessageTypeDeleteOutput{ + Status: "ok", + TypeID: typeID, + Message: "Message type deleted", + }) +} diff --git a/internal/commands/messages.go b/internal/commands/messages.go new file mode 100644 index 0000000..2c809a7 --- /dev/null +++ b/internal/commands/messages.go @@ -0,0 +1,274 @@ +package commands + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/rzolkos/basecamp-cli/internal/client" +) + +// fetchMessageBoard gets the message board for a project +func fetchMessageBoard(cl *client.Client, projectID string) (ProjectDetail, MessageBoard, error) { + project, err := fetchProject(cl, projectID) + if err != nil { + return ProjectDetail{}, MessageBoard{}, err + } + + messageBoardURL, err := getDockURL(project, "message_board") + if err != nil { + return ProjectDetail{}, MessageBoard{}, err + } + + boardData, err := cl.Get(messageBoardURL) + if err != nil { + return ProjectDetail{}, MessageBoard{}, err + } + + var board MessageBoard + if err := json.Unmarshal(boardData, &board); err != nil { + return ProjectDetail{}, MessageBoard{}, err + } + + return project, board, nil +} + +type MessagesCmd struct{} + +type MessageBoard struct { + ID int `json:"id"` + MessagesURL string `json:"messages_url"` +} + +type Message struct { + ID int `json:"id"` + Subject string `json:"subject"` + Content string `json:"content"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + CommentsCount int `json:"comments_count"` + CommentsURL string `json:"comments_url"` + URL string `json:"app_url"` + Creator Creator `json:"creator"` +} + +type MessageListOutput struct { + ProjectID int `json:"project_id"` + MessageBoardID int `json:"message_board_id"` + Messages []MessageOutputBrief `json:"messages"` +} + +type MessageOutputBrief struct { + ID int `json:"id"` + Subject string `json:"subject"` + Creator string `json:"creator"` + CreatedAt string `json:"created_at"` + CommentsCount int `json:"comments_count"` +} + +func (c *MessagesCmd) Run(args []string) error { + projectID, _, err := getProjectID(args) + if err != nil { + return err + } + + cl, err := client.New() + if err != nil { + return err + } + + project, board, err := fetchMessageBoard(cl, projectID) + if err != nil { + return err + } + + // Get messages + messagesData, err := cl.Get(board.MessagesURL) + if err != nil { + return err + } + + var messages []Message + if err := json.Unmarshal(messagesData, &messages); err != nil { + return err + } + + output := MessageListOutput{ + ProjectID: project.ID, + MessageBoardID: board.ID, + Messages: make([]MessageOutputBrief, len(messages)), + } + + for i, m := range messages { + output.Messages[i] = MessageOutputBrief{ + ID: m.ID, + Subject: m.Subject, + Creator: m.Creator.Name, + CreatedAt: m.CreatedAt, + CommentsCount: m.CommentsCount, + } + } + + return PrintJSON(output) +} + +type MessageCmd struct{} + +type MessageDetailOutput struct { + ID int `json:"id"` + Subject string `json:"subject"` + Content string `json:"content"` + Creator string `json:"creator"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + CommentsCount int `json:"comments_count"` + URL string `json:"url"` + Comments []CommentOutput `json:"comments,omitempty"` +} + +func (c *MessageCmd) Run(args []string) error { + projectID, remaining, err := getProjectID(args) + if err != nil { + return err + } + + // Parse message_id and flags + var messageID string + showComments := false + + for i := 0; i < len(remaining); i++ { + if remaining[i] == "--comments" { + showComments = true + } else if messageID == "" { + messageID = remaining[i] + } + } + + if messageID == "" { + return errors.New("message_id required") + } + + cl, err := client.New() + if err != nil { + return err + } + + // Get message directly + messageData, err := cl.Get("/buckets/" + projectID + "/messages/" + messageID + ".json") + if err != nil { + return err + } + + var message Message + if err := json.Unmarshal(messageData, &message); err != nil { + return err + } + + output := MessageDetailOutput{ + ID: message.ID, + Subject: message.Subject, + Content: stripHTML(message.Content), + Creator: message.Creator.Name, + CreatedAt: message.CreatedAt, + UpdatedAt: message.UpdatedAt, + CommentsCount: message.CommentsCount, + URL: message.URL, + } + + if showComments && message.CommentsURL != "" { + comments, err := fetchComments(cl, message.CommentsURL) + if err != nil { + return err + } + output.Comments = comments + } + + return PrintJSON(output) +} + +type MessageCreateCmd struct{} + +type MessageCreateOutput struct { + Status string `json:"status"` + ID int `json:"id"` + Subject string `json:"subject"` + Message string `json:"message"` +} + +func (c *MessageCreateCmd) Run(args []string) error { + projectID, remaining, err := getProjectID(args) + if err != nil { + return err + } + + // Parse flags + var subject, content string + + for i := 0; i < len(remaining); i++ { + switch remaining[i] { + case "--subject": + if i+1 < len(remaining) { + subject = remaining[i+1] + i++ + } + case "--content": + if i+1 < len(remaining) { + content = remaining[i+1] + i++ + } + } + } + + if subject == "" { + return errors.New("--subject required") + } + + cl, err := client.New() + if err != nil { + return err + } + + _, board, err := fetchMessageBoard(cl, projectID) + if err != nil { + return err + } + + // Create message + payload := map[string]any{ + "subject": subject, + "status": "active", + } + if content != "" { + payload["content"] = content + } + + payloadBytes, err := json.Marshal(payload) + if err != nil { + return err + } + + // POST to messages URL + messagesURL := board.MessagesURL + // Convert from full URL to path + if idx := strings.Index(messagesURL, "/buckets/"); idx != -1 { + messagesURL = messagesURL[idx:] + } + + responseData, err := cl.Post(messagesURL, payloadBytes) + if err != nil { + return err + } + + var created Message + if err := json.Unmarshal(responseData, &created); err != nil { + return err + } + + return PrintJSON(MessageCreateOutput{ + Status: "ok", + ID: created.ID, + Subject: created.Subject, + Message: fmt.Sprintf("Message '%s' created", created.Subject), + }) +} diff --git a/internal/commands/people.go b/internal/commands/people.go new file mode 100644 index 0000000..e267b36 --- /dev/null +++ b/internal/commands/people.go @@ -0,0 +1,378 @@ +package commands + +import ( + "encoding/json" + "errors" + "fmt" + + "github.com/rzolkos/basecamp-cli/internal/client" +) + +// Person represents a Basecamp user +type Person struct { + ID int `json:"id"` + Name string `json:"name"` + EmailAddress string `json:"email_address"` + Title string `json:"title"` + Bio string `json:"bio"` + Location string `json:"location"` + Admin bool `json:"admin"` + Owner bool `json:"owner"` + Client bool `json:"client"` + Employee bool `json:"employee"` + TimeZone string `json:"time_zone"` + AvatarURL string `json:"avatar_url"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Company Company `json:"company"` +} + +type Company struct { + ID int `json:"id"` + Name string `json:"name"` +} + +// PersonOutput is the brief output for person listings +type PersonOutput struct { + ID int `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + Title string `json:"title,omitempty"` + Admin bool `json:"admin"` + Owner bool `json:"owner"` + Company string `json:"company,omitempty"` + Location string `json:"location,omitempty"` +} + +// PeopleCmd lists all people +type PeopleCmd struct{} + +type PeopleOutput struct { + Count int `json:"count"` + People []PersonOutput `json:"people"` +} + +func (c *PeopleCmd) Run(args []string) error { + cl, err := client.New() + if err != nil { + return err + } + + data, err := cl.Get("/people.json") + if err != nil { + return err + } + + var people []Person + if err := json.Unmarshal(data, &people); err != nil { + return err + } + + output := PeopleOutput{ + Count: len(people), + People: make([]PersonOutput, len(people)), + } + + for i, p := range people { + output.People[i] = personToOutput(p) + } + + return PrintJSON(output) +} + +// PersonCmd views a single person +type PersonCmd struct{} + +type PersonDetailOutput struct { + ID int `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + Title string `json:"title,omitempty"` + Bio string `json:"bio,omitempty"` + Location string `json:"location,omitempty"` + Admin bool `json:"admin"` + Owner bool `json:"owner"` + Client bool `json:"client"` + Employee bool `json:"employee"` + TimeZone string `json:"time_zone"` + Company string `json:"company,omitempty"` + AvatarURL string `json:"avatar_url,omitempty"` + CreatedAt string `json:"created_at"` +} + +func (c *PersonCmd) Run(args []string) error { + if len(args) < 1 { + return errors.New("usage: basecamp person ") + } + personID := args[0] + + cl, err := client.New() + if err != nil { + return err + } + + data, err := cl.Get("/people/" + personID + ".json") + if err != nil { + return err + } + + var person Person + if err := json.Unmarshal(data, &person); err != nil { + return err + } + + return PrintJSON(PersonDetailOutput{ + ID: person.ID, + Name: person.Name, + Email: person.EmailAddress, + Title: person.Title, + Bio: person.Bio, + Location: person.Location, + Admin: person.Admin, + Owner: person.Owner, + Client: person.Client, + Employee: person.Employee, + TimeZone: person.TimeZone, + Company: person.Company.Name, + AvatarURL: person.AvatarURL, + CreatedAt: person.CreatedAt, + }) +} + +// PeoplePingableCmd lists pingable people +type PeoplePingableCmd struct{} + +func (c *PeoplePingableCmd) Run(args []string) error { + cl, err := client.New() + if err != nil { + return err + } + + data, err := cl.Get("/circles/people.json") + if err != nil { + return err + } + + var people []Person + if err := json.Unmarshal(data, &people); err != nil { + return err + } + + output := PeopleOutput{ + Count: len(people), + People: make([]PersonOutput, len(people)), + } + + for i, p := range people { + output.People[i] = personToOutput(p) + } + + return PrintJSON(output) +} + +// PeopleProjectCmd lists people on a project +type PeopleProjectCmd struct{} + +type PeopleProjectOutput struct { + ProjectID int `json:"project_id"` + Count int `json:"count"` + People []PersonOutput `json:"people"` +} + +func (c *PeopleProjectCmd) Run(args []string) error { + projectID, _, err := getProjectID(args) + if err != nil { + return err + } + + cl, err := client.New() + if err != nil { + return err + } + + data, err := cl.Get("/projects/" + projectID + "/people.json") + if err != nil { + return err + } + + var people []Person + if err := json.Unmarshal(data, &people); err != nil { + return err + } + + output := PeopleProjectOutput{ + ProjectID: 0, + Count: len(people), + People: make([]PersonOutput, len(people)), + } + + // Parse project ID for output + fmt.Sscanf(projectID, "%d", &output.ProjectID) + + for i, p := range people { + output.People[i] = personToOutput(p) + } + + return PrintJSON(output) +} + +// MyProfileCmd shows the current user's profile +type MyProfileCmd struct{} + +func (c *MyProfileCmd) Run(args []string) error { + cl, err := client.New() + if err != nil { + return err + } + + data, err := cl.Get("/my/profile.json") + if err != nil { + return err + } + + var person Person + if err := json.Unmarshal(data, &person); err != nil { + return err + } + + return PrintJSON(PersonDetailOutput{ + ID: person.ID, + Name: person.Name, + Email: person.EmailAddress, + Title: person.Title, + Bio: person.Bio, + Location: person.Location, + Admin: person.Admin, + Owner: person.Owner, + Client: person.Client, + Employee: person.Employee, + TimeZone: person.TimeZone, + Company: person.Company.Name, + AvatarURL: person.AvatarURL, + CreatedAt: person.CreatedAt, + }) +} + +// ProjectAccessCmd updates who can access a project +type ProjectAccessCmd struct{} + +type ProjectAccessOutput struct { + Status string `json:"status"` + Granted []string `json:"granted,omitempty"` + Revoked []string `json:"revoked,omitempty"` + Message string `json:"message"` +} + +func (c *ProjectAccessCmd) Run(args []string) error { + projectID, remaining, err := getProjectID(args) + if err != nil { + return err + } + + // Parse --grant and --revoke flags + var grant, revoke string + + for i := 0; i < len(remaining); i++ { + switch remaining[i] { + case "--grant": + if i+1 < len(remaining) { + grant = remaining[i+1] + i++ + } + case "--revoke": + if i+1 < len(remaining) { + revoke = remaining[i+1] + i++ + } + } + } + + if grant == "" && revoke == "" { + return errors.New("at least one of --grant or --revoke required (comma-separated person IDs)") + } + + cl, err := client.New() + if err != nil { + return err + } + + payload := map[string]any{} + if grant != "" { + payload["grant"] = parseIDList(grant) + } + if revoke != "" { + payload["revoke"] = parseIDList(revoke) + } + + data, err := cl.Put("/projects/"+projectID+"/people/users.json", payload) + if err != nil { + return err + } + + var result struct { + Granted []Person `json:"granted"` + Revoked []Person `json:"revoked"` + } + if err := json.Unmarshal(data, &result); err != nil { + return err + } + + output := ProjectAccessOutput{ + Status: "ok", + Message: "Project access updated", + } + + for _, p := range result.Granted { + output.Granted = append(output.Granted, p.Name) + } + for _, p := range result.Revoked { + output.Revoked = append(output.Revoked, p.Name) + } + + return PrintJSON(output) +} + +// Helper to convert Person to PersonOutput +func personToOutput(p Person) PersonOutput { + return PersonOutput{ + ID: p.ID, + Name: p.Name, + Email: p.EmailAddress, + Title: p.Title, + Admin: p.Admin, + Owner: p.Owner, + Company: p.Company.Name, + Location: p.Location, + } +} + +// Helper to parse comma-separated ID list to int slice +func parseIDList(s string) []int { + var ids []int + for _, part := range splitComma(s) { + var id int + if _, err := fmt.Sscanf(part, "%d", &id); err == nil { + ids = append(ids, id) + } + } + return ids +} + +func splitComma(s string) []string { + var result []string + current := "" + for _, c := range s { + if c == ',' { + if current != "" { + result = append(result, current) + current = "" + } + } else if c != ' ' { + current += string(c) + } + } + if current != "" { + result = append(result, current) + } + return result +} diff --git a/internal/commands/questionnaires.go b/internal/commands/questionnaires.go new file mode 100644 index 0000000..e5ed421 --- /dev/null +++ b/internal/commands/questionnaires.go @@ -0,0 +1,387 @@ +package commands + +import ( + "encoding/json" + "errors" + "fmt" + + "github.com/rzolkos/basecamp-cli/internal/client" +) + +// Questionnaire represents a Basecamp questionnaire (automatic check-ins) +type Questionnaire struct { + ID int `json:"id"` + Title string `json:"title"` + QuestionsURL string `json:"questions_url"` +} + +// Question represents a check-in question +type Question struct { + ID int `json:"id"` + Title string `json:"title"` + Schedule string `json:"schedule"` + Paused bool `json:"paused"` + AnswersURL string `json:"answers_url"` + CommentsCount int `json:"comments_count"` + CommentsURL string `json:"comments_url"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Creator Creator `json:"creator"` + URL string `json:"app_url"` +} + +// QuestionAnswer represents an answer to a question +type QuestionAnswer struct { + ID int `json:"id"` + Content string `json:"content"` + GroupOn string `json:"group_on"` + CommentsCount int `json:"comments_count"` + CommentsURL string `json:"comments_url"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Creator Creator `json:"creator"` + URL string `json:"app_url"` +} + +// fetchQuestionnaire gets the questionnaire for a project +func fetchQuestionnaire(cl *client.Client, projectID string) (ProjectDetail, Questionnaire, error) { + project, err := fetchProject(cl, projectID) + if err != nil { + return ProjectDetail{}, Questionnaire{}, err + } + + questionnaireURL, err := getDockURL(project, "questionnaire") + if err != nil { + return ProjectDetail{}, Questionnaire{}, err + } + + data, err := cl.Get(questionnaireURL) + if err != nil { + return ProjectDetail{}, Questionnaire{}, err + } + + var questionnaire Questionnaire + if err := json.Unmarshal(data, &questionnaire); err != nil { + return ProjectDetail{}, Questionnaire{}, err + } + + return project, questionnaire, nil +} + +// QuestionnaireCmd gets the questionnaire for a project +type QuestionnaireCmd struct{} + +type QuestionnaireOutput struct { + ProjectID int `json:"project_id"` + ProjectName string `json:"project_name"` + QuestionnaireID int `json:"questionnaire_id"` + Title string `json:"title"` +} + +func (c *QuestionnaireCmd) Run(args []string) error { + projectID, _, err := getProjectID(args) + if err != nil { + return err + } + + cl, err := client.New() + if err != nil { + return err + } + + project, questionnaire, err := fetchQuestionnaire(cl, projectID) + if err != nil { + return err + } + + return PrintJSON(QuestionnaireOutput{ + ProjectID: project.ID, + ProjectName: project.Name, + QuestionnaireID: questionnaire.ID, + Title: questionnaire.Title, + }) +} + +// QuestionsCmd lists questions in a questionnaire +type QuestionsCmd struct{} + +type QuestionsOutput struct { + ProjectID int `json:"project_id"` + QuestionnaireID int `json:"questionnaire_id"` + Questions []QuestionBrief `json:"questions"` +} + +type QuestionBrief struct { + ID int `json:"id"` + Title string `json:"title"` + Schedule string `json:"schedule"` + Paused bool `json:"paused"` +} + +func (c *QuestionsCmd) Run(args []string) error { + projectID, _, err := getProjectID(args) + if err != nil { + return err + } + + cl, err := client.New() + if err != nil { + return err + } + + project, questionnaire, err := fetchQuestionnaire(cl, projectID) + if err != nil { + return err + } + + data, err := cl.Get(questionnaire.QuestionsURL) + if err != nil { + return err + } + + var questions []Question + if err := json.Unmarshal(data, &questions); err != nil { + return err + } + + output := QuestionsOutput{ + ProjectID: project.ID, + QuestionnaireID: questionnaire.ID, + Questions: make([]QuestionBrief, len(questions)), + } + + for i, q := range questions { + output.Questions[i] = QuestionBrief{ + ID: q.ID, + Title: q.Title, + Schedule: q.Schedule, + Paused: q.Paused, + } + } + + return PrintJSON(output) +} + +// QuestionCmd views a single question +type QuestionCmd struct{} + +type QuestionDetailOutput struct { + ID int `json:"id"` + Title string `json:"title"` + Schedule string `json:"schedule"` + Paused bool `json:"paused"` + Creator string `json:"creator"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + CommentsCount int `json:"comments_count"` + URL string `json:"url"` + Comments []CommentOutput `json:"comments,omitempty"` +} + +func (c *QuestionCmd) Run(args []string) error { + projectID, remaining, err := getProjectID(args) + if err != nil { + return err + } + + // Parse question_id and flags + var questionID string + showComments := false + + for i := 0; i < len(remaining); i++ { + if remaining[i] == "--comments" { + showComments = true + } else if questionID == "" { + questionID = remaining[i] + } + } + + if questionID == "" { + return errors.New("question_id required") + } + + cl, err := client.New() + if err != nil { + return err + } + + data, err := cl.Get("/buckets/" + projectID + "/questions/" + questionID + ".json") + if err != nil { + return err + } + + var question Question + if err := json.Unmarshal(data, &question); err != nil { + return err + } + + output := QuestionDetailOutput{ + ID: question.ID, + Title: question.Title, + Schedule: question.Schedule, + Paused: question.Paused, + Creator: question.Creator.Name, + CreatedAt: question.CreatedAt, + UpdatedAt: question.UpdatedAt, + CommentsCount: question.CommentsCount, + URL: question.URL, + } + + if showComments && question.CommentsURL != "" { + comments, err := fetchComments(cl, question.CommentsURL) + if err != nil { + return err + } + output.Comments = comments + } + + return PrintJSON(output) +} + +// QuestionAnswersCmd lists answers to a question +type QuestionAnswersCmd struct{} + +type QuestionAnswersOutput struct { + QuestionID int `json:"question_id"` + Answers []QuestionAnswerBrief `json:"answers"` +} + +type QuestionAnswerBrief struct { + ID int `json:"id"` + Content string `json:"content"` + GroupOn string `json:"group_on"` + Creator string `json:"creator"` + CreatedAt string `json:"created_at"` +} + +func (c *QuestionAnswersCmd) Run(args []string) error { + projectID, remaining, err := getProjectID(args) + if err != nil { + return err + } + + if len(remaining) < 1 { + return errors.New("question_id required") + } + questionID := remaining[0] + + cl, err := client.New() + if err != nil { + return err + } + + // First get the question to get its answers URL + questionData, err := cl.Get("/buckets/" + projectID + "/questions/" + questionID + ".json") + if err != nil { + return err + } + + var question Question + if err := json.Unmarshal(questionData, &question); err != nil { + return err + } + + // Fetch answers + answersData, err := cl.GetAll(question.AnswersURL) + if err != nil { + return err + } + + answers := make([]QuestionAnswerBrief, len(answersData)) + for i, answerJSON := range answersData { + var answer QuestionAnswer + if err := json.Unmarshal(answerJSON, &answer); err != nil { + return err + } + answers[i] = QuestionAnswerBrief{ + ID: answer.ID, + Content: stripHTML(answer.Content), + GroupOn: answer.GroupOn, + Creator: answer.Creator.Name, + CreatedAt: answer.CreatedAt, + } + } + + // Parse question ID for output + var qID int + fmt.Sscanf(questionID, "%d", &qID) + + return PrintJSON(QuestionAnswersOutput{ + QuestionID: qID, + Answers: answers, + }) +} + +// QuestionAnswerCmd views a single answer +type QuestionAnswerCmd struct{} + +type QuestionAnswerDetailOutput struct { + ID int `json:"id"` + Content string `json:"content"` + GroupOn string `json:"group_on"` + Creator string `json:"creator"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + CommentsCount int `json:"comments_count"` + URL string `json:"url"` + Comments []CommentOutput `json:"comments,omitempty"` +} + +func (c *QuestionAnswerCmd) Run(args []string) error { + projectID, remaining, err := getProjectID(args) + if err != nil { + return err + } + + // Parse answer_id and flags + var answerID string + showComments := false + + for i := 0; i < len(remaining); i++ { + if remaining[i] == "--comments" { + showComments = true + } else if answerID == "" { + answerID = remaining[i] + } + } + + if answerID == "" { + return errors.New("answer_id required") + } + + cl, err := client.New() + if err != nil { + return err + } + + data, err := cl.Get("/buckets/" + projectID + "/question_answers/" + answerID + ".json") + if err != nil { + return err + } + + var answer QuestionAnswer + if err := json.Unmarshal(data, &answer); err != nil { + return err + } + + output := QuestionAnswerDetailOutput{ + ID: answer.ID, + Content: stripHTML(answer.Content), + GroupOn: answer.GroupOn, + Creator: answer.Creator.Name, + CreatedAt: answer.CreatedAt, + UpdatedAt: answer.UpdatedAt, + CommentsCount: answer.CommentsCount, + URL: answer.URL, + } + + if showComments && answer.CommentsURL != "" { + comments, err := fetchComments(cl, answer.CommentsURL) + if err != nil { + return err + } + output.Comments = comments + } + + return PrintJSON(output) +} diff --git a/internal/commands/recordings.go b/internal/commands/recordings.go new file mode 100644 index 0000000..ed21403 --- /dev/null +++ b/internal/commands/recordings.go @@ -0,0 +1,106 @@ +package commands + +import ( + "errors" + + "github.com/rzolkos/basecamp-cli/internal/client" +) + +// ArchiveCmd archives a recording +type ArchiveCmd struct{} + +type RecordingStatusOutput struct { + Status string `json:"status"` + RecordingID string `json:"recording_id"` + Message string `json:"message"` +} + +func (c *ArchiveCmd) Run(args []string) error { + projectID, remaining, err := getProjectID(args) + if err != nil { + return err + } + + if len(remaining) < 1 { + return errors.New("recording_id required") + } + recordingID := remaining[0] + + cl, err := client.New() + if err != nil { + return err + } + + _, err = cl.Put("/buckets/"+projectID+"/recordings/"+recordingID+"/status/archived.json", nil) + if err != nil { + return err + } + + return PrintJSON(RecordingStatusOutput{ + Status: "ok", + RecordingID: recordingID, + Message: "Recording archived", + }) +} + +// UnarchiveCmd unarchives a recording (sets to active) +type UnarchiveCmd struct{} + +func (c *UnarchiveCmd) Run(args []string) error { + projectID, remaining, err := getProjectID(args) + if err != nil { + return err + } + + if len(remaining) < 1 { + return errors.New("recording_id required") + } + recordingID := remaining[0] + + cl, err := client.New() + if err != nil { + return err + } + + _, err = cl.Put("/buckets/"+projectID+"/recordings/"+recordingID+"/status/active.json", nil) + if err != nil { + return err + } + + return PrintJSON(RecordingStatusOutput{ + Status: "ok", + RecordingID: recordingID, + Message: "Recording unarchived", + }) +} + +// TrashCmd trashes a recording +type TrashCmd struct{} + +func (c *TrashCmd) Run(args []string) error { + projectID, remaining, err := getProjectID(args) + if err != nil { + return err + } + + if len(remaining) < 1 { + return errors.New("recording_id required") + } + recordingID := remaining[0] + + cl, err := client.New() + if err != nil { + return err + } + + _, err = cl.Put("/buckets/"+projectID+"/recordings/"+recordingID+"/status/trashed.json", nil) + if err != nil { + return err + } + + return PrintJSON(RecordingStatusOutput{ + Status: "ok", + RecordingID: recordingID, + Message: "Recording trashed", + }) +} diff --git a/internal/commands/root.go b/internal/commands/root.go index 1b88fae..06a7d3d 100644 --- a/internal/commands/root.go +++ b/internal/commands/root.go @@ -14,13 +14,69 @@ type Command interface { } var commands = map[string]func() Command{ - "init": func() Command { return &InitCmd{} }, - "auth": func() Command { return &AuthCmd{} }, - "projects": func() Command { return &ProjectsCmd{} }, - "boards": func() Command { return &BoardsCmd{} }, - "cards": func() Command { return &CardsCmd{} }, - "card": func() Command { return &CardCmd{} }, - "move": func() Command { return &MoveCmd{} }, + "init": func() Command { return &InitCmd{} }, + "auth": func() Command { return &AuthCmd{} }, + "projects": func() Command { return &ProjectsCmd{} }, + "boards": func() Command { return &BoardsCmd{} }, + "cards": func() Command { return &CardsCmd{} }, + "card": func() Command { return &CardCmd{} }, + "move": func() Command { return &MoveCmd{} }, + "todolists": func() Command { return &TodolistsCmd{} }, + "todos": func() Command { return &TodosCmd{} }, + "todo": func() Command { return &TodoCmd{} }, + "todo-create": func() Command { return &TodoCreateCmd{} }, + "todo-complete": func() Command { return &TodoCompleteCmd{} }, + "todo-uncomplete": func() Command { return &TodoUncompleteCmd{} }, + "messages": func() Command { return &MessagesCmd{} }, + "message": func() Command { return &MessageCmd{} }, + "message-create": func() Command { return &MessageCreateCmd{} }, + "comment-add": func() Command { return &CommentAddCmd{} }, + "docs": func() Command { return &DocsCmd{} }, + "doc": func() Command { return &DocCmd{} }, + "doc-create": func() Command { return &DocCreateCmd{} }, + "schedule": func() Command { return &ScheduleCmd{} }, + "event": func() Command { return &EventCmd{} }, + "event-create": func() Command { return &EventCreateCmd{} }, + "campfire": func() Command { return &CampfireCmd{} }, + "campfire-post": func() Command { return &CampfirePostCmd{} }, + "columns": func() Command { return &ColumnsCmd{} }, + "card-create": func() Command { return &CardCreateCmd{} }, + "card-update": func() Command { return &CardUpdateCmd{} }, + "search": func() Command { return &SearchCmd{} }, + "step-create": func() Command { return &StepCreateCmd{} }, + "step-update": func() Command { return &StepUpdateCmd{} }, + "step-complete": func() Command { return &StepCompleteCmd{} }, + "step-uncomplete": func() Command { return &StepUncompleteCmd{} }, + "step-reposition": func() Command { return &StepRepositionCmd{} }, + "people": func() Command { return &PeopleCmd{} }, + "person": func() Command { return &PersonCmd{} }, + "people-pingable": func() Command { return &PeoplePingableCmd{} }, + "people-project": func() Command { return &PeopleProjectCmd{} }, + "my-profile": func() Command { return &MyProfileCmd{} }, + "project-access": func() Command { return &ProjectAccessCmd{} }, + "questionnaire": func() Command { return &QuestionnaireCmd{} }, + "questions": func() Command { return &QuestionsCmd{} }, + "question": func() Command { return &QuestionCmd{} }, + "question-answers": func() Command { return &QuestionAnswersCmd{} }, + "question-answer": func() Command { return &QuestionAnswerCmd{} }, + "todolist-groups": func() Command { return &TodolistGroupsCmd{} }, + "todolist-group": func() Command { return &TodolistGroupCmd{} }, + "todolist-group-create": func() Command { return &TodolistGroupCreateCmd{} }, + "todo-reposition": func() Command { return &TodoRepositionCmd{} }, + "upload": func() Command { return &UploadCmd{} }, + "uploads": func() Command { return &UploadsCmd{} }, + "upload-view": func() Command { return &UploadViewCmd{} }, + "archive": func() Command { return &ArchiveCmd{} }, + "unarchive": func() Command { return &UnarchiveCmd{} }, + "trash": func() Command { return &TrashCmd{} }, + "message-types": func() Command { return &MessageTypesCmd{} }, + "message-type": func() Command { return &MessageTypeCmd{} }, + "message-type-create": func() Command { return &MessageTypeCreateCmd{} }, + "message-type-update": func() Command { return &MessageTypeUpdateCmd{} }, + "message-type-delete": func() Command { return &MessageTypeDeleteCmd{} }, + "events": func() Command { return &EventsCmd{} }, + "events-project": func() Command { return &EventsProjectCmd{} }, + "events-recording": func() Command { return &EventsRecordingCmd{} }, } func Execute(args []string, version string) { @@ -62,10 +118,100 @@ Commands: init Configure credentials auth Authenticate with OAuth projects List all projects + +Card Tables: boards [project_id] List card tables in a project + columns [project_id] List columns in a board cards [project_id] List cards (--column to filter) card [project_id] View card details (--comments for comments) + card-create [project_id] Create card (--column, --title required) + card-update [project_id] Update card (--title, --content, --due) move [project_id] Move card (--to required) + +Card Steps: + step-create [project_id] Create step (--title required) + step-update [project_id] Update step (--title, --due, --assignees) + step-complete [project_id] Mark step as complete + step-uncomplete [project_id] Mark step as incomplete + step-reposition [project_id] Reposition step (--position required) + +Todos: + todolists [project_id] List todo lists + todos [project_id] List todos (--completed for completed) + todo [project_id] View todo details + todo-create [project_id] Create todo (--content required) + todo-complete [project_id] Mark todo as complete + todo-uncomplete [project_id] Mark todo as incomplete + todo-reposition [project_id] Reposition todo (--position required) + +Todo Groups: + todolist-groups [project_id] List groups in a todolist + todolist-group [project_id] View group details + todolist-group-create [project_id] Create group (--name required) + +Messages: + messages [project_id] List messages + message [project_id] View message (--comments for comments) + message-create [project_id] Create message (--subject required) + +Comments: + comment-add [project_id] Add comment to recording (--content required) + +Documents: + docs [project_id] List documents + doc [project_id] View document (--comments for comments) + doc-create [project_id] Create document (--title required) + +Schedule: + schedule [project_id] List schedule entries + event [project_id] View event (--comments for comments) + event-create [project_id] Create event (--summary, --starts-at, --ends-at) + +Campfire: + campfire [project_id] List campfire messages + campfire-post [project_id] Post to campfire (--content required) + +Search: + search Search across all projects + (--type , --project optional) + +People: + people List all people + person View person details + people-pingable List pingable people + people-project [project_id] List people on a project + my-profile View your own profile + project-access [project_id] Update project access (--grant, --revoke) + +Automatic Check-ins: + questionnaire [project_id] Get questionnaire info + questions [project_id] List check-in questions + question [project_id] View question (--comments for comments) + question-answers [project_id] List answers to a question + question-answer [project_id] View answer (--comments for comments) + +Uploads: + upload Upload a file (returns attachable_sgid) + uploads [project_id] List uploads in a vault + upload-view [project_id] View upload (--comments for comments) + +Recordings: + archive [project_id] Archive a recording + unarchive [project_id] Unarchive a recording + trash [project_id] Trash a recording + +Message Types: + message-types [project_id] List message types + message-type [project_id] View message type + message-type-create [project_id] Create type (--name, --icon required) + message-type-update [project_id] Update type (--name, --icon) + message-type-delete [project_id] Delete type + +Activity Events: + events List all events (across all projects) + events-project [project_id] List events for a project + events-recording [project_id] List events for a recording + version Show version Project ID can be omitted if .basecamp.yml exists in current or parent directory: @@ -73,11 +219,11 @@ Project ID can be omitted if .basecamp.yml exists in current or parent directory Examples: basecamp projects - basecamp boards 12345678 - basecamp boards # uses .basecamp.yml - basecamp cards 12345678 87654321 --column "In Progress" - basecamp card 12345678 44444444 --comments - basecamp move 12345678 87654321 44444444 --to "Done" + basecamp boards + basecamp todolists + basecamp todos 12345 + basecamp todo-create 12345 --content "Fix bug" + basecamp todo-complete 67890 `, version) } diff --git a/internal/commands/schedules.go b/internal/commands/schedules.go new file mode 100644 index 0000000..3205de3 --- /dev/null +++ b/internal/commands/schedules.go @@ -0,0 +1,308 @@ +package commands + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/rzolkos/basecamp-cli/internal/client" +) + +// fetchSchedule gets the schedule for a project +func fetchSchedule(cl *client.Client, projectID string) (ProjectDetail, Schedule, error) { + project, err := fetchProject(cl, projectID) + if err != nil { + return ProjectDetail{}, Schedule{}, err + } + + scheduleURL, err := getDockURL(project, "schedule") + if err != nil { + return ProjectDetail{}, Schedule{}, err + } + + scheduleData, err := cl.Get(scheduleURL) + if err != nil { + return ProjectDetail{}, Schedule{}, err + } + + var schedule Schedule + if err := json.Unmarshal(scheduleData, &schedule); err != nil { + return ProjectDetail{}, Schedule{}, err + } + + return project, schedule, nil +} + +type ScheduleCmd struct{} + +type Schedule struct { + ID int `json:"id"` + Title string `json:"title"` + EntriesURL string `json:"entries_url"` +} + +type ScheduleEntry struct { + ID int `json:"id"` + Summary string `json:"summary"` + Description string `json:"description"` + StartsAt string `json:"starts_at"` + EndsAt string `json:"ends_at"` + AllDay bool `json:"all_day"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + CommentsCount int `json:"comments_count"` + CommentsURL string `json:"comments_url"` + URL string `json:"app_url"` + Creator Creator `json:"creator"` + Participants []Creator `json:"participants"` +} + +type ScheduleListOutput struct { + ProjectID int `json:"project_id"` + ScheduleID int `json:"schedule_id"` + Entries []ScheduleEntryBrief `json:"entries"` +} + +type ScheduleEntryBrief struct { + ID int `json:"id"` + Summary string `json:"summary"` + StartsAt string `json:"starts_at"` + EndsAt string `json:"ends_at"` + AllDay bool `json:"all_day"` +} + +func (c *ScheduleCmd) Run(args []string) error { + projectID, _, err := getProjectID(args) + if err != nil { + return err + } + + cl, err := client.New() + if err != nil { + return err + } + + project, schedule, err := fetchSchedule(cl, projectID) + if err != nil { + return err + } + + // Get entries + entriesData, err := cl.Get(schedule.EntriesURL) + if err != nil { + return err + } + + var entries []ScheduleEntry + if err := json.Unmarshal(entriesData, &entries); err != nil { + return err + } + + output := ScheduleListOutput{ + ProjectID: project.ID, + ScheduleID: schedule.ID, + Entries: make([]ScheduleEntryBrief, len(entries)), + } + + for i, e := range entries { + output.Entries[i] = ScheduleEntryBrief{ + ID: e.ID, + Summary: e.Summary, + StartsAt: e.StartsAt, + EndsAt: e.EndsAt, + AllDay: e.AllDay, + } + } + + return PrintJSON(output) +} + +type EventCmd struct{} + +type EventDetailOutput struct { + ID int `json:"id"` + Summary string `json:"summary"` + Description string `json:"description,omitempty"` + StartsAt string `json:"starts_at"` + EndsAt string `json:"ends_at"` + AllDay bool `json:"all_day"` + Creator string `json:"creator"` + Participants []string `json:"participants,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + CommentsCount int `json:"comments_count"` + URL string `json:"url"` + Comments []CommentOutput `json:"comments,omitempty"` +} + +func (c *EventCmd) Run(args []string) error { + projectID, remaining, err := getProjectID(args) + if err != nil { + return err + } + + // Parse entry_id and flags + var entryID string + showComments := false + + for i := 0; i < len(remaining); i++ { + if remaining[i] == "--comments" { + showComments = true + } else if entryID == "" { + entryID = remaining[i] + } + } + + if entryID == "" { + return errors.New("entry_id required") + } + + cl, err := client.New() + if err != nil { + return err + } + + // Get entry directly + entryData, err := cl.Get("/buckets/" + projectID + "/schedule_entries/" + entryID + ".json") + if err != nil { + return err + } + + var entry ScheduleEntry + if err := json.Unmarshal(entryData, &entry); err != nil { + return err + } + + var participants []string + for _, p := range entry.Participants { + participants = append(participants, p.Name) + } + + output := EventDetailOutput{ + ID: entry.ID, + Summary: entry.Summary, + Description: stripHTML(entry.Description), + StartsAt: entry.StartsAt, + EndsAt: entry.EndsAt, + AllDay: entry.AllDay, + Creator: entry.Creator.Name, + Participants: participants, + CreatedAt: entry.CreatedAt, + UpdatedAt: entry.UpdatedAt, + CommentsCount: entry.CommentsCount, + URL: entry.URL, + } + + if showComments && entry.CommentsURL != "" { + comments, err := fetchComments(cl, entry.CommentsURL) + if err != nil { + return err + } + output.Comments = comments + } + + return PrintJSON(output) +} + +type EventCreateCmd struct{} + +type EventCreateOutput struct { + Status string `json:"status"` + ID int `json:"id"` + Summary string `json:"summary"` + Message string `json:"message"` +} + +func (c *EventCreateCmd) Run(args []string) error { + projectID, remaining, err := getProjectID(args) + if err != nil { + return err + } + + // Parse flags + var summary, description, startsAt, endsAt string + allDay := false + + for i := 0; i < len(remaining); i++ { + switch remaining[i] { + case "--summary": + if i+1 < len(remaining) { + summary = remaining[i+1] + i++ + } + case "--description": + if i+1 < len(remaining) { + description = remaining[i+1] + i++ + } + case "--starts-at": + if i+1 < len(remaining) { + startsAt = remaining[i+1] + i++ + } + case "--ends-at": + if i+1 < len(remaining) { + endsAt = remaining[i+1] + i++ + } + case "--all-day": + allDay = true + } + } + + if summary == "" { + return errors.New("--summary required") + } + if startsAt == "" { + return errors.New("--starts-at required") + } + if endsAt == "" { + return errors.New("--ends-at required") + } + + cl, err := client.New() + if err != nil { + return err + } + + _, schedule, err := fetchSchedule(cl, projectID) + if err != nil { + return err + } + + // Create entry + payload := map[string]any{ + "summary": summary, + "starts_at": startsAt, + "ends_at": endsAt, + "all_day": allDay, + } + if description != "" { + payload["description"] = description + } + + // POST to entries URL + entriesURL := schedule.EntriesURL + // Convert from full URL to path + if idx := strings.Index(entriesURL, "/buckets/"); idx != -1 { + entriesURL = entriesURL[idx:] + } + + responseData, err := cl.Post(entriesURL, payload) + if err != nil { + return err + } + + var created ScheduleEntry + if err := json.Unmarshal(responseData, &created); err != nil { + return err + } + + return PrintJSON(EventCreateOutput{ + Status: "ok", + ID: created.ID, + Summary: created.Summary, + Message: fmt.Sprintf("Event '%s' created", created.Summary), + }) +} diff --git a/internal/commands/search.go b/internal/commands/search.go new file mode 100644 index 0000000..1aded49 --- /dev/null +++ b/internal/commands/search.go @@ -0,0 +1,118 @@ +package commands + +import ( + "encoding/json" + "errors" + "net/url" + + "github.com/rzolkos/basecamp-cli/internal/client" +) + +type SearchCmd struct{} + +type SearchResult struct { + ID int `json:"id"` + Title string `json:"title"` + Type string `json:"type"` + PlainTextContent string `json:"plain_text_content"` + CreatedAt string `json:"created_at"` + AppURL string `json:"app_url"` + Creator Creator `json:"creator"` + Bucket struct { + ID int `json:"id"` + Name string `json:"name"` + } `json:"bucket"` + Parent struct { + ID int `json:"id"` + Title string `json:"title"` + Type string `json:"type"` + } `json:"parent"` +} + +type SearchResultOutput struct { + ID int `json:"id"` + Title string `json:"title"` + Type string `json:"type"` + Snippet string `json:"snippet,omitempty"` + Project string `json:"project"` + Creator string `json:"creator"` + URL string `json:"url"` +} + +type SearchOutput struct { + Query string `json:"query"` + Results []SearchResultOutput `json:"results"` +} + +func (c *SearchCmd) Run(args []string) error { + // Parse query and flags + var query, searchType, projectID string + + for i := 0; i < len(args); i++ { + switch args[i] { + case "--type": + if i+1 < len(args) { + searchType = args[i+1] + i++ + } + case "--project": + if i+1 < len(args) { + projectID = args[i+1] + i++ + } + default: + if query == "" { + query = args[i] + } + } + } + + if query == "" { + return errors.New("search query required") + } + + cl, err := client.New() + if err != nil { + return err + } + + // Build search URL + params := url.Values{} + params.Set("q", query) + if searchType != "" { + params.Set("type", searchType) + } + if projectID != "" { + params.Set("bucket_id", projectID) + } + + searchURL := "/search.json?" + params.Encode() + data, err := cl.Get(searchURL) + if err != nil { + return err + } + + var results []SearchResult + if err := json.Unmarshal(data, &results); err != nil { + return err + } + + output := SearchOutput{ + Query: query, + Results: make([]SearchResultOutput, len(results)), + } + + for i, r := range results { + output.Results[i] = SearchResultOutput{ + ID: r.ID, + Title: r.Title, + Type: r.Type, + Snippet: stripHTML(r.PlainTextContent), + Project: r.Bucket.Name, + Creator: r.Creator.Name, + URL: r.AppURL, + } + } + + return PrintJSON(output) +} diff --git a/internal/commands/steps.go b/internal/commands/steps.go new file mode 100644 index 0000000..cef0987 --- /dev/null +++ b/internal/commands/steps.go @@ -0,0 +1,348 @@ +package commands + +import ( + "encoding/json" + "errors" + "fmt" + + "github.com/rzolkos/basecamp-cli/internal/client" +) + +// Step represents a card step/checklist item +type Step struct { + ID int `json:"id"` + Title string `json:"title"` + Completed bool `json:"completed"` + DueOn string `json:"due_on"` + Position int `json:"position"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Creator Creator `json:"creator"` + Assignees []Assignee `json:"assignees"` +} + +// StepOutput is the brief output for steps in card listings +type StepOutput struct { + ID int `json:"id"` + Title string `json:"title"` + Completed bool `json:"completed"` + DueOn string `json:"due_on,omitempty"` + Position int `json:"position"` + Assignees []string `json:"assignees,omitempty"` +} + +// StepCreateCmd creates a step in a card +type StepCreateCmd struct{} + +type StepCreateOutput struct { + Status string `json:"status"` + ID int `json:"id"` + Title string `json:"title"` + Message string `json:"message"` +} + +func (c *StepCreateCmd) Run(args []string) error { + projectID, remaining, err := getProjectID(args) + if err != nil { + return err + } + + // Parse card_id and flags + var cardID, title, dueOn, assignees string + + for i := 0; i < len(remaining); i++ { + switch remaining[i] { + case "--title": + if i+1 < len(remaining) { + title = remaining[i+1] + i++ + } + case "--due": + if i+1 < len(remaining) { + dueOn = remaining[i+1] + i++ + } + case "--assignees": + if i+1 < len(remaining) { + assignees = remaining[i+1] + i++ + } + default: + if cardID == "" { + cardID = remaining[i] + } + } + } + + if cardID == "" { + return errors.New("card_id required") + } + if title == "" { + return errors.New("--title required") + } + + cl, err := client.New() + if err != nil { + return err + } + + payload := map[string]any{ + "title": title, + } + if dueOn != "" { + payload["due_on"] = dueOn + } + if assignees != "" { + payload["assignees"] = assignees + } + + path := fmt.Sprintf("/buckets/%s/card_tables/cards/%s/steps.json", projectID, cardID) + responseData, err := cl.Post(path, payload) + if err != nil { + return err + } + + var created Step + if err := json.Unmarshal(responseData, &created); err != nil { + return err + } + + return PrintJSON(StepCreateOutput{ + Status: "ok", + ID: created.ID, + Title: created.Title, + Message: fmt.Sprintf("Step '%s' created", created.Title), + }) +} + +// StepUpdateCmd updates a step +type StepUpdateCmd struct{} + +type StepUpdateOutput struct { + Status string `json:"status"` + ID int `json:"id"` + Title string `json:"title"` + Message string `json:"message"` +} + +func (c *StepUpdateCmd) Run(args []string) error { + projectID, remaining, err := getProjectID(args) + if err != nil { + return err + } + + // Parse step_id and flags + var stepID, title, dueOn, assignees string + + for i := 0; i < len(remaining); i++ { + switch remaining[i] { + case "--title": + if i+1 < len(remaining) { + title = remaining[i+1] + i++ + } + case "--due": + if i+1 < len(remaining) { + dueOn = remaining[i+1] + i++ + } + case "--assignees": + if i+1 < len(remaining) { + assignees = remaining[i+1] + i++ + } + default: + if stepID == "" { + stepID = remaining[i] + } + } + } + + if stepID == "" { + return errors.New("step_id required") + } + if title == "" && dueOn == "" && assignees == "" { + return errors.New("at least one of --title, --due, or --assignees required") + } + + cl, err := client.New() + if err != nil { + return err + } + + payload := map[string]any{} + if title != "" { + payload["title"] = title + } + if dueOn != "" { + payload["due_on"] = dueOn + } + if assignees != "" { + payload["assignees"] = assignees + } + + path := fmt.Sprintf("/buckets/%s/card_tables/steps/%s.json", projectID, stepID) + responseData, err := cl.Put(path, payload) + if err != nil { + return err + } + + var updated Step + if err := json.Unmarshal(responseData, &updated); err != nil { + return err + } + + return PrintJSON(StepUpdateOutput{ + Status: "ok", + ID: updated.ID, + Title: updated.Title, + Message: fmt.Sprintf("Step '%s' updated", updated.Title), + }) +} + +// StepCompleteCmd marks a step as complete +type StepCompleteCmd struct{} + +type StepCompleteOutput struct { + Status string `json:"status"` + StepID string `json:"step_id"` + Message string `json:"message"` +} + +func (c *StepCompleteCmd) Run(args []string) error { + projectID, remaining, err := getProjectID(args) + if err != nil { + return err + } + + if len(remaining) < 1 { + return errors.New("usage: basecamp step-complete [project_id] ") + } + stepID := remaining[0] + + cl, err := client.New() + if err != nil { + return err + } + + payload := map[string]string{ + "completion": "on", + } + + path := fmt.Sprintf("/buckets/%s/card_tables/steps/%s/completions.json", projectID, stepID) + _, err = cl.Put(path, payload) + if err != nil { + return err + } + + return PrintJSON(StepCompleteOutput{ + Status: "ok", + StepID: stepID, + Message: "Step completed", + }) +} + +// StepUncompleteCmd marks a step as uncomplete +type StepUncompleteCmd struct{} + +func (c *StepUncompleteCmd) Run(args []string) error { + projectID, remaining, err := getProjectID(args) + if err != nil { + return err + } + + if len(remaining) < 1 { + return errors.New("usage: basecamp step-uncomplete [project_id] ") + } + stepID := remaining[0] + + cl, err := client.New() + if err != nil { + return err + } + + payload := map[string]string{ + "completion": "off", + } + + path := fmt.Sprintf("/buckets/%s/card_tables/steps/%s/completions.json", projectID, stepID) + _, err = cl.Put(path, payload) + if err != nil { + return err + } + + return PrintJSON(StepCompleteOutput{ + Status: "ok", + StepID: stepID, + Message: "Step uncompleted", + }) +} + +// StepRepositionCmd changes a step's position +type StepRepositionCmd struct{} + +type StepRepositionOutput struct { + Status string `json:"status"` + StepID string `json:"step_id"` + Position string `json:"position"` + Message string `json:"message"` +} + +func (c *StepRepositionCmd) Run(args []string) error { + projectID, remaining, err := getProjectID(args) + if err != nil { + return err + } + + // Parse card_id, step_id, and --position flag + var cardID, stepID, position string + + for i := 0; i < len(remaining); i++ { + switch remaining[i] { + case "--position": + if i+1 < len(remaining) { + position = remaining[i+1] + i++ + } + default: + if cardID == "" { + cardID = remaining[i] + } else if stepID == "" { + stepID = remaining[i] + } + } + } + + if cardID == "" { + return errors.New("card_id required") + } + if stepID == "" { + return errors.New("step_id required") + } + if position == "" { + return errors.New("--position required") + } + + cl, err := client.New() + if err != nil { + return err + } + + payload := map[string]any{ + "source_id": stepID, + "position": position, + } + + path := fmt.Sprintf("/buckets/%s/card_tables/cards/%s/positions.json", projectID, cardID) + _, err = cl.Post(path, payload) + if err != nil { + return err + } + + return PrintJSON(StepRepositionOutput{ + Status: "ok", + StepID: stepID, + Position: position, + Message: fmt.Sprintf("Step repositioned to %s", position), + }) +} diff --git a/internal/commands/todolists.go b/internal/commands/todolists.go new file mode 100644 index 0000000..199d5f8 --- /dev/null +++ b/internal/commands/todolists.go @@ -0,0 +1,309 @@ +package commands + +import ( + "encoding/json" + "errors" + "fmt" + + "github.com/rzolkos/basecamp-cli/internal/client" +) + +// fetchTodoSet gets the todoset for a project +func fetchTodoSet(cl *client.Client, projectID string) (ProjectDetail, TodoSet, error) { + project, err := fetchProject(cl, projectID) + if err != nil { + return ProjectDetail{}, TodoSet{}, err + } + + todosetURL, err := getDockURL(project, "todoset") + if err != nil { + return ProjectDetail{}, TodoSet{}, err + } + + data, err := cl.Get(todosetURL) + if err != nil { + return ProjectDetail{}, TodoSet{}, err + } + + var todoset TodoSet + if err := json.Unmarshal(data, &todoset); err != nil { + return ProjectDetail{}, TodoSet{}, err + } + + return project, todoset, nil +} + +type TodolistsCmd struct{} + +type TodoSet struct { + ID int `json:"id"` + TodolistsURL string `json:"todolists_url"` + GroupsURL string `json:"groups_url"` +} + +type Todolist struct { + ID int `json:"id"` + Title string `json:"title"` + TodosURL string `json:"todos_url"` + CompletedRatio string `json:"completed_ratio"` + Completed bool `json:"completed"` +} + +type TodolistOutput struct { + ID int `json:"id"` + Title string `json:"title"` + CompletedRatio string `json:"completed_ratio"` + Completed bool `json:"completed"` +} + +type TodolistsOutput struct { + ProjectID int `json:"project_id"` + TodosetID int `json:"todoset_id"` + Todolists []TodolistOutput `json:"todolists"` +} + +func (c *TodolistsCmd) Run(args []string) error { + projectID, _, err := getProjectID(args) + if err != nil { + return err + } + + cl, err := client.New() + if err != nil { + return err + } + + project, todoset, err := fetchTodoSet(cl, projectID) + if err != nil { + return err + } + + // Get todolists + todolistsData, err := cl.Get(todoset.TodolistsURL) + if err != nil { + return err + } + + var todolists []Todolist + if err := json.Unmarshal(todolistsData, &todolists); err != nil { + return err + } + + output := TodolistsOutput{ + ProjectID: project.ID, + TodosetID: todoset.ID, + Todolists: make([]TodolistOutput, len(todolists)), + } + + for i, tl := range todolists { + output.Todolists[i] = TodolistOutput{ + ID: tl.ID, + Title: tl.Title, + CompletedRatio: tl.CompletedRatio, + Completed: tl.Completed, + } + } + + return PrintJSON(output) +} + +// TodolistGroup represents a group of todolists +type TodolistGroup struct { + ID int `json:"id"` + Name string `json:"name"` + Position int `json:"position"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Color string `json:"color"` +} + +// TodolistGroupsCmd lists todolist groups within a todolist +type TodolistGroupsCmd struct{} + +type TodolistGroupsOutput struct { + ProjectID int `json:"project_id"` + TodolistID int `json:"todolist_id"` + Groups []TodolistGroupBrief `json:"groups"` +} + +type TodolistGroupBrief struct { + ID int `json:"id"` + Name string `json:"name"` + Position int `json:"position"` + Color string `json:"color,omitempty"` +} + +func (c *TodolistGroupsCmd) Run(args []string) error { + projectID, remaining, err := getProjectID(args) + if err != nil { + return err + } + + if len(remaining) < 1 { + return errors.New("todolist_id required") + } + todolistID := remaining[0] + + cl, err := client.New() + if err != nil { + return err + } + + // Groups are listed under a specific todolist + groupsURL := fmt.Sprintf("/buckets/%s/todolists/%s/groups.json", projectID, todolistID) + + data, err := cl.Get(groupsURL) + if err != nil { + return err + } + + var groups []TodolistGroup + if err := json.Unmarshal(data, &groups); err != nil { + return err + } + + var tlID int + fmt.Sscanf(todolistID, "%d", &tlID) + + output := TodolistGroupsOutput{ + ProjectID: 0, + TodolistID: tlID, + Groups: make([]TodolistGroupBrief, len(groups)), + } + fmt.Sscanf(projectID, "%d", &output.ProjectID) + + for i, g := range groups { + output.Groups[i] = TodolistGroupBrief{ + ID: g.ID, + Name: g.Name, + Position: g.Position, + Color: g.Color, + } + } + + return PrintJSON(output) +} + +// TodolistGroupCmd views a single group (groups are todolists with group_position_url) +type TodolistGroupCmd struct{} + +type TodolistGroupDetailOutput struct { + ID int `json:"id"` + Name string `json:"name"` + Position int `json:"position"` + Color string `json:"color,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +func (c *TodolistGroupCmd) Run(args []string) error { + projectID, remaining, err := getProjectID(args) + if err != nil { + return err + } + + if len(remaining) < 1 { + return errors.New("group_id required") + } + groupID := remaining[0] + + cl, err := client.New() + if err != nil { + return err + } + + // Groups are accessed via the todolists endpoint (they are todolists with group_position_url) + data, err := cl.Get("/buckets/" + projectID + "/todolists/" + groupID + ".json") + if err != nil { + return err + } + + var group TodolistGroup + if err := json.Unmarshal(data, &group); err != nil { + return err + } + + return PrintJSON(TodolistGroupDetailOutput{ + ID: group.ID, + Name: group.Name, + Position: group.Position, + Color: group.Color, + CreatedAt: group.CreatedAt, + UpdatedAt: group.UpdatedAt, + }) +} + +// TodolistGroupCreateCmd creates a new group within a todolist +type TodolistGroupCreateCmd struct{} + +type TodolistGroupCreateOutput struct { + Status string `json:"status"` + ID int `json:"id"` + Name string `json:"name"` + Message string `json:"message"` +} + +func (c *TodolistGroupCreateCmd) Run(args []string) error { + projectID, remaining, err := getProjectID(args) + if err != nil { + return err + } + + // First arg should be todolist_id + if len(remaining) < 1 { + return errors.New("todolist_id required") + } + todolistID := remaining[0] + + // Parse flags + var name, color string + + for i := 1; i < len(remaining); i++ { + switch remaining[i] { + case "--name": + if i+1 < len(remaining) { + name = remaining[i+1] + i++ + } + case "--color": + if i+1 < len(remaining) { + color = remaining[i+1] + i++ + } + } + } + + if name == "" { + return errors.New("--name required") + } + + cl, err := client.New() + if err != nil { + return err + } + + payload := map[string]any{ + "name": name, + } + if color != "" { + payload["color"] = color + } + + // Groups are created under a specific todolist + data, err := cl.Post("/buckets/"+projectID+"/todolists/"+todolistID+"/groups.json", payload) + if err != nil { + return err + } + + var group TodolistGroup + if err := json.Unmarshal(data, &group); err != nil { + return err + } + + return PrintJSON(TodolistGroupCreateOutput{ + Status: "ok", + ID: group.ID, + Name: group.Name, + Message: fmt.Sprintf("Group '%s' created", group.Name), + }) +} diff --git a/internal/commands/todos.go b/internal/commands/todos.go new file mode 100644 index 0000000..25a5939 --- /dev/null +++ b/internal/commands/todos.go @@ -0,0 +1,356 @@ +package commands + +import ( + "encoding/json" + "errors" + "strconv" + "strings" + + "github.com/rzolkos/basecamp-cli/internal/client" +) + +type TodosCmd struct{} + +type Todo struct { + ID int `json:"id"` + Content string `json:"content"` + Description string `json:"description"` + Completed bool `json:"completed"` + DueOn string `json:"due_on"` + StartsOn string `json:"starts_on"` + Creator Creator `json:"creator"` + Assignees []Assignee `json:"assignees"` + CommentsURL string `json:"comments_url"` +} + +type TodoOutput struct { + ID int `json:"id"` + Content string `json:"content"` + Completed bool `json:"completed"` + DueOn string `json:"due_on,omitempty"` + Assignees []string `json:"assignees,omitempty"` +} + +type TodosOutput struct { + TodolistID int `json:"todolist_id"` + Todos []TodoOutput `json:"todos"` +} + +func (c *TodosCmd) Run(args []string) error { + projectID, remaining, err := getProjectID(args) + if err != nil { + return err + } + + if len(remaining) < 1 { + return errors.New("usage: basecamp todos [project_id] [--completed]") + } + todolistID := remaining[0] + + // Check for --completed flag + showCompleted := false + for _, arg := range remaining[1:] { + if arg == "--completed" { + showCompleted = true + break + } + } + + cl, err := client.New() + if err != nil { + return err + } + + // Build URL with optional completed filter + url := "/buckets/" + projectID + "/todolists/" + todolistID + "/todos.json" + if showCompleted { + url += "?completed=true" + } + + data, err := cl.Get(url) + if err != nil { + return err + } + + var todos []Todo + if err := json.Unmarshal(data, &todos); err != nil { + return err + } + + output := TodosOutput{ + TodolistID: 0, // Will be parsed from todolistID + Todos: make([]TodoOutput, len(todos)), + } + + // Parse todolistID + if tlID, err := strconv.Atoi(todolistID); err == nil { + output.TodolistID = tlID + } + + for i, todo := range todos { + var assignees []string + for _, a := range todo.Assignees { + assignees = append(assignees, a.Name) + } + + output.Todos[i] = TodoOutput{ + ID: todo.ID, + Content: stripHTML(todo.Content), + Completed: todo.Completed, + DueOn: todo.DueOn, + Assignees: assignees, + } + } + + return PrintJSON(output) +} + +type TodoCmd struct{} + +type TodoDetailOutput struct { + ID int `json:"id"` + Content string `json:"content"` + Description string `json:"description,omitempty"` + Completed bool `json:"completed"` + DueOn string `json:"due_on,omitempty"` + StartsOn string `json:"starts_on,omitempty"` + Creator string `json:"creator"` + Assignees []string `json:"assignees,omitempty"` +} + +func (c *TodoCmd) Run(args []string) error { + projectID, remaining, err := getProjectID(args) + if err != nil { + return err + } + + if len(remaining) < 1 { + return errors.New("usage: basecamp todo [project_id] ") + } + todoID := remaining[0] + + cl, err := client.New() + if err != nil { + return err + } + + data, err := cl.Get("/buckets/" + projectID + "/todos/" + todoID + ".json") + if err != nil { + return err + } + + var todo Todo + if err := json.Unmarshal(data, &todo); err != nil { + return err + } + + var assignees []string + for _, a := range todo.Assignees { + assignees = append(assignees, a.Name) + } + + output := TodoDetailOutput{ + ID: todo.ID, + Content: stripHTML(todo.Content), + Description: stripHTML(todo.Description), + Completed: todo.Completed, + DueOn: todo.DueOn, + StartsOn: todo.StartsOn, + Creator: todo.Creator.Name, + Assignees: assignees, + } + + return PrintJSON(output) +} + +type TodoCreateCmd struct{} + +func (c *TodoCreateCmd) Run(args []string) error { + projectID, remaining, err := getProjectID(args) + if err != nil { + return err + } + + if len(remaining) < 1 { + return errors.New("usage: basecamp todo-create [project_id] --content [--due ] [--description ]") + } + todolistID := remaining[0] + + var content, description, dueOn string + for i := 1; i < len(remaining); i++ { + switch remaining[i] { + case "--content": + if i+1 < len(remaining) { + content = remaining[i+1] + i++ + } + case "--description": + if i+1 < len(remaining) { + description = remaining[i+1] + i++ + } + case "--due": + if i+1 < len(remaining) { + dueOn = remaining[i+1] + i++ + } + } + } + + if content == "" { + return errors.New("--content is required") + } + + cl, err := client.New() + if err != nil { + return err + } + + payload := map[string]any{ + "content": content, + } + if description != "" { + payload["description"] = description + } + if dueOn != "" { + payload["due_on"] = dueOn + } + + data, err := cl.Post("/buckets/"+projectID+"/todolists/"+todolistID+"/todos.json", payload) + if err != nil { + return err + } + + var todo Todo + if err := json.Unmarshal(data, &todo); err != nil { + return err + } + + return PrintJSON(map[string]any{ + "status": "ok", + "id": todo.ID, + "content": stripHTML(todo.Content), + "message": "Todo created", + }) +} + +type TodoCompleteCmd struct{} + +func (c *TodoCompleteCmd) Run(args []string) error { + projectID, remaining, err := getProjectID(args) + if err != nil { + return err + } + + if len(remaining) < 1 { + return errors.New("usage: basecamp todo-complete [project_id] ") + } + todoID := remaining[0] + + cl, err := client.New() + if err != nil { + return err + } + + _, err = cl.Post("/buckets/"+projectID+"/todos/"+todoID+"/completion.json", nil) + if err != nil { + // 204 No Content is expected, which may come back as empty response + if !strings.Contains(err.Error(), "API error") { + return err + } + } + + return PrintJSON(map[string]any{ + "status": "ok", + "todo_id": todoID, + "message": "Todo completed", + }) +} + +type TodoUncompleteCmd struct{} + +func (c *TodoUncompleteCmd) Run(args []string) error { + projectID, remaining, err := getProjectID(args) + if err != nil { + return err + } + + if len(remaining) < 1 { + return errors.New("usage: basecamp todo-uncomplete [project_id] ") + } + todoID := remaining[0] + + cl, err := client.New() + if err != nil { + return err + } + + _, err = cl.Delete("/buckets/" + projectID + "/todos/" + todoID + "/completion.json") + if err != nil { + // 204 No Content is expected + if !strings.Contains(err.Error(), "API error") { + return err + } + } + + return PrintJSON(map[string]any{ + "status": "ok", + "todo_id": todoID, + "message": "Todo uncompleted", + }) +} + +// TodoRepositionCmd repositions a todo within its list +type TodoRepositionCmd struct{} + +func (c *TodoRepositionCmd) Run(args []string) error { + projectID, remaining, err := getProjectID(args) + if err != nil { + return err + } + + if len(remaining) < 1 { + return errors.New("todo_id required") + } + todoID := remaining[0] + + // Parse --position flag + var position string + for i := 1; i < len(remaining); i++ { + if remaining[i] == "--position" && i+1 < len(remaining) { + position = remaining[i+1] + break + } + } + + if position == "" { + return errors.New("--position required (1-indexed)") + } + + // Validate position is a number + pos, err := strconv.Atoi(position) + if err != nil { + return errors.New("--position must be a number") + } + + cl, err := client.New() + if err != nil { + return err + } + + payload := map[string]any{ + "position": pos, + } + + _, err = cl.Put("/buckets/"+projectID+"/todos/"+todoID+"/position.json", payload) + if err != nil { + return err + } + + return PrintJSON(map[string]any{ + "status": "ok", + "todo_id": todoID, + "position": position, + "message": "Todo repositioned to " + position, + }) +} diff --git a/internal/commands/uploads.go b/internal/commands/uploads.go new file mode 100644 index 0000000..e4060f7 --- /dev/null +++ b/internal/commands/uploads.go @@ -0,0 +1,250 @@ +package commands + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "mime" + "os" + "path/filepath" + + "github.com/rzolkos/basecamp-cli/internal/client" +) + +// Upload represents a Basecamp upload (file in a vault) +type Upload struct { + ID int `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + ContentType string `json:"content_type"` + ByteSize int64 `json:"byte_size"` + Width int `json:"width"` + Height int `json:"height"` + DownloadURL string `json:"download_url"` + AppURL string `json:"app_url"` + CommentsCount int `json:"comments_count"` + CommentsURL string `json:"comments_url"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Creator Creator `json:"creator"` +} + +// UploadCmd uploads a file and returns the attachable_sgid +type UploadCmd struct{} + +type UploadOutput struct { + Status string `json:"status"` + AttachableSGID string `json:"attachable_sgid"` + Message string `json:"message"` +} + +func (c *UploadCmd) Run(args []string) error { + if len(args) < 1 { + return errors.New("file path required") + } + filePath := args[0] + + // Read the file + file, err := os.Open(filePath) + if err != nil { + return fmt.Errorf("failed to open file: %w", err) + } + defer file.Close() + + // Get file info + fileInfo, err := file.Stat() + if err != nil { + return fmt.Errorf("failed to stat file: %w", err) + } + + // Determine content type + ext := filepath.Ext(filePath) + contentType := mime.TypeByExtension(ext) + if contentType == "" { + contentType = "application/octet-stream" + } + + // Read file contents + fileData, err := io.ReadAll(file) + if err != nil { + return fmt.Errorf("failed to read file: %w", err) + } + + cl, err := client.New() + if err != nil { + return err + } + + // Upload the file + fileName := filepath.Base(filePath) + data, err := cl.UploadFile("/attachments.json?name="+fileName, fileData, contentType, fileInfo.Size()) + if err != nil { + return err + } + + var result struct { + AttachableSGID string `json:"attachable_sgid"` + } + if err := json.Unmarshal(data, &result); err != nil { + return err + } + + return PrintJSON(UploadOutput{ + Status: "ok", + AttachableSGID: result.AttachableSGID, + Message: fmt.Sprintf("File '%s' uploaded", fileName), + }) +} + +// UploadsCmd lists uploads in a vault +type UploadsCmd struct{} + +type UploadsOutput struct { + ProjectID int `json:"project_id"` + VaultID int `json:"vault_id"` + Uploads []UploadBrief `json:"uploads"` +} + +type UploadBrief struct { + ID int `json:"id"` + Title string `json:"title"` + ContentType string `json:"content_type"` + ByteSize int64 `json:"byte_size"` + Creator string `json:"creator"` + CreatedAt string `json:"created_at"` +} + +func (c *UploadsCmd) Run(args []string) error { + projectID, remaining, err := getProjectID(args) + if err != nil { + return err + } + + if len(remaining) < 1 { + return errors.New("vault_id required") + } + vaultID := remaining[0] + + cl, err := client.New() + if err != nil { + return err + } + + data, err := cl.Get("/buckets/" + projectID + "/vaults/" + vaultID + "/uploads.json") + if err != nil { + return err + } + + var uploads []Upload + if err := json.Unmarshal(data, &uploads); err != nil { + return err + } + + var pID, vID int + fmt.Sscanf(projectID, "%d", &pID) + fmt.Sscanf(vaultID, "%d", &vID) + + output := UploadsOutput{ + ProjectID: pID, + VaultID: vID, + Uploads: make([]UploadBrief, len(uploads)), + } + + for i, u := range uploads { + output.Uploads[i] = UploadBrief{ + ID: u.ID, + Title: u.Title, + ContentType: u.ContentType, + ByteSize: u.ByteSize, + Creator: u.Creator.Name, + CreatedAt: u.CreatedAt, + } + } + + return PrintJSON(output) +} + +// UploadViewCmd views a single upload +type UploadViewCmd struct{} + +type UploadDetailOutput struct { + ID int `json:"id"` + Title string `json:"title"` + Description string `json:"description,omitempty"` + ContentType string `json:"content_type"` + ByteSize int64 `json:"byte_size"` + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` + DownloadURL string `json:"download_url"` + URL string `json:"url"` + Creator string `json:"creator"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + CommentsCount int `json:"comments_count"` + Comments []CommentOutput `json:"comments,omitempty"` +} + +func (c *UploadViewCmd) Run(args []string) error { + projectID, remaining, err := getProjectID(args) + if err != nil { + return err + } + + // Parse upload_id and flags + var uploadID string + showComments := false + + for i := 0; i < len(remaining); i++ { + if remaining[i] == "--comments" { + showComments = true + } else if uploadID == "" { + uploadID = remaining[i] + } + } + + if uploadID == "" { + return errors.New("upload_id required") + } + + cl, err := client.New() + if err != nil { + return err + } + + data, err := cl.Get("/buckets/" + projectID + "/uploads/" + uploadID + ".json") + if err != nil { + return err + } + + var upload Upload + if err := json.Unmarshal(data, &upload); err != nil { + return err + } + + output := UploadDetailOutput{ + ID: upload.ID, + Title: upload.Title, + Description: stripHTML(upload.Description), + ContentType: upload.ContentType, + ByteSize: upload.ByteSize, + Width: upload.Width, + Height: upload.Height, + DownloadURL: upload.DownloadURL, + URL: upload.AppURL, + Creator: upload.Creator.Name, + CreatedAt: upload.CreatedAt, + UpdatedAt: upload.UpdatedAt, + CommentsCount: upload.CommentsCount, + } + + if showComments && upload.CommentsURL != "" { + comments, err := fetchComments(cl, upload.CommentsURL) + if err != nil { + return err + } + output.Comments = comments + } + + return PrintJSON(output) +} diff --git a/skills/basecamp/SKILL.md b/skills/basecamp/SKILL.md index ce10400..36b399e 100644 --- a/skills/basecamp/SKILL.md +++ b/skills/basecamp/SKILL.md @@ -1,207 +1,202 @@ --- name: basecamp -description: Interact with Basecamp projects and card tables via CLI. List projects, browse Kanban boards, view cards with comments, and move cards between columns. Use when the user mentions Basecamp, card tables, Kanban boards, or wants to check project status. +description: Interact with Basecamp projects via CLI. Manage card tables, todos, messages, documents, schedules, campfires, people, check-ins, uploads, and more. Use when the user mentions Basecamp, cards, todos, messages, or project management. --- # Basecamp CLI -Interact with Basecamp projects and card tables using the `basecamp` command. All output is JSON for easy parsing with `jq`. +Command-line interface for Basecamp. All output is JSON. ## Prerequisites -The CLI must be installed and authenticated: - ```bash -basecamp version # Verify installation -basecamp projects # Verify authentication +basecamp version # Verify installation +basecamp projects # Verify authentication (run `basecamp auth` if needed) +``` + +## Project Context + +Create `.basecamp.yml` in a directory to set default project: + +```yaml +project_id: 12345678 ``` -If not authenticated, the user needs to run `basecamp auth`. +Then omit project_id from commands when in that directory. -## Commands +## Commands Reference -### List projects +### Projects & Boards ```bash -basecamp projects +basecamp projects # List all projects +basecamp boards [project_id] # List card tables +basecamp columns [project_id] # List columns in board ``` -Output: -```json -[ - { - "id": 12345678, - "name": "Website Redesign", - "description": "Project description here", - "status": "active" - }, - { - "id": 23456789, - "name": "Mobile App", - "status": "active" - } -] +### Cards + +```bash +basecamp cards [project_id] # List cards +basecamp cards [project_id] --column "Name" # Filter by column +basecamp card [project_id] # View card +basecamp card [project_id] --comments # With comments +basecamp card-create [project_id] --column --title "Title" +basecamp card-update [project_id] --title "New" --content "Text" +basecamp move [project_id] --to "Column Name" ``` -### List boards in a project +### Card Steps (Checklists) ```bash -basecamp boards +basecamp step-create [project_id] --title "Step" +basecamp step-create [project_id] --title "Step" --due 2026-02-01 --assignees "123,456" +basecamp step-update [project_id] --title "Updated" +basecamp step-complete [project_id] +basecamp step-uncomplete [project_id] +basecamp step-reposition [project_id] --position 0 ``` -Output: -```json -{ - "project_id": 12345678, - "project_name": "Website Redesign", - "board_id": 87654321, - "board_title": "Development Tasks", - "columns": [ - {"title": "Backlog", "cards_count": 12}, - {"title": "In Progress", "cards_count": 3}, - {"title": "Done", "cards_count": 45} - ] -} +### Todos + +```bash +basecamp todolists [project_id] # List todo lists +basecamp todos [project_id] # List todos +basecamp todos [project_id] --completed # Completed todos +basecamp todo [project_id] # View todo +basecamp todo-create [project_id] --content "Task" --due 2026-02-01 +basecamp todo-complete [project_id] +basecamp todo-uncomplete [project_id] +basecamp todo-reposition [project_id] --position 1 ``` -### List cards +### Todo Groups ```bash -basecamp cards -basecamp cards --column "In Progress" +basecamp todolist-groups [project_id] # List groups +basecamp todolist-group [project_id] # View group +basecamp todolist-group-create [project_id] --name "Sprint 1" --color green ``` -Output: -```json -{ - "board_id": 87654321, - "board_title": "Development Tasks", - "columns": [ - { - "column": "In Progress", - "cards": [ - {"id": 44444444, "title": "Implement dark mode", "creator": "John Doe"}, - {"id": 55555555, "title": "Refactor authentication", "creator": "Jane Smith"} - ] - } - ] -} +### Messages + +```bash +basecamp messages [project_id] # List messages +basecamp message [project_id] # View message +basecamp message [project_id] --comments # With comments +basecamp message-create [project_id] --subject "Subject" --content "Body" ``` -### View card details +### Comments ```bash -basecamp card -basecamp card --comments +basecamp comment-add [project_id] --content "Comment" ``` -Output: -```json -{ - "id": 44444444, - "title": "Implement dark mode", - "creator": "John Doe", - "created_at": "2025-01-15T09:30:00.000Z", - "updated_at": "2025-01-20T14:22:00.000Z", - "url": "https://3.basecamp.com/.../cards/44444444", - "assignees": ["Jane Smith"], - "description": "Add dark mode support to the application.", - "comments": [ - { - "id": 1, - "author": "Jane Smith", - "content": "Started on the color palette.", - "created_at": "2025-01-16T10:00:00.000Z" - }, - { - "id": 2, - "author": "John Doe", - "content": "Looks great!", - "created_at": "2025-01-17T09:15:00.000Z" - } - ] -} -``` - -### Move a card - -```bash -basecamp move --to "Done" -``` - -Output: -```json -{ - "status": "ok", - "card_id": "44444444", - "column": "Done", - "message": "Card 44444444 moved to 'Done'" -} +### Documents + +```bash +basecamp docs [project_id] # List documents +basecamp doc [project_id] # View document +basecamp doc [project_id] --comments # With comments +basecamp doc-create [project_id] --title "Title" --content "Content" ``` -## Workflow example +### Schedule -To check what's being worked on and move a completed card: +```bash +basecamp schedule [project_id] # List entries +basecamp event [project_id] # View event +basecamp event [project_id] --comments # With comments +basecamp event-create [project_id] --summary "Meeting" --starts-at "2026-02-01T10:00:00Z" --ends-at "2026-02-01T11:00:00Z" +basecamp event-create [project_id] --summary "Holiday" --starts-at "2026-02-01" --ends-at "2026-02-01" --all-day +``` + +### Campfire ```bash -# Find the project -basecamp projects | jq '.[] | select(.status == "active")' +basecamp campfire [project_id] # List messages +basecamp campfire-post [project_id] --content "Hello!" +``` + +### Search -# Find the board -basecamp boards 12345678 +```bash +basecamp search "query" # Search all projects +basecamp search "query" --type Todo # Filter by type +basecamp search "query" --project # Filter by project +``` -# See cards in progress -basecamp cards 12345678 87654321 --column "Progress" +Types: Todo, Message, Document, Kanban::Card, Schedule::Entry, Comment -# View details of a specific card -basecamp card 12345678 44444444 --comments +### People -# Move it to Done -basecamp move 12345678 87654321 44444444 --to "Done" +```bash +basecamp people # List all people +basecamp person # View person +basecamp people-pingable # Pingable people +basecamp people-project [project_id] # Project members +basecamp my-profile # Your profile +basecamp project-access [project_id] --grant "123,456" --revoke "789" ``` -## Configuration +### Automatic Check-ins -Config files follow XDG Base Directory spec: +```bash +basecamp questionnaire [project_id] # Questionnaire info +basecamp questions [project_id] # List questions +basecamp question [project_id] # View question +basecamp question [project_id] --comments # With comments +basecamp question-answers [project_id] # List answers +basecamp question-answer [project_id] # View answer +``` -| File | Path | -|------|------| -| Config | `~/.config/basecamp/config.json` | -| Token | `~/.local/share/basecamp/token.json` | +### Uploads -To set up from scratch: ```bash -basecamp init # Configure client_id, client_secret, account_id -basecamp auth # Authenticate via OAuth +basecamp upload /path/to/file.pdf # Upload file (returns sgid) +basecamp uploads [project_id] # List uploads in vault +basecamp upload-view [project_id] # View upload +basecamp upload-view [project_id] --comments # With comments ``` -## Project-specific config +### Recordings Management -Create `.basecamp.yml` in your project directory to set a default project: +```bash +basecamp archive [project_id] # Archive recording +basecamp unarchive [project_id] # Unarchive +basecamp trash [project_id] # Trash recording +``` -```yaml -project_id: 12345678 +### Message Types + +```bash +basecamp message-types [project_id] # List types +basecamp message-type [project_id] # View type +basecamp message-type-create [project_id] --name "Announcement" --icon "๐Ÿ“ข" +basecamp message-type-update [project_id] --name "Update" --icon "โœ…" +basecamp message-type-delete [project_id] ``` -The CLI searches current directory and parents, so you can: -- Put it in your repo root for per-project defaults -- Omit project_id from commands when in that directory +### Activity Events ```bash -# With .basecamp.yml in current or parent directory: -basecamp boards # no project_id needed -basecamp cards 87654321 # just board_id -basecamp card 44444444 # just card_id +basecamp events # All events +basecamp events-project [project_id] # Project events +basecamp events-recording [project_id] # Recording events ``` -## Error handling +## Error Handling + +Errors return JSON to stderr: -Errors are returned as JSON on stderr: ```json {"error": "not authenticated, run 'basecamp auth' first"} ``` -If you see "not authenticated" or "token expired", the user needs to run: -```bash -basecamp auth -``` +## Tips + +- All commands output JSON - pipe to `jq` for filtering +- Use `--comments` flag to include comments on supported commands +- Recording IDs work across types (todos, cards, messages, etc.) +- Get vault_id from `basecamp docs` output for upload commands