shellscripts 0.23.1__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.
Files changed (45) hide show
  1. shell_scripts/__init__.py +5 -0
  2. shell_scripts/__main__.py +5 -0
  3. shell_scripts/commands/__init__.py +81 -0
  4. shell_scripts/commands/_dc_common.py +138 -0
  5. shell_scripts/commands/ai_install.py +453 -0
  6. shell_scripts/commands/bin_links.py +78 -0
  7. shell_scripts/commands/claude.py +56 -0
  8. shell_scripts/commands/clean.py +98 -0
  9. shell_scripts/commands/codex.py +107 -0
  10. shell_scripts/commands/copilot.py +54 -0
  11. shell_scripts/commands/dicom2jpg.py +98 -0
  12. shell_scripts/commands/dicomviewer.py +83 -0
  13. shell_scripts/commands/diff_cmd.py +50 -0
  14. shell_scripts/commands/doxygen_cmd.py +159 -0
  15. shell_scripts/commands/edit_cmd.py +50 -0
  16. shell_scripts/commands/gemini.py +54 -0
  17. shell_scripts/commands/kiro.py +54 -0
  18. shell_scripts/commands/opencode.py +54 -0
  19. shell_scripts/commands/pdf_crop.py +520 -0
  20. shell_scripts/commands/pdf_merge.py +180 -0
  21. shell_scripts/commands/pdf_split_by_format.py +243 -0
  22. shell_scripts/commands/pdf_split_by_toc.py +197 -0
  23. shell_scripts/commands/pdf_tiler_090.py +61 -0
  24. shell_scripts/commands/pdf_tiler_100.py +61 -0
  25. shell_scripts/commands/pdf_toc_clean.py +175 -0
  26. shell_scripts/commands/pi.py +61 -0
  27. shell_scripts/commands/req_cmd.py +362 -0
  28. shell_scripts/commands/tests_cmd.py +87 -0
  29. shell_scripts/commands/ubuntu_dark_theme.py +64 -0
  30. shell_scripts/commands/venv_cmd.py +82 -0
  31. shell_scripts/commands/video2h264.py +89 -0
  32. shell_scripts/commands/video2h265.py +87 -0
  33. shell_scripts/commands/view_cmd.py +50 -0
  34. shell_scripts/commands/vscode_cmd.py +42 -0
  35. shell_scripts/commands/vsinsider_cmd.py +42 -0
  36. shell_scripts/config.py +328 -0
  37. shell_scripts/core.py +324 -0
  38. shell_scripts/utils.py +455 -0
  39. shell_scripts/version_check.py +323 -0
  40. shellscripts-0.23.1.dist-info/METADATA +310 -0
  41. shellscripts-0.23.1.dist-info/RECORD +45 -0
  42. shellscripts-0.23.1.dist-info/WHEEL +5 -0
  43. shellscripts-0.23.1.dist-info/entry_points.txt +3 -0
  44. shellscripts-0.23.1.dist-info/licenses/LICENSE +674 -0
  45. shellscripts-0.23.1.dist-info/top_level.txt +1 -0
shell_scripts/utils.py ADDED
@@ -0,0 +1,455 @@
1
+ #!/usr/bin/env python3
2
+ """@brief Shared CLI utility helpers for platform, subprocess, and output handling.
3
+
4
+ @details Provides reusable terminal formatting utilities, project-root
5
+ resolution, platform detection with runtime OS caching, command availability
6
+ checks, and terminal-state restoration primitives used by CLI command wrappers.
7
+ @satisfies CTN-003, CTN-005, DES-002, REQ-047, REQ-055, REQ-056, REQ-064, REQ-065
8
+ """
9
+
10
+ import os
11
+ import sys
12
+ import subprocess
13
+ import shutil
14
+ import shlex
15
+ from pathlib import Path
16
+ from typing import overload
17
+
18
+ try:
19
+ import termios
20
+ except ImportError:
21
+ termios = None
22
+
23
+ RESET = "\033[0m"
24
+ BOLD = "\033[1m"
25
+ RED = "\033[31m"
26
+ GREEN = "\033[32m"
27
+ YELLOW = "\033[33m"
28
+ BLUE = "\033[34m"
29
+ MAGENTA = "\033[35m"
30
+ CYAN = "\033[36m"
31
+ WHITE = "\033[37m"
32
+ BRIGHT_RED = "\033[91m"
33
+ BRIGHT_GREEN = "\033[92m"
34
+ BRIGHT_YELLOW = "\033[93m"
35
+ BRIGHT_BLUE = "\033[94m"
36
+ BRIGHT_CYAN = "\033[96m"
37
+ BRIGHT_WHITE = "\033[97m"
38
+
39
+ ## @var _RUNTIME_OS
40
+ # @brief Cached normalized runtime operating-system token.
41
+ # @details Initialized on first detection and reused to guarantee stable
42
+ # startup-level OS semantics across command execution flow.
43
+ # @satisfies DES-002, REQ-047
44
+ _RUNTIME_OS = None
45
+
46
+ ## @var _MOUSE_OFF_ESCAPE_SEQUENCE
47
+ # @brief Escape-sequence payload that disables known xterm mouse-reporting modes.
48
+ # @details Concatenates CSI mode-off controls for legacy X10 mode (`?9l`) and
49
+ # common xterm mouse tracking modes (`?1000l`..`?1016l`).
50
+ # @satisfies REQ-064, REQ-065
51
+ _MOUSE_OFF_ESCAPE_SEQUENCE = (
52
+ "\x1b[?9l"
53
+ "\x1b[?1000l"
54
+ "\x1b[?1001l"
55
+ "\x1b[?1002l"
56
+ "\x1b[?1003l"
57
+ "\x1b[?1004l"
58
+ "\x1b[?1005l"
59
+ "\x1b[?1006l"
60
+ "\x1b[?1007l"
61
+ "\x1b[?1015l"
62
+ "\x1b[?1016l"
63
+ )
64
+
65
+
66
+ def color_enabled():
67
+ if os.environ.get("NO_COLOR"):
68
+ return False
69
+ return sys.stdout.isatty()
70
+
71
+
72
+ def c(text, color):
73
+ if color_enabled():
74
+ return f"{color}{text}{RESET}"
75
+ return str(text)
76
+
77
+
78
+ def print_info(msg):
79
+ print(c(f"[INFO] {msg}", BRIGHT_BLUE + BOLD))
80
+
81
+
82
+ def print_error(msg):
83
+ print(c(f"[ERROR] {msg}", BRIGHT_RED + BOLD), file=sys.stderr)
84
+
85
+
86
+ def print_warn(msg):
87
+ print(c(f"[WARN] {msg}", BRIGHT_YELLOW + BOLD), file=sys.stderr)
88
+
89
+
90
+ def print_success(msg):
91
+ print(c(f"[OK] {msg}", BRIGHT_GREEN + BOLD))
92
+
93
+
94
+ def get_project_root():
95
+ try:
96
+ result = subprocess.run(
97
+ ["git", "rev-parse", "--show-toplevel"],
98
+ capture_output=True, text=True, timeout=5
99
+ )
100
+ if result.returncode == 0:
101
+ return Path(result.stdout.strip())
102
+ except (subprocess.TimeoutExpired, FileNotFoundError):
103
+ pass
104
+ return None
105
+
106
+
107
+ def require_project_root():
108
+ require_commands("git")
109
+ root = get_project_root()
110
+ if root is None:
111
+ print_error("Cannot determine the project root. Are you inside a git repository?")
112
+ sys.exit(1)
113
+ return root
114
+
115
+
116
+ def detect_runtime_os():
117
+ """@brief Detect and cache runtime operating-system token.
118
+
119
+ @details Normalizes `sys.platform` into deterministic categories
120
+ (`windows`, `linux`, `darwin`, `other`) and stores the result in module
121
+ cache for subsequent calls. Time complexity O(1).
122
+ @return {str} Normalized runtime operating-system token.
123
+ @satisfies DES-002, REQ-047
124
+ """
125
+
126
+ global _RUNTIME_OS
127
+ platform_token = sys.platform.lower()
128
+ if platform_token.startswith("win"):
129
+ _RUNTIME_OS = "windows"
130
+ elif platform_token.startswith("linux"):
131
+ _RUNTIME_OS = "linux"
132
+ elif platform_token.startswith("darwin"):
133
+ _RUNTIME_OS = "darwin"
134
+ else:
135
+ _RUNTIME_OS = "other"
136
+ return _RUNTIME_OS
137
+
138
+
139
+ def get_runtime_os():
140
+ """@brief Return cached runtime operating-system token.
141
+
142
+ @details Lazily initializes the cache via `detect_runtime_os` when unset,
143
+ preserving a single startup-consistent OS classification.
144
+ @return {str} Normalized runtime operating-system token.
145
+ @satisfies DES-002, REQ-047
146
+ """
147
+
148
+ if _RUNTIME_OS is None:
149
+ return detect_runtime_os()
150
+ return _RUNTIME_OS
151
+
152
+
153
+ def is_windows():
154
+ """@brief Check whether runtime operating system is Windows.
155
+
156
+ @details Evaluates cached runtime token from `get_runtime_os`.
157
+ @return {bool} `True` when runtime OS is Windows; otherwise `False`.
158
+ @satisfies DES-013, REQ-008, REQ-047
159
+ """
160
+
161
+ return get_runtime_os() == "windows"
162
+
163
+
164
+ def is_linux():
165
+ """@brief Check whether runtime operating system is Linux.
166
+
167
+ @details Evaluates cached runtime token from `get_runtime_os`.
168
+ @return {bool} `True` when runtime OS is Linux; otherwise `False`.
169
+ @satisfies CTN-004, REQ-004, REQ-005, REQ-047
170
+ """
171
+
172
+ return get_runtime_os() == "linux"
173
+
174
+
175
+ def _is_executable_file(path: Path) -> bool:
176
+ """@brief Validate executable-file capability for a filesystem path.
177
+
178
+ @details Expands user-home markers, checks path kind, and applies platform
179
+ executable checks. On Windows, missing-extension candidates are validated
180
+ against `PATHEXT` suffixes.
181
+ @param path {Path} Candidate executable filesystem path.
182
+ @return {bool} `True` when the path resolves to an executable file.
183
+ @satisfies CTN-003, REQ-055
184
+ """
185
+
186
+ return _resolve_executable_file(path) is not None
187
+
188
+
189
+ def _resolve_executable_file(path: Path) -> Path | None:
190
+ """@brief Resolve an executable filesystem path for the runtime OS.
191
+
192
+ @details Expands user-home markers and resolves Windows extension-less
193
+ candidates through `PATHEXT` fallback checks.
194
+ @param path {Path} Candidate executable filesystem path.
195
+ @return {Path | None} Resolved executable path when runnable, else `None`.
196
+ @satisfies CTN-003, REQ-055
197
+ """
198
+
199
+ expanded = path.expanduser()
200
+ if expanded.suffix:
201
+ if expanded.is_file() and os.access(str(expanded), os.X_OK):
202
+ return expanded
203
+ return None
204
+
205
+ if expanded.is_file() and os.access(str(expanded), os.X_OK):
206
+ return expanded
207
+
208
+ if get_runtime_os() != "windows":
209
+ return None
210
+
211
+ path_ext = os.environ.get("PATHEXT", ".EXE;.CMD;.BAT;.COM")
212
+ for ext in [item for item in path_ext.split(";") if item]:
213
+ candidate = Path(f"{expanded}{ext}")
214
+ if candidate.is_file() and os.access(str(candidate), os.X_OK):
215
+ return candidate
216
+ return None
217
+
218
+
219
+ def resolve_executable_command(command: str) -> str | None:
220
+ """@brief Resolve a runnable command token to an executable path.
221
+
222
+ @details Accepts command names or executable paths and returns the concrete
223
+ runnable executable path when available on the current runtime platform.
224
+ @param command {str} Command token or filesystem path to executable.
225
+ @return {str | None} Resolved executable path or `None` when not runnable.
226
+ @satisfies CTN-003, REQ-055
227
+ """
228
+
229
+ if not isinstance(command, str) or not command.strip():
230
+ return None
231
+
232
+ normalized = command.strip()
233
+ path_like = os.sep in normalized or (os.altsep is not None and os.altsep in normalized)
234
+ if path_like or Path(normalized).is_absolute():
235
+ resolved_path = _resolve_executable_file(Path(normalized))
236
+ return str(resolved_path) if resolved_path is not None else None
237
+
238
+ return shutil.which(normalized)
239
+
240
+
241
+ def is_executable_command(command: str) -> bool:
242
+ """@brief Determine whether an external command is executable on runtime OS.
243
+
244
+ @details Accepts command names or executable paths. Name-based checks use
245
+ `PATH` resolution via `shutil.which`; path-based checks verify executable
246
+ file metadata, including Windows `PATHEXT` variants.
247
+ @param command {str} Command token or filesystem path to executable.
248
+ @return {bool} `True` when command is executable; otherwise `False`.
249
+ @satisfies CTN-003, REQ-055
250
+ """
251
+
252
+ return resolve_executable_command(command) is not None
253
+
254
+
255
+ def command_exists(cmd):
256
+ return is_executable_command(cmd)
257
+
258
+
259
+ @overload
260
+ def require_commands(cmd: str, /) -> str:
261
+ ...
262
+
263
+
264
+ @overload
265
+ def require_commands(cmd1: str, cmd2: str, /, *cmds: str) -> list[str]:
266
+ ...
267
+
268
+
269
+ def require_commands(*cmds: str) -> str | list[str]:
270
+ resolved = []
271
+ for cmd in cmds:
272
+ resolved_cmd = resolve_executable_command(cmd)
273
+ if resolved_cmd is None:
274
+ print_error(f"Command not executable: {cmd}")
275
+ sys.exit(1)
276
+ resolved.append(resolved_cmd)
277
+ if len(resolved) == 1:
278
+ return resolved[0]
279
+ return resolved
280
+
281
+
282
+ def _is_shell_assignment_token(token):
283
+ """@brief Check whether a token is a shell variable assignment.
284
+
285
+ @details Matches `NAME=value` form where `NAME` obeys shell identifier
286
+ syntax and therefore does not represent an executable token.
287
+ @param token {str} Shell token candidate.
288
+ @return {bool} `True` when token is an assignment expression.
289
+ @satisfies REQ-056
290
+ """
291
+
292
+ if "=" not in token:
293
+ return False
294
+ key = token.split("=", 1)[0]
295
+ return key.replace("_", "a").isalnum() and not key[0].isdigit()
296
+
297
+
298
+ def extract_shell_executables(command_line):
299
+ """@brief Extract executable tokens from a shell command line.
300
+
301
+ @details Tokenizes command line using runtime-OS splitting mode and returns
302
+ ordered unique executable candidates at command boundaries (`&&`, `||`,
303
+ `;`, `|`) including wrapper commands such as `sudo`.
304
+ @param command_line {str} Raw shell command line.
305
+ @return {list[str]} Ordered executable token list.
306
+ @satisfies REQ-056
307
+ """
308
+
309
+ if not command_line.strip():
310
+ return []
311
+ try:
312
+ tokens = shlex.split(command_line, posix=get_runtime_os() != "windows")
313
+ except ValueError:
314
+ tokens = command_line.split()
315
+
316
+ boundaries = {"&&", "||", ";", "|"}
317
+ wrapper_commands = {"sudo", "env", "command", "nohup", "time"}
318
+ executables = []
319
+ expect_executable = True
320
+
321
+ for token in tokens:
322
+ if token in boundaries:
323
+ expect_executable = True
324
+ continue
325
+ if not expect_executable:
326
+ continue
327
+ if _is_shell_assignment_token(token):
328
+ continue
329
+ executables.append(token)
330
+ expect_executable = token in wrapper_commands
331
+
332
+ seen = set()
333
+ ordered = []
334
+ for token in executables:
335
+ if token in seen:
336
+ continue
337
+ seen.add(token)
338
+ ordered.append(token)
339
+ return ordered
340
+
341
+
342
+ def require_shell_command_executables(command_line):
343
+ """@brief Validate executable availability for a shell command line.
344
+
345
+ @details Extracts executable tokens from `command_line`, validates each
346
+ token via `is_executable_command`, prints deterministic error with missing
347
+ command name, and terminates process on first failure.
348
+ @param command_line {str} Raw shell command line.
349
+ @return {None} Process exits on validation failure.
350
+ @satisfies REQ-056
351
+ """
352
+
353
+ for token in extract_shell_executables(command_line):
354
+ if not is_executable_command(token):
355
+ print_error(f"Command not executable: {token}")
356
+ sys.exit(1)
357
+
358
+
359
+ def _is_tty_stream(stream):
360
+ """@brief Determine whether a stream is an attached TTY.
361
+
362
+ @details Performs capability checks (`isatty`, callable) and returns
363
+ deterministic boolean without raising on unsupported stream objects.
364
+ Time complexity O(1).
365
+ @param stream {object} Stream-like object (stdin/stdout/stderr candidate).
366
+ @return {bool} `True` when stream supports and reports TTY attachment.
367
+ @satisfies REQ-064
368
+ """
369
+
370
+ isatty = getattr(stream, "isatty", None)
371
+ if not callable(isatty):
372
+ return False
373
+ try:
374
+ return bool(isatty())
375
+ except OSError:
376
+ return False
377
+
378
+
379
+ def capture_terminal_state():
380
+ """@brief Capture current stdin terminal attributes when available.
381
+
382
+ @details Reads current TTY attributes via `termios.tcgetattr` only on
383
+ runtimes exposing `termios` and when stdin is a TTY. Returns `None` when
384
+ attributes are unavailable. Time complexity O(1).
385
+ @return {list[object] | None} Saved TTY attributes for later restoration.
386
+ @satisfies REQ-064
387
+ """
388
+
389
+ if termios is None or not _is_tty_stream(sys.stdin):
390
+ return None
391
+ try:
392
+ stdin_fd = sys.stdin.fileno()
393
+ except (AttributeError, OSError, ValueError):
394
+ return None
395
+ tcgetattr = getattr(termios, "tcgetattr", None)
396
+ if not callable(tcgetattr):
397
+ return None
398
+ try:
399
+ return tcgetattr(stdin_fd)
400
+ except OSError:
401
+ return None
402
+
403
+
404
+ def reset_terminal_state(saved_tty=None):
405
+ """@brief Restore terminal raw/cbreak and mouse-tracking state.
406
+
407
+ @details Restores previously captured stdin termios attributes when present,
408
+ writes `_MOUSE_OFF_ESCAPE_SEQUENCE` (`?9l`, `?1000l`, `?1001l`, `?1002l`,
409
+ `?1003l`, `?1004l`, `?1005l`, `?1006l`, `?1007l`, `?1015l`, `?1016l`) to
410
+ TTY stdout, and best-effort runs `stty sane` for Git Bash/Unix-compatible
411
+ terminals. Failures are ignored to preserve wrapper exit semantics. Time
412
+ complexity O(1).
413
+ @param saved_tty {list[object] | None} Attributes from `capture_terminal_state`.
414
+ @return {None} Performs best-effort terminal-state restoration.
415
+ @satisfies REQ-064, REQ-065
416
+ """
417
+
418
+ tcsetattr = None
419
+ tcsa_drain = None
420
+ if saved_tty is not None and termios is not None and _is_tty_stream(sys.stdin):
421
+ tcsetattr = getattr(termios, "tcsetattr", None)
422
+ tcsa_drain = getattr(termios, "TCSADRAIN", None)
423
+ if (
424
+ saved_tty is not None
425
+ and _is_tty_stream(sys.stdin)
426
+ and callable(tcsetattr)
427
+ and tcsa_drain is not None
428
+ ):
429
+ try:
430
+ tcsetattr(sys.stdin.fileno(), tcsa_drain, saved_tty)
431
+ except (AttributeError, OSError, ValueError):
432
+ pass
433
+
434
+ if _is_tty_stream(sys.stdout):
435
+ try:
436
+ sys.stdout.write(_MOUSE_OFF_ESCAPE_SEQUENCE)
437
+ sys.stdout.flush()
438
+ except OSError:
439
+ pass
440
+
441
+ if _is_tty_stream(sys.stdin):
442
+ try:
443
+ subprocess.run(
444
+ ["stty", "sane"],
445
+ stdin=sys.stdin,
446
+ stdout=subprocess.DEVNULL,
447
+ stderr=subprocess.DEVNULL,
448
+ check=False,
449
+ )
450
+ except OSError:
451
+ pass
452
+
453
+
454
+ def run_cmd(cmd, **kwargs):
455
+ return subprocess.run(cmd, **kwargs)