- Shell 59.6%
- Nix 40.4%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| .claude-plugin | ||
| .forgejo/workflows | ||
| plugin | ||
| .gitignore | ||
| alejandra.toml | ||
| claude-plugin.nix | ||
| flake.lock | ||
| flake.nix | ||
| hm-module.nix | ||
| package.nix | ||
| README.md | ||
| sources.json | ||
| update.sh | ||
| zeit-wrapper.sh | ||
zeit 1.1.0
A Nix flake packaging zeit, a command line tool for tracking time spent on tasks and projects, plus a Claude Code plugin that records every Claude Code turn in it. Upstream zeit lives at https://tty.fail/mrus/zeit.
On top of a plain buildGoModule it adds two things:
- Shell completions for bash, zsh and fish, generated at build time.
zeit-wrapper.sh, which creates the badger database on first use. Every read-only zeit command opens the database read-only and badger refuses to open one that does not exist, so on a fresh machine every read fails with "no manifest found" until some write command has run. The wrapper imports an empty JSON document once, which brings the database into existence and stores nothing. The script itself carries the longer explanation.
zeit is under the SEGV License, which restricts fields of use and so is unfree
by nixpkgs' reckoning. This flake sets allowUnfree for its own outputs;
consumers using overlays.default build against their own nixpkgs and have to
allow it there.
Usage
{
inputs.zeit.url = "git+https://git.cumsek.com/flakes/zeit";
}
Then either take the package directly:
environment.systemPackages = [inputs.zeit.packages.${pkgs.system}.default];
or add the overlay and use pkgs.zeit:
nixpkgs.overlays = [inputs.zeit.overlays.default];
Home Manager
homeModules.default installs zeit and, optionally, the Claude Code plugin:
{inputs, ...}: {
imports = [inputs.zeit.homeModules.default];
programs.zeit = {
enable = true;
claudeCode.enable = true; # needs programs.claude-code.enable
};
}
claudeCode.enable registers the plugin through programs.claude-code.plugins
and adds the Bash(zeit-claude start:*) permission rule so Claude can label
turns without asking each time (claudeCode.allowLabelling = false leaves the
rule out). claudeCode.defaultTask sets the task that unlabelled turns go under.
Claude Code plugin
The plugin in plugin/ records each Claude Code turn as one zeit block, from
the moment a prompt is submitted until Claude stops generating:
sequenceDiagram
participant U as User
participant H as Hooks
participant C as Claude
participant Z as zeit
U->>H: submit prompt (UserPromptSubmit)
H->>H: remember start time, project from git root
H->>C: additionalContext: label this turn
C->>Z: zeit-claude start -t debugging -n "..."
Note over Z: block opened, backdated to the prompt
C->>H: finish generating (Stop)
H->>Z: zeit end
zeit cannot rename a block's project or task after it is created, so the hook does not open a placeholder block. Claude opens the real one, backdated so the time before its first tool call is still counted.
- Project defaults to the git repository's directory name (or the working
directory's if there is no repository), lowercased. Claude can pass
-pto pick another. - Task and note come from Claude. The prompt hook lists the task names that already exist so Claude reuses them.
- Unlabelled turns, such as a quick answer with no tool calls, are logged
after they finish under
chat(or$ZEIT_CLAUDE_DEFAULT_TASK), with the first line of the prompt as the note. They are skipped if another block is running, because zeit would cut that block short to make room for a backdated one. - Interrupted turns don't trigger the Stop hook, so the next prompt, or the end of the session, closes their block.
- Concurrent sessions keep separate state under
$XDG_STATE_HOME/zeit-claude/, and each session only closes the block it opened.
A labelled turn takes over the timer: a block you started by hand before the prompt is closed at the prompt, and nothing is left running when the turn ends.
Installing without Home Manager
This repository is also a Claude Code marketplace. The plugin runs zeit, jq
and git from PATH, so those need to be installed:
/plugin marketplace add https://git.cumsek.com/flakes/zeit.git
/plugin install zeit@zeit
To load it for a single session, point --plugin-dir at a checkout or at the
Nix package, which brings its own zeit and jq:
claude --plugin-dir ./plugin
claude --plugin-dir "$(nix build --no-link --print-out-paths git+https://git.cumsek.com/flakes/zeit#claude-plugin)"
Plugins cannot grant permissions. To stop Claude asking before each label, add
this to ~/.claude/settings.json:
{"permissions": {"allow": ["Bash(zeit-claude start:*)"]}}
Updating
.forgejo/workflows/update.yml runs daily at 04:17 UTC (and on demand from the
Actions tab): it updates flake.lock, points sources.json at the newest
upstream tag with update.sh (rewriting the version in this title to match),
and pushes to main only if nix flake check passes and zeit version
reports the new version. The lock and the zeit bump land as separate commits.
By hand, the same thing is:
nix flake update
nix shell --inputs-from . nixpkgs#git nixpkgs#curl nixpkgs#jq -c ./update.sh
sed -i "1s/.*/# zeit $(nix eval --raw .#zeit.version)/" README.md
nix flake check -L
sources.json holds version, hash, vendorHash, and the commit and
date that make would bake into runtime.{Version,Commit,Date} via
-ldflags -X. Those two are recorded rather than read from git so the build
stays reproducible. The version is taken from the git tags, not the Gitea
releases feed, because upstream does not cut a release for every tag. The
hashes are the ones nix reports when update.sh builds zeit.src and
zeit.goModules with a fake hash, so they always match the fetcher.
Checks
nix flake check -L
Builds zeit and the plugin, plays Claude Code turns through the packaged
plugin against a throwaway database (plugin/tests/turns.sh), runs
alejandra, deadnix, statix and shellcheck, and fails if this README's
title does not match the packaged version. .forgejo/workflows/check.yml runs
the same command and nothing else.
Licence
The packaging and the plugin are trivial; zeit itself is under the SEGV License 1.1, as
recorded in meta.license.