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.
- devkittools/__init__.py +3 -0
- devkittools/__main__.py +6 -0
- devkittools/cli.py +179 -0
- devkittools/tools/__init__.py +1 -0
- devkittools/tools/bench.py +73 -0
- devkittools/tools/changeloggen.py +181 -0
- devkittools/tools/clean.py +110 -0
- devkittools/tools/colortool.py +126 -0
- devkittools/tools/commitwriter.py +199 -0
- devkittools/tools/contextpacker.py +226 -0
- devkittools/tools/cron.py +152 -0
- devkittools/tools/doctor.py +74 -0
- devkittools/tools/du.py +72 -0
- devkittools/tools/enc.py +131 -0
- devkittools/tools/envtool.py +141 -0
- devkittools/tools/findtool.py +119 -0
- devkittools/tools/gen.py +182 -0
- devkittools/tools/gx.py +184 -0
- devkittools/tools/hashtool.py +93 -0
- devkittools/tools/httptool.py +131 -0
- devkittools/tools/ignore.py +213 -0
- devkittools/tools/initproj.py +166 -0
- devkittools/tools/jsontool.py +200 -0
- devkittools/tools/net.py +114 -0
- devkittools/tools/portkiller.py +246 -0
- devkittools/tools/regextool.py +84 -0
- devkittools/tools/repostats.py +197 -0
- devkittools/tools/secrets_scan.py +108 -0
- devkittools/tools/serve.py +118 -0
- devkittools/tools/snip.py +94 -0
- devkittools/tools/system.py +157 -0
- devkittools/tools/timetool.py +130 -0
- devkittools/tools/todo.py +69 -0
- devkittools/tools/tree.py +86 -0
- devkittools/tools/watch.py +82 -0
- devkittools/utils.py +561 -0
- dktcli-1.0.0.dist-info/METADATA +148 -0
- dktcli-1.0.0.dist-info/RECORD +42 -0
- dktcli-1.0.0.dist-info/WHEEL +5 -0
- dktcli-1.0.0.dist-info/entry_points.txt +3 -0
- dktcli-1.0.0.dist-info/licenses/LICENSE +21 -0
- dktcli-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""dkt color (col) - convert colors, preview swatches, check contrast, build palettes."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import colorsys
|
|
6
|
+
import re
|
|
7
|
+
|
|
8
|
+
from ..utils import DktError, c, color_enabled, kv
|
|
9
|
+
|
|
10
|
+
META = ("Generate & Convert", "color", "col", "hex/rgb/hsl converter with live swatches + contrast check")
|
|
11
|
+
|
|
12
|
+
EPILOG = """\
|
|
13
|
+
examples:
|
|
14
|
+
dkt col "#ff6b35"
|
|
15
|
+
dkt col rgb(30,144,255)
|
|
16
|
+
dkt col 120,60,200 plain r,g,b
|
|
17
|
+
dkt col hsl(210,80%,50%)
|
|
18
|
+
dkt col tomato a few CSS names work too
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
NAMES = {
|
|
22
|
+
"black": "#000000", "white": "#ffffff", "red": "#ff0000", "green": "#008000", "blue": "#0000ff",
|
|
23
|
+
"yellow": "#ffff00", "cyan": "#00ffff", "magenta": "#ff00ff", "orange": "#ffa500", "purple": "#800080",
|
|
24
|
+
"pink": "#ffc0cb", "gray": "#808080", "grey": "#808080", "tomato": "#ff6347", "gold": "#ffd700",
|
|
25
|
+
"teal": "#008080", "navy": "#000080", "coral": "#ff7f50", "salmon": "#fa8072", "indigo": "#4b0082",
|
|
26
|
+
"violet": "#ee82ee", "crimson": "#dc143c", "lime": "#00ff00", "maroon": "#800000", "olive": "#808000",
|
|
27
|
+
"silver": "#c0c0c0", "skyblue": "#87ceeb", "turquoise": "#40e0d0", "orchid": "#da70d6", "brown": "#a52a2a",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def parse_color(s: str):
|
|
32
|
+
s = s.strip().lower()
|
|
33
|
+
if s in NAMES:
|
|
34
|
+
s = NAMES[s]
|
|
35
|
+
m = re.fullmatch(r"#?([0-9a-f]{3}|[0-9a-f]{6})", s)
|
|
36
|
+
if m:
|
|
37
|
+
h = m.group(1)
|
|
38
|
+
if len(h) == 3:
|
|
39
|
+
h = "".join(ch * 2 for ch in h)
|
|
40
|
+
return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
|
|
41
|
+
m = re.fullmatch(r"(?:rgb\()?\s*(\d{1,3})\s*[, ]\s*(\d{1,3})\s*[, ]\s*(\d{1,3})\s*\)?", s)
|
|
42
|
+
if m:
|
|
43
|
+
r, g, b = (int(x) for x in m.groups())
|
|
44
|
+
if max(r, g, b) > 255:
|
|
45
|
+
raise DktError("RGB values must be 0-255.")
|
|
46
|
+
return r, g, b
|
|
47
|
+
m = re.fullmatch(r"hsl\(\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)%?\s*,\s*(\d+(?:\.\d+)?)%?\s*\)", s)
|
|
48
|
+
if m:
|
|
49
|
+
h, sat, l = float(m.group(1)) / 360, float(m.group(2)) / 100, float(m.group(3)) / 100
|
|
50
|
+
r, g, b = colorsys.hls_to_rgb(h, l, sat)
|
|
51
|
+
return int(round(r * 255)), int(round(g * 255)), int(round(b * 255))
|
|
52
|
+
raise DktError("Couldn't parse color '%s'. Try #ff6b35, rgb(255,107,53), hsl(20,100%%,60%%) or a name." % s)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def swatch(rgb, width: int = 6) -> str:
|
|
56
|
+
if not color_enabled():
|
|
57
|
+
return ""
|
|
58
|
+
return "\033[48;2;%d;%d;%dm%s\033[0m" % (rgb[0], rgb[1], rgb[2], " " * width)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def hexstr(rgb) -> str:
|
|
62
|
+
return "#%02x%02x%02x" % tuple(rgb)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def luminance(rgb) -> float:
|
|
66
|
+
def ch(v):
|
|
67
|
+
v /= 255.0
|
|
68
|
+
return v / 12.92 if v <= 0.03928 else ((v + 0.055) / 1.055) ** 2.4
|
|
69
|
+
r, g, b = (ch(x) for x in rgb)
|
|
70
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def contrast(a, b) -> float:
|
|
74
|
+
la, lb = luminance(a), luminance(b)
|
|
75
|
+
hi, lo = max(la, lb), min(la, lb)
|
|
76
|
+
return (hi + 0.05) / (lo + 0.05)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def hsl_of(rgb):
|
|
80
|
+
h, l, s = colorsys.rgb_to_hls(*(x / 255.0 for x in rgb))
|
|
81
|
+
return h * 360, s * 100, l * 100
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def from_hsl(h, s, l):
|
|
85
|
+
r, g, b = colorsys.hls_to_rgb((h % 360) / 360.0, max(0, min(100, l)) / 100.0, max(0, min(100, s)) / 100.0)
|
|
86
|
+
return int(round(r * 255)), int(round(g * 255)), int(round(b * 255))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def run(args) -> int:
|
|
90
|
+
rgb = parse_color(" ".join(args.color))
|
|
91
|
+
h, s, l = hsl_of(rgb)
|
|
92
|
+
print()
|
|
93
|
+
print(" " + swatch(rgb, 20))
|
|
94
|
+
print(" " + swatch(rgb, 20))
|
|
95
|
+
print()
|
|
96
|
+
kv([("hex", c(hexstr(rgb), "bold")),
|
|
97
|
+
("rgb", "rgb(%d, %d, %d)" % rgb),
|
|
98
|
+
("hsl", "hsl(%d, %d%%, %d%%)" % (round(h), round(s), round(l))),
|
|
99
|
+
("int", str(rgb[0] << 16 | rgb[1] << 8 | rgb[2]))])
|
|
100
|
+
cw, cb = contrast(rgb, (255, 255, 255)), contrast(rgb, (0, 0, 0))
|
|
101
|
+
|
|
102
|
+
def grade(r):
|
|
103
|
+
return c("AAA", "bgreen") if r >= 7 else c("AA", "green") if r >= 4.5 else c("AA large", "yellow") if r >= 3 else c("fail", "red")
|
|
104
|
+
print()
|
|
105
|
+
kv([("on white", "%.2f:1 %s" % (cw, grade(cw))), ("on black", "%.2f:1 %s" % (cb, grade(cb)))])
|
|
106
|
+
print()
|
|
107
|
+
rows = [
|
|
108
|
+
("shades", [from_hsl(h, s, l * f) for f in (0.2, 0.4, 0.6, 0.8, 1.0)]),
|
|
109
|
+
("tints", [from_hsl(h, s, l + (100 - l) * f) for f in (0.0, 0.25, 0.5, 0.75, 0.9)]),
|
|
110
|
+
("complement", [rgb, from_hsl(h + 180, s, l)]),
|
|
111
|
+
("triad", [rgb, from_hsl(h + 120, s, l), from_hsl(h + 240, s, l)]),
|
|
112
|
+
("analogous", [from_hsl(h - 30, s, l), rgb, from_hsl(h + 30, s, l)]),
|
|
113
|
+
]
|
|
114
|
+
for name, cols in rows:
|
|
115
|
+
print(" %s %s %s" % (c(name.ljust(11), "gray"), "".join(swatch(x, 4) for x in cols),
|
|
116
|
+
c(" ".join(hexstr(x) for x in cols), "gray")))
|
|
117
|
+
print()
|
|
118
|
+
return 0
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def register(sub, common) -> None:
|
|
122
|
+
p = sub.add_parser("color", aliases=["col"], parents=[common], help=META[3],
|
|
123
|
+
description="Convert a color and show swatches, contrast and palettes.",
|
|
124
|
+
epilog=EPILOG, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
125
|
+
p.add_argument("color", nargs="+", help="#hex, rgb(...), r,g,b, hsl(...) or a name")
|
|
126
|
+
p.set_defaults(func=run)
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""dkt commitwriter (cw) - write a commit message from your diff using a local Ollama model."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import subprocess
|
|
9
|
+
import urllib.error
|
|
10
|
+
import urllib.request
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import List, Optional
|
|
13
|
+
|
|
14
|
+
from ..utils import get_setting, DktError, c, git, info, require_git_repo, resolve_folder
|
|
15
|
+
|
|
16
|
+
EPILOG = """\
|
|
17
|
+
examples:
|
|
18
|
+
dkt cw message for your staged changes
|
|
19
|
+
dkt cw --commit ...then ask to commit with it
|
|
20
|
+
dkt cw --commit -y commit without asking
|
|
21
|
+
dkt cw --all include unstaged changes to tracked files
|
|
22
|
+
dkt cw --model qwen2.5-coder:7b --style simple
|
|
23
|
+
dkt cw --hint "fixes the login bug"
|
|
24
|
+
git commit -m "$(dkt cw --raw)"
|
|
25
|
+
|
|
26
|
+
Needs Ollama (https://ollama.com) running locally, with at least one model pulled.
|
|
27
|
+
Env vars: DKT_MODEL (default model), OLLAMA_HOST (default http://localhost:11434)
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
LOCK_EXCLUDES = [":(exclude)package-lock.json", ":(exclude)yarn.lock", ":(exclude)pnpm-lock.yaml",
|
|
31
|
+
":(exclude)poetry.lock", ":(exclude)Cargo.lock", ":(exclude)composer.lock"]
|
|
32
|
+
|
|
33
|
+
SYSTEM_CONVENTIONAL = (
|
|
34
|
+
"You are an expert at writing git commit messages. Given a git diff, write ONE commit message.\n"
|
|
35
|
+
"Rules:\n"
|
|
36
|
+
"- Use Conventional Commits: type(scope): subject\n"
|
|
37
|
+
"- Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore\n"
|
|
38
|
+
"- Subject: imperative mood, lowercase, no trailing period, max 72 characters\n"
|
|
39
|
+
"- If the change is non-trivial, add a blank line and then 2-5 short bullets starting with '- ' "
|
|
40
|
+
"explaining what changed and why\n"
|
|
41
|
+
"- Describe only what the diff shows. Do not invent details.\n"
|
|
42
|
+
"- Output ONLY the commit message. No code fences, no quotes, no preamble."
|
|
43
|
+
)
|
|
44
|
+
SYSTEM_SIMPLE = (
|
|
45
|
+
"You are an expert at writing git commit messages. Given a git diff, write ONE commit message.\n"
|
|
46
|
+
"Rules:\n"
|
|
47
|
+
"- First line: imperative mood, capitalized, no trailing period, max 72 characters, no type prefix\n"
|
|
48
|
+
"- If the change is non-trivial, add a blank line and then 2-4 short bullets starting with '- '\n"
|
|
49
|
+
"- Describe only what the diff shows. Do not invent details.\n"
|
|
50
|
+
"- Output ONLY the commit message. No code fences, no quotes, no preamble."
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def ollama_base(host: Optional[str]) -> str:
|
|
55
|
+
host = host or os.environ.get("OLLAMA_HOST") or "http://localhost:11434"
|
|
56
|
+
if not host.startswith("http"):
|
|
57
|
+
host = "http://" + host
|
|
58
|
+
return host.replace("0.0.0.0", "127.0.0.1").rstrip("/")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def http_json(base: str, path: str, payload: Optional[dict] = None, timeout: int = 300) -> dict:
|
|
62
|
+
data = json.dumps(payload).encode("utf-8") if payload is not None else None
|
|
63
|
+
req = urllib.request.Request(base + path, data=data, headers={"Content-Type": "application/json"})
|
|
64
|
+
try:
|
|
65
|
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
66
|
+
return json.loads(r.read().decode("utf-8"))
|
|
67
|
+
except urllib.error.HTTPError as e:
|
|
68
|
+
body = e.read().decode("utf-8", errors="replace")[:300]
|
|
69
|
+
raise DktError("Ollama returned HTTP %d: %s" % (e.code, body))
|
|
70
|
+
except (OSError, ValueError):
|
|
71
|
+
raise DktError("Could not reach Ollama at %s. Is it running? (start it with: ollama serve)" % base)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def pick_model(base: str, wanted: Optional[str]) -> str:
|
|
75
|
+
if wanted:
|
|
76
|
+
return wanted
|
|
77
|
+
data = http_json(base, "/api/tags", timeout=10)
|
|
78
|
+
names = [m.get("name", "") for m in data.get("models", [])]
|
|
79
|
+
names = [n for n in names if n and not any(x in n.lower() for x in ("embed", "nomic", "bge"))]
|
|
80
|
+
if not names:
|
|
81
|
+
raise DktError("No Ollama models installed. Pull one first, e.g.: ollama pull qwen2.5-coder:7b")
|
|
82
|
+
coders = [n for n in names if "coder" in n.lower()]
|
|
83
|
+
return (coders or names)[0]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def clean_message(text: str) -> str:
|
|
87
|
+
text = re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL)
|
|
88
|
+
text = text.strip()
|
|
89
|
+
text = re.sub(r"^```[a-zA-Z]*\n?", "", text)
|
|
90
|
+
text = re.sub(r"\n?```$", "", text)
|
|
91
|
+
text = text.strip().strip('"').strip("'").strip()
|
|
92
|
+
return text
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def get_diff(folder: Path, include_all: bool) -> "tuple":
|
|
96
|
+
if include_all:
|
|
97
|
+
base = ["diff", "HEAD"]
|
|
98
|
+
if not git(folder, "rev-parse", "--verify", "HEAD", check=False).strip():
|
|
99
|
+
base = ["diff"]
|
|
100
|
+
else:
|
|
101
|
+
base = ["diff", "--staged"]
|
|
102
|
+
stat = git(folder, *(base + ["--stat"]), check=False)
|
|
103
|
+
diff = git(folder, *(base + ["--no-color", "--", "."] + LOCK_EXCLUDES), check=False)
|
|
104
|
+
if not diff.strip():
|
|
105
|
+
diff = git(folder, *(base + ["--no-color"]), check=False)
|
|
106
|
+
return stat, diff
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def generate_message(base: str, model: str, stat: str, diff: str, style: str, hint: Optional[str]) -> str:
|
|
110
|
+
user = []
|
|
111
|
+
if hint:
|
|
112
|
+
user.append("Context from the developer: %s\n" % hint)
|
|
113
|
+
user.append("Files changed:\n%s\n\nDiff:\n%s" % (stat.strip(), diff))
|
|
114
|
+
payload = {
|
|
115
|
+
"model": model, "stream": False, "options": {"temperature": 0.2},
|
|
116
|
+
"messages": [
|
|
117
|
+
{"role": "system", "content": SYSTEM_CONVENTIONAL if style == "conventional" else SYSTEM_SIMPLE},
|
|
118
|
+
{"role": "user", "content": "\n".join(user)},
|
|
119
|
+
],
|
|
120
|
+
}
|
|
121
|
+
data = http_json(base, "/api/chat", payload)
|
|
122
|
+
msg = clean_message(((data.get("message") or {}).get("content")) or "")
|
|
123
|
+
if not msg:
|
|
124
|
+
raise DktError("The model returned an empty message. Try another --model.")
|
|
125
|
+
return msg
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def run(args: argparse.Namespace) -> int:
|
|
129
|
+
folder = resolve_folder(args.folder)
|
|
130
|
+
require_git_repo(folder)
|
|
131
|
+
stat, diff = get_diff(folder, args.all)
|
|
132
|
+
if not diff.strip():
|
|
133
|
+
raise DktError("Nothing to describe. Stage changes with 'git add' (or use --all for unstaged tracked changes).")
|
|
134
|
+
truncated = False
|
|
135
|
+
if len(diff) > args.max_chars:
|
|
136
|
+
diff = diff[:args.max_chars] + "\n... [diff truncated]"
|
|
137
|
+
truncated = True
|
|
138
|
+
|
|
139
|
+
base = ollama_base(args.host)
|
|
140
|
+
model = pick_model(base, args.model or get_setting("model"))
|
|
141
|
+
info(c("Using model %s" % model, "dim") + (c(" (diff truncated)", "yellow") if truncated else ""))
|
|
142
|
+
info(c("Thinking...", "dim"))
|
|
143
|
+
msg = generate_message(base, model, stat, diff, args.style, args.hint)
|
|
144
|
+
|
|
145
|
+
if args.raw:
|
|
146
|
+
print(msg)
|
|
147
|
+
return 0
|
|
148
|
+
info("")
|
|
149
|
+
info(c("Suggested commit message:", "bold"))
|
|
150
|
+
print(msg)
|
|
151
|
+
if not args.commit:
|
|
152
|
+
info("")
|
|
153
|
+
info(c("Tip: run with --commit to use it directly.", "dim"))
|
|
154
|
+
return 0
|
|
155
|
+
|
|
156
|
+
if not args.yes:
|
|
157
|
+
try:
|
|
158
|
+
ans = input("\nCommit with this message? [y/N/e(dit)] ").strip().lower()
|
|
159
|
+
except EOFError:
|
|
160
|
+
ans = "n"
|
|
161
|
+
if ans not in ("y", "yes", "e", "edit"):
|
|
162
|
+
info("Aborted - nothing committed.")
|
|
163
|
+
return 1
|
|
164
|
+
edit = ans in ("e", "edit")
|
|
165
|
+
else:
|
|
166
|
+
edit = False
|
|
167
|
+
cmd = ["git", "-C", str(folder), "commit"]
|
|
168
|
+
if args.all:
|
|
169
|
+
cmd.append("-a")
|
|
170
|
+
if edit:
|
|
171
|
+
cmd.append("-e")
|
|
172
|
+
cmd += ["-m", msg]
|
|
173
|
+
code = subprocess.call(cmd)
|
|
174
|
+
if code == 0:
|
|
175
|
+
info(c("✔ Committed", "green"))
|
|
176
|
+
return code
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def register(sub, common) -> None:
|
|
180
|
+
p = sub.add_parser(
|
|
181
|
+
"commitwriter", aliases=["cw"], parents=[common],
|
|
182
|
+
help="write a commit message from your diff (local Ollama)",
|
|
183
|
+
description="Reads your staged git diff and asks a local Ollama model to write a\n"
|
|
184
|
+
"clean commit message. Works fully offline.",
|
|
185
|
+
epilog=EPILOG, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
186
|
+
p.add_argument("-f", "--folder", metavar="PATH", help="project folder (default: current folder)")
|
|
187
|
+
p.add_argument("-m", "--model", metavar="NAME", help="Ollama model (default: $DKT_MODEL or first installed)")
|
|
188
|
+
p.add_argument("--host", metavar="URL", help="Ollama server (default: $OLLAMA_HOST or http://localhost:11434)")
|
|
189
|
+
p.add_argument("--style", choices=["conventional", "simple"], default="conventional", help="message style")
|
|
190
|
+
p.add_argument("--hint", metavar="TEXT", help="extra context for the model")
|
|
191
|
+
p.add_argument("-a", "--all", action="store_true", help="include unstaged changes to tracked files")
|
|
192
|
+
p.add_argument("-c", "--commit", action="store_true", help="offer to commit with the message")
|
|
193
|
+
p.add_argument("-y", "--yes", action="store_true", help="with --commit, don't ask for confirmation")
|
|
194
|
+
p.add_argument("--raw", action="store_true", help="print only the message (for scripts)")
|
|
195
|
+
p.add_argument("--max-chars", type=int, default=12000, metavar="N", help="max diff characters sent (default 12000)")
|
|
196
|
+
p.set_defaults(func=run)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
META = ("AI Helpers", "commitwriter", "cw", "write commit messages from your diff with local Ollama")
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""dkt projectcontextpacker (pcp) - pack a project into one prompt-friendly file."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import fnmatch
|
|
6
|
+
import sys
|
|
7
|
+
from collections import Counter
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Dict, List, Optional
|
|
10
|
+
|
|
11
|
+
from ..utils import (DktError, c, copy_to_clipboard, human_num, info, is_binary_chunk,
|
|
12
|
+
list_project_files, resolve_folder)
|
|
13
|
+
|
|
14
|
+
EPILOG = """\
|
|
15
|
+
examples:
|
|
16
|
+
dkt pcp pack the current folder -> <name>-context.md
|
|
17
|
+
dkt pcp --folder "C:\\code\\app" --copy
|
|
18
|
+
dkt pcp --max-tokens 50000 stay under a token budget
|
|
19
|
+
dkt pcp --include .py .md only these extensions
|
|
20
|
+
dkt pcp --exclude "tests/*" "*.svg" skip patterns
|
|
21
|
+
dkt pcp --format xml --stdout
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
BINARY_EXT = {
|
|
25
|
+
".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ico", ".webp", ".tiff", ".psd", ".mp3", ".wav",
|
|
26
|
+
".ogg", ".flac", ".mp4", ".mov", ".avi", ".mkv", ".webm", ".zip", ".tar", ".gz", ".7z",
|
|
27
|
+
".rar", ".bz2", ".xz", ".exe", ".dll", ".so", ".dylib", ".bin", ".class", ".jar", ".pyc",
|
|
28
|
+
".pyo", ".woff", ".woff2", ".ttf", ".otf", ".eot", ".pdf", ".o", ".a", ".lib", ".iso",
|
|
29
|
+
".db", ".sqlite", ".mcpack", ".mcaddon", ".nbt", ".dat",
|
|
30
|
+
}
|
|
31
|
+
SKIP_NAMES = {
|
|
32
|
+
"package-lock.json", "yarn.lock", "pnpm-lock.yaml", "poetry.lock", "cargo.lock",
|
|
33
|
+
"composer.lock", "gemfile.lock", "pipfile.lock", "bun.lockb", ".ds_store", "thumbs.db",
|
|
34
|
+
}
|
|
35
|
+
FENCE_LANG = {
|
|
36
|
+
".py": "python", ".js": "javascript", ".mjs": "javascript", ".jsx": "jsx", ".ts": "typescript",
|
|
37
|
+
".tsx": "tsx", ".java": "java", ".kt": "kotlin", ".c": "c", ".h": "c", ".cpp": "cpp",
|
|
38
|
+
".cs": "csharp", ".go": "go", ".rs": "rust", ".rb": "ruby", ".php": "php", ".sh": "bash",
|
|
39
|
+
".ps1": "powershell", ".bat": "bat", ".html": "html", ".css": "css", ".json": "json",
|
|
40
|
+
".yml": "yaml", ".yaml": "yaml", ".toml": "toml", ".md": "markdown", ".sql": "sql",
|
|
41
|
+
".xml": "xml", ".vue": "vue", ".lua": "lua",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def count_tokens(text: str) -> int:
|
|
46
|
+
try:
|
|
47
|
+
import tiktoken # type: ignore
|
|
48
|
+
return len(tiktoken.get_encoding("cl100k_base").encode(text, disallowed_special=()))
|
|
49
|
+
except Exception:
|
|
50
|
+
return max(1, len(text) // 4) if text else 0
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def build_tree(paths: List[Path]) -> List[str]:
|
|
54
|
+
root = {} # type: dict
|
|
55
|
+
for p in paths:
|
|
56
|
+
node = root
|
|
57
|
+
for part in p.parts[:-1]:
|
|
58
|
+
node = node.setdefault(part, {})
|
|
59
|
+
node[p.parts[-1]] = None
|
|
60
|
+
lines = [] # type: List[str]
|
|
61
|
+
|
|
62
|
+
def walk(node: dict, prefix: str) -> None:
|
|
63
|
+
items = sorted(node.items(), key=lambda kv: (kv[1] is None, kv[0].lower()))
|
|
64
|
+
for i, (name, child) in enumerate(items):
|
|
65
|
+
last = i == len(items) - 1
|
|
66
|
+
lines.append(prefix + ("└── " if last else "├── ") + name + ("/" if child is not None else ""))
|
|
67
|
+
if child is not None:
|
|
68
|
+
walk(child, prefix + (" " if last else "│ "))
|
|
69
|
+
|
|
70
|
+
walk(root, "")
|
|
71
|
+
return lines
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _matches(rel: Path, patterns: List[str]) -> bool:
|
|
75
|
+
posix = rel.as_posix()
|
|
76
|
+
for pat in patterns:
|
|
77
|
+
if fnmatch.fnmatch(posix, pat) or fnmatch.fnmatch(rel.name, pat):
|
|
78
|
+
return True
|
|
79
|
+
if pat.endswith("/") and posix.startswith(pat):
|
|
80
|
+
return True
|
|
81
|
+
return False
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def render(name: str, entries: List[dict], fmt: str) -> str:
|
|
85
|
+
tree = build_tree([e["rel"] for e in entries])
|
|
86
|
+
if fmt == "xml":
|
|
87
|
+
out = ["<project name=\"%s\">" % name, "<directory_structure>", "%s/" % name]
|
|
88
|
+
out += tree
|
|
89
|
+
out += ["</directory_structure>", "<files>"]
|
|
90
|
+
for e in entries:
|
|
91
|
+
out.append("<file path=\"%s\">" % e["rel"].as_posix())
|
|
92
|
+
out.append(e["text"].rstrip("\n"))
|
|
93
|
+
out.append("</file>")
|
|
94
|
+
out += ["</files>", "</project>"]
|
|
95
|
+
return "\n".join(out) + "\n"
|
|
96
|
+
out = ["# Project: %s" % name, "", "_Packed with devkittools `dkt pcp`_", "",
|
|
97
|
+
"## Directory structure", "", "```", "%s/" % name]
|
|
98
|
+
out += tree
|
|
99
|
+
out += ["```", "", "## Files", ""]
|
|
100
|
+
for e in entries:
|
|
101
|
+
fence = "```"
|
|
102
|
+
while fence in e["text"]:
|
|
103
|
+
fence += "`"
|
|
104
|
+
out.append("### %s" % e["rel"].as_posix())
|
|
105
|
+
out.append("")
|
|
106
|
+
out.append(fence + FENCE_LANG.get(e["rel"].suffix.lower(), ""))
|
|
107
|
+
out.append(e["text"].rstrip("\n"))
|
|
108
|
+
out.append(fence)
|
|
109
|
+
out.append("")
|
|
110
|
+
return "\n".join(out)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def run(args: argparse.Namespace) -> int:
|
|
114
|
+
folder = resolve_folder(args.folder)
|
|
115
|
+
ext = "xml" if args.format == "xml" else "md"
|
|
116
|
+
out_path = Path(args.output).resolve() if args.output else Path.cwd() / ("%s-context.%s" % (folder.name, ext))
|
|
117
|
+
include = [("." + e.lstrip(".")).lower() for e in (args.include or [])]
|
|
118
|
+
exclude = args.exclude or []
|
|
119
|
+
max_bytes = int(args.max_file_kb * 1024)
|
|
120
|
+
|
|
121
|
+
skipped = Counter() # type: Counter
|
|
122
|
+
candidates = [] # type: List[dict]
|
|
123
|
+
for rel in list_project_files(folder):
|
|
124
|
+
full = folder / rel
|
|
125
|
+
if full.resolve() == out_path:
|
|
126
|
+
continue
|
|
127
|
+
low = rel.name.lower()
|
|
128
|
+
if low in SKIP_NAMES:
|
|
129
|
+
skipped["lock/system files"] += 1
|
|
130
|
+
continue
|
|
131
|
+
if rel.suffix.lower() in BINARY_EXT:
|
|
132
|
+
skipped["binary files"] += 1
|
|
133
|
+
continue
|
|
134
|
+
if low.endswith((".min.js", ".min.css", ".map")):
|
|
135
|
+
skipped["minified/map files"] += 1
|
|
136
|
+
continue
|
|
137
|
+
if include and rel.suffix.lower() not in include:
|
|
138
|
+
skipped["not in --include"] += 1
|
|
139
|
+
continue
|
|
140
|
+
if exclude and _matches(rel, exclude):
|
|
141
|
+
skipped["matched --exclude"] += 1
|
|
142
|
+
continue
|
|
143
|
+
try:
|
|
144
|
+
size = full.stat().st_size
|
|
145
|
+
if size > max_bytes:
|
|
146
|
+
skipped["too large (> %g KB)" % args.max_file_kb] += 1
|
|
147
|
+
continue
|
|
148
|
+
raw = full.read_bytes()
|
|
149
|
+
except OSError:
|
|
150
|
+
skipped["unreadable"] += 1
|
|
151
|
+
continue
|
|
152
|
+
if is_binary_chunk(raw[:8192]):
|
|
153
|
+
skipped["binary files"] += 1
|
|
154
|
+
continue
|
|
155
|
+
text = raw.decode("utf-8", errors="replace")
|
|
156
|
+
if not text.strip():
|
|
157
|
+
skipped["empty files"] += 1
|
|
158
|
+
continue
|
|
159
|
+
candidates.append({"rel": rel, "text": text, "tokens": count_tokens(text)})
|
|
160
|
+
|
|
161
|
+
if not candidates:
|
|
162
|
+
raise DktError("No files to pack in %s (after filtering)." % folder)
|
|
163
|
+
|
|
164
|
+
selected = candidates
|
|
165
|
+
if args.max_tokens:
|
|
166
|
+
selected = []
|
|
167
|
+
running = 400
|
|
168
|
+
for e in sorted(candidates, key=lambda e: (len(e["rel"].parts), e["rel"].as_posix())):
|
|
169
|
+
cost = e["tokens"] + 30
|
|
170
|
+
if running + cost > args.max_tokens:
|
|
171
|
+
skipped["over --max-tokens budget"] += 1
|
|
172
|
+
continue
|
|
173
|
+
running += cost
|
|
174
|
+
selected.append(e)
|
|
175
|
+
if not selected:
|
|
176
|
+
raise DktError("--max-tokens %d is too small to fit any file." % args.max_tokens)
|
|
177
|
+
selected.sort(key=lambda e: e["rel"].as_posix())
|
|
178
|
+
|
|
179
|
+
doc = render(folder.name, selected, args.format)
|
|
180
|
+
total_tokens = count_tokens(doc)
|
|
181
|
+
|
|
182
|
+
if args.stdout:
|
|
183
|
+
sys.stdout.write(doc)
|
|
184
|
+
else:
|
|
185
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
186
|
+
out_path.write_text(doc, encoding="utf-8")
|
|
187
|
+
if args.copy:
|
|
188
|
+
if copy_to_clipboard(doc):
|
|
189
|
+
info(c("✔ Copied to clipboard", "green"))
|
|
190
|
+
else:
|
|
191
|
+
info(c("! Could not access the clipboard", "yellow"))
|
|
192
|
+
|
|
193
|
+
info("")
|
|
194
|
+
if not args.stdout:
|
|
195
|
+
info("%s %s" % (c("✔ Packed ->", "green"), out_path))
|
|
196
|
+
info(" Files included : %s" % human_num(len(selected)))
|
|
197
|
+
info(" Total size : %s chars, ~%s tokens" % (human_num(len(doc)), human_num(total_tokens)))
|
|
198
|
+
if skipped:
|
|
199
|
+
info(" Skipped : " + ", ".join("%d %s" % (n, why) for why, n in skipped.items()))
|
|
200
|
+
info(" Biggest files :")
|
|
201
|
+
for e in sorted(selected, key=lambda e: e["tokens"], reverse=True)[:5]:
|
|
202
|
+
info(" %8s tokens %s" % (human_num(e["tokens"]), e["rel"].as_posix()))
|
|
203
|
+
return 0
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def register(sub, common) -> None:
|
|
207
|
+
p = sub.add_parser(
|
|
208
|
+
"projectcontextpacker", aliases=["pcp"], parents=[common],
|
|
209
|
+
help="pack a project into one AI-prompt-friendly file",
|
|
210
|
+
description="Bundle a project into a single file (directory tree + file contents)\n"
|
|
211
|
+
"ready to paste into an AI chat. Respects .gitignore, skips binaries,\n"
|
|
212
|
+
"lock files and huge files, and reports token counts.",
|
|
213
|
+
epilog=EPILOG, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
214
|
+
p.add_argument("-f", "--folder", metavar="PATH", help="project folder (default: current folder)")
|
|
215
|
+
p.add_argument("-o", "--output", metavar="FILE", help="output file (default: ./<name>-context.md)")
|
|
216
|
+
p.add_argument("--format", choices=["markdown", "xml"], default="markdown", help="output format")
|
|
217
|
+
p.add_argument("--max-tokens", type=int, metavar="N", help="stay under roughly N tokens")
|
|
218
|
+
p.add_argument("--max-file-kb", type=float, default=200, metavar="KB", help="skip files bigger than this (default 200)")
|
|
219
|
+
p.add_argument("--include", nargs="+", metavar="EXT", help="only these extensions, e.g. .py .md")
|
|
220
|
+
p.add_argument("--exclude", nargs="+", metavar="GLOB", help="skip paths matching these globs")
|
|
221
|
+
p.add_argument("--copy", action="store_true", help="also copy the result to the clipboard")
|
|
222
|
+
p.add_argument("--stdout", action="store_true", help="print to stdout instead of writing a file")
|
|
223
|
+
p.set_defaults(func=run)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
META = ("AI Helpers", "projectcontextpacker", "pcp", "pack a project into one AI-prompt file with token counts")
|