devobin 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. devobin/__init__.py +4 -0
  2. devobin/app.py +42 -0
  3. devobin/cli/__init__.py +1 -0
  4. devobin/cli/chat.py +831 -0
  5. devobin/cli/commands.py +362 -0
  6. devobin/cli/slash_menu.py +74 -0
  7. devobin/cli/terminal.py +155 -0
  8. devobin/cli/ui.py +129 -0
  9. devobin/config/__init__.py +1 -0
  10. devobin/config/settings.py +124 -0
  11. devobin/core/__init__.py +1 -0
  12. devobin/core/analyzer.py +166 -0
  13. devobin/core/executor.py +244 -0
  14. devobin/core/memory.py +78 -0
  15. devobin/core/optimizer.py +106 -0
  16. devobin/core/prompt_builder.py +513 -0
  17. devobin/core/researcher.py +283 -0
  18. devobin/core/rtl.py +93 -0
  19. devobin/core/scanner.py +206 -0
  20. devobin/core/tools.py +283 -0
  21. devobin/core/validator.py +235 -0
  22. devobin/main.py +33 -0
  23. devobin/output/__init__.py +1 -0
  24. devobin/providers/__init__.py +85 -0
  25. devobin/providers/anthropic_provider.py +72 -0
  26. devobin/providers/google_provider.py +90 -0
  27. devobin/providers/ollama_provider.py +81 -0
  28. devobin/providers/openai_provider.py +102 -0
  29. devobin/storage/__init__.py +1 -0
  30. devobin/storage/database.py +207 -0
  31. devobin/ui/__init__.py +1 -0
  32. devobin/ui/ask_modal.py +96 -0
  33. devobin/ui/chat_screen.py +1029 -0
  34. devobin/ui/command_palette.py +131 -0
  35. devobin/ui/connect_modal.py +166 -0
  36. devobin/ui/export_modal.py +159 -0
  37. devobin/ui/history_modal.py +137 -0
  38. devobin/ui/memory_modal.py +88 -0
  39. devobin/ui/model_modal.py +143 -0
  40. devobin/ui/permission_modal.py +92 -0
  41. devobin/ui/project_modal.py +133 -0
  42. devobin/ui/settings_modal.py +89 -0
  43. devobin-1.0.0.dist-info/METADATA +364 -0
  44. devobin-1.0.0.dist-info/RECORD +47 -0
  45. devobin-1.0.0.dist-info/WHEEL +4 -0
  46. devobin-1.0.0.dist-info/entry_points.txt +2 -0
  47. devobin-1.0.0.dist-info/licenses/LICENSE +21 -0
devobin/cli/chat.py ADDED
@@ -0,0 +1,831 @@
1
+ """Interactive chat system for DevObin AI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+
8
+ from prompt_toolkit import PromptSession
9
+ from prompt_toolkit.completion import Completer, Completion
10
+ from prompt_toolkit.history import InMemoryHistory
11
+
12
+ from devobin.cli.slash_menu import SlashMenu
13
+ from devobin.cli.terminal import (
14
+ console,
15
+ render_banner,
16
+ render_status_bar,
17
+ render_divider,
18
+ render_action_menu,
19
+ )
20
+ from devobin.cli.ui import (
21
+ print_detected,
22
+ print_error,
23
+ print_success,
24
+ print_warning,
25
+ print_status,
26
+ )
27
+ from devobin.config.settings import get_config
28
+ from devobin.core.analyzer import analyze_input
29
+ from devobin.core.memory import MemoryManager
30
+ from devobin.core.optimizer import compress_context
31
+ from devobin.core.executor import get_executor, get_file_manager
32
+ from devobin.core.rtl import smart_display
33
+ from devobin.storage.database import ProjectStorage
34
+
35
+ SYSTEM_PROMPT = """You are DevObin — an AI Prompt Engineering System.
36
+
37
+ Your ROLE has changed. You are a PROMPT ARCHITECT, not a CODER.
38
+ You create instructions FOR other AI agents to implement. You do NOT write the implementation yourself.
39
+
40
+ ## STRICT RULES
41
+
42
+ 1. NEVER generate complete source code
43
+ 2. NEVER generate complete files
44
+ 3. NEVER generate full applications
45
+ 4. NEVER provide production-ready implementations
46
+ 5. NEVER output code blocks (```html, ```css, ```js, ```python, etc.)
47
+
48
+ ## ALLOWED
49
+
50
+ - Architecture explanations
51
+ - Technical requirements
52
+ - Engineering instructions
53
+ - Design patterns
54
+ - Short educational snippets (max 3 lines)
55
+ - Pseudocode
56
+ - Feature descriptions
57
+ - Responsive design specs
58
+ - UI/UX requirements
59
+ - Performance rules
60
+ - Security rules
61
+
62
+ ## FORBIDDEN
63
+
64
+ - Full HTML files
65
+ - Full CSS files
66
+ - Full JavaScript files
67
+ - Full Python files
68
+ - Complete React/Vue/Angular components
69
+ - Complete projects
70
+ - Multi-file implementations
71
+ - Any code that could be directly executed
72
+
73
+ ## IF USER REQUESTS CODE
74
+
75
+ Do NOT comply.
76
+
77
+ Instead respond:
78
+ "I am DevObin — a prompt engineering tool. I create engineering instructions for AI coding agents. I do NOT write code.
79
+
80
+ Use /export to generate your engineering document, then give it to Codex/Claude/Cursor to implement."
81
+
82
+ ## OUTPUT FORMAT
83
+
84
+ Your output must be structured text with these sections:
85
+ - Role (who the coding agent should be)
86
+ - Project Goal
87
+ - Technical Constraints
88
+ - Required Sections
89
+ - Implementation Details
90
+ - Responsive Design
91
+ - UI/UX Quality
92
+ - Performance Rules
93
+
94
+ Your output is optimized as a PROMPT for coding agents, NOT as code itself.
95
+
96
+ ## AVAILABLE COMMANDS
97
+
98
+ User commands: /run, /read, /write, /ls, /export, /connect, /model, /project, /memory"""
99
+
100
+
101
+ class DevObinCompleter(Completer):
102
+ """Autocomplete for slash commands."""
103
+
104
+ COMMANDS = [
105
+ "/connect", "/model", "/models", "/project", "/memory",
106
+ "/history", "/export", "/research", "/settings", "/clear",
107
+ "/help", "/exit", "/quit",
108
+ "/run", "/exec", "/read", "/cat", "/write", "/create",
109
+ "/ls", "/dir", "/tree", "/cwd", "/cd", "/rm", "/delete",
110
+ "/touch", "/mkdir", "/env", "/pip", "/npm", "/git", "/python",
111
+ ]
112
+
113
+ def get_completions(self, document, complete_event):
114
+ text = document.text_before_cursor
115
+ if text.startswith("/"):
116
+ word = text.lower()
117
+ for cmd in self.COMMANDS:
118
+ if cmd.startswith(word):
119
+ yield Completion(cmd, start_position=-len(text))
120
+
121
+
122
+ class ChatSession:
123
+ """Manages an interactive chat session with the AI."""
124
+
125
+ def __init__(self) -> None:
126
+ self.config = get_config()
127
+ self.project = self.config.get("active_project")
128
+ self.memory = MemoryManager(self.project)
129
+ self.storage = ProjectStorage(self.project) if self.project else None
130
+ self.running = False
131
+ self.slash_menu = SlashMenu()
132
+ self._last_ai_response: str = ""
133
+ self._register_commands()
134
+ self._session = PromptSession(
135
+ completer=DevObinCompleter(),
136
+ history=InMemoryHistory(),
137
+ )
138
+
139
+ def _register_commands(self) -> None:
140
+ """Register all slash commands."""
141
+ self.slash_menu.register("/help", "Show commands", self._cmd_help)
142
+ self.slash_menu.register("/connect", "Connect AI provider", self._cmd_connect)
143
+ self.slash_menu.register("/models", "List models", self._cmd_models)
144
+ self.slash_menu.register("/model", "Change model", self._cmd_model, requires_args=True)
145
+ self.slash_menu.register("/project", "Manage projects", self._cmd_project)
146
+ self.slash_menu.register("/memory", "View memory", self._cmd_memory)
147
+ self.slash_menu.register("/export", "Generate prompt file", self._cmd_export)
148
+ self.slash_menu.register("/settings", "Settings", self._cmd_settings)
149
+ self.slash_menu.register("/clear", "Clear chat", self._cmd_clear)
150
+ self.slash_menu.register("/exit", "Exit DevObin", self._cmd_exit)
151
+ self.slash_menu.register("/quit", "Exit DevObin", self._cmd_exit)
152
+ # Shell & file commands
153
+ self.slash_menu.register("/run", "Run shell command", self._cmd_run)
154
+ self.slash_menu.register("/exec", "Run shell command", self._cmd_run)
155
+ self.slash_menu.register("/read", "Read file", self._cmd_read)
156
+ self.slash_menu.register("/cat", "Read file", self._cmd_read)
157
+ self.slash_menu.register("/write", "Write/create file", self._cmd_write)
158
+ self.slash_menu.register("/create", "Write/create file", self._cmd_write)
159
+ self.slash_menu.register("/ls", "List directory", self._cmd_ls)
160
+ self.slash_menu.register("/dir", "List directory", self._cmd_ls)
161
+ self.slash_menu.register("/tree", "Show file tree", self._cmd_tree)
162
+ self.slash_menu.register("/cwd", "Show current directory", self._cmd_cwd)
163
+ self.slash_menu.register("/cd", "Change directory", self._cmd_cd)
164
+ self.slash_menu.register("/rm", "Delete file", self._cmd_rm)
165
+ self.slash_menu.register("/delete", "Delete file", self._cmd_rm)
166
+ self.slash_menu.register("/touch", "Create empty file", self._cmd_touch)
167
+ self.slash_menu.register("/mkdir", "Create directory", self._cmd_mkdir)
168
+ self.slash_menu.register("/env", "Show environment", self._cmd_env)
169
+ self.slash_menu.register("/pip", "Pip commands", self._cmd_pip)
170
+ self.slash_menu.register("/npm", "Npm commands", self._cmd_npm)
171
+ self.slash_menu.register("/git", "Git commands", self._cmd_git)
172
+ self.slash_menu.register("/python", "Run Python", self._cmd_python)
173
+
174
+ def start(self) -> None:
175
+ """Start the interactive chat loop."""
176
+ render_banner()
177
+ render_status_bar()
178
+ render_divider()
179
+ console.print()
180
+
181
+ if not self.config.active_provider:
182
+ console.print(" [bold cyan]Welcome to DevObin AI![/]")
183
+ console.print(" [dim]Type /connect to set up your AI model.[/]")
184
+ console.print(" [dim]Type / to see all commands.[/]")
185
+ console.print()
186
+ else:
187
+ console.print(" [bold cyan]Ready![/]")
188
+ console.print(" [dim]Type your project idea or / for commands.[/]")
189
+ console.print()
190
+
191
+ self.running = True
192
+
193
+ while self.running:
194
+ try:
195
+ user_input = self._get_input()
196
+ if user_input is None:
197
+ continue
198
+
199
+ user_input = user_input.strip()
200
+ if not user_input:
201
+ continue
202
+
203
+ if user_input.startswith("/"):
204
+ self._execute_command(user_input)
205
+ self._refresh_state()
206
+ continue
207
+
208
+ self._process_message(user_input)
209
+
210
+ except KeyboardInterrupt:
211
+ console.print()
212
+ print_status("Use [bold]/exit[/] to quit.")
213
+ except EOFError:
214
+ self.running = False
215
+
216
+ def _get_input(self) -> str | None:
217
+ """Get user input using prompt-toolkit."""
218
+ try:
219
+ return self._session.prompt("\n You › ")
220
+ except (KeyboardInterrupt, EOFError):
221
+ return None
222
+
223
+ def _execute_command(self, input_text: str) -> None:
224
+ """Execute a slash command synchronously."""
225
+ parts = input_text.strip().split(maxsplit=1)
226
+ cmd = parts[0].lower() if parts else ""
227
+ args = parts[1] if len(parts) > 1 else ""
228
+
229
+ command = self.slash_menu.get_command(cmd)
230
+ if command:
231
+ if asyncio.iscoroutinefunction(command.handler):
232
+ asyncio.run(command.handler(args))
233
+ else:
234
+ command.handler(args)
235
+ elif cmd == "/":
236
+ self.slash_menu.show_menu()
237
+ else:
238
+ print_error(f"Unknown command: {cmd}")
239
+
240
+ def _refresh_state(self) -> None:
241
+ """Refresh project and memory references after commands."""
242
+ self.project = self.config.get("active_project")
243
+ self.memory = MemoryManager(self.project)
244
+ self.storage = ProjectStorage(self.project) if self.project else None
245
+
246
+ def _process_message(self, user_input: str) -> None:
247
+ """Process a user message through the AI pipeline."""
248
+ if self.storage:
249
+ self.storage.add_message("user", user_input)
250
+
251
+ analysis = analyze_input(user_input)
252
+
253
+ if analysis.detected_techs:
254
+ tech_names = list(set(t.name for t in analysis.detected_techs))
255
+ print_detected(tech_names, "Detected")
256
+ console.print()
257
+
258
+ if self.config.active_provider:
259
+ self._chat_with_ai(user_input, analysis)
260
+ else:
261
+ self._local_analysis(user_input, analysis)
262
+
263
+ def _chat_with_ai(self, user_input: str, analysis) -> None:
264
+ """Chat with the configured AI provider."""
265
+ from devobin.providers import get_provider
266
+
267
+ try:
268
+ provider = get_provider()
269
+ except ValueError as e:
270
+ print_error(str(e))
271
+ return
272
+
273
+ messages = []
274
+ if self.storage:
275
+ messages = self.storage.get_context_messages(max_messages=20)
276
+
277
+ memory_ctx = self.memory.get_memory_context()
278
+ system = SYSTEM_PROMPT
279
+ if memory_ctx:
280
+ system += f"\n\n## User Preferences & Context\n{memory_ctx}"
281
+
282
+ messages = compress_context(messages, max_tokens=6000)
283
+
284
+ console.print(" [bold green]DevObin[/] ", end="")
285
+
286
+ async def _stream():
287
+ full_response = ""
288
+ async for chunk in provider.chat_stream(
289
+ messages=messages,
290
+ system=system,
291
+ temperature=0.7,
292
+ max_tokens=8192,
293
+ ):
294
+ full_response += chunk
295
+ # Show raw during streaming
296
+ print(chunk, end="", flush=True)
297
+ # After streaming, reshape RTL text
298
+ reshaped = smart_display(full_response)
299
+ if reshaped != full_response:
300
+ # Clear and reprint reshaped version
301
+ print()
302
+ print(f" {reshaped}")
303
+ return full_response
304
+
305
+ try:
306
+ full_response = asyncio.run(_stream())
307
+ console.print()
308
+ console.print()
309
+
310
+ self._last_ai_response = full_response
311
+
312
+ if self.storage:
313
+ self.storage.add_message("assistant", full_response)
314
+
315
+ self._extract_memories(user_input, full_response)
316
+
317
+ render_action_menu([
318
+ ("Export Prompt", "Generate project_prompt.md"),
319
+ ("Continue", "Keep refining context"),
320
+ ("Add Rules", "Add architecture/security rules"),
321
+ ])
322
+ console.print()
323
+
324
+ except Exception as e:
325
+ console.print()
326
+ print_error(f"AI request failed: {e}")
327
+
328
+ def _local_analysis(self, user_input: str, analysis) -> None:
329
+ """Perform local analysis without an AI provider."""
330
+ console.print()
331
+
332
+ if analysis.detected_techs:
333
+ from devobin.core.researcher import research_analysis
334
+ research = research_analysis(analysis)
335
+
336
+ for name, topic in research.items():
337
+ if topic.best_practices:
338
+ console.print(f" [bold cyan]{name}[/] best practices:")
339
+ for bp in topic.best_practices[:3]:
340
+ console.print(f" • {bp}")
341
+ console.print()
342
+
343
+ if analysis.requirements:
344
+ console.print(" [bold]Key Requirements:[/]")
345
+ for req in analysis.requirements:
346
+ console.print(f" → {req}")
347
+ console.print()
348
+
349
+ print_status("Connect an AI model with [bold]/connect[/] for full interactive experience.")
350
+ print_status("Use [bold]/export[/] to generate the project_prompt.md file.")
351
+ else:
352
+ print_status("I can help you define your project. Try describing:")
353
+ console.print(" • What you want to build")
354
+ console.print(" • Technologies you want to use")
355
+ console.print(" • Scale requirements")
356
+ console.print()
357
+ print_status("Example: [bold]I want to create a Django ecommerce platform with React[/]")
358
+
359
+ console.print()
360
+
361
+ def _extract_memories(self, user_input: str, response: str) -> None:
362
+ """Extract and save memories from conversation."""
363
+ preference_patterns = [
364
+ "i prefer", "i always use", "i like to use", "my preferred",
365
+ "i want to use", "let's use", "we should use", "i use",
366
+ ]
367
+
368
+ lower_input = user_input.lower()
369
+ for pattern in preference_patterns:
370
+ if pattern in lower_input:
371
+ self.memory.remember(user_input, scope="global", category="preference")
372
+ break
373
+
374
+ analysis = analyze_input(user_input)
375
+ if analysis.detected_techs:
376
+ for tech in analysis.detected_techs:
377
+ if any(kw in lower_input for kw in ["use", "with", "using", "built with", "stack"]):
378
+ self.memory.remember(
379
+ f"Technology choice: {tech.name} ({tech.category})",
380
+ scope="project",
381
+ category="technology",
382
+ )
383
+
384
+ # ── Slash Command Handlers ────────────────────────────────────
385
+
386
+ def _cmd_help(self, args: str) -> None:
387
+ from devobin.cli.ui import print_help
388
+ print_help()
389
+
390
+ def _cmd_connect(self, args: str) -> None:
391
+ """Interactive provider connection wizard."""
392
+ from rich.prompt import Prompt
393
+
394
+ config = get_config()
395
+
396
+ console.print()
397
+ console.print(" [bold cyan]Connect AI Provider[/]")
398
+ console.print()
399
+
400
+ provider_types = ["openai", "anthropic", "google", "ollama", "openai-compatible"]
401
+ console.print(" Provider types:")
402
+ for i, pt in enumerate(provider_types, 1):
403
+ console.print(f" {i}. {pt}")
404
+ console.print()
405
+
406
+ choice = Prompt.ask(" Select", choices=[str(i) for i in range(1, 6)], default="1")
407
+ provider_type = provider_types[int(choice) - 1]
408
+
409
+ console.print()
410
+ name = Prompt.ask(" Name", default=provider_type)
411
+ api_key = Prompt.ask(" API key", password=True, default="")
412
+
413
+ base_url = ""
414
+ if provider_type == "openai-compatible":
415
+ base_url = Prompt.ask(" Base URL", default="https://api.openai.com/v1")
416
+ elif provider_type == "ollama":
417
+ base_url = Prompt.ask(" Ollama URL", default="http://localhost:11434")
418
+
419
+ temp_config = {
420
+ "api_key": api_key,
421
+ "base_url": base_url or None,
422
+ "model": "",
423
+ "type": provider_type,
424
+ }
425
+
426
+ model = ""
427
+ try:
428
+ if provider_type in ("openai", "openai-compatible"):
429
+ from devobin.providers.openai_provider import OpenAIProvider
430
+ temp_config["model"] = "gpt-4o"
431
+ p = OpenAIProvider(temp_config)
432
+ models = p.available_models()
433
+ elif provider_type == "anthropic":
434
+ from devobin.providers.anthropic_provider import AnthropicProvider
435
+ temp_config["model"] = "claude-sonnet-4-20250514"
436
+ p = AnthropicProvider(temp_config)
437
+ models = p.available_models()
438
+ elif provider_type == "google":
439
+ from devobin.providers.google_provider import GoogleProvider
440
+ temp_config["model"] = "gemini-2.0-flash"
441
+ p = GoogleProvider(temp_config)
442
+ models = p.available_models()
443
+ elif provider_type == "ollama":
444
+ from devobin.providers.ollama_provider import OllamaProvider
445
+ temp_config["model"] = "llama3"
446
+ p = OllamaProvider(temp_config)
447
+ models = p.available_models()
448
+ else:
449
+ models = []
450
+ except Exception:
451
+ models = []
452
+
453
+ if models:
454
+ console.print()
455
+ console.print(" Available models:")
456
+ for i, m in enumerate(models, 1):
457
+ console.print(f" {i}. {m}")
458
+ console.print()
459
+ model_choice = Prompt.ask(" Select model (number or name)", default="1")
460
+ try:
461
+ idx = int(model_choice) - 1
462
+ model = models[idx] if 0 <= idx < len(models) else model_choice
463
+ except ValueError:
464
+ model = model_choice
465
+ else:
466
+ model = Prompt.ask(" Model name", default="gpt-4o")
467
+
468
+ provider_config = {
469
+ "type": provider_type,
470
+ "api_key": api_key,
471
+ "base_url": base_url or None,
472
+ "model": model,
473
+ }
474
+
475
+ config.add_provider(name, provider_config)
476
+ config.active_provider = name
477
+ config.active_model = model
478
+
479
+ console.print()
480
+ print_success(f"Connected to {name} ({provider_type}) — model: {model}")
481
+ console.print()
482
+
483
+ def _cmd_models(self, args: str) -> None:
484
+ from devobin.cli.ui import print_provider_table
485
+ config = get_config()
486
+ providers = config.list_providers()
487
+ if not providers:
488
+ print_warning("No providers configured. Use /connect to set one up.")
489
+ return
490
+ print_provider_table(providers)
491
+
492
+ def _cmd_model(self, args: str) -> None:
493
+ config = get_config()
494
+ if not args:
495
+ current = config.active_model or "None"
496
+ print_status(f"Current model: [bold]{current}[/]")
497
+ return
498
+ config.active_model = args.strip()
499
+ print_success(f"Switched to model: {args.strip()}")
500
+
501
+ def _cmd_project(self, args: str) -> None:
502
+ config = get_config()
503
+ parts = args.strip().split(maxsplit=1)
504
+ sub = parts[0].lower() if parts else ""
505
+ proj_name = parts[1].strip() if len(parts) > 1 else ""
506
+
507
+ if sub == "create":
508
+ if not proj_name:
509
+ print_error("Usage: /project create <name>")
510
+ return
511
+ ProjectStorage(proj_name)
512
+ config.set("active_project", proj_name)
513
+ print_success(f"Created project: {proj_name}")
514
+
515
+ elif sub == "switch":
516
+ if not proj_name:
517
+ print_error("Usage: /project switch <name>")
518
+ return
519
+ projects = [p.name for p in config.projects_dir().iterdir() if p.is_dir()]
520
+ if proj_name not in projects:
521
+ print_error(f"Project '{proj_name}' not found.")
522
+ return
523
+ config.set("active_project", proj_name)
524
+ print_success(f"Switched to project: {proj_name}")
525
+
526
+ elif sub == "list":
527
+ projects = [p.name for p in config.projects_dir().iterdir() if p.is_dir()]
528
+ if not projects:
529
+ print_status("No projects yet. Use /project create <name> to start.")
530
+ else:
531
+ console.print()
532
+ console.print(" [bold cyan]Projects:[/]")
533
+ active = config.get("active_project")
534
+ for p in sorted(projects):
535
+ marker = " [green]← active[/]" if p == active else ""
536
+ console.print(f" • {p}{marker}")
537
+ console.print()
538
+ else:
539
+ console.print(" [bold]Project Commands:[/]")
540
+ console.print(" /project create <name>")
541
+ console.print(" /project switch <name>")
542
+ console.print(" /project list")
543
+
544
+ def _cmd_memory(self, args: str) -> None:
545
+ global_mems = self.memory.get_global_memories()
546
+ project_mems = self.memory.get_project_memories()
547
+
548
+ if not global_mems and not project_mems:
549
+ print_status("No memories saved yet.")
550
+ return
551
+
552
+ if global_mems:
553
+ console.print()
554
+ console.print(" [bold cyan]Global Memories:[/]")
555
+ for m in global_mems[-15:]:
556
+ cat = m.get("category", "general")
557
+ console.print(f" [dim][{cat}][/dim] {m['content'][:100]}")
558
+
559
+ if project_mems:
560
+ console.print()
561
+ console.print(" [bold cyan]Project Memories:[/]")
562
+ for m in project_mems[-15:]:
563
+ cat = m.get("category", "general")
564
+ console.print(f" [dim][{cat}][/dim] {m['content'][:100]}")
565
+
566
+ console.print()
567
+
568
+ def _cmd_export(self, args: str) -> None:
569
+ """Generate the project prompt file. Supports /export [filename]."""
570
+ from devobin.cli.ui import render_markdown
571
+
572
+ config = get_config()
573
+ project = config.get("active_project")
574
+ custom_name = args.strip() if args.strip() else None
575
+
576
+ # Use the actual AI response if available
577
+ if self._last_ai_response and self._last_ai_response.strip():
578
+ prompt_content = self._last_ai_response.strip()
579
+ name = custom_name or project or "project"
580
+ slug = name.lower().strip()
581
+ slug = slug.replace(" ", "_").replace("-", "_")
582
+ slug = "".join(c for c in slug if c.isalnum() or c == "_")
583
+ filename = f"{slug}_prompt.md"
584
+ else:
585
+ # Fallback to prompt_builder template
586
+ if not project:
587
+ print_warning("No active project. Use /project create <name> first.")
588
+ return
589
+
590
+ storage = ProjectStorage(project)
591
+ history = storage.get_history()
592
+
593
+ if not history:
594
+ print_warning("No conversation history to export.")
595
+ return
596
+
597
+ from devobin.core.researcher import research_analysis
598
+ from devobin.core.prompt_builder import build_prompt
599
+
600
+ user_msgs = " ".join(m["content"] for m in history if m["role"] == "user")
601
+ analysis = analyze_input(user_msgs)
602
+ research = research_analysis(analysis)
603
+
604
+ memory_ctx = self.memory.get_memory_context()
605
+ filename, prompt_content = build_prompt(
606
+ analysis, research,
607
+ memory_context=memory_ctx,
608
+ project_name=custom_name or project,
609
+ )
610
+
611
+ # Save to current working directory (where CLI was opened)
612
+ cwd = os.getcwd()
613
+ output_path = os.path.join(cwd, filename)
614
+ with open(output_path, "w", encoding="utf-8") as f:
615
+ f.write(prompt_content)
616
+
617
+ print_success(f"Created: {output_path}")
618
+ console.print()
619
+ render_markdown(prompt_content[:2000])
620
+ if len(prompt_content) > 2000:
621
+ console.print("\n [dim]... (truncated preview)[/]")
622
+ console.print()
623
+
624
+ def _cmd_settings(self, args: str) -> None:
625
+ from devobin.config.settings import CONFIG_DIR
626
+ config = get_config()
627
+
628
+ console.print()
629
+ console.print(" [bold cyan]Settings:[/]")
630
+ console.print(f" Provider: [bold]{config.active_provider or 'None'}[/]")
631
+ console.print(f" Model: [bold]{config.active_model or 'None'}[/]")
632
+ console.print(f" Project: [bold]{config.get('active_project') or 'None'}[/]")
633
+ console.print(f" Config: [dim]{CONFIG_DIR}[/]")
634
+ console.print()
635
+
636
+ def _cmd_clear(self, args: str) -> None:
637
+ if self.storage:
638
+ self.storage.clear_history()
639
+ print_success("Conversation cleared.")
640
+ else:
641
+ print_warning("No active project to clear.")
642
+
643
+ def _cmd_exit(self, args: str) -> None:
644
+ console.print()
645
+ print_success("Goodbye!")
646
+ console.print()
647
+ self.running = False
648
+
649
+ # ── Shell & File Commands ──────────────────────────────────────
650
+
651
+ def _cmd_run(self, args: str) -> None:
652
+ """Execute a shell command."""
653
+ if not args.strip():
654
+ print_error("Usage: /run <command>")
655
+ return
656
+ executor = get_executor()
657
+ console.print(f" [dim]$ {escape(args)}[/]")
658
+ result = executor.run(args)
659
+ if result.stdout.strip():
660
+ console.print(f" {result.stdout.strip()}")
661
+ if result.stderr.strip():
662
+ console.print(f" [red]{result.stderr.strip()}[/]")
663
+ if result.timed_out:
664
+ console.print(" [red]Command timed out after 30s[/]")
665
+ if self.storage:
666
+ self.storage.add_message("user", f"/run {args}")
667
+ self.storage.add_message("assistant", f"[command output]\n{result.output}")
668
+ console.print()
669
+
670
+ def _cmd_read(self, args: str) -> None:
671
+ """Read a file."""
672
+ if not args.strip():
673
+ print_error("Usage: /read <file_path>")
674
+ return
675
+ files = get_file_manager()
676
+ result = files.read(args.strip())
677
+ if result.success:
678
+ console.print(f" [dim]{result.path}[/] ({result.size} bytes)")
679
+ console.print()
680
+ for line in result.content.split("\n")[:50]:
681
+ console.print(f" {line}")
682
+ if result.content.count("\n") > 50:
683
+ console.print(f"\n [dim]... ({result.content.count(chr(10)) - 50} more lines)[/]")
684
+ else:
685
+ print_error(result.error)
686
+ if self.storage and result.success:
687
+ self.storage.add_message("user", f"/read {args}")
688
+ console.print()
689
+
690
+ def _cmd_write(self, args: str) -> None:
691
+ """Write content to a file."""
692
+ if not args.strip():
693
+ print_error("Usage: /write <file_path> <content>")
694
+ return
695
+ parts = args.strip().split("\n", 1)
696
+ filepath = parts[0].strip()
697
+ content = parts[1] if len(parts) > 1 else ""
698
+ if not content:
699
+ print_error(f"Provide content after the filename.\nUsage: /write {filepath}\n<your content here>")
700
+ return
701
+ files = get_file_manager()
702
+ result = files.write(filepath, content)
703
+ if result.success:
704
+ print_success(f"Written {result.size} bytes to {result.path}")
705
+ else:
706
+ print_error(result.error)
707
+ console.print()
708
+
709
+ def _cmd_ls(self, args: str) -> None:
710
+ """List directory contents."""
711
+ files = get_file_manager()
712
+ result = files.list_dir(args.strip() or ".")
713
+ if result.success:
714
+ console.print(f" [bold]{result.path}[/] ({len(result.files)} items)")
715
+ for f in result.files:
716
+ icon = "[blue]📁[/]" if f.endswith("/") else "📄"
717
+ console.print(f" {icon} {f}")
718
+ else:
719
+ print_error(result.error)
720
+ console.print()
721
+
722
+ def _cmd_tree(self, args: str) -> None:
723
+ """Show project file tree."""
724
+ executor = get_executor()
725
+ path = args.strip() or "."
726
+ result = executor.run(
727
+ f'find "{path}" -not -path "*/.git/*" -not -path "*/__pycache__/*" '
728
+ f'-not -path "*/node_modules/*" -not -path "*/.venv/*" | head -50'
729
+ )
730
+ if result.stdout.strip():
731
+ console.print(f" [bold]{path}[/]")
732
+ for line in result.stdout.strip().split("\n"):
733
+ console.print(f" {line}")
734
+ else:
735
+ self._cmd_ls(args)
736
+ console.print()
737
+
738
+ def _cmd_cwd(self, _args: str = "") -> None:
739
+ """Show current working directory."""
740
+ import os
741
+ console.print(f" [bold]cwd:[/] {os.getcwd()}")
742
+ console.print()
743
+
744
+ def _cmd_cd(self, args: str) -> None:
745
+ """Change working directory."""
746
+ import os
747
+ if not args.strip():
748
+ print_error("Usage: /cd <path>")
749
+ return
750
+ try:
751
+ os.chdir(args.strip())
752
+ print_success(f"cwd: {os.getcwd()}")
753
+ except Exception as e:
754
+ print_error(f"cd failed: {e}")
755
+ console.print()
756
+
757
+ def _cmd_rm(self, args: str) -> None:
758
+ """Delete a file."""
759
+ if not args.strip():
760
+ print_error("Usage: /rm <file_or_dir>")
761
+ return
762
+ files = get_file_manager()
763
+ result = files.delete(args.strip())
764
+ if result.success:
765
+ print_success(f"Deleted: {result.path}")
766
+ else:
767
+ print_error(result.error)
768
+ console.print()
769
+
770
+ def _cmd_touch(self, args: str) -> None:
771
+ """Create an empty file."""
772
+ if not args.strip():
773
+ print_error("Usage: /touch <file_path>")
774
+ return
775
+ files = get_file_manager()
776
+ result = files.write(args.strip(), "")
777
+ if result.success:
778
+ print_success(f"Created: {args.strip()}")
779
+ else:
780
+ print_error(result.error)
781
+ console.print()
782
+
783
+ def _cmd_mkdir(self, args: str) -> None:
784
+ """Create a directory."""
785
+ import os
786
+ if not args.strip():
787
+ print_error("Usage: /mkdir <directory>")
788
+ return
789
+ try:
790
+ os.makedirs(args.strip(), exist_ok=True)
791
+ print_success(f"Created directory: {args.strip()}")
792
+ except Exception as e:
793
+ print_error(f"mkdir failed: {e}")
794
+ console.print()
795
+
796
+ def _cmd_env(self, args: str) -> None:
797
+ """Show environment variables."""
798
+ import os
799
+ if args.strip():
800
+ val = os.environ.get(args.strip().upper(), "not set")
801
+ console.print(f" {args.strip().upper()}={val}")
802
+ else:
803
+ safe_keys = ["PATH", "HOME", "USER", "SHELL", "PYTHON", "NODE", "PWD",
804
+ "VIRTUAL_ENV", "CONDA_DEFAULT_ENV", "TERM", "LANG", "EDITOR"]
805
+ env_vars = {k: v for k, v in os.environ.items()
806
+ if any(sk in k.upper() for sk in safe_keys)}
807
+ for k, v in sorted(env_vars.items()):
808
+ console.print(f" [bold]{k}[/]={v}")
809
+ console.print()
810
+
811
+ def _cmd_pip(self, args: str) -> None:
812
+ """Run pip command."""
813
+ self._cmd_run(f"pip {args}" if args.strip() else "pip --version")
814
+
815
+ def _cmd_npm(self, args: str) -> None:
816
+ """Run npm command."""
817
+ self._cmd_run(f"npm {args}" if args.strip() else "npm --version")
818
+
819
+ def _cmd_git(self, args: str) -> None:
820
+ """Run git command."""
821
+ self._cmd_run(f"git {args}" if args.strip() else "git --version")
822
+
823
+ def _cmd_python(self, args: str) -> None:
824
+ """Run Python code."""
825
+ if not args.strip():
826
+ self._cmd_run("python --version")
827
+ elif args.strip().endswith(".py"):
828
+ self._cmd_run(f"python {args}")
829
+ else:
830
+ import shlex
831
+ self._cmd_run(f'python -c {shlex.quote(args)}')