CLAUDE.md · git:20261017.0fed824 · 2026-10-17 · sha256 a287b215971cab2a
CLAUDE.md git:20261017.0fed824A
Immutable. This exact content never changes and is served at /api/v1/blob/a287b215971cab2a.
# CLAUDE.md
Guidance for Claude Code in this repository. It lists commands and the
non-obvious rules that have caused real bugs. Feature behavior lives in
`specs/NNN-*/` and the code; this file points there instead of restating it.
## Critical Instructions
- **Do not run `git commit`** unless the user asks for a commit, directly or by
invoking a workflow whose purpose is a commit (for example `/pick-issue`).
- **Always run `make lint` and `make test`** before claiming success.
- **Do not modify `.golangci.yml`** without explicit approval.
- The constitution (`.specify/memory/constitution.md`) wins over any runtime
mode instruction (learning, explanatory). Principle VI forbids TODOs and
stubs. Record a conflict with `/speckit.revisit`.
## Project Overview
FinFocus Core is a CLI and plugin host that calculates cloud costs from Pulumi
(and Terraform state) definitions: projected estimates, actual history, and
Kubernetes allocation, all through gRPC plugins. Sibling repos:
`finfocus-spec` (protos, `pluginsdk`) and the `finfocus-plugin-*` plugins
(aws-public, azure-public, ...). Cross-repo changes follow the constitution's
protocol. Plugin domain knowledge stays in the plugin; core and spec get
generic channels only.
## Specs
Specs, plans, and tasks live in `specs/NNN-*/`, produced by **Spec Kit**
(`.specify/` and the `/speckit-*` skills). It is the only pipeline: output
from other planning tools must become a Spec Kit feature folder before
implementation.
## Commands
```bash
make tools # Install the toolchain pinned in mise.toml
make build # bin/finfocus
make test # Unit tests: internal/, pkg/, and the plugin modules
make test-race
make test-integration # Slower; see Integration Tests below
make test-e2e # Needs AWS credentials and Pulumi
make test-e2e-kind # kind cluster: run-rate, then historical (Prometheus)
make lint # golangci-lint, markdownlint, actionlint
make validate # go mod tidy -diff, go vet
make docs-lint
make docs-serve # Astro site at http://localhost:4321/finfocus/
make check-plugin-boundaries
make test-kubernetes # Also test-jev, test-prometheus (nested plugin modules)
make install-kubernetes # Also install-jev, install-prometheus, install-recorder
```
```bash
go test -run TestName ./internal/cli/
go test -race -shuffle=on -count=3 ./internal/<pkg>/... # Parallel-safety check
./bin/finfocus plugin list --output json
```
- `make lint` checks golangci-lint's version against `mise.toml` and runs
`$(HOME)/go/bin/golangci-lint`. When that binary lags the pin, run
`make lint GOLANGCI_LINT="$(mise which golangci-lint)"`.
- `pkill golangci-lint || true` clears a parallel-runner conflict. Lint can run
longer than five minutes.
- After a rebase, run `go vet ./...` before trusting `make test`. A widened
interface (for example `engine.ResourcePricer`) breaks test doubles that
another merged PR added, with no git conflict.
## Architecture
| Package | Role |
| --- | --- |
| `internal/cli/` | Cobra commands, routed through `ax.Execute` (ax-go) |
| `internal/engine/` | Cost orchestration, cluster allocation, rendering |
| `internal/pluginhost/` | gRPC plugin launch, connect, cleanup |
| `internal/registry/` | Discovery in `<home>/plugins/<name>/<version>/`, install |
| `internal/ingest/` | Pulumi plan and state parsing |
| `internal/proto/` | Adapter: descriptors, pre-flight validation |
| `internal/router/` | Plugin routing with priority and fallback |
| `internal/analyzer/` | Pulumi Analyzer gRPC server |
| `internal/tui/` | Bubble Tea v2 / Lipgloss v2 (`charm.land` imports) |
| `internal/config/` | Two-tier config |
| `plugins/{kubernetes,jev,prometheus}/` | Nested modules; never import core |
Entry point `cmd/finfocus/main.go`. Exit codes: 0, 1 (ax `ExitInternal`), and
the budget code from `cost --exit-code` (default 1), preserved through
`ax.Execute` by `cli.toAxExitError`. User-input errors (flag combinations,
malformed input files, bad dates) exit 2 through `toValidationError`
(`ax.ExitValidation`). Codes 3 and 4 (`ExitNetwork`, `ExitAuth`) are not used yet.
### Configuration Resolution
- Project settings (config, dismissals): `--project-dir`, then
`FINFOCUS_PROJECT_DIR`, then the nearest `Pulumi.yaml` (`$PROJECT/.finfocus/`),
then `~/.finfocus/`.
- Global resources (plugins, cache, logs): `FINFOCUS_HOME`, then
`PULUMI_HOME/finfocus`, then `~/.finfocus/`. With none of those, it is
`./.finfocus` (`config.UsesWorkingDirFallback`).
- Project `config.hujson` overrides global at the top-level key (shallow
merge). The global loader migrates a legacy `config.yaml` to Hujson on first
read; a legacy project `config.yaml` is read as an overlay and left as it is.
## Key Patterns
- `RunE`, not `Run`. `cmd.Printf`, not `fmt.Printf`. Defer cleanup right after
acquiring a resource. Dates accept `2006-01-02` and RFC3339.
- Pre-flight validation (`internal/proto/`, `pluginsdk` validators) returns a
`$0` placeholder with a `VALIDATION:` note (plugin errors use `ERROR:`) and
still calls the plugin for valid resources. A resource with `ref.*` tags and
no SKU uses `ValidateProjectedCostRequestLenient`.
- Logging: `--debug` > `FINFOCUS_LOG_LEVEL`/`FINFOCUS_LOG_FORMAT` > config >
info/console. `FINFOCUS_TRACE_ID` injects a trace id.
- `ax.Execute` mounts `--format json|human`, `--dry-run`, `--yes`, and
`--idempotency-key` on every command, plus `__schema` and `mcp-server`
(`--mcp` is the stdio alias). A command must not redeclare `--format`;
Cobra rejects it.
## Testing
- **Testify only** (`require` to stop, `assert` to continue); see `AGENTS.md`.
Never write `if got != want { t.Errorf(...) }` or raw `t.Fatalf`.
- **TUI changes**: render the view and read it, then run
`go test -run TestGolden ./internal/tui/...` (`UPDATE_GOLDEN=1` to
regenerate). `assert.Contains` alone is not enough.
- **Expected plugin failures**: log expected errors with `t.Logf`; fail only
when the required error is absent. `connection refused`, `context deadline
exceeded`, or `EOF` in logs of a passing test are normal.
- Coverage targets (80%, 95% on critical paths) are in the constitution.
### Parallel Tests
Call `t.Parallel()` at the top level and in each `t.Run`, except:
- Tests that touch process-wide state: `t.Setenv`, `t.Chdir`, `os.Setenv`,
package-level variables, `config.ResetGlobalConfigForTest`/`SetGlobalConfig`,
or anything that builds a root command (`NewRootCmd*` sets the resolved
project dir), including helpers such as `stubHome` and `WithEnv`.
- A test that writes a binary or script and then executes it. A concurrent
fork inherits the write fd and the exec fails with `text file busy`
(golang/go#22315).
- Mark them `//nolint:paralleltest // <reason>` above `func` (or on the
`t.Run`/`for` line for some subtests).
- Do not share a mutable fixture across subtests; production code mutates
protos in place. In a parent with parallel subtests use `t.Cleanup`, never
`defer`.
### E2E
- `test/e2e/` is its own module and must run the real `finfocus` binary;
never stub cost values. AWS E2E needs `PULUMI_CONFIG_PASSPHRASE` and
`~/.pulumi/bin` on `PATH`.
- The run-rate half of `make test-e2e-kind` installs aws-public into the real
`~/.finfocus`. `TestCostCluster_KindHistorical` uses a temp home: run
`test/e2e/kind/setup.sh`, apply `test/e2e/kind/prometheus.yaml`, then
`go test -tags e2e_kind -run '^TestCostCluster_KindHistorical$' ./...` from
`test/e2e` (its own module; a bare run from the repo root skips it) with
`FINFOCUS_BINARY` set.
- Remote-write fixtures need `storage.tsdb.out_of_order_time_window` in the
Prometheus config and a window relative to now. A fixed date is rejected as
out of bounds once a newer sample exists, and later falls out of the 15-day
retention.
- `make test-e2e-kind` pins `E2E_AWS_PUBLIC_VERSION` (Renovate bumps it).
### Integration Tests (`test/integration/`)
- `helpers.CLIHelper.Execute` must go through `ax.Execute`; a bare
`cli.NewRootCmd().Execute()` lacks `--format`, `--dry-run`, `--yes`, and
`--idempotency-key`.
- Tests built with `helpers.NewCLIHelper(t)` and no `WithEnv` read the real
`~/.finfocus/config.hujson`. Compare the failure *set* with a pre-change
baseline, not the count.
- Setting `HOME` for a `go`/`make` child moves Go's caches when `GOPATH` is not
exported (CI); cleanup then fails on read-only module files. Pass the real
`GOMODCACHE`/`GOCACHE` (see `scaffold_build_test.go`). Reproduce with
`env -u GOPATH go test ...`.
- `config.yaml` fixtures are intentional legacy input for auto-migration.
## Releases
- release-please manages `CHANGELOG.md` and versions; never edit them by hand.
A `feat` below 1.0 bumps the patch.
- A new nested plugin goes into `release-please-config.json` with
`initial-version` and its path in the root package's `exclude-paths`. Do
not add it to `.release-please-manifest.json`: a hand-written `0.1.0` makes
the first release `0.1.1`. The release PR adds the entry.
- `release-monorepo-plugin.yml` builds any `<name>-vX.Y.Z` tag from
`plugins/<name>/cmd`; a new plugin needs no workflow change. Plugin tag
prefixes must not start with `v`.
- Nested modules must pin the same `finfocus-spec` as the root `go.mod`; CI
compares them.
## Pulumi Integration
- `pulumi preview --json` nests resources under `newState`; ingestion must read
`inputs` and `type` there or plugins get `InvalidArgument`.
- `parent` is the stack, not a pricing reference. Cross-resource refs follow
`propertyDependencies` only and become `ref.<property>.{urn,type,region,sku}`
tags (`specs/618-cross-resource-refs/`). The unknown sentinel
`04da6b54-80e4-46f7-96ec-b56ff0331ba9` is never sent as a tag, SKU, or
region. A referenced SKU is never copied into the child's `Sku`.
- **No credential or Pulumi secret reaches a plugin.** One rule each:
`config.IsCredentialName` (which `engine.isCredentialKey` calls) /
`skipDottedSegment` for names and `history.IsPulumiSecret`
for values. They apply to collapsed tags, dotted keys
(`specs/619-dotted-tag-keys/`), `ResourceDescriptor.attributes`
(`specs/621-k8s-workload-projected-cost/`), actual-cost tags, and
`EstimateCost` attributes. Do not add a second rule.
- Anything new sent to a plugin must be in the projected cache key; that is
why keys end in `/tags-`, `/refs-`, `/attrs-` digests and feature suffixes
(`/pricing-spec`, `/growth-v1`).
- `__`-prefixed inputs (`__defaults`, `__provider`) are Pulumi internals; filter
them from anything shown to users.
- Analyzer import: `pulumirpc "github.com/pulumi/pulumi/sdk/v3/proto/go"`
(not `.../proto/go/pulumirpc`).
## Plugins
- **Recorder** (reference/contract plugin): `FINFOCUS_RECORDER_OUTPUT_DIR`
(default `./recorded_data`), `FINFOCUS_RECORDER_MOCK_RESPONSE`.
- **Jev scorer** (`plugins/jev/`): off unless `TYPESAFE_API_KEY` is set; the key
never appears in logs, errors, tests, or files. The recommendation id never
leaves the plugin (answers map back by position). `priority` is always one
record per request (batching drops rank quality). Scores rank only; never
gate an irreversible action on one.
- **Agent skills**: finfocus-specific skills live in `agent-skills/` here;
generic cost-workflow skills live in `rshade/agent-skills`.
## Package-Specific Gotchas
### Plugin Host (`internal/pluginhost/`)
- Ports: allocate, hold, release, bind, retried by `StartWithRetry`. A port
stays in `pendingPorts` until its plugin binds. A skipped pending port's
listener is closed at once: the readiness probe is a bare TCP dial, so a
held listener would answer for another plugin.
- Always `cmd.Wait()` after `cmd.Process.Kill()`.
- Core never sets `PORT`; plugins use `--port` or `FINFOCUS_PLUGIN_PORT`.
### CLI (`internal/cli/`)
- `analyzer serve` prints only the port on stdout (Pulumi handshake); all logs
go to stderr.
- **Tests leak into the real `~/.finfocus`** unless they set
`t.Setenv("FINFOCUS_HOME", t.TempDir())`; this has produced tests that pass
only after a sibling seeded the store. A test that fails alone
(`HOME=$(mktemp -d) go test -run Name`) is order-dependent, not flaky. The
same leak makes `TestPluginListCmd_TableOutputUnchanged` fail when several
plugin versions are installed locally.
- Reject an unknown `--output` before loading state; early returns on empty
results otherwise exit 0 silently (see `config_routes.go`, `plugin_list.go`).
- `config validate` and every `cost` pre-run share `ValidateConfigSource`.
The on-disk budget field is `cost.budgets.global.amount`; flat
`cost.budgets.amount` is warned and ignored.
- Do not put `--plain` (or other accessibility flags) on the `cost` parent:
`cost history view` has its own `--plain`. Resolve modes with
`tui.DetectResolvedOutputMode`, never `DetectOutputModeFor`, which re-reads
`NO_COLOR` and undoes an explicit `--color`.
- Budget CLI flags (`--exit-on-threshold`, `--exit-code`) are
`BudgetFlagOverrides` on the command context, never written onto the global
config; only a copy of the global scope receives them.
- Budget notifications (`specs/625-budget-alert-notifications/`): stderr only,
never change stdout or the exit code. Only `${FINFOCUS_NOTIFY_*}` expands,
and a project-sourced destination with `${` is skipped without a lookup.
Sends are HTTPS only, follow no redirects, and time out after 10s. Failure
reasons redact every variable value, URL, and secret-looking header, and
never include a response body.
`ThresholdStatus.Notifications` is `json:"-"`; masking is display-only
because `Config.Save` marshals the struct. Tests swap the client with
`cli.SetNotificationClientForTest`.
- The overview docs page is `docs/src/content/docs/commands/overview.md`.
- The dashboard Config page and `config set` patch `config.hujson` through
`config.PatchFile`. Do not call `Config.Save` for that write: it rewrites the
struct from `New()`, folds environment variables into the file, and drops
comments. `baseVersion` is the SHA-256 hex of the file bytes, or `absent`
when the file is missing. A legacy project `config.yaml` is hashed as raw
YAML and converted inside the patch, after that hash check, not before it.
Only the global loader rewrites a legacy file to `config.hujson`. `config set`
runs after that migration and passes an empty `baseVersion`. A request the
caller got wrong is a `config.InvalidChangeError` (400); any other write
error is logged and returned as a generic 500, because it can carry paths.
### Registry (`internal/registry/`)
- `Open` launches up to 8 plugins concurrently; clients keep
`ListLatestPlugins` order, sorted by name (router priority ties rely on it).
- With `region` in `plugin.metadata.json`, discovery looks for
`finfocus-plugin-<name>-<region>` first.
- Only a confirmed checksum mismatch is fatal; `--skip-checksum` exists.
- Monorepo plugins (`tag_prefix: "kubernetes-"`): "latest" is the highest
prefixed stable semver, never `/releases/latest`; installs use the canonical
version; the entry needs `asset_hints.asset_prefix`.
- A latest release with no assets yet (release-please publishes before the
archives upload) is replaced by the newest earlier release with assets. An
explicit version is never swapped; it returns `*NoAssetsError`
(`ErrReleaseHasNoAssets`), which the CLI's version fallback still handles.
- `DefaultPluginDir()` is `config.ResolveConfigDir()/plugins`.
### Router (`internal/router/`)
- A `$0.00` result is valid and does not fall back; only nil/empty does.
- Higher priority number wins. No routing config means no router (all
plugins are queried).
- Provider means the billing cloud. `resourcetype.NormalizeProvider` holds the
only alias table (`aws-native`→`aws`, `azure-native`/`azurerm`→`azure`,
`google-native`/`google`→`gcp`); never split a type token for a provider
anywhere else. `resource_type` keeps the package, so `aws-native:*` patterns
still match.
### Engine (`internal/engine/`)
- `hoursPerMonth = 730`. Pricing-spec `per_day` uses 30 days (720 h). Do not
"fix" one against the other: both are AWS's own conventions.
- JSON/NDJSON error codes are `PLUGIN_ERROR`, `VALIDATION_ERROR`,
`TIMEOUT_ERROR`, and `NO_COST_DATA` (`types.go`); `NO_COST_DATA` is not an
error.
- A failed plugin call keeps the gRPC status as `<Code>: <message>`
(`pluginRPCError`). Only an empty projected result is `no cost data
available`. Core does not read `aws:region` from provider or stack config.
- Projected diff (`cost projected`): replacement steps sharing a URN are
collapsed into one `update` (`collapseReplacements`), or the old resource
counts twice. Both sides use the same basis and the same plan-wide `ref.*`
resolution. Unpriced `pulumi:` types are left out.
- Cost history (`internal/history`, `cost history *`): files are
`<project>@<stack>.history.db`, separate from the resource-observation
`history.db`. `collect` fails a checkpoint rather than store an inaccurate
total (#549). `internal/history` must not import `engine` or `ingest`.
History subcommands reuse the parent `--stack`. `cost history export` takes
`csv`/`ndjson` through the root `--format` by rewriting it in `Args` before
`ax.Execute` resolves the mode. `pulumi stack history` is read in pages of
100 until a page adds no new version; a short page is not the end, and the
CLI's default page is only 10 updates.
- `--explain`, `--pricing-spec-fallback`, and estimate-TUI discovery call
`GetPricingSpec`; disabled runs never call it, and each call has a 5s
deadline. A `$0` rate is a price; a plugin must answer `zero_cost` or an
error for "no price". Tiers are graduated, and tiers that do not cover the
whole quantity fall through to YAML.
- `cost forecast` (`specs/626-cost-forecast/`) does not change `cost projected`
totals. A blank currency is USD.
- `checkPluginSupports` caches per client, provider, type, region, SKU,
feature, and attributes digest. SKU must stay in the key, or one SKU-less
answer poisons every SKU in that region. The SDK's "not implemented" reason
is fail-open, like an RPC error.
- Plugin `expires_at` sets a per-entry TTL (capped at 7 days; a past time skips
caching). A plugin whose price depends on its own environment must send
`expires_at` = now, because the cache key cannot see it.
- Cache hits append "(cached)", with a leading space, to the adapter. A
corrupt cache is deleted and recreated. `cost.cache.lru_enabled` wraps
Bolt in `cache.TieredStore`.
- Budget health: OK < 80%, WARNING 80-89%, CRITICAL 90-100%, EXCEEDED > 100%;
aggregation takes the worst.
- Recommendations: `Recommendation.ID` is the dismissal id. The cache key hashes
inputs plus dismissed ids; `--include-dismissed` still sends excluded ids and
adds `/include-dismissed`.
- Scoring (`internal/scoring`, off by default) pseudonymizes identifiers by
default, only warns on failure, and never dismisses or hides anything.
Scorer-only plugins are dropped from cost features before `Supports`.
### Overview Field Semantics (`internal/engine/overview_*.go`)
| Field | Basis | Source |
| --- | --- | --- |
| `MTDCost` | Day 1 to today (spent so far) | Actual plugin |
| `MonthlyCost` | Full 730 h month | Projected plugin |
| `ExtrapolatedMonthly` | Calendar month from the MTD trend | `CalculateCostDrift` |
| `Delta` (drift) | `ExtrapolatedMonthly - MonthlyCost` | `CalculateCostDrift` |
- `MTDCost` and `MonthlyCost` are different units; never subtract them without
extrapolating first.
- Population by status:
| Status | Actual | Projected | Drift | PropertyDiffs |
| --- | --- | --- | --- | --- |
| Active | yes | yes | maybe | no |
| Updating / Replacing | yes | yes | maybe | yes |
| Creating | no | yes | no | no |
| Deleting | yes | no | no | no |
- The Delta column always comes from `CalculateRowDelta()`, which is
status-aware; never compute it inline. Update/replace is projected(after)
minus the baseline projected(current) (`GetBaselineProjectedMonthlyCost`),
the same basis. It falls back to extrapolated actual only without a
baseline, and shows no delta without property diffs. Creating is
`+projected`, deleting is `-extrapolatedActual`, active is the drift delta.
- `GetExtrapolatedActual()` and `ForceExtrapolateActual()` use 30 days
(stable deltas); `CalculateCostDrift()` uses calendar days. Do not unify
them.
- `CostDrift == nil` is normal: day 1-2, drift under 10%, new or deleted, or
created under 3 days ago.
- TUI diff values are truncated to 40 characters (`truncateDiffValue`). State-only
mode marks projected costs with `*`; `p` runs a preview and updates rows in
place.
### Cluster Allocation (`internal/engine/cluster*.go`, `plugins/`)
Specs 613-623 hold the details. Invariants:
- Core never interprets Kubernetes. Nodes arrive from `GetStats` as ordinary
descriptors and are priced by the engine; grouping is string-map aggregation.
- Run-rate prices with `GetProjectedCostWithErrors` and `Monthly`. A window
(`--from`/`--to`) prices with `GetWindowCost` and `TotalCost` only, labels the
period with `FormatWindow` (not `FormatPeriod`, which `cost actual` keeps),
and requires a source in historical mode (`ErrStatsModeMismatch`).
- A `$0` or missing price is unpriced (aws-public returns `$0` for unknown
types). All nodes unpriced is fatal. A stats warning starting `incomplete:`
sets `Incomplete`.
- Conservation is enforced in core (`VerifyConservation`, shared SDK rule)
even though the conformance suite checks it too.
- Nodes are keyed by cluster and node name; the priceable `id` stays the
Kubernetes node name.
- The idle row stays at `$0`: `ValidateAllocateResponse` needs one per priced
node. Fargate pods are their own priceables and never fold into idle.
- `plugins/kubernetes` and `plugins/prometheus` must not import core or each
other. Prometheus node identity copies the descriptor rules from
`plugins/kubernetes/usage/nodes.go`.
- Prometheus collection (`plugins/prometheus/`):
- Take `max by (node)` of kube-state-metrics capacity before integrating.
Replicas export the same node twice, and a sum doubles capacity.
- cAdvisor scraped from the kubelet often has no `node` label. The node
then comes from `kube_pod_info`, and matching must accept the empty label.
- Clusters are discovered from `kube_node_labels` or
`kube_node_status_allocatable` before any sum drops the label.
- Retention shorter than the window is detected store-wide
(`promql.StoreStart`), not per pod. A hole shorter than the 5m lookback is
not detected.
- The bearer token goes only to the configured scheme and host (a redirect
elsewhere would receive it otherwise), and URLs in errors and logs drop
userinfo and query.
- Pod label keys from kube-state-metrics are sanitized
(`app.kubernetes.io/name` becomes `app_kubernetes_io_name`).
### MCP Server (`internal/cli/mcp.go`, `output_mode.go`)
- ax-go re-executes the shared root once per `tools/call`, so persistent
pre/post run per call. Dispatched calls reuse the server's
`loggingSession` and never close it.
- One mutex serializes every call; a blocking command stalls the server. That
is why `analyzer serve` and `cost history collect` are excluded
(`mcpExcludedCommands`, `mcp.Exclude`). Never use `Hidden` to exclude a
group: ax-go prunes the whole subtree.
- `resolveOutputFormat`: explicit `--output`, then `--format`, then
`AGENT_MODE`. It ignores TTY detection on purpose. New commands with an
output flag must use it.
- Flag values reset per call only on a real server; assert cross-call
behavior in the integration test.
- `internal/cli/testdata/mcp/tools.golden` pins the tool list. A new command
must be exposed or excluded on purpose (`UPDATE_GOLDEN=1`).
- The MCP handshake rejects `dev`/`unknown`; build test binaries with
`-ldflags -X .../pkg/version.version=...`.
### Plugin Upgrade (`internal/pluginupgrade/`, `agent-skills/finfocus-plugin-upgrade/`)
- Bumping finfocus-spec needs a hop: `hops.go` must reach
`pluginsdk.SpecVersion` and have a `references/to-vX.Y.Z.md` guide (tests
enforce both), even for an additive release. Add the guide's row to
`SKILL.md` and rebuild the `.skill` zip (`SKILL.md` + `references/`).
- `SpecVersion` must be `v`-prefixed, or `GetPluginInfo` returns `Internal`.
- `plugin.manifest.json` is the flat `registry.Manifest`, not the pluginsdk
manifest; `plugin validate` needs name and version to match the install dir.
- Skill install runs `npx skills@1.7.0 add` in an empty temp dir, never the
plugin dir (its `.npmrc` and `node_modules` could run code), and writes
through an `os.Root`. Every failure is a warning. Tests never run npx.
## Recent Changes
- 628-web-config-page: dashboard Config page and `config set` patch `config.hujson` in place
- 626-cost-forecast: `cost forecast` projects plugin GrowthType with finfocus-spec `pricing.ApplyGrowth`
- 625-budget-alert-notifications: Slack and generic HTTPS webhook budget alert destinations (stdlib `net/http`)
- 623-prometheus-usage-source: Prometheus usage source plugin and historical `cost cluster` windows
## Active Technologies
- Go 1.27.1 (`go.mod`), finfocus-spec v0.7.5 (`pluginsdk`), Cobra, ax-go, gRPC, zerolog, BoltDB (`go.etcd.io/bbolt`), Bubble Tea v2, testify; `prometheus/client_golang` and client-go in plugin modules only