tunacode-cli 0.0.67__py3-none-any.whl → 0.0.68__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 -0
- tunacode/cli/commands/implementations/__init__.py +2 -0
- tunacode/cli/commands/implementations/command_reload.py +48 -0
- tunacode/cli/commands/implementations/quickstart.py +43 -0
- tunacode/cli/commands/registry.py +131 -1
- tunacode/cli/commands/slash/__init__.py +32 -0
- tunacode/cli/commands/slash/command.py +157 -0
- tunacode/cli/commands/slash/loader.py +134 -0
- tunacode/cli/commands/slash/processor.py +294 -0
- tunacode/cli/commands/slash/types.py +93 -0
- tunacode/cli/commands/slash/validator.py +399 -0
- tunacode/cli/main.py +4 -1
- tunacode/cli/repl.py +25 -0
- tunacode/configuration/defaults.py +1 -0
- tunacode/constants.py +1 -1
- tunacode/core/agents/agent_components/agent_helpers.py +14 -13
- tunacode/core/agents/main.py +1 -1
- tunacode/core/agents/utils.py +4 -3
- tunacode/core/setup/config_setup.py +231 -6
- tunacode/core/setup/coordinator.py +13 -5
- tunacode/core/setup/git_safety_setup.py +5 -1
- tunacode/exceptions.py +119 -5
- tunacode/setup.py +5 -2
- tunacode/tools/glob.py +9 -46
- tunacode/tools/grep.py +9 -51
- 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/ui/output.py +1 -1
- tunacode/utils/user_configuration.py +45 -0
- tunacode_cli-0.0.68.dist-info/METADATA +192 -0
- {tunacode_cli-0.0.67.dist-info → tunacode_cli-0.0.68.dist-info}/RECORD +37 -24
- tunacode_cli-0.0.67.dist-info/METADATA +0 -327
- {tunacode_cli-0.0.67.dist-info → tunacode_cli-0.0.68.dist-info}/WHEEL +0 -0
- {tunacode_cli-0.0.67.dist-info → tunacode_cli-0.0.68.dist-info}/entry_points.txt +0 -0
- {tunacode_cli-0.0.67.dist-info → tunacode_cli-0.0.68.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
|
-
|
|
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)
|