Skip to content
Open
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
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,14 @@ For full flag/argument reference, use `band <command> --help`. This section cove

- **`number order` costs money.** No undo — you must `number release` to give it back.
- **`number search` results are not reserved.** Between search and order, someone else can take the number.
- **`number details` is the Dashboard view; `number get` is the Universal Platform voice record.** `details` works on any account and shows geography, features (E911/LIDB/DLDA), messaging settings, TN attributes, and the per-number origination route plan (priority + weight per endpoint) where one is configured. `get` shows the VCP assignment and only works where the UP voice API is enabled. To answer "how is this number routed?": try `number details` first — if it shows an `OriginationRoutePlan`, that is the routing; on UP accounts follow `number get` → `vcp get <vcp-id>` instead.
- **`number list` filter flags hit different endpoints than the bare command.** Plain `number list` uses `/tns`, which works for credentials without the inservice role. The `--npa-nxx`/`--state`/`--ratecenter`/`--lata`/`--subaccount` filters use the inservice endpoints and may return 403 where the bare list succeeds. `--ratecenter` requires `--state`; `--location` requires `--subaccount`; `--disconnected` combines with nothing.
- **`number count` is cheap.** It uses the totals endpoints — prefer it over listing and counting client-side.

### Toll-free routing

- **`tollfree template` is account-gated.** The underlying endpoint requires the `TollFreeTemplateAssignmentSearch` account setting (off by default; Bandwidth enables it on request). Expect exit 2 with a "not enabled on account" message until then — that is the correct behavior, not a bug. Numbers must be in-service on the account, toll-free (800/888/877/866/855/844/833), and at most 5000 per invocation.
- **The template name is the answer, not a carrier name.** The CLI returns `templateName` exactly as the registry stores it; mapping template names to ingress carriers is operator knowledge the API does not expose.

### VCPs

Expand Down
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,8 @@ band number list # list your number
band number search --area-code 919 --quantity 5 # search available numbers
band number order +15555550100 --subaccount <subaccount-id> --wait # order (blocks until active)
band number activate +15555550100 --voice-inbound --wait # turn on inbound voice
band number details +15555550100 # everything configured on a number
band number count # how many numbers do I have?
band number release +15555550100 # release a number
```

Expand Down Expand Up @@ -422,9 +424,18 @@ Sub-accounts (formerly known as sites) are the top-level container. Locations (f
| `band number get <number>` | Get voice config details (including VCP assignment) |
| `band number activate <number...>` | Activate voice/messaging services (e.g. enable inbound) |
| `band number deactivate <number...>` | Deactivate voice/messaging services |
| `band number list` | List your in-service numbers |
| `band number list` | List your in-service numbers (filter with `--npa-nxx`, `--state`, `--ratecenter`, `--subaccount`, `--location`, `--disconnected`) |
| `band number count` | Count numbers without listing them |
| `band number details <number>` | Full Dashboard view of a number (geography, features, messaging, per-number route plan) |
| `band number nnroutes <number>` | List NetNumber routes available to a number |
| `band number release <number>` | Release a number |

### Toll-free routing

| Command | What it does |
|---------|-------------|
| `band tollfree template <number...>` | Look up the routing template assigned to toll-free numbers (account-gated; 403 until enabled) |

### Messaging

| Command | What it does |
Expand Down
81 changes: 81 additions & 0 deletions cmd/number/count.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package number

import (
"fmt"

"github.com/spf13/cobra"

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

var (
countSubaccount string
countLocation string
countDisconnected bool
)

func init() {
countCmd.Flags().StringVar(&countSubaccount, "subaccount", "", "Count numbers on a sub-account (site ID)")
countCmd.Flags().StringVar(&countLocation, "location", "", "Count numbers on a location (SIP peer ID); requires --subaccount")
countCmd.Flags().BoolVar(&countDisconnected, "disconnected", false, "Count disconnected numbers instead of in-service ones")
Cmd.AddCommand(countCmd)
}

var countCmd = &cobra.Command{
Use: "count",
Short: "Count phone numbers without listing them",
Long: `Returns the number of phone numbers on the account, a sub-account, or a
location using the Dashboard totals endpoints — no paging through the
full inventory.`,
Example: ` band number count
band number count --disconnected
band number count --subaccount 407
band number count --subaccount 407 --location 500017`,
RunE: runCount,
}

// countPath maps count flags to the matching totals endpoint.
func countPath(acctID string, subaccount, location string, disconnected bool) (string, error) {
if disconnected && (subaccount != "" || location != "") {
return "", cmdutil.NewFlagError("--disconnected cannot be combined with --subaccount or --location")
}
if location != "" && subaccount == "" {
return "", cmdutil.NewFlagError("--location requires --subaccount")
}
switch {
case disconnected:
return fmt.Sprintf("/accounts/%s/discnumbers/totals", acctID), nil
case subaccount != "" && location != "":
return fmt.Sprintf("/accounts/%s/sites/%s/sippeers/%s/totaltns", acctID, subaccount, location), nil
case subaccount != "":
return fmt.Sprintf("/accounts/%s/sites/%s/totaltns", acctID, subaccount), nil
default:
return fmt.Sprintf("/accounts/%s/inserviceNumbers/totals", acctID), nil
}
}

func runCount(cmd *cobra.Command, args []string) error {
// Validate flags before authenticating so misuse fails fast.
if _, err := countPath("x", countSubaccount, countLocation, countDisconnected); err != nil {
return err
}

client, acctID, err := cmdutil.DashboardClient(cmdutil.AccountIDFlag(cmd))
if err != nil {
return err
}

path, err := countPath(acctID, countSubaccount, countLocation, countDisconnected)
if err != nil {
return err
}

var result interface{}
if err := client.Get(path, &result); err != nil {
return fmt.Errorf("counting phone numbers: %w", err)
}

format, plain := cmdutil.OutputFlags(cmd)
return output.StdoutAuto(format, plain, output.FlattenAndNormalize(result))
}
73 changes: 73 additions & 0 deletions cmd/number/details.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package number

import (
"errors"
"fmt"

"github.com/spf13/cobra"

"github.com/Bandwidth/cli/internal/api"
"github.com/Bandwidth/cli/internal/cmdutil"
"github.com/Bandwidth/cli/internal/output"
)

func init() {
Cmd.AddCommand(detailsCmd)
}

var detailsCmd = &cobra.Command{
Use: "details <number>",
Short: "Get the full Dashboard view of a phone number",
Long: `Returns everything the Bandwidth Dashboard knows about a phone number:
geography (LATA, state, rate center), vendor, sub-account and location,
service types, features (E911, LIDB, DLDA), messaging settings including
the assigned NN route, TN attributes, and — where configured — the
per-number origination route plan with priority and weight per endpoint.

This is the Dashboard (legacy platform) view and works for any number on
the account. For the Universal Platform voice record (VCP assignment),
use "band number get" instead.`,
Example: ` band number details +19195551234
band number details 8005551234 --plain`,
Args: cobra.ExactArgs(1),
RunE: runDetails,
}

// unwrapTelephoneNumberDetails strips the TelephoneNumberResponse envelope so
// the useful fields sit at the top level. Unexpected shapes pass through.
func unwrapTelephoneNumberDetails(result interface{}) interface{} {
m, ok := result.(map[string]interface{})
if !ok {
return result
}
resp, ok := m["TelephoneNumberResponse"].(map[string]interface{})
if !ok {
resp = m
}
if details, ok := resp["TelephoneNumberDetails"]; ok {
return details
}
return result
}

func runDetails(cmd *cobra.Command, args []string) error {
number := cmdutil.NormalizeE164(args[0])

client, acctID, err := cmdutil.DashboardClient(cmdutil.AccountIDFlag(cmd))
if err != nil {
return err
}

var result interface{}
if err := client.Get(fmt.Sprintf("/tns/%s/tndetails", number), &result); err != nil {
var apiErr *api.APIError
if errors.As(err, &apiErr) && apiErr.StatusCode == 404 {
// Keep the APIError wrapped so the 404 still maps to exit 3.
return fmt.Errorf("getting number details: %s not found on account %s: %w", number, acctID, err)
}
return fmt.Errorf("getting number details: %w", err)
}

format, plain := cmdutil.OutputFlags(cmd)
return output.StdoutAuto(format, plain, unwrapTelephoneNumberDetails(result))
}
Loading
Loading