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.
- tunacode/cli/commands/__init__.py +2 -2
- tunacode/cli/commands/implementations/__init__.py +2 -3
- tunacode/cli/commands/implementations/command_reload.py +48 -0
- tunacode/cli/commands/implementations/debug.py +2 -2
- tunacode/cli/commands/implementations/development.py +10 -8
- tunacode/cli/commands/implementations/model.py +357 -29
- tunacode/cli/commands/implementations/quickstart.py +43 -0
- tunacode/cli/commands/implementations/system.py +96 -3
- tunacode/cli/commands/implementations/template.py +0 -2
- tunacode/cli/commands/registry.py +139 -5
- tunacode/cli/commands/slash/__init__.py +32 -0
- tunacode/cli/commands/slash/command.py +157 -0
- tunacode/cli/commands/slash/loader.py +135 -0
- tunacode/cli/commands/slash/processor.py +294 -0
- tunacode/cli/commands/slash/types.py +93 -0
- tunacode/cli/commands/slash/validator.py +400 -0
- tunacode/cli/main.py +23 -2
- tunacode/cli/repl.py +217 -190
- tunacode/cli/repl_components/command_parser.py +38 -4
- tunacode/cli/repl_components/error_recovery.py +85 -4
- tunacode/cli/repl_components/output_display.py +12 -1
- tunacode/cli/repl_components/tool_executor.py +1 -1
- tunacode/configuration/defaults.py +12 -3
- tunacode/configuration/key_descriptions.py +284 -0
- tunacode/configuration/settings.py +0 -1
- tunacode/constants.py +12 -40
- tunacode/core/agents/__init__.py +43 -2
- tunacode/core/agents/agent_components/__init__.py +7 -0
- tunacode/core/agents/agent_components/agent_config.py +249 -55
- tunacode/core/agents/agent_components/agent_helpers.py +43 -13
- tunacode/core/agents/agent_components/node_processor.py +179 -139
- tunacode/core/agents/agent_components/response_state.py +123 -6
- tunacode/core/agents/agent_components/state_transition.py +116 -0
- tunacode/core/agents/agent_components/streaming.py +296 -0
- tunacode/core/agents/agent_components/task_completion.py +19 -6
- tunacode/core/agents/agent_components/tool_buffer.py +21 -1
- tunacode/core/agents/agent_components/tool_executor.py +10 -0
- tunacode/core/agents/main.py +522 -370
- tunacode/core/agents/main_legact.py +538 -0
- tunacode/core/agents/prompts.py +66 -0
- tunacode/core/agents/utils.py +29 -121
- tunacode/core/code_index.py +83 -29
- tunacode/core/setup/__init__.py +0 -2
- tunacode/core/setup/config_setup.py +110 -20
- tunacode/core/setup/config_wizard.py +230 -0
- tunacode/core/setup/coordinator.py +14 -5
- tunacode/core/state.py +16 -20
- tunacode/core/token_usage/usage_tracker.py +5 -3
- tunacode/core/tool_authorization.py +352 -0
- tunacode/core/tool_handler.py +67 -40
- tunacode/exceptions.py +119 -5
- tunacode/prompts/system.xml +751 -0
- tunacode/services/mcp.py +125 -7
- tunacode/setup.py +5 -25
- tunacode/tools/base.py +163 -0
- tunacode/tools/bash.py +110 -1
- tunacode/tools/glob.py +332 -34
- tunacode/tools/grep.py +179 -82
- tunacode/tools/grep_components/result_formatter.py +98 -4
- tunacode/tools/list_dir.py +132 -2
- tunacode/tools/prompts/bash_prompt.xml +72 -0
- tunacode/tools/prompts/glob_prompt.xml +45 -0
- tunacode/tools/prompts/grep_prompt.xml +98 -0
- tunacode/tools/prompts/list_dir_prompt.xml +31 -0
- tunacode/tools/prompts/react_prompt.xml +23 -0
- tunacode/tools/prompts/read_file_prompt.xml +54 -0
- tunacode/tools/prompts/run_command_prompt.xml +64 -0
- tunacode/tools/prompts/update_file_prompt.xml +53 -0
- tunacode/tools/prompts/write_file_prompt.xml +37 -0
- tunacode/tools/react.py +153 -0
- tunacode/tools/read_file.py +91 -0
- tunacode/tools/run_command.py +114 -0
- tunacode/tools/schema_assembler.py +167 -0
- tunacode/tools/update_file.py +94 -0
- tunacode/tools/write_file.py +86 -0
- tunacode/tools/xml_helper.py +83 -0
- tunacode/tutorial/__init__.py +9 -0
- tunacode/tutorial/content.py +98 -0
- tunacode/tutorial/manager.py +182 -0
- tunacode/tutorial/steps.py +124 -0
- tunacode/types.py +20 -27
- tunacode/ui/completers.py +434 -50
- tunacode/ui/config_dashboard.py +585 -0
- tunacode/ui/console.py +63 -11
- tunacode/ui/input.py +20 -3
- tunacode/ui/keybindings.py +7 -4
- tunacode/ui/model_selector.py +395 -0
- tunacode/ui/output.py +40 -19
- tunacode/ui/panels.py +212 -43
- tunacode/ui/path_heuristics.py +91 -0
- tunacode/ui/prompt_manager.py +5 -1
- tunacode/ui/tool_ui.py +33 -10
- tunacode/utils/api_key_validation.py +93 -0
- tunacode/utils/config_comparator.py +340 -0
- tunacode/utils/json_utils.py +206 -0
- tunacode/utils/message_utils.py +14 -4
- tunacode/utils/models_registry.py +593 -0
- tunacode/utils/ripgrep.py +332 -9
- tunacode/utils/text_utils.py +18 -1
- tunacode/utils/user_configuration.py +45 -0
- tunacode_cli-0.0.78.6.dist-info/METADATA +260 -0
- tunacode_cli-0.0.78.6.dist-info/RECORD +158 -0
- {tunacode_cli-0.0.55.dist-info → tunacode_cli-0.0.78.6.dist-info}/WHEEL +1 -2
- tunacode/cli/commands/implementations/todo.py +0 -217
- tunacode/context.py +0 -71
- tunacode/core/setup/git_safety_setup.py +0 -182
- tunacode/prompts/system.md +0 -731
- tunacode/tools/read_file_async_poc.py +0 -196
- tunacode/tools/todo.py +0 -349
- tunacode_cli-0.0.55.dist-info/METADATA +0 -322
- tunacode_cli-0.0.55.dist-info/RECORD +0 -126
- tunacode_cli-0.0.55.dist-info/top_level.txt +0 -1
- {tunacode_cli-0.0.55.dist-info → tunacode_cli-0.0.78.6.dist-info}/entry_points.txt +0 -0
- {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
|
|
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:
|
|
128
|
-
await ui.muted(" pip:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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)
|