monkeys

Projects and profiles

A project names what it needs once, in a .monkeys file next to the code:

@foo
DATABASE_URL
STRIPE_SECRET_KEY
OPENROUTER_API_KEY

The first line names the profile; the rest are names, and # starts a comment. Commit it. It is the secret half of .env.example, and a project keeps one or the other, since two lists of the same names drift.

In that directory or any below it within the checkout, run takes only the command:

monkeys run ./hello.sh
monkeys run npm run dev

The profile scopes every name. monkeys set STRIPE_SECRET_KEY there stores foo/STRIPE_SECRET_KEY, which is what run reads, and preview with no names gives the file's names from that profile. list stays global and shows the prefixes, so you can see which project each value belongs to.

The profile's name is the project's and its values are yours. Everyone who clones the repository gets the same names, and each of them fills their own keyring, so a .monkeys file can be committed and a keyring never has to be.

A profile never reads from the personal one. Names stored without a profile are yours alone, and a name missing in @foo is missing there even when a bare copy exists, so a project cannot quietly pick up a value meant for another.

Several profiles

A profile line can name several profiles, and a file can hold several blocks:

@test.foo,foo
DATABASE_URL
STRIPE_SECRET_KEY
@foo
SENTRY_DSN

A profile's names are those of every block that lists it, so both profiles here need DATABASE_URL and STRIPE_SECRET_KEY, and foo also needs SENTRY_DSN. The first profile in the file is the one run uses when none is given. A leading @profile picks another declared one, and a prefix that fits only one of them is enough, the way a short git hash is. A profile the file does not declare is refused with the declared ones listed, and a prefix that fits several is refused with those:

monkeys run @foo ./deploy
monkeys set @foo SENTRY_DSN

A value missing in one profile stops only that profile, and only when it is used: foo can be half filled while test.foo runs. doctor reads the whole file and shows every profile with what it has and lacks, in colour on a terminal, and exits non-zero while anything is missing:

monkeys doctor
@test.foo  default
  ✓ DATABASE_URL
  ✓ STRIPE_SECRET_KEY
@foo
  ✓ DATABASE_URL
  ✗ STRIPE_SECRET_KEY
  ✗ SENTRY_DSN

doctor --short says only what is wrong, one line per profile with a problem, and nothing at all when there is none, which is the form to hand a script or an agent:

monkeys doctor --short
missing @foo: STRIPE_SECRET_KEY,SENTRY_DSN

Two profiles of one project usually share most of their values, and the second is filled from the first:

monkeys fill @foo --with @test.foo
filled @foo from @test.foo: STRIPE_SECRET_KEY
kept 1 @foo already had
still missing in @foo: SENTRY_DSN

fill moves only the names the target lacks and never touches a value it already holds, so it is safe to run twice. It names every value it moved, since a production profile filled from test is a decision to see written down, and it exits non-zero while anything is still missing. No value is printed. Inside a project the names are the ones the file lists for the target; elsewhere they are whatever the source holds.

Profile names take letters, digits, _, - and .. A dotted name in the style of a bundle identifier keeps two projects' test apart in one keyring. Two parts, test.foo, is enough for most; a third, test.foo.lee, is for a keyring that holds many projects and collides at two. A single word does for a profile nothing else will collide with.

The personal profile

A name stored outside any project has no prefix and needs no @. That is where a value that belongs to you rather than to a project lives, such as the key a tool you start from anywhere reads:

monkeys set TYPESAFE_API_KEY
monkeys run TYPESAFE_API_KEY claude

Inside a project every command is scoped to that project's profile, so those personal names are out of reach there: monkeys run reads foo/, and monkeys set TYPESAFE_API_KEY would write foo/TYPESAFE_API_KEY. A bare @ means the personal profile. It sets the project file aside, so names are given again, and reaches the unprefixed names without leaving the directory:

monkeys run @ TYPESAFE_API_KEY claude
monkeys set @ TYPESAFE_API_KEY
monkeys preview @ TYPESAFE_API_KEY

It is the same value either way; the @ only says which profile to look in when a file would otherwise decide.

A missing value says where it is missing from, and the set it asks for works from any directory:

monkeys run ./hello.sh
monkeys: STRIPE_SECRET_KEY is not stored yet in @foo
nothing ran. a human has to store it, then try again:
  monkeys set @foo STRIPE_SECRET_KEY

Inside a project, everything after run is the command, and a name the file already lists is refused rather than run as a program:

monkeys run STRIPE_SECRET_KEY ./hello.sh
monkeys: ~/foo/.monkeys already lists STRIPE_SECRET_KEY for @foo
inside a project, run takes only the command: monkeys run <command>

--all there means every name stored under the profile, listed or not.

Sharing profiles

A project's values leave the keyring as one encrypted file, and only that way. pack takes every profile the file declares:

monkeys pack
Passphrase:
Again:
wrote test.foo.monkeys: @test.foo,foo @foo, 5 values

--only says which, and reads the way the file is written: a @profile line opens a block, @a,b opens one for several profiles at once, and the names after it belong to every profile in that block. A block with no names after it goes whole; names before any @ come from the default profile, the first the file mentions. That is how a teammate gets test and never production, or one key on its own:

monkeys pack --only @test.foo
monkeys pack shared --only @test.foo @foo
monkeys pack --only DATABASE_URL
monkeys pack --only @test.foo,foo DATABASE_URL
monkeys pack --only @test.foo DATABASE_URL @foo SENTRY_DSN
wrote test.foo.monkeys: @test.foo, 2 values
wrote shared.monkeys: @test.foo @foo, 5 values
wrote test.foo.monkeys: @test.foo, 1 value
wrote test.foo.monkeys: @test.foo,foo, 2 values
wrote test.foo.monkeys: @test.foo @foo, 2 values

The bundle keeps that shape, block for block, and unpack writes it back as the project file. A leading @profile before --only means that one profile, with the names that follow. The file name goes before --only, which takes the rest of the line. A name a profile does not list is refused rather than left out.

The file takes the first profile's name unless a word after pack names it. It carries each profile's name, the names the project lists for it, and their values, sealed with ChaCha20-Poly1305 under a key scrypt derives from the passphrase. The file is safe to send over whatever you already use; the passphrase goes another way. A pack with a value still missing refuses, since a bundle that fills half a profile is a bug for whoever receives it.

The other side runs unpack anywhere inside the checkout:

monkeys unpack test.foo
Passphrase:
wrote .monkeys: @test.foo,foo @foo, 3 names
stored test.foo/DATABASE_URL, foo/DATABASE_URL, test.foo/STRIPE_SECRET_KEY, foo/STRIPE_SECRET_KEY
stored foo/SENTRY_DSN

The values go into that person's keyring under each bundle's profile, and the profiles and names become a .monkeys file at the root of the git checkout, the way .gitignore sits at the root, so monkeys run ./hello.sh works from any directory in it. Outside a checkout the file goes in the current directory, and a second argument names the directory outright. When a .monkeys file is already there, unpack adds a block at the end for the names the file does not yet list, grouped the way the bundle groups them, and leaves the rest of the file alone.

run looks for the file from the current directory upward, nearest first, and stops at the root of the git checkout, so a file above the checkout is never read. Outside a checkout only the current directory counts, and where no file is found run takes the names on the line, as it does anywhere else.

Both commands read the passphrase from standard input when it is not a terminal, for the rare script that needs to.

A bundle has no place in a repository, and the ignore rule needs two lines, because *.monkeys alone also matches the .monkeys file you do commit:

*.monkeys
!.monkeys

On this page