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,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")