contextzip 0.4.4__tar.gz → 0.4.6__tar.gz

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 (52) hide show
  1. {contextzip-0.4.4 → contextzip-0.4.6}/PKG-INFO +6 -2
  2. {contextzip-0.4.4 → contextzip-0.4.6}/README.md +5 -1
  3. {contextzip-0.4.4 → contextzip-0.4.6}/contextzip/__init__.py +1 -1
  4. contextzip-0.4.6/contextzip/_x11_clip.py +338 -0
  5. {contextzip-0.4.4 → contextzip-0.4.6}/contextzip/api.py +52 -2
  6. {contextzip-0.4.4 → contextzip-0.4.6}/contextzip/cli.py +17 -11
  7. {contextzip-0.4.4 → contextzip-0.4.6}/contextzip/cli_ai.py +1 -28
  8. {contextzip-0.4.4 → contextzip-0.4.6}/contextzip/cli_display.py +10 -3
  9. contextzip-0.4.6/contextzip/clipboard.py +552 -0
  10. {contextzip-0.4.4 → contextzip-0.4.6}/contextzip/error_parser.py +12 -0
  11. {contextzip-0.4.4 → contextzip-0.4.6}/contextzip/filters.py +116 -8
  12. {contextzip-0.4.4 → contextzip-0.4.6}/contextzip/watcher.py +8 -0
  13. {contextzip-0.4.4 → contextzip-0.4.6}/contextzip.egg-info/PKG-INFO +6 -2
  14. {contextzip-0.4.4 → contextzip-0.4.6}/contextzip.egg-info/SOURCES.txt +1 -0
  15. {contextzip-0.4.4 → contextzip-0.4.6}/pyproject.toml +1 -1
  16. contextzip-0.4.4/contextzip/clipboard.py +0 -223
  17. {contextzip-0.4.4 → contextzip-0.4.6}/LICENSE +0 -0
  18. {contextzip-0.4.4 → contextzip-0.4.6}/contextzip/ai/__init__.py +0 -0
  19. {contextzip-0.4.4 → contextzip-0.4.6}/contextzip/ai/gemini.py +0 -0
  20. {contextzip-0.4.4 → contextzip-0.4.6}/contextzip/ai/heuristic.py +0 -0
  21. {contextzip-0.4.4 → contextzip-0.4.6}/contextzip/ai/selector.py +0 -0
  22. {contextzip-0.4.4 → contextzip-0.4.6}/contextzip/applier.py +0 -0
  23. {contextzip-0.4.4 → contextzip-0.4.6}/contextzip/cleanup.py +0 -0
  24. {contextzip-0.4.4 → contextzip-0.4.6}/contextzip/cli_onboard.py +0 -0
  25. {contextzip-0.4.4 → contextzip-0.4.6}/contextzip/config.py +0 -0
  26. {contextzip-0.4.4 → contextzip-0.4.6}/contextzip/detector.py +0 -0
  27. {contextzip-0.4.4 → contextzip-0.4.6}/contextzip/git.py +0 -0
  28. {contextzip-0.4.4 → contextzip-0.4.6}/contextzip/packager.py +0 -0
  29. {contextzip-0.4.4 → contextzip-0.4.6}/contextzip/project_config.py +0 -0
  30. {contextzip-0.4.4 → contextzip-0.4.6}/contextzip/redact.py +0 -0
  31. {contextzip-0.4.4 → contextzip-0.4.6}/contextzip/report.py +0 -0
  32. {contextzip-0.4.4 → contextzip-0.4.6}/contextzip/rules/__init__.py +0 -0
  33. {contextzip-0.4.4 → contextzip-0.4.6}/contextzip/rules/base.py +0 -0
  34. {contextzip-0.4.4 → contextzip-0.4.6}/contextzip/rules/errors/__init__.py +0 -0
  35. {contextzip-0.4.4 → contextzip-0.4.6}/contextzip/rules/errors/node.py +0 -0
  36. {contextzip-0.4.4 → contextzip-0.4.6}/contextzip/rules/errors/python.py +0 -0
  37. {contextzip-0.4.4 → contextzip-0.4.6}/contextzip/rules/go.py +0 -0
  38. {contextzip-0.4.4 → contextzip-0.4.6}/contextzip/rules/node.py +0 -0
  39. {contextzip-0.4.4 → contextzip-0.4.6}/contextzip/rules/python.py +0 -0
  40. {contextzip-0.4.4 → contextzip-0.4.6}/contextzip/rules/ruby.py +0 -0
  41. {contextzip-0.4.4 → contextzip-0.4.6}/contextzip/rules/rust.py +0 -0
  42. {contextzip-0.4.4 → contextzip-0.4.6}/contextzip/updater.py +0 -0
  43. {contextzip-0.4.4 → contextzip-0.4.6}/contextzip/webui/__init__.py +0 -0
  44. {contextzip-0.4.4 → contextzip-0.4.6}/contextzip/webui/assets.py +0 -0
  45. {contextzip-0.4.4 → contextzip-0.4.6}/contextzip/webui/persist.py +0 -0
  46. {contextzip-0.4.4 → contextzip-0.4.6}/contextzip/webui/server.py +0 -0
  47. {contextzip-0.4.4 → contextzip-0.4.6}/contextzip/webui/suggestions.py +0 -0
  48. {contextzip-0.4.4 → contextzip-0.4.6}/contextzip.egg-info/dependency_links.txt +0 -0
  49. {contextzip-0.4.4 → contextzip-0.4.6}/contextzip.egg-info/entry_points.txt +0 -0
  50. {contextzip-0.4.4 → contextzip-0.4.6}/contextzip.egg-info/requires.txt +0 -0
  51. {contextzip-0.4.4 → contextzip-0.4.6}/contextzip.egg-info/top_level.txt +0 -0
  52. {contextzip-0.4.4 → contextzip-0.4.6}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: contextzip
3
- Version: 0.4.4
3
+ Version: 0.4.6
4
4
  Summary: Intelligently package your codebase for AI tools
5
5
  Author-email: Deepesh <akadeepesh@gmail.com>
6
6
  License-Expression: MIT
@@ -27,7 +27,11 @@ Requires-Dist: pathspec>=0.12
27
27
  Requires-Dist: httpx>=0.27
28
28
  Dynamic: license-file
29
29
 
30
- <img width="1536" height="1024" alt="cz" src="https://github.com/user-attachments/assets/a930b49c-34a0-447b-8f3a-1909eae977c8" />
30
+
31
+
32
+ https://github.com/user-attachments/assets/91913ad6-6161-430d-9075-b0fa76498021
33
+
34
+ <!-- <img width="1536" height="1024" alt="cz" src="https://github.com/user-attachments/assets/a930b49c-34a0-447b-8f3a-1909eae977c8" /> -->
31
35
 
32
36
  # contextzip
33
37
 
@@ -1,4 +1,8 @@
1
- <img width="1536" height="1024" alt="cz" src="https://github.com/user-attachments/assets/a930b49c-34a0-447b-8f3a-1909eae977c8" />
1
+
2
+
3
+ https://github.com/user-attachments/assets/91913ad6-6161-430d-9075-b0fa76498021
4
+
5
+ <!-- <img width="1536" height="1024" alt="cz" src="https://github.com/user-attachments/assets/a930b49c-34a0-447b-8f3a-1909eae977c8" /> -->
2
6
 
3
7
  # contextzip
4
8
 
@@ -1,6 +1,6 @@
1
1
  """contextzip — intelligent codebase packager for AI tools."""
2
2
 
3
- __version__ = "0.4.4"
3
+ __version__ = "0.4.6"
4
4
 
5
5
  from contextzip.api import (
6
6
  FileCollection,
@@ -0,0 +1,338 @@
1
+ """
2
+ _x11_clip.py — a tiny X11 CLIPBOARD owner written against the raw X protocol.
3
+
4
+ Why this exists: on Linux, "a file on the clipboard" means owning the X
5
+ CLIPBOARD selection and answering requests for ``text/uri-list`` (what
6
+ browsers read when you paste) with a ``file:///…`` URL. Normally that needs
7
+ ``xclip`` — a system package that ``pip install`` can't provide. The protocol
8
+ subset needed is small, so contextzip speaks it itself: standard library only,
9
+ no dependencies.
10
+
11
+ It offers the same targets a file manager offers when you press Ctrl+C on a
12
+ file, so it works for browsers (text/uri-list), GNOME-family file managers
13
+ (x-special/gnome-copied-files) and KDE (application/x-kde-cutselection).
14
+
15
+ Runs two ways:
16
+ * imported: ``read_selection()`` reads the clipboard back (verification)
17
+ * as a script ``python _x11_clip.py serve <abs-path>`` — the background
18
+ process that keeps owning the selection until another app copies
19
+ something (SelectionClear) or the X connection closes. It is launched by
20
+ path, so this file must stay importable without the contextzip package.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import os
26
+ import socket
27
+ import struct
28
+ import sys
29
+ from pathlib import Path
30
+
31
+ URI_LIST = "text/uri-list"
32
+ GNOME_COPIED = "x-special/gnome-copied-files"
33
+ KDE_CUT = "application/x-kde-cutselection"
34
+
35
+ _ATOM = 4 # predefined atom: ATOM
36
+ _SELECTION_CLEAR = 29
37
+ _SELECTION_REQUEST = 30
38
+ _SELECTION_NOTIFY = 31
39
+
40
+
41
+ class X11Error(Exception):
42
+ pass
43
+
44
+
45
+ def _pad(n: int) -> int:
46
+ return (4 - n % 4) % 4
47
+
48
+
49
+ def payloads_for(path: str) -> dict[str, bytes]:
50
+ """Clipboard targets (and their data) for one file, as a file manager sets them."""
51
+ uri = Path(path).as_uri()
52
+ return {
53
+ URI_LIST: (uri + "\r\n").encode(),
54
+ GNOME_COPIED: ("copy\n" + uri).encode(),
55
+ KDE_CUT: b"0",
56
+ }
57
+
58
+
59
+ # ---------------------------------------------------------------------------
60
+ # Connection
61
+ # ---------------------------------------------------------------------------
62
+
63
+
64
+ def _parse_display(display: str) -> tuple[str, int]:
65
+ host, sep, rest = display.rpartition(":")
66
+ if not sep or not rest:
67
+ raise X11Error(f"unusable DISPLAY {display!r}")
68
+ try:
69
+ return host, int(rest.split(".")[0])
70
+ except ValueError:
71
+ raise X11Error(f"unusable DISPLAY {display!r}") from None
72
+
73
+
74
+ def _open_socket(host: str, num: int, timeout: float | None) -> socket.socket:
75
+ if host in ("", "unix"):
76
+ # Xorg/XWayland listen on a filesystem socket and (on Linux) an abstract one.
77
+ for addr in (f"/tmp/.X11-unix/X{num}", f"\0/tmp/.X11-unix/X{num}"):
78
+ sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
79
+ sock.settimeout(timeout)
80
+ try:
81
+ sock.connect(addr)
82
+ return sock
83
+ except OSError:
84
+ sock.close()
85
+ raise X11Error(f"cannot connect to X display :{num}")
86
+ try:
87
+ return socket.create_connection((host, 6000 + num), timeout)
88
+ except OSError as exc:
89
+ raise X11Error(f"cannot connect to X display {host}:{num}") from exc
90
+
91
+
92
+ def _read_xauthority() -> list[tuple[int, bytes, bytes, bytes, bytes]]:
93
+ path = os.environ.get("XAUTHORITY") or os.path.join(os.path.expanduser("~"), ".Xauthority")
94
+ try:
95
+ data = Path(path).read_bytes()
96
+ except OSError:
97
+ return []
98
+ entries, i = [], 0
99
+ try:
100
+ while i + 2 <= len(data):
101
+ (family,) = struct.unpack(">H", data[i : i + 2])
102
+ i += 2
103
+ fields = []
104
+ for _ in range(4): # address, display number, auth name, auth data
105
+ (n,) = struct.unpack(">H", data[i : i + 2])
106
+ i += 2
107
+ fields.append(data[i : i + n])
108
+ i += n
109
+ entries.append((family, *fields))
110
+ except struct.error:
111
+ pass
112
+ return entries
113
+
114
+
115
+ def _auth_for(host: str, num: int) -> tuple[bytes, bytes]:
116
+ """MIT-MAGIC-COOKIE-1 for this display from ~/.Xauthority ($XAUTHORITY), if any."""
117
+ cookies = [
118
+ e
119
+ for e in _read_xauthority()
120
+ if e[3] == b"MIT-MAGIC-COOKIE-1" and e[2] in (b"", str(num).encode())
121
+ ]
122
+ local = host in ("", "unix")
123
+ me = socket.gethostname().encode()
124
+ for family, addr, _number, name, value in cookies:
125
+ if local and (family == 65535 or (family == 256 and addr == me)):
126
+ return name, value
127
+ if cookies: # hostname changed (containers, renamed machines): number match is enough
128
+ return cookies[0][3], cookies[0][4]
129
+ return b"", b"" # open display (xhost +local:, Xvfb -ac)
130
+
131
+
132
+ class Connection:
133
+ """Just enough X11 client for selection handling. Little-endian, core protocol only."""
134
+
135
+ def __init__(self, display: str | None = None, timeout: float | None = 5.0):
136
+ display = display if display is not None else os.environ.get("DISPLAY", "")
137
+ host, num = _parse_display(display)
138
+ self.sock = _open_socket(host, num, timeout)
139
+ self.sock.settimeout(timeout)
140
+ self.seq = 0
141
+ self.events: list[tuple[int, bytes]] = []
142
+ self._handshake(*_auth_for(host, num))
143
+ self._next_id = 0
144
+
145
+ # -- wire helpers -------------------------------------------------------
146
+
147
+ def _recv(self, n: int) -> bytes:
148
+ buf = b""
149
+ while len(buf) < n:
150
+ chunk = self.sock.recv(n - len(buf))
151
+ if not chunk:
152
+ raise X11Error("X connection closed")
153
+ buf += chunk
154
+ return buf
155
+
156
+ def _handshake(self, auth_name: bytes, auth_data: bytes) -> None:
157
+ req = struct.pack("<BxHHHH2x", 0x6C, 11, 0, len(auth_name), len(auth_data))
158
+ req += auth_name + b"\0" * _pad(len(auth_name))
159
+ req += auth_data + b"\0" * _pad(len(auth_data))
160
+ self.sock.sendall(req)
161
+ status, reason_len, _maj, _min, words = struct.unpack("<BBHHH", self._recv(8))
162
+ body = self._recv(words * 4)
163
+ if status != 1:
164
+ reason = body[:reason_len].decode("latin-1", "replace") or "authorization failed"
165
+ raise X11Error(f"X server refused connection: {reason}")
166
+ self.id_base, self.id_mask = struct.unpack("<II", body[4:12])
167
+ (vendor_len,) = struct.unpack("<H", body[16:18])
168
+ n_formats = body[21]
169
+ off = 32 + vendor_len + _pad(vendor_len) + n_formats * 8
170
+ (self.root,) = struct.unpack("<I", body[off : off + 4])
171
+
172
+ def _request(self, opcode: int, data_byte: int, body: bytes) -> None:
173
+ assert len(body) % 4 == 0
174
+ self.sock.sendall(struct.pack("<BBH", opcode, data_byte, 1 + len(body) // 4) + body)
175
+ self.seq = (self.seq + 1) & 0xFFFF
176
+
177
+ def _read_packet(self) -> tuple[int, bytes]:
178
+ head = self._recv(32)
179
+ code = head[0] & 0x7F
180
+ if code == 1: # reply: may carry extra data
181
+ (extra,) = struct.unpack("<I", head[4:8])
182
+ head += self._recv(extra * 4)
183
+ return code, head
184
+
185
+ def _reply(self) -> bytes:
186
+ while True:
187
+ code, pkt = self._read_packet()
188
+ if code == 0:
189
+ raise X11Error(f"X error {pkt[1]} (request {struct.unpack('<H', pkt[2:4])[0]})")
190
+ if code == 1:
191
+ return pkt
192
+ self.events.append((code, pkt))
193
+
194
+ def next_event(self) -> tuple[int, bytes]:
195
+ """Next event; asynchronous errors are ignored."""
196
+ while True:
197
+ if self.events:
198
+ return self.events.pop(0)
199
+ code, pkt = self._read_packet()
200
+ if code not in (0, 1):
201
+ return code, pkt
202
+
203
+ # -- requests -----------------------------------------------------------
204
+
205
+ def intern(self, name: str) -> int:
206
+ raw = name.encode()
207
+ self._request(16, 0, struct.pack("<HH", len(raw), 0) + raw + b"\0" * _pad(len(raw)))
208
+ return struct.unpack("<I", self._reply()[8:12])[0]
209
+
210
+ def create_window(self) -> int:
211
+ """A 1x1 InputOnly window: selections need a window to be owned from."""
212
+ step = self.id_mask & -self.id_mask
213
+ wid = self.id_base | (step * (self._next_id + 1))
214
+ self._next_id += 1
215
+ # wid, parent, x, y, w, h, border, class=InputOnly, visual=CopyFromParent, mask=0
216
+ self._request(1, 0, struct.pack("<IIhhHHHHII", wid, self.root, 0, 0, 1, 1, 0, 2, 0, 0))
217
+ return wid
218
+
219
+ def set_selection_owner(self, window: int, selection: int) -> None:
220
+ self._request(22, 0, struct.pack("<III", window, selection, 0)) # CurrentTime
221
+
222
+ def get_selection_owner(self, selection: int) -> int:
223
+ self._request(23, 0, struct.pack("<I", selection))
224
+ return struct.unpack("<I", self._reply()[8:12])[0]
225
+
226
+ def change_property(self, window: int, prop: int, typ: int, fmt: int, data: bytes) -> None:
227
+ units = len(data) // (fmt // 8)
228
+ body = struct.pack("<IIIB3xI", window, prop, typ, fmt, units)
229
+ self._request(18, 0, body + data + b"\0" * _pad(len(data))) # mode Replace
230
+
231
+ def send_selection_notify(
232
+ self, requestor: int, selection: int, target: int, prop: int, time: int
233
+ ) -> None:
234
+ event = struct.pack("<BBHIIIII", _SELECTION_NOTIFY, 0, 0, time, requestor, selection, target, prop)
235
+ self._request(25, 0, struct.pack("<II", requestor, 0) + event + b"\0" * 8)
236
+
237
+ def convert_selection(self, requestor: int, selection: int, target: int, prop: int) -> None:
238
+ self._request(24, 0, struct.pack("<IIIII", requestor, selection, target, prop, 0))
239
+
240
+ def get_property(self, window: int, prop: int) -> tuple[int, bytes]:
241
+ self._request(20, 1, struct.pack("<IIIII", window, prop, 0, 0, 0x1FFFFF)) # delete=True
242
+ pkt = self._reply()
243
+ fmt = pkt[1]
244
+ (typ,) = struct.unpack("<I", pkt[8:12])
245
+ (units,) = struct.unpack("<I", pkt[16:20])
246
+ return typ, pkt[32 : 32 + units * (fmt // 8 if fmt else 0)]
247
+
248
+ def close(self) -> None:
249
+ try:
250
+ self.sock.close()
251
+ except OSError:
252
+ pass
253
+
254
+
255
+ # ---------------------------------------------------------------------------
256
+ # Reading (used to verify the copy, and by the tests)
257
+ # ---------------------------------------------------------------------------
258
+
259
+
260
+ def read_selection(
261
+ target: str = URI_LIST, *, display: str | None = None, timeout: float = 2.0
262
+ ) -> bytes | None:
263
+ """Ask the current CLIPBOARD owner for *target*. None if nobody offers it."""
264
+ conn = Connection(display, timeout=timeout)
265
+ try:
266
+ win = conn.create_window()
267
+ clipboard, dest = conn.intern("CLIPBOARD"), conn.intern("CZ_CLIPBOARD_READ")
268
+ conn.convert_selection(win, clipboard, conn.intern(target), dest)
269
+ while True:
270
+ code, pkt = conn.next_event()
271
+ if code == _SELECTION_NOTIFY:
272
+ (prop,) = struct.unpack("<I", pkt[20:24])
273
+ if not prop:
274
+ return None
275
+ return conn.get_property(win, prop)[1]
276
+ except (OSError, X11Error):
277
+ return None
278
+ finally:
279
+ conn.close()
280
+
281
+
282
+ # ---------------------------------------------------------------------------
283
+ # Owning (the background process)
284
+ # ---------------------------------------------------------------------------
285
+
286
+
287
+ def serve(path: str, display: str | None = None, on_ready=None) -> None:
288
+ """
289
+ Own the CLIPBOARD selection for *path* until something else takes it.
290
+ *on_ready* is called once ownership is confirmed (the launcher waits for it).
291
+ """
292
+ conn = Connection(display, timeout=5.0)
293
+ win = conn.create_window()
294
+ clipboard, targets = conn.intern("CLIPBOARD"), conn.intern("TARGETS")
295
+ offered = {conn.intern(name): data for name, data in payloads_for(path).items()}
296
+
297
+ conn.set_selection_owner(win, clipboard)
298
+ if conn.get_selection_owner(clipboard) != win:
299
+ raise X11Error("could not become CLIPBOARD owner")
300
+ conn.sock.settimeout(None) # from here on: block until the server says something
301
+ if on_ready:
302
+ on_ready()
303
+
304
+ while True:
305
+ code, pkt = conn.next_event()
306
+ if code == _SELECTION_CLEAR:
307
+ return # someone else copied something — our job is done
308
+ if code != _SELECTION_REQUEST:
309
+ continue
310
+ time, _owner, requestor, selection, target, prop = struct.unpack("<IIIIII", pkt[4:28])
311
+ reply_prop = prop or target # obsolete clients send no property
312
+ if selection == clipboard and target == targets:
313
+ atoms = [targets, *offered]
314
+ conn.change_property(requestor, reply_prop, _ATOM, 32, struct.pack(f"<{len(atoms)}I", *atoms))
315
+ elif selection == clipboard and target in offered:
316
+ conn.change_property(requestor, reply_prop, target, 8, offered[target])
317
+ else:
318
+ reply_prop = 0 # refuse (TIMESTAMP, MULTIPLE, other selections, …)
319
+ conn.send_selection_notify(requestor, selection, target, reply_prop, time)
320
+
321
+
322
+ def main(argv: list[str]) -> int:
323
+ if len(argv) == 3 and argv[1] == "serve":
324
+ def ready() -> None:
325
+ sys.stdout.write("ready\n")
326
+ sys.stdout.flush()
327
+
328
+ try:
329
+ serve(argv[2], on_ready=ready)
330
+ except (OSError, X11Error):
331
+ return 1
332
+ return 0
333
+ sys.stderr.write("usage: _x11_clip.py serve <absolute-path>\n")
334
+ return 2
335
+
336
+
337
+ if __name__ == "__main__":
338
+ sys.exit(main(sys.argv))
@@ -43,12 +43,15 @@ from contextzip.applier import ApplyResult
43
43
  from contextzip.detector import DetectionResult, detect
44
44
  from contextzip.filters import (
45
45
  ResolveResult,
46
+ build_force_include_spec,
46
47
  build_spec,
48
+ normalize_pattern,
47
49
  resolve_files,
48
50
  resolve_files_from_git,
49
51
  )
50
52
  from contextzip.git import GitError, GitErrorKind, get_changed_files
51
53
  from contextzip.packager import PackageResult, create_zip_silent
54
+ from contextzip.project_config import load_project_config
52
55
 
53
56
 
54
57
  # ---------------------------------------------------------------------------
@@ -139,19 +142,27 @@ class ZipNotFoundError(ContextzipError):
139
142
 
140
143
  def get_git_changes(
141
144
  path: str | Path | None = None,
145
+ *,
146
+ use_project_config: bool = True,
142
147
  ) -> FileCollection:
143
148
  """
144
149
  Return the files that git reports as modified, added, or untracked.
145
150
 
146
151
  The git root is found automatically by walking up from *path* (or the
147
152
  current working directory). Files are filtered through contextzip's base
148
- safety rules so secrets, binaries, and similar files are always excluded.
153
+ safety rules so secrets, binaries, and similar files are always excluded,
154
+ and through the project's ``.contextzip/config.json``
155
+ (``always_include`` / ``always_exclude`` / ``limits``) — the same as the
156
+ CLI's ``--git-changes``.
149
157
 
150
158
  Parameters
151
159
  ----------
152
160
  path:
153
161
  Directory to start from. Defaults to ``Path.cwd()``. The actual
154
162
  git root may be a parent of this directory.
163
+ use_project_config:
164
+ Apply the project's ``.contextzip/config.json``. Defaults to
165
+ ``True``; pass ``False`` to use only the built-in rules.
155
166
 
156
167
  Returns
157
168
  -------
@@ -192,9 +203,12 @@ def get_git_changes(
192
203
  if git_result.is_empty:
193
204
  return FileCollection(project_dir=project_dir)
194
205
 
206
+ cfg = load_project_config(project_dir) if use_project_config else None
207
+
195
208
  resolved = resolve_files_from_git(
196
209
  git_files=git_result.files,
197
210
  project_dir=project_dir,
211
+ **_config_kwargs(cfg),
198
212
  )
199
213
 
200
214
  detection = detect(project_dir)
@@ -212,6 +226,7 @@ def get_files(
212
226
  include: list[str] | None = None,
213
227
  exclude: list[str] | None = None,
214
228
  use_gitignore: bool = True,
229
+ use_project_config: bool = True,
215
230
  ) -> FileCollection:
216
231
  """
217
232
  Return all project files after applying contextzip's standard exclusion rules.
@@ -229,6 +244,11 @@ def get_files(
229
244
  use_gitignore:
230
245
  Whether to apply the project's ``.gitignore`` file.
231
246
  Defaults to ``True``.
247
+ use_project_config:
248
+ Apply the project's ``.contextzip/config.json`` (``always_include``,
249
+ ``always_exclude``, ``limits``), the same as the CLI. Defaults to
250
+ ``True``; pass ``False`` to use only the built-in rules and the
251
+ arguments above.
232
252
 
233
253
  Returns
234
254
  -------
@@ -256,9 +276,12 @@ def get_files(
256
276
 
257
277
  gitignore_path = (project_dir / ".gitignore") if use_gitignore else None
258
278
 
279
+ cfg = load_project_config(project_dir) if use_project_config else None
280
+ cfg_kwargs = _config_kwargs(cfg, extra_exclude=exclude)
281
+
259
282
  spec = build_spec(
260
283
  rule_modules=detection.rule_modules,
261
- extra_exclude=exclude or None,
284
+ extra_exclude=cfg_kwargs["extra_exclude"],
262
285
  gitignore_path=gitignore_path,
263
286
  )
264
287
 
@@ -266,6 +289,8 @@ def get_files(
266
289
  project_dir=project_dir,
267
290
  spec=spec,
268
291
  include_only=include or None,
292
+ force_include=cfg_kwargs["force_include"],
293
+ large_file_warn_bytes=cfg_kwargs["large_file_warn_bytes"],
269
294
  )
270
295
 
271
296
  return _resolve_result_to_collection(resolved, project_dir, detection)
@@ -447,6 +472,31 @@ def detect_ecosystem(
447
472
  # ---------------------------------------------------------------------------
448
473
 
449
474
 
475
+ def _config_kwargs(cfg, extra_exclude: list[str] | None = None) -> dict:
476
+ """
477
+ Translate a ProjectConfig (or None) into the shared filter inputs:
478
+ extra_exclude (caller patterns + always_exclude), force_include
479
+ (always_include) and large_file_warn_bytes (limits.max_file_size_mb).
480
+ """
481
+ from contextzip.filters import LARGE_FILE_WARN_BYTES
482
+
483
+ patterns = [normalize_pattern(p) for p in extra_exclude or []]
484
+ if cfg is None:
485
+ return {
486
+ "extra_exclude": patterns or None,
487
+ "force_include": None,
488
+ "large_file_warn_bytes": LARGE_FILE_WARN_BYTES,
489
+ }
490
+ patterns += [normalize_pattern(p) for p in cfg.always_exclude]
491
+ return {
492
+ "extra_exclude": patterns or None,
493
+ "force_include": build_force_include_spec(
494
+ [normalize_pattern(p) for p in cfg.always_include]
495
+ ),
496
+ "large_file_warn_bytes": int(cfg.limits.max_file_size_mb * 1024 * 1024),
497
+ }
498
+
499
+
450
500
  def _resolve_dir(path: str | Path | None) -> Path:
451
501
  if path is None:
452
502
  return Path(os.getcwd()).resolve()
@@ -927,6 +927,18 @@ def _run(
927
927
  if saved:
928
928
  project_cfg = load_project_config(project_dir)
929
929
 
930
+ # ── Include/exclude inputs shared by every scan mode ─────────────────────
931
+ # CLI --exclude/-e patterns plus any standing always_exclude patterns from
932
+ # project config — both are additive, persistent behavior comes from
933
+ # config.json so it doesn't need to be re-typed every run. always_include
934
+ # is a standing negation. Built once here so standard and --git-changes
935
+ # runs honour project config identically.
936
+ normalized_exclude = [normalize_pattern(p) for p in extra_exclude or []]
937
+ normalized_exclude += [normalize_pattern(p) for p in project_cfg.always_exclude]
938
+ force_include = build_force_include_spec(
939
+ [normalize_pattern(p) for p in project_cfg.always_include]
940
+ )
941
+
930
942
  # ── Git-changes mode ─────────────────────────────────────────────────────
931
943
  if git_changes:
932
944
  with console.status("[cyan]Querying git for changed files…[/]", spinner="dots"):
@@ -939,7 +951,7 @@ def _run(
939
951
  print_git_deleted_and_submodules(git_result)
940
952
 
941
953
  if git_result.is_empty:
942
- info("Nothing to package — working tree is clean.")
954
+ info("Nothing to compress in this one — working tree is clean.")
943
955
  return
944
956
 
945
957
  scan_label, scan_detail = git_scan_label_and_detail(git_result)
@@ -953,27 +965,21 @@ def _run(
953
965
  large_file_warn_bytes=int(
954
966
  project_cfg.limits.max_file_size_mb * 1024 * 1024
955
967
  ),
968
+ extra_exclude=normalized_exclude if normalized_exclude else None,
969
+ include_only=include_only if include_only else None,
970
+ force_include=force_include,
956
971
  )
957
972
 
958
973
  else:
959
974
  # ── Build exclusion spec ─────────────────────────────────────────────
960
975
  gitignore_path = None if no_gitignore else (project_dir / ".gitignore")
961
976
 
962
- # CLI --exclude/-e patterns plus any standing always_exclude patterns
963
- # from project config — both are additive, persistent behavior comes
964
- # from config.json so it doesn't need to be re-typed every run.
965
- normalized_exclude = [normalize_pattern(p) for p in extra_exclude or []]
966
- normalized_exclude += [normalize_pattern(p) for p in project_cfg.always_exclude]
967
-
968
977
  with console.status("[cyan]Building exclusion rules…[/]", spinner="dots"):
969
978
  spec = build_spec(
970
979
  rule_modules=detection.rule_modules,
971
980
  extra_exclude=normalized_exclude if normalized_exclude else None,
972
981
  gitignore_path=gitignore_path,
973
982
  )
974
- force_include = build_force_include_spec(
975
- [normalize_pattern(p) for p in project_cfg.always_include]
976
- )
977
983
 
978
984
  # ── Resolve files ─────────────────────────────────────────────────────
979
985
  with console.status("[cyan]Scanning project files…[/]", spinner="dots"):
@@ -1027,7 +1033,7 @@ def _run(
1027
1033
  return
1028
1034
 
1029
1035
  if not resolved.included:
1030
- err("Nothing to package — all files were excluded.")
1036
+ err("Nothing to compress in this one — all files were excluded.")
1031
1037
  info("Try contextzip include PATH or -i PATH to override.")
1032
1038
  return
1033
1039
 
@@ -15,38 +15,11 @@ from pathlib import Path
15
15
  from rich.console import Console
16
16
 
17
17
  from contextzip.cli_display import print_ai_selection, err, warn, info
18
+ from contextzip.filters import normalize_pattern # noqa: F401 (re-exported for cli.py)
18
19
 
19
20
  console = Console()
20
21
 
21
22
 
22
- # ---------------------------------------------------------------------------
23
- # Pattern normalisation
24
- # ---------------------------------------------------------------------------
25
-
26
-
27
- def normalize_pattern(p: str) -> str:
28
- """
29
- Canonicalise a user-supplied exclusion pattern.
30
-
31
- Rules applied in order:
32
- 1. Strip a leading ``./`` or ``.\\`` so that ``./CHANGELOG.md``
33
- and ``CHANGELOG.md`` are treated identically.
34
- 2. Replace every backslash with a forward slash for cross-platform
35
- consistency (Windows paths entered on the CLI).
36
- 3. Collapse ``folder/*`` → ``folder/`` so that gitignore-style
37
- directory globs work as expected.
38
- """
39
- # 1. Strip leading ./ or .\\
40
- if p.startswith("./") or p.startswith(".\\"):
41
- p = p[2:]
42
- # 2. Normalise path separators
43
- p = p.replace("\\", "/")
44
- # 3. folder/* → folder/
45
- if p.endswith("/*"):
46
- p = p[:-1]
47
- return p
48
-
49
-
50
23
  # ---------------------------------------------------------------------------
51
24
  # AI selection (full run — produces ZIP)
52
25
  # ---------------------------------------------------------------------------
@@ -64,6 +64,10 @@ def print_detection(detection, *, con: Console = console) -> None:
64
64
  """One line: what was detected and how confident contextzip is."""
65
65
  if detection.is_unknown:
66
66
  ok("Detected", "unknown ecosystem, base rules only", con=con)
67
+ info(
68
+ "Don't see your language? Add it here: https://example.com",
69
+ con=con,
70
+ )
67
71
  return
68
72
 
69
73
  parts = []
@@ -295,13 +299,16 @@ def print_ai_selection(
295
299
 
296
300
 
297
301
  def print_clipboard_result(cb, *, con: Console = console) -> None:
298
- """One line reflecting whichever clipboard tier fired — folder-opened is silent."""
302
+ """One line when the ZIP is on the clipboard; otherwise say why it isn't."""
299
303
  if cb.tier == Tier.FILE_ON_CLIPBOARD:
300
304
  ok("Ready to paste", con=con)
301
- elif cb.tier == Tier.FOLDER_OPENED:
302
- pass # opening the folder is a convenience, not worth a log line
305
+ return
306
+ if cb.tier == Tier.FOLDER_OPENED:
307
+ warn("Couldn't copy the ZIP to the clipboard — opened its folder instead", con=con)
303
308
  else:
304
309
  info(cb.message, con=con)
310
+ if cb.hint:
311
+ info(cb.hint, con=con)
305
312
 
306
313
 
307
314
  # ---------------------------------------------------------------------------