Skip to content

Commit e93383e

Browse files
Korijnclaude
andauthored
Lead with the unquoted command form, and take -- the way every tool does (#39)
keycmd already ran a command written out as separate arguments, but the docs treated it as the exception: every example put the command in quotes, which reads as a requirement rather than as the tool for the one job it is needed for. Quotes are what you reach for when you want a shell — a pipe, an &&, a credential expanded into the command line — and almost nothing else needs one. `keycmd npm install` is the form the docs now lead with, on the landing pages, in the quick start, in the examples and in the guide, which is also what the README meant by "prefix any command with keycmd". Writing a command out that way brings `--` with it, since every tool that goes on to run another one takes it. keycmd took it as the first word of the command instead, so `keycmd -- npm install` reached the shell as `-- npm install` and failed on it. `end_of_options` removes the one that ends keycmd's own options, which also makes a command whose first word starts with a dash reachable; any further `--` is the command's own and is passed along untouched. --help gains a usage line and examples covering both forms, since that is where someone looks before the docs. The one place the unquoted form cannot keep its word boundaries is WSL, where wsl.exe strips the quotes off its own command line before the distribution sees them. That is the platform's limit rather than keycmd's, and the WSL guide now says so. Claude-Session: https://claude.ai/code/session_01HFqRvGXvoDW1tiX7dJHoYb Co-authored-by: Claude <noreply@anthropic.com>
1 parent 47bc61a commit e93383e

15 files changed

Lines changed: 226 additions & 61 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ The README is a landing page and nothing more: what it says about behaviour it s
4444

4545
## Architecture
4646

47-
`cli.main` wires the three halves together: `load_conf` produces the configuration, `get_env` turns it into an environment, and `run_cmd`/`run_shell` hand that environment to a shell. `--detect-backend` and `--reset-backend` return before any of it, since neither has a use for a configuration or a command. Errors reach the user through `logs.error`, which exits with status 1 and takes the hint lines that go under the error with it; `logs.vlog` output only appears under `--verbose` and is the first thing to reach for when debugging a configuration.
47+
`cli.main` wires the three halves together: `load_conf` produces the configuration, `get_env` turns it into an environment, and `run_cmd`/`run_shell` hand that environment to a shell. `--detect-backend` and `--reset-backend` return before any of it, since neither has a use for a configuration or a command. The command is an argparse `REMAINDER`, so everything from its first word onwards reaches it verbatim — including options keycmd has of its own — which leaves `end_of_options` to strip the `--` that argparse keeps in place, since only the first one is keycmd's to remove. Errors reach the user through `logs.error`, which exits with status 1 and takes the hint lines that go under the error with it; `logs.vlog` output only appears under `--verbose` and is the first thing to reach for when debugging a configuration.
4848

4949
**`conf.py` — where the configuration comes from.** Later sources win, merged deeply by `merge_conf`: defaults, then `~/.keycmd`, then every `.keycmd` found walking up from the working directory (outermost first), then the first `pyproject.toml` found walking up, whose `[tool.keycmd]` table is used. Both searches cover the same ground, so `load_conf` collects them in a single pass over `walk_up`, which stops at a `.git` directory, at the home folder, and at the root of the file system, so the walk never escapes a repository. `USERPROFILE` is a module attribute so tests can point the user config elsewhere. The merged result is `cast` to `Conf` rather than validated: it is user authored, and `get_env` reports violations as user errors.
5050

@@ -54,7 +54,7 @@ The README is a landing page and nothing more: what it says about behaviour it s
5454

5555
**`wsl.py` — the boundary between WSL and Windows.** WSL users install keycmd on Windows, which leaves it a Windows process with a Windows idea of a shell. `from_wsl` decides whether it was called from a distro — a `wsl.exe`/`wslhost.exe` ancestor decides it, a Windows shell found first decides against it, and a UNC working directory settles the rest — after which `run_shell`/`run_cmd` hand the work to `wsl.exe` rather than to a Windows shell. A UNC working directory also names the distro, which `wsl_argv` passes as `--distribution` so that a second distro does not send the command to the default one. `KEYCMD_WSL` overrides that decision in either direction. Neither side of the boundary inherits the other's environment, so `share_env` lists the exposed variables in `WSLENV`, which is the only thing that crosses.
5656

57-
**`shell.py` — where the platform differences live.** `get_shell` asks shellingham which shell invoked the process and falls back to `$SHELL` or `%COMSPEC%`. `cmd` takes `/C` and keeps the command's arguments separate; every other shell takes `-c` and the single string `join_cmd` builds. One argument is already a command line — the form the README recommends — and is handed over as typed, so the shell interprets it; several arguments are an argv vector, and `quote` protects each so that the shell does not split them into words a second time. `quote` reaches for `shlex` for posix shells and doubles the quote for powershell, which also needs the call operator once its command name ends up quoted. `exec` replaces the process with `execvpe` on posix, but runs a subprocess on Windows, which has no equivalent; `USE_SUBPROCESS` and the `IS_WINDOWS`/`IS_POSIX` flags are module attributes so tests can drive both paths on either platform.
57+
**`shell.py` — where the platform differences live.** `get_shell` asks shellingham which shell invoked the process and falls back to `$SHELL` or `%COMSPEC%`. `cmd` takes `/C` and keeps the command's arguments separate; every other shell takes `-c` and the single string `join_cmd` builds. Several arguments are an argv vector — the form the docs lead with, `keycmd npm install`and `quote` protects each so that the shell does not split them into words a second time; one argument is already a command line, and is handed over as typed, so the shell interprets it. `quote` reaches for `shlex` for posix shells and doubles the quote for powershell, which also needs the call operator once its command name ends up quoted. `exec` replaces the process with `execvpe` on posix, but runs a subprocess on Windows, which has no equivalent; `USE_SUBPROCESS` and the `IS_WINDOWS`/`IS_POSIX` flags are module attributes so tests can drive both paths on either platform.
5858

5959
## Conventions
6060

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,10 @@ Store a credential in your OS keyring, name it in a `.keycmd` file:
2222
OPENAI_API_KEY = { credential = "my-openai-token", username = "your-username" }
2323
```
2424

25-
...and run anything that needs it:
25+
...and run anything that needs it, by putting `keycmd` in front of the command you were going to run anyway:
2626

2727
```bash
28-
keycmd 'python my_openai_script.py'
28+
keycmd python my_openai_script.py
2929
```
3030

3131
The variable exists inside that command, and nowhere else — no `.env` file, no secret pasted into your terminal, nothing left behind afterwards. 😱 → 😌
@@ -45,7 +45,7 @@ Continue with the [Quick Start tutorial](https://korijn.github.io/keycmd/getting
4545
Everything lives at **[korijn.github.io/keycmd](https://korijn.github.io/keycmd)**:
4646

4747
* [Installation](https://korijn.github.io/keycmd/getting-started/installation/) — globally, under pyenv, or from WSL
48-
* [Running commands](https://korijn.github.io/keycmd/guide/running-commands/)the two invocation forms, quoting, subshells
48+
* [Running commands](https://korijn.github.io/keycmd/guide/running-commands/)prefixing a command, quoting one, subshells
4949
* [Configuration](https://korijn.github.io/keycmd/guide/configuration/) — where it lives, keys, format strings, aliases
5050
* [Keyring backends](https://korijn.github.io/keycmd/guide/keyring-backends/) — third party backends, and keycmd's startup time
5151
* [WSL](https://korijn.github.io/keycmd/guide/wsl/) — reaching the Windows Credential Manager from a distribution

docs/examples/azure-artifacts.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ always-auth=true
9595
Now I can set up my `node_modules` just by calling:
9696

9797
```bash
98-
keycmd 'npm install'
98+
keycmd npm install
9999
```
100100

101101
🚀
@@ -116,4 +116,4 @@ secrets:
116116
environment: PAT_B64
117117
```
118118
119-
When I call `keycmd 'docker compose build'` these two variables are exposed by keycmd and subsequently they are available as [docker compose build secrets](https://docs.docker.com/compose/use-secrets/). 👌
119+
When I call `keycmd docker compose build` these two variables are exposed by keycmd and subsequently they are available as [docker compose build secrets](https://docs.docker.com/compose/use-secrets/). 👌

docs/examples/openai.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,13 @@ OPENAI_API_KEY = { credential = "my-openai-token", username = "your-username" }
2020
Now you can run any OpenAI script by just prefixing your command with `keycmd`:
2121

2222
```bash
23-
keycmd 'python my_openai_script.py'
23+
keycmd python my_openai_script.py
2424
```
2525

2626
Or a Jupyter notebook:
2727

2828
```bash
29-
keycmd 'jupyter notebook'
29+
keycmd jupyter notebook
3030
```
3131

3232
That's all! 🤘 Now you can rest easily, knowing your tokens are safe. 🛌💤

docs/getting-started/quick-start.md

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,17 @@ This says: look up the credential `my-secret` for user `my-username`, and expose
5050

5151
## 3. Run a command
5252

53-
Open a terminal and run a command that prints the secret. That looks different depending on the shell you use:
53+
Open a terminal and put `keycmd` in front of the command you want the secret to reach. That is all there is to it, and it is how you will use keycmd for real:
54+
55+
```bash
56+
keycmd npm install
57+
keycmd docker compose up
58+
keycmd pytest
59+
```
60+
61+
The tools you run read their credentials from the environment themselves, and keycmd is what puts them there.
62+
63+
To check your setup right now, print the secret instead. That is the one thing that *does* need quotes, since a variable written in your command line is expanded by your own shell — before keycmd has set it:
5464

5565
=== "bash / zsh"
5666

@@ -78,11 +88,11 @@ You've successfully set up keycmd! 👏
7888

7989
keycmd read your configuration, looked `my-secret` up in your OS keyring, put the password in the environment as `SECRET`, and handed that environment to your shell along with your command. When the command finished, the variable went with it: your own shell never had it.
8090

81-
Note the quotes in the bash and PowerShell examples. Quoting the whole command as one argument is what lets *your command's shell* expand `$SECRET`, rather than your own shell expanding it before keycmd ever sees it. See [Running commands](../guide/running-commands.md) for the details.
91+
Note the quotes in the bash and PowerShell examples. Quoting the whole command as one argument is what lets *your command's shell* expand `$SECRET`, rather than your own shell expanding it into nothing beforehand. (`cmd` is the exception: it leaves an undefined `%SECRET%` alone, so it survives the trip unquoted.) Commands that read the environment themselves — which is nearly all of them — need none of this. See [Running commands](../guide/running-commands.md) for the details.
8292

8393
## Where to go next
8494

85-
* [Running commands](../guide/running-commands.md)the two ways to invoke keycmd, quoting, and subshells.
95+
* [Running commands](../guide/running-commands.md)prefixing a command, quoting one, and subshells.
8696
* [Configuration](../guide/configuration.md) — where configuration lives, and everything you can put in it.
8797
* [Examples](../examples/openai.md) — an OpenAI API key, and a real world setup where poetry, npm and docker compose share a single Azure DevOps token.
8898
* [Troubleshooting](../guide/troubleshooting.md) — if any of the above did not go as planned, `keycmd --verbose` will tell you why.

docs/guide/keyring-backends.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ keycmd: PYTHON_KEYRING_BACKEND=keyring.backends.SecretService.Keyring already na
6666
If keyring finds no backend it can use, there is nowhere for keycmd to read credentials from, and it says so rather than failing on the first lookup:
6767

6868
```
69-
❯ keycmd 'npm install'
69+
❯ keycmd npm install
7070
keycmd: error: keyring has no backend to read credentials from
7171
keycmd: hint: install one for this platform, or name one you have with PYTHON_KEYRING_BACKEND
7272
keycmd: hint: see https://github.com/jaraco/keyring#third-party-backends

docs/guide/running-commands.md

Lines changed: 46 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,42 +3,74 @@
33
There are two ways to use keycmd:
44

55
```bash
6-
keycmd 'your command' # run one command with the credentials exposed
6+
keycmd your command # run one command with the credentials exposed
77
keycmd --shell # open a subshell with the credentials exposed
88
```
99

1010
The first is the preferred one, since your secrets are only exposed as environment variables for the duration of a single command. The second is less preferable, but can be convenient when you are debugging a process that depends on the credentials you are exposing.
1111

12-
## One command
12+
## Prefix your command
1313

14-
In its most common form, keycmd takes the command to run as a single quoted argument:
14+
Write the command the way you would have written it anyway, and put `keycmd` in front of it:
1515

1616
```bash
17-
keycmd 'npm install'
17+
keycmd npm install
18+
keycmd docker compose up -d
19+
keycmd pytest -k auth --maxfail 1
1820
```
1921

20-
Quoting the whole command as one argument is what lets you use your shell's syntax inside it:
22+
That is the whole idea, and it is all most commands need: the tool you are running reads its credential from the environment itself, and keycmd is what puts it there.
23+
24+
Everything after `keycmd`'s own options belongs to your command, dashes and all. `keycmd pytest --verbose` runs pytest verbosely; it is `keycmd --verbose pytest` that makes *keycmd* verbose.
25+
26+
Each argument is passed on as the word it was, so nothing you typed is split or expanded a second time:
2127

2228
```bash
23-
keycmd 'echo $SECRET | tr a-z A-Z'
29+
# arrives as a single argument, spaces and all
30+
keycmd mytool --message 'hello world'
2431
```
2532

26-
keycmd hands that line to your shell exactly as you typed it, and your shell does the rest — pipes, redirects, variable expansion and all. This matters especially for the credentials themselves: `$SECRET` has to be expanded by the shell keycmd starts, because that is the only shell the variable exists in.
33+
The quotes there are your own shell's, doing their usual job. keycmd re-quotes each argument for the shell it hands the command to, so what `mytool` receives is the argv your shell built.
34+
35+
!!! note "Two limits on Windows"
2736

28-
## Separate arguments
37+
Both are the platform's rather than keycmd's, and no amount of quoting lifts either. `cmd` reaches a command through the Windows command line, which cannot hold a newline and which expands `%VAR%` inside an argument. Windows PowerShell drops an embedded `"` and an empty argument when it calls a native command; `pwsh` (PowerShell 7.3 and up) does not.
2938

30-
You can also write the command out as separate arguments, and then keycmd keeps them separate:
39+
## Ending keycmd's options with `--`
40+
41+
A command whose *first* word starts with a dash would be read as an option of keycmd's. Put `--` in front of it to say that keycmd's own options have ended:
3142

3243
```bash
33-
# arrives as a single argument, spaces and all
34-
keycmd mytool --message 'hello world'
44+
keycmd -- --my-oddly-named-tool
3545
```
3646

37-
Since each argument is passed on as the word it was, your shell's syntax is *not* interpreted a second time in this form. If you want `$SECRET` expanded, either let your own shell expand it, or use the single argument form above.
47+
`--` is also simply a habit worth keeping, since every tool that goes on to run another one takes it:
48+
49+
```bash
50+
keycmd -- npm install
51+
```
52+
53+
Only the `--` that ends keycmd's options is removed; any further `--` is your command's own and is passed along untouched.
54+
55+
## A quoted command line
56+
57+
Written as a single quoted argument, the command is handed to your shell as typed, and the shell interprets it — pipes, redirects, globs, `&&` and variable expansion included:
58+
59+
```bash
60+
keycmd 'echo $SECRET | tr a-z A-Z'
61+
keycmd 'npm ci && npm run build'
62+
```
63+
64+
This matters especially for the credentials themselves. `$SECRET` has to be expanded by the shell keycmd starts, because that is the only shell the variable exists in:
65+
66+
```bash
67+
keycmd echo $SECRET # your own shell expands it, before keycmd sets it — empty
68+
keycmd 'echo $SECRET' # the shell keycmd starts expands it — correct
69+
```
3870

3971
!!! tip "Which form should I use?"
4072

41-
Use the quoted form for anything that needs a shell: pipes, `&&`, redirects, globs, and above all the credentials you came here for. Use separate arguments when you are passing along text that must survive untouched, such as an argument that itself contains `$` or quotes.
73+
Prefix your command as you normally write it, and reach for quotes when you need a shell: pipes, `&&`, redirects, globs, and above all a credential you want expanded into the command line rather than read from the environment.
4274

4375
## A subshell
4476

@@ -52,7 +84,7 @@ Every command you run in it has the credentials available, until you exit it. Ke
5284

5385
## Which shell keycmd uses
5486

55-
keycmd asks [shellingham](https://github.com/sarugaku/shellingham) which shell invoked it, and falls back on `$SHELL` on posix or `%COMSPEC%` on Windows if that fails. In other words, it runs your command in the shell you were already using.
87+
keycmd asks [shellingham](https://github.com/sarugaku/shellingham) which shell invoked it, and falls back on `$SHELL` on posix or `%COMSPEC%` on Windows if that fails. In other words, it runs your command in the shell you were already using — in both forms, which is what lets `keycmd npm install` find the `npm.cmd` on your Windows `PATH`.
5688

5789
On posix, keycmd replaces its own process with the shell (`execvpe`), so it does not sit in the process tree waiting around. Windows has no equivalent, so there keycmd runs the shell as a subprocess and passes its exit code along.
5890

docs/guide/troubleshooting.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ That output answers, in order, the four questions a misbehaving run usually come
2929
* **Which backend answered?** See [keyring backends](keyring-backends.md).
3030
* **What was actually run?** Including the shell, and the exact argument vector handed to it.
3131

32-
The example above uses `cmd.exe`; in bash or PowerShell the command would be quoted as one argument, as in `keycmd -v 'echo $ARTIFACTS_TOKEN_B64'`.
32+
The example above echoes a variable, which is why the command is written out for `cmd.exe` rather than prefixed the usual way; in bash or PowerShell it would be quoted as one argument, as in `keycmd -v 'echo $ARTIFACTS_TOKEN_B64'`. See [running commands](running-commands.md).
3333

3434
## Common problems
3535

@@ -48,7 +48,15 @@ keycmd echo $SECRET # your shell expands $SECRET — before keycmd sets it
4848
keycmd 'echo $SECRET' # the shell keycmd starts expands it — correct
4949
```
5050

51-
Quote the whole command, so that the shell keycmd starts is the one interpreting it. See [running commands](running-commands.md).
51+
Quote the whole command, so that the shell keycmd starts is the one interpreting it. This only comes up when you write the credential into the command line yourself; a tool that reads it from its own environment needs nothing but `keycmd` in front of it. See [running commands](running-commands.md).
52+
53+
### `sh: --: invalid option`, or `--: command not found`
54+
55+
Your keycmd is old enough to pass a leading `--` on to the shell as the first word of the command. Upgrade, or leave the `--` out — `keycmd npm install` works on every version.
56+
57+
### keycmd took my command's `--verbose` (or `--version`, or `-v`)
58+
59+
Only the options *before* your command are keycmd's; everything from the first word of the command onwards is passed on untouched. `keycmd --verbose pytest` makes keycmd verbose, `keycmd pytest --verbose` makes pytest verbose. If the command's own name starts with a dash, put `--` in front of it.
5260

5361
### `keycmd: error: keyring has no backend to read credentials from`
5462

docs/guide/wsl.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ keycmd works out where it was called from by looking at its own process tree and
3030

3131
If you have more than one distribution installed, and you are working somewhere on the distribution's own file system, that working directory also names the distribution, and keycmd passes it to `wsl.exe` as `--distribution`, so your command goes to the distribution you are in rather than the default one.
3232

33+
!!! warning "Quotes do not survive the crossing"
34+
35+
`wsl.exe` strips the quotes from its own command line before the distribution's shell ever sees it, so an argument containing spaces arrives as several words no matter how it is written. `keycmd npm install` is unaffected, and so is anything else without spaces inside an argument; `keycmd mytool --message 'hello world'` is not, and there is nothing keycmd can do about it from the Windows side.
36+
3337
## Your credentials have to be told to cross
3438

3539
Your credentials do not come along by themselves, since neither side of the WSL boundary inherits the other's environment. Only the variables listed in [`WSLENV`](https://devblogs.microsoft.com/commandline/share-environment-vars-between-wsl-and-windows/) make the trip, so keycmd adds the variables from your configuration to it. Anything you had already listed in `WSLENV` yourself is kept.
@@ -39,8 +43,8 @@ Your credentials do not come along by themselves, since neither side of the WSL
3943
Set the `KEYCMD_WSL` environment variable to override the decision in either direction:
4044

4145
```bash
42-
KEYCMD_WSL=0 keycmd 'echo $SECRET' # stay on the windows side
43-
KEYCMD_WSL=1 keycmd 'echo $SECRET' # go through wsl.exe regardless
46+
KEYCMD_WSL=0 keycmd npm install # stay on the windows side
47+
KEYCMD_WSL=1 keycmd npm install # go through wsl.exe regardless
4448
```
4549

4650
`keycmd --verbose` reports which way it went, and what it based that on:

0 commit comments

Comments
 (0)