krnl-code 1.0.4__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.
Files changed (56) hide show
  1. krnl_agent/__init__.py +9 -0
  2. krnl_agent/__main__.py +7 -0
  3. krnl_agent/agent_registry.py +95 -0
  4. krnl_agent/agent_selector.py +69 -0
  5. krnl_agent/audit_log.py +155 -0
  6. krnl_agent/background.py +94 -0
  7. krnl_agent/checkpoints.py +67 -0
  8. krnl_agent/ci.py +73 -0
  9. krnl_agent/cli.py +1458 -0
  10. krnl_agent/commands.py +42 -0
  11. krnl_agent/config.py +425 -0
  12. krnl_agent/context.py +352 -0
  13. krnl_agent/depaudit.py +63 -0
  14. krnl_agent/deploy.py +245 -0
  15. krnl_agent/doctor.py +106 -0
  16. krnl_agent/events.py +141 -0
  17. krnl_agent/gitignore.py +47 -0
  18. krnl_agent/graph.py +928 -0
  19. krnl_agent/guardrails.py +70 -0
  20. krnl_agent/headless.py +60 -0
  21. krnl_agent/history.py +49 -0
  22. krnl_agent/hooks.py +72 -0
  23. krnl_agent/ingest.py +129 -0
  24. krnl_agent/llm.py +456 -0
  25. krnl_agent/loop.py +779 -0
  26. krnl_agent/mcp_client.py +128 -0
  27. krnl_agent/memory.py +61 -0
  28. krnl_agent/modelrouter.py +151 -0
  29. krnl_agent/monitor.py +112 -0
  30. krnl_agent/notify.py +119 -0
  31. krnl_agent/parallel_executor.py +139 -0
  32. krnl_agent/permissions.py +128 -0
  33. krnl_agent/plugins.py +105 -0
  34. krnl_agent/pricing.py +85 -0
  35. krnl_agent/prompts.py +60 -0
  36. krnl_agent/repomap.py +133 -0
  37. krnl_agent/sandbox.py +69 -0
  38. krnl_agent/scaffold.py +167 -0
  39. krnl_agent/schedules.py +137 -0
  40. krnl_agent/secrets.py +100 -0
  41. krnl_agent/selfheal.py +87 -0
  42. krnl_agent/server.py +302 -0
  43. krnl_agent/sessions.py +258 -0
  44. krnl_agent/settings.py +59 -0
  45. krnl_agent/skills.py +73 -0
  46. krnl_agent/teams.py +38 -0
  47. krnl_agent/tool_schemas.py +431 -0
  48. krnl_agent/tools.py +694 -0
  49. krnl_agent/webtools.py +139 -0
  50. krnl_code-1.0.4.dist-info/METADATA +214 -0
  51. krnl_code-1.0.4.dist-info/RECORD +56 -0
  52. krnl_code-1.0.4.dist-info/WHEEL +5 -0
  53. krnl_code-1.0.4.dist-info/entry_points.txt +2 -0
  54. krnl_code-1.0.4.dist-info/licenses/LICENSE +147 -0
  55. krnl_code-1.0.4.dist-info/licenses/NOTICE +4 -0
  56. krnl_code-1.0.4.dist-info/top_level.txt +1 -0
krnl_agent/tools.py ADDED
@@ -0,0 +1,694 @@
1
+ """Tool implementations. Every tool is workspace-sandboxed.
2
+
3
+ A tool returns a `ToolOutcome`:
4
+ * output — text fed back to the model
5
+ * ok — whether it succeeded
6
+ * diff — unified diff (for file-mutating tools, used for the approval UI)
7
+ * is_new — whether a file is being created (affects diff rendering)
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import difflib
12
+ import fnmatch
13
+ import os
14
+ import shutil
15
+ import subprocess
16
+ import threading
17
+ from dataclasses import dataclass
18
+ from pathlib import Path
19
+ from typing import Callable, Optional
20
+
21
+ from .config import AgentConfig
22
+
23
+
24
+ @dataclass
25
+ class ToolOutcome:
26
+ output: str
27
+ ok: bool = True
28
+ diff: Optional[str] = None
29
+ is_new: bool = False
30
+
31
+
32
+ class ToolContext:
33
+ def __init__(
34
+ self,
35
+ workspace: str,
36
+ agent_cfg: AgentConfig,
37
+ ignore: list[str],
38
+ web: Optional[dict] = None,
39
+ deploy_cfg: Optional[dict] = None,
40
+ ):
41
+ self.root = Path(workspace).resolve()
42
+ self.cfg = agent_cfg
43
+ self.ignore = ignore
44
+ # web search backend, e.g. {"provider": "duckduckgo", "api_key": ""}
45
+ self.web = web or {}
46
+ # auto-deploy policy (free_tier_only / allow_billable / cost_ceiling_usd)
47
+ self.deploy_cfg = deploy_cfg or {}
48
+
49
+ # --- sandbox helpers --------------------------------------------------- #
50
+ def resolve(self, rel: str) -> Path:
51
+ full = (self.root / rel).resolve()
52
+ try:
53
+ full.relative_to(self.root)
54
+ except ValueError:
55
+ raise PermissionError(f"Path '{rel}' is outside the workspace.")
56
+ return full
57
+
58
+ def rel(self, full: Path) -> str:
59
+ return str(full.relative_to(self.root)).replace(os.sep, "/")
60
+
61
+ def is_ignored(self, rel_path: str) -> bool:
62
+ rel_path = rel_path.replace(os.sep, "/")
63
+ for pat in self.ignore:
64
+ if fnmatch.fnmatch(rel_path, pat) or fnmatch.fnmatch(
65
+ rel_path, pat.rstrip("/*") + "/*"
66
+ ):
67
+ return True
68
+ # match a directory prefix like ".git/**"
69
+ top = pat.split("/", 1)[0]
70
+ if rel_path == top or rel_path.startswith(top + "/"):
71
+ if "**" in pat or "/" in pat:
72
+ return True
73
+ return False
74
+
75
+
76
+ def _is_binary(path: Path) -> bool:
77
+ """Heuristic: a NUL byte in the first 4 KB means it's not text."""
78
+ try:
79
+ with open(path, "rb") as f:
80
+ return b"\x00" in f.read(4096)
81
+ except Exception:
82
+ return False
83
+
84
+
85
+ # --------------------------------------------------------------------------- #
86
+ # Read-only tools
87
+ # --------------------------------------------------------------------------- #
88
+ def list_files(ctx: ToolContext, path: str = ".") -> ToolOutcome:
89
+ base = ctx.resolve(path)
90
+ if not base.exists():
91
+ return ToolOutcome(f"Path not found: {path}", ok=False)
92
+ rows: list[str] = []
93
+ for root, dirs, files in os.walk(base):
94
+ # prune ignored dirs in-place for speed
95
+ rroot = ctx.rel(Path(root))
96
+ dirs[:] = [
97
+ d for d in sorted(dirs) if not ctx.is_ignored(f"{rroot}/{d}".lstrip("./"))
98
+ ]
99
+ for f in sorted(files):
100
+ rp = ctx.rel(Path(root) / f)
101
+ if not ctx.is_ignored(rp):
102
+ rows.append(rp)
103
+ if len(rows) > 800:
104
+ rows.append("... (truncated)")
105
+ break
106
+ return ToolOutcome("\n".join(rows) if rows else "(empty)")
107
+
108
+
109
+ def read_file(
110
+ ctx: ToolContext,
111
+ path: str,
112
+ start_line: Optional[int] = None,
113
+ end_line: Optional[int] = None,
114
+ ) -> ToolOutcome:
115
+ if ctx.is_ignored(path):
116
+ return ToolOutcome(f"Refusing to read ignored/secret file: {path}", ok=False)
117
+ full = ctx.resolve(path)
118
+ if not full.is_file():
119
+ return ToolOutcome(f"File not found: {path}", ok=False)
120
+ if full.stat().st_size > ctx.cfg.max_file_bytes:
121
+ return ToolOutcome(
122
+ f"File too large ({full.stat().st_size} bytes > "
123
+ f"{ctx.cfg.max_file_bytes}). Read a line range instead.",
124
+ ok=False,
125
+ )
126
+ if _is_binary(full):
127
+ return ToolOutcome(
128
+ f"{path} appears to be a binary/non-text file; cannot read as text.",
129
+ ok=False,
130
+ )
131
+ text = full.read_text(encoding="utf-8", errors="replace")
132
+ lines = text.splitlines()
133
+ if start_line or end_line:
134
+ s = max(1, start_line or 1)
135
+ e = min(len(lines), end_line or len(lines))
136
+ snippet = lines[s - 1 : e]
137
+ numbered = "\n".join(f"{s + i}\t{ln}" for i, ln in enumerate(snippet))
138
+ return ToolOutcome(numbered or "(empty range)")
139
+ numbered = "\n".join(f"{i + 1}\t{ln}" for i, ln in enumerate(lines))
140
+ return ToolOutcome(numbered or "(empty file)")
141
+
142
+
143
+ def search_text(ctx: ToolContext, query: str, glob: Optional[str] = None) -> ToolOutcome:
144
+ rg = shutil.which("rg")
145
+ if rg:
146
+ cmd = [rg, "--line-number", "--no-heading", "--color", "never", "-S"]
147
+ if glob:
148
+ cmd += ["--glob", glob]
149
+ cmd += [query, "."]
150
+ try:
151
+ res = subprocess.run(
152
+ cmd, cwd=ctx.root, capture_output=True, text=True, timeout=20
153
+ )
154
+ out = res.stdout.strip()
155
+ return ToolOutcome(out[:8000] if out else "(no matches)")
156
+ except Exception:
157
+ pass # fall through to python implementation
158
+ # pure-python fallback
159
+ hits: list[str] = []
160
+ for root, dirs, files in os.walk(ctx.root):
161
+ dirs[:] = [
162
+ d
163
+ for d in dirs
164
+ if not ctx.is_ignored(f"{ctx.rel(Path(root))}/{d}".lstrip("./"))
165
+ ]
166
+ for f in files:
167
+ rp = ctx.rel(Path(root) / f)
168
+ if ctx.is_ignored(rp) or (glob and not fnmatch.fnmatch(f, glob)):
169
+ continue
170
+ try:
171
+ with open(Path(root) / f, encoding="utf-8", errors="ignore") as fh:
172
+ for i, line in enumerate(fh, 1):
173
+ if query in line:
174
+ hits.append(f"{rp}:{i}:{line.rstrip()}")
175
+ if len(hits) >= 200:
176
+ return ToolOutcome("\n".join(hits) + "\n... (truncated)")
177
+ except Exception:
178
+ continue
179
+ return ToolOutcome("\n".join(hits) if hits else "(no matches)")
180
+
181
+
182
+ def glob_files(ctx: ToolContext, pattern: str, path: str = ".") -> ToolOutcome:
183
+ """Find files by glob pattern (supports ** for recursion)."""
184
+ base = ctx.resolve(path)
185
+ if not base.exists():
186
+ return ToolOutcome(f"Path not found: {path}", ok=False)
187
+ matches: list[str] = []
188
+ try:
189
+ for p in sorted(base.glob(pattern)):
190
+ if p.is_file():
191
+ rel = ctx.rel(p)
192
+ if not ctx.is_ignored(rel):
193
+ matches.append(rel)
194
+ if len(matches) >= 500:
195
+ matches.append("... (truncated)")
196
+ break
197
+ except Exception as e: # noqa: BLE001
198
+ return ToolOutcome(f"glob error: {e}", ok=False)
199
+ return ToolOutcome("\n".join(matches) if matches else "(no matches)")
200
+
201
+
202
+ # --------------------------------------------------------------------------- #
203
+ # Code intelligence / security (read-only)
204
+ # --------------------------------------------------------------------------- #
205
+ def repo_map(ctx: ToolContext, path: str = ".", lang: str = "") -> ToolOutcome:
206
+ """Compact symbol/outline map of the repo — read this before reading files."""
207
+ from . import repomap
208
+
209
+ return ToolOutcome(repomap.build_map(ctx, path, lang or None))
210
+
211
+
212
+ def secret_scan(ctx: ToolContext, path: str = ".") -> ToolOutcome:
213
+ """Scan the workspace for hard-coded secrets/credentials."""
214
+ from . import secrets
215
+
216
+ count, report = secrets.scan(ctx, path)
217
+ return ToolOutcome(report, ok=(count == 0))
218
+
219
+
220
+ def dependency_audit(ctx: ToolContext) -> ToolOutcome:
221
+ """Audit project dependencies for known vulnerabilities."""
222
+ from . import depaudit
223
+
224
+ clean, report = depaudit.audit(ctx)
225
+ return ToolOutcome(report, ok=clean)
226
+
227
+
228
+ # --------------------------------------------------------------------------- #
229
+ # Deploy / monitor / self-heal
230
+ # --------------------------------------------------------------------------- #
231
+ def deploy_check(ctx: ToolContext) -> ToolOutcome:
232
+ """List deploy targets and whether each is ready (CLI installed + token set)."""
233
+ from . import deploy as _dep
234
+
235
+ rows = _dep.readiness()
236
+ ready = [r for r in rows if r["ready"]]
237
+ lines = [f"Deploy targets ({len(ready)} ready):"]
238
+ for r in rows:
239
+ mark = "✔" if r["ready"] else ("·" if r["cli_ok"] else "✗")
240
+ tier = "free" if r["free_tier"] and not r["billable"] else "billable"
241
+ lines.append(f" {mark} {r['name']:<14} [{r['kind']}/{tier}] cli={r['cli']} "
242
+ f"token={r['token_env']} {'READY' if r['ready'] else ''}")
243
+ lines.append("\nSuggested default: " + (_dep.suggest() or "(none)"))
244
+ return ToolOutcome("\n".join(lines))
245
+
246
+
247
+ def deploy(ctx: ToolContext, target: str, params: Optional[dict] = None) -> ToolOutcome:
248
+ """Deploy the current project to a target (gated by the spend policy)."""
249
+ from . import deploy as _dep
250
+ from . import guardrails
251
+
252
+ decision, reason = guardrails.spend_gate(ctx.deploy_cfg, target)
253
+ if decision == "deny":
254
+ return ToolOutcome(f"Deploy to {target} blocked: {reason}", ok=False)
255
+ ok, cmd = _dep.deploy_command(target, params)
256
+ if not ok:
257
+ return ToolOutcome(cmd, ok=False)
258
+ if decision == "ask":
259
+ # Surface to the agent; the run_command approval gate is the human checkpoint.
260
+ cmd = cmd # the agent will run this via run_command after confirming with the user
261
+ out = run_command(ctx, cmd)
262
+ note = f"[spend-gate: {reason}]\n"
263
+ return ToolOutcome(note + out.output, ok=out.ok)
264
+ out = run_command(ctx, cmd)
265
+ return ToolOutcome(out.output, ok=out.ok)
266
+
267
+
268
+ def rollback(ctx: ToolContext, target: str, params: Optional[dict] = None) -> ToolOutcome:
269
+ """Roll a target back to its previous known-good release (safe to automate)."""
270
+ from . import deploy as _dep
271
+
272
+ ok, cmd = _dep.rollback_command(target, params)
273
+ if not ok:
274
+ return ToolOutcome(cmd, ok=False)
275
+ return run_command(ctx, cmd)
276
+
277
+
278
+ def provision_db(ctx: ToolContext, provider: str, params: Optional[dict] = None) -> ToolOutcome:
279
+ """Provision a managed database (neon / supabase)."""
280
+ from . import deploy as _dep
281
+
282
+ ok, cmd = _dep.deploy_command(provider, params)
283
+ if not ok:
284
+ return ToolOutcome(cmd, ok=False)
285
+ return run_command(ctx, cmd)
286
+
287
+
288
+ def monitor_status(ctx: ToolContext) -> ToolOutcome:
289
+ """Report configured monitoring providers and current error/uptime status."""
290
+ from . import monitor
291
+
292
+ return ToolOutcome(monitor.status())
293
+
294
+
295
+ def health_check(ctx: ToolContext, url: str, path: str = "/health") -> ToolOutcome:
296
+ """Check a deployed app's health endpoint."""
297
+ from . import selfheal
298
+
299
+ healthy, detail = selfheal.check_health(url, path)
300
+ return ToolOutcome(detail, ok=healthy)
301
+
302
+
303
+ # --------------------------------------------------------------------------- #
304
+ # Mutating tools (gated by approval in the loop)
305
+ # --------------------------------------------------------------------------- #
306
+ def _unified(path: str, before: str, after: str) -> str:
307
+ return "".join(
308
+ difflib.unified_diff(
309
+ before.splitlines(keepends=True),
310
+ after.splitlines(keepends=True),
311
+ fromfile=f"a/{path}",
312
+ tofile=f"b/{path}",
313
+ )
314
+ )
315
+
316
+
317
+ def _prepare_write(ctx: ToolContext, path: str, content: str) -> ToolOutcome:
318
+ """Build the diff/preview WITHOUT writing (used to ask for approval)."""
319
+ if ctx.is_ignored(path):
320
+ return ToolOutcome(f"Refusing to write ignored/secret path: {path}", ok=False)
321
+ full = ctx.resolve(path)
322
+ is_new = not full.exists()
323
+ before = "" if is_new else full.read_text(encoding="utf-8", errors="replace")
324
+ patch = _unified(path, before, content)
325
+ if not patch and not is_new:
326
+ return ToolOutcome(f"No change: {path} already has that content.", ok=True)
327
+ return ToolOutcome(output="", ok=True, diff=patch, is_new=is_new)
328
+
329
+
330
+ def write_file(ctx: ToolContext, path: str, content: str) -> ToolOutcome:
331
+ pre = _prepare_write(ctx, path, content)
332
+ if not pre.ok:
333
+ return pre
334
+ full = ctx.resolve(path)
335
+ full.parent.mkdir(parents=True, exist_ok=True)
336
+ full.write_text(content, encoding="utf-8")
337
+ return ToolOutcome(f"Wrote {path} ({len(content)} bytes).", diff=pre.diff, is_new=pre.is_new)
338
+
339
+
340
+ def edit_file(
341
+ ctx: ToolContext,
342
+ path: str,
343
+ old_string: str,
344
+ new_string: str,
345
+ replace_all: bool = False,
346
+ ) -> ToolOutcome:
347
+ if ctx.is_ignored(path):
348
+ return ToolOutcome(f"Refusing to edit ignored/secret path: {path}", ok=False)
349
+ full = ctx.resolve(path)
350
+ if not full.is_file():
351
+ return ToolOutcome(f"File not found: {path}", ok=False)
352
+ before = full.read_text(encoding="utf-8", errors="replace")
353
+ count = before.count(old_string)
354
+ if count == 0:
355
+ return ToolOutcome(
356
+ f"`old_string` not found in {path}. Re-read the file; it must match "
357
+ f"exactly (whitespace included).",
358
+ ok=False,
359
+ )
360
+ if count > 1 and not replace_all:
361
+ return ToolOutcome(
362
+ f"`old_string` is not unique in {path} ({count} matches). Add more "
363
+ f"surrounding context or set replace_all=true.",
364
+ ok=False,
365
+ )
366
+ after = before.replace(old_string, new_string)
367
+ patch = _unified(path, before, after)
368
+ full.write_text(after, encoding="utf-8")
369
+ n = count if replace_all else 1
370
+ return ToolOutcome(f"Edited {path} ({n} replacement(s)).", diff=patch)
371
+
372
+
373
+ def _apply_edits(before: str, edits: list, path: str):
374
+ """Apply a list of {old_string,new_string,replace_all} edits. Returns
375
+ (after, error). `error` is None on success."""
376
+ text = before
377
+ for i, e in enumerate(edits):
378
+ old = e.get("old_string", "")
379
+ new = e.get("new_string", "")
380
+ if not old:
381
+ return text, f"edit {i}: empty old_string"
382
+ cnt = text.count(old)
383
+ if cnt == 0:
384
+ return text, f"edit {i}: old_string not found in {path}"
385
+ if cnt > 1 and not e.get("replace_all"):
386
+ return text, f"edit {i}: old_string not unique ({cnt}); add context or replace_all"
387
+ text = text.replace(old, new) if e.get("replace_all") else text.replace(old, new, 1)
388
+ return text, None
389
+
390
+
391
+ def multi_edit(ctx: ToolContext, path: str, edits: list) -> ToolOutcome:
392
+ """Apply several edits to one file atomically (all-or-nothing)."""
393
+ if ctx.is_ignored(path):
394
+ return ToolOutcome(f"Refusing to edit ignored/secret path: {path}", ok=False)
395
+ full = ctx.resolve(path)
396
+ if not full.is_file():
397
+ return ToolOutcome(f"File not found: {path}", ok=False)
398
+ before = full.read_text(encoding="utf-8", errors="replace")
399
+ after, err = _apply_edits(before, edits or [], path)
400
+ if err:
401
+ return ToolOutcome(err, ok=False)
402
+ patch = _unified(path, before, after)
403
+ full.write_text(after, encoding="utf-8")
404
+ return ToolOutcome(f"Applied {len(edits)} edit(s) to {path}.", diff=patch)
405
+
406
+
407
+ def create_file(ctx: ToolContext, path: str, content: str) -> ToolOutcome:
408
+ full = ctx.resolve(path)
409
+ if full.exists():
410
+ return ToolOutcome(f"File already exists: {path}. Use write_file/edit_file.", ok=False)
411
+ return write_file(ctx, path, content)
412
+
413
+
414
+ def delete_file(ctx: ToolContext, path: str) -> ToolOutcome:
415
+ if ctx.is_ignored(path):
416
+ return ToolOutcome(f"Refusing to delete ignored/secret path: {path}", ok=False)
417
+ full = ctx.resolve(path)
418
+ if not full.is_file():
419
+ return ToolOutcome(f"File not found: {path}", ok=False)
420
+ before = full.read_text(encoding="utf-8", errors="replace")
421
+ full.unlink()
422
+ return ToolOutcome(f"Deleted {path}.", diff=_unified(path, before, ""))
423
+
424
+
425
+ def run_command(
426
+ ctx: ToolContext,
427
+ command: str,
428
+ on_output: Optional[Callable[[str], None]] = None,
429
+ ) -> ToolOutcome:
430
+ """Run a shell command, streaming each output line to `on_output` if given.
431
+
432
+ Output is merged (stdout+stderr) and bounded by a watchdog timer so a hung
433
+ process is always killed after `max_command_seconds`.
434
+ """
435
+ try:
436
+ proc = subprocess.Popen(
437
+ command,
438
+ cwd=ctx.root,
439
+ shell=True,
440
+ stdout=subprocess.PIPE,
441
+ stderr=subprocess.STDOUT,
442
+ text=True,
443
+ bufsize=1,
444
+ )
445
+ except Exception as e: # noqa: BLE001
446
+ return ToolOutcome(f"Failed to start command: {e}", ok=False)
447
+
448
+ killed = {"v": False}
449
+
450
+ def _kill():
451
+ killed["v"] = True
452
+ try:
453
+ proc.kill()
454
+ except Exception:
455
+ pass
456
+
457
+ timer = threading.Timer(ctx.cfg.max_command_seconds, _kill)
458
+ timer.start()
459
+ captured: list[str] = []
460
+ try:
461
+ if proc.stdout:
462
+ for line in proc.stdout:
463
+ captured.append(line)
464
+ if on_output:
465
+ on_output(line)
466
+ proc.wait()
467
+ finally:
468
+ timer.cancel()
469
+
470
+ rc = proc.returncode
471
+ out = "".join(captured)
472
+ if len(out) > 8000:
473
+ out = "… (truncated)\n" + out[-8000:]
474
+ parts = [f"$ {command}", f"(exit {rc})"]
475
+ if killed["v"]:
476
+ parts.append(f"[timed out after {ctx.cfg.max_command_seconds}s — killed]")
477
+ if out.strip():
478
+ parts.append("--- output ---\n" + out)
479
+ return ToolOutcome("\n".join(parts), ok=(rc == 0 and not killed["v"]))
480
+
481
+
482
+ # --------------------------------------------------------------------------- #
483
+ # Web tools
484
+ # --------------------------------------------------------------------------- #
485
+ def web_fetch(ctx: ToolContext, url: str) -> ToolOutcome:
486
+ from . import webtools
487
+
488
+ ok, text = webtools.web_fetch(url, max_chars=ctx.cfg.max_file_bytes)
489
+ return ToolOutcome(text, ok=ok)
490
+
491
+
492
+ def web_search(ctx: ToolContext, query: str) -> ToolOutcome:
493
+ from . import webtools
494
+
495
+ provider = ctx.web.get("provider", "duckduckgo")
496
+ ok, text = webtools.web_search(query, provider=provider, api_key=ctx.web.get("api_key", ""))
497
+ return ToolOutcome(text, ok=ok)
498
+
499
+
500
+ # --------------------------------------------------------------------------- #
501
+ # Background processes
502
+ # --------------------------------------------------------------------------- #
503
+ def bash_background(ctx: ToolContext, command: str) -> ToolOutcome:
504
+ from .background import manager
505
+
506
+ pid = manager.start(str(ctx.root), command)
507
+ return ToolOutcome(f"Started background process {pid}: {command}")
508
+
509
+
510
+ def process_output(ctx: ToolContext, id: str, tail: int = 100) -> ToolOutcome:
511
+ from .background import manager
512
+
513
+ return ToolOutcome(manager.output(id, tail))
514
+
515
+
516
+ def process_kill(ctx: ToolContext, id: str) -> ToolOutcome:
517
+ from .background import manager
518
+
519
+ return ToolOutcome(manager.kill(id))
520
+
521
+
522
+ def process_list(ctx: ToolContext) -> ToolOutcome:
523
+ from .background import manager
524
+
525
+ return ToolOutcome(manager.list_())
526
+
527
+
528
+ # --------------------------------------------------------------------------- #
529
+ # Git helpers (thin, safe wrappers)
530
+ # --------------------------------------------------------------------------- #
531
+ def _git(ctx: ToolContext, args: str) -> ToolOutcome:
532
+ return run_command(ctx, f"git {args}")
533
+
534
+
535
+ def git_status(ctx: ToolContext) -> ToolOutcome:
536
+ return _git(ctx, "status --short --branch")
537
+
538
+
539
+ def git_diff(ctx: ToolContext, path: str = "") -> ToolOutcome:
540
+ return _git(ctx, f"diff -- {path}" if path else "diff")
541
+
542
+
543
+ def git_commit(ctx: ToolContext, message: str, all: bool = True) -> ToolOutcome:
544
+ safe = message.replace('"', '\\"')
545
+ add = "git add -A && " if all else ""
546
+ return run_command(ctx, f'{add}git commit -m "{safe}"')
547
+
548
+
549
+ # --------------------------------------------------------------------------- #
550
+ # Git worktrees — isolated copies to work safely in parallel
551
+ # --------------------------------------------------------------------------- #
552
+ def worktree_add(ctx: ToolContext, name: str, branch: str = "") -> ToolOutcome:
553
+ rel = f".krnl/worktrees/{name}"
554
+ flag = f"-b {branch} " if branch else ""
555
+ out = run_command(ctx, f"git worktree add {flag}{rel}")
556
+ if out.ok:
557
+ out.output += f"\n(isolated worktree at {rel}; run commands there with: cd {rel} && …)"
558
+ return out
559
+
560
+
561
+ def worktree_list(ctx: ToolContext) -> ToolOutcome:
562
+ return run_command(ctx, "git worktree list")
563
+
564
+
565
+ def worktree_remove(ctx: ToolContext, name: str) -> ToolOutcome:
566
+ return run_command(ctx, f"git worktree remove .krnl/worktrees/{name} --force")
567
+
568
+
569
+ # --------------------------------------------------------------------------- #
570
+ # CI / PR helpers
571
+ # --------------------------------------------------------------------------- #
572
+ def git_branch(ctx: ToolContext, name: str) -> ToolOutcome:
573
+ return run_command(ctx, f"git checkout -b {name}")
574
+
575
+
576
+ def git_push(ctx: ToolContext, branch: str = "") -> ToolOutcome:
577
+ target = branch or "HEAD"
578
+ return run_command(ctx, f"git push -u origin {target}")
579
+
580
+
581
+ def open_pr(ctx: ToolContext, title: str, body: str = "") -> ToolOutcome:
582
+ if not shutil.which("gh"):
583
+ return ToolOutcome("GitHub CLI (gh) not found — install it to open PRs.", ok=False)
584
+ safe_t = title.replace('"', '\\"')
585
+ safe_b = body.replace('"', '\\"')
586
+ return run_command(ctx, f'gh pr create --title "{safe_t}" --body "{safe_b}"')
587
+
588
+
589
+ # --------------------------------------------------------------------------- #
590
+ # Dispatch table
591
+ # --------------------------------------------------------------------------- #
592
+ TOOL_FUNCS = {
593
+ "list_files": list_files,
594
+ "read_file": read_file,
595
+ "search_text": search_text,
596
+ "glob": glob_files,
597
+ "repo_map": repo_map,
598
+ "secret_scan": secret_scan,
599
+ "dependency_audit": dependency_audit,
600
+ "deploy_check": deploy_check,
601
+ "deploy": deploy,
602
+ "rollback": rollback,
603
+ "provision_db": provision_db,
604
+ "monitor_status": monitor_status,
605
+ "health_check": health_check,
606
+ "write_file": write_file,
607
+ "edit_file": edit_file,
608
+ "multi_edit": multi_edit,
609
+ "create_file": create_file,
610
+ "delete_file": delete_file,
611
+ "run_command": run_command,
612
+ "web_fetch": web_fetch,
613
+ "web_search": web_search,
614
+ "bash_background": bash_background,
615
+ "process_output": process_output,
616
+ "process_kill": process_kill,
617
+ "process_list": process_list,
618
+ "git_status": git_status,
619
+ "git_diff": git_diff,
620
+ "git_commit": git_commit,
621
+ "worktree_add": worktree_add,
622
+ "worktree_list": worktree_list,
623
+ "worktree_remove": worktree_remove,
624
+ "git_branch": git_branch,
625
+ "git_push": git_push,
626
+ "open_pr": open_pr,
627
+ }
628
+
629
+
630
+ def preview_for(ctx: ToolContext, name: str, args: dict) -> Optional[str]:
631
+ """Build a human-readable preview shown in the approval prompt."""
632
+ if "__raw__" in args:
633
+ return f"⚠ Failed to parse tool arguments as valid JSON. Raw content: {args['__raw__'][:300]}..."
634
+ try:
635
+ if name in ("write_file", "create_file"):
636
+ pre = _prepare_write(ctx, args.get("path", ""), args.get("content", ""))
637
+ return pre.diff or pre.output
638
+ if name == "edit_file":
639
+ full = ctx.resolve(args["path"])
640
+ if not full.is_file():
641
+ return f"(new edit target missing: {args['path']})"
642
+ before = full.read_text(encoding="utf-8", errors="replace")
643
+ old, new = args.get("old_string", ""), args.get("new_string", "")
644
+ if old not in before:
645
+ return f"⚠ old_string not found in {args['path']}"
646
+ after = before.replace(old, new, -1 if args.get("replace_all") else 1)
647
+ return _unified(args["path"], before, after)
648
+ if name == "multi_edit":
649
+ full = ctx.resolve(args["path"])
650
+ if not full.is_file():
651
+ return f"(edit target missing: {args['path']})"
652
+ before = full.read_text(encoding="utf-8", errors="replace")
653
+ after, err = _apply_edits(before, args.get("edits", []), args["path"])
654
+ return _unified(args["path"], before, after) if not err else f"⚠ {err}"
655
+ if name == "delete_file":
656
+ return f"DELETE {args.get('path')}"
657
+ if name == "run_command":
658
+ return f"$ {args.get('command')}"
659
+ if name == "bash_background":
660
+ return f"$ {args.get('command')} (background)"
661
+ if name == "git_commit":
662
+ return f"git commit -m \"{args.get('message', '')}\""
663
+ if name in ("deploy", "provision_db"):
664
+ from . import deploy as _dep
665
+ tgt = args.get("target") or args.get("provider", "")
666
+ ok, cmd = _dep.deploy_command(tgt, args.get("params"))
667
+ return f"DEPLOY {tgt}: $ {cmd}" if ok else f"⚠ {cmd}"
668
+ if name == "rollback":
669
+ from . import deploy as _dep
670
+ ok, cmd = _dep.rollback_command(args.get("target", ""), args.get("params"))
671
+ return f"ROLLBACK: $ {cmd}" if ok else f"⚠ {cmd}"
672
+ except Exception as e: # noqa: BLE001
673
+ return f"(could not build preview: {e})"
674
+ return None
675
+
676
+
677
+ def execute(ctx: ToolContext, name: str, args: dict) -> ToolOutcome:
678
+ if "__raw__" in args:
679
+ return ToolOutcome(
680
+ "Failed to parse tool arguments as valid JSON. "
681
+ "Please ensure your tool arguments are valid JSON and all special characters/quotes are properly escaped.",
682
+ ok=False
683
+ )
684
+ fn = TOOL_FUNCS.get(name)
685
+ if not fn:
686
+ return ToolOutcome(f"Unknown tool: {name}", ok=False)
687
+ try:
688
+ return fn(ctx, **args)
689
+ except TypeError as e:
690
+ return ToolOutcome(f"Bad arguments for {name}: {e}", ok=False)
691
+ except PermissionError as e:
692
+ return ToolOutcome(str(e), ok=False)
693
+ except Exception as e: # noqa: BLE001
694
+ return ToolOutcome(f"Tool {name} failed: {type(e).__name__}: {e}", ok=False)