plutus-scan 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +206 -2
  3. package/bin/plutus-scan.js +4 -0
  4. package/package.json +41 -4
  5. package/src/analysis/anomalies.js +227 -0
  6. package/src/analysis/breakdowns.js +41 -0
  7. package/src/analysis/candidate-rules-focus.js +131 -0
  8. package/src/analysis/candidates.js +326 -0
  9. package/src/analysis/drivers.js +119 -0
  10. package/src/analysis/forecast.js +217 -0
  11. package/src/analysis/monthly.js +89 -0
  12. package/src/cli.js +625 -0
  13. package/src/model.js +144 -0
  14. package/src/report/client.js +643 -0
  15. package/src/report/dashboard.js +492 -0
  16. package/src/report/data.js +222 -0
  17. package/src/report/headline.js +137 -0
  18. package/src/report/html.js +442 -0
  19. package/src/report/markdown.js +97 -0
  20. package/src/report/plutus-link.js +25 -0
  21. package/src/report/releases.js +31 -0
  22. package/src/report/sparkline.js +46 -0
  23. package/src/report/template.html +451 -0
  24. package/src/report/terminal.js +255 -0
  25. package/src/sources/anthropic.js +102 -0
  26. package/src/sources/aws.js +381 -0
  27. package/src/sources/demo.js +274 -0
  28. package/src/sources/focus/csv.js +113 -0
  29. package/src/sources/focus/index.js +287 -0
  30. package/src/sources/focus/parquet.js +74 -0
  31. package/src/sources/focus/rows.js +127 -0
  32. package/src/sources/github-auth.js +79 -0
  33. package/src/sources/github.js +313 -0
  34. package/src/sources/openai.js +121 -0
  35. package/src/transport.js +159 -0
  36. package/src/tui/app.js +158 -0
  37. package/src/tui/screen.js +78 -0
  38. package/src/tui/text.js +67 -0
  39. package/src/tui/views/anomalies.js +110 -0
  40. package/src/tui/views/drivers.js +115 -0
  41. package/src/tui/views/list.js +68 -0
  42. package/src/tui/views/overview.js +82 -0
  43. package/src/ui/progress.js +231 -0
  44. package/src/ui/theme.js +100 -0
  45. package/src/ui/widgets.js +230 -0
  46. package/src/version.js +6 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Plutus
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,207 @@
1
- # Temporary Holding Version
1
+ # plutus-scan
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ What changed in my cloud and AI bill, and where? One command, runs on your machine.
4
+
5
+ ![Headline and daily spend chart from a demo report](docs/screenshot.png)
6
+
7
+ ```
8
+ npx plutus-scan
9
+ ```
10
+
11
+ It reads your AWS bill through your own `aws` CLI, plus OpenAI and Anthropic spend if you give it an admin key, plus any FOCUS export you point it at. It prints a summary that leads with one quotable sentence. In a terminal it then writes a self-contained HTML report to your temp folder (`plutus-scan/report-<last day>.html`) and opens it; pass `--no-open` to skip opening or `--no-html` to skip the report. Piped output and CI runs print the summary only. Use `--html <file> --open` to choose the path, or `--md` or `--json` for a Markdown summary or JSON.
12
+
13
+ ## Try it without an account
14
+
15
+ ```
16
+ npx plutus-scan --demo
17
+ ```
18
+
19
+ `--demo` reports on 90 days of synthetic spend for Example Corp, a fictional company: AWS services (including EBS snapshots in a dozen regions, and a spread of small charges that make about twenty candidates to review), a little OpenAI and Anthropic, Savings Plan and Reserved Instance fees, an upfront purchase, tax, and tags with about a quarter of spend untagged. It contacts no provider and reads no credentials. The data is generated from a fixed seed, so every run gives the same figures, with one real spike, one service that steps up and stays up, and one small wobble that should not count as an anomaly. It takes the same output flags as a real run, such as `--html report.html --open`, and can't be combined with flags that would read an account.
20
+
21
+ The summary it prints starts like this (trimmed):
22
+
23
+ ```
24
+ Headline
25
+ Last 30 days: $49,619 ($40,388 cloud, $7,800 commitments, $1,431 AI), +7% vs the previous 30.
26
+ Biggest mover: AWS Amazon Relational Database Service / InstanceUsage:db.r6g.xlarge +$1,476.
27
+ Projected November total: $50,375.
28
+
29
+ Top drivers (last 30 days against the previous 30)
30
+ last 30 previous change share
31
+ AWS Amazon Relational Database Service $6,095 $4,619 +$1,476 38.5%
32
+ USE1-InstanceUsage:db.r6g.xlarge $6,095 $4,619 +$1,476
33
+ AWS Amazon Elastic Compute Cloud $19,729 $18,788 +$941 24.5%
34
+ USE1-BoxUsage:m7g.xlarge $19,688 $18,749 +$939
35
+ AWS EC2 - Other $3,792 $3,356 +$436 11.4%
36
+ USE1-NatGateway-Bytes $2,201 $1,833 +$368
37
+
38
+ Anomalies
39
+ cost baseline change severity
40
+ 2026-10-29 AWS EC2 - Other $438 $116 +$323 high
41
+
42
+ AI by project
43
+ OpenAI support-bot $1,590 41.3%
44
+ Anthropic support-bot $1,586 41.2%
45
+ OpenAI search-assist $672 17.5%
46
+ ```
47
+
48
+ The dates follow the day you run it; the figures do not change.
49
+
50
+ ## What it calls and what it doesn't
51
+
52
+ plutus-scan runs locally. Nothing leaves your machine except the requests below, and it sends no telemetry and makes no update check. It never contacts plutus-cloud.com.
53
+
54
+ **Hosts.** Only the ones you configure:
55
+
56
+ | Host | Path | When |
57
+ |---|---|---|
58
+ | `api.openai.com` | `GET /v1/organization/costs`, `GET /v1/organization/projects` | `OPENAI_ADMIN_KEY` or `--openai-key` is set |
59
+ | `api.anthropic.com` | `GET /v1/organizations/cost_report`, `GET /v1/organizations/workspaces` | `ANTHROPIC_ADMIN_KEY` or `--anthropic-key` is set |
60
+ | `api.github.com` | `GET /repos/<owner>/<repo>/releases` | you pass `--github owner/repo` |
61
+ | `api.github.com` | `POST /graphql` (read-only queries) | you pass `--github <org or user>` |
62
+
63
+ **AWS goes through your own `aws` CLI.** plutus-scan never reads your AWS credentials and does not bundle an SDK. It inherits whatever profile, SSO session or environment variables your `aws` already uses (`--profile` is passed through). It runs only these commands:
64
+
65
+ - `aws sts get-caller-identity`, to check that credentials work
66
+ - `aws ce get-cost-and-usage`
67
+ - `aws ce list-cost-allocation-tags`
68
+ - `aws s3 ls <prefix> --recursive` and `aws s3 cp <object> -`, only for an `s3://` FOCUS input
69
+
70
+ It needs AWS CLI v2 on your PATH. If `aws` is missing or not signed in, AWS is skipped and the report says so.
71
+
72
+ **GitHub can use your `gh` CLI login the same way.** When you pass `--github` and set no token, plutus-scan runs `gh auth token --hostname github.com` (no shell, a 5-second timeout) and uses what it prints. That is the only `gh` command it runs, it runs only when `gh` is on your PATH, and a missing `gh`, an error or empty output just means no token. `--no-gh-auth` turns this off. `--demo` never runs it.
73
+
74
+ **Keys.** `OPENAI_ADMIN_KEY`, `ANTHROPIC_ADMIN_KEY` and the GitHub token (optional for single repos, required for an org; from `--github-token`, `GITHUB_TOKEN`, `GH_TOKEN` or your `gh` login, in that order) are read from flags, the environment or `gh`. They are never written to disk, logged or included in any output; `--dry-run`, the notes and the progress line name only where a key came from.
75
+
76
+ **The HTML report cannot call out.** It is one file with its data inlined and its chart drawn as inline SVG, system fonts only, and this content security policy:
77
+
78
+ ```html
79
+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:">
80
+ ```
81
+
82
+ To check it, open the report in a browser, open devtools, and look at the Network tab (it stays empty after load) and at the `<meta>` tag in the Elements tab. The `default-src 'none'` rule means the page cannot fetch anything, even if something tried.
83
+
84
+ **Dependencies.** One runtime dependency, `hyparquet`, for Parquet FOCUS files, pinned exactly with the lockfile committed.
85
+
86
+ ### Read-only IAM policy
87
+
88
+ This is everything the AWS part needs:
89
+
90
+ ```json
91
+ {
92
+ "Version": "2012-10-17",
93
+ "Statement": [
94
+ {
95
+ "Effect": "Allow",
96
+ "Action": [
97
+ "ce:GetCostAndUsage",
98
+ "ce:ListCostAllocationTags"
99
+ ],
100
+ "Resource": "*"
101
+ }
102
+ ]
103
+ }
104
+ ```
105
+
106
+ An `s3://` FOCUS input also needs `s3:ListBucket` and `s3:GetObject` on that bucket, and `kms:Decrypt` if the bucket is encrypted with a KMS key. In an AWS Organization, Cost Explorer shows all accounts only from the management account; a member account sees its own spend.
107
+
108
+ ### Cost Explorer pricing
109
+
110
+ AWS charges $0.01 per Cost Explorer API request. A run makes at least 4 billed requests (the daily query, the service drill-down, the candidate query and the tag list), plus one for each active cost allocation tag key it measures (up to 5), so a typical run is 4 to 10 requests, about four to ten cents. Each page of results is one request, so a large AWS Organization, whose daily spend can run to many pages, makes more. There is no cap by default, so a run never stops early; `--max-aws-requests <n>` sets a hard limit if you want one. If that cap is reached, plutus-scan stops, uses what it has, and a note names what was skipped and whether the totals are complete. Every output states the actual request count and cost; if every source fails, the count and cost still go to stderr. Listing cost allocation tags is also a billed request.
111
+
112
+ ### See the requests before they happen
113
+
114
+ `--dry-run` lists every request it would make (host and path, or the exact `aws` command, shell-quoted so it can be pasted), which credential each one uses (the environment variable name only) and the estimated AWS cost, and makes none of them. The free `aws sts get-caller-identity` check is not listed, and the drill-down and tag commands show placeholders until real data picks the services and tag keys.
115
+
116
+ ```
117
+ npx plutus-scan --dry-run --openai-key x
118
+ ```
119
+
120
+ ## Inputs and flags
121
+
122
+ With no flags it uses AWS if `aws` is signed in. Add whatever else you have.
123
+
124
+ | Input | How |
125
+ |---|---|
126
+ | AWS | default; `--profile <name>`, `--no-aws` to skip, `--aws` to force it when a FOCUS input already holds AWS rows (Cost Explorer then replaces the FOCUS AWS rows, and an unavailable AWS counts as a failed source) |
127
+ | OpenAI | `OPENAI_ADMIN_KEY` or `--openai-key`; spend by model and by project |
128
+ | Anthropic | `ANTHROPIC_ADMIN_KEY` or `--anthropic-key`; spend by model and by workspace. Priority Tier spend is not in this API, and every output says so |
129
+ | FOCUS | `--focus <path>`, repeatable: a file, a directory (recursive; `.csv`, `.csv.gz`, `.parquet`) or an `s3://` prefix |
130
+ | GitHub releases | `--github owner/repo` or `--github <org>`, repeatable and mixed; marks releases on the chart and shows the nearest ones beside an anomaly, as context and never as a cause (see below) |
131
+
132
+ FOCUS is the way in for Azure, GCP and OCI, or for anyone who already has an export. Required columns are `BilledCost`, `ChargePeriodStart` and `BillingCurrency`. Parquet is read uncompressed or Snappy only, with a clear message otherwise. The files in one directory (or one `s3://` prefix) count as one export, so multi-part exports are summed; when two exports cover the same provider and day, the one with the newest file wins and a note says so. To try the tool without your own data, download the official [FOCUS 1.0 sample](https://github.com/FinOps-Open-Cost-and-Usage-Spec/FOCUS-Sample-Data) (`FOCUS-1.0/focus_sample.csv`) and run `npx plutus-scan --focus focus_sample.csv --end 2024-09-30 --days 30 --no-aws` (the sample covers September 2024).
133
+
134
+ Other flags:
135
+
136
+ ```
137
+ --days <n> Days to report, ending at --end (default 90, max 395)
138
+ --end <YYYY-MM-DD> Last day of the window (default: two days ago, UTC)
139
+ --max-aws-requests <n> Optional cap on billed Cost Explorer requests (no cap by default)
140
+ --max-tag-keys <n> Cost allocation tag keys to measure (default 5)
141
+ --github <owner/repo> Mark releases of this repo on the chart (repeatable)
142
+ --github <owner> Mark releases of every repo in this org or user (repeatable; needs a token)
143
+ --github-exclude <o/repo> Leave this repo of an org out (repeatable)
144
+ --github-token <token> GitHub token (default: GITHUB_TOKEN, then GH_TOKEN, then your gh login)
145
+ --no-gh-auth Do not ask the gh CLI for its login's token
146
+ --html <file> [--open] Write a self-contained HTML report to a path you choose
147
+ --no-html Skip the automatic HTML report
148
+ --no-open Write the automatic HTML report without opening it
149
+ --json <file|-> Write the report data as JSON
150
+ --md <file|-> Write a Markdown summary
151
+ --tui Full-screen keyboard view
152
+ --braille Braille sparkline
153
+ --no-color No colour in the terminal summary
154
+ --dry-run List every request without making any
155
+ ```
156
+
157
+ **GitHub releases.** `--github owner/repo` reads one repo's releases, with or without a token. `--github acme` (no slash) reads every repo in the org `acme`, or of the user `acme` when no org has that name, leaving out archived repos and forks; `--github-exclude acme/nightly` leaves out a noisy one. Both forms can be repeated and mixed, and a repo its org already covers is read once. Org mode uses the GraphQL API, which needs a token: without one the run stops before any request, since GitHub also allows only 60 unauthenticated requests an hour. The token is the first of `--github-token`, `GITHUB_TOKEN`, `GH_TOKEN` and your `gh` CLI login, so if you already use `gh`, `gh auth login` once is all the setup there is; with a single repo, the `gh` login lifts that 60-an-hour limit too. A note in the report and the progress line say where the token came from ("via gh login", "via GITHUB_TOKEN", "via --github-token"), never the token. One query reads 100 repos with each repo's 20 newest releases, so a 200-repo org takes two calls; a repo is paged further only while its newest releases are still inside the window. The token needs read access to the repos (a fine-grained token with Contents read, or a classic token with `repo` for private ones); a note says how many repos were read and how many had releases in the window, and when only public repos were visible. Drafts are skipped and prereleases are kept, since a release candidate is often what a staging stack runs. Releases on the same day are one chart marker ("3 releases", each named in the tooltip), the chart draws at most 30 release days (any an anomaly points at first, then the busiest), and an anomaly's context names every release of its nearest release day with its repo. `--dry-run` lists an org as one GraphQL request plus an estimate, because its size is not known until it is read. GitHub requests are free and counted on the progress line ("acme: 143 repos, 37 releases; 3 requests via gh login").
158
+
159
+ The window ends two days ago by default, because the last day or two of billing data is incomplete. Mixed currencies are not converted: the dominant currency is used and the rest are listed as excluded.
160
+
161
+ **Commitments.** Savings Plans and Reserved Instance charges are counted as commitments, a third category beside cloud usage and AI. From Cost Explorer these are the Savings Plan recurring and upfront fees and the Reserved Instance fees, under each name Cost Explorer has used for them (`SavingsPlanRecurringFee`, `SavingsPlanUpfrontFee`, `RIFee`, `Recurring` or `Recurring reservation fee`, `Fee`, `Upfront` or `Upfront reservation fee`). They are shown as the Savings Plan name, as `<service> reservations` for the monthly reservation fee, or as `<service> upfront fees` for a fee charged on purchase; from a FOCUS export they are `Purchase` rows. Commitments count in every total and in the month-end projection, appear in the headline and on the chart when there are any, and are drivers in their own right, but they are never flagged as anomalies (a Reserved Instance fee lands on the 1st of every month) or listed as candidates, and a service's usage types never include its fees.
162
+
163
+ **AI in a FOCUS export.** Rows whose provider or publisher is an AI provider (OpenAI, Anthropic, Mistral AI, Cohere and similar), or whose `ServiceCategory` is `AI and Machine Learning`, are AI spend, reported by project (the sub-account) like the OpenAI and Anthropic sources, and left out of the untagged share. A `Purchase` row stays a commitment. AI billed through live AWS Cost Explorer, such as Bedrock, stays cloud, because Cost Explorer does not say which services are AI.
164
+
165
+ **Monthly charges.** Tax, a monthly Reserved Instance fee and other charges billed once on the 1st for the whole month are spread over that month in the daily charts (HTML, terminal and TUI) and in the forecast, drawn as a hatched "Monthly charges, spread" segment, so the 1st does not look like a spike. The HTML report has an "As billed" switch to see them on their day. Totals, the headline, drivers and tables stay as billed, and these charges are never anomalies.
166
+
167
+ **Credits.** Credits and refunds count in every total, but anomalies are judged on charges before credits: a credit booked under a service would otherwise lower that weekday's baseline for four weeks and make an ordinary day look like a spike.
168
+
169
+ ## Outputs
170
+
171
+ Every output leads with one quotable sentence: the last 30 days split into cloud and AI (and commitments, when there are any), the change against the previous 30, the biggest mover with its leading usage type, and the month-end projection. The terminal, HTML and TUI add the full headline block (top three movers and the largest anomaly). Every output also states what each source's amounts include (for AWS, UnblendedCost with credits, refunds and tax as billed), the last complete day and the currency, and when AWS was queried, the actual Cost Explorer request count and cost.
172
+
173
+ - **Progress**: on a terminal, while sources are read, a live panel on stderr shows one line per source (a spinner and its current step, then a tick, a cross or why it was skipped, with days, services, requests and cost), the analysis steps, and the running request count, Cost Explorer cost and elapsed time. It collapses to one summary line before the report, and the cursor always comes back, on Ctrl-C too. Piped output, CI, `--json` and `--md` keep plain one-line progress messages. See [demo-run.cast](docs/terminal-samples/demo-run.cast).
174
+ - **Terminal** (default): on a terminal, a dashboard: the headline, tiles for the last 30 days, the change, the forecast and commitments, a daily spend bar chart stacked by cloud, commitments and AI with the forecast and anomaly marks, top movers beside the largest anomaly, top drivers with their usage types and a 60-day trend, anomalies, candidates to review (the five strongest, then a count of the rest), AI spend by project, then notes and the AWS request line. Colour uses 24-bit, 256 or 16 colours depending on `COLORTERM` and `TERM`; set `NO_COLOR` or pass `--no-color` to turn it off. Below 100 columns the layout is compact, and every line fits at 80. Piped output and CI runs get a plain summary of the same sections with no borders and no colour, so it stays stable to parse. Samples of both are in [docs/terminal-samples](docs/terminal-samples/).
175
+ - **`--md <file|->`**: the same content as GitHub-flavoured Markdown: the headline sentence, a drivers table, the top anomalies, the five strongest candidates and a count of the rest, and the basis. Tables and `<sub>` text render on GitHub (PR descriptions, issues, comments). Slack does not render them, so paste the headline sentence there instead.
176
+ - **`--json <file|->`**: the full report data with `schemaVersion: 1`, the tool version, the window, sources and row counts.
177
+ - **`--html <file>`**: the offline report described above: headline with a copy button, daily spend with anomaly and release markers and a dashed forecast, drivers and candidates that each open to a sub-table of their usage types (by region, cost and share), anomalies, AI by project, untagged share, and basis. It follows your light or dark preference, with a toggle, and prints cleanly in the light theme. plutus-scan prints a warning after writing it, because the file contains real spend figures. A report from `--demo` says at the top that its figures are synthetic, and a bare `--demo` run names its file `demo-report-<last day>.html`, so it never replaces a real report.
178
+ - **`--tui`**: a full-screen, keyboard-only view of the same data with overview, drivers and anomalies, and drill-in to usage types. `1` `2` `3`, Tab and Shift+Tab, or the left and right arrows switch views (wrapping, and from a drill-in too), arrows or `j` `k` move, Enter drills in, Esc goes back, `h` writes `plutus-scan-report.html` in the current directory, `q` quits. It needs a TTY of at least 80x24, checked before any request is made, and restores your terminal on exit. The overview shows the headline block, totals, the last complete day, the AWS request count and cost, and the basis and notes as far as the screen allows.
179
+
180
+ ## Candidates to review
181
+
182
+ Candidates to review are not proven savings. They come from billing data only: usage types whose names point at a kind of charge that often hides waste, each sized by its last 30 days of spend. A candidate is a place worth a look. Whether the spend is waste depends on things a bill cannot show.
183
+
184
+ There are 46 AWS rules, service by service, and a usage type matches at most one of them:
185
+
186
+ - **Compute and storage**: idle and in-use public IPv4 addresses, NAT gateways, older EC2 generations, Dedicated Instances, burstable CPU credit charges, gp2, Provisioned IOPS and HDD or magnetic EBS volumes, snapshots and fast snapshot restore.
187
+ - **Network**: cross-AZ, inter-region and internet data transfer, Classic Load Balancer traffic, VPC interface endpoints, Transit Gateway.
188
+ - **Databases and caches**: older RDS, ElastiCache, OpenSearch and Redshift generations, RDS Multi-AZ, provisioned IOPS, backup storage and gp2 storage, Aurora I/O requests, DynamoDB provisioned capacity and backups.
189
+ - **Storage and serverless**: S3 Standard storage, requests and early-delete fees, Lambda provisioned concurrency and x86 duration, ECR storage.
190
+ - **Observability and security**: CloudWatch custom metrics and alarms, Logs ingestion, Logs storage and API requests, KMS keys and requests, Secrets Manager secrets, CloudTrail paid and data events, AWS Config.
191
+ - **Containers, streaming and ML**: EKS clusters and extended support, Kinesis shard hours, SageMaker endpoint and notebook hours.
192
+
193
+ Each rule has a strength. A candidate is "Often worth changing" when the billing line itself is evidence of something usually worth changing: an idle resource, a superseded generation or storage class with a cheaper like-for-like successor, or a surcharge such as extended support. It is "Worth a look" when the line is a legitimate cost that is only sometimes waste, such as S3 storage, an EKS control plane or provisioned capacity. When unsure a rule is "Worth a look". Candidates that Plutus can fix come first, then the strong ones, then the rest, each group by spend, so a big cost centre does not bury a small likely finding. `--json` carries a `strength` field (`"strong"` or `"look"`) on each candidate.
194
+
195
+ Candidates also work on Azure and Google Cloud, from a FOCUS export (`--focus`). There are 11 Azure rules (older VM series, Standard HDD and Premium SSD managed disks, unmanaged page blob disks, public IP addresses, Log Analytics ingestion and retention, inter-region and internet data transfer, VPN gateways, backup) and 8 Google Cloud rules (idle Cloud SQL addresses, static external IPs, standard persistent disks and snapshots, Cloud NAT, N1 instances, load balancer forwarding rules, Artifact Registry storage). They read the row's `ChargeDescription` and `ServiceName`, matched on strings seen in the official FOCUS sample data or in Google's own documentation, and are ordered and labelled the same way. Plutus does not open pull requests for them. A FOCUS export cannot say whether a disk or address is attached, so these too are places to look. Some signals are not covered because no real string was found for them, such as Google Cloud Logging ingestion and Azure NAT gateway.
196
+
197
+ A rule reads the name of a usage type and, where several services share a name, the service. It never reads utilisation, CloudWatch or your repos, so none of them can say a resource is idle, only that its kind of charge is worth checking. Each pattern was checked against real usage types in the public AWS Price List; the list of rules and what each matches is in [docs/design.md](docs/design.md). The terminal summary, the dashboard and the Markdown summary show the first five in that order and end with "and N more, listed in --json and the HTML report"; the HTML report and `--json` list every candidate over $10. Reading the rows costs no extra Cost Explorer request: the candidate query already covers every service on the bill.
198
+
199
+ For one of them, gp2 volumes, Plutus can open a pull request against your Terraform that fixes the waste (changing the volume type to gp3). That candidate says so beside its figure: "If this is waste, Plutus can open the pull request that fixes it." In the HTML report and the Markdown summary that sentence is a plain link; the plain terminal summary shows the sentence (the full-screen dashboard shortens it to "Plutus can open the fix as a pull request") and, once at the end of the section, the bare address `https://plutus-cloud.com/?utm_source=plutus-scan&utm_medium=cli` to follow. Printing it is not a request. plutus-scan itself recommends no code change and never looks in a repo, and every other candidate, idle public IPs included, says nothing about pull requests. `--json` carries a `plutusFix` field on each candidate (`"pull_request"` or `null`).
200
+
201
+ ## What Plutus adds
202
+
203
+ plutus-scan is a snapshot of one bill, taken once. [Plutus](https://plutus-cloud.com/?utm_source=plutus-scan&utm_medium=readme) tracks the same spend daily, per team and per customer, with alerts, and is where the questions this tool leaves open get answered: who owns this AI project, is this candidate actually waste, what happens tomorrow. plutus-scan has no paid tier, license key or upload step, and works fully without it. The pull-request line on the gp2 candidate, and the link in the footer of every HTML and Markdown report, are the same kind of pointer: a link you can follow or ignore, never a request plutus-scan makes. It still never contacts plutus-cloud.com.
204
+
205
+ ## License
206
+
207
+ MIT. See `LICENSE`. The FOCUS sample used as a test fixture is CC BY 4.0, credited in `test/fixtures/focus/official-sample.NOTICE.md`.
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ import { main } from '../src/cli.js';
3
+
4
+ process.exitCode = await main(process.argv.slice(2));
package/package.json CHANGED
@@ -1,6 +1,43 @@
1
1
  {
2
2
  "name": "plutus-scan",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Zero-setup CLI that reports what changed in a cloud or AI bill",
5
+ "keywords": [
6
+ "finops",
7
+ "cloud-cost",
8
+ "aws",
9
+ "cost-explorer",
10
+ "openai",
11
+ "anthropic",
12
+ "focus",
13
+ "billing",
14
+ "cli"
15
+ ],
16
+ "homepage": "https://github.com/plutus-cloud/plutus-scan#readme",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/plutus-cloud/plutus-scan.git"
20
+ },
21
+ "bugs": {
22
+ "url": "https://github.com/plutus-cloud/plutus-scan/issues"
23
+ },
24
+ "type": "module",
25
+ "license": "MIT",
26
+ "bin": {
27
+ "plutus-scan": "bin/plutus-scan.js"
28
+ },
29
+ "files": [
30
+ "bin",
31
+ "src",
32
+ "LICENSE"
33
+ ],
34
+ "scripts": {
35
+ "test": "node --test"
36
+ },
37
+ "engines": {
38
+ "node": ">=20"
39
+ },
40
+ "dependencies": {
41
+ "hyparquet": "1.33.0"
42
+ }
43
+ }
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Daily spend anomalies: each day of the window is judged against its own series history.
3
+ * Adapted from Plutus's private backend (plutus-backend/lib/anomaly-detection.js, commit
4
+ * 8f3e0d23): pickBaseline, floorCentsFor, severityFor, compareSeries and isSpike. The sliding
5
+ * evaluation over the window, the median floor base and the grouping of flagged days into
6
+ * episodes are new.
7
+ */
8
+ import { addDays, eachDay } from '../model.js';
9
+
10
+ /** @typedef {import('../model.js').Row} Row */
11
+ /** @typedef {import('../model.js').Window} Window */
12
+ /** @typedef {import('../model.js').Release} Release */
13
+ /** @typedef {{ date: string, cost: number, baseline: number, delta: number }} FlaggedDay */
14
+ /**
15
+ * One episode: a run of flagged days for one series. `date`, `cost`, `baseline`, `delta`,
16
+ * `deltaPct` and `severity` describe the peak day; `excess` is the sum of `delta` over every
17
+ * flagged day; `dayTotal` is the non-commitment spend of every series on the peak day. `release` is
18
+ * the nearest release before the run; `releases` is every release on that day (an org can ship
19
+ * several at once), so a report can name them all.
20
+ * @typedef {{ date: string, start: string, end: string, provider: string, service: string,
21
+ * cost: number, baseline: number, delta: number, deltaPct: number, severity: 'low'|'medium'|'high',
22
+ * excess: number, dayTotal: number, flaggedDays: FlaggedDay[], release: Release|null,
23
+ * releases: Release[] }} Anomaly
24
+ */
25
+
26
+ export const ANOMALY_MIN_DAYS = 8;
27
+ export const ANOMALY_LIMIT = 10;
28
+
29
+ const SPIKE_THRESHOLD_PCT = 50;
30
+ const RELATIVE_FLOOR_PCT = 2;
31
+ const MAX_FLOOR_CENTS = 50000;
32
+ const SMALL_ABSOLUTE_FLOOR_CENTS = 1000;
33
+ const MIN_BASELINE_DAYS = 3;
34
+ const WINDOW_DAYS = 8;
35
+ const SAME_WEEKDAY_OCCURRENCES = 4;
36
+ export const WEEKDAY_WINDOW_DAYS = 29;
37
+ const SEVERITY_HIGH_FLOOR_MULTIPLE = 10;
38
+ const SEVERITY_MEDIUM_FLOOR_MULTIPLE = 3;
39
+ // Flagged days of one series separated by at most this many unflagged days are one episode.
40
+ // The 4-week baseline lags a level shift, so a service that runs high for a week is flagged on
41
+ // most of its days; counting each day would fill the list with one cause.
42
+ const EPISODE_MAX_GAP_DAYS = 1;
43
+ const SEVERITY_RANK = { low: 0, medium: 1, high: 2 };
44
+
45
+ /** @param {string} day YYYY-MM-DD @returns {number} 0 (Sunday) to 6 */
46
+ function dayOfWeek(day) {
47
+ return new Date(`${day}T00:00:00Z`).getUTCDay();
48
+ }
49
+
50
+ /**
51
+ * Judge the current day against the mean of the last 4 same-weekday days, else the prior 7 days.
52
+ * @param {{ day: string }} current
53
+ * @param {{ day: string, daily_cost: number }[]} priorDays
54
+ */
55
+ function pickBaseline(current, priorDays) {
56
+ const weekday = dayOfWeek(current.day);
57
+ const sameWeekdayDays = priorDays
58
+ .filter((d) => dayOfWeek(d.day) === weekday)
59
+ .slice(-SAME_WEEKDAY_OCCURRENCES);
60
+ if (sameWeekdayDays.length >= SAME_WEEKDAY_OCCURRENCES) {
61
+ return { baselineDays: sameWeekdayDays };
62
+ }
63
+ const flatBaselineDays = priorDays.slice(-(WINDOW_DAYS - 1));
64
+ if (flatBaselineDays.length >= MIN_BASELINE_DAYS) {
65
+ return { baselineDays: flatBaselineDays };
66
+ }
67
+ return null;
68
+ }
69
+
70
+ /** @param {number[]} values @returns {number} */
71
+ function median(values) {
72
+ const sorted = [...values].sort((a, b) => a - b);
73
+ const mid = Math.floor(sorted.length / 2);
74
+ return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
75
+ }
76
+
77
+ /**
78
+ * The absolute floor in cents: 2% of the mean daily total (in dollars), clamped to $10..$500.
79
+ * @param {number} meanDailyTotalDollars
80
+ */
81
+ function floorCentsFor(meanDailyTotalDollars) {
82
+ const relativeFloorCents = Math.round(meanDailyTotalDollars * 100 * (RELATIVE_FLOOR_PCT / 100));
83
+ return Math.max(SMALL_ABSOLUTE_FLOOR_CENTS, Math.min(MAX_FLOOR_CENTS, relativeFloorCents));
84
+ }
85
+
86
+ /** @param {number} deltaCents @param {number} floorCents @returns {'low'|'medium'|'high'} */
87
+ function severityFor(deltaCents, floorCents) {
88
+ if (deltaCents >= floorCents * SEVERITY_HIGH_FLOOR_MULTIPLE) return 'high';
89
+ if (deltaCents >= floorCents * SEVERITY_MEDIUM_FLOOR_MULTIPLE) return 'medium';
90
+ return 'low';
91
+ }
92
+
93
+ /**
94
+ * @param {{ day: string, daily_cost: number }[]} days one series in ascending order, last is current
95
+ */
96
+ function compareSeries(days) {
97
+ if (days.length < MIN_BASELINE_DAYS + 1) return null;
98
+ const current = days[days.length - 1];
99
+ const baseline = pickBaseline(current, days.slice(0, -1));
100
+ if (!baseline) return null;
101
+
102
+ const { baselineDays } = baseline;
103
+ const baselineMeanDollars = baselineDays.reduce((sum, d) => sum + d.daily_cost, 0) / baselineDays.length;
104
+ const currentCents = Math.round(current.daily_cost * 100);
105
+ const baselineCents = Math.round(baselineMeanDollars * 100);
106
+ if (baselineCents <= 0) return null;
107
+ const deltaCents = currentCents - baselineCents;
108
+ return {
109
+ day: current.day,
110
+ currentCents,
111
+ baselineCents,
112
+ deltaCents,
113
+ pctChange: Math.round((deltaCents / baselineCents) * 100),
114
+ };
115
+ }
116
+
117
+ /** Applied to the unrounded percentage, as in Plutus. */
118
+ function isSpike(comparison, floorCents) {
119
+ const pctChange = (comparison.deltaCents / comparison.baselineCents) * 100;
120
+ return pctChange >= SPIKE_THRESHOLD_PCT && comparison.deltaCents >= floorCents;
121
+ }
122
+
123
+ /**
124
+ * The latest release dated from 2 days before the date (or from `from`) up to the date itself.
125
+ * @param {string} date @param {Release[]} releases @param {string} [from] @returns {Release|null}
126
+ */
127
+ export function nearestRelease(date, releases, from = addDays(date, -2)) {
128
+ let best = null;
129
+ for (const r of releases) {
130
+ if (r.date >= from && r.date <= date && (!best || r.date > best.date)) best = r;
131
+ }
132
+ return best;
133
+ }
134
+
135
+ /**
136
+ * Commitment rows (Savings Plan and Reserved Instance fees) are ignored: they post on a schedule,
137
+ * often on the 1st of the month, and are not usage. So are negative rows (credits and refunds): a
138
+ * credit booked under a service would lower that weekday's baseline for four weeks and make an
139
+ * ordinary day look like a spike. Days are judged on charges before credits.
140
+ * @param {Row[]} rows @param {Window} window @param {Release[]} [releases]
141
+ * @returns {Anomaly[]}
142
+ */
143
+ export function detectAnomalies(rows, window, releases = []) {
144
+ if (window.days < ANOMALY_MIN_DAYS) return [];
145
+ const days = eachDay(window);
146
+ const index = new Map(days.map((d, i) => [d, i]));
147
+
148
+ /** @type {Map<string, { provider: string, service: string, values: number[] }>} */
149
+ const series = new Map();
150
+ const totals = new Array(days.length).fill(0);
151
+ for (const r of rows) {
152
+ const i = index.get(r.date);
153
+ if (i === undefined || r.kind === 'commitment' || r.cost < 0) continue;
154
+ const key = `${r.provider}\u0000${r.service}`;
155
+ let s = series.get(key);
156
+ if (!s) {
157
+ s = { provider: r.provider, service: r.service, values: new Array(days.length).fill(0) };
158
+ series.set(key, s);
159
+ }
160
+ s.values[i] += r.cost;
161
+ totals[i] += r.cost;
162
+ }
163
+
164
+ /** @type {Map<{ provider: string, service: string, values: number[] }, (FlaggedDay & { i: number, deltaPct: number, severity: Anomaly["severity"] })[]>} */
165
+ const flagged = new Map();
166
+ for (let i = 1; i < days.length; i += 1) {
167
+ // The median, not the mean: a fee on the 1st of the month would otherwise lift the floor
168
+ // for the following week and change the severity of the same rise by its date.
169
+ const floorCents = floorCentsFor(median(totals.slice(Math.max(0, i - WINDOW_DAYS), i)));
170
+ for (const s of series.values()) {
171
+ // pickBaseline looks back at most 28 days, so older history cannot change the result.
172
+ const history = [];
173
+ for (let j = Math.max(0, i - (WEEKDAY_WINDOW_DAYS - 1)); j <= i; j += 1) history.push({ day: days[j], daily_cost: s.values[j] });
174
+ const c = compareSeries(history);
175
+ if (!c || !isSpike(c, floorCents)) continue;
176
+ if (!flagged.has(s)) flagged.set(s, []);
177
+ flagged.get(s).push({
178
+ i,
179
+ date: days[i],
180
+ cost: c.currentCents / 100,
181
+ baseline: c.baselineCents / 100,
182
+ delta: c.deltaCents / 100,
183
+ deltaPct: c.pctChange,
184
+ severity: severityFor(c.deltaCents, floorCents),
185
+ });
186
+ }
187
+ }
188
+
189
+ /** @type {Anomaly[]} */
190
+ const found = [];
191
+ for (const [s, hits] of flagged) {
192
+ let run = [];
193
+ const close = () => {
194
+ const peak = run.reduce((best, d) => (d.delta > best.delta ? d : best), run[0]);
195
+ found.push({
196
+ date: peak.date,
197
+ start: run[0].date,
198
+ end: run[run.length - 1].date,
199
+ provider: s.provider,
200
+ service: s.service,
201
+ cost: peak.cost,
202
+ baseline: peak.baseline,
203
+ delta: peak.delta,
204
+ deltaPct: peak.deltaPct,
205
+ severity: run.reduce((top, d) => (SEVERITY_RANK[d.severity] > SEVERITY_RANK[top] ? d.severity : top), peak.severity),
206
+ excess: Math.round(run.reduce((sum, d) => sum + d.delta * 100, 0)) / 100,
207
+ dayTotal: Math.round(totals[peak.i] * 100) / 100,
208
+ flaggedDays: run.map((d) => ({ date: d.date, cost: d.cost, baseline: d.baseline, delta: d.delta })),
209
+ release: null,
210
+ releases: [],
211
+ });
212
+ run = [];
213
+ };
214
+ for (const hit of hits) {
215
+ if (run.length && hit.i - run[run.length - 1].i > EPISODE_MAX_GAP_DAYS + 1) close();
216
+ run.push(hit);
217
+ }
218
+ close();
219
+ }
220
+
221
+ found.sort((a, b) => b.delta - a.delta || a.date.localeCompare(b.date) || a.service.localeCompare(b.service));
222
+ return found.slice(0, ANOMALY_LIMIT)
223
+ .map((a) => {
224
+ const release = nearestRelease(a.date, releases, addDays(a.start, -2));
225
+ return { ...a, release, releases: release ? releases.filter((r) => r.date === release.date) : [] };
226
+ });
227
+ }
@@ -0,0 +1,41 @@
1
+ /** Untagged share and AI spend by project. */
2
+
3
+ const round1 = (n) => Math.round(n * 10) / 10;
4
+
5
+ /**
6
+ * @param {import('../model.js').TagCoverage[]} tagCoverage
7
+ * @returns {{ provider: string, key: string, label: 'Untagged', untaggedCost: number,
8
+ * taggedCost: number, untaggedPct: number|null }[]}
9
+ */
10
+ export function untaggedShare(tagCoverage) {
11
+ return tagCoverage.map((t) => {
12
+ const total = t.taggedCost + t.untaggedCost;
13
+ return {
14
+ provider: t.provider, key: t.key, label: 'Untagged',
15
+ untaggedCost: t.untaggedCost, taggedCost: t.taggedCost,
16
+ untaggedPct: total === 0 ? null : round1((t.untaggedCost / total) * 100),
17
+ };
18
+ });
19
+ }
20
+
21
+ /**
22
+ * AI spend per (provider, project) with share of the AI total.
23
+ * @param {import('../model.js').Row[]} rows
24
+ * @returns {{ provider: string, project: string, cost: number, sharePct: number }[]}
25
+ */
26
+ export function aiByProject(rows) {
27
+ const groups = new Map();
28
+ let total = 0;
29
+ for (const r of rows) {
30
+ if (r.kind !== 'ai') continue;
31
+ const project = r.project ?? '(no project)';
32
+ const key = `${r.provider}\u0000${project}`;
33
+ const g = groups.get(key) ?? { provider: r.provider, project, cost: 0 };
34
+ g.cost += r.cost;
35
+ groups.set(key, g);
36
+ total += r.cost;
37
+ }
38
+ return [...groups.values()]
39
+ .map((g) => ({ ...g, sharePct: total === 0 ? 0 : round1((g.cost / total) * 100) }))
40
+ .sort((a, b) => b.cost - a.cost);
41
+ }