tunacode-cli 0.0.34__py3-none-any.whl → 0.0.36__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.

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