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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 36 additions & 57 deletions internal/commands/chat.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package commands

import (
"encoding/json"
"fmt"
"os"
"path/filepath"
Expand Down Expand Up @@ -32,7 +31,7 @@ func NewChatCmd() *cobra.Command {
Use 'basecamp chat list' to see chats in a project.
Use 'basecamp chat messages' to view recent messages.
Use 'basecamp chat post "message"' to post a message.`,
Annotations: map[string]string{"agent_notes": "Projects may have multiple chats; use `chat list` to see them\nContent supports Markdown — converted to HTML automatically\nChat is project-scoped, no cross-project chat queries"},
Annotations: map[string]string{"agent_notes": "Projects may have multiple chatsuse --chat to target a specific one\nContent supports Markdown — converted to HTML automatically\nChat is project-scoped, no cross-project chat queries"},
}

cmd.PersistentFlags().StringVarP(&project, "project", "p", "", "Project ID or name")
Expand Down Expand Up @@ -87,12 +86,12 @@ func runChatList(cmd *cobra.Command, app *appctx.App, project, chatID string, al
output.WithBreadcrumbs(
output.Breadcrumb{
Action: "messages",
Cmd: "basecamp chat <id> messages --in <project>",
Cmd: "basecamp chat messages --chat <id> --in <project>",
Description: "View messages",
},
output.Breadcrumb{
Action: "post",
Cmd: "basecamp chat <id> post \"message\" --in <project>",
Cmd: "basecamp chat post \"message\" --chat <id> --in <project>",
Description: "Post message",
Comment thread
jeremy marked this conversation as resolved.
},
),
Expand All @@ -119,7 +118,7 @@ func runChatList(cmd *cobra.Command, app *appctx.App, project, chatID string, al
return err
}

// If a specific chat ID was given via -c, fetch just that one
// If a specific chat ID was given via --chat, fetch just that one
if chatID != "" {
chatIDInt, parseErr := strconv.ParseInt(chatID, 10, 64)
if parseErr != nil {
Expand All @@ -137,62 +136,42 @@ func runChatList(cmd *cobra.Command, app *appctx.App, project, chatID string, al
)
}

// Fetch project dock and find all chat entries (supports multi-chat projects)
path := fmt.Sprintf("/projects/%s.json", resolvedProjectID)
resp, err := app.Account().Get(cmd.Context(), path)
// Get all enabled chats from project dock
enabled, allTools, err := getDockTools(cmd.Context(), app, resolvedProjectID, "chat")
if err != nil {
return convertSDKError(err)
}

var projectData struct {
Dock []DockTool `json:"dock"`
return err
}
if err := json.Unmarshal(resp.Data, &projectData); err != nil {
return fmt.Errorf("failed to parse project: %w", err)
if len(enabled) == 0 {
return dockToolNotFoundError(allTools, "chat", resolvedProjectID, "chat")
}

// Collect enabled chat dock entries and fetch full chat details
// Fetch full details for each enabled chat
var chats []*basecamp.Campfire
var hasDisabled bool
for _, tool := range projectData.Dock {
if tool.Name != "chat" {
continue
}
if !tool.Enabled {
hasDisabled = true
continue
}
chat, getErr := app.Account().Campfires().Get(cmd.Context(), tool.ID)
for _, match := range enabled {
chat, getErr := app.Account().Campfires().Get(cmd.Context(), match.ID)
if getErr != nil {
return getErr
}
chats = append(chats, chat)
}

if len(chats) == 0 {
hint := "Project has no chat"
if hasDisabled {
hint = "Chat is disabled for this project"
}
return output.ErrNotFoundHint("chat", resolvedProjectID, hint)
// Summary: title-based for single, count-based for multiple
var summary string
if len(chats) == 1 {
summary = fmt.Sprintf("Chat: %s", chatTitle(chats[0]))
} else {
summary = fmt.Sprintf("%d chats in project", len(chats))
}

summary := fmt.Sprintf("%d chat(s)", len(chats))
// Breadcrumbs: concrete ID for single, placeholder for multiple
chatRef := "<id>"
if len(chats) == 1 {
chatRef = strconv.FormatInt(chats[0].ID, 10)
}

return app.OK(chats,
output.WithSummary(summary),
output.WithBreadcrumbs(
output.Breadcrumb{
Action: "messages",
Cmd: fmt.Sprintf("basecamp chat messages -c <id> --in %s", resolvedProjectID),
Description: "View messages",
},
output.Breadcrumb{
Action: "post",
Cmd: fmt.Sprintf("basecamp chat post \"message\" -c <id> --in %s", resolvedProjectID),
Description: "Post message",
},
),
output.WithBreadcrumbs(chatListBreadcrumbs(chatRef, resolvedProjectID)...),
)
Comment thread
jeremy marked this conversation as resolved.
}

Expand All @@ -207,12 +186,12 @@ func chatListBreadcrumbs(chatID, projectID string) []output.Breadcrumb {
return []output.Breadcrumb{
{
Action: "messages",
Cmd: fmt.Sprintf("basecamp chat messages -c %s --in %s", chatID, projectID),
Cmd: fmt.Sprintf("basecamp chat messages --chat %s --in %s", chatID, projectID),
Description: "View messages",
},
{
Action: "post",
Cmd: fmt.Sprintf("basecamp chat post \"message\" -c %s --in %s", chatID, projectID),
Cmd: fmt.Sprintf("basecamp chat post \"message\" --chat %s --in %s", chatID, projectID),
Description: "Post message",
},
}
Expand Down Expand Up @@ -293,12 +272,12 @@ func runChatMessages(cmd *cobra.Command, app *appctx.App, chatID, project string
output.WithBreadcrumbs(
output.Breadcrumb{
Action: "post",
Cmd: fmt.Sprintf("basecamp chat %s post \"message\" --in %s", chatID, resolvedProjectID),
Cmd: fmt.Sprintf("basecamp chat post \"message\" --chat %s --in %s", chatID, resolvedProjectID),
Description: "Post message",
},
output.Breadcrumb{
Action: "more",
Cmd: fmt.Sprintf("basecamp chat %s messages --limit 50 --in %s", chatID, resolvedProjectID),
Cmd: fmt.Sprintf("basecamp chat messages --limit 50 --chat %s --in %s", chatID, resolvedProjectID),
Description: "Load more",
},
),
Expand Down Expand Up @@ -397,25 +376,25 @@ func runChatPost(cmd *cobra.Command, app *appctx.App, chatID, project, content,
breadcrumbs = append(breadcrumbs,
output.Breadcrumb{
Action: "messages",
Cmd: fmt.Sprintf("basecamp chat %s messages --in %s", chatID, resolvedProjectID),
Cmd: fmt.Sprintf("basecamp chat messages --chat %s --in %s", chatID, resolvedProjectID),
Description: "View messages",
},
output.Breadcrumb{
Action: "post",
Cmd: fmt.Sprintf("basecamp chat %s post \"reply\" --in %s", chatID, resolvedProjectID),
Cmd: fmt.Sprintf("basecamp chat post \"reply\" --chat %s --in %s", chatID, resolvedProjectID),
Description: "Post another",
},
)
} else {
breadcrumbs = append(breadcrumbs,
output.Breadcrumb{
Action: "messages",
Cmd: fmt.Sprintf("basecamp chat %s messages", chatID),
Cmd: fmt.Sprintf("basecamp chat messages --chat %s", chatID),
Description: "View messages",
},
output.Breadcrumb{
Action: "post",
Cmd: fmt.Sprintf("basecamp chat %s post \"reply\"", chatID),
Cmd: fmt.Sprintf("basecamp chat post \"reply\" --chat %s", chatID),
Description: "Post another",
},
)
Expand Down Expand Up @@ -516,15 +495,15 @@ func runChatUpload(cmd *cobra.Command, app *appctx.App, chatID, project, filePat
breadcrumbs = append(breadcrumbs,
output.Breadcrumb{
Action: "messages",
Cmd: fmt.Sprintf("basecamp chat %s messages --in %s", chatID, resolvedProjectID),
Cmd: fmt.Sprintf("basecamp chat messages --chat %s --in %s", chatID, resolvedProjectID),
Description: "View messages",
},
)
} else {
breadcrumbs = append(breadcrumbs,
output.Breadcrumb{
Action: "messages",
Cmd: fmt.Sprintf("basecamp chat %s messages", chatID),
Cmd: fmt.Sprintf("basecamp chat messages --chat %s", chatID),
Description: "View messages",
},
)
Expand Down Expand Up @@ -620,7 +599,7 @@ You can pass either a line ID or a Basecamp line URL:
},
output.Breadcrumb{
Action: "messages",
Cmd: fmt.Sprintf("basecamp chat %s messages --in %s", effectiveChatID, resolvedProjectID),
Cmd: fmt.Sprintf("basecamp chat messages --chat %s --in %s", effectiveChatID, resolvedProjectID),
Description: "Back to messages",
},
),
Expand Down Expand Up @@ -718,7 +697,7 @@ You can pass either a line ID or a Basecamp line URL:
output.WithBreadcrumbs(
output.Breadcrumb{
Action: "messages",
Cmd: fmt.Sprintf("basecamp chat %s messages --in %s", effectiveChatID, resolvedProjectID),
Cmd: fmt.Sprintf("basecamp chat messages --chat %s --in %s", effectiveChatID, resolvedProjectID),
Description: "Back to messages",
},
),
Expand Down
131 changes: 131 additions & 0 deletions internal/commands/chat_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,137 @@ func TestChatListDisabledChat(t *testing.T) {
assert.Contains(t, e.Hint, "disabled")
}

// TestChatListMultipleChatsBreadcrumbs verifies breadcrumbs use
// --chat flag syntax with placeholder for multi-chat projects.
func TestChatListMultipleChatsBreadcrumbs(t *testing.T) {
app, buf := newTestAppWithTransport(t, &mockMultiChatTransport{})
app.Flags.Hints = true

cmd := NewChatCmd()
err := executeChatCommand(cmd, app, "list")
require.NoError(t, err)

var envelope struct {
Summary string `json:"summary"`
Breadcrumbs []struct {
Cmd string `json:"cmd"`
} `json:"breadcrumbs"`
}
require.NoError(t, json.Unmarshal(buf.Bytes(), &envelope))

assert.Contains(t, envelope.Summary, "2 chats")

require.NotEmpty(t, envelope.Breadcrumbs)
for _, bc := range envelope.Breadcrumbs {
assert.Contains(t, bc.Cmd, "--chat")
}
}

// TestChatListSingleChatSummary verifies title-based summary and
// concrete chat ID in breadcrumbs for single-chat projects.
func TestChatListSingleChatSummary(t *testing.T) {
transport := &mockSingleChatTransport{}
app, buf := newTestAppWithTransport(t, transport)
app.Flags.Hints = true

cmd := NewChatCmd()
err := executeChatCommand(cmd, app, "list")
require.NoError(t, err)

var envelope struct {
Data []map[string]any `json:"data"`
Summary string `json:"summary"`
Breadcrumbs []struct {
Cmd string `json:"cmd"`
} `json:"breadcrumbs"`
}
require.NoError(t, json.Unmarshal(buf.Bytes(), &envelope))

require.Len(t, envelope.Data, 1)
assert.Contains(t, envelope.Summary, "Team Chat")

require.NotEmpty(t, envelope.Breadcrumbs)
for _, bc := range envelope.Breadcrumbs {
assert.Contains(t, bc.Cmd, "--chat 501")
}
}

// mockSingleChatTransport returns a project with one chat dock entry.
type mockSingleChatTransport struct{}

func (t *mockSingleChatTransport) RoundTrip(req *http.Request) (*http.Response, error) {
header := make(http.Header)
header.Set("Content-Type", "application/json")

var body string
switch {
case strings.Contains(req.URL.Path, "/projects.json"):
body = `[{"id": 123, "name": "Test Project"}]`
case strings.Contains(req.URL.Path, "/projects/123"):
body = `{"id": 123, "dock": [{"name": "chat", "id": 501, "title": "Team Chat", "enabled": true}]}`
case strings.HasSuffix(req.URL.Path, "/chats/501"):
body = `{"id": 501, "title": "Team Chat", "type": "Chat::Transcript", "status": "active",` +
`"visible_to_clients": false, "inherits_status": true,` +
`"url": "https://example.com", "app_url": "https://example.com",` +
`"created_at": "2024-01-01T00:00:00Z", "updated_at": "2024-01-01T00:00:00Z"}`
default:
body = `{}`
}

return &http.Response{
StatusCode: 200,
Body: io.NopCloser(strings.NewReader(body)),
Header: header,
}, nil
}

// mockChatListAllTransport handles the account-wide chat list endpoint.
type mockChatListAllTransport struct{}

func (t *mockChatListAllTransport) RoundTrip(req *http.Request) (*http.Response, error) {
header := make(http.Header)
header.Set("Content-Type", "application/json")

if strings.HasSuffix(req.URL.Path, "/chats.json") {
body := `[{"id": 789, "title": "General", "type": "Chat::Transcript"}]`
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(strings.NewReader(body)),
Header: header,
}, nil
}

return &http.Response{
StatusCode: 200,
Body: io.NopCloser(strings.NewReader(`{}`)),
Header: header,
}, nil
}

// TestChatListAllBreadcrumbSyntax verifies that --all breadcrumbs use
// --chat flag syntax, not the old positional syntax.
func TestChatListAllBreadcrumbSyntax(t *testing.T) {
app, buf := newTestAppWithTransport(t, &mockChatListAllTransport{})
app.Flags.Hints = true

cmd := NewChatCmd()
err := executeChatCommand(cmd, app, "list", "--all")
require.NoError(t, err)

var envelope struct {
Breadcrumbs []struct {
Cmd string `json:"cmd"`
} `json:"breadcrumbs"`
}
require.NoError(t, json.Unmarshal(buf.Bytes(), &envelope))
require.NotEmpty(t, envelope.Breadcrumbs)

for _, bc := range envelope.Breadcrumbs {
assert.Contains(t, bc.Cmd, "--chat")
assert.NotContains(t, bc.Cmd, "chat <id> messages")
}
}

// TestChatPostViaSubcommandWithChatFlag verifies the proper way to post
// to a specific chat: `basecamp chat post <msg> --chat <id>`.
func TestChatPostViaSubcommandWithChatFlag(t *testing.T) {
Expand Down
Loading
Loading