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
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,20 @@
## Quick Start

```bash
brew install --cask basecamp/tap/basecamp
basecamp auth login
curl -fsSL https://basecamp.com/install-cli | bash
```

That's it. You now have full access to Basecamp from your terminal.

<details>
<summary>Other installation methods</summary>

**Brew / macOS**

```
brew install --cask basecamp/tap/basecamp
```

**Arch Linux / Omarchy (AUR):**
```bash
yay -S basecamp-cli
Expand Down
19 changes: 9 additions & 10 deletions install.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,13 @@ Install the Basecamp CLI and connect it to your AI agent. Execute all steps auto

## Step 1: Install Basecamp CLI

Choose ONE installation method:
Run this in your terminal:

```bash
curl -fsSL https://basecamp.com/install-cli | bash
```

Alternatively install manually:

### Option A: Homebrew (macOS/Linux) — Recommended
```bash
Expand All @@ -43,19 +49,12 @@ Arm64: substitute `arm64` for `amd64` in the filename. Verify the SHA-256 checks
nix profile install github:basecamp/basecamp-cli
```

### Option E: Shell script
```bash
curl -fsSL https://raw.githubusercontent.com/basecamp/basecamp-cli/main/scripts/install.sh | bash
```

The install script downloads the latest release, verifies the SHA-256 checksum, and verifies the cosign signature when cosign is available.

### Option F: Go install
### Option E: Go install
```bash
go install github.com/basecamp/basecamp-cli/cmd/basecamp@latest
```

### Option G: GitHub Release
### Option F: GitHub Release
Download the archive for your platform from [Releases](https://github.com/basecamp/basecamp-cli/releases), extract, and move `basecamp` to a directory on your PATH.

**Verify:**
Expand Down
15 changes: 12 additions & 3 deletions internal/commands/people.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"os"
"strconv"
"strings"

"github.com/spf13/cobra"

Expand Down Expand Up @@ -83,9 +84,7 @@ func runMe(cmd *cobra.Command, args []string) error {
return convertSDKError(err)
}

// Store user email for "me" resolution in future commands (non-fatal if fails).
// Note: authInfo.Identity.ID is a cross-account identity ID, not an account-scoped
// person ID, so we only store the email here.
// Store user email for display purposes (non-fatal if fails).
_ = app.Auth.SetUserEmail(authInfo.Identity.EmailAddress)

Comment thread
jeremy marked this conversation as resolved.
// Build account output (already filtered to bc3 by SDK)
Expand Down Expand Up @@ -310,6 +309,16 @@ func runPeopleShow(cmd *cobra.Command, args []string) error {
return err
}

// "me" can be answered directly by /my/profile.json — no need to
// resolve to an ID and then re-fetch the same person.
if strings.EqualFold(args[0], "me") {
person, err := app.Account().People().Me(cmd.Context())
if err != nil {
return convertSDKError(err)
}
return app.OK(person, output.WithSummary(person.Name))
}

// Resolve person name/ID
personIDStr, _, err := app.Names.ResolvePerson(cmd.Context(), args[0])
if err != nil {
Expand Down
37 changes: 28 additions & 9 deletions internal/commands/todos.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ func NewTodosCmd() *cobra.Command {
Use: "todos",
Short: "Manage todos",
Long: "List, show, create, and manage Basecamp todos.",
Annotations: map[string]string{"agent_notes": "--assignee only works on todos, not cards or other recording types\nbasecamp done accepts multiple IDs: basecamp done 1 2 3\nCross-project assigned todos: use basecamp reports assigned --json (recordings lacks assignee data)\nUse basecamp todo --content \"text\" not basecamp todo \"text\""},
Annotations: map[string]string{"agent_notes": "--assignee only works on todos, not cards or other recording types\nbasecamp done accepts multiple IDs: basecamp done 1 2 3\n--assignee and --overdue require a project (--in, global flag, or config default); for cross-project use basecamp reports assigned/overdue\nUse basecamp todo --content \"text\" not basecamp todo \"text\""},
RunE: func(cmd *cobra.Command, args []string) error {
// Default to list when called without subcommand
return runTodosList(cmd, flags)
Expand Down Expand Up @@ -281,6 +281,24 @@ func runTodosList(cmd *cobra.Command, flags todosListFlags) error {
return err
}

// --assignee and --overdue filter within a single project. When no
// project is set anywhere (flag, global flag, config), the interactive
// picker would silently scope results to one arbitrary project. Error
// early and point to the Reports API for cross-project queries.
projectKnown := flags.project != "" || app.Flags.Project != "" || app.Config.ProjectID != ""
if !projectKnown {
if flags.assignee != "" {
return output.ErrUsageHint(
"--assignee requires a project (--in or default config)",
"For cross-project assigned todos: basecamp reports assigned")
}
if flags.overdue {
return output.ErrUsageHint(
"--overdue requires a project (--in or default config)",
"For cross-project overdue todos: basecamp reports overdue")
}
}
Comment thread
jeremy marked this conversation as resolved.

// Use project from flag or config, with interactive fallback
project := flags.project
if project == "" {
Expand Down Expand Up @@ -849,13 +867,13 @@ Actions (at least one required):

Examples:
# Preview overdue todos without taking action
basecamp todos sweep --overdue --dry-run
basecamp todos sweep --in <project> --overdue --dry-run

# Complete all overdue todos with a comment
basecamp todos sweep --overdue --complete --comment "Cleaning up overdue items"
basecamp todos sweep --in <project> --overdue --complete --comment "Cleaning up overdue items"

# Add comment to all todos assigned to me
basecamp todos sweep --assignee me --comment "Following up"`,
basecamp todos sweep --in <project> --assignee me --comment "Following up"`,
RunE: func(cmd *cobra.Command, args []string) error {
app := appctx.FromContext(cmd.Context())
if err := ensureAccount(cmd, app); err != nil {
Expand All @@ -872,18 +890,19 @@ Examples:
return output.ErrUsageHint("Sweep requires an action", "Use --comment and/or --complete")
}

// Resolve project, with interactive fallback
// Resolve project from flag, global flag, or config default.
// Don't fall through to interactive picker for sweep — acting
// on an arbitrary project chosen mid-flow is too risky.
if project == "" {
project = app.Flags.Project
}
if project == "" {
project = app.Config.ProjectID
}
if project == "" {
if err := ensureProject(cmd, app); err != nil {
return err
}
project = app.Config.ProjectID
return output.ErrUsageHint(
"Sweep requires a project",
"Use --in <project> or set a default with: basecamp config set project <name>")
}

// Resolve project name to ID
Expand Down
79 changes: 79 additions & 0 deletions internal/commands/todos_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -409,3 +409,82 @@ func TestTodosCreateContentIsPlainText(t *testing.T) {
assert.Equal(t, plainTextContent, content,
"Todo content should be plain text, not HTML-wrapped")
}

func TestTodosAssigneeWithoutProjectErrors(t *testing.T) {
app, _ := setupTodosTestApp(t)

cmd := NewTodosCmd()
err := executeTodosCommand(cmd, app, "--assignee", "me")
require.Error(t, err)

var e *output.Error
require.True(t, errors.As(err, &e))
assert.Contains(t, e.Message, "--assignee requires a project")
assert.Contains(t, e.Hint, "reports assigned")
}

func TestTodosOverdueWithoutProjectErrors(t *testing.T) {
app, _ := setupTodosTestApp(t)

cmd := NewTodosCmd()
err := executeTodosCommand(cmd, app, "--overdue")
require.Error(t, err)

var e *output.Error
require.True(t, errors.As(err, &e))
assert.Contains(t, e.Message, "--overdue requires a project")
assert.Contains(t, e.Hint, "reports overdue")
}

func TestTodosAssigneeWithConfigDefaultProceeds(t *testing.T) {
app, _ := setupTodosTestApp(t)
app.Config.ProjectID = "123"

cmd := NewTodosCmd()
err := executeTodosCommand(cmd, app, "--assignee", "me")
require.Error(t, err)

// Should proceed past the guard and fail on network (not the project error)
var e *output.Error
if errors.As(err, &e) {
assert.NotContains(t, e.Message, "--assignee requires a project")
}
}
Comment thread
jeremy marked this conversation as resolved.

func TestTodosAssigneeWithFlagProceeds(t *testing.T) {
app, _ := setupTodosTestApp(t)

cmd := NewTodosCmd()
err := executeTodosCommand(cmd, app, "--assignee", "me", "--in", "123")
require.Error(t, err)

// Should proceed past the guard and fail on project fetch (network disabled)
var e *output.Error
if errors.As(err, &e) {
assert.NotContains(t, e.Message, "--assignee requires a project")
}
}

func TestTodosSweepWithoutProjectErrors(t *testing.T) {
app, _ := setupTodosTestApp(t)

cmd := NewTodosCmd()
err := executeTodosCommand(cmd, app, "sweep", "--assignee", "me", "--comment", "test")
require.Error(t, err)

var e *output.Error
require.True(t, errors.As(err, &e))
assert.Contains(t, e.Message, "Sweep requires a project")
}

func TestTodosSweepOverdueWithoutProjectErrors(t *testing.T) {
app, _ := setupTodosTestApp(t)

cmd := NewTodosCmd()
err := executeTodosCommand(cmd, app, "sweep", "--overdue", "--complete")
require.Error(t, err)

var e *output.Error
require.True(t, errors.As(err, &e))
assert.Contains(t, e.Message, "Sweep requires a project")
}
60 changes: 46 additions & 14 deletions internal/names/resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,16 @@ type Resolver struct {
auth *auth.Manager
accountID string

// resolveMeFn overrides the "me" resolution path. Nil in production
// (uses SDK People().Me), set in tests to return canned values.
resolveMeFn func(context.Context) (int64, string, error)

// Session-scoped cache
mu sync.RWMutex
projects []Project
people []Person
todolists map[string][]Todolist // keyed by project ID
me *Person // cached /my/profile.json result
}

// Project represents a Basecamp project for name resolution.
Expand Down Expand Up @@ -75,6 +80,7 @@ func (r *Resolver) SetAccountID(accountID string) {
// Clear cache since data is account-specific
r.projects = nil
r.people = nil
r.me = nil
r.todolists = make(map[string][]Todolist)
}
}
Expand Down Expand Up @@ -139,24 +145,21 @@ func (r *Resolver) ResolveProject(ctx context.Context, input string) (string, st
// Special case: "me" resolves to the current user.
// Returns the ID and the person's name for display.
func (r *Resolver) ResolvePerson(ctx context.Context, input string) (string, string, error) {
// Handle "me" keyword — resolve via stored email against account people list.
// The stored user ID is a cross-account identity ID which doesn't match
// account-scoped person IDs, so we match by email instead.
if strings.ToLower(input) == "me" {
email := r.auth.GetUserEmail()
if email == "" {
return "", "", output.ErrAuth("Could not resolve your identity. Run: basecamp auth login")
// Handle "me" keyword — resolve via /my/profile.json which returns
// the authenticated user's account-scoped person record directly.
if strings.EqualFold(input, "me") {
if r.resolveMeFn != nil {
id, name, err := r.resolveMeFn(ctx)
if err != nil {
return "", "", err
}
return strconv.FormatInt(id, 10), name, nil
}
people, err := r.getPeople(ctx)
person, err := r.getMe(ctx)
if err != nil {
return "", "", err
}
for _, p := range people {
if strings.EqualFold(p.Email, email) {
return strconv.FormatInt(p.ID, 10), p.Name, nil
}
}
return "", "", output.ErrAuth(fmt.Sprintf("Your email (%s) was not found in this account. Check your account selection or run: basecamp auth login", email))
return strconv.FormatInt(person.ID, 10), person.Name, nil
}

// Numeric ID passthrough
Expand Down Expand Up @@ -265,11 +268,40 @@ func (r *Resolver) ClearCache() {
defer r.mu.Unlock()
r.projects = nil
r.people = nil
r.me = nil
r.todolists = make(map[string][]Todolist)
}

// Data fetching with caching

func (r *Resolver) getMe(ctx context.Context) (*Person, error) {
r.mu.RLock()
if r.me != nil {
defer r.mu.RUnlock()
return r.me, nil
}
r.mu.RUnlock()

r.mu.Lock()
defer r.mu.Unlock()

if r.me != nil {
return r.me, nil
}

person, err := r.forAccount().People().Me(ctx)
if err != nil {
return nil, convertSDKError(err)
}

Comment thread
jeremy marked this conversation as resolved.
r.me = &Person{
ID: person.ID,
Name: person.Name,
Email: person.EmailAddress,
}
return r.me, nil
}

func (r *Resolver) getProjects(ctx context.Context) ([]Project, error) {
r.mu.RLock()
if r.projects != nil {
Expand Down
Loading
Loading