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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .surface
Original file line number Diff line number Diff line change
Expand Up @@ -855,6 +855,7 @@ FLAG basecamp campfire delete --agent type=bool
FLAG basecamp campfire delete --cache-dir type=string
FLAG basecamp campfire delete --campfire type=string
FLAG basecamp campfire delete --count type=bool
FLAG basecamp campfire delete --force type=bool
FLAG basecamp campfire delete --hints type=bool
FLAG basecamp campfire delete --ids-only type=bool
FLAG basecamp campfire delete --in type=string
Expand Down
5 changes: 5 additions & 0 deletions internal/commands/boost.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package commands
import (
"fmt"
"strconv"
"unicode/utf8"

"github.com/spf13/cobra"

Expand Down Expand Up @@ -251,6 +252,10 @@ Use --event to boost a specific event within the item.`,
}

func runBoostCreate(cmd *cobra.Command, app *appctx.App, recording, project, content, eventID string) error {
if n := utf8.RuneCountInString(content); n > 16 {
return output.ErrUsage(fmt.Sprintf("Boost content too long (%d characters, max 16)", n))
}

recordingID, urlProjectID := extractWithProject(recording)

projectID := project
Expand Down
29 changes: 29 additions & 0 deletions internal/commands/boost_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,35 @@ func TestBoostShowNilBoosterSummary(t *testing.T) {
assert.NotContains(t, summary, "by ", "summary should not contain trailing 'by ' when booster is nil")
}

// TestBoostCreateRejectsLongContent verifies that boost create rejects content over 16 characters.
func TestBoostCreateRejectsLongContent(t *testing.T) {
t.Setenv("BASECAMP_NO_KEYRING", "1")

transport := &mockBoostTransport{}
app, _ := newBoostTestApp(transport)

cmd := NewBoostsCmd()
err := executeBoostCommand(cmd, app, "create", "456", "this is way long!")
require.Error(t, err)

var e *output.Error
require.True(t, errors.As(err, &e))
assert.Contains(t, e.Message, "Boost content too long")
}

// TestBoostCreateAcceptsMaxContent verifies that 16-character content passes validation.
func TestBoostCreateAcceptsMaxContent(t *testing.T) {
t.Setenv("BASECAMP_NO_KEYRING", "1")

transport := &mockBoostTransport{}
app, _ := newBoostTestApp(transport)

cmd := NewBoostsCmd()
err := executeBoostCommand(cmd, app, "create", "456", "exactly16chars!!")
require.NoError(t, err)
assert.Equal(t, "POST", transport.capturedMethod)
}

// mockBoostNilBoosterTransport returns a boost with no booster field.
type mockBoostNilBoosterTransport struct{}

Expand Down
21 changes: 20 additions & 1 deletion internal/commands/campfire.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"github.com/basecamp/basecamp-cli/internal/appctx"
"github.com/basecamp/basecamp-cli/internal/output"
"github.com/basecamp/basecamp-cli/internal/richtext"
"github.com/basecamp/basecamp-cli/internal/tui"
)

// NewCampfireCmd creates the campfire command for real-time chat.
Expand Down Expand Up @@ -573,11 +574,15 @@ You can pass either a line ID or a Basecamp line URL:
}

func newCampfireLineDeleteCmd(project, campfireID *string) *cobra.Command {
var force bool

cmd := &cobra.Command{
Use: "delete <id|url>",
Short: "Delete a message",
Long: `Delete a message line from a Campfire.

This permanently deletes the message — it is not moved to trash.

You can pass either a line ID or a Basecamp line URL:
basecamp campfire delete 789 --in my-project
basecamp campfire delete https://3.basecamp.com/123/buckets/456/chats/789/lines/111`,
Expand Down Expand Up @@ -632,6 +637,17 @@ You can pass either a line ID or a Basecamp line URL:
return output.ErrUsage("Invalid line ID")
}

// Confirm destructive action in interactive mode
if !force && !isMachineOutput(cmd) {
confirmed, err := tui.ConfirmDangerous("Permanently delete this campfire line?")
if err != nil {
return nil //nolint:nilerr // user canceled prompt
}
if !confirmed {
return nil
}
}

// Delete line using SDK
err = app.Account().Campfires().DeleteLine(cmd.Context(), campfireIDInt, lineIDInt)
if err != nil {
Expand All @@ -640,7 +656,7 @@ You can pass either a line ID or a Basecamp line URL:

summary := fmt.Sprintf("Deleted line #%s", lineID)

return app.OK(map[string]any{},
return app.OK(map[string]any{"deleted": true, "id": lineID},
output.WithSummary(summary),
output.WithBreadcrumbs(
output.Breadcrumb{
Expand All @@ -652,6 +668,9 @@ You can pass either a line ID or a Basecamp line URL:
)
},
}

cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation prompt")

return cmd
}

Expand Down
127 changes: 127 additions & 0 deletions internal/commands/campfire_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,73 @@ func (t *mockCampfireCreateTransport) RoundTrip(req *http.Request) (*http.Respon
return nil, errors.New("unexpected request")
}

// mockCampfireDeleteTransport handles resolver API calls and responds to DELETE requests.
type mockCampfireDeleteTransport struct {
capturedMethod string
capturedPath string
}

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

if req.Method == "GET" {
var body string
if strings.Contains(req.URL.Path, "/projects.json") {
body = `[{"id": 123, "name": "Test Project"}]`
} else if strings.Contains(req.URL.Path, "/projects/") {
body = `{"id": 123, "dock": [{"name": "chat", "id": 789, "enabled": true}]}`
} else {
body = `{}`
}
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(strings.NewReader(body)),
Header: header,
}, nil
}

if req.Method == "DELETE" {
t.capturedMethod = req.Method
t.capturedPath = req.URL.Path
return &http.Response{
StatusCode: 204,
Body: io.NopCloser(strings.NewReader("")),
Header: header,
}, nil
}

return nil, errors.New("unexpected request")
}

func newCampfireDeleteTestApp(transport http.RoundTripper) (*appctx.App, *bytes.Buffer) {
buf := &bytes.Buffer{}
cfg := &config.Config{
AccountID: "99999",
ProjectID: "123",
}

sdkCfg := &basecamp.Config{}
sdkClient := basecamp.NewClient(sdkCfg, &campfireTestTokenProvider{},
basecamp.WithTransport(transport),
basecamp.WithMaxRetries(1),
)
authMgr := auth.NewManager(cfg, nil)
nameResolver := names.NewResolver(sdkClient, authMgr, cfg.AccountID)

app := &appctx.App{
Config: cfg,
Auth: authMgr,
SDK: sdkClient,
Names: nameResolver,
Output: output.New(output.Options{
Format: output.FormatJSON,
Writer: buf,
}),
}
return app, buf
}

// executeCampfireCommand executes a cobra command with the given args.
func executeCampfireCommand(cmd *cobra.Command, app *appctx.App, args ...string) error {
cmd.SetArgs(args)
Expand Down Expand Up @@ -284,3 +351,63 @@ func TestCampfirePostViaSubcommandWithCampfireFlag(t *testing.T) {
assert.Equal(t, "<b>Hello</b>", requestBody["content"],
"content should be passed through subcommand path")
}

// TestCampfireDeleteReturnsDeletedPayload verifies that delete returns {"deleted": true, "id": "..."}.
func TestCampfireDeleteReturnsDeletedPayload(t *testing.T) {
t.Setenv("BASECAMP_NO_KEYRING", "1")

transport := &mockCampfireDeleteTransport{}
app, buf := newCampfireDeleteTestApp(transport)
app.Flags.Agent = true // skip confirmation prompt

cmd := NewCampfireCmd()
err := executeCampfireCommand(cmd, app, "delete", "111", "--force")
require.NoError(t, err)

assert.Equal(t, "DELETE", transport.capturedMethod)

var envelope map[string]any
err = json.Unmarshal(buf.Bytes(), &envelope)
require.NoError(t, err)

data, ok := envelope["data"].(map[string]any)
require.True(t, ok, "expected data object in envelope")
assert.Equal(t, true, data["deleted"])
assert.Equal(t, "111", data["id"])
}

// TestCampfireDeleteSkipsPromptInAgentMode verifies that --agent mode skips the
// confirmation prompt and issues the DELETE call.
func TestCampfireDeleteSkipsPromptInAgentMode(t *testing.T) {
t.Setenv("BASECAMP_NO_KEYRING", "1")

transport := &mockCampfireDeleteTransport{}
app, _ := newCampfireDeleteTestApp(transport)
app.Flags.Agent = true // machine output — no prompt

cmd := NewCampfireCmd()
err := executeCampfireCommand(cmd, app, "delete", "111")
require.NoError(t, err)

assert.Equal(t, "DELETE", transport.capturedMethod)
assert.Contains(t, transport.capturedPath, "/lines/")
}

// TestCampfireDeleteForceSkipsPrompt verifies that --force bypasses the confirmation
// prompt even when not in machine-output mode.
func TestCampfireDeleteForceSkipsPrompt(t *testing.T) {
t.Setenv("BASECAMP_NO_KEYRING", "1")

transport := &mockCampfireDeleteTransport{}
app, _ := newCampfireDeleteTestApp(transport)
// Flags.Agent is false — not in machine mode.
// Test stdout is *bytes.Buffer (not *os.File), so isMachineOutput TTY check
// falls through to false. Without --force this would attempt tui.ConfirmDangerous.

cmd := NewCampfireCmd()
err := executeCampfireCommand(cmd, app, "delete", "111", "--force")
require.NoError(t, err)

assert.Equal(t, "DELETE", transport.capturedMethod)
assert.Contains(t, transport.capturedPath, "/lines/")
}
11 changes: 11 additions & 0 deletions internal/commands/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package commands
import (
"fmt"
"strconv"
"unicode/utf8"

"github.com/spf13/cobra"

Expand Down Expand Up @@ -147,6 +148,12 @@ For example, clone a Campfire to create a second chat room in the same project.`
title = args[0]
}

if title != "" {
if n := utf8.RuneCountInString(title); n > 64 {
return output.ErrUsage(fmt.Sprintf("Tool name too long (%d characters, max 64)", n))
}
}

// Resolve project, with interactive fallback
projectID := *project
if projectID == "" {
Expand Down Expand Up @@ -232,6 +239,10 @@ func newToolsUpdateCmd(project *string) *cobra.Command {

title := args[1]

if n := utf8.RuneCountInString(title); n > 64 {
return output.ErrUsage(fmt.Sprintf("Tool name too long (%d characters, max 64)", n))
}

// Resolve project, with interactive fallback
projectID := *project
if projectID == "" {
Expand Down
71 changes: 71 additions & 0 deletions internal/commands/tools_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package commands

import (
"errors"
"strings"
"testing"

"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -83,3 +84,73 @@ func TestToolsRepositionRequiresPosition(t *testing.T) {
require.True(t, errors.As(err, &e))
assert.Equal(t, "--position is required (1-based)", e.Message)
}

// TestToolsUpdateRejectsLongTitle verifies that tool rename rejects titles over 64 characters.
func TestToolsUpdateRejectsLongTitle(t *testing.T) {
app, _ := setupTestApp(t)
app.Config.ProjectID = "123"

project := ""
cmd := newToolsUpdateCmd(&project)

longTitle := strings.Repeat("x", 65)
err := executeCommand(cmd, app, "456", longTitle)
require.NotNil(t, err)

var e *output.Error
require.True(t, errors.As(err, &e))
assert.Contains(t, e.Message, "Tool name too long")
}

// TestToolsUpdateAcceptsMaxTitle verifies that a 64-character title passes validation.
func TestToolsUpdateAcceptsMaxTitle(t *testing.T) {
app, _ := setupTestApp(t)
app.Config.ProjectID = "123"

project := ""
cmd := newToolsUpdateCmd(&project)

maxTitle := strings.Repeat("x", 64)
err := executeCommand(cmd, app, "456", maxTitle)
require.NotNil(t, err) // fails at network, not validation

var e *output.Error
if errors.As(err, &e) {
assert.NotContains(t, e.Message, "too long")
}
}

// TestToolsCreateRejectsLongTitle verifies that tool create rejects titles over 64 characters.
func TestToolsCreateRejectsLongTitle(t *testing.T) {
app, _ := setupTestApp(t)
app.Config.ProjectID = "123"

project := ""
cmd := newToolsCreateCmd(&project)

longTitle := strings.Repeat("x", 65)
err := executeCommand(cmd, app, "--source", "999", longTitle)
require.NotNil(t, err)

var e *output.Error
require.True(t, errors.As(err, &e))
assert.Contains(t, e.Message, "Tool name too long")
}

// TestToolsCreateAcceptsMaxTitle verifies that a 64-character title passes create validation.
func TestToolsCreateAcceptsMaxTitle(t *testing.T) {
app, _ := setupTestApp(t)
app.Config.ProjectID = "123"

project := ""
cmd := newToolsCreateCmd(&project)

maxTitle := strings.Repeat("x", 64)
err := executeCommand(cmd, app, "--source", "999", maxTitle)
require.NotNil(t, err) // fails at network, not validation

var e *output.Error
if errors.As(err, &e) {
assert.NotContains(t, e.Message, "too long")
}
}
2 changes: 1 addition & 1 deletion skills/basecamp/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -515,7 +515,7 @@ basecamp campfire --in <project> --json # List campfires
basecamp campfire messages --in <project> --json # List messages
basecamp campfire post "Hello!" --in <project>
basecamp campfire line <line_id> --in <project> # Show line
basecamp campfire delete <line_id> --in <project> # Delete line
basecamp campfire delete <line_id> --in <project> --force # Delete line (permanent, not trashable)
```

### People
Expand Down
Loading