dktcli 1.0.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.
Files changed (42) hide show
  1. devkittools/__init__.py +3 -0
  2. devkittools/__main__.py +6 -0
  3. devkittools/cli.py +179 -0
  4. devkittools/tools/__init__.py +1 -0
  5. devkittools/tools/bench.py +73 -0
  6. devkittools/tools/changeloggen.py +181 -0
  7. devkittools/tools/clean.py +110 -0
  8. devkittools/tools/colortool.py +126 -0
  9. devkittools/tools/commitwriter.py +199 -0
  10. devkittools/tools/contextpacker.py +226 -0
  11. devkittools/tools/cron.py +152 -0
  12. devkittools/tools/doctor.py +74 -0
  13. devkittools/tools/du.py +72 -0
  14. devkittools/tools/enc.py +131 -0
  15. devkittools/tools/envtool.py +141 -0
  16. devkittools/tools/findtool.py +119 -0
  17. devkittools/tools/gen.py +182 -0
  18. devkittools/tools/gx.py +184 -0
  19. devkittools/tools/hashtool.py +93 -0
  20. devkittools/tools/httptool.py +131 -0
  21. devkittools/tools/ignore.py +213 -0
  22. devkittools/tools/initproj.py +166 -0
  23. devkittools/tools/jsontool.py +200 -0
  24. devkittools/tools/net.py +114 -0
  25. devkittools/tools/portkiller.py +246 -0
  26. devkittools/tools/regextool.py +84 -0
  27. devkittools/tools/repostats.py +197 -0
  28. devkittools/tools/secrets_scan.py +108 -0
  29. devkittools/tools/serve.py +118 -0
  30. devkittools/tools/snip.py +94 -0
  31. devkittools/tools/system.py +157 -0
  32. devkittools/tools/timetool.py +130 -0
  33. devkittools/tools/todo.py +69 -0
  34. devkittools/tools/tree.py +86 -0
  35. devkittools/tools/watch.py +82 -0
  36. devkittools/utils.py +561 -0
  37. dktcli-1.0.0.dist-info/METADATA +148 -0
  38. dktcli-1.0.0.dist-info/RECORD +42 -0
  39. dktcli-1.0.0.dist-info/WHEEL +5 -0
  40. dktcli-1.0.0.dist-info/entry_points.txt +3 -0
  41. dktcli-1.0.0.dist-info/licenses/LICENSE +21 -0
  42. dktcli-1.0.0.dist-info/top_level.txt +1 -0
devkittools/utils.py ADDED
@@ -0,0 +1,561 @@
1
+ """Shared helpers for devkittools."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import re
7
+ import shutil
8
+ import subprocess
9
+ import sys
10
+ import tempfile
11
+ import threading
12
+ import time
13
+ from pathlib import Path
14
+ from typing import List, Optional, Sequence, Tuple
15
+
16
+
17
+ class DktError(Exception):
18
+ """A user-facing error. Printed without a traceback."""
19
+
20
+
21
+ # ----------------------------------------------------------------- console
22
+
23
+ _COLOR = False
24
+ _CODES = {
25
+ "black": "30", "red": "31", "green": "32", "yellow": "33", "blue": "34",
26
+ "magenta": "35", "cyan": "36", "white": "37", "gray": "90",
27
+ "bred": "91", "bgreen": "92", "byellow": "93", "bblue": "94",
28
+ "bmagenta": "95", "bcyan": "96", "bwhite": "97",
29
+ "bold": "1", "dim": "2", "italic": "3", "underline": "4", "reverse": "7",
30
+ }
31
+
32
+
33
+ def setup_console() -> None:
34
+ """Make output UTF-8 safe and enable ANSI colors when possible."""
35
+ global _COLOR
36
+ for stream in (sys.stdout, sys.stderr):
37
+ try:
38
+ stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined]
39
+ except Exception:
40
+ pass
41
+ if os.environ.get("NO_COLOR"):
42
+ _COLOR = False
43
+ return
44
+ if sys.stdout.isatty():
45
+ if os.name == "nt":
46
+ os.system("") # enables ANSI escape processing on Windows 10+
47
+ _COLOR = True
48
+
49
+
50
+ def c(text, color: str) -> str:
51
+ """Colorize text. `color` may combine styles: c("hi", "bold cyan")."""
52
+ if not _COLOR:
53
+ return str(text)
54
+ codes = ";".join(_CODES[x] for x in color.split())
55
+ return "\033[%sm%s\033[0m" % (codes, text)
56
+
57
+
58
+ def info(msg: str = "") -> None:
59
+ """Status output goes to stderr so stdout stays clean for piping."""
60
+ print(msg, file=sys.stderr)
61
+
62
+
63
+ def print_table(headers: Sequence[str], rows: Sequence[Sequence[object]], indent: str = " ") -> None:
64
+ widths = [len(h) for h in headers]
65
+ srows = [[str(x) for x in r] for r in rows]
66
+ for r in srows:
67
+ for i, cell in enumerate(r):
68
+ widths[i] = max(widths[i], len(cell))
69
+ head = " ".join(h.ljust(widths[i]) for i, h in enumerate(headers))
70
+ print(indent + c(head, "bold"))
71
+ print(indent + c(" ".join("-" * w for w in widths), "dim"))
72
+ for r in srows:
73
+ print(indent + " ".join(cell.ljust(widths[i]) for i, cell in enumerate(r)))
74
+
75
+
76
+ def bar(value: float, maximum: float, width: int = 20) -> str:
77
+ if maximum <= 0:
78
+ filled = 0
79
+ else:
80
+ filled = int(round(width * value / maximum))
81
+ return "█" * filled + "░" * (width - filled)
82
+
83
+
84
+ def human_size(n: float) -> str:
85
+ for unit in ("B", "KB", "MB", "GB"):
86
+ if n < 1024 or unit == "GB":
87
+ return ("%d %s" % (n, unit)) if unit == "B" else ("%.1f %s" % (n, unit))
88
+ n /= 1024.0
89
+ return "%d B" % n
90
+
91
+
92
+ def human_num(n: int) -> str:
93
+ return "{:,}".format(n)
94
+
95
+
96
+ def str2bool(value) -> bool:
97
+ if isinstance(value, bool):
98
+ return value
99
+ v = str(value).strip().lower()
100
+ if v in ("1", "true", "t", "yes", "y", "on"):
101
+ return True
102
+ if v in ("0", "false", "f", "no", "n", "off", ""):
103
+ return False
104
+ raise DktError("Expected true/false but got '%s'" % value)
105
+
106
+
107
+ # ----------------------------------------------------------------- processes
108
+
109
+ def decode_output(data: bytes) -> str:
110
+ if os.name == "nt":
111
+ try:
112
+ return data.decode("oem", errors="replace")
113
+ except LookupError:
114
+ pass
115
+ return data.decode("utf-8", errors="replace")
116
+
117
+
118
+ def run_capture(cmd: Sequence[str]) -> Tuple[int, str, str]:
119
+ """Run a command, return (returncode, stdout, stderr). Never raises on non-zero."""
120
+ try:
121
+ r = subprocess.run(list(cmd), stdout=subprocess.PIPE, stderr=subprocess.PIPE,
122
+ stdin=subprocess.DEVNULL)
123
+ except FileNotFoundError:
124
+ raise DktError("Command not found: %s" % cmd[0])
125
+ return r.returncode, decode_output(r.stdout), decode_output(r.stderr)
126
+
127
+
128
+ # ----------------------------------------------------------------- folders / git
129
+
130
+ def resolve_folder(value: Optional[str]) -> Path:
131
+ p = Path(value).expanduser() if value else Path.cwd()
132
+ p = p.resolve()
133
+ if not p.is_dir():
134
+ raise DktError("Folder not found: %s" % p)
135
+ return p
136
+
137
+
138
+ def git(folder: Path, *args: str, check: bool = True) -> str:
139
+ """Run git inside `folder` and return stdout (UTF-8)."""
140
+ cmd = ["git", "-C", str(folder), "-c", "core.quotepath=false"] + list(args)
141
+ try:
142
+ r = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
143
+ stdin=subprocess.DEVNULL)
144
+ except FileNotFoundError:
145
+ raise DktError("git is not installed or not on PATH. Get it at https://git-scm.com")
146
+ if r.returncode != 0:
147
+ if check:
148
+ msg = r.stderr.decode("utf-8", errors="replace").strip()
149
+ raise DktError(msg or "git %s failed" % " ".join(args))
150
+ return ""
151
+ return r.stdout.decode("utf-8", errors="replace")
152
+
153
+
154
+ def is_git_repo(folder: Path) -> bool:
155
+ try:
156
+ return git(folder, "rev-parse", "--is-inside-work-tree", check=False).strip() == "true"
157
+ except DktError:
158
+ return False
159
+
160
+
161
+ def require_git_repo(folder: Path) -> None:
162
+ if not is_git_repo(folder):
163
+ raise DktError("%s is not inside a git repository." % folder)
164
+
165
+
166
+ IGNORE_DIRS = {
167
+ ".git", ".hg", ".svn", "node_modules", "__pycache__", ".venv", "venv", "env",
168
+ ".idea", ".vscode", "dist", "build", ".mypy_cache", ".pytest_cache", ".tox",
169
+ "target", ".next", ".gradle", ".cache", "coverage",
170
+ }
171
+
172
+
173
+ def list_project_files(folder: Path) -> List[Path]:
174
+ """Relative paths of project files. Respects .gitignore inside git repos."""
175
+ if is_git_repo(folder):
176
+ try:
177
+ r = subprocess.run(
178
+ ["git", "-C", str(folder), "ls-files", "-co", "--exclude-standard", "-z"],
179
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=subprocess.DEVNULL, check=True)
180
+ names = [n for n in r.stdout.decode("utf-8", errors="replace").split("\0") if n]
181
+ files = [Path(n) for n in names if (folder / n).is_file()]
182
+ return sorted(set(files))
183
+ except Exception:
184
+ pass
185
+ files = []
186
+ for root, dirs, fnames in os.walk(str(folder)):
187
+ dirs[:] = [d for d in dirs if d not in IGNORE_DIRS and not d.endswith(".egg-info")]
188
+ for f in fnames:
189
+ files.append(Path(root, f).relative_to(folder))
190
+ return sorted(files)
191
+
192
+
193
+ def is_binary_chunk(chunk: bytes) -> bool:
194
+ return b"\0" in chunk
195
+
196
+
197
+ # ----------------------------------------------------------------- clipboard
198
+
199
+ def copy_to_clipboard(text: str) -> bool:
200
+ try:
201
+ if os.name == "nt":
202
+ with tempfile.NamedTemporaryFile("w", suffix=".txt", delete=False, encoding="utf-8") as f:
203
+ f.write(text)
204
+ tmp = f.name
205
+ try:
206
+ cmd = "Get-Content -Raw -Encoding UTF8 -LiteralPath '%s' | Set-Clipboard" % tmp
207
+ r = subprocess.run(["powershell", "-NoProfile", "-Command", cmd],
208
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
209
+ return r.returncode == 0
210
+ finally:
211
+ try:
212
+ os.unlink(tmp)
213
+ except OSError:
214
+ pass
215
+ if sys.platform == "darwin" and shutil.which("pbcopy"):
216
+ subprocess.run(["pbcopy"], input=text.encode("utf-8"), check=True)
217
+ return True
218
+ for tool in (["wl-copy"], ["xclip", "-selection", "clipboard"], ["xsel", "--clipboard", "--input"]):
219
+ if shutil.which(tool[0]):
220
+ subprocess.run(tool, input=text.encode("utf-8"), check=True)
221
+ return True
222
+ except Exception:
223
+ return False
224
+ return False
225
+
226
+
227
+ # ================================================================= UI toolkit
228
+ # Everything below is pure stdlib: boxes, banners, spinners, prompts, config.
229
+
230
+ _ANSI_RE = re.compile(r"\033\[[0-9;]*m")
231
+
232
+
233
+ def color_enabled() -> bool:
234
+ return _COLOR
235
+
236
+
237
+ def vlen(text: str) -> int:
238
+ """Visible length of a string (ANSI escapes removed)."""
239
+ return len(_ANSI_RE.sub("", text))
240
+
241
+
242
+ def strip_ansi(text: str) -> str:
243
+ return _ANSI_RE.sub("", text)
244
+
245
+
246
+ def term_width(default: int = 80) -> int:
247
+ try:
248
+ return max(40, min(shutil.get_terminal_size((default, 24)).columns, 140))
249
+ except Exception:
250
+ return default
251
+
252
+
253
+ _UNICODE_SYMS = {
254
+ "ok": "✔", "fail": "✘", "warn": "▲", "arrow": "➜", "dot": "•", "info": "ℹ",
255
+ "tl": "╭", "tr": "╮", "bl": "╰", "br": "╯", "h": "─", "v": "│",
256
+ "t": "├", "l": "└", "pipe": "│",
257
+ }
258
+ _ASCII_SYMS = {
259
+ "ok": "+", "fail": "x", "warn": "!", "arrow": ">", "dot": "*", "info": "i",
260
+ "tl": "+", "tr": "+", "bl": "+", "br": "+", "h": "-", "v": "|",
261
+ "t": "|", "l": "`", "pipe": "|",
262
+ }
263
+
264
+
265
+ def sym(name: str) -> str:
266
+ if os.environ.get("DKT_ASCII"):
267
+ return _ASCII_SYMS[name]
268
+ enc = (getattr(sys.stdout, "encoding", None) or "").lower()
269
+ return (_UNICODE_SYMS if "utf" in enc else _ASCII_SYMS)[name]
270
+
271
+
272
+ def ok(msg: str) -> None:
273
+ print("%s %s" % (c(sym("ok"), "bgreen"), msg))
274
+
275
+
276
+ def warn(msg: str) -> None:
277
+ print("%s %s" % (c(sym("warn"), "byellow"), msg))
278
+
279
+
280
+ def fail(msg: str) -> None:
281
+ print("%s %s" % (c(sym("fail"), "bred"), msg))
282
+
283
+
284
+ def note(msg: str) -> None:
285
+ print("%s %s" % (c(sym("info"), "bcyan"), msg))
286
+
287
+
288
+ def heading(title: str, subtitle: str = "") -> None:
289
+ """Section heading with a rule underneath."""
290
+ print()
291
+ print(" " + c(title, "bold bcyan") + ((" " + c(subtitle, "gray")) if subtitle else ""))
292
+ print(" " + c(sym("h") * min(term_width() - 4, max(vlen(title) + vlen(subtitle) + 1, 24)), "gray"))
293
+
294
+
295
+ def rule(title: str = "", width: Optional[int] = None) -> str:
296
+ width = width or term_width() - 2
297
+ if not title:
298
+ return c(sym("h") * width, "gray")
299
+ left = sym("h") * 3 + " "
300
+ right = " " + sym("h") * max(3, width - vlen(title) - 5)
301
+ return c(left, "gray") + c(title, "bold") + c(right, "gray")
302
+
303
+
304
+ def box(title: str, lines: Sequence[str], color: str = "bcyan", width: Optional[int] = None) -> None:
305
+ """Draw a rounded box around lines of text."""
306
+ lines = [str(x) for x in lines]
307
+ inner = max([vlen(x) for x in lines] + [vlen(title) + 2, 20])
308
+ if width:
309
+ inner = max(inner, width - 4)
310
+ inner = min(inner, term_width() - 4)
311
+ top_title = (" " + title + " ") if title else ""
312
+ top = c(sym("tl") + sym("h"), color) + c(top_title, "bold " + color) + \
313
+ c(sym("h") * max(0, inner + 1 - vlen(top_title)) + sym("tr"), color)
314
+ print(top)
315
+ for ln in lines:
316
+ pad = " " * max(0, inner - vlen(ln))
317
+ print(c(sym("v"), color) + " " + ln + pad + " " + c(sym("v"), color))
318
+ print(c(sym("bl") + sym("h") * (inner + 2) + sym("br"), color))
319
+
320
+
321
+ def kv(rows: Sequence[Tuple[str, object]], indent: str = " ", key_color: str = "gray") -> None:
322
+ """Aligned key/value list."""
323
+ if not rows:
324
+ return
325
+ w = max(len(str(k)) for k, _ in rows)
326
+ for k, v in rows:
327
+ print("%s%s %s" % (indent, c(str(k).ljust(w), key_color), v))
328
+
329
+
330
+ _GRADIENT = (51, 45, 39, 33, 99, 135, 171, 207)
331
+
332
+
333
+ def gradient(lines: Sequence[str]) -> List[str]:
334
+ """Color lines with a cyan -> magenta gradient (256-color terminals)."""
335
+ if not _COLOR:
336
+ return list(lines)
337
+ out = []
338
+ n = max(1, len(lines) - 1)
339
+ for i, ln in enumerate(lines):
340
+ code = _GRADIENT[int(round(i / n * (len(_GRADIENT) - 1)))]
341
+ out.append("\033[38;5;%dm%s\033[0m" % (code, ln))
342
+ return out
343
+
344
+
345
+ _LOGO = [
346
+ r" ____ _ _______ ",
347
+ r" | _ \| |/ /_ _|",
348
+ r" | | | | ' / | | ",
349
+ r" | |_| | . \ | | ",
350
+ r" |____/|_|\_\ |_| ",
351
+ ]
352
+
353
+
354
+ def banner(subtitle: str = "") -> None:
355
+ from . import __version__
356
+ logo = gradient(_LOGO)
357
+ side = [
358
+ "",
359
+ c("devkittools", "bold") + " " + c("v" + __version__, "gray"),
360
+ c("the developer's swiss army knife", "gray"),
361
+ c(subtitle, "bcyan") if subtitle else "",
362
+ "",
363
+ ]
364
+ print()
365
+ for a, b in zip(logo, side):
366
+ print(a + " " + b)
367
+ print()
368
+
369
+
370
+ def table(headers: Sequence[str], rows: Sequence[Sequence[object]], indent: str = " ") -> None:
371
+ """Like print_table but ANSI-aware so cells can be colored."""
372
+ srows = [[str(x) for x in r] for r in rows]
373
+ widths = [vlen(h) for h in headers]
374
+ for r in srows:
375
+ for i, cell in enumerate(r):
376
+ widths[i] = max(widths[i], vlen(cell))
377
+ pad = lambda s, w: s + " " * (w - vlen(s)) # noqa: E731
378
+ print(indent + c(" ".join(pad(h, widths[i]) for i, h in enumerate(headers)), "bold"))
379
+ print(indent + c(" ".join(sym("h") * w for w in widths), "gray"))
380
+ for r in srows:
381
+ print(indent + " ".join(pad(cell, widths[i]) for i, cell in enumerate(r)))
382
+
383
+
384
+ class Spinner:
385
+ """`with Spinner("working..."):` - animated on a TTY, silent otherwise."""
386
+
387
+ FRAMES_U = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
388
+ FRAMES_A = "|/-\\"
389
+
390
+ def __init__(self, text: str = "working"):
391
+ self.text = text
392
+ self._stop = threading.Event()
393
+ self._thread = None # type: Optional[threading.Thread]
394
+ self._active = False
395
+
396
+ def update(self, text: str) -> None:
397
+ self.text = text
398
+
399
+ def _spin(self) -> None:
400
+ frames = self.FRAMES_U if sym("ok") != "+" else self.FRAMES_A
401
+ i = 0
402
+ while not self._stop.is_set():
403
+ sys.stderr.write("\r %s %s\033[K" % (c(frames[i % len(frames)], "bcyan"), self.text))
404
+ sys.stderr.flush()
405
+ i += 1
406
+ time.sleep(0.08)
407
+
408
+ def __enter__(self):
409
+ if _COLOR and sys.stderr.isatty():
410
+ self._active = True
411
+ self._thread = threading.Thread(target=self._spin, daemon=True)
412
+ self._thread.start()
413
+ return self
414
+
415
+ def __exit__(self, *exc):
416
+ if self._active and self._thread:
417
+ self._stop.set()
418
+ self._thread.join()
419
+ sys.stderr.write("\r\033[K")
420
+ sys.stderr.flush()
421
+ return False
422
+
423
+
424
+ # ----------------------------------------------------------------- prompts
425
+
426
+ def is_interactive() -> bool:
427
+ try:
428
+ return sys.stdin.isatty() and sys.stdout.isatty()
429
+ except Exception:
430
+ return False
431
+
432
+
433
+ def ask(prompt: str, default: Optional[str] = None) -> str:
434
+ if not is_interactive():
435
+ return default or ""
436
+ suffix = " [%s]" % default if default else ""
437
+ try:
438
+ val = input("%s %s%s: " % (c(sym("arrow"), "bcyan"), prompt, c(suffix, "gray"))).strip()
439
+ except EOFError:
440
+ return default or ""
441
+ return val or (default or "")
442
+
443
+
444
+ def confirm(prompt: str, default: bool = False) -> bool:
445
+ if not is_interactive():
446
+ return default
447
+ hint = "Y/n" if default else "y/N"
448
+ try:
449
+ val = input("%s %s %s " % (c("?", "bmagenta"), prompt, c("(%s)" % hint, "gray"))).strip().lower()
450
+ except EOFError:
451
+ return default
452
+ if not val:
453
+ return default
454
+ return val in ("y", "yes")
455
+
456
+
457
+ # ----------------------------------------------------------------- input helpers
458
+
459
+ def paste_from_clipboard() -> Optional[str]:
460
+ try:
461
+ if os.name == "nt":
462
+ r = subprocess.run(["powershell", "-NoProfile", "-Command", "Get-Clipboard -Raw"],
463
+ stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
464
+ return r.stdout.decode("utf-8", errors="replace").rstrip("\r\n") if r.returncode == 0 else None
465
+ if sys.platform == "darwin" and shutil.which("pbpaste"):
466
+ return subprocess.run(["pbpaste"], stdout=subprocess.PIPE).stdout.decode("utf-8", errors="replace")
467
+ for tool in (["wl-paste", "-n"], ["xclip", "-selection", "clipboard", "-o"], ["xsel", "--clipboard", "--output"]):
468
+ if shutil.which(tool[0]):
469
+ return subprocess.run(tool, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL).stdout.decode("utf-8", errors="replace")
470
+ except Exception:
471
+ return None
472
+ return None
473
+
474
+
475
+ def read_input(text: Optional[str] = None, file: Optional[str] = None, paste: bool = False,
476
+ what: str = "input") -> str:
477
+ """Get text from (in order): --file, positional text, --paste, piped stdin."""
478
+ if file:
479
+ p = Path(file).expanduser()
480
+ if not p.is_file():
481
+ raise DktError("File not found: %s" % p)
482
+ return p.read_text(encoding="utf-8", errors="replace")
483
+ if text is not None and text != "-":
484
+ return text
485
+ if paste:
486
+ got = paste_from_clipboard()
487
+ if got is None:
488
+ raise DktError("Could not read the clipboard on this system.")
489
+ return got
490
+ if text == "-" or not sys.stdin.isatty():
491
+ return sys.stdin.read()
492
+ raise DktError("No %s given. Pass it as an argument, use --file/--paste, or pipe it in." % what)
493
+
494
+
495
+ def output_result(text: str, copy: bool = False, quiet: bool = False) -> None:
496
+ """Print text to stdout and optionally copy it to the clipboard."""
497
+ print(text)
498
+ if copy:
499
+ if copy_to_clipboard(text):
500
+ if not quiet:
501
+ info(c("%s copied to clipboard" % sym("ok"), "green"))
502
+ else:
503
+ info(c("could not copy to clipboard on this system", "yellow"))
504
+
505
+
506
+ # ----------------------------------------------------------------- config
507
+
508
+ def config_dir() -> Path:
509
+ if os.name == "nt":
510
+ base = os.environ.get("APPDATA") or str(Path.home() / "AppData" / "Roaming")
511
+ else:
512
+ base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
513
+ return Path(base) / "dkt"
514
+
515
+
516
+ def load_json_file(path: Path, default):
517
+ try:
518
+ return json.loads(path.read_text(encoding="utf-8"))
519
+ except Exception:
520
+ return default
521
+
522
+
523
+ def save_json_file(path: Path, data) -> None:
524
+ path.parent.mkdir(parents=True, exist_ok=True)
525
+ path.write_text(json.dumps(data, indent=2, ensure_ascii=False, sort_keys=True), encoding="utf-8")
526
+
527
+
528
+ def load_config() -> dict:
529
+ return load_json_file(config_dir() / "config.json", {})
530
+
531
+
532
+ def get_setting(key: str, default=None):
533
+ """Environment variable DKT_<KEY> wins, then ~/.config/dkt/config.json."""
534
+ env = os.environ.get("DKT_" + key.upper())
535
+ if env:
536
+ return env
537
+ return load_config().get(key, default)
538
+
539
+
540
+ # ----------------------------------------------------------------- misc
541
+
542
+ def tool_version(cmd: Sequence[str]) -> Optional[str]:
543
+ """First line of `cmd --version`, or None if the tool is missing."""
544
+ if not shutil.which(cmd[0]):
545
+ return None
546
+ try:
547
+ r = subprocess.run(list(cmd), stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
548
+ stdin=subprocess.DEVNULL, timeout=10)
549
+ out = decode_output(r.stdout).strip().splitlines()
550
+ return out[0].strip() if out else "installed"
551
+ except Exception:
552
+ return "installed"
553
+
554
+
555
+ def walk_files(folder: Path, skip_dirs=None):
556
+ """Yield files under folder, skipping heavy/vcs directories (no gitignore needed)."""
557
+ skip = IGNORE_DIRS if skip_dirs is None else skip_dirs
558
+ for root, dirs, files in os.walk(str(folder)):
559
+ dirs[:] = [d for d in dirs if d not in skip and not d.endswith(".egg-info")]
560
+ for f in files:
561
+ yield Path(root, f)