- Go 98.1%
- Shell 1%
- Just 0.9%
| .just | ||
| .woodpecker | ||
| internal | ||
| scripts | ||
| .gitignore | ||
| CHANGELOG.md | ||
| config.example.toml | ||
| go.mod | ||
| go.sum | ||
| justfile | ||
| main.go | ||
| README.md | ||
skiff
System snapshot and profile switcher.
Run skiff with no arguments for a snapshot of how the machine is working right
now. Use skiff switch PROFILE to flip between named profiles that start/stop
systemd units and run shell hooks.
Usage
skiff # print the system snapshot
skiff switch media # apply "media" as the exclusive state (full reset)
skiff switch gaming --dry-run
skiff load agent # add the "agent" profile's units on top, stop nothing
skiff unload agent # stop the "agent" profile's units
skiff eject # stop all managed units, clear the active profile
skiff list # list profiles (* marks the active one)
skiff current # print the active profile
skiff install # symlink unit files from the units dir into systemd
skiff install --dry-run # show what install would do
skiff uninstall # remove skiff-installed units and drop-ins
skiff units # list skiff-managed units and their status
Snapshot
A bare skiff prints, with usage bars and color when writing to a terminal:
- CPU model, core count, load averages, and busy percentage
- RAM used/available and swap
- GPU name, VRAM, utilization, temperature, and power (NVIDIA via
nvidia-smi, AMD via sysfs) - CPU and NVMe temperatures
- Disk usage for
/and$HOME - The managed units (named by any profile) that are currently up — or failed/transitional — each labeled with the profile(s) it belongs to. Cleanly stopped units are hidden, so this section shows what's actually running and vanishes entirely when nothing managed is up
- The busiest processes by CPU and by memory
CPU and per-process usage are sampled over a short window, so the snapshot
reflects the last fraction of a second rather than an all-time average. Color
is suppressed when output is not a terminal or NO_COLOR is set.
Profiles
Profiles live in ~/.config/skiff/config.toml (override with --config, or set
$XDG_CONFIG_HOME). See config.example.toml for the
full format. A profile is just a named set of systemd units (split into
system-wide and per-user lists) plus optional hooks:
[profiles.media]
description = "Media workstation"
system_units = ["docker.service"] # managed via sudo systemctl
user_units = ["syncthing.service"] # managed via systemctl --user
pre = ["echo 'entering media'"]
post = ["notify-send skiff 'media' || true"]
[profiles.agent]
description = "AI agent services"
system_units = ["ollama.service"]
switch / eject / load / unload
What a profile does is decided by the verb, not by the profile:
skiff switch NAMEapplies a profile as the exclusive state. It runs the outgoing profile'sleavehooks, the new profile'sprehooks, stops every other managed unit not in the profile (so it tears down whatever an earlierloadbrought up), starts the profile's units, runspost, records the profile as active, then runsenter. A clean full reset — what you want when flipping to, say, gaming.skiff load NAMEis additive: it runspre, starts the profile's units on top of whatever is already running (stopping nothing), then runspost. It does not change the active profile or runenter/leave. Use it to layer, say, anagentprofile onto your current state.skiff unload NAMEstops the profile's units.skiff ejectisswitchwith no destination: it runs the active profile'sleavehooks, stops every managed unit (tearing down anything aloadbrought up too), and clears the active profile — a clean slate with nothing managed running. Takes no profile argument.
Hooks
| Hook | When it runs |
|---|---|
pre |
before units change, on switch and load |
post |
after units change, on switch and load |
enter |
login: on switch into this profile, after its units are up |
leave |
logout: on switch away from this profile, before the new changes |
pre/post bracket the unit changes — use them for unit setup/teardown that
applies however the profile is brought up. enter/leave are session
(login/logout) hooks that fire only when the active profile actually
changes, so re-switching to the profile you're already on does neither:
[profiles.work]
system_units = ["docker.service"]
enter = ["~/bin/work-login.sh"] # on `skiff switch work` (not on `skiff load work`)
leave = ["~/bin/work-logout.sh"] # on switching from work to another profile
The full switch order is
leave (old) → pre → stop others → start → post → enter (new).
Don't launch long-lived or GUI apps from hooks. Hooks run synchronously and attached to your terminal, so
skiff switchblocks until the command exits and the app dies with your shell. Run such apps assystem_units/user_unitsinstead — systemd runs them detached, skiff manages them declaratively (started/stopped with the profile, shown in the snapshot), and a staleenter-hook launch can't leak past a later switch. Reserve hooks for quick, self-contained setup commands. (For a GUI app there's no shipped unit; drop a small~/.config/systemd/user/<app>.serviceand list it underuser_units.)
So skiff switch media then skiff load agent runs both; a later
skiff switch gaming resets to just gaming, tearing the agent units down with
everything else.
Units already in the desired state are untouched, so every command is
idempotent. --dry-run prints the exact commands without running them;
--no-hooks skips the shell hooks.
The active profile (the last one switched to) is recorded in
~/.local/state/skiff/active (or $XDG_STATE_HOME); eject clears it and
load/unload don't touch it — what's actually running is shown by the
snapshot's unit list, where each unit is labeled with the profile(s) it belongs
to.
Managing unit files
Profiles reference units by name; they don't have to be units skiff owns. But
skiff can also manage the unit files, so you can keep them under version
control (chezmoi, git) without tracking the messy live ~/.config/systemd/user.
Author your unit files in ~/.config/skiff/units/ (override with units_dir in
the config), then:
skiff installsymlinks each unit file into~/.config/systemd/user/, writes a<unit>.d/skiff.confdrop-in recordingX-Skiff-Profile=…for every unit a profile references, and runssystemctl --user daemon-reload. It is an idempotent reconcile: re-running fixes drift, prunes symlinks whose source you removed, and refreshes drop-ins to match the config. A non-skiff file already in place is never overwritten — it's reported and skipped.--dry-runshows the plan.skiff uninstallstops and (if enabled) disables each skiff-installed unit, removes its symlink, removes every skiff drop-in, and daemon-reloads — leaving the systemd dir as it was.skiff unitslists every unit skiff manages, with whether the file islinked/unlinked/foreign, its state, enable-state, and profiles. Anything not listed is a manual unit skiff doesn't touch.
Two self-identifying markers make this exact, and answer "which units are skiff's vs my manual ones":
| On disk | Means |
|---|---|
| symlink into the units dir | skiff installed this file |
<unit>.d/skiff.conf (X-Skiff-Profile=…) |
skiff uses this unit in those profiles |
| both | installed and in a profile |
| neither | a purely manual unit — yours |
The config is the single source of truth for membership; drop-ins are generated
from it, never hand-edited. systemctl --user cat <unit> shows the drop-in
inline (X- keys are ignored by systemd). A natural pairing is a chezmoi
run_onchange_ hook that runs skiff install whenever the tracked units/ dir
changes, so chezmoi apply both lays down the files and syncs them into systemd.
Scope is user units only; skiff doesn't manage enable-state — systemctl --user enable stays yours.
Migrating existing units
Your hand-authored units currently live as real files in ~/.config/systemd/user/.
Since install won't overwrite a non-skiff file (it reports and skips it),
move them into the units dir rather than copying — otherwise you'd leave the
unmanaged original in place and install would skip it:
mkdir -p ~/.config/skiff/units
# top-level real files are your candidates; symlinks and the *.wants/ dirs are
# systemd's (enable links, etc.) — leave those alone
find ~/.config/systemd/user -maxdepth 1 -type f
mv ~/.config/systemd/user/foo.service ~/.config/skiff/units/
skiff install --dry-run # review: each moved unit should show `link`
skiff install
skiff units # confirm they show `linked`
Enabled units keep working: their *.wants/ symlink resolves through the new
symlink skiff puts back. Don't move package units (those live in
/usr/lib/systemd/user) — reference them in a profile and skiff tags them with a
drop-in without owning the file (they show as foreign in skiff units).
Storage
- Config:
~/.config/skiff/config.toml - Unit files (source for
install):~/.config/skiff/units/ - Active profile:
~/.local/state/skiff/active
Build
just build # builds ./skiff
just install # builds and installs to /usr/local/bin