edcopy 0.1.0__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.
@@ -0,0 +1,33 @@
1
+ # Python cache/bytecode
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # Virtual environments
7
+ .venv/
8
+ venv/
9
+
10
+ # Build artifacts
11
+ build/
12
+ dist/
13
+ *.egg-info/
14
+ .eggs/
15
+
16
+ # Tooling caches
17
+ .pytest_cache/
18
+ .mypy_cache/
19
+ .ruff_cache/
20
+ .coverage
21
+ .coverage.*
22
+ htmlcov/
23
+
24
+ # Editor/OS
25
+ .DS_Store
26
+ .idea/
27
+ .vscode/
28
+
29
+ # Generated by edcopy
30
+ prompt.md
31
+
32
+ # Dossier des exemples
33
+ examples/
edcopy-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,76 @@
1
+ Metadata-Version: 2.4
2
+ Name: edcopy
3
+ Version: 0.1.0
4
+ Summary: Interactive CLI to collect files and build a numbered prompt.md for LLM workflows.
5
+ Author: Edmond La Chance
6
+ License: MIT
7
+ Keywords: cli,developer-tools,gemini,llm,prompt,tui
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Environment :: Console
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: Software Development :: Build Tools
18
+ Classifier: Topic :: Utilities
19
+ Requires-Python: >=3.10
20
+ Requires-Dist: prompt-toolkit>=3.0.0
21
+ Description-Content-Type: text/markdown
22
+
23
+ ## edcopy
24
+
25
+ `edcopy` est un petit outil CLI pour préparer un `prompt.md` à partir de fichiers sélectionnés dans un TUI.
26
+
27
+ ### Installation (global)
28
+
29
+ Depuis ce dossier:
30
+
31
+ ```bash
32
+ uv tool install .
33
+ ```
34
+
35
+ Puis:
36
+
37
+ ```bash
38
+ edcopy
39
+ ```
40
+
41
+ ### Usage rapide
42
+
43
+ Dans le TUI:
44
+
45
+ - Tape une ligne contenant un ou plusieurs chemins de fichiers (ex: `src/main.py README.md`)
46
+ - Utilise `Tab` pour autocompléter les fichiers (scan récursif depuis le dossier courant)
47
+ - Si tu tapes un chemin partiel (ex: `test1`) puis `Entrée`, `edcopy` prend automatiquement le meilleur match
48
+ - `Ctrl+C` termine la sélection et génère `prompt.md`
49
+ - `Entrée` sur une ligne vide génère aussi `prompt.md`
50
+ - Au lancement, un `prompt.md` existant est supprimé
51
+
52
+ Commandes disponibles:
53
+
54
+ - `@list` pour voir la sélection actuelle
55
+ - `@undo` pour enlever le dernier fichier ajouté
56
+ - `@clear` pour vider la sélection
57
+ - `@rescan` pour rescanner les fichiers
58
+ - `@done` pour générer
59
+ - `@quit` pour quitter sans générer
60
+
61
+ ### Exemple de sortie
62
+
63
+ ````md
64
+ ## Fichier 1: `file.py`
65
+ Chemin: `/abs/path/to/file.py`
66
+
67
+ ```python
68
+ 1 | print("hello")
69
+ 2 | print("world")
70
+ ```
71
+
72
+ ---
73
+
74
+ ## Fichier 2: `file2.py`
75
+ Chemin: `/abs/path/to/file2.py`
76
+ ````
edcopy-0.1.0/README.md ADDED
@@ -0,0 +1,54 @@
1
+ ## edcopy
2
+
3
+ `edcopy` est un petit outil CLI pour préparer un `prompt.md` à partir de fichiers sélectionnés dans un TUI.
4
+
5
+ ### Installation (global)
6
+
7
+ Depuis ce dossier:
8
+
9
+ ```bash
10
+ uv tool install .
11
+ ```
12
+
13
+ Puis:
14
+
15
+ ```bash
16
+ edcopy
17
+ ```
18
+
19
+ ### Usage rapide
20
+
21
+ Dans le TUI:
22
+
23
+ - Tape une ligne contenant un ou plusieurs chemins de fichiers (ex: `src/main.py README.md`)
24
+ - Utilise `Tab` pour autocompléter les fichiers (scan récursif depuis le dossier courant)
25
+ - Si tu tapes un chemin partiel (ex: `test1`) puis `Entrée`, `edcopy` prend automatiquement le meilleur match
26
+ - `Ctrl+C` termine la sélection et génère `prompt.md`
27
+ - `Entrée` sur une ligne vide génère aussi `prompt.md`
28
+ - Au lancement, un `prompt.md` existant est supprimé
29
+
30
+ Commandes disponibles:
31
+
32
+ - `@list` pour voir la sélection actuelle
33
+ - `@undo` pour enlever le dernier fichier ajouté
34
+ - `@clear` pour vider la sélection
35
+ - `@rescan` pour rescanner les fichiers
36
+ - `@done` pour générer
37
+ - `@quit` pour quitter sans générer
38
+
39
+ ### Exemple de sortie
40
+
41
+ ````md
42
+ ## Fichier 1: `file.py`
43
+ Chemin: `/abs/path/to/file.py`
44
+
45
+ ```python
46
+ 1 | print("hello")
47
+ 2 | print("world")
48
+ ```
49
+
50
+ ---
51
+
52
+ ## Fichier 2: `file2.py`
53
+ Chemin: `/abs/path/to/file2.py`
54
+ ````
@@ -0,0 +1,44 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "edcopy"
7
+ version = "0.1.0"
8
+ description = "Interactive CLI to collect files and build a numbered prompt.md for LLM workflows."
9
+ readme = { file = "README.md", content-type = "text/markdown" }
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ authors = [
13
+ { name = "Edmond La Chance" },
14
+ ]
15
+ keywords = [
16
+ "cli",
17
+ "tui",
18
+ "prompt",
19
+ "llm",
20
+ "gemini",
21
+ "developer-tools",
22
+ ]
23
+ classifiers = [
24
+ "Development Status :: 3 - Alpha",
25
+ "Environment :: Console",
26
+ "Intended Audience :: Developers",
27
+ "License :: OSI Approved :: MIT License",
28
+ "Operating System :: OS Independent",
29
+ "Programming Language :: Python :: 3",
30
+ "Programming Language :: Python :: 3.10",
31
+ "Programming Language :: Python :: 3.11",
32
+ "Programming Language :: Python :: 3.12",
33
+ "Topic :: Software Development :: Build Tools",
34
+ "Topic :: Utilities",
35
+ ]
36
+ dependencies = [
37
+ "prompt-toolkit>=3.0.0",
38
+ ]
39
+
40
+ [project.scripts]
41
+ edcopy = "edcopy.cli:main"
42
+
43
+ [tool.hatch.build.targets.wheel]
44
+ packages = ["src/edcopy"]
@@ -0,0 +1,2 @@
1
+ """edcopy package."""
2
+
@@ -0,0 +1,359 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import os
5
+ import re
6
+ import sys
7
+ from collections import OrderedDict
8
+ from pathlib import Path
9
+ from typing import Iterable
10
+
11
+ from prompt_toolkit import PromptSession
12
+ from prompt_toolkit.completion import Completer, Completion
13
+
14
+ IGNORED_DIRS = {
15
+ ".git",
16
+ ".hg",
17
+ ".svn",
18
+ ".venv",
19
+ "venv",
20
+ "node_modules",
21
+ "__pycache__",
22
+ ".mypy_cache",
23
+ ".pytest_cache",
24
+ }
25
+
26
+ TOKEN_PATTERN = re.compile(r"[^\s,;]+")
27
+
28
+
29
+ class MentionCompleter(Completer):
30
+ def __init__(self, files: list[str]) -> None:
31
+ self.files = files
32
+
33
+ def set_files(self, files: list[str]) -> None:
34
+ self.files = files
35
+
36
+ def _match_rank(self, query: str, rel_path: str) -> int | None:
37
+ rel_lower = rel_path.lower()
38
+ basename_lower = Path(rel_path).name.lower()
39
+
40
+ if rel_lower.startswith(query):
41
+ return 0
42
+ if basename_lower.startswith(query):
43
+ return 1
44
+ if f"/{query}" in rel_lower:
45
+ return 2
46
+ if query in rel_lower:
47
+ return 3
48
+ return None
49
+
50
+ def ranked_matches(self, query: str) -> list[str]:
51
+ q = query.lower().strip()
52
+ ranked_matches: list[tuple[int, str]] = []
53
+ for rel_path in self.files:
54
+ if not q:
55
+ ranked_matches.append((0, rel_path))
56
+ continue
57
+ rank = self._match_rank(q, rel_path)
58
+ if rank is None:
59
+ continue
60
+ ranked_matches.append((rank, rel_path))
61
+
62
+ ranked_matches.sort(key=lambda item: (item[0], len(item[1]), item[1]))
63
+ return [path for _, path in ranked_matches]
64
+
65
+ def get_completions(self, document, complete_event):
66
+ text = document.text_before_cursor
67
+ match = re.search(r"(^|[\s,;])([^\s,;]*)$", text)
68
+ if not match:
69
+ return
70
+
71
+ raw_prefix = match.group(2)
72
+ query = raw_prefix[1:] if raw_prefix.startswith("@") else raw_prefix
73
+ start_position = -len(raw_prefix)
74
+
75
+ for count, rel_path in enumerate(self.ranked_matches(query)):
76
+ yield Completion(
77
+ text=rel_path,
78
+ start_position=start_position,
79
+ display=rel_path,
80
+ )
81
+ if count >= 199:
82
+ return
83
+
84
+
85
+ def discover_files(root: Path) -> list[str]:
86
+ discovered: list[str] = []
87
+ for current, dirs, files in os.walk(root, topdown=True):
88
+ dirs[:] = [d for d in dirs if d not in IGNORED_DIRS]
89
+ current_path = Path(current)
90
+ for filename in files:
91
+ path = current_path / filename
92
+ try:
93
+ rel = path.relative_to(root).as_posix()
94
+ except ValueError:
95
+ continue
96
+ discovered.append(rel)
97
+ discovered.sort()
98
+ return discovered
99
+
100
+
101
+ def extract_candidates(line: str) -> list[str]:
102
+ candidates: list[str] = []
103
+ for token in TOKEN_PATTERN.findall(line):
104
+ value = token[1:] if token.startswith("@") else token
105
+ if value:
106
+ candidates.append(value)
107
+ return candidates
108
+
109
+
110
+ def resolve_mentions(root: Path, mentions: Iterable[str]) -> list[Path]:
111
+ resolved: list[Path] = []
112
+ for mention in mentions:
113
+ path = Path(mention)
114
+ if not path.is_absolute():
115
+ path = (root / path).resolve()
116
+ else:
117
+ path = path.resolve()
118
+ resolved.append(path)
119
+ return resolved
120
+
121
+
122
+ def format_numbered_lines(content: str) -> str:
123
+ lines = content.splitlines()
124
+ if not lines:
125
+ return "1 | "
126
+ return "\n".join(f"{idx} | {line}" for idx, line in enumerate(lines, start=1))
127
+
128
+
129
+ def read_file_content(path: Path) -> str:
130
+ return path.read_text(encoding="utf-8", errors="replace")
131
+
132
+
133
+ def language_from_suffix(path: Path) -> str:
134
+ mapping = {
135
+ ".py": "python",
136
+ ".js": "javascript",
137
+ ".ts": "typescript",
138
+ ".tsx": "tsx",
139
+ ".jsx": "jsx",
140
+ ".json": "json",
141
+ ".md": "markdown",
142
+ ".yml": "yaml",
143
+ ".yaml": "yaml",
144
+ ".sh": "bash",
145
+ ".toml": "toml",
146
+ ".html": "html",
147
+ ".css": "css",
148
+ ".sql": "sql",
149
+ ".go": "go",
150
+ ".rs": "rust",
151
+ ".java": "java",
152
+ ".c": "c",
153
+ ".cpp": "cpp",
154
+ }
155
+ return mapping.get(path.suffix.lower(), "text")
156
+
157
+
158
+ def format_file_section(index: int, file_path: Path, content: str) -> str:
159
+ language = language_from_suffix(file_path)
160
+ numbered = format_numbered_lines(content)
161
+ return "\n".join(
162
+ [
163
+ f"## Fichier {index}: `{file_path.name}`",
164
+ f"Chemin: `{file_path}`",
165
+ "",
166
+ f"```{language}",
167
+ numbered,
168
+ "```",
169
+ ]
170
+ )
171
+
172
+
173
+ def write_prompt(selected_files: list[Path], output: Path) -> None:
174
+ if not selected_files:
175
+ output.write_text("", encoding="utf-8")
176
+ return
177
+
178
+ sections: list[str] = []
179
+ for index, file_path in enumerate(selected_files, start=1):
180
+ try:
181
+ content = read_file_content(file_path)
182
+ except OSError as exc:
183
+ sections.append(
184
+ "\n".join(
185
+ [
186
+ f"## Fichier {index}: `{file_path.name}`",
187
+ f"Chemin: `{file_path}`",
188
+ "",
189
+ f"[ERROR] Unable to read file: {exc}",
190
+ ]
191
+ )
192
+ )
193
+ continue
194
+
195
+ sections.append(format_file_section(index, file_path, content))
196
+
197
+ output.write_text("\n\n---\n\n".join(sections) + "\n", encoding="utf-8")
198
+
199
+
200
+ def run_tui(root: Path, output: Path) -> int:
201
+ if output.exists():
202
+ try:
203
+ output.unlink()
204
+ print(f"Sortie précédente supprimée: {output}")
205
+ except OSError as exc:
206
+ print(f"[WARN] Impossible de supprimer {output}: {exc}")
207
+
208
+ file_index = discover_files(root)
209
+ completer = MentionCompleter(file_index)
210
+ session = PromptSession(completer=completer, complete_while_typing=True)
211
+
212
+ selected: OrderedDict[Path, None] = OrderedDict()
213
+
214
+ print(f"edcopy - root: {root}")
215
+ print(f"Sortie: {output}")
216
+ print("Ajoute des fichiers en tapant leur chemin puis Entrée.")
217
+ print("Commandes: @list, @undo, @clear, @rescan, @done, @quit")
218
+ print("Ligne vide => @done | Ctrl+C => terminer et générer")
219
+
220
+ while True:
221
+ try:
222
+ line = session.prompt("edcopy> ")
223
+ except KeyboardInterrupt:
224
+ print("\nFin de sélection (Ctrl+C).")
225
+ break
226
+ except EOFError:
227
+ print()
228
+ break
229
+
230
+ stripped = line.strip()
231
+ if stripped == "":
232
+ break
233
+ if stripped == "@quit":
234
+ print("Aucun fichier généré.")
235
+ return 0
236
+ if stripped == "@done":
237
+ break
238
+ if stripped == "@clear":
239
+ selected.clear()
240
+ print("Sélection vidée.")
241
+ continue
242
+ if stripped == "@list":
243
+ if not selected:
244
+ print("Aucun fichier sélectionné.")
245
+ else:
246
+ print("Fichiers sélectionnés:")
247
+ for item in selected:
248
+ print(f"- {item}")
249
+ continue
250
+ if stripped == "@undo":
251
+ if not selected:
252
+ print("Aucun fichier à retirer.")
253
+ continue
254
+ removed, _ = selected.popitem(last=True)
255
+ print(f"Retiré: {removed}")
256
+ continue
257
+ if stripped == "@rescan":
258
+ file_index = discover_files(root)
259
+ completer.set_files(file_index)
260
+ print(f"Rescan terminé: {len(file_index)} fichiers indexés.")
261
+ continue
262
+ if stripped.startswith("/"):
263
+ print("Commande invalide. Utilise le préfixe @ (ex: @list).")
264
+ continue
265
+
266
+ candidates = extract_candidates(line)
267
+ if not candidates:
268
+ print("Aucun chemin détecté.")
269
+ continue
270
+
271
+ for raw_candidate in candidates:
272
+ candidate = resolve_mentions(root, [raw_candidate])[0]
273
+ auto_label = ""
274
+ if not candidate.exists() and not Path(raw_candidate).is_absolute():
275
+ matches = completer.ranked_matches(raw_candidate)
276
+ if matches:
277
+ matched_rel = matches[0]
278
+ candidate = (root / matched_rel).resolve()
279
+ auto_label = f"[AUTO] {raw_candidate} -> {matched_rel} | "
280
+
281
+ if not candidate.exists():
282
+ print(f"[WARN] Introuvable: {raw_candidate}")
283
+ continue
284
+ if not candidate.is_file():
285
+ print(f"[WARN] Pas un fichier: {candidate}")
286
+ continue
287
+ if candidate in selected:
288
+ print(f"[SKIP] Déjà ajouté: {candidate}")
289
+ continue
290
+ selected[candidate] = None
291
+ print(f"{auto_label}[OK] Ajouté: {candidate}")
292
+
293
+ write_prompt(list(selected.keys()), output)
294
+ print(f"prompt généré: {output} ({len(selected)} fichiers)")
295
+ return 0
296
+
297
+
298
+ def parse_args(argv: list[str]) -> argparse.Namespace:
299
+ parser = argparse.ArgumentParser(
300
+ prog="edcopy",
301
+ description="Collect files from a TUI and build a numbered prompt.md",
302
+ )
303
+ parser.add_argument(
304
+ "-o",
305
+ "--output",
306
+ default="prompt.md",
307
+ help="Chemin de sortie (défaut: prompt.md)",
308
+ )
309
+ parser.add_argument(
310
+ "-f",
311
+ "--file",
312
+ action="append",
313
+ default=[],
314
+ help="Ajoute un fichier directement (peut être répété).",
315
+ )
316
+ return parser.parse_args(argv)
317
+
318
+
319
+ def run_non_interactive(root: Path, files: list[str], output: Path) -> int:
320
+ selected: OrderedDict[Path, None] = OrderedDict()
321
+ for item in files:
322
+ mention = item[1:] if item.startswith("@") else item
323
+ candidate = Path(mention)
324
+ if not candidate.is_absolute():
325
+ candidate = (root / candidate).resolve()
326
+ else:
327
+ candidate = candidate.resolve()
328
+
329
+ if not candidate.exists():
330
+ print(f"[WARN] Introuvable: {candidate}")
331
+ continue
332
+ if not candidate.is_file():
333
+ print(f"[WARN] Pas un fichier: {candidate}")
334
+ continue
335
+ selected[candidate] = None
336
+
337
+ if not selected:
338
+ print("Aucun fichier valide fourni.")
339
+ return 1
340
+
341
+ write_prompt(list(selected.keys()), output)
342
+ print(f"prompt généré: {output} ({len(selected)} fichiers)")
343
+ return 0
344
+
345
+
346
+ def main() -> int:
347
+ args = parse_args(sys.argv[1:])
348
+ root = Path.cwd()
349
+ output = Path(args.output)
350
+ if not output.is_absolute():
351
+ output = (root / output).resolve()
352
+
353
+ if args.file:
354
+ return run_non_interactive(root, args.file, output)
355
+ return run_tui(root, output)
356
+
357
+
358
+ if __name__ == "__main__":
359
+ raise SystemExit(main())
edcopy-0.1.0/uv.lock ADDED
@@ -0,0 +1,35 @@
1
+ version = 1
2
+ revision = 3
3
+ requires-python = ">=3.10"
4
+
5
+ [[package]]
6
+ name = "edcopy"
7
+ version = "0.1.0"
8
+ source = { editable = "." }
9
+ dependencies = [
10
+ { name = "prompt-toolkit" },
11
+ ]
12
+
13
+ [package.metadata]
14
+ requires-dist = [{ name = "prompt-toolkit", specifier = ">=3.0.0" }]
15
+
16
+ [[package]]
17
+ name = "prompt-toolkit"
18
+ version = "3.0.52"
19
+ source = { registry = "https://pypi.org/simple" }
20
+ dependencies = [
21
+ { name = "wcwidth" },
22
+ ]
23
+ sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" }
24
+ wheels = [
25
+ { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" },
26
+ ]
27
+
28
+ [[package]]
29
+ name = "wcwidth"
30
+ version = "0.6.0"
31
+ source = { registry = "https://pypi.org/simple" }
32
+ sdist = { url = "https://files.pythonhosted.org/packages/35/a2/8e3becb46433538a38726c948d3399905a4c7cabd0df578ede5dc51f0ec2/wcwidth-0.6.0.tar.gz", hash = "sha256:cdc4e4262d6ef9a1a57e018384cbeb1208d8abbc64176027e2c2455c81313159", size = 159684, upload-time = "2026-02-06T19:19:40.919Z" }
33
+ wheels = [
34
+ { url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189, upload-time = "2026-02-06T19:19:39.646Z" },
35
+ ]