kubrick-cli 0.1.2__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.
@@ -0,0 +1,172 @@
1
+ """Setup wizard for first-time Kubrick configuration."""
2
+
3
+ from rich.console import Console
4
+ from rich.panel import Panel
5
+ from rich.prompt import Prompt
6
+ from rich.table import Table
7
+
8
+ from .providers.factory import ProviderFactory
9
+
10
+ console = Console()
11
+
12
+
13
+ class SetupWizard:
14
+ """
15
+ Interactive setup wizard for Kubrick configuration.
16
+
17
+ This wizard automatically discovers all available providers and
18
+ generates the configuration UI based on provider metadata.
19
+ """
20
+
21
+ @staticmethod
22
+ def run() -> dict:
23
+ """
24
+ Run the setup wizard.
25
+
26
+ Returns:
27
+ Dictionary with provider configuration
28
+ """
29
+ console.print(
30
+ Panel.fit(
31
+ "[bold cyan]Welcome to Kubrick![/bold cyan]\n\n"
32
+ "Let's set up your AI provider.\n"
33
+ "You can change these settings later in ~/.kubrick/config.json",
34
+ border_style="cyan",
35
+ )
36
+ )
37
+
38
+ # Step 1: Select provider (dynamically discovered)
39
+ provider_metadata = SetupWizard._select_provider()
40
+
41
+ # Step 2: Get provider-specific configuration (dynamic)
42
+ config = SetupWizard._configure_provider(provider_metadata)
43
+ config["provider"] = provider_metadata.name
44
+
45
+ # Step 3: Show summary
46
+ SetupWizard._show_summary(provider_metadata, config)
47
+
48
+ console.print("\n[green]✓ Setup complete![/green]")
49
+ console.print(
50
+ "[dim]Your configuration has been saved to ~/.kubrick/config.json[/dim]\n"
51
+ )
52
+
53
+ return config
54
+
55
+ @staticmethod
56
+ def _select_provider():
57
+ """
58
+ Prompt user to select a provider from all discovered providers.
59
+
60
+ Returns:
61
+ ProviderMetadata object for selected provider
62
+ """
63
+ console.print("\n[bold]Step 1: Select Your AI Provider[/bold]\n")
64
+
65
+ # Get all available providers
66
+ providers = ProviderFactory.list_available_providers()
67
+
68
+ if not providers:
69
+ raise RuntimeError(
70
+ "No providers found! Please ensure provider files are in kubrick_cli/providers/"
71
+ )
72
+
73
+ # Build provider table
74
+ table = Table(show_header=True, header_style="bold cyan")
75
+ table.add_column("Option", style="cyan")
76
+ table.add_column("Provider", style="green")
77
+ table.add_column("Description")
78
+
79
+ # Create mapping of choices to providers
80
+ choice_map = {}
81
+ for idx, provider in enumerate(providers, start=1):
82
+ choice_map[str(idx)] = provider
83
+ table.add_row(
84
+ str(idx),
85
+ provider.display_name,
86
+ provider.description,
87
+ )
88
+
89
+ console.print(table)
90
+ console.print()
91
+
92
+ # Get user choice
93
+ choices = list(choice_map.keys())
94
+ choice = Prompt.ask(
95
+ "[bold yellow]Choose your provider[/bold yellow]",
96
+ choices=choices,
97
+ default="1",
98
+ )
99
+
100
+ return choice_map[choice]
101
+
102
+ @staticmethod
103
+ def _configure_provider(metadata) -> dict:
104
+ """
105
+ Get provider-specific configuration based on metadata.
106
+
107
+ Args:
108
+ metadata: ProviderMetadata object
109
+
110
+ Returns:
111
+ Configuration dictionary
112
+ """
113
+ console.print(f"\n[bold]Step 2: Configure {metadata.display_name}[/bold]\n")
114
+
115
+ config = {}
116
+
117
+ for field in metadata.config_fields:
118
+ key = field["key"]
119
+ label = field["label"]
120
+ field_type = field.get("type", "text")
121
+ default = field.get("default")
122
+ help_text = field.get("help_text")
123
+
124
+ # Show help text if provided
125
+ if help_text:
126
+ console.print(f"[dim]{help_text}[/dim]\n")
127
+
128
+ # Prompt for the value
129
+ if field_type == "password":
130
+ value = Prompt.ask(
131
+ f"[cyan]{label}[/cyan]",
132
+ password=True,
133
+ )
134
+ elif default is not None:
135
+ value = Prompt.ask(
136
+ f"[cyan]{label}[/cyan]",
137
+ default=str(default),
138
+ )
139
+ else:
140
+ value = Prompt.ask(f"[cyan]{label}[/cyan]")
141
+
142
+ config[key] = value
143
+
144
+ return config
145
+
146
+ @staticmethod
147
+ def _show_summary(metadata, config: dict):
148
+ """
149
+ Show configuration summary.
150
+
151
+ Args:
152
+ metadata: ProviderMetadata object
153
+ config: Configuration dict
154
+ """
155
+ console.print("\n[bold]Configuration Summary[/bold]\n")
156
+
157
+ console.print(f"[cyan]Provider:[/cyan] {metadata.display_name}")
158
+
159
+ # Show each configured field
160
+ for field in metadata.config_fields:
161
+ key = field["key"]
162
+ label = field["label"]
163
+ field_type = field.get("type", "text")
164
+ value = config.get(key)
165
+
166
+ # Mask passwords
167
+ if field_type == "password" and value:
168
+ display_value = f"{'*' * 20}{value[-4:]}"
169
+ else:
170
+ display_value = value
171
+
172
+ console.print(f"[cyan]{label}:[/cyan] {display_value}")
kubrick_cli/tools.py ADDED
@@ -0,0 +1,407 @@
1
+ """Tool definitions and execution handlers."""
2
+
3
+ import subprocess
4
+ from pathlib import Path
5
+ from typing import Any, Dict
6
+
7
+ TOOL_DEFINITIONS = [
8
+ {
9
+ "name": "read_file",
10
+ "description": "Read the contents of a file from the filesystem",
11
+ "read_only": True,
12
+ "estimated_duration": 1,
13
+ "parameters": {
14
+ "type": "object",
15
+ "properties": {
16
+ "file_path": {
17
+ "type": "string",
18
+ "description": "The absolute or relative path to the file to read",
19
+ },
20
+ },
21
+ "required": ["file_path"],
22
+ },
23
+ },
24
+ {
25
+ "name": "write_file",
26
+ "description": "Write content to a file (creates or overwrites)",
27
+ "read_only": False,
28
+ "estimated_duration": 2,
29
+ "parameters": {
30
+ "type": "object",
31
+ "properties": {
32
+ "file_path": {
33
+ "type": "string",
34
+ "description": "The absolute or relative path to the file to write",
35
+ },
36
+ "content": {
37
+ "type": "string",
38
+ "description": "The content to write to the file",
39
+ },
40
+ },
41
+ "required": ["file_path", "content"],
42
+ },
43
+ },
44
+ {
45
+ "name": "edit_file",
46
+ "description": "Edit a file by replacing a specific string with new content",
47
+ "read_only": False,
48
+ "estimated_duration": 2,
49
+ "parameters": {
50
+ "type": "object",
51
+ "properties": {
52
+ "file_path": {
53
+ "type": "string",
54
+ "description": "The path to the file to edit",
55
+ },
56
+ "old_string": {
57
+ "type": "string",
58
+ "description": "The exact string to find and replace",
59
+ },
60
+ "new_string": {
61
+ "type": "string",
62
+ "description": "The new string to replace with",
63
+ },
64
+ },
65
+ "required": ["file_path", "old_string", "new_string"],
66
+ },
67
+ },
68
+ {
69
+ "name": "list_files",
70
+ "description": "List files matching a glob pattern (*.py, **/*.py, etc)",
71
+ "read_only": True,
72
+ "estimated_duration": 1,
73
+ "parameters": {
74
+ "type": "object",
75
+ "properties": {
76
+ "pattern": {
77
+ "type": "string",
78
+ "description": "Glob pattern to match files (e.g., '*.py', 'src/**/*.ts')",
79
+ },
80
+ "directory": {
81
+ "type": "string",
82
+ "description": "Directory to search in (defaults to current directory)",
83
+ },
84
+ },
85
+ "required": ["pattern"],
86
+ },
87
+ },
88
+ {
89
+ "name": "search_files",
90
+ "description": "Search for text content within files using grep-like functionality",
91
+ "read_only": True,
92
+ "estimated_duration": 2,
93
+ "parameters": {
94
+ "type": "object",
95
+ "properties": {
96
+ "pattern": {
97
+ "type": "string",
98
+ "description": "Text pattern or regex to search for",
99
+ },
100
+ "file_pattern": {
101
+ "type": "string",
102
+ "description": "File glob pattern to search within (e.g., '*.py')",
103
+ },
104
+ "directory": {
105
+ "type": "string",
106
+ "description": "Directory to search in (defaults to current directory)",
107
+ },
108
+ },
109
+ "required": ["pattern"],
110
+ },
111
+ },
112
+ {
113
+ "name": "run_bash",
114
+ "description": "Execute a bash command and return its output",
115
+ "read_only": False,
116
+ "estimated_duration": 5,
117
+ "parameters": {
118
+ "type": "object",
119
+ "properties": {
120
+ "command": {
121
+ "type": "string",
122
+ "description": "The bash command to execute",
123
+ },
124
+ },
125
+ "required": ["command"],
126
+ },
127
+ },
128
+ {
129
+ "name": "create_directory",
130
+ "description": "Create a new directory",
131
+ "read_only": False,
132
+ "estimated_duration": 1,
133
+ "parameters": {
134
+ "type": "object",
135
+ "properties": {
136
+ "path": {
137
+ "type": "string",
138
+ "description": "The path of the directory to create",
139
+ },
140
+ },
141
+ "required": ["path"],
142
+ },
143
+ },
144
+ ]
145
+
146
+
147
+ class ToolExecutor:
148
+ """Handles execution of tools."""
149
+
150
+ def __init__(self, working_dir: str = None, safety_manager=None):
151
+ """
152
+ Initialize tool executor.
153
+
154
+ Args:
155
+ working_dir: Working directory for file operations (defaults to current directory)
156
+ safety_manager: Optional SafetyManager for validation
157
+ """
158
+ self.working_dir = Path(working_dir) if working_dir else Path.cwd()
159
+ self.safety_manager = safety_manager
160
+
161
+ def _resolve_path(self, path: str) -> Path:
162
+ """Resolve a path relative to working directory."""
163
+ p = Path(path)
164
+ if p.is_absolute():
165
+ return p
166
+ return (self.working_dir / p).resolve()
167
+
168
+ def execute(self, tool_name: str, parameters: Dict[str, Any]) -> Dict[str, Any]:
169
+ """
170
+ Execute a tool with given parameters.
171
+
172
+ Args:
173
+ tool_name: Name of the tool to execute
174
+ parameters: Tool parameters
175
+
176
+ Returns:
177
+ Dict with 'success', 'result', and optionally 'error' keys
178
+ """
179
+ try:
180
+ if tool_name == "read_file":
181
+ return self._read_file(parameters)
182
+ elif tool_name == "write_file":
183
+ return self._write_file(parameters)
184
+ elif tool_name == "edit_file":
185
+ return self._edit_file(parameters)
186
+ elif tool_name == "list_files":
187
+ return self._list_files(parameters)
188
+ elif tool_name == "search_files":
189
+ return self._search_files(parameters)
190
+ elif tool_name == "run_bash":
191
+ return self._run_bash(parameters)
192
+ elif tool_name == "create_directory":
193
+ return self._create_directory(parameters)
194
+ else:
195
+ return {
196
+ "success": False,
197
+ "error": f"Unknown tool: {tool_name}",
198
+ }
199
+ except Exception as e:
200
+ return {
201
+ "success": False,
202
+ "error": str(e),
203
+ }
204
+
205
+ def _read_file(self, params: Dict) -> Dict:
206
+ """Read a file."""
207
+ file_path = self._resolve_path(params["file_path"])
208
+
209
+ if not file_path.exists():
210
+ return {"success": False, "error": f"File not found: {file_path}"}
211
+
212
+ content = file_path.read_text()
213
+ return {
214
+ "success": True,
215
+ "result": f"Content of {file_path}:\n\n{content}",
216
+ }
217
+
218
+ def _write_file(self, params: Dict) -> Dict:
219
+ """Write to a file."""
220
+ file_path = self._resolve_path(params["file_path"])
221
+ content = params["content"]
222
+
223
+ # Create parent directories if needed
224
+ file_path.parent.mkdir(parents=True, exist_ok=True)
225
+
226
+ file_path.write_text(content)
227
+ return {
228
+ "success": True,
229
+ "result": f"Successfully wrote {len(content)} characters to {file_path}",
230
+ }
231
+
232
+ def _edit_file(self, params: Dict) -> Dict:
233
+ """Edit a file by replacing text."""
234
+ file_path = self._resolve_path(params["file_path"])
235
+ old_string = params["old_string"]
236
+ new_string = params["new_string"]
237
+
238
+ if not file_path.exists():
239
+ return {"success": False, "error": f"File not found: {file_path}"}
240
+
241
+ content = file_path.read_text()
242
+
243
+ if old_string not in content:
244
+ return {
245
+ "success": False,
246
+ "error": f"String not found in file: {old_string[:100]}...",
247
+ }
248
+
249
+ new_content = content.replace(old_string, new_string, 1)
250
+ file_path.write_text(new_content)
251
+
252
+ return {
253
+ "success": True,
254
+ "result": f"Successfully edited {file_path}",
255
+ }
256
+
257
+ def _list_files(self, params: Dict) -> Dict:
258
+ """List files matching a pattern."""
259
+ pattern = params["pattern"]
260
+ directory = params.get("directory", ".")
261
+ search_dir = self._resolve_path(directory)
262
+
263
+ # Use glob to find matching files and directories
264
+ files = []
265
+ directories = []
266
+
267
+ for match in search_dir.glob(pattern):
268
+ rel_path = match.relative_to(search_dir)
269
+ if match.is_file():
270
+ files.append(str(rel_path))
271
+ elif match.is_dir():
272
+ directories.append(str(rel_path) + "/")
273
+
274
+ files.sort()
275
+ directories.sort()
276
+
277
+ # Combine results
278
+ matches = directories + files
279
+
280
+ if not matches:
281
+ result = f"No files or directories found matching pattern: {pattern}"
282
+ else:
283
+ dir_count = len(directories)
284
+ file_count = len(files)
285
+ result = f"Found {dir_count} directories and {file_count} files:\n"
286
+
287
+ if directories:
288
+ result += "\nDirectories:\n" + "\n".join(directories)
289
+ if files:
290
+ result += "\n\nFiles:\n" + "\n".join(files)
291
+
292
+ return {"success": True, "result": result}
293
+
294
+ def _search_files(self, params: Dict) -> Dict:
295
+ """Search for text in files."""
296
+ pattern = params["pattern"]
297
+ file_pattern = params.get("file_pattern", "**/*")
298
+ directory = params.get("directory", ".")
299
+ search_dir = self._resolve_path(directory)
300
+
301
+ results = []
302
+
303
+ # Find files matching the file pattern
304
+ for file_path in search_dir.glob(file_pattern):
305
+ if not file_path.is_file():
306
+ continue
307
+
308
+ try:
309
+ content = file_path.read_text()
310
+ lines = content.split("\n")
311
+
312
+ for line_num, line in enumerate(lines, 1):
313
+ if pattern in line:
314
+ rel_path = file_path.relative_to(search_dir)
315
+ results.append(f"{rel_path}:{line_num}: {line.strip()}")
316
+ except (UnicodeDecodeError, PermissionError):
317
+ # Skip binary files or files we can't read
318
+ continue
319
+
320
+ if not results:
321
+ result = f"No matches found for pattern: {pattern}"
322
+ else:
323
+ result = f"Found {len(results)} matches:\n" + "\n".join(results[:50])
324
+ if len(results) > 50:
325
+ result += f"\n... and {len(results) - 50} more"
326
+
327
+ return {"success": True, "result": result}
328
+
329
+ def _run_bash(self, params: Dict) -> Dict:
330
+ """Run a bash command."""
331
+ command = params["command"]
332
+
333
+ # Safety check: validate command with SafetyManager
334
+ if self.safety_manager:
335
+ is_safe, warning = self.safety_manager.validate_bash_command(command)
336
+
337
+ if not is_safe:
338
+ # Request user confirmation for dangerous command
339
+ confirmed = self.safety_manager.get_user_confirmation(warning, command)
340
+
341
+ if not confirmed:
342
+ return {
343
+ "success": False,
344
+ "error": "Command cancelled by user (dangerous command)",
345
+ }
346
+
347
+ try:
348
+ # Use timeout from safety config if available
349
+ timeout = 30
350
+ if self.safety_manager and hasattr(self.safety_manager, "config"):
351
+ timeout = self.safety_manager.config.tool_timeout_seconds
352
+
353
+ result = subprocess.run(
354
+ command,
355
+ shell=True, # nosec B602 - CLI tool with intentional shell access
356
+ capture_output=True,
357
+ text=True,
358
+ timeout=timeout,
359
+ cwd=self.working_dir,
360
+ )
361
+
362
+ output = result.stdout
363
+ if result.stderr:
364
+ output += f"\nSTDERR:\n{result.stderr}"
365
+
366
+ return {
367
+ "success": True,
368
+ "result": f"Command: {command}\nExit code: {result.returncode}\n\n{output}",
369
+ }
370
+ except subprocess.TimeoutExpired:
371
+ return {
372
+ "success": False,
373
+ "error": f"Command timed out after {timeout} seconds",
374
+ }
375
+
376
+ def _create_directory(self, params: Dict) -> Dict:
377
+ """Create a directory."""
378
+ path = self._resolve_path(params["path"])
379
+ path.mkdir(parents=True, exist_ok=True)
380
+ return {
381
+ "success": True,
382
+ "result": f"Created directory: {path}",
383
+ }
384
+
385
+
386
+ def get_tools_prompt() -> str:
387
+ """Get a formatted string describing available tools for the LLM."""
388
+ tools_desc = ""
389
+
390
+ for tool in TOOL_DEFINITIONS:
391
+ tools_desc += f"### {tool['name']}\n"
392
+ tools_desc += f"{tool['description']}\n\n"
393
+
394
+ # List parameters more clearly
395
+ props = tool["parameters"].get("properties", {})
396
+ required = tool["parameters"].get("required", [])
397
+
398
+ if props:
399
+ tools_desc += "**Parameters:**\n"
400
+ for param_name, param_info in props.items():
401
+ req_marker = " (required)" if param_name in required else " (optional)"
402
+ param_desc = param_info.get("description", "No description")
403
+ tools_desc += f"- `{param_name}`: {param_desc}{req_marker}\n"
404
+
405
+ tools_desc += "\n"
406
+
407
+ return tools_desc