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.
- krnl_agent/__init__.py +9 -0
- krnl_agent/__main__.py +7 -0
- krnl_agent/agent_registry.py +95 -0
- krnl_agent/agent_selector.py +69 -0
- krnl_agent/audit_log.py +155 -0
- krnl_agent/background.py +94 -0
- krnl_agent/checkpoints.py +67 -0
- krnl_agent/ci.py +73 -0
- krnl_agent/cli.py +1458 -0
- krnl_agent/commands.py +42 -0
- krnl_agent/config.py +425 -0
- krnl_agent/context.py +352 -0
- krnl_agent/depaudit.py +63 -0
- krnl_agent/deploy.py +245 -0
- krnl_agent/doctor.py +106 -0
- krnl_agent/events.py +141 -0
- krnl_agent/gitignore.py +47 -0
- krnl_agent/graph.py +928 -0
- krnl_agent/guardrails.py +70 -0
- krnl_agent/headless.py +60 -0
- krnl_agent/history.py +49 -0
- krnl_agent/hooks.py +72 -0
- krnl_agent/ingest.py +129 -0
- krnl_agent/llm.py +456 -0
- krnl_agent/loop.py +779 -0
- krnl_agent/mcp_client.py +128 -0
- krnl_agent/memory.py +61 -0
- krnl_agent/modelrouter.py +151 -0
- krnl_agent/monitor.py +112 -0
- krnl_agent/notify.py +119 -0
- krnl_agent/parallel_executor.py +139 -0
- krnl_agent/permissions.py +128 -0
- krnl_agent/plugins.py +105 -0
- krnl_agent/pricing.py +85 -0
- krnl_agent/prompts.py +60 -0
- krnl_agent/repomap.py +133 -0
- krnl_agent/sandbox.py +69 -0
- krnl_agent/scaffold.py +167 -0
- krnl_agent/schedules.py +137 -0
- krnl_agent/secrets.py +100 -0
- krnl_agent/selfheal.py +87 -0
- krnl_agent/server.py +302 -0
- krnl_agent/sessions.py +258 -0
- krnl_agent/settings.py +59 -0
- krnl_agent/skills.py +73 -0
- krnl_agent/teams.py +38 -0
- krnl_agent/tool_schemas.py +431 -0
- krnl_agent/tools.py +694 -0
- krnl_agent/webtools.py +139 -0
- krnl_code-1.0.4.dist-info/METADATA +214 -0
- krnl_code-1.0.4.dist-info/RECORD +56 -0
- krnl_code-1.0.4.dist-info/WHEEL +5 -0
- krnl_code-1.0.4.dist-info/entry_points.txt +2 -0
- krnl_code-1.0.4.dist-info/licenses/LICENSE +147 -0
- krnl_code-1.0.4.dist-info/licenses/NOTICE +4 -0
- 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())
|