Project Registry¶
~/Projects/projects.toml records which machine each project lives on, where it lives there,
and which tmux session holds it. workon finds projects by scanning ~/Projects and ~/Work,
which structurally cannot see a checkout that lives on another Mac; the registry can.
[machines.studio]
host = "mac-studio-2023"
[machines.mini]
host = "mac-mini-pro-2023"
[defaults]
tmux = false # a project gets a session when it asks for one
home_dir = "~/Projects"
work_dir = "~/Work"
[projects.notes]
path = "~/Projects/notes"
# no machine: opens wherever you are, which is true of any synced directory
# nothing has claimed
[projects.django-news]
machine = "studio" # claimed: a session for it runs on the Studio
path = "~/Work/django-news"
tmux = true
tmux_session = "django-news"
[projects.pghub]
machine = "mini"
path = "~/Projects/pghub"
tmux = true
tmux_path = "~/Projects/pghub/pghub-git" # optional: where the work happens
tmux_session = "pghub-git"
path is the project directory; tmux_path is the checkout inside it that the tmux session
actually runs in. Both are separate facts because they differ constantly — the project is
~/Projects/pghub but the session lives in ~/Projects/pghub/pghub-git. tmux_path is
entirely optional and is written only when the two differ, so a project whose work happens at
its own root carries no tmux_path at all. workon lands in tmux_path when it is
set, and in path otherwise.
machine is optional too, and most entries do without it. A project names a machine when
something proved one — a session running there — and otherwise carries none, which resolves
as "wherever you are". That is the honest answer for a Syncthing-mirrored directory: it
exists on all three Macs, so its location says nothing about where the work happens, and a
guessed owner would send you across the network to open something already in front of you.
Set $PROJECTS_TOML to point somewhere else for a single run.
The registry model is defined with pydantic in home/bin/_projects.py. _cmux.py stays on
plain dataclasses on purpose: every cmux-* and tmux-remote-* script imports it, and none of
them should have to grow a dependency to do so. Only projects declares pydantic.
workon and mkproject¶
Registry-aware companions to workon and mkproject, defined in home/.bashrc.d/60-workon.bash.
They are shell functions rather than scripts because the local case has to change the calling
shell's directory and environment.
| Command | Description |
|---|---|
workon <project> |
Open a project wherever it lives |
workon --auto[=<p>] |
The default: consult the registry, then cd locally or mosh out |
workon --local[=<p>] |
Force a local cd + virtualenv activation |
workon --remote[=<p>] |
Force a mosh to its registered machine |
workon --host=<m> <p> |
Open it on that machine instead, just this once; --machine is the same flag |
workon --tmux <p> |
Attach a tmux session for a local open too |
workon --no-tmux <p> |
Plain cd + activate, even when WORKON_TMUX=1 is set |
workon --list (-l) |
Print every name completion knows, one per line |
workon --sessions (-s) |
Show the tmux sessions live on every Mac, and what opens each |
workon -s --kill <p> |
Kill a session by project key or session name |
workon --help (-h) |
Usage |
workon-refresh |
Rebuild the completion cache now |
mkproject <name> |
Create, register, and open a new project |
There is one workon and one mkproject — no separate remote command to reach for.
--auto is the default and is what plain workon <project> does: consult the registry,
then cd locally or mosh out. --local, --remote, and --auto take the name either as
--local foo or --local=foo; --host takes it as --host studio or --host=studio.
Flags and the name can come in any order. A second bare argument, an unknown flag, or a
--host with no machine after it is a usage error with exit code 2.
Two environment variables shape it. WORKON_TMUX=1 makes every local open attach a
session, as if --tmux were passed; --no-tmux overrides it for one call.
WORKON_PROJECT_DIRS is the list of roots the unregistered fallback and the completion
cache scan, ~/Projects and ~/Work by default.
workon trusts projects resolve and its exit code. Only exit code 3, the resolver's
"not in the registry" answer, sends workon to the directory scan. Any other failure —
a registry that does not parse, an entry that fails validation, a projects script that
is missing from $PATH — is printed and workon stops with that exit code, rather than
silently opening a same-named local directory that may not be the project you meant.
A project that is not in the registry falls back to the original directory scan of
~/Projects, ~/Work, and ~/.virtualenvs, so nothing that worked before the registry
existed has stopped working.
Opening a project is a cd plus a virtualenv activation. No tmux session is involved unless
something asks for one — tmux = true on the entry, --tmux on the command, or
WORKON_TMUX=1 in the environment. A project gets a session because it said so, not
because it failed to say otherwise, and that holds for remote opens too: reaching another
Mac without tmux = true gets you a login shell in the right directory.
workon notes # cd + activate, wherever it lives
workon notes --tmux # ...and attach a session after all
workon django-news # remote: mosh mac-studio-2023, attach (its entry sets tmux = true)
workon # no argument: list what is registered
Tab completion reads a cache at ~/.cache/workon/names rather than calling projects on
every keypress. projects is a uv run script and costs ~300ms to start — fine when you
typed it, an eternity to sit through on a TAB. The cache rebuilds when the registry,
~/Projects, ~/Work, or ~/.virtualenvs is newer than it, which is four [[ -nt ]]
builtins and no subprocess in the common case. That takes a TAB from 580ms to
unmeasurable, and a new project still shows up the moment it exists, whether it arrived
through projects add or a bare mkdir.
workon-refresh rebuilds it by hand, for warming the cache from a profile or when you
want to be sure.
Names are matched loosely: a project registered as thumb.im also answers to thumb-im,
the slug tmux actually shows you.
Seeing what is running¶
workon --sessions (-s) probes every Mac at once and shows what is live, with the command
that gets you back into each one:
$ workon --sessions
mini (mac-mini-pro-2023)
django-news-com attached 1w workon django-news.com
djangoconus-automation-git attached 1w workon djangoconus-automation
dotfiles detached 1w workon dotfiles
studio (mac-studio-2023)
toggl-agent-git attached 1w workon agents
12 session(s)
The right-hand column is the point. Session names and project keys drift apart constantly —
the session is django-news-com, the thing you type is workon django-news.com; the session
is toggl-agent-git, the project is agents — so the listing tells you what to type rather
than leaving you to work it out. A session matches its project by path first (including a
checkout nested inside the project directory, which is the usual case) and by name second,
because a path is where the session actually is while a name is a label.
A session with no registered project behind it is called out rather than hidden — it is real
work the registry does not know about, and usually wants a projects add.
Remaining arguments pass through to projects sessions:
workon -s -a # only sessions with a client attached
workon -s -m studio # one Mac
workon -s --names | fzf | xargs workon # pick a live session and open it
workon -s --kill agents # kill that session, wherever it is running
workon -s --kill agents --yes # ...without the confirmation
--names prints bare project names for piping, and lists only registered ones — a name
workon cannot open is worse than absent. Unreachable Macs report to stderr, so a sleeping
machine stays visible without corrupting a pipe.
--kill (-k) takes either spelling the listing shows — the tmux session name or the
project key — so the thing you kill is the thing you would have typed workon for, without
looking up its real session name first. --kill django-news.com finds the django-news-com
session, since both sides are slugified.
It prints what it is about to destroy and asks first; --yes (-y) skips the prompt. Two
refusals are deliberate: a name matching sessions on more than one Mac is reported rather
than resolved, because guessing which copy you meant is not a guess worth making with
someone's running work, and a Mac that failed to answer is reported too, since the session
you are looking for might be on exactly that one.
$ workon -s --kill agents
Kill toggl-agent-git on studio (mac-studio-2023)?
3 window(s), detached, in ~/Projects/agents/toggl-agent-git
Everything running in it goes away [y/N]:
This overlaps tmux-remote-ls on purpose: that answers "what is running
where", this answers "what do I type to get back into it".
mkproject creates the directory, a uv venv, and an .envrc, registers the project, and
opens it. Creation always happens here, even when the project is registered to another
machine: ~/Projects and ~/Work are Syncthing folders, so the directory and its .envrc
travel on their own. The venv does not travel — .venv/ is in .stignore — and does not
need to: the generated .envrc is layout uv, so direnv builds a native one the first time
you enter the directory over there.
No machine is recorded unless you pass --machine. A brand-new project has no history
saying where it is worked on, and the directory will exist on every Mac within the minute,
so naming an owner would be inventing a fact.
mkproject scratch # ~/Projects/scratch, no machine, no session
mkproject client-site --work # ~/Work/client-site
mkproject api --machine studio --python 3.13
mkproject api --tmux # wire it up for tmux from the start
mkproject api --session api-git # name the tmux session something else
mkproject api --path ~/Code/api # somewhere other than home_dir or work_dir
mkproject api --no-attach # create and register, don't open
mkproject api --dry-run # print what would be created; nothing runs
| Option | Description |
|---|---|
--machine KEY, --host KEY |
Record an owner. --host is accepted for symmetry with workon and is passed on as --machine |
--path DIR |
Create the project here instead of <home_dir>/<name> |
--work |
Create under work_dir instead of home_dir |
--session NAME |
tmux session name when the project key is not what tmux should show |
--tmux / --no-tmux |
Register tmux = true and write use tmux, or pin tmux = false and leave it out |
--python VERSION |
Python for the uv venv; default 3 |
--no-attach |
Create and register, but do not workon it afterwards |
--dry-run (-n) |
Print the commands projects create would run. Implies --no-attach |
--help (-h) |
Usage |
mkproject is a thin wrapper: the first bare word is the name, --host is rewritten
to --machine, --no-attach and --dry-run are noted, and everything else goes to
projects create untouched. So any option projects create --help lists works here, and
an option it rejects is rejected there, with its error message. Tab completion offers the
machine keys after --machine or --host and a short list of Python versions after
--python.
The generated .envrc is layout uv, plus use tmux <session> when the project is a tmux
one, so it picks up the direnv auto-attach machinery and settles on
the same session name the registry uses. (The pre-registry mkproject wrote a bare
source .venv/bin/activate, which bypasses layout uv and never wires up tmux.)
The two halves have to agree: --tmux writes tmux = true and the use tmux line,
and without it neither is written. An .envrc that autoattaches a session the registry
disclaims would fight itself. The session name is slugified the same way everywhere, so
mkproject thumb.im --tmux writes use tmux thumb-im rather than a name tmux would reject.
Managing the registry¶
| Command | Description |
|---|---|
projects / projects list |
List project names, one per line; --long/-l groups by machine |
projects add NAME |
Register a project |
projects set NAME |
Change one project's details in place |
projects remove NAME |
Unregister a project; the directory is untouched |
projects create NAME |
Create the directory, venv, and .envrc, then register |
projects import |
Import ~/Projects and ~/Work, deciding machines from evidence |
projects resolve NAME |
Show machine, path, session, and the command to get there |
projects sessions |
Show live tmux sessions on every Mac, mapped to project names |
projects machines |
Add, remove, and list machines |
projects init |
Create the registry, importing hosts.toml if present |
projects edit |
Open the registry in $EDITOR |
projects set is the one to reach for when an entry needs a fact it does not have — which,
after an import, is most of the interesting ones. add --force rewrites the whole entry
from its arguments, so anything you do not repeat is dropped; set touches only the fields
you name:
projects set pghub --tmux # this one wants a session
projects set pghub --machine studio # ...and it lives on the Studio
projects set pghub --session pghub-git # pin the tmux session name
projects set pghub --tmux-path ~/Projects/pghub/pghub-git # where the session runs
projects set pghub --clear tmux --clear session # back to the defaults
--clear unsets machine, tmux, tmux_path, session, or description, and is
repeatable. projects set pghub --clear machine hands a project back to "wherever you are",
which is how you undo a machine that a session justified once and no longer does. Clearing
tmux is not the same as --no-tmux: the field is a tri-state, and absent means follow
[defaults] while false pins it off regardless of what the default becomes. Only path
cannot be cleared — an entry without one cannot be resolved. Paths are stored as ~/...
however you type them, so they mean the same thing on every Mac.
Importing an existing setup¶
projects import brings ~/Projects and ~/Work in wholesale. The interesting part is how
it picks a machine, because the roots are Syncthing-mirrored — all three Macs hold
substantially the same ~250 directories, so a directory's presence proves nothing about
where you actually work on it.
So the import records a machine from evidence, or records none at all:
| Reason | Signal | Machine |
|---|---|---|
session |
a tmux session for it is running there, with a client attached | that machine |
session-idle |
...running there, but detached | that machine |
workspace |
the cmux session dump pins it to that machine | that machine |
default |
the directory exists under a root, and nothing else is known | none |
Every directory under both roots is registered, keyed by its own name. Most come out as two lines — a name and a path — because a directory that exists on all three Macs is not evidence of anything, and an entry with no machine opens wherever you are. Of ~250 directories here, the handful with a live session are the only ones that name one.
A session then enriches the entry it belongs to rather than replacing it: path stays the
project directory you imported, and the session contributes the machine, tmux_path, and
tmux_session.
agents -> studio:~/Projects/agents
+tmux_path=~/Projects/agents/toggl-agent-git
+tmux_session=toggl-agent-git (session)
That matters because the bare directory entry alone would point at ~/Projects/agents and
start a second tmux session next to the one already running. The enriched entry attaches
the one that is actually there.
projects import --dry-run # show each assignment and the reason for it
projects import # apply; only ever adds
projects import --sessions-only # register just the evidence-backed projects
projects import --no-sessions # skip the ssh probe entirely (offline)
projects import --force # re-assign entries whose evidence has since changed
--force is how a machine-less entry gets promoted once a session exists to prove where it
belongs: it compares machine, path, and session name, so an entry pointing at the project
root moves to the checkout the session is really in.
It only ever promotes. A directory with no evidence behind it never rewrites an entry that
already exists, so running --force with --no-sessions, or while a Mac happens to be
asleep and unreachable, cannot strip the machine and session off everything that Mac owns.
Removing a machine on purpose is projects set NAME --clear machine.
Names that exist under both roots¶
Seven directories here share a name between ~/Projects and ~/Work (revsys-office,
revsys.com, westerveltco-cms, ...). Both get registered: the ~/Work copy takes a work-
prefix, so ~/Projects/revsys-office is revsys-office and ~/Work/revsys-office is
work-revsys-office. Only the colliding names are renamed — the other 29 ~/Work projects
keep the plain name you would actually type.
The prefix is applied everywhere a name is derived, so a tmux session running under
~/Work/revsys-office enriches work-revsys-office rather than quietly landing on its
~/Projects namesake.
One collision the prefix cannot fix is still reported rather than silently merged: a session
outside both roots is keyed by its session name and can shadow a real directory — dotfiles
runs in ~/.homesick/repos/dotfiles while ~/Projects/dotfiles also exists.
projects scan is a deprecated alias that forwards here.
projects list is bare by default — one name per line, nothing to strip — so it pipes
straight into grep, fzf, and xargs. With 253 projects registered, the grouped view is
the exception rather than the rule:
projects list # 253 bare names
projects list -m studio # just the ones on the Studio
projects list | fzf | xargs workon # pick one and open it
projects list --long # grouped by machine, with paths and sessions
projects resolve --shell is the interface workon consumes; --json is the same data
for anything else:
$ projects resolve django-news
django-news (remote via mac-studio-2023)
machine studio
path ~/Work/django-news
session django-news
command mosh mac-studio-2023 -- bash -lc 'cd "$HOME"/Work/django-news; tmux new-session -A -s django-news -c "$HOME"/Work/django-news'
The ~ in a remote path is deliberately left unexpanded: it has to expand against the remote
home directory, not this machine's.