handycode 2.1.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.
- handycode/__init__.py +13 -0
- handycode/__main__.py +5 -0
- handycode/assistant.py +533 -0
- handycode/cli.py +130 -0
- handycode/config.py +78 -0
- handycode/file_manager.py +238 -0
- handycode/logo.py +90 -0
- handycode/main.py +26 -0
- handycode/models.py +77 -0
- handycode/project_templates.py +55 -0
- handycode/security.py +103 -0
- handycode/utils.py +92 -0
- handycode-2.1.0.dist-info/METADATA +20 -0
- handycode-2.1.0.dist-info/RECORD +18 -0
- handycode-2.1.0.dist-info/WHEEL +5 -0
- handycode-2.1.0.dist-info/entry_points.txt +3 -0
- handycode-2.1.0.dist-info/licenses/LICENSE +21 -0
- handycode-2.1.0.dist-info/top_level.txt +1 -0
handycode/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""
|
|
2
|
+
HandyCode - AI Ассистент для разработки
|
|
3
|
+
Аналог Claude Code для командной строки
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
__version__ = "1.0.0"
|
|
7
|
+
__author__ = "AURA Tec."
|
|
8
|
+
__license__ = "MIT"
|
|
9
|
+
|
|
10
|
+
from .main import main
|
|
11
|
+
from .assistant import HandyCode
|
|
12
|
+
|
|
13
|
+
__all__ = ["main", "HandyCode"]
|
handycode/__main__.py
ADDED
handycode/assistant.py
ADDED
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Основной класс ассистента HandyCode
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
import atexit
|
|
10
|
+
import signal
|
|
11
|
+
import shutil
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import List, Dict, Optional
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
import readline
|
|
18
|
+
HAS_READLINE = True
|
|
19
|
+
except ImportError:
|
|
20
|
+
HAS_READLINE = False
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
import requests
|
|
24
|
+
HAS_REQUESTS = True
|
|
25
|
+
except ImportError:
|
|
26
|
+
HAS_REQUESTS = False
|
|
27
|
+
import urllib.request
|
|
28
|
+
import urllib.error
|
|
29
|
+
import ssl
|
|
30
|
+
|
|
31
|
+
from handycode.config import Config
|
|
32
|
+
from handycode.models import MODELS, get_model_settings
|
|
33
|
+
from handycode.file_manager import FileManager
|
|
34
|
+
from handycode.security import SecurityChecker
|
|
35
|
+
from handycode.utils import (
|
|
36
|
+
print_colored, print_header, print_success,
|
|
37
|
+
print_error, print_warning, print_info, print_logo
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class HandyCode:
|
|
42
|
+
def __init__(self, project_path, model="deepseek", auto_approve=False, config=None):
|
|
43
|
+
self.project_path = project_path
|
|
44
|
+
self.auto_approve = auto_approve
|
|
45
|
+
self.config = config or Config()
|
|
46
|
+
|
|
47
|
+
self.api_key = self.config.get_api_key()
|
|
48
|
+
if not self.api_key:
|
|
49
|
+
raise ValueError("API key not found")
|
|
50
|
+
|
|
51
|
+
self.api_url = "https://openrouter.ai/api/v1/chat/completions"
|
|
52
|
+
self.current_model = MODELS.get(model, MODELS["deepseek"])
|
|
53
|
+
self.model_settings = get_model_settings(self.current_model)
|
|
54
|
+
|
|
55
|
+
self.file_manager = FileManager(self.project_path)
|
|
56
|
+
self.security = SecurityChecker(self.project_path)
|
|
57
|
+
|
|
58
|
+
project_context = self._build_project_context()
|
|
59
|
+
|
|
60
|
+
self.conversation_history = [
|
|
61
|
+
{"role": "system", "content": self._get_system_prompt() + project_context}
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
self.stats = {
|
|
65
|
+
"messages_sent": 0,
|
|
66
|
+
"files_created": [],
|
|
67
|
+
"files_modified": [],
|
|
68
|
+
"files_deleted": [],
|
|
69
|
+
"commands_executed": [],
|
|
70
|
+
"start_time": datetime.now()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
self._setup_readline()
|
|
74
|
+
signal.signal(signal.SIGINT, self._signal_handler)
|
|
75
|
+
self._interrupt_count = 0
|
|
76
|
+
|
|
77
|
+
def _build_project_context(self):
|
|
78
|
+
"""Собирает контекст проекта"""
|
|
79
|
+
context = f"\n\n=== PROJECT CONTEXT ===\n"
|
|
80
|
+
context += f"Working directory: {self.project_path}\n"
|
|
81
|
+
context += f"OS: {sys.platform}\n"
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
all_files = []
|
|
85
|
+
for ext in self.file_manager.allowed_extensions:
|
|
86
|
+
all_files.extend(self.project_path.rglob(f"*{ext}"))
|
|
87
|
+
|
|
88
|
+
all_files.extend(self.project_path.rglob("*"))
|
|
89
|
+
all_files = list(set(all_files))
|
|
90
|
+
|
|
91
|
+
files = [f for f in all_files if f.is_file()
|
|
92
|
+
and not any(ex in f.parts for ex in self.file_manager.excluded_dirs)]
|
|
93
|
+
|
|
94
|
+
context += f"\nFiles in project ({len(files)} total):\n"
|
|
95
|
+
|
|
96
|
+
for file in sorted(files):
|
|
97
|
+
try:
|
|
98
|
+
rel_path = file.relative_to(self.project_path)
|
|
99
|
+
size = file.stat().st_size
|
|
100
|
+
context += f" - {rel_path} ({self._format_size(size)})\n"
|
|
101
|
+
except:
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
context += f"\nFile contents (for context):\n"
|
|
105
|
+
total_size = 0
|
|
106
|
+
max_total = 30000
|
|
107
|
+
|
|
108
|
+
for file in sorted(files):
|
|
109
|
+
if total_size >= max_total:
|
|
110
|
+
break
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
content = file.read_text(encoding='utf-8', errors='ignore')
|
|
114
|
+
if len(content) > 3000:
|
|
115
|
+
content = content[:3000] + "\n... (truncated)"
|
|
116
|
+
|
|
117
|
+
rel_path = file.relative_to(self.project_path)
|
|
118
|
+
context += f"\n=== {rel_path} ===\n{content}\n"
|
|
119
|
+
total_size += len(content)
|
|
120
|
+
except:
|
|
121
|
+
pass
|
|
122
|
+
|
|
123
|
+
except Exception as e:
|
|
124
|
+
context += f"\nError: {e}\n"
|
|
125
|
+
|
|
126
|
+
return context
|
|
127
|
+
|
|
128
|
+
def _format_size(self, size):
|
|
129
|
+
for unit in ['B', 'KB', 'MB', 'GB']:
|
|
130
|
+
if size < 1024:
|
|
131
|
+
return f"{size:.0f}{unit}"
|
|
132
|
+
size /= 1024
|
|
133
|
+
return f"{size:.0f}GB"
|
|
134
|
+
|
|
135
|
+
def _get_system_prompt(self):
|
|
136
|
+
return """You are HandyCode, a powerful AI code assistant with FULL file system access.
|
|
137
|
+
|
|
138
|
+
YOU CAN:
|
|
139
|
+
- CREATE files: [[CREATE:path/to/file.ext]] content
|
|
140
|
+
- MODIFY files: [[MODIFY:path/to/file.ext]] new content
|
|
141
|
+
- DELETE files: [[DELETE:path/to/file.ext]]
|
|
142
|
+
- READ files: [[READ:path/to/file.ext]]
|
|
143
|
+
- LIST directory: [[LIST:path/to/dir]]
|
|
144
|
+
- EXECUTE commands: [[EXEC:command]]
|
|
145
|
+
- CREATE folders: [[MKDIR:path/to/dir]]
|
|
146
|
+
- COPY files: [[COPY:source]] -> [[CREATE:destination]] (use CREATE with content)
|
|
147
|
+
- MOVE files: [[MOVE:source]] [[CREATE:destination]]
|
|
148
|
+
|
|
149
|
+
FILES ARE AUTO-CREATED without asking. Only COMMANDS need confirmation.
|
|
150
|
+
You see ALL project files and their contents.
|
|
151
|
+
|
|
152
|
+
RULES:
|
|
153
|
+
1. CREATE/MODIFY/DELETE/MKDIR happen automatically
|
|
154
|
+
2. EXEC commands need user confirmation
|
|
155
|
+
3. Show COMPLETE file contents
|
|
156
|
+
4. Create ALL needed files
|
|
157
|
+
5. Use the project context
|
|
158
|
+
|
|
159
|
+
Speak Russian. Write code in English."""
|
|
160
|
+
|
|
161
|
+
def _setup_readline(self):
|
|
162
|
+
if not HAS_READLINE:
|
|
163
|
+
return
|
|
164
|
+
try:
|
|
165
|
+
histfile = os.path.join(os.path.expanduser("~"), ".handycode", "history")
|
|
166
|
+
os.makedirs(os.path.dirname(histfile), exist_ok=True)
|
|
167
|
+
readline.read_history_file(histfile)
|
|
168
|
+
readline.set_history_length(1000)
|
|
169
|
+
atexit.register(readline.write_history_file, histfile)
|
|
170
|
+
except:
|
|
171
|
+
pass
|
|
172
|
+
|
|
173
|
+
def _signal_handler(self, sig, frame):
|
|
174
|
+
self._interrupt_count += 1
|
|
175
|
+
if self._interrupt_count == 1:
|
|
176
|
+
print("\n\nPress Ctrl+C again to exit")
|
|
177
|
+
else:
|
|
178
|
+
os._exit(0)
|
|
179
|
+
|
|
180
|
+
def reset_interrupt(self):
|
|
181
|
+
self._interrupt_count = 0
|
|
182
|
+
|
|
183
|
+
def _make_request_stream(self, data):
|
|
184
|
+
"""Отправляет запрос и получает ответ в реальном времени"""
|
|
185
|
+
if not HAS_REQUESTS:
|
|
186
|
+
return self._make_request(data)
|
|
187
|
+
|
|
188
|
+
try:
|
|
189
|
+
response = requests.post(
|
|
190
|
+
self.api_url,
|
|
191
|
+
headers={
|
|
192
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
193
|
+
"Content-Type": "application/json"
|
|
194
|
+
},
|
|
195
|
+
json={**data, "stream": True},
|
|
196
|
+
timeout=120,
|
|
197
|
+
stream=True
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
full_response = ""
|
|
201
|
+
|
|
202
|
+
for line in response.iter_lines():
|
|
203
|
+
if line:
|
|
204
|
+
line = line.decode('utf-8')
|
|
205
|
+
if line.startswith('data: '):
|
|
206
|
+
data_str = line[6:]
|
|
207
|
+
if data_str.strip() == '[DONE]':
|
|
208
|
+
break
|
|
209
|
+
try:
|
|
210
|
+
chunk = json.loads(data_str)
|
|
211
|
+
if 'choices' in chunk and chunk['choices']:
|
|
212
|
+
delta = chunk['choices'][0].get('delta', {})
|
|
213
|
+
content = delta.get('content', '')
|
|
214
|
+
if content:
|
|
215
|
+
print(content, end="", flush=True)
|
|
216
|
+
full_response += content
|
|
217
|
+
except:
|
|
218
|
+
continue
|
|
219
|
+
|
|
220
|
+
print()
|
|
221
|
+
return full_response
|
|
222
|
+
|
|
223
|
+
except Exception as e:
|
|
224
|
+
print_error(f"API Error: {e}")
|
|
225
|
+
return ""
|
|
226
|
+
|
|
227
|
+
def _make_request(self, data):
|
|
228
|
+
"""Обычный запрос без стриминга"""
|
|
229
|
+
if HAS_REQUESTS:
|
|
230
|
+
try:
|
|
231
|
+
response = requests.post(
|
|
232
|
+
self.api_url,
|
|
233
|
+
headers={
|
|
234
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
235
|
+
"Content-Type": "application/json"
|
|
236
|
+
},
|
|
237
|
+
json=data,
|
|
238
|
+
timeout=120
|
|
239
|
+
)
|
|
240
|
+
result = response.json()
|
|
241
|
+
if 'choices' in result and result['choices']:
|
|
242
|
+
return result['choices'][0]['message']['content']
|
|
243
|
+
except Exception as e:
|
|
244
|
+
print_error(f"API Error: {e}")
|
|
245
|
+
return ""
|
|
246
|
+
|
|
247
|
+
def send_message(self, user_input):
|
|
248
|
+
if user_input.startswith('/'):
|
|
249
|
+
return self._handle_command(user_input)
|
|
250
|
+
|
|
251
|
+
self.conversation_history.append({"role": "user", "content": user_input})
|
|
252
|
+
|
|
253
|
+
payload = {
|
|
254
|
+
"model": self.current_model,
|
|
255
|
+
"messages": self.conversation_history,
|
|
256
|
+
"temperature": self.model_settings.get("temperature", 0.3),
|
|
257
|
+
"max_tokens": self.model_settings.get("max_tokens", 8000),
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
try:
|
|
261
|
+
print_info(f"\nDEEPSEEK:")
|
|
262
|
+
response = self._make_request_stream(payload)
|
|
263
|
+
|
|
264
|
+
if response:
|
|
265
|
+
self.conversation_history.append({"role": "assistant", "content": response})
|
|
266
|
+
|
|
267
|
+
actions = self._parse_actions(response)
|
|
268
|
+
if actions:
|
|
269
|
+
self._execute_actions_smart(actions)
|
|
270
|
+
|
|
271
|
+
self.stats["messages_sent"] += 1
|
|
272
|
+
return response
|
|
273
|
+
except Exception as e:
|
|
274
|
+
return print_error(f"Error: {e}")
|
|
275
|
+
|
|
276
|
+
def _parse_actions(self, response):
|
|
277
|
+
"""Парсит все типы действий"""
|
|
278
|
+
actions = []
|
|
279
|
+
|
|
280
|
+
# CREATE
|
|
281
|
+
for match in re.finditer(r'\[\[CREATE:(.+?)\]\](.*?)(?=\[\[|$)', response, re.DOTALL):
|
|
282
|
+
path = match.group(1).strip()
|
|
283
|
+
content = match.group(2).strip()
|
|
284
|
+
content = re.sub(r'^```[\w]*\n?', '', content)
|
|
285
|
+
content = re.sub(r'\n?```$', '', content)
|
|
286
|
+
actions.append({'type': 'create', 'path': path, 'content': content})
|
|
287
|
+
|
|
288
|
+
# MODIFY
|
|
289
|
+
for match in re.finditer(r'\[\[MODIFY:(.+?)\]\](.*?)(?=\[\[|$)', response, re.DOTALL):
|
|
290
|
+
path = match.group(1).strip()
|
|
291
|
+
content = match.group(2).strip()
|
|
292
|
+
content = re.sub(r'^```[\w]*\n?', '', content)
|
|
293
|
+
content = re.sub(r'\n?```$', '', content)
|
|
294
|
+
actions.append({'type': 'modify', 'path': path, 'content': content})
|
|
295
|
+
|
|
296
|
+
# DELETE
|
|
297
|
+
for match in re.finditer(r'\[\[DELETE:(.+?)\]\]', response):
|
|
298
|
+
actions.append({'type': 'delete', 'path': match.group(1).strip()})
|
|
299
|
+
|
|
300
|
+
# READ
|
|
301
|
+
for match in re.finditer(r'\[\[READ:(.+?)\]\]', response):
|
|
302
|
+
actions.append({'type': 'read', 'path': match.group(1).strip()})
|
|
303
|
+
|
|
304
|
+
# LIST
|
|
305
|
+
for match in re.finditer(r'\[\[LIST:(.+?)\]\]', response):
|
|
306
|
+
actions.append({'type': 'list', 'path': match.group(1).strip()})
|
|
307
|
+
|
|
308
|
+
# MKDIR
|
|
309
|
+
for match in re.finditer(r'\[\[MKDIR:(.+?)\]\]', response):
|
|
310
|
+
actions.append({'type': 'mkdir', 'path': match.group(1).strip()})
|
|
311
|
+
|
|
312
|
+
# COPY
|
|
313
|
+
for match in re.finditer(r'\[\[COPY:(.+?)\]\]', response):
|
|
314
|
+
actions.append({'type': 'copy', 'path': match.group(1).strip()})
|
|
315
|
+
|
|
316
|
+
# MOVE
|
|
317
|
+
for match in re.finditer(r'\[\[MOVE:(.+?)\]\]', response):
|
|
318
|
+
actions.append({'type': 'move', 'path': match.group(1).strip()})
|
|
319
|
+
|
|
320
|
+
# EXEC
|
|
321
|
+
for match in re.finditer(r'\[\[EXEC:(.+?)\]\]', response):
|
|
322
|
+
actions.append({'type': 'exec', 'command': match.group(1).strip()})
|
|
323
|
+
|
|
324
|
+
return actions
|
|
325
|
+
|
|
326
|
+
def _execute_actions_smart(self, actions):
|
|
327
|
+
"""Умное выполнение: файлы авто, команды с подтверждением"""
|
|
328
|
+
if not actions:
|
|
329
|
+
return
|
|
330
|
+
|
|
331
|
+
file_actions = [a for a in actions if a['type'] in ['create', 'modify', 'delete', 'mkdir', 'read', 'list', 'copy', 'move']]
|
|
332
|
+
exec_actions = [a for a in actions if a['type'] == 'exec']
|
|
333
|
+
|
|
334
|
+
# Файловые операции выполняем автоматически
|
|
335
|
+
if file_actions:
|
|
336
|
+
print_header("\nAUTO FILE OPERATIONS:")
|
|
337
|
+
for action in file_actions:
|
|
338
|
+
self._execute_action(action)
|
|
339
|
+
|
|
340
|
+
# Команды требуют подтверждения
|
|
341
|
+
if exec_actions:
|
|
342
|
+
print_header("\nCOMMANDS TO EXECUTE:")
|
|
343
|
+
for i, action in enumerate(exec_actions, 1):
|
|
344
|
+
print(f" {i}. {action['command']}")
|
|
345
|
+
|
|
346
|
+
print("\n[A] Execute all [S] Skip all [1-N] Select [C] Cancel")
|
|
347
|
+
choice = input("> ").strip().upper()
|
|
348
|
+
|
|
349
|
+
if choice == 'A':
|
|
350
|
+
for action in exec_actions:
|
|
351
|
+
self._execute_action(action)
|
|
352
|
+
elif choice == 'S':
|
|
353
|
+
print_warning("Skipped")
|
|
354
|
+
elif choice == 'C':
|
|
355
|
+
print_warning("Cancelled")
|
|
356
|
+
elif choice.isdigit():
|
|
357
|
+
idx = int(choice) - 1
|
|
358
|
+
if 0 <= idx < len(exec_actions):
|
|
359
|
+
self._execute_action(exec_actions[idx])
|
|
360
|
+
|
|
361
|
+
def _execute_action(self, action):
|
|
362
|
+
"""Выполняет одно действие"""
|
|
363
|
+
try:
|
|
364
|
+
if action['type'] == 'create':
|
|
365
|
+
self.file_manager.create_file(action['path'], action['content'])
|
|
366
|
+
self.stats["files_created"].append(action['path'])
|
|
367
|
+
|
|
368
|
+
elif action['type'] == 'modify':
|
|
369
|
+
self.file_manager.modify_file(action['path'], action['content'])
|
|
370
|
+
self.stats["files_modified"].append(action['path'])
|
|
371
|
+
|
|
372
|
+
elif action['type'] == 'delete':
|
|
373
|
+
self._delete_file(action['path'])
|
|
374
|
+
self.stats["files_deleted"].append(action['path'])
|
|
375
|
+
|
|
376
|
+
elif action['type'] == 'read':
|
|
377
|
+
self._read_file(action['path'])
|
|
378
|
+
|
|
379
|
+
elif action['type'] == 'list':
|
|
380
|
+
self._list_directory(action['path'])
|
|
381
|
+
|
|
382
|
+
elif action['type'] == 'mkdir':
|
|
383
|
+
self._make_directory(action['path'])
|
|
384
|
+
|
|
385
|
+
elif action['type'] == 'exec':
|
|
386
|
+
self.file_manager.execute_command(action['command'])
|
|
387
|
+
self.stats["commands_executed"].append(action['command'])
|
|
388
|
+
|
|
389
|
+
elif action['type'] == 'copy':
|
|
390
|
+
self._copy_file(action['path'])
|
|
391
|
+
|
|
392
|
+
elif action['type'] == 'move':
|
|
393
|
+
self._move_file(action['path'])
|
|
394
|
+
|
|
395
|
+
except Exception as e:
|
|
396
|
+
print_error(f"Failed: {e}")
|
|
397
|
+
|
|
398
|
+
def _delete_file(self, path):
|
|
399
|
+
"""Удаляет файл"""
|
|
400
|
+
full_path = self.project_path / path
|
|
401
|
+
if not self.security.is_safe_path(str(path)):
|
|
402
|
+
print_error(f"Unsafe: {path}")
|
|
403
|
+
return
|
|
404
|
+
|
|
405
|
+
if full_path.exists():
|
|
406
|
+
# Бэкап перед удалением
|
|
407
|
+
backup = full_path.with_suffix(full_path.suffix + '.bak')
|
|
408
|
+
shutil.copy2(full_path, backup)
|
|
409
|
+
full_path.unlink()
|
|
410
|
+
print_success(f"Deleted: {path} (backup: {backup.name})")
|
|
411
|
+
else:
|
|
412
|
+
print_warning(f"Not found: {path}")
|
|
413
|
+
|
|
414
|
+
def _read_file(self, path):
|
|
415
|
+
"""Читает и показывает файл"""
|
|
416
|
+
full_path = self.project_path / path
|
|
417
|
+
if full_path.exists():
|
|
418
|
+
content = full_path.read_text(encoding='utf-8', errors='ignore')
|
|
419
|
+
print_header(f"\n=== {path} ===")
|
|
420
|
+
print(content)
|
|
421
|
+
print_header("=" * (len(path) + 8))
|
|
422
|
+
else:
|
|
423
|
+
print_warning(f"Not found: {path}")
|
|
424
|
+
|
|
425
|
+
def _list_directory(self, path):
|
|
426
|
+
"""Показывает содержимое директории"""
|
|
427
|
+
full_path = self.project_path / path
|
|
428
|
+
if full_path.exists() and full_path.is_dir():
|
|
429
|
+
items = sorted(full_path.iterdir())
|
|
430
|
+
print_header(f"\n=== {path}/ ({len(items)} items) ===")
|
|
431
|
+
for item in items:
|
|
432
|
+
if item.is_dir():
|
|
433
|
+
print(f" [DIR] {item.name}/")
|
|
434
|
+
else:
|
|
435
|
+
size = item.stat().st_size
|
|
436
|
+
print(f" [FILE] {item.name} ({self._format_size(size)})")
|
|
437
|
+
else:
|
|
438
|
+
print_warning(f"Not found: {path}")
|
|
439
|
+
|
|
440
|
+
def _make_directory(self, path):
|
|
441
|
+
"""Создаёт директорию"""
|
|
442
|
+
full_path = self.project_path / path
|
|
443
|
+
full_path.mkdir(parents=True, exist_ok=True)
|
|
444
|
+
print_success(f"Created dir: {path}")
|
|
445
|
+
|
|
446
|
+
def _copy_file(self, source):
|
|
447
|
+
"""Копирует файл (ожидает, что следом будет CREATE)"""
|
|
448
|
+
print_info(f"Copy source noted: {source}")
|
|
449
|
+
|
|
450
|
+
def _move_file(self, source):
|
|
451
|
+
"""Перемещает файл (ожидает, что следом будет CREATE)"""
|
|
452
|
+
full_path = self.project_path / source
|
|
453
|
+
if full_path.exists():
|
|
454
|
+
print_info(f"Move source noted: {source}")
|
|
455
|
+
else:
|
|
456
|
+
print_warning(f"Source not found: {source}")
|
|
457
|
+
|
|
458
|
+
def _handle_command(self, user_input):
|
|
459
|
+
cmd = user_input.lower().split()[0]
|
|
460
|
+
if cmd in ['/help', '/h']:
|
|
461
|
+
print("""
|
|
462
|
+
COMMANDS:
|
|
463
|
+
/help Show help
|
|
464
|
+
/scan Scan project
|
|
465
|
+
/models List models
|
|
466
|
+
/model N Switch model
|
|
467
|
+
/clear Clear history
|
|
468
|
+
/save Save session
|
|
469
|
+
/stats Statistics
|
|
470
|
+
/exit Exit
|
|
471
|
+
""")
|
|
472
|
+
elif cmd in ['/scan', '/s']:
|
|
473
|
+
print(self.file_manager.scan_project())
|
|
474
|
+
elif cmd in ['/models', '/m']:
|
|
475
|
+
for name in MODELS:
|
|
476
|
+
print(f" {name}")
|
|
477
|
+
elif cmd in ['/clear', '/c']:
|
|
478
|
+
self.conversation_history = [self.conversation_history[0]]
|
|
479
|
+
print_success("Cleared")
|
|
480
|
+
elif cmd in ['/save']:
|
|
481
|
+
self.file_manager.save_session(self.conversation_history, self.current_model, self.stats)
|
|
482
|
+
elif cmd in ['/stats']:
|
|
483
|
+
duration = datetime.now() - self.stats["start_time"]
|
|
484
|
+
print(f"Messages: {self.stats['messages_sent']}")
|
|
485
|
+
print(f"Created: {len(self.stats['files_created'])} files")
|
|
486
|
+
print(f"Modified: {len(self.stats['files_modified'])} files")
|
|
487
|
+
print(f"Deleted: {len(self.stats['files_deleted'])} files")
|
|
488
|
+
print(f"Commands: {len(self.stats['commands_executed'])}")
|
|
489
|
+
print(f"Duration: {duration}")
|
|
490
|
+
elif cmd in ['/exit', '/q']:
|
|
491
|
+
print_success("Goodbye!")
|
|
492
|
+
os._exit(0)
|
|
493
|
+
elif cmd in ['/model'] and len(user_input.split()) > 1:
|
|
494
|
+
model_name = user_input.split()[1]
|
|
495
|
+
if model_name in MODELS:
|
|
496
|
+
self.current_model = MODELS[model_name]
|
|
497
|
+
self.model_settings = get_model_settings(self.current_model)
|
|
498
|
+
print_success(f"Switched to: {model_name}")
|
|
499
|
+
else:
|
|
500
|
+
print_error(f"Unknown model: {model_name}")
|
|
501
|
+
return ""
|
|
502
|
+
|
|
503
|
+
def execute_command(self, command):
|
|
504
|
+
self.send_message(command)
|
|
505
|
+
|
|
506
|
+
def run(self):
|
|
507
|
+
print_logo()
|
|
508
|
+
print()
|
|
509
|
+
print_info(f"Project: {self.project_path}")
|
|
510
|
+
print_info(f"Model: {self.current_model}")
|
|
511
|
+
|
|
512
|
+
try:
|
|
513
|
+
files = list(self.project_path.rglob("*"))
|
|
514
|
+
files = [f for f in files if f.is_file()
|
|
515
|
+
and not any(ex in f.parts for ex in self.file_manager.excluded_dirs)]
|
|
516
|
+
visible = [f for f in files if not f.name.startswith('.')]
|
|
517
|
+
if visible:
|
|
518
|
+
print_info(f"\nFound {len(visible)} files (AI sees all)")
|
|
519
|
+
except:
|
|
520
|
+
pass
|
|
521
|
+
|
|
522
|
+
print_info("/help for commands\n")
|
|
523
|
+
|
|
524
|
+
while True:
|
|
525
|
+
try:
|
|
526
|
+
self.reset_interrupt()
|
|
527
|
+
user_input = input("> ").strip()
|
|
528
|
+
if user_input:
|
|
529
|
+
self.send_message(user_input)
|
|
530
|
+
except KeyboardInterrupt:
|
|
531
|
+
continue
|
|
532
|
+
except EOFError:
|
|
533
|
+
break
|
handycode/cli.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Интерфейс командной строки для HandyCode
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from handycode.assistant import HandyCode
|
|
11
|
+
from handycode.config import Config
|
|
12
|
+
from handycode.utils import print_error, print_warning, print_info, print_logo, print_success
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def create_parser() -> argparse.ArgumentParser:
|
|
16
|
+
"""Создаёт парсер аргументов"""
|
|
17
|
+
parser = argparse.ArgumentParser(
|
|
18
|
+
prog='handycode',
|
|
19
|
+
description='HandyCode - AI Assistant for coding',
|
|
20
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
21
|
+
epilog="""
|
|
22
|
+
Examples:
|
|
23
|
+
hc Interactive mode
|
|
24
|
+
hc -p /path/to/project Open project
|
|
25
|
+
hc -c "Create React app" Run command
|
|
26
|
+
hc -m gpt4 Use model
|
|
27
|
+
hc -y -c "Create Python API" Auto-approve
|
|
28
|
+
"""
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
parser.add_argument('-p', '--project', type=str, help='Project directory')
|
|
32
|
+
parser.add_argument('-m', '--model', type=str, default='deepseek', help='AI model')
|
|
33
|
+
parser.add_argument('-y', '--yes', action='store_true', help='Auto-approve')
|
|
34
|
+
parser.add_argument('-c', '--command', type=str, help='Execute command and exit')
|
|
35
|
+
parser.add_argument('--version', action='version', version='HandyCode v2.0.0')
|
|
36
|
+
|
|
37
|
+
return parser
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def main():
|
|
41
|
+
"""Главная функция CLI"""
|
|
42
|
+
parser = create_parser()
|
|
43
|
+
args = parser.parse_args()
|
|
44
|
+
|
|
45
|
+
config = Config()
|
|
46
|
+
|
|
47
|
+
project_dir = args.project if args.project else os.getcwd()
|
|
48
|
+
project_path = Path(project_dir).resolve()
|
|
49
|
+
|
|
50
|
+
if not validate_project_dir(project_path):
|
|
51
|
+
sys.exit(1)
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
assistant = HandyCode(
|
|
55
|
+
project_path=project_path,
|
|
56
|
+
model=args.model,
|
|
57
|
+
auto_approve=args.yes,
|
|
58
|
+
config=config
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
if args.command:
|
|
62
|
+
assistant.execute_command(args.command)
|
|
63
|
+
else:
|
|
64
|
+
assistant.run()
|
|
65
|
+
except KeyboardInterrupt:
|
|
66
|
+
print("\n\nGoodbye!")
|
|
67
|
+
sys.exit(0)
|
|
68
|
+
except Exception as e:
|
|
69
|
+
print_error(f"\nError: {e}")
|
|
70
|
+
sys.exit(1)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def validate_project_dir(project_path: Path) -> bool:
|
|
74
|
+
"""Проверяет директорию проекта"""
|
|
75
|
+
if not project_path.exists():
|
|
76
|
+
print_warning(f"Directory does not exist: {project_path}")
|
|
77
|
+
create = input("Create it? [Y/n]: ").strip().lower()
|
|
78
|
+
if create in ['', 'y', 'yes']:
|
|
79
|
+
project_path.mkdir(parents=True, exist_ok=True)
|
|
80
|
+
print_success(f"Created: {project_path}")
|
|
81
|
+
else:
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
if not project_path.is_dir():
|
|
85
|
+
print_error(f"Not a directory: {project_path}")
|
|
86
|
+
return False
|
|
87
|
+
|
|
88
|
+
dangerous_paths = [
|
|
89
|
+
'/', '/etc', '/bin', '/sbin', '/usr', '/System',
|
|
90
|
+
'/Windows', '/boot', '/root', '/var', '/tmp'
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
path_str = str(project_path)
|
|
94
|
+
is_dangerous = any(path_str == d or path_str.startswith(d + '/') for d in dangerous_paths)
|
|
95
|
+
|
|
96
|
+
if is_dangerous:
|
|
97
|
+
print_error(f"WARNING: System directory!")
|
|
98
|
+
print_error(f"Path: {project_path}")
|
|
99
|
+
confirm = input("Continue? Type 'YES': ").strip()
|
|
100
|
+
if confirm != 'YES':
|
|
101
|
+
return False
|
|
102
|
+
|
|
103
|
+
print(f"\nProject directory: {project_path}")
|
|
104
|
+
|
|
105
|
+
home = Path.home()
|
|
106
|
+
if project_path == home:
|
|
107
|
+
print_warning("WARNING: This is your HOME directory!")
|
|
108
|
+
confirm = input("Continue? [y/N]: ").strip().lower()
|
|
109
|
+
if confirm not in ['y', 'yes']:
|
|
110
|
+
return False
|
|
111
|
+
|
|
112
|
+
contents = list(project_path.iterdir())
|
|
113
|
+
if contents:
|
|
114
|
+
visible = [c for c in contents if not c.name.startswith('.')]
|
|
115
|
+
if visible:
|
|
116
|
+
print(f"\nDirectory contains {len(visible)} items:")
|
|
117
|
+
for item in visible[:10]:
|
|
118
|
+
item_type = "[DIR]" if item.is_dir() else "[FILE]"
|
|
119
|
+
print(f" {item_type} {item.name}")
|
|
120
|
+
if len(visible) > 10:
|
|
121
|
+
print(f" ... and {len(visible) - 10} more")
|
|
122
|
+
print_warning("\nDirectory is not empty!")
|
|
123
|
+
|
|
124
|
+
confirm = input("\nUse this directory? [Y/n]: ").strip().lower()
|
|
125
|
+
if confirm not in ['', 'y', 'yes']:
|
|
126
|
+
print_warning("Cancelled")
|
|
127
|
+
return False
|
|
128
|
+
|
|
129
|
+
print_success(f"Using: {project_path}\n")
|
|
130
|
+
return True
|