tunacode-cli 0.0.1__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/__init__.py +0 -0
- tunacode/cli/__init__.py +4 -0
- tunacode/cli/commands.py +632 -0
- tunacode/cli/main.py +47 -0
- tunacode/cli/repl.py +251 -0
- tunacode/configuration/__init__.py +1 -0
- tunacode/configuration/defaults.py +26 -0
- tunacode/configuration/models.py +69 -0
- tunacode/configuration/settings.py +32 -0
- tunacode/constants.py +129 -0
- tunacode/context.py +83 -0
- tunacode/core/__init__.py +0 -0
- tunacode/core/agents/__init__.py +0 -0
- tunacode/core/agents/main.py +119 -0
- tunacode/core/setup/__init__.py +17 -0
- tunacode/core/setup/agent_setup.py +41 -0
- tunacode/core/setup/base.py +37 -0
- tunacode/core/setup/config_setup.py +179 -0
- tunacode/core/setup/coordinator.py +45 -0
- tunacode/core/setup/environment_setup.py +62 -0
- tunacode/core/setup/git_safety_setup.py +188 -0
- tunacode/core/setup/undo_setup.py +32 -0
- tunacode/core/state.py +43 -0
- tunacode/core/tool_handler.py +57 -0
- tunacode/exceptions.py +105 -0
- tunacode/prompts/system.txt +71 -0
- tunacode/py.typed +0 -0
- tunacode/services/__init__.py +1 -0
- tunacode/services/mcp.py +86 -0
- tunacode/services/undo_service.py +244 -0
- tunacode/setup.py +50 -0
- tunacode/tools/__init__.py +0 -0
- tunacode/tools/base.py +244 -0
- tunacode/tools/read_file.py +89 -0
- tunacode/tools/run_command.py +107 -0
- tunacode/tools/update_file.py +117 -0
- tunacode/tools/write_file.py +82 -0
- tunacode/types.py +259 -0
- tunacode/ui/__init__.py +1 -0
- tunacode/ui/completers.py +129 -0
- tunacode/ui/console.py +74 -0
- tunacode/ui/constants.py +16 -0
- tunacode/ui/decorators.py +59 -0
- tunacode/ui/input.py +95 -0
- tunacode/ui/keybindings.py +27 -0
- tunacode/ui/lexers.py +46 -0
- tunacode/ui/output.py +109 -0
- tunacode/ui/panels.py +156 -0
- tunacode/ui/prompt_manager.py +117 -0
- tunacode/ui/tool_ui.py +187 -0
- tunacode/ui/validators.py +23 -0
- tunacode/utils/__init__.py +0 -0
- tunacode/utils/bm25.py +55 -0
- tunacode/utils/diff_utils.py +69 -0
- tunacode/utils/file_utils.py +41 -0
- tunacode/utils/ripgrep.py +17 -0
- tunacode/utils/system.py +336 -0
- tunacode/utils/text_utils.py +87 -0
- tunacode/utils/user_configuration.py +54 -0
- tunacode_cli-0.0.1.dist-info/METADATA +242 -0
- tunacode_cli-0.0.1.dist-info/RECORD +65 -0
- tunacode_cli-0.0.1.dist-info/WHEEL +5 -0
- tunacode_cli-0.0.1.dist-info/entry_points.txt +2 -0
- tunacode_cli-0.0.1.dist-info/licenses/LICENSE +21 -0
- tunacode_cli-0.0.1.dist-info/top_level.txt +1 -0
tunacode/__init__.py
ADDED
|
File without changes
|
tunacode/cli/__init__.py
ADDED
tunacode/cli/commands.py
ADDED
|
@@ -0,0 +1,632 @@
|
|
|
1
|
+
"""Command system for Sidekick CLI."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from typing import Any, Dict, List, Optional, Type
|
|
7
|
+
|
|
8
|
+
from .. import utils
|
|
9
|
+
from ..configuration.models import ModelRegistry
|
|
10
|
+
from ..exceptions import ValidationError
|
|
11
|
+
from ..services.undo_service import perform_undo
|
|
12
|
+
from ..types import CommandArgs, CommandContext, CommandResult, ProcessRequestCallback
|
|
13
|
+
from ..ui import console as ui
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CommandCategory(Enum):
|
|
17
|
+
"""Categories for organizing commands."""
|
|
18
|
+
|
|
19
|
+
SYSTEM = "system"
|
|
20
|
+
NAVIGATION = "navigation"
|
|
21
|
+
DEVELOPMENT = "development"
|
|
22
|
+
MODEL = "model"
|
|
23
|
+
DEBUG = "debug"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Command(ABC):
|
|
27
|
+
"""Base class for all commands."""
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
@abstractmethod
|
|
31
|
+
def name(self) -> str:
|
|
32
|
+
"""The primary name of the command."""
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
@abstractmethod
|
|
37
|
+
def aliases(self) -> CommandArgs:
|
|
38
|
+
"""Alternative names/aliases for the command."""
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def description(self) -> str:
|
|
43
|
+
"""Description of what the command does."""
|
|
44
|
+
return ""
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def category(self) -> CommandCategory:
|
|
48
|
+
"""Category this command belongs to."""
|
|
49
|
+
return CommandCategory.SYSTEM
|
|
50
|
+
|
|
51
|
+
@abstractmethod
|
|
52
|
+
async def execute(self, args: CommandArgs, context: CommandContext) -> CommandResult:
|
|
53
|
+
"""
|
|
54
|
+
Execute the command.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
args: Command arguments (excluding the command name)
|
|
58
|
+
context: Execution context with state and config
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
Command-specific return value
|
|
62
|
+
"""
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class CommandSpec:
|
|
68
|
+
"""Specification for a command's metadata."""
|
|
69
|
+
|
|
70
|
+
name: str
|
|
71
|
+
aliases: List[str]
|
|
72
|
+
description: str
|
|
73
|
+
category: CommandCategory = CommandCategory.SYSTEM
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class SimpleCommand(Command):
|
|
77
|
+
"""Base class for simple commands without complex logic."""
|
|
78
|
+
|
|
79
|
+
def __init__(self, spec: CommandSpec):
|
|
80
|
+
self.spec = spec
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def name(self) -> str:
|
|
84
|
+
"""The primary name of the command."""
|
|
85
|
+
return self.spec.name
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def aliases(self) -> CommandArgs:
|
|
89
|
+
"""Alternative names/aliases for the command."""
|
|
90
|
+
return self.spec.aliases
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def description(self) -> str:
|
|
94
|
+
"""Description of what the command does."""
|
|
95
|
+
return self.spec.description
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def category(self) -> CommandCategory:
|
|
99
|
+
"""Category this command belongs to."""
|
|
100
|
+
return self.spec.category
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class YoloCommand(SimpleCommand):
|
|
104
|
+
"""Toggle YOLO mode (skip confirmations)."""
|
|
105
|
+
|
|
106
|
+
def __init__(self):
|
|
107
|
+
super().__init__(
|
|
108
|
+
CommandSpec(
|
|
109
|
+
name="yolo",
|
|
110
|
+
aliases=["/yolo"],
|
|
111
|
+
description="Toggle YOLO mode (skip tool confirmations)",
|
|
112
|
+
category=CommandCategory.DEVELOPMENT,
|
|
113
|
+
)
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
async def execute(self, args: List[str], context: CommandContext) -> None:
|
|
117
|
+
state = context.state_manager.session
|
|
118
|
+
state.yolo = not state.yolo
|
|
119
|
+
if state.yolo:
|
|
120
|
+
await ui.success("Ooh shit, its YOLO time!\n")
|
|
121
|
+
else:
|
|
122
|
+
await ui.info("Pfft, boring...\n")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class DumpCommand(SimpleCommand):
|
|
126
|
+
"""Dump message history."""
|
|
127
|
+
|
|
128
|
+
def __init__(self):
|
|
129
|
+
super().__init__(
|
|
130
|
+
CommandSpec(
|
|
131
|
+
name="dump",
|
|
132
|
+
aliases=["/dump"],
|
|
133
|
+
description="Dump the current message history",
|
|
134
|
+
category=CommandCategory.DEBUG,
|
|
135
|
+
)
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
async def execute(self, args: List[str], context: CommandContext) -> None:
|
|
139
|
+
await ui.dump_messages(context.state_manager.session.messages)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class ClearCommand(SimpleCommand):
|
|
143
|
+
"""Clear screen and message history."""
|
|
144
|
+
|
|
145
|
+
def __init__(self):
|
|
146
|
+
super().__init__(
|
|
147
|
+
CommandSpec(
|
|
148
|
+
name="clear",
|
|
149
|
+
aliases=["/clear"],
|
|
150
|
+
description="Clear the screen and message history",
|
|
151
|
+
category=CommandCategory.NAVIGATION,
|
|
152
|
+
)
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
async def execute(self, args: List[str], context: CommandContext) -> None:
|
|
156
|
+
await ui.clear()
|
|
157
|
+
context.state_manager.session.messages = []
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class TunaCodeCommand(SimpleCommand):
|
|
161
|
+
"""Use BM25 to inspect the codebase and read relevant files."""
|
|
162
|
+
|
|
163
|
+
def __init__(self):
|
|
164
|
+
super().__init__(
|
|
165
|
+
CommandSpec(
|
|
166
|
+
name="tunaCode",
|
|
167
|
+
aliases=["/tunaCode"],
|
|
168
|
+
description="Scan repo with BM25 and display key files",
|
|
169
|
+
category=CommandCategory.DEVELOPMENT,
|
|
170
|
+
)
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
async def execute(self, args: List[str], context: CommandContext) -> None:
|
|
174
|
+
from pathlib import Path
|
|
175
|
+
|
|
176
|
+
from tunacode.constants import UI_COLORS
|
|
177
|
+
from tunacode.utils.file_utils import DotDict
|
|
178
|
+
|
|
179
|
+
from ..tools.read_file import read_file
|
|
180
|
+
from ..utils.bm25 import BM25, tokenize
|
|
181
|
+
from ..utils.text_utils import ext_to_lang
|
|
182
|
+
|
|
183
|
+
colors = DotDict(UI_COLORS)
|
|
184
|
+
|
|
185
|
+
query = " ".join(args) if args else "overview"
|
|
186
|
+
await ui.info("Building BM25 index of repository")
|
|
187
|
+
|
|
188
|
+
docs: List[str] = []
|
|
189
|
+
paths: List[Path] = []
|
|
190
|
+
exts = {".py", ".js", ".ts", ".java", ".c", ".cpp", ".md", ".txt"}
|
|
191
|
+
for path in Path(".").rglob("*"):
|
|
192
|
+
if path.is_file() and path.suffix in exts:
|
|
193
|
+
try:
|
|
194
|
+
docs.append(path.read_text(encoding="utf-8"))
|
|
195
|
+
paths.append(path)
|
|
196
|
+
except Exception:
|
|
197
|
+
continue
|
|
198
|
+
|
|
199
|
+
if not docs:
|
|
200
|
+
await ui.error("No files found to index")
|
|
201
|
+
return
|
|
202
|
+
|
|
203
|
+
bm25 = BM25(docs)
|
|
204
|
+
scores = bm25.get_scores(tokenize(query))
|
|
205
|
+
ranked = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)[:5]
|
|
206
|
+
|
|
207
|
+
for idx in ranked:
|
|
208
|
+
file_path = paths[idx]
|
|
209
|
+
content = await read_file(str(file_path))
|
|
210
|
+
lang = ext_to_lang(str(file_path))
|
|
211
|
+
await ui.panel(
|
|
212
|
+
str(file_path),
|
|
213
|
+
f"```{lang}\n{content}\n```",
|
|
214
|
+
border_style=colors.muted,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class HelpCommand(SimpleCommand):
|
|
219
|
+
"""Show help information."""
|
|
220
|
+
|
|
221
|
+
def __init__(self, command_registry=None):
|
|
222
|
+
super().__init__(
|
|
223
|
+
CommandSpec(
|
|
224
|
+
name="help",
|
|
225
|
+
aliases=["/help"],
|
|
226
|
+
description="Show help information",
|
|
227
|
+
category=CommandCategory.SYSTEM,
|
|
228
|
+
)
|
|
229
|
+
)
|
|
230
|
+
self._command_registry = command_registry
|
|
231
|
+
|
|
232
|
+
async def execute(self, args: List[str], context: CommandContext) -> None:
|
|
233
|
+
await ui.help(self._command_registry)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class UndoCommand(SimpleCommand):
|
|
237
|
+
"""Undo the last file operation."""
|
|
238
|
+
|
|
239
|
+
def __init__(self):
|
|
240
|
+
super().__init__(
|
|
241
|
+
CommandSpec(
|
|
242
|
+
name="undo",
|
|
243
|
+
aliases=["/undo"],
|
|
244
|
+
description="Undo the last file operation",
|
|
245
|
+
category=CommandCategory.DEVELOPMENT,
|
|
246
|
+
)
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
async def execute(self, args: List[str], context: CommandContext) -> None:
|
|
250
|
+
success, message = perform_undo(context.state_manager)
|
|
251
|
+
if success:
|
|
252
|
+
await ui.success(message)
|
|
253
|
+
else:
|
|
254
|
+
# Provide more helpful information when undo fails
|
|
255
|
+
await ui.warning(message)
|
|
256
|
+
if "not in a git repository" in message.lower():
|
|
257
|
+
await ui.muted("💡 To enable undo functionality:")
|
|
258
|
+
await ui.muted(" • Run 'git init' to initialize a git repository")
|
|
259
|
+
await ui.muted(" • Or work in a directory that's already a git repository")
|
|
260
|
+
await ui.muted(" • File operations will still work, but can't be undone")
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
class BranchCommand(SimpleCommand):
|
|
265
|
+
"""Create and switch to a new git branch."""
|
|
266
|
+
|
|
267
|
+
def __init__(self):
|
|
268
|
+
super().__init__(
|
|
269
|
+
CommandSpec(
|
|
270
|
+
name="branch",
|
|
271
|
+
aliases=["/branch"],
|
|
272
|
+
description="Create and switch to a new git branch",
|
|
273
|
+
category=CommandCategory.DEVELOPMENT,
|
|
274
|
+
)
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
async def execute(self, args: List[str], context: CommandContext) -> None:
|
|
278
|
+
import subprocess
|
|
279
|
+
|
|
280
|
+
from ..services.undo_service import is_in_git_project
|
|
281
|
+
|
|
282
|
+
if not args:
|
|
283
|
+
await ui.error("Usage: /branch <branch-name>")
|
|
284
|
+
return
|
|
285
|
+
|
|
286
|
+
if not is_in_git_project():
|
|
287
|
+
await ui.error("Not a git repository")
|
|
288
|
+
return
|
|
289
|
+
|
|
290
|
+
branch_name = args[0]
|
|
291
|
+
|
|
292
|
+
try:
|
|
293
|
+
subprocess.run(
|
|
294
|
+
["git", "checkout", "-b", branch_name],
|
|
295
|
+
capture_output=True,
|
|
296
|
+
text=True,
|
|
297
|
+
check=True,
|
|
298
|
+
timeout=5,
|
|
299
|
+
)
|
|
300
|
+
await ui.success(f"Switched to new branch '{branch_name}'")
|
|
301
|
+
except subprocess.TimeoutExpired:
|
|
302
|
+
await ui.error("Git command timed out")
|
|
303
|
+
except subprocess.CalledProcessError as e:
|
|
304
|
+
error_msg = e.stderr.strip() if e.stderr else str(e)
|
|
305
|
+
await ui.error(f"Git error: {error_msg}")
|
|
306
|
+
except FileNotFoundError:
|
|
307
|
+
await ui.error("Git executable not found")
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
class InitCommand(SimpleCommand):
|
|
311
|
+
"""Analyse the repository and generate TUNACODE.md."""
|
|
312
|
+
|
|
313
|
+
def __init__(self):
|
|
314
|
+
super().__init__(
|
|
315
|
+
CommandSpec(
|
|
316
|
+
name="init",
|
|
317
|
+
aliases=["/init"],
|
|
318
|
+
description="Analyse the repo and create TUNACODE.md",
|
|
319
|
+
category=CommandCategory.DEVELOPMENT,
|
|
320
|
+
)
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
async def execute(self, args: List[str], context: CommandContext) -> None:
|
|
324
|
+
import json
|
|
325
|
+
from pathlib import Path
|
|
326
|
+
|
|
327
|
+
from .. import context as ctx
|
|
328
|
+
|
|
329
|
+
await ui.info("Gathering repository context")
|
|
330
|
+
data = await ctx.get_context()
|
|
331
|
+
|
|
332
|
+
prompt = (
|
|
333
|
+
"Using the following repository context, summarise build commands "
|
|
334
|
+
"and coding conventions. Return markdown for a TUNACODE.md file.\n\n"
|
|
335
|
+
+ json.dumps(data, indent=2)
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
process_request = context.process_request
|
|
339
|
+
content = ""
|
|
340
|
+
if process_request:
|
|
341
|
+
res = await process_request(prompt, context.state_manager, output=False)
|
|
342
|
+
try:
|
|
343
|
+
content = res.result.output
|
|
344
|
+
except Exception:
|
|
345
|
+
content = ""
|
|
346
|
+
|
|
347
|
+
if not content:
|
|
348
|
+
content = "# TUNACODE\n\n" + json.dumps(data, indent=2)
|
|
349
|
+
|
|
350
|
+
Path("TUNACODE.md").write_text(content, encoding="utf-8")
|
|
351
|
+
await ui.success("TUNACODE.md written")
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
class CompactCommand(SimpleCommand):
|
|
355
|
+
"""Compact conversation context."""
|
|
356
|
+
|
|
357
|
+
def __init__(self, process_request_callback: Optional[ProcessRequestCallback] = None):
|
|
358
|
+
super().__init__(
|
|
359
|
+
CommandSpec(
|
|
360
|
+
name="compact",
|
|
361
|
+
aliases=["/compact"],
|
|
362
|
+
description="Summarize and compact the conversation history",
|
|
363
|
+
category=CommandCategory.SYSTEM,
|
|
364
|
+
)
|
|
365
|
+
)
|
|
366
|
+
self._process_request = process_request_callback
|
|
367
|
+
|
|
368
|
+
async def execute(self, args: List[str], context: CommandContext) -> None:
|
|
369
|
+
# Use the injected callback or get it from context
|
|
370
|
+
process_request = self._process_request or context.process_request
|
|
371
|
+
|
|
372
|
+
if not process_request:
|
|
373
|
+
await ui.error("Compact command not available - process_request not configured")
|
|
374
|
+
return
|
|
375
|
+
|
|
376
|
+
# Get the current agent, create a summary of context, and trim message history
|
|
377
|
+
await process_request(
|
|
378
|
+
"Summarize the conversation so far", context.state_manager, output=False
|
|
379
|
+
)
|
|
380
|
+
await ui.success("Context history has been summarized and truncated.")
|
|
381
|
+
context.state_manager.session.messages = context.state_manager.session.messages[-2:]
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
class ModelCommand(SimpleCommand):
|
|
385
|
+
"""Manage model selection."""
|
|
386
|
+
|
|
387
|
+
def __init__(self):
|
|
388
|
+
super().__init__(
|
|
389
|
+
CommandSpec(
|
|
390
|
+
name="model",
|
|
391
|
+
aliases=["/model"],
|
|
392
|
+
description="List models or select a model (e.g., /model 3 or /model 3 default)",
|
|
393
|
+
category=CommandCategory.MODEL,
|
|
394
|
+
)
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
async def execute(self, args: CommandArgs, context: CommandContext) -> Optional[str]:
|
|
398
|
+
if not args:
|
|
399
|
+
# No arguments - list models
|
|
400
|
+
await ui.models(context.state_manager)
|
|
401
|
+
return None
|
|
402
|
+
|
|
403
|
+
# Parse model index
|
|
404
|
+
try:
|
|
405
|
+
model_index = int(args[0])
|
|
406
|
+
except ValueError:
|
|
407
|
+
await ui.error(f"Invalid model index: {args[0]}")
|
|
408
|
+
return None
|
|
409
|
+
|
|
410
|
+
# Get model list
|
|
411
|
+
model_registry = ModelRegistry()
|
|
412
|
+
models = list(model_registry.list_models().keys())
|
|
413
|
+
if model_index < 0 or model_index >= len(models):
|
|
414
|
+
await ui.error(f"Model index {model_index} out of range")
|
|
415
|
+
return None
|
|
416
|
+
|
|
417
|
+
# Set the model
|
|
418
|
+
model = models[model_index]
|
|
419
|
+
context.state_manager.session.current_model = model
|
|
420
|
+
|
|
421
|
+
# Check if setting as default
|
|
422
|
+
if len(args) > 1 and args[1] == "default":
|
|
423
|
+
utils.user_configuration.set_default_model(model, context.state_manager)
|
|
424
|
+
await ui.muted("Updating default model")
|
|
425
|
+
return "restart"
|
|
426
|
+
else:
|
|
427
|
+
# Show success message with the new model
|
|
428
|
+
await ui.success(f"Switched to model: {model}")
|
|
429
|
+
return None
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
@dataclass
|
|
433
|
+
class CommandDependencies:
|
|
434
|
+
"""Container for command dependencies."""
|
|
435
|
+
|
|
436
|
+
process_request_callback: Optional[ProcessRequestCallback] = None
|
|
437
|
+
command_registry: Optional[Any] = None # Reference to the registry itself
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
class CommandFactory:
|
|
441
|
+
"""Factory for creating commands with proper dependency injection."""
|
|
442
|
+
|
|
443
|
+
def __init__(self, dependencies: Optional[CommandDependencies] = None):
|
|
444
|
+
self.dependencies = dependencies or CommandDependencies()
|
|
445
|
+
|
|
446
|
+
def create_command(self, command_class: Type[Command]) -> Command:
|
|
447
|
+
"""Create a command instance with proper dependencies."""
|
|
448
|
+
# Special handling for commands that need dependencies
|
|
449
|
+
if command_class == CompactCommand:
|
|
450
|
+
return CompactCommand(self.dependencies.process_request_callback)
|
|
451
|
+
elif command_class == HelpCommand:
|
|
452
|
+
return HelpCommand(self.dependencies.command_registry)
|
|
453
|
+
|
|
454
|
+
# Default creation for commands without dependencies
|
|
455
|
+
return command_class()
|
|
456
|
+
|
|
457
|
+
def update_dependencies(self, **kwargs) -> None:
|
|
458
|
+
"""Update factory dependencies."""
|
|
459
|
+
for key, value in kwargs.items():
|
|
460
|
+
if hasattr(self.dependencies, key):
|
|
461
|
+
setattr(self.dependencies, key, value)
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
class CommandRegistry:
|
|
465
|
+
"""Registry for managing commands with auto-discovery and categories."""
|
|
466
|
+
|
|
467
|
+
def __init__(self, factory: Optional[CommandFactory] = None):
|
|
468
|
+
self._commands: Dict[str, Command] = {}
|
|
469
|
+
self._categories: Dict[CommandCategory, List[Command]] = {
|
|
470
|
+
category: [] for category in CommandCategory
|
|
471
|
+
}
|
|
472
|
+
self._factory = factory or CommandFactory()
|
|
473
|
+
self._discovered = False
|
|
474
|
+
|
|
475
|
+
# Set registry reference in factory dependencies
|
|
476
|
+
self._factory.update_dependencies(command_registry=self)
|
|
477
|
+
|
|
478
|
+
def register(self, command: Command) -> None:
|
|
479
|
+
"""Register a command and its aliases."""
|
|
480
|
+
# Register by primary name
|
|
481
|
+
self._commands[command.name] = command
|
|
482
|
+
|
|
483
|
+
# Register all aliases
|
|
484
|
+
for alias in command.aliases:
|
|
485
|
+
self._commands[alias.lower()] = command
|
|
486
|
+
|
|
487
|
+
# Add to category (remove existing instance first to prevent duplicates)
|
|
488
|
+
category_commands = self._categories[command.category]
|
|
489
|
+
# Remove any existing instance of this command class
|
|
490
|
+
self._categories[command.category] = [
|
|
491
|
+
cmd for cmd in category_commands
|
|
492
|
+
if cmd.__class__ != command.__class__
|
|
493
|
+
]
|
|
494
|
+
# Add the new instance
|
|
495
|
+
self._categories[command.category].append(command)
|
|
496
|
+
|
|
497
|
+
def register_command_class(self, command_class: Type[Command]) -> None:
|
|
498
|
+
"""Register a command class using the factory."""
|
|
499
|
+
command = self._factory.create_command(command_class)
|
|
500
|
+
self.register(command)
|
|
501
|
+
|
|
502
|
+
def discover_commands(self) -> None:
|
|
503
|
+
"""Auto-discover and register all command classes."""
|
|
504
|
+
if self._discovered:
|
|
505
|
+
return
|
|
506
|
+
|
|
507
|
+
# List of all command classes to register
|
|
508
|
+
command_classes = [
|
|
509
|
+
YoloCommand,
|
|
510
|
+
DumpCommand,
|
|
511
|
+
ClearCommand,
|
|
512
|
+
HelpCommand,
|
|
513
|
+
UndoCommand,
|
|
514
|
+
BranchCommand,
|
|
515
|
+
InitCommand,
|
|
516
|
+
# TunaCodeCommand, # TODO: Temporarily disabled
|
|
517
|
+
CompactCommand,
|
|
518
|
+
ModelCommand,
|
|
519
|
+
]
|
|
520
|
+
|
|
521
|
+
# Register all discovered commands
|
|
522
|
+
for command_class in command_classes:
|
|
523
|
+
self.register_command_class(command_class)
|
|
524
|
+
|
|
525
|
+
self._discovered = True
|
|
526
|
+
|
|
527
|
+
def register_all_default_commands(self) -> None:
|
|
528
|
+
"""Register all default commands (backward compatibility)."""
|
|
529
|
+
self.discover_commands()
|
|
530
|
+
|
|
531
|
+
def set_process_request_callback(self, callback: ProcessRequestCallback) -> None:
|
|
532
|
+
"""Set the process_request callback for commands that need it."""
|
|
533
|
+
# Only update if callback has changed
|
|
534
|
+
if self._factory.dependencies.process_request_callback == callback:
|
|
535
|
+
return
|
|
536
|
+
|
|
537
|
+
self._factory.update_dependencies(process_request_callback=callback)
|
|
538
|
+
|
|
539
|
+
# Re-register CompactCommand with new dependency if already registered
|
|
540
|
+
if "compact" in self._commands:
|
|
541
|
+
self.register_command_class(CompactCommand)
|
|
542
|
+
|
|
543
|
+
async def execute(self, command_text: str, context: CommandContext) -> Any:
|
|
544
|
+
"""
|
|
545
|
+
Execute a command.
|
|
546
|
+
|
|
547
|
+
Args:
|
|
548
|
+
command_text: The full command text
|
|
549
|
+
context: Execution context
|
|
550
|
+
|
|
551
|
+
Returns:
|
|
552
|
+
Command-specific return value, or None if command not found
|
|
553
|
+
|
|
554
|
+
Raises:
|
|
555
|
+
ValidationError: If command is not found or empty
|
|
556
|
+
"""
|
|
557
|
+
# Ensure commands are discovered
|
|
558
|
+
self.discover_commands()
|
|
559
|
+
|
|
560
|
+
parts = command_text.split()
|
|
561
|
+
if not parts:
|
|
562
|
+
raise ValidationError("Empty command")
|
|
563
|
+
|
|
564
|
+
command_name = parts[0].lower()
|
|
565
|
+
args = parts[1:]
|
|
566
|
+
|
|
567
|
+
# First try exact match
|
|
568
|
+
if command_name in self._commands:
|
|
569
|
+
command = self._commands[command_name]
|
|
570
|
+
return await command.execute(args, context)
|
|
571
|
+
|
|
572
|
+
# Try partial matching
|
|
573
|
+
matches = self.find_matching_commands(command_name)
|
|
574
|
+
|
|
575
|
+
if not matches:
|
|
576
|
+
raise ValidationError(f"Unknown command: {command_name}")
|
|
577
|
+
elif len(matches) == 1:
|
|
578
|
+
# Unambiguous match
|
|
579
|
+
command = self._commands[matches[0]]
|
|
580
|
+
return await command.execute(args, context)
|
|
581
|
+
else:
|
|
582
|
+
# Ambiguous - show possibilities
|
|
583
|
+
raise ValidationError(
|
|
584
|
+
f"Ambiguous command '{command_name}'. Did you mean: {', '.join(sorted(set(matches)))}?"
|
|
585
|
+
)
|
|
586
|
+
|
|
587
|
+
def find_matching_commands(self, partial_command: str) -> List[str]:
|
|
588
|
+
"""
|
|
589
|
+
Find all commands that start with the given partial command.
|
|
590
|
+
|
|
591
|
+
Args:
|
|
592
|
+
partial_command: The partial command to match
|
|
593
|
+
|
|
594
|
+
Returns:
|
|
595
|
+
List of matching command names
|
|
596
|
+
"""
|
|
597
|
+
self.discover_commands()
|
|
598
|
+
partial = partial_command.lower()
|
|
599
|
+
return [cmd for cmd in self._commands.keys() if cmd.startswith(partial)]
|
|
600
|
+
|
|
601
|
+
def is_command(self, text: str) -> bool:
|
|
602
|
+
"""Check if text starts with a registered command (supports partial matching)."""
|
|
603
|
+
if not text:
|
|
604
|
+
return False
|
|
605
|
+
|
|
606
|
+
parts = text.split()
|
|
607
|
+
if not parts:
|
|
608
|
+
return False
|
|
609
|
+
|
|
610
|
+
command_name = parts[0].lower()
|
|
611
|
+
|
|
612
|
+
# Check exact match first
|
|
613
|
+
if command_name in self._commands:
|
|
614
|
+
return True
|
|
615
|
+
|
|
616
|
+
# Check partial match
|
|
617
|
+
return len(self.find_matching_commands(command_name)) > 0
|
|
618
|
+
|
|
619
|
+
def get_command_names(self) -> CommandArgs:
|
|
620
|
+
"""Get all registered command names (including aliases)."""
|
|
621
|
+
self.discover_commands()
|
|
622
|
+
return sorted(self._commands.keys())
|
|
623
|
+
|
|
624
|
+
def get_commands_by_category(self, category: CommandCategory) -> List[Command]:
|
|
625
|
+
"""Get all commands in a specific category."""
|
|
626
|
+
self.discover_commands()
|
|
627
|
+
return self._categories.get(category, [])
|
|
628
|
+
|
|
629
|
+
def get_all_categories(self) -> Dict[CommandCategory, List[Command]]:
|
|
630
|
+
"""Get all commands organized by category."""
|
|
631
|
+
self.discover_commands()
|
|
632
|
+
return self._categories.copy()
|
tunacode/cli/main.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Module: sidekick.cli.main
|
|
3
|
+
|
|
4
|
+
CLI entry point and main command handling for the Sidekick application.
|
|
5
|
+
Manages application startup, version checking, and REPL initialization.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from tunacode.cli.repl import repl
|
|
13
|
+
from tunacode.configuration.settings import ApplicationSettings
|
|
14
|
+
from tunacode.core.state import StateManager
|
|
15
|
+
from tunacode.setup import setup
|
|
16
|
+
from tunacode.ui import console as ui
|
|
17
|
+
from tunacode.utils.system import check_for_updates
|
|
18
|
+
|
|
19
|
+
app_settings = ApplicationSettings()
|
|
20
|
+
app = typer.Typer(help=app_settings.name)
|
|
21
|
+
state_manager = StateManager()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@app.command()
|
|
25
|
+
def main(
|
|
26
|
+
version: bool = typer.Option(False, "--version", "-v", help="Show version and exit."),
|
|
27
|
+
run_setup: bool = typer.Option(False, "--setup", help="Run setup process."),
|
|
28
|
+
):
|
|
29
|
+
if version:
|
|
30
|
+
asyncio.run(ui.version())
|
|
31
|
+
return
|
|
32
|
+
|
|
33
|
+
asyncio.run(ui.banner())
|
|
34
|
+
|
|
35
|
+
has_update, latest_version = check_for_updates()
|
|
36
|
+
if has_update:
|
|
37
|
+
asyncio.run(ui.show_update_message(latest_version))
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
asyncio.run(setup(run_setup, state_manager))
|
|
41
|
+
asyncio.run(repl(state_manager))
|
|
42
|
+
except Exception as e:
|
|
43
|
+
asyncio.run(ui.error(str(e)))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
if __name__ == "__main__":
|
|
47
|
+
app()
|