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/cli.py ADDED
@@ -0,0 +1,1458 @@
1
+ """Command-line interface.
2
+
3
+ Just run `krnl-agent` to drop into an interactive chat (Claude-CLI style) where
4
+ you configure everything in-session:
5
+
6
+ krnl-agent # interactive chat in the current folder
7
+ krnl-agent run "add a /health route to app.py"
8
+ krnl-agent serve --port 0 # API/WebSocket server (0 = auto-port)
9
+ krnl-agent providers # list providers
10
+ krnl-agent init # scaffold config.yaml + .env (optional)
11
+
12
+ In chat, configure with slash commands (persisted to ~/.krnl-agent/config.json):
13
+ /provider [name] /key [value] /model [name] /baseurl [url]
14
+ /config /providers /reset /yes /help /exit
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import asyncio
20
+ import getpass
21
+ import json
22
+ import os
23
+ import sys
24
+ import time
25
+ from pathlib import Path
26
+
27
+ from rich.console import Console
28
+ from rich.panel import Panel
29
+ from rich.prompt import Prompt
30
+
31
+ from . import settings as user_settings
32
+ from .config import BUILTIN_PROVIDERS, load_config
33
+ from .events import AgentIO, ApprovalDecision
34
+ from .llm import build_client
35
+ from .loop import AgentSession
36
+
37
+
38
+ def _force_utf8() -> None:
39
+ """Avoid UnicodeEncodeError for box-drawing/emoji on Windows code pages."""
40
+ for stream in (sys.stdout, sys.stderr):
41
+ try:
42
+ stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined]
43
+ except Exception:
44
+ pass
45
+
46
+
47
+ _force_utf8()
48
+ console = Console(legacy_windows=False)
49
+
50
+ _SPINNER = "⣾⣽⣻⢿⡿⣟⣯⣷"
51
+ _VERBS = ["Thinking", "Reading", "Scanning", "Tracing", "Untangling",
52
+ "Refactoring", "Debugging", "Compiling", "Linting", "Patching",
53
+ "Sleuthing", "Reckoning", "Wrangling", "Stitching", "Synthesizing",
54
+ "Architecting", "Spelunking", "Excavating", "Calibrating", "Percolating"]
55
+
56
+
57
+ # --------------------------------------------------------------------------- #
58
+ # Terminal IO
59
+ # --------------------------------------------------------------------------- #
60
+ class TerminalIO(AgentIO):
61
+ """Terminal renderer with a live status footer (spinner,
62
+ rotating verb, elapsed time, live tokens/cost, tool & sub-agent counters, and
63
+ the current activity), streamed tokens, and a completion summary."""
64
+
65
+ def __init__(self, auto_approve: bool):
66
+ self.auto_approve = auto_approve
67
+ self._tty = sys.stdout.isatty()
68
+ self._live = None
69
+ self._ticker = None
70
+ self._stream = "" # in-progress streamed text (assistant / command)
71
+ self._activity = "" # current tool / sub-agent label
72
+ self._thinking_text = "" # live streamed thinking text
73
+ self._thinking_printed = False # track if thinking block has been committed to scroll
74
+ self._start = 0.0
75
+ self.tokens = 0
76
+ self.cost = 0.0
77
+ self.tool_count = 0
78
+ self.agent_count = 0
79
+ self.steps: list = []
80
+ self._finished = True
81
+ self._dirty = False # plain-mode: unfinished streamed line on screen
82
+ self.turn_tokens = 0
83
+ self.turn_prompt_tokens = 0
84
+ self.turn_completion_tokens = 0
85
+
86
+ # ----- live status footer -------------------------------------------- #
87
+ def begin_run(self) -> None:
88
+ self._stream = ""
89
+ self._activity = ""
90
+ self._thinking_text = ""
91
+ self._thinking_printed = False # track if we've committed thinking to scroll
92
+ self.steps = []
93
+ self.tool_count = self.agent_count = self.tokens = 0
94
+ self.cost = 0.0
95
+ self._finished = False
96
+ self._start = time.monotonic()
97
+ self.turn_tokens = 0
98
+ self.turn_prompt_tokens = 0
99
+ self.turn_completion_tokens = 0
100
+ if self._tty:
101
+ self._start_live()
102
+
103
+ def _start_live(self) -> None:
104
+ try:
105
+ from rich.live import Live
106
+
107
+ self._live = Live(self._render(), console=console, refresh_per_second=12)
108
+ self._live.start()
109
+ self._ticker = asyncio.create_task(self._tick())
110
+ except Exception:
111
+ self._live = None
112
+
113
+ async def _tick(self) -> None:
114
+ try:
115
+ while self._live is not None:
116
+ await asyncio.sleep(0.1)
117
+ if self._live is not None:
118
+ self._live.update(self._render())
119
+ except asyncio.CancelledError:
120
+ pass
121
+
122
+ def _render(self):
123
+ from rich.console import Group
124
+ from rich.panel import Panel as RichPanel
125
+ from rich.text import Text
126
+
127
+ elapsed = time.monotonic() - self._start
128
+ verb = _VERBS[int(elapsed / 2) % len(_VERBS)]
129
+ spin = _SPINNER[int(elapsed * 10) % len(_SPINNER)]
130
+ tok = f"{self.tokens / 1000:.1f}k" if self.tokens >= 1000 else str(self.tokens)
131
+
132
+ parts = []
133
+
134
+ # Thinking block: show as a labeled section if we have any text
135
+ if self._thinking_text:
136
+ from rich.text import Text as RichText
137
+ label = RichText()
138
+ label.append("💭 Thinking", style="bold dim yellow")
139
+ thinking_body = RichText(self._thinking_text, style="dim italic")
140
+ parts.append(label)
141
+ parts.append(thinking_body)
142
+
143
+ # Streamed response body (if any tokens have arrived)
144
+ if self._stream:
145
+ parts.append(Text(self._stream))
146
+
147
+ # Footer spinner line
148
+ footer = Text()
149
+ footer.append(f" {spin} ", style="cyan")
150
+ footer.append(f"{verb}… ", style="bold cyan")
151
+ footer.append(f"· {elapsed:.0f}s · {tok} tok", style="dim")
152
+ if self.cost:
153
+ footer.append(f" · ${self.cost:.4f}", style="dim")
154
+ footer.append(f" · {self.tool_count} tools", style="dim")
155
+ if self.agent_count:
156
+ footer.append(f" · {self.agent_count} agents", style="dim")
157
+ if self._activity:
158
+ footer.append(f" 🛠️ {self._activity}", style="bold yellow")
159
+ parts.append(footer)
160
+ return Group(*parts)
161
+
162
+ def _print(self, renderable) -> None:
163
+ (self._live.console if self._live is not None else console).print(renderable)
164
+
165
+ def _flush_stream(self) -> None:
166
+ if self._live is not None:
167
+ if self._stream:
168
+ from rich.text import Text
169
+
170
+ self._print(Text(self._stream)) # commit streamed text above footer
171
+ self._stream = ""
172
+ elif self._dirty:
173
+ sys.stdout.write("\n")
174
+ sys.stdout.flush()
175
+ self._dirty = False
176
+
177
+ def emit_sync(self, event: dict) -> None:
178
+ if event.get("type") == "token":
179
+ # First real token: commit accumulated thinking text as a permanent printed block
180
+ if self._thinking_text and not self._thinking_printed:
181
+ self._thinking_printed = True
182
+ if self._live is not None:
183
+ from rich.text import Text
184
+ label = Text()
185
+ label.append("╔═ 💭 Thinking ", style="bold yellow")
186
+ label.append("═" * max(0, 60 - len(self._thinking_text.splitlines()[0][:40])), style="dim yellow")
187
+ self._print(label)
188
+ self._print(Text(self._thinking_text.strip(), style="dim italic"))
189
+ self._print(Text("╚" + "═" * 70, style="dim yellow"))
190
+ self._thinking_text = ""
191
+ else:
192
+ sys.stdout.write(f"\033[33m╔═ 💭 Thinking ══\033[0m\n")
193
+ sys.stdout.write(f"\033[90m{self._thinking_text.strip()}\033[0m\n")
194
+ sys.stdout.write(f"\033[33m╚{'═' * 70}\033[0m\n")
195
+ sys.stdout.flush()
196
+ self._thinking_text = ""
197
+ if self._live is not None:
198
+ self._stream += event["text"]
199
+ else:
200
+ sys.stdout.write(event["text"])
201
+ sys.stdout.flush()
202
+ self._dirty = True
203
+ elif event.get("type") == "command_output":
204
+ if self._live is not None:
205
+ self._stream += event["text"]
206
+ else:
207
+ sys.stdout.write(event["text"])
208
+ sys.stdout.flush()
209
+ self._dirty = True
210
+ elif event.get("type") == "thinking_token":
211
+ if self._live is not None:
212
+ self._thinking_text += event["text"]
213
+ else:
214
+ sys.stdout.write(f"\033[90m{event['text']}\033[0m")
215
+ sys.stdout.flush()
216
+ self._dirty = True
217
+
218
+ async def emit(self, event: dict) -> None:
219
+ t = event["type"]
220
+ if t == "status":
221
+ return
222
+ if t == "token":
223
+ # For tokens, just accumulate - will be flushed on assistant_message
224
+ return
225
+ self._flush_stream() # commit any in-progress streamed text first
226
+ if t == "assistant_message":
227
+ # Flush the stream to ensure all tokens are displayed
228
+ self._flush_stream()
229
+ return
230
+ elif t == "tool_start":
231
+ self.tool_count += 1
232
+ self._thinking_text = "" # Clear thinking text
233
+ args = event["args"]
234
+ detail = args.get("path") or args.get("command") or args.get("query") or args.get("name") or ""
235
+ label = f"{event['name']} {detail}".strip()
236
+ self._activity = label
237
+ self.steps.append(label)
238
+ self._print(f"[bold cyan]🗂️ Running {event['name']}...[/bold cyan] [dim]({detail})[/dim]")
239
+ elif t == "diff":
240
+ self._print_diff(event["patch"])
241
+ elif t == "tool_result":
242
+ self._activity = ""
243
+ mark = "[bold green]✓[/bold green]" if event["ok"] else "[bold red]✗[/bold red]"
244
+ status_text = "Completed" if event["ok"] else "Failed"
245
+ self._print(f"{mark} [bold green]{status_text} {event['name']}[/bold green]")
246
+ out = (event["output"] or "").strip()
247
+ if out:
248
+ snippet = out if len(out) < 1200 else out[:1200] + " …"
249
+ self._print(f" [dim]{snippet}[/dim]")
250
+ elif t == "error":
251
+ self._print(f"[bold red]✗ Error:[/bold red] {event['message']}")
252
+ elif t == "usage":
253
+ self.tokens = event.get("session_tokens", self.tokens)
254
+ self.cost = event.get("session_cost", self.cost)
255
+ p_tok = event.get("prompt_tokens", 0)
256
+ c_tok = event.get("completion_tokens", 0)
257
+ self.turn_prompt_tokens += p_tok
258
+ self.turn_completion_tokens += c_tok
259
+ self.turn_tokens += p_tok + c_tok
260
+ elif t == "info":
261
+ self._print(f"[bold cyan]ℹ[/bold cyan] {event['message']}")
262
+ elif t == "thinking":
263
+ self._print(f"[dim]💭 {event['text']}[/dim]")
264
+ elif t == "plan":
265
+ self._print(Panel(event["text"], title="Proposed plan", border_style="magenta"))
266
+ elif t == "todos":
267
+ marks = {"completed": "[green]✔[/green]", "in_progress": "[yellow]▸[/yellow]", "pending": "[dim]○[/dim]"}
268
+ self._print("[bold]Tasks Checklists:[/bold]")
269
+ for item in event["items"]:
270
+ self._print(f" {marks.get(item.get('status'), '○')} {item.get('content', '')}")
271
+ elif t == "subagent_start":
272
+ self.agent_count += 1
273
+ self._thinking_text = "" # Clear thinking text
274
+ self._activity = f"sub-agent: {event['description']}"
275
+ self.steps.append(f"sub-agent: {event['description']}")
276
+ self._print(f"[bold purple]🤖 Sub-agent starting...[/bold purple] {event['description']}")
277
+ elif t == "subagent_end":
278
+ self._activity = ""
279
+ self._print(f"[bold purple]🤖 Sub-agent completed[/bold purple] [dim]({event['summary']})[/dim]")
280
+ elif t == "cancelled":
281
+ self._print("[yellow]■ cancelled[/yellow]")
282
+ elif t == "done":
283
+ self._finish()
284
+
285
+ def _finish(self) -> None:
286
+ if self._finished:
287
+ return
288
+ self._finished = True
289
+ self._flush_stream()
290
+ if self._ticker is not None:
291
+ self._ticker.cancel()
292
+ self._ticker = None
293
+ if self._live is not None:
294
+ try:
295
+ from rich.text import Text
296
+
297
+ self._live.update(Text(""))
298
+ self._live.stop()
299
+ except Exception:
300
+ pass
301
+ self._live = None
302
+ elapsed = time.monotonic() - self._start
303
+ cost = f" · ${self.cost:.4f}" if self.cost else ""
304
+ lines = [
305
+ f"completed in [bold green]{elapsed:.0f}s[/bold green] · [cyan]{self.turn_tokens}[/cyan] tokens this turn "
306
+ f"([dim]{self.turn_prompt_tokens} context / {self.turn_completion_tokens} completion[/dim] · "
307
+ f"[dim]{self.tokens} session total[/dim]){cost} · "
308
+ f"[yellow]{self.tool_count}[/yellow] tool calls · [magenta]{self.agent_count}[/magenta] sub-agents"
309
+ ]
310
+ if self.steps:
311
+ lines.append("")
312
+ lines.append("[bold]Steps taken:[/bold]")
313
+ for i, s in enumerate(self.steps[:25], 1):
314
+ lines.append(f" {i}. [dim]{s}[/dim]")
315
+ if len(self.steps) > 25:
316
+ lines.append(f" … and {len(self.steps) - 25} more")
317
+ console.print(Panel("\n".join(lines), title="[bold green]✦ Krnl Agent Run Completed[/bold green]", border_style="green", padding=(1, 2)))
318
+
319
+ def _pause_live(self) -> bool:
320
+ if self._live is None:
321
+ return False
322
+ if self._ticker is not None:
323
+ self._ticker.cancel()
324
+ self._ticker = None
325
+ try:
326
+ self._live.stop()
327
+ except Exception:
328
+ pass
329
+ self._live = None
330
+ return True
331
+
332
+ def _print_diff(self, patch: str) -> None:
333
+ for line in patch.splitlines():
334
+ if line.startswith("+") and not line.startswith("+++"):
335
+ self._print(f"[green]{line}[/green]")
336
+ elif line.startswith("-") and not line.startswith("---"):
337
+ self._print(f"[red]{line}[/red]")
338
+ elif line.startswith("@@"):
339
+ self._print(f"[magenta]{line}[/magenta]")
340
+ else:
341
+ self._print(f"[dim]{line}[/dim]")
342
+
343
+ async def request_approval(self, request: dict) -> ApprovalDecision:
344
+ self._flush_stream()
345
+ resume = self._pause_live()
346
+ preview = request.get("preview") or ""
347
+ title = f"Approve {request['name']}?"
348
+ if preview:
349
+ if "\n" in preview and ("+++" in preview or "@@" in preview):
350
+ self._print_diff(preview)
351
+ else:
352
+ console.print(Panel(preview, title=title, border_style="yellow"))
353
+ if self.auto_approve:
354
+ console.print("[green]auto-approved[/green]")
355
+ if resume:
356
+ self._start_live()
357
+ return ApprovalDecision(approved=True)
358
+ try:
359
+ answer = await asyncio.to_thread(
360
+ Prompt.ask,
361
+ f"[yellow]{title}[/yellow] (y=yes, a=always, n=no, or type feedback)",
362
+ default="y",
363
+ )
364
+ finally:
365
+ if resume:
366
+ self._start_live()
367
+ a = answer.strip().lower()
368
+ if a in ("y", "yes", ""):
369
+ return ApprovalDecision(approved=True)
370
+ if a in ("a", "always"):
371
+ return ApprovalDecision(approved=True, always=True)
372
+ if a in ("n", "no"):
373
+ return ApprovalDecision(approved=False, feedback="rejected by user")
374
+ return ApprovalDecision(approved=False, feedback=answer.strip())
375
+
376
+
377
+ # --------------------------------------------------------------------------- #
378
+ # Config resolution (CLI args + persisted user settings)
379
+ # --------------------------------------------------------------------------- #
380
+ def resolve_config(args):
381
+ """Merge built-ins ← config.yaml ← ~/.krnl-agent ← CLI flags."""
382
+ s = user_settings.load()
383
+ base = load_config(args.config)
384
+ provider = args.provider or s.get("provider") or base.provider.name
385
+ over = (s.get("providers") or {}).get(provider, {})
386
+ cfg = load_config(
387
+ args.config,
388
+ provider=provider,
389
+ model=args.model or over.get("model"),
390
+ api_key=over.get("api_key"),
391
+ base_url=over.get("base_url"),
392
+ )
393
+ if getattr(args, "yes", False):
394
+ cfg.agent.auto_approve_writes = True
395
+ cfg.agent.auto_approve_commands = True
396
+ return cfg
397
+
398
+
399
+ def _key_missing(cfg) -> bool:
400
+ p = cfg.provider
401
+ if p.api_key:
402
+ return False
403
+ if not p.api_key_env: # e.g. ollama / custom — no key required
404
+ return False
405
+ return not os.getenv(p.api_key_env)
406
+
407
+
408
+ def _reload(session: AgentSession, args) -> None:
409
+ cfg = resolve_config(args)
410
+ session.config = cfg
411
+ session.ctx.cfg = cfg.agent
412
+ session.client = build_client(cfg.provider, cfg.agent)
413
+
414
+
415
+ # --------------------------------------------------------------------------- #
416
+ # Commands
417
+ # --------------------------------------------------------------------------- #
418
+ async def _run_task(task: str, args) -> None:
419
+ cfg = resolve_config(args)
420
+ workspace = str(Path(args.workspace).resolve())
421
+ console.print(
422
+ f"[dim]workspace={workspace} · provider={cfg.provider.name} · "
423
+ f"model={cfg.provider.model}[/dim]"
424
+ )
425
+ if _key_missing(cfg):
426
+ console.print(
427
+ f"[red]No API key for '{cfg.provider.name}'.[/red]\n"
428
+ f"The agent needs an API key to call tools and write files.\n"
429
+ f"Set the env var {cfg.provider.api_key_env} or run "
430
+ f"[bold]krnl-agent[/bold] and use /key to configure interactively."
431
+ )
432
+ return
433
+ dangerous = getattr(args, "dangerous", False)
434
+ io = TerminalIO(auto_approve=args.yes or dangerous)
435
+ io.begin_run()
436
+ await AgentSession(workspace, cfg, io, dangerous=dangerous).run(task)
437
+
438
+
439
+ def cmd_run(args) -> None:
440
+ if getattr(args, "json", False):
441
+ from .headless import run_headless
442
+
443
+ res = asyncio.run(run_headless(
444
+ args.task, str(Path(args.workspace).resolve()),
445
+ provider=args.provider, model=args.model,
446
+ json_stream=True, team=getattr(args, "team_name", None),
447
+ ))
448
+ print(json.dumps({"type": "result", **res}))
449
+ else:
450
+ asyncio.run(_run_task(args.task, args))
451
+
452
+
453
+ def cmd_team(args) -> None:
454
+ from . import teams
455
+
456
+ rows = teams.list_teams()
457
+ if rows:
458
+ for t in rows:
459
+ console.print(f" [cyan]{t['id']}[/cyan] {(t.get('title') or '')[:60]}")
460
+ console.print("[dim]resume: krnl-agent --team-name <name>[/dim]")
461
+ else:
462
+ console.print("[dim]no teams yet — start one with: krnl-agent --team-name <name>[/dim]")
463
+
464
+
465
+ def cmd_schedule(args) -> None:
466
+ from . import schedules
467
+
468
+ if args.action == "create":
469
+ if not (args.name and args.cron and args.prompt):
470
+ console.print("[red]usage: schedule create <name> --cron \"...\" --prompt \"...\"[/red]")
471
+ return
472
+ s = schedules.create(args.name, args.cron, args.prompt,
473
+ str(Path(args.workspace).resolve()), args.provider, args.model)
474
+ console.print(f"[green]created[/green] schedule {s['id']} · {s['cron']} · {s['name']}")
475
+ elif args.action == "list":
476
+ rows = schedules.list_schedules()
477
+ if not rows:
478
+ console.print("[dim]no schedules[/dim]")
479
+ for r in rows:
480
+ console.print(f" [cyan]{r['id']}[/cyan] {r['cron']:<18} {r['name']} [dim]{r['workspace']}[/dim]")
481
+ elif args.action == "remove":
482
+ console.print("[green]removed[/green]" if schedules.remove(args.name or "") else "[yellow]not found[/yellow]")
483
+ elif args.action == "run":
484
+ from .headless import run_headless
485
+
486
+ s = schedules.get(args.name or "")
487
+ if not s:
488
+ console.print("[red]no such schedule id[/red]")
489
+ return
490
+ res = asyncio.run(run_headless(s["prompt"], s["workspace"],
491
+ provider=s.get("provider"), model=s.get("model")))
492
+ console.print(res["final"][:1000])
493
+ elif args.action == "daemon":
494
+ asyncio.run(_schedule_daemon())
495
+
496
+
497
+ async def _schedule_daemon() -> None:
498
+ from datetime import datetime
499
+
500
+ from . import schedules
501
+ from .headless import run_headless
502
+
503
+ console.print("[cyan]Krnl Agent scheduler[/cyan] running — Ctrl+C to stop")
504
+ while True:
505
+ now = datetime.now().replace(second=0, microsecond=0)
506
+ for s in schedules.due(now):
507
+ schedules.mark_run(s["id"], now.strftime("%Y-%m-%d %H:%M"))
508
+ console.print(f"[green]▸ running[/green] {s['name']} ({s['id']})")
509
+ try:
510
+ await run_headless(s["prompt"], s["workspace"],
511
+ provider=s.get("provider"), model=s.get("model"))
512
+ except Exception as e: # noqa: BLE001
513
+ console.print(f"[red]schedule error:[/red] {e}")
514
+ await asyncio.sleep(60)
515
+
516
+
517
+ HELP = """[bold green]✦ Commands[/bold green]
518
+ [bold cyan]/provider[/bold cyan] [name] [dim]switch provider (interactive if no name)[/dim]
519
+ [bold cyan]/key[/bold cyan] [value] [dim]set API key for the current provider (hidden prompt if blank)[/dim]
520
+ [bold cyan]/model[/bold cyan] [name] [dim]set the model for the current provider[/dim]
521
+ [bold cyan]/baseurl[/bold cyan] [url] [dim]set a custom base URL (self-hosted / custom endpoints)[/dim]
522
+ [bold cyan]/config[/bold cyan] [dim]show current provider, model, base URL, key status[/dim]
523
+ [bold cyan]/providers[/bold cyan] [dim]list known providers[/dim]
524
+ [bold cyan]/models[/bold cyan] [dim]show the multi-model routing table (which model per phase)[/dim]
525
+
526
+ [bold green]✦ Workflow Modes[/bold green]
527
+ [bold cyan]/plan[/bold cyan] [dim]plan mode for the next task (research → approve → execute)[/dim]
528
+ [bold cyan]/execute[/bold cyan] [dim]execute mode — carry out the next task directly (default)[/dim]
529
+ [bold cyan]/review[/bold cyan] [dim]review the current git changes for bugs (like a code review)[/dim]
530
+ [bold cyan]/init[/bold cyan] [dim]create an AGENTS.md project-memory file[/dim]
531
+ [bold cyan]/onboard[/bold cyan] [dim]scaffold .krnl/ (memory + skill + project doc) for this repo[/dim]
532
+ [bold cyan]/skills[/bold cyan] [dim]list available skills (.krnl/skills/<name>/SKILL.md)[/dim]
533
+
534
+ [bold green]✦ Security & Testing[/bold green]
535
+ [bold cyan]/security[/bold cyan] [dim]full security audit of the codebase[/dim]
536
+ [bold cyan]/scan[/bold cyan] [dim]fast secret + dependency vulnerability scan[/dim]
537
+ [bold cyan]/secfix[/bold cyan] [dim]autonomous security remediation (audit → fix → verify)[/dim]
538
+ [bold cyan]/test[/bold cyan] [target] [dim]write tests for the code and run them[/dim]
539
+ [bold cyan]/testall[/bold cyan] [dim]build & run a comprehensive test suite for the project[/dim]
540
+
541
+ [bold green]✦ Operations & Deployment[/bold green]
542
+ [bold cyan]/ship[/bold cyan] [what] [dim]plan→build→test→scan→deploy→monitor, end to end[/dim]
543
+ [bold cyan]/deploy[/bold cyan] [target] [dim]deploy the project to a live URL (one prompt)[/dim]
544
+ [bold cyan]/deploys[/bold cyan] [dim]list deploy targets and which are ready (CLI + token)[/dim]
545
+ [bold cyan]/monitor[/bold cyan] [dim]show monitoring status (errors / uptime / providers)[/dim]
546
+ [bold cyan]/heal[/bold cyan] [url] [dim]self-heal: health-check, auto-rollback, error→PR[/dim]
547
+ [bold cyan]/audit[/bold cyan] [dim]show & verify the tamper-evident action audit log[/dim]
548
+ [bold cyan]/doctor[/bold cyan] [dim]run an environment self-check[/dim]
549
+
550
+ [bold green]✦ Session Control[/bold green]
551
+ [bold cyan]/compact[/bold cyan] [dim]summarize the conversation to free up context[/dim]
552
+ [bold cyan]/search[/bold cyan] <text> [dim]search your past sessions[/dim]
553
+ [bold cyan]/usage[/bold cyan] [dim]show tokens, cost, and tool usage this session[/dim]
554
+ [bold cyan]/effort[/bold cyan] <level> [dim]reasoning effort: low | medium | high[/dim]
555
+ [bold cyan]/undo[/bold cyan] [dim]revert the file changes from the last task[/dim]
556
+ [bold cyan]/reset[/bold cyan] [dim]clear the conversation context[/dim]
557
+ [bold cyan]/yes[/bold cyan] [dim]toggle auto-approve for edits & commands[/dim]
558
+ [bold cyan]/dangerous[/bold cyan] [dim]toggle DANGEROUS mode (run everything, never ask)[/dim]
559
+ [bold cyan]/help[/bold cyan] [dim]show this help (Ctrl+C during a task = stop)[/dim]
560
+ [bold cyan]/exit[/bold cyan] [dim]quit[/dim]"""
561
+
562
+
563
+ # (name, description) for every built-in slash command - powers /help and the
564
+ # "/" autocomplete popup.
565
+ SLASH_COMMANDS = [
566
+ ("provider", "switch provider"),
567
+ ("key", "set API key"),
568
+ ("model", "set the model"),
569
+ ("baseurl", "set a custom base URL"),
570
+ ("config", "show current settings"),
571
+ ("providers", "list known providers"),
572
+ ("models", "show the multi-model routing table"),
573
+ ("plan", "plan mode for the next task"),
574
+ ("execute", "execute mode — carry out the next task directly"),
575
+ ("review", "review the current git changes"),
576
+ ("compact", "summarize the conversation"),
577
+ ("init", "create AGENTS.md memory"),
578
+ ("onboard", "scaffold .krnl/ memory + skill + project doc"),
579
+ ("skills", "list available skills"),
580
+ ("search", "search past sessions"),
581
+ ("usage", "tokens, cost, tool usage"),
582
+ ("effort", "reasoning effort low|medium|high"),
583
+ ("undo", "revert the last task's changes"),
584
+ ("reset", "clear the conversation context"),
585
+ ("yes", "toggle auto-approve"),
586
+ ("dangerous", "toggle DANGEROUS mode (no prompts)"),
587
+ ("security", "security audit of the code"),
588
+ ("scan", "fast secret + dependency scan"),
589
+ ("secfix", "autonomous security remediation"),
590
+ ("test", "write + run tests for the code"),
591
+ ("testall", "build & run a full test suite"),
592
+ ("ship", "plan→build→test→scan→deploy→monitor"),
593
+ ("deploy", "deploy the project to a live URL"),
594
+ ("deploys", "list deploy targets + readiness"),
595
+ ("monitor", "show monitoring status"),
596
+ ("heal", "self-heal: health-check, rollback, error→PR"),
597
+ ("audit", "show & verify the action audit log"),
598
+ ("doctor", "environment self-check"),
599
+ ("help", "show help"),
600
+ ("exit", "quit"),
601
+ ]
602
+
603
+ SECURITY_PROMPT = (
604
+ "Perform a thorough SECURITY REVIEW of this codebase. Use search_text and "
605
+ "read_file to inspect the real code. Look for: injection (SQL/command/XSS/"
606
+ "template), broken auth/authorization, secrets or credentials committed in "
607
+ "code, insecure deserialization, path traversal, SSRF, unsafe eval/exec, weak "
608
+ "or misused crypto, missing input validation, insecure defaults, vulnerable or "
609
+ "outdated dependencies, and sensitive data exposure/logging. Report findings "
610
+ "prioritized by severity (critical/high/medium/low) with file:line, a short "
611
+ "explanation, and a concrete fix. Do NOT modify files unless asked."
612
+ )
613
+ TEST_PROMPT = (
614
+ "Write automated tests for the recent/changed code (or the module I name). "
615
+ "Detect the test framework from the project (pytest, jest/vitest, go test, "
616
+ "cargo test, etc.). Create well-structured tests covering happy paths, edge "
617
+ "cases, and error handling. Then RUN them and fix failures until they pass. "
618
+ "Use todo_write to track progress. Summarize what you added and the results."
619
+ )
620
+ TESTALL_PROMPT = (
621
+ "Build a comprehensive automated TEST SUITE for this entire project. Steps: "
622
+ "(1) detect the language(s) and test framework; (2) map modules and find which "
623
+ "are untested; (3) generate unit tests for each, plus a few key integration "
624
+ "tests; (4) run the full suite and iterate until it passes; (5) report what was "
625
+ "added, coverage gaps that remain, and the final results. Use todo_write to "
626
+ "plan and spawn_agent for independent modules where it speeds things up."
627
+ )
628
+ DEPLOY_PROMPT = (
629
+ "Deploy this project to a live URL. Steps: (1) call deploy_check to see which "
630
+ "targets are READY (CLI + token) and pick the best free-tier one for this stack "
631
+ "(prefer the suggested default); (2) if the app needs a database, provision_db "
632
+ "(neon or supabase) first and wire the connection string in as a secret/env var; "
633
+ "(3) generate any missing platform config (Dockerfile, fly.toml, wrangler.toml, "
634
+ "etc.) and a /health endpoint if absent; (4) call deploy with the chosen target — "
635
+ "billable targets need explicit user approval, free-tier ones proceed; (5) report "
636
+ "the live URL. Never print secret values; read tokens from env. Use todo_write to "
637
+ "track steps."
638
+ )
639
+ SHIP_PROMPT = (
640
+ "Take this project from here to LIVE and MONITORED, end to end, with approval at "
641
+ "the risky steps. Pipeline: (1) PLAN — restate what we're shipping, detect the "
642
+ "stack, pick a deploy target (deploy_check) and DB if needed; (2) BUILD — make it "
643
+ "run, generating any missing config and a /health endpoint; (3) TEST — write and "
644
+ "run tests, fix failures; (4) SECURITY — run secret_scan and dependency_audit and "
645
+ "fix High/Critical findings before shipping; (5) DEPLOY — provision_db if needed, "
646
+ "then deploy (free-tier preferred; ask before anything billable) and capture the "
647
+ "URL; (6) MONITOR — ensure a /health endpoint, then call monitor_status and tell "
648
+ "the user how to wire Sentry/OTel/uptime (set the tokens). Finish with the live "
649
+ "URL, what was deployed, and how to watch it. Use todo_write throughout; never "
650
+ "echo secrets."
651
+ )
652
+ HEAL_PROMPT = (
653
+ "Run a self-healing pass on the deployed app. (1) If a URL is given, health_check "
654
+ "it; if unhealthy, rollback the target to its last known-good release (this is "
655
+ "safe and pre-approved). (2) Call monitor_status to pull current production "
656
+ "errors. (3) For each real error, find the root cause in the code, write a fix "
657
+ "PLUS a regression test, and open a PR (open_pr) — do NOT auto-merge or deploy the "
658
+ "fix; leave it for human review per policy. Summarize: health, what was rolled "
659
+ "back, and the PRs opened."
660
+ )
661
+ SCAN_PROMPT = (
662
+ "Run a fast risk scan of this repository: call secret_scan to find hard-coded "
663
+ "credentials and dependency_audit to find vulnerable dependencies. Summarize "
664
+ "every finding with severity and a one-line fix. Do NOT modify files."
665
+ )
666
+ SECFIX_PROMPT = (
667
+ "Autonomous security remediation loop. (1) AUDIT: run secret_scan and "
668
+ "dependency_audit and do a focused code review for injection, authz, SSRF, path "
669
+ "traversal, and unsafe eval/exec. (2) PLAN: list findings by severity with a fix "
670
+ "for each. (3) FIX: implement the fixes (move secrets to env, pin/upgrade deps, "
671
+ "validate input, etc.), highest severity first, asking approval for each change. "
672
+ "(4) VERIFY: re-run the scans and the test suite to confirm nothing broke and the "
673
+ "issue is gone. Use todo_write to track every finding through to resolution and "
674
+ "report a before/after summary."
675
+ )
676
+
677
+
678
+ def _build_prompt_session(workspace: str):
679
+ """A prompt_toolkit session whose completer pops up all slash commands the
680
+ moment you type '/'. Returns None if prompt_toolkit isn't available or stdin
681
+ isn't an interactive terminal (e.g. piped input)."""
682
+ try:
683
+ if not sys.stdin.isatty():
684
+ return None
685
+ from prompt_toolkit import PromptSession
686
+ from prompt_toolkit.completion import Completer, Completion
687
+ except Exception:
688
+ return None
689
+
690
+ cmds = list(SLASH_COMMANDS)
691
+ try:
692
+ from .commands import load_commands
693
+
694
+ for name in load_commands(workspace):
695
+ cmds.append((name, "custom command"))
696
+ except Exception:
697
+ pass
698
+
699
+ class _SlashCompleter(Completer):
700
+ def get_completions(self, document, complete_event):
701
+ text = document.text_before_cursor
702
+ if not text.startswith("/"):
703
+ return
704
+ word = text[1:].lower()
705
+ for name, desc in cmds:
706
+ if name.lower().startswith(word):
707
+ yield Completion("/" + name, start_position=-len(text),
708
+ display="/" + name, display_meta=desc)
709
+
710
+ return PromptSession(completer=_SlashCompleter(), complete_while_typing=True)
711
+
712
+
713
+ def _print_providers() -> None:
714
+ from rich.table import Table
715
+
716
+ s = user_settings.load()
717
+ cfg = load_config()
718
+ table = Table(title="Providers")
719
+ table.add_column("name")
720
+ table.add_column("type")
721
+ table.add_column("model")
722
+ table.add_column("key", justify="center")
723
+ for name, p in cfg.all_providers.items():
724
+ over = (s.get("providers") or {}).get(name, {})
725
+ has_key = bool(
726
+ over.get("api_key") or (p.api_key_env and os.getenv(p.api_key_env))
727
+ )
728
+ needs = bool(p.api_key_env)
729
+ key_mark = "✓" if has_key else ("—" if not needs else "[red]✗[/red]")
730
+ table.add_row(name, p.type, over.get("model") or p.model, key_mark)
731
+ console.print(table)
732
+
733
+
734
+ def _print_models(cfg=None) -> None:
735
+ """Show the multi-model routing table: which model runs each phase + its price."""
736
+ from rich.table import Table
737
+
738
+ from .modelrouter import ModelRouter
739
+
740
+ cfg = cfg or load_config()
741
+ router = ModelRouter(cfg)
742
+ table = Table(title=f"Model routing (strategy: {router.strategy})")
743
+ table.add_column("phase")
744
+ table.add_column("role")
745
+ table.add_column("provider")
746
+ table.add_column("model")
747
+ table.add_column("in $/1M", justify="right")
748
+ table.add_column("out $/1M", justify="right")
749
+ for row in router.summary():
750
+ table.add_row(
751
+ row["phase"], row["role"], row["provider"], row["model"],
752
+ "—" if row["in_per_1m"] is None else f"{row['in_per_1m']:.2f}",
753
+ "—" if row["out_per_1m"] is None else f"{row['out_per_1m']:.2f}",
754
+ )
755
+ console.print(table)
756
+ if not cfg.models and not cfg.routing and not cfg.router:
757
+ console.print("[dim]Single-model setup. Configure `models:` and `routing:` in "
758
+ "config.yaml to assign different models per phase. See "
759
+ "docs/MULTI_MODEL.md.[/dim]")
760
+
761
+
762
+ def _pick_provider() -> str:
763
+ names = list(BUILTIN_PROVIDERS)
764
+ for i, n in enumerate(names, 1):
765
+ console.print(f" [cyan]{i}[/cyan]. {n}")
766
+ ans = Prompt.ask("provider (number or name)", default="").strip()
767
+ if ans.isdigit() and 1 <= int(ans) <= len(names):
768
+ return names[int(ans) - 1]
769
+ return ans
770
+
771
+
772
+ async def _handle_command(line: str, session: AgentSession, args, io: TerminalIO) -> str:
773
+ parts = line.split(maxsplit=1)
774
+ cmd = parts[0].lower()
775
+ rest = parts[1].strip() if len(parts) > 1 else ""
776
+
777
+ if cmd in ("/exit", "/quit"):
778
+ return "exit"
779
+ if cmd == "/help":
780
+ console.print(HELP)
781
+ elif cmd == "/plan":
782
+ session.plan_mode = True
783
+ console.print("[magenta]plan mode ON[/magenta] — your next task will research & propose a plan first.")
784
+ elif cmd in ("/execute", "/exec", "/run"):
785
+ session.plan_mode = False
786
+ console.print("[green]execute mode ON[/green] — your next task will be carried out directly.")
787
+ elif cmd == "/compact":
788
+ from .context import summarize_history
789
+
790
+ new, changed = summarize_history(session.client, session.messages)
791
+ if changed:
792
+ session.messages = new
793
+ console.print("[green]conversation compacted[/green]")
794
+ else:
795
+ console.print("[dim]nothing to compact yet[/dim]")
796
+ elif cmd == "/undo":
797
+ reverted = session.undo()
798
+ if reverted:
799
+ console.print(f"[green]reverted {len(reverted)} file(s)[/green]")
800
+ for p in reverted:
801
+ console.print(f" [dim]{p}[/dim]")
802
+ else:
803
+ console.print("[dim]nothing to undo[/dim]")
804
+ elif cmd == "/reset":
805
+ session.messages.clear()
806
+ console.print("[dim]context cleared[/dim]")
807
+ elif cmd == "/yes":
808
+ io.auto_approve = not io.auto_approve
809
+ console.print(f"[dim]auto-approve = {io.auto_approve}[/dim]")
810
+ elif cmd in ("/dangerous", "/yolo"):
811
+ session.dangerous = not session.dangerous
812
+ io.auto_approve = session.dangerous or io.auto_approve
813
+ if session.dangerous:
814
+ console.print("[bold red]DANGEROUS MODE ON[/bold red] - the agent will now run "
815
+ "EVERYTHING (edits, commands, deletes) without asking. Use with care.")
816
+ else:
817
+ console.print("[green]dangerous mode OFF[/green] - approvals restored.")
818
+ elif cmd == "/providers":
819
+ _print_providers()
820
+ elif cmd == "/models":
821
+ _print_models(session.config)
822
+ elif cmd == "/config":
823
+ p = session.config.provider
824
+ keyset = bool(p.api_key or (p.api_key_env and os.getenv(p.api_key_env)))
825
+ console.print(
826
+ f"provider : [cyan]{p.name}[/cyan]\n"
827
+ f"model : {p.model}\n"
828
+ f"base_url : {p.base_url or '(default)'}\n"
829
+ f"api key : {'[green]set[/green]' if keyset else '[red]not set[/red]'}"
830
+ )
831
+ elif cmd == "/provider":
832
+ name = rest or await asyncio.to_thread(_pick_provider)
833
+ if not name:
834
+ pass
835
+ elif name not in session.config.all_providers:
836
+ console.print(f"[red]unknown provider:[/red] {name} (/providers)")
837
+ else:
838
+ user_settings.set_active_provider(name)
839
+ args.provider = name
840
+ args.model = None # let the new provider's own model apply
841
+ _reload(session, args)
842
+ console.print(
843
+ f"[green]provider → {session.config.provider.name}[/green] "
844
+ f"({session.config.provider.model})"
845
+ )
846
+ if _key_missing(session.config):
847
+ console.print("[yellow]no API key for this provider — use /key[/yellow]")
848
+ elif cmd == "/key":
849
+ provider = session.config.provider.name
850
+ value = rest or await asyncio.to_thread(
851
+ getpass.getpass, f"API key for {provider} (hidden): "
852
+ )
853
+ if value.strip():
854
+ user_settings.set_provider_field(provider, "api_key", value.strip())
855
+ _reload(session, args)
856
+ console.print(f"[green]key saved for {provider}[/green]")
857
+ elif cmd == "/model":
858
+ provider = session.config.provider.name
859
+ value = rest or await asyncio.to_thread(Prompt.ask, "model")
860
+ if value.strip():
861
+ user_settings.set_provider_field(provider, "model", value.strip())
862
+ args.model = value.strip()
863
+ _reload(session, args)
864
+ console.print(f"[green]model → {session.config.provider.model}[/green]")
865
+ elif cmd == "/baseurl":
866
+ provider = session.config.provider.name
867
+ value = rest or await asyncio.to_thread(Prompt.ask, "base url")
868
+ if value.strip():
869
+ user_settings.set_provider_field(provider, "base_url", value.strip())
870
+ _reload(session, args)
871
+ console.print(f"[green]base_url → {session.config.provider.base_url}[/green]")
872
+ elif cmd == "/init":
873
+ from .memory import write_template
874
+
875
+ p = write_template(session.workspace)
876
+ console.print(f"[green]wrote {p.name}[/green] — edit it with project instructions.")
877
+ elif cmd == "/onboard":
878
+ from . import scaffold
879
+ from .skills import load_skills
880
+
881
+ created = scaffold.scaffold(session.workspace)
882
+ session.skills = load_skills(session.workspace)
883
+ if session.messages and session.messages[0].get("role") == "system":
884
+ session.messages.pop(0) # rebuild system prompt (picks up new memory/skill)
885
+ console.print(
886
+ "[green]onboarded[/green] — created " + (", ".join(created) if created else "(already set up)")
887
+ )
888
+ elif cmd == "/usage":
889
+ from . import pricing
890
+
891
+ p = session.config.provider
892
+ priced = pricing.is_priced(p.model, session.config.pricing)
893
+ cost_str = f"${session.session_cost:.4f}" if priced else "[yellow]pricing not set[/yellow]"
894
+ console.print(
895
+ f"[bold]Usage this session[/bold]\n"
896
+ f" model: [cyan]{p.name}/{p.model}[/cyan]\n"
897
+ f" tokens: {session.session_tokens} · cost: {cost_str}\n"
898
+ f" sub-agents: {session.subagent_calls}"
899
+ )
900
+ if not priced and session.session_tokens:
901
+ console.print(
902
+ f"[dim] No built-in price for '{p.model}'. Add it to config.yaml:\n"
903
+ f" pricing:\n \"{p.model}\": {{ input: 1.25, output: 10 }} # USD per 1M tokens[/dim]"
904
+ )
905
+ if session.tool_calls:
906
+ console.print(" tools: " + ", ".join(f"{k} x{v}" for k, v in session.tool_calls.most_common()))
907
+ elif cmd == "/skills":
908
+ if session.skills:
909
+ for name, s in session.skills.items():
910
+ console.print(f" [cyan]{name}[/cyan] — {s['description']}")
911
+ else:
912
+ console.print("[dim]no skills found (add .krnl/skills/<name>/SKILL.md)[/dim]")
913
+ elif cmd == "/search":
914
+ from . import sessions as _sessions
915
+
916
+ if not rest:
917
+ console.print("[red]usage: /search <text>[/red]")
918
+ else:
919
+ hits = _sessions.search(rest, session.workspace)
920
+ if hits:
921
+ for h in hits:
922
+ console.print(f" [cyan]{h['id']}[/cyan] {h['title']} [dim]…{h['snippet']}…[/dim]")
923
+ else:
924
+ console.print("[dim]no matches in past sessions[/dim]")
925
+ elif cmd == "/effort":
926
+ level = (rest or "medium").strip().lower()
927
+ if level not in ("low", "medium", "high"):
928
+ console.print("[red]usage: /effort low|medium|high[/red]")
929
+ else:
930
+ session.config.agent.reasoning_effort = level
931
+ session.config.agent.thinking = level == "high"
932
+ from .llm import build_client
933
+
934
+ session.client = build_client(session.config.provider, session.config.agent)
935
+ console.print(f"[green]effort → {level}[/green]")
936
+ elif cmd == "/review":
937
+ return ("run:Review the current uncommitted git changes (call git_diff first) "
938
+ "for bugs, security issues, race conditions, and improvements. Give a "
939
+ "concise, prioritized findings list. Do NOT modify files unless asked.")
940
+ elif cmd == "/security":
941
+ return "run:" + SECURITY_PROMPT
942
+ elif cmd == "/test":
943
+ return "run:" + (rest and f"{TEST_PROMPT}\nTarget: {rest}" or TEST_PROMPT)
944
+ elif cmd == "/testall":
945
+ return "run:" + TESTALL_PROMPT
946
+ elif cmd == "/scan":
947
+ return "run:" + SCAN_PROMPT
948
+ elif cmd == "/secfix":
949
+ return "run:" + SECFIX_PROMPT
950
+ elif cmd == "/deploy":
951
+ return "run:" + (rest and f"{DEPLOY_PROMPT}\nTarget/notes: {rest}" or DEPLOY_PROMPT)
952
+ elif cmd == "/ship":
953
+ return "run:" + (rest and f"{SHIP_PROMPT}\nWhat to ship: {rest}" or SHIP_PROMPT)
954
+ elif cmd == "/heal":
955
+ return "run:" + (rest and f"{HEAL_PROMPT}\nDeployed URL: {rest}" or HEAL_PROMPT)
956
+ elif cmd == "/monitor":
957
+ from . import monitor
958
+
959
+ console.print(monitor.status())
960
+ elif cmd == "/deploys":
961
+ from .tools import deploy_check as _dc
962
+
963
+ console.print(_dc(session.ctx).output)
964
+ elif cmd == "/doctor":
965
+ from . import doctor
966
+
967
+ console.print(doctor.format_report(doctor.run_checks(session.config, session.workspace)))
968
+ elif cmd == "/audit":
969
+ from . import audit_log
970
+
971
+ intact, msg = audit_log.verify(session.workspace)
972
+ console.print(audit_log.tail(session.workspace, 25))
973
+ console.print(("[green]" if intact else "[red]") + msg + "[/]")
974
+ else:
975
+ return "unknown"
976
+ return "handled"
977
+
978
+
979
+ async def _chat(args) -> None:
980
+ cfg = resolve_config(args)
981
+ workspace = str(Path(args.workspace).resolve())
982
+ io = TerminalIO(auto_approve=getattr(args, "yes", False))
983
+ sid = getattr(args, "session", None)
984
+ team = getattr(args, "team_name", None)
985
+ dangerous = getattr(args, "dangerous", False)
986
+ persist = bool(getattr(args, "resume", False) or sid)
987
+ session = AgentSession(workspace, cfg, io, persist=persist, session_id=sid,
988
+ team=team, dangerous=dangerous)
989
+ if dangerous:
990
+ io.auto_approve = True
991
+
992
+ # Auto-onboard: scaffold .krnl/ (memory + skill + project doc) on first run.
993
+ if (cfg.agent.auto_onboard and not getattr(args, "no_onboard", False)
994
+ and not getattr(args, "resume", False)):
995
+ from . import scaffold
996
+ from .skills import load_skills
997
+
998
+ if scaffold.needs_scaffold(workspace):
999
+ created = scaffold.scaffold(workspace)
1000
+ if created:
1001
+ session.skills = load_skills(workspace)
1002
+ console.print(
1003
+ f"[green]✦ Onboarded this project[/green] [dim](created {len(created)} "
1004
+ f"files under .krnl/: memory, project doc, a skill - edit "
1005
+ f".krnl/AGENTS.md to guide me)[/dim]"
1006
+ )
1007
+
1008
+ if team:
1009
+ console.print(f"[magenta]team mode:[/magenta] coordinating '{team}' (state persists)")
1010
+ if dangerous:
1011
+ console.print("[bold red]DANGEROUS MODE ON[/bold red] - the agent will run everything without asking.")
1012
+ console.print(
1013
+ Panel(
1014
+ f"[bold green]✦ Krnl Agent[/bold green] · [bold cyan]{cfg.provider.name}[/bold cyan] · {cfg.provider.model}\n"
1015
+ f"[dim]Workspace:[/dim] [white]{workspace}[/white]\n"
1016
+ f"[dim]Type a task, or[/dim] [bold cyan]/help[/bold cyan] [dim]for commands.[/dim]",
1017
+ border_style="cyan",
1018
+ padding=(1, 2)
1019
+ )
1020
+ )
1021
+ if _key_missing(cfg):
1022
+ console.print(
1023
+ f"[yellow]No API key for '{cfg.provider.name}'.[/yellow] "
1024
+ "Set one with [bold]/key[/bold] (or switch with [bold]/provider[/bold])."
1025
+ )
1026
+
1027
+ ptk = _build_prompt_session(workspace) # '/' autocomplete popup (if available)
1028
+ if ptk is None:
1029
+ console.print("[dim](tip: pip install prompt_toolkit for '/' command autocomplete)[/dim]")
1030
+
1031
+ while True:
1032
+ try:
1033
+ if ptk is not None:
1034
+ from prompt_toolkit.formatted_text import HTML
1035
+ msg = await ptk.prompt_async(HTML("<ansibrightwhite>you</ansibrightwhite> <ansibrightpurple>❯</ansibrightpurple> "))
1036
+ else:
1037
+ msg = await asyncio.to_thread(Prompt.ask, "[bold white]you[/bold white] [bold purple]❯[/bold purple]")
1038
+ except (EOFError, KeyboardInterrupt):
1039
+ break
1040
+ msg = msg.strip()
1041
+ if not msg:
1042
+ continue
1043
+ if msg.startswith("/"):
1044
+ result = await _handle_command(msg, session, args, io)
1045
+ if result == "exit":
1046
+ break
1047
+ if result and result.startswith("run:"):
1048
+ msg = result[4:] # a command that expands into a task
1049
+ elif result == "unknown":
1050
+ # maybe a custom command from .krnl/commands/<name>.md
1051
+ from .commands import expand_command, load_commands
1052
+
1053
+ name, _, rest = msg[1:].partition(" ")
1054
+ custom = load_commands(workspace)
1055
+ if name in custom:
1056
+ msg = expand_command(custom[name], rest.strip())
1057
+ else:
1058
+ console.print(f"[red]unknown command[/red] /{name} (/help)")
1059
+ continue
1060
+ else:
1061
+ continue
1062
+ if _key_missing(session.config):
1063
+ console.print(
1064
+ "[yellow]No API key set — use /key first (or /provider to switch).[/yellow]"
1065
+ )
1066
+ continue
1067
+ io.begin_run()
1068
+ task = asyncio.ensure_future(session.run(msg))
1069
+ try:
1070
+ await task
1071
+ except KeyboardInterrupt:
1072
+ session.cancel()
1073
+ console.print("\n[yellow]stopping…[/yellow]")
1074
+ try:
1075
+ await task
1076
+ except Exception:
1077
+ pass
1078
+ finally:
1079
+ io._finish() # safety: ensure the live footer is stopped
1080
+ console.print("[dim]bye[/dim]")
1081
+
1082
+
1083
+ def cmd_chat(args) -> None:
1084
+ asyncio.run(_chat(args))
1085
+
1086
+
1087
+ def cmd_serve(args) -> None:
1088
+ from .server import serve
1089
+
1090
+ console.print(f"[cyan]Krnl Agent server[/cyan] on http://{args.host}:{args.port}")
1091
+ if args.host not in ("127.0.0.1", "localhost") and args.no_auth:
1092
+ console.print("[red]warning:[/red] serving on a public host with auth disabled!")
1093
+ serve(
1094
+ host=args.host,
1095
+ port=args.port,
1096
+ reload=args.reload,
1097
+ token=args.token,
1098
+ auth=not args.no_auth,
1099
+ )
1100
+
1101
+
1102
+ def cmd_providers(args) -> None:
1103
+ _print_providers()
1104
+
1105
+
1106
+ def cmd_models(args) -> None:
1107
+ try:
1108
+ cfg = resolve_config(args)
1109
+ except Exception:
1110
+ cfg = load_config()
1111
+ _print_models(cfg)
1112
+
1113
+
1114
+ def cmd_security(args) -> None:
1115
+ asyncio.run(_run_task(SECURITY_PROMPT, args))
1116
+
1117
+
1118
+ def cmd_test(args) -> None:
1119
+ prompt = TESTALL_PROMPT if getattr(args, "all", False) else TEST_PROMPT
1120
+ asyncio.run(_run_task(prompt, args))
1121
+
1122
+
1123
+ def cmd_scan(args) -> None:
1124
+ asyncio.run(_run_task(SCAN_PROMPT, args))
1125
+
1126
+
1127
+ def cmd_secfix(args) -> None:
1128
+ asyncio.run(_run_task(SECFIX_PROMPT, args))
1129
+
1130
+
1131
+ def cmd_deploy(args) -> None:
1132
+ if getattr(args, "check", False):
1133
+ from .tools import ToolContext, deploy_check
1134
+ cfg = resolve_config(args)
1135
+ ctx = ToolContext(str(Path(args.workspace).resolve()), cfg.agent, list(cfg.ignore),
1136
+ deploy_cfg=cfg.deploy)
1137
+ console.print(deploy_check(ctx).output)
1138
+ return
1139
+ prompt = DEPLOY_PROMPT + (f"\nTarget/notes: {args.what}" if getattr(args, "what", None) else "")
1140
+ asyncio.run(_run_task(prompt, args))
1141
+
1142
+
1143
+ def cmd_ship(args) -> None:
1144
+ prompt = SHIP_PROMPT + (f"\nWhat to ship: {args.what}" if getattr(args, "what", None) else "")
1145
+ asyncio.run(_run_task(prompt, args))
1146
+
1147
+
1148
+ def cmd_heal(args) -> None:
1149
+ prompt = HEAL_PROMPT + (f"\nDeployed URL: {args.url}" if getattr(args, "url", None) else "")
1150
+ asyncio.run(_run_task(prompt, args))
1151
+
1152
+
1153
+ def cmd_monitor(args) -> None:
1154
+ from . import monitor
1155
+
1156
+ console.print(monitor.status())
1157
+
1158
+
1159
+ def cmd_doctor(args) -> None:
1160
+ from . import doctor
1161
+
1162
+ try:
1163
+ cfg = resolve_config(args)
1164
+ except Exception:
1165
+ cfg = None
1166
+ workspace = str(Path(getattr(args, "workspace", ".")).resolve())
1167
+ console.print(doctor.format_report(doctor.run_checks(cfg, workspace)))
1168
+
1169
+
1170
+ def cmd_audit(args) -> None:
1171
+ from . import audit_log
1172
+
1173
+ workspace = str(Path(getattr(args, "workspace", ".")).resolve())
1174
+ console.print(audit_log.tail(workspace, getattr(args, "lines", 25)))
1175
+ intact, msg = audit_log.verify(workspace)
1176
+ console.print(("[green]" if intact else "[red]") + msg + "[/]")
1177
+
1178
+
1179
+ def cmd_init_ci(args) -> None:
1180
+ from . import ci
1181
+
1182
+ workspace = str(Path(getattr(args, "workspace", ".")).resolve())
1183
+ ok, where = ci.write_workflow(workspace, force=getattr(args, "force", False))
1184
+ if ok:
1185
+ console.print(f"[green]Wrote[/green] {where}")
1186
+ console.print("[dim]Add your provider key as a repo secret, then commit it.[/dim]")
1187
+ else:
1188
+ console.print(f"[yellow]{where}[/yellow]")
1189
+
1190
+
1191
+ def cmd_onboard(args) -> None:
1192
+ from . import scaffold
1193
+
1194
+ ws = str(Path(args.workspace).resolve())
1195
+ created = scaffold.scaffold(ws)
1196
+ if created:
1197
+ console.print("[green]Onboarded.[/green] Created under .krnl/:")
1198
+ for c in created:
1199
+ console.print(f" [dim]{c}[/dim]")
1200
+ console.print("[dim]Edit .krnl/AGENTS.md to give the agent project memory/rules.[/dim]")
1201
+ else:
1202
+ console.print("[dim].krnl is already set up.[/dim]")
1203
+
1204
+
1205
+ def cmd_update(args) -> None:
1206
+ import subprocess
1207
+
1208
+ pkg = "krnl-coding-agent"
1209
+ console.print(f"[cyan]Updating {pkg} to the latest version...[/cyan]")
1210
+ r = subprocess.run(
1211
+ [sys.executable, "-m", "pip", "install", "-U", "--no-cache-dir", pkg]
1212
+ )
1213
+ if r.returncode == 0:
1214
+ from . import __version__
1215
+
1216
+ console.print(f"[green]Done.[/green] Restart krnl-agent to use the new version "
1217
+ f"(was {__version__}).")
1218
+ else:
1219
+ console.print("[red]Update failed.[/red] Close any running krnl-agent and retry, "
1220
+ "or run: pip install -U krnl-coding-agent")
1221
+
1222
+
1223
+ def cmd_plugin(args) -> None:
1224
+ from . import plugins
1225
+
1226
+ if args.action == "list":
1227
+ rows = plugins.list_plugins()
1228
+ if rows:
1229
+ for p in rows:
1230
+ console.print(f" [cyan]{p['name']}[/cyan] {p['description']}")
1231
+ else:
1232
+ console.print("[dim]no plugins installed[/dim]")
1233
+ elif args.action == "add":
1234
+ if not args.source:
1235
+ console.print("[red]usage: krnl-agent plugin add <dir|zip-url>[/red]")
1236
+ return
1237
+ name = plugins.install(args.source)
1238
+ console.print(f"[green]installed plugin '{name}'[/green]")
1239
+ elif args.action == "remove":
1240
+ ok = plugins.remove(args.source or "")
1241
+ console.print(f"[green]removed[/green]" if ok else "[yellow]not found[/yellow]")
1242
+
1243
+
1244
+ def cmd_sessions(args) -> None:
1245
+ from rich.table import Table
1246
+
1247
+ from . import sessions
1248
+
1249
+ ws = None if getattr(args, "all", False) else str(Path(args.workspace).resolve())
1250
+ rows = sessions.list_sessions(ws)
1251
+ if not rows:
1252
+ console.print("[dim]no saved sessions[/dim]")
1253
+ return
1254
+ table = Table(title="Sessions")
1255
+ table.add_column("id")
1256
+ table.add_column("title")
1257
+ table.add_column("workspace")
1258
+ for r in rows[:40]:
1259
+ table.add_row(r["id"], (r.get("title") or "")[:50], (r.get("workspace") or "")[-40:])
1260
+ console.print(table)
1261
+ console.print("[dim]resume with: krnl-agent chat --session <id>[/dim]")
1262
+
1263
+
1264
+ # Embedded templates so `init` works even from a pip install (the example files
1265
+ # are not shipped inside the wheel).
1266
+ _CONFIG_TEMPLATE = """# Krnl Agent config. `active` picks a provider profile below.
1267
+ # Every profile is OpenAI-compatible unless type: anthropic. That covers
1268
+ # Krnl/Krnl, OpenAI, OpenRouter, Ollama, vLLM, LM Studio, and more.
1269
+ active: openai
1270
+
1271
+ providers:
1272
+ krnl:
1273
+ type: openai
1274
+ base_url: https://api.krnl.one/api/v1
1275
+ api_key_env: KRL_API_KEY
1276
+ model: gpt-4.1-mini
1277
+ openai:
1278
+ type: openai
1279
+ base_url: https://api.openai.com/v1
1280
+ api_key_env: OPENAI_API_KEY
1281
+ model: gpt-4o-mini
1282
+ ollama:
1283
+ type: openai
1284
+ base_url: http://localhost:11434/v1
1285
+ api_key_env: null
1286
+ model: qwen2.5-coder:7b
1287
+ anthropic:
1288
+ type: anthropic
1289
+ api_key_env: ANTHROPIC_API_KEY
1290
+ model: claude-sonnet-4-6
1291
+
1292
+ agent:
1293
+ max_steps: 30
1294
+ auto_approve_writes: false
1295
+ auto_approve_commands: false
1296
+ """
1297
+
1298
+ _ENV_TEMPLATE = """# Only set the key(s) for the provider(s) you use.
1299
+ KRL_API_KEY=
1300
+ OPENAI_API_KEY=
1301
+ OPENROUTER_API_KEY=
1302
+ ANTHROPIC_API_KEY=
1303
+ """
1304
+
1305
+
1306
+ def cmd_init(args) -> None:
1307
+ for name, content in (("config.yaml", _CONFIG_TEMPLATE), (".env", _ENV_TEMPLATE)):
1308
+ dst = Path.cwd() / name
1309
+ if dst.exists():
1310
+ console.print(f"[yellow]skip[/yellow] {name} already exists")
1311
+ else:
1312
+ dst.write_text(content, encoding="utf-8")
1313
+ console.print(f"[green]created[/green] {name}")
1314
+ console.print(
1315
+ "Tip: you can also just run [bold]krnl-agent[/bold] and use /provider and "
1316
+ "/key — no files needed."
1317
+ )
1318
+
1319
+
1320
+ # --------------------------------------------------------------------------- #
1321
+ # Parser
1322
+ # --------------------------------------------------------------------------- #
1323
+ def cmd_version(args) -> None:
1324
+ from . import __version__
1325
+
1326
+ console.print(f"krnl-agent {__version__} (krnl-coding-agent)")
1327
+
1328
+
1329
+ def build_parser() -> argparse.ArgumentParser:
1330
+ from . import __version__
1331
+
1332
+ p = argparse.ArgumentParser(prog="krnl-agent", description="Krnl coding agent.")
1333
+ p.add_argument(
1334
+ "--version", "-V", action="version",
1335
+ version=f"krnl-agent {__version__} (krnl-coding-agent)",
1336
+ )
1337
+ p.add_argument("--config", help="Path to config.yaml")
1338
+ p.add_argument("--provider", help="Override active provider profile")
1339
+ p.add_argument("--model", help="Override model id")
1340
+ p.add_argument("--workspace", default=os.getcwd(), help="Workspace root (default: cwd)")
1341
+ p.add_argument("--team-name", dest="team_name", help="Run as a coordinator for this named team")
1342
+ p.add_argument("--dangerous", action="store_true",
1343
+ help="DANGEROUS: auto-run everything, never ask for approval (YOLO mode)")
1344
+ p.add_argument("--no-onboard", dest="no_onboard", action="store_true",
1345
+ help="Do not auto-scaffold a .krnl/ wrapper on first run")
1346
+ sub = p.add_subparsers(dest="command")
1347
+ sub.required = False # bare `krnl-agent` -> chat
1348
+
1349
+ r = sub.add_parser("run", help="Run a single task and exit")
1350
+ r.add_argument("task")
1351
+ r.add_argument("--yes", "-y", action="store_true", help="Auto-approve all actions")
1352
+ r.add_argument("--json", action="store_true", help="Headless: stream events as JSON (auto-approve)")
1353
+ r.set_defaults(func=cmd_run)
1354
+
1355
+ c = sub.add_parser("chat", help="Interactive REPL")
1356
+ c.add_argument("--yes", "-y", action="store_true", help="Auto-approve all actions")
1357
+ c.add_argument("--resume", action="store_true", help="Resume the latest session for this workspace")
1358
+ c.add_argument("--session", help="Resume a specific session id (see: krnl-agent sessions)")
1359
+ c.set_defaults(func=cmd_chat)
1360
+
1361
+ s = sub.add_parser("serve", help="Start the FastAPI server")
1362
+ s.add_argument("--host", default="127.0.0.1", help="Bind host (0.0.0.0 for remote/cloud)")
1363
+ s.add_argument("--port", type=int, default=8000, help="Port (0 = auto-pick a free port)")
1364
+ s.add_argument("--token", help="Bearer token clients must send (else one is generated)")
1365
+ s.add_argument("--no-auth", action="store_true", help="Disable token auth (local only!)")
1366
+ s.add_argument("--reload", action="store_true")
1367
+ s.set_defaults(func=cmd_serve)
1368
+
1369
+ sub.add_parser("providers", help="List configured providers").set_defaults(func=cmd_providers)
1370
+ sub.add_parser("models", help="Show the multi-model routing table (per-phase model + price)").set_defaults(func=cmd_models)
1371
+ sub.add_parser("init", help="Scaffold config.yaml and .env").set_defaults(func=cmd_init)
1372
+ sub.add_parser("update", help="Update krnl-coding-agent to the latest version").set_defaults(func=cmd_update)
1373
+ sub.add_parser("version", help="Show the installed version").set_defaults(func=cmd_version)
1374
+ sub.add_parser("onboard", help="Scaffold .krnl/ (memory + skill + project doc)").set_defaults(func=cmd_onboard)
1375
+
1376
+ sec = sub.add_parser("security", help="Run a security audit of the codebase")
1377
+ sec.add_argument("--yes", "-y", action="store_true", help="Auto-approve all actions")
1378
+ sec.set_defaults(func=cmd_security)
1379
+
1380
+ tst = sub.add_parser("test", help="Write and run tests for the code")
1381
+ tst.add_argument("--all", action="store_true", help="Build a full test suite for the whole project")
1382
+ tst.add_argument("--yes", "-y", action="store_true", help="Auto-approve all actions")
1383
+ tst.set_defaults(func=cmd_test)
1384
+
1385
+ scn = sub.add_parser("scan", help="Fast secret + dependency vulnerability scan")
1386
+ scn.add_argument("--yes", "-y", action="store_true", help="Auto-approve all actions")
1387
+ scn.set_defaults(func=cmd_scan)
1388
+
1389
+ sfx = sub.add_parser("secfix", help="Autonomous security remediation (audit → fix → verify)")
1390
+ sfx.add_argument("--yes", "-y", action="store_true", help="Auto-approve all actions")
1391
+ sfx.set_defaults(func=cmd_secfix)
1392
+
1393
+ dep = sub.add_parser("deploy", help="Deploy the project to a live URL (one prompt)")
1394
+ dep.add_argument("what", nargs="?", help="Target or free-text notes (e.g. 'to cloudrun')")
1395
+ dep.add_argument("--check", action="store_true", help="Just list deploy targets + readiness")
1396
+ dep.add_argument("--yes", "-y", action="store_true", help="Auto-approve all actions")
1397
+ dep.set_defaults(func=cmd_deploy)
1398
+
1399
+ shp = sub.add_parser("ship", help="Plan→build→test→scan→deploy→monitor, end to end")
1400
+ shp.add_argument("what", nargs="?", help="What to build/ship, in one sentence")
1401
+ shp.add_argument("--yes", "-y", action="store_true", help="Auto-approve all actions")
1402
+ shp.set_defaults(func=cmd_ship)
1403
+
1404
+ hl = sub.add_parser("heal", help="Self-heal: health-check, auto-rollback, error→PR")
1405
+ hl.add_argument("url", nargs="?", help="Deployed app URL to check")
1406
+ hl.add_argument("--yes", "-y", action="store_true", help="Auto-approve all actions")
1407
+ hl.set_defaults(func=cmd_heal)
1408
+
1409
+ sub.add_parser("monitor", help="Show monitoring status (errors/uptime/providers)").set_defaults(func=cmd_monitor)
1410
+
1411
+ sub.add_parser("doctor", help="Run an environment self-check").set_defaults(func=cmd_doctor)
1412
+
1413
+ aud = sub.add_parser("audit", help="Show & verify the tamper-evident action audit log")
1414
+ aud.add_argument("--lines", type=int, default=25, help="How many recent records to show")
1415
+ aud.set_defaults(func=cmd_audit)
1416
+
1417
+ ici = sub.add_parser("init-ci", help="Write a GitHub Actions workflow for the agent")
1418
+ ici.add_argument("--force", action="store_true", help="Overwrite an existing workflow")
1419
+ ici.set_defaults(func=cmd_init_ci)
1420
+
1421
+ pl = sub.add_parser("plugin", help="Manage plugins (skills/commands/MCP bundles)")
1422
+ pl.add_argument("action", choices=["add", "list", "remove"])
1423
+ pl.add_argument("source", nargs="?", help="Plugin dir / .zip URL (add) or name (remove)")
1424
+ pl.set_defaults(func=cmd_plugin)
1425
+
1426
+ se = sub.add_parser("sessions", help="List saved sessions (dashboard)")
1427
+ se.add_argument("--all", action="store_true", help="All workspaces, not just this one")
1428
+ se.set_defaults(func=cmd_sessions)
1429
+
1430
+ sub.add_parser("team", help="List multi-agent teams").set_defaults(func=cmd_team)
1431
+
1432
+ sch = sub.add_parser("schedule", help="Scheduled agents (cron)")
1433
+ sch.add_argument("action", choices=["create", "list", "remove", "run", "daemon"])
1434
+ sch.add_argument("name", nargs="?", help="Schedule name (create) or id (remove/run)")
1435
+ sch.add_argument("--cron", help='Cron expression, e.g. "0 9 * * MON-FRI"')
1436
+ sch.add_argument("--prompt", help="Task prompt to run")
1437
+ sch.set_defaults(func=cmd_schedule)
1438
+ return p
1439
+
1440
+
1441
+ def main(argv=None) -> int:
1442
+ args = build_parser().parse_args(argv)
1443
+ if not getattr(args, "command", None):
1444
+ args.func = cmd_chat
1445
+ args.yes = False
1446
+ try:
1447
+ args.func(args)
1448
+ except KeyboardInterrupt:
1449
+ console.print("\n[dim]interrupted[/dim]")
1450
+ return 130
1451
+ except Exception as e: # noqa: BLE001
1452
+ console.print(f"[red]fatal:[/red] {type(e).__name__}: {e}")
1453
+ return 1
1454
+ return 0
1455
+
1456
+
1457
+ if __name__ == "__main__":
1458
+ sys.exit(main())