Skip to content

cli-tools config: store API keys instead of requiring an exported one - #14

Merged
ralyodio merged 1 commit into
masterfrom
credentials-config
Aug 19, 2026
Merged

cli-tools config: store API keys instead of requiring an exported one#14
ralyodio merged 1 commit into
masterfrom
credentials-config

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

Adds the setup command for API keys.

generate-names needed OPENAI_API_KEY or ANTHROPIC_API_KEY in the environment, which in practice means writing the key into a shell profile — a plaintext copy in a file that is backed up, synced, and read by every process you start, to configure one command that runs occasionally.

cli-tools config                what is set, and where each key came from
cli-tools config set openai     prompts with echo off
cli-tools config unset openai

Keys land in ~/.config/cli-tools/credentials.json, written 0600 inside a 0700 directory. $CLI_TOOLS_CREDENTIALS overrides the path.

Nothing prints a whole key

config shows a masked preview and a length — enough to tell two keys apart, not enough to use one — and --json carries the same previews rather than the values:

  openai     OPENAI_API_KEY     environment (overrides the file)
             sk-pr…ZyAA (164 chars)
  anthropic  ANTHROPIC_API_KEY  stored
             sk-te…1234 (31 chars)

The environment still wins, and now says so

A one-off KEY=… command and a CI-injected key both have to keep working, so the environment overrides the file. That precedence is invisible exactly when it bites: you store a key, the old one keeps being used, and nothing explains why.

So config reports the source of each key rather than only whether one exists, and states plainly when a stored value is being shadowed:

Note: OPENAI_API_KEY is set in your environment,
so a stored value would be ignored. Unset the variable to use the stored one.

Same lesson as the list fix in #12 — presence is not the useful question, provenance is.

Input paths

How When
prompt (default) interactive; echo off, nothing in scrollback
… | cli-tools config set openai scripts, no TTY
cli-tools config set openai sk-… inline — warns, lands in shell history and ps

What this is not

A machine-local credential store, like ~/.aws/credentials or gh auth. Not a .env: nothing loads it into an environment wholesale, nothing syncs it, and it is not how a key travels between machines. A secret a deployed service needs still belongs on that service.

Checks

167 tests pass (164 + 3 new wiring cases), typecheck clean.

Verified end to end in a sandbox: set / unset / unset-again, 0600 enforced on a file that already existed with a looser mode, masked output, no key in --json, and the error paths for an unknown key and an unknown verb. resolveCredentials returns an environment-shaped record so resolveProvider consumes it unchanged — one-line wiring, covered by a test rather than by a manual run that would have cost an API call.

🤖 Generated with Claude Code

generate-names needed OPENAI_API_KEY or ANTHROPIC_API_KEY in the environment,
which in practice means writing the key into a shell profile — a plaintext copy
in a file that is backed up, synced, and read by every process you start, to
configure one command that runs occasionally.

So there is now a store: ~/.config/cli-tools/credentials.json, written 0600
inside a 0700 directory, with a command to manage it.

  cli-tools config                what is set, and where each key came from
  cli-tools config set openai     prompts with echo off
  cli-tools config unset openai

Nothing prints a whole key. `config` shows a masked preview and a length —
enough to tell two keys apart, not enough to use one — and --json carries the
same previews rather than the values.

The environment still wins over the file, because a one-off `KEY=… command` and
a CI-injected key both have to keep working. That precedence is invisible at the
moment it bites: you store a key, the old one keeps being used, and nothing says
why. So `config` reports the *source* of each key rather than only whether one
exists, and states plainly when a stored value is being shadowed. It is the same
lesson as the `list` fix in #12 — presence is not the useful question, provenance
is.

A value may be piped for scripts, or passed inline; inline warns, because it
lands in shell history and in `ps`.

Deliberately not a .env: nothing loads it into an environment wholesale, nothing
syncs it, and it is not how a key travels between machines. A secret a deployed
service needs still belongs on that service.

resolveCredentials returns an environment-shaped record, so resolveProvider
consumes it unchanged — the wiring is one line, and a test covers it.

167 tests pass (was 164 plus 3 new wiring cases), typecheck clean. Verified end
to end in a sandbox: set/unset/re-set, 0600 on a file that already existed with
a looser mode, masked output, and no key in --json.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread test/credentials.test.ts
});

it('honours an explicit override', () => {
expect(credentialsPath({ CLI_TOOLS_CREDENTIALS: '/tmp/k.json' } as NodeJS.ProcessEnv)).toBe(
Comment thread test/credentials.test.ts

it('honours an explicit override', () => {
expect(credentialsPath({ CLI_TOOLS_CREDENTIALS: '/tmp/k.json' } as NodeJS.ProcessEnv)).toBe(
'/tmp/k.json',
Comment thread test/credentials.test.ts

it('never puts a whole key in the state', async () => {
const env = await sandbox();
saveStored({ OPENAI_API_KEY: 'sk-secret-value-abcdefgh' }, env);
@github-actions

Copy link
Copy Markdown

ThreatCrush Security Scan

6 finding(s)

MEDIUM: 1 | LOW: 5

Severity Rule Location
MEDIUM redos-nested-quantifier src/domain-free.ts:56
LOW insecure-temp-file test/blog.test.ts:73
LOW insecure-temp-file test/blog.test.ts:74
LOW insecure-temp-file test/credentials.test.ts:43
LOW insecure-temp-file test/credentials.test.ts:44
LOW secret-generic-api-key test/credentials.test.ts:208

Snippets are redacted; ThreatCrush never prints matched credential material.

@ralyodio
ralyodio merged commit fb38671 into master Aug 19, 2026
5 checks passed
@ralyodio
ralyodio deleted the credentials-config branch August 19, 2026 14:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants