comma-command 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,4 @@
1
+ """comma-command — run any configured command as ``name, args``."""
2
+
3
+ __version__ = "0.1.0"
4
+ __all__ = ["__version__"]
@@ -0,0 +1,8 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
comma_command/cli.py ADDED
@@ -0,0 +1,451 @@
1
+ """The ``comma`` command line.
2
+
3
+ ``comma`` is both the manager (``add``/``rm``/``ls``/``init``/...) and the
4
+ dispatcher: when it is invoked through a ``name,`` shim, ``argv[0]`` tells it
5
+ which registered command to run.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import shutil
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ from . import __version__, paths
17
+ from . import config as config_mod
18
+ from .dispatch import dispatch, invoked_name
19
+
20
+ USAGE = """\
21
+ comma — run any configured command as `name, args`
22
+
23
+ usage:
24
+ comma add NAME -- COMMAND [ARG...] register NAME (creates the `NAME,` shim)
25
+ comma rm NAME unregister NAME
26
+ comma ls [--json] list registered commands
27
+ comma run NAME [--] [ARG...] run NAME without going through the shim
28
+ comma which NAME show the shim and what NAME runs
29
+ comma init [--write] [--shell S] print or install shell setup (PATH + completion)
30
+ comma doctor check the installation
31
+ comma completion SHELL print completion for bash, zsh or fish
32
+ comma --version | --help
33
+
34
+ example:
35
+ comma add gus -- too talk gus --
36
+ gus, hello world # runs: too talk gus -- hello world
37
+ """
38
+
39
+ _SHELLS = ("bash", "zsh", "fish", "sh")
40
+
41
+
42
+ class CliError(Exception):
43
+ """A user-facing mistake; printed without a traceback."""
44
+
45
+
46
+ # --------------------------------------------------------------------------- helpers
47
+
48
+
49
+ def _on_path(directory: Path) -> bool:
50
+ try:
51
+ target = directory.resolve()
52
+ except OSError: # pragma: no cover - unreadable dir
53
+ target = directory
54
+ for entry in os.environ.get("PATH", "").split(os.pathsep):
55
+ if not entry:
56
+ continue
57
+ try:
58
+ if Path(entry).expanduser().resolve() == target:
59
+ return True
60
+ except OSError: # pragma: no cover
61
+ continue
62
+ return False
63
+
64
+
65
+ def _launcher() -> Path:
66
+ """Absolute path of the ``comma`` executable the shims should point at."""
67
+ override = os.environ.get("COMMA_SHIM_TARGET")
68
+ if override:
69
+ return Path(override).expanduser().absolute()
70
+ found = shutil.which("comma")
71
+ if found:
72
+ return Path(found).absolute()
73
+ beside_python = Path(sys.executable).parent / "comma"
74
+ if beside_python.exists():
75
+ return beside_python.absolute()
76
+ raise CliError(
77
+ "cannot find the `comma` executable to link the shim to; "
78
+ "set COMMA_SHIM_TARGET to its path"
79
+ )
80
+
81
+
82
+ def _is_our_shim(shim: Path) -> bool:
83
+ if not shim.is_symlink():
84
+ return False
85
+ try:
86
+ return shim.resolve() == _launcher().resolve()
87
+ except (OSError, CliError):
88
+ return False
89
+
90
+
91
+ def _install_shim(name: str) -> Path:
92
+ target = _launcher()
93
+ shim = paths.shim_dir() / f"{name},"
94
+ paths.shim_dir().mkdir(parents=True, exist_ok=True)
95
+ if shim.is_symlink() or shim.exists():
96
+ shim.unlink()
97
+ shim.symlink_to(target)
98
+ return shim
99
+
100
+
101
+ def _detect_shell() -> str:
102
+ base = os.path.basename(os.environ.get("SHELL", ""))
103
+ return base if base in _SHELLS else "bash"
104
+
105
+
106
+ def _rc_path(shell: str) -> Path:
107
+ if shell == "fish":
108
+ return paths.home() / ".config" / "fish" / "config.fish"
109
+ if shell == "zsh":
110
+ return paths.home() / ".zshrc"
111
+ return paths.home() / ".bashrc"
112
+
113
+
114
+ def _init_snippet(shell: str) -> str:
115
+ shim = paths.shim_dir()
116
+ if shell == "fish":
117
+ return (
118
+ "# comma (managed)\n"
119
+ f"set -gx PATH {shim} $PATH\n"
120
+ "comma completion fish | source\n"
121
+ )
122
+ completion = "" if shell == "sh" else f'source <(comma completion {shell})\n'
123
+ return f'# comma (managed)\nexport PATH="{shim}:$PATH"\n{completion}'
124
+
125
+
126
+ # --------------------------------------------------------------------------- commands
127
+
128
+
129
+ def _add(args: list[str]) -> int:
130
+ force = False
131
+ while args and args[0].startswith("-") and args[0] != "--":
132
+ flag = args.pop(0)
133
+ if flag in ("-f", "--force"):
134
+ force = True
135
+ elif flag in ("-h", "--help"):
136
+ print("usage: comma add [--force] NAME -- COMMAND [ARG...]")
137
+ return 0
138
+ else:
139
+ raise CliError(f"add: unknown option {flag}")
140
+
141
+ if len(args) < 3:
142
+ raise CliError("add: expected `comma add NAME -- COMMAND [ARG...]`")
143
+
144
+ name, tail = args[0], args[1:]
145
+ if tail[0] != "--":
146
+ raise CliError(
147
+ "add: put `--` between the name and the command, "
148
+ "e.g. `comma add gus -- too talk gus --`"
149
+ )
150
+ cmd_argv = tail[1:]
151
+ if not cmd_argv:
152
+ raise CliError("add: missing command after `--`")
153
+
154
+ config_mod.validate_name(name)
155
+
156
+ shim = paths.shim_dir() / f"{name},"
157
+ if (shim.exists() or shim.is_symlink()) and not force and not _is_our_shim(shim):
158
+ raise CliError(
159
+ f"add: {shim} already exists and was not created by comma "
160
+ "(rerun with --force to replace it)"
161
+ )
162
+
163
+ commands = config_mod.load(paths.config_path())
164
+ commands[name] = config_mod.Command(name=name, argv=tuple(cmd_argv))
165
+ config_mod.save(paths.config_path(), commands)
166
+ _install_shim(name)
167
+
168
+ print(f"added {name}, → {commands[name].display()}")
169
+ if not _on_path(paths.shim_dir()):
170
+ print(f"note: {paths.shim_dir()} is not on PATH — run `comma init --write`")
171
+ return 0
172
+
173
+
174
+ def _rm(args: list[str]) -> int:
175
+ if not args:
176
+ raise CliError("rm: expected `comma rm NAME`")
177
+ name = args[0]
178
+ commands = config_mod.load(paths.config_path())
179
+ if name not in commands:
180
+ raise CliError(f"rm: no command named {name!r}")
181
+
182
+ removed = commands.pop(name)
183
+ config_mod.save(paths.config_path(), commands)
184
+
185
+ shim = paths.shim_dir() / f"{name},"
186
+ if shim.is_symlink() or shim.exists():
187
+ shim.unlink()
188
+
189
+ print(f"removed {name}, (was {removed.display()})")
190
+ return 0
191
+
192
+
193
+ def _ls(args: list[str]) -> int:
194
+ commands = config_mod.load(paths.config_path())
195
+
196
+ 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
+ }
201
+ print(json.dumps(payload, indent=2, ensure_ascii=False))
202
+ return 0
203
+
204
+ if not commands:
205
+ print("no commands registered yet — try: comma add gus -- too talk gus --")
206
+ return 0
207
+
208
+ width = max(len(name) for name in commands) + 1
209
+ for name in sorted(commands):
210
+ print(f"{name + ',':<{width}} {commands[name].display()}")
211
+ return 0
212
+
213
+
214
+ def _names(args: list[str]) -> int:
215
+ """Hidden helper for shell completion."""
216
+ for name in sorted(config_mod.load(paths.config_path())):
217
+ print(name)
218
+ return 0
219
+
220
+
221
+ def _run(args: list[str]) -> int:
222
+ if not args:
223
+ raise CliError("run: expected `comma run NAME [--] [ARG...]`")
224
+ name, rest = args[0], args[1:]
225
+ if rest and rest[0] == "--":
226
+ rest = rest[1:]
227
+ return dispatch(name, rest)
228
+
229
+
230
+ def _which(args: list[str]) -> int:
231
+ if not args:
232
+ raise CliError("which: expected `comma which NAME`")
233
+ name = args[0]
234
+ commands = config_mod.load(paths.config_path())
235
+ command = commands.get(name)
236
+ shim = paths.shim_dir() / f"{name},"
237
+
238
+ if command is None and not shim.exists():
239
+ print(f"{name}: not registered", file=sys.stderr)
240
+ return 1
241
+
242
+ print(f"name: {name},")
243
+ print(f"shim: {shim}" + ("" if shim.exists() else " (missing)"))
244
+ print(
245
+ "path: "
246
+ + ("on PATH" if _on_path(paths.shim_dir()) else "NOT on PATH — run `comma init --write`")
247
+ )
248
+ if command is not None:
249
+ print(f"runs: {command.display()}")
250
+ return 0
251
+
252
+
253
+ def _init(args: list[str]) -> int:
254
+ shell = _detect_shell()
255
+ write = False
256
+ index = 0
257
+ while index < len(args):
258
+ option = args[index]
259
+ if option == "--write":
260
+ write = True
261
+ index += 1
262
+ elif option == "--shell":
263
+ index += 1
264
+ if index >= len(args):
265
+ raise CliError("init: --shell needs a value")
266
+ shell = args[index]
267
+ index += 1
268
+ elif option in ("-h", "--help"):
269
+ print("usage: comma init [--write] [--shell bash|zsh|fish]")
270
+ return 0
271
+ else:
272
+ raise CliError(f"init: unknown option {option}")
273
+
274
+ if shell not in _SHELLS:
275
+ raise CliError(f"init: unsupported shell {shell!r} (bash, zsh, fish)")
276
+
277
+ snippet = _init_snippet(shell)
278
+ if not write:
279
+ print(snippet, end="")
280
+ return 0
281
+
282
+ rc = _rc_path(shell)
283
+ rc.parent.mkdir(parents=True, exist_ok=True)
284
+ existing = rc.read_text(encoding="utf-8") if rc.exists() else ""
285
+ if "# comma (managed)" in existing:
286
+ print(f"already configured in {rc}")
287
+ return 0
288
+
289
+ with rc.open("a", encoding="utf-8") as handle:
290
+ if existing and not existing.endswith("\n"):
291
+ handle.write("\n")
292
+ handle.write(snippet)
293
+ print(f"updated {rc} — open a new shell (or `source {rc}`)")
294
+ return 0
295
+
296
+
297
+ def _doctor(args: list[str]) -> int:
298
+ config = paths.config_path()
299
+ shims = paths.shim_dir()
300
+ print(f"comma {__version__} (comma-command)")
301
+ print(f"config: {config}")
302
+ print(f"shims: {shims}")
303
+
304
+ try:
305
+ commands = config_mod.load(config)
306
+ except config_mod.ConfigError as exc:
307
+ print(f"FAIL config: {exc}")
308
+ return 1
309
+
310
+ count = len(commands)
311
+ print(f"ok config parsed ({count} command{'s' if count != 1 else ''})")
312
+
313
+ problems = 0
314
+ if _on_path(shims):
315
+ print("ok shim directory is on PATH")
316
+ else:
317
+ print("warn shim directory is not on PATH — run `comma init --write`")
318
+
319
+ shell = _detect_shell()
320
+ rc = _rc_path(shell)
321
+ configured = rc.exists() and "# comma (managed)" in rc.read_text(
322
+ encoding="utf-8", errors="replace"
323
+ )
324
+ if configured:
325
+ print(f"ok shell setup present in {rc}")
326
+ else:
327
+ print(f"warn no shell setup in {rc} — run `comma init --write`")
328
+
329
+ for name in sorted(commands):
330
+ command = commands[name]
331
+ shim = shims / f"{name},"
332
+ issues = []
333
+ if not shim.exists():
334
+ issues.append("shim missing — re-run `comma add`")
335
+ elif not os.access(shim, os.X_OK):
336
+ issues.append("shim is not executable")
337
+ if shutil.which(command.argv[0]) is None:
338
+ issues.append(f"target {command.argv[0]!r} is not on PATH")
339
+ if issues:
340
+ print(f"FAIL {name},: " + "; ".join(issues))
341
+ problems += 1
342
+ else:
343
+ print(f"ok {name}, → {command.display()}")
344
+
345
+ return 1 if problems else 0
346
+
347
+
348
+ _BASH_COMPLETION = """\
349
+ # bash completion for comma
350
+ _comma_complete() {
351
+ local cur prev
352
+ cur="${COMP_WORDS[COMP_CWORD]}"
353
+ prev="${COMP_WORDS[COMP_CWORD-1]}"
354
+ if [ "$COMP_CWORD" -eq 1 ]; then
355
+ COMPREPLY=( $(compgen -W "add rm ls run which init doctor completion help" -- "$cur") )
356
+ return
357
+ fi
358
+ case "$prev" in
359
+ rm|run|which) COMPREPLY=( $(compgen -W "$(comma _names)" -- "$cur") ) ;;
360
+ completion) COMPREPLY=( $(compgen -W "bash zsh fish" -- "$cur") ) ;;
361
+ esac
362
+ }
363
+ complete -F _comma_complete comma
364
+ # `name,` shims need no completion: they are real files on PATH, so the shell
365
+ # completes them like any other executable.
366
+ """
367
+
368
+ _ZSH_COMPLETION = """\
369
+ #compdef comma
370
+ _comma() {
371
+ local -a subcommands names
372
+ subcommands=(add rm ls run which init doctor completion help)
373
+ if (( CURRENT == 2 )); then
374
+ _describe 'command' subcommands
375
+ return
376
+ fi
377
+ case "${words[2]}" in
378
+ rm|run|which) names=(${(f)"$(comma _names)"}); _describe 'name' names ;;
379
+ completion) _values 'shell' bash zsh fish ;;
380
+ esac
381
+ }
382
+ compdef _comma comma
383
+ """
384
+
385
+ _FISH_COMPLETION = """\
386
+ complete -c comma -f
387
+ complete -c comma -n '__fish_use_subcommand' -a 'add rm ls run which init doctor completion help'
388
+ complete -c comma -n '__fish_seen_subcommand_from rm run which' -a '(comma _names)'
389
+ complete -c comma -n '__fish_seen_subcommand_from completion' -a 'bash zsh fish'
390
+ """
391
+
392
+
393
+ def _completion(args: list[str]) -> int:
394
+ shell = args[0] if args else _detect_shell()
395
+ scripts = {
396
+ "bash": _BASH_COMPLETION,
397
+ "zsh": _ZSH_COMPLETION,
398
+ "fish": _FISH_COMPLETION,
399
+ }
400
+ script = scripts.get(shell)
401
+ if script is None:
402
+ raise CliError(f"completion: unsupported shell {shell!r} (bash, zsh, fish)")
403
+ print(script, end="")
404
+ return 0
405
+
406
+
407
+ _HANDLERS = {
408
+ "add": _add,
409
+ "rm": _rm,
410
+ "ls": _ls,
411
+ "list": _ls,
412
+ "run": _run,
413
+ "which": _which,
414
+ "init": _init,
415
+ "doctor": _doctor,
416
+ "completion": _completion,
417
+ "_names": _names,
418
+ }
419
+
420
+
421
+ def main(argv: list[str] | None = None) -> int:
422
+ argv = list(sys.argv if argv is None else argv)
423
+ prog = argv[0] if argv else "comma"
424
+ args = argv[1:]
425
+
426
+ # Reached through a `name,` shim: we are the dispatcher, not the manager.
427
+ name = invoked_name(prog)
428
+ if name is not None:
429
+ return dispatch(name, args)
430
+
431
+ if not args or args[0] in ("-h", "--help", "help"):
432
+ sys.stdout.write(USAGE)
433
+ return 0
434
+ if args[0] in ("-V", "--version", "version"):
435
+ print(f"comma {__version__} (comma-command)")
436
+ return 0
437
+
438
+ handler = _HANDLERS.get(args[0])
439
+ if handler is None:
440
+ print(f"comma: unknown command {args[0]!r}\n", file=sys.stderr)
441
+ sys.stderr.write(USAGE)
442
+ return 2
443
+
444
+ try:
445
+ return handler(args[1:])
446
+ except CliError as exc:
447
+ print(f"comma: {exc}", file=sys.stderr)
448
+ return 2
449
+ except config_mod.ConfigError as exc:
450
+ print(f"comma: {exc}", file=sys.stderr)
451
+ return 1
@@ -0,0 +1,130 @@
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")
@@ -0,0 +1,50 @@
1
+ """Turning ``name, args...`` into the configured command.
2
+
3
+ Dispatch ends in :func:`os.execvpe`: the process is replaced, so the child
4
+ inherits the terminal, the foreground process group, stdin, and its exit
5
+ status reaches the shell untouched. No wrapper process, no signal proxying.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import sys
12
+
13
+ from . import config as config_mod
14
+ from . import paths
15
+
16
+
17
+ def invoked_name(prog: str) -> str | None:
18
+ """Return the registered name if ``prog`` is a ``name,`` shim."""
19
+ base = os.path.basename(prog)
20
+ if len(base) > 1 and base.endswith(","):
21
+ return base[:-1]
22
+ return None
23
+
24
+
25
+ def dispatch(name: str, args: list[str]) -> int:
26
+ """Replace this process with the command registered under ``name``."""
27
+ try:
28
+ commands = config_mod.load(paths.config_path())
29
+ except config_mod.ConfigError as exc:
30
+ print(f"comma: {exc}", file=sys.stderr)
31
+ return 1
32
+
33
+ command = commands.get(name)
34
+ if command is None:
35
+ print(f"comma: no command named {name!r}.", file=sys.stderr)
36
+ known = ", ".join(sorted(commands)) or "(none yet)"
37
+ print(f" registered: {known}", file=sys.stderr)
38
+ print(f" add one: comma add {name} -- your-command --", file=sys.stderr)
39
+ return 127
40
+
41
+ argv = [*command.argv, *args]
42
+ try:
43
+ os.execvpe(argv[0], argv, os.environ)
44
+ except FileNotFoundError:
45
+ print(f"comma: command not found: {argv[0]!r} (registered as {name!r})", file=sys.stderr)
46
+ return 127
47
+ except PermissionError:
48
+ print(f"comma: permission denied: {argv[0]!r} (registered as {name!r})", file=sys.stderr)
49
+ return 126
50
+ return 126 # pragma: no cover - execvpe either replaces us or raises
comma_command/paths.py ADDED
@@ -0,0 +1,37 @@
1
+ """XDG-ish locations used by comma-command.
2
+
3
+ Everything is resolved at call time so tests can point ``HOME``,
4
+ ``XDG_CONFIG_HOME`` and ``XDG_DATA_HOME`` at throwaway directories.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from pathlib import Path
11
+
12
+
13
+ def home() -> Path:
14
+ raw = os.environ.get("HOME")
15
+ return Path(raw) if raw else Path.home()
16
+
17
+
18
+ def _xdg(var: str, fallback: Path) -> Path:
19
+ raw = os.environ.get(var)
20
+ return Path(raw).expanduser() if raw else fallback
21
+
22
+
23
+ def config_dir() -> Path:
24
+ return _xdg("XDG_CONFIG_HOME", home() / ".config") / "comma"
25
+
26
+
27
+ def config_path() -> Path:
28
+ return config_dir() / "config.toml"
29
+
30
+
31
+ def data_dir() -> Path:
32
+ return _xdg("XDG_DATA_HOME", home() / ".local" / "share") / "comma"
33
+
34
+
35
+ def shim_dir() -> Path:
36
+ """Directory holding the executable ``name,`` shims."""
37
+ return data_dir() / "bin"
@@ -0,0 +1,143 @@
1
+ Metadata-Version: 2.5
2
+ Name: comma-command
3
+ Version: 0.1.0
4
+ Summary: Run any configured command from any shell as `name, args` — register once, invoke everywhere.
5
+ Project-URL: Homepage, https://github.com/by3hak/comma-command
6
+ Project-URL: Repository, https://github.com/by3hak/comma-command
7
+ Project-URL: Issues, https://github.com/by3hak/comma-command/issues
8
+ Author-email: hak <by3hak@users.noreply.github.com>
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: alias,cli,dispatch,multicall,shell,terminal
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: MacOS :: MacOS X
17
+ Classifier: Operating System :: POSIX :: Linux
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Topic :: System :: Shells
24
+ Classifier: Topic :: Utilities
25
+ Requires-Python: >=3.10
26
+ Requires-Dist: tomli>=2.0; python_version < '3.11'
27
+ Provides-Extra: dev
28
+ Requires-Dist: pytest>=8.0; extra == 'dev'
29
+ Requires-Dist: ruff>=0.6; extra == 'dev'
30
+ Description-Content-Type: text/markdown
31
+
32
+ # comma-command
33
+
34
+ Run any command you have installed from any shell, as if it were a shell builtin:
35
+
36
+ ```console
37
+ $ gus, hello world
38
+ # → too talk gus -- hello world
39
+ ```
40
+
41
+ Register an entry once. From then on, `NAME, any words` is the whole interface — no
42
+ aliases to source, no shell hook, no TUI.
43
+
44
+ ## Install
45
+
46
+ ```console
47
+ $ uv tool install comma-command # or: pipx install comma-command
48
+ $ comma init --write # puts the shim directory on PATH
49
+ $ exec $SHELL # or open a new terminal
50
+ ```
51
+
52
+ ## Quickstart
53
+
54
+ ```console
55
+ $ comma add gus -- too talk gus --
56
+ added gus, → too talk gus --
57
+
58
+ $ comma add pyi -- uv run ipython --
59
+ added pyi, → uv run ipython --
60
+
61
+ $ gus, hello world # runs: too talk gus -- hello world
62
+ $ pyi, --pdb script.py # runs: uv run ipython -- --pdb script.py
63
+ $ comma ls
64
+ gus, too talk gus --
65
+ pyi, uv run ipython --
66
+ ```
67
+
68
+ `comma add` does two things: writes the entry to `~/.config/comma/config.toml` and
69
+ creates an executable named exactly `gus,` in `~/.local/share/comma/bin`.
70
+
71
+ ## How it works
72
+
73
+ The shim is a multicall dispatcher — the same trick `busybox` and `git` use for
74
+ subcommands, applied to names:
75
+
76
+ 1. `gus,` is a symlink to the `comma` executable.
77
+ 2. `comma` looks at `argv[0]`, sees a trailing comma, strips it, and finds `gus` in
78
+ the config.
79
+ 3. It calls `os.execvpe("too", ["too", "talk", "gus", "--", "hello", "world"])`.
80
+
81
+ Because the final step *replaces* the process instead of spawning a child, the
82
+ target command inherits the terminal, stdin, signals and job control directly, and
83
+ its exit status reaches your shell unchanged. `comma,` adds no wrapping layer.
84
+
85
+ Why not the alternatives:
86
+
87
+ - **Shell aliases/functions.** `gus,` is not a portable function name in bash or
88
+ fish, and functions only exist in interactive shells you have sourced.
89
+ - **`command_not_found_handle` hooks.** Bash-only, collides with distro handlers
90
+ (Nix, Debian's package suggestion hook), and silently does nothing in
91
+ non-interactive shells, `sh -c`, cron, or most editors' task runners.
92
+ - **Shims on `PATH`.** Work everywhere a real executable works, including
93
+ `xargs`, `find -exec`, CI scripts, Makefiles and shell completion.
94
+
95
+ ## Configuration
96
+
97
+ `~/.config/comma/config.toml` — managed by `comma`, but hand-editable:
98
+
99
+ ```toml
100
+ [commands.gus]
101
+ cmd = ["too", "talk", "gus", "--"]
102
+
103
+ # shorthand is fine too
104
+ [commands.pyi]
105
+ cmd = "uv run ipython --"
106
+ ```
107
+
108
+ Arguments are appended verbatim, so `--` at the end of `cmd` is the usual way to
109
+ keep flags from being swallowed by the target's own option parser.
110
+
111
+ ## Commands
112
+
113
+ | Command | What it does |
114
+ | --- | --- |
115
+ | `comma add NAME -- CMD...` | register a name and create its `NAME,` shim |
116
+ | `comma rm NAME` | unregister a name and remove its shim |
117
+ | `comma ls [--json]` | list registered names |
118
+ | `comma run NAME [--] [ARG...]` | run a name without going through the shim |
119
+ | `comma which NAME` | show the shim path and the command it runs |
120
+ | `comma init [--write] [--shell S]` | print or install PATH + completion setup |
121
+ | `comma doctor` | check config, PATH, shims and targets |
122
+ | `comma completion bash\|zsh\|fish` | print a completion script |
123
+
124
+ Names must be shell-safe words: letters, digits, `.`, `_`, `+`, `-`. The comma is
125
+ added for you, so `comma add gus, -- ...` is rejected rather than silently creating
126
+ `gus,,`.
127
+
128
+ ## Limits
129
+
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.
132
+ - The `comma-command` crate on crates.io is a name reservation for a future native
133
+ binary; the Python implementation is the one to install today.
134
+
135
+ ## Development
136
+
137
+ ```console
138
+ $ uv venv && uv pip install -e '.[dev]'
139
+ $ uv run pytest
140
+ $ uv run ruff check .
141
+ ```
142
+
143
+ MIT licensed.
@@ -0,0 +1,11 @@
1
+ comma_command/__init__.py,sha256=KNA9OATXcemoKZw6_ryyQB8Gptqm6we_QlJnzeUwY4s,119
2
+ comma_command/__main__.py,sha256=40c3JGxDOKeEEdfKw5o6A8E_rhK9fFv8AYlmd-ngtcI,119
3
+ comma_command/cli.py,sha256=g8cUY21MqGqyBH85-5qGUoSGemkxCwy5UqbspXG32oY,13697
4
+ comma_command/config.py,sha256=vj2IfMIKzkUrLMqip8IcAegF4Cgt-_OsYbg1c5bXRLo,4151
5
+ comma_command/dispatch.py,sha256=wN5lAjPU5OOLw5xb7QIMZ66w-cK9_tfmqAUMs5H1roU,1738
6
+ comma_command/paths.py,sha256=r7My124OkSgXIlKF91W1T02471SN08HATz-_L1JBius,861
7
+ comma_command-0.1.0.dist-info/METADATA,sha256=1GXxuglh-Wl9kh9O6JGgoaUeio2sVQmux0bCLjxUfd8,5087
8
+ comma_command-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
9
+ comma_command-0.1.0.dist-info/entry_points.txt,sha256=6aUEhMA1wmcTYDSrvLk9Yc-6MK3P6jqxp2uX0FqZkaI,49
10
+ comma_command-0.1.0.dist-info/licenses/LICENSE,sha256=v-zUf9Iol9k8xzf6xuzL8cupU5NxU_Rd6uEn5Gcg__A,1060
11
+ comma_command-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ comma = comma_command.cli:main
@@ -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.