kopipasta 0.25.0__py3-none-any.whl → 0.27.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.

Potentially problematic release.


This version of kopipasta might be problematic. Click here for more details.

@@ -0,0 +1,284 @@
1
+ import os
2
+ import re
3
+ import json
4
+ import ast
5
+ from typing import Dict, List, Optional, Set, Tuple
6
+
7
+ # --- Global Cache for tsconfig.json data ---
8
+ # Key: absolute path to tsconfig.json file
9
+ # Value: Tuple (absolute_base_url: Optional[str], alias_paths_map: Dict[str, List[str]])
10
+ _tsconfig_configs_cache: Dict[str, Tuple[Optional[str], Dict[str, List[str]]]] = {}
11
+
12
+
13
+ # --- TypeScript Alias and Import Resolution ---
14
+
15
+ def find_relevant_tsconfig_path(file_path_abs: str, project_root_abs: str) -> Optional[str]:
16
+ """
17
+ Finds the most relevant tsconfig.json by searching upwards from the file's directory,
18
+ stopping at project_root_abs.
19
+ Searches for 'tsconfig.json' first, then 'tsconfig.*.json' in each directory.
20
+ """
21
+ current_dir = os.path.dirname(os.path.normpath(file_path_abs))
22
+ project_root_abs_norm = os.path.normpath(project_root_abs)
23
+
24
+ while current_dir.startswith(project_root_abs_norm) and len(current_dir) >= len(project_root_abs_norm):
25
+ potential_tsconfig = os.path.join(current_dir, "tsconfig.json")
26
+ if os.path.isfile(potential_tsconfig):
27
+ return os.path.normpath(potential_tsconfig)
28
+
29
+ try:
30
+ variant_tsconfigs = sorted([
31
+ f for f in os.listdir(current_dir)
32
+ if f.startswith("tsconfig.") and f.endswith(".json") and
33
+ os.path.isfile(os.path.join(current_dir, f))
34
+ ])
35
+ if variant_tsconfigs:
36
+ return os.path.normpath(os.path.join(current_dir, variant_tsconfigs[0]))
37
+ except OSError:
38
+ pass
39
+
40
+ if current_dir == project_root_abs_norm:
41
+ break
42
+
43
+ parent_dir = os.path.dirname(current_dir)
44
+ if parent_dir == current_dir:
45
+ break
46
+ current_dir = parent_dir
47
+ return None
48
+
49
+
50
+ def load_tsconfig_config(tsconfig_path_abs: str) -> Tuple[Optional[str], Dict[str, List[str]]]:
51
+ """
52
+ Loads baseUrl and paths from a specific tsconfig.json.
53
+ Caches results.
54
+ Returns (absolute_base_url, paths_map).
55
+ """
56
+ if tsconfig_path_abs in _tsconfig_configs_cache:
57
+ return _tsconfig_configs_cache[tsconfig_path_abs]
58
+
59
+ if not os.path.isfile(tsconfig_path_abs):
60
+ _tsconfig_configs_cache[tsconfig_path_abs] = (None, {})
61
+ return None, {}
62
+
63
+ try:
64
+ with open(tsconfig_path_abs, 'r', encoding='utf-8') as f:
65
+ content = f.read()
66
+ content = re.sub(r"//.*?\n", "\n", content)
67
+ content = re.sub(r"/\*.*?\*/", "", content, flags=re.DOTALL)
68
+ config = json.loads(content)
69
+
70
+ compiler_options = config.get("compilerOptions", {})
71
+ tsconfig_dir = os.path.dirname(tsconfig_path_abs)
72
+ base_url_from_config = compiler_options.get("baseUrl", ".")
73
+ abs_base_url = os.path.normpath(os.path.join(tsconfig_dir, base_url_from_config))
74
+
75
+ paths = compiler_options.get("paths", {})
76
+ processed_paths = {key: (val if isinstance(val, list) else [val]) for key, val in paths.items()}
77
+
78
+ # print(f"DEBUG: Loaded config from {os.path.relpath(tsconfig_path_abs)}: effective abs_baseUrl='{abs_base_url}', {len(processed_paths)} path alias(es).")
79
+ _tsconfig_configs_cache[tsconfig_path_abs] = (abs_base_url, processed_paths)
80
+ return abs_base_url, processed_paths
81
+ except Exception as e:
82
+ print(f"Warning: Could not parse {os.path.relpath(tsconfig_path_abs)}: {e}")
83
+ _tsconfig_configs_cache[tsconfig_path_abs] = (None, {})
84
+ return None, {}
85
+
86
+
87
+ def _probe_ts_path_candidates(candidate_base_path_abs: str) -> Optional[str]:
88
+ """
89
+ Given a candidate base absolute path, tries to find a corresponding file.
90
+ """
91
+ possible_extensions = ['.ts', '.tsx', '.js', '.jsx', '.json']
92
+
93
+ if os.path.isfile(candidate_base_path_abs):
94
+ return candidate_base_path_abs
95
+
96
+ stem, original_ext = os.path.splitext(candidate_base_path_abs)
97
+ base_for_ext_check = stem if original_ext.lower() in possible_extensions else candidate_base_path_abs
98
+
99
+ for ext in possible_extensions:
100
+ path_with_ext = base_for_ext_check + ext
101
+ if os.path.isfile(path_with_ext):
102
+ return path_with_ext
103
+
104
+ if os.path.isdir(base_for_ext_check):
105
+ for ext in possible_extensions:
106
+ index_file_path = os.path.join(base_for_ext_check, "index" + ext)
107
+ if os.path.isfile(index_file_path):
108
+ return index_file_path
109
+ return None
110
+
111
+
112
+ def resolve_ts_import_path(
113
+ import_str: str,
114
+ current_file_dir_abs: str,
115
+ abs_base_url: Optional[str],
116
+ alias_map: Dict[str, List[str]]
117
+ ) -> Optional[str]:
118
+ """
119
+ Resolves a TypeScript import string to an absolute file path.
120
+ """
121
+ candidate_targets_abs: List[str] = []
122
+ sorted_alias_keys = sorted(alias_map.keys(), key=len, reverse=True)
123
+ alias_matched_and_resolved = False
124
+
125
+ for alias_pattern in sorted_alias_keys:
126
+ alias_prefix_pattern = alias_pattern.replace("/*", "")
127
+ if import_str.startswith(alias_prefix_pattern):
128
+ import_suffix = import_str[len(alias_prefix_pattern):]
129
+ for mapping_path_template_list in alias_map[alias_pattern]:
130
+ for mapping_path_template in (mapping_path_template_list if isinstance(mapping_path_template_list, list) else [mapping_path_template_list]):
131
+ if "/*" in alias_pattern :
132
+ resolved_relative_to_base = mapping_path_template.replace("*", import_suffix, 1)
133
+ else:
134
+ resolved_relative_to_base = mapping_path_template
135
+ if abs_base_url:
136
+ abs_candidate = os.path.normpath(os.path.join(abs_base_url, resolved_relative_to_base))
137
+ candidate_targets_abs.append(abs_candidate)
138
+ else:
139
+ print(f"Warning: TS Alias '{alias_pattern}' used, but no abs_base_url for context of '{current_file_dir_abs}'.")
140
+ if candidate_targets_abs:
141
+ alias_matched_and_resolved = True
142
+ break
143
+
144
+ if not alias_matched_and_resolved and import_str.startswith('.'):
145
+ abs_candidate = os.path.normpath(os.path.join(current_file_dir_abs, import_str))
146
+ candidate_targets_abs.append(abs_candidate)
147
+ elif not alias_matched_and_resolved and abs_base_url and not import_str.startswith('.'):
148
+ abs_candidate = os.path.normpath(os.path.join(abs_base_url, import_str))
149
+ candidate_targets_abs.append(abs_candidate)
150
+
151
+ for cand_abs_path in candidate_targets_abs:
152
+ resolved_file = _probe_ts_path_candidates(cand_abs_path)
153
+ if resolved_file:
154
+ return resolved_file
155
+ return None
156
+
157
+
158
+ def parse_typescript_imports(
159
+ file_content: str,
160
+ file_path_abs: str,
161
+ project_root_abs: str
162
+ ) -> Set[str]:
163
+ resolved_imports_abs_paths = set()
164
+ relevant_tsconfig_abs_path = find_relevant_tsconfig_path(file_path_abs, project_root_abs)
165
+
166
+ abs_base_url, alias_map = None, {}
167
+ if relevant_tsconfig_abs_path:
168
+ abs_base_url, alias_map = load_tsconfig_config(relevant_tsconfig_abs_path)
169
+ else:
170
+ # print(f"Warning: No tsconfig.json found for {os.path.relpath(file_path_abs, project_root_abs)}. Import resolution might be limited.")
171
+ abs_base_url = project_root_abs
172
+
173
+ import_regex = re.compile(
174
+ r"""
175
+ (?:import|export)
176
+ (?:\s+(?:type\s+)?(?:[\w*{}\s,\[\]:\."'`-]+)\s+from)?
177
+ \s*['"`]([^'"\n`]+?)['"`]
178
+ |require\s*\(\s*['"`]([^'"\n`]+?)['"`]\s*\)
179
+ |import\s*\(\s*['"`]([^'"\n`]+?)['"`]\s*\)
180
+ """,
181
+ re.VERBOSE | re.MULTILINE
182
+ )
183
+
184
+ current_file_dir_abs = os.path.dirname(file_path_abs)
185
+
186
+ for match in import_regex.finditer(file_content):
187
+ import_str_candidate = next((g for g in match.groups() if g is not None), None)
188
+ if import_str_candidate:
189
+ is_likely_external = (
190
+ not import_str_candidate.startswith(('.', '/')) and
191
+ not any(import_str_candidate.startswith(alias_pattern.replace("/*", "")) for alias_pattern in alias_map) and
192
+ not (abs_base_url and os.path.exists(os.path.join(abs_base_url, import_str_candidate))) and
193
+ (import_str_candidate.count('/') == 0 or (import_str_candidate.startswith('@') and import_str_candidate.count('/') == 1)) and
194
+ '.' not in import_str_candidate.split('/')[0]
195
+ )
196
+ if is_likely_external:
197
+ continue
198
+
199
+ resolved_abs_path = resolve_ts_import_path(
200
+ import_str_candidate,
201
+ current_file_dir_abs,
202
+ abs_base_url,
203
+ alias_map
204
+ )
205
+
206
+ if resolved_abs_path:
207
+ norm_resolved_path = os.path.normpath(resolved_abs_path)
208
+ if norm_resolved_path.startswith(os.path.normpath(project_root_abs)):
209
+ resolved_imports_abs_paths.add(norm_resolved_path)
210
+ return resolved_imports_abs_paths
211
+
212
+
213
+ # --- Python Import Resolution ---
214
+
215
+ def resolve_python_import(
216
+ module_name_parts: List[str],
217
+ current_file_dir_abs: str,
218
+ project_root_abs: str,
219
+ level: int
220
+ ) -> Optional[str]:
221
+ base_path_to_search = ""
222
+ if level > 0:
223
+ base_path_to_search = current_file_dir_abs
224
+ for _ in range(level - 1):
225
+ base_path_to_search = os.path.dirname(base_path_to_search)
226
+ else:
227
+ base_path_to_search = project_root_abs
228
+
229
+ candidate_rel_path = os.path.join(*module_name_parts)
230
+ potential_abs_path = os.path.join(base_path_to_search, candidate_rel_path)
231
+
232
+ py_file = potential_abs_path + ".py"
233
+ if os.path.isfile(py_file):
234
+ return os.path.normpath(py_file)
235
+
236
+ init_file = os.path.join(potential_abs_path, "__init__.py")
237
+ if os.path.isdir(potential_abs_path) and os.path.isfile(init_file):
238
+ return os.path.normpath(init_file)
239
+
240
+ if level == 0 and base_path_to_search == project_root_abs:
241
+ src_base_path = os.path.join(project_root_abs, "src")
242
+ if os.path.isdir(src_base_path):
243
+ potential_abs_path_src = os.path.join(src_base_path, candidate_rel_path)
244
+ py_file_src = potential_abs_path_src + ".py"
245
+ if os.path.isfile(py_file_src):
246
+ return os.path.normpath(py_file_src)
247
+ init_file_src = os.path.join(potential_abs_path_src, "__init__.py")
248
+ if os.path.isdir(potential_abs_path_src) and os.path.isfile(init_file_src):
249
+ return os.path.normpath(init_file_src)
250
+ return None
251
+
252
+
253
+ def parse_python_imports(file_content: str, file_path_abs: str, project_root_abs: str) -> Set[str]:
254
+ resolved_imports = set()
255
+ current_file_dir_abs = os.path.dirname(file_path_abs)
256
+
257
+ try:
258
+ tree = ast.parse(file_content, filename=file_path_abs)
259
+ except SyntaxError:
260
+ # print(f"Warning: Syntax error in {file_path_abs}, cannot parse Python imports.")
261
+ return resolved_imports
262
+
263
+ for node in ast.walk(tree):
264
+ if isinstance(node, ast.Import):
265
+ for alias in node.names:
266
+ module_parts = alias.name.split('.')
267
+ resolved = resolve_python_import(module_parts, current_file_dir_abs, project_root_abs, level=0)
268
+ if resolved and os.path.exists(resolved) and os.path.normpath(resolved).startswith(os.path.normpath(project_root_abs)):
269
+ resolved_imports.add(os.path.normpath(resolved))
270
+ elif isinstance(node, ast.ImportFrom):
271
+ level_to_resolve = node.level
272
+ if node.module:
273
+ module_parts = node.module.split('.')
274
+ resolved = resolve_python_import(module_parts, current_file_dir_abs, project_root_abs, level_to_resolve)
275
+ if resolved and os.path.exists(resolved) and os.path.normpath(resolved).startswith(os.path.normpath(project_root_abs)):
276
+ resolved_imports.add(os.path.normpath(resolved))
277
+ else:
278
+ for alias in node.names:
279
+ item_name_parts = alias.name.split('.')
280
+ resolved = resolve_python_import(item_name_parts, current_file_dir_abs, project_root_abs, level=level_to_resolve)
281
+ if resolved and os.path.exists(resolved) and os.path.normpath(resolved).startswith(os.path.normpath(project_root_abs)):
282
+ resolved_imports.add(os.path.normpath(resolved))
283
+ return resolved_imports
284
+
kopipasta/main.py CHANGED
@@ -24,6 +24,8 @@ from google import genai
24
24
  from google.genai.types import GenerateContentConfig
25
25
  from prompt_toolkit import prompt # Added for multiline input
26
26
 
27
+ import kopipasta.import_parser as import_parser
28
+
27
29
  FileTuple = Tuple[str, bool, Optional[List[str]], str]
28
30
 
29
31
  class SimplePatchItem(BaseModel):
@@ -115,6 +117,109 @@ def apply_simple_patch(patch_item: SimplePatchItem) -> bool:
115
117
  print("-" * 20)
116
118
  return False
117
119
 
120
+ def _propose_and_add_dependencies(
121
+ file_just_added: str,
122
+ project_root_abs: str,
123
+ files_to_include: List[FileTuple],
124
+ current_char_count: int
125
+ ) -> Tuple[List[FileTuple], int]:
126
+ """
127
+ Analyzes a file for local dependencies and interactively asks the user to add them.
128
+ """
129
+ language = get_language_for_file(file_just_added)
130
+ if language not in ['python', 'typescript', 'javascript', 'tsx', 'jsx']:
131
+ return [], 0 # Only analyze languages we can parse
132
+
133
+ print(f"Analyzing {get_relative_path(file_just_added)} for local dependencies...")
134
+
135
+ try:
136
+ file_content = read_file_contents(file_just_added)
137
+ if not file_content:
138
+ return [], 0
139
+
140
+ resolved_deps_abs: Set[str] = set()
141
+ if language == 'python':
142
+ resolved_deps_abs = import_parser.parse_python_imports(file_content, file_just_added, project_root_abs)
143
+ elif language in ['typescript', 'javascript', 'tsx', 'jsx']:
144
+ resolved_deps_abs = import_parser.parse_typescript_imports(file_content, file_just_added, project_root_abs)
145
+
146
+ # Filter out dependencies that are already in the context
147
+ included_paths = {os.path.abspath(f[0]) for f in files_to_include}
148
+ suggested_deps = sorted([
149
+ dep for dep in resolved_deps_abs
150
+ if os.path.abspath(dep) not in included_paths and os.path.abspath(dep) != os.path.abspath(file_just_added)
151
+ ])
152
+
153
+ if not suggested_deps:
154
+ print("No new local dependencies found.")
155
+ return [], 0
156
+
157
+ print(f"\nFound {len(suggested_deps)} new local {'dependency' if len(suggested_deps) == 1 else 'dependencies'}:")
158
+ for i, dep_path in enumerate(suggested_deps):
159
+ print(f" ({i+1}) {get_relative_path(dep_path)}")
160
+
161
+ while True:
162
+ choice = input("\nAdd dependencies? (a)ll, (n)one, or enter numbers (e.g. 1, 3-4): ").lower()
163
+
164
+ deps_to_add_paths = None
165
+ if choice == 'a':
166
+ deps_to_add_paths = suggested_deps
167
+ break
168
+ if choice == 'n':
169
+ deps_to_add_paths = []
170
+ print(f"Skipped {len(suggested_deps)} dependencies.")
171
+ break
172
+
173
+ # Try to parse the input as numbers directly.
174
+ try:
175
+ selected_indices = set()
176
+ parts = choice.replace(' ', '').split(',')
177
+ if all(p.strip() for p in parts): # Ensure no empty parts like in "1,"
178
+ for part in parts:
179
+ if '-' in part:
180
+ start_str, end_str = part.split('-', 1)
181
+ start = int(start_str)
182
+ end = int(end_str)
183
+ if start > end:
184
+ start, end = end, start
185
+ selected_indices.update(range(start - 1, end))
186
+ else:
187
+ selected_indices.add(int(part) - 1)
188
+
189
+ # Validate that all selected numbers are within the valid range
190
+ if all(0 <= i < len(suggested_deps) for i in selected_indices):
191
+ deps_to_add_paths = [
192
+ suggested_deps[i] for i in sorted(list(selected_indices))
193
+ ]
194
+ break # Success! Exit the loop.
195
+ else:
196
+ print(f"Error: Invalid number selection. Please choose numbers between 1 and {len(suggested_deps)}.")
197
+ else:
198
+ raise ValueError("Empty part detected in input.")
199
+
200
+
201
+ except ValueError:
202
+ # This will catch any input that isn't 'a', 'n', or a valid number/range.
203
+ print("Invalid choice. Please enter 'a', 'n', or a list/range of numbers (e.g., '1,3' or '2-4').")
204
+
205
+ if not deps_to_add_paths:
206
+ return [], 0 # No dependencies were selected
207
+
208
+ newly_added_files: List[FileTuple] = []
209
+ char_count_delta = 0
210
+ for dep_path in deps_to_add_paths:
211
+ # Assume non-large for now for simplicity, can be enhanced later
212
+ file_size = os.path.getsize(dep_path)
213
+ newly_added_files.append((dep_path, False, None, get_language_for_file(dep_path)))
214
+ char_count_delta += file_size
215
+ print(f"Added dependency: {get_relative_path(dep_path)} ({get_human_readable_size(file_size)})")
216
+
217
+ return newly_added_files, char_count_delta
218
+
219
+ except Exception as e:
220
+ print(f"Warning: Could not analyze dependencies for {get_relative_path(file_just_added)}: {e}")
221
+ return [], 0
222
+
118
223
  def get_colored_code(file_path, code):
119
224
  try:
120
225
  lexer = get_lexer_for_filename(file_path)
@@ -540,7 +645,7 @@ def print_char_count(count):
540
645
  token_estimate = count // 4
541
646
  print(f"\rCurrent prompt size: {count} characters (~ {token_estimate} tokens)", flush=True)
542
647
 
543
- def select_files_in_directory(directory: str, ignore_patterns: List[str], current_char_count: int = 0) -> Tuple[List[FileTuple], int]:
648
+ def select_files_in_directory(directory: str, ignore_patterns: List[str], project_root_abs: str, current_char_count: int = 0) -> Tuple[List[FileTuple], int]:
544
649
  files = [f for f in os.listdir(directory)
545
650
  if os.path.isfile(os.path.join(directory, f)) and not is_ignored(os.path.join(directory, f), ignore_patterns) and not is_binary(os.path.join(directory, f))]
546
651
 
@@ -561,7 +666,9 @@ def select_files_in_directory(directory: str, ignore_patterns: List[str], curren
561
666
  print_char_count(current_char_count)
562
667
  choice = input("(y)es add all / (n)o ignore all / (s)elect individually / (q)uit? ").lower()
563
668
  selected_files: List[FileTuple] = []
669
+ char_count_delta = 0
564
670
  if choice == 'y':
671
+ files_to_add_after_loop = []
565
672
  for file in files:
566
673
  file_path = os.path.join(directory, file)
567
674
  if is_large_file(file_path):
@@ -571,14 +678,23 @@ def select_files_in_directory(directory: str, ignore_patterns: List[str], curren
571
678
  break
572
679
  print("Invalid choice. Please enter 'f' or 's'.")
573
680
  if snippet_choice == 's':
574
- selected_files.append((file, True, None, get_language_for_file(file)))
575
- current_char_count += len(get_file_snippet(file_path))
681
+ selected_files.append((file_path, True, None, get_language_for_file(file_path)))
682
+ char_count_delta += len(get_file_snippet(file_path))
576
683
  else:
577
- selected_files.append((file, False, None, get_language_for_file(file)))
578
- current_char_count += os.path.getsize(file_path)
684
+ selected_files.append((file_path, False, None, get_language_for_file(file_path)))
685
+ char_count_delta += os.path.getsize(file_path)
579
686
  else:
580
- selected_files.append((file, False, None, get_language_for_file(file)))
581
- current_char_count += os.path.getsize(file_path)
687
+ selected_files.append((file_path, False, None, get_language_for_file(file_path)))
688
+ char_count_delta += os.path.getsize(file_path)
689
+ files_to_add_after_loop.append(file_path)
690
+
691
+ # Analyze dependencies after the loop
692
+ current_char_count += char_count_delta
693
+ for file_path in files_to_add_after_loop:
694
+ new_deps, deps_char_count = _propose_and_add_dependencies(file_path, project_root_abs, selected_files, current_char_count)
695
+ selected_files.extend(new_deps)
696
+ current_char_count += deps_char_count
697
+
582
698
  print(f"Added all files from {directory}")
583
699
  return selected_files, current_char_count
584
700
  elif choice == 'n':
@@ -596,6 +712,7 @@ def select_files_in_directory(directory: str, ignore_patterns: List[str], curren
596
712
  print_char_count(current_char_count)
597
713
  file_choice = input(f"{file} ({file_size_readable}, ~{file_char_estimate} chars, ~{file_token_estimate} tokens) (y/n/p/q)? ").lower()
598
714
  if file_choice == 'y':
715
+ file_to_add = None
599
716
  if is_large_file(file_path):
600
717
  while True:
601
718
  snippet_choice = input(f"{file} is large. Use (f)ull content or (s)nippet? ").lower()
@@ -603,14 +720,21 @@ def select_files_in_directory(directory: str, ignore_patterns: List[str], curren
603
720
  break
604
721
  print("Invalid choice. Please enter 'f' or 's'.")
605
722
  if snippet_choice == 's':
606
- selected_files.append((file, True, None, get_language_for_file(file_path)))
723
+ file_to_add = (file_path, True, None, get_language_for_file(file_path))
607
724
  current_char_count += len(get_file_snippet(file_path))
608
725
  else:
609
- selected_files.append((file, False, None, get_language_for_file(file_path)))
726
+ file_to_add = (file_path, False, None, get_language_for_file(file_path))
610
727
  current_char_count += file_char_estimate
611
728
  else:
612
- selected_files.append((file, False, None, get_language_for_file(file_path)))
729
+ file_to_add = (file_path, False, None, get_language_for_file(file_path))
613
730
  current_char_count += file_char_estimate
731
+
732
+ if file_to_add:
733
+ selected_files.append(file_to_add)
734
+ # Analyze dependencies immediately after adding
735
+ new_deps, deps_char_count = _propose_and_add_dependencies(file_path, project_root_abs, selected_files, current_char_count)
736
+ selected_files.extend(new_deps)
737
+ current_char_count += deps_char_count
614
738
  break
615
739
  elif file_choice == 'n':
616
740
  break
@@ -633,7 +757,7 @@ def select_files_in_directory(directory: str, ignore_patterns: List[str], curren
633
757
  else:
634
758
  print("Invalid choice. Please try again.")
635
759
 
636
- def process_directory(directory: str, ignore_patterns: List[str], current_char_count: int = 0) -> Tuple[List[FileTuple], Set[str], int]:
760
+ def process_directory(directory: str, ignore_patterns: List[str], project_root_abs: str, current_char_count: int = 0) -> Tuple[List[FileTuple], Set[str], int]:
637
761
  files_to_include: List[FileTuple] = []
638
762
  processed_dirs: Set[str] = set()
639
763
 
@@ -647,10 +771,10 @@ def process_directory(directory: str, ignore_patterns: List[str], current_char_c
647
771
  print(f"\nExploring directory: {root}")
648
772
  choice = input("(y)es explore / (n)o skip / (q)uit? ").lower()
649
773
  if choice == 'y':
650
- selected_files, current_char_count = select_files_in_directory(root, ignore_patterns, current_char_count)
651
- for file_tuple in selected_files:
652
- full_path = os.path.join(root, file_tuple[0])
653
- files_to_include.append((full_path, file_tuple[1], file_tuple[2], file_tuple[3]))
774
+ # Pass project_root_abs down
775
+ selected_files, current_char_count = select_files_in_directory(root, ignore_patterns, project_root_abs, current_char_count)
776
+ # The paths in selected_files are already absolute now
777
+ files_to_include.extend(selected_files)
654
778
  processed_dirs.add(root)
655
779
  elif choice == 'n':
656
780
  dirs[:] = [] # Skip all subdirectories
@@ -763,29 +887,50 @@ def generate_prompt_template(files_to_include: List[FileTuple], ignore_patterns:
763
887
  prompt += "\n\n"
764
888
  prompt += "## Instructions for Achieving the Task\n\n"
765
889
  analysis_text = (
766
- "1. **Confirm and Understand the Task**:\n"
767
- " - Rephrase the task in your own words to ensure understanding.\n"
768
- " - Ask for any necessary clarifications.\n"
769
- " - Once everything is clear, ask to proceed.\n\n"
770
- "2. **Outline a Plan**:\n"
771
- " - Provide a brief plan on how to approach the task.\n"
772
- " - Make minimal incremental changes to maintain a working codebase at each step.\n"
773
- " - This is an iterative process aimed at achieving the task step by step.\n\n"
774
- "3. **Implement Changes Iteratively**:\n"
775
- " - Apply changes in small, manageable increments.\n"
776
- " - Ensure the codebase remains functional after each change.\n"
777
- " - After each increment, verify stability before proceeding to the next step.\n\n"
778
- "4. **Present Code Changes Clearly**:\n"
779
- " - Specify the file being modified at the beginning of each code block.\n"
780
- " - Format changes for clarity:\n"
781
- " - For small changes: Show only the changed lines with clear comments.\n"
782
- " - For larger changes: Provide the full new implementation of changed parts, using placeholders like `'// ... (rest of the function)'` for unchanged code.\n"
783
- " - Provide context by including important unchanged parts as needed.\n"
784
- " - Use clear comments to explain the changes and reference old code if helpful.\n\n"
785
- "5. **Encourage User Testing and Collaboration**:\n"
786
- " - Ask the user to test the code on their machine after each change.\n"
787
- " - If debugging is needed, include debugging messages in the code.\n"
788
- " - Request the user to share any error messages or outputs from debugging to assist further.\n"
890
+ "### Your Operating Model: The Expert Council\n\n"
891
+ "You will operate as a council of expert personas in a direct, collaborative partnership with me, the user. Your entire thinking process should be externalized as a dialogue between these personas. Any persona can and should interact directly with me.\n\n"
892
+ "#### Core Personas\n"
893
+ "- **Facilitator**: The lead coordinator. You summarize discussions, ensure the team stays on track, and are responsible for presenting the final, consolidated plans and code to me.\n"
894
+ "- **Architect**: Focuses on high-level design, system structure, dependencies, scalability, and long-term maintainability. Asks 'Is this well-designed?'\n"
895
+ "- **Builder**: The hands-on engineer. Focuses on implementation details, writing clean and functional code, and debugging. Asks 'How do we build this?' **When presenting code obey Rules for Presenting Code**\n"
896
+ "- **Critique**: The quality advocate. Focuses on identifying edge cases, potential bugs, and security vulnerabilities. Asks 'What could go wrong?' and provides constructive critique on both the team's proposals and my (the user's) requests.\n\n"
897
+ "#### Dynamic Personas\n"
898
+ "When the task requires it, introduce other specialist personas. For example: UX Designer, DevOps Engineer, Data Scientist, etc.\n\n"
899
+ "### Development Workflow: A Structured Dialogue\n\n"
900
+ "Follow this conversational workflow. Prefix direct communication to me with the persona's name (e.g., 'Critique to User: ...').\n\n"
901
+ "1. **Understand & Clarify (Dialogue)**\n"
902
+ " - **Facilitator**: Start by stating the goal as you understand it.\n"
903
+ " - **All**: Discuss the requirements. Any persona can directly ask me for clarification. Identify and list any missing information ([MISSING]) or assumptions ([ASSUMPTION]).\n"
904
+ " - **Critique**: Directly challenge any part of my request that seems ambiguous, risky, or suboptimal.\n\n"
905
+ "2. **Plan (Dialogue)**\n"
906
+ " - **Architect**: Propose one or more high-level plans. Directly ask me for input on tradeoffs if necessary.\n"
907
+ " - **Critique**: Challenge the plans. Point out risks or edge cases.\n"
908
+ " - **Builder**: Comment on the implementation feasibility.\n"
909
+ " - **Facilitator**: Synthesize the discussion, select the best plan, and present a clear, step-by-step summary to me for approval.\n\n"
910
+ "3. **Implement (Code & Dialogue)**\n"
911
+ " - **Facilitator**: State which step of the plan is being implemented.\n"
912
+ " - **Builder**: Write the code. If you encounter an issue, you can ask me directly for more context.\n"
913
+ " - **Critique**: Review the code and the underlying assumptions.\n"
914
+ " - **Facilitator**: Present the final, reviewed code for that step. Ask me to test it if appropriate.\n\n"
915
+ "4. **Present E2E Draft (Consolidation)**\n"
916
+ " - When I ask you to consolidate, your goal is to provide a clear, unambiguous set of changes that I can directly copy and paste into my editor.\n"
917
+ " - **Present changes grouped by file.** For each change, provide the **entire updated function, class, or logical block** to make replacement easy.\n"
918
+ " - Use context comments to show where the new code block fits.\n"
919
+ " - **Do not use a diff format (`+` or `-` lines).** Provide the final, clean code.\n\n"
920
+ "5. **Validate & Iterate (Feedback Loop)**\n"
921
+ " - After presenting the consolidated draft, you will explicitly prompt me for testing and wait for my feedback.\n"
922
+ " - Upon receiving my feedback, the **Facilitator** will announce the next step.\n"
923
+ " - If the feedback indicates a minor bug, you will return to **Step 3 (Implement)** to fix it.\n"
924
+ " - If the feedback reveals a fundamental design flaw or misunderstanding, you will return to **Step 2 (Plan)** or even **Step 1 (Understand)** to re-evaluate the approach.\n\n"
925
+ "### Rules for Presenting Code\n\n"
926
+ "- **Always specify the filename** at the start of a code block (e.g., `// FILE: kopipasta/main.py`).\n"
927
+ "- **Incremental Changes**: During implementation, show only the changed functions or classes. Use comments like `// ... existing code ...` to represent unchanged parts.\n"
928
+ "- **Missing Files**: Never write placeholder code. Instead, state it's missing and request it.\n\n"
929
+ "### Your Permissions\n\n"
930
+ "- **You are empowered to interact with me directly.** Any persona can ask me questions or provide constructive feedback on my requests, assumptions, or the provided context.\n"
931
+ "- You MUST request any file shown in the project tree that you need but was not provided.\n"
932
+ "- You MUST ask me to run code, test integrations (APIs, databases), and share the output.\n"
933
+ "- You MUST ask for clarification instead of making risky assumptions.\n"
789
934
  )
790
935
  prompt += analysis_text
791
936
  return prompt, cursor_position
@@ -1032,6 +1177,8 @@ def main():
1032
1177
 
1033
1178
  ignore_patterns = read_gitignore()
1034
1179
  env_vars = read_env_file()
1180
+ project_root_abs = os.path.abspath(os.getcwd())
1181
+
1035
1182
 
1036
1183
  files_to_include: List[FileTuple] = []
1037
1184
  processed_dirs = set()
@@ -1074,53 +1221,59 @@ def main():
1074
1221
  print(f"Added {'snippet of ' if is_snippet else ''}web content from: {input_path}")
1075
1222
  print_char_count(current_char_count)
1076
1223
  elif os.path.isfile(input_path):
1077
- # Handle files provided directly via command line
1078
- if not is_ignored(input_path, ignore_patterns) and not is_binary(input_path):
1079
- file_size = os.path.getsize(input_path)
1224
+ abs_input_path = os.path.abspath(input_path)
1225
+ if not is_ignored(abs_input_path, ignore_patterns) and not is_binary(abs_input_path):
1226
+ file_size = os.path.getsize(abs_input_path)
1080
1227
  file_size_readable = get_human_readable_size(file_size)
1081
1228
  file_char_estimate = file_size
1082
- language = get_language_for_file(input_path)
1229
+ language = get_language_for_file(abs_input_path)
1230
+ file_to_add = None
1083
1231
 
1084
- if is_large_file(input_path):
1085
- print(f"\nFile {input_path} ({file_size_readable}, ~{file_char_estimate} chars) is large.")
1232
+ if is_large_file(abs_input_path):
1233
+ print(f"\nFile {get_relative_path(abs_input_path)} ({file_size_readable}, ~{file_char_estimate} chars) is large.")
1086
1234
  print("Preview (first ~50 lines or 4KB):")
1087
- print(get_colored_file_snippet(input_path))
1235
+ print(get_colored_file_snippet(abs_input_path))
1088
1236
  print("-" * 40)
1089
1237
  while True:
1090
1238
  print_char_count(current_char_count)
1091
- choice = input(f"How to include large file {input_path}? (f)ull / (s)nippet / (p)atches / (n)o skip: ").lower()
1239
+ choice = input(f"How to include large file {get_relative_path(abs_input_path)}? (f)ull / (s)nippet / (p)atches / (n)o skip: ").lower()
1092
1240
  if choice == 'f':
1093
- files_to_include.append((input_path, False, None, language))
1241
+ file_to_add = (abs_input_path, False, None, language)
1094
1242
  current_char_count += file_char_estimate
1095
- print(f"Added full file: {input_path}")
1243
+ print(f"Added full file: {get_relative_path(abs_input_path)}")
1096
1244
  break
1097
1245
  elif choice == 's':
1098
- snippet_content = get_file_snippet(input_path)
1099
- files_to_include.append((input_path, True, None, language))
1246
+ snippet_content = get_file_snippet(abs_input_path)
1247
+ file_to_add = (abs_input_path, True, None, language)
1100
1248
  current_char_count += len(snippet_content)
1101
- print(f"Added snippet of file: {input_path}")
1249
+ print(f"Added snippet of file: {get_relative_path(abs_input_path)}")
1102
1250
  break
1103
1251
  elif choice == 'p':
1104
- chunks, char_count = select_file_patches(input_path)
1252
+ chunks, char_count = select_file_patches(abs_input_path)
1105
1253
  if chunks:
1106
- files_to_include.append((input_path, False, chunks, language))
1254
+ file_to_add = (abs_input_path, False, chunks, language)
1107
1255
  current_char_count += char_count
1108
- print(f"Added selected patches from file: {input_path}")
1256
+ print(f"Added selected patches from file: {get_relative_path(abs_input_path)}")
1109
1257
  else:
1110
- print(f"No patches selected for {input_path}. Skipping file.")
1258
+ print(f"No patches selected for {get_relative_path(abs_input_path)}. Skipping file.")
1111
1259
  break
1112
1260
  elif choice == 'n':
1113
- print(f"Skipped large file: {input_path}")
1261
+ print(f"Skipped large file: {get_relative_path(abs_input_path)}")
1114
1262
  break
1115
1263
  else:
1116
1264
  print("Invalid choice. Please enter 'f', 's', 'p', or 'n'.")
1117
1265
  else:
1118
- # Automatically include non-large files
1119
- files_to_include.append((input_path, False, None, language))
1266
+ file_to_add = (abs_input_path, False, None, language)
1120
1267
  current_char_count += file_char_estimate
1121
- print(f"Added file: {input_path} ({file_size_readable})")
1122
-
1123
- # Display current count after processing the file
1268
+ print(f"Added file: {get_relative_path(abs_input_path)} ({file_size_readable})")
1269
+
1270
+ if file_to_add:
1271
+ files_to_include.append(file_to_add)
1272
+ # --- NEW: Call dependency analysis ---
1273
+ new_deps, deps_char_count = _propose_and_add_dependencies(abs_input_path, project_root_abs, files_to_include, current_char_count)
1274
+ files_to_include.extend(new_deps)
1275
+ current_char_count += deps_char_count
1276
+
1124
1277
  print_char_count(current_char_count)
1125
1278
 
1126
1279
  else:
@@ -1129,10 +1282,11 @@ def main():
1129
1282
  elif is_binary(input_path):
1130
1283
  print(f"Ignoring binary file: {input_path}")
1131
1284
  else:
1132
- print(f"Ignoring file: {input_path}") # Should not happen if logic is correct, but fallback.
1285
+ print(f"Ignoring file: {input_path}")
1133
1286
  elif os.path.isdir(input_path):
1134
1287
  print(f"\nProcessing directory specified directly: {input_path}")
1135
- dir_files, dir_processed, current_char_count = process_directory(input_path, ignore_patterns, current_char_count)
1288
+ # Pass project_root_abs to process_directory
1289
+ dir_files, dir_processed, current_char_count = process_directory(input_path, ignore_patterns, project_root_abs, current_char_count)
1136
1290
  files_to_include.extend(dir_files)
1137
1291
  processed_dirs.update(dir_processed)
1138
1292
  else:
@@ -1144,42 +1298,34 @@ def main():
1144
1298
 
1145
1299
  print("\nFile and web content selection complete.")
1146
1300
  print_char_count(current_char_count) # Print final count before prompt generation
1147
- print(f"Summary: Added {len(files_to_include)} files and {len(web_contents)} web sources.")
1148
1301
 
1149
1302
  added_files_count = len(files_to_include)
1150
- added_dirs_count = len(processed_dirs) # Count unique processed directories
1303
+ added_dirs_count = len(processed_dirs)
1151
1304
  added_web_count = len(web_contents)
1152
1305
  print(f"Summary: Added {added_files_count} files/patches from {added_dirs_count} directories and {added_web_count} web sources.")
1153
1306
 
1154
1307
  prompt_template, cursor_position = generate_prompt_template(files_to_include, ignore_patterns, web_contents, env_vars)
1155
1308
 
1156
- # Logic branching for interactive mode vs. clipboard mode
1157
1309
  if args.interactive:
1158
1310
  print("\nPreparing initial prompt for editing...")
1159
- # Determine the initial content for the editor
1160
1311
  if args.task:
1161
- # Pre-populate the task section if --task was provided
1162
1312
  editor_initial_content = prompt_template[:cursor_position] + args.task + prompt_template[cursor_position:]
1163
1313
  print("Pre-populating editor with task provided via --task argument.")
1164
1314
  else:
1165
- # Use the template as is (user will add task in the editor)
1166
1315
  editor_initial_content = prompt_template
1167
1316
  print("Opening editor for you to add the task instructions.")
1168
1317
 
1169
- # Always open the editor in interactive mode
1170
1318
  initial_chat_prompt = open_editor_for_input(editor_initial_content, cursor_position)
1171
1319
  print("Editor closed. Starting interactive chat session...")
1172
- start_chat_session(initial_chat_prompt) # Start the chat with the edited prompt else:
1320
+ start_chat_session(initial_chat_prompt)
1173
1321
  else:
1174
- # Original non-interactive behavior
1175
1322
  if args.task:
1176
1323
  task_description = args.task
1177
- # Insert task description before "## Task Instructions"
1178
1324
  task_marker = "## Task Instructions\n\n"
1179
1325
  insertion_point = prompt_template.find(task_marker)
1180
1326
  if insertion_point != -1:
1181
1327
  final_prompt = prompt_template[:insertion_point + len(task_marker)] + task_description + "\n\n" + prompt_template[insertion_point + len(task_marker):]
1182
- else: # Fallback if marker not found
1328
+ else:
1183
1329
  final_prompt = prompt_template[:cursor_position] + task_description + prompt_template[cursor_position:]
1184
1330
  print("\nUsing task description from -t argument.")
1185
1331
  else:
@@ -1191,7 +1337,6 @@ def main():
1191
1337
  print(final_prompt)
1192
1338
  print("-" * 80)
1193
1339
 
1194
- # Copy the prompt to clipboard
1195
1340
  try:
1196
1341
  pyperclip.copy(final_prompt)
1197
1342
  separator = "\n" + "=" * 40 + "\n☕🍝 Kopipasta Complete! 🍝☕\n" + "=" * 40 + "\n"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: kopipasta
3
- Version: 0.25.0
3
+ Version: 0.27.0
4
4
  Summary: A CLI tool to generate prompts with project structure and file contents
5
5
  Home-page: https://github.com/mkorpela/kopipasta
6
6
  Author: Mikko Korpela
@@ -0,0 +1,9 @@
1
+ kopipasta/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ kopipasta/import_parser.py,sha256=yLzkMlQm2avKjfqcpMY0PxbA_2ihV9gSYJplreWIPEQ,12424
3
+ kopipasta/main.py,sha256=gTIPH5dScVKym_5QFrWE3gmChvrFaIgMJyvnTBxc_iI,62607
4
+ kopipasta-0.27.0.dist-info/LICENSE,sha256=xw4C9TAU7LFu4r_MwSbky90uzkzNtRwAo3c51IWR8lk,1091
5
+ kopipasta-0.27.0.dist-info/METADATA,sha256=ubZEEhK8410J4kZ5x1MTAWfm2EXPO4NtLqbpUB44rcM,8610
6
+ kopipasta-0.27.0.dist-info/WHEEL,sha256=GV9aMThwP_4oNCtvEC2ec3qUYutgWeAzklro_0m4WJQ,91
7
+ kopipasta-0.27.0.dist-info/entry_points.txt,sha256=but54qDNz1-F8fVvGstq_QID5tHjczP7bO7rWLFkc6Y,50
8
+ kopipasta-0.27.0.dist-info/top_level.txt,sha256=iXohixMuCdw8UjGDUp0ouICLYBDrx207sgZIJ9lxn0o,10
9
+ kopipasta-0.27.0.dist-info/RECORD,,
@@ -1,8 +0,0 @@
1
- kopipasta/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- kopipasta/main.py,sha256=U4_31xXY2xzYZ-Exm1B6TYzofHfH3NU4iEq6XBHyBj0,53647
3
- kopipasta-0.25.0.dist-info/LICENSE,sha256=xw4C9TAU7LFu4r_MwSbky90uzkzNtRwAo3c51IWR8lk,1091
4
- kopipasta-0.25.0.dist-info/METADATA,sha256=03L8Zbl0k7pgrrSxL-_DsWbIT-zTRFkENabTcC1MHmo,8610
5
- kopipasta-0.25.0.dist-info/WHEEL,sha256=GV9aMThwP_4oNCtvEC2ec3qUYutgWeAzklro_0m4WJQ,91
6
- kopipasta-0.25.0.dist-info/entry_points.txt,sha256=but54qDNz1-F8fVvGstq_QID5tHjczP7bO7rWLFkc6Y,50
7
- kopipasta-0.25.0.dist-info/top_level.txt,sha256=iXohixMuCdw8UjGDUp0ouICLYBDrx207sgZIJ9lxn0o,10
8
- kopipasta-0.25.0.dist-info/RECORD,,