tunacode-cli 0.0.67__py3-none-any.whl → 0.0.69__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of tunacode-cli might be problematic. Click here for more details.

Files changed (38) hide show
  1. tunacode/cli/commands/__init__.py +2 -0
  2. tunacode/cli/commands/implementations/__init__.py +2 -0
  3. tunacode/cli/commands/implementations/command_reload.py +48 -0
  4. tunacode/cli/commands/implementations/quickstart.py +43 -0
  5. tunacode/cli/commands/registry.py +131 -1
  6. tunacode/cli/commands/slash/__init__.py +32 -0
  7. tunacode/cli/commands/slash/command.py +157 -0
  8. tunacode/cli/commands/slash/loader.py +134 -0
  9. tunacode/cli/commands/slash/processor.py +294 -0
  10. tunacode/cli/commands/slash/types.py +93 -0
  11. tunacode/cli/commands/slash/validator.py +399 -0
  12. tunacode/cli/main.py +4 -1
  13. tunacode/cli/repl.py +25 -0
  14. tunacode/configuration/defaults.py +1 -0
  15. tunacode/constants.py +1 -1
  16. tunacode/core/agents/agent_components/agent_helpers.py +14 -13
  17. tunacode/core/agents/main.py +1 -1
  18. tunacode/core/agents/utils.py +4 -3
  19. tunacode/core/setup/config_setup.py +231 -6
  20. tunacode/core/setup/coordinator.py +13 -5
  21. tunacode/core/setup/git_safety_setup.py +5 -1
  22. tunacode/exceptions.py +119 -5
  23. tunacode/setup.py +5 -2
  24. tunacode/tools/glob.py +9 -46
  25. tunacode/tools/grep.py +9 -51
  26. tunacode/tools/xml_helper.py +83 -0
  27. tunacode/tutorial/__init__.py +9 -0
  28. tunacode/tutorial/content.py +98 -0
  29. tunacode/tutorial/manager.py +182 -0
  30. tunacode/tutorial/steps.py +124 -0
  31. tunacode/ui/output.py +1 -1
  32. tunacode/utils/user_configuration.py +45 -0
  33. tunacode_cli-0.0.69.dist-info/METADATA +192 -0
  34. {tunacode_cli-0.0.67.dist-info → tunacode_cli-0.0.69.dist-info}/RECORD +37 -24
  35. tunacode_cli-0.0.67.dist-info/METADATA +0 -327
  36. {tunacode_cli-0.0.67.dist-info → tunacode_cli-0.0.69.dist-info}/WHEEL +0 -0
  37. {tunacode_cli-0.0.67.dist-info → tunacode_cli-0.0.69.dist-info}/entry_points.txt +0 -0
  38. {tunacode_cli-0.0.67.dist-info → tunacode_cli-0.0.69.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,399 @@
1
+ """Security validation for slash command execution."""
2
+
3
+ import logging
4
+ import re
5
+ from pathlib import Path
6
+ from typing import List
7
+
8
+ from .types import SecurityLevel, SecurityViolation, ValidationResult
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class CommandValidator:
14
+ """Comprehensive security validation system for slash commands."""
15
+
16
+ def __init__(self, security_level: SecurityLevel = SecurityLevel.MODERATE):
17
+ self.security_level = security_level
18
+ self._init_security_rules()
19
+
20
+ def _init_security_rules(self) -> None:
21
+ """Initialize security rules based on security level."""
22
+
23
+ # Always blocked commands (all security levels)
24
+ self.ALWAYS_BLOCKED = {
25
+ "rm",
26
+ "rmdir",
27
+ "del",
28
+ "format",
29
+ "fdisk",
30
+ "mkfs",
31
+ "sudo",
32
+ "su",
33
+ "passwd",
34
+ "useradd",
35
+ "userdel",
36
+ "chmod",
37
+ "chown",
38
+ "chgrp",
39
+ "setfacl",
40
+ "iptables",
41
+ "firewall-cmd",
42
+ "ufw",
43
+ "systemctl",
44
+ "service",
45
+ "initctl",
46
+ "wget",
47
+ "curl",
48
+ "nc",
49
+ "netcat",
50
+ "telnet",
51
+ "dd",
52
+ "mount",
53
+ "umount",
54
+ "fsck",
55
+ }
56
+
57
+ # Dangerous patterns (all security levels)
58
+ self.DANGEROUS_PATTERNS = [
59
+ r"rm\s+.*-[rf]", # Recursive/force delete
60
+ r">\s*/dev/", # Device access
61
+ r"\|\s*sh\b", # Pipe to shell
62
+ r"\|\s*bash\b", # Pipe to bash
63
+ r"`[^`]*`", # Command substitution
64
+ r"\$\([^)]*\)", # Command substitution
65
+ r"(?:;|\|\||&&)", # Command chaining (;, ||, &&)
66
+ r"[|&]+\s*$", # Trailing pipes
67
+ r"eval\s+", # Dynamic evaluation
68
+ r"exec\s+", # Process replacement
69
+ r"source\s+", # Script sourcing
70
+ r"\.\s+/", # Script sourcing (dot)
71
+ ]
72
+
73
+ # Security level specific rules
74
+ if self.security_level == SecurityLevel.STRICT:
75
+ self.ALLOWED_COMMANDS = {
76
+ "git": ["status", "branch", "log", "show"],
77
+ "ls": ["-la", "-l", "-a"],
78
+ "cat": [],
79
+ "echo": [],
80
+ "date": [],
81
+ "pwd": [],
82
+ }
83
+ elif self.security_level == SecurityLevel.MODERATE:
84
+ self.ALLOWED_COMMANDS = {
85
+ "git": ["status", "branch", "log", "diff", "show", "remote", "config"],
86
+ "npm": ["list", "info", "view", "outdated", "audit", "install"],
87
+ "python": ["-c", "-m", "--version", "-V"],
88
+ "node": ["--version", "-v", "-e"],
89
+ "ls": ["-la", "-l", "-a", "-h", "-R"],
90
+ "cat": [],
91
+ "head": [],
92
+ "tail": [],
93
+ "echo": [],
94
+ "date": [],
95
+ "pwd": [],
96
+ "whoami": [],
97
+ "grep": ["-r", "-n", "-i", "-v"],
98
+ "find": ["-name", "-type", "-size"],
99
+ "wc": ["-l", "-w", "-c"],
100
+ "sort": [],
101
+ "uniq": [],
102
+ "cut": [],
103
+ }
104
+ else: # PERMISSIVE
105
+ self.ALLOWED_COMMANDS = {
106
+ # All moderate commands plus more
107
+ "git": ["status", "branch", "log", "diff", "show", "remote", "config"],
108
+ "npm": ["list", "info", "view", "outdated", "audit"],
109
+ "python": ["-c", "-m", "--version", "-V"],
110
+ "node": ["--version", "-v", "-e"],
111
+ "ls": ["-la", "-l", "-a", "-h", "-R"],
112
+ "cat": [],
113
+ "head": [],
114
+ "tail": [],
115
+ "echo": [],
116
+ "date": [],
117
+ "pwd": [],
118
+ "whoami": [],
119
+ "grep": ["-r", "-n", "-i", "-v"],
120
+ "find": ["-name", "-type", "-size"],
121
+ "wc": ["-l", "-w", "-c"],
122
+ "sort": [],
123
+ "uniq": [],
124
+ "cut": [],
125
+ "make": ["--version", "-n"], # Dry run only
126
+ "docker": ["ps", "images", "info", "version"],
127
+ "kubectl": ["get", "describe", "logs", "version"],
128
+ "terraform": ["version", "validate", "plan"],
129
+ }
130
+
131
+ def validate_shell_command(self, command: str) -> ValidationResult:
132
+ """Comprehensive command validation with detailed results."""
133
+ command = command.strip()
134
+ violations = []
135
+
136
+ if not command:
137
+ violations.append(
138
+ SecurityViolation(
139
+ type="empty_command",
140
+ message="Empty command not allowed",
141
+ command=command,
142
+ severity="error",
143
+ )
144
+ )
145
+ return ValidationResult(False, violations)
146
+
147
+ # Parse command
148
+ parts = command.split()
149
+ base_command = parts[0].lower()
150
+
151
+ # Check for always blocked commands
152
+ if base_command in self.ALWAYS_BLOCKED:
153
+ violations.append(
154
+ SecurityViolation(
155
+ type="blocked_command",
156
+ message=f"Command '{base_command}' is always blocked",
157
+ command=command,
158
+ severity="error",
159
+ )
160
+ )
161
+ return ValidationResult(False, violations)
162
+
163
+ # Check dangerous patterns
164
+ for pattern in self.DANGEROUS_PATTERNS:
165
+ if re.search(pattern, command, re.IGNORECASE):
166
+ violations.append(
167
+ SecurityViolation(
168
+ type="dangerous_pattern",
169
+ message=f"Command matches dangerous pattern: {pattern}",
170
+ command=command,
171
+ severity="error",
172
+ )
173
+ )
174
+ return ValidationResult(False, violations)
175
+
176
+ # Check against whitelist
177
+ if base_command not in self.ALLOWED_COMMANDS:
178
+ violations.append(
179
+ SecurityViolation(
180
+ type="unknown_command",
181
+ message=f"Command '{base_command}' not in whitelist",
182
+ command=command,
183
+ severity="warning"
184
+ if self.security_level == SecurityLevel.PERMISSIVE
185
+ else "error",
186
+ )
187
+ )
188
+
189
+ if self.security_level != SecurityLevel.PERMISSIVE:
190
+ return ValidationResult(False, violations)
191
+ else:
192
+ # Validate subcommands if specified
193
+ allowed_subcommands = self.ALLOWED_COMMANDS[base_command]
194
+ if allowed_subcommands and len(parts) > 1:
195
+ subcommand = parts[1]
196
+ if subcommand not in allowed_subcommands:
197
+ # Allow python scripts
198
+ if base_command == "python" and subcommand.endswith(".py"):
199
+ pass
200
+ elif base_command == "grep":
201
+ pass
202
+ else:
203
+ violations.append(
204
+ SecurityViolation(
205
+ type="invalid_subcommand",
206
+ message=f"Subcommand '{subcommand}' not allowed for '{base_command}'",
207
+ command=command,
208
+ severity="error",
209
+ )
210
+ )
211
+ return ValidationResult(False, violations)
212
+
213
+ # Additional validation checks
214
+ violations.extend(self._check_file_access_patterns(command))
215
+ violations.extend(self._check_network_patterns(command))
216
+ violations.extend(self._check_privilege_escalation(command))
217
+
218
+ # Determine if command is allowed
219
+ error_violations = [v for v in violations if v.severity == "error"]
220
+ allowed = len(error_violations) == 0
221
+
222
+ return ValidationResult(allowed, violations)
223
+
224
+ def _check_file_access_patterns(self, command: str) -> List[SecurityViolation]:
225
+ """Check for suspicious file access patterns."""
226
+ violations = []
227
+
228
+ # Sensitive file patterns
229
+ sensitive_patterns = [
230
+ r"/etc/passwd",
231
+ r"/etc/shadow",
232
+ r"/etc/hosts",
233
+ r"/home/[^/]+/\.ssh",
234
+ r"~/.ssh",
235
+ r"/var/log/",
236
+ r"/proc/",
237
+ r"/sys/",
238
+ r"\.env",
239
+ r"\.secret",
240
+ r"\.key",
241
+ ]
242
+
243
+ for pattern in sensitive_patterns:
244
+ if re.search(pattern, command, re.IGNORECASE):
245
+ violations.append(
246
+ SecurityViolation(
247
+ type="sensitive_file_access",
248
+ message=f"Potential access to sensitive file: {pattern}",
249
+ command=command,
250
+ severity="warning",
251
+ )
252
+ )
253
+
254
+ return violations
255
+
256
+ def _check_network_patterns(self, command: str) -> List[SecurityViolation]:
257
+ """Check for network access patterns."""
258
+ violations = []
259
+
260
+ network_patterns = [
261
+ r"curl\s+",
262
+ r"wget\s+",
263
+ r"nc\s+",
264
+ r"netcat\s+",
265
+ r"ssh\s+",
266
+ r"scp\s+",
267
+ r"rsync\s+.*:",
268
+ r"ftp\s+",
269
+ r"telnet\s+",
270
+ ]
271
+
272
+ for pattern in network_patterns:
273
+ if re.search(pattern, command, re.IGNORECASE):
274
+ violations.append(
275
+ SecurityViolation(
276
+ type="network_access",
277
+ message=f"Network access detected: {pattern}",
278
+ command=command,
279
+ severity="error",
280
+ )
281
+ )
282
+
283
+ return violations
284
+
285
+ def _check_privilege_escalation(self, command: str) -> List[SecurityViolation]:
286
+ """Check for privilege escalation attempts."""
287
+ violations = []
288
+
289
+ privilege_patterns = [
290
+ r"sudo\s+",
291
+ r"su\s+",
292
+ r"doas\s+",
293
+ r"pkexec\s+",
294
+ r"runuser\s+",
295
+ ]
296
+
297
+ for pattern in privilege_patterns:
298
+ if re.search(pattern, command, re.IGNORECASE):
299
+ violations.append(
300
+ SecurityViolation(
301
+ type="privilege_escalation",
302
+ message=f"Privilege escalation attempt: {pattern}",
303
+ command=command,
304
+ severity="error",
305
+ )
306
+ )
307
+
308
+ return violations
309
+
310
+ def validate_file_path(self, file_path: str, base_path: Path) -> ValidationResult:
311
+ """Enhanced file path validation."""
312
+ violations = []
313
+
314
+ try:
315
+ # Basic path traversal check
316
+ resolved = (base_path / file_path).resolve()
317
+ base_resolved = base_path.resolve()
318
+
319
+ if not str(resolved).startswith(str(base_resolved)):
320
+ violations.append(
321
+ SecurityViolation(
322
+ type="path_traversal",
323
+ message=f"Path traversal detected: {file_path}",
324
+ command=file_path,
325
+ severity="error",
326
+ )
327
+ )
328
+ return ValidationResult(False, violations)
329
+
330
+ # Check for sensitive file access
331
+ sensitive_dirs = [".ssh", ".git", ".env", "node_modules"]
332
+ path_parts = Path(file_path).parts
333
+
334
+ for sensitive in sensitive_dirs:
335
+ if sensitive in path_parts:
336
+ violations.append(
337
+ SecurityViolation(
338
+ type="sensitive_directory",
339
+ message=f"Access to sensitive directory: {sensitive}",
340
+ command=file_path,
341
+ severity="warning",
342
+ )
343
+ )
344
+
345
+ return ValidationResult(True, violations)
346
+
347
+ except (OSError, ValueError) as e:
348
+ violations.append(
349
+ SecurityViolation(
350
+ type="invalid_path",
351
+ message=f"Invalid file path: {str(e)}",
352
+ command=file_path,
353
+ severity="error",
354
+ )
355
+ )
356
+ return ValidationResult(False, violations)
357
+
358
+ def validate_glob_pattern(self, pattern: str) -> ValidationResult:
359
+ """Enhanced glob pattern validation."""
360
+ violations = []
361
+
362
+ # Dangerous glob patterns
363
+ dangerous_globs = [
364
+ r"\.\./.*", # Parent directory traversal
365
+ r"/etc/.*",
366
+ r"/home/.*",
367
+ r"/root/.*", # System directories
368
+ r"/var/.*",
369
+ r"/usr/bin/.*",
370
+ r"/bin/.*",
371
+ r".*\.key$",
372
+ r".*\.secret$",
373
+ r".*\.pem$", # Sensitive files
374
+ ]
375
+
376
+ for dangerous in dangerous_globs:
377
+ if re.match(dangerous, pattern, re.IGNORECASE):
378
+ violations.append(
379
+ SecurityViolation(
380
+ type="dangerous_glob",
381
+ message=f"Dangerous glob pattern: {dangerous}",
382
+ command=pattern,
383
+ severity="error",
384
+ )
385
+ )
386
+ return ValidationResult(False, violations)
387
+
388
+ # Check for overly broad patterns
389
+ if pattern in ["**/*", "*", "**"]:
390
+ violations.append(
391
+ SecurityViolation(
392
+ type="broad_glob",
393
+ message="Overly broad glob pattern",
394
+ command=pattern,
395
+ severity="warning",
396
+ )
397
+ )
398
+
399
+ return ValidationResult(True, violations)
tunacode/cli/main.py CHANGED
@@ -27,6 +27,9 @@ state_manager = StateManager()
27
27
  def main(
28
28
  version: bool = typer.Option(False, "--version", "-v", help="Show version and exit."),
29
29
  run_setup: bool = typer.Option(False, "--setup", help="Run setup process."),
30
+ wizard: bool = typer.Option(
31
+ False, "--wizard", help="Run interactive setup wizard for guided configuration."
32
+ ),
30
33
  baseurl: str = typer.Option(
31
34
  None, "--baseurl", help="API base URL (e.g., https://openrouter.ai/api/v1)"
32
35
  ),
@@ -60,7 +63,7 @@ def main(
60
63
  cli_config = {k: v for k, v in cli_config.items() if v is not None}
61
64
 
62
65
  try:
63
- await setup(run_setup, state_manager, cli_config)
66
+ await setup(run_setup or wizard, state_manager, cli_config, wizard_mode=wizard)
64
67
 
65
68
  # Initialize ToolHandler after setup
66
69
  tool_handler = ToolHandler(state_manager)
tunacode/cli/repl.py CHANGED
@@ -446,6 +446,9 @@ async def repl(state_manager: StateManager):
446
446
  await ui.success("Ready to assist")
447
447
  state_manager.session._startup_shown = True
448
448
 
449
+ # Offer tutorial to first-time users
450
+ await _offer_tutorial_if_appropriate(state_manager)
451
+
449
452
  instance = agent.get_or_create_agent(state_manager.session.current_model, state_manager)
450
453
 
451
454
  async with instance.run_mcp_servers():
@@ -539,3 +542,25 @@ async def repl(state_manager: StateManager):
539
542
  except (TypeError, ValueError):
540
543
  pass
541
544
  await ui.info(MSG_SESSION_ENDED)
545
+
546
+
547
+ async def _offer_tutorial_if_appropriate(state_manager: StateManager) -> None:
548
+ """Offer tutorial to first-time users if appropriate."""
549
+ try:
550
+ from tunacode.tutorial import TutorialManager
551
+
552
+ tutorial_manager = TutorialManager(state_manager)
553
+
554
+ # Check if we should offer tutorial
555
+ if await tutorial_manager.should_offer_tutorial():
556
+ # Offer tutorial to user
557
+ accepted = await tutorial_manager.offer_tutorial()
558
+ if accepted:
559
+ # Run tutorial
560
+ await tutorial_manager.run_tutorial()
561
+ except ImportError:
562
+ # Tutorial system not available, silently continue
563
+ pass
564
+ except Exception as e:
565
+ # Don't let tutorial errors crash the REPL
566
+ logger.warning(f"Tutorial offer failed: {e}")
@@ -24,6 +24,7 @@ DEFAULT_USER_CONFIG: UserConfig = {
24
24
  "fallback_response": True,
25
25
  "fallback_verbosity": "normal", # Options: minimal, normal, detailed
26
26
  "context_window_size": 200000,
27
+ "enable_streaming": True, # Always enable streaming
27
28
  "ripgrep": {
28
29
  "use_bundled": False, # Use system ripgrep binary
29
30
  "timeout": 10, # Search timeout in seconds
tunacode/constants.py CHANGED
@@ -9,7 +9,7 @@ from enum import Enum
9
9
 
10
10
  # Application info
11
11
  APP_NAME = "TunaCode"
12
- APP_VERSION = "0.0.67"
12
+ APP_VERSION = "0.0.69"
13
13
 
14
14
 
15
15
  # File patterns
@@ -94,27 +94,28 @@ def create_empty_response_message(
94
94
  iteration: int,
95
95
  state_manager: StateManager,
96
96
  ) -> str:
97
- """Create an aggressive message for handling empty responses."""
97
+ """Create a constructive message for handling empty responses."""
98
98
  tools_context = get_recent_tools_context(tool_calls)
99
99
 
100
- content = f"""FAILURE DETECTED: You returned {("an " + empty_reason if empty_reason != "empty" else "an empty")} response.
101
-
102
- This is UNACCEPTABLE. You FAILED to produce output.
100
+ content = f"""Response appears {empty_reason if empty_reason != "empty" else "empty"} or incomplete. Let's troubleshoot and try again.
103
101
 
104
102
  Task: {message[:200]}...
105
103
  {tools_context}
106
- Current iteration: {iteration}
104
+ Attempt: {iteration}
105
+
106
+ Please take one of these specific actions:
107
107
 
108
- TRY AGAIN RIGHT NOW:
108
+ 1. **Search yielded no results?** → Try alternative search terms or broader patterns
109
+ 2. **Found what you need?** → Use TUNACODE_TASK_COMPLETE to finalize
110
+ 3. **Encountering a blocker?** → Explain the specific issue preventing progress
111
+ 4. **Need more context?** → Use list_dir or expand your search scope
109
112
 
110
- 1. If your search returned no results → Try a DIFFERENT search pattern
111
- 2. If you found what you need Use TUNACODE_TASK_COMPLETE
112
- 3. If you're stuck EXPLAIN SPECIFICALLY what's blocking you
113
- 4. If you need to explore Use list_dir or broader searches
113
+ **Expected in your response:**
114
+ - Execute at least one tool OR provide substantial analysis
115
+ - If stuck, clearly describe what you've tried and what's blocking you
116
+ - Avoid empty responses - the system needs actionable output to proceed
114
117
 
115
- YOU MUST PRODUCE REAL OUTPUT IN THIS RESPONSE. NO EXCUSES.
116
- EXECUTE A TOOL OR PROVIDE SUBSTANTIAL CONTENT.
117
- DO NOT RETURN ANOTHER EMPTY RESPONSE."""
118
+ Ready to continue with a complete response."""
118
119
 
119
120
  return content
120
121
 
@@ -214,7 +214,7 @@ async def process_request(
214
214
  await ui.muted(
215
215
  f" Recent tools: {get_recent_tools_context(state_manager.session.tool_calls)}"
216
216
  )
217
- await ui.muted(" Injecting 'YOU FAILED TRY HARDER' prompt")
217
+ await ui.muted(" Injecting retry guidance prompt")
218
218
 
219
219
  state_manager.session.consecutive_empty_responses = 0
220
220
  else:
@@ -278,12 +278,10 @@ async def extract_and_execute_tool_calls(
278
278
  if not tool_callback:
279
279
  return
280
280
 
281
- # Format 1: {"tool": "name", "args": {...}}
282
- await parse_json_tool_calls(text, tool_callback, state_manager)
283
-
284
281
  # Format 2: Tool calls in code blocks
285
282
  code_block_pattern = r'```json\s*(\{(?:[^{}]|"[^"]*"|(?:\{[^}]*\}))*"tool"(?:[^{}]|"[^"]*"|(?:\{[^}]*\}))*\})\s*```'
286
283
  code_matches = re.findall(code_block_pattern, text, re.MULTILINE | re.DOTALL)
284
+ remaining_text = re.sub(code_block_pattern, "", text)
287
285
 
288
286
  for match in code_matches:
289
287
  try:
@@ -332,6 +330,9 @@ async def extract_and_execute_tool_calls(
332
330
  if state_manager.session.show_thoughts:
333
331
  await ui.error(f"Error parsing code block tool call: {e!s}")
334
332
 
333
+ # Format 1: {"tool": "name", "args": {...}}
334
+ await parse_json_tool_calls(remaining_text, tool_callback, state_manager)
335
+
335
336
 
336
337
  def patch_tool_messages(
337
338
  error_message: ErrorMessage = "Tool operation failed",