Skip to content

Commit fb38671

Browse files
ralyodioclaude
andauthored
cli-tools config: store API keys instead of requiring an exported one (#14)
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>
1 parent 171cd2f commit fb38671

9 files changed

Lines changed: 672 additions & 11 deletions

File tree

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ node_modules/
66
# Belongs in ~/.config/cli-tools/blog.json, never in the repository.
77
blog.config.json
88

9+
# API keys. These belong in ~/.config/cli-tools/credentials.json, 0600, and
10+
# never in a repository — see `cli-tools config`.
11+
credentials.json
12+
913
# Local environment and credentials, in every form they usually turn up in.
1014
.env
1115
.env.*

README.md

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ Check what landed, and wire up the pit aliases:
4545
```sh
4646
cli-tools list # * runs from here, ! is shadowed by another copy
4747
cli-tools aliases --install # /blog /free /merge /prs /whois
48+
cli-tools config # API keys: what is set, and where it came from
4849
cli-tools update # git pull, reinstall, relink
4950
```
5051

@@ -90,6 +91,45 @@ pnpm unlink:bin # remove ours
9091
ln -sf ~/scripts/bin/gh-prs-merge ~/.local/bin/gh-prs-merge # and so on
9192
```
9293

94+
## API keys
95+
96+
`generate-names` needs an OpenAI or Anthropic key. Store one once, and nothing
97+
has to carry it in an environment again:
98+
99+
```sh
100+
cli-tools config set openai # prompts; the value is never echoed
101+
cli-tools config # what is set, and which source is winning
102+
cli-tools config unset openai
103+
```
104+
105+
Keys live in `~/.config/cli-tools/credentials.json`, written `0600` in a `0700`
106+
directory (`$CLI_TOOLS_CREDENTIALS` overrides the path). Nothing prints a whole
107+
key back — `config` shows a masked preview and a length, which is enough to tell
108+
two keys apart and not enough to use one. `--json` is machine-readable and
109+
carries the same masked previews, not the values.
110+
111+
| Key | Variable | Used by |
112+
| --- | --- | --- |
113+
| `openai` | `OPENAI_API_KEY` | `generate-names` |
114+
| `anthropic` | `ANTHROPIC_API_KEY` | `generate-names` |
115+
116+
**The environment wins over the file.** A key exported in your shell or injected
117+
by CI overrides a stored one, so a one-off `OPENAI_API_KEY=… generate-names …`
118+
still behaves. Because that is otherwise invisible — you store a key, and the
119+
old one keeps being used — `cli-tools config` reports the *source* of each key
120+
rather than only whether one exists, and says so explicitly when a stored value
121+
is being shadowed.
122+
123+
A value can be passed inline (`cli-tools config set openai sk-…`) for scripts,
124+
and piped (`… | cli-tools config set openai`) when there is no TTY. Inline is
125+
the worst of the three: it lands in shell history and in `ps`, so the command
126+
warns when you use it interactively.
127+
128+
This is a machine-local credential store, the same kind of thing as
129+
`~/.aws/credentials` — not a `.env`, not something to copy between machines, and
130+
not where a production secret belongs. A secret that a deployed service needs
131+
goes on that service, with your vault as the record.
132+
93133
## Usage
94134

95135
### `gh-prs`
@@ -201,9 +241,11 @@ and shuffled. Asking a model for 1,000 names directly repeats itself within a
201241
few hundred, drifts off-brief, and costs far more — and the call count here is
202242
the same whether you ask for 10 names or 10,000.
203243

204-
Needs `OPENAI_API_KEY` or `ANTHROPIC_API_KEY`. Whichever is set is used;
205-
OpenAI wins if both are. Defaults are the cheap tier on each side
206-
(`gpt-4.1-mini` / `claude-haiku-4-5`) and are overridable with `--model`.
244+
Needs a key — `cli-tools config set openai` stores one (see [API
245+
keys](#api-keys)), and `OPENAI_API_KEY` / `ANTHROPIC_API_KEY` still work and
246+
take precedence. Whichever provider has a key is used; OpenAI wins if both do.
247+
Defaults are the cheap tier on each side (`gpt-4.1-mini` / `claude-haiku-4-5`)
248+
and are overridable with `--model`.
207249

208250
| Flag | Effect |
209251
| --- | --- |

bin/cli-tools.ts

Lines changed: 174 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,15 @@ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2020
import { dirname, join } from 'node:path';
2121

2222
import { parseArgs, UsageError } from '../src/args.ts';
23+
import {
24+
credentialsPath,
25+
keyStates,
26+
keyVariable,
27+
KNOWN_KEYS,
28+
loadStored,
29+
mask,
30+
saveStored,
31+
} from '../src/credentials.ts';
2332
import { isMain } from '../src/is-main.ts';
2433
import {
2534
aliasesPath,
@@ -36,6 +45,7 @@ const USAGE = `Usage:
3645
cli-tools link [--force]
3746
cli-tools unlink
3847
cli-tools aliases [--install]
48+
cli-tools config [set <key> [value] | unset <key>]
3949
cli-tools <command> [args…]
4050
4151
Commands:
@@ -44,12 +54,17 @@ Commands:
4454
link Symlink the commands into ~/.local/bin
4555
unlink Remove the symlinks we own
4656
aliases Print the moshcode pit aliases, or write them with --install
57+
config API keys: what is set, where it came from, and how to change it
4758
where Print the checkout this command is running from
4859
60+
Keys (config set <key>):
61+
openai OPENAI_API_KEY generate-names
62+
anthropic ANTHROPIC_API_KEY generate-names
63+
4964
Options:
5065
--force link: take over a symlink owned by another checkout
5166
--install aliases: merge them into ~/.moshcode/aliases.json
52-
--json list/aliases: machine-readable
67+
--json list/aliases/config: machine-readable (config never prints a key)
5368
-h, --help
5469
`;
5570

@@ -127,6 +142,157 @@ function writeAliases(): number {
127142
return 0;
128143
}
129144

145+
/**
146+
* Read one line without echoing it.
147+
*
148+
* A key typed at a visible prompt ends up in the scrollback of whatever
149+
* terminal, screen share or recording happens to be running, which is most of
150+
* the reason to have this command rather than telling people to edit the file.
151+
* Piped input is read as-is, so `… | cli-tools config set openai` works in a
152+
* script without a TTY.
153+
*/
154+
async function promptSecret(label: string): Promise<string> {
155+
if (!process.stdin.isTTY) {
156+
const chunks: Buffer[] = [];
157+
for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk));
158+
return Buffer.concat(chunks).toString('utf8').trim();
159+
}
160+
161+
process.stderr.write(label);
162+
process.stdin.setRawMode(true);
163+
process.stdin.resume();
164+
165+
return new Promise<string>((resolve) => {
166+
let value = '';
167+
const onData = (chunk: Buffer) => {
168+
for (const byte of chunk) {
169+
// Enter, or EOF/interrupt.
170+
if (byte === 0x0d || byte === 0x0a || byte === 0x04) {
171+
finish();
172+
return;
173+
}
174+
if (byte === 0x03) {
175+
process.stderr.write('\n');
176+
process.exit(130);
177+
}
178+
// Backspace / delete.
179+
if (byte === 0x7f || byte === 0x08) {
180+
value = value.slice(0, -1);
181+
continue;
182+
}
183+
value += String.fromCharCode(byte);
184+
}
185+
};
186+
const finish = () => {
187+
process.stdin.off('data', onData);
188+
process.stdin.setRawMode(false);
189+
process.stdin.pause();
190+
process.stderr.write('\n');
191+
resolve(value.trim());
192+
};
193+
process.stdin.on('data', onData);
194+
});
195+
}
196+
197+
async function configCommand(rest: readonly string[], json: boolean): Promise<number> {
198+
const [verb, name, ...more] = rest;
199+
200+
if (!verb) {
201+
const states = keyStates();
202+
if (json) {
203+
process.stdout.write(`${JSON.stringify({ path: credentialsPath(), keys: states }, null, 2)}\n`);
204+
return 0;
205+
}
206+
207+
process.stdout.write(`${credentialsPath()}\n\n`);
208+
for (const state of states) {
209+
const where =
210+
state.source === 'env'
211+
? 'environment (overrides the file)'
212+
: state.source === 'file'
213+
? 'stored'
214+
: 'not set';
215+
process.stdout.write(
216+
` ${state.name.padEnd(10)} ${state.variable.padEnd(18)} ${where}\n` +
217+
(state.preview ? `${' '.repeat(13)}${state.preview}\n` : ''),
218+
);
219+
}
220+
221+
const shadowed = states.filter((state) => state.source === 'env');
222+
if (shadowed.length > 0) {
223+
// The failure this heads off: storing a key, still getting the old one,
224+
// and having nothing on screen explain why.
225+
process.stdout.write(
226+
`\nNote: ${shadowed.map((s) => s.variable).join(', ')} ${shadowed.length === 1 ? 'is' : 'are'} set in your environment,\n` +
227+
'so a stored value would be ignored. Unset the variable to use the stored one.\n',
228+
);
229+
}
230+
if (states.every((state) => state.source === 'unset')) {
231+
process.stdout.write('\nNothing set. Add one with:\n cli-tools config set openai\n');
232+
}
233+
return 0;
234+
}
235+
236+
if (verb !== 'set' && verb !== 'unset') {
237+
process.stderr.write(`config: unknown verb "${verb}" (expected set or unset)\n`);
238+
return 1;
239+
}
240+
241+
if (!name) {
242+
process.stderr.write(`config ${verb}: name a key — ${Object.keys(KNOWN_KEYS).join(', ')}\n`);
243+
return 1;
244+
}
245+
246+
const variable = keyVariable(name);
247+
if (!variable) {
248+
process.stderr.write(
249+
`config: unknown key "${name}". Known keys: ${Object.keys(KNOWN_KEYS).join(', ')}\n`,
250+
);
251+
return 1;
252+
}
253+
254+
const stored = loadStored();
255+
256+
if (verb === 'unset') {
257+
if (!Object.hasOwn(stored, variable)) {
258+
process.stdout.write(`config: ${variable} was not stored — nothing to remove.\n`);
259+
return 0;
260+
}
261+
delete stored[variable];
262+
process.stdout.write(`config: removed ${variable} from ${saveStored(stored)}\n`);
263+
return 0;
264+
}
265+
266+
// An inline value is accepted because scripts need it, but it lands in shell
267+
// history and the process list, so the prompt is the default and this says so.
268+
let value = more.length > 0 ? more.join(' ').trim() : '';
269+
if (!value) {
270+
value = await promptSecret(`${variable}: `);
271+
} else if (process.stdin.isTTY) {
272+
process.stderr.write(
273+
'config: a value on the command line is visible in shell history and `ps`.\n' +
274+
` Prefer \`cli-tools config set ${name}\` and type it at the prompt.\n`,
275+
);
276+
}
277+
278+
if (!value) {
279+
process.stderr.write('config: no value given — nothing stored.\n');
280+
return 1;
281+
}
282+
283+
stored[variable] = value;
284+
const path = saveStored(stored);
285+
process.stdout.write(`config: stored ${variable} (${mask(value)}) in ${path}\n`);
286+
287+
if (process.env[variable]) {
288+
process.stdout.write(
289+
`\nNote: ${variable} is also set in your environment, which wins.\n` +
290+
` Unset it for the stored value to take effect.\n`,
291+
);
292+
}
293+
return 0;
294+
}
295+
130296
export async function run(argv: readonly string[]): Promise<number> {
131297
// The first word is the command, and everything after it belongs to that
132298
// command — parsed here only for our own verbs, and passed through untouched
@@ -145,7 +311,7 @@ export async function run(argv: readonly string[]): Promise<number> {
145311
// Anything that is not one of ours is one of the commands: pass it straight
146312
// through, arguments and streams untouched, so `cli-tools gh-prs --orgs x`
147313
// behaves exactly as `gh-prs --orgs x` does.
148-
const known = new Set(['list', 'update', 'link', 'unlink', 'aliases', 'where']);
314+
const known = new Set(['list', 'update', 'link', 'unlink', 'aliases', 'config', 'where']);
149315
if (!known.has(command)) {
150316
const match = commands(root).find((entry) => entry.name === command);
151317
if (!match) {
@@ -172,6 +338,12 @@ export async function run(argv: readonly string[]): Promise<number> {
172338
process.stdout.write(`${root}\n`);
173339
return 0;
174340

341+
// positional, so `--json` is a flag here rather than part of a key's value.
342+
// A value that begins with a dash cannot be passed inline for the same
343+
// reason; type it at the prompt, which is the better habit anyway.
344+
case 'config':
345+
return configCommand(options.positional, options.flags.has('--json'));
346+
175347
case 'list': {
176348
const binDir = join(root, 'bin');
177349
const all = commands(root).map((entry) => ({

bin/generate-names.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
*/
99

1010
import { UsageError, integer, parseArgs } from '../src/args.ts';
11+
import { resolveCredentials } from '../src/credentials.ts';
1112
import { isMain } from '../src/is-main.ts';
1213
import {
1314
DEFAULT_COUNT,
@@ -37,8 +38,15 @@ Options:
3738
--timeout MS API timeout (default: 60000)
3839
-h, --help show this help
3940
40-
Needs OPENAI_API_KEY or ANTHROPIC_API_KEY. Names go to stdout and nothing
41-
else does, so the output pipes cleanly.
41+
Needs an OpenAI or Anthropic key. Store one once:
42+
43+
cli-tools config set openai # prompts, nothing echoed or logged
44+
cli-tools config # what is set, and where it came from
45+
46+
kept 0600 in ~/.config/cli-tools/credentials.json. OPENAI_API_KEY and
47+
ANTHROPIC_API_KEY still work and take precedence over a stored key.
48+
49+
Names go to stdout and nothing else does, so the output pipes cleanly.
4250
`;
4351

4452
if (isMain(import.meta.url)) {
@@ -70,16 +78,20 @@ if (isMain(import.meta.url)) {
7078
const tld = (values.get('--tld') ?? DEFAULT_TLD).replace(/^\./, '');
7179
if (!/^[a-z]{2,}$/i.test(tld)) throw new UsageError(`--tld must be letters, got "${tld}"`);
7280

81+
// Stored keys first, environment on top — see src/credentials.ts. Shaped
82+
// as an environment record so resolveProvider needs no change.
83+
const credentials = resolveCredentials(process.env);
84+
7385
let provider;
7486
try {
75-
provider = resolveProvider(process.env, values.get('--provider'));
87+
provider = resolveProvider(credentials, values.get('--provider'));
7688
} catch (error) {
7789
// A bad --provider is a typo and a missing key is a setup problem; both
7890
// are the caller's to fix, so report them like any other usage error.
7991
throw new UsageError(error instanceof Error ? error.message : String(error));
8092
}
8193
const model = values.get('--model') ?? DEFAULT_MODELS[provider];
82-
const apiKey = process.env[provider === 'openai' ? 'OPENAI_API_KEY' : 'ANTHROPIC_API_KEY']!;
94+
const apiKey = credentials[provider === 'openai' ? 'OPENAI_API_KEY' : 'ANTHROPIC_API_KEY']!;
8395
const call =
8496
provider === 'openai'
8597
? openaiCaller(apiKey, model, timeout)

0 commit comments

Comments
 (0)