Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ node_modules/
# Belongs in ~/.config/cli-tools/blog.json, never in the repository.
blog.config.json

# API keys. These belong in ~/.config/cli-tools/credentials.json, 0600, and
# never in a repository — see `cli-tools config`.
credentials.json

# Local environment and credentials, in every form they usually turn up in.
.env
.env.*
Expand Down
48 changes: 45 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ Check what landed, and wire up the pit aliases:
```sh
cli-tools list # * runs from here, ! is shadowed by another copy
cli-tools aliases --install # /blog /free /merge /prs /whois
cli-tools config # API keys: what is set, and where it came from
cli-tools update # git pull, reinstall, relink
```

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

## API keys

`generate-names` needs an OpenAI or Anthropic key. Store one once, and nothing
has to carry it in an environment again:

```sh
cli-tools config set openai # prompts; the value is never echoed
cli-tools config # what is set, and which source is winning
cli-tools config unset openai
```

Keys live in `~/.config/cli-tools/credentials.json`, written `0600` in a `0700`
directory (`$CLI_TOOLS_CREDENTIALS` overrides the path). Nothing prints a whole
key back — `config` shows a masked preview and a length, which is enough to tell
two keys apart and not enough to use one. `--json` is machine-readable and
carries the same masked previews, not the values.

| Key | Variable | Used by |
| --- | --- | --- |
| `openai` | `OPENAI_API_KEY` | `generate-names` |
| `anthropic` | `ANTHROPIC_API_KEY` | `generate-names` |

**The environment wins over the file.** A key exported in your shell or injected
by CI overrides a stored one, so a one-off `OPENAI_API_KEY=… generate-names …`
still behaves. Because that is otherwise invisible — you store a key, and the
old one keeps being used — `cli-tools config` reports the *source* of each key
rather than only whether one exists, and says so explicitly when a stored value
is being shadowed.

A value can be passed inline (`cli-tools config set openai sk-…`) for scripts,
and piped (`… | cli-tools config set openai`) when there is no TTY. Inline is
the worst of the three: it lands in shell history and in `ps`, so the command
warns when you use it interactively.

This is a machine-local credential store, the same kind of thing as
`~/.aws/credentials` — not a `.env`, not something to copy between machines, and
not where a production secret belongs. A secret that a deployed service needs
goes on that service, with your vault as the record.

## Usage

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

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

| Flag | Effect |
| --- | --- |
Expand Down
176 changes: 174 additions & 2 deletions bin/cli-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';

import { parseArgs, UsageError } from '../src/args.ts';
import {
credentialsPath,
keyStates,
keyVariable,
KNOWN_KEYS,
loadStored,
mask,
saveStored,
} from '../src/credentials.ts';
import { isMain } from '../src/is-main.ts';
import {
aliasesPath,
Expand All @@ -36,6 +45,7 @@ const USAGE = `Usage:
cli-tools link [--force]
cli-tools unlink
cli-tools aliases [--install]
cli-tools config [set <key> [value] | unset <key>]
cli-tools <command> [args…]

Commands:
Expand All @@ -44,12 +54,17 @@ Commands:
link Symlink the commands into ~/.local/bin
unlink Remove the symlinks we own
aliases Print the moshcode pit aliases, or write them with --install
config API keys: what is set, where it came from, and how to change it
where Print the checkout this command is running from

Keys (config set <key>):
openai OPENAI_API_KEY generate-names
anthropic ANTHROPIC_API_KEY generate-names

Options:
--force link: take over a symlink owned by another checkout
--install aliases: merge them into ~/.moshcode/aliases.json
--json list/aliases: machine-readable
--json list/aliases/config: machine-readable (config never prints a key)
-h, --help
`;

Expand Down Expand Up @@ -127,6 +142,157 @@ function writeAliases(): number {
return 0;
}

/**
* Read one line without echoing it.
*
* A key typed at a visible prompt ends up in the scrollback of whatever
* terminal, screen share or recording happens to be running, which is most of
* the reason to have this command rather than telling people to edit the file.
* Piped input is read as-is, so `… | cli-tools config set openai` works in a
* script without a TTY.
*/
async function promptSecret(label: string): Promise<string> {
if (!process.stdin.isTTY) {
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk));
return Buffer.concat(chunks).toString('utf8').trim();
}

process.stderr.write(label);
process.stdin.setRawMode(true);
process.stdin.resume();

return new Promise<string>((resolve) => {
let value = '';
const onData = (chunk: Buffer) => {
for (const byte of chunk) {
// Enter, or EOF/interrupt.
if (byte === 0x0d || byte === 0x0a || byte === 0x04) {
finish();
return;
}
if (byte === 0x03) {
process.stderr.write('\n');
process.exit(130);
}
// Backspace / delete.
if (byte === 0x7f || byte === 0x08) {
value = value.slice(0, -1);
continue;
}
value += String.fromCharCode(byte);
}
};
const finish = () => {
process.stdin.off('data', onData);
process.stdin.setRawMode(false);
process.stdin.pause();
process.stderr.write('\n');
resolve(value.trim());
};
process.stdin.on('data', onData);
});
}

async function configCommand(rest: readonly string[], json: boolean): Promise<number> {
const [verb, name, ...more] = rest;

if (!verb) {
const states = keyStates();
if (json) {
process.stdout.write(`${JSON.stringify({ path: credentialsPath(), keys: states }, null, 2)}\n`);
return 0;
}

process.stdout.write(`${credentialsPath()}\n\n`);
for (const state of states) {
const where =
state.source === 'env'
? 'environment (overrides the file)'
: state.source === 'file'
? 'stored'
: 'not set';
process.stdout.write(
` ${state.name.padEnd(10)} ${state.variable.padEnd(18)} ${where}\n` +
(state.preview ? `${' '.repeat(13)}${state.preview}\n` : ''),
);
}

const shadowed = states.filter((state) => state.source === 'env');
if (shadowed.length > 0) {
// The failure this heads off: storing a key, still getting the old one,
// and having nothing on screen explain why.
process.stdout.write(
`\nNote: ${shadowed.map((s) => s.variable).join(', ')} ${shadowed.length === 1 ? 'is' : 'are'} set in your environment,\n` +
'so a stored value would be ignored. Unset the variable to use the stored one.\n',
);
}
if (states.every((state) => state.source === 'unset')) {
process.stdout.write('\nNothing set. Add one with:\n cli-tools config set openai\n');
}
return 0;
}

if (verb !== 'set' && verb !== 'unset') {
process.stderr.write(`config: unknown verb "${verb}" (expected set or unset)\n`);
return 1;
}

if (!name) {
process.stderr.write(`config ${verb}: name a key — ${Object.keys(KNOWN_KEYS).join(', ')}\n`);
return 1;
}

const variable = keyVariable(name);
if (!variable) {
process.stderr.write(
`config: unknown key "${name}". Known keys: ${Object.keys(KNOWN_KEYS).join(', ')}\n`,
);
return 1;
}

const stored = loadStored();

if (verb === 'unset') {
if (!Object.hasOwn(stored, variable)) {
process.stdout.write(`config: ${variable} was not stored — nothing to remove.\n`);
return 0;
}
delete stored[variable];
process.stdout.write(`config: removed ${variable} from ${saveStored(stored)}\n`);
return 0;
}

// An inline value is accepted because scripts need it, but it lands in shell
// history and the process list, so the prompt is the default and this says so.
let value = more.length > 0 ? more.join(' ').trim() : '';
if (!value) {
value = await promptSecret(`${variable}: `);
} else if (process.stdin.isTTY) {
process.stderr.write(
'config: a value on the command line is visible in shell history and `ps`.\n' +
` Prefer \`cli-tools config set ${name}\` and type it at the prompt.\n`,
);
}

if (!value) {
process.stderr.write('config: no value given — nothing stored.\n');
return 1;
}

stored[variable] = value;
const path = saveStored(stored);
process.stdout.write(`config: stored ${variable} (${mask(value)}) in ${path}\n`);

if (process.env[variable]) {
process.stdout.write(
`\nNote: ${variable} is also set in your environment, which wins.\n` +
` Unset it for the stored value to take effect.\n`,
);
}
return 0;
}

export async function run(argv: readonly string[]): Promise<number> {
// The first word is the command, and everything after it belongs to that
// command — parsed here only for our own verbs, and passed through untouched
Expand All @@ -145,7 +311,7 @@ export async function run(argv: readonly string[]): Promise<number> {
// Anything that is not one of ours is one of the commands: pass it straight
// through, arguments and streams untouched, so `cli-tools gh-prs --orgs x`
// behaves exactly as `gh-prs --orgs x` does.
const known = new Set(['list', 'update', 'link', 'unlink', 'aliases', 'where']);
const known = new Set(['list', 'update', 'link', 'unlink', 'aliases', 'config', 'where']);
if (!known.has(command)) {
const match = commands(root).find((entry) => entry.name === command);
if (!match) {
Expand All @@ -172,6 +338,12 @@ export async function run(argv: readonly string[]): Promise<number> {
process.stdout.write(`${root}\n`);
return 0;

// positional, so `--json` is a flag here rather than part of a key's value.
// A value that begins with a dash cannot be passed inline for the same
// reason; type it at the prompt, which is the better habit anyway.
case 'config':
return configCommand(options.positional, options.flags.has('--json'));

case 'list': {
const binDir = join(root, 'bin');
const all = commands(root).map((entry) => ({
Expand Down
20 changes: 16 additions & 4 deletions bin/generate-names.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
*/

import { UsageError, integer, parseArgs } from '../src/args.ts';
import { resolveCredentials } from '../src/credentials.ts';
import { isMain } from '../src/is-main.ts';
import {
DEFAULT_COUNT,
Expand Down Expand Up @@ -37,8 +38,15 @@ Options:
--timeout MS API timeout (default: 60000)
-h, --help show this help

Needs OPENAI_API_KEY or ANTHROPIC_API_KEY. Names go to stdout and nothing
else does, so the output pipes cleanly.
Needs an OpenAI or Anthropic key. Store one once:

cli-tools config set openai # prompts, nothing echoed or logged
cli-tools config # what is set, and where it came from

kept 0600 in ~/.config/cli-tools/credentials.json. OPENAI_API_KEY and
ANTHROPIC_API_KEY still work and take precedence over a stored key.

Names go to stdout and nothing else does, so the output pipes cleanly.
`;

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

// Stored keys first, environment on top — see src/credentials.ts. Shaped
// as an environment record so resolveProvider needs no change.
const credentials = resolveCredentials(process.env);

let provider;
try {
provider = resolveProvider(process.env, values.get('--provider'));
provider = resolveProvider(credentials, values.get('--provider'));
} catch (error) {
// A bad --provider is a typo and a missing key is a setup problem; both
// are the caller's to fix, so report them like any other usage error.
throw new UsageError(error instanceof Error ? error.message : String(error));
}
const model = values.get('--model') ?? DEFAULT_MODELS[provider];
const apiKey = process.env[provider === 'openai' ? 'OPENAI_API_KEY' : 'ANTHROPIC_API_KEY']!;
const apiKey = credentials[provider === 'openai' ? 'OPENAI_API_KEY' : 'ANTHROPIC_API_KEY']!;
const call =
provider === 'openai'
? openaiCaller(apiKey, model, timeout)
Expand Down
Loading
Loading