comma-command 0.1.1__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.1 → comma_command-0.2.0}/.gitignore +3 -0
- {comma_command-0.1.1 → comma_command-0.2.0}/PKG-INFO +41 -4
- {comma_command-0.1.1 → comma_command-0.2.0}/README.md +40 -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.1 → comma_command-0.2.0}/pyproject.toml +1 -1
- {comma_command-0.1.1 → comma_command-0.2.0}/src/comma_command/__init__.py +1 -1
- {comma_command-0.1.1 → comma_command-0.2.0}/src/comma_command/cli.py +103 -8
- comma_command-0.2.0/src/comma_command/config.py +211 -0
- {comma_command-0.1.1 → comma_command-0.2.0}/src/comma_command/dispatch.py +26 -1
- {comma_command-0.1.1 → comma_command-0.2.0}/tests/conftest.py +10 -1
- {comma_command-0.1.1 → comma_command-0.2.0}/tests/test_cli.py +2 -1
- comma_command-0.2.0/tests/test_env_cwd.py +173 -0
- comma_command-0.1.1/src/comma_command/config.py +0 -130
- {comma_command-0.1.1 → comma_command-0.2.0}/.github/workflows/ci.yml +0 -0
- {comma_command-0.1.1 → comma_command-0.2.0}/LICENSE +0 -0
- {comma_command-0.1.1 → comma_command-0.2.0}/src/comma_command/__main__.py +0 -0
- {comma_command-0.1.1 → comma_command-0.2.0}/src/comma_command/paths.py +0 -0
- {comma_command-0.1.1 → comma_command-0.2.0}/tests/test_config.py +0 -0
- {comma_command-0.1.1 → comma_command-0.2.0}/tests/test_dispatch.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
|
|
@@ -110,14 +110,34 @@ cmd = ["too", "talk", "gus", "--"]
|
|
|
110
110
|
cmd = "uv run ipython --"
|
|
111
111
|
```
|
|
112
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
|
+
|
|
113
117
|
Arguments are appended verbatim, so `--` at the end of `cmd` is the usual way to
|
|
114
118
|
keep flags from being swallowed by the target's own option parser.
|
|
115
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
|
+
|
|
116
135
|
## Commands
|
|
117
136
|
|
|
118
137
|
| Command | What it does |
|
|
119
138
|
| --- | --- |
|
|
120
|
-
| `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 |
|
|
121
141
|
| `comma rm NAME` | unregister a name and remove its shim |
|
|
122
142
|
| `comma ls [--json]` | list registered names |
|
|
123
143
|
| `comma run NAME [--] [ARG...]` | run a name without going through the shim |
|
|
@@ -131,10 +151,27 @@ Names must be shell-safe words: letters, digits, `.`, `_`, `+`, `-`. The comma i
|
|
|
131
151
|
added for you, so `comma add gus, -- ...` is rejected rather than silently creating
|
|
132
152
|
`gus,,`.
|
|
133
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
|
+
|
|
134
172
|
## Limits
|
|
135
173
|
|
|
136
|
-
- POSIX first. Windows
|
|
137
|
-
- 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.
|
|
138
175
|
- The `comma-command` crate on crates.io is a name reservation for a future native
|
|
139
176
|
binary; the Python implementation is the one to install today.
|
|
140
177
|
|
|
@@ -79,14 +79,34 @@ cmd = ["too", "talk", "gus", "--"]
|
|
|
79
79
|
cmd = "uv run ipython --"
|
|
80
80
|
```
|
|
81
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
|
+
|
|
82
86
|
Arguments are appended verbatim, so `--` at the end of `cmd` is the usual way to
|
|
83
87
|
keep flags from being swallowed by the target's own option parser.
|
|
84
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
|
+
|
|
85
104
|
## Commands
|
|
86
105
|
|
|
87
106
|
| Command | What it does |
|
|
88
107
|
| --- | --- |
|
|
89
|
-
| `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 |
|
|
90
110
|
| `comma rm NAME` | unregister a name and remove its shim |
|
|
91
111
|
| `comma ls [--json]` | list registered names |
|
|
92
112
|
| `comma run NAME [--] [ARG...]` | run a name without going through the shim |
|
|
@@ -100,10 +120,27 @@ Names must be shell-safe words: letters, digits, `.`, `_`, `+`, `-`. The comma i
|
|
|
100
120
|
added for you, so `comma add gus, -- ...` is rejected rather than silently creating
|
|
101
121
|
`gus,,`.
|
|
102
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
|
+
|
|
103
141
|
## Limits
|
|
104
142
|
|
|
105
|
-
- POSIX first. Windows
|
|
106
|
-
- 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.
|
|
107
144
|
- The `comma-command` crate on crates.io is a name reservation for a future native
|
|
108
145
|
binary; the Python implementation is the one to install today.
|
|
109
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,7 +23,9 @@ 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
|
|
@@ -129,12 +133,35 @@ def _init_snippet(shell: str) -> str:
|
|
|
129
133
|
|
|
130
134
|
def _add(args: list[str]) -> int:
|
|
131
135
|
force = False
|
|
136
|
+
env: dict[str, str] = {}
|
|
137
|
+
env_unset: list[str] = []
|
|
138
|
+
cwd: str | None = None
|
|
139
|
+
|
|
132
140
|
while args and args[0].startswith("-") and args[0] != "--":
|
|
133
141
|
flag = args.pop(0)
|
|
134
142
|
if flag in ("-f", "--force"):
|
|
135
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)
|
|
136
160
|
elif flag in ("-h", "--help"):
|
|
137
|
-
print(
|
|
161
|
+
print(
|
|
162
|
+
"usage: comma add [--force] [--env KEY=VALUE]... [--unset KEY]... "
|
|
163
|
+
"[--cwd DIR] NAME -- COMMAND [ARG...]"
|
|
164
|
+
)
|
|
138
165
|
return 0
|
|
139
166
|
else:
|
|
140
167
|
raise CliError(f"add: unknown option {flag}")
|
|
@@ -162,7 +189,13 @@ def _add(args: list[str]) -> int:
|
|
|
162
189
|
)
|
|
163
190
|
|
|
164
191
|
commands = config_mod.load(paths.config_path())
|
|
165
|
-
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
|
+
)
|
|
166
199
|
config_mod.save(paths.config_path(), commands)
|
|
167
200
|
_install_shim(name)
|
|
168
201
|
|
|
@@ -195,10 +228,16 @@ def _ls(args: list[str]) -> int:
|
|
|
195
228
|
commands = config_mod.load(paths.config_path())
|
|
196
229
|
|
|
197
230
|
if "--json" in args:
|
|
198
|
-
payload = {
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
|
202
241
|
print(json.dumps(payload, indent=2, ensure_ascii=False))
|
|
203
242
|
return 0
|
|
204
243
|
|
|
@@ -208,7 +247,11 @@ def _ls(args: list[str]) -> int:
|
|
|
208
247
|
|
|
209
248
|
width = max(len(name) for name in commands) + 1
|
|
210
249
|
for name in sorted(commands):
|
|
211
|
-
|
|
250
|
+
line = f"{name + ',':<{width}} {commands[name].display()}"
|
|
251
|
+
modifiers = commands[name].modifiers()
|
|
252
|
+
if modifiers:
|
|
253
|
+
line += " [" + " ".join(modifiers) + "]"
|
|
254
|
+
print(line)
|
|
212
255
|
return 0
|
|
213
256
|
|
|
214
257
|
|
|
@@ -337,6 +380,8 @@ def _doctor(args: list[str]) -> int:
|
|
|
337
380
|
issues.append("shim is not executable")
|
|
338
381
|
if shutil.which(command.argv[0]) is None:
|
|
339
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")
|
|
340
385
|
if issues:
|
|
341
386
|
print(f"FAIL {name},: " + "; ".join(issues))
|
|
342
387
|
problems += 1
|
|
@@ -405,6 +450,55 @@ def _completion(args: list[str]) -> int:
|
|
|
405
450
|
return 0
|
|
406
451
|
|
|
407
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
|
+
|
|
408
502
|
_HANDLERS = {
|
|
409
503
|
"add": _add,
|
|
410
504
|
"rm": _rm,
|
|
@@ -415,6 +509,7 @@ _HANDLERS = {
|
|
|
415
509
|
"init": _init,
|
|
416
510
|
"doctor": _doctor,
|
|
417
511
|
"completion": _completion,
|
|
512
|
+
"edit": _edit,
|
|
418
513
|
"_names": _names,
|
|
419
514
|
}
|
|
420
515
|
|
|
@@ -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"),
|
|
@@ -120,5 +120,6 @@ def test_version_and_help(comma, env) -> None:
|
|
|
120
120
|
|
|
121
121
|
assert f"comma {__version__}" in comma("--version").stdout
|
|
122
122
|
usage = comma("--help").stdout
|
|
123
|
-
assert "comma add NAME" in usage
|
|
123
|
+
assert "comma add" in usage and "NAME -- COMMAND" in usage
|
|
124
|
+
assert "comma edit" in usage
|
|
124
125
|
assert comma("bogus").returncode == 2
|
|
@@ -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
|
|
File without changes
|