Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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))
}
120 changes: 117 additions & 3 deletions cmd/number/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,28 @@ import (
"github.com/Bandwidth/cli/internal/output"
)

var listStatus string
var (
listStatus string
listNpaNxx string
listState string
listRateCenter string
listLata string
listSubaccount string
listLocation string
listDisconnected bool
)

func init() {
listCmd.Flags().StringVar(&listStatus, "status", "Inservice",
"Comma-separated statuses to include. Common values: Inservice (live), "+
"InAccount (assigned, not yet live), Aging (released, in aging period).")
listCmd.Flags().StringVar(&listNpaNxx, "npa-nxx", "", "Filter to a 6-digit NPA-NXX prefix (in-service numbers only; requires the inservice role)")
listCmd.Flags().StringVar(&listState, "state", "", "Filter to a 2-letter state/province (in-service numbers only)")
listCmd.Flags().StringVar(&listRateCenter, "ratecenter", "", "Filter to a rate center; requires --state")
listCmd.Flags().StringVar(&listLata, "lata", "", "Filter to a LATA (in-service numbers only)")
listCmd.Flags().StringVar(&listSubaccount, "subaccount", "", "List numbers on a sub-account (site ID)")
listCmd.Flags().StringVar(&listLocation, "location", "", "List numbers on a location (SIP peer ID); requires --subaccount")
listCmd.Flags().BoolVar(&listDisconnected, "disconnected", false, "List disconnected numbers instead of in-service ones")
Cmd.AddCommand(listCmd)
}

Expand All @@ -31,17 +47,45 @@ By default, returns only numbers in service (ready to route calls or send
messages). Pass --status to include numbers in other states.`,
Example: ` band number list # default: only in-service
band number list --status Inservice,InAccount # include numbers just ordered
band number list --status Aging # numbers being released`,
band number list --status Aging # numbers being released
band number list --npa-nxx 919555 # in-service numbers in an NPA-NXX
band number list --state NC --ratecenter RALEIGH # in-service numbers in a rate center
band number list --subaccount 407 # numbers on a sub-account
band number list --subaccount 407 --location 500017
band number list --disconnected # recently disconnected numbers`,
RunE: runList,
}

func runList(cmd *cobra.Command, args []string) error {
// --status has a default, so only treat it as user intent when changed;
// otherwise the default value would conflict with every filter flag.
opts := listOptions{
NpaNxx: listNpaNxx,
State: listState,
RateCenter: listRateCenter,
Lata: listLata,
Subaccount: listSubaccount,
Location: listLocation,
Disconnected: listDisconnected,
}
if cmd.Flags().Changed("status") {
opts.Status = listStatus
}
if err := opts.validate(); err != nil {
return err
}

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

numbers, err := fetchAccountNumbers(client, acctID, listStatus)
var numbers []string
if query := buildListQuery(acctID, opts); query != nil {
numbers, err = fetchPagedNumbers(client, query)
} else {
numbers, err = fetchAccountNumbers(client, acctID, listStatus)
}
if err != nil {
return err
}
Expand Down Expand Up @@ -112,6 +156,41 @@ func wrapTNsError(err error, acctID string, isBuild bool) error {
"Contact your Bandwidth account manager to assign this role.", acctID), err)
}

// pagedListSize is the page size for the inserviceNumbers/discnumbers/site
// list endpoints. These endpoints document no maximum; 1000 keeps request
// counts low while staying well under any plausible server cap.
const pagedListSize = 1000

// fetchPagedNumbers pages through a filtered list endpoint and returns the
// merged E.164 numbers. The endpoints use 1-based page/size query params and
// signal the last page by returning fewer rows than requested.
func fetchPagedNumbers(client *api.Client, query *listQuery) ([]string, error) {
var all []string
for page := 1; page <= tnsMaxPages; page++ {
q := url.Values{}
for k, vs := range query.Query {
for _, v := range vs {
q.Add(k, v)
}
}
q.Set("page", strconv.Itoa(page))
q.Set("size", strconv.Itoa(pagedListSize))

var result interface{}
if err := client.Get(query.Path+"?"+q.Encode(), &result); err != nil {
return nil, fmt.Errorf("listing phone numbers: %w", err)
}

batch := extractFullNumbers(result)
all = append(all, batch...)
if len(batch) < pagedListSize {
return all, nil
}
}
return nil, fmt.Errorf("listing phone numbers: exceeded %d pages (%d numbers); "+
"narrow the query or contact support", tnsMaxPages, tnsMaxPages*pagedListSize)
}

// extractFullNumbers walks a decoded /tns response and returns each
// TelephoneNumber's FullNumber formatted as E.164.
func extractFullNumbers(raw interface{}) []string {
Expand All @@ -127,6 +206,13 @@ func collectFullNumbers(v interface{}, out *[]string) {
*out = append(*out, cmdutil.NormalizeE164(fn))
return
}
// The inserviceNumbers and discnumbers endpoints return bare strings
// under <TelephoneNumbers><TelephoneNumber>, not FullNumber objects.
if tn, ok := x["TelephoneNumber"]; ok {
if collectBareNumbers(tn, out) {
return
}
}
for _, child := range x {
collectFullNumbers(child, out)
}
Expand All @@ -136,3 +222,31 @@ func collectFullNumbers(v interface{}, out *[]string) {
}
}
}

// collectBareNumbers appends bare-string telephone numbers (a single string
// or a list of strings) and reports whether it consumed the value. Object
// forms of TelephoneNumber return false so the caller keeps walking.
func collectBareNumbers(v interface{}, out *[]string) bool {
switch x := v.(type) {
case string:
if x != "" {
*out = append(*out, cmdutil.NormalizeE164(x))
}
return true
case []interface{}:
consumed := false
for _, item := range x {
if s, ok := item.(string); ok {
if s != "" {
*out = append(*out, cmdutil.NormalizeE164(s))
}
consumed = true
} else {
collectFullNumbers(item, out)
consumed = true
}
}
return consumed
}
return false
}
Loading
Loading