Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
7e149c9
Bump basecamp-sdk to v0.7.0
jeremy Mar 24, 2026
014f535
Add attachments download command with parallel download and stdout st…
jeremy Mar 24, 2026
363f6d7
Surface inline_attachments in show commands with notice and breadcrumb
jeremy Mar 24, 2026
cddbad3
Add inline attachment workflow recipe and API communique
jeremy Mar 24, 2026
b6aee2b
Field-scoped attachment collections, notice composition, integer prec…
jeremy Mar 24, 2026
ada58ca
Return error when all attachment downloads fail
jeremy Mar 24, 2026
b4d296c
Address review: Args validator, smoke test isolation, PR description …
jeremy Mar 24, 2026
d35ed30
UseNumber in fetchItemContent for line/reply parent ID precision
jeremy Mar 24, 2026
e6b9306
Fail on no downloadable URLs; use deduped filename in error results
jeremy Mar 24, 2026
e809805
Fix NoOptDefVal for --download-attachments; --file on non-downloadabl…
jeremy Mar 24, 2026
966cb65
Fix misleading breadcrumb, path traversal error type, show attachment…
jeremy Mar 24, 2026
04e6725
Update CLI surface snapshot and supporting changes
jeremy Mar 24, 2026
aa10ad2
Regenerate CLI surface snapshot after rebase
jeremy Mar 24, 2026
fbdcb2b
Regenerate CLI surface after rebase onto sdk-0.7.0
jeremy Mar 24, 2026
17022b9
Close remaining test gaps: files download --out -, show collision sce…
jeremy Mar 24, 2026
84145ee
Tighten tests, deduplicate project resolution, add Args validators
jeremy Mar 24, 2026
15bcaf1
Assert both sgid and filename in native attachments preservation test
jeremy Mar 24, 2026
38723c6
Regenerate CLI surface after rebase onto sdk-0.7.0
jeremy Mar 25, 2026
7af2ada
Use structured JSON assertions in native attachments test
jeremy Mar 25, 2026
7f75d13
Bump SDK to v0.7.1, accept inbox_forwards URL type in attachments
jeremy Mar 25, 2026
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
41 changes: 41 additions & 0 deletions .surface
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ ARG basecamp assign 00 <id|url>...
ARG basecamp assignments due 00 [scope]
ARG basecamp attach 00 <file>
ARG basecamp attach 01 [<file2]
ARG basecamp attachments download 00 <id|url>
ARG basecamp attachments list 00 <id|url>
ARG basecamp bonfire layout load 00 <name>
ARG basecamp bonfire layout save 00 <name>
Expand Down Expand Up @@ -447,6 +448,7 @@ CMD basecamp assignments due
CMD basecamp assignments list
CMD basecamp attach
CMD basecamp attachments
CMD basecamp attachments download
CMD basecamp attachments list
CMD basecamp auth
CMD basecamp auth login
Expand Down Expand Up @@ -1676,6 +1678,31 @@ FLAG basecamp attachments --stats type=bool
FLAG basecamp attachments --styled type=bool
FLAG basecamp attachments --todolist type=string
FLAG basecamp attachments --verbose type=count
FLAG basecamp attachments download --account type=string
FLAG basecamp attachments download --agent type=bool
FLAG basecamp attachments download --cache-dir type=string
FLAG basecamp attachments download --count type=bool
FLAG basecamp attachments download --file type=string
FLAG basecamp attachments download --help type=bool
FLAG basecamp attachments download --hints type=bool
FLAG basecamp attachments download --ids-only type=bool
FLAG basecamp attachments download --in type=string
FLAG basecamp attachments download --index type=int
FLAG basecamp attachments download --jq type=string
FLAG basecamp attachments download --json type=bool
FLAG basecamp attachments download --markdown type=bool
FLAG basecamp attachments download --md type=bool
FLAG basecamp attachments download --no-hints type=bool
FLAG basecamp attachments download --no-stats type=bool
FLAG basecamp attachments download --out type=string
FLAG basecamp attachments download --profile type=string
FLAG basecamp attachments download --project type=string
FLAG basecamp attachments download --quiet type=bool
FLAG basecamp attachments download --stats type=bool
FLAG basecamp attachments download --styled type=bool
FLAG basecamp attachments download --todolist type=string
FLAG basecamp attachments download --type type=string
FLAG basecamp attachments download --verbose type=count
FLAG basecamp attachments list --account type=string
FLAG basecamp attachments list --agent type=bool
FLAG basecamp attachments list --cache-dir type=string
Expand Down Expand Up @@ -2882,6 +2909,7 @@ FLAG basecamp cards show --agent type=bool
FLAG basecamp cards show --cache-dir type=string
FLAG basecamp cards show --card-table type=string
FLAG basecamp cards show --count type=bool
FLAG basecamp cards show --download-attachments type=string
FLAG basecamp cards show --help type=bool
FLAG basecamp cards show --hints type=bool
FLAG basecamp cards show --ids-only type=bool
Expand Down Expand Up @@ -4890,6 +4918,7 @@ FLAG basecamp docs show --account type=string
FLAG basecamp docs show --agent type=bool
FLAG basecamp docs show --cache-dir type=string
FLAG basecamp docs show --count type=bool
FLAG basecamp docs show --download-attachments type=string
FLAG basecamp docs show --folder type=string
FLAG basecamp docs show --help type=bool
FLAG basecamp docs show --hints type=bool
Expand Down Expand Up @@ -5789,6 +5818,7 @@ FLAG basecamp documents show --account type=string
FLAG basecamp documents show --agent type=bool
FLAG basecamp documents show --cache-dir type=string
FLAG basecamp documents show --count type=bool
FLAG basecamp documents show --download-attachments type=string
FLAG basecamp documents show --folder type=string
FLAG basecamp documents show --help type=bool
FLAG basecamp documents show --hints type=bool
Expand Down Expand Up @@ -6712,6 +6742,7 @@ FLAG basecamp file show --account type=string
FLAG basecamp file show --agent type=bool
FLAG basecamp file show --cache-dir type=string
FLAG basecamp file show --count type=bool
FLAG basecamp file show --download-attachments type=string
FLAG basecamp file show --folder type=string
FLAG basecamp file show --help type=bool
FLAG basecamp file show --hints type=bool
Expand Down Expand Up @@ -7590,6 +7621,7 @@ FLAG basecamp files show --account type=string
FLAG basecamp files show --agent type=bool
FLAG basecamp files show --cache-dir type=string
FLAG basecamp files show --count type=bool
FLAG basecamp files show --download-attachments type=string
FLAG basecamp files show --folder type=string
FLAG basecamp files show --help type=bool
FLAG basecamp files show --hints type=bool
Expand Down Expand Up @@ -8468,6 +8500,7 @@ FLAG basecamp folders show --account type=string
FLAG basecamp folders show --agent type=bool
FLAG basecamp folders show --cache-dir type=string
FLAG basecamp folders show --count type=bool
FLAG basecamp folders show --download-attachments type=string
FLAG basecamp folders show --folder type=string
FLAG basecamp folders show --help type=bool
FLAG basecamp folders show --hints type=bool
Expand Down Expand Up @@ -9692,6 +9725,7 @@ FLAG basecamp messages show --account type=string
FLAG basecamp messages show --agent type=bool
FLAG basecamp messages show --cache-dir type=string
FLAG basecamp messages show --count type=bool
FLAG basecamp messages show --download-attachments type=string
FLAG basecamp messages show --help type=bool
FLAG basecamp messages show --hints type=bool
FLAG basecamp messages show --ids-only type=bool
Expand Down Expand Up @@ -10098,6 +10132,7 @@ FLAG basecamp msgs show --account type=string
FLAG basecamp msgs show --agent type=bool
FLAG basecamp msgs show --cache-dir type=string
FLAG basecamp msgs show --count type=bool
FLAG basecamp msgs show --download-attachments type=string
FLAG basecamp msgs show --help type=bool
FLAG basecamp msgs show --hints type=bool
FLAG basecamp msgs show --ids-only type=bool
Expand Down Expand Up @@ -11515,6 +11550,7 @@ FLAG basecamp show --account type=string
FLAG basecamp show --agent type=bool
FLAG basecamp show --cache-dir type=string
FLAG basecamp show --count type=bool
FLAG basecamp show --download-attachments type=string
FLAG basecamp show --help type=bool
FLAG basecamp show --hints type=bool
FLAG basecamp show --ids-only type=bool
Expand Down Expand Up @@ -13389,6 +13425,7 @@ FLAG basecamp todos show --account type=string
FLAG basecamp todos show --agent type=bool
FLAG basecamp todos show --cache-dir type=string
FLAG basecamp todos show --count type=bool
FLAG basecamp todos show --download-attachments type=string
FLAG basecamp todos show --help type=bool
FLAG basecamp todos show --hints type=bool
FLAG basecamp todos show --ids-only type=bool
Expand Down Expand Up @@ -13973,6 +14010,7 @@ FLAG basecamp uploads show --account type=string
FLAG basecamp uploads show --agent type=bool
FLAG basecamp uploads show --cache-dir type=string
FLAG basecamp uploads show --count type=bool
FLAG basecamp uploads show --download-attachments type=string
FLAG basecamp uploads show --folder type=string
FLAG basecamp uploads show --help type=bool
FLAG basecamp uploads show --hints type=bool
Expand Down Expand Up @@ -14542,6 +14580,7 @@ FLAG basecamp vault show --account type=string
FLAG basecamp vault show --agent type=bool
FLAG basecamp vault show --cache-dir type=string
FLAG basecamp vault show --count type=bool
FLAG basecamp vault show --download-attachments type=string
FLAG basecamp vault show --folder type=string
FLAG basecamp vault show --help type=bool
FLAG basecamp vault show --hints type=bool
Expand Down Expand Up @@ -15420,6 +15459,7 @@ FLAG basecamp vaults show --account type=string
FLAG basecamp vaults show --agent type=bool
FLAG basecamp vaults show --cache-dir type=string
FLAG basecamp vaults show --count type=bool
FLAG basecamp vaults show --download-attachments type=string
FLAG basecamp vaults show --folder type=string
FLAG basecamp vaults show --help type=bool
FLAG basecamp vaults show --hints type=bool
Expand Down Expand Up @@ -16102,6 +16142,7 @@ SUB basecamp assignments due
SUB basecamp assignments list
SUB basecamp attach
SUB basecamp attachments
SUB basecamp attachments download
SUB basecamp attachments list
SUB basecamp auth
SUB basecamp auth login
Expand Down
2 changes: 2 additions & 0 deletions .surface-breaking
Original file line number Diff line number Diff line change
Expand Up @@ -728,3 +728,5 @@ FLAG basecamp chat list --chat type=string
FLAG basecamp chat messages --chat type=string
FLAG basecamp chat post --chat type=string
FLAG basecamp chat upload --chat type=string
FLAG basecamp recordings --assignee type=string
FLAG basecamp recordings list --assignee type=string
156 changes: 156 additions & 0 deletions COMMUNIQUE-inline-attachments-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
# Communique: Inline Attachment Metadata in Recording API Responses

**From:** Basecamp CLI team
**To:** BC3 Rails development
**Re:** Surfacing `<bc-attachment>` metadata as structured data in API responses
**Context:** CLI PR #326 — `basecamp attachments download`

---

## The problem

The CLI needs to let agents and humans download inline file attachments
(images, PDFs, etc.) embedded in messages, todos, cards, and documents.
Today the only way to discover these attachments is to parse the HTML body
and regex out `<bc-attachment>` elements:

```html
<bc-attachment sgid="BAh7CEk"
content-type="application/pdf"
href="https://storage.3.basecamp.com/123/blobs/abc/download/report.pdf"
filename="report.pdf"
filesize="12345">
</bc-attachment>
```

This works, but it's fragile client-side work that every API consumer has
to replicate independently — and it depends on Trix/Basecamp internal HTML
conventions that aren't part of the documented API contract.

## What we're doing now (client-side)

The CLI's `richtext.ExtractAttachments(html)` function:

1. Regex-scans for `<bc-attachment ...>` opening tags
2. Skips mentions (`content-type="application/vnd.basecamp.mention"`)
3. Skips tags without `href` (not downloadable)
4. Extracts `href`, `filename`, `filesize`, `content-type`, `sgid`

This produces an `[]InlineAttachment` array that the CLI surfaces as an
`inline_attachments` field in show command responses and uses as the
download manifest for `basecamp attachments download`.

### Pain points

**Content field ambiguity.** The rich-text body lives in different fields
depending on recording type:

| Type | Plain-text field | Rich HTML field |
|----------|-----------------|-----------------|
| Todo | `content` | `description` |
| Message | `subject` | `content` |
| Card | — | `content` |
| Document | `title` | `content` |

The CLI has to sniff both `content` and `description`, check which one
contains HTML, and pick the right one. This is the kind of thing that
breaks silently.

**Storage URL opacity.** The `href` values in `<bc-attachment>` tags are
storage URLs (`https://storage.3.basecamp.com/...`). The SDK rewrites
these through the API host for auth, then follows a redirect to a signed
S3 URL. This two-hop dance works, but the URLs aren't guaranteed stable —
they're an implementation detail of how Trix stores blob references.

**No discoverability.** An API consumer can't tell whether a recording has
inline attachments without fetching and parsing the full HTML body. For
agents that want to decide whether to download images for multimodal
analysis, this is a wasted round-trip.

## Proposed API enhancement

Add an `inline_attachments` array to recording responses that contain rich
text. Return it alongside the existing `content`/`description` fields.

```json
{
"id": 789,
"type": "Message",
"subject": "Q4 Report",
"content": "<p>See attached: <bc-attachment ...>report.pdf</bc-attachment></p>",
"inline_attachments": [
{
"sgid": "BAh7CEk",
"filename": "report.pdf",
"content_type": "application/pdf",
"byte_size": 12345,
"download_url": "https://3.basecampapi.com/123/blobs/abc/download/report.pdf"
}
]
}
```

Fields:

- **`sgid`** — the signed global ID (already in the HTML, used for
ActionText references)
- **`filename`** — original upload filename
- **`content_type`** — MIME type
- **`byte_size`** — integer, not string (the HTML `filesize` attribute is
a string today)
- **`download_url`** — a stable API-routable URL that the client can GET
with auth headers, rather than a raw storage URL that requires
rewriting. Ideally the same URL shape that `Upload#download_url` already
returns.

### Scope

Only file attachments. Mentions (`application/vnd.basecamp.mention`) are
excluded. Tags without a downloadable blob reference are excluded.

### Which recording types

Any type whose API response includes a rich-text HTML body:

- `Message` (field: `content`)
- `Todo` (field: `description`)
- `Kanban::Card` (field: `content`)
- `Document` (field: `content`)
- `Comment` (field: `content`)
- `Question::Answer` (field: `content`)

The field would appear only when attachments are present (empty array or
omitted when none).

## What this unblocks

- **CLI:** Drop the regex parser, use structured data, remove the
content-vs-description sniffing
- **SDK:** Add `InlineAttachments` field to recording structs
- **Agents:** Discover attachments from list/show responses without
parsing HTML — enables "does this message have images I should look at?"
decisions
- **Third-party integrations:** Any API consumer that wants to mirror or
process attachments gets a stable contract instead of HTML scraping

## Impact on existing behavior

Additive. The HTML body continues to contain `<bc-attachment>` elements
as before. The new field is additional structured metadata derived from
the same source. No breaking changes.

## Migration path

If this ships, the CLI can:

1. Check for `inline_attachments` in the API response
2. Fall back to `richtext.ExtractAttachments(html)` when absent (older
API versions, or types not yet covered)
3. Eventually remove the regex path once the API field is universal

---

**Question for BC3:** Is this something that could be derived at the
serializer level (walking the ActionText body's `<bc-attachment>` nodes
and emitting structured metadata), or does it need deeper plumbing through
the blob/attachment infrastructure?
18 changes: 18 additions & 0 deletions e2e/smoke/smoke_communication.bats
Original file line number Diff line number Diff line change
Expand Up @@ -128,3 +128,21 @@ setup_file() {
assert_success
assert_json_value '.ok' 'true'
}

# --- Attachments ---

@test "attachments download handles recording without attachments" {
ensure_message || return 0

# A message without inline attachments produces a structured error.
# Use --out to avoid polluting the working directory if the message
# happens to contain attachments.
run_smoke basecamp attachments download "$QA_MESSAGE" --out "$BATS_FILE_TMPDIR" --json
if [[ "$status" -eq 0 ]]; then
# Recording has attachments — verify structured result
assert_json_value '.ok' 'true'
else
# No attachments — verify the specific error message
assert_output_contains "No downloadable attachments found"
fi
}
1 change: 1 addition & 0 deletions internal/commands/assignments.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ func NewAssignmentsCmd() *cobra.Command {

Shows both priority and non-priority items. Use subcommands to filter
by completion status or due date.`,
Args: cobra.NoArgs,
Annotations: map[string]string{
"agent_notes": "Account-wide — no --in <project> needed.\n" +
"Shows priorities and non-priorities.\n" +
Expand Down
Loading
Loading