tunacode-cli 0.0.17__py3-none-any.whl → 0.0.19__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 (47) hide show
  1. tunacode/cli/commands.py +73 -41
  2. tunacode/cli/main.py +29 -26
  3. tunacode/cli/repl.py +91 -37
  4. tunacode/cli/textual_app.py +69 -66
  5. tunacode/cli/textual_bridge.py +33 -32
  6. tunacode/configuration/settings.py +2 -9
  7. tunacode/constants.py +2 -4
  8. tunacode/context.py +1 -1
  9. tunacode/core/agents/__init__.py +12 -0
  10. tunacode/core/agents/main.py +89 -63
  11. tunacode/core/agents/orchestrator.py +99 -0
  12. tunacode/core/agents/planner_schema.py +9 -0
  13. tunacode/core/agents/readonly.py +51 -0
  14. tunacode/core/background/__init__.py +0 -0
  15. tunacode/core/background/manager.py +36 -0
  16. tunacode/core/llm/__init__.py +0 -0
  17. tunacode/core/llm/planner.py +63 -0
  18. tunacode/core/setup/config_setup.py +79 -44
  19. tunacode/core/setup/coordinator.py +20 -13
  20. tunacode/core/setup/git_safety_setup.py +35 -49
  21. tunacode/core/state.py +2 -9
  22. tunacode/exceptions.py +0 -2
  23. tunacode/prompts/system.txt +179 -69
  24. tunacode/tools/__init__.py +10 -1
  25. tunacode/tools/base.py +1 -1
  26. tunacode/tools/bash.py +5 -5
  27. tunacode/tools/grep.py +210 -250
  28. tunacode/tools/read_file.py +2 -8
  29. tunacode/tools/run_command.py +4 -11
  30. tunacode/tools/update_file.py +2 -6
  31. tunacode/ui/completers.py +32 -31
  32. tunacode/ui/console.py +3 -3
  33. tunacode/ui/input.py +8 -5
  34. tunacode/ui/keybindings.py +1 -3
  35. tunacode/ui/lexers.py +16 -16
  36. tunacode/ui/output.py +2 -2
  37. tunacode/ui/panels.py +8 -8
  38. tunacode/ui/prompt_manager.py +19 -7
  39. tunacode/utils/import_cache.py +11 -0
  40. tunacode/utils/user_configuration.py +24 -2
  41. {tunacode_cli-0.0.17.dist-info → tunacode_cli-0.0.19.dist-info}/METADATA +68 -11
  42. tunacode_cli-0.0.19.dist-info/RECORD +75 -0
  43. tunacode_cli-0.0.17.dist-info/RECORD +0 -67
  44. {tunacode_cli-0.0.17.dist-info → tunacode_cli-0.0.19.dist-info}/WHEEL +0 -0
  45. {tunacode_cli-0.0.17.dist-info → tunacode_cli-0.0.19.dist-info}/entry_points.txt +0 -0
  46. {tunacode_cli-0.0.17.dist-info → tunacode_cli-0.0.19.dist-info}/licenses/LICENSE +0 -0
  47. {tunacode_cli-0.0.17.dist-info → tunacode_cli-0.0.19.dist-info}/top_level.txt +0 -0
@@ -38,9 +38,30 @@ class ConfigSetup(BaseSetup):
38
38
  return True
39
39
 
40
40
  async def execute(self, force_setup: bool = False) -> None:
41
- """Setup configuration and run onboarding if needed."""
41
+ """Setup configuration and run onboarding if needed, with config fingerprint fast path."""
42
+ import hashlib
43
+
42
44
  self.state_manager.session.device_id = system.get_device_id()
43
45
  loaded_config = user_configuration.load_config()
46
+ # Fast path: if config fingerprint matches last loaded and config is already present, skip reprocessing
47
+ new_fp = None
48
+ if loaded_config:
49
+ b = json.dumps(loaded_config, sort_keys=True).encode()
50
+ new_fp = hashlib.sha1(b).hexdigest()[:12]
51
+ last_fp = getattr(self.state_manager, "_config_fingerprint", None)
52
+ if (
53
+ loaded_config
54
+ and not force_setup
55
+ and new_fp
56
+ and last_fp == new_fp
57
+ and getattr(self.state_manager, "_config_valid", False)
58
+ ):
59
+ # Fast path: config unchanged, already validated
60
+ self.state_manager.session.user_config = loaded_config
61
+ self.state_manager.session.current_model = loaded_config["default_model"]
62
+ return
63
+ # Save current config fingerprint for next run
64
+ self.state_manager._config_fingerprint = new_fp
44
65
 
45
66
  # Handle CLI configuration if provided
46
67
  if self.cli_config and any(self.cli_config.values()):
@@ -50,9 +71,7 @@ class ConfigSetup(BaseSetup):
50
71
  if loaded_config and not force_setup:
51
72
  # Silent loading
52
73
  # Merge loaded config with defaults to ensure all required keys exist
53
- self.state_manager.session.user_config = self._merge_with_defaults(
54
- loaded_config
55
- )
74
+ self.state_manager.session.user_config = self._merge_with_defaults(loaded_config)
56
75
  else:
57
76
  if force_setup:
58
77
  await ui.muted("Running setup process, resetting config")
@@ -64,13 +83,18 @@ class ConfigSetup(BaseSetup):
64
83
  else:
65
84
  # No config found - show CLI usage instead of onboarding
66
85
  from tunacode.ui.console import console
86
+
67
87
  console.print("\n[bold red]No configuration found![/bold red]")
68
88
  console.print("\n[bold]Quick Setup:[/bold]")
69
89
  console.print("Configure TunaCode using CLI flags:")
70
90
  console.print("\n[blue]Examples:[/blue]")
71
91
  console.print(" [green]tunacode --model 'openai:gpt-4' --key 'your-key'[/green]")
72
- console.print(" [green]tunacode --model 'anthropic:claude-3-opus' --key 'your-key'[/green]")
73
- console.print(" [green]tunacode --model 'openrouter:anthropic/claude-3.5-sonnet' --key 'your-key' --baseurl 'https://openrouter.ai/api/v1'[/green]")
92
+ console.print(
93
+ " [green]tunacode --model 'anthropic:claude-3-opus' --key 'your-key'[/green]"
94
+ )
95
+ console.print(
96
+ " [green]tunacode --model 'openrouter:anthropic/claude-3.5-sonnet' --key 'your-key' --baseurl 'https://openrouter.ai/api/v1'[/green]"
97
+ )
74
98
  console.print("\n[yellow]Run 'tunacode --help' for more options[/yellow]\n")
75
99
  raise SystemExit(0)
76
100
 
@@ -84,23 +108,24 @@ class ConfigSetup(BaseSetup):
84
108
 
85
109
  # No model validation - trust user's model choice
86
110
 
87
- self.state_manager.session.current_model = (
88
- self.state_manager.session.user_config["default_model"]
89
- )
111
+ self.state_manager.session.current_model = self.state_manager.session.user_config[
112
+ "default_model"
113
+ ]
90
114
 
91
115
  async def validate(self) -> bool:
92
116
  """Validate that configuration is properly set up."""
93
117
  # Check that we have a user config
118
+ valid = True
94
119
  if not self.state_manager.session.user_config:
95
- return False
96
-
97
- # Check that we have a default model
98
- if not self.state_manager.session.user_config.get("default_model"):
99
- return False
100
-
101
- # No model validation - trust user input
102
-
103
- return True
120
+ valid = False
121
+ elif not self.state_manager.session.user_config.get("default_model"):
122
+ valid = False
123
+ # Cache result for fastpath
124
+ if valid:
125
+ setattr(self.state_manager, "_config_valid", True)
126
+ else:
127
+ setattr(self.state_manager, "_config_valid", False)
128
+ return valid
104
129
 
105
130
  def _merge_with_defaults(self, loaded_config: UserConfig) -> UserConfig:
106
131
  """Merge loaded config with defaults to ensure all required keys exist."""
@@ -119,9 +144,7 @@ class ConfigSetup(BaseSetup):
119
144
 
120
145
  async def _onboarding(self):
121
146
  """Run the onboarding process for new users."""
122
- initial_config = json.dumps(
123
- self.state_manager.session.user_config, sort_keys=True
124
- )
147
+ initial_config = json.dumps(self.state_manager.session.user_config, sort_keys=True)
125
148
 
126
149
  await self._step1_api_keys()
127
150
 
@@ -134,15 +157,11 @@ class ConfigSetup(BaseSetup):
134
157
  await self._step2_default_model()
135
158
 
136
159
  # Compare configs to see if anything changed
137
- current_config = json.dumps(
138
- self.state_manager.session.user_config, sort_keys=True
139
- )
160
+ current_config = json.dumps(self.state_manager.session.user_config, sort_keys=True)
140
161
  if initial_config != current_config:
141
162
  if user_configuration.save_config(self.state_manager):
142
163
  message = f"Config saved to: [bold]{self.config_file}[/bold]"
143
- await ui.panel(
144
- "Finished", message, top=0, border_style=UI_COLORS["success"]
145
- )
164
+ await ui.panel("Finished", message, top=0, border_style=UI_COLORS["success"])
146
165
  else:
147
166
  await ui.error("Failed to save configuration.")
148
167
  else:
@@ -194,8 +213,10 @@ class ConfigSetup(BaseSetup):
194
213
  async def _step2_default_model_simple(self):
195
214
  """Simple model selection - just enter model name."""
196
215
  await ui.muted("Format: provider:model-name")
197
- await ui.muted("Examples: openai:gpt-4.1, anthropic:claude-3-opus, google-gla:gemini-2.0-flash")
198
-
216
+ await ui.muted(
217
+ "Examples: openai:gpt-4.1, anthropic:claude-3-opus, google-gla:gemini-2.0-flash"
218
+ )
219
+
199
220
  while True:
200
221
  model_name = await ui.input(
201
222
  "step2",
@@ -203,14 +224,14 @@ class ConfigSetup(BaseSetup):
203
224
  state_manager=self.state_manager,
204
225
  )
205
226
  model_name = model_name.strip()
206
-
227
+
207
228
  # Check if provider prefix is present
208
229
  if ":" not in model_name:
209
230
  await ui.error("Model name must include provider prefix")
210
231
  await ui.muted("Format: provider:model-name")
211
232
  await ui.muted("You can always change it later with /model")
212
233
  continue
213
-
234
+
214
235
  # No validation - user is responsible for correct model names
215
236
  self.state_manager.session.user_config["default_model"] = model_name
216
237
  await ui.warning("Model set without validation - verify the model name is correct")
@@ -224,26 +245,38 @@ class ConfigSetup(BaseSetup):
224
245
  self.state_manager.session.user_config = self._merge_with_defaults(loaded_config)
225
246
  else:
226
247
  self.state_manager.session.user_config = DEFAULT_USER_CONFIG.copy()
227
-
248
+
228
249
  # Apply CLI overrides
229
250
  if self.cli_config.get("key"):
230
251
  # Determine which API key to set based on the model or baseurl
231
252
  if self.cli_config.get("baseurl") and "openrouter" in self.cli_config["baseurl"]:
232
- self.state_manager.session.user_config["env"]["OPENROUTER_API_KEY"] = self.cli_config["key"]
253
+ self.state_manager.session.user_config["env"]["OPENROUTER_API_KEY"] = (
254
+ self.cli_config["key"]
255
+ )
233
256
  elif self.cli_config.get("model"):
234
257
  if "claude" in self.cli_config["model"] or "anthropic" in self.cli_config["model"]:
235
- self.state_manager.session.user_config["env"]["ANTHROPIC_API_KEY"] = self.cli_config["key"]
258
+ self.state_manager.session.user_config["env"]["ANTHROPIC_API_KEY"] = (
259
+ self.cli_config["key"]
260
+ )
236
261
  elif "gpt" in self.cli_config["model"] or "openai" in self.cli_config["model"]:
237
- self.state_manager.session.user_config["env"]["OPENAI_API_KEY"] = self.cli_config["key"]
262
+ self.state_manager.session.user_config["env"]["OPENAI_API_KEY"] = (
263
+ self.cli_config["key"]
264
+ )
238
265
  elif "gemini" in self.cli_config["model"]:
239
- self.state_manager.session.user_config["env"]["GEMINI_API_KEY"] = self.cli_config["key"]
266
+ self.state_manager.session.user_config["env"]["GEMINI_API_KEY"] = (
267
+ self.cli_config["key"]
268
+ )
240
269
  else:
241
270
  # Default to OpenRouter for unknown models
242
- self.state_manager.session.user_config["env"]["OPENROUTER_API_KEY"] = self.cli_config["key"]
243
-
271
+ self.state_manager.session.user_config["env"]["OPENROUTER_API_KEY"] = (
272
+ self.cli_config["key"]
273
+ )
274
+
244
275
  if self.cli_config.get("baseurl"):
245
- self.state_manager.session.user_config["env"]["OPENAI_BASE_URL"] = self.cli_config["baseurl"]
246
-
276
+ self.state_manager.session.user_config["env"]["OPENAI_BASE_URL"] = self.cli_config[
277
+ "baseurl"
278
+ ]
279
+
247
280
  if self.cli_config.get("model"):
248
281
  model = self.cli_config["model"]
249
282
  # Require provider prefix
@@ -253,12 +286,14 @@ class ConfigSetup(BaseSetup):
253
286
  "Format: provider:model-name\n"
254
287
  "Examples: openai:gpt-4.1, anthropic:claude-3-opus"
255
288
  )
256
-
289
+
257
290
  self.state_manager.session.user_config["default_model"] = model
258
-
291
+
259
292
  # Set current model
260
- self.state_manager.session.current_model = self.state_manager.session.user_config["default_model"]
261
-
293
+ self.state_manager.session.current_model = self.state_manager.session.user_config[
294
+ "default_model"
295
+ ]
296
+
262
297
  # Save the configuration
263
298
  if user_configuration.save_config(self.state_manager):
264
299
  await ui.warning("Model set without validation - verify the model name is correct")
@@ -23,24 +23,31 @@ class SetupCoordinator:
23
23
  self.setup_steps.append(step)
24
24
 
25
25
  async def run_setup(self, force_setup: bool = False) -> None:
26
- """Run all registered setup steps in order."""
26
+ """Run all registered setup steps concurrently if possible."""
27
+ # Run should_run checks sequentially (they may depend on order)
28
+ steps_to_run = []
27
29
  for step in self.setup_steps:
28
30
  try:
29
31
  if await step.should_run(force_setup):
30
- # Silent setup - no messages
31
- await step.execute(force_setup)
32
-
33
- if not await step.validate():
34
- await ui.error(f"Setup validation failed: {step.name}")
35
- raise RuntimeError(
36
- f"Setup step '{step.name}' failed validation"
37
- )
38
- else:
39
- # Skip silently
40
- pass
32
+ steps_to_run.append(step)
41
33
  except Exception as e:
42
- await ui.error(f"Setup failed at step '{step.name}': {str(e)}")
34
+ await ui.error(
35
+ f"Setup failed at step '{getattr(step, 'name', repr(step))}': {str(e)}"
36
+ )
43
37
  raise
38
+ # Run all .execute(force_setup) in parallel where possible (independent steps)
39
+ from asyncio import gather
40
+
41
+ try:
42
+ await gather(*(step.execute(force_setup) for step in steps_to_run))
43
+ # Now validate all sequentially: if any fail, raise error
44
+ for step in steps_to_run:
45
+ if not await step.validate():
46
+ await ui.error(f"Setup validation failed: {step.name}")
47
+ raise RuntimeError(f"Setup step '{step.name}' failed validation")
48
+ except Exception as e:
49
+ await ui.error(f"Setup error: {str(e)}")
50
+ raise
44
51
 
45
52
  def clear_steps(self) -> None:
46
53
  """Clear all registered setup steps."""
@@ -13,15 +13,12 @@ from tunacode.ui.panels import panel
13
13
  async def yes_no_prompt(question: str, default: bool = True) -> bool:
14
14
  """Simple yes/no prompt."""
15
15
  default_text = "[Y/n]" if default else "[y/N]"
16
- response = await prompt_input(
17
- session_key="yes_no",
18
- pretext=f"{question} {default_text}: "
19
- )
20
-
16
+ response = await prompt_input(session_key="yes_no", pretext=f"{question} {default_text}: ")
17
+
21
18
  if not response.strip():
22
19
  return default
23
-
24
- return response.lower().strip() in ['y', 'yes']
20
+
21
+ return response.lower().strip() in ["y", "yes"]
25
22
 
26
23
 
27
24
  class GitSafetySetup(BaseSetup):
@@ -29,7 +26,7 @@ class GitSafetySetup(BaseSetup):
29
26
 
30
27
  def __init__(self, state_manager: StateManager):
31
28
  super().__init__(state_manager)
32
-
29
+
33
30
  @property
34
31
  def name(self) -> str:
35
32
  """Return the name of this setup step."""
@@ -45,18 +42,15 @@ class GitSafetySetup(BaseSetup):
45
42
  try:
46
43
  # Check if git is installed
47
44
  result = subprocess.run(
48
- ["git", "--version"],
49
- capture_output=True,
50
- text=True,
51
- check=False
45
+ ["git", "--version"], capture_output=True, text=True, check=False
52
46
  )
53
-
47
+
54
48
  if result.returncode != 0:
55
49
  await panel(
56
50
  "⚠️ Git Not Found",
57
51
  "Git is not installed or not in PATH. TunaCode will modify files directly.\n"
58
52
  "It's strongly recommended to install Git for safety.",
59
- border_style="yellow"
53
+ border_style="yellow",
60
54
  )
61
55
  return
62
56
 
@@ -66,33 +60,30 @@ class GitSafetySetup(BaseSetup):
66
60
  capture_output=True,
67
61
  text=True,
68
62
  check=False,
69
- cwd=Path.cwd()
63
+ cwd=Path.cwd(),
70
64
  )
71
-
65
+
72
66
  if result.returncode != 0:
73
67
  await panel(
74
68
  "⚠️ Not a Git Repository",
75
69
  "This directory is not a Git repository. TunaCode will modify files directly.\n"
76
70
  "Consider initializing a Git repository for safety: git init",
77
- border_style="yellow"
71
+ border_style="yellow",
78
72
  )
79
73
  return
80
74
 
81
75
  # Get current branch name
82
76
  result = subprocess.run(
83
- ["git", "branch", "--show-current"],
84
- capture_output=True,
85
- text=True,
86
- check=True
77
+ ["git", "branch", "--show-current"], capture_output=True, text=True, check=True
87
78
  )
88
79
  current_branch = result.stdout.strip()
89
-
80
+
90
81
  if not current_branch:
91
82
  # Detached HEAD state
92
83
  await panel(
93
84
  "⚠️ Detached HEAD State",
94
85
  "You're in a detached HEAD state. TunaCode will continue without creating a branch.",
95
- border_style="yellow"
86
+ border_style="yellow",
96
87
  )
97
88
  return
98
89
 
@@ -103,57 +94,52 @@ class GitSafetySetup(BaseSetup):
103
94
 
104
95
  # Propose new branch name
105
96
  new_branch = f"{current_branch}-tunacode"
106
-
97
+
107
98
  # Check if there are uncommitted changes
108
99
  result = subprocess.run(
109
- ["git", "status", "--porcelain"],
110
- capture_output=True,
111
- text=True,
112
- check=True
100
+ ["git", "status", "--porcelain"], capture_output=True, text=True, check=True
113
101
  )
114
-
102
+
115
103
  has_changes = bool(result.stdout.strip())
116
-
104
+
117
105
  # Ask user if they want to create a safety branch
118
106
  message = (
119
107
  f"For safety, TunaCode can create a new branch '{new_branch}' based on '{current_branch}'.\n"
120
108
  f"This helps protect your work from unintended changes.\n"
121
109
  )
122
-
110
+
123
111
  if has_changes:
124
- message += "\n⚠️ You have uncommitted changes that will be brought to the new branch."
125
-
126
- create_branch = await yes_no_prompt(
127
- f"{message}\n\nCreate safety branch?",
128
- default=True
129
- )
130
-
112
+ message += (
113
+ "\n⚠️ You have uncommitted changes that will be brought to the new branch."
114
+ )
115
+
116
+ create_branch = await yes_no_prompt(f"{message}\n\nCreate safety branch?", default=True)
117
+
131
118
  if not create_branch:
132
119
  # User declined - show warning
133
120
  await panel(
134
121
  "⚠️ Working Without Safety Branch",
135
122
  "You've chosen to work directly on your current branch.\n"
136
123
  "TunaCode will modify files in place. Make sure you have backups!",
137
- border_style="red"
124
+ border_style="red",
138
125
  )
139
126
  # Save preference
140
127
  self.state_manager.session.user_config["skip_git_safety"] = True
141
128
  return
142
-
129
+
143
130
  # Create and checkout the new branch
144
131
  try:
145
132
  # Check if branch already exists
146
133
  result = subprocess.run(
147
134
  ["git", "show-ref", "--verify", f"refs/heads/{new_branch}"],
148
135
  capture_output=True,
149
- check=False
136
+ check=False,
150
137
  )
151
-
138
+
152
139
  if result.returncode == 0:
153
140
  # Branch exists, ask to use it
154
141
  use_existing = await yes_no_prompt(
155
- f"Branch '{new_branch}' already exists. Switch to it?",
156
- default=True
142
+ f"Branch '{new_branch}' already exists. Switch to it?", default=True
157
143
  )
158
144
  if use_existing:
159
145
  subprocess.run(["git", "checkout", new_branch], check=True)
@@ -164,24 +150,24 @@ class GitSafetySetup(BaseSetup):
164
150
  # Create new branch
165
151
  subprocess.run(["git", "checkout", "-b", new_branch], check=True)
166
152
  await ui.success(f"Created and switched to new branch: {new_branch}")
167
-
153
+
168
154
  except subprocess.CalledProcessError as e:
169
155
  await panel(
170
156
  "❌ Failed to Create Branch",
171
157
  f"Could not create branch '{new_branch}': {str(e)}\n"
172
158
  "Continuing on current branch.",
173
- border_style="red"
159
+ border_style="red",
174
160
  )
175
-
161
+
176
162
  except Exception as e:
177
163
  # Non-fatal error - just warn the user
178
164
  await panel(
179
165
  "⚠️ Git Safety Setup Failed",
180
166
  f"Could not set up Git safety: {str(e)}\n"
181
167
  "TunaCode will continue without branch protection.",
182
- border_style="yellow"
168
+ border_style="yellow",
183
169
  )
184
170
 
185
171
  async def validate(self) -> bool:
186
172
  """Validate git safety setup - always returns True as this is optional."""
187
- return True
173
+ return True
tunacode/core/state.py CHANGED
@@ -8,15 +8,8 @@ import uuid
8
8
  from dataclasses import dataclass, field
9
9
  from typing import Any, Optional
10
10
 
11
- from tunacode.types import (
12
- DeviceId,
13
- InputSessions,
14
- MessageHistory,
15
- ModelName,
16
- SessionId,
17
- ToolName,
18
- UserConfig,
19
- )
11
+ from tunacode.types import (DeviceId, InputSessions, MessageHistory, ModelName, SessionId, ToolName,
12
+ UserConfig)
20
13
 
21
14
 
22
15
  @dataclass
tunacode/exceptions.py CHANGED
@@ -77,8 +77,6 @@ class MCPError(ServiceError):
77
77
  super().__init__(f"MCP server '{server_name}' error: {message}")
78
78
 
79
79
 
80
-
81
-
82
80
  class GitOperationError(ServiceError):
83
81
  """Raised when Git operations fail."""
84
82