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.
Files changed (23) hide show
  1. {comma_command-0.1.1 → comma_command-0.2.0}/.gitignore +3 -0
  2. {comma_command-0.1.1 → comma_command-0.2.0}/PKG-INFO +41 -4
  3. {comma_command-0.1.1 → comma_command-0.2.0}/README.md +40 -3
  4. comma_command-0.2.0/crates/comma-command/Cargo.lock +7 -0
  5. comma_command-0.2.0/crates/comma-command/Cargo.toml +12 -0
  6. comma_command-0.2.0/crates/comma-command/LICENSE +21 -0
  7. comma_command-0.2.0/crates/comma-command/README.md +11 -0
  8. comma_command-0.2.0/crates/comma-command/src/lib.rs +6 -0
  9. {comma_command-0.1.1 → comma_command-0.2.0}/pyproject.toml +1 -1
  10. {comma_command-0.1.1 → comma_command-0.2.0}/src/comma_command/__init__.py +1 -1
  11. {comma_command-0.1.1 → comma_command-0.2.0}/src/comma_command/cli.py +103 -8
  12. comma_command-0.2.0/src/comma_command/config.py +211 -0
  13. {comma_command-0.1.1 → comma_command-0.2.0}/src/comma_command/dispatch.py +26 -1
  14. {comma_command-0.1.1 → comma_command-0.2.0}/tests/conftest.py +10 -1
  15. {comma_command-0.1.1 → comma_command-0.2.0}/tests/test_cli.py +2 -1
  16. comma_command-0.2.0/tests/test_env_cwd.py +173 -0
  17. comma_command-0.1.1/src/comma_command/config.py +0 -130
  18. {comma_command-0.1.1 → comma_command-0.2.0}/.github/workflows/ci.yml +0 -0
  19. {comma_command-0.1.1 → comma_command-0.2.0}/LICENSE +0 -0
  20. {comma_command-0.1.1 → comma_command-0.2.0}/src/comma_command/__main__.py +0 -0
  21. {comma_command-0.1.1 → comma_command-0.2.0}/src/comma_command/paths.py +0 -0
  22. {comma_command-0.1.1 → comma_command-0.2.0}/tests/test_config.py +0 -0
  23. {comma_command-0.1.1 → comma_command-0.2.0}/tests/test_dispatch.py +0 -0
@@ -10,3 +10,6 @@ dist/
10
10
  .uv/
11
11
  *.swp
12
12
  .DS_Store
13
+
14
+ # Rust build output
15
+ target/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: comma-command
3
- Version: 0.1.1
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 needs a `.cmd` shim layer, which is not in 0.1.
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 needs a `.cmd` shim layer, which is not in 0.1.
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,7 @@
1
+ # This file is automatically @generated by Cargo.
2
+ # It is not intended for manual editing.
3
+ version = 4
4
+
5
+ [[package]]
6
+ name = "comma-command"
7
+ version = "0.0.1"
@@ -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.
@@ -0,0 +1,6 @@
1
+ //! Name-reservation placeholder for the `comma-command` project.
2
+ //!
3
+ //! The supported implementation is currently the Python CLI. This crate does not
4
+ //! expose a usable API or binary; a native Rust implementation may be added later.
5
+
6
+ #![forbid(unsafe_code)]
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "comma-command"
7
- version = "0.1.1"
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" }
@@ -1,4 +1,4 @@
1
1
  """comma-command — run any configured command as ``name, args``."""
2
2
 
3
- __version__ = "0.1.1"
3
+ __version__ = "0.2.0"
4
4
  __all__ = ["__version__"]
@@ -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 NAME -- COMMAND [ARG...] register NAME (creates the `NAME,` shim)
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("usage: comma add [--force] NAME -- COMMAND [ARG...]")
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(name=name, argv=tuple(cmd_argv))
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
- name: {"cmd": list(cmd.argv), "shim": str(paths.shim_dir() / f"{name},")}
200
- for name, cmd in sorted(commands.items())
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
- print(f"{name + ',':<{width}} {commands[name].display()}")
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, os.environ)
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
- **os.environ,
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