Skip to content

Latest commit

 

History

History
308 lines (248 loc) · 27.1 KB

CLAUDE.md

File metadata and controls

308 lines (248 loc) · 27.1 KB

CLAUDE.md

This file is the single source of truth for repository guidelines, used by Claude Code and all AI coding agents.

Project Overview

actop — Python-based performance monitoring CLI for Apple Silicon Macs (M1/M2/M3/M4 chips). An independent, original project inspired by tlkh/asitop (built to fill its whole-chip/sudoless/programmable gaps; not a code fork). Uses IOReport (in-process) for Apple Silicon power/frequency/residency metrics. Combines these with native ctypes syscalls (native_sys.py, replacing psutil), sysctl, and system_profiler into a real-time terminal dashboard showing CPU/GPU/ANE utilization, per-core frequency, memory/bandwidth, thermal state, and process info.

Python Environment (Required)

  • Always use the repository virtual environment at .venv.
  • Prefer explicit executables over shell-global tools:
    • .venv/bin/python
    • .venv/bin/pip
    • .venv/bin/pytest
  • Do not run python, pip, or pytest from the global environment for this repo.

Build, Test, and Development Commands

  • git config core.hooksPath .githooks: run once per clone to activate the local hooks (pre-commit secret redaction + pre-push main guard + pre-push bare-tag nudge). Fresh clones have no hooks until this is set.
  • .venv/bin/python -m pip install -e ".[dev]": install editable + dev deps (ruff).
  • .venv/bin/python -m actop.actop --help: validate CLI parsing and flags.
  • .venv/bin/python -m actop.actop --interval 2 --avg 30: run the tool locally.
  • .venv/bin/python -m build: build source/wheel artifacts.
  • .venv/bin/pytest -q: run automated tests.

Architecture & Core Components

Module Role
actop/actop.py CLI entry point (cli()), argument parsing, thin wrapper launching the Textual TUI
actop/sampler.py IOReportSampler: subscription lifecycle, delta computation, SampleResult conversion, DVFS table discovery via native ctypes
actop/ioreport.py ctypes bindings to libIOReport.dylib and CoreFoundation (IOReportSubscription, cfstr, cf_release)
actop/smc.py SMC temperature reader: IOKit ctypes bindings to AppleSMC, key discovery, CPU/GPU die temperature reads
actop/gpu_registry.py GPU accelerator registry reads via IOKit ctypes bindings: get_gpu_time_by_pid() sums accumulatedGPUTime off each AGXDeviceUserClient; get_gpu_perf_stats() reads device-level Device/Renderer/Tiler utilization % off the accelerator's own PerformanceStatistics dict (driver point reads — a fallback for gpu_util_pct plus the compute-vs-geometry breakdown IOReport cannot express; see docs/SPEC-system.md §3.8)
actop/disk_registry.py Disk I/O via IOKit ctypes bindings: read_disk_totals() sums cumulative Statistics counters across AppleAPFSVolume entries ("Bytes read/written from block device"), falling back to IOBlockStorageDriver on non-APFS; cumulative counters are deltas in the sampler — see docs/SPEC-system.md §2.4
actop/utils.py L1 acquisition + platform discovery: native ctypes RAM/swap metrics and per-process CPU/GPU time collection (via native_sys.py), sysctl/system_profiler SoC info. Byte quantities cross layer seams as raw bytes (exact, prefix-free, Prometheus base-unit convention); GiB/MiB formatting is presentation and lives in tui/, and bandwidth stays decimal GB/s because that is Apple's unit for the bus — see docs/SPEC-system.md §2.1. Holds no derived domain math
actop/analytics.py L2 domain analytics over acquired data points (imports only models/power_scaling, never tui/*): per-process power attribution (attribute_power), throttle detection (domain_throttling), bandwidth/package-power/I-O-rate normalization (io_rate_percent + the net/disk chart floors), and the stateful AlertEngine (sustain-counted alerts + session-energy integral → AlertFrame)
actop/soc_profiles.py 16 built-in M1–M4 SoC profiles with power/bandwidth reference values; tier fallbacks for unknown chips
actop/power_scaling.py Power chart scaling: auto mode (rolling peak x1.25) vs profile mode (SoC reference wattage)
actop/config.py DashboardConfig frozen dataclass; create_dashboard_config() merges CLI args with SoC info
actop/models.py SystemSnapshot, CoreSample, ProcessSample dataclasses (public API types)
actop/api.py Monitor, Profiler, AsyncMonitor — public Python API for hardware profiling; Monitor(include_processes=True) opts into watt-attributed ProcessSamples
actop/tui/app.py ActopApp: Textual App with polling worker, process table, app-level status bar (fed by AlertsComputed), interactive sort/filter/pause; l cycles the layout preset, c toggles the per-core panels, and --layout/process-table width live at app level
actop/tui/widgets.py HardwareDashboard widget plus the custom BrailleChart sparkline widget, core rows, and alert computation. Composes seven titled sections (P-CPU, E-CPU, GPU · ANE, Memory, Network, Disk, Power) and owns the grid/stack layout presets (set_layout_preset, width auto-degrade below ~96 cols) plus the runtime per-core toggle (set_show_cores, hidden by default); posts the status string up via AlertsComputed. TUI CSS is embedded inline as each component's DEFAULT_CSS (no separate .tcss file).

Data flow: ioreport.py provides ctypes bindings for IOReport snapshots and deltas → sampler.py subscribes to Energy Model / CPU Stats / GPU Stats channels, computes deltas between consecutive snapshots, reads SMC die temperatures via smc.py, and converts raw items into a SampleResult (power in watts, frequency in MHz, activity in percent, temperatures in °C) → api.py wraps the sampler in Monitor and maps SampleResult to SystemSnapshot (including CoreSample lists; the driver's gpu_registry.get_gpu_perf_stats() Device/Renderer/Tiler read, which also selects gpu_util_source — residency stays primary unless the GPU DVFS table went unclassified; and, when include_processes=True, watt-attributed ProcessSample lists computed in L2 via analytics.attribute_power) → SystemSnapshot is the sole per-frame contract: tui/widgets.py feeds it into custom BrailleChart sparkline widgets (arranged in seven titled sections — P-CPU, E-CPU, GPU · ANE, Memory, Network, Disk, Power — under a grid or stack layout preset; the two I/O sections hide themselves when the platform exposes no counters) and posts the composed thermal/alert status up to tui/app.py via an AlertsComputed message for the fixed app-level status bar; tui/app.py reads processes off the snapshot and drives the render loop (the TUI imports no L1 acquisition/attribution).

SoC compatibility: Explicit M1–M4 recognition (16 profiles). Unknown future chips fall back to tier defaults (base/Pro/Max/Ultra) using the latest known generation's reference values.

Release Process

main is PR-only (branch protection + .githooks/pre-commit redaction check and .githooks/pre-push guard; run git config core.hooksPath .githooks once). The Homebrew formula lives in the separate tap repo binlecode/homebrew-actop (not this repo); CI syncs it on tag. PyPI publishing is LOCAL-ONLY and part of the tag flow — scripts/tag_release.sh builds the sdist + wheel and uploads them with twine right after it pushes the tag and creates the GitHub Release, so cutting a release and publishing it are one act. No workflow publishes to PyPI: there is no publish-pypi.yml, and OIDC Trusted Publishing is not used. Credentials come from the environment (TWINE_USERNAME / TWINE_PASSWORD, exported by ~/.zshrc); the script verifies both are present before it pushes anything, because a credential failure found after the tag is pushed leaves a half-cut release. CI/CD mechanics are owned by the workflow files themselves (.github/workflows/main-ci.yml, release-formula.yml, guard-release.yml) — each carries its one-time setup inline. guard-release.yml is the post-push integrity gate: every tag push triggers a scan that verifies every v* tag has a corresponding GitHub Release object, catching the "silent link breakage" that caused the v0.8.7–v1.6.6 release-object gap.

RULE — cutting a release REQUIRES a GitHub Release, not just a tag. A release is cut with scripts/tag_release.sh, which pushes the git tag and creates the GitHub Release for it via gh release create (release notes pulled from the matching CHANGELOG.md section). A bare git tag + push is not a release: it never surfaces on the /releases page or as Latest, and leaves the Releases history silently behind the code. Never cut a release by hand-tagging alone — if the gh release create step fails, finish it manually (see playbooks below) before calling the release cut done.

When to cut a release

A release needs something that changed for users. Repo-internal churn — CI workflows, the release scripts themselves, docs, the cover page, agent skills, test-only edits — lands under CHANGELOG.md [Unreleased] and ships with the next release that carries a user-visible change. It is never a release on its own.

One release per change set, and version numbers are permanent. PyPI burns a version string on first upload: it can be yanked but never reused, so every throwaway release permanently consumes a number and adds a row to /releases that users have to reason about. On 2026-08-13, six releases (1.9.2–1.9.7) landed inside forty minutes, every one of them a fix to the release pipeline that had just shipped the previous one. That is the failure mode this rule exists to stop: when the release flow itself is what is broken, fix it on a branch and cut one release at the end.

The only reason to re-tag the same day is a release that is broken for users — install fails, import fails, the TUI crashes on launch. Anything less waits.

If several unreleased changes have accumulated, a release PR rolls them into one dated section; do not manufacture one section per merged PR.

Release steps

# 1. Bump version and changelog via a RELEASE PR (never commit the bump to main).
#    Only do this when "When to cut a release" above says it is warranted.
git switch -c release-vX.Y.Z
# edit pyproject.toml ([project].version) and CHANGELOG.md (move Unreleased -> new version + date)
git commit -am "Release vX.Y.Z" && git push -u origin release-vX.Y.Z
gh pr create --base main --fill

# 2. Run checks (locally and/or on the PR)
.venv/bin/python -m ruff check --fix .
.venv/bin/python -m ruff format .
.venv/bin/python -m actop.actop --help
.venv/bin/pytest -q

# 3. Merge and sync
gh pr merge --merge --delete-branch
git checkout main && git pull --ff-only

# 4. Cut the release — tag + GitHub Release + PyPI publish, all from here.
#    Needs TWINE_USERNAME/TWINE_PASSWORD in the env and .[release] installed;
#    the script pre-flights both before it pushes anything.
#    NOTE: this uploads to PyPI. The version string is burned permanently.
scripts/tag_release.sh "X.Y.Z"

# 5. Monitor CI — wait for release-formula and guard-release to pass
gh run list -R binlecode/actop --limit 10

# 6. Verify
pipx install actop && actop --version                # PyPI
brew update && brew upgrade binlecode/actop/actop    # Homebrew (tap repo synced)

Failure playbooks

  • tag_release.sh fails — local main behind/diverged: the bump PR may not be merged/pulled yet.
    git fetch origin && git checkout main && git pull --ff-only origin main
    scripts/tag_release.sh "X.Y.Z"
  • gh release create fails or is skipped (auth/permissions): the tag may be pushed but no GitHub Release exists — the release cut is not done until it does. Confirm gh auth status and re-run just the release step against the existing tag:
    gh release create "vX.Y.Z" --title "actop X.Y.Z" --notes "$(awk '/^## \['"X.Y.Z"'\]/ {f=1; next} f && /^## \[/ {exit} f' CHANGELOG.md)"
    Never leave a release cut as a bare tag — verify it appears on /releases before moving on.
  • release-formula fails with tag/version mismatch: tag vX.Y.Z doesn't match pyproject.toml in the tag commit. Fix in a new PR, merge, then create a new tag (never reuse the old tag).
  • release-formula fails at tap checkout/push: missing or under-scoped HOMEBREW_TAP_TOKEN. Confirm the secret exists on binlecode/actop and the PAT has Contents: Read/write on binlecode/homebrew-actop. Re-run the failed job.
  • tag_release.sh aborts in pre-flight (missing build/twine, or empty TWINE_*): nothing was pushed — that is the point of checking first. Install the tooling (.venv/bin/python -m pip install -e ".[release]") or fix the credential export (env | grep -c TWINE_PASSWORD, expect 1; a renamed token file silently yields an empty value because ~/.zshrc uses 2>/dev/null), then re-run.
  • PyPI upload fails after the tag and GitHub Release exist: do not re-cut the tag — it and the Release are already correct, and the version cannot be reused on PyPI anyway. Fix the cause and re-run only the upload from the tag's checkout: .venv/bin/python -m twine upload --skip-existing dist/*. --skip-existing makes repeats safe.
  • Build produced the wrong version: the script checks for dist/actop-X.Y.Z.tar.gz and the matching wheel and aborts before uploading if either is missing, so a stale dist/ cannot be shipped (it wipes dist/ first for the same reason). Re-run the build from a clean checkout of the tag.
  • Emergency rerun without resource refresh: workflow_dispatch on release-formula with refresh_resources=false as a temporary workaround; follow up with a normal run (refresh_resources=true).

PyPI publishing — local only

There is one flow: local, token-driven, run by scripts/tag_release.sh as part of cutting the tag. No workflow publishes to PyPI — publish-pypi.yml was removed and OIDC Trusted Publishing is not used, so the long-lived project-scoped token is the only publishing credential. The steps the script runs, should you ever need them by hand from a clean checkout of the tag:

rm -rf dist                                     # stale artifacts would be uploaded by the glob
.venv/bin/python -m build                       # produces dist/*.tar.gz and dist/*.whl
.venv/bin/python -m twine check dist/*          # catches what PyPI would reject
.venv/bin/python -m twine upload --skip-existing dist/*   # credentials already in the env

Requires the release extra (pip install -e ".[release]"), which is deliberately outside dev so Linux CI stays lean.

The credential comes from the system environment — check there first, do not hunt for the token file. ~/.zshrc exports TWINE_USERNAME=__token__ and TWINE_PASSWORD=$(cat $HOME/env-secrets/api_keys/pypi-org-api-token-binlecode 2>/dev/null) in every login shell, so twine upload authenticates with no flags, no prompt, and nothing pasted on a command line. Confirm it is present with env | grep -c TWINE_PASSWORD (expect 1) — never echo the value. The file under ~/env-secrets/ is only the backing store for that export; the env var is the interface.

Hygiene — keep the token; never delete it. It stays (mode 600) and is refreshed when it expires, not revoked. It is now the only way to publish at all: with publish-pypi.yml gone there is no OIDC fallback, so deleting the token does not remove a spare path — it removes the sole path, and releases stop shipping. Worse, the export is written with 2>/dev/null: delete or rename the file and TWINE_PASSWORD silently becomes the empty string, turning a missing credential into a confusing PyPI auth failure instead of an obvious missing-file error. (tag_release.sh now pre-flights for exactly this before pushing anything.) An earlier version of this section said to delete the token once OIDC was confirmed; that was wrong and is retired.

Rules for the retained token: never commit it or paste it into a PR/issue (.githooks/pre-commit redaction is a backstop, not the control); never echo it — rely on the env export; keep it project-scoped to actop, never account-wide, so a leak cannot reach other projects. On refresh, write the new token into the existing filename — ~/.zshrc names pypi-org-api-token-binlecode literally, not by glob, so renaming the file (e.g. to add an -exp-<month>-<year> suffix like the other dated keys) breaks the export and, because of the 2>/dev/null, does so silently. To adopt a dated name, edit the .zshrc path in the same commit as the rename. Revoke on pypi.org only if the token leaks or is replaced.

Homebrew Core submission (optional)

To make actop natively trusted without users running brew tap, meet Homebrew Core's popularity bar (75+ stars, notable usage), run brew audit --new-formula actop, and open a PR to homebrew-core.

Quick reference

gh run list -R binlecode/actop --limit 12
gh run view -R binlecode/actop <RUN_ID> --log-failed
git ls-remote --tags origin "v*"
gh secret list -R binlecode/actop                 # confirm HOMEBREW_TAP_TOKEN
env | grep -c TWINE_PASSWORD                      # expect 1 (never echo the value)
.venv/bin/python -c "import build, twine"         # release extra installed?

SDLC & Architectural Documentation

The docs/ directory contains essential system reviews, research, and operations guides:

  • docs/SPEC-system.md: Detailed system design reference — native bindings, sampling layer, SoC profile fallback, TUI rendering, testing contract. Kept in sync with the code on every PR that touches architecture.
  • docs/ROADMAP.md: Open hardware/metric-coverage gaps and their priority.

Agent skills live in .claude/skills/ — nowhere else. Claude Code discovers project skills only under .claude/skills/ (plus ~/.claude/skills/ and plugins); a skill parked anywhere else is invisible to the agent and will simply never be invoked. record-tui-gif, publish-cover, capture-tui-diagram, and run-actop were moved to .agents/skills/ in a past docs reorg and silently stopped being offered — they were still correct, still committed, and still useless, because nothing surfaced them. Do not "consolidate" them elsewhere; if another tool needs its own convention, give that tool a copy or a symlink and leave .claude/skills/ as the real home.

Conformance auditing: the /audit-conformance skill (.claude/skills/audit-conformance/) periodically judgment-scans the whole tree against 12 coding rules (layering, dead code, DRY, naming, swallowed errors) — the whole-codebase counterpart to diff-scoped /code-review. It writes an actionable docs/TODO-conformance-YYYY-MM-DD.md and never proposes structural/guard tests (they would violate the functional-tests-only mandate).

Coding Style & Naming Conventions

  • Follow existing Python style: 4-space indentation, snake_case for functions/variables, short focused modules.
  • Never use from x import *; always import explicitly.
  • Keep parser keys and metric field names consistent with existing patterns (for example, P-Cluster_active, gpu_W).
  • Prefer small, incremental changes in existing files over large refactors.
  • No formatter/linter config is checked in; match surrounding code style when editing.

Testing Guidelines (HARD RULE — enforced at review)

  • All tests are located in the tests/ folder.
  • Run .venv/bin/pytest -q for all code changes.
  • Run a single test file: .venv/bin/pytest tests/test_cli_contract.py -q
  • Run a single test function: .venv/bin/pytest tests/test_cli_contract.py::test_name -q

The functional-only mandate

Every test MUST answer "what production failure mode or regression risk does this test catch that no other test catches?" If you cannot give a concrete answer (broken export format, wrong flag forwarding, missing process data, etc.), the test is structural — do not add it; delete it if present. This rule binds to all tests, not just new ones.

Reject a test (it is structural — do not add it; remove it if present) when it:

  • calls an underscore-prefixed (private) function or method as the unit under test (e.g. _avg_max, _inline_spark, _format_core_entry);
  • reads or writes private attributes to arrange or assert state (e.g. dash._sample_count = 5, dash._core_hist, dash._chart_glyph);
  • asserts internal implementation details, helper math constants, or a private function's output in isolation;
  • uses a mock, fake, monkeypatch, or a synthetic subclass/harness that overrides real behavior to fake layout, I/O, or data;
  • exists only to raise coverage;
  • is a standalone default-value assertion (e.g. parse_args([]).some_flag is False) — what would break if this test were deleted? A behavioral test that uses a different value or exercises the same code path already guards that regression. Default-value checks are redundant, not load-bearing;
  • is a format-contract / shape test against a synthetic input. Constructing a synthetic AlertFrame and asserting its keys appear in JSON is structural — it does not exercise the actual pipeline. A functional test mocks Monitor, feeds real snapshots through AlertEngine, and asserts the computed alert values match the threshold/sustain logic. Same for "single-line JSON", "TYPE header present", "values parse as numbers" — those are output-shape properties already verified by every functional test that reads and parses the output;
  • is a standalone parse_args "this flag accepts value X" test when a behavioral test already drives that flag with that value — e.g. parse_args(["--flag"]).flag is True is redundant when parse_args(["--json", "--flag"]).flag is True exists and catches both the parsing and the forwarding;
  • asserts only shape/bounds on real public data (e.g. "returns a non-negative dict", "values partition to ≤ 1.0") with no real workload behind it, when a behavioral test already exercises the same code path — the bounds/invariant becomes an assertion inside that behavioral test, not a standalone test of its own;
  • runs an identical subprocess call as another test just to assert different substrings from the same stdout — consolidate the assertions into one test rather than paying the fork cost twice.

Accept a test (it is functional) when it drives a public surface:

CLI invocation (subprocess / build_parser().parse_args), the public API (Monitor / Profiler), the real config merge (create_dashboard_config), documented public module functions (e.g. power_to_percent, get_soc_profile), real export/format contracts (NDJSON / Prometheus), real hardware/file I/O paths, or a real widget rendered through its public path (BrailleChart.render() via the data setter, or a HardwareDashboard mounted with Textual App.run_test() and fed real SystemSnapshots through update_metrics).

A minimal Textual host App used solely to mount a real widget is allowed (it is a mount point, not a fake); faking the data or the logic under test is not.

Before writing a test, apply this litmus:

  1. Can I name the production failure this test will catch? If the answer is "it protects against argparse breaking" for a default-value assertion, or "it catches a regression in the flag's boolean default," that failure mode is already covered by every behavioral test that parses the flag with a non-default value — skip it.
  2. Does another test already drive the same code path? Two tests that both call parse_args(["---flag"]) and both assert the flag is True are one too many. Keep the one that also asserts the flag's downstream effect.
  3. Is the assertion independently load-bearing? If you can delete the assertion and no behavioral test would fail, it is cosmetic — inline it into a behavioral test or drop it.

Minimum checks before opening a PR:

  • .venv/bin/python -m actop.actop --help
  • .venv/bin/pytest -q
  • Run actop on Apple Silicon and confirm gauges/charts update without crashes.
  • For parser or metric changes, include a reproducible sample input/output note in the PR description.

Commit & Pull Request Guidelines

  • Branch from main; PR strictly into main. Every branch forks from main and targets main. Never fork a feature branch off another feature branch (no stacked PRs): if you need work that is still on an unmerged branch, wait for it to merge and re-branch from main. This holds especially for CI/CD and release changes — they land via a single PR into main, never a chained branch.
  • A normal PR does NOT bump the version. It adds its entry under CHANGELOG.md [Unreleased] and leaves pyproject.toml alone. Only a release PR moves [Unreleased] into a dated version section and bumps [project].version — patch by default, minor for a milestone (major reserved for breaking API/CLI changes) — and a release PR is the only kind followed by scripts/tag_release.sh. See When to cut a release in the Release Process section for when a release PR is warranted; publishing to PyPI is a third, manual step after the tag — see the Release Process section above.
  • Use concise, imperative commit subjects (as seen in history), e.g. Add support for M1 Ultra or actop/utils.py: add bandwidth of M2.
  • Keep commits scoped to one logical change.
  • Before every commit and before every push, always run:
    • .venv/bin/ruff check --fix .
    • .venv/bin/ruff format .
  • PRs should include:
    • clear summary of behavior change,
    • tested macOS/chip details (for example, Ventura + M2),
    • commands used for validation,
    • screenshot or terminal capture for UI-visible changes.

Security & Configuration Tips

RULE — never delete, revoke, or rotate a credential, and never propose it as cleanup. Not the PyPI token, not HOMEBREW_TAP_TOKEN, nothing under ~/env-secrets/. Deleting a credential is irreversible from this side, and the ones that look idle are usually the break-glass path for a pipeline that is already broken — removing them costs nothing until the day it costs everything. Credential lifecycle is the maintainer's call alone: if a credential genuinely looks stale, say so in one line and stop. Do not run the deletion, do not stage it, do not put it on a checklist. This rule exists because an agent read the old "delete the token once OIDC is confirmed" hygiene note as an action item and proposed deleting the live PyPI break-glass token immediately after a successful release (2026-08-23); the note is retired, but the reflex it triggered is what this rule blocks.

Reading secrets: prefer the system environment (TWINE_USERNAME / TWINE_PASSWORD are pre-exported by ~/.zshrc — see PyPI publishing — two flows) over reading files under ~/env-secrets/. Never cat, echo, print, or interpolate a secret value into a command line, a commit, a PR body, or an issue: mask it (sed -E 's/[A-Za-z0-9_-]{20,}/<REDACTED>/g') or, better, assert on its presence (env | grep -c TWINE_PASSWORD) instead of its content. Filenames and env-var names are not secrets; their values are.

  • The IOReport backend runs unprivileged.
  • Avoid introducing persistent privileged processes, unsafe temporary-file handling, or shell commands that invoke sudo.