tunacode-cli 0.0.55__py3-none-any.whl → 0.0.78.6__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 (114) hide show
  1. tunacode/cli/commands/__init__.py +2 -2
  2. tunacode/cli/commands/implementations/__init__.py +2 -3
  3. tunacode/cli/commands/implementations/command_reload.py +48 -0
  4. tunacode/cli/commands/implementations/debug.py +2 -2
  5. tunacode/cli/commands/implementations/development.py +10 -8
  6. tunacode/cli/commands/implementations/model.py +357 -29
  7. tunacode/cli/commands/implementations/quickstart.py +43 -0
  8. tunacode/cli/commands/implementations/system.py +96 -3
  9. tunacode/cli/commands/implementations/template.py +0 -2
  10. tunacode/cli/commands/registry.py +139 -5
  11. tunacode/cli/commands/slash/__init__.py +32 -0
  12. tunacode/cli/commands/slash/command.py +157 -0
  13. tunacode/cli/commands/slash/loader.py +135 -0
  14. tunacode/cli/commands/slash/processor.py +294 -0
  15. tunacode/cli/commands/slash/types.py +93 -0
  16. tunacode/cli/commands/slash/validator.py +400 -0
  17. tunacode/cli/main.py +23 -2
  18. tunacode/cli/repl.py +217 -190
  19. tunacode/cli/repl_components/command_parser.py +38 -4
  20. tunacode/cli/repl_components/error_recovery.py +85 -4
  21. tunacode/cli/repl_components/output_display.py +12 -1
  22. tunacode/cli/repl_components/tool_executor.py +1 -1
  23. tunacode/configuration/defaults.py +12 -3
  24. tunacode/configuration/key_descriptions.py +284 -0
  25. tunacode/configuration/settings.py +0 -1
  26. tunacode/constants.py +12 -40
  27. tunacode/core/agents/__init__.py +43 -2
  28. tunacode/core/agents/agent_components/__init__.py +7 -0
  29. tunacode/core/agents/agent_components/agent_config.py +249 -55
  30. tunacode/core/agents/agent_components/agent_helpers.py +43 -13
  31. tunacode/core/agents/agent_components/node_processor.py +179 -139
  32. tunacode/core/agents/agent_components/response_state.py +123 -6
  33. tunacode/core/agents/agent_components/state_transition.py +116 -0
  34. tunacode/core/agents/agent_components/streaming.py +296 -0
  35. tunacode/core/agents/agent_components/task_completion.py +19 -6
  36. tunacode/core/agents/agent_components/tool_buffer.py +21 -1
  37. tunacode/core/agents/agent_components/tool_executor.py +10 -0
  38. tunacode/core/agents/main.py +522 -370
  39. tunacode/core/agents/main_legact.py +538 -0
  40. tunacode/core/agents/prompts.py +66 -0
  41. tunacode/core/agents/utils.py +29 -121
  42. tunacode/core/code_index.py +83 -29
  43. tunacode/core/setup/__init__.py +0 -2
  44. tunacode/core/setup/config_setup.py +110 -20
  45. tunacode/core/setup/config_wizard.py +230 -0
  46. tunacode/core/setup/coordinator.py +14 -5
  47. tunacode/core/state.py +16 -20
  48. tunacode/core/token_usage/usage_tracker.py +5 -3
  49. tunacode/core/tool_authorization.py +352 -0
  50. tunacode/core/tool_handler.py +67 -40
  51. tunacode/exceptions.py +119 -5
  52. tunacode/prompts/system.xml +751 -0
  53. tunacode/services/mcp.py +125 -7
  54. tunacode/setup.py +5 -25
  55. tunacode/tools/base.py +163 -0
  56. tunacode/tools/bash.py +110 -1
  57. tunacode/tools/glob.py +332 -34
  58. tunacode/tools/grep.py +179 -82
  59. tunacode/tools/grep_components/result_formatter.py +98 -4
  60. tunacode/tools/list_dir.py +132 -2
  61. tunacode/tools/prompts/bash_prompt.xml +72 -0
  62. tunacode/tools/prompts/glob_prompt.xml +45 -0
  63. tunacode/tools/prompts/grep_prompt.xml +98 -0
  64. tunacode/tools/prompts/list_dir_prompt.xml +31 -0
  65. tunacode/tools/prompts/react_prompt.xml +23 -0
  66. tunacode/tools/prompts/read_file_prompt.xml +54 -0
  67. tunacode/tools/prompts/run_command_prompt.xml +64 -0
  68. tunacode/tools/prompts/update_file_prompt.xml +53 -0
  69. tunacode/tools/prompts/write_file_prompt.xml +37 -0
  70. tunacode/tools/react.py +153 -0
  71. tunacode/tools/read_file.py +91 -0
  72. tunacode/tools/run_command.py +114 -0
  73. tunacode/tools/schema_assembler.py +167 -0
  74. tunacode/tools/update_file.py +94 -0
  75. tunacode/tools/write_file.py +86 -0
  76. tunacode/tools/xml_helper.py +83 -0
  77. tunacode/tutorial/__init__.py +9 -0
  78. tunacode/tutorial/content.py +98 -0
  79. tunacode/tutorial/manager.py +182 -0
  80. tunacode/tutorial/steps.py +124 -0
  81. tunacode/types.py +20 -27
  82. tunacode/ui/completers.py +434 -50
  83. tunacode/ui/config_dashboard.py +585 -0
  84. tunacode/ui/console.py +63 -11
  85. tunacode/ui/input.py +20 -3
  86. tunacode/ui/keybindings.py +7 -4
  87. tunacode/ui/model_selector.py +395 -0
  88. tunacode/ui/output.py +40 -19
  89. tunacode/ui/panels.py +212 -43
  90. tunacode/ui/path_heuristics.py +91 -0
  91. tunacode/ui/prompt_manager.py +5 -1
  92. tunacode/ui/tool_ui.py +33 -10
  93. tunacode/utils/api_key_validation.py +93 -0
  94. tunacode/utils/config_comparator.py +340 -0
  95. tunacode/utils/json_utils.py +206 -0
  96. tunacode/utils/message_utils.py +14 -4
  97. tunacode/utils/models_registry.py +593 -0
  98. tunacode/utils/ripgrep.py +332 -9
  99. tunacode/utils/text_utils.py +18 -1
  100. tunacode/utils/user_configuration.py +45 -0
  101. tunacode_cli-0.0.78.6.dist-info/METADATA +260 -0
  102. tunacode_cli-0.0.78.6.dist-info/RECORD +158 -0
  103. {tunacode_cli-0.0.55.dist-info → tunacode_cli-0.0.78.6.dist-info}/WHEEL +1 -2
  104. tunacode/cli/commands/implementations/todo.py +0 -217
  105. tunacode/context.py +0 -71
  106. tunacode/core/setup/git_safety_setup.py +0 -182
  107. tunacode/prompts/system.md +0 -731
  108. tunacode/tools/read_file_async_poc.py +0 -196
  109. tunacode/tools/todo.py +0 -349
  110. tunacode_cli-0.0.55.dist-info/METADATA +0 -322
  111. tunacode_cli-0.0.55.dist-info/RECORD +0 -126
  112. tunacode_cli-0.0.55.dist-info/top_level.txt +0 -1
  113. {tunacode_cli-0.0.55.dist-info → tunacode_cli-0.0.78.6.dist-info}/entry_points.txt +0 -0
  114. {tunacode_cli-0.0.55.dist-info → tunacode_cli-0.0.78.6.dist-info}/licenses/LICENSE +0 -0
@@ -1,5 +1,6 @@
1
1
  """System-level commands for TunaCode CLI."""
2
2
 
3
+ import os
3
4
  import shutil
4
5
  import subprocess
5
6
  import sys
@@ -39,7 +40,7 @@ class ClearCommand(SimpleCommand):
39
40
 
40
41
  async def execute(self, args: List[str], context: CommandContext) -> None:
41
42
  # Patch any orphaned tool calls before clearing
42
- from tunacode.core.agents.main import patch_tool_messages
43
+ from tunacode.core.agents import patch_tool_messages
43
44
 
44
45
  patch_tool_messages("Conversation cleared", context.state_manager)
45
46
 
@@ -107,6 +108,56 @@ class UpdateCommand(SimpleCommand):
107
108
  except (subprocess.TimeoutExpired, subprocess.CalledProcessError):
108
109
  pass
109
110
 
111
+ # Check if installed via venv (from install script)
112
+ if not installation_method:
113
+ venv_dir = os.path.expanduser("~/.tunacode-venv")
114
+ venv_tunacode = os.path.join(venv_dir, "bin", "tunacode")
115
+ venv_python = os.path.join(venv_dir, "bin", "python")
116
+
117
+ if os.path.exists(venv_tunacode) and os.path.exists(venv_python):
118
+ # Try UV first if available (UV-created venvs don't have pip module)
119
+ if shutil.which("uv"):
120
+ try:
121
+ result = subprocess.run(
122
+ ["uv", "pip", "show", "--python", venv_python, "tunacode-cli"],
123
+ capture_output=True,
124
+ text=True,
125
+ timeout=10,
126
+ )
127
+ if result.returncode == 0:
128
+ installation_method = "venv"
129
+ except (subprocess.TimeoutExpired, subprocess.CalledProcessError):
130
+ pass
131
+
132
+ # Fall back to python -m pip for pip-created venvs
133
+ if not installation_method:
134
+ try:
135
+ result = subprocess.run(
136
+ [venv_python, "-m", "pip", "show", "tunacode-cli"],
137
+ capture_output=True,
138
+ text=True,
139
+ timeout=10,
140
+ )
141
+ if result.returncode == 0:
142
+ installation_method = "venv"
143
+ except (subprocess.TimeoutExpired, subprocess.CalledProcessError):
144
+ pass
145
+
146
+ # Check if installed via uv tool
147
+ if not installation_method:
148
+ if shutil.which("uv"):
149
+ try:
150
+ result = subprocess.run(
151
+ ["uv", "tool", "list"],
152
+ capture_output=True,
153
+ text=True,
154
+ timeout=10,
155
+ )
156
+ if result.returncode == 0 and "tunacode-cli" in result.stdout.lower():
157
+ installation_method = "uv_tool"
158
+ except (subprocess.TimeoutExpired, subprocess.CalledProcessError):
159
+ pass
160
+
110
161
  # Check if installed via pip
111
162
  if not installation_method:
112
163
  try:
@@ -124,8 +175,13 @@ class UpdateCommand(SimpleCommand):
124
175
  if not installation_method:
125
176
  await ui.error("Could not detect TunaCode installation method")
126
177
  await ui.muted("Manual update options:")
127
- await ui.muted(" pipx: pipx upgrade tunacode")
128
- await ui.muted(" pip: pip install --upgrade tunacode-cli")
178
+ await ui.muted(" pipx: pipx upgrade tunacode")
179
+ await ui.muted(" pip: pip install --upgrade tunacode-cli")
180
+ await ui.muted(" uv tool: uv tool upgrade tunacode-cli")
181
+ await ui.muted(
182
+ " venv: uv pip install --python ~/.tunacode-venv/bin/python "
183
+ "--upgrade tunacode-cli"
184
+ )
129
185
  return
130
186
 
131
187
  # Perform update based on detected method
@@ -138,6 +194,43 @@ class UpdateCommand(SimpleCommand):
138
194
  text=True,
139
195
  timeout=60,
140
196
  )
197
+ elif installation_method == "venv":
198
+ venv_dir = os.path.expanduser("~/.tunacode-venv")
199
+ venv_python = os.path.join(venv_dir, "bin", "python")
200
+
201
+ # Check if uv is available (same logic as install script)
202
+ if shutil.which("uv"):
203
+ await ui.info("Updating via UV in venv...")
204
+ result = subprocess.run(
205
+ [
206
+ "uv",
207
+ "pip",
208
+ "install",
209
+ "--python",
210
+ venv_python,
211
+ "--upgrade",
212
+ "tunacode-cli",
213
+ ],
214
+ capture_output=True,
215
+ text=True,
216
+ timeout=60,
217
+ )
218
+ else:
219
+ await ui.info("Updating via pip in venv...")
220
+ result = subprocess.run(
221
+ [venv_python, "-m", "pip", "install", "--upgrade", "tunacode-cli"],
222
+ capture_output=True,
223
+ text=True,
224
+ timeout=60,
225
+ )
226
+ elif installation_method == "uv_tool":
227
+ await ui.info("Updating via UV tool...")
228
+ result = subprocess.run(
229
+ ["uv", "tool", "upgrade", "tunacode-cli"],
230
+ capture_output=True,
231
+ text=True,
232
+ timeout=60,
233
+ )
141
234
  else: # pip
142
235
  await ui.info("Updating via pip...")
143
236
  result = subprocess.run(
@@ -121,8 +121,6 @@ class TemplateCommand(SimpleCommand):
121
121
  await ui.muted(' "allowed_tools": ["read_file", "grep", "list_dir", "run_command"]')
122
122
  await ui.muted("}")
123
123
 
124
- # TODO: Implement interactive creation when proper input handling is available
125
-
126
124
  async def _clear_template(self, context: CommandContext) -> None:
127
125
  """Clear the currently active template."""
128
126
  if hasattr(context.state_manager, "tool_handler") and context.state_manager.tool_handler:
@@ -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,
@@ -23,6 +26,7 @@ from .implementations.debug import (
23
26
  )
24
27
  from .implementations.development import BranchCommand, InitCommand
25
28
  from .implementations.model import ModelCommand
29
+ from .implementations.quickstart import QuickStartCommand
26
30
  from .implementations.system import (
27
31
  ClearCommand,
28
32
  HelpCommand,
@@ -31,9 +35,10 @@ from .implementations.system import (
31
35
  UpdateCommand,
32
36
  )
33
37
  from .implementations.template import TemplateCommand
34
- from .implementations.todo import TodoCommand
35
38
  from .template_shortcut import TemplateShortcutCommand
36
39
 
40
+ logger = logging.getLogger(__name__)
41
+
37
42
 
38
43
  @dataclass
39
44
  class CommandDependencies:
@@ -56,6 +61,8 @@ class CommandFactory:
56
61
  return CompactCommand(self.dependencies.process_request_callback)
57
62
  elif command_class == HelpCommand:
58
63
  return HelpCommand(self.dependencies.command_registry)
64
+ elif command_class == CommandReloadCommand:
65
+ return CommandReloadCommand(self.dependencies.command_registry)
59
66
 
60
67
  # Default creation for commands without dependencies
61
68
  return command_class()
@@ -79,6 +86,11 @@ class CommandRegistry:
79
86
  self._discovered = False
80
87
  self._shortcuts_loaded = False
81
88
 
89
+ # Slash command support
90
+ self._slash_loader: Optional[Any] = None # SlashCommandLoader
91
+ self._slash_discovery_result: Optional[Any] = None # CommandDiscoveryResult
92
+ self._slash_enabled: bool = True # Feature flag
93
+
82
94
  # Set registry reference in factory dependencies
83
95
  self._factory.update_dependencies(command_registry=self)
84
96
 
@@ -110,6 +122,17 @@ class CommandRegistry:
110
122
  if self._discovered:
111
123
  return
112
124
 
125
+ # Step 1: Discover built-in commands
126
+ self._discover_builtin_commands()
127
+
128
+ # Step 2: Discover slash commands (if enabled)
129
+ if self._slash_enabled:
130
+ self._discover_slash_commands()
131
+
132
+ self._discovered = True
133
+
134
+ def _discover_builtin_commands(self) -> None:
135
+ """Discover and register built-in command classes."""
113
136
  # List of all command classes to register
114
137
  command_classes = [
115
138
  YoloCommand,
@@ -128,14 +151,67 @@ class CommandRegistry:
128
151
  ModelCommand,
129
152
  InitCommand,
130
153
  TemplateCommand,
131
- TodoCommand,
154
+ CommandReloadCommand,
155
+ QuickStartCommand, # Add quickstart command
132
156
  ]
133
157
 
134
158
  # Register all discovered commands
135
159
  for command_class in command_classes:
136
160
  self.register_command_class(command_class) # type: ignore[arg-type]
137
161
 
138
- self._discovered = True
162
+ def _discover_slash_commands(self) -> None:
163
+ """Discover and register markdown-based slash commands."""
164
+ try:
165
+ if not self._slash_loader:
166
+ # Dynamic import to avoid circular dependency
167
+ from .slash.loader import SlashCommandLoader
168
+
169
+ project_root = Path.cwd()
170
+ user_home = Path.home()
171
+ self._slash_loader = SlashCommandLoader(project_root, user_home)
172
+
173
+ self._slash_discovery_result = self._slash_loader.discover_commands()
174
+
175
+ # Register all discovered commands
176
+ registered_count = 0
177
+ for command_name, command in self._slash_discovery_result.commands.items():
178
+ try:
179
+ self.register(command)
180
+ registered_count += 1
181
+ except Exception as e:
182
+ logger.warning(f"Failed to register slash command '{command_name}': {e}")
183
+
184
+ # Log discovery summary
185
+ if registered_count > 0:
186
+ logger.info(f"Registered {registered_count} slash commands")
187
+
188
+ # Report conflicts and errors
189
+ self._report_slash_command_issues()
190
+
191
+ except Exception as e:
192
+ logger.error(f"Slash command discovery failed: {e}")
193
+ # Don't fail the entire system if slash commands can't load
194
+
195
+ def _report_slash_command_issues(self) -> None:
196
+ """Report conflicts and errors from slash command discovery."""
197
+ if not self._slash_discovery_result:
198
+ return
199
+
200
+ # Report conflicts
201
+ if self._slash_discovery_result.conflicts:
202
+ logger.info(f"Resolved {len(self._slash_discovery_result.conflicts)} command conflicts")
203
+ for cmd_name, conflicting_paths in self._slash_discovery_result.conflicts:
204
+ logger.debug(
205
+ f" {cmd_name}: {conflicting_paths[1]} overrode {conflicting_paths[0]}"
206
+ )
207
+
208
+ # Report errors (limit to first 3 for brevity)
209
+ if self._slash_discovery_result.errors:
210
+ logger.warning(
211
+ f"Failed to load {len(self._slash_discovery_result.errors)} command files"
212
+ )
213
+ for path, error in self._slash_discovery_result.errors[:3]:
214
+ logger.warning(f" {path}: {str(error)[:100]}...")
139
215
 
140
216
  def register_all_default_commands(self) -> None:
141
217
  """Register all default commands (backward compatibility)."""
@@ -222,7 +298,7 @@ class CommandRegistry:
222
298
 
223
299
  def find_matching_commands(self, partial_command: str) -> List[str]:
224
300
  """
225
- Find all commands that start with the given partial command.
301
+ Find commands matching the given partial command.
226
302
 
227
303
  Args:
228
304
  partial_command: The partial command to match
@@ -232,7 +308,13 @@ class CommandRegistry:
232
308
  """
233
309
  self.discover_commands()
234
310
  partial = partial_command.lower()
235
- return [cmd for cmd in self._commands.keys() if cmd.startswith(partial)]
311
+
312
+ # CLAUDE_ANCHOR[key=86cc1a41] Prefix-only command matching after removing fuzzy fallback
313
+ prefix_matches = [cmd for cmd in self._commands.keys() if cmd.startswith(partial)]
314
+ if prefix_matches:
315
+ return prefix_matches
316
+
317
+ return []
236
318
 
237
319
  def is_command(self, text: str) -> bool:
238
320
  """Check if text starts with a registered command (supports partial matching)."""
@@ -266,3 +348,55 @@ class CommandRegistry:
266
348
  """Get all commands organized by category."""
267
349
  self.discover_commands()
268
350
  return self._categories.copy()
351
+
352
+ # Slash command utilities
353
+ def get_slash_commands(self) -> Dict[str, Command]:
354
+ """Get all registered slash commands."""
355
+ slash_commands = {}
356
+ for name, command in self._commands.items():
357
+ # Duck typing for SlashCommand - check if it has file_path attribute
358
+ if hasattr(command, "file_path") and hasattr(command, "namespace"):
359
+ slash_commands[name] = command
360
+ return slash_commands
361
+
362
+ def reload_slash_commands(self) -> int:
363
+ """Reload slash commands (useful for development)."""
364
+ if not self._slash_enabled:
365
+ return 0
366
+
367
+ slash_commands = self.get_slash_commands()
368
+ for cmd_name in list(slash_commands.keys()):
369
+ if cmd_name in self._commands:
370
+ del self._commands[cmd_name]
371
+ # Also remove from category
372
+ for category_commands in self._categories.values():
373
+ category_commands[:] = [
374
+ cmd
375
+ for cmd in category_commands
376
+ if not (hasattr(cmd, "file_path") and cmd.name == cmd_name)
377
+ ]
378
+
379
+ # Rediscover slash commands
380
+ self._slash_loader = None
381
+ self._slash_discovery_result = None
382
+ self._discover_slash_commands()
383
+
384
+ return len(self.get_slash_commands())
385
+
386
+ def enable_slash_commands(self, enabled: bool = True) -> None:
387
+ """Enable or disable slash command discovery."""
388
+ self._slash_enabled = enabled
389
+
390
+ def get_slash_command_stats(self) -> Dict[str, Any]:
391
+ """Get detailed statistics about slash command discovery."""
392
+ if not self._slash_discovery_result:
393
+ return {"enabled": self._slash_enabled, "discovered": False}
394
+
395
+ return {
396
+ "enabled": self._slash_enabled,
397
+ "discovered": True,
398
+ "stats": self._slash_discovery_result.stats,
399
+ "conflicts": len(self._slash_discovery_result.conflicts),
400
+ "errors": len(self._slash_discovery_result.errors),
401
+ "registered_commands": len(self.get_slash_commands()),
402
+ }
@@ -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,135 @@
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 "
78
+ f"{stats['scanned_dirs']} directories"
79
+ )
80
+ return CommandDiscoveryResult(all_commands, conflicts, errors, stats)
81
+
82
+ def _scan_directory(
83
+ self, directory: Path, source: CommandSource, namespace: str
84
+ ) -> Dict[str, SlashCommand]:
85
+ """Recursively scan directory for markdown files."""
86
+ commands = {}
87
+
88
+ for md_file in directory.rglob("*.md"):
89
+ try:
90
+ # Calculate command parts from file path
91
+ relative_path = md_file.relative_to(directory)
92
+ command_parts = list(relative_path.parts[:-1]) # Directories
93
+ command_parts.append(relative_path.stem) # Filename without .md
94
+
95
+ # Create command
96
+ command = SlashCommand(md_file, namespace, command_parts)
97
+ # Set source in metadata (will be used for precedence)
98
+ if not hasattr(command, "_metadata") or command._metadata is None:
99
+ from .types import SlashCommandMetadata
100
+
101
+ command._metadata = SlashCommandMetadata(description="", source=source)
102
+ else:
103
+ command._metadata.source = source
104
+
105
+ command_name = command.name
106
+ commands[command_name] = command
107
+
108
+ except Exception as e:
109
+ logger.warning(f"Failed to load command from {md_file}: {e}")
110
+
111
+ return commands
112
+
113
+ def reload_commands(self) -> CommandDiscoveryResult:
114
+ """Reload all commands (useful for development)."""
115
+ self._cache.clear()
116
+ return self.discover_commands()
117
+
118
+ def get_command_by_path(self, file_path: Path) -> SlashCommand:
119
+ """Get command for a specific file path."""
120
+ # Determine namespace and command parts from path
121
+ for directory, source, namespace in self.directories:
122
+ try:
123
+ if file_path.is_relative_to(directory):
124
+ relative_path = file_path.relative_to(directory)
125
+ command_parts = list(relative_path.parts[:-1])
126
+ command_parts.append(relative_path.stem)
127
+
128
+ command = SlashCommand(file_path, namespace, command_parts)
129
+ return command
130
+ except (ValueError, AttributeError):
131
+ continue
132
+
133
+ # Fallback to project namespace if path not in known directories
134
+ parts = file_path.stem.split("_") if file_path.stem else ["unknown"]
135
+ return SlashCommand(file_path, "project", parts)