comma-command 0.1.0__tar.gz → 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {comma_command-0.1.0 → comma_command-0.2.0}/.gitignore +3 -0
- {comma_command-0.1.0 → comma_command-0.2.0}/PKG-INFO +47 -4
- {comma_command-0.1.0 → comma_command-0.2.0}/README.md +46 -3
- comma_command-0.2.0/crates/comma-command/Cargo.lock +7 -0
- comma_command-0.2.0/crates/comma-command/Cargo.toml +12 -0
- comma_command-0.2.0/crates/comma-command/LICENSE +21 -0
- comma_command-0.2.0/crates/comma-command/README.md +11 -0
- comma_command-0.2.0/crates/comma-command/src/lib.rs +6 -0
- {comma_command-0.1.0 → comma_command-0.2.0}/pyproject.toml +1 -1
- {comma_command-0.1.0 → comma_command-0.2.0}/src/comma_command/__init__.py +1 -1
- {comma_command-0.1.0 → comma_command-0.2.0}/src/comma_command/cli.py +110 -8
- comma_command-0.2.0/src/comma_command/config.py +211 -0
- {comma_command-0.1.0 → comma_command-0.2.0}/src/comma_command/dispatch.py +26 -1
- {comma_command-0.1.0 → comma_command-0.2.0}/tests/conftest.py +10 -1
- {comma_command-0.1.0 → comma_command-0.2.0}/tests/test_cli.py +5 -2
- {comma_command-0.1.0 → comma_command-0.2.0}/tests/test_dispatch.py +16 -0
- comma_command-0.2.0/tests/test_env_cwd.py +173 -0
- comma_command-0.1.0/src/comma_command/config.py +0 -130
- {comma_command-0.1.0 → comma_command-0.2.0}/.github/workflows/ci.yml +0 -0
- {comma_command-0.1.0 → comma_command-0.2.0}/LICENSE +0 -0
- {comma_command-0.1.0 → comma_command-0.2.0}/src/comma_command/__main__.py +0 -0
- {comma_command-0.1.0 → comma_command-0.2.0}/src/comma_command/paths.py +0 -0
- {comma_command-0.1.0 → comma_command-0.2.0}/tests/test_config.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: comma-command
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Run any configured command from any shell as `name, args` — register once, invoke everywhere.
|
|
5
5
|
Project-URL: Homepage, https://github.com/by3hak/comma-command
|
|
6
6
|
Project-URL: Repository, https://github.com/by3hak/comma-command
|
|
@@ -78,6 +78,11 @@ subcommands, applied to names:
|
|
|
78
78
|
the config.
|
|
79
79
|
3. It calls `os.execvpe("too", ["too", "talk", "gus", "--", "hello", "world"])`.
|
|
80
80
|
|
|
81
|
+
If the shim directory is not on `PATH` — a CI job, a container, a repo where nobody
|
|
82
|
+
ran `comma init` — `comma gus, hello world` reaches the same dispatch path. `comma`
|
|
83
|
+
takes `NAME,` rather than bare `NAME` so that a registered name can never collide
|
|
84
|
+
with the management subcommands (`ls`, `run`, `doctor`, ...).
|
|
85
|
+
|
|
81
86
|
Because the final step *replaces* the process instead of spawning a child, the
|
|
82
87
|
target command inherits the terminal, stdin, signals and job control directly, and
|
|
83
88
|
its exit status reaches your shell unchanged. `comma,` adds no wrapping layer.
|
|
@@ -105,17 +110,38 @@ cmd = ["too", "talk", "gus", "--"]
|
|
|
105
110
|
cmd = "uv run ipython --"
|
|
106
111
|
```
|
|
107
112
|
|
|
113
|
+
This `[commands.<name>]` table is the canonical schema: per-command options such as
|
|
114
|
+
`env` and `cwd` will live in it. The `name = "..."` shorthand is accepted as input
|
|
115
|
+
sugar and is shown here only so you can read someone else's config.
|
|
116
|
+
|
|
108
117
|
Arguments are appended verbatim, so `--` at the end of `cmd` is the usual way to
|
|
109
118
|
keep flags from being swallowed by the target's own option parser.
|
|
110
119
|
|
|
120
|
+
Per-command settings live in the same table:
|
|
121
|
+
|
|
122
|
+
```toml
|
|
123
|
+
[commands.deploy]
|
|
124
|
+
cmd = ["kubectl", "apply", "-f", "k8s/"]
|
|
125
|
+
cwd = "~/src/infra"
|
|
126
|
+
env = { KUBECONFIG = "~/.kube/staging" }
|
|
127
|
+
env_unset = ["HTTP_PROXY"]
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
`env` is **merged into** the inherited environment, so `PATH` and `HOME` keep working,
|
|
131
|
+
and `env_unset` drops inherited variables first — a name may not appear in both.
|
|
132
|
+
`cwd` is `~`-expanded and applied before the target runs. The same options exist on
|
|
133
|
+
the command line: `comma add --env K=V --unset K --cwd DIR NAME -- CMD...`.
|
|
134
|
+
|
|
111
135
|
## Commands
|
|
112
136
|
|
|
113
137
|
| Command | What it does |
|
|
114
138
|
| --- | --- |
|
|
115
|
-
| `comma add NAME -- CMD...` | register a name and create its `NAME,` shim |
|
|
139
|
+
| `comma add [--env K=V] [--unset K] [--cwd DIR] NAME -- CMD...` | register a name and create its `NAME,` shim |
|
|
140
|
+
| `comma edit` | open the config in `$EDITOR`, re-validate it, reconcile the shims |
|
|
116
141
|
| `comma rm NAME` | unregister a name and remove its shim |
|
|
117
142
|
| `comma ls [--json]` | list registered names |
|
|
118
143
|
| `comma run NAME [--] [ARG...]` | run a name without going through the shim |
|
|
144
|
+
| `comma NAME, [ARG...]` | the shell spelling, without the shim on PATH (handy in CI) |
|
|
119
145
|
| `comma which NAME` | show the shim path and the command it runs |
|
|
120
146
|
| `comma init [--write] [--shell S]` | print or install PATH + completion setup |
|
|
121
147
|
| `comma doctor` | check config, PATH, shims and targets |
|
|
@@ -125,10 +151,27 @@ Names must be shell-safe words: letters, digits, `.`, `_`, `+`, `-`. The comma i
|
|
|
125
151
|
added for you, so `comma add gus, -- ...` is rejected rather than silently creating
|
|
126
152
|
`gus,,`.
|
|
127
153
|
|
|
154
|
+
## Design decisions
|
|
155
|
+
|
|
156
|
+
Recorded so they do not get relitigated every release:
|
|
157
|
+
|
|
158
|
+
- **No `command_not_found_handle` fallback.** The hook only fires when a PATH lookup
|
|
159
|
+
fails, so it cannot resolve a name whose `NAME,` file already exists; it is also
|
|
160
|
+
bash-only and fights the distro's own hook. `comma add` instead refuses to
|
|
161
|
+
overwrite a foreign `NAME,` unless you pass `--force`, and CI/scripts use
|
|
162
|
+
`comma NAME, ...`.
|
|
163
|
+
- **`[commands.<name>]` is canonical.** Per-command options (`env`, `cwd`, ...)
|
|
164
|
+
live in that sub-table. The `name = "..."` shorthand stays as input sugar and
|
|
165
|
+
is not documented twice.
|
|
166
|
+
- **No argument completion for target commands.** Command-position completion is
|
|
167
|
+
free because the shims are real files on `PATH`, and `comma`'s own subcommands
|
|
168
|
+
have completion. Completing a target's *arguments* would need a completion
|
|
169
|
+
protocol per target — out of scope for 0.1, and better designed once someone
|
|
170
|
+
actually needs it.
|
|
171
|
+
|
|
128
172
|
## Limits
|
|
129
173
|
|
|
130
|
-
- POSIX first. Windows
|
|
131
|
-
- No per-command `env`/`cwd` yet; use `cmd = ["sh", "-c", "..."]` as an escape hatch.
|
|
174
|
+
- POSIX first. Windows would need a `.cmd` shim layer, which is not implemented yet.
|
|
132
175
|
- The `comma-command` crate on crates.io is a name reservation for a future native
|
|
133
176
|
binary; the Python implementation is the one to install today.
|
|
134
177
|
|
|
@@ -47,6 +47,11 @@ subcommands, applied to names:
|
|
|
47
47
|
the config.
|
|
48
48
|
3. It calls `os.execvpe("too", ["too", "talk", "gus", "--", "hello", "world"])`.
|
|
49
49
|
|
|
50
|
+
If the shim directory is not on `PATH` — a CI job, a container, a repo where nobody
|
|
51
|
+
ran `comma init` — `comma gus, hello world` reaches the same dispatch path. `comma`
|
|
52
|
+
takes `NAME,` rather than bare `NAME` so that a registered name can never collide
|
|
53
|
+
with the management subcommands (`ls`, `run`, `doctor`, ...).
|
|
54
|
+
|
|
50
55
|
Because the final step *replaces* the process instead of spawning a child, the
|
|
51
56
|
target command inherits the terminal, stdin, signals and job control directly, and
|
|
52
57
|
its exit status reaches your shell unchanged. `comma,` adds no wrapping layer.
|
|
@@ -74,17 +79,38 @@ cmd = ["too", "talk", "gus", "--"]
|
|
|
74
79
|
cmd = "uv run ipython --"
|
|
75
80
|
```
|
|
76
81
|
|
|
82
|
+
This `[commands.<name>]` table is the canonical schema: per-command options such as
|
|
83
|
+
`env` and `cwd` will live in it. The `name = "..."` shorthand is accepted as input
|
|
84
|
+
sugar and is shown here only so you can read someone else's config.
|
|
85
|
+
|
|
77
86
|
Arguments are appended verbatim, so `--` at the end of `cmd` is the usual way to
|
|
78
87
|
keep flags from being swallowed by the target's own option parser.
|
|
79
88
|
|
|
89
|
+
Per-command settings live in the same table:
|
|
90
|
+
|
|
91
|
+
```toml
|
|
92
|
+
[commands.deploy]
|
|
93
|
+
cmd = ["kubectl", "apply", "-f", "k8s/"]
|
|
94
|
+
cwd = "~/src/infra"
|
|
95
|
+
env = { KUBECONFIG = "~/.kube/staging" }
|
|
96
|
+
env_unset = ["HTTP_PROXY"]
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
`env` is **merged into** the inherited environment, so `PATH` and `HOME` keep working,
|
|
100
|
+
and `env_unset` drops inherited variables first — a name may not appear in both.
|
|
101
|
+
`cwd` is `~`-expanded and applied before the target runs. The same options exist on
|
|
102
|
+
the command line: `comma add --env K=V --unset K --cwd DIR NAME -- CMD...`.
|
|
103
|
+
|
|
80
104
|
## Commands
|
|
81
105
|
|
|
82
106
|
| Command | What it does |
|
|
83
107
|
| --- | --- |
|
|
84
|
-
| `comma add NAME -- CMD...` | register a name and create its `NAME,` shim |
|
|
108
|
+
| `comma add [--env K=V] [--unset K] [--cwd DIR] NAME -- CMD...` | register a name and create its `NAME,` shim |
|
|
109
|
+
| `comma edit` | open the config in `$EDITOR`, re-validate it, reconcile the shims |
|
|
85
110
|
| `comma rm NAME` | unregister a name and remove its shim |
|
|
86
111
|
| `comma ls [--json]` | list registered names |
|
|
87
112
|
| `comma run NAME [--] [ARG...]` | run a name without going through the shim |
|
|
113
|
+
| `comma NAME, [ARG...]` | the shell spelling, without the shim on PATH (handy in CI) |
|
|
88
114
|
| `comma which NAME` | show the shim path and the command it runs |
|
|
89
115
|
| `comma init [--write] [--shell S]` | print or install PATH + completion setup |
|
|
90
116
|
| `comma doctor` | check config, PATH, shims and targets |
|
|
@@ -94,10 +120,27 @@ Names must be shell-safe words: letters, digits, `.`, `_`, `+`, `-`. The comma i
|
|
|
94
120
|
added for you, so `comma add gus, -- ...` is rejected rather than silently creating
|
|
95
121
|
`gus,,`.
|
|
96
122
|
|
|
123
|
+
## Design decisions
|
|
124
|
+
|
|
125
|
+
Recorded so they do not get relitigated every release:
|
|
126
|
+
|
|
127
|
+
- **No `command_not_found_handle` fallback.** The hook only fires when a PATH lookup
|
|
128
|
+
fails, so it cannot resolve a name whose `NAME,` file already exists; it is also
|
|
129
|
+
bash-only and fights the distro's own hook. `comma add` instead refuses to
|
|
130
|
+
overwrite a foreign `NAME,` unless you pass `--force`, and CI/scripts use
|
|
131
|
+
`comma NAME, ...`.
|
|
132
|
+
- **`[commands.<name>]` is canonical.** Per-command options (`env`, `cwd`, ...)
|
|
133
|
+
live in that sub-table. The `name = "..."` shorthand stays as input sugar and
|
|
134
|
+
is not documented twice.
|
|
135
|
+
- **No argument completion for target commands.** Command-position completion is
|
|
136
|
+
free because the shims are real files on `PATH`, and `comma`'s own subcommands
|
|
137
|
+
have completion. Completing a target's *arguments* would need a completion
|
|
138
|
+
protocol per target — out of scope for 0.1, and better designed once someone
|
|
139
|
+
actually needs it.
|
|
140
|
+
|
|
97
141
|
## Limits
|
|
98
142
|
|
|
99
|
-
- POSIX first. Windows
|
|
100
|
-
- No per-command `env`/`cwd` yet; use `cmd = ["sh", "-c", "..."]` as an escape hatch.
|
|
143
|
+
- POSIX first. Windows would need a `.cmd` shim layer, which is not implemented yet.
|
|
101
144
|
- The `comma-command` crate on crates.io is a name reservation for a future native
|
|
102
145
|
binary; the Python implementation is the one to install today.
|
|
103
146
|
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
[package]
|
|
2
|
+
name = "comma-command"
|
|
3
|
+
version = "0.0.1"
|
|
4
|
+
edition = "2021"
|
|
5
|
+
description = "Name reservation for the comma-command CLI; the native Rust implementation is not available yet."
|
|
6
|
+
readme = "README.md"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
repository = "https://github.com/by3hak/comma-command"
|
|
9
|
+
keywords = ["cli", "shell", "dispatch"]
|
|
10
|
+
|
|
11
|
+
[lib]
|
|
12
|
+
path = "src/lib.rs"
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 hak
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# comma-command (Rust crate placeholder)
|
|
2
|
+
|
|
3
|
+
This crate reserves the `comma-command` name on crates.io for the
|
|
4
|
+
[comma-command project](https://github.com/by3hak/comma-command).
|
|
5
|
+
|
|
6
|
+
The usable implementation is currently the Python CLI (`pip install comma-command`).
|
|
7
|
+
This Rust crate contains no command-line binary or supported API yet; do not add it
|
|
8
|
+
as a dependency expecting functionality. A native Rust implementation may be
|
|
9
|
+
published in a future version.
|
|
10
|
+
|
|
11
|
+
This placeholder is MIT licensed, matching the project repository.
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "comma-command"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "Run any configured command from any shell as `name, args` — register once, invoke everywhere."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -9,7 +9,9 @@ from __future__ import annotations
|
|
|
9
9
|
|
|
10
10
|
import json
|
|
11
11
|
import os
|
|
12
|
+
import shlex
|
|
12
13
|
import shutil
|
|
14
|
+
import subprocess
|
|
13
15
|
import sys
|
|
14
16
|
from pathlib import Path
|
|
15
17
|
|
|
@@ -21,10 +23,13 @@ USAGE = """\
|
|
|
21
23
|
comma — run any configured command as `name, args`
|
|
22
24
|
|
|
23
25
|
usage:
|
|
24
|
-
comma add
|
|
26
|
+
comma add [--env K=V]... [--unset K]... [--cwd DIR] [--force] NAME -- COMMAND...
|
|
27
|
+
register NAME (creates the `NAME,` shim)
|
|
28
|
+
comma edit open the config in $EDITOR, then re-validate
|
|
25
29
|
comma rm NAME unregister NAME
|
|
26
30
|
comma ls [--json] list registered commands
|
|
27
31
|
comma run NAME [--] [ARG...] run NAME without going through the shim
|
|
32
|
+
comma NAME, [ARG...] same as `NAME,`, for shells without the shim on PATH
|
|
28
33
|
comma which NAME show the shim and what NAME runs
|
|
29
34
|
comma init [--write] [--shell S] print or install shell setup (PATH + completion)
|
|
30
35
|
comma doctor check the installation
|
|
@@ -128,12 +133,35 @@ def _init_snippet(shell: str) -> str:
|
|
|
128
133
|
|
|
129
134
|
def _add(args: list[str]) -> int:
|
|
130
135
|
force = False
|
|
136
|
+
env: dict[str, str] = {}
|
|
137
|
+
env_unset: list[str] = []
|
|
138
|
+
cwd: str | None = None
|
|
139
|
+
|
|
131
140
|
while args and args[0].startswith("-") and args[0] != "--":
|
|
132
141
|
flag = args.pop(0)
|
|
133
142
|
if flag in ("-f", "--force"):
|
|
134
143
|
force = True
|
|
144
|
+
elif flag in ("-e", "--env"):
|
|
145
|
+
if not args:
|
|
146
|
+
raise CliError("add: --env needs KEY=VALUE")
|
|
147
|
+
pair = args.pop(0)
|
|
148
|
+
key, sep, value = pair.partition("=")
|
|
149
|
+
if not sep or not key:
|
|
150
|
+
raise CliError(f"add: --env expects KEY=VALUE, got {pair!r}")
|
|
151
|
+
env[key] = value
|
|
152
|
+
elif flag in ("-u", "--unset"):
|
|
153
|
+
if not args:
|
|
154
|
+
raise CliError("add: --unset needs a variable name")
|
|
155
|
+
env_unset.append(args.pop(0))
|
|
156
|
+
elif flag == "--cwd":
|
|
157
|
+
if not args:
|
|
158
|
+
raise CliError("add: --cwd needs a directory")
|
|
159
|
+
cwd = args.pop(0)
|
|
135
160
|
elif flag in ("-h", "--help"):
|
|
136
|
-
print(
|
|
161
|
+
print(
|
|
162
|
+
"usage: comma add [--force] [--env KEY=VALUE]... [--unset KEY]... "
|
|
163
|
+
"[--cwd DIR] NAME -- COMMAND [ARG...]"
|
|
164
|
+
)
|
|
137
165
|
return 0
|
|
138
166
|
else:
|
|
139
167
|
raise CliError(f"add: unknown option {flag}")
|
|
@@ -161,7 +189,13 @@ def _add(args: list[str]) -> int:
|
|
|
161
189
|
)
|
|
162
190
|
|
|
163
191
|
commands = config_mod.load(paths.config_path())
|
|
164
|
-
commands[name] = config_mod.Command(
|
|
192
|
+
commands[name] = config_mod.Command(
|
|
193
|
+
name=name,
|
|
194
|
+
argv=tuple(cmd_argv),
|
|
195
|
+
cwd=cwd,
|
|
196
|
+
env=env,
|
|
197
|
+
env_unset=tuple(env_unset),
|
|
198
|
+
)
|
|
165
199
|
config_mod.save(paths.config_path(), commands)
|
|
166
200
|
_install_shim(name)
|
|
167
201
|
|
|
@@ -194,10 +228,16 @@ def _ls(args: list[str]) -> int:
|
|
|
194
228
|
commands = config_mod.load(paths.config_path())
|
|
195
229
|
|
|
196
230
|
if "--json" in args:
|
|
197
|
-
payload = {
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
231
|
+
payload = {}
|
|
232
|
+
for name, cmd in sorted(commands.items()):
|
|
233
|
+
entry = {"cmd": list(cmd.argv), "shim": str(paths.shim_dir() / f"{name},")}
|
|
234
|
+
if cmd.cwd:
|
|
235
|
+
entry["cwd"] = cmd.cwd
|
|
236
|
+
if cmd.env:
|
|
237
|
+
entry["env"] = dict(cmd.env)
|
|
238
|
+
if cmd.env_unset:
|
|
239
|
+
entry["env_unset"] = list(cmd.env_unset)
|
|
240
|
+
payload[name] = entry
|
|
201
241
|
print(json.dumps(payload, indent=2, ensure_ascii=False))
|
|
202
242
|
return 0
|
|
203
243
|
|
|
@@ -207,7 +247,11 @@ def _ls(args: list[str]) -> int:
|
|
|
207
247
|
|
|
208
248
|
width = max(len(name) for name in commands) + 1
|
|
209
249
|
for name in sorted(commands):
|
|
210
|
-
|
|
250
|
+
line = f"{name + ',':<{width}} {commands[name].display()}"
|
|
251
|
+
modifiers = commands[name].modifiers()
|
|
252
|
+
if modifiers:
|
|
253
|
+
line += " [" + " ".join(modifiers) + "]"
|
|
254
|
+
print(line)
|
|
211
255
|
return 0
|
|
212
256
|
|
|
213
257
|
|
|
@@ -336,6 +380,8 @@ def _doctor(args: list[str]) -> int:
|
|
|
336
380
|
issues.append("shim is not executable")
|
|
337
381
|
if shutil.which(command.argv[0]) is None:
|
|
338
382
|
issues.append(f"target {command.argv[0]!r} is not on PATH")
|
|
383
|
+
if command.cwd and not Path(os.path.expanduser(command.cwd)).is_dir():
|
|
384
|
+
issues.append(f"cwd {command.cwd!r} is not a directory")
|
|
339
385
|
if issues:
|
|
340
386
|
print(f"FAIL {name},: " + "; ".join(issues))
|
|
341
387
|
problems += 1
|
|
@@ -404,6 +450,55 @@ def _completion(args: list[str]) -> int:
|
|
|
404
450
|
return 0
|
|
405
451
|
|
|
406
452
|
|
|
453
|
+
def _reconcile(commands: dict[str, config_mod.Command]) -> None:
|
|
454
|
+
"""Make the shim directory match the config (used after `comma edit`)."""
|
|
455
|
+
paths.shim_dir().mkdir(parents=True, exist_ok=True)
|
|
456
|
+
for name in commands:
|
|
457
|
+
_install_shim(name)
|
|
458
|
+
for shim in paths.shim_dir().iterdir():
|
|
459
|
+
if not shim.name.endswith(",") or shim.name[:-1] in commands:
|
|
460
|
+
continue
|
|
461
|
+
try:
|
|
462
|
+
ours = _is_our_shim(shim)
|
|
463
|
+
except (OSError, CliError):
|
|
464
|
+
ours = False
|
|
465
|
+
if ours:
|
|
466
|
+
shim.unlink()
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _edit(args: list[str]) -> int:
|
|
470
|
+
if args and args[0] in ("-h", "--help"):
|
|
471
|
+
print("usage: comma edit — opens the config in $VISUAL/$EDITOR, then re-validates")
|
|
472
|
+
return 0
|
|
473
|
+
if args:
|
|
474
|
+
raise CliError("edit: takes no arguments; it opens the whole config file")
|
|
475
|
+
|
|
476
|
+
path = paths.config_path()
|
|
477
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
478
|
+
if not path.exists():
|
|
479
|
+
config_mod.save(path, {})
|
|
480
|
+
|
|
481
|
+
editor = os.environ.get("VISUAL") or os.environ.get("EDITOR") or "vi"
|
|
482
|
+
try:
|
|
483
|
+
result = subprocess.run([*shlex.split(editor), str(path)])
|
|
484
|
+
except OSError as exc:
|
|
485
|
+
raise CliError(f"edit: cannot run editor {editor!r}: {exc}") from exc
|
|
486
|
+
if result.returncode != 0:
|
|
487
|
+
return result.returncode
|
|
488
|
+
|
|
489
|
+
try:
|
|
490
|
+
commands = config_mod.load(path)
|
|
491
|
+
except config_mod.ConfigError as exc:
|
|
492
|
+
print(f"comma: {exc}", file=sys.stderr)
|
|
493
|
+
print(f"comma: fix {path} and run `comma edit` again", file=sys.stderr)
|
|
494
|
+
return 1
|
|
495
|
+
|
|
496
|
+
_reconcile(commands)
|
|
497
|
+
count = len(commands)
|
|
498
|
+
print(f"{path}: {count} command{'s' if count != 1 else ''}, shims up to date")
|
|
499
|
+
return 0
|
|
500
|
+
|
|
501
|
+
|
|
407
502
|
_HANDLERS = {
|
|
408
503
|
"add": _add,
|
|
409
504
|
"rm": _rm,
|
|
@@ -414,6 +509,7 @@ _HANDLERS = {
|
|
|
414
509
|
"init": _init,
|
|
415
510
|
"doctor": _doctor,
|
|
416
511
|
"completion": _completion,
|
|
512
|
+
"edit": _edit,
|
|
417
513
|
"_names": _names,
|
|
418
514
|
}
|
|
419
515
|
|
|
@@ -428,6 +524,12 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
428
524
|
if name is not None:
|
|
429
525
|
return dispatch(name, args)
|
|
430
526
|
|
|
527
|
+
# `comma NAME, args...` — the same spelling as the shell form, for machines
|
|
528
|
+
# where the shim directory is not on PATH. Not `comma NAME args`: that would
|
|
529
|
+
# collide with the management subcommands (an alias called `ls` or `run`).
|
|
530
|
+
if args and len(args[0]) > 1 and args[0].endswith(","):
|
|
531
|
+
return dispatch(args[0][:-1], args[1:])
|
|
532
|
+
|
|
431
533
|
if not args or args[0] in ("-h", "--help", "help"):
|
|
432
534
|
sys.stdout.write(USAGE)
|
|
433
535
|
return 0
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Reading and writing ``config.toml``.
|
|
2
|
+
|
|
3
|
+
The canonical schema is a table per command::
|
|
4
|
+
|
|
5
|
+
[commands.gus]
|
|
6
|
+
cmd = ["too", "talk", "gus", "--"]
|
|
7
|
+
cwd = "~/work"
|
|
8
|
+
env = { TOO_PROFILE = "work" }
|
|
9
|
+
env_unset = ["HTTP_PROXY"]
|
|
10
|
+
|
|
11
|
+
``cwd``, ``env`` and ``env_unset`` are optional. ``env`` is merged into the
|
|
12
|
+
inherited environment; ``env_unset`` removes inherited variables first, so a key
|
|
13
|
+
may not appear in both. The shorthand ``[commands] gus = "too talk gus --"`` is
|
|
14
|
+
accepted as input sugar.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import re
|
|
21
|
+
import shlex
|
|
22
|
+
from collections.abc import Mapping
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
try: # Python 3.11+
|
|
27
|
+
import tomllib
|
|
28
|
+
except ModuleNotFoundError: # pragma: no cover - 3.10 only
|
|
29
|
+
import tomli as tomllib # type: ignore[no-redef]
|
|
30
|
+
|
|
31
|
+
#: Names become ``<name>,`` file names, so keep them boring: no slashes, no
|
|
32
|
+
#: leading dash (looks like a flag), no trailing comma (ambiguous on dispatch).
|
|
33
|
+
_NAME_RE = re.compile(r"^[A-Za-z0-9_+][A-Za-z0-9._+-]*$")
|
|
34
|
+
_BARE_KEY_RE = re.compile(r"^[A-Za-z0-9_-]+$")
|
|
35
|
+
_KNOWN_KEYS = frozenset({"cmd", "command", "cwd", "env", "env_unset"})
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ConfigError(Exception):
|
|
39
|
+
"""Raised when the config file exists but cannot be understood."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class Command:
|
|
44
|
+
"""One registered name."""
|
|
45
|
+
|
|
46
|
+
name: str
|
|
47
|
+
argv: tuple[str, ...]
|
|
48
|
+
cwd: str | None = None
|
|
49
|
+
env: Mapping[str, str] = field(default_factory=dict)
|
|
50
|
+
env_unset: tuple[str, ...] = ()
|
|
51
|
+
|
|
52
|
+
def display(self) -> str:
|
|
53
|
+
return " ".join(shlex.quote(part) for part in self.argv)
|
|
54
|
+
|
|
55
|
+
def modifiers(self) -> list[str]:
|
|
56
|
+
"""Human-readable list of the non-default settings, for `comma ls`."""
|
|
57
|
+
out = []
|
|
58
|
+
if self.cwd:
|
|
59
|
+
out.append(f"cwd={self.cwd}")
|
|
60
|
+
if self.env:
|
|
61
|
+
out.append("env=" + ",".join(sorted(self.env)))
|
|
62
|
+
if self.env_unset:
|
|
63
|
+
out.append("unset=" + ",".join(self.env_unset))
|
|
64
|
+
return out
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def validate_name(name: str) -> str:
|
|
68
|
+
if not name:
|
|
69
|
+
raise ConfigError("name cannot be empty")
|
|
70
|
+
if name.endswith(","):
|
|
71
|
+
raise ConfigError(f"invalid name {name!r}: the comma is added for you")
|
|
72
|
+
if name.startswith("-"):
|
|
73
|
+
raise ConfigError(f"invalid name {name!r}: must not start with '-'")
|
|
74
|
+
if name in (".", ".."):
|
|
75
|
+
raise ConfigError(f"invalid name {name!r}")
|
|
76
|
+
if not _NAME_RE.match(name):
|
|
77
|
+
raise ConfigError(
|
|
78
|
+
f"invalid name {name!r}: use letters, digits, '.', '_', '+', '-'"
|
|
79
|
+
)
|
|
80
|
+
return name
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _coerce_env(name: str, raw: object) -> dict[str, str]:
|
|
84
|
+
if not isinstance(raw, dict):
|
|
85
|
+
raise ConfigError(f"[commands.{name}].env must be a table of KEY = \"VALUE\"")
|
|
86
|
+
env: dict[str, str] = {}
|
|
87
|
+
for key, value in raw.items():
|
|
88
|
+
if not key or "=" in key:
|
|
89
|
+
raise ConfigError(f"[commands.{name}].env: invalid variable name {key!r}")
|
|
90
|
+
if not isinstance(value, str):
|
|
91
|
+
raise ConfigError(
|
|
92
|
+
f"[commands.{name}].env.{key} must be a string "
|
|
93
|
+
f"(got {type(value).__name__})"
|
|
94
|
+
)
|
|
95
|
+
env[key] = value
|
|
96
|
+
return env
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _coerce_env_unset(name: str, raw: object) -> tuple[str, ...]:
|
|
100
|
+
if not isinstance(raw, list) or not all(isinstance(item, str) for item in raw):
|
|
101
|
+
raise ConfigError(f"[commands.{name}].env_unset must be a list of variable names")
|
|
102
|
+
for item in raw:
|
|
103
|
+
if not item or "=" in item:
|
|
104
|
+
raise ConfigError(f"[commands.{name}].env_unset: invalid variable name {item!r}")
|
|
105
|
+
return tuple(raw)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _coerce(name: str, value: object) -> Command:
|
|
109
|
+
if isinstance(value, str):
|
|
110
|
+
argv = shlex.split(value)
|
|
111
|
+
cwd = None
|
|
112
|
+
env: dict[str, str] = {}
|
|
113
|
+
env_unset: tuple[str, ...] = ()
|
|
114
|
+
elif isinstance(value, dict):
|
|
115
|
+
unknown = set(value) - _KNOWN_KEYS
|
|
116
|
+
if unknown:
|
|
117
|
+
raise ConfigError(
|
|
118
|
+
f"[commands.{name}]: unknown key(s) {', '.join(sorted(unknown))} "
|
|
119
|
+
f"(known: {', '.join(sorted(_KNOWN_KEYS))})"
|
|
120
|
+
)
|
|
121
|
+
key = "cmd" if "cmd" in value else "command" if "command" in value else None
|
|
122
|
+
if key is None:
|
|
123
|
+
raise ConfigError(f"[commands.{name}] needs a 'cmd' key")
|
|
124
|
+
raw = value[key]
|
|
125
|
+
if isinstance(raw, str):
|
|
126
|
+
argv = shlex.split(raw)
|
|
127
|
+
elif isinstance(raw, list) and all(isinstance(part, str) for part in raw):
|
|
128
|
+
argv = list(raw)
|
|
129
|
+
else:
|
|
130
|
+
raise ConfigError(f"[commands.{name}].cmd must be a string or a list of strings")
|
|
131
|
+
|
|
132
|
+
cwd = value.get("cwd")
|
|
133
|
+
if cwd is not None and (not isinstance(cwd, str) or not cwd):
|
|
134
|
+
raise ConfigError(f"[commands.{name}].cwd must be a non-empty string")
|
|
135
|
+
|
|
136
|
+
env = _coerce_env(name, value["env"]) if "env" in value else {}
|
|
137
|
+
env_unset = (
|
|
138
|
+
_coerce_env_unset(name, value["env_unset"]) if "env_unset" in value else ()
|
|
139
|
+
)
|
|
140
|
+
else:
|
|
141
|
+
raise ConfigError(
|
|
142
|
+
f"[commands.{name}] must be a string or a table with a 'cmd' key"
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
if not argv:
|
|
146
|
+
raise ConfigError(f"command {name!r} is empty")
|
|
147
|
+
|
|
148
|
+
overlap = sorted(set(env) & set(env_unset))
|
|
149
|
+
if overlap:
|
|
150
|
+
raise ConfigError(
|
|
151
|
+
f"[commands.{name}]: {', '.join(overlap)} in both env and env_unset — "
|
|
152
|
+
"pick one, the order would be ambiguous"
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
return Command(name=name, argv=tuple(argv), cwd=cwd, env=env, env_unset=env_unset)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def load(path: Path) -> dict[str, Command]:
|
|
159
|
+
"""Parse ``path``. A missing file is an empty config, not an error."""
|
|
160
|
+
if not path.exists():
|
|
161
|
+
return {}
|
|
162
|
+
try:
|
|
163
|
+
raw = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
164
|
+
except (tomllib.TOMLDecodeError, UnicodeDecodeError) as exc:
|
|
165
|
+
raise ConfigError(f"{path}: invalid TOML: {exc}") from exc
|
|
166
|
+
|
|
167
|
+
table = raw.get("commands", {})
|
|
168
|
+
if not isinstance(table, dict):
|
|
169
|
+
raise ConfigError(f"{path}: 'commands' must be a table")
|
|
170
|
+
|
|
171
|
+
commands: dict[str, Command] = {}
|
|
172
|
+
for name, value in table.items():
|
|
173
|
+
validate_name(name)
|
|
174
|
+
commands[name] = _coerce(name, value)
|
|
175
|
+
return commands
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _toml_key(name: str) -> str:
|
|
179
|
+
return name if _BARE_KEY_RE.match(name) else _toml_string(name)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _toml_string(text: str) -> str:
|
|
183
|
+
# JSON string escapes are a subset of TOML basic-string escapes.
|
|
184
|
+
return json.dumps(text, ensure_ascii=False)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def save(path: Path, commands: dict[str, Command]) -> None:
|
|
188
|
+
lines = [
|
|
189
|
+
"# comma config — manage it with `comma add NAME -- COMMAND...`",
|
|
190
|
+
"# docs: https://github.com/by3hak/comma-command",
|
|
191
|
+
"",
|
|
192
|
+
]
|
|
193
|
+
for name in sorted(commands):
|
|
194
|
+
command = commands[name]
|
|
195
|
+
rendered = ", ".join(_toml_string(part) for part in command.argv)
|
|
196
|
+
lines.append(f"[commands.{_toml_key(name)}]")
|
|
197
|
+
lines.append(f"cmd = [{rendered}]")
|
|
198
|
+
if command.cwd:
|
|
199
|
+
lines.append(f"cwd = {_toml_string(command.cwd)}")
|
|
200
|
+
if command.env:
|
|
201
|
+
pairs = ", ".join(
|
|
202
|
+
f"{_toml_string(key)} = {_toml_string(value)}"
|
|
203
|
+
for key, value in sorted(command.env.items())
|
|
204
|
+
)
|
|
205
|
+
lines.append(f"env = {{ {pairs} }}")
|
|
206
|
+
if command.env_unset:
|
|
207
|
+
keys = ", ".join(_toml_string(key) for key in command.env_unset)
|
|
208
|
+
lines.append(f"env_unset = [{keys}]")
|
|
209
|
+
lines.append("")
|
|
210
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
211
|
+
path.write_text("\n".join(lines), encoding="utf-8")
|
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
Dispatch ends in :func:`os.execvpe`: the process is replaced, so the child
|
|
4
4
|
inherits the terminal, the foreground process group, stdin, and its exit
|
|
5
5
|
status reaches the shell untouched. No wrapper process, no signal proxying.
|
|
6
|
+
|
|
7
|
+
Per-command ``cwd``/``env``/``env_unset`` are applied here, in that order:
|
|
8
|
+
change directory, then build the environment (drop inherited variables, overlay
|
|
9
|
+
the configured ones), then exec.
|
|
6
10
|
"""
|
|
7
11
|
|
|
8
12
|
from __future__ import annotations
|
|
@@ -22,6 +26,15 @@ def invoked_name(prog: str) -> str | None:
|
|
|
22
26
|
return None
|
|
23
27
|
|
|
24
28
|
|
|
29
|
+
def build_env(command: config_mod.Command, base: dict[str, str] | None = None) -> dict[str, str]:
|
|
30
|
+
"""Inherited environment, minus ``env_unset``, plus ``env``."""
|
|
31
|
+
env = dict(os.environ if base is None else base)
|
|
32
|
+
for key in command.env_unset:
|
|
33
|
+
env.pop(key, None)
|
|
34
|
+
env.update(command.env)
|
|
35
|
+
return env
|
|
36
|
+
|
|
37
|
+
|
|
25
38
|
def dispatch(name: str, args: list[str]) -> int:
|
|
26
39
|
"""Replace this process with the command registered under ``name``."""
|
|
27
40
|
try:
|
|
@@ -38,9 +51,21 @@ def dispatch(name: str, args: list[str]) -> int:
|
|
|
38
51
|
print(f" add one: comma add {name} -- your-command --", file=sys.stderr)
|
|
39
52
|
return 127
|
|
40
53
|
|
|
54
|
+
if command.cwd:
|
|
55
|
+
target = os.path.expanduser(command.cwd)
|
|
56
|
+
try:
|
|
57
|
+
os.chdir(target)
|
|
58
|
+
except OSError as exc:
|
|
59
|
+
print(
|
|
60
|
+
f"comma: cannot change to cwd {command.cwd!r} for {name!r}: {exc}",
|
|
61
|
+
file=sys.stderr,
|
|
62
|
+
)
|
|
63
|
+
return 1
|
|
64
|
+
|
|
41
65
|
argv = [*command.argv, *args]
|
|
66
|
+
env = build_env(command)
|
|
42
67
|
try:
|
|
43
|
-
os.execvpe(argv[0], argv,
|
|
68
|
+
os.execvpe(argv[0], argv, env)
|
|
44
69
|
except FileNotFoundError:
|
|
45
70
|
print(f"comma: command not found: {argv[0]!r} (registered as {name!r})", file=sys.stderr)
|
|
46
71
|
return 127
|
|
@@ -11,6 +11,14 @@ ROOT = Path(__file__).resolve().parents[1]
|
|
|
11
11
|
SRC = ROOT / "src"
|
|
12
12
|
|
|
13
13
|
|
|
14
|
+
#: Only these inherited variables reach a test subprocess. Anything else — API
|
|
15
|
+
#: keys, tokens, cloud credentials — must not leak into a test environment, a
|
|
16
|
+
#: child process, or a pytest assertion dump.
|
|
17
|
+
SAFE_INHERIT = frozenset(
|
|
18
|
+
{"PATH", "LANG", "LC_ALL", "LC_CTYPE", "TMPDIR", "TERM", "USER", "SYSTEMROOT"}
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
14
22
|
@pytest.fixture
|
|
15
23
|
def env(tmp_path: Path) -> dict[str, str]:
|
|
16
24
|
"""A hermetic environment: throwaway HOME, XDG dirs, src on PYTHONPATH."""
|
|
@@ -19,8 +27,9 @@ def env(tmp_path: Path) -> dict[str, str]:
|
|
|
19
27
|
pythonpath = str(SRC)
|
|
20
28
|
if os.environ.get("PYTHONPATH"):
|
|
21
29
|
pythonpath += os.pathsep + os.environ["PYTHONPATH"]
|
|
30
|
+
inherited = {k: v for k, v in os.environ.items() if k in SAFE_INHERIT}
|
|
22
31
|
return {
|
|
23
|
-
**
|
|
32
|
+
**inherited,
|
|
24
33
|
"HOME": str(home),
|
|
25
34
|
"XDG_CONFIG_HOME": str(tmp_path / "config"),
|
|
26
35
|
"XDG_DATA_HOME": str(tmp_path / "data"),
|
|
@@ -116,7 +116,10 @@ def test_completion_scripts(comma, env) -> None:
|
|
|
116
116
|
|
|
117
117
|
|
|
118
118
|
def test_version_and_help(comma, env) -> None:
|
|
119
|
-
|
|
119
|
+
from comma_command import __version__
|
|
120
|
+
|
|
121
|
+
assert f"comma {__version__}" in comma("--version").stdout
|
|
120
122
|
usage = comma("--help").stdout
|
|
121
|
-
assert "comma add NAME" in usage
|
|
123
|
+
assert "comma add" in usage and "NAME -- COMMAND" in usage
|
|
124
|
+
assert "comma edit" in usage
|
|
122
125
|
assert comma("bogus").returncode == 2
|
|
@@ -88,3 +88,19 @@ def test_broken_config_is_reported(comma, env, launcher, echoargs) -> None:
|
|
|
88
88
|
|
|
89
89
|
assert result.returncode == 1
|
|
90
90
|
assert "invalid TOML" in result.stderr
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_name_comma_sugar_without_shim(comma, env, launcher, echoargs) -> None:
|
|
94
|
+
comma("add", "gus", "--", str(echoargs), "--")
|
|
95
|
+
|
|
96
|
+
result = comma("gus,", "hello", "world")
|
|
97
|
+
|
|
98
|
+
assert result.returncode == 0
|
|
99
|
+
assert result.stdout.splitlines() == ["--", "hello", "world"]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_name_comma_sugar_unknown_name(comma, env, launcher) -> None:
|
|
103
|
+
result = comma("ghost,", "hi")
|
|
104
|
+
|
|
105
|
+
assert result.returncode == 127
|
|
106
|
+
assert "no command named 'ghost'" in result.stderr
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Per-command ``cwd`` / ``env`` / ``env_unset`` and ``comma edit``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from conftest import activate, run_shim, shim_dir
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def write_config(env: dict[str, str], body: str) -> Path:
|
|
13
|
+
path = Path(env["XDG_CONFIG_HOME"]) / "comma" / "config.toml"
|
|
14
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
15
|
+
path.write_text(body, encoding="utf-8")
|
|
16
|
+
return path
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def make_shim(env: dict[str, str], launcher: Path, name: str) -> Path:
|
|
20
|
+
shim = shim_dir(env) / f"{name},"
|
|
21
|
+
shim.parent.mkdir(parents=True, exist_ok=True)
|
|
22
|
+
shim.symlink_to(launcher)
|
|
23
|
+
return shim
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_add_records_env_and_cwd(comma, env, launcher, tmp_path) -> None:
|
|
27
|
+
work = tmp_path / "work"
|
|
28
|
+
work.mkdir()
|
|
29
|
+
|
|
30
|
+
result = comma(
|
|
31
|
+
"add", "--env", "PROFILE=work", "--env", "X=1", "--cwd", str(work),
|
|
32
|
+
"gus", "--", "/bin/echo", "hi",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
assert result.returncode == 0, result.stderr
|
|
36
|
+
text = (Path(env["XDG_CONFIG_HOME"]) / "comma" / "config.toml").read_text()
|
|
37
|
+
assert "cwd = " in text
|
|
38
|
+
assert "env = { " in text
|
|
39
|
+
|
|
40
|
+
payload = json.loads(comma("ls", "--json").stdout)
|
|
41
|
+
assert payload["gus"]["env"] == {"PROFILE": "work", "X": "1"}
|
|
42
|
+
assert payload["gus"]["cwd"] == str(work)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_ls_text_shows_modifiers(comma, env, launcher, tmp_path) -> None:
|
|
46
|
+
work = tmp_path / "work"
|
|
47
|
+
work.mkdir()
|
|
48
|
+
comma("add", "--env", "A=1", "--cwd", str(work), "gus", "--", "/bin/echo", "hi")
|
|
49
|
+
|
|
50
|
+
listing = comma("ls").stdout
|
|
51
|
+
|
|
52
|
+
assert "cwd=" in listing
|
|
53
|
+
assert "env=A" in listing
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_env_overlays_inherited_environment(comma, env, launcher) -> None:
|
|
57
|
+
comma(
|
|
58
|
+
"add", "--env", "A=from-config", "envtest", "--",
|
|
59
|
+
"/bin/sh", "-c", 'printf "%s\\n" "$A" "$B"',
|
|
60
|
+
)
|
|
61
|
+
activate(env)
|
|
62
|
+
env["B"] = "inherited"
|
|
63
|
+
|
|
64
|
+
result = run_shim(env, "envtest,")
|
|
65
|
+
|
|
66
|
+
assert result.returncode == 0, result.stderr
|
|
67
|
+
assert result.stdout.splitlines() == ["from-config", "inherited"]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_env_unset_removes_inherited_variable(comma, env, launcher) -> None:
|
|
71
|
+
write_config(
|
|
72
|
+
env,
|
|
73
|
+
'[commands.check]\n'
|
|
74
|
+
'cmd = ["/bin/sh", "-c", \'printf "%s\\n" "${TOKEN:-absent}"\']\n'
|
|
75
|
+
'env_unset = ["TOKEN"]\n',
|
|
76
|
+
)
|
|
77
|
+
make_shim(env, launcher, "check")
|
|
78
|
+
activate(env)
|
|
79
|
+
env["TOKEN"] = "inherited-value"
|
|
80
|
+
|
|
81
|
+
result = run_shim(env, "check,")
|
|
82
|
+
|
|
83
|
+
assert result.stdout.splitlines() == ["absent"]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_cwd_is_applied(comma, env, launcher, tmp_path) -> None:
|
|
87
|
+
work = tmp_path / "proj"
|
|
88
|
+
work.mkdir()
|
|
89
|
+
comma("add", "--cwd", str(work), "here", "--", "/bin/sh", "-c", "pwd")
|
|
90
|
+
activate(env)
|
|
91
|
+
|
|
92
|
+
result = run_shim(env, "here,")
|
|
93
|
+
|
|
94
|
+
assert os.path.realpath(result.stdout.strip()) == os.path.realpath(work)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_missing_cwd_fails_cleanly(comma, env, launcher) -> None:
|
|
98
|
+
comma("add", "--cwd", "/nope/nowhere", "gone", "--", "/bin/echo", "hi")
|
|
99
|
+
activate(env)
|
|
100
|
+
|
|
101
|
+
result = run_shim(env, "gone,")
|
|
102
|
+
|
|
103
|
+
assert result.returncode == 1
|
|
104
|
+
assert "cannot change to cwd" in result.stderr
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_env_and_env_unset_overlap_is_rejected(comma, env, launcher) -> None:
|
|
108
|
+
write_config(
|
|
109
|
+
env,
|
|
110
|
+
'[commands.bad]\ncmd = ["/bin/echo", "hi"]\nenv = { A = "1" }\nenv_unset = ["A"]\n',
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
result = comma("ls")
|
|
114
|
+
|
|
115
|
+
assert result.returncode == 1
|
|
116
|
+
assert "both env and env_unset" in result.stderr
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_unknown_key_is_rejected(comma, env, launcher) -> None:
|
|
120
|
+
write_config(env, '[commands.typo]\ncmd = ["/bin/echo", "hi"]\nevn = { A = "1" }\n')
|
|
121
|
+
|
|
122
|
+
result = comma("ls")
|
|
123
|
+
|
|
124
|
+
assert result.returncode == 1
|
|
125
|
+
assert "unknown key" in result.stderr
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _editor(tmp_path: Path, body: str) -> str:
|
|
129
|
+
script = tmp_path / "fake-editor.py"
|
|
130
|
+
script.write_text("#!/usr/bin/env python3\nimport sys\n" + body, encoding="utf-8")
|
|
131
|
+
script.chmod(0o755)
|
|
132
|
+
return str(script)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_edit_reconciles_shims(comma, env, launcher, tmp_path) -> None:
|
|
136
|
+
editor = _editor(
|
|
137
|
+
tmp_path,
|
|
138
|
+
"open(sys.argv[1], 'a').write('\\n[commands.ed]\\ncmd = [\"/bin/echo\", \"edited\"]\\n')\n",
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
result = comma("edit", env_overrides={"EDITOR": editor})
|
|
142
|
+
|
|
143
|
+
assert result.returncode == 0, result.stderr
|
|
144
|
+
assert (shim_dir(env) / "ed,").is_symlink()
|
|
145
|
+
activate(env)
|
|
146
|
+
assert run_shim(env, "ed,").stdout.strip() == "edited"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_edit_removes_orphan_shims(comma, env, launcher, tmp_path) -> None:
|
|
150
|
+
make_shim(env, launcher, "orphan")
|
|
151
|
+
editor = _editor(tmp_path, "pass\n")
|
|
152
|
+
|
|
153
|
+
result = comma("edit", env_overrides={"EDITOR": editor})
|
|
154
|
+
|
|
155
|
+
assert result.returncode == 0, result.stderr
|
|
156
|
+
assert not (shim_dir(env) / "orphan,").exists()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def test_edit_reports_broken_config(comma, env, launcher, tmp_path) -> None:
|
|
160
|
+
editor = _editor(tmp_path, "open(sys.argv[1], 'a').write('\\n[commands.broken\\n')\n")
|
|
161
|
+
|
|
162
|
+
result = comma("edit", env_overrides={"EDITOR": editor})
|
|
163
|
+
|
|
164
|
+
assert result.returncode == 1
|
|
165
|
+
assert "invalid TOML" in result.stderr
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def test_add_unset_flag(comma, env, launcher) -> None:
|
|
169
|
+
comma("add", "--unset", "TOKEN", "clean", "--", "/bin/echo", "hi")
|
|
170
|
+
|
|
171
|
+
payload = json.loads(comma("ls", "--json").stdout)
|
|
172
|
+
|
|
173
|
+
assert payload["clean"]["env_unset"] == ["TOKEN"]
|
|
@@ -1,130 +0,0 @@
|
|
|
1
|
-
"""Reading and writing ``config.toml``.
|
|
2
|
-
|
|
3
|
-
Two ways to declare a command, both equivalent::
|
|
4
|
-
|
|
5
|
-
[commands.gus]
|
|
6
|
-
cmd = ["too", "talk", "gus", "--"]
|
|
7
|
-
|
|
8
|
-
[commands]
|
|
9
|
-
gus = "too talk gus --"
|
|
10
|
-
|
|
11
|
-
The list form is the canonical one: it survives arguments containing spaces
|
|
12
|
-
without quoting games.
|
|
13
|
-
"""
|
|
14
|
-
|
|
15
|
-
from __future__ import annotations
|
|
16
|
-
|
|
17
|
-
import json
|
|
18
|
-
import re
|
|
19
|
-
import shlex
|
|
20
|
-
from dataclasses import dataclass
|
|
21
|
-
from pathlib import Path
|
|
22
|
-
|
|
23
|
-
try: # Python 3.11+
|
|
24
|
-
import tomllib
|
|
25
|
-
except ModuleNotFoundError: # pragma: no cover - 3.10 only
|
|
26
|
-
import tomli as tomllib # type: ignore[no-redef]
|
|
27
|
-
|
|
28
|
-
#: Names become ``<name>,`` file names, so keep them boring: no slashes, no
|
|
29
|
-
#: leading dash (looks like a flag), no trailing comma (ambiguous on dispatch).
|
|
30
|
-
_NAME_RE = re.compile(r"^[A-Za-z0-9_+][A-Za-z0-9._+-]*$")
|
|
31
|
-
_BARE_KEY_RE = re.compile(r"^[A-Za-z0-9_-]+$")
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
class ConfigError(Exception):
|
|
35
|
-
"""Raised when the config file exists but cannot be understood."""
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
@dataclass(frozen=True)
|
|
39
|
-
class Command:
|
|
40
|
-
"""One registered name."""
|
|
41
|
-
|
|
42
|
-
name: str
|
|
43
|
-
argv: tuple[str, ...]
|
|
44
|
-
|
|
45
|
-
def display(self) -> str:
|
|
46
|
-
return " ".join(shlex.quote(part) for part in self.argv)
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
def validate_name(name: str) -> str:
|
|
50
|
-
if not name:
|
|
51
|
-
raise ConfigError("name cannot be empty")
|
|
52
|
-
if name.endswith(","):
|
|
53
|
-
raise ConfigError(f"invalid name {name!r}: the comma is added for you")
|
|
54
|
-
if name.startswith("-"):
|
|
55
|
-
raise ConfigError(f"invalid name {name!r}: must not start with '-'")
|
|
56
|
-
if name in (".", ".."):
|
|
57
|
-
raise ConfigError(f"invalid name {name!r}")
|
|
58
|
-
if not _NAME_RE.match(name):
|
|
59
|
-
raise ConfigError(
|
|
60
|
-
f"invalid name {name!r}: use letters, digits, '.', '_', '+', '-'"
|
|
61
|
-
)
|
|
62
|
-
return name
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
def _coerce(name: str, value: object) -> tuple[str, ...]:
|
|
66
|
-
if isinstance(value, str):
|
|
67
|
-
argv = shlex.split(value)
|
|
68
|
-
elif isinstance(value, dict):
|
|
69
|
-
key = "cmd" if "cmd" in value else "command" if "command" in value else None
|
|
70
|
-
if key is None:
|
|
71
|
-
raise ConfigError(f"[commands.{name}] needs a 'cmd' key")
|
|
72
|
-
raw = value[key]
|
|
73
|
-
if isinstance(raw, str):
|
|
74
|
-
argv = shlex.split(raw)
|
|
75
|
-
elif isinstance(raw, list) and all(isinstance(part, str) for part in raw):
|
|
76
|
-
argv = list(raw)
|
|
77
|
-
else:
|
|
78
|
-
raise ConfigError(f"[commands.{name}].cmd must be a string or a list of strings")
|
|
79
|
-
else:
|
|
80
|
-
raise ConfigError(
|
|
81
|
-
f"[commands.{name}] must be a string or a table with a 'cmd' key"
|
|
82
|
-
)
|
|
83
|
-
if not argv:
|
|
84
|
-
raise ConfigError(f"command {name!r} is empty")
|
|
85
|
-
return tuple(argv)
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
def load(path: Path) -> dict[str, Command]:
|
|
89
|
-
"""Parse ``path``. A missing file is an empty config, not an error."""
|
|
90
|
-
if not path.exists():
|
|
91
|
-
return {}
|
|
92
|
-
try:
|
|
93
|
-
raw = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
94
|
-
except (tomllib.TOMLDecodeError, UnicodeDecodeError) as exc:
|
|
95
|
-
raise ConfigError(f"{path}: invalid TOML: {exc}") from exc
|
|
96
|
-
|
|
97
|
-
table = raw.get("commands", {})
|
|
98
|
-
if not isinstance(table, dict):
|
|
99
|
-
raise ConfigError(f"{path}: 'commands' must be a table")
|
|
100
|
-
|
|
101
|
-
commands: dict[str, Command] = {}
|
|
102
|
-
for name, value in table.items():
|
|
103
|
-
validate_name(name)
|
|
104
|
-
commands[name] = Command(name=name, argv=_coerce(name, value))
|
|
105
|
-
return commands
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
def _toml_key(name: str) -> str:
|
|
109
|
-
return name if _BARE_KEY_RE.match(name) else json.dumps(name)
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
def _toml_string(text: str) -> str:
|
|
113
|
-
# JSON string escapes are a subset of TOML basic-string escapes.
|
|
114
|
-
return json.dumps(text, ensure_ascii=False)
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
def save(path: Path, commands: dict[str, Command]) -> None:
|
|
118
|
-
lines = [
|
|
119
|
-
"# comma config — manage it with `comma add NAME -- COMMAND...`",
|
|
120
|
-
"# docs: https://github.com/by3hak/comma-command",
|
|
121
|
-
"",
|
|
122
|
-
]
|
|
123
|
-
for name in sorted(commands):
|
|
124
|
-
command = commands[name]
|
|
125
|
-
rendered = ", ".join(_toml_string(part) for part in command.argv)
|
|
126
|
-
lines.append(f"[commands.{_toml_key(name)}]")
|
|
127
|
-
lines.append(f"cmd = [{rendered}]")
|
|
128
|
-
lines.append("")
|
|
129
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
130
|
-
path.write_text("\n".join(lines), encoding="utf-8")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|