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.
Files changed (23) hide show
  1. {comma_command-0.1.0 → comma_command-0.2.0}/.gitignore +3 -0
  2. {comma_command-0.1.0 → comma_command-0.2.0}/PKG-INFO +47 -4
  3. {comma_command-0.1.0 → comma_command-0.2.0}/README.md +46 -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.0 → comma_command-0.2.0}/pyproject.toml +1 -1
  10. {comma_command-0.1.0 → comma_command-0.2.0}/src/comma_command/__init__.py +1 -1
  11. {comma_command-0.1.0 → comma_command-0.2.0}/src/comma_command/cli.py +110 -8
  12. comma_command-0.2.0/src/comma_command/config.py +211 -0
  13. {comma_command-0.1.0 → comma_command-0.2.0}/src/comma_command/dispatch.py +26 -1
  14. {comma_command-0.1.0 → comma_command-0.2.0}/tests/conftest.py +10 -1
  15. {comma_command-0.1.0 → comma_command-0.2.0}/tests/test_cli.py +5 -2
  16. {comma_command-0.1.0 → comma_command-0.2.0}/tests/test_dispatch.py +16 -0
  17. comma_command-0.2.0/tests/test_env_cwd.py +173 -0
  18. comma_command-0.1.0/src/comma_command/config.py +0 -130
  19. {comma_command-0.1.0 → comma_command-0.2.0}/.github/workflows/ci.yml +0 -0
  20. {comma_command-0.1.0 → comma_command-0.2.0}/LICENSE +0 -0
  21. {comma_command-0.1.0 → comma_command-0.2.0}/src/comma_command/__main__.py +0 -0
  22. {comma_command-0.1.0 → comma_command-0.2.0}/src/comma_command/paths.py +0 -0
  23. {comma_command-0.1.0 → comma_command-0.2.0}/tests/test_config.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.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 needs a `.cmd` shim layer, which is not in 0.1.
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 needs a `.cmd` shim layer, which is not in 0.1.
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,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.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" }
@@ -1,4 +1,4 @@
1
1
  """comma-command — run any configured command as ``name, args``."""
2
2
 
3
- __version__ = "0.1.0"
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,10 +23,13 @@ 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
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("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
+ )
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(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
+ )
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
- name: {"cmd": list(cmd.argv), "shim": str(paths.shim_dir() / f"{name},")}
199
- for name, cmd in sorted(commands.items())
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
- 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)
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, 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"),
@@ -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
- assert "comma 0.1.0" in comma("--version").stdout
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