No description
  • Go 98.1%
  • Shell 1%
  • Just 0.9%
Find a file
Viktor Varland 6f63df1722
All checks were successful
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/tag/release Pipeline was successful
docs: changelog for v1.1.0
2026-06-26 09:21:55 +02:00
.just docs: correct wrangle regen command to wrangle init 2026-06-26 09:20:02 +02:00
.woodpecker ci: add Woodpecker build + release pipelines and changelog tooling 2026-06-24 13:50:57 +02:00
internal fix: correct module path to git.meatbag.se/varl/skiff 2026-06-25 09:28:40 +02:00
scripts docs: correct wrangle regen command to wrangle init 2026-06-26 09:20:02 +02:00
.gitignore refactor!: rename svs to skiff 2026-06-16 22:20:55 +02:00
CHANGELOG.md docs: changelog for v1.1.0 2026-06-26 09:21:55 +02:00
config.example.toml feat: manage user unit files via install/uninstall 2026-06-22 11:31:09 +02:00
go.mod feat: add shell completion command 2026-06-25 11:43:19 +02:00
go.sum feat: add shell completion command 2026-06-25 11:43:19 +02:00
justfile chore: replace release pipeline with wrangle 2026-06-25 09:32:55 +02:00
main.go feat(completion): complete the profile arg and --config path 2026-06-25 12:23:58 +02:00
README.md docs: add migrating existing units guide 2026-06-22 11:48:28 +02:00

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 NAME applies a profile as the exclusive state. It runs the outgoing profile's leave hooks, the new profile's pre hooks, stops every other managed unit not in the profile (so it tears down whatever an earlier load brought up), starts the profile's units, runs post, records the profile as active, then runs enter. A clean full reset — what you want when flipping to, say, gaming.
  • skiff load NAME is additive: it runs pre, starts the profile's units on top of whatever is already running (stopping nothing), then runs post. It does not change the active profile or run enter/leave. Use it to layer, say, an agent profile onto your current state.
  • skiff unload NAME stops the profile's units.
  • skiff eject is switch with no destination: it runs the active profile's leave hooks, stops every managed unit (tearing down anything a load brought 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 switch blocks until the command exits and the app dies with your shell. Run such apps as system_units / user_units instead — systemd runs them detached, skiff manages them declaratively (started/stopped with the profile, shown in the snapshot), and a stale enter-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>.service and list it under user_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 install symlinks each unit file into ~/.config/systemd/user/, writes a <unit>.d/skiff.conf drop-in recording X-Skiff-Profile=… for every unit a profile references, and runs systemctl --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-run shows the plan.
  • skiff uninstall stops 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 units lists every unit skiff manages, with whether the file is linked/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