tunacode-cli 0.0.35__py3-none-any.whl → 0.0.37__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 tunacode-cli might be problematic. Click here for more details.

Files changed (32) hide show
  1. tunacode/cli/commands/__init__.py +62 -0
  2. tunacode/cli/commands/base.py +99 -0
  3. tunacode/cli/commands/implementations/__init__.py +38 -0
  4. tunacode/cli/commands/implementations/conversation.py +115 -0
  5. tunacode/cli/commands/implementations/debug.py +189 -0
  6. tunacode/cli/commands/implementations/development.py +77 -0
  7. tunacode/cli/commands/implementations/model.py +61 -0
  8. tunacode/cli/commands/implementations/system.py +216 -0
  9. tunacode/cli/commands/registry.py +236 -0
  10. tunacode/cli/repl.py +91 -30
  11. tunacode/configuration/settings.py +9 -2
  12. tunacode/constants.py +1 -1
  13. tunacode/core/agents/main.py +53 -3
  14. tunacode/core/agents/utils.py +304 -0
  15. tunacode/core/setup/config_setup.py +0 -1
  16. tunacode/core/state.py +13 -2
  17. tunacode/setup.py +7 -2
  18. tunacode/tools/read_file.py +8 -2
  19. tunacode/tools/read_file_async_poc.py +18 -10
  20. tunacode/tools/run_command.py +11 -4
  21. tunacode/ui/console.py +31 -4
  22. tunacode/ui/output.py +7 -2
  23. tunacode/ui/panels.py +98 -5
  24. tunacode/ui/utils.py +3 -0
  25. tunacode/utils/text_utils.py +6 -2
  26. {tunacode_cli-0.0.35.dist-info → tunacode_cli-0.0.37.dist-info}/METADATA +17 -17
  27. {tunacode_cli-0.0.35.dist-info → tunacode_cli-0.0.37.dist-info}/RECORD +31 -21
  28. tunacode/cli/commands.py +0 -893
  29. {tunacode_cli-0.0.35.dist-info → tunacode_cli-0.0.37.dist-info}/WHEEL +0 -0
  30. {tunacode_cli-0.0.35.dist-info → tunacode_cli-0.0.37.dist-info}/entry_points.txt +0 -0
  31. {tunacode_cli-0.0.35.dist-info → tunacode_cli-0.0.37.dist-info}/licenses/LICENSE +0 -0
  32. {tunacode_cli-0.0.35.dist-info → tunacode_cli-0.0.37.dist-info}/top_level.txt +0 -0
tunacode/cli/commands.py DELETED
@@ -1,893 +0,0 @@
1
- """Command system for TunaCode CLI."""
2
-
3
- from abc import ABC, abstractmethod
4
- from dataclasses import dataclass
5
- from enum import Enum
6
- from typing import Any, Dict, List, Optional, Type
7
-
8
- from .. import utils
9
- from ..exceptions import ConfigurationError, ValidationError
10
- from ..types import CommandArgs, CommandContext, CommandResult, ProcessRequestCallback
11
- from ..ui import console as ui
12
-
13
-
14
- class CommandCategory(Enum):
15
- """Categories for organizing commands."""
16
-
17
- SYSTEM = "system"
18
- NAVIGATION = "navigation"
19
- DEVELOPMENT = "development"
20
- MODEL = "model"
21
- DEBUG = "debug"
22
-
23
-
24
- class Command(ABC):
25
- """Base class for all commands."""
26
-
27
- @property
28
- @abstractmethod
29
- def name(self) -> str:
30
- """The primary name of the command."""
31
- pass
32
-
33
- @property
34
- @abstractmethod
35
- def aliases(self) -> CommandArgs:
36
- """Alternative names/aliases for the command."""
37
- pass
38
-
39
- @property
40
- def description(self) -> str:
41
- """Description of what the command does."""
42
- return ""
43
-
44
- @property
45
- def category(self) -> CommandCategory:
46
- """Category this command belongs to."""
47
- return CommandCategory.SYSTEM
48
-
49
- @abstractmethod
50
- async def execute(self, args: CommandArgs, context: CommandContext) -> CommandResult:
51
- """
52
- Execute the command.
53
-
54
- Args:
55
- args: Command arguments (excluding the command name)
56
- context: Execution context with state and config
57
-
58
- Returns:
59
- Command-specific return value
60
- """
61
- pass
62
-
63
-
64
- @dataclass
65
- class CommandSpec:
66
- """Specification for a command's metadata."""
67
-
68
- name: str
69
- aliases: List[str]
70
- description: str
71
- category: CommandCategory = CommandCategory.SYSTEM
72
-
73
-
74
- class SimpleCommand(Command):
75
- """Base class for simple commands without complex logic.
76
-
77
- This class provides a standard implementation for commands that don't
78
- require special initialization or complex behavior. It reads all
79
- properties from a class-level CommandSpec attribute.
80
- """
81
-
82
- spec: CommandSpec
83
-
84
- @property
85
- def name(self) -> str:
86
- """The primary name of the command."""
87
- return self.__class__.spec.name
88
-
89
- @property
90
- def aliases(self) -> CommandArgs:
91
- """Alternative names/aliases for the command."""
92
- return self.__class__.spec.aliases
93
-
94
- @property
95
- def description(self) -> str:
96
- """Description of what the command does."""
97
- return self.__class__.spec.description
98
-
99
- @property
100
- def category(self) -> CommandCategory:
101
- """Category this command belongs to."""
102
- return self.__class__.spec.category
103
-
104
-
105
- class YoloCommand(SimpleCommand):
106
- """Toggle YOLO mode (skip confirmations)."""
107
-
108
- spec = CommandSpec(
109
- name="yolo",
110
- aliases=["/yolo"],
111
- description="Toggle YOLO mode (skip tool confirmations)",
112
- category=CommandCategory.DEVELOPMENT,
113
- )
114
-
115
- async def execute(self, args: List[str], context: CommandContext) -> None:
116
- state = context.state_manager.session
117
- state.yolo = not state.yolo
118
- if state.yolo:
119
- await ui.success("All tools are now active ⚡ Please proceed with caution.\n")
120
- else:
121
- await ui.info("Tool confirmations re-enabled for safety.\n")
122
-
123
-
124
- class DumpCommand(SimpleCommand):
125
- """Dump message history."""
126
-
127
- spec = CommandSpec(
128
- name="dump",
129
- aliases=["/dump"],
130
- description="Dump the current message history",
131
- category=CommandCategory.DEBUG,
132
- )
133
-
134
- async def execute(self, args: List[str], context: CommandContext) -> None:
135
- await ui.dump_messages(context.state_manager.session.messages)
136
-
137
-
138
- class ThoughtsCommand(SimpleCommand):
139
- """Toggle display of agent thoughts."""
140
-
141
- spec = CommandSpec(
142
- name="thoughts",
143
- aliases=["/thoughts"],
144
- description="Show or hide agent thought messages",
145
- category=CommandCategory.DEBUG,
146
- )
147
-
148
- async def execute(self, args: List[str], context: CommandContext) -> None:
149
- state = context.state_manager.session
150
-
151
- # No args - toggle
152
- if not args:
153
- state.show_thoughts = not state.show_thoughts
154
- status = "ON" if state.show_thoughts else "OFF"
155
- await ui.success(f"Thought display {status}")
156
- return
157
-
158
- # Parse argument
159
- arg = args[0].lower()
160
- if arg in {"on", "1", "true"}:
161
- state.show_thoughts = True
162
- elif arg in {"off", "0", "false"}:
163
- state.show_thoughts = False
164
- else:
165
- await ui.error("Usage: /thoughts [on|off]")
166
- return
167
-
168
- status = "ON" if state.show_thoughts else "OFF"
169
- await ui.success(f"Thought display {status}")
170
-
171
-
172
- class IterationsCommand(SimpleCommand):
173
- """Configure maximum agent iterations for ReAct reasoning."""
174
-
175
- spec = CommandSpec(
176
- name="iterations",
177
- aliases=["/iterations"],
178
- description="Set maximum agent iterations for complex reasoning",
179
- category=CommandCategory.DEBUG,
180
- )
181
-
182
- async def execute(self, args: List[str], context: CommandContext) -> None:
183
- state = context.state_manager.session
184
-
185
- # Guard clause - handle "no args" case first and return early
186
- if not args:
187
- current = state.user_config.get("settings", {}).get("max_iterations", 40)
188
- await ui.info(f"Current maximum iterations: {current}")
189
- await ui.muted("Usage: /iterations <number> (1-100)")
190
- return
191
-
192
- # update the logic to not be as nested messely, the above guars needing to get as messy
193
- try:
194
- new_limit = int(args[0])
195
- if new_limit < 1 or new_limit > 100:
196
- await ui.error("Iterations must be between 1 and 100")
197
- return
198
-
199
- # Update the user config
200
- if "settings" not in state.user_config:
201
- state.user_config["settings"] = {}
202
- state.user_config["settings"]["max_iterations"] = new_limit
203
-
204
- await ui.success(f"Maximum iterations set to {new_limit}")
205
- except ValueError:
206
- await ui.error("Please provide a valid number")
207
-
208
-
209
- class ClearCommand(SimpleCommand):
210
- """Clear screen and message history."""
211
-
212
- spec = CommandSpec(
213
- name="clear",
214
- aliases=["/clear"],
215
- description="Clear the screen and message history",
216
- category=CommandCategory.NAVIGATION,
217
- )
218
-
219
- async def execute(self, args: List[str], context: CommandContext) -> None:
220
- # Patch any orphaned tool calls before clearing
221
- from tunacode.core.agents.main import patch_tool_messages
222
-
223
- patch_tool_messages("Conversation cleared", context.state_manager)
224
-
225
- await ui.clear()
226
- context.state_manager.session.messages = []
227
- context.state_manager.session.files_in_context.clear()
228
- await ui.success("Message history and file context cleared")
229
-
230
-
231
- class FixCommand(SimpleCommand):
232
- """Fix orphaned tool calls that cause API errors."""
233
-
234
- spec = CommandSpec(
235
- name="fix",
236
- aliases=["/fix"],
237
- description="Fix orphaned tool calls causing API errors",
238
- category=CommandCategory.DEBUG,
239
- )
240
-
241
- async def execute(self, args: List[str], context: CommandContext) -> None:
242
- from tunacode.core.agents.main import patch_tool_messages
243
-
244
- # Count current messages
245
- before_count = len(context.state_manager.session.messages)
246
-
247
- # Patch orphaned tool calls
248
- patch_tool_messages("Tool call resolved by /fix command", context.state_manager)
249
-
250
- # Count after patching
251
- after_count = len(context.state_manager.session.messages)
252
- patched_count = after_count - before_count
253
-
254
- if patched_count > 0:
255
- await ui.success(f"Fixed {patched_count} orphaned tool call(s)")
256
- await ui.muted("You can now continue the conversation normally")
257
- else:
258
- await ui.info("No orphaned tool calls found")
259
-
260
-
261
- class ParseToolsCommand(SimpleCommand):
262
- """Parse and execute JSON tool calls from the last response."""
263
-
264
- spec = CommandSpec(
265
- name="parsetools",
266
- aliases=["/parsetools"],
267
- description=("Parse JSON tool calls from last response when structured calling fails"),
268
- category=CommandCategory.DEBUG,
269
- )
270
-
271
- async def execute(self, args: List[str], context: CommandContext) -> None:
272
- from tunacode.core.agents.main import extract_and_execute_tool_calls
273
-
274
- # Find the last model response in messages
275
- messages = context.state_manager.session.messages
276
- if not messages:
277
- await ui.error("No message history found")
278
- return
279
-
280
- # Look for the most recent response with text content
281
- found_content = False
282
- for msg in reversed(messages):
283
- if hasattr(msg, "parts"):
284
- for part in msg.parts:
285
- if hasattr(part, "content") and isinstance(part.content, str):
286
- # Create tool callback
287
- from tunacode.cli.repl import _tool_handler
288
-
289
- def tool_callback_with_state(part, node):
290
- return _tool_handler(part, node, context.state_manager)
291
-
292
- try:
293
- await extract_and_execute_tool_calls(
294
- part.content,
295
- tool_callback_with_state,
296
- context.state_manager,
297
- )
298
- await ui.success("JSON tool parsing completed")
299
- found_content = True
300
- return
301
- except Exception as e:
302
- await ui.error(f"Failed to parse tools: {str(e)}")
303
- return
304
-
305
- if not found_content:
306
- await ui.error("No parseable content found in recent messages")
307
-
308
-
309
- class RefreshConfigCommand(SimpleCommand):
310
- """Refresh configuration from defaults."""
311
-
312
- spec = CommandSpec(
313
- name="refresh",
314
- aliases=["/refresh"],
315
- description="Refresh configuration from defaults (useful after updates)",
316
- category=CommandCategory.SYSTEM,
317
- )
318
-
319
- async def execute(self, args: List[str], context: CommandContext) -> None:
320
- from tunacode.configuration.defaults import DEFAULT_USER_CONFIG
321
-
322
- # Update current session config with latest defaults
323
- for key, value in DEFAULT_USER_CONFIG.items():
324
- if key not in context.state_manager.session.user_config:
325
- context.state_manager.session.user_config[key] = value
326
- elif isinstance(value, dict):
327
- # Merge dict values, preserving user overrides
328
- for subkey, subvalue in value.items():
329
- if subkey not in context.state_manager.session.user_config[key]:
330
- context.state_manager.session.user_config[key][subkey] = subvalue
331
-
332
- # Show updated max_iterations
333
- max_iterations = context.state_manager.session.user_config.get("settings", {}).get(
334
- "max_iterations", 20
335
- )
336
- await ui.success(f"Configuration refreshed - max iterations: {max_iterations}")
337
-
338
-
339
- class HelpCommand(SimpleCommand):
340
- """Show help information."""
341
-
342
- spec = CommandSpec(
343
- name="help",
344
- aliases=["/help"],
345
- description="Show help information",
346
- category=CommandCategory.SYSTEM,
347
- )
348
-
349
- def __init__(self, command_registry=None):
350
- self._command_registry = command_registry
351
-
352
- async def execute(self, args: List[str], context: CommandContext) -> None:
353
- await ui.help(self._command_registry)
354
-
355
-
356
- class BranchCommand(SimpleCommand):
357
- """Create and switch to a new git branch."""
358
-
359
- spec = CommandSpec(
360
- name="branch",
361
- aliases=["/branch"],
362
- description="Create and switch to a new git branch",
363
- category=CommandCategory.DEVELOPMENT,
364
- )
365
-
366
- async def execute(self, args: List[str], context: CommandContext) -> None:
367
- import os
368
- import subprocess
369
-
370
- if not args:
371
- await ui.error("Usage: /branch <branch-name>")
372
- return
373
-
374
- if not os.path.exists(".git"):
375
- await ui.error("Not a git repository")
376
- return
377
-
378
- branch_name = args[0]
379
-
380
- try:
381
- subprocess.run(
382
- ["git", "checkout", "-b", branch_name],
383
- capture_output=True,
384
- text=True,
385
- check=True,
386
- timeout=5,
387
- )
388
- await ui.success(f"Switched to new branch '{branch_name}'")
389
- except subprocess.TimeoutExpired:
390
- await ui.error("Git command timed out")
391
- except subprocess.CalledProcessError as e:
392
- error_msg = e.stderr.strip() if e.stderr else str(e)
393
- await ui.error(f"Git error: {error_msg}")
394
- except FileNotFoundError:
395
- await ui.error("Git executable not found")
396
-
397
-
398
- class CompactCommand(SimpleCommand):
399
- """Compact conversation context."""
400
-
401
- spec = CommandSpec(
402
- name="compact",
403
- aliases=["/compact"],
404
- description="Summarize and compact the conversation history",
405
- category=CommandCategory.SYSTEM,
406
- )
407
-
408
- def __init__(self, process_request_callback: Optional[ProcessRequestCallback] = None):
409
- self._process_request = process_request_callback
410
-
411
- async def execute(self, args: List[str], context: CommandContext) -> None:
412
- # Use the injected callback or get it from context
413
- process_request = self._process_request or context.process_request
414
-
415
- if not process_request:
416
- await ui.error("Compact command not available - process_request not configured")
417
- return
418
-
419
- # Count current messages
420
- original_count = len(context.state_manager.session.messages)
421
-
422
- # Generate summary with output captured
423
- summary_prompt = (
424
- "Summarize the conversation so far in a concise paragraph, "
425
- "focusing on the main topics discussed and any important context "
426
- "that should be preserved."
427
- )
428
- result = await process_request(
429
- summary_prompt,
430
- context.state_manager,
431
- output=False, # We'll handle the output ourselves
432
- )
433
-
434
- # Extract summary text from result
435
- summary_text = ""
436
-
437
- # First try: standard result structure
438
- if (
439
- result
440
- and hasattr(result, "result")
441
- and result.result
442
- and hasattr(result.result, "output")
443
- ):
444
- summary_text = result.result.output
445
-
446
- # Second try: check messages for assistant response
447
- if not summary_text:
448
- messages = context.state_manager.session.messages
449
- # Look through new messages in reverse order
450
- for i in range(len(messages) - 1, original_count - 1, -1):
451
- msg = messages[i]
452
- # Handle ModelResponse objects
453
- if hasattr(msg, "parts") and msg.parts:
454
- for part in msg.parts:
455
- if hasattr(part, "content") and part.content:
456
- content = part.content
457
- # Skip JSON thought objects
458
- if content.strip().startswith('{"thought"'):
459
- lines = content.split("\n")
460
- # Find the actual summary after the JSON
461
- for i, line in enumerate(lines):
462
- if (
463
- line.strip()
464
- and not line.strip().startswith("{")
465
- and not line.strip().endswith("}")
466
- ):
467
- summary_text = "\n".join(lines[i:]).strip()
468
- break
469
- else:
470
- summary_text = content
471
- if summary_text:
472
- break
473
- # Handle dict-style messages
474
- elif isinstance(msg, dict):
475
- if msg.get("role") == "assistant" and msg.get("content"):
476
- summary_text = msg["content"]
477
- break
478
- # Handle other message types
479
- elif hasattr(msg, "content") and hasattr(msg, "role"):
480
- if getattr(msg, "role", None) == "assistant":
481
- summary_text = msg.content
482
- break
483
-
484
- if summary_text:
485
- break
486
-
487
- if not summary_text:
488
- await ui.error("Failed to generate summary - no assistant response found")
489
- return
490
-
491
- # Display summary in a formatted panel
492
- from tunacode.ui import panels
493
-
494
- await panels.panel("Conversation Summary", summary_text, border_style="cyan")
495
-
496
- # Show statistics
497
- await ui.info(f"Current message count: {original_count}")
498
- await ui.info("After compaction: 3 (summary + last 2 messages)")
499
-
500
- # Truncate the conversation history
501
- context.state_manager.session.messages = context.state_manager.session.messages[-2:]
502
-
503
- await ui.success("Context history has been summarized and truncated.")
504
-
505
-
506
- class UpdateCommand(SimpleCommand):
507
- """Update TunaCode to the latest version."""
508
-
509
- spec = CommandSpec(
510
- name="update",
511
- aliases=["/update"],
512
- description="Update TunaCode to the latest version",
513
- category=CommandCategory.SYSTEM,
514
- )
515
-
516
- async def execute(self, args: List[str], context: CommandContext) -> None:
517
- import shutil
518
- import subprocess
519
- import sys
520
-
521
- await ui.info("Checking for TunaCode updates...")
522
-
523
- # Detect installation method
524
- installation_method = None
525
-
526
- # Check if installed via pipx
527
- if shutil.which("pipx"):
528
- try:
529
- result = subprocess.run(
530
- ["pipx", "list"], capture_output=True, text=True, timeout=10
531
- )
532
- pipx_installed = "tunacode" in result.stdout.lower()
533
- if pipx_installed:
534
- installation_method = "pipx"
535
- except (subprocess.TimeoutExpired, subprocess.CalledProcessError):
536
- pass
537
-
538
- # Check if installed via pip
539
- if not installation_method:
540
- try:
541
- result = subprocess.run(
542
- [sys.executable, "-m", "pip", "show", "tunacode-cli"],
543
- capture_output=True,
544
- text=True,
545
- timeout=10,
546
- )
547
- if result.returncode == 0:
548
- installation_method = "pip"
549
- except (subprocess.TimeoutExpired, subprocess.CalledProcessError):
550
- pass
551
-
552
- if not installation_method:
553
- await ui.error("Could not detect TunaCode installation method")
554
- await ui.muted("Manual update options:")
555
- await ui.muted(" pipx: pipx upgrade tunacode")
556
- await ui.muted(" pip: pip install --upgrade tunacode-cli")
557
- return
558
-
559
- # Perform update based on detected method
560
- try:
561
- if installation_method == "pipx":
562
- await ui.info("Updating via pipx...")
563
- result = subprocess.run(
564
- ["pipx", "upgrade", "tunacode"],
565
- capture_output=True,
566
- text=True,
567
- timeout=60,
568
- )
569
- else: # pip
570
- await ui.info("Updating via pip...")
571
- result = subprocess.run(
572
- [
573
- sys.executable,
574
- "-m",
575
- "pip",
576
- "install",
577
- "--upgrade",
578
- "tunacode-cli",
579
- ],
580
- capture_output=True,
581
- text=True,
582
- timeout=60,
583
- )
584
-
585
- if result.returncode == 0:
586
- await ui.success("TunaCode updated successfully!")
587
- await ui.muted("Restart TunaCode to use the new version")
588
-
589
- # Show update output if available
590
- if result.stdout.strip():
591
- output_lines = result.stdout.strip().split("\n")
592
- for line in output_lines[-5:]: # Show last 5 lines
593
- if line.strip():
594
- await ui.muted(f" {line}")
595
- else:
596
- await ui.error("Update failed")
597
- if result.stderr:
598
- await ui.muted(f"Error: {result.stderr.strip()}")
599
-
600
- except subprocess.TimeoutExpired:
601
- await ui.error("Update timed out")
602
- except subprocess.CalledProcessError as e:
603
- await ui.error(f"Update failed: {e}")
604
- except FileNotFoundError:
605
- await ui.error(f"Could not find {installation_method} executable")
606
-
607
-
608
- class ModelCommand(SimpleCommand):
609
- """Manage model selection."""
610
-
611
- spec = CommandSpec(
612
- name="model",
613
- aliases=["/model"],
614
- description="Switch model (e.g., /model gpt-4 or /model openai:gpt-4)",
615
- category=CommandCategory.MODEL,
616
- )
617
-
618
- async def execute(self, args: CommandArgs, context: CommandContext) -> Optional[str]:
619
- # No arguments - show current model
620
- if not args:
621
- current_model = context.state_manager.session.current_model
622
- await ui.info(f"Current model: {current_model}")
623
- await ui.muted("Usage: /model <provider:model-name> [default]")
624
- await ui.muted("Example: /model openai:gpt-4.1")
625
- return None
626
-
627
- # Get the model name from args
628
- model_name = args[0]
629
-
630
- # Check if provider prefix is present
631
- if ":" not in model_name:
632
- await ui.error("Model name must include provider prefix")
633
- await ui.muted("Format: provider:model-name")
634
- await ui.muted(
635
- "Examples: openai:gpt-4.1, anthropic:claude-3-opus, google-gla:gemini-2.0-flash"
636
- )
637
- return None
638
-
639
- # No validation - user is responsible for correct model names
640
- await ui.warning("Model set without validation - verify the model name is correct")
641
-
642
- # Set the model
643
- context.state_manager.session.current_model = model_name
644
-
645
- # Check if setting as default
646
- if len(args) > 1 and args[1] == "default":
647
- try:
648
- utils.user_configuration.set_default_model(model_name, context.state_manager)
649
- await ui.muted("Updating default model")
650
- return "restart"
651
- except ConfigurationError as e:
652
- await ui.error(str(e))
653
- return None
654
-
655
- # Show success message with the new model
656
- await ui.success(f"Switched to model: {model_name}")
657
- return None
658
-
659
-
660
- @dataclass
661
- class CommandDependencies:
662
- """Container for command dependencies."""
663
-
664
- process_request_callback: Optional[ProcessRequestCallback] = None
665
- command_registry: Optional[Any] = None # Reference to the registry itself
666
-
667
-
668
- class CommandFactory:
669
- """Factory for creating commands with proper dependency injection."""
670
-
671
- def __init__(self, dependencies: Optional[CommandDependencies] = None):
672
- self.dependencies = dependencies or CommandDependencies()
673
-
674
- def create_command(self, command_class: Type[Command]) -> Command:
675
- """Create a command instance with proper dependencies."""
676
- # Special handling for commands that need dependencies
677
- if command_class == CompactCommand:
678
- return CompactCommand(self.dependencies.process_request_callback)
679
- elif command_class == HelpCommand:
680
- return HelpCommand(self.dependencies.command_registry)
681
-
682
- # Default creation for commands without dependencies
683
- return command_class()
684
-
685
- def update_dependencies(self, **kwargs) -> None:
686
- """Update factory dependencies."""
687
- for key, value in kwargs.items():
688
- if hasattr(self.dependencies, key):
689
- setattr(self.dependencies, key, value)
690
-
691
-
692
- class InitCommand(SimpleCommand):
693
- """Creates or updates TUNACODE.md with project-specific context."""
694
-
695
- spec = CommandSpec(
696
- name="/init",
697
- aliases=[],
698
- description="Analyze codebase and create/update TUNACODE.md file",
699
- category=CommandCategory.DEVELOPMENT,
700
- )
701
-
702
- async def execute(self, args, context: CommandContext) -> CommandResult:
703
- """Execute the init command."""
704
- # Minimal implementation to make test pass
705
- prompt = """Please analyze this codebase and create a TUNACODE.md file containing:
706
- 1. Build/lint/test commands - especially for running a single test
707
- 2. Code style guidelines including imports, formatting, types, naming conventions, error handling, etc.
708
-
709
- The file you create will be given to agentic coding agents (such as yourself) that operate in this repository.
710
- Make it about 20 lines long.
711
- If there's already a TUNACODE.md, improve it.
712
- If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md),
713
- make sure to include them."""
714
-
715
- # Call the agent to analyze and create/update the file
716
- await context.process_request(prompt, context.state_manager)
717
-
718
- return None
719
-
720
-
721
- class CommandRegistry:
722
- """Registry for managing commands with auto-discovery and categories."""
723
-
724
- def __init__(self, factory: Optional[CommandFactory] = None):
725
- self._commands: Dict[str, Command] = {}
726
- self._categories: Dict[CommandCategory, List[Command]] = {
727
- category: [] for category in CommandCategory
728
- }
729
- self._factory = factory or CommandFactory()
730
- self._discovered = False
731
-
732
- # Set registry reference in factory dependencies
733
- self._factory.update_dependencies(command_registry=self)
734
-
735
- def register(self, command: Command) -> None:
736
- """Register a command and its aliases."""
737
- # Register by primary name
738
- self._commands[command.name] = command
739
-
740
- # Register all aliases
741
- for alias in command.aliases:
742
- self._commands[alias.lower()] = command
743
-
744
- # Add to category (remove existing instance first to prevent duplicates)
745
- category_commands = self._categories[command.category]
746
- # Remove any existing instance of this command class
747
- self._categories[command.category] = [
748
- cmd for cmd in category_commands if cmd.__class__ != command.__class__
749
- ]
750
- # Add the new instance
751
- self._categories[command.category].append(command)
752
-
753
- def register_command_class(self, command_class: Type[Command]) -> None:
754
- """Register a command class using the factory."""
755
- command = self._factory.create_command(command_class)
756
- self.register(command)
757
-
758
- def discover_commands(self) -> None:
759
- """Auto-discover and register all command classes."""
760
- if self._discovered:
761
- return
762
-
763
- # List of all command classes to register
764
- command_classes = [
765
- YoloCommand,
766
- DumpCommand,
767
- ThoughtsCommand,
768
- IterationsCommand,
769
- ClearCommand,
770
- FixCommand,
771
- ParseToolsCommand,
772
- RefreshConfigCommand,
773
- UpdateCommand,
774
- HelpCommand,
775
- BranchCommand,
776
- CompactCommand,
777
- ModelCommand,
778
- InitCommand,
779
- ]
780
-
781
- # Register all discovered commands
782
- for command_class in command_classes:
783
- self.register_command_class(command_class)
784
-
785
- self._discovered = True
786
-
787
- def register_all_default_commands(self) -> None:
788
- """Register all default commands (backward compatibility)."""
789
- self.discover_commands()
790
-
791
- def set_process_request_callback(self, callback: ProcessRequestCallback) -> None:
792
- """Set the process_request callback for commands that need it."""
793
- # Only update if callback has changed
794
- if self._factory.dependencies.process_request_callback == callback:
795
- return
796
-
797
- self._factory.update_dependencies(process_request_callback=callback)
798
-
799
- # Re-register CompactCommand with new dependency if already registered
800
- if "compact" in self._commands:
801
- self.register_command_class(CompactCommand)
802
-
803
- async def execute(self, command_text: str, context: CommandContext) -> Any:
804
- """
805
- Execute a command.
806
-
807
- Args:
808
- command_text: The full command text
809
- context: Execution context
810
-
811
- Returns:
812
- Command-specific return value, or None if command not found
813
-
814
- Raises:
815
- ValidationError: If command is not found or empty
816
- """
817
- # Ensure commands are discovered
818
- self.discover_commands()
819
-
820
- parts = command_text.split()
821
- if not parts:
822
- raise ValidationError("Empty command")
823
-
824
- command_name = parts[0].lower()
825
- args = parts[1:]
826
-
827
- # First try exact match
828
- if command_name in self._commands:
829
- command = self._commands[command_name]
830
- return await command.execute(args, context)
831
-
832
- # Try partial matching
833
- matches = self.find_matching_commands(command_name)
834
-
835
- if not matches:
836
- raise ValidationError(f"Unknown command: {command_name}")
837
- elif len(matches) == 1:
838
- # Unambiguous match
839
- command = self._commands[matches[0]]
840
- return await command.execute(args, context)
841
- else:
842
- # Ambiguous - show possibilities
843
- matches_str = ", ".join(sorted(set(matches)))
844
- raise ValidationError(
845
- f"Ambiguous command '{command_name}'. Did you mean: {matches_str}?"
846
- )
847
-
848
- def find_matching_commands(self, partial_command: str) -> List[str]:
849
- """
850
- Find all commands that start with the given partial command.
851
-
852
- Args:
853
- partial_command: The partial command to match
854
-
855
- Returns:
856
- List of matching command names
857
- """
858
- self.discover_commands()
859
- partial = partial_command.lower()
860
- return [cmd for cmd in self._commands.keys() if cmd.startswith(partial)]
861
-
862
- def is_command(self, text: str) -> bool:
863
- """Check if text starts with a registered command (supports partial matching)."""
864
- if not text:
865
- return False
866
-
867
- parts = text.split()
868
- if not parts:
869
- return False
870
-
871
- command_name = parts[0].lower()
872
-
873
- # Check exact match first
874
- if command_name in self._commands:
875
- return True
876
-
877
- # Check partial match
878
- return len(self.find_matching_commands(command_name)) > 0
879
-
880
- def get_command_names(self) -> CommandArgs:
881
- """Get all registered command names (including aliases)."""
882
- self.discover_commands()
883
- return sorted(self._commands.keys())
884
-
885
- def get_commands_by_category(self, category: CommandCategory) -> List[Command]:
886
- """Get all commands in a specific category."""
887
- self.discover_commands()
888
- return self._categories.get(category, [])
889
-
890
- def get_all_categories(self) -> Dict[CommandCategory, List[Command]]:
891
- """Get all commands organized by category."""
892
- self.discover_commands()
893
- return self._categories.copy()