Personal workspace bootstrap for a fresh Mac or Linux machine — developer tools, keys, projects, and every AI coding agent configured from one command.
Installs the toolchain, deploys SSH keys and credentials from Proton Pass,
populates the workspace directory (~/<repo-name>/) with your project
repos, and gives your agentic AI tools — Claude Code, Codex CLI, Gemini CLI,
Cursor, GitHub Copilot — one shared, agent-neutral configuration: a single
AGENTS.md for instructions and a single mcp.json for MCP servers,
version-controlled in your private workspace repo and linked into each
tool's own config location. Use any agent, or all of them side by side —
they read the same brief. Adopt a new one or drop one without reconfiguring
anything.
Your agent brief stays private: the instructions and server list live in your own repo, not in public dotfiles — this repo ships only the mechanism.
It also closes the loop: a harvest-and-distill workflow turns what your agents learn — the memories and notes they accumulate as you work — back into that shared brief, as reusable, generalized instructions. See Distilling learned knowledge below.
Prerequisite: install the Proton Pass desktop app and sign in.
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/sakaal/setup/master/setup.sh)"That one line:
- Fetches
setup.shover HTTPS fromraw.githubusercontent.comand runs it from stdin (the script is never written to disk in this form). - On a fresh Mac, installs Command Line Tools (one sudo prompt) so
that
gitis available, then clones the repo to~/setup(see below to choose another location). The install is therefore always a real git working copy —git pull,git status,git tag --verifywork as standard. - Re-executes from the clone and continues with the remaining prereqs, Pass authentication, and the ansible playbook.
Then type your Proton Pass master password + TOTP once when prompted (plus your sudo password on a fresh Mac if Command Line Tools need installing). After that, the bootstrap runs unattended.
Re-running with the same one-liner is safe and idempotent — existing state is detected and only what is missing or out of date is changed.
To use your own manifest repo instead of the default, pass its remote as an
optional argument. The repo lands under your home directory by its own name
(basename minus .git, like git clone). Through the curl one-liner the
argument goes after a $0 placeholder (here setup):
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/sakaal/setup/master/setup.sh)" setup https://github.com/youruser/my-projects.gitAny transport git understands works — SSH (git@host:owner/repo.git) or
HTTPS. The bootstrap assumes your git/SSH client is already configured to
reach it; it just hands git the reference.
By default the workspace lands at ~/<repo-basename>/. When the remote's name
can't be the local directory name you want — e.g. a Bitbucket slug like
projects-sakari.maaranen that you'd rather have as ~/projects — set
WORKSPACE_DIR to the directory name:
WORKSPACE_DIR=projects /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/sakaal/setup/master/setup.sh)" setup git@bitbucket.example.com:you/projects-sakari.maaranen.gitIt must be a plain directory name (no slashes) and passed on every run: it
decides where setup looks for the workspace, so idempotency depends on it.
Don't rename the directory after cloning — a later run would recreate the
basename directory and drift; set WORKSPACE_DIR instead.
The one-liner installs its clone to ~/setup. If that path is already
occupied by something else, the script halts without touching it. Set
SETUP_DIR at the front of the line to install elsewhere:
SETUP_DIR=~/workspace/setup /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/sakaal/setup/master/setup.sh)"Multiple environment variables combine space-separated on the same line:
SETUP_DIR=~/workspace/setup SETUP_REF=v2.5.0 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/sakaal/setup/master/setup.sh)"SETUP_REF pins what gets installed: the entry script clones that ref
and re-executes from the clone, so everything past the initial
bootstrap runs the pinned version:
SETUP_REF=v2.5.0 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/sakaal/setup/master/setup.sh)"The ref in the URL (master above) selects only the entry script
itself, which cannot know which URL it came from — so pinning the URL
alone still installs master. For an end-to-end pin, including the
entry script, set both to the same tag:
SETUP_REF=v2.5.0 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/sakaal/setup/v2.5.0/setup.sh)"Tags are immutable refs. When a release is GPG-signed, you can
curl … sha256sums.txt (or equivalent) and verify the entry script's
hash before running. Both pinning and signature verification rest on
the git host's own conventions — no extra tooling in this repo.
If you've already cloned the repo — anywhere you like, including inside
your workspace directory — ./setup.sh runs in place. There is no fixed
install location; SETUP_DIR only tells the one-liner where to put (or
find) its own clone, and is ignored when you run from a working copy.
- Installs the platform's build tools if missing — Command Line Tools on
macOS,
gitvia the distro package manager on Linux. - Installs the tool chain per platform:
- macOS: Homebrew, then
git,gh,pass-cli, andpipxvia brew. - Linux (Fedora-family / dnf primary; apt and pacman also handled):
git,gh,pipx, and base packages via the distro manager, andpass-clivia Proton's official installer into~/.local/bin. - Both: Ansible is installed via
pipx(--include-deps), so it always ships thecommunity.generalcollection the playbook needs.
- macOS: Homebrew, then
- Logs in to Proton Pass via
pass-cli login(interactive, one-time). - Discovers items in your
Setup KeysPass vault and deploys them:id_*items → SSH keypairs in~/.ssh/github-pat→ GitHub PAT helper forgitandghvault_pass-*items → Ansible Vault password identity (script-based)
- Lays the workspace manifest repo onto
~/<repo-name>/(the optional argument overrides the default repo). - Clones the repos listed in
workspace.reposas siblings inside the workspace directory, and applies any git identity the manifest declares: an optional top-levelconfig.user(name/email) becomes your git global identity, and a per-repoconfig.usersets that repo's local identity (overriding the global default). Both are optional — without them setup leaves git identity untouched. The identity values live in the private workspace repo, never in this public one. - Configures a machine-local baseline: global gitignore,
~/.local/binon PATH, and AI-assistant wiring. The agent-neutral sources —ai/AGENTS.md(instructions) andai/mcp.json(MCP server list) — live in the private workspace repo, not here;~/.config/ai/holds stable symlinks to them. A single data-driven manifest,agent-map.json, maps every tool/class to its path and sync method; both the playbook and~/bin/ai-syncread it and loop generically, so adding a tool is a manifest edit, not code. Present tools are pointed at the hub via symlink/@importstub, andai-syncrenders the MCP list into each tool's own format — add-only, never overwriting existing entries. Companions~/bin/ai-harvestand~/bin/ai-distill(deployed, never run by the bootstrap) support the learning loop — cataloging each tool's accumulated knowledge and distilling it back into the shared sources under human review. See Distilling learned knowledge.
As you work, your AI tools accumulate knowledge — memories, notes, session transcripts. Most of it is specific to one project, but some is generalizable craft ("prefer retry-with-backoff behind gateway APIs", "state a requirement's standing justification") that would help everywhere. The distill workflow turns that scattered, project-specific knowledge into a small set of reusable, generalized instructions in your shared agent brief — reviewed by you, never promoted automatically.
It is a three-part pipeline, all human-gated:
- Harvest —
~/bin/ai-harvestreads the manifest and catalogs every local store your tools keep (memory, transcripts) as a read-only index of URLs with checksums under~/.local/state/ai/harvest/. It reads content only to hash it and writes nothing back. - Distill —
~/bin/ai-distill preparesanitizes and de-duplicates the harvested memories into a work package and opens a git worktree of your workspace repo on branchdistill/<run>, off the live path. A human-supervised agent session (thedistillplugin) generalizes, categorizes, de-duplicates, and triages them, and writes the distilled instructions directly into that worktree'sai/, refining, adding scopedrules/files, reorganizing.~/bin/ai-distill gatechecks the branch diff (prose only, no secrets, no leaked project identifiers). - Review & apply — the git diff of that branch is the proposal. You
review it (approve, or ask for edits the agent makes in the worktree and
re-gates), and on approval
~/bin/ai-distill applymerges the branch into your liveai/and removes the worktree. Nothing reaches the liveai/un-reviewed, and because apply merges exactly the reviewed branch, what lands is exactly what you saw. Suspected injections are held in a quarantine pen outside every repo; genuinely valuable source-specific finds can be sent to their own repo via the same worktree flow (add-target).
The scripts are the deterministic guarantees; the plugin is the supervised
judgment in between. The full design and its threat model are in
docs/ai-pipeline-threat-model.md.
Distillation writes into the same private workspace repo that holds your
shared agent brief — its ai/ directory (ai/AGENTS.md, ai/rules/…), the
sources that setup distributes to every tool. That is the whole loop: your
tools accumulate knowledge → distill lifts the general part into ai/ →
setup fans ai/ back out to every tool. So the distill workflow only makes
sense once setup has laid down your workspace repo; promotions have nowhere
to land without it.
The distillation session ships as a Claude Code plugin, and this repo is its
marketplace. From a clone of this repo (the setup bootstrap leaves one):
/plugin marketplace add sakaal/setup
/plugin install distill@setupThe plugin carries only mechanism — no personal content, no model credentials;
everything it processes is read from your local machine state at run time. Its
inference stages are agent-neutral process wrapped in Claude-specific
packaging (like the per-tool emitters ai-sync uses), so the same scripts and
runbook could back a wrapper for another tool later.
Run a distillation whenever your tools have accumulated enough new knowledge to be worth curating:
/distill:distill
The session runs harvest → prepare, works through the generalize/categorize/
deduplicate/triage stages under the runbook, and writes the distilled ai/ in
the run's worktree. It then shows you the branch diff (git -C <worktree> diff) — the proposal. You review it interactively (approve, or ask for edits
the agent makes in the worktree and re-gates), and on approval it runs
ai-distill apply, which merges the branch into your live ai/ and cleans up
the worktree. The run is complete when that merge lands; the next setup run
(or ai-sync) distributes it to every tool.
You can also drive the deterministic parts directly — ai-harvest, then
ai-distill prepare / gate / apply (and discard, add-target) — for
inspection or scripting; the plugin is only needed for the inference stages in
the middle.
Each run lives in its own directory under ~/.local/state/ai/distill/, named
run-<timestamp>, with a latest symlink to the newest. Everything the run
produces is there, mode 0700, and nothing about a run leaves your machine.
What you will find, and what it is for:
| Path | What it is |
|---|---|
worktree-workspace/ |
A git worktree of your workspace repo on branch distill/<run>, off the live path. The session writes the distilled ai/ here. This is what you review, as a git diff, and edit if you want changes. |
report.md |
The run digest: counts, the items the sanitizer flagged for a look (hidden/encoded content, look-alike characters), what it mechanically excluded, and — appended by the session — where each item was routed (quarantined, suggested to a repo, discarded, promoted). It is a short index, not the content; the content is the git diff. |
quarantine/ |
Items the session judged possible injection attempts, held here outside every repo. Nothing is applied from here unless you decide an item is a false alarm and ask for it. |
items.json |
The sanitized input the session reasoned over. It is the one large file, and it is pruned once the run is applied (it can be rebuilt from the harvest catalog); everything else is kept as a compact record. |
denylist.json, targets.json |
Small state: the identifiers to redact, and the worktrees/branches this run owns. |
To review, edit, approve, and apply a run (the /distill:distill session walks you
through this, but you can do it by hand):
- Read the diff.
git -C ~/.local/state/ai/distill/latest/worktree-workspace diffshows exactly what the run proposes for yourai/: additions, edits, and removals across files. Skimreport.mdfor the quarantined and flagged items, which need a closer look. - Edit if you want changes. Edit the files in that worktree directly, or
ask the session to. The live
ai/is untouched while you do. - Gate.
ai-distill gate <run>re-checks the change: no secrets, no executable configuration, no leaked project identifiers, onlyai/prose files. Fix anything it flags. - Apply, or discard. When you approve,
ai-distill apply <run>merges the branch into your liveai/, removes the worktree, and prunesitems.json; the change is then a normal commit in your workspace repo's history, and the nextsetup/ai-syncdistributes it. To reject the whole run instead,ai-distill discard <run>removes the worktree and branch, changing nothing.
The design guarantees you are the gate: the session edits only the worktree, so
nothing reaches your live ai/ until you approve and apply merges the branch
you reviewed. See docs/ai-pipeline-threat-model.md
§7 for the full rationale.
setup.sh Entry point — installs prerequisites, hands off to ansible
setup.yml Ansible orchestrator — imports tasks/01..09 sequentially
hosts.yml Localhost-only inventory
tasks/ Per-stage task files (01-discover ... 09-ai-config)
files/ Static files deployed verbatim by stages — includes the
agent-map.json manifest and the ai-sync / ai-harvest /
ai-distill helper scripts
keys/ Manually-triggered utility scripts (rotate-github-pat,
rotate-vault-password, adopt-ssh-keys)
docs/ Design docs (ai-pipeline-threat-model.md)
.claude-plugin/ Marketplace manifest — this repo is a Claude Code marketplace
plugins/distill/ The distill agent plugin (command, skill, agents, hooks)
tests/ Fixture tests for ai-harvest and ai-distill
legacy/ Earlier (2018) version of this repo, kept for reference
The keys/ directory contains scripts that are not part of the unattended
bootstrap path. They're for one-off operations like rotating a credential.
Each script is self-documenting via --help.
The bootstrap is non-destructive. It will not overwrite SSH keys whose
fingerprints differ from what's in Pass, will not git reset --hard over
local changes, and will halt-and-report on any unexpected divergence rather
than silently fixing it. Re-running after fixing a flagged issue picks up
where the previous run left off.