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
@@ -0,0 +1,197 @@
1
+ """dkt repostats (rs) - quick stats and health summary for a project."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import os
7
+ from collections import Counter
8
+ from pathlib import Path
9
+ from typing import Dict, List, Optional
10
+
11
+ from ..utils import (bar, c, git, human_num, human_size, is_git_repo, list_project_files,
12
+ resolve_folder)
13
+
14
+ EPILOG = """\
15
+ examples:
16
+ dkt rs stats for the current folder
17
+ dkt rs --folder "C:\\code\\app" --top 10
18
+ dkt rs --json machine-readable output
19
+ """
20
+
21
+ LANGS = {
22
+ ".py": "Python", ".js": "JavaScript", ".mjs": "JavaScript", ".cjs": "JavaScript",
23
+ ".jsx": "JavaScript (JSX)", ".ts": "TypeScript", ".tsx": "TypeScript (TSX)",
24
+ ".java": "Java", ".kt": "Kotlin", ".c": "C", ".h": "C/C++ Header", ".hpp": "C/C++ Header",
25
+ ".cpp": "C++", ".cc": "C++", ".cs": "C#", ".go": "Go", ".rs": "Rust", ".rb": "Ruby",
26
+ ".php": "PHP", ".swift": "Swift", ".lua": "Lua", ".sh": "Shell", ".bash": "Shell",
27
+ ".ps1": "PowerShell", ".bat": "Batch", ".cmd": "Batch", ".html": "HTML", ".htm": "HTML",
28
+ ".css": "CSS", ".scss": "SCSS", ".sass": "SCSS", ".less": "Less", ".json": "JSON",
29
+ ".yml": "YAML", ".yaml": "YAML", ".toml": "TOML", ".xml": "XML", ".md": "Markdown",
30
+ ".txt": "Text", ".sql": "SQL", ".vue": "Vue", ".svelte": "Svelte", ".dart": "Dart",
31
+ ".r": "R", ".mcfunction": "mcfunction", ".ini": "INI", ".cfg": "Config", ".gradle": "Gradle",
32
+ }
33
+ NAMES = {"dockerfile": "Dockerfile", "makefile": "Makefile"}
34
+ MAX_READ = 5 * 1024 * 1024
35
+
36
+
37
+ def language_of(path: Path) -> str:
38
+ n = path.name.lower()
39
+ if n in NAMES:
40
+ return NAMES[n]
41
+ return LANGS.get(path.suffix.lower(), "Other")
42
+
43
+
44
+ def collect(folder: Path, files: List[Path]) -> dict:
45
+ langs = {} # type: Dict[str, List[int]]
46
+ biggest = []
47
+ total = {"files": 0, "text_files": 0, "lines": 0, "blank": 0, "bytes": 0, "skipped": 0}
48
+ for rel in files:
49
+ full = folder / rel
50
+ try:
51
+ size = full.stat().st_size
52
+ except OSError:
53
+ continue
54
+ total["files"] += 1
55
+ total["bytes"] += size
56
+ if size > MAX_READ:
57
+ total["skipped"] += 1
58
+ continue
59
+ try:
60
+ data = full.read_bytes()
61
+ except OSError:
62
+ continue
63
+ if b"\0" in data[:8192]:
64
+ total["skipped"] += 1
65
+ continue
66
+ lines = data.count(b"\n") + (1 if data and not data.endswith(b"\n") else 0)
67
+ blank = sum(1 for ln in data.splitlines() if not ln.strip())
68
+ lang = language_of(rel)
69
+ entry = langs.setdefault(lang, [0, 0, 0])
70
+ entry[0] += 1
71
+ entry[1] += lines
72
+ entry[2] += lines - blank
73
+ total["text_files"] += 1
74
+ total["lines"] += lines
75
+ total["blank"] += blank
76
+ biggest.append((lines, rel.as_posix(), size))
77
+ biggest.sort(reverse=True)
78
+ return {"langs": langs, "biggest": biggest, "total": total}
79
+
80
+
81
+ def git_stats(folder: Path, top: int, known: set) -> Optional[dict]:
82
+ if not is_git_repo(folder):
83
+ return None
84
+ try:
85
+ commits = int(git(folder, "rev-list", "--count", "HEAD", check=False).strip() or 0)
86
+ except ValueError:
87
+ commits = 0
88
+ if commits == 0:
89
+ return None
90
+ contributors = []
91
+ for line in git(folder, "shortlog", "-sn", "--no-merges", "HEAD", check=False).splitlines():
92
+ if "\t" in line:
93
+ n, name = line.strip().split("\t", 1)
94
+ contributors.append((name.strip(), int(n)))
95
+ roots = [d for d in git(folder, "log", "--max-parents=0", "--format=%ad", "--date=short",
96
+ check=False).split() if d]
97
+ last = git(folder, "log", "-1", "--format=%ad", "--date=short", check=False).strip()
98
+ churn = Counter()
99
+ out = git(folder, "log", "-n", "3000", "--name-only", "--pretty=format:", "--relative", check=False)
100
+ for line in out.splitlines():
101
+ line = line.strip()
102
+ if line and line in known:
103
+ churn[line] += 1
104
+ return {"commits": commits, "contributors": contributors, "first": min(roots) if roots else "?",
105
+ "last": last, "most_changed": churn.most_common(top)}
106
+
107
+
108
+ def health(folder: Path) -> List[tuple]:
109
+ names = {n.lower() for n in os.listdir(str(folder))}
110
+ has_tests = any(n in names for n in ("tests", "test", "__tests__", "spec")) or any(
111
+ n.startswith("test_") for n in names)
112
+ return [
113
+ ("README", any(n.startswith("readme") for n in names)),
114
+ ("LICENSE", any(n.startswith(("license", "licence", "copying")) for n in names)),
115
+ (".gitignore", ".gitignore" in names),
116
+ ("Tests", has_tests),
117
+ ("CI config", (folder / ".github" / "workflows").is_dir() or ".gitlab-ci.yml" in names),
118
+ ]
119
+
120
+
121
+ def run(args: argparse.Namespace) -> int:
122
+ folder = resolve_folder(args.folder)
123
+ files = list_project_files(folder)
124
+ data = collect(folder, files)
125
+ known = {f.as_posix() for f in files}
126
+ gs = git_stats(folder, args.top, known)
127
+ hc = health(folder)
128
+ t = data["total"]
129
+ langs = sorted(data["langs"].items(), key=lambda kv: kv[1][1], reverse=True)
130
+
131
+ if args.json:
132
+ print(json.dumps({
133
+ "folder": str(folder), "totals": t,
134
+ "languages": {k: {"files": v[0], "lines": v[1], "code_lines": v[2]} for k, v in langs},
135
+ "largest_files": [{"path": p, "lines": l, "bytes": s} for l, p, s in data["biggest"][:args.top]],
136
+ "git": gs, "health": {k: v for k, v in hc},
137
+ }, indent=2))
138
+ return 0
139
+
140
+ print()
141
+ print(c(" RepoStats", "bold") + " - " + c(folder.name, "cyan"))
142
+ print(c(" " + str(folder), "dim"))
143
+ print()
144
+ print(c(" Overview", "bold"))
145
+ print(" Files %s (%s)" % (human_num(t["files"]), human_size(t["bytes"])))
146
+ print(" Lines %s (code %s, blank %s)" % (
147
+ human_num(t["lines"]), human_num(t["lines"] - t["blank"]), human_num(t["blank"])))
148
+ print(" Languages %d" % len(langs))
149
+ if t["skipped"]:
150
+ print(c(" (%d binary/huge files not counted for lines)" % t["skipped"], "dim"))
151
+ print()
152
+ if langs:
153
+ print(c(" Languages", "bold"))
154
+ mx = max(v[1] for _, v in langs) or 1
155
+ for name, (nf, nl, _) in langs[:12]:
156
+ pct = 100.0 * nl / t["lines"] if t["lines"] else 0
157
+ print(" %-18s %5s %-5s %9s lines %5.1f%% %s" % (name, human_num(nf), "file" if nf == 1 else "files", human_num(nl), pct,
158
+ c(bar(nl, mx, 16), "cyan")))
159
+ print()
160
+ if data["biggest"]:
161
+ print(c(" Largest files (by lines)", "bold"))
162
+ for lines, path, size in data["biggest"][:args.top]:
163
+ print(" %7s %s" % (human_num(lines), path))
164
+ print()
165
+ if gs:
166
+ print(c(" Git", "bold"))
167
+ print(" Commits %s (%s -> %s)" % (human_num(gs["commits"]), gs["first"], gs["last"]))
168
+ print(" Authors %d" % len(gs["contributors"]))
169
+ for name, n in gs["contributors"][:args.top]:
170
+ print(" %5d %s" % (n, name))
171
+ if gs["most_changed"]:
172
+ print()
173
+ print(c(" Most changed files", "bold"))
174
+ for path, n in gs["most_changed"]:
175
+ print(" %5dx %s" % (n, path))
176
+ print()
177
+ print(c(" Health check", "bold"))
178
+ for name, ok in hc:
179
+ print(" %s %s" % (c("✔", "green") if ok else c("✘", "red"), name))
180
+ print()
181
+ return 0
182
+
183
+
184
+ def register(sub, common) -> None:
185
+ p = sub.add_parser(
186
+ "repostats", aliases=["rs"], parents=[common],
187
+ help="language breakdown, biggest files, git activity",
188
+ description="Quick stats for any project: lines per language, biggest files,\n"
189
+ "most-changed files, contributors and a health checklist.",
190
+ epilog=EPILOG, formatter_class=argparse.RawDescriptionHelpFormatter)
191
+ p.add_argument("-f", "--folder", metavar="PATH", help="project folder (default: current folder)")
192
+ p.add_argument("-n", "--top", type=int, default=5, metavar="N", help="rows in top-N lists (default 5)")
193
+ p.add_argument("--json", action="store_true", help="output JSON")
194
+ p.set_defaults(func=run)
195
+
196
+
197
+ META = ("Project & Code", "repostats", "rs", "lines per language, biggest files, git activity, health")
@@ -0,0 +1,108 @@
1
+ """dkt secrets (sec) - scan a project for accidentally committed secrets."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import math
7
+ import re
8
+ from collections import Counter
9
+
10
+ from ..utils import c, fail, is_binary_chunk, list_project_files, ok, resolve_folder, sym, warn
11
+
12
+ META = ("Project & Code", "secrets", "sec", "scan for leaked API keys, tokens, private keys")
13
+
14
+ EPILOG = """\
15
+ examples:
16
+ dkt sec scan the current project
17
+ dkt sec --entropy also flag random-looking strings assigned to keys
18
+ dkt sec --json for CI; exits 1 when something is found
19
+ Values are always masked in the output.
20
+ """
21
+
22
+ PATTERNS = [
23
+ ("AWS access key", re.compile(r"\b(AKIA|ASIA)[0-9A-Z]{16}\b")),
24
+ ("GitHub token", re.compile(r"\b(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,}\b")),
25
+ ("GitHub fine-grained token", re.compile(r"\bgithub_pat_[A-Za-z0-9_]{50,}\b")),
26
+ ("Slack token", re.compile(r"\bxox[abprs]-[A-Za-z0-9-]{10,}\b")),
27
+ ("Stripe live key", re.compile(r"\b[sr]k_live_[A-Za-z0-9]{16,}\b")),
28
+ ("Google API key", re.compile(r"\bAIza[0-9A-Za-z_\-]{35}\b")),
29
+ ("OpenAI-style key", re.compile(r"\bsk-[A-Za-z0-9_\-]{32,}\b")),
30
+ ("Anthropic key", re.compile(r"\bsk-ant-[A-Za-z0-9_\-]{20,}\b")),
31
+ ("Private key block", re.compile(r"-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----")),
32
+ ("JWT", re.compile(r"\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b")),
33
+ ("Discord bot token", re.compile(r"\b[MNO][A-Za-z\d_-]{23,25}\.[\w-]{6}\.[\w-]{27,}\b")),
34
+ ("Connection string with password", re.compile(r"\b[a-z][a-z0-9+.-]*://[^\s:/@]+:[^\s:/@]{3,}@[^\s/]+")),
35
+ ]
36
+ ASSIGN_RE = re.compile(r"""(?i)\b([a-z0-9_]*(?:secret|token|passw(?:or)?d|api[_-]?key|apikey|auth)[a-z0-9_]*)\b\s*[:=]\s*['"]([^'"\s]{8,})['"]""")
37
+ SKIP_EXT = {".png", ".jpg", ".jpeg", ".gif", ".ico", ".svg", ".woff", ".woff2", ".ttf", ".pdf", ".zip", ".lock", ".min.js", ".map"}
38
+ PLACEHOLDER = re.compile(r"(?i)(example|your[_-]|xxx|changeme|placeholder|<.*>|\$\{|{{|dummy|sample|test|\*{3,})")
39
+
40
+
41
+ def entropy(s: str) -> float:
42
+ if not s:
43
+ return 0.0
44
+ n = len(s)
45
+ return -sum(v / n * math.log2(v / n) for v in Counter(s).values())
46
+
47
+
48
+ def masked(v: str) -> str:
49
+ return v[:4] + "*" * 6 + v[-2:] if len(v) > 10 else "*" * len(v)
50
+
51
+
52
+ def run(args) -> int:
53
+ folder = resolve_folder(args.folder)
54
+ findings = []
55
+ scanned = 0
56
+ for rel in list_project_files(folder):
57
+ p = folder / rel
58
+ if p.suffix.lower() in SKIP_EXT or p.name.endswith(".min.js"):
59
+ continue
60
+ try:
61
+ if p.stat().st_size > 1_000_000:
62
+ continue
63
+ raw = p.read_bytes()
64
+ except OSError:
65
+ continue
66
+ if is_binary_chunk(raw[:4096]):
67
+ continue
68
+ scanned += 1
69
+ text = raw.decode("utf-8", errors="replace")
70
+ for i, line in enumerate(text.splitlines(), 1):
71
+ if len(line) > 2000:
72
+ continue
73
+ line_hit = False
74
+ for label, rx in PATTERNS:
75
+ m = rx.search(line)
76
+ if m:
77
+ line_hit = True
78
+ findings.append((str(rel).replace("\\", "/"), i, label, masked(m.group(0))))
79
+ if args.entropy and not line_hit:
80
+ for m in ASSIGN_RE.finditer(line):
81
+ val = m.group(2)
82
+ if not PLACEHOLDER.search(val) and entropy(val) > 3.5:
83
+ findings.append((str(rel).replace("\\", "/"), i, "high-entropy %s" % m.group(1), masked(val)))
84
+ if p.name.startswith(".env") and p.name not in (".env.example", ".env.sample", ".env.template"):
85
+ findings.append((str(rel).replace("\\", "/"), 0, ".env file in project (is it git-ignored?)", ""))
86
+ if args.json:
87
+ print(json.dumps([dict(zip(("file", "line", "type", "value"), f)) for f in findings], indent=2))
88
+ return 1 if findings else 0
89
+ if not findings:
90
+ ok("no secrets found (%d files scanned)" % scanned)
91
+ return 0
92
+ for fpath, line, label, val in findings:
93
+ loc = "%s:%s" % (fpath, line) if line else fpath
94
+ print(" %s %s %s %s" % (c(sym("fail"), "bred"), c(label, "bold"), c(loc, "bcyan"), c(val, "gray")))
95
+ print()
96
+ warn("%d potential secret%s in %d files. Rotate anything real, then remove it from git history." % (
97
+ len(findings), "" if len(findings) == 1 else "s", scanned))
98
+ return 1
99
+
100
+
101
+ def register(sub, common) -> None:
102
+ p = sub.add_parser("secrets", aliases=["sec"], parents=[common], help=META[3],
103
+ description="Scan for leaked credentials (respects .gitignore).",
104
+ epilog=EPILOG, formatter_class=argparse.RawDescriptionHelpFormatter)
105
+ p.add_argument("-f", "--folder", metavar="PATH")
106
+ p.add_argument("--entropy", action="store_true", help="also flag high-entropy values assigned to key/secret/token names")
107
+ p.add_argument("--json", action="store_true")
108
+ p.set_defaults(func=run)
@@ -0,0 +1,118 @@
1
+ """dkt serve (sv) - instant static file server with CORS, SPA mode and LAN URL."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import functools
6
+ import http.server
7
+ import socket
8
+ import socketserver
9
+ import threading
10
+ import time
11
+ import webbrowser
12
+ from pathlib import Path
13
+
14
+ from ..utils import DktError, box, c, resolve_folder, sym
15
+
16
+ META = ("Servers & Network", "serve", "sv", "static file server: CORS, SPA mode, LAN URL, open browser")
17
+
18
+ EPILOG = """\
19
+ examples:
20
+ dkt sv serve the current folder on :8000
21
+ dkt sv -p 3000 --open pick a port and open the browser
22
+ dkt sv public --spa single-page-app mode (unknown paths -> index.html)
23
+ dkt sv --cors --no-cache handy for front-end work
24
+ """
25
+
26
+
27
+ def lan_ip() -> str:
28
+ try:
29
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
30
+ s.connect(("10.255.255.255", 1))
31
+ ip = s.getsockname()[0]
32
+ s.close()
33
+ return ip
34
+ except Exception:
35
+ return "127.0.0.1"
36
+
37
+
38
+ def make_handler(root: Path, cors: bool, spa: bool, no_cache: bool, quiet: bool):
39
+ class Handler(http.server.SimpleHTTPRequestHandler):
40
+ def end_headers(self):
41
+ if cors:
42
+ self.send_header("Access-Control-Allow-Origin", "*")
43
+ self.send_header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
44
+ self.send_header("Access-Control-Allow-Headers", "*")
45
+ if no_cache:
46
+ self.send_header("Cache-Control", "no-store, must-revalidate")
47
+ super().end_headers()
48
+
49
+ def do_OPTIONS(self):
50
+ self.send_response(204)
51
+ self.end_headers()
52
+
53
+ def send_head(self):
54
+ if spa:
55
+ path = Path(self.translate_path(self.path))
56
+ if not path.exists() and "." not in Path(self.path.split("?")[0]).name:
57
+ self.path = "/index.html"
58
+ return super().send_head()
59
+
60
+ def log_message(self, fmt, *args):
61
+ if quiet:
62
+ return
63
+ code = str(args[1]) if len(args) > 1 else ""
64
+ col = "bgreen" if code.startswith("2") else "byellow" if code.startswith(("3", "1")) else "bred"
65
+ print(" %s %s %s" % (c(time.strftime("%H:%M:%S"), "gray"), c(code, col), args[0] if args else ""))
66
+ return functools.partial(Handler, directory=str(root))
67
+
68
+
69
+ def run(args) -> int:
70
+ root = resolve_folder(args.folder)
71
+ handler = make_handler(root, args.cors, args.spa, args.no_cache, args.quiet)
72
+
73
+ class Server(socketserver.ThreadingMixIn, http.server.HTTPServer):
74
+ daemon_threads = True
75
+ allow_reuse_address = True
76
+
77
+ port = args.port
78
+ srv = None
79
+ for p in range(port, port + 20):
80
+ try:
81
+ srv = Server((args.host, p), handler)
82
+ port = p
83
+ break
84
+ except OSError:
85
+ continue
86
+ if srv is None:
87
+ raise DktError("Ports %d-%d are all busy. Try 'dkt pk --show %d'." % (args.port, args.port + 19, args.port))
88
+ lines = ["%s http://localhost:%d" % (c("local ", "gray"), port)]
89
+ if args.host in ("0.0.0.0", ""):
90
+ lines.append("%s http://%s:%d" % (c("network", "gray"), lan_ip(), port))
91
+ lines += ["", c("serving ", "gray") + str(root), c("Ctrl+C to stop", "gray")]
92
+ if port != args.port:
93
+ lines.insert(0, c("port %d was busy, using %d" % (args.port, port), "yellow"))
94
+ box("dkt serve", lines)
95
+ if args.open:
96
+ threading.Timer(0.4, lambda: webbrowser.open("http://localhost:%d" % port)).start()
97
+ try:
98
+ srv.serve_forever()
99
+ except KeyboardInterrupt:
100
+ print("\n stopped.")
101
+ finally:
102
+ srv.server_close()
103
+ return 0
104
+
105
+
106
+ def register(sub, common) -> None:
107
+ p = sub.add_parser("serve", aliases=["sv"], parents=[common], help=META[3],
108
+ description="Serve a folder over HTTP.", epilog=EPILOG,
109
+ formatter_class=argparse.RawDescriptionHelpFormatter)
110
+ p.add_argument("folder", nargs="?", help="folder to serve (default: current)")
111
+ p.add_argument("-p", "--port", type=int, default=8000)
112
+ p.add_argument("--host", default="0.0.0.0", help="bind address (default 0.0.0.0; use 127.0.0.1 for local only)")
113
+ p.add_argument("--cors", action="store_true", help="send permissive CORS headers")
114
+ p.add_argument("--spa", action="store_true", help="fallback to index.html for unknown routes")
115
+ p.add_argument("--no-cache", action="store_true", help="disable browser caching")
116
+ p.add_argument("--open", action="store_true", help="open in the default browser")
117
+ p.add_argument("-q", "--quiet", action="store_true", help="don't log requests")
118
+ p.set_defaults(func=run)
@@ -0,0 +1,94 @@
1
+ """dkt snip - a personal snippet / command notebook that lives in your terminal."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import sys
6
+
7
+ from ..utils import (DktError, c, config_dir, copy_to_clipboard, info, load_json_file, ok, read_input,
8
+ save_json_file, sym, table)
9
+
10
+ META = ("Data & Text", "snip", "", "save & recall snippets / commands (copy to clipboard)")
11
+
12
+ EPILOG = """\
13
+ examples:
14
+ dkt snip add deploy "ssh me@host 'cd app && git pull'"
15
+ dkt snip add sql-dump --paste save from clipboard
16
+ echo "some text" | dkt snip add note1
17
+ dkt snip get deploy --copy
18
+ dkt snip ls all snippets
19
+ dkt snip ls ssh search names and content
20
+ dkt snip rm deploy
21
+ """
22
+
23
+
24
+ def _path():
25
+ return config_dir() / "snippets.json"
26
+
27
+
28
+ def _load():
29
+ return load_json_file(_path(), {})
30
+
31
+
32
+ def cmd_add(args) -> int:
33
+ text = read_input(args.text, None, args.paste, "snippet text")
34
+ data = _load()
35
+ existed = args.name in data
36
+ data[args.name] = text.rstrip("\n")
37
+ save_json_file(_path(), data)
38
+ ok("%s snippet '%s' (%d chars)" % ("updated" if existed else "saved", args.name, len(text)))
39
+ return 0
40
+
41
+
42
+ def cmd_get(args) -> int:
43
+ data = _load()
44
+ if args.name not in data:
45
+ close = [k for k in data if args.name.lower() in k.lower()]
46
+ raise DktError("No snippet named '%s'.%s" % (args.name, (" Did you mean: " + ", ".join(close)) if close else ""))
47
+ print(data[args.name])
48
+ if args.copy and copy_to_clipboard(data[args.name]):
49
+ info(c("%s copied to clipboard" % sym("ok"), "green"))
50
+ return 0
51
+
52
+
53
+ def cmd_ls(args) -> int:
54
+ data = _load()
55
+ q = (args.query or "").lower()
56
+ rows = [(c(k, "bold bcyan"), v.replace("\n", " ")[:70]) for k, v in sorted(data.items())
57
+ if not q or q in k.lower() or q in v.lower()]
58
+ if not rows:
59
+ print(" no snippets%s" % (" matching '%s'" % q if q else " yet - add one with: dkt snip add NAME TEXT"))
60
+ return 0
61
+ table(["NAME", "PREVIEW"], rows)
62
+ return 0
63
+
64
+
65
+ def cmd_rm(args) -> int:
66
+ data = _load()
67
+ if args.name not in data:
68
+ raise DktError("No snippet named '%s'." % args.name)
69
+ del data[args.name]
70
+ save_json_file(_path(), data)
71
+ ok("removed '%s'" % args.name)
72
+ return 0
73
+
74
+
75
+ def register(sub, common) -> None:
76
+ p = sub.add_parser("snip", parents=[common], help=META[3], description="Personal snippet store.",
77
+ epilog=EPILOG, formatter_class=argparse.RawDescriptionHelpFormatter)
78
+ p.set_defaults(func=lambda a: (p.print_help() or 0))
79
+ g = p.add_subparsers(dest="sub", metavar="<add|get|ls|rm>")
80
+ a = g.add_parser("add", help="save a snippet")
81
+ a.add_argument("name")
82
+ a.add_argument("text", nargs="?")
83
+ a.add_argument("--paste", action="store_true", help="take text from the clipboard")
84
+ a.set_defaults(func=cmd_add)
85
+ gt = g.add_parser("get", help="print (and optionally copy) a snippet")
86
+ gt.add_argument("name")
87
+ gt.add_argument("--copy", action="store_true")
88
+ gt.set_defaults(func=cmd_get)
89
+ ls = g.add_parser("ls", aliases=["list"], help="list / search snippets")
90
+ ls.add_argument("query", nargs="?")
91
+ ls.set_defaults(func=cmd_ls)
92
+ rm = g.add_parser("rm", help="delete a snippet")
93
+ rm.add_argument("name")
94
+ rm.set_defaults(func=cmd_rm)
@@ -0,0 +1,157 @@
1
+ """dkt update / config / completion / about - the toolkit's own housekeeping commands."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import subprocess
7
+ import sys
8
+ import urllib.request
9
+ from pathlib import Path
10
+
11
+ from ..utils import (DktError, Spinner, banner, box, c, config_dir, info, kv, load_config, ok, save_json_file,
12
+ sym, warn)
13
+
14
+ META = ("dkt itself", "update", "", "upgrade dkt to the newest version from PyPI")
15
+
16
+ PKG = "dktcli"
17
+
18
+
19
+ def _ver(s: str):
20
+ out = []
21
+ for part in s.split("."):
22
+ digits = "".join(ch for ch in part if ch.isdigit())
23
+ out.append(int(digits) if digits else 0)
24
+ return tuple(out)
25
+
26
+
27
+ def latest_version(timeout: float = 6.0):
28
+ req = urllib.request.Request("https://pypi.org/pypi/%s/json" % PKG, headers={"User-Agent": "dkt-update"})
29
+ with urllib.request.urlopen(req, timeout=timeout) as r:
30
+ return json.loads(r.read().decode())["info"]["version"]
31
+
32
+
33
+ def source_checkout():
34
+ root = Path(__file__).resolve().parents[3]
35
+ return root if (root / ".git").exists() and (root / "pyproject.toml").exists() else None
36
+
37
+
38
+ def run_update(args) -> int:
39
+ from .. import __version__
40
+ src = source_checkout()
41
+ if src and not args.force:
42
+ warn("dkt is running from a source checkout (%s)." % src)
43
+ print(" Update it with: update.bat (or: git pull && pip install -e .)")
44
+ return 0
45
+ try:
46
+ with Spinner("checking PyPI"):
47
+ latest = latest_version()
48
+ except Exception:
49
+ latest = None
50
+ if latest:
51
+ if _ver(latest) <= _ver(__version__) and not args.force:
52
+ ok("already up to date (v%s)" % __version__)
53
+ return 0
54
+ print(" v%s %s v%s" % (__version__, c(sym("arrow"), "bcyan"), c(latest, "bold bgreen")))
55
+ else:
56
+ info(c("could not reach PyPI - trying pip anyway", "yellow"))
57
+ if args.check:
58
+ return 0
59
+ with Spinner("upgrading"):
60
+ r = subprocess.run([sys.executable, "-m", "pip", "install", "--upgrade", PKG],
61
+ stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
62
+ if r.returncode != 0:
63
+ print(r.stdout.decode("utf-8", errors="replace")[-1500:])
64
+ raise DktError("pip upgrade failed. Try: python -m pip install --upgrade %s" % PKG)
65
+ ok("updated! Run 'dkt --version' to confirm.")
66
+ return 0
67
+
68
+
69
+ def run_config(args) -> int:
70
+ cfg = load_config()
71
+ path = config_dir() / "config.json"
72
+ act = args.action or "list"
73
+ if act == "path":
74
+ print(path)
75
+ elif act == "list":
76
+ if not cfg:
77
+ print(" no settings yet. Try: dkt config set author \"Your Name\"")
78
+ print(c(" known keys: author, model (Ollama model for commit writer)", "gray"))
79
+ else:
80
+ kv(sorted(cfg.items()), key_color="bcyan")
81
+ elif act == "get":
82
+ if not args.key or args.key not in cfg:
83
+ raise DktError("Setting '%s' is not set." % args.key)
84
+ print(cfg[args.key])
85
+ elif act == "set":
86
+ if not args.key or args.value is None:
87
+ raise DktError("Usage: dkt config set KEY VALUE")
88
+ cfg[args.key] = args.value
89
+ save_json_file(path, cfg)
90
+ ok("%s = %s" % (args.key, args.value))
91
+ elif act == "unset":
92
+ if args.key in cfg:
93
+ del cfg[args.key]
94
+ save_json_file(path, cfg)
95
+ ok("removed %s" % args.key)
96
+ return 0
97
+
98
+
99
+ def run_about(args) -> int:
100
+ from .. import __version__
101
+ banner()
102
+ box("tips", [
103
+ "%s %s" % (c("dkt menu", "bold bcyan"), "interactive launcher"),
104
+ "%s %s" % (c("dkt <tool> -h", "bold bcyan"), "help + examples for any tool"),
105
+ "%s %s" % (c("dkt doctor", "bold bcyan"), "check your dev environment"),
106
+ "%s %s" % (c("dkt completion", "bold bcyan"), "tab-completion for your shell"),
107
+ "%s %s" % (c("dkt update", "bold bcyan"), "get the latest version"),
108
+ ])
109
+ return 0
110
+
111
+
112
+ def _names(parser_sub):
113
+ return sorted(parser_sub.choices)
114
+
115
+
116
+ def run_completion(args, sub) -> int:
117
+ names = " ".join(_names(sub))
118
+ shell = args.shell
119
+ if shell == "bash":
120
+ print('_dkt() { COMPREPLY=( $(compgen -W "%s" -- "${COMP_WORDS[1]}") ); }\ncomplete -F _dkt dkt' % names)
121
+ print("# add to ~/.bashrc: eval \"$(dkt completion bash)\"", file=sys.stderr)
122
+ elif shell == "zsh":
123
+ print("_dkt() { reply=(%s) }\ncompctl -K _dkt dkt" % names)
124
+ print("# add to ~/.zshrc: eval \"$(dkt completion zsh)\"", file=sys.stderr)
125
+ else:
126
+ quoted = ",".join("'%s'" % n for n in _names(sub))
127
+ print("Register-ArgumentCompleter -Native -CommandName dkt -ScriptBlock {\n"
128
+ " param($wordToComplete, $commandAst, $cursorPosition)\n"
129
+ " @(%s) | Where-Object { $_ -like \"$wordToComplete*\" } |\n"
130
+ " ForEach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) }\n"
131
+ "}" % quoted)
132
+ print("# add to your profile: dkt completion powershell | Out-String | Add-Content $PROFILE", file=sys.stderr)
133
+ return 0
134
+
135
+
136
+ def register(sub, common) -> None:
137
+ u = sub.add_parser("update", aliases=["upgrade"], parents=[common], help=META[3],
138
+ description="Upgrade dkt using pip.")
139
+ u.add_argument("--check", action="store_true", help="only check for a newer version")
140
+ u.add_argument("--force", action="store_true", help="reinstall even if up to date / from source")
141
+ u.set_defaults(func=run_update)
142
+
143
+ cf = sub.add_parser("config", parents=[common], help="view or change dkt settings",
144
+ description="Settings live in a small JSON file in your user config folder.")
145
+ cf.add_argument("action", nargs="?", choices=["list", "get", "set", "unset", "path"])
146
+ cf.add_argument("key", nargs="?")
147
+ cf.add_argument("value", nargs="?")
148
+ cf.set_defaults(func=run_config)
149
+
150
+ cp = sub.add_parser("completion", parents=[common], help="print shell tab-completion script",
151
+ description="Print a completion script for your shell.")
152
+ cp.add_argument("shell", nargs="?", choices=["powershell", "bash", "zsh"],
153
+ default="powershell" if sys.platform.startswith("win") else "bash")
154
+ cp.set_defaults(func=lambda a: run_completion(a, sub))
155
+
156
+ ab = sub.add_parser("about", parents=[common], help="logo, version and tips")
157
+ ab.set_defaults(func=run_about)