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.
Files changed (52) hide show
  1. krodo/__init__.py +0 -0
  2. krodo/cli/__init__.py +0 -0
  3. krodo/cli/banner.py +71 -0
  4. krodo/cli/diff_preview.py +87 -0
  5. krodo/cli/doctor.py +276 -0
  6. krodo/cli/group.py +202 -0
  7. krodo/cli/main.py +692 -0
  8. krodo/cli/repl.py +306 -0
  9. krodo/cli/resume.py +490 -0
  10. krodo/cli/undo.py +245 -0
  11. krodo/core/__init__.py +25 -0
  12. krodo/core/budget.py +248 -0
  13. krodo/core/compression.py +343 -0
  14. krodo/core/config.py +141 -0
  15. krodo/core/context.py +160 -0
  16. krodo/core/events.py +195 -0
  17. krodo/core/loop.py +671 -0
  18. krodo/core/recovery.py +317 -0
  19. krodo/core/types.py +100 -0
  20. krodo/core/workspace.py +153 -0
  21. krodo/llm/__init__.py +5 -0
  22. krodo/llm/litellm_provider.py +338 -0
  23. krodo/llm/protocols.py +68 -0
  24. krodo/llm/streaming.py +132 -0
  25. krodo/memory/__init__.py +12 -0
  26. krodo/memory/agents_md.py +276 -0
  27. krodo/memory/replay.py +196 -0
  28. krodo/memory/store.py +300 -0
  29. krodo/obs/__init__.py +5 -0
  30. krodo/obs/cost.py +75 -0
  31. krodo/obs/logger.py +191 -0
  32. krodo/sandbox/__init__.py +5 -0
  33. krodo/sandbox/approval.py +293 -0
  34. krodo/sandbox/checkpoint.py +207 -0
  35. krodo/sandbox/firewall.py +131 -0
  36. krodo/sandbox/ignore.py +260 -0
  37. krodo/sandbox/path_filter.py +81 -0
  38. krodo/sandbox/protocols.py +55 -0
  39. krodo/tools/__init__.py +5 -0
  40. krodo/tools/builtin/__init__.py +0 -0
  41. krodo/tools/builtin/fs.py +322 -0
  42. krodo/tools/builtin/git.py +241 -0
  43. krodo/tools/builtin/patch.py +315 -0
  44. krodo/tools/builtin/search.py +385 -0
  45. krodo/tools/builtin/shell.py +121 -0
  46. krodo/tools/protocols.py +79 -0
  47. krodo/tools/registry.py +63 -0
  48. krodo-0.1.0.dist-info/METADATA +421 -0
  49. krodo-0.1.0.dist-info/RECORD +52 -0
  50. krodo-0.1.0.dist-info/WHEEL +4 -0
  51. krodo-0.1.0.dist-info/entry_points.txt +2 -0
  52. 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
+ }