squidbot 0.1.0__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,599 @@
1
+ """Coding agent tools for Zig and Python with workspace support."""
2
+
3
+ import asyncio
4
+ import logging
5
+ import os
6
+ import shutil
7
+ import subprocess
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+ from typing import Literal
11
+
12
+ from ..config import DATA_DIR
13
+ from .base import Tool
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ # Workspace directory
18
+ WORKSPACE_DIR = DATA_DIR / "coding"
19
+
20
+ # Supported languages
21
+ Language = Literal["zig", "python"]
22
+
23
+
24
+ def get_workspace() -> Path:
25
+ """Get or create the coding workspace directory."""
26
+ WORKSPACE_DIR.mkdir(parents=True, exist_ok=True)
27
+ return WORKSPACE_DIR
28
+
29
+
30
+ def get_project_dir(project_name: str) -> Path:
31
+ """Get project directory within workspace."""
32
+ project_dir = get_workspace() / project_name
33
+ project_dir.mkdir(parents=True, exist_ok=True)
34
+ return project_dir
35
+
36
+
37
+ async def run_command(
38
+ cmd: list[str],
39
+ cwd: Path | None = None,
40
+ timeout: int = 30,
41
+ ) -> tuple[int, str, str]:
42
+ """Run a command asynchronously with timeout."""
43
+ try:
44
+ process = await asyncio.create_subprocess_exec(
45
+ *cmd,
46
+ cwd=cwd,
47
+ stdout=asyncio.subprocess.PIPE,
48
+ stderr=asyncio.subprocess.PIPE,
49
+ )
50
+ stdout, stderr = await asyncio.wait_for(
51
+ process.communicate(),
52
+ timeout=timeout,
53
+ )
54
+ return (
55
+ process.returncode or 0,
56
+ stdout.decode("utf-8", errors="replace"),
57
+ stderr.decode("utf-8", errors="replace"),
58
+ )
59
+ except asyncio.TimeoutError:
60
+ process.kill()
61
+ return -1, "", f"Command timed out after {timeout}s"
62
+ except Exception as e:
63
+ return -1, "", str(e)
64
+
65
+
66
+ class CodeWriteTool(Tool):
67
+ """Write code to a file in the workspace."""
68
+
69
+ @property
70
+ def name(self) -> str:
71
+ return "code_write"
72
+
73
+ @property
74
+ def description(self) -> str:
75
+ return (
76
+ "Write code to a file in the coding workspace (.squidbot/coding/). "
77
+ "Supports Zig (.zig) and Python (.py) files. "
78
+ "Creates project directories automatically."
79
+ )
80
+
81
+ @property
82
+ def parameters(self) -> dict:
83
+ return {
84
+ "type": "object",
85
+ "properties": {
86
+ "project": {
87
+ "type": "string",
88
+ "description": "Project name (creates subdirectory)",
89
+ },
90
+ "filename": {
91
+ "type": "string",
92
+ "description": "Filename with extension (.zig or .py)",
93
+ },
94
+ "code": {
95
+ "type": "string",
96
+ "description": "The code to write",
97
+ },
98
+ },
99
+ "required": ["project", "filename", "code"],
100
+ }
101
+
102
+ async def execute(self, project: str, filename: str, code: str) -> str:
103
+ project_dir = get_project_dir(project)
104
+ file_path = project_dir / filename
105
+
106
+ # Validate extension
107
+ ext = file_path.suffix.lower()
108
+ if ext not in (".zig", ".py"):
109
+ return f"Error: Unsupported file type '{ext}'. Use .zig or .py"
110
+
111
+ # Write file
112
+ file_path.write_text(code, encoding="utf-8")
113
+ logger.info(f"Wrote {len(code)} bytes to {file_path}")
114
+
115
+ # Try to get relative path, fall back to absolute
116
+ try:
117
+ rel_path = file_path.relative_to(DATA_DIR.parent)
118
+ return f"Written to {rel_path}"
119
+ except ValueError:
120
+ return f"Written to {file_path}"
121
+
122
+
123
+ class CodeReadTool(Tool):
124
+ """Read code from a file in the workspace."""
125
+
126
+ @property
127
+ def name(self) -> str:
128
+ return "code_read"
129
+
130
+ @property
131
+ def description(self) -> str:
132
+ return "Read code from a file in the coding workspace."
133
+
134
+ @property
135
+ def parameters(self) -> dict:
136
+ return {
137
+ "type": "object",
138
+ "properties": {
139
+ "project": {
140
+ "type": "string",
141
+ "description": "Project name",
142
+ },
143
+ "filename": {
144
+ "type": "string",
145
+ "description": "Filename to read",
146
+ },
147
+ },
148
+ "required": ["project", "filename"],
149
+ }
150
+
151
+ async def execute(self, project: str, filename: str) -> str:
152
+ project_dir = get_project_dir(project)
153
+ file_path = project_dir / filename
154
+
155
+ if not file_path.exists():
156
+ return f"Error: File not found: {filename}"
157
+
158
+ code = file_path.read_text(encoding="utf-8")
159
+ return f"```{file_path.suffix[1:]}\n{code}\n```"
160
+
161
+
162
+ class CodeRunTool(Tool):
163
+ """Run code in the workspace."""
164
+
165
+ @property
166
+ def name(self) -> str:
167
+ return "code_run"
168
+
169
+ @property
170
+ def description(self) -> str:
171
+ return (
172
+ "Run code in the workspace. "
173
+ "For Zig: compiles and runs .zig files. "
174
+ "For Python: executes .py files. "
175
+ "Returns stdout, stderr, and exit code."
176
+ )
177
+
178
+ @property
179
+ def parameters(self) -> dict:
180
+ return {
181
+ "type": "object",
182
+ "properties": {
183
+ "project": {
184
+ "type": "string",
185
+ "description": "Project name",
186
+ },
187
+ "filename": {
188
+ "type": "string",
189
+ "description": "Filename to run",
190
+ },
191
+ "args": {
192
+ "type": "array",
193
+ "items": {"type": "string"},
194
+ "description": "Command line arguments (optional)",
195
+ },
196
+ "timeout": {
197
+ "type": "integer",
198
+ "description": "Timeout in seconds (default 30)",
199
+ "default": 30,
200
+ },
201
+ },
202
+ "required": ["project", "filename"],
203
+ }
204
+
205
+ async def execute(
206
+ self,
207
+ project: str,
208
+ filename: str,
209
+ args: list[str] | None = None,
210
+ timeout: int = 30,
211
+ ) -> str:
212
+ project_dir = get_project_dir(project)
213
+ file_path = project_dir / filename
214
+ args = args or []
215
+
216
+ if not file_path.exists():
217
+ return f"Error: File not found: {filename}"
218
+
219
+ ext = file_path.suffix.lower()
220
+
221
+ if ext == ".zig":
222
+ return await self._run_zig(file_path, project_dir, args, timeout)
223
+ elif ext == ".py":
224
+ return await self._run_python(file_path, args, timeout)
225
+ else:
226
+ return f"Error: Cannot run '{ext}' files. Use .zig or .py"
227
+
228
+ async def _run_zig(
229
+ self,
230
+ file_path: Path,
231
+ project_dir: Path,
232
+ args: list[str],
233
+ timeout: int,
234
+ ) -> str:
235
+ # Check if zig is available
236
+ zig_path = shutil.which("zig")
237
+ if not zig_path:
238
+ return "Error: Zig compiler not found. Please install Zig."
239
+
240
+ # Compile and run
241
+ cmd = ["zig", "run", str(file_path)] + args
242
+ code, stdout, stderr = await run_command(cmd, cwd=project_dir, timeout=timeout)
243
+
244
+ result = []
245
+ if stdout:
246
+ result.append(f"stdout:\n{stdout}")
247
+ if stderr:
248
+ result.append(f"stderr:\n{stderr}")
249
+ result.append(f"exit code: {code}")
250
+
251
+ return "\n".join(result) if result else "No output"
252
+
253
+ async def _run_python(
254
+ self,
255
+ file_path: Path,
256
+ args: list[str],
257
+ timeout: int,
258
+ ) -> str:
259
+ # Use same Python interpreter
260
+ import sys
261
+
262
+ cmd = [sys.executable, str(file_path)] + args
263
+ code, stdout, stderr = await run_command(
264
+ cmd, cwd=file_path.parent, timeout=timeout
265
+ )
266
+
267
+ result = []
268
+ if stdout:
269
+ result.append(f"stdout:\n{stdout}")
270
+ if stderr:
271
+ result.append(f"stderr:\n{stderr}")
272
+ result.append(f"exit code: {code}")
273
+
274
+ return "\n".join(result) if result else "No output"
275
+
276
+
277
+ class CodeListTool(Tool):
278
+ """List files in a project."""
279
+
280
+ @property
281
+ def name(self) -> str:
282
+ return "code_list"
283
+
284
+ @property
285
+ def description(self) -> str:
286
+ return "List files in a project or all projects in the workspace."
287
+
288
+ @property
289
+ def parameters(self) -> dict:
290
+ return {
291
+ "type": "object",
292
+ "properties": {
293
+ "project": {
294
+ "type": "string",
295
+ "description": "Project name (optional, lists all projects if omitted)",
296
+ },
297
+ },
298
+ "required": [],
299
+ }
300
+
301
+ async def execute(self, project: str | None = None) -> str:
302
+ workspace = get_workspace()
303
+
304
+ if project:
305
+ project_dir = workspace / project
306
+ if not project_dir.exists():
307
+ return f"Project '{project}' not found"
308
+
309
+ files = sorted(project_dir.rglob("*"))
310
+ if not files:
311
+ return f"Project '{project}' is empty"
312
+
313
+ lines = [f"Files in {project}:"]
314
+ for f in files:
315
+ if f.is_file():
316
+ rel = f.relative_to(project_dir)
317
+ size = f.stat().st_size
318
+ lines.append(f" {rel} ({size} bytes)")
319
+ return "\n".join(lines)
320
+ else:
321
+ # List all projects
322
+ projects = sorted([d for d in workspace.iterdir() if d.is_dir()])
323
+ if not projects:
324
+ return "No projects in workspace"
325
+
326
+ lines = ["Projects:"]
327
+ for p in projects:
328
+ file_count = len(list(p.rglob("*")))
329
+ lines.append(f" {p.name}/ ({file_count} files)")
330
+ return "\n".join(lines)
331
+
332
+
333
+ class CodeDeleteTool(Tool):
334
+ """Delete a file or project."""
335
+
336
+ @property
337
+ def name(self) -> str:
338
+ return "code_delete"
339
+
340
+ @property
341
+ def description(self) -> str:
342
+ return "Delete a file or entire project from the workspace."
343
+
344
+ @property
345
+ def parameters(self) -> dict:
346
+ return {
347
+ "type": "object",
348
+ "properties": {
349
+ "project": {
350
+ "type": "string",
351
+ "description": "Project name",
352
+ },
353
+ "filename": {
354
+ "type": "string",
355
+ "description": "Filename to delete (omit to delete entire project)",
356
+ },
357
+ },
358
+ "required": ["project"],
359
+ }
360
+
361
+ async def execute(self, project: str, filename: str | None = None) -> str:
362
+ workspace = get_workspace()
363
+ project_dir = workspace / project
364
+
365
+ if not project_dir.exists():
366
+ return f"Project '{project}' not found"
367
+
368
+ if filename:
369
+ file_path = project_dir / filename
370
+ if not file_path.exists():
371
+ return f"File '{filename}' not found in {project}"
372
+ file_path.unlink()
373
+ return f"Deleted {filename} from {project}"
374
+ else:
375
+ shutil.rmtree(project_dir)
376
+ return f"Deleted project '{project}'"
377
+
378
+
379
+ class ZigBuildTool(Tool):
380
+ """Build a Zig project."""
381
+
382
+ @property
383
+ def name(self) -> str:
384
+ return "zig_build"
385
+
386
+ @property
387
+ def description(self) -> str:
388
+ return (
389
+ "Build a Zig project. Can create optimized release builds. "
390
+ "For single files, compiles to executable. "
391
+ "For projects with build.zig, runs 'zig build'."
392
+ )
393
+
394
+ @property
395
+ def parameters(self) -> dict:
396
+ return {
397
+ "type": "object",
398
+ "properties": {
399
+ "project": {
400
+ "type": "string",
401
+ "description": "Project name",
402
+ },
403
+ "filename": {
404
+ "type": "string",
405
+ "description": "Main .zig file (optional if build.zig exists)",
406
+ },
407
+ "release": {
408
+ "type": "boolean",
409
+ "description": "Build optimized release (default false)",
410
+ "default": False,
411
+ },
412
+ "output": {
413
+ "type": "string",
414
+ "description": "Output executable name (optional)",
415
+ },
416
+ },
417
+ "required": ["project"],
418
+ }
419
+
420
+ async def execute(
421
+ self,
422
+ project: str,
423
+ filename: str | None = None,
424
+ release: bool = False,
425
+ output: str | None = None,
426
+ ) -> str:
427
+ project_dir = get_project_dir(project)
428
+
429
+ # Check if zig is available
430
+ zig_path = shutil.which("zig")
431
+ if not zig_path:
432
+ return "Error: Zig compiler not found. Please install Zig."
433
+
434
+ build_zig = project_dir / "build.zig"
435
+
436
+ if build_zig.exists():
437
+ # Use build.zig
438
+ cmd = ["zig", "build"]
439
+ if release:
440
+ cmd.append("-Doptimize=ReleaseFast")
441
+ elif filename:
442
+ # Single file build
443
+ file_path = project_dir / filename
444
+ if not file_path.exists():
445
+ return f"Error: File not found: {filename}"
446
+
447
+ out_name = output or file_path.stem
448
+ cmd = ["zig", "build-exe", str(file_path), f"-femit-bin={out_name}"]
449
+ if release:
450
+ cmd.append("-O")
451
+ cmd.append("ReleaseFast")
452
+ else:
453
+ return "Error: No build.zig found and no filename specified"
454
+
455
+ code, stdout, stderr = await run_command(cmd, cwd=project_dir, timeout=120)
456
+
457
+ result = []
458
+ if stdout:
459
+ result.append(f"stdout:\n{stdout}")
460
+ if stderr:
461
+ result.append(f"stderr:\n{stderr}")
462
+
463
+ if code == 0:
464
+ result.append("Build successful!")
465
+ else:
466
+ result.append(f"Build failed (exit code: {code})")
467
+
468
+ return "\n".join(result)
469
+
470
+
471
+ class ZigTestTool(Tool):
472
+ """Run Zig tests."""
473
+
474
+ @property
475
+ def name(self) -> str:
476
+ return "zig_test"
477
+
478
+ @property
479
+ def description(self) -> str:
480
+ return "Run Zig tests in a file or project."
481
+
482
+ @property
483
+ def parameters(self) -> dict:
484
+ return {
485
+ "type": "object",
486
+ "properties": {
487
+ "project": {
488
+ "type": "string",
489
+ "description": "Project name",
490
+ },
491
+ "filename": {
492
+ "type": "string",
493
+ "description": ".zig file with tests (optional if build.zig exists)",
494
+ },
495
+ },
496
+ "required": ["project"],
497
+ }
498
+
499
+ async def execute(self, project: str, filename: str | None = None) -> str:
500
+ project_dir = get_project_dir(project)
501
+
502
+ zig_path = shutil.which("zig")
503
+ if not zig_path:
504
+ return "Error: Zig compiler not found. Please install Zig."
505
+
506
+ build_zig = project_dir / "build.zig"
507
+
508
+ if build_zig.exists() and not filename:
509
+ cmd = ["zig", "build", "test"]
510
+ elif filename:
511
+ file_path = project_dir / filename
512
+ if not file_path.exists():
513
+ return f"Error: File not found: {filename}"
514
+ cmd = ["zig", "test", str(file_path)]
515
+ else:
516
+ return "Error: No build.zig found and no filename specified"
517
+
518
+ code, stdout, stderr = await run_command(cmd, cwd=project_dir, timeout=60)
519
+
520
+ result = []
521
+ if stdout:
522
+ result.append(stdout)
523
+ if stderr:
524
+ result.append(stderr)
525
+
526
+ if code == 0:
527
+ result.append("All tests passed!")
528
+ else:
529
+ result.append(f"Tests failed (exit code: {code})")
530
+
531
+ return "\n".join(result)
532
+
533
+
534
+ class PythonTestTool(Tool):
535
+ """Run Python tests with pytest."""
536
+
537
+ @property
538
+ def name(self) -> str:
539
+ return "python_test"
540
+
541
+ @property
542
+ def description(self) -> str:
543
+ return "Run Python tests with pytest in a project."
544
+
545
+ @property
546
+ def parameters(self) -> dict:
547
+ return {
548
+ "type": "object",
549
+ "properties": {
550
+ "project": {
551
+ "type": "string",
552
+ "description": "Project name",
553
+ },
554
+ "filename": {
555
+ "type": "string",
556
+ "description": "Specific test file (optional)",
557
+ },
558
+ },
559
+ "required": ["project"],
560
+ }
561
+
562
+ async def execute(self, project: str, filename: str | None = None) -> str:
563
+ import sys
564
+
565
+ project_dir = get_project_dir(project)
566
+
567
+ cmd = [sys.executable, "-m", "pytest", "-v"]
568
+ if filename:
569
+ file_path = project_dir / filename
570
+ if not file_path.exists():
571
+ return f"Error: File not found: {filename}"
572
+ cmd.append(str(file_path))
573
+ else:
574
+ cmd.append(str(project_dir))
575
+
576
+ code, stdout, stderr = await run_command(cmd, cwd=project_dir, timeout=120)
577
+
578
+ result = []
579
+ if stdout:
580
+ result.append(stdout)
581
+ if stderr:
582
+ result.append(stderr)
583
+
584
+ return "\n".join(result) if result else "No test output"
585
+
586
+
587
+ # Export all coding tools
588
+ def get_coding_tools() -> list[Tool]:
589
+ """Get all coding agent tools."""
590
+ return [
591
+ CodeWriteTool(),
592
+ CodeReadTool(),
593
+ CodeRunTool(),
594
+ CodeListTool(),
595
+ CodeDeleteTool(),
596
+ ZigBuildTool(),
597
+ ZigTestTool(),
598
+ PythonTestTool(),
599
+ ]