diff --git a/.github/workflows/security-audit.yml b/.github/workflows/security-audit.yml
index 89b1c76..bf63ec0 100644
--- a/.github/workflows/security-audit.yml
+++ b/.github/workflows/security-audit.yml
@@ -20,12 +20,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install stable Rust toolchain
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
+ uses: dtolnay/rust-toolchain@18f0487477426dce244608110aa2660c2893deb7 # stable
with:
toolchain: stable
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 13d03b1..877cc03 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,25 @@
# Changelog
+## 2026-11-03 — v1.0.2
+
+Release train v1.0.2 (catalog schema 1, catalog_version 1.0.2).
+
+### Dependency updates
+
+- Updated the workspace lockfile with semver-compatible patches: `libc` 0.2.191, `libredox` 0.1.25, `objc2` 0.6.5, `redox_users` 0.5.3, `serde_json` 1.0.154, `syn` 3.0.9, `thiserror` 2.0.21, `thiserror-impl` 2.0.21 and `unicode-ident` 1.0.27.
+- `gauges` 0.2.1: moved to `sysinfo` 0.40.1 and adapted the fallible constructors and the removal of `Disks::list()`. The measurements and output format are unchanged.
+- `theme-manager` 0.2.1: moved to `dirs` 7.0.0. The `dirs::config_dir()` call is unchanged.
+- Updated the security-audit workflow to `actions/checkout` 7.0.1 and the pinned `dtolnay/rust-toolchain` commit.
+- Regenerated `index.json` so the catalog reports the new `gauges` and `theme-manager` versions.
+
+### Addon catalog index
+
+- Added the root `index.json`, a machine-readable addon catalog consumed by `xfetch plugin search` and the other remote addon discovery commands.
+- Added `scripts/generate_catalog.py` to regenerate the catalog from each plugin's `Cargo.toml` or `xfetch-plugin.json`; `--check` fails when the committed index is stale.
+- The catalog lists all 20 addons, sorted by name. Native entries use `runtime: "native"` and WebAssembly entries use `runtime: "wasm"` with their manifest capabilities preserved.
+- Documented `banner`, `gauges` and `langs` in the README and platform compatibility tables. All three were already present in `index.json`.
+- Crate versions remain 0.2.0 except `banner-text` 0.1.0, `gauges` 0.2.1 and `theme-manager` 0.2.1; the catalog version is 1.0.2.
+
## 2026-10-24 — Dependency security automation
- Added `.github/workflows/security-audit.yml`: runs `cargo audit` 0.22.2 with `--deny warnings` on push, pull request and a weekly schedule, with actions pinned by commit SHA and no persisted checkout credentials.
diff --git a/Cargo.lock b/Cargo.lock
index 0e55c90..d772adb 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -14,17 +14,11 @@ version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
-[[package]]
-name = "cfg-if"
-version = "1.0.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
-
[[package]]
name = "dirs"
-version = "6.0.0"
+version = "7.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
+checksum = "8d57d423b3c82e89b9a24ca3091fee61f456a26edbd28d26c65906f4bc1dcd8f"
dependencies = [
"dirs-sys",
]
@@ -51,17 +45,6 @@ dependencies = [
"objc2",
]
-[[package]]
-name = "getrandom"
-version = "0.2.17"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
-dependencies = [
- "cfg-if",
- "libc",
- "wasi",
-]
-
[[package]]
name = "itoa"
version = "1.0.18"
@@ -70,15 +53,15 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "libc"
-version = "0.2.189"
+version = "0.2.191"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
+checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
[[package]]
name = "libredox"
-version = "0.1.20"
+version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a"
+checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d"
dependencies = [
"libc",
]
@@ -100,9 +83,9 @@ dependencies = [
[[package]]
name = "objc2"
-version = "0.6.4"
+version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f"
+checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8"
dependencies = [
"objc2-encode",
]
@@ -181,11 +164,10 @@ dependencies = [
[[package]]
name = "redox_users"
-version = "0.5.2"
+version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
+checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0"
dependencies = [
- "getrandom",
"libredox",
"thiserror",
]
@@ -217,14 +199,14 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
- "syn 3.0.3",
+ "syn 3.0.9",
]
[[package]]
name = "serde_json"
-version = "1.0.151"
+version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
+checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
@@ -246,9 +228,9 @@ dependencies = [
[[package]]
name = "syn"
-version = "3.0.3"
+version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
+checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
@@ -257,9 +239,9 @@ dependencies = [
[[package]]
name = "sysinfo"
-version = "0.39.6"
+version = "0.40.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d2071df9448915b71c4fe6d25deaf1c22f12bd234f01540b77312bb8e41361e6"
+checksum = "0e8a3edbe0394ce115f3917a00349dd242be09eb5a091ed509eaf67b55cfb8a8"
dependencies = [
"libc",
"memchr",
@@ -272,35 +254,29 @@ dependencies = [
[[package]]
name = "thiserror"
-version = "2.0.20"
+version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
+checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
-version = "2.0.20"
+version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
+checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
dependencies = [
"proc-macro2",
"quote",
- "syn 3.0.3",
+ "syn 3.0.9",
]
[[package]]
name = "unicode-ident"
-version = "1.0.24"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
-
-[[package]]
-name = "wasi"
-version = "0.11.1+wasi-snapshot-preview1"
+version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "winapi"
@@ -517,7 +493,7 @@ dependencies = [
[[package]]
name = "xfetch-plugin-gauges"
-version = "0.2.0"
+version = "0.2.1"
dependencies = [
"serde",
"sysinfo",
@@ -571,7 +547,7 @@ dependencies = [
[[package]]
name = "xfetch-plugin-theme-manager"
-version = "0.2.0"
+version = "0.2.1"
dependencies = [
"dirs",
"serde",
diff --git a/README.md b/README.md
index 4347443..0e3727a 100644
--- a/README.md
+++ b/README.md
@@ -37,6 +37,10 @@
animate-logo |
Animated ASCII logos with sweep, wave, rainbow, sparkle, breathing, and frame modes. |
+
+ banner |
+ Prints a Chinese/Japanese proverb banner under or above the ASCII logo; requires a CJK-capable terminal font. |
+
banner-text |
Emits text lines from a file or literal argument; works with image logos. |
@@ -49,10 +53,18 @@
docker |
Displays Docker container statistics using the local Docker CLI. |
+
+ gauges |
+ Renders memory, swap, disk and battery usage as progress bars. |
+
github-stats |
Displays GitHub user statistics such as stars, repos, pull requests, and followers. |
+
+ langs |
+ Shows the languages of a GitHub repository or aggregates a user/org with brand colors. |
+
display-resolution |
Displays monitor resolution and refresh rate. |
diff --git a/docs/compatibility.md b/docs/compatibility.md
index 5bea5c3..64fa6a6 100644
--- a/docs/compatibility.md
+++ b/docs/compatibility.md
@@ -22,6 +22,11 @@
✓ | ✓ | ✓ |
Pure computation, no OS dependencies. |
+
+ banner |
+ ✓ | ✓ | ✓ |
+ Logo-animation plugin; pure computation, but needs a TTY and a CJK-capable font. |
+
banner-text |
✓ | ✓ | ✓ |
@@ -42,11 +47,21 @@
✓ | ✓ | ✓ |
Requires the Docker CLI (Docker Desktop on Windows/macOS). |
+
+ gauges |
+ ✓ | ✓ | ✓ |
+ Memory, swap and disks via sysinfo on all platforms; battery lines are Linux-only. |
+
github-stats |
✓ | ✓ | ✓ |
Requires curl and network access. |
+
+ langs |
+ ✓ | ✓ | ✓ |
+ Requires curl and network access to the GitHub API; set GITHUB_TOKEN/GH_TOKEN for higher rate limits. |
+
music-player |
✓ | ~ | ✗ |
diff --git a/index.json b/index.json
new file mode 100644
index 0000000..cd61989
--- /dev/null
+++ b/index.json
@@ -0,0 +1,220 @@
+{
+ "schema_version": 1,
+ "catalog_version": "1.0.2",
+ "entries": [
+ {
+ "name": "animate-logo",
+ "description": "Xfetch plugin that animates the logo with user-controlled frames and duration",
+ "version": "0.2.0",
+ "kind": "logo_animation",
+ "runtime": "native",
+ "path": "plugins/animate-logo",
+ "install": "animate-logo"
+ },
+ {
+ "name": "banner",
+ "description": "Xfetch logo-animation plugin that prints a Chinese/Japanese proverb banner under (or above) the logo",
+ "version": "0.2.0",
+ "kind": "logo_animation",
+ "runtime": "native",
+ "path": "plugins/banner",
+ "install": "banner"
+ },
+ {
+ "name": "banner-text",
+ "description": "Xfetch info plugin that emits text lines from a file or literal argument",
+ "version": "0.1.0",
+ "kind": "info_provider",
+ "runtime": "native",
+ "path": "plugins/banner-text",
+ "install": "banner-text"
+ },
+ {
+ "name": "chocolatey",
+ "description": "Xfetch plugin that counts locally installed Chocolatey packages",
+ "version": "0.2.0",
+ "kind": "info_provider",
+ "runtime": "native",
+ "path": "plugins/chocolatey",
+ "install": "chocolatey"
+ },
+ {
+ "name": "display-resolution",
+ "description": "Xfetch plugin that reports the display resolution via xrandr/wayland probes",
+ "version": "0.2.0",
+ "kind": "info_provider",
+ "runtime": "native",
+ "path": "plugins/display-resolution",
+ "install": "display-resolution"
+ },
+ {
+ "name": "docker",
+ "description": "Xfetch plugin that reports Docker status and containers from the local daemon",
+ "version": "0.2.0",
+ "kind": "info_provider",
+ "runtime": "native",
+ "path": "plugins/docker",
+ "install": "docker"
+ },
+ {
+ "name": "gauges",
+ "description": "Xfetch plugin that renders memory, swap, disk and battery usage as progress bars",
+ "version": "0.2.1",
+ "kind": "info_provider",
+ "runtime": "native",
+ "path": "plugins/gauges",
+ "install": "gauges"
+ },
+ {
+ "name": "github-stats",
+ "description": "Xfetch plugin that shows GitHub statistics via the GitHub API",
+ "version": "0.2.0",
+ "kind": "info_provider",
+ "runtime": "native",
+ "path": "plugins/github-stats",
+ "install": "github-stats"
+ },
+ {
+ "name": "langs",
+ "description": "Xfetch plugin that shows the languages of a repository or an org/user with glyphs and brand colors",
+ "version": "0.2.0",
+ "kind": "info_provider",
+ "runtime": "native",
+ "path": "plugins/langs",
+ "install": "langs"
+ },
+ {
+ "name": "music-player",
+ "description": "Xfetch plugin that shows the current track via playerctl/mpd/Spotify probes",
+ "version": "0.2.0",
+ "kind": "info_provider",
+ "runtime": "native",
+ "path": "plugins/music-player",
+ "install": "music-player"
+ },
+ {
+ "name": "temperature",
+ "description": "Xfetch plugin that shows CPU temperature from hwmon/sensors probes",
+ "version": "0.2.0",
+ "kind": "info_provider",
+ "runtime": "native",
+ "path": "plugins/temperature",
+ "install": "temperature"
+ },
+ {
+ "name": "theme-detection",
+ "description": "Xfetch plugin that detects the system color theme (GTK/KDE Plasma)",
+ "version": "0.2.0",
+ "kind": "info_provider",
+ "runtime": "native",
+ "path": "plugins/theme-detection",
+ "install": "theme-detection"
+ },
+ {
+ "name": "theme-manager",
+ "description": "Xfetch plugin that fetches and manages themes from the XFetch registry",
+ "version": "0.2.1",
+ "kind": "info_provider",
+ "runtime": "native",
+ "path": "plugins/theme-manager",
+ "install": "theme-manager"
+ },
+ {
+ "name": "timezone",
+ "description": "Xfetch plugin that shows the current timezone via local probes",
+ "version": "0.2.0",
+ "kind": "info_provider",
+ "runtime": "native",
+ "path": "plugins/timezone",
+ "install": "timezone"
+ },
+ {
+ "name": "user-info",
+ "description": "Xfetch plugin that shows user account information from local probes",
+ "version": "0.2.0",
+ "kind": "info_provider",
+ "runtime": "native",
+ "path": "plugins/user-info",
+ "install": "user-info"
+ },
+ {
+ "name": "wasm-crypto",
+ "description": "Crypto spot prices (Coinbase) fetched through the sandboxed HTTP host call",
+ "version": "0.2.0",
+ "kind": "info_provider",
+ "runtime": "wasm",
+ "guest_runtime": "core",
+ "path": "plugins/wasm-crypto",
+ "install": "wasm-crypto",
+ "capabilities": {
+ "http": {
+ "allow": [
+ "https://api.coinbase.com/*"
+ ]
+ }
+ }
+ },
+ {
+ "name": "wasm-ip-geo",
+ "description": "Public IP, location, network and timezone via ipapi.co (Python component)",
+ "version": "0.2.0",
+ "kind": "info_provider",
+ "runtime": "wasm",
+ "guest_runtime": "component",
+ "path": "plugins/wasm-ip-geo",
+ "install": "wasm-ip-geo",
+ "capabilities": {
+ "http": {
+ "allow": [
+ "https://ipapi.co/*"
+ ]
+ }
+ }
+ },
+ {
+ "name": "wasm-pacman",
+ "description": "Package counts (repository vs AUR/foreign) from the local pacman database",
+ "version": "0.2.0",
+ "kind": "info_provider",
+ "runtime": "wasm",
+ "guest_runtime": "core",
+ "path": "plugins/wasm-pacman",
+ "install": "wasm-pacman",
+ "capabilities": {
+ "exec": {
+ "allow": [
+ "pacman"
+ ]
+ }
+ }
+ },
+ {
+ "name": "wasm-proc",
+ "description": "Load average, memory usage and uptime read from /proc (freestanding C)",
+ "version": "0.2.0",
+ "kind": "info_provider",
+ "runtime": "wasm",
+ "guest_runtime": "core",
+ "path": "plugins/wasm-proc",
+ "install": "wasm-proc",
+ "capabilities": {
+ "fs": [
+ {
+ "host": "/proc",
+ "guest": "/proc",
+ "mode": "ro"
+ }
+ ]
+ }
+ },
+ {
+ "name": "weather",
+ "description": "Xfetch plugin that shows the current weather from wttr.in",
+ "version": "0.2.0",
+ "kind": "info_provider",
+ "runtime": "native",
+ "path": "plugins/weather",
+ "install": "weather"
+ }
+ ]
+}
diff --git a/plugins/gauges/Cargo.toml b/plugins/gauges/Cargo.toml
index 85bad0c..4197af5 100644
--- a/plugins/gauges/Cargo.toml
+++ b/plugins/gauges/Cargo.toml
@@ -1,11 +1,11 @@
[package]
name = "xfetch-plugin-gauges"
-version = "0.2.0"
+version = "0.2.1"
edition = "2024"
license = "MIT"
description = "Xfetch plugin that renders memory, swap, disk and battery usage as progress bars"
[dependencies]
serde = { version = "1", features = ["derive"] }
-sysinfo = "0.39"
+sysinfo = "0.40.1"
xfetch-plugin-api = { workspace = true }
diff --git a/plugins/gauges/src/main.rs b/plugins/gauges/src/main.rs
index 648833e..c43bcb1 100644
--- a/plugins/gauges/src/main.rs
+++ b/plugins/gauges/src/main.rs
@@ -102,10 +102,11 @@ fn collect(args: &PluginArgs) -> Vec {
let mut lines = Vec::new();
- if enabled("memory") || enabled("swap") {
- let sys = System::new_with_specifics(
+ if (enabled("memory") || enabled("swap"))
+ && let Ok(sys) = System::new_with_specifics(
RefreshKind::nothing().with_memory(MemoryRefreshKind::everything()),
- );
+ )
+ {
if enabled("memory") {
lines.push(format!(
"{} {}",
@@ -204,11 +205,13 @@ fn human(bytes: u64) -> String {
}
fn disk_lines(style: &str, width: usize, include_tmpfs: bool, glyph: &str) -> Vec {
- let disks = Disks::new_with_refreshed_list();
+ let Ok(disks) = Disks::new_with_refreshed_list() else {
+ return Vec::new();
+ };
let mut seen = std::collections::HashSet::new();
let mut rows: Vec<(String, String)> = Vec::new();
- for disk in disks.list() {
+ for disk in &disks {
let total = disk.total_space();
if total == 0 {
continue;
diff --git a/plugins/theme-manager/Cargo.toml b/plugins/theme-manager/Cargo.toml
index 9487801..0300d15 100644
--- a/plugins/theme-manager/Cargo.toml
+++ b/plugins/theme-manager/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "xfetch-plugin-theme-manager"
-version = "0.2.0"
+version = "0.2.1"
edition = "2024"
license = "MIT"
description = "Xfetch plugin that fetches and manages themes from the XFetch registry"
@@ -8,5 +8,5 @@ description = "Xfetch plugin that fetches and manages themes from the XFetch reg
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
-dirs = "6"
+dirs = "7"
xfetch-plugin-api = { workspace = true }
diff --git a/scripts/generate_catalog.py b/scripts/generate_catalog.py
new file mode 100755
index 0000000..7eb028c
--- /dev/null
+++ b/scripts/generate_catalog.py
@@ -0,0 +1,234 @@
+#!/usr/bin/env python3
+"""Generate the machine-readable addon catalog at the repository root.
+
+Every addon lives in ``plugins//`` and is either a native plugin with a
+``Cargo.toml`` or a WebAssembly plugin with an ``xfetch-plugin.json`` manifest.
+The catalog only carries discovery metadata; installation still reads the
+plugin manifest, so this file cannot grant capabilities by itself.
+
+Usage:
+ python3 scripts/generate_catalog.py
+ python3 scripts/generate_catalog.py --check
+
+``--check`` exits non-zero when ``index.json`` differs from what the generator
+would write, which makes stale catalogs easy to catch in CI.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import sys
+import tomllib
+from pathlib import Path
+from typing import Any
+
+ROOT = Path(__file__).resolve().parent.parent
+PLUGINS_DIR = ROOT / "plugins"
+CATALOG_PATH = ROOT / "index.json"
+
+SCHEMA_VERSION = 1
+CATALOG_VERSION = "1.0.2"
+NATIVE_PACKAGE_PREFIX = "xfetch-plugin-"
+LOGO_ANIMATION_PLUGINS = frozenset({"animate-logo", "banner"})
+GUEST_RUNTIMES = frozenset({"core", "component"})
+
+
+class CatalogError(Exception):
+ """A plugin directory cannot be turned into a catalog entry."""
+
+
+def _read_json(path: Path) -> dict[str, Any]:
+ try:
+ with path.open("r", encoding="utf-8") as handle:
+ data = json.load(handle)
+ except (OSError, json.JSONDecodeError) as err:
+ raise CatalogError(f"cannot read {path.relative_to(ROOT)}: {err}") from err
+ if not isinstance(data, dict):
+ raise CatalogError(f"{path.relative_to(ROOT)} must contain a JSON object")
+ return data
+
+
+def _read_cargo_package(path: Path) -> dict[str, Any]:
+ try:
+ with path.open("rb") as handle:
+ data = tomllib.load(handle)
+ except (OSError, tomllib.TOMLDecodeError) as err:
+ raise CatalogError(f"cannot read {path.relative_to(ROOT)}: {err}") from err
+ package = data.get("package")
+ if not isinstance(package, dict):
+ raise CatalogError(f"{path.relative_to(ROOT)} has no [package] table")
+ return package
+
+
+def _required_string(mapping: dict[str, Any], key: str, source: Path) -> str:
+ value = mapping.get(key)
+ if not isinstance(value, str) or not value.strip():
+ raise CatalogError(
+ f"{source.relative_to(ROOT)} is missing a non-empty '{key}'"
+ )
+ return value.strip()
+
+
+def _wasm_entry(directory: Path) -> dict[str, Any]:
+ manifest_path = directory / "xfetch-plugin.json"
+ manifest = _read_json(manifest_path)
+
+ name = _required_string(manifest, "name", manifest_path)
+ if name != directory.name:
+ raise CatalogError(
+ f"{manifest_path.relative_to(ROOT)} names '{name}' but lives in "
+ f"'{directory.name}'"
+ )
+
+ kind = _required_string(manifest, "kind", manifest_path)
+ version = _required_string(manifest, "version", manifest_path)
+ description = _required_string(manifest, "description", manifest_path)
+
+ capabilities = manifest.get("capabilities")
+ if not isinstance(capabilities, dict):
+ raise CatalogError(
+ f"{manifest_path.relative_to(ROOT)} is missing a 'capabilities' object"
+ )
+
+ guest_runtime = manifest.get("runtime")
+ if guest_runtime not in GUEST_RUNTIMES:
+ raise CatalogError(
+ f"{manifest_path.relative_to(ROOT)} has unsupported runtime "
+ f"{guest_runtime!r}; expected 'core' or 'component'"
+ )
+
+ return {
+ "name": name,
+ "description": description,
+ "version": version,
+ "kind": kind,
+ "runtime": "wasm",
+ "guest_runtime": guest_runtime,
+ "path": f"plugins/{name}",
+ "install": name,
+ "capabilities": capabilities,
+ }
+
+
+def _native_entry(directory: Path) -> dict[str, Any]:
+ cargo_path = directory / "Cargo.toml"
+ package = _read_cargo_package(cargo_path)
+
+ package_name = _required_string(package, "name", cargo_path)
+ expected_name = f"{NATIVE_PACKAGE_PREFIX}{directory.name}"
+ if package_name != expected_name:
+ raise CatalogError(
+ f"{cargo_path.relative_to(ROOT)} package name '{package_name}' does "
+ f"not match directory '{directory.name}' (expected '{expected_name}')"
+ )
+
+ version = _required_string(package, "version", cargo_path)
+ description = _required_string(package, "description", cargo_path)
+ kind = "logo_animation" if directory.name in LOGO_ANIMATION_PLUGINS else "info_provider"
+
+ return {
+ "name": directory.name,
+ "description": description,
+ "version": version,
+ "kind": kind,
+ "runtime": "native",
+ "path": f"plugins/{directory.name}",
+ "install": directory.name,
+ }
+
+
+def _is_native_plugin(directory: Path) -> bool:
+ cargo_path = directory / "Cargo.toml"
+ if not cargo_path.is_file():
+ return False
+ try:
+ with cargo_path.open("rb") as handle:
+ package = tomllib.load(handle).get("package")
+ except (OSError, tomllib.TOMLDecodeError):
+ return False
+ if not isinstance(package, dict):
+ return False
+ name = package.get("name")
+ return isinstance(name, str) and name.startswith(NATIVE_PACKAGE_PREFIX)
+
+
+def collect_entries() -> list[dict[str, Any]]:
+ """Return catalog entries sorted by addon name."""
+ if not PLUGINS_DIR.is_dir():
+ raise CatalogError("plugins/ directory not found")
+
+ entries: list[dict[str, Any]] = []
+ for directory in sorted(PLUGINS_DIR.iterdir(), key=lambda path: path.name):
+ if not directory.is_dir() or directory.name.startswith("."):
+ continue
+ if (directory / "xfetch-plugin.json").is_file():
+ entries.append(_wasm_entry(directory))
+ elif _is_native_plugin(directory):
+ entries.append(_native_entry(directory))
+
+ entries.sort(key=lambda entry: entry["name"])
+ names = [entry["name"] for entry in entries]
+ duplicates = sorted({name for name in names if names.count(name) > 1})
+ if duplicates:
+ raise CatalogError(f"duplicate catalog entries: {', '.join(duplicates)}")
+ if not entries:
+ raise CatalogError("no addons found under plugins/")
+ return entries
+
+
+def render_catalog(entries: list[dict[str, Any]]) -> str:
+ catalog = {
+ "schema_version": SCHEMA_VERSION,
+ "catalog_version": CATALOG_VERSION,
+ "entries": entries,
+ }
+ return json.dumps(catalog, indent=2, ensure_ascii=False) + "\n"
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(
+ description="Generate or verify the repository addon catalog."
+ )
+ parser.add_argument(
+ "--check",
+ action="store_true",
+ help="fail if index.json is missing or out of date instead of writing it",
+ )
+ args = parser.parse_args(argv)
+
+ try:
+ entries = collect_entries()
+ except CatalogError as err:
+ print(f"error: {err}", file=sys.stderr)
+ return 1
+
+ rendered = render_catalog(entries)
+
+ if args.check:
+ try:
+ current = CATALOG_PATH.read_text(encoding="utf-8")
+ except OSError as err:
+ print(f"error: cannot read index.json: {err}", file=sys.stderr)
+ return 1
+ if current != rendered:
+ print(
+ "error: index.json is stale; run "
+ "python3 scripts/generate_catalog.py",
+ file=sys.stderr,
+ )
+ return 1
+ print(f"index.json is up to date ({len(entries)} entries).")
+ return 0
+
+ try:
+ CATALOG_PATH.write_text(rendered, encoding="utf-8")
+ except OSError as err:
+ print(f"error: cannot write index.json: {err}", file=sys.stderr)
+ return 1
+ print(f"Wrote index.json ({len(entries)} entries).")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())