This file is the single source of truth for repository guidelines, used by Claude Code and all AI coding agents.
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.
- 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, orpytestfrom the global environment for this repo.
git config core.hooksPath .githooks: run once per clone to activate the local hooks (pre-commitsecret redaction +pre-pushmainguard +pre-pushbare-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.
| 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.
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.
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.
# 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)tag_release.shfails — localmainbehind/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 createfails or is skipped (auth/permissions): the tag may be pushed but no GitHub Release exists — the release cut is not done until it does. Confirmgh auth statusand re-run just the release step against the existing tag:Never leave a release cut as a bare tag — verify it appears ongh release create "vX.Y.Z" --title "actop X.Y.Z" --notes "$(awk '/^## \['"X.Y.Z"'\]/ {f=1; next} f && /^## \[/ {exit} f' CHANGELOG.md)"
/releasesbefore moving on.release-formulafails with tag/version mismatch: tagvX.Y.Zdoesn't matchpyproject.tomlin the tag commit. Fix in a new PR, merge, then create a new tag (never reuse the old tag).release-formulafails at tap checkout/push: missing or under-scopedHOMEBREW_TAP_TOKEN. Confirm the secret exists onbinlecode/actopand the PAT has Contents: Read/write onbinlecode/homebrew-actop. Re-run the failed job.tag_release.shaborts in pre-flight (missingbuild/twine, or emptyTWINE_*): 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, expect1; a renamed token file silently yields an empty value because~/.zshrcuses2>/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-existingmakes repeats safe. - Build produced the wrong version: the script checks for
dist/actop-X.Y.Z.tar.gzand the matching wheel and aborts before uploading if either is missing, so a staledist/cannot be shipped (it wipesdist/first for the same reason). Re-run the build from a clean checkout of the tag. - Emergency rerun without resource refresh:
workflow_dispatchonrelease-formulawithrefresh_resources=falseas a temporary workaround; follow up with a normal run (refresh_resources=true).
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 envRequires 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.
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.
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?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).
- 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.
- All tests are located in the
tests/folder. - Run
.venv/bin/pytest -qfor 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
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.
- 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
AlertFrameand asserting its keys appear in JSON is structural — it does not exercise the actual pipeline. A functional test mocksMonitor, feeds real snapshots throughAlertEngine, 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 Trueis redundant whenparse_args(["--json", "--flag"]).flag is Trueexists 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.
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.
- 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.
- Does another test already drive the same code path? Two tests that both
call
parse_args(["---flag"])and both assert the flag isTrueare one too many. Keep the one that also asserts the flag's downstream effect. - 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.
.venv/bin/python -m actop.actop --help.venv/bin/pytest -q- Run
actopon 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.
- Branch from
main; PR strictly intomain. Every branch forks frommainand targetsmain. 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 frommain. This holds especially for CI/CD and release changes — they land via a single PR intomain, never a chained branch. - A normal PR does NOT bump the version. It adds its entry under
CHANGELOG.md[Unreleased]and leavespyproject.tomlalone. 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 byscripts/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 Ultraoractop/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.
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.