---
name: b2
description: >-
  Manage ACG's Backblaze B2 storage account (Native API v3) — the LIVE production
  account (accountId 46f69bc61163, us-west-001) holding per-client MSP360/CloudBerry
  backup destinations. List buckets/keys/files, compute per-bucket size, run the
  headline storage-cost report (mspbackups calc); provision/delete buckets and scoped
  keys (destructive ops gated behind --confirm). Read-only by default. Triggers:
  backblaze, b2, b2 storage, bucket, storage cost, backup storage, mspbackups storage.
---

# Backblaze B2 Skill

Standalone CLI client for the Backblaze B2 Native API v3. Talks to the live ACG
B2 account. Read-only by default; destructive operations are gated behind
`--confirm`.

## Running the CLI

This machine's Python launcher is `py` (per identity.json). The scripts also
work with `python`/`python3`.

```bash
# from the scripts dir, or pass full paths
bash "$CLAUDETOOLS_ROOT/.claude/scripts/py.sh" "$CLAUDETOOLS_ROOT/.claude/skills/b2/scripts/b2.py" status
bash "$CLAUDETOOLS_ROOT/.claude/scripts/py.sh" "$CLAUDETOOLS_ROOT/.claude/skills/b2/scripts/b2.py" buckets
bash "$CLAUDETOOLS_ROOT/.claude/scripts/py.sh" "$CLAUDETOOLS_ROOT/.claude/skills/b2/scripts/b2.py" usage --json
```

Transport auto-selects: uses `httpx` if installed, otherwise stdlib `urllib`
(no third-party dependency required).

## Credentials

Credentials are NEVER hardcoded. At runtime the client loads them from the SOPS
vault:

```
bash "$CLAUDETOOLS_ROOT/.claude/scripts/vault.sh" \
     get-field projects/claudetools/backblaze-b2.sops.yaml key_id
bash "$CLAUDETOOLS_ROOT/.claude/scripts/vault.sh" \
     get-field projects/claudetools/backblaze-b2.sops.yaml credentials.application_key
```

`CLAUDETOOLS_ROOT` resolves from the env var, else `claudetools_root` in
`.claude/identity.json`, else the repo root derived from the script's own
location (no hardcoded drive letters). For testing you can override with the
`B2_KEY_ID` and `B2_APPLICATION_KEY` env vars (both must be set to use the
override).

Authorization is HTTP Basic against `b2_authorize_account` (username = key id,
password = application key). The skill uses the "ClaudeTools" application key
(`00146f69bc611630000000009`).

## Cache model (important — the token is a SECRET)

After a successful authorize, the CLI caches the auth result at
`.claude/skills/b2/.cache/auth.json`:

- **Cached:** `authorizationToken` (the bearer token, valid ~24h), the
  per-account `apiUrl` / `s3ApiUrl` / `downloadUrl`, `accountId`, key
  capabilities, and key scope (`bucketId` / `namePrefix`).
- **TTL:** treated as valid for ~23h. The client re-authorizes when the cache is
  stale, or automatically on a `401` with `expired_auth_token` / `bad_auth_token`
  (exactly one re-authorize + retry per call).
- **This cache holds a live secret.** It is gitignored twice (root `.gitignore`
  plus a local `.gitignore` in the skill dir) and must never be committed.

The per-account `apiUrl` always comes from the live authorize response — there
is no config file for endpoints (project rule).

## Safety gating

Destructive subcommands refuse to run without `--confirm`; without it they print
what they would do and exit non-zero (3):

- `create-bucket <name> [--type allPrivate|allPublic] --confirm`
- `create-key --name <n> --capabilities <c1,c2,...> [--bucket <name>] [--prefix <p>] [--duration-seconds N] --confirm`
- `delete-bucket <name> --confirm` (B2 refuses to delete a non-empty bucket — the error is surfaced verbatim)
- `delete-key <applicationKeyId> --confirm`
- `delete-prefix <name> <prefix> [<prefix> ...] --confirm` (schedules an IRREVERSIBLE lifecycle purge — see "Prefix purge / lifecycle" below)
- `lifecycle-remove <name> <prefix> [<prefix> ...] --confirm` (removes lifecycle rules)

`create-key` prints the returned `applicationKey` exactly ONCE with a warning —
B2 never shows it again, so store it in the vault immediately.

`raw` refuses any method name containing `create` / `delete` / `update` / `hide`
/ `cancel` unless `--confirm` is passed. `raw` prints the upstream response
verbatim, which may carry sensitive data (keys, tokens) — review before pasting
into tickets/logs. Destructive calls are NEVER retried automatically.

## Common commands

```bash
B2="bash "$CLAUDETOOLS_ROOT/.claude/scripts/py.sh" $CLAUDETOOLS_ROOT/.claude/skills/b2/scripts/b2.py"

# Status / inventory
$B2 status
$B2 buckets
$B2 keys

# Files
$B2 files ACG-Internal                       # latest names
$B2 files ACG-Internal --prefix MBS- --limit 50
$B2 files ACG-Internal --versions            # all versions

# Size + cost
$B2 bucket-size ACG-Internal
$B2 usage                                     # headline cost report, all buckets
$B2 usage --bucket ACG-Dataforth              # one bucket
$B2 cost --json                               # alias for usage

# Provisioning (gated)
$B2 create-bucket ACG-NewClient --confirm
$B2 create-key --name acg-newclient-backup \
    --capabilities listBuckets,listFiles,readFiles,writeFiles,deleteFiles \
    --bucket ACG-NewClient --confirm
$B2 delete-bucket ACG-OldClient --confirm
$B2 delete-key 00146f69bc611630000000abc --confirm

# Lifecycle / prefix purge (see next section)
$B2 lifecycle ACG-Internal                    # read-only: list current rules
$B2 delete-prefix ACG-Internal "MBS-<guid>/CBB_<machine>/" --confirm
$B2 lifecycle-remove ACG-Internal "MBS-<guid>/CBB_<machine>/" --confirm

# Power use — any v3 method directly
$B2 raw --method b2_list_buckets --body '{"accountId":"46f69bc61163"}'
```

## Prefix purge / lifecycle

Some backup destinations hold **1.2M+ file versions** under a single machine
prefix. Per-file deletion (`b2_delete_file_version` per version) is impractical
at that scale, so this skill purges a prefix via a **B2 bucket lifecycle rule**
instead. B2's own daily lifecycle pass does the deletion server-side.

### How the purge mechanism works

A purge rule is:

```json
{ "fileNamePrefix": "MBS-<guid>/CBB_<machine>/",
  "daysFromUploadingToHiding": 1,
  "daysFromHidingToDeleting": 1 }
```

With both day-counts at `1`, B2's daily lifecycle pass **hides** any file more
than 1 day old, then **deletes** hidden files more than 1 day old. Every version
under the prefix (all are years old) becomes eligible immediately, so the prefix
is fully purged within **~24-48h** (two daily passes). This is **irreversible**:
there is no recycle bin and no undo once the pass deletes a version.

`fileNamePrefix: ""` means the WHOLE BUCKET — the skill refuses that and any
other too-broad prefix (see safety below).

### Lifecycle rules REPLACE, they don't append

`b2_update_bucket`'s `lifecycleRules` **replaces the entire array** — it is not
additive. The skill therefore always reads the current rules (`b2_list_buckets`
scoped to the bucketId), merges the change, and writes the full set back. This
read-merge-write of the complete array is what preserves pre-existing rules.

**This account's `b2_update_bucket` does NOT support `ifRevisionMatch`** (it
returns HTTP 400 `bad_request: unknown field ... ifRevisionMatch`), so the skill
sends no optimistic-lock token and writes are **last-write-wins**. There is no
409-conflict retry (no revision guard to violate); the skill makes at most one
defensive retry on a transient 5xx, then fails — it never loops. The bucket
`revision` is still read and displayed for reference, but is never sent back.
B2 allows up to **100** lifecycle rules per bucket; the skill refuses an update
that would exceed that.

### Commands

```bash
B2="bash "$CLAUDETOOLS_ROOT/.claude/scripts/py.sh" $CLAUDETOOLS_ROOT/.claude/skills/b2/scripts/b2.py"

# 1. READ-ONLY: see the bucket's current lifecycle rules + revision
$B2 lifecycle ACG-Internal
$B2 lifecycle ACG-Internal --json

# 2. DRY-RUN: what WOULD be scheduled (no --confirm -> exits 3, writes nothing)
$B2 delete-prefix ACG-Internal "MBS-<guid>/CBB_<machine>/"

# 3. COMMIT the purge (gated). Idempotent: an identical rule already present
#    is skipped. You can pass several prefixes at once.
$B2 delete-prefix ACG-Internal \
    "MBS-<guid>/CBB_OLDPC1/" \
    "MBS-<guid>/CBB_OLDPC2/" --confirm

# 4. After ~24-48h, verify the data is gone (size should have dropped)
$B2 bucket-size ACG-Internal

# 5. Clean up the now-spent purge rule(s) so they don't sit on the bucket
$B2 lifecycle-remove ACG-Internal \
    "MBS-<guid>/CBB_OLDPC1/" \
    "MBS-<guid>/CBB_OLDPC2/" --confirm
```

### Safety validations (hard-fail even with --confirm)

`delete-prefix` refuses (exit 2) any prefix that is:

- empty, `"/"`, `"*"`, or **contains no `/`** — too broad (would risk a
  whole-bucket or whole-account-tree purge);
- exactly an **account root** `MBS-<guid>/` (one path segment + trailing slash) —
  this would purge ALL machines under that account. Override only with the extra
  `--allow-account-root` flag (NOT used by default — purge machine-level `CBB_`
  prefixes instead).

A valid machine target looks like `MBS-<guid>/CBB_<machine>/`. The command also
**warns** (does not fail) when a prefix lacks a trailing `/`, since a slash-less
prefix can match a sibling whose name merely starts with the same characters.

Without `--confirm`, `delete-prefix` prints the exact rule(s) it WOULD add and a
prominent `[WARNING]` about irreversible deletion, then exits non-zero (3) —
matching the other gated commands. `lifecycle-remove` is similarly gated
(removing a rule is far less dangerous than adding one, but gated for
consistency); without `--confirm` it lists which existing rules it would remove
and exits 3.

### Cleanup workflow

`delete-prefix` -> wait ~24-48h for B2's lifecycle pass -> `bucket-size` to
verify the stored size dropped -> `lifecycle-remove` to strip the spent purge
rule. Leaving the rule in place is harmless (everything under the prefix is
already gone) but tidy buckets are easier to reason about.

## Storage cost

ACG's Backblaze B2 cost basis is **$0.00695 per GB** stored, defined as
`RATE_PER_GB_USD` in `scripts/b2_client.py` (recorded in
`.claude/memory/reference_backblaze_storage_rate.md`) and used by the GuruRMM
mspbackups storage-cost calc. Override at the CLI with `--rate`.

GB is **decimal** (`bytes / 1e9`), matching how storage providers bill — NOT
2^30. Cost = `GB * rate`.

Size MUST be summed over **all file versions** (`b2_list_file_versions`,
`action == "upload"`), not just the latest names, because B2 bills every stored
version. `bucket-size` and `usage` do this automatically. For very large buckets
this issues many list transactions — `usage` prints a `[WARNING]` to that effect.

## Account structure (for context)

- accountId `46f69bc61163`, region `us-west-001`.
- 12 buckets (all `allPrivate`), mostly per-client MSP360/CloudBerry backup
  destinations with keys like `MBS-<guid>/CBB_<CLIENT>/...`: ACG-BST, ACG-Brett,
  ACG-Dataforth, ACG-GLAZTECH, ACG-IX, ACG-Internal, ACG-Lens, ACG-PST,
  ACG-REDNOUR, Horseshoe, MSPBackups20200311, VWP-Backup. `usage` derives a
  client label by stripping the `ACG-` prefix.
- 2 application keys: `cloudberrykey` (the MSP360/CloudBerry key) and
  `ClaudeTools` (the key this skill uses).

## Reference

Full verified v3 auth flow, every method used with request/response shape, the
file-version billing note, pagination, the error shape, and the cost formula:
`references/api-reference.md`.
