Files
claudetools/.claude/skills/b2/SKILL.md
Mike Swanson 96fb4110ea Add b2 skill: Backblaze B2 management CLI (storage cost, prefix purge)
B2 Native API v3 client for the ACG B2 account: status, buckets, keys,
files, bucket-size, usage/cost ($0.00695/GB), gated create/delete bucket+key,
and gated lifecycle-based delete-prefix/lifecycle-remove for prefix purges.
Read-only by default; destructive ops require --confirm.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 14:31:09 -07:00

11 KiB

name, description
name description
b2 Manage Arizona Computer Guru's (ACG) Backblaze B2 storage account via the B2 Native API v3. Talks to the LIVE production B2 account (accountId 46f69bc61163, region us-west-001) that holds the per-client MSP360/CloudBerry backup destinations. List buckets and application keys, list files / file versions, compute per-bucket stored size, and produce the headline storage-cost report (the mspbackups storage-cost calc). Provision buckets and scoped backup keys and delete buckets/keys (all destructive ops are gated behind --confirm). Read-only by default. Invoke for: "backblaze", "b2", "b2 storage", "bucket", "storage cost", "backup storage", "mspbackups storage", "list buckets b2".

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.

# from the scripts dir, or pass full paths
py "$CLAUDETOOLS_ROOT/.claude/skills/b2/scripts/b2.py" status
py "$CLAUDETOOLS_ROOT/.claude/skills/b2/scripts/b2.py" buckets
py "$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

B2="py $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:

{ "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

B2="py $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.