krodo 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- krodo/__init__.py +0 -0
- krodo/cli/__init__.py +0 -0
- krodo/cli/banner.py +71 -0
- krodo/cli/diff_preview.py +87 -0
- krodo/cli/doctor.py +276 -0
- krodo/cli/group.py +202 -0
- krodo/cli/main.py +692 -0
- krodo/cli/repl.py +306 -0
- krodo/cli/resume.py +490 -0
- krodo/cli/undo.py +245 -0
- krodo/core/__init__.py +25 -0
- krodo/core/budget.py +248 -0
- krodo/core/compression.py +343 -0
- krodo/core/config.py +141 -0
- krodo/core/context.py +160 -0
- krodo/core/events.py +195 -0
- krodo/core/loop.py +671 -0
- krodo/core/recovery.py +317 -0
- krodo/core/types.py +100 -0
- krodo/core/workspace.py +153 -0
- krodo/llm/__init__.py +5 -0
- krodo/llm/litellm_provider.py +338 -0
- krodo/llm/protocols.py +68 -0
- krodo/llm/streaming.py +132 -0
- krodo/memory/__init__.py +12 -0
- krodo/memory/agents_md.py +276 -0
- krodo/memory/replay.py +196 -0
- krodo/memory/store.py +300 -0
- krodo/obs/__init__.py +5 -0
- krodo/obs/cost.py +75 -0
- krodo/obs/logger.py +191 -0
- krodo/sandbox/__init__.py +5 -0
- krodo/sandbox/approval.py +293 -0
- krodo/sandbox/checkpoint.py +207 -0
- krodo/sandbox/firewall.py +131 -0
- krodo/sandbox/ignore.py +260 -0
- krodo/sandbox/path_filter.py +81 -0
- krodo/sandbox/protocols.py +55 -0
- krodo/tools/__init__.py +5 -0
- krodo/tools/builtin/__init__.py +0 -0
- krodo/tools/builtin/fs.py +322 -0
- krodo/tools/builtin/git.py +241 -0
- krodo/tools/builtin/patch.py +315 -0
- krodo/tools/builtin/search.py +385 -0
- krodo/tools/builtin/shell.py +121 -0
- krodo/tools/protocols.py +79 -0
- krodo/tools/registry.py +63 -0
- krodo-0.1.0.dist-info/METADATA +421 -0
- krodo-0.1.0.dist-info/RECORD +52 -0
- krodo-0.1.0.dist-info/WHEEL +4 -0
- krodo-0.1.0.dist-info/entry_points.txt +2 -0
- krodo-0.1.0.dist-info/licenses/LICENSE +201 -0
krodo/__init__.py
ADDED
|
File without changes
|
krodo/cli/__init__.py
ADDED
|
File without changes
|
krodo/cli/banner.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Krodo CLI banner — printed once per session to confirm workspace identity.
|
|
2
|
+
|
|
3
|
+
The banner is a Rich Panel that shows:
|
|
4
|
+
- krodo version
|
|
5
|
+
- workspace root (absolute path)
|
|
6
|
+
- workspace source (how the root was discovered)
|
|
7
|
+
- resolved model string (LiteLLM format, e.g. zai/glm-4.6)
|
|
8
|
+
- approval mode
|
|
9
|
+
|
|
10
|
+
This implements the §6 invariant:
|
|
11
|
+
"工具分发时 workspace 已注入;banner 在 session 开始时可见"
|
|
12
|
+
|
|
13
|
+
Usage::
|
|
14
|
+
|
|
15
|
+
from krodo.cli.banner import print_banner
|
|
16
|
+
print_banner(workspace, approval_mode="auto_edit", model="zai/glm-4.6")
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
22
|
+
|
|
23
|
+
from rich.console import Console
|
|
24
|
+
from rich.panel import Panel
|
|
25
|
+
from rich.text import Text
|
|
26
|
+
|
|
27
|
+
from krodo.core.workspace import Workspace
|
|
28
|
+
|
|
29
|
+
_console = Console(stderr=False)
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
_VERSION = version("krodo")
|
|
33
|
+
except PackageNotFoundError:
|
|
34
|
+
_VERSION = "dev"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def print_banner(
|
|
38
|
+
workspace: Workspace,
|
|
39
|
+
approval_mode: str = "auto_edit",
|
|
40
|
+
model: str | None = None,
|
|
41
|
+
) -> None:
|
|
42
|
+
"""Print the session banner to stdout using Rich."""
|
|
43
|
+
content = Text()
|
|
44
|
+
content.append("workspace ", style="bold cyan")
|
|
45
|
+
content.append(str(workspace.root), style="green")
|
|
46
|
+
content.append(f" [{workspace.source}]\n", style="dim")
|
|
47
|
+
|
|
48
|
+
content.append("git ", style="bold cyan")
|
|
49
|
+
if workspace.git_root is not None:
|
|
50
|
+
content.append(str(workspace.git_root), style="green")
|
|
51
|
+
else:
|
|
52
|
+
content.append("none", style="dim")
|
|
53
|
+
content.append("\n")
|
|
54
|
+
|
|
55
|
+
content.append("model ", style="bold cyan")
|
|
56
|
+
if model:
|
|
57
|
+
content.append(model, style="green")
|
|
58
|
+
else:
|
|
59
|
+
content.append("(unset)", style="dim")
|
|
60
|
+
content.append("\n")
|
|
61
|
+
|
|
62
|
+
content.append("approval ", style="bold cyan")
|
|
63
|
+
content.append(approval_mode, style="yellow")
|
|
64
|
+
|
|
65
|
+
panel = Panel(
|
|
66
|
+
content,
|
|
67
|
+
title=f"[bold white]krodo {_VERSION}[/bold white]",
|
|
68
|
+
border_style="cyan",
|
|
69
|
+
expand=False,
|
|
70
|
+
)
|
|
71
|
+
_console.print(panel)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""diff_preview — Rich-highlighted unified diff for the approval prompt (M4 PR3).
|
|
2
|
+
|
|
3
|
+
Shows the user a coloured diff of what the agent is about to write, so they
|
|
4
|
+
can see the full absolute path and exact changes before approving.
|
|
5
|
+
|
|
6
|
+
Design decisions:
|
|
7
|
+
- Old=None → treat as a new file; display content as pure additions.
|
|
8
|
+
- Binary / very large diffs are truncated to 200 lines with a notice.
|
|
9
|
+
- CRLF line endings are normalised to LF before diffing; the display uses
|
|
10
|
+
the normalised form (the actual write preserves whatever encoding the tool
|
|
11
|
+
chooses).
|
|
12
|
+
- The function returns a string (already colourised via Rich markup) rather
|
|
13
|
+
than a Renderable, so callers can just print() it or pass it to
|
|
14
|
+
console.print() without worrying about Rich internals.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import difflib
|
|
20
|
+
|
|
21
|
+
from rich.syntax import Syntax
|
|
22
|
+
|
|
23
|
+
_MAX_DIFF_LINES = 200
|
|
24
|
+
_TRUNC_NOTICE = "\n... [diff truncated — showing first 200 lines] ..."
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def render_diff(old: str | None, new: str, path: str) -> Syntax:
|
|
28
|
+
"""Return a Rich Syntax object containing a unified diff.
|
|
29
|
+
|
|
30
|
+
Parameters
|
|
31
|
+
----------
|
|
32
|
+
old:
|
|
33
|
+
Original file content. Pass ``None`` for new files (all lines shown
|
|
34
|
+
as additions).
|
|
35
|
+
new:
|
|
36
|
+
New file content to write.
|
|
37
|
+
path:
|
|
38
|
+
File path shown in the diff header (should be an absolute path so
|
|
39
|
+
users can confirm which file will be changed).
|
|
40
|
+
"""
|
|
41
|
+
old_text = _normalise(old) if old is not None else ""
|
|
42
|
+
new_text = _normalise(new)
|
|
43
|
+
|
|
44
|
+
old_lines = old_text.splitlines(keepends=True)
|
|
45
|
+
new_lines = new_text.splitlines(keepends=True)
|
|
46
|
+
|
|
47
|
+
from_name = f"a/{path}" if old is not None else "/dev/null"
|
|
48
|
+
to_name = f"b/{path}"
|
|
49
|
+
|
|
50
|
+
diff_lines = list(
|
|
51
|
+
difflib.unified_diff(
|
|
52
|
+
old_lines,
|
|
53
|
+
new_lines,
|
|
54
|
+
fromfile=from_name,
|
|
55
|
+
tofile=to_name,
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
if not diff_lines:
|
|
60
|
+
diff_text = f"--- {from_name}\n+++ {to_name}\n(no changes)"
|
|
61
|
+
else:
|
|
62
|
+
truncated = len(diff_lines) > _MAX_DIFF_LINES
|
|
63
|
+
visible = diff_lines[:_MAX_DIFF_LINES]
|
|
64
|
+
diff_text = "".join(visible)
|
|
65
|
+
if truncated:
|
|
66
|
+
diff_text += _TRUNC_NOTICE
|
|
67
|
+
|
|
68
|
+
return Syntax(diff_text, "diff", theme="monokai", line_numbers=False)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def render_new_file(content: str, path: str) -> Syntax:
|
|
72
|
+
"""Return a Rich Syntax object for a brand-new file (no old content).
|
|
73
|
+
|
|
74
|
+
This is a convenience wrapper around render_diff(old=None, ...) that
|
|
75
|
+
also annotates the header to make it clear it's a new file.
|
|
76
|
+
"""
|
|
77
|
+
return render_diff(None, content, path)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
# Helpers
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _normalise(text: str) -> str:
|
|
86
|
+
"""Normalise line endings to LF."""
|
|
87
|
+
return text.replace("\r\n", "\n").replace("\r", "\n")
|
krodo/cli/doctor.py
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""krodo doctor — pre-flight connectivity check for the configured LLM provider.
|
|
2
|
+
|
|
3
|
+
Usage::
|
|
4
|
+
|
|
5
|
+
krodo doctor
|
|
6
|
+
krodo doctor --model anthropic/glm-4.7 --api-base https://... --api-key sk-...
|
|
7
|
+
|
|
8
|
+
Sends a minimal 1-token ping to verify:
|
|
9
|
+
- model string / provider prefix
|
|
10
|
+
- api_base URL (if set)
|
|
11
|
+
- api_key validity (first 8 chars shown, rest masked)
|
|
12
|
+
- round-trip latency
|
|
13
|
+
- tool-call schema round-trip (writes a synthetic tool def and checks it comes back)
|
|
14
|
+
|
|
15
|
+
Exits 0 on success, 1 on failure.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import asyncio
|
|
21
|
+
import time
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
import typer
|
|
25
|
+
|
|
26
|
+
_doctor_app = typer.Typer(
|
|
27
|
+
name="doctor",
|
|
28
|
+
help="Check LLM provider connectivity and configuration.",
|
|
29
|
+
add_completion=False,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
_DEFAULT_MODEL = "anthropic/claude-3-5-sonnet-20241022"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def register_doctor_app(app: typer.Typer) -> None:
|
|
36
|
+
"""Register the `doctor` subcommand onto *app*."""
|
|
37
|
+
app.add_typer(_doctor_app)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@_doctor_app.callback(invoke_without_command=True)
|
|
41
|
+
def doctor(
|
|
42
|
+
model: str = typer.Option(
|
|
43
|
+
_DEFAULT_MODEL,
|
|
44
|
+
"--model",
|
|
45
|
+
"-m",
|
|
46
|
+
help="LiteLLM model string (e.g. anthropic/glm-4.7)",
|
|
47
|
+
envvar="KRODO_MODEL",
|
|
48
|
+
),
|
|
49
|
+
api_key: str | None = typer.Option(
|
|
50
|
+
None,
|
|
51
|
+
"--api-key",
|
|
52
|
+
help="LLM API key (or set provider env var)",
|
|
53
|
+
envvar="KRODO_API_KEY",
|
|
54
|
+
),
|
|
55
|
+
api_base: str | None = typer.Option(
|
|
56
|
+
None,
|
|
57
|
+
"--api-base",
|
|
58
|
+
help="Custom LLM API base URL",
|
|
59
|
+
envvar="KRODO_API_BASE",
|
|
60
|
+
),
|
|
61
|
+
max_tokens: int = typer.Option(
|
|
62
|
+
16384,
|
|
63
|
+
"--max-tokens",
|
|
64
|
+
help="Configured max output tokens per response (displayed only).",
|
|
65
|
+
envvar="KRODO_MAX_TOKENS",
|
|
66
|
+
),
|
|
67
|
+
) -> None:
|
|
68
|
+
"""Run a pre-flight connectivity check against the LLM provider."""
|
|
69
|
+
asyncio.run(
|
|
70
|
+
_async_doctor(
|
|
71
|
+
model=model,
|
|
72
|
+
api_key=api_key,
|
|
73
|
+
api_base=api_base,
|
|
74
|
+
max_tokens=max_tokens,
|
|
75
|
+
)
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
async def _async_doctor(
|
|
80
|
+
model: str,
|
|
81
|
+
api_key: str | None,
|
|
82
|
+
api_base: str | None,
|
|
83
|
+
max_tokens: int = 16384,
|
|
84
|
+
) -> None:
|
|
85
|
+
from rich.console import Console # noqa: PLC0415
|
|
86
|
+
from rich.table import Table # noqa: PLC0415
|
|
87
|
+
|
|
88
|
+
from krodo.core.budget import get_context_window # noqa: PLC0415
|
|
89
|
+
from krodo.core.config import load_config # noqa: PLC0415
|
|
90
|
+
|
|
91
|
+
console = Console()
|
|
92
|
+
console.print("\n[bold cyan]krodo doctor[/bold cyan] — LLM connectivity check\n")
|
|
93
|
+
|
|
94
|
+
# ----------------------------------------------------------------
|
|
95
|
+
# Config sources (M5.4)
|
|
96
|
+
# ----------------------------------------------------------------
|
|
97
|
+
from pathlib import Path as _Path # noqa: PLC0415
|
|
98
|
+
|
|
99
|
+
_workspace_root = _Path.cwd()
|
|
100
|
+
_cfg, _cfg_sources = load_config(_workspace_root)
|
|
101
|
+
# If config supplies a model and the CLI flag was left at its default,
|
|
102
|
+
# use the config's model — mirrors the logic in main.main() / resume.
|
|
103
|
+
# Otherwise the doctor pings the hardcoded default, which is misleading
|
|
104
|
+
# (config shows zai/glm-4.6 but ping goes to anthropic/claude-3-5-sonnet).
|
|
105
|
+
if _cfg.model is not None and model == _DEFAULT_MODEL:
|
|
106
|
+
model = _cfg.model
|
|
107
|
+
if _cfg_sources:
|
|
108
|
+
console.print("[bold]config sources[/bold]")
|
|
109
|
+
src_grid = Table.grid(padding=(0, 2))
|
|
110
|
+
for src in _cfg_sources:
|
|
111
|
+
src_grid.add_row("", f"[dim]{src}[/dim]")
|
|
112
|
+
if _cfg.model is not None:
|
|
113
|
+
src_grid.add_row(" model", f"[dim]{_cfg.model}[/dim]")
|
|
114
|
+
if _cfg.max_tokens is not None:
|
|
115
|
+
src_grid.add_row(" max_tokens", f"[dim]{_cfg.max_tokens:,}[/dim]")
|
|
116
|
+
if _cfg.approval is not None:
|
|
117
|
+
src_grid.add_row(" approval", f"[dim]{_cfg.approval}[/dim]")
|
|
118
|
+
console.print(src_grid)
|
|
119
|
+
console.print()
|
|
120
|
+
|
|
121
|
+
# ----------------------------------------------------------------
|
|
122
|
+
# Configuration summary
|
|
123
|
+
# ----------------------------------------------------------------
|
|
124
|
+
key_display = _mask_key(api_key) if api_key else "[dim](from env)[/dim]"
|
|
125
|
+
base_display = api_base or "[dim](provider default)[/dim]"
|
|
126
|
+
|
|
127
|
+
cfg = Table.grid(padding=(0, 2))
|
|
128
|
+
cfg.add_row("[bold]model[/bold]", model)
|
|
129
|
+
cfg.add_row("[bold]api_base[/bold]", base_display)
|
|
130
|
+
cfg.add_row("[bold]api_key[/bold]", key_display)
|
|
131
|
+
console.print(cfg)
|
|
132
|
+
console.print()
|
|
133
|
+
|
|
134
|
+
# ----------------------------------------------------------------
|
|
135
|
+
# Output budget — exposed because GLM-style models with too small a
|
|
136
|
+
# max_tokens will silently truncate write_file args to '{}'.
|
|
137
|
+
# ----------------------------------------------------------------
|
|
138
|
+
context_window = get_context_window(model)
|
|
139
|
+
budget = Table.grid(padding=(0, 2))
|
|
140
|
+
budget.add_row("[bold]max_tokens (output)[/bold]", f"{max_tokens:,}")
|
|
141
|
+
budget.add_row("[bold]context window[/bold]", f"{context_window:,} tokens (model default)")
|
|
142
|
+
console.print("[bold]output budget[/bold]")
|
|
143
|
+
console.print(budget)
|
|
144
|
+
console.print(
|
|
145
|
+
"[dim]Tip: if you see invalid_args aborts, raise --max-tokens "
|
|
146
|
+
"or lower the task scope.[/dim]\n"
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
# ----------------------------------------------------------------
|
|
150
|
+
# 1-token ping
|
|
151
|
+
# ----------------------------------------------------------------
|
|
152
|
+
console.print("[dim]Sending 1-token ping…[/dim]")
|
|
153
|
+
ok, latency_ms, error = await _ping(model, api_key, api_base)
|
|
154
|
+
|
|
155
|
+
if ok:
|
|
156
|
+
console.print(f"[green]✓ ping OK[/green] ({latency_ms:.0f} ms)")
|
|
157
|
+
else:
|
|
158
|
+
console.print(f"[red]✗ ping FAILED[/red] ({latency_ms:.0f} ms)")
|
|
159
|
+
console.print(f"\n[red]Error:[/red] {error}")
|
|
160
|
+
_print_hints(model, api_base, console)
|
|
161
|
+
raise typer.Exit(1)
|
|
162
|
+
|
|
163
|
+
# ----------------------------------------------------------------
|
|
164
|
+
# Tool-call schema round-trip
|
|
165
|
+
# ----------------------------------------------------------------
|
|
166
|
+
console.print("[dim]Checking tool-call schema round-trip…[/dim]")
|
|
167
|
+
tool_ok, tool_error = await _tool_ping(model, api_key, api_base)
|
|
168
|
+
if tool_ok:
|
|
169
|
+
console.print("[green]✓ tool_call schema OK[/green]")
|
|
170
|
+
else:
|
|
171
|
+
console.print(f"[yellow]⚠ tool_call schema check failed:[/yellow] {tool_error}")
|
|
172
|
+
console.print(" Tool calling may not work correctly with this model/provider.")
|
|
173
|
+
|
|
174
|
+
console.print("\n[bold green]All checks passed.[/bold green]\n")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
async def _ping(
|
|
178
|
+
model: str,
|
|
179
|
+
api_key: str | None,
|
|
180
|
+
api_base: str | None,
|
|
181
|
+
) -> tuple[bool, float, str]:
|
|
182
|
+
"""Send a minimal non-tool completion; return (ok, latency_ms, error_str)."""
|
|
183
|
+
import litellm # noqa: PLC0415
|
|
184
|
+
|
|
185
|
+
kwargs: dict[str, Any] = {
|
|
186
|
+
"model": model,
|
|
187
|
+
"messages": [{"role": "user", "content": "Reply with the single word: ok"}],
|
|
188
|
+
"max_tokens": 3,
|
|
189
|
+
}
|
|
190
|
+
if api_key:
|
|
191
|
+
kwargs["api_key"] = api_key
|
|
192
|
+
if api_base:
|
|
193
|
+
kwargs["api_base"] = api_base
|
|
194
|
+
|
|
195
|
+
t0 = time.perf_counter()
|
|
196
|
+
try:
|
|
197
|
+
await litellm.acompletion(**kwargs)
|
|
198
|
+
latency = (time.perf_counter() - t0) * 1000
|
|
199
|
+
return True, latency, ""
|
|
200
|
+
except Exception as exc: # noqa: BLE001
|
|
201
|
+
latency = (time.perf_counter() - t0) * 1000
|
|
202
|
+
return False, latency, str(exc)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
async def _tool_ping(
|
|
206
|
+
model: str,
|
|
207
|
+
api_key: str | None,
|
|
208
|
+
api_base: str | None,
|
|
209
|
+
) -> tuple[bool, str]:
|
|
210
|
+
"""Check that the provider supports tool_use by sending a synthetic tool def."""
|
|
211
|
+
import litellm # noqa: PLC0415
|
|
212
|
+
|
|
213
|
+
tool_def: dict[str, Any] = {
|
|
214
|
+
"type": "function",
|
|
215
|
+
"function": {
|
|
216
|
+
"name": "krodo_health_check",
|
|
217
|
+
"description": "Health-check tool — ignore.",
|
|
218
|
+
"parameters": {
|
|
219
|
+
"type": "object",
|
|
220
|
+
"properties": {"status": {"type": "string"}},
|
|
221
|
+
"required": ["status"],
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
}
|
|
225
|
+
kwargs: dict[str, Any] = {
|
|
226
|
+
"model": model,
|
|
227
|
+
"messages": [
|
|
228
|
+
{"role": "user", "content": "Call the krodo_health_check tool with status=ok"}
|
|
229
|
+
],
|
|
230
|
+
"tools": [tool_def],
|
|
231
|
+
"max_tokens": 64,
|
|
232
|
+
}
|
|
233
|
+
if api_key:
|
|
234
|
+
kwargs["api_key"] = api_key
|
|
235
|
+
if api_base:
|
|
236
|
+
kwargs["api_base"] = api_base
|
|
237
|
+
|
|
238
|
+
try:
|
|
239
|
+
resp = await litellm.acompletion(**kwargs)
|
|
240
|
+
msg = resp.choices[0].message
|
|
241
|
+
if getattr(msg, "tool_calls", None):
|
|
242
|
+
return True, ""
|
|
243
|
+
# Model may answer in text when tool_choice is auto — still OK
|
|
244
|
+
return True, ""
|
|
245
|
+
except Exception as exc: # noqa: BLE001
|
|
246
|
+
return False, str(exc)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _mask_key(key: str) -> str:
|
|
250
|
+
if len(key) <= 8:
|
|
251
|
+
return "***"
|
|
252
|
+
return key[:8] + "..." + "[MASKED]"
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _print_hints(model: str, api_base: str | None, console: Any) -> None:
|
|
256
|
+
provider = model.split("/")[0] if "/" in model else "unknown"
|
|
257
|
+
console.print("\n[bold yellow]Troubleshooting hints:[/bold yellow]")
|
|
258
|
+
|
|
259
|
+
if provider in ("anthropic",) and api_base:
|
|
260
|
+
console.print(
|
|
261
|
+
" • You are using [bold]anthropic[/bold] provider prefix with a custom api_base.\n"
|
|
262
|
+
" If your endpoint speaks OpenAI Chat Completions, change the model prefix:\n"
|
|
263
|
+
f" [dim]KRODO_MODEL=openai/{model.split('/', 1)[-1]}[/dim]"
|
|
264
|
+
)
|
|
265
|
+
elif provider == "openai" and api_base:
|
|
266
|
+
console.print(
|
|
267
|
+
" • You are using [bold]openai[/bold] provider prefix with a custom api_base.\n"
|
|
268
|
+
" Make sure your endpoint exposes /v1/chat/completions."
|
|
269
|
+
)
|
|
270
|
+
else:
|
|
271
|
+
console.print(
|
|
272
|
+
" • Check that KRODO_API_KEY / KRODO_API_BASE match the provider.\n"
|
|
273
|
+
" • LiteLLM model strings use the format: [bold]<provider>/<model-id>[/bold]\n"
|
|
274
|
+
" e.g. anthropic/claude-3-5-sonnet-20241022, openai/gpt-4o, openai/glm-4.7"
|
|
275
|
+
)
|
|
276
|
+
console.print()
|
krodo/cli/group.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""KrodoGroup — custom Click/Typer Group that pre-routes subcommands.
|
|
2
|
+
|
|
3
|
+
PROBLEM
|
|
4
|
+
-------
|
|
5
|
+
The main Typer app declares both a positional ``prompt`` Argument (for
|
|
6
|
+
``krodo "task"`` headless mode) and three sub-apps (``undo`` / ``resume`` /
|
|
7
|
+
``doctor``). Click's parser consumes positional Arguments *before* it
|
|
8
|
+
dispatches subcommands, so a token like ``resume`` is silently swallowed as
|
|
9
|
+
``prompt`` and never reaches the subcommand router. This causes:
|
|
10
|
+
|
|
11
|
+
* ``krodo resume --root /X`` → "No such command '--root'" error
|
|
12
|
+
* ``krodo --root /X resume`` → silent failure (LLM gets prompt="resume")
|
|
13
|
+
|
|
14
|
+
FIX: args-split strategy
|
|
15
|
+
------------------------
|
|
16
|
+
Override ``parse_args`` to scan *args* for the first token that is both
|
|
17
|
+
(a) not an option or its value, and (b) a registered subcommand name. When
|
|
18
|
+
found, split at that index:
|
|
19
|
+
|
|
20
|
+
* *group_args* (everything before the subcommand token) → parsed by
|
|
21
|
+
``click.Command.parse_args``, which fills group-level options and leaves
|
|
22
|
+
the ``prompt`` Argument at its default ``None``.
|
|
23
|
+
* *subcmd_args* (everything after the subcommand token) → assigned to
|
|
24
|
+
``ctx.args``; the subcommand name goes into ``ctx._protected_args`` so
|
|
25
|
+
Click's invocation machinery dispatches it correctly.
|
|
26
|
+
|
|
27
|
+
FOOT-GUN FIX: --root propagation
|
|
28
|
+
---------------------------------
|
|
29
|
+
When the user writes ``krodo --root /X resume``, the group-level ``--root``
|
|
30
|
+
ends up in *group_args* and is parsed onto the group ctx, but the ``resume``
|
|
31
|
+
subcommand callback declares its own ``--root`` which defaults to ``None``.
|
|
32
|
+
``_propagate_defaults`` copies shared group options into ``ctx.default_map``
|
|
33
|
+
so the subcommand sees them as defaults (but its own explicit ``--root`` still
|
|
34
|
+
wins).
|
|
35
|
+
|
|
36
|
+
RISKS
|
|
37
|
+
-----
|
|
38
|
+
* ``ctx._protected_args`` is a Click 8.x internal (underscore-prefixed).
|
|
39
|
+
The public ``ctx.protected_args`` property is deprecated in 8.x and will be
|
|
40
|
+
removed in Click 9.0. This file should be audited when upgrading Click.
|
|
41
|
+
Pin ``click>=8,<9`` in pyproject.toml.
|
|
42
|
+
* ``default_map`` only fills values that are still at the option's built-in
|
|
43
|
+
default; an explicit CLI flag in the subcommand always takes precedence.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
from __future__ import annotations
|
|
47
|
+
|
|
48
|
+
from typing import Any
|
|
49
|
+
|
|
50
|
+
import click
|
|
51
|
+
from typer.core import TyperGroup
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class KrodoGroup(TyperGroup):
|
|
55
|
+
"""TyperGroup subclass that correctly routes Krodo's named subcommands.
|
|
56
|
+
|
|
57
|
+
WARN: depends on Click 8.x internals (ctx._protected_args).
|
|
58
|
+
Review this file when upgrading click past 8.x.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
# ------------------------------------------------------------------
|
|
62
|
+
# Public override
|
|
63
|
+
# ------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]:
|
|
66
|
+
"""Split args at the first subcommand token, if any."""
|
|
67
|
+
if args and self.commands:
|
|
68
|
+
split = self._find_subcommand_split(ctx, args)
|
|
69
|
+
if split is not None:
|
|
70
|
+
cmd_index, cmd_name = split
|
|
71
|
+
group_args = list(args[:cmd_index])
|
|
72
|
+
subcmd_args = list(args[cmd_index + 1 :])
|
|
73
|
+
|
|
74
|
+
# Only parse group-level options — skips Group.parse_args's
|
|
75
|
+
# automatic ``rest[:1]`` assignment that re-runs subcommand
|
|
76
|
+
# dispatch based on leftover positional tokens.
|
|
77
|
+
click.Command.parse_args(self, ctx, group_args)
|
|
78
|
+
|
|
79
|
+
# Manually wire Click's subcommand dispatch mechanism.
|
|
80
|
+
# WARN: _protected_args is a Click 8.x internal.
|
|
81
|
+
ctx._protected_args = [cmd_name] # noqa: SLF001
|
|
82
|
+
ctx.args = subcmd_args
|
|
83
|
+
|
|
84
|
+
# Propagate shared group options as subcommand defaults.
|
|
85
|
+
self._propagate_defaults(ctx, cmd_name)
|
|
86
|
+
|
|
87
|
+
return ctx.args
|
|
88
|
+
|
|
89
|
+
return super().parse_args(ctx, args)
|
|
90
|
+
|
|
91
|
+
# ------------------------------------------------------------------
|
|
92
|
+
# Helpers
|
|
93
|
+
# ------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
def _find_subcommand_split(
|
|
96
|
+
self,
|
|
97
|
+
ctx: click.Context,
|
|
98
|
+
args: list[str],
|
|
99
|
+
) -> tuple[int, str] | None:
|
|
100
|
+
"""Return *(index, name)* of the first subcommand token in *args*.
|
|
101
|
+
|
|
102
|
+
Correctly skips over option tokens and their values so that option
|
|
103
|
+
*values* that happen to match a subcommand name (e.g.
|
|
104
|
+
``--model resume``) are not misidentified as subcommands.
|
|
105
|
+
|
|
106
|
+
Returns ``None`` if no subcommand token is found.
|
|
107
|
+
"""
|
|
108
|
+
# Build a lookup of all long and short option strings → is_flag
|
|
109
|
+
flag_opts: set[str] = set()
|
|
110
|
+
value_opts: set[str] = set()
|
|
111
|
+
for param in self.get_params(ctx):
|
|
112
|
+
if not isinstance(param, click.Option):
|
|
113
|
+
continue
|
|
114
|
+
if param.is_flag or param.is_eager:
|
|
115
|
+
flag_opts.update(param.opts)
|
|
116
|
+
else:
|
|
117
|
+
value_opts.update(param.opts)
|
|
118
|
+
|
|
119
|
+
known_subcmds = set(self.commands.keys())
|
|
120
|
+
|
|
121
|
+
i = 0
|
|
122
|
+
skip_next = False # True when the previous token was a value-taking option
|
|
123
|
+
while i < len(args):
|
|
124
|
+
tok = args[i]
|
|
125
|
+
|
|
126
|
+
if skip_next:
|
|
127
|
+
# This token is the *value* for the previous option — skip it.
|
|
128
|
+
skip_next = False
|
|
129
|
+
i += 1
|
|
130
|
+
continue
|
|
131
|
+
|
|
132
|
+
if tok == "--":
|
|
133
|
+
# End of options; everything after is positional.
|
|
134
|
+
# Return the first positional after "--" if it is a subcommand.
|
|
135
|
+
for j in range(i + 1, len(args)):
|
|
136
|
+
if args[j] in known_subcmds:
|
|
137
|
+
return j, args[j]
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
if tok.startswith("--"):
|
|
141
|
+
if "=" in tok:
|
|
142
|
+
# ``--key=value`` — inline value, no next token consumed.
|
|
143
|
+
i += 1
|
|
144
|
+
continue
|
|
145
|
+
opt_name = tok
|
|
146
|
+
if opt_name in value_opts:
|
|
147
|
+
# ``--key value`` — next token is the value.
|
|
148
|
+
skip_next = True
|
|
149
|
+
i += 1
|
|
150
|
+
continue
|
|
151
|
+
if opt_name in flag_opts:
|
|
152
|
+
# Boolean flag — no value token.
|
|
153
|
+
i += 1
|
|
154
|
+
continue
|
|
155
|
+
# Unknown long option (e.g. ``--help``) — treat as flag.
|
|
156
|
+
i += 1
|
|
157
|
+
continue
|
|
158
|
+
|
|
159
|
+
if tok.startswith("-") and len(tok) > 1:
|
|
160
|
+
# Short option: ``-r``, ``-rv``, ``-r /path``
|
|
161
|
+
short = tok[:2] # e.g. ``-r``
|
|
162
|
+
if short in value_opts:
|
|
163
|
+
if len(tok) > 2:
|
|
164
|
+
# Value is glued: ``-r/path`` — no next token consumed.
|
|
165
|
+
i += 1
|
|
166
|
+
continue
|
|
167
|
+
# Value is the next token.
|
|
168
|
+
skip_next = True
|
|
169
|
+
i += 1
|
|
170
|
+
continue
|
|
171
|
+
# Boolean short or unknown: skip.
|
|
172
|
+
i += 1
|
|
173
|
+
continue
|
|
174
|
+
|
|
175
|
+
# Non-option token — candidate for subcommand or positional.
|
|
176
|
+
if tok in known_subcmds:
|
|
177
|
+
return i, tok
|
|
178
|
+
# It's a positional (the ``prompt`` Argument) — stop looking.
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
def _propagate_defaults(self, ctx: click.Context, cmd_name: str) -> None:
|
|
184
|
+
"""Copy shared group options into ``ctx.default_map`` for *cmd_name*.
|
|
185
|
+
|
|
186
|
+
This allows ``krodo --root /X resume`` to work correctly: the group
|
|
187
|
+
parses ``--root /X`` onto its own ctx, and this method makes ``/X``
|
|
188
|
+
available to the ``resume`` subcommand as a default (its own explicit
|
|
189
|
+
``--root`` still takes priority because Click only consults
|
|
190
|
+
``default_map`` when the option is still at its built-in default).
|
|
191
|
+
"""
|
|
192
|
+
shared_keys = frozenset({"root", "model", "api_key", "api_base", "approval", "max_tokens"})
|
|
193
|
+
inherited: dict[str, Any] = {
|
|
194
|
+
k: v for k, v in ctx.params.items() if k in shared_keys and v is not None
|
|
195
|
+
}
|
|
196
|
+
if not inherited:
|
|
197
|
+
return
|
|
198
|
+
existing: dict[str, Any] = (ctx.default_map or {}).get(cmd_name, {})
|
|
199
|
+
ctx.default_map = {
|
|
200
|
+
**(ctx.default_map or {}),
|
|
201
|
+
cmd_name: {**existing, **inherited},
|
|
202
|
+
}
|