Configuration
aikeys configuration
aikeys keeps two JSON files in one home directory: config.json, which you and your agents may read, and vault.json, which only the CLI should write.
Setup
aikeys setup # default store: file aikeys setup --store keychain # default store: native keychain aikeys setup --force # overwrite an existing config.json
setup creates the home directory with mode 0700, writes config.json with mode 0600, and creates an empty vault.json (mode 0600) only if one does not already exist. --store must be file or keychain. Running it again without --force fails. --force rewrites config.json with the defaults and leaves the vault and its entries alone. Setup does not ask for a password; the file store takes a password each time a secret is written or read.
Files and paths
| Path | Mode | Contents |
|---|---|---|
~/.apikeys/ | 0700 | Home directory. Override with AIKEYS_HOME. |
~/.apikeys/config.json | 0600 | Default store, vault location, keychain service name, template and agent policy notes |
~/.apikeys/vault.json | 0600 | One entry per secret version: ciphertext for the file store, a pointer for the keychain store |
aikeys where prints the resolved home, config and vault paths. The home directory gets mode 0700 when aikeys creates it. config.json, vault.json and rendered output are always written atomically at 0600: aikeys writes a new 0600 temporary file in the same directory and renames it over the target, so an existing file never keeps a looser mode, and a symlink at the target is replaced rather than followed.
Keychain or file vault
| file | keychain | |
|---|---|---|
| Where the value lives | vault.json, AES-256-GCM ciphertext | macOS Keychain or Linux Secret Service |
| Password | Vault password on every set and every read | None from aikeys; the OS keychain controls access |
| Backend command | None (Node.js crypto) | security on macOS, secret-tool on Linux |
| What vault.json holds | Algorithm, KDF, salt, IV, tag and ciphertext | Store name, keychain account and timestamp only |
| Windows | Supported | Not supported; set fails and tells you to use --store file |
The store is chosen per write: set --store wins, then defaultStore from config.json, then file. A read uses whatever store the entry was written to, so one vault can mix both.
Keychain items are saved under the service name from keychainService (default aikeys) with the account section/key@version. Reads look the entry up in vault.json first, so a keychain item without a matching vault entry is not found.
There is no fallback between stores. If you ask for the keychain store on a system without security or secret-tool, the write fails and nothing is stored. The confirmation line names the store the secret was written to.
Keychain limits
The secret reaches security and secret-tool on stdin, never as a command-line argument. On macOS aikeys drives security -i and sends the value hex-encoded, which limits a keychain secret to roughly 1,900 bytes (less with long names); longer secrets are refused. Every keychain write is read back and compared, and on a mismatch the item is deleted and set fails, which in practice rejects values containing tabs or other non-printable characters. Keychain service and account names may only use letters, numbers, dot, underscore, dash, slash and @, so versions stored in the keychain are limited to those characters. Use the file store for anything outside these limits.
config.json reference
~/.apikeys/config.json (as written by setup)
{
"version": 1,
"defaultStore": "file",
"vaultFile": "vault.json",
"keychainService": "aikeys",
"template": {
"tokenPattern": "{{aikeys:<section>/<key>@<version>}}",
"examples": [
"OPENAI_API_KEY={{aikeys:openai/api_key@prod}}",
"ANTHROPIC_API_KEY={{aikeys:anthropic/api_key}}"
]
},
"agentPolicy": {
"preferRender": true,
"doNotPrintSecretsInLogs": true,
"allowedCommands": [
"aikeys get <section>/<key> [version]",
"aikeys render <template> <output>"
]
}
}| Key | Default | Used by the CLI | Notes |
|---|---|---|---|
version | 1 | No | Format version |
defaultStore | file | Yes | file or keychain. Used by set without --store and by scan --import. |
vaultFile | vault.json | Yes | Relative paths resolve inside the home directory; absolute paths are used as given |
keychainService | aikeys | Yes | Service name for keychain items |
template.tokenPattern | see above | No | Documents the placeholder form for agents and scripts |
template.examples | see above | No | Example template lines |
agentPolicy.* | see below | Notice only | Advisory policy for agents; see Policy options |
The file is plain JSON on purpose, so agents and scripts can read the conventions without running the CLI. Until it exists, set, get, render and scan --import fail with run `aikeys setup` first. where, help and a plain scan work without it.
vault.json reference
~/.apikeys/vault.json
{
"version": 1,
"entries": {
"openai/api_key": {
"versions": {
"prod": {
"store": "file",
"encrypted": {
"alg": "aes-256-gcm",
"kdf": "scrypt",
"salt": "<base64, 16 bytes>",
"iv": "<base64, 12 bytes>",
"tag": "<base64, 16 bytes>",
"ciphertext": "<base64>"
},
"updatedAt": "2026-09-28T12:24:51.206Z"
}
}
},
"github/token": {
"versions": {
"default": {
"store": "keychain",
"account": "github/token@default",
"updatedAt": "2026-09-28T12:30:02.114Z"
}
}
}
}
}| Field | Notes |
|---|---|
entries.<section/key>.versions.<version> | One record per stored version. Writing the same name and version replaces it. |
store | file or keychain. Any other value is refused on read. |
encrypted | File store only. alg must be aes-256-gcm and kdf must be scrypt; salt, IV and tag must be 16, 12 and 16 bytes. |
account | Keychain store only. The keychain account name. |
updatedAt | ISO 8601 time of the last write |
Secret names, versions, store types and timestamps are stored in clear text. Only the values are encrypted. Do not edit this file by hand.
Commands and flags
aikeys setup [--store file|keychain] [--force] aikeys set <section>/<key> [version] [--store file|keychain] aikeys get <section>/<key> [version] aikeys render <template> <output> aikeys scan <dir-tree> [--import] aikeys where aikeys help
| Command | Flags | Behavior |
|---|---|---|
setup | --store file|keychain, --force | Writes config.json and an empty vault.json. See Setup. |
set | --store file|keychain | Stores a secret. Version defaults to default. An empty secret exits 1. put is an alias. |
get | none | Writes the value to stdout. A trailing newline is added only when stdout is a terminal, so $(aikeys get ...) returns the exact value. |
render | none | Replaces every placeholder in the template and writes the output file atomically at 0600. Prints rendered <output>. |
scan | --import | Prints file:line name masked-value for each finding. With --import, also stores each finding. |
where | none | Prints home, config and vault paths. Works before setup. |
help | none | Also -h, --help, or no command |
Flags may appear anywhere after the command. --store takes a value, which must be file or keychain for both setup and set; any other value is an error.
Environment variables
| Variable | Effect |
|---|---|
AIKEYS_HOME | Home directory instead of ~/.apikeys |
AIKEYS_PASSWORD | Vault password for the file store. Used for every password prompt, so no prompt appears. |
AIKEYS_SECRET | Secret value for set. Used instead of stdin or the prompt. |
These are the only variables aikeys reads.
How secrets and passwords are read
Each prompt resolves its input in this order:
| Prompt | 1 | 2 | 3 |
|---|---|---|---|
| Vault password | AIKEYS_PASSWORD | stdin, whenever it is not a terminal | Hidden prompt on stderr |
| Secret (set) | AIKEYS_SECRET | stdin, whenever it is not a terminal | Hidden prompt on stderr |
stdin is read to the end and trailing whitespace, including the final newline, is removed. It can only be read once, so when you pipe the secret into set for the file store, pass the password in AIKEYS_PASSWORD. The secret is read before the password.
The vault password is asked for once per process, so rendering a template with several file-store placeholders prompts once. Every entry has its own salt, and nothing checks that all entries share one password.
empty secret refused or empty vault password refused and stores or prints nothing. This includes empty or closed stdin (for example < /dev/null in a CI job) and Ctrl-D at the prompt. Non-terminal stdin is always used when the variable is not set; it never falls through to a prompt.Template syntax
{{aikeys:section/key@version}}
{{aikeys:section/key}} # version "default"Placeholders can appear anywhere in any text file, any number of times. Text outside placeholders is copied unchanged.
| Rule | Detail |
|---|---|
| No whitespace | A placeholder containing a space or line break is not recognized and is left as written |
| Missing secret | render stops with missing secret: section/key@version and writes no output |
| Output file | Written atomically with mode 0600. An existing output is replaced, so its old mode does not survive, and a failed render leaves the previous file untouched. |
| Literal values | Every placeholder is resolved first, then substituted in one pass. Values are inserted byte for byte, and a placeholder that appears inside a secret value is not expanded. |
| No escaping | There is no way to write a literal placeholder into the output |
Secret names and versions
| Part | Rule |
|---|---|
| section/key | Exactly one slash. Each side uses letters, numbers, dot, underscore or dash. |
| version | Any label without spaces. Defaults to default. |
| Examples | openai/api_key prod, github/token, stripe/secret_key test |
Scanner rules
| Pattern name | Matches |
|---|---|
openai | sk- followed by 20 or more letters, digits, underscores or dashes |
anthropic | sk-ant- followed by 20 or more of the same. These also match openai, so they are reported twice. |
aws_access_key_id | AKIA followed by 16 uppercase letters or digits |
| variable name, lowercased | An assignment such as FOO_API_KEY=value where the name contains API, TOKEN, SECRET or KEY and the value is 16 or more characters |
The scanner skips .git, node_modules and .apikeys directories, symbolic links, files over 2 MiB, files containing a NUL byte, and files ending in .png, .jpg, .jpeg, .gif, .pdf, .zip, .gz, .tar, .ico, .woff or .woff2. Values are masked as the first four and last four characters, or all asterisks when eight characters or fewer.
With --import, each finding is stored as scan/<file>-<name>-<n>@imported in the default store, where the file path is lowercased with other characters replaced by dashes and n numbers the findings in order.
Policy options
The agentPolicy block in config.json is guidance for agents that read the file. The CLI does not enforce it: it cannot tell an agent from a person, and it does not check commands against allowedCommands. The first time it reads a config containing the block, it prints a one-time notice to stderr saying so.
| Key | Default | Meaning |
|---|---|---|
preferRender | true | Generate config files with render rather than pasting values |
doNotPrintSecretsInLogs | true | Keep values out of logs, chat and summaries |
allowedCommands | get, render | The commands an agent should use |
What the CLI does enforce
| Control | Detail |
|---|---|
| Name format | Names outside section/key are refused |
| Empty input | Empty secrets and empty vault passwords exit 1 |
| Store choice | Only file or keychain; no silent fallback between them |
| Keychain writes | Secret passed on stdin, never argv; read back and verified |
| Vault primitives | Only aes-256-gcm with scrypt decrypts; anything else fails closed |
| File modes | 0700 home when created; config, vault and rendered output always written atomically at 0600 |
| Masked scan output | Findings are never printed in full |
To restrict an agent for real, give it a shell without write access to the aikeys home, or keep file-store secrets behind a password the agent does not have.
Exit codes
| Code | Meaning |
|---|---|
| 0 | Success. scan exits 0 whether or not it finds anything. |
| 1 | Any error. The message goes to stderr as aikeys: <message>, and stdout stays empty for a failed get. |