skrooge
CLAUDE.md6 starsChanged 17 days agoApache-2.0View on GitHub
Skrooge is a CLI tool for estimating Kubernetes scaling costs on GCP. Given a deployment's replica count, CPU/memory changes, and instance type, it calculates how many additional GCP instances are needed and their cost across different pricing tiers. skrooge tui does the same interactively, for several workloads at once. Repository: getsentry/skrooge Language: Python 3.11+ License: Apache 2.0 The project is managed with uv and built with the uv_build backend. This creates .venv with the package (editable) and the dev group.…
- Reads credentials
The file
We split this CLAUDE.md into the rules it gives an agent
2 rules. About 0 are common, 0 are distinctive, and 2 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈0
Distinctive · estimate
This author's own choices. Worth a look.
2
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 56
When changing dependencies, use uv add/uv remove (or edit pyproject.toml and run uv lock) and commit uv.lock.
- Very specific: line 141
Package data: Read bundled files with importlib.resources.files("skrooge"), not paths relative to __file__.
≈ “Common” is an estimate: at least 1 in 5 of the 26,980 CLAUDE.md files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
CLAUDE.md
Project Overview
Skrooge is a CLI tool for estimating Kubernetes scaling costs on GCP. Given a deployment's replica count, CPU/memory changes, and instance type, it calculates how many additional GCP instances are needed and their cost across different pricing tiers. skrooge tui does the same interactively, for several workloads at once.
Repository: getsentry/skrooge
Language: Python 3.11+
License: Apache 2.0
Repository Structure
skrooge/
├── skrooge/ # Main package
│ ├── __main__.py # Entry point for `python -m skrooge`
│ ├── cli.py # Click CLI commands and options
│ ├── utils.py # Core logic (instance lookup, constrained resource, instance count, cost)
│ ├── render.py # Output formatting (english, json)
│ ├── tui.py # Textual app for `skrooge tui` (live multi-workload estimates)
│ ├── scraper.py # Generates, validates and diffs instances.json from the Cloud Billing Catalog API
│ ├── machine_types.py # Hard-coded machine type specs the scraper uses
│ └── instances.json # Bundled GCP instance specs and regional pricing (~1MB)
├── tests/
│ ├── test_skrooge.py # CLI integration tests (Click CliRunner)
│ ├── test_tui.py # TUI tests (Textual's run_test pilot) and workloads file handling
│ ├── test_utils.py # Unit tests for calculation functions
│ ├── test_scraper.py # Unit tests for SKU parsing, price mapping, validation and diffs
│ └── fixtures/ # Fixed data for tests: Cloud Billing Catalog API responses, and part of instances.json
├── scripts/
│ └── bump-version.sh # Version bumping script (run by Craft; updates pyproject.toml and uv.lock)
├── .github/
│ ├── ISSUE_TEMPLATE/ # Issue form for missing instance types, linked from the unknown instance type error
│ ├── workflows/ # CI/CD (test, publish, release, changelog, price updates)
│ └── dependabot.yml # Monthly grouped updates for GitHub Actions and uv.lock
├── pyproject.toml # Project metadata, dependencies, tool config
├── uv.lock # Locked dependency versions (commit changes to this)
├── .pre-commit-config.yaml # Pre-commit hooks (ruff, whitespace, yaml, toml)
└── .craft.yml # Sentry Craft release configurationDevelopment Setup
The project is managed with uv and built with the uv_build backend.
uv syncThis creates .venv with the package (editable) and the dev group. Dependencies defined in pyproject.toml:
- Runtime:
click,click-log,textual(which bringsrich) devgroup (installed by default):cogapp,pytest,pytest-cov,ruff
The scraper only uses the standard library.
When changing dependencies, use uv add/uv remove (or edit pyproject.toml and run uv lock) and commit uv.lock; CI uses uv sync --locked and fails if the lockfile is stale.
Common Commands
# Run tests
uv run pytest
# Run the TUI (loads and saves skrooge.json by default)
uv run skrooge tui
# Run tests with coverage (as CI does)
uv run pytest --cov --cov-report=xml
# Lint
uv run ruff check .
# Format
uv run ruff format .
# Regenerate the CLI help in README.md (CI pushes it to same-repo PR branches if you forget)
uv run cog -r README.md
# Build the sdist and wheel into dist/
uv build
# Update GCP instance data (needs an access token or a Cloud Billing API key)
GCP_BILLING_ACCESS_TOKEN="$(gcloud auth print-access-token)" uv run python -m skrooge.scraper scrape --output new.json
uv run python -m skrooge.scraper diff skrooge/instances.json new.jsonArchitecture
The codebase follows a simple pattern of a calculation core with two front ends:
cli.py: Defines the Click CLI group and theestimate,regionsandtuicommands (tuiimportstui.pylazily so the others don't load Textual).estimatechecks its flags (required,--replicasat least 1, or--min-replicasand--max-replicasinstead for an autoscaling deployment,--cpuand--memnot both 0), then converts anyEstimateErrorfrom the pipeline intoclick.BadParameter.--regiontakes its choices fromavailable_regions()and usesmetavar="REGION", soestimate --help(and the README help generated from it) doesn't list every region and doesn't change when the price data does, andRegionChoice, whose error suggests similar regions instead of Click's list of every choice.--packing-efficiencysets the bin-packed estimate's packing efficiency for both resources, and--cpu-packing-efficiency/--mem-packing-efficiencyoverride it for one.regionslistsavailable_regions(), optionally filtered by instance type and cost class.utils.py: Shared constants (COST_CLASSES) and the calculation pipeline:load_instance_types()(cached) /available_regions()— loadinstances.jsonand list the GCP regions it has pricing for, optionally only in one cost class or for one instance typefind_instance_data()— look up an instance type, raisingInstanceNotFoundErrorwith fuzzy-match suggestions (difflib)check_pricing()— raisesPricingNotFoundError, suggesting cost classes, similar regions, and theregionscommand for ones that have pricing. Not every instance type has every cost class in every region, so never index pricing without checking.determine_constrained_resource()— determines if CPU or memory is the limiting factordetermine_instance_count_required()— calculates how many instances are neededcalculate_cost()— computes hourly/monthly/yearly costs for whole nodesresource_prices()— the per-vCPU and per-GiB prices of an instance type, for bin-packed estimates. Usesinstances.json's top-levelresource_priceswhen the scraper has recorded them, otherwisederive_resource_prices()solves for them with a least squares fit over the family's machine types. Shared-core types (SHARED_CORE_INSTANCE_TYPES) and GPU types (machine_types.GPUS) are never priced by resource (priced_by_resource()), and the prices must reproduce the machine type prices to within 1% (adds_up()), or there's no bin-packed estimate. Deriving also needs at least two memory-to-vCPU ratios in the family.calculate_resource_cost()— the bin-packed cost by resource and in total, each resource divided by its packing efficiency ({"cores": ..., "memory": ...}, as everywhere it's passed around)calculate_estimate()— runs the whole pipeline (rejecting shared-core instance types) and returns the result dict thatrenderand the TUI consume, includingresource_costs(None when the pricing can't be split). Every user-facing problem it finds is anEstimateError.calculate_autoscaling_estimate()— runscalculate_estimate()at each replica count the scaling pattern reaches between min and max replicas (replicas_shares()). A pattern is a name inSCALING_PATTERNS, whose(share of the week, load)levels put load 0 at min and 1 at max, rounded up byreplicas_at(), or a percentage string like"25%", the share of the time at max replicas, with min the rest (the CLI'sScalingPatterntype accepts either), and returns the estimate at max replicas withreplicas,required_instance_count,costsandresource_costsreplaced by their weekly averages, plus anautoscalingkey with the pattern and the estimates at min and max. Only the CLI uses it; the TUI doesn't support autoscaling.
render.py: Output dispatching forenglish(human-readable),json,scrooge, andmcduckformats. The two characters sharerender_character(), which prints their speech in a cowsay-stylespeech_bubble()above their ASCII art (SCROOGE,MCDUCK).scrooge_speech()andmcduck_speech()say the amounts fromspoken()and pick opening and parting words from their moods (SCROOGE_MOODS,MCDUCK_MOODS) by the whole-node monthly cost. For an autoscaling estimate, they give the replica range, the cost at min and max replicas, and the weekly averages under the scaling pattern.render_english()hands estimates with anautoscalingkey torender_autoscaling_english().tui.py:SkroogeApp, a Textual app with an input form, an estimate panel (both cost models and every cost class for the selected workload, andshape_costs(), the other machine types in its family with as many vCPUs), a settings bar, and a workloads table with a net total, whichctrl+ycopies withmarkdown_table().Workloadholds replicas and per-replica CPU and memory before and after the change (replicas_before,cpu_after, ...), keeping numeric fields as the text typed.estimate_workload()validates them and passes the total change tocalculate_estimate()as one replica's.Settingsholds the cluster-wide cost model (whole-nodesorbin-packed) and CPU and memory packing efficiencies, saved in the workloads file next to the workloads. In the TUI a negative change counts as a saving: with whole nodes, the sign of the constrained resource's change decides; bin-packed, each resource is signed by its own change (signed_resource_costs()). The CLI reports magnitudes only.
Data flow: CLI parses args → calculate_estimate() → result dict to render. The TUI recalculates every workload with calculate_estimate() on each input change.
Textual's Input.Changed, Select.Changed, and DataTable.RowHighlighted messages can arrive after the form or table has moved on (filling in the form posts them too). TUI handlers therefore read the widget's current value or cursor rather than the message's, which makes stale messages no-ops.
Testing
Tests use pytest, configured in the native [tool.pytest] table in pyproject.toml with strict = true and filterwarnings = ["error"], so any warning (including deprecations from Click) fails the suite. CLI tests use Click's CliRunner for integration testing; don't use runner.isolated_filesystem(), which is deprecated in Click 8.5.
tests/test_skrooge.py: CLI integration tests (version flag, invalid input handling, negative value normalization, cost output verification, verbosity logging, autoscaling options,regionsfilters). Tests that check prices or which regions and cost classes exist use theinstance_datafixture's fixed data, not the bundledinstances.json, so the automated price updates don't break them. The fixture patchesload_instance_typesin each module that imports it.tests/test_utils.py: Unit tests fordetermine_constrained_resourceanddetermine_instance_count_requiredwith a c2-standard-30 fixture, plus instance lookup, resource prices,calculate_estimateandcalculate_autoscaling_estimatetests/test_tui.py: DrivesSkroogeAppheadlessly withApp.run_test(), in both cost models. Async tests are wrapped by theapp_testdecorator, which runs them withasyncio.run, so no pytest-asyncio is needed.tests/test_scraper.py: Unit tests for the scraper againsttests/fixtures/billing_catalog_skus.json, with no network access
The constrained-resource and instance-count unit tests use a hardcoded c2_standard_30_instance_data dict rather than loading from instances.json. The other utils tests and the TUI tests use tests/fixtures/instances.json, a fixed copy of part of instances.json (us-central1 only); add any machine types a new test needs to it rather than reading the bundled data. The scraper fixture is in the Cloud Billing Catalog API's response shape, but its prices are synthetic; never put real or made-up prices in instances.json by hand.
CI/CD
GitHub Actions workflows target main branch:
python-package.yml: On every push/PR to main, runs these jobs:lint(ruff checkandruff format --check),readme(runscog -r README.mdon the PR's head commit, and fails if the help changed, so a stale commit can't be merged),readme-push(on same-repo PRs, except Dependabot's, which get no secrets, commits the README thatreadmeregenerated and pushes it to the PR branch with the Sentry release bot's token; it's a separate job that never runs the PR's code, so that code can't steal the token),test(pytest --covacross Python 3.11–3.15, uploading coverage to Codecov), andpackage(uv build, then runs the CLI from the built wheel).publish.yml: Triggered by pushes torelease/**branches. Runs tests, then builds withuv buildand uploadsdist/*as a workflow artifact named after the commit SHA, which Craft publishes to PyPI.release.yml: Manual workflow dispatch using Sentry's Craft for version bumping (viascripts/bump-version.sh, which needs uv), changelog generation, and GitHub releases.changelog-preview.yml: Previews changelog entries on PRs.update-instances.yml: Daily (and manual) run of the scraper, authenticated with workload identity federation when theGCP_WORKLOAD_IDENTITY_PROVIDERandGCP_SERVICE_ACCOUNTvariables are set, otherwise theGCP_BILLING_API_KEYsecret. It validates the output, runs the tests against it, and ifinstances.jsonchanged, force-pushes a single commit on top ofmaintoautomation/update-instancesand opens or updates one PR with the diff report as its description. It uses the Sentry release bot GitHub App's token, because PRs opened withGITHUB_TOKENdon't trigger CI. It never pushes tomain.
The step-by-step release process (changelog, running the Release workflow with an explicit version; getsentry/publish auto-approves skrooge's releases, so there's no approval step) is documented in the "Releasing" section of README.md. Craft doesn't edit CHANGELOG.md here (no changelog policy is configured), and .craft.yml's minVersion: 1.8.1 means the Release workflow needs an explicit version, not auto or a bump keyword.
Code Conventions
- Linting/Formatting: Ruff (configured in
pyproject.toml) with the default rules plusB,C4,I, andUP. - GitHub Actions: Pin every action to a full commit SHA with a
# vX.Y.Zcomment; Dependabot keeps them current. - Style: PEP 8, snake_case for all functions and variables.
- CLI framework: Click with
click_logfor verbosity control. - Error handling:
click.BadParameterfor user-facing errors, with fuzzy match suggestions viadifflib.get_close_matches. - Logging: Each module uses
logger = logging.getLogger(__name__); never log on the root logger.cli.pyattachesclick_logto theskroogepackage logger, so--verbositycovers every module. - Package data: Read bundled files with
importlib.resources.files("skrooge"), not paths relative to__file__. - Imports: Standard library first, third-party second, local (relative) imports third (enforced by ruff's
Irules). - No type annotations: The codebase does not use type hints consistently.
- README help: The
--helpoutput inREADME.mdis generated by cog from the[[[cog ... ]]]blocks; don't edit it by hand. Runuv run cog -r README.mdafter changing any command, option or help text. - Units: CPU in milli-cores, memory in MiB. Instance specs use cores and GiB.
Key Data
instances.json structure:
{
"<family>": {
"<instance-type>": {
"regions": {
"<region>": {
"ondemand": <float>,
"sud": <float>,
"preemptible": <float>,
"cud-1y": <float>,
"cud-3y": <float>
}
},
"specs": {
"cores": <int>,
"memory": <float>,
"local_ssd": <int>,
"gpu": <int>,
...
}
}
},
"resource_prices": {
"<family>": {
"<region>": {
"<cost class>": {"cores": <float>, "memory": <float>}
}
}
}
}20 instance families, 317 instance types and 5 cost classes. Specs for c4d, n4d and n4a come from gcloud compute machine-types list, so they only have cores, memory and cpu. m2 is priced as M1 plus GCP's on-demand "Memory Optimized Upgrade Premium" SKUs, added to every m2 cost class. Regions and cost classes come from the Cloud Billing Catalog API, so not every machine type has every cost class in every region. There's no SKU for sustained use discounts, so sud is the on-demand price with the family's multiplier from SUD_MULTIPLIERS in scraper.py applied to vCPUs and memory (not GPUs).
resource_prices holds the hourly price of one vCPU (cores) and one GiB (memory) for each family priced from GCP's per-vCPU and per-GiB SKUs (every family but f1 and g1). A machine type's price is cores * cores price + memory * memory price, except for the GPU families a2 and g2, whose machine prices also include their GPUs, and the shared-core e2-micro, e2-small and e2-medium, which are billed for 0.25, 0.5 and 1 vCPU. Code that walks the top-level keys as instance families must skip resource_prices.
Pre-commit Hooks
Configured in .pre-commit-config.yaml:
- Trailing whitespace removal
- End-of-file newline
- YAML and TOML syntax checks
- Large file prevention
- Ruff lint and format
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

