Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
4512bae
feat(api): add ForEachPage so list commands share one paging loop
kshahbw Aug 17, 2026
39b5d26
fix(api): remove pageSize default to match EncodeQuery convention
kshahbw Aug 17, 2026
2b67f02
feat(customerprofile): add create, update, delete, and history servic…
kshahbw Aug 17, 2026
a3f96df
fix(customerprofile): guard JSON raw helpers against XML clients, cov…
kshahbw Aug 18, 2026
60d1f2f
feat(customerprofile): add option structs and a lossless update overlay
kshahbw Aug 18, 2026
2ae53be
fix(customerprofile): deep-copy nested maps in the update overlay
kshahbw Aug 18, 2026
2d132b0
feat(customerprofile): add create, list, and get commands
kshahbw Aug 18, 2026
9b0ca97
test(customerprofile): use testutil.NewTestRoot/CaptureStdout in the …
kshahbw Aug 18, 2026
e1acf70
test(customerprofile): add regression guard for runCmd's shared testRoot
kshahbw Aug 18, 2026
170c196
feat(customerprofile): add update with a lossless read-modify-write path
kshahbw Aug 18, 2026
b97a59a
fix(customerprofile): send null, not empty string, to clear a field o…
kshahbw Aug 18, 2026
2594201
feat(customerprofile): add delete with --confirm and restore
kshahbw Aug 18, 2026
7ae68c3
feat(customerprofile): add history list and history get
kshahbw Aug 18, 2026
9376191
fix(customerprofile): warn on truncated history list, document respon…
kshahbw Aug 18, 2026
498d7a9
docs: document the customer-profile command tree
kshahbw Aug 18, 2026
b61d176
docs: fix AGENTS.md if-not-exists principle and delete/restore clarity
kshahbw Aug 18, 2026
b931788
fix(customerprofile): guard stray args, map role failures to exit 4, …
kshahbw Aug 18, 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
89 changes: 88 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
These principles guide how the CLI is built. If you're contributing changes, maintain them:

- **`--plain` output must be stable and parseable.** Agents depend on flat JSON. Don't change the shape of `--plain` output without a migration path.
- **`--if-not-exists` for idempotency.** Any create command should support this flag so agents can retry safely.
- **`--if-not-exists` for idempotency, where a safe natural key exists.** Create commands support this flag when there's a stable identity to retry against. A command that lacks one omits the flag deliberately and documents why (see [Customer Profiles](#customer-profiles)) rather than risk a retry silently reusing the wrong resource.
- **`--wait` for async operations.** Agents can't poll — give them a way to block until the operation completes.
- **Structured exit codes.** Agents use exit codes for control flow, not string parsing. See [Exit Codes](#exit-codes).
- **Update this file.** If you add, remove, or change a command, update this file alongside the README.
Expand Down Expand Up @@ -880,6 +880,93 @@ band tnoption assign +19195551234 --campaign-id CA3XKE1 --wait

**If `band tendlc` returns 403:** Don't retry — escalate. Tell the user: "Your credential may not have the Campaign Management role, or your account may not have the Registration Center feature enabled. Contact your Bandwidth account manager to check your configuration."

## Customer Profiles

A customer profile is required to register a 10DLC brand, and **a profile backs
exactly one brand** — reusing a profile ID on a second brand fails with
`cannot be assigned to another brand`. Create a fresh profile per brand. The
prerequisite chain is **customer profile → brand → campaign**; brand and
campaign registration still happen in the Bandwidth App, not the CLI.

Requires the **Customer Profiles Access role** — check with `band auth status --plain`.

### Create, list, and get

```bash
band customer-profile create --name "Acme Corp" --plain
# → {"accountId":"9901287","addressId":null,"contact":null,"createdDate":"...","id":"622t7KB9oZkl9kQob0b8el","modifiedDate":"...","name":"Acme Corp","softDeleted":false,"totalCampaigns":0,"version":0,"website":null}

band customer-profile list --all --plain # walks every page; cannot combine with --offset
band customer-profile get 622t7KB9oZkl9kQob0b8el --plain
```

Keys come back alphabetical because the payload is a Go map — don't expect a
"nicer" ordering; the docs match reality, not a prettified version of it.

`list` excludes soft-deleted profiles; `get` still returns them, reporting
`softDeleted: true`. Without `--all`, a truncated page warns on stderr.

**`create` deliberately has no `--if-not-exists`** (see the [Design
Principles](#design-principles) exception). A profile has no safe natural key
to match on, and it's strictly 1:1 with a brand — a retry that silently reused
an existing profile could link an old brand's profile to a new brand's data.
Each brand needs its own freshly created profile; a retry must create, not reuse.

**`create` is also non-idempotent, which cuts the other way after an
ambiguous failure.** If a `create` call fails ambiguously — e.g. the
connection drops after the POST reached the server but before the response
reached you — do not blindly retry. A blind retry can create a *second*,
duplicate profile if the first one actually succeeded. Instead, run `band
customer-profile list --plain` and reconcile the results against what you
just submitted (name, website, contact) to determine whether the first call
already created a profile. If you cannot establish uniqueness that way, stop
and escalate rather than guessing.

### Update is read-modify-write

The API replaces the whole record, so `update` reads the profile first and
re-sends it with your changes applied. Fields you do not pass are preserved.
**Passing a flag with an empty value clears that field** — it sends JSON
`null`, not an empty string, because the API rejects empty strings. A
concurrent edit between the read and the write is caught by the API's version
check and exits **4** — retry the command.

```bash
band customer-profile update 622t7KB9oZkl9kQob0b8el --name "New Name" --plain
band customer-profile update 622t7KB9oZkl9kQob0b8el --website "" --plain # clears the website
```

### Delete is a soft delete

`delete` requires `--confirm`. That's a flag, never a prompt, so agents and
humans share one contract. The record leaves listings but stays retrievable by
ID with `softDeleted: true`, and `restore` brings it back — no confirm needed.

```bash
band customer-profile delete 622t7KB9oZkl9kQob0b8el --confirm --plain
# → {"deleted":true,"id":"622t7KB9oZkl9kQob0b8el","restore":"band customer-profile restore 622t7KB9oZkl9kQob0b8el"}
band customer-profile restore 622t7KB9oZkl9kQob0b8el --plain
```

Note these are two different fields on two different resources, not a typo of
each other: `deleted: true` is the delete command's own receipt, confirming the
204 completed synchronously. `softDeleted: true` is a field on the profile
itself, seen when you `get` it afterward — there is no `deleted` field on the
profile, and no `softDeleted` field on the receipt.

### Version history

`history list` and `history get` return a `{data, metadata}` envelope, newest
first — the profile snapshot lives under `data`, and `version`, `operation`,
`userName`, `createdDate` live under `metadata`. So the version is at
`metadata.version`, NOT top-level the way it is on `customer-profile get`.
Observed `metadata.operation` values: `CREATED`, `UPDATED`, `DELETED`.

```bash
band customer-profile history list 622t7KB9oZkl9kQob0b8el --plain
band customer-profile history get 622t7KB9oZkl9kQob0b8el 1 --plain
```

## Toll-Free Verification (TFV)

These commands manage toll-free number verification via the Athena v2 API. A 403 means the TFV role isn't enabled on the credential — contact your Bandwidth account manager to enable it.
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,18 @@ The CLI checks three things before every send:
| **Callback URL** | App has a real callback URL (not `example.com`, `localhost`, etc.) | Send blocked — tells you to run `band app update --callback-url` |
| **Number registration** | 10DLC numbers are on an approved campaign; toll-free numbers have TFV approval | Send blocked — tells you what's missing |

### Customer profiles (10DLC prerequisite)

Registering a 10DLC brand starts with a customer profile — and a profile backs exactly one brand, so create a fresh one for each brand you register.

```sh
band customer-profile create --name "Acme Corp" --plain
band customer-profile list --plain
band customer-profile get <id> --plain
```

Brand and campaign registration still happen in the Bandwidth App. See [AGENTS.md](AGENTS.md) for the full command reference, including update, delete/restore, and version history.

### 10DLC campaigns (local numbers)

If you're sending from a standard 10-digit local number, it must be assigned to an approved 10DLC campaign. Without this, carriers will block your messages. The CLI detects this and blocks the send with a diagnostic message.
Expand Down
65 changes: 65 additions & 0 deletions cmd/customerprofile/create.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package customerprofile

import (
"github.com/spf13/cobra"

"github.com/Bandwidth/cli/internal/cmdutil"
cpsvc "github.com/Bandwidth/cli/internal/customerprofile"
"github.com/Bandwidth/cli/internal/output"
)

var createOpts cpsvc.CreateOptions

func init() {
f := createCmd.Flags()
f.StringVar(&createOpts.Name, "name", "", "Profile name (required)")
f.StringVar(&createOpts.Website, "website", "", "Business website URL")
f.StringVar(&createOpts.ContactName, "contact-name", "", "Contact name (required if any other contact field is set)")
f.StringVar(&createOpts.ContactPhone, "contact-phone", "", "Contact phone in E.164")
f.StringVar(&createOpts.ContactEmail, "contact-email", "", "Contact email")
f.StringVar(&createOpts.AddressID, "address-id", "", "Existing address ID to associate")
Cmd.AddCommand(createCmd)
}

var createCmd = &cobra.Command{
Use: "create",
Short: "Create a customer profile",
Long: `Creates a customer profile.

A profile backs exactly one 10DLC brand, so create a new one for each brand
you intend to register — reusing a profile fails at brand creation.

This is a non-idempotent write: after an ambiguous failure, do not blindly
retry — list profiles and reconcile against what you submitted first.`,
Example: ` band customer-profile create --name "Acme Corp" --plain

band customer-profile create --name "Acme Corp" \
--website https://acme.com \
--contact-name "Ops Team" --contact-email ops@acme.com`,
// No positional args: this is a non-idempotent create, so a stray
// positional (e.g. a typo'd second word meant for another flag) must be
// rejected rather than silently ignored and creating an unintended profile.
Args: cobra.NoArgs,
// Required-ness is enforced in RunE, not via MarkFlagRequired: cobra
// rejects before RunE, which reports one flag at a time and would block a
// future interactive prompt from filling them in.
RunE: func(cmd *cobra.Command, args []string) error {
if err := cpsvc.ValidateCreate(createOpts); err != nil {
return err
}
svc, err := service(cmd)
if err != nil {
return err
}
env, err := svc.Create(cpsvc.BuildCreateRequest(createOpts))
if err != nil {
return roleGateError(err)
}
obj, err := env.Object()
if err != nil {
return err
}
format, plain := cmdutil.OutputFlags(cmd)
return output.StdoutAuto(format, plain, obj)
},
}
37 changes: 37 additions & 0 deletions cmd/customerprofile/customerprofile.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Package customerprofile implements `band customer-profile`.
//
// Customer profiles are a Numbers v2 resource, but they matter here because a
// profile is a hard prerequisite for 10DLC brand registration: a profile backs
// EXACTLY ONE brand, and reusing one fails with "cannot be assigned to another
// brand". Every new brand needs a freshly created profile.
package customerprofile

import (
"github.com/spf13/cobra"

"github.com/Bandwidth/cli/internal/cmdutil"
cpsvc "github.com/Bandwidth/cli/internal/customerprofile"
)

// Cmd is the `band customer-profile` parent command.
var Cmd = &cobra.Command{
Use: "customer-profile",
Short: "Manage customer profiles",
Long: `Create and manage customer profiles.

A customer profile is required to register a 10DLC brand, and a profile backs
exactly one brand — create a new profile for each brand you register.

Requires the Customer Profiles Access role. Check with 'band auth status --plain'.`,
}

// service builds a customer-profile service for the active account. A package
// var, not a plain func, so tests can substitute a service pointed at a stub —
// the same seam as cmd/sip's service and cmd/tendlc's.
var service = func(cmd *cobra.Command) (*cpsvc.Service, error) {
client, acctID, err := cmdutil.PlatformClient(cmdutil.AccountIDFlag(cmd))
if err != nil {
return nil, err
}
return cpsvc.NewService(client, acctID), nil
}
Loading