threatlenscli — ThreatLens CLI
Snyk-style scanning in your CI. The scanners run on the runner; only the results go to your ThreatLens server. The server never builds your code, never pulls your private images and never needs a Docker socket.
| Command | Scans | Engines (on this machine) | Snyk equivalent |
|---|---|---|---|
threatlenscli oss [PATH] |
open-source dependencies, incl. transitive | trivy fs (lockfiles) ∪ trivy rootfs (installed artefacts) ∪ optional build-tool SBOM |
snyk test / snyk monitor |
threatlenscli code [PATH] |
SAST, code quality, secrets, IaC misconfig | Semgrep CE, Gitleaks (else Trivy secret), Trivy misconfig | snyk code test + snyk iac test |
threatlenscli image <REF> (scan, container) |
container image: OS + library vulns, secrets, misconfig | Trivy (same flags as the server) | snyk container test |
It is a single Python file with zero pip dependencies (Python 3.8+).
Install
From PyPI (the package is aspora-threatlenscli; the command is threatlenscli):
pipx install aspora-threatlenscli # or: pip install aspora-threatlenscli
pipx install aspora-threatlenscli==0.4.0 # in CI: pin the version
Or the single file straight from the repository:
sudo install -m 755 cli/threatlenscli /usr/local/bin/threatlenscli # or: ./cli/threatlenscli install
threatlenscli --version # threatlenscli 0.2.0
threatlenscli doctor # checks everything below
In CI, install threatlenscli from the commit you reviewed and verify it (SUP-1/SUP-5):
curl -fsSLo threatlenscli https://raw.githubusercontent.com/<org>/<repo>/<40-char-sha>/cli/threatlenscli
echo "<sha256 from: git show <sha>:cli/threatlenscli | sha256sum> threatlenscli" | sha256sum -c -
Requirements on the runner:
- Trivy >= 0.53 on
PATH. Install a pinned release and verify its checksum. threatlenscli refuses the credential-stealing releases 0.69.4, 0.69.5 and 0.69.6 (GHSA-69fq-xp46-6x23). Ifdoctorever reports one, remove it and rotate every secret that runner can reach. Add more entries withTHREATLENS_SCANNER_VERSION_DENYLIST=trivy:x.y.z,.... - For
code: semgrep, installed hash-locked from the server'sbackend/semgrep.lockof the reviewed commit (python3.14 -m venv /opt/semgrep && /opt/semgrep/bin/pip install --require-hashes --only-binary=:all: -r backend/semgrep.lock; the lock is resolved for CPython 3.14 on Linux, so another interpreter fails loudly instead of installing unhashed files) and, optionally, gitleaks (v8 with thedircommand). Without semgrep the code scan has no SAST and no quality rules. threatlenscli says so on stderr, and--require-sastturns that into exit 2. semgrep and gitleaks are checked against the same denylist (THREATLENS_SCANNER_VERSION_DENYLIST=semgrep:x.y.z,gitleaks:x.y.z); an unparseable version exits 2 (fail closed). - For
image: the image must be reachable by Trivy (local Docker daemon or a registry). Registry credentials go inTRIVY_USERNAME/TRIVY_PASSWORD, never on argv.
GitHub Actions: pin every third-party action (including any Trivy action) by its full commit SHA. The trivy-action and setup-trivy tags were compromised in the same incident.
Authenticate
An admin creates a key in Settings → API Keys. Give CI keys the narrowest scope:
| Command | Scope | Legacy scope (still accepted, audited as deprecated) |
|---|---|---|
threatlenscli oss |
ingest:oss |
code:write |
threatlenscli code |
ingest:code |
code:write |
threatlenscli image |
ingest:image |
images:write |
An ingest:* key can upload results and read back its own runs. It cannot read
other findings, add images or trigger server scans. Keys expire after 90 days
by default.
Allowed identities. A key can be bound to the projects it may write, using
glob patterns over normalised identities: github.com/acme/* or
github.com/acme/payments (* matches one path segment, ** any number).
With an empty list, the key may only write projects it created itself. An
upload for any other project gets 403, and the refusal is audited. Set these
bindings for every shared CI key, and again after rotating a key.
threatlenscli login # asks for your ThreatLens URL, opens the browser: sign in, approve, done
login opens the ThreatLens sign-in page in your browser (or prints the link, for
SSH sessions). Sign in, check that the code matches your terminal, and approve; the
terminal then says Authenticated as you@company.com and every command works. The
CLI receives its own upload-only key (90 days, revocable; threatlenscli logout
revokes it). Admins' CLIs may upload for any repository; a member's CLI only for
projects and branches it creates itself. To paste a key instead:
threatlenscli login --with-api-key.
No server is built in. In CI set both environment variables; they always win over
~/.threatlenscli/config.json. On a CI runner an env key is never paired with the
server a stale config on a shared runner names:
export THREATLENS_SERVER=${{ vars.THREATLENS_SERVER }} # e.g. https://threatlens.example.com
export THREATLENS_API_KEY=${{ secrets.THREATLENS_API_KEY }}
Transport rules:
- TLS certificates are always verified. Use
SSL_CERT_FILEfor a private CA. - Plain
http://works only for loopback. For another host, outside CI only, passlogin --insecureor setTHREATLENS_ALLOW_INSECURE=1. It is never allowed in CI. - Redirects to another host or port, or from https to http, are refused, so the
key is never forwarded. A key saved for server A is never sent to a different
THREATLENS_SERVER. - Reads (GET) retry 429, 502, 503, 504 and network errors with backoff
(
Retry-Afterhonoured). The upload (POST) is retried only when the server explicitly did not process it (429, or 503ingest_busy/scan_in_progress) or the request never left the runner (connection refused, DNS, send timeout). A lost response (read timeout, reset, 502/504) is NOT retried: the server may have stored the results, so the CLI exits 2 and says to check the UI.--upload-timeout(default 300 s) sets how long to wait for the response. Large uploads are gzip-compressed.
Exit codes (all scan commands)
| Code | Meaning |
|---|---|
0 |
scan completed, nothing at or above the gate |
1 |
scan completed, gating issues found |
2 |
error: scanner failure or timeout, network/TLS, HTTP 4xx/5xx, upload refused, bad flag (--fail-on patchable, unknown --fail-on value), --require-sast without semgrep, denylisted or too-old Trivy |
3 |
nothing scannable. oss: no package resolved by any pass (the unresolved manifests are listed). code: no file to scan. Never for images |
Gating (CI policy)
--severity-threshold low|medium|high|critical issues at or ABOVE this severity gate (default low;
'unknown' gates as medium)
--fail-on all fail only if a gating issue has a fix
--fail-on upgradable fail only if upgrading fixes it (unknown upgradability + a fix counts as upgradable)
--fail-on none report, never fail
(`threatlenscli code` ignores all/upgradable with a warning: secrets and SAST
findings have no upgrade fix, so those values would disable the gate)
--include-dev include dev dependencies (npm, yarn, gradle) — off by default, like `snyk test`.
Without it, dev dependencies are removed from every pass, including the copies
`npm ci` installed in node_modules (matched by purl, else name+version) and
binaries shipped inside a dev-only package (esbuild's Go binary), so they
neither gate nor upload; the count is printed and recorded in meta/coverage
--exclude-base-image-vulns image only: ignore OS vulns that come from the base image (needs --file)
--require-fresh-db exit 2 unless the Trivy DB was built within 48 h and is not past its NextUpdate
(useful with --offline runners that restore a cached DB)
Code-quality and licence findings are reported (JSON, SARIF, the UI) but never fail
the build; threatlenscli code --gate-quality lets them gate too.
The gate uses the server's triage: findings marked false positive, accepted
risk or not affected (and not past their suppress_until) do not fail the build.
Nothing else carries over. A finding this scan reports is present, so a
default-branch row that the pipeline closed (resolved, fixed, stale,
not_applicable) still gates: a PR that brings back a vulnerability main
already fixed fails. Only an advisory withdrawn upstream (rejected) is exempt.
With --no-upload the gate runs on local results only, without triage; threatlenscli
notes this on stderr.
--fail-on critical,high (the old severity list) is still accepted. It now means
--severity-threshold high: at or above, so a critical fails a "high" gate.
Outputs
| Flag | Output |
|---|---|
--json |
exactly one JSON document on stdout (schema: vss-cli-result/1); all progress goes to stderr |
--json-file-output PATH |
the same document, written to a file |
--sarif-file-output PATH |
SARIF 2.1.0 for GitHub code scanning: one run per engine with distinct categories (vss/oss/, vss/image/, vss/code/semgrep/, vss/code/gitleaks/, vss/code/trivy/), text only, repo-relative paths, redacted |
--sbom-file-output PATH |
CycloneDX from trivy convert (inventory only, no vulnerabilities). Trivy 0.74 writes specVersion 1.7. For oss it covers every pass (lockfiles, installed artefacts, --sbom) after dev dependencies are excluded (unless --include-dev) |
JSON and SARIF are redacted: secrets never appear, even for public repositories
whose CI artefacts are public. No runner path leaves the machine: the report
ArtifactName and the SBOM metadata.component.name carry the project identity,
and absolute paths (target, $VIRTUAL_ENV, temp dir, --artifact, --sbom)
are replaced by placeholders in uploads and output files.
Trivy DB (C14): unless --offline, every scan first runs --download-db-only,
then --download-java-db-only (oss/image), then — for image and code — a
misconfig checks-bundle prefetch (a trivy config of an empty private dir).
Sources are pinned to mirror.gcr.io / ghcr.io unless the runner sets its own
TRIVY_DB_REPOSITORY / TRIVY_JAVA_DB_REPOSITORY / TRIVY_CHECKS_BUNDLE_REPOSITORY.
Every scan then runs with --skip-db-update --skip-java-db-update --skip-check-update. A failed prefetch is a warning (the embedded checks are
used) and is recorded as meta.check_bundle_prefetch / check_bundle_stale.
threatlenscli oss — run it after the build
npm ci # or: pip install -r requirements.txt into a venv / mvn package / gradle build
threatlenscli oss . --severity-threshold high --sarif-file-output oss.sarif
- Pass 1,
trivy fs: reads lockfiles with the dependency graph (relationship,introduced_through, dev flag, licences). It runs inprecisemode, so an unpinned manifest yields no package rather than a made-up version. - Pass 2,
trivy rootfs: reads what is installed (node_modules,site-packages, a venv ($VIRTUAL_ENVis added automatically), JAR/WAR files, Go and Rust binaries).--artifact PATHadds built outputs;--lockfile-onlyskips this pass. - JVM builds keep dependencies in
~/.m2/~/.gradle, outside the project:--sbom target/bom.jsoningests a cyclonedx-maven-plugin or cyclonedx-gradle-plugin SBOM (re-matched bytrivy sbom, which takes no--skip-check-update: it has no misconfig checks). The path is checked (exists, <= 128 MiB, CycloneDX or SPDX JSON) before any Trivy pass runs.- Manifests without a lockfile are resolved automatically on a CI runner,
like
snyk test(elsewhere pass--resolve;--no-resolveturns it off):- a workspace lockfile in a parent folder (npm/pnpm/yarn, uv/poetry/pdm, Cargo);
- the ecosystem's own tool, writing into a private dir, never the repo:
npm install --package-lock-only --ignore-scripts,pip install --dry-run --report,composer update --no-install --no-scripts --no-plugins,bundle lock,cargo generate-lockfile,dotnet restore --use-lock-file,mvn dependency:copy-dependencies, a Gradle init-script task. The nearestgradlew/mvnwup to the git root is used (multi-module builds); - JVM only: the JARs built in a parent build root (Dockerfile in
services/api,./gradlew assembleat the repo root). A missing tool or a failed run is a warning, never fatal. Build tools execute your build configuration; the server never does.
- Detected manifests that did not resolve (
build.gradlewithoutgradle.lockfile, unpinnedrequirements.txt,.csprojwithoutpackages.lock.json,requirements.lock, ...) are listed with a fix. When no package resolves at all, the exit code is 3, never a false "no vulnerabilities". - Identity is the normalised git remote (
github.com/acme/api, credentials stripped) plus the sub-path in a monorepo. Without a remote, pass--project-name. CLI projects are separate from GitHub-imported ones; the UI links them by remote. - Only the default branch is monitored: its upload becomes the project's inventory, findings are reconciled, the daily re-check watches it and Slack alerts fire. Uploads from other branches (PRs, feature branches) are stored as separate branch snapshots. They return triage-aware gate results and never touch default-branch findings.
- The default branch comes from
refs/remotes/origin/HEAD,CI_DEFAULT_BRANCH(GitLab),BUILDKITE_PIPELINE_DEFAULT_BRANCH, or on GitHub Actions (whose shallow checkout has no origin/HEAD)repository.default_branchin$GITHUB_EVENT_PATH. Once the server has stored a project's default branch it keeps it: a different claim from a client is logged, audited and ignored. With no default known at all, onlymain/masterare monitored, and that guess is not stored. - A pull/merge-request build is never monitored, whatever its head branch
is called (a fork PR from its own
main). threatlenscli detects it fromGITHUB_HEAD_REF/GITHUB_EVENT_NAME,CI_MERGE_REQUEST_IID,BITBUCKET_PR_ID,BUILDKITE_PULL_REQUEST,CIRCLE_PULL_REQUEST,SYSTEM_PULLREQUEST_PULLREQUESTIDorCHANGE_ID. - The server re-derives everything from the raw Trivy reports (it never trusts
the client's merge) and unions them with a live OSV query of every versioned
package, so a finding carries
engines: trivy,osvor both. An OSV outage never resolves OSV-found findings. - An upload resolves findings that disappeared only when it is provably
complete: fresh Trivy DB (not
--offline), all requested passes present, no package-count collapse, and no manifest that resolved last run and is unresolved now (a monorepo sub-project whose install failed). Otherwise nothing is resolved and the run's coverage says why.
threatlenscli code
threatlenscli code . --require-sast --severity-threshold high --sarif-file-output code.sarif
- Semgrep packs: the security base (
p/default,p/owasp-top-ten,p/cwe-top-25,p/secrets,p/security-audit), language and framework packs from detected files, IaC/CI packs, and quality packs (p/r2c-bug-scan,p/r2c-best-practices,r/<lang>.lang.correctness, ...). The server's list (GET /api/code/config?languages=…&frameworks=…&iac=…, sent the local detection) wins when it is available;coverage.semgrep_pack_sourcesays which list ran. The local fallback (--no-upload, or the server unreachable) uses tables that mirror the server's defaultselect_configsexactly (a parity test compares them), includingp/cifor CI configs (.github/workflows,.gitlab-ci.yml,Jenkinsfile, ...); a server-sideCODE_SEMGREP_CONFIGSoverride is not applied locally (coverage.semgrep_pack_note). Semgrep always runs with--metrics=off, never--config auto,--disable-nosemand--verbose(only then does Semgrep reportpaths.skipped; its log goes to a file in the private dir). Files over--semgrep-max-target-bytes(default 1000000, the server's) or that time out / fail to parse are counted incoverage.semgrep(skipped_paths,skipped_by_reason), warned about, and uploaded as repo-relativepaths.skippedso the server never resolves a finding in a file Semgrep did not read. Extra configs:--semgrep-config p/jwtor a local rules file. - Gitleaks (when installed) runs without
--redactinto a 0600 report inside a private temp dir. threatlenscli digests each secret (sha256), then drops the raw value and masks Match/Line before anything is uploaded; the report is deleted. gitleaks always honours a repo.gitleaksignore, and threatlenscli reports when one exists. Without gitleaks, Trivy's secret scanner is used, and the digest is recovered from the file, not from Trivy's masked match. Gitleaks skips files over--gitleaks-max-target-mb(default 50, the server'sGITLEAKS_MAX_TARGET_MB) without a word, so threatlenscli lists them itself:coverage.bounds.GITLEAKS_MAX_TARGET_MB(value,hit,skipped_sample),coverage.gitleaks_skipped.pathsand a warning. - Trivy misconfig: Dockerfile, Kubernetes, Helm, CloudFormation, Azure ARM,
Terraform plan JSON and Ansible (
--misconfig-scanners azure-arm,cloudformation,dockerfile,helm,kubernetes,terraformplan-json,ansible), with**/<dir>skip globs. Trivy's Terraform scanner never runs in threatlenscli: it resolvesmodulesources from the scanned tree, including loopback addresses no proxy can block, so a fork PR's.tfcould make the runner connect where it chooses. When.tf,.tf.json,.tofuor.tofu.jsonfiles are present, threatlenscli warns and recordscoverage.terraform_not_scanned; only Semgrepp/terraformrules cover them. Full Terraform IaC coverage needs the server code scan (which scans a guarded copy of the tree). Trivy runs with no network: a dead loopback proxy (HTTPS_PROXY/HTTP_PROXY/ALL_PROXYin both cases, emptyNO_PROXY/no_proxy),GIT_ALLOW_PROTOCOL=vss-noneand a privateTMPDIR. - Semgrep's secret digest is sha256 of the credential literal (one quoted
literal, or the value of a single
NAME=VALUEassignment), the same rule as the server and Gitleaks/Trivy, so one secret found by several engines is one finding. --tracked-onlyreports only files git tracks (git ls-files -z, so non-ASCII names match). Committed.envand.github/**files are kept. Ifgit ls-filesfails or times out,--tracked-onlyexits 2 instead of filtering against an unknown list.- Secret findings from Semgrep are masked before upload, in
--jsonand in SARIF: the matched span, each of its lines, quoted literals inside it and every metavariable value (what rule messages interpolate) are masked on the full source line before it is clipped. If a literal would still be visible, the snippet is dropped and the message becomes the rule title. The mask is the server's: a known token prefix plus the last 2 characters for tokens of 16+ characters (ghp_****…i2),****…xyfor 24+ characters, and nothing at all for shorter secrets (********). - Local findings (
--no-uploadgate,--json, SARIF) use the server's identities: one secret found by several engines is ONE finding (merged on file- secret digest,
engineslists them); misconfig identity is rule + resource + a hash of the cause lines + an occurrence index, never a line number, so SARIFpartialFingerprintssurvive edits above the finding. A secret's local fingerprint is keyed with a per-run random key and SARIF omitspartialFingerprintsfor secrets, so a published artifact carries nothing derived from a credential. SARIF tags quality resultscorrectness/maintainability/performanceand licence resultslicense(nosecuritytag orsecurity-severity).
- secret digest,
--offline: Semgrep registry packs (p/...,r/...) are downloads from semgrep.dev, so they are skipped. Only--semgrep-config <local rules>run; with none, Semgrep is skipped with a warning (--require-sastthen exits 2). No checks-bundle prefetch runs (cached or embedded misconfig checks, recorded ascheck_bundle_stale).
threatlenscli image
threatlenscli image my-app:1.4.2 --file Dockerfile --platform linux/amd64 --severity-threshold critical
This uses the server's exact Trivy argv, flag for flag and in order
(GET /api/images/scan-profile shows it): --scanners vuln,secret,misconfig,
--image-config-scanners misconfig,secret, --pkg-types os,library,
--list-all-pkgs, --skip-check-update, --no-progress,
--misconfig-scanners dockerfile,kubernetes,helm,cloudformation,azure-arm,ansible
(never Terraform: its module resolution would make the runner fetch
attacker-chosen URLs), --detection-priority comprehensive,
--max-image-size 16384MiB and --image-src docker,remote -- <ref>. The only
differences are no --cache-dir and --offline-scan with --offline. A Trivy
too old for --max-image-size drops it with a warning (meta.flags_dropped).
The scan runs with GIT_ALLOW_PROTOCOL=vss-none and a private TMPDIR
(registry egress stays: the image must be pulled). --file uploads the
Dockerfile's FROM lines for base-image attribution and advice.
CI recipes
# GitHub Actions — PRs gate, main monitors (same command; the branch decides)
- run: npm ci
- run: threatlenscli oss . --severity-threshold high --sarif-file-output oss.sarif
env: { THREATLENS_SERVER: ${{ vars.THREATLENS_SERVER }}, THREATLENS_API_KEY: ${{ secrets.THREATLENS_OSS_KEY }} }
- run: threatlenscli code . --require-sast --severity-threshold high --sarif-file-output code.sarif
env: { THREATLENS_SERVER: ${{ vars.THREATLENS_SERVER }}, THREATLENS_API_KEY: ${{ secrets.THREATLENS_CODE_KEY }} }
- uses: github/codeql-action/upload-sarif@<full-commit-sha>
if: always()
with: { sarif_file: oss.sarif, category: threatlens-oss }
# GitLab CI
threatlens:
script:
- threatlenscli oss . --severity-threshold high --json-file-output threatlens-oss.json
artifacts: { paths: [threatlens-oss.json], when: always }
Every upload carries provenance in meta: the Trivy, Semgrep and Gitleaks
versions, the Trivy binary sha256, the Trivy DB UpdatedAt, sanitised argv
(the target and temp dirs become <target>/<tmp>, any other absolute path keeps
only its basename, credential flag values are masked), git remote, branch and commit, and the scanner
environment variables that were ignored (for example a TRIVY_SEVERITY set in
CI).
Other commands
threatlenscli login [--server URL] browser sign-in (--with-api-key: paste a key); 0600 config
threatlenscli logout remove saved config
threatlenscli config show config (key masked; env overrides flagged)
threatlenscli doctor scanners, denylist, DB age, server reachability, key scopes + identities
threatlenscli list [--type repos] images (default) or repositories on the server
threatlenscli install [--dest DIR] symlink this script onto PATH
Measuring parity with Snyk
The JSON document written by --json-file-output (schema: vss-cli-result/1)
is the ThreatLens input of the parity harness in backend/tests/parity/. Run the scan
with --no-upload on the same machine and day as the Snyk run, for example:
threatlenscli image nginx:1.25@sha256:<digest> --no-upload --json-file-output threatlens-image.json
snyk container test nginx:1.25@sha256:<digest> --json-file-output=snyk-container.json
python3 backend/tests/parity/parity_harness.py compare --snyk-container snyk-container.json --vss threatlens-image.json
The corpus, the commands for every target, the match rules and the pass bar
(recall >= 80 % per product, precision reported) are in
docs/parity-benchmark.md. No parity figure is
claimed until it has been measured that way.
Honest limitations
- Semgrep CE is intraprocedural: there is no cross-function or cross-file taint (Snyk Code has it). Pro-only rules are unavailable.
- Registry rules are under the Semgrep Rules License and are fetched live; threatlenscli bundles none.
trivy rootfsfinds installed packages but has no dependency graph, so those findings haverelationship: unknown. Rust binaries are found only when built with cargo-auditable.--fail-on upgradableknows upgradability exactly only for direct dependencies. For transitive ones, "has a fix" is treated as upgradable (fails closed).- The Trivy DB is the newest build available when the run starts, which can lag upstream by up to about 24 h.
- A code scan does not scan git history for secrets.
Metadata
Release files for aspora-threatlenscli 0.4.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| aspora_threatlenscli-0.4.0.tar.gz | 86.9 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| aspora_threatlenscli-0.4.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 174.3 kB
Release files / aspora_threatlenscli-0.4.0.tar.gz
| Download URL | aspora_threatlenscli-0.4.0.tar.gz |
|---|---|
| Size | 86.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
1e3bfbb424ed15ecfcfe32edef88733dfa66b6832eb1cbd1faab8804dc20c7a3
|
|
BLAKE2b-256 checksum How to use checksums |
422e5913aece543056e279c75f3cc98c5f80f0cb5b6deaa4f829cd7cc8c37f36
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.12.19 {"installer":{"name":"uv","version":"0.12.19","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Release files / aspora_threatlenscli-0.4.0-py3-none-any.whl
| Download URL | aspora_threatlenscli-0.4.0-py3-none-any.whl |
|---|---|
| Size | 87.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
6e1592e8bf01f793362e866629553c91d5cfcd34268c338326236a32f3d6b842
|
|
BLAKE2b-256 checksum How to use checksums |
eac0542ee8f930aac304fdca10720b836a5a8c86423426fd094fee0f2671d0dd
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.12.19 {"installer":{"name":"uv","version":"0.12.19","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|