tunacode-cli 0.0.67__py3-none-any.whl → 0.0.69__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 (38) hide show
  1. tunacode/cli/commands/__init__.py +2 -0
  2. tunacode/cli/commands/implementations/__init__.py +2 -0
  3. tunacode/cli/commands/implementations/command_reload.py +48 -0
  4. tunacode/cli/commands/implementations/quickstart.py +43 -0
  5. tunacode/cli/commands/registry.py +131 -1
  6. tunacode/cli/commands/slash/__init__.py +32 -0
  7. tunacode/cli/commands/slash/command.py +157 -0
  8. tunacode/cli/commands/slash/loader.py +134 -0
  9. tunacode/cli/commands/slash/processor.py +294 -0
  10. tunacode/cli/commands/slash/types.py +93 -0
  11. tunacode/cli/commands/slash/validator.py +399 -0
  12. tunacode/cli/main.py +4 -1
  13. tunacode/cli/repl.py +25 -0
  14. tunacode/configuration/defaults.py +1 -0
  15. tunacode/constants.py +1 -1
  16. tunacode/core/agents/agent_components/agent_helpers.py +14 -13
  17. tunacode/core/agents/main.py +1 -1
  18. tunacode/core/agents/utils.py +4 -3
  19. tunacode/core/setup/config_setup.py +231 -6
  20. tunacode/core/setup/coordinator.py +13 -5
  21. tunacode/core/setup/git_safety_setup.py +5 -1
  22. tunacode/exceptions.py +119 -5
  23. tunacode/setup.py +5 -2
  24. tunacode/tools/glob.py +9 -46
  25. tunacode/tools/grep.py +9 -51
  26. tunacode/tools/xml_helper.py +83 -0
  27. tunacode/tutorial/__init__.py +9 -0
  28. tunacode/tutorial/content.py +98 -0
  29. tunacode/tutorial/manager.py +182 -0
  30. tunacode/tutorial/steps.py +124 -0
  31. tunacode/ui/output.py +1 -1
  32. tunacode/utils/user_configuration.py +45 -0
  33. tunacode_cli-0.0.69.dist-info/METADATA +192 -0
  34. {tunacode_cli-0.0.67.dist-info → tunacode_cli-0.0.69.dist-info}/RECORD +37 -24
  35. tunacode_cli-0.0.67.dist-info/METADATA +0 -327
  36. {tunacode_cli-0.0.67.dist-info → tunacode_cli-0.0.69.dist-info}/WHEEL +0 -0
  37. {tunacode_cli-0.0.67.dist-info → tunacode_cli-0.0.69.dist-info}/entry_points.txt +0 -0
  38. {tunacode_cli-0.0.67.dist-info → tunacode_cli-0.0.69.dist-info}/licenses/LICENSE +0 -0
@@ -18,6 +18,7 @@ from .base import Command, CommandCategory, CommandSpec, SimpleCommand
18
18
  from .implementations import (
19
19
  BranchCommand,
20
20
  ClearCommand,
21
+ CommandReloadCommand,
21
22
  CompactCommand,
22
23
  DumpCommand,
23
24
  FixCommand,
@@ -63,4 +64,5 @@ __all__ = [
63
64
  "ModelCommand",
64
65
  "InitCommand",
65
66
  "TodoCommand",
67
+ "CommandReloadCommand",
66
68
  ]
@@ -1,6 +1,7 @@
1
1
  """Command implementations for TunaCode CLI."""
2
2
 
3
3
  # Import all command classes for easy access
4
+ from .command_reload import CommandReloadCommand
4
5
  from .conversation import CompactCommand
5
6
  from .debug import (
6
7
  DumpCommand,
@@ -28,6 +29,7 @@ __all__ = [
28
29
  "RefreshConfigCommand",
29
30
  "StreamingCommand",
30
31
  "UpdateCommand",
32
+ "CommandReloadCommand",
31
33
  # Debug commands
32
34
  "YoloCommand",
33
35
  "DumpCommand",
@@ -0,0 +1,48 @@
1
+ """Command reload implementation."""
2
+
3
+ from pathlib import Path
4
+ from typing import List
5
+
6
+ from ....types import CommandContext
7
+ from ....ui import console as ui
8
+ from ..base import CommandCategory, CommandSpec, SimpleCommand
9
+
10
+
11
+ class CommandReloadCommand(SimpleCommand):
12
+ """Reload slash commands to discover new commands."""
13
+
14
+ spec = CommandSpec(
15
+ name="command-reload",
16
+ aliases=["/command-reload"],
17
+ description="Reload slash commands to discover newly added commands",
18
+ category=CommandCategory.DEVELOPMENT,
19
+ )
20
+
21
+ def __init__(self, command_registry=None):
22
+ self._command_registry = command_registry
23
+
24
+ async def execute(self, args: List[str], context: CommandContext) -> None:
25
+ # Check if any command directories exist
26
+ command_dirs = [
27
+ Path(".tunacode/commands"),
28
+ Path(".claude/commands"),
29
+ Path.home() / ".tunacode/commands",
30
+ Path.home() / ".claude/commands",
31
+ ]
32
+
33
+ dirs_exist = any(cmd_dir.exists() for cmd_dir in command_dirs)
34
+
35
+ if not dirs_exist:
36
+ await ui.info("No commands directory found")
37
+ return
38
+
39
+ # Reload commands using registry
40
+ if self._command_registry:
41
+ try:
42
+ self._command_registry.reload_slash_commands()
43
+ await ui.success("Commands reloaded")
44
+ return
45
+ except Exception as e:
46
+ await ui.error(f"Reload failed: {e}")
47
+ else:
48
+ await ui.error("Command registry not available")
@@ -0,0 +1,43 @@
1
+ """
2
+ Module: tunacode.cli.commands.implementations.quickstart
3
+
4
+ QuickStart command implementation for interactive tutorial system.
5
+ Provides guided introduction to TunaCode for new users.
6
+ """
7
+
8
+ import logging
9
+
10
+ from tunacode.types import CommandContext, CommandResult
11
+
12
+ from ..base import CommandCategory, CommandSpec, SimpleCommand
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class QuickStartCommand(SimpleCommand):
18
+ """Interactive quickstart tutorial command."""
19
+
20
+ spec = CommandSpec(
21
+ name="quickstart",
22
+ aliases=["/quickstart", "/qs"],
23
+ description="Interactive tutorial for getting started with TunaCode",
24
+ category=CommandCategory.SYSTEM,
25
+ )
26
+
27
+ async def execute(self, _args: list[str], context: CommandContext) -> CommandResult:
28
+ """Execute the quickstart tutorial."""
29
+ try:
30
+ from ....tutorial import TutorialManager
31
+ except ImportError:
32
+ await context.ui.error("Tutorial system is not available")
33
+ return
34
+
35
+ tutorial_manager = TutorialManager(context.state_manager)
36
+
37
+ # Always run tutorial when explicitly requested
38
+ success = await tutorial_manager.run_tutorial()
39
+
40
+ if success:
41
+ await context.ui.success("✅ Tutorial completed successfully!")
42
+ else:
43
+ await context.ui.info("Tutorial was cancelled or interrupted")
@@ -3,7 +3,9 @@
3
3
  CLAUDE_ANCHOR[command-registry]: Central command registration and execution
4
4
  """
5
5
 
6
+ import logging
6
7
  from dataclasses import dataclass
8
+ from pathlib import Path
7
9
  from typing import Any, Dict, List, Optional, Type
8
10
 
9
11
  from ...exceptions import ValidationError
@@ -12,6 +14,7 @@ from ...types import CommandArgs, CommandContext, ProcessRequestCallback
12
14
  from .base import Command, CommandCategory
13
15
 
14
16
  # Import all command implementations
17
+ from .implementations.command_reload import CommandReloadCommand
15
18
  from .implementations.conversation import CompactCommand
16
19
  from .implementations.debug import (
17
20
  DumpCommand,
@@ -24,6 +27,7 @@ from .implementations.debug import (
24
27
  from .implementations.development import BranchCommand, InitCommand
25
28
  from .implementations.model import ModelCommand
26
29
  from .implementations.plan import ExitPlanCommand, PlanCommand
30
+ from .implementations.quickstart import QuickStartCommand
27
31
  from .implementations.system import (
28
32
  ClearCommand,
29
33
  HelpCommand,
@@ -35,6 +39,8 @@ from .implementations.template import TemplateCommand
35
39
  from .implementations.todo import TodoCommand
36
40
  from .template_shortcut import TemplateShortcutCommand
37
41
 
42
+ logger = logging.getLogger(__name__)
43
+
38
44
 
39
45
  @dataclass
40
46
  class CommandDependencies:
@@ -57,6 +63,8 @@ class CommandFactory:
57
63
  return CompactCommand(self.dependencies.process_request_callback)
58
64
  elif command_class == HelpCommand:
59
65
  return HelpCommand(self.dependencies.command_registry)
66
+ elif command_class == CommandReloadCommand:
67
+ return CommandReloadCommand(self.dependencies.command_registry)
60
68
 
61
69
  # Default creation for commands without dependencies
62
70
  return command_class()
@@ -80,6 +88,11 @@ class CommandRegistry:
80
88
  self._discovered = False
81
89
  self._shortcuts_loaded = False
82
90
 
91
+ # Slash command support
92
+ self._slash_loader: Optional[Any] = None # SlashCommandLoader
93
+ self._slash_discovery_result: Optional[Any] = None # CommandDiscoveryResult
94
+ self._slash_enabled: bool = True # Feature flag
95
+
83
96
  # Set registry reference in factory dependencies
84
97
  self._factory.update_dependencies(command_registry=self)
85
98
 
@@ -111,6 +124,17 @@ class CommandRegistry:
111
124
  if self._discovered:
112
125
  return
113
126
 
127
+ # Step 1: Discover built-in commands
128
+ self._discover_builtin_commands()
129
+
130
+ # Step 2: Discover slash commands (if enabled)
131
+ if self._slash_enabled:
132
+ self._discover_slash_commands()
133
+
134
+ self._discovered = True
135
+
136
+ def _discover_builtin_commands(self) -> None:
137
+ """Discover and register built-in command classes."""
114
138
  # List of all command classes to register
115
139
  command_classes = [
116
140
  YoloCommand,
@@ -130,15 +154,69 @@ class CommandRegistry:
130
154
  InitCommand,
131
155
  TemplateCommand,
132
156
  TodoCommand,
157
+ CommandReloadCommand,
133
158
  PlanCommand, # Add plan command
134
159
  ExitPlanCommand, # Add exit plan command
160
+ QuickStartCommand, # Add quickstart command
135
161
  ]
136
162
 
137
163
  # Register all discovered commands
138
164
  for command_class in command_classes:
139
165
  self.register_command_class(command_class) # type: ignore[arg-type]
140
166
 
141
- self._discovered = True
167
+ def _discover_slash_commands(self) -> None:
168
+ """Discover and register markdown-based slash commands."""
169
+ try:
170
+ if not self._slash_loader:
171
+ # Dynamic import to avoid circular dependency
172
+ from .slash.loader import SlashCommandLoader
173
+
174
+ project_root = Path.cwd()
175
+ user_home = Path.home()
176
+ self._slash_loader = SlashCommandLoader(project_root, user_home)
177
+
178
+ self._slash_discovery_result = self._slash_loader.discover_commands()
179
+
180
+ # Register all discovered commands
181
+ registered_count = 0
182
+ for command_name, command in self._slash_discovery_result.commands.items():
183
+ try:
184
+ self.register(command)
185
+ registered_count += 1
186
+ except Exception as e:
187
+ logger.warning(f"Failed to register slash command '{command_name}': {e}")
188
+
189
+ # Log discovery summary
190
+ if registered_count > 0:
191
+ logger.info(f"Registered {registered_count} slash commands")
192
+
193
+ # Report conflicts and errors
194
+ self._report_slash_command_issues()
195
+
196
+ except Exception as e:
197
+ logger.error(f"Slash command discovery failed: {e}")
198
+ # Don't fail the entire system if slash commands can't load
199
+
200
+ def _report_slash_command_issues(self) -> None:
201
+ """Report conflicts and errors from slash command discovery."""
202
+ if not self._slash_discovery_result:
203
+ return
204
+
205
+ # Report conflicts
206
+ if self._slash_discovery_result.conflicts:
207
+ logger.info(f"Resolved {len(self._slash_discovery_result.conflicts)} command conflicts")
208
+ for cmd_name, conflicting_paths in self._slash_discovery_result.conflicts:
209
+ logger.debug(
210
+ f" {cmd_name}: {conflicting_paths[1]} overrode {conflicting_paths[0]}"
211
+ )
212
+
213
+ # Report errors (limit to first 3 for brevity)
214
+ if self._slash_discovery_result.errors:
215
+ logger.warning(
216
+ f"Failed to load {len(self._slash_discovery_result.errors)} command files"
217
+ )
218
+ for path, error in self._slash_discovery_result.errors[:3]:
219
+ logger.warning(f" {path}: {str(error)[:100]}...")
142
220
 
143
221
  def register_all_default_commands(self) -> None:
144
222
  """Register all default commands (backward compatibility)."""
@@ -269,3 +347,55 @@ class CommandRegistry:
269
347
  """Get all commands organized by category."""
270
348
  self.discover_commands()
271
349
  return self._categories.copy()
350
+
351
+ # Slash command utilities
352
+ def get_slash_commands(self) -> Dict[str, Command]:
353
+ """Get all registered slash commands."""
354
+ slash_commands = {}
355
+ for name, command in self._commands.items():
356
+ # Duck typing for SlashCommand - check if it has file_path attribute
357
+ if hasattr(command, "file_path") and hasattr(command, "namespace"):
358
+ slash_commands[name] = command
359
+ return slash_commands
360
+
361
+ def reload_slash_commands(self) -> int:
362
+ """Reload slash commands (useful for development)."""
363
+ if not self._slash_enabled:
364
+ return 0
365
+
366
+ slash_commands = self.get_slash_commands()
367
+ for cmd_name in list(slash_commands.keys()):
368
+ if cmd_name in self._commands:
369
+ del self._commands[cmd_name]
370
+ # Also remove from category
371
+ for category_commands in self._categories.values():
372
+ category_commands[:] = [
373
+ cmd
374
+ for cmd in category_commands
375
+ if not (hasattr(cmd, "file_path") and cmd.name == cmd_name)
376
+ ]
377
+
378
+ # Rediscover slash commands
379
+ self._slash_loader = None
380
+ self._slash_discovery_result = None
381
+ self._discover_slash_commands()
382
+
383
+ return len(self.get_slash_commands())
384
+
385
+ def enable_slash_commands(self, enabled: bool = True) -> None:
386
+ """Enable or disable slash command discovery."""
387
+ self._slash_enabled = enabled
388
+
389
+ def get_slash_command_stats(self) -> Dict[str, Any]:
390
+ """Get detailed statistics about slash command discovery."""
391
+ if not self._slash_discovery_result:
392
+ return {"enabled": self._slash_enabled, "discovered": False}
393
+
394
+ return {
395
+ "enabled": self._slash_enabled,
396
+ "discovered": True,
397
+ "stats": self._slash_discovery_result.stats,
398
+ "conflicts": len(self._slash_discovery_result.conflicts),
399
+ "errors": len(self._slash_discovery_result.errors),
400
+ "registered_commands": len(self.get_slash_commands()),
401
+ }
@@ -0,0 +1,32 @@
1
+ """Slash command system for TunaCode.
2
+
3
+ This module provides extensible markdown-based custom commands that can be
4
+ created by users and shared across teams.
5
+ """
6
+
7
+ # Only export the main classes that external code needs
8
+ from .types import (
9
+ CommandDiscoveryResult,
10
+ CommandSource,
11
+ ContextInjectionResult,
12
+ SecurityLevel,
13
+ SecurityViolation,
14
+ SlashCommandMetadata,
15
+ ValidationResult,
16
+ )
17
+
18
+ __all__ = [
19
+ "CommandSource",
20
+ "SlashCommandMetadata",
21
+ "CommandDiscoveryResult",
22
+ "ContextInjectionResult",
23
+ "SecurityLevel",
24
+ "SecurityViolation",
25
+ "ValidationResult",
26
+ ]
27
+
28
+ # Other classes can be imported directly when needed:
29
+ # from .command import SlashCommand
30
+ # from .loader import SlashCommandLoader
31
+ # from .processor import MarkdownTemplateProcessor
32
+ # from .validator import CommandValidator
@@ -0,0 +1,157 @@
1
+ """SlashCommand implementation for markdown-based commands."""
2
+
3
+ import logging
4
+ from pathlib import Path
5
+ from typing import TYPE_CHECKING, List, Optional
6
+
7
+ from ..base import Command, CommandCategory
8
+ from .processor import MarkdownTemplateProcessor
9
+ from .types import SlashCommandMetadata
10
+
11
+ if TYPE_CHECKING:
12
+ from ....types import CommandArgs, CommandContext, CommandResult
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class SlashCommand(Command):
18
+ """Markdown-based slash command implementation."""
19
+
20
+ def __init__(self, file_path: Path, namespace: str, command_parts: List[str]):
21
+ self.file_path = file_path
22
+ self.namespace = namespace # "project" or "user"
23
+ self.command_parts = command_parts # ["test", "unit"] for test:unit
24
+ self._metadata: Optional[SlashCommandMetadata] = None
25
+ self._content: Optional[str] = None
26
+ self._loaded = False
27
+
28
+ def _lazy_load(self) -> None:
29
+ """Load content and metadata on first access."""
30
+ if self._loaded:
31
+ return
32
+
33
+ try:
34
+ content = self.file_path.read_text(encoding="utf-8")
35
+ processor = MarkdownTemplateProcessor()
36
+ frontmatter, markdown = processor.parse_frontmatter(content)
37
+
38
+ self._content = markdown
39
+ self._metadata = SlashCommandMetadata(
40
+ description=frontmatter.get("description", "Custom command")
41
+ if frontmatter
42
+ else "Custom command",
43
+ allowed_tools=frontmatter.get("allowed-tools") if frontmatter else None,
44
+ timeout=frontmatter.get("timeout") if frontmatter else None,
45
+ parameters=frontmatter.get("parameters", {}) if frontmatter else {},
46
+ )
47
+ self._loaded = True
48
+
49
+ except Exception as e:
50
+ logger.error(f"Failed to load slash command {self.file_path}: {e}")
51
+ self._metadata = SlashCommandMetadata(description=f"Error loading command: {str(e)}")
52
+ self._content = ""
53
+ self._loaded = True
54
+
55
+ @property
56
+ def name(self) -> str:
57
+ """The primary name of the command."""
58
+ return f"{self.namespace}:{':'.join(self.command_parts)}"
59
+
60
+ @property
61
+ def aliases(self) -> List[str]:
62
+ """Alternative names/aliases for the command."""
63
+ return [f"/{self.name}"] # Slash prefix for invocation
64
+
65
+ @property
66
+ def description(self) -> str:
67
+ """Description of what the command does."""
68
+ self._lazy_load()
69
+ return self._metadata.description if self._metadata else "Custom command"
70
+
71
+ @property
72
+ def category(self) -> CommandCategory:
73
+ """Category this command belongs to."""
74
+ return CommandCategory.SYSTEM # Could be made configurable
75
+
76
+ async def execute(self, args: "CommandArgs", context: "CommandContext") -> "CommandResult":
77
+ """Execute the slash command."""
78
+ self._lazy_load()
79
+
80
+ if not self._content:
81
+ return "Error: Could not load command content"
82
+
83
+ # Process template with context injection
84
+ max_context_size = 100_000
85
+ max_files = 50
86
+ if self._metadata and self._metadata.parameters:
87
+ max_context_size = int(self._metadata.parameters.get("max_context_size", 100_000))
88
+ max_files = int(self._metadata.parameters.get("max_files", 50))
89
+
90
+ processor = MarkdownTemplateProcessor(
91
+ max_context_size=max_context_size, max_files=max_files
92
+ )
93
+
94
+ injection_result = processor.process_template_with_context(self._content, args, context)
95
+
96
+ # Show warnings if any (import ui dynamically to avoid circular imports)
97
+ if injection_result.warnings:
98
+ try:
99
+ from ....ui import console as ui
100
+
101
+ for warning in injection_result.warnings:
102
+ await ui.warning(f"Context injection: {warning}")
103
+ except ImportError:
104
+ # Fallback to logging if UI not available
105
+ for warning in injection_result.warnings:
106
+ logger.warning(f"Context injection: {warning}")
107
+
108
+ # Show context stats for debugging (if enabled)
109
+ try:
110
+ if context.state_manager.session.show_thoughts:
111
+ from ....ui import console as ui
112
+
113
+ await ui.muted(
114
+ f"Context: {len(injection_result.included_files)} files, "
115
+ f"{injection_result.total_size} chars, "
116
+ f"{len(injection_result.executed_commands)} commands"
117
+ )
118
+ except (ImportError, AttributeError):
119
+ pass # Skip context stats if not available
120
+
121
+ # Apply tool restrictions if specified
122
+ if self._metadata and self._metadata.allowed_tools:
123
+ # Set tool handler restrictions similar to template system
124
+ if (
125
+ hasattr(context.state_manager, "tool_handler")
126
+ and context.state_manager.tool_handler
127
+ ):
128
+ # Store current restrictions and apply new ones
129
+ original_restrictions = getattr(
130
+ context.state_manager.tool_handler, "allowed_tools", None
131
+ )
132
+ context.state_manager.tool_handler.allowed_tools = self._metadata.allowed_tools
133
+
134
+ try:
135
+ # Execute with restrictions
136
+ if context.process_request:
137
+ result = await context.process_request(
138
+ injection_result.processed_content, context.state_manager, True
139
+ )
140
+ return result
141
+ else:
142
+ return "Error: No process_request callback available"
143
+ finally:
144
+ # Restore original restrictions
145
+ context.state_manager.tool_handler.allowed_tools = original_restrictions
146
+ else:
147
+ # Execute without restrictions
148
+ if context.process_request:
149
+ result = await context.process_request(
150
+ injection_result.processed_content, context.state_manager, True
151
+ )
152
+ return result
153
+ else:
154
+ return "Error: No process_request callback available"
155
+
156
+ # Default return if no metadata
157
+ return "Command executed"
@@ -0,0 +1,134 @@
1
+ """SlashCommandLoader for discovering and loading markdown-based commands."""
2
+
3
+ import logging
4
+ from pathlib import Path
5
+ from typing import Any, Dict, List, Tuple
6
+
7
+ from .command import SlashCommand
8
+ from .types import CommandDiscoveryResult, CommandSource
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class SlashCommandLoader:
14
+ """Discovers and loads markdown-based slash commands with precedence rules."""
15
+
16
+ def __init__(self, project_root: Path, user_home: Path):
17
+ self.project_root = project_root
18
+ self.user_home = user_home
19
+ self.directories = self._build_directory_list()
20
+ self._cache: Dict[str, SlashCommand] = {}
21
+
22
+ def _build_directory_list(self) -> List[Tuple[Path, CommandSource, str]]:
23
+ """Build prioritized directory list with sources and namespaces."""
24
+ return [
25
+ (
26
+ self.project_root / ".tunacode" / "commands",
27
+ CommandSource.PROJECT_TUNACODE,
28
+ "project",
29
+ ),
30
+ (self.project_root / ".claude" / "commands", CommandSource.PROJECT_CLAUDE, "project"),
31
+ (self.user_home / ".tunacode" / "commands", CommandSource.USER_TUNACODE, "user"),
32
+ (self.user_home / ".claude" / "commands", CommandSource.USER_CLAUDE, "user"),
33
+ ]
34
+
35
+ def discover_commands(self) -> CommandDiscoveryResult:
36
+ """Main discovery method with conflict resolution."""
37
+ all_commands: Dict[str, Any] = {}
38
+ conflicts = []
39
+ errors = []
40
+ stats = {"scanned_dirs": 0, "found_files": 0, "loaded_commands": 0}
41
+
42
+ for directory, source, namespace in self.directories:
43
+ if not directory.exists():
44
+ continue
45
+
46
+ stats["scanned_dirs"] += 1
47
+
48
+ try:
49
+ dir_commands = self._scan_directory(directory, source, namespace)
50
+ stats["found_files"] += len(dir_commands)
51
+
52
+ # Handle conflicts with precedence
53
+ for cmd_name, cmd in dir_commands.items():
54
+ if cmd_name in all_commands:
55
+ existing_cmd = all_commands[cmd_name]
56
+ # Lower source value = higher priority
57
+ if (
58
+ source.value < existing_cmd._metadata.source.value
59
+ if existing_cmd._metadata
60
+ else float("inf")
61
+ ):
62
+ conflicts.append((cmd_name, [existing_cmd.file_path, cmd.file_path]))
63
+ all_commands[cmd_name] = cmd
64
+ logger.info(f"Command '{cmd_name}' overridden by {source.name}")
65
+ else:
66
+ all_commands[cmd_name] = cmd
67
+
68
+ stats["loaded_commands"] += len(
69
+ [c for c in dir_commands.values() if c.name in all_commands]
70
+ )
71
+
72
+ except Exception as e:
73
+ errors.append((directory, e))
74
+ logger.error(f"Error scanning {directory}: {e}")
75
+
76
+ logger.info(
77
+ f"Discovered {len(all_commands)} slash commands from {stats['scanned_dirs']} directories"
78
+ )
79
+ return CommandDiscoveryResult(all_commands, conflicts, errors, stats)
80
+
81
+ def _scan_directory(
82
+ self, directory: Path, source: CommandSource, namespace: str
83
+ ) -> Dict[str, SlashCommand]:
84
+ """Recursively scan directory for markdown files."""
85
+ commands = {}
86
+
87
+ for md_file in directory.rglob("*.md"):
88
+ try:
89
+ # Calculate command parts from file path
90
+ relative_path = md_file.relative_to(directory)
91
+ command_parts = list(relative_path.parts[:-1]) # Directories
92
+ command_parts.append(relative_path.stem) # Filename without .md
93
+
94
+ # Create command
95
+ command = SlashCommand(md_file, namespace, command_parts)
96
+ # Set source in metadata (will be used for precedence)
97
+ if not hasattr(command, "_metadata") or command._metadata is None:
98
+ from .types import SlashCommandMetadata
99
+
100
+ command._metadata = SlashCommandMetadata(description="", source=source)
101
+ else:
102
+ command._metadata.source = source
103
+
104
+ command_name = command.name
105
+ commands[command_name] = command
106
+
107
+ except Exception as e:
108
+ logger.warning(f"Failed to load command from {md_file}: {e}")
109
+
110
+ return commands
111
+
112
+ def reload_commands(self) -> CommandDiscoveryResult:
113
+ """Reload all commands (useful for development)."""
114
+ self._cache.clear()
115
+ return self.discover_commands()
116
+
117
+ def get_command_by_path(self, file_path: Path) -> SlashCommand:
118
+ """Get command for a specific file path."""
119
+ # Determine namespace and command parts from path
120
+ for directory, source, namespace in self.directories:
121
+ try:
122
+ if file_path.is_relative_to(directory):
123
+ relative_path = file_path.relative_to(directory)
124
+ command_parts = list(relative_path.parts[:-1])
125
+ command_parts.append(relative_path.stem)
126
+
127
+ command = SlashCommand(file_path, namespace, command_parts)
128
+ return command
129
+ except (ValueError, AttributeError):
130
+ continue
131
+
132
+ # Fallback to project namespace if path not in known directories
133
+ parts = file_path.stem.split("_") if file_path.stem else ["unknown"]
134
+ return SlashCommand(file_path, "project", parts)