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.
- comma_command/__init__.py +4 -0
- comma_command/__main__.py +8 -0
- comma_command/cli.py +451 -0
- comma_command/config.py +130 -0
- comma_command/dispatch.py +50 -0
- comma_command/paths.py +37 -0
- comma_command-0.1.0.dist-info/METADATA +143 -0
- comma_command-0.1.0.dist-info/RECORD +11 -0
- comma_command-0.1.0.dist-info/WHEEL +4 -0
- comma_command-0.1.0.dist-info/entry_points.txt +2 -0
- comma_command-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|
comma_command/config.py
ADDED
|
@@ -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,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.
|