kiwimatecoder 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 (104) hide show
  1. kiwimatecoder/__init__.py +8 -0
  2. kiwimatecoder/__main__.py +12 -0
  3. kiwimatecoder/acp/__init__.py +13 -0
  4. kiwimatecoder/acp/protocol.py +115 -0
  5. kiwimatecoder/acp/server.py +823 -0
  6. kiwimatecoder/acp/stdio.py +32 -0
  7. kiwimatecoder/agent.py +1162 -0
  8. kiwimatecoder/ai.py +68 -0
  9. kiwimatecoder/audit.py +65 -0
  10. kiwimatecoder/browser.py +261 -0
  11. kiwimatecoder/catalog.py +445 -0
  12. kiwimatecoder/checkpoints.py +103 -0
  13. kiwimatecoder/client.py +820 -0
  14. kiwimatecoder/clipboard.py +99 -0
  15. kiwimatecoder/commands.py +6757 -0
  16. kiwimatecoder/config.py +5335 -0
  17. kiwimatecoder/diagnostics.py +295 -0
  18. kiwimatecoder/documents.py +299 -0
  19. kiwimatecoder/evals/__init__.py +39 -0
  20. kiwimatecoder/evals/cases.py +230 -0
  21. kiwimatecoder/evals/render.py +46 -0
  22. kiwimatecoder/evals/runner.py +319 -0
  23. kiwimatecoder/events.py +95 -0
  24. kiwimatecoder/forge.py +172 -0
  25. kiwimatecoder/git.py +164 -0
  26. kiwimatecoder/headless.py +255 -0
  27. kiwimatecoder/hooks.py +170 -0
  28. kiwimatecoder/hunks.py +260 -0
  29. kiwimatecoder/i18n.py +368 -0
  30. kiwimatecoder/images.py +195 -0
  31. kiwimatecoder/index/__init__.py +14 -0
  32. kiwimatecoder/index/builder.py +376 -0
  33. kiwimatecoder/index/embeddings.py +151 -0
  34. kiwimatecoder/index/search.py +247 -0
  35. kiwimatecoder/index/store.py +251 -0
  36. kiwimatecoder/instructions.py +83 -0
  37. kiwimatecoder/jobs.py +594 -0
  38. kiwimatecoder/lsp/__init__.py +56 -0
  39. kiwimatecoder/lsp/client.py +548 -0
  40. kiwimatecoder/lsp/manager.py +353 -0
  41. kiwimatecoder/lsp/protocol.py +79 -0
  42. kiwimatecoder/main.py +4258 -0
  43. kiwimatecoder/mcp/__init__.py +45 -0
  44. kiwimatecoder/mcp/client.py +385 -0
  45. kiwimatecoder/mcp/errors.py +7 -0
  46. kiwimatecoder/mcp/manager.py +253 -0
  47. kiwimatecoder/mcp/protocol.py +132 -0
  48. kiwimatecoder/media.py +904 -0
  49. kiwimatecoder/memory.py +141 -0
  50. kiwimatecoder/network.py +112 -0
  51. kiwimatecoder/notify.py +139 -0
  52. kiwimatecoder/permissions.py +171 -0
  53. kiwimatecoder/pickers.py +417 -0
  54. kiwimatecoder/plugins.py +245 -0
  55. kiwimatecoder/pricing.py +167 -0
  56. kiwimatecoder/prompts.py +213 -0
  57. kiwimatecoder/providers.py +342 -0
  58. kiwimatecoder/redaction.py +66 -0
  59. kiwimatecoder/remote.py +294 -0
  60. kiwimatecoder/repl.py +1388 -0
  61. kiwimatecoder/routing.py +65 -0
  62. kiwimatecoder/sandbox.py +169 -0
  63. kiwimatecoder/sdk.py +146 -0
  64. kiwimatecoder/session.py +574 -0
  65. kiwimatecoder/share.py +386 -0
  66. kiwimatecoder/shell.py +660 -0
  67. kiwimatecoder/skills.py +138 -0
  68. kiwimatecoder/sync.py +513 -0
  69. kiwimatecoder/team.py +249 -0
  70. kiwimatecoder/telemetry.py +392 -0
  71. kiwimatecoder/templates.py +108 -0
  72. kiwimatecoder/tools/__init__.py +190 -0
  73. kiwimatecoder/tools/ask.py +62 -0
  74. kiwimatecoder/tools/base.py +69 -0
  75. kiwimatecoder/tools/browser.py +261 -0
  76. kiwimatecoder/tools/edit_file.py +143 -0
  77. kiwimatecoder/tools/forge.py +207 -0
  78. kiwimatecoder/tools/git.py +158 -0
  79. kiwimatecoder/tools/image.py +300 -0
  80. kiwimatecoder/tools/io.py +346 -0
  81. kiwimatecoder/tools/list_dir.py +67 -0
  82. kiwimatecoder/tools/lsp.py +254 -0
  83. kiwimatecoder/tools/memory.py +115 -0
  84. kiwimatecoder/tools/paths.py +234 -0
  85. kiwimatecoder/tools/read_file.py +113 -0
  86. kiwimatecoder/tools/registry.py +91 -0
  87. kiwimatecoder/tools/run_bash.py +189 -0
  88. kiwimatecoder/tools/search.py +224 -0
  89. kiwimatecoder/tools/selection.py +108 -0
  90. kiwimatecoder/tools/shell.py +158 -0
  91. kiwimatecoder/tools/skill.py +46 -0
  92. kiwimatecoder/tools/task.py +61 -0
  93. kiwimatecoder/tools/todo.py +77 -0
  94. kiwimatecoder/tools/web.py +146 -0
  95. kiwimatecoder/tools/write_file.py +83 -0
  96. kiwimatecoder/ui.py +189 -0
  97. kiwimatecoder/updater.py +502 -0
  98. kiwimatecoder/web.py +499 -0
  99. kiwimatecoder-0.1.0.dist-info/METADATA +2016 -0
  100. kiwimatecoder-0.1.0.dist-info/RECORD +104 -0
  101. kiwimatecoder-0.1.0.dist-info/WHEEL +5 -0
  102. kiwimatecoder-0.1.0.dist-info/entry_points.txt +2 -0
  103. kiwimatecoder-0.1.0.dist-info/licenses/LICENSE +21 -0
  104. kiwimatecoder-0.1.0.dist-info/top_level.txt +1 -0
kiwimatecoder/main.py ADDED
@@ -0,0 +1,4258 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import sys
6
+ from collections.abc import Callable, Sequence
7
+ from pathlib import Path
8
+ from typing import Annotated, Any
9
+
10
+ import typer
11
+ from rich.console import Console
12
+ from rich.markup import escape
13
+ from rich.panel import Panel
14
+ from rich.table import Table
15
+
16
+ from kiwimatecoder import __version__, i18n, ui
17
+ from kiwimatecoder.ai import stream_response
18
+ from kiwimatecoder.catalog import probe, summarize_ids
19
+ from kiwimatecoder.config import (
20
+ ModelNotChosenError,
21
+ add_command_rule,
22
+ add_provider,
23
+ apply_model_filter,
24
+ apply_profile,
25
+ clear_always_allowed_tools,
26
+ clear_budget,
27
+ clear_command_rules,
28
+ describe_key,
29
+ get_active_provider_ids,
30
+ get_acp,
31
+ get_always_allowed_tools,
32
+ get_browser,
33
+ get_budget,
34
+ get_command_rules,
35
+ get_compact_at_tokens,
36
+ get_context_window,
37
+ get_default_mode,
38
+ get_index,
39
+ get_key,
40
+ get_lsp,
41
+ get_mcp_servers,
42
+ get_media,
43
+ get_memory,
44
+ get_model_catalog,
45
+ get_model_filter,
46
+ get_network,
47
+ get_output_style,
48
+ get_profile,
49
+ get_profiles,
50
+ get_prompt_cache,
51
+ get_provider_config,
52
+ get_provider_model,
53
+ get_remote,
54
+ get_sampling,
55
+ get_sandbox,
56
+ get_selected_provider_id,
57
+ get_shell_config,
58
+ get_subagents,
59
+ get_system_prompt,
60
+ get_team,
61
+ get_telemetry,
62
+ get_trusted_workspace,
63
+ get_ui,
64
+ get_verify_command,
65
+ get_web,
66
+ list_provider_configs,
67
+ load_config,
68
+ project_config_path,
69
+ remove_always_allowed_tool,
70
+ remove_command_rule,
71
+ remove_key,
72
+ remove_mcp_server,
73
+ remove_profile,
74
+ remove_provider,
75
+ rename_profile,
76
+ reset_default_mode,
77
+ require_model,
78
+ reset_sampling,
79
+ resolve_model,
80
+ save_profile,
81
+ set_acp,
82
+ set_budget,
83
+ set_browser,
84
+ set_default_mode,
85
+ set_index,
86
+ set_key,
87
+ set_lsp,
88
+ set_mcp_server,
89
+ set_media,
90
+ set_media_model,
91
+ set_memory,
92
+ set_model_filter,
93
+ set_network,
94
+ set_output_style,
95
+ set_prompt_cache,
96
+ set_remote,
97
+ set_sampling,
98
+ set_sandbox,
99
+ set_provider_model,
100
+ set_selected_provider,
101
+ set_shell_config,
102
+ set_subagents,
103
+ set_sync,
104
+ set_system_prompt,
105
+ set_team,
106
+ set_telemetry,
107
+ set_trusted_workspace,
108
+ set_ui,
109
+ set_verify_command,
110
+ set_web,
111
+ update_provider,
112
+ validate_config,
113
+ )
114
+ from kiwimatecoder.i18n import t
115
+ from kiwimatecoder.permissions import PermissionMode
116
+ from kiwimatecoder.providers import ProviderConfig
117
+ from kiwimatecoder.session import Session, apply_session_profile
118
+ from kiwimatecoder.updater import run_update
119
+
120
+ app = typer.Typer(
121
+ help="KiwiMateCoder - agentic AI coding assistant CLI",
122
+ invoke_without_command=True,
123
+ no_args_is_help=False,
124
+ )
125
+ console = Console()
126
+ config_app = typer.Typer(
127
+ help="Manage KiwiMateCoder configuration", invoke_without_command=True
128
+ )
129
+ app.add_typer(config_app, name="config")
130
+
131
+
132
+ @config_app.callback()
133
+ def config_main(ctx: typer.Context) -> None:
134
+ """Show a quick orientation when no config subcommand is given."""
135
+ if ctx.invoked_subcommand is not None:
136
+ return
137
+ console.print("[bold]Manage KiwiMateCoder configuration[/bold]")
138
+ console.print(" [cyan]config show[/cyan] Show current settings")
139
+ console.print(" [cyan]config key set <provider> <key>[/cyan] Save an API key")
140
+ console.print(" [cyan]config provider use <id>[/cyan] Set the default provider")
141
+ console.print(" [cyan]config model set <id>[/cyan] Choose the provider's model")
142
+ console.print(" [cyan]config mode set <ask|auto-accept|plan>[/cyan] Set default mode")
143
+ console.print(" [cyan]config models show[/cyan] List the models offered")
144
+ console.print(
145
+ " [cyan]config ui show[/cyan] Theme, color, output, ASCII, locale, keys, notify"
146
+ )
147
+ console.print(" [cyan]config web show[/cyan] Web fetch/search limits")
148
+ console.print(" [cyan]config network show[/cyan] Proxy, CA bundle, offline mode")
149
+ console.print(" [cyan]config index show[/cyan] Codebase index status")
150
+ console.print(" [cyan]config sandbox show[/cyan] OS-level command sandbox")
151
+ console.print(" [cyan]config remote show[/cyan] SSH/devcontainer command execution")
152
+ console.print(" [cyan]config acp show[/cyan] ACP editor-integration timeout")
153
+ console.print(" [cyan]config media show[/cyan] Opt-in image and video generation")
154
+ console.print(" [cyan]config telemetry show[/cyan] Opt-in local telemetry and crash reports")
155
+ console.print("Run [cyan]config <section> --help[/cyan] for details.")
156
+
157
+
158
+ def _resolve_provider(provider_id: str) -> None:
159
+ """Validate a provider id, exiting with a red message when unknown."""
160
+ try:
161
+ get_provider_config(provider_id)
162
+ except KeyError as exc:
163
+ console.print(f"[red]{escape(str(exc))}[/red]")
164
+ raise typer.Exit(1)
165
+
166
+
167
+ def _check() -> str:
168
+ """Return the active success glyph, escaped for Rich markup."""
169
+ from rich.markup import escape
170
+
171
+ from kiwimatecoder.ui import glyph
172
+
173
+ return escape(glyph("check"))
174
+
175
+
176
+ def _cross() -> str:
177
+ """Return the active failure glyph, escaped for Rich markup."""
178
+ from rich.markup import escape
179
+
180
+ from kiwimatecoder.ui import glyph
181
+
182
+ return escape(glyph("cross"))
183
+
184
+
185
+ def _print_ui(current: dict[str, Any]) -> None:
186
+ """Print the current UI preferences as a short summary."""
187
+ console.print(
188
+ f"Theme: [cyan]{current['theme']}[/cyan]\n"
189
+ f"Color: [cyan]{current['color']}[/cyan]\n"
190
+ f"Output mode: [cyan]{current['output_mode']}[/cyan]\n"
191
+ f"ASCII: [cyan]{'on' if current['ascii'] else 'off'}[/cyan]\n"
192
+ f"Locale: [cyan]{current['locale']}[/cyan]\n"
193
+ f"Keybindings: [cyan]{current['keybindings']}[/cyan]\n"
194
+ f"Notify: [cyan]{current['notify']}[/cyan] "
195
+ f"(after [cyan]{current['notify_after_seconds']}s[/cyan])\n"
196
+ f"Spinner: [cyan]{current['spinner']}[/cyan]"
197
+ )
198
+
199
+
200
+ key_app = typer.Typer(help="Save, remove, or list API keys.")
201
+ config_app.add_typer(key_app, name="key")
202
+
203
+ provider_app = typer.Typer(help="List or manage providers.")
204
+ config_app.add_typer(provider_app, name="provider")
205
+
206
+ model_app = typer.Typer(help="Choose or forget the model used with a provider.")
207
+ config_app.add_typer(model_app, name="model")
208
+
209
+ mode_app = typer.Typer(help="Set or reset the default permission mode.")
210
+ config_app.add_typer(mode_app, name="mode")
211
+
212
+ models_app = typer.Typer(help="Manage model visibility and the model catalog.")
213
+ config_app.add_typer(models_app, name="models")
214
+
215
+ media_app = typer.Typer(help="Configure opt-in image and video generation.")
216
+ config_app.add_typer(media_app, name="media")
217
+
218
+ telemetry_app = typer.Typer(
219
+ help="Configure opt-in local telemetry, debug logging, and crash reports."
220
+ )
221
+ config_app.add_typer(telemetry_app, name="telemetry")
222
+
223
+ jobs_app = typer.Typer(help="Manage background and scheduled agent jobs.")
224
+ app.add_typer(jobs_app, name="jobs")
225
+
226
+ sync_app = typer.Typer(
227
+ help="Sync saved sessions across machines through a shared folder (opt-in)."
228
+ )
229
+ app.add_typer(sync_app, name="sync")
230
+
231
+ share_app = typer.Typer(
232
+ help="Share redacted session bundles (local file or secret GitHub gist)."
233
+ )
234
+ app.add_typer(share_app, name="share")
235
+
236
+ team_app = typer.Typer(help="Shared team policy overlay.")
237
+ config_app.add_typer(team_app, name="team")
238
+
239
+ eval_app = typer.Typer(
240
+ help="Run the eval harness: deterministic prompt/tool regression checks."
241
+ )
242
+ app.add_typer(eval_app, name="eval")
243
+
244
+
245
+ # --- canonical `config key ...` ---------------------------------------------
246
+
247
+
248
+ @key_app.command("set")
249
+ def key_set(
250
+ provider: Annotated[str, typer.Argument(help="Provider id")],
251
+ key: Annotated[str, typer.Argument(help="Your API key")],
252
+ ) -> None:
253
+ """Save an API key for a provider."""
254
+ try:
255
+ warning = set_key(provider, key)
256
+ except KeyError as exc:
257
+ console.print(f"[red]{escape(str(exc))}[/red]")
258
+ raise typer.Exit(1)
259
+ console.print(
260
+ f"[green]{_check()} API key saved for[/green] [cyan]{escape(str(provider))}[/cyan] "
261
+ + f"— {escape(str(describe_key(provider)))}."
262
+ )
263
+ if warning:
264
+ console.print(f"[yellow]{escape(str(warning))}[/yellow]")
265
+
266
+
267
+ @key_app.command("remove")
268
+ def key_remove(provider: Annotated[str, typer.Argument(help="Provider id")]) -> None:
269
+ """Remove a stored API key for a provider."""
270
+ try:
271
+ existed = remove_key(provider)
272
+ except KeyError as exc:
273
+ console.print(f"[red]{escape(str(exc))}[/red]")
274
+ raise typer.Exit(1)
275
+ if existed:
276
+ console.print(
277
+ f"[green]{_check()} Removed stored API key for[/green] [cyan]{escape(str(provider))}[/cyan]."
278
+ )
279
+ else:
280
+ console.print(f"[dim]No stored API key for {escape(str(provider))}.[/dim]")
281
+
282
+
283
+ @key_app.command("list")
284
+ def key_list() -> None:
285
+ """List providers and the status of their API keys."""
286
+ table = Table(title="API keys", show_header=True)
287
+ table.add_column("provider", style="cyan")
288
+ table.add_column("env var")
289
+ table.add_column("status")
290
+ for provider in list_provider_configs():
291
+ table.add_row(escape(str(provider.id)), escape(str(provider.key_env)), escape(str(describe_key(provider.id))))
292
+ console.print(table)
293
+
294
+
295
+ # --- canonical `config provider ...` ----------------------------------------
296
+
297
+
298
+ @provider_app.command("use")
299
+ def provider_use(provider: Annotated[str, typer.Argument(help="Provider id")]) -> None:
300
+ """Persist and switch to a provider as the default."""
301
+ try:
302
+ set_selected_provider(provider)
303
+ except KeyError as exc:
304
+ console.print(f"[red]{escape(str(exc))}[/red]")
305
+ raise typer.Exit(1)
306
+ console.print(f"[green]{_check()} Default provider set to [cyan]{escape(str(provider))}[/cyan].[/green]")
307
+
308
+
309
+ @provider_app.command("list")
310
+ def provider_list() -> None:
311
+ """List all built-in and custom providers."""
312
+ table = Table(title="Providers", show_header=True)
313
+ table.add_column("id", style="cyan")
314
+ table.add_column("name")
315
+ table.add_column("model")
316
+ table.add_column("auth")
317
+ for provider in list_provider_configs():
318
+ auth = provider.key_header
319
+ if provider.key_prefix:
320
+ auth += f": {provider.key_prefix.strip()}"
321
+ table.add_row(
322
+ escape(str(provider.id)),
323
+ escape(str(f"{provider.name} (experimental)" if provider.experimental else provider.name)),
324
+ escape(str(get_provider_model(provider.id)
325
+ or ("(from server)" if provider.is_local else "—"))),
326
+ escape(str(auth)),
327
+ )
328
+ console.print(table)
329
+
330
+
331
+ @provider_app.command("add")
332
+ def provider_add(
333
+ provider_id: Annotated[str, typer.Argument(help="Unique id (no spaces)")],
334
+ name: Annotated[str, typer.Argument(help="Display name")],
335
+ base_url: Annotated[str, typer.Argument(help="Base URL including /v1")],
336
+ model: Annotated[str, typer.Argument(help="Model to use with this provider")],
337
+ key_env: Annotated[
338
+ str | None,
339
+ typer.Option(
340
+ "--key-env",
341
+ help="API key environment variable (default: <ID>_API_KEY)",
342
+ ),
343
+ ] = None,
344
+ key_header: Annotated[
345
+ str | None,
346
+ typer.Option(
347
+ "--key-header",
348
+ help="Auth header name (default: Authorization)",
349
+ ),
350
+ ] = None,
351
+ key_prefix: Annotated[
352
+ str | None,
353
+ typer.Option(
354
+ "--key-prefix",
355
+ help="Prefix before the key (default: 'Bearer '; use '' for none)",
356
+ ),
357
+ ] = None,
358
+ api_version: Annotated[
359
+ str | None,
360
+ typer.Option(
361
+ "--api-version",
362
+ help="Azure-style ?api-version= value (default: none)",
363
+ ),
364
+ ] = None,
365
+ ) -> None:
366
+ """Add an OpenAI-compatible custom provider."""
367
+ try:
368
+ provider = add_provider(
369
+ provider_id,
370
+ name,
371
+ base_url,
372
+ model,
373
+ key_env,
374
+ key_header=key_header,
375
+ key_prefix=key_prefix,
376
+ api_version=api_version,
377
+ )
378
+ except ValueError as exc:
379
+ console.print(f"[red]{escape(str(exc))}[/red]")
380
+ raise typer.Exit(1)
381
+ console.print(
382
+ f"[green]{_check()} Added provider[/green] [cyan]{escape(str(provider.id))}[/cyan] "
383
+ + f"({escape(str(provider.name))}) with model [cyan]{escape(str(model))}[/cyan]."
384
+ )
385
+
386
+
387
+ @provider_app.command("remove")
388
+ def provider_remove(provider: Annotated[str, typer.Argument(help="Provider id")]) -> None:
389
+ """Remove a custom provider."""
390
+ try:
391
+ remove_provider(provider)
392
+ except (KeyError, ValueError) as exc:
393
+ console.print(f"[red]{escape(str(exc))}[/red]")
394
+ raise typer.Exit(1)
395
+ console.print(f"[green]{_check()} Removed provider {escape(str(provider))}.[/green]")
396
+
397
+
398
+ @provider_app.command("edit")
399
+ def provider_edit(
400
+ provider: Annotated[str, typer.Argument(help="Provider id")],
401
+ name: Annotated[str | None, typer.Option("--name", help="New display name")] = None,
402
+ base_url: Annotated[str | None, typer.Option("--base-url", help="New base URL")] = None,
403
+ model: Annotated[
404
+ str | None,
405
+ typer.Option("--model", "--default-model", help="New model to use with it"),
406
+ ] = None,
407
+ key_env: Annotated[
408
+ str | None, typer.Option("--key-env", help="New key environment variable")
409
+ ] = None,
410
+ compat: Annotated[
411
+ str | None, typer.Option("--compat", help="'openai' or 'anthropic'")
412
+ ] = None,
413
+ key_header: Annotated[
414
+ str | None,
415
+ typer.Option("--key-header", help="New auth header name"),
416
+ ] = None,
417
+ key_prefix: Annotated[
418
+ str | None,
419
+ typer.Option(
420
+ "--key-prefix",
421
+ help="New prefix before the key ('' clears the prefix)",
422
+ ),
423
+ ] = None,
424
+ api_version: Annotated[
425
+ str | None,
426
+ typer.Option(
427
+ "--api-version",
428
+ help="New ?api-version= value ('' clears it)",
429
+ ),
430
+ ] = None,
431
+ ) -> None:
432
+ """Update fields of a custom provider."""
433
+ try:
434
+ update_provider(
435
+ provider,
436
+ name=name,
437
+ base_url=base_url,
438
+ model=model,
439
+ key_env=key_env,
440
+ compat=compat,
441
+ key_header=key_header,
442
+ key_prefix=key_prefix,
443
+ api_version=api_version,
444
+ )
445
+ except ValueError as exc:
446
+ console.print(f"[red]{escape(str(exc))}[/red]")
447
+ raise typer.Exit(1)
448
+ console.print(f"[green]{_check()} Updated provider {escape(str(provider))}.[/green]")
449
+
450
+
451
+ # --- canonical `config model ...` -------------------------------------------
452
+
453
+
454
+ _MODEL_PROVIDER_OPTION = typer.Option(
455
+ "--provider", "-p", help="Provider id (default: the active provider)"
456
+ )
457
+
458
+
459
+ @model_app.command("set")
460
+ def model_set(
461
+ model: Annotated[str, typer.Argument(help="Model id")],
462
+ provider: Annotated[str | None, _MODEL_PROVIDER_OPTION] = None,
463
+ ) -> None:
464
+ """Choose the model to use with a provider."""
465
+ provider_id = provider or get_selected_provider_id()
466
+ try:
467
+ provider_cfg = get_provider_config(provider_id)
468
+ set_provider_model(provider_id, model)
469
+ except KeyError as exc:
470
+ console.print(f"[red]{escape(str(exc))}[/red]")
471
+ raise typer.Exit(1)
472
+ console.print(
473
+ f"[green]{_check()} Model for {escape(str(provider_cfg.name))} set to "
474
+ + f"[cyan]{escape(str(model))}[/cyan].[/green]"
475
+ )
476
+
477
+
478
+ @model_app.command("reset")
479
+ def model_reset(
480
+ provider: Annotated[str | None, _MODEL_PROVIDER_OPTION] = None,
481
+ ) -> None:
482
+ """Forget the model chosen for a provider."""
483
+ provider_id = provider or get_selected_provider_id()
484
+ try:
485
+ provider_cfg = get_provider_config(provider_id)
486
+ set_provider_model(provider_id, None)
487
+ except KeyError as exc:
488
+ console.print(f"[red]{escape(str(exc))}[/red]")
489
+ raise typer.Exit(1)
490
+ follow_up = (
491
+ "Sessions use the first model the server lists."
492
+ if provider_cfg.is_local
493
+ else "You will be asked to choose one next time."
494
+ )
495
+ console.print(
496
+ f"[green]{_check()} Model choice for {escape(str(provider_cfg.name))} cleared.[/green] "
497
+ + follow_up
498
+ )
499
+
500
+
501
+ # --- canonical `config mode ...` --------------------------------------------
502
+
503
+
504
+ @mode_app.command("set")
505
+ def mode_set(
506
+ mode: Annotated[
507
+ str,
508
+ typer.Argument(help="Permission mode: ask, auto-accept, or plan"),
509
+ ]
510
+ ) -> None:
511
+ """Set the default permission mode for new sessions."""
512
+ try:
513
+ effective = set_default_mode(mode)
514
+ except ValueError as exc:
515
+ console.print(f"[red]{escape(str(exc))}[/red]")
516
+ raise typer.Exit(1)
517
+ console.print(f"[green]{_check()} Default mode set to [cyan]{effective}[/cyan].[/green]")
518
+
519
+
520
+ @mode_app.command("reset")
521
+ def mode_reset() -> None:
522
+ """Reset the default permission mode to 'ask'."""
523
+ effective = reset_default_mode()
524
+ console.print(f"[green]{_check()} Default mode reset to [cyan]{effective}[/cyan].[/green]")
525
+
526
+
527
+ # --- canonical `config models ...` ------------------------------------------
528
+
529
+
530
+ @models_app.command("show")
531
+ def models_show(
532
+ provider: Annotated[
533
+ str | None,
534
+ typer.Option(
535
+ "--provider", "-p", help="Provider id (default: configured provider)"
536
+ ),
537
+ ] = None,
538
+ ) -> None:
539
+ """List the models offered for a provider, newest first."""
540
+ _print_models(provider, refresh=False)
541
+
542
+
543
+ @models_app.command("refresh")
544
+ def models_refresh(
545
+ provider: Annotated[
546
+ str | None,
547
+ typer.Option(
548
+ "--provider", "-p", help="Provider id (default: configured provider)"
549
+ ),
550
+ ] = None,
551
+ ) -> None:
552
+ """Fetch the provider's live model list, dropping deprecated ids."""
553
+ _print_models(provider, refresh=True)
554
+
555
+
556
+ @models_app.command("allow")
557
+ def models_allow(
558
+ models: Annotated[list[str], typer.Argument(help="Model ids to show")],
559
+ provider: Annotated[
560
+ str | None,
561
+ typer.Option(
562
+ "--provider", "-p", help="Provider id (default: configured provider)"
563
+ ),
564
+ ] = None,
565
+ ) -> None:
566
+ """Only show these models for a provider."""
567
+ _set_filter(provider, "allow", models)
568
+
569
+
570
+ @models_app.command("deny")
571
+ def models_deny(
572
+ models: Annotated[list[str], typer.Argument(help="Model ids to hide")],
573
+ provider: Annotated[
574
+ str | None,
575
+ typer.Option(
576
+ "--provider", "-p", help="Provider id (default: configured provider)"
577
+ ),
578
+ ] = None,
579
+ ) -> None:
580
+ """Hide these models for a provider."""
581
+ _set_filter(provider, "deny", models)
582
+
583
+
584
+ @models_app.command("clear")
585
+ def models_clear(
586
+ provider: Annotated[
587
+ str | None,
588
+ typer.Option(
589
+ "--provider", "-p", help="Provider id (default: configured provider)"
590
+ ),
591
+ ] = None,
592
+ ) -> None:
593
+ """Clear model visibility for a provider."""
594
+ pid = provider or get_selected_provider_id()
595
+ _resolve_provider(pid)
596
+ set_model_filter(pid, "all", [])
597
+ console.print(f"[green]{_check()} Cleared model visibility for {escape(str(pid))}.[/green]")
598
+
599
+
600
+ # --- canonical `config permissions ...` -------------------------------------
601
+
602
+ permissions_app = typer.Typer(help="Manage persisted 'always allow' tool approvals.")
603
+ config_app.add_typer(permissions_app, name="permissions")
604
+
605
+
606
+ @permissions_app.command("list")
607
+ def permissions_list() -> None:
608
+ """List tools approved with 'always'."""
609
+ names = get_always_allowed_tools()
610
+ if not names:
611
+ console.print(
612
+ "[dim]No persisted tool approvals. Answer 'always' at an approval "
613
+ "prompt to add one.[/dim]"
614
+ )
615
+ return
616
+ table = Table(title="Always-allowed tools", show_header=True)
617
+ table.add_column("Tool", style="cyan")
618
+ for name in names:
619
+ table.add_row(escape(str(name)))
620
+ console.print(table)
621
+
622
+
623
+ @permissions_app.command("remove")
624
+ def permissions_remove(
625
+ tool: Annotated[str, typer.Argument(help="Tool name")],
626
+ ) -> None:
627
+ """Remove a persisted tool approval."""
628
+ if remove_always_allowed_tool(tool):
629
+ console.print(f"[green]{_check()} Removed persisted approval for {escape(str(tool))}.[/green]")
630
+ else:
631
+ console.print(f"[dim]No persisted approval for {escape(str(tool))}.[/dim]")
632
+
633
+
634
+ @permissions_app.command("clear")
635
+ def permissions_clear() -> None:
636
+ """Remove every persisted tool approval."""
637
+ count = clear_always_allowed_tools()
638
+ console.print(f"[green]{_check()} Cleared {count} persisted tool approval(s).[/green]")
639
+
640
+
641
+ # --- canonical `config commands ...` ----------------------------------------
642
+
643
+ commands_app = typer.Typer(
644
+ help="Auto-approve or hard-block run_bash commands by regex."
645
+ )
646
+ config_app.add_typer(commands_app, name="commands")
647
+
648
+
649
+ @commands_app.command("list")
650
+ def commands_list() -> None:
651
+ """List the run_bash allow/deny rules."""
652
+ rules = get_command_rules()
653
+ if not rules["allow"] and not rules["deny"]:
654
+ console.print("[dim]No command rules set.[/dim]")
655
+ return
656
+ table = Table(title="Command rules (run_bash)", show_header=True)
657
+ table.add_column("Kind", style="cyan")
658
+ table.add_column("Pattern")
659
+ for kind in ("deny", "allow"):
660
+ for pattern in rules[kind]:
661
+ table.add_row(escape(str(kind)), escape(str(pattern)))
662
+ console.print(table)
663
+
664
+
665
+ def _add_rule(kind: str, pattern: str) -> None:
666
+ try:
667
+ rules = add_command_rule(kind, pattern)
668
+ except ValueError as exc:
669
+ console.print(f"[red]{escape(str(exc))}[/red]")
670
+ raise typer.Exit(1)
671
+ console.print(f"[green]{_check()} Added {kind} rule:[/green] {escape(str(rules[kind][-1]))}")
672
+
673
+
674
+ @commands_app.command("allow")
675
+ def commands_allow(
676
+ pattern: Annotated[str, typer.Argument(help="Regex that auto-approves a command")],
677
+ ) -> None:
678
+ """Auto-approve matching run_bash commands."""
679
+ _add_rule("allow", pattern)
680
+
681
+
682
+ @commands_app.command("deny")
683
+ def commands_deny(
684
+ pattern: Annotated[str, typer.Argument(help="Regex that blocks a command")],
685
+ ) -> None:
686
+ """Hard-block matching run_bash commands."""
687
+ _add_rule("deny", pattern)
688
+
689
+
690
+ @commands_app.command("remove")
691
+ def commands_remove(
692
+ kind: Annotated[str, typer.Argument(help="'allow' or 'deny'")],
693
+ pattern: Annotated[str, typer.Argument(help="Regex to remove")],
694
+ ) -> None:
695
+ """Remove one command rule."""
696
+ try:
697
+ existed = remove_command_rule(kind, pattern)
698
+ except ValueError as exc:
699
+ console.print(f"[red]{escape(str(exc))}[/red]")
700
+ raise typer.Exit(1)
701
+ if existed:
702
+ console.print(f"[green]{_check()} Removed {kind} rule:[/green] {escape(str(pattern))}")
703
+ else:
704
+ console.print(f"[dim]No such {kind} rule: {escape(str(pattern))}[/dim]")
705
+
706
+
707
+ @commands_app.command("clear")
708
+ def commands_clear() -> None:
709
+ """Remove every command rule."""
710
+ clear_command_rules()
711
+ console.print(f"[green]{_check()} Cleared all command rules.[/green]")
712
+
713
+
714
+ # --- canonical `config mcp ...` ---------------------------------------------
715
+
716
+ mcp_app = typer.Typer(help="Manage MCP (Model Context Protocol) servers.")
717
+ config_app.add_typer(mcp_app, name="mcp")
718
+
719
+
720
+ @mcp_app.command("list")
721
+ def mcp_list() -> None:
722
+ """List configured MCP servers."""
723
+ servers = get_mcp_servers()
724
+ if not servers:
725
+ console.print("[dim]No MCP servers configured.[/dim]")
726
+ return
727
+ table = Table(title="MCP servers", show_header=True)
728
+ table.add_column("server", style="cyan")
729
+ table.add_column("transport")
730
+ table.add_column("target")
731
+ table.add_column("status")
732
+ for name, spec in servers.items():
733
+ if spec.get("command"):
734
+ transport = "stdio"
735
+ target = " ".join(
736
+ [str(spec["command"]), *[str(arg) for arg in spec.get("args") or []]]
737
+ )
738
+ else:
739
+ transport = "http"
740
+ target = str(spec.get("url") or "")
741
+ status = "disabled" if spec.get("disabled") else "enabled"
742
+ table.add_row(escape(str(name)), escape(str(transport)), escape(str(target)), escape(str(status)))
743
+ console.print(table)
744
+
745
+
746
+ def _parse_header(raw: str) -> tuple[str, str]:
747
+ name, _, value = raw.partition(":")
748
+ name = name.strip()
749
+ value = value.strip()
750
+ if not name or not value:
751
+ raise ValueError(f"Invalid header '{raw}': expected 'Name: value'.")
752
+ return name, value
753
+
754
+
755
+ @mcp_app.command("add")
756
+ def mcp_add(
757
+ name: Annotated[str, typer.Argument(help="Server name (\\[a-z0-9]\\[a-z0-9_-]*)")],
758
+ command: Annotated[
759
+ str | None, typer.Option("--command", help="stdio command to run")
760
+ ] = None,
761
+ args: Annotated[
762
+ str | None,
763
+ typer.Option("--args", help="stdio arguments (quote the whole value)"),
764
+ ] = None,
765
+ url: Annotated[
766
+ str | None, typer.Option("--url", help="HTTP MCP endpoint")
767
+ ] = None,
768
+ header: Annotated[
769
+ list[str] | None,
770
+ typer.Option("--header", "-H", help="HTTP header 'Name: value' (repeatable)"),
771
+ ] = None,
772
+ ) -> None:
773
+ """Add or replace an MCP server (set exactly one of --command or --url)."""
774
+ spec: dict[str, Any] = {}
775
+ if command:
776
+ spec["command"] = command
777
+ if args:
778
+ from kiwimatecoder.commands import split_args
779
+
780
+ spec["args"] = split_args(args)
781
+ if url:
782
+ spec["url"] = url
783
+ if header:
784
+ headers: dict[str, str] = {}
785
+ try:
786
+ for raw in header:
787
+ key, value = _parse_header(raw)
788
+ headers[key] = value
789
+ except ValueError as exc:
790
+ console.print(f"[red]{escape(str(exc))}[/red]")
791
+ raise typer.Exit(1)
792
+ spec["headers"] = headers
793
+ try:
794
+ servers = set_mcp_server(name, spec)
795
+ except ValueError as exc:
796
+ console.print(f"[red]{escape(str(exc))}[/red]")
797
+ raise typer.Exit(1)
798
+ console.print(
799
+ f"[green]{_check()} MCP server [cyan]{escape(str(name))}[/cyan] saved "
800
+ f"({len(servers)} total).[/green]"
801
+ )
802
+
803
+
804
+ @mcp_app.command("remove")
805
+ def mcp_remove(name: Annotated[str, typer.Argument(help="Server name")]) -> None:
806
+ """Remove an MCP server."""
807
+ if remove_mcp_server(name):
808
+ console.print(f"[green]{_check()} Removed MCP server {escape(str(name))}.[/green]")
809
+ else:
810
+ console.print(f"[dim]No MCP server named {escape(str(name))}.[/dim]")
811
+
812
+
813
+ # --- canonical `config lsp ...` ---------------------------------------------
814
+
815
+ lsp_app = typer.Typer(help="Language server diagnostics settings.")
816
+ config_app.add_typer(lsp_app, name="lsp")
817
+
818
+
819
+ def _lsp_state(value: str) -> bool:
820
+ """Parse an on/off argument, raising ``ValueError`` otherwise."""
821
+ token = value.strip().lower()
822
+ if token in {"on", "true", "yes", "enable", "enabled"}:
823
+ return True
824
+ if token in {"off", "false", "no", "disable", "disabled"}:
825
+ return False
826
+ raise ValueError("Expected 'on' or 'off'.")
827
+
828
+
829
+ @lsp_app.command("show")
830
+ def lsp_show() -> None:
831
+ """Show language server settings and which presets are installed."""
832
+ from kiwimatecoder import lsp as lsp_module
833
+
834
+ settings = get_lsp()
835
+ state = "on" if settings["enabled"] else "off"
836
+ console.print(
837
+ f"LSP: [cyan]{state}[/cyan] "
838
+ f"(timeout {settings['timeout']:g}s, diagnostics after edits "
839
+ f"[cyan]{'on' if settings['diagnostics_after_edits'] else 'off'}[/cyan])"
840
+ )
841
+ installed = lsp_module.available_servers()
842
+ if installed:
843
+ console.print(
844
+ "Available servers: "
845
+ + ", ".join(
846
+ f"[cyan]{escape(name)}[/cyan] ({escape(str(spec.command))})"
847
+ for name, spec in sorted(installed.items())
848
+ )
849
+ )
850
+ else:
851
+ console.print("[dim]No language server commands found on PATH.[/dim]")
852
+ overrides = settings["servers"]
853
+ if overrides:
854
+ console.print(
855
+ "Overrides: "
856
+ + ", ".join(f"[cyan]{escape(name)}[/cyan]" for name in sorted(overrides))
857
+ )
858
+
859
+
860
+ @lsp_app.command("enable")
861
+ def lsp_enable(
862
+ state: Annotated[str, typer.Argument(help="'on' or 'off'")] = "on",
863
+ ) -> None:
864
+ """Enable or disable language-server diagnostics."""
865
+ try:
866
+ enabled = _lsp_state(state)
867
+ except ValueError as exc:
868
+ console.print(f"[red]{escape(str(exc))}[/red]")
869
+ raise typer.Exit(1)
870
+ settings = set_lsp(enabled=enabled)
871
+ label = "on" if settings["enabled"] else "off"
872
+ console.print(
873
+ f"[green]{_check()} LSP {label}.[/green] Servers start on first use."
874
+ )
875
+
876
+
877
+ @lsp_app.command("after-edits")
878
+ def lsp_after_edits(
879
+ state: Annotated[str, typer.Argument(help="'on' or 'off'")] = "on",
880
+ ) -> None:
881
+ """Toggle diagnostics appended after successful file edits."""
882
+ try:
883
+ enabled = _lsp_state(state)
884
+ except ValueError as exc:
885
+ console.print(f"[red]{escape(str(exc))}[/red]")
886
+ raise typer.Exit(1)
887
+ settings = set_lsp(diagnostics_after_edits=enabled)
888
+ label = "on" if settings["diagnostics_after_edits"] else "off"
889
+ console.print(
890
+ f"[green]{_check()} LSP diagnostics after edits {label}.[/green]"
891
+ )
892
+
893
+
894
+ # --- canonical `config subagents ...` ---------------------------------------
895
+
896
+ subagents_app = typer.Typer(help="Subagent (task tool) settings.")
897
+ config_app.add_typer(subagents_app, name="subagents")
898
+
899
+
900
+ def _subagents_line() -> str:
901
+ settings = get_subagents()
902
+ state = "on" if settings["enabled"] else "off"
903
+ model = escape(settings["model"] or "(session model)")
904
+ return (
905
+ f"Subagents: [cyan]{state}[/cyan] "
906
+ f"(max steps {settings['max_steps']}, model {model})"
907
+ )
908
+
909
+
910
+ @subagents_app.command("show")
911
+ def subagents_show() -> None:
912
+ """Show whether subagents are enabled and their step/model limits."""
913
+ console.print(_subagents_line())
914
+
915
+
916
+ @subagents_app.command("enable")
917
+ def subagents_enable(
918
+ state: Annotated[str, typer.Argument(help="'on' or 'off'")] = "on",
919
+ ) -> None:
920
+ """Enable or disable the task tool."""
921
+ token = state.strip().lower()
922
+ if token in {"on", "true", "enable", "enabled"}:
923
+ enabled = True
924
+ elif token in {"off", "false", "disable", "disabled"}:
925
+ enabled = False
926
+ else:
927
+ console.print("[red]Expected 'on' or 'off'.[/red]")
928
+ raise typer.Exit(1)
929
+ set_subagents(enabled=enabled)
930
+ console.print(
931
+ f"[green]{_check()} Subagents {'on' if enabled else 'off'}.[/green]"
932
+ )
933
+
934
+
935
+ @subagents_app.command("max-steps")
936
+ def subagents_max_steps(
937
+ value: Annotated[
938
+ str | None,
939
+ typer.Argument(help="Step limit 1-100 (omit to show the current value)"),
940
+ ] = None,
941
+ ) -> None:
942
+ """Set the maximum tool batches a subagent may run."""
943
+ if value is None:
944
+ console.print(
945
+ f"Subagent max steps: [cyan]{get_subagents()['max_steps']}[/cyan]"
946
+ )
947
+ return
948
+ try:
949
+ settings = set_subagents(max_steps=value)
950
+ except ValueError as exc:
951
+ console.print(f"[red]{escape(str(exc))}[/red]")
952
+ raise typer.Exit(1)
953
+ console.print(
954
+ f"[green]{_check()} Subagent max steps:[/green] {settings['max_steps']}"
955
+ )
956
+
957
+
958
+ @subagents_app.command("model")
959
+ def subagents_model(
960
+ value: Annotated[
961
+ str | None,
962
+ typer.Argument(help="Model override (omit to show; 'clear' to reset)"),
963
+ ] = None,
964
+ ) -> None:
965
+ """Set the model subagents use (empty means the session model)."""
966
+ if value is None:
967
+ current = get_subagents()["model"] or "(session model)"
968
+ console.print(f"Subagent model: [cyan]{escape(str(current))}[/cyan]")
969
+ return
970
+ if value.strip().lower() in {"clear", "none", "off"}:
971
+ value = ""
972
+ settings = set_subagents(model=value)
973
+ console.print(
974
+ f"[green]{_check()} Subagent model:[/green] "
975
+ f"{escape(str(settings['model'] or '(session model)'))}"
976
+ )
977
+
978
+
979
+ # --- canonical `config browser ...` -----------------------------------------
980
+
981
+ browser_app = typer.Typer(help="Optional Playwright browser automation settings.")
982
+ config_app.add_typer(browser_app, name="browser")
983
+
984
+
985
+ def _print_browser(settings: dict[str, Any]) -> None:
986
+ console.print(
987
+ f"Browser automation: "
988
+ f"[cyan]{'on' if settings['enabled'] else 'off'}[/cyan]\n"
989
+ f"Headless: [cyan]{'on' if settings['headless'] else 'off'}[/cyan]\n"
990
+ f"Timeout: [cyan]{settings['timeout_ms']}ms[/cyan]"
991
+ )
992
+
993
+
994
+ @browser_app.command("show")
995
+ def browser_show() -> None:
996
+ """Show browser automation settings."""
997
+ _print_browser(get_browser())
998
+
999
+
1000
+ @browser_app.command("enable")
1001
+ def browser_enable(
1002
+ state: Annotated[str, typer.Argument(help="'on' or 'off'")] = "on",
1003
+ ) -> None:
1004
+ """Enable or disable the browser tool."""
1005
+ token = state.strip().lower()
1006
+ if token in {"on", "true", "enable", "enabled"}:
1007
+ enabled = True
1008
+ elif token in {"off", "false", "disable", "disabled"}:
1009
+ enabled = False
1010
+ else:
1011
+ console.print("[red]Expected 'on' or 'off'.[/red]")
1012
+ raise typer.Exit(1)
1013
+ set_browser(enabled=enabled)
1014
+ console.print(
1015
+ f"[green]{_check()} Browser automation "
1016
+ f"{'on' if enabled else 'off'}.[/green]"
1017
+ )
1018
+
1019
+
1020
+ @browser_app.command("headless")
1021
+ def browser_headless(
1022
+ state: Annotated[
1023
+ str | None,
1024
+ typer.Argument(help="'on' or 'off' (omit to show the current value)"),
1025
+ ] = None,
1026
+ ) -> None:
1027
+ """Run Chromium headless (on) or with a visible window (off)."""
1028
+ if state is None:
1029
+ current = "on" if get_browser()["headless"] else "off"
1030
+ console.print(f"Browser headless: [cyan]{escape(str(current))}[/cyan]")
1031
+ return
1032
+ token = state.strip().lower()
1033
+ if token in {"on", "true", "enable", "enabled"}:
1034
+ enabled = True
1035
+ elif token in {"off", "false", "disable", "disabled"}:
1036
+ enabled = False
1037
+ else:
1038
+ console.print("[red]Expected 'on' or 'off'.[/red]")
1039
+ raise typer.Exit(1)
1040
+ settings = set_browser(headless=enabled)
1041
+ console.print(
1042
+ f"[green]{_check()} Browser headless:[/green] "
1043
+ f"{'on' if settings['headless'] else 'off'}"
1044
+ )
1045
+
1046
+
1047
+ @browser_app.command("timeout")
1048
+ def browser_timeout(
1049
+ value: Annotated[
1050
+ str | None,
1051
+ typer.Argument(help="Timeout in ms 1000-120000 (omit to show)"),
1052
+ ] = None,
1053
+ ) -> None:
1054
+ """Set the default action timeout in milliseconds."""
1055
+ if value is None:
1056
+ console.print(f"Browser timeout: [cyan]{get_browser()['timeout_ms']}ms[/cyan]")
1057
+ return
1058
+ try:
1059
+ settings = set_browser(timeout_ms=value)
1060
+ except ValueError as exc:
1061
+ console.print(f"[red]{escape(str(exc))}[/red]")
1062
+ raise typer.Exit(1)
1063
+ console.print(f"[green]{_check()} Browser timeout:[/green] {settings['timeout_ms']}ms")
1064
+
1065
+
1066
+ # --- canonical `config shell ...` -------------------------------------------
1067
+
1068
+
1069
+ shell_app = typer.Typer(help="Persistent shell and background job settings.")
1070
+ config_app.add_typer(shell_app, name="shell")
1071
+
1072
+
1073
+ def _print_shell(settings: dict[str, Any]) -> None:
1074
+ console.print(
1075
+ f"Persistent shell: "
1076
+ f"[cyan]{'on' if settings['persistent'] else 'off'}[/cyan]\n"
1077
+ f"Command timeout: [cyan]{settings['timeout']}s[/cyan]\n"
1078
+ f"Background job cap: [cyan]{settings['max_jobs']}[/cyan]"
1079
+ )
1080
+
1081
+
1082
+ def _parse_on_off(value: str) -> bool:
1083
+ token = value.strip().lower()
1084
+ if token in {"on", "true", "enable", "enabled"}:
1085
+ return True
1086
+ if token in {"off", "false", "disable", "disabled"}:
1087
+ return False
1088
+ console.print("[red]Expected 'on' or 'off'.[/red]")
1089
+ raise typer.Exit(1)
1090
+
1091
+
1092
+ @shell_app.command("show")
1093
+ def shell_show() -> None:
1094
+ """Show persistent-shell settings."""
1095
+ _print_shell(get_shell_config())
1096
+
1097
+
1098
+ @shell_app.command("persistent")
1099
+ def shell_persistent(
1100
+ state: Annotated[
1101
+ str | None,
1102
+ typer.Argument(help="'on' or 'off' (omit to show the current value)"),
1103
+ ] = None,
1104
+ ) -> None:
1105
+ """Enable or disable the stateful `shell` tool."""
1106
+ if state is None:
1107
+ current = "on" if get_shell_config()["persistent"] else "off"
1108
+ console.print(f"Persistent shell: [cyan]{escape(str(current))}[/cyan]")
1109
+ return
1110
+ settings = set_shell_config(persistent=_parse_on_off(state))
1111
+ console.print(
1112
+ f"[green]{_check()} Persistent shell:[/green] "
1113
+ f"{'on' if settings['persistent'] else 'off'}"
1114
+ )
1115
+
1116
+
1117
+ @shell_app.command("timeout")
1118
+ def shell_timeout(
1119
+ value: Annotated[
1120
+ str | None,
1121
+ typer.Argument(help="Timeout in seconds 1-3600 (omit to show)"),
1122
+ ] = None,
1123
+ ) -> None:
1124
+ """Set how long one `shell` command may run before it is killed."""
1125
+ if value is None:
1126
+ console.print(f"Shell timeout: [cyan]{get_shell_config()['timeout']}s[/cyan]")
1127
+ return
1128
+ try:
1129
+ settings = set_shell_config(timeout=value)
1130
+ except ValueError as exc:
1131
+ console.print(f"[red]{escape(str(exc))}[/red]")
1132
+ raise typer.Exit(1)
1133
+ console.print(f"[green]{_check()} Shell timeout:[/green] {settings['timeout']}s")
1134
+
1135
+
1136
+ @shell_app.command("max-jobs")
1137
+ def shell_max_jobs(
1138
+ value: Annotated[
1139
+ str | None,
1140
+ typer.Argument(help="Concurrent background jobs 1-100 (omit to show)"),
1141
+ ] = None,
1142
+ ) -> None:
1143
+ """Set the maximum number of concurrent background jobs."""
1144
+ if value is None:
1145
+ console.print(
1146
+ f"Background job cap: [cyan]{get_shell_config()['max_jobs']}[/cyan]"
1147
+ )
1148
+ return
1149
+ try:
1150
+ settings = set_shell_config(max_jobs=value)
1151
+ except ValueError as exc:
1152
+ console.print(f"[red]{escape(str(exc))}[/red]")
1153
+ raise typer.Exit(1)
1154
+ console.print(f"[green]{_check()} Background job cap:[/green] {settings['max_jobs']}")
1155
+
1156
+
1157
+ # --- canonical `config sandbox ...` -----------------------------------------
1158
+
1159
+
1160
+ sandbox_app = typer.Typer(help="Optional OS-level command sandboxing.")
1161
+ config_app.add_typer(sandbox_app, name="sandbox")
1162
+
1163
+
1164
+ def _print_sandbox(settings: dict[str, Any]) -> None:
1165
+ writable = ", ".join(settings["extra_writable"]) or "(none)"
1166
+ console.print(
1167
+ f"Sandbox: [cyan]{'on' if settings['enabled'] else 'off'}[/cyan]\n"
1168
+ f"Network: [cyan]{'on' if settings['network'] else 'off'}[/cyan]\n"
1169
+ f"Extra writable paths: [cyan]{escape(writable)}[/cyan]"
1170
+ )
1171
+
1172
+
1173
+ @sandbox_app.command("show")
1174
+ def sandbox_show() -> None:
1175
+ """Show sandbox settings and the detected backend."""
1176
+ from kiwimatecoder import sandbox as sandbox_module
1177
+
1178
+ _print_sandbox(get_sandbox())
1179
+ available = sandbox_module.sandbox_available()
1180
+ if available is None:
1181
+ console.print("[dim]No sandbox backend detected on this platform.[/dim]")
1182
+ else:
1183
+ backend, path = available
1184
+ console.print(f"Backend: [cyan]{backend}[/cyan] ({escape(str(path))})")
1185
+
1186
+
1187
+ @sandbox_app.command("enable")
1188
+ def sandbox_enable(
1189
+ state: Annotated[
1190
+ str | None,
1191
+ typer.Argument(help="'on' or 'off' (omit to show the current value)"),
1192
+ ] = None,
1193
+ ) -> None:
1194
+ """Enable or disable OS-level sandboxing for shell commands."""
1195
+ if state is None:
1196
+ current = "on" if get_sandbox()["enabled"] else "off"
1197
+ console.print(f"Sandbox: [cyan]{escape(str(current))}[/cyan]")
1198
+ return
1199
+ settings = set_sandbox(enabled=_parse_on_off(state))
1200
+ console.print(
1201
+ f"[green]{_check()} Sandbox:[/green] "
1202
+ f"{'on' if settings['enabled'] else 'off'}"
1203
+ )
1204
+
1205
+
1206
+ @sandbox_app.command("network")
1207
+ def sandbox_network(
1208
+ state: Annotated[
1209
+ str | None,
1210
+ typer.Argument(help="'on' or 'off' (omit to show the current value)"),
1211
+ ] = None,
1212
+ ) -> None:
1213
+ """Allow or block network access inside the sandbox."""
1214
+ if state is None:
1215
+ current = "on" if get_sandbox()["network"] else "off"
1216
+ console.print(f"Sandbox network: [cyan]{escape(str(current))}[/cyan]")
1217
+ return
1218
+ settings = set_sandbox(network=_parse_on_off(state))
1219
+ console.print(
1220
+ f"[green]{_check()} Sandbox network:[/green] "
1221
+ f"{'on' if settings['network'] else 'off'}"
1222
+ )
1223
+
1224
+
1225
+ @sandbox_app.command("add-path")
1226
+ def sandbox_add_path(
1227
+ path: Annotated[
1228
+ str, typer.Argument(help="Path to make writable inside the sandbox")
1229
+ ],
1230
+ ) -> None:
1231
+ """Add an extra writable path to the sandbox profile."""
1232
+ paths = list(get_sandbox()["extra_writable"])
1233
+ if path not in paths:
1234
+ paths.append(path)
1235
+ try:
1236
+ set_sandbox(extra_writable=paths)
1237
+ except ValueError as exc:
1238
+ console.print(f"[red]{escape(str(exc))}[/red]")
1239
+ raise typer.Exit(1)
1240
+ console.print(f"[green]{_check()} Writable in sandbox:[/green] {escape(str(path))}")
1241
+
1242
+
1243
+ @sandbox_app.command("remove-path")
1244
+ def sandbox_remove_path(
1245
+ path: Annotated[str, typer.Argument(help="Path to remove")],
1246
+ ) -> None:
1247
+ """Remove an extra writable path from the sandbox profile."""
1248
+ current = get_sandbox()["extra_writable"]
1249
+ paths = [item for item in current if item != path]
1250
+ if len(paths) == len(current):
1251
+ console.print(f"[dim]No such writable path: {escape(str(path))}[/dim]")
1252
+ return
1253
+ set_sandbox(extra_writable=paths)
1254
+ console.print(f"[green]{_check()} Removed writable path:[/green] {escape(str(path))}")
1255
+
1256
+
1257
+ @sandbox_app.command("clear-paths")
1258
+ def sandbox_clear_paths() -> None:
1259
+ """Remove every extra writable path."""
1260
+ set_sandbox(extra_writable=[])
1261
+ console.print(f"[green]{_check()} Sandbox extra writable paths cleared.[/green]")
1262
+
1263
+
1264
+ # --- canonical `config remote ...` ------------------------------------------
1265
+
1266
+
1267
+ remote_app = typer.Typer(help="Run shell commands over SSH or in a devcontainer.")
1268
+ config_app.add_typer(remote_app, name="remote")
1269
+
1270
+
1271
+ def _remote_address(settings: dict[str, Any]) -> str:
1272
+ host = str(settings["host"] or "")
1273
+ if not host:
1274
+ return "(none)"
1275
+ address = f"{settings['user']}@{host}" if settings["user"] else host
1276
+ if settings["port"] != 22:
1277
+ address += f":{settings['port']}"
1278
+ return address
1279
+
1280
+
1281
+ def _print_remote(settings: dict[str, Any]) -> None:
1282
+ console.print(
1283
+ f"Remote: [cyan]{'on' if settings['enabled'] else 'off'}[/cyan]\n"
1284
+ f"Host: [cyan]{escape(_remote_address(settings))}[/cyan]\n"
1285
+ f"Identity: [cyan]{escape(settings['identity'] or '(default)')}[/cyan]\n"
1286
+ f"Workspace: [cyan]{escape(settings['workspace'] or '(remote default)')}[/cyan]\n"
1287
+ f"Devcontainer: [cyan]{escape(settings['devcontainer'])}[/cyan]"
1288
+ )
1289
+
1290
+
1291
+ @remote_app.command("show")
1292
+ def remote_config_show() -> None:
1293
+ """Show remote settings and any devcontainer detected in this directory."""
1294
+ from kiwimatecoder import remote as remote_module
1295
+
1296
+ _print_remote(get_remote())
1297
+ info = remote_module.detect_devcontainer(Path.cwd())
1298
+ if info is not None:
1299
+ console.print(
1300
+ f"Detected devcontainer: [cyan]{escape(info.name)}[/cyan] "
1301
+ f"(workspace folder [cyan]{escape(info.workspace_folder)}[/cyan])"
1302
+ )
1303
+
1304
+
1305
+ @remote_app.command("enable")
1306
+ def remote_config_enable(
1307
+ state: Annotated[
1308
+ str | None,
1309
+ typer.Argument(help="'on' or 'off' (omit to show the current value)"),
1310
+ ] = None,
1311
+ ) -> None:
1312
+ """Enable or disable remote/devcontainer command execution."""
1313
+ if state is None:
1314
+ current = "on" if get_remote()["enabled"] else "off"
1315
+ console.print(f"Remote: [cyan]{escape(str(current))}[/cyan]")
1316
+ return
1317
+ try:
1318
+ settings = set_remote(enabled=_parse_on_off(state))
1319
+ except ValueError as exc:
1320
+ console.print(f"[red]{escape(str(exc))}[/red]")
1321
+ raise typer.Exit(1)
1322
+ console.print(
1323
+ f"[green]{_check()} Remote:[/green] "
1324
+ f"{'on' if settings['enabled'] else 'off'}"
1325
+ )
1326
+
1327
+
1328
+ @remote_app.command("host")
1329
+ def remote_config_host(
1330
+ value: Annotated[
1331
+ str | None, typer.Argument(help="SSH host (omit to show the current value)")
1332
+ ] = None,
1333
+ ) -> None:
1334
+ """Set the SSH host remote commands run on."""
1335
+ if value is None:
1336
+ console.print(
1337
+ f"Remote host: [cyan]{escape(get_remote()['host'] or '(none)')}[/cyan]"
1338
+ )
1339
+ return
1340
+ try:
1341
+ settings = set_remote(host=value)
1342
+ except ValueError as exc:
1343
+ console.print(f"[red]{escape(str(exc))}[/red]")
1344
+ raise typer.Exit(1)
1345
+ console.print(
1346
+ f"[green]{_check()} Remote host:[/green] "
1347
+ f"{escape(settings['host'] or '(none)')}"
1348
+ )
1349
+
1350
+
1351
+ @remote_app.command("user")
1352
+ def remote_config_user(
1353
+ value: Annotated[
1354
+ str | None, typer.Argument(help="SSH user (omit to show the current value)")
1355
+ ] = None,
1356
+ ) -> None:
1357
+ """Set the SSH user remote commands run as."""
1358
+ if value is None:
1359
+ console.print(
1360
+ f"Remote user: [cyan]{escape(get_remote()['user'] or '(none)')}[/cyan]"
1361
+ )
1362
+ return
1363
+ try:
1364
+ settings = set_remote(user=value)
1365
+ except ValueError as exc:
1366
+ console.print(f"[red]{escape(str(exc))}[/red]")
1367
+ raise typer.Exit(1)
1368
+ console.print(
1369
+ f"[green]{_check()} Remote user:[/green] "
1370
+ f"{escape(settings['user'] or '(none)')}"
1371
+ )
1372
+
1373
+
1374
+ @remote_app.command("port")
1375
+ def remote_config_port(
1376
+ value: Annotated[
1377
+ str | None, typer.Argument(help="SSH port 1-65535 (omit to show)")
1378
+ ] = None,
1379
+ ) -> None:
1380
+ """Set the SSH port (default 22)."""
1381
+ if value is None:
1382
+ console.print(f"Remote port: [cyan]{get_remote()['port']}[/cyan]")
1383
+ return
1384
+ try:
1385
+ settings = set_remote(port=value)
1386
+ except ValueError as exc:
1387
+ console.print(f"[red]{escape(str(exc))}[/red]")
1388
+ raise typer.Exit(1)
1389
+ console.print(f"[green]{_check()} Remote port:[/green] {settings['port']}")
1390
+
1391
+
1392
+ @remote_app.command("identity")
1393
+ def remote_config_identity(
1394
+ value: Annotated[
1395
+ str | None,
1396
+ typer.Argument(help="SSH identity file (omit to show the current value)"),
1397
+ ] = None,
1398
+ ) -> None:
1399
+ """Set the SSH private key used for remote commands."""
1400
+ if value is None:
1401
+ console.print(
1402
+ f"Remote identity: [cyan]{escape(get_remote()['identity'] or '(default)')}[/cyan]"
1403
+ )
1404
+ return
1405
+ try:
1406
+ settings = set_remote(identity=value)
1407
+ except ValueError as exc:
1408
+ console.print(f"[red]{escape(str(exc))}[/red]")
1409
+ raise typer.Exit(1)
1410
+ console.print(
1411
+ f"[green]{_check()} Remote identity:[/green] "
1412
+ f"{escape(settings['identity'] or '(default)')}"
1413
+ )
1414
+
1415
+
1416
+ @remote_app.command("workspace")
1417
+ def remote_config_workspace(
1418
+ value: Annotated[
1419
+ str | None,
1420
+ typer.Argument(help="Remote working directory (omit to show the current value)"),
1421
+ ] = None,
1422
+ ) -> None:
1423
+ """Set the directory remote commands run in."""
1424
+ if value is None:
1425
+ console.print(
1426
+ "Remote workspace: "
1427
+ f"[cyan]{escape(get_remote()['workspace'] or '(remote default)')}[/cyan]"
1428
+ )
1429
+ return
1430
+ try:
1431
+ settings = set_remote(workspace=value)
1432
+ except ValueError as exc:
1433
+ console.print(f"[red]{escape(str(exc))}[/red]")
1434
+ raise typer.Exit(1)
1435
+ console.print(
1436
+ f"[green]{_check()} Remote workspace:[/green] "
1437
+ f"{escape(settings['workspace'] or '(remote default)')}"
1438
+ )
1439
+
1440
+
1441
+ @remote_app.command("devcontainer")
1442
+ def remote_config_devcontainer(
1443
+ value: Annotated[
1444
+ str | None,
1445
+ typer.Argument(
1446
+ help="'auto', 'off', or a container name (omit to show the current value)"
1447
+ ),
1448
+ ] = None,
1449
+ ) -> None:
1450
+ """Choose how a detected devcontainer is used for command execution."""
1451
+ if value is None:
1452
+ console.print(f"Devcontainer: [cyan]{escape(get_remote()['devcontainer'])}[/cyan]")
1453
+ return
1454
+ try:
1455
+ settings = set_remote(devcontainer=value)
1456
+ except ValueError as exc:
1457
+ console.print(f"[red]{escape(str(exc))}[/red]")
1458
+ raise typer.Exit(1)
1459
+ console.print(
1460
+ f"[green]{_check()} Devcontainer:[/green] {escape(settings['devcontainer'])}"
1461
+ )
1462
+
1463
+
1464
+ # --- canonical `config acp ...` ---------------------------------------------
1465
+
1466
+
1467
+ acp_config_app = typer.Typer(help="Agent Client Protocol (editor integration) settings.")
1468
+ config_app.add_typer(acp_config_app, name="acp")
1469
+
1470
+
1471
+ @acp_config_app.command("show")
1472
+ def acp_config_show() -> None:
1473
+ """Show ACP server settings."""
1474
+ settings = get_acp()
1475
+ console.print(
1476
+ f"ACP permission timeout: [cyan]{settings['permission_timeout']}s[/cyan]"
1477
+ )
1478
+
1479
+
1480
+ @acp_config_app.command("timeout")
1481
+ def acp_config_timeout(
1482
+ seconds: Annotated[
1483
+ int | None,
1484
+ typer.Argument(help="Seconds to wait for the editor's approval (omit to show)"),
1485
+ ] = None,
1486
+ ) -> None:
1487
+ """Set how long the editor has to answer a permission request."""
1488
+ if seconds is None:
1489
+ console.print(
1490
+ f"ACP permission timeout: [cyan]{get_acp()['permission_timeout']}s[/cyan]"
1491
+ )
1492
+ return
1493
+ try:
1494
+ settings = set_acp(permission_timeout=seconds)
1495
+ except ValueError as exc:
1496
+ console.print(f"[red]{escape(str(exc))}[/red]")
1497
+ raise typer.Exit(1)
1498
+ console.print(
1499
+ f"[green]{_check()} ACP permission timeout:[/green] "
1500
+ f"{settings['permission_timeout']}s"
1501
+ )
1502
+
1503
+
1504
+ @config_app.command("trusted-workspace")
1505
+ def trusted_workspace_cmd(
1506
+ state: Annotated[
1507
+ str | None,
1508
+ typer.Argument(help="'on' or 'off' (omit to show the current value)"),
1509
+ ] = None,
1510
+ ) -> None:
1511
+ """Allow or forbid read-only access outside the workspace root."""
1512
+ if state is None:
1513
+ current = "on" if get_trusted_workspace() else "off"
1514
+ console.print(f"Trusted workspace: [cyan]{escape(str(current))}[/cyan]")
1515
+ return
1516
+ token = state.strip().lower()
1517
+ if token in {"on", "true", "enable", "enabled"}:
1518
+ set_trusted_workspace(True)
1519
+ console.print(
1520
+ f"[yellow]{_check()} Trusted workspace on:[/yellow] read-only tools may read "
1521
+ "outside the workspace root; writes stay sandboxed."
1522
+ )
1523
+ elif token in {"off", "false", "disable", "disabled"}:
1524
+ set_trusted_workspace(False)
1525
+ console.print(f"[green]{_check()} Trusted workspace off.[/green]")
1526
+ else:
1527
+ console.print("[red]Expected 'on' or 'off'.[/red]")
1528
+ raise typer.Exit(1)
1529
+
1530
+
1531
+ @config_app.command("verify")
1532
+ def verify_cmd(
1533
+ action: Annotated[str, typer.Argument(help="show, set, or clear")] = "show",
1534
+ command: Annotated[
1535
+ str | None, typer.Argument(help="Command to run when action is 'set'")
1536
+ ] = None,
1537
+ ) -> None:
1538
+ """Show, set, or clear the command run automatically after edits."""
1539
+ if action in {"show", "status"}:
1540
+ current = get_verify_command()
1541
+ if current:
1542
+ console.print(f"Auto-verify: [cyan]{escape(str(current))}[/cyan]")
1543
+ else:
1544
+ console.print("[dim]Auto-verify is off.[/dim]")
1545
+ return
1546
+ if action in {"set", "update"}:
1547
+ if not command:
1548
+ console.print("[red]Usage: config verify set <command>[/red]")
1549
+ raise typer.Exit(1)
1550
+ set_verify_command(command)
1551
+ console.print(f"[green]{_check()} Auto-verify set to:[/green] {escape(str(command))}")
1552
+ return
1553
+ if action in {"clear", "reset", "off"}:
1554
+ set_verify_command("")
1555
+ console.print(f"[green]{_check()} Auto-verify disabled.[/green]")
1556
+ return
1557
+ console.print("[red]Expected 'show', 'set', or 'clear'.[/red]")
1558
+ raise typer.Exit(1)
1559
+
1560
+
1561
+ @config_app.command("budget")
1562
+ def budget_cmd(
1563
+ action: Annotated[
1564
+ str, typer.Argument(help="show, tokens, cost, or clear")
1565
+ ] = "show",
1566
+ value: Annotated[
1567
+ str | None, typer.Argument(help="Token count or USD amount")
1568
+ ] = None,
1569
+ ) -> None:
1570
+ """Set or clear session token/cost budget limits."""
1571
+ if action in {"show", "status"}:
1572
+ budget = get_budget()
1573
+ if not budget:
1574
+ console.print("[dim]No budget limits set.[/dim]")
1575
+ else:
1576
+ console.print(
1577
+ "Budget: "
1578
+ + ", ".join(f"[cyan]{key}[/cyan]={val}" for key, val in budget.items())
1579
+ )
1580
+ return
1581
+ if action in {"clear", "reset"}:
1582
+ clear_budget()
1583
+ console.print(f"[green]{_check()} Budget limits cleared.[/green]")
1584
+ return
1585
+ if action in {"tokens", "token"}:
1586
+ limit = None if value in (None, "clear", "none", "off") else value
1587
+ try:
1588
+ set_budget(max_tokens=limit)
1589
+ except ValueError as exc:
1590
+ console.print(f"[red]{escape(str(exc))}[/red]")
1591
+ raise typer.Exit(1)
1592
+ console.print(f"[green]{_check()} Token budget:[/green] {get_budget().get('max_tokens')}")
1593
+ return
1594
+ if action in {"cost", "usd"}:
1595
+ limit = None if value in (None, "clear", "none", "off") else value
1596
+ try:
1597
+ set_budget(max_cost_usd=limit)
1598
+ except ValueError as exc:
1599
+ console.print(f"[red]{escape(str(exc))}[/red]")
1600
+ raise typer.Exit(1)
1601
+ console.print(
1602
+ f"[green]{_check()} Cost budget:[/green] ${get_budget().get('max_cost_usd')}"
1603
+ )
1604
+ return
1605
+ console.print("[red]Expected 'show', 'tokens', 'cost', or 'clear'.[/red]")
1606
+ raise typer.Exit(1)
1607
+
1608
+
1609
+ @config_app.command("cache")
1610
+ def cache_cmd(
1611
+ state: Annotated[
1612
+ str | None,
1613
+ typer.Argument(help="'on' or 'off' (omit to show the current value)"),
1614
+ ] = None,
1615
+ ) -> None:
1616
+ """Toggle prompt caching for native Anthropic providers."""
1617
+ if state is None:
1618
+ current = "on" if get_prompt_cache() else "off"
1619
+ console.print(f"Prompt caching: [cyan]{escape(str(current))}[/cyan]")
1620
+ return
1621
+ token = state.strip().lower()
1622
+ if token in {"on", "true", "enable", "enabled"}:
1623
+ set_prompt_cache(True)
1624
+ console.print(
1625
+ f"[green]{_check()} Prompt caching on:[/green] native Anthropic requests mark "
1626
+ "the system prompt and tool definitions as cache breakpoints."
1627
+ )
1628
+ elif token in {"off", "false", "disable", "disabled"}:
1629
+ set_prompt_cache(False)
1630
+ console.print(f"[green]{_check()} Prompt caching off.[/green]")
1631
+ else:
1632
+ console.print("[red]Expected 'on' or 'off'.[/red]")
1633
+ raise typer.Exit(1)
1634
+
1635
+
1636
+ # --- canonical `config web ...` ---------------------------------------------
1637
+
1638
+ web_app = typer.Typer(help="Web fetch/search settings.")
1639
+ config_app.add_typer(web_app, name="web")
1640
+
1641
+
1642
+ def _print_web(settings: dict[str, object]) -> None:
1643
+ console.print(
1644
+ f"Web max chars: [cyan]{settings['max_chars']}[/cyan]\n"
1645
+ f"Web timeout: [cyan]{settings['timeout']}s[/cyan]\n"
1646
+ f"Allow local addresses: "
1647
+ f"[cyan]{'on' if settings['allow_local'] else 'off'}[/cyan]\n"
1648
+ f"Search provider: [cyan]{settings['search_provider']}[/cyan]"
1649
+ )
1650
+
1651
+
1652
+ @web_app.command("show")
1653
+ def web_show() -> None:
1654
+ """Show web fetch/search limits and local-address access."""
1655
+ _print_web(get_web())
1656
+
1657
+
1658
+ @web_app.command("max-chars")
1659
+ def web_max_chars(
1660
+ value: Annotated[
1661
+ str | None,
1662
+ typer.Argument(help="Maximum characters to return (omit to show the current value)"),
1663
+ ] = None,
1664
+ ) -> None:
1665
+ """Set the maximum characters web_fetch returns."""
1666
+ if value is None:
1667
+ console.print(f"Web max chars: [cyan]{get_web()['max_chars']}[/cyan]")
1668
+ return
1669
+ try:
1670
+ settings = set_web(max_chars=value)
1671
+ except ValueError as exc:
1672
+ console.print(f"[red]{escape(str(exc))}[/red]")
1673
+ raise typer.Exit(1)
1674
+ console.print(f"[green]{_check()} Web max chars:[/green] {settings['max_chars']}")
1675
+
1676
+
1677
+ @web_app.command("timeout")
1678
+ def web_timeout(
1679
+ value: Annotated[
1680
+ str | None,
1681
+ typer.Argument(help="Timeout in seconds (omit to show the current value)"),
1682
+ ] = None,
1683
+ ) -> None:
1684
+ """Set the web fetch/search timeout in seconds."""
1685
+ if value is None:
1686
+ console.print(f"Web timeout: [cyan]{get_web()['timeout']:g}s[/cyan]")
1687
+ return
1688
+ try:
1689
+ settings = set_web(timeout=value)
1690
+ except ValueError as exc:
1691
+ console.print(f"[red]{escape(str(exc))}[/red]")
1692
+ raise typer.Exit(1)
1693
+ console.print(f"[green]{_check()} Web timeout:[/green] {settings['timeout']:g}s")
1694
+
1695
+
1696
+ @web_app.command("allow-local")
1697
+ def web_allow_local(
1698
+ state: Annotated[
1699
+ str | None,
1700
+ typer.Argument(help="'on' or 'off' (omit to show the current value)"),
1701
+ ] = None,
1702
+ ) -> None:
1703
+ """Allow web_fetch to reach local/private addresses (default off)."""
1704
+ if state is None:
1705
+ current = "on" if get_web()["allow_local"] else "off"
1706
+ console.print(f"Allow local addresses: [cyan]{escape(str(current))}[/cyan]")
1707
+ return
1708
+ token = state.strip().lower()
1709
+ if token in {"on", "true", "enable", "enabled"}:
1710
+ set_web(allow_local=True)
1711
+ console.print(
1712
+ f"[yellow]{_check()} Allow local addresses on:[/yellow] web_fetch may "
1713
+ "reach localhost and private network hosts."
1714
+ )
1715
+ elif token in {"off", "false", "disable", "disabled"}:
1716
+ set_web(allow_local=False)
1717
+ console.print(f"[green]{_check()} Allow local addresses off.[/green]")
1718
+ else:
1719
+ console.print("[red]Expected 'on' or 'off'.[/red]")
1720
+ raise typer.Exit(1)
1721
+
1722
+
1723
+ # --- canonical `config network ...` -----------------------------------------
1724
+
1725
+ network_app = typer.Typer(help="Proxy, custom CA, and offline mode.")
1726
+ config_app.add_typer(network_app, name="network")
1727
+
1728
+ _NETWORK_CLEAR_TOKENS = {"clear", "none", "off", "-", "default"}
1729
+
1730
+
1731
+ def _is_network_clear(value: str) -> bool:
1732
+ return value.strip().lower() in _NETWORK_CLEAR_TOKENS
1733
+
1734
+
1735
+ def _print_network(settings: dict[str, Any]) -> None:
1736
+ console.print(
1737
+ f"Proxy: [cyan]{escape(str(settings['proxy'] or 'none'))}[/cyan]\n"
1738
+ f"CA bundle: [cyan]{escape(str(settings['ca_bundle'] or 'system default'))}[/cyan]\n"
1739
+ f"Offline mode: [cyan]{'on' if settings['offline'] else 'off'}[/cyan]"
1740
+ )
1741
+
1742
+
1743
+ @network_app.command("show")
1744
+ def network_show() -> None:
1745
+ """Show proxy, custom CA bundle, and offline-mode settings."""
1746
+ _print_network(get_network())
1747
+
1748
+
1749
+ @network_app.command("proxy")
1750
+ def network_proxy(
1751
+ value: Annotated[
1752
+ str | None,
1753
+ typer.Argument(help="Proxy URL, 'clear', or omit to show the current value"),
1754
+ ] = None,
1755
+ ) -> None:
1756
+ """Set or clear the HTTP(S) proxy used for every request."""
1757
+ if value is None:
1758
+ console.print(f"Proxy: [cyan]{escape(str(get_network()['proxy'] or 'none'))}[/cyan]")
1759
+ return
1760
+ cleaned = "" if _is_network_clear(value) else value
1761
+ try:
1762
+ settings = set_network(proxy=cleaned)
1763
+ except ValueError as exc:
1764
+ console.print(f"[red]{escape(str(exc))}[/red]")
1765
+ raise typer.Exit(1)
1766
+ console.print(f"[green]{_check()} Proxy:[/green] {escape(str(settings['proxy'] or 'none'))}")
1767
+
1768
+
1769
+ @network_app.command("ca")
1770
+ def network_ca(
1771
+ value: Annotated[
1772
+ str | None,
1773
+ typer.Argument(
1774
+ help="CA bundle path, 'clear', or omit to show the current value"
1775
+ ),
1776
+ ] = None,
1777
+ ) -> None:
1778
+ """Set or clear the custom CA bundle used for TLS verification."""
1779
+ if value is None:
1780
+ console.print(
1781
+ f"CA bundle: [cyan]{escape(str(get_network()['ca_bundle'] or 'system default'))}[/cyan]"
1782
+ )
1783
+ return
1784
+ cleaned = "" if _is_network_clear(value) else value
1785
+ try:
1786
+ settings = set_network(ca_bundle=cleaned)
1787
+ except ValueError as exc:
1788
+ console.print(f"[red]{escape(str(exc))}[/red]")
1789
+ raise typer.Exit(1)
1790
+ console.print(
1791
+ f"[green]{_check()} CA bundle:[/green] "
1792
+ f"{escape(str(settings['ca_bundle'] or 'system default'))}"
1793
+ )
1794
+
1795
+
1796
+ @network_app.command("offline")
1797
+ def network_offline(
1798
+ state: Annotated[
1799
+ str | None,
1800
+ typer.Argument(help="'on' or 'off' (omit to show the current value)"),
1801
+ ] = None,
1802
+ ) -> None:
1803
+ """Block cloud requests; local providers keep working."""
1804
+ if state is None:
1805
+ current = "on" if get_network()["offline"] else "off"
1806
+ console.print(f"Offline mode: [cyan]{escape(str(current))}[/cyan]")
1807
+ return
1808
+ token = state.strip().lower()
1809
+ if token in {"on", "true", "enable", "enabled", "yes"}:
1810
+ set_network(offline=True)
1811
+ console.print(
1812
+ f"[yellow]{_check()} Offline mode on:[/yellow] cloud requests and web "
1813
+ "tools are blocked; local providers still work."
1814
+ )
1815
+ elif token in {"off", "false", "disable", "disabled", "no"}:
1816
+ set_network(offline=False)
1817
+ console.print(f"[green]{_check()} Offline mode off.[/green]")
1818
+ else:
1819
+ console.print("[red]Expected 'on' or 'off'.[/red]")
1820
+ raise typer.Exit(1)
1821
+
1822
+
1823
+ # --- canonical `config index ...` -------------------------------------------
1824
+
1825
+ index_app = typer.Typer(help="Codebase index and semantic search settings.")
1826
+ config_app.add_typer(index_app, name="index")
1827
+
1828
+
1829
+ @index_app.command("show")
1830
+ def index_show() -> None:
1831
+ """Show the index status for the current directory."""
1832
+ from kiwimatecoder.index.store import index_status
1833
+
1834
+ settings = get_index()
1835
+ status = index_status(Path.cwd())
1836
+ embeddings = settings["embeddings"]
1837
+ if embeddings["provider"] and embeddings["model"]:
1838
+ embed_line = (
1839
+ f"[cyan]on[/cyan] ({escape(embeddings['provider'])}:"
1840
+ f"{escape(embeddings['model'])})"
1841
+ )
1842
+ else:
1843
+ embed_line = "[cyan]off[/cyan]"
1844
+ console.print(
1845
+ f"Index: [cyan]{'on' if status.enabled else 'off'}[/cyan]\n"
1846
+ f"Files indexed: [cyan]{status.files}[/cyan] "
1847
+ f"([yellow]{status.stale}[/yellow] stale)\n"
1848
+ f"Terms: [cyan]{status.terms}[/cyan]\n"
1849
+ f"Store: [cyan]{escape(str(status.path))}[/cyan] ({status.size_bytes} bytes)\n"
1850
+ f"Embeddings: {embed_line}"
1851
+ )
1852
+
1853
+
1854
+ @index_app.command("embed-provider")
1855
+ def index_embed_provider(
1856
+ provider: Annotated[
1857
+ str,
1858
+ typer.Argument(help="Provider id ('none' or empty clears it)"),
1859
+ ] = "",
1860
+ ) -> None:
1861
+ """Set the OpenAI-compatible provider used for embeddings."""
1862
+ cleaned = provider.strip()
1863
+ if cleaned.lower() in {"", "none", "off", "-", "clear"}:
1864
+ cleaned = ""
1865
+ try:
1866
+ settings = set_index(embed_provider=cleaned)
1867
+ except ValueError as exc:
1868
+ console.print(f"[red]{escape(str(exc))}[/red]")
1869
+ raise typer.Exit(1)
1870
+ console.print(
1871
+ f"[green]{_check()} Embedding provider:[/green] "
1872
+ f"[cyan]{escape(str(settings['embeddings']['provider'] or 'off'))}[/cyan]"
1873
+ )
1874
+
1875
+
1876
+ @index_app.command("embed-model")
1877
+ def index_embed_model(
1878
+ model: Annotated[str, typer.Argument(help="Embedding model id")],
1879
+ ) -> None:
1880
+ """Set the embedding model id (empty clears it)."""
1881
+ cleaned = model.strip()
1882
+ if cleaned.lower() in {"none", "off", "-", "clear"}:
1883
+ cleaned = ""
1884
+ settings = set_index(embed_model=cleaned)
1885
+ console.print(
1886
+ f"[green]{_check()} Embedding model:[/green] "
1887
+ f"[cyan]{escape(str(settings['embeddings']['model'] or 'off'))}[/cyan]"
1888
+ )
1889
+
1890
+
1891
+ @index_app.command("clear")
1892
+ def index_clear() -> None:
1893
+ """Delete the index for the current directory."""
1894
+ from kiwimatecoder.index.store import clear_store
1895
+
1896
+ if clear_store(Path.cwd()):
1897
+ console.print(f"[green]{_check()} Codebase index cleared.[/green]")
1898
+ else:
1899
+ console.print("[dim]No codebase index to clear.[/dim]")
1900
+
1901
+
1902
+ # --- canonical `config memory ...` ------------------------------------------
1903
+
1904
+ memory_app = typer.Typer(help="Persistent project and user memory settings.")
1905
+ config_app.add_typer(memory_app, name="memory")
1906
+
1907
+
1908
+ def _print_memory(settings: dict[str, object]) -> None:
1909
+ console.print(
1910
+ f"Memory: [cyan]{'on' if settings['enabled'] else 'off'}[/cyan]\n"
1911
+ f"Max bytes in the prompt: [cyan]{settings['max_bytes']}[/cyan]"
1912
+ )
1913
+
1914
+
1915
+ @memory_app.command("show")
1916
+ def memory_show() -> None:
1917
+ """Show whether memory is enabled and the prompt byte budget."""
1918
+ _print_memory(get_memory())
1919
+
1920
+
1921
+ @memory_app.command("max-bytes")
1922
+ def memory_max_bytes(
1923
+ value: Annotated[
1924
+ str | None,
1925
+ typer.Argument(help="Prompt byte budget (omit to show the current value)"),
1926
+ ] = None,
1927
+ ) -> None:
1928
+ """Set the maximum memory bytes included in the system prompt."""
1929
+ if value is None:
1930
+ console.print(
1931
+ f"Max memory bytes: [cyan]{get_memory()['max_bytes']}[/cyan]"
1932
+ )
1933
+ return
1934
+ try:
1935
+ settings = set_memory(max_bytes=value)
1936
+ except ValueError as exc:
1937
+ console.print(f"[red]{escape(str(exc))}[/red]")
1938
+ raise typer.Exit(1)
1939
+ console.print(
1940
+ f"[green]{_check()} Max memory bytes:[/green] {settings['max_bytes']}"
1941
+ )
1942
+
1943
+
1944
+ @memory_app.command("enable")
1945
+ def memory_enable(
1946
+ state: Annotated[
1947
+ str | None,
1948
+ typer.Argument(help="'on' or 'off' (omit to show the current value)"),
1949
+ ] = None,
1950
+ ) -> None:
1951
+ """Enable or disable persistent memory in the system prompt."""
1952
+ if state is None:
1953
+ console.print(
1954
+ f"Memory: [cyan]{'on' if get_memory()['enabled'] else 'off'}[/cyan]"
1955
+ )
1956
+ return
1957
+ token = state.strip().lower()
1958
+ if token in {"on", "true", "enable", "enabled"}:
1959
+ set_memory(enabled=True)
1960
+ console.print(f"[green]{_check()} Memory on.[/green]")
1961
+ elif token in {"off", "false", "disable", "disabled"}:
1962
+ set_memory(enabled=False)
1963
+ console.print(f"[green]{_check()} Memory off.[/green]")
1964
+ else:
1965
+ console.print("[red]Expected 'on' or 'off'.[/red]")
1966
+ raise typer.Exit(1)
1967
+
1968
+
1969
+ # --- canonical `config sampling ...` ----------------------------------------
1970
+
1971
+ sampling_app = typer.Typer(help="Get or set sampling parameters.")
1972
+ config_app.add_typer(sampling_app, name="sampling")
1973
+
1974
+
1975
+ def _print_sampling(sampling: dict[str, object]) -> None:
1976
+ if not sampling:
1977
+ console.print("[dim]Sampling: provider defaults (nothing set).[/dim]")
1978
+ return
1979
+ console.print(
1980
+ "Sampling: "
1981
+ + ", ".join(
1982
+ f"[cyan]{escape(str(key))}[/cyan]={escape(str(value))}"
1983
+ for key, value in sampling.items()
1984
+ )
1985
+ )
1986
+
1987
+
1988
+ @sampling_app.command("show")
1989
+ def sampling_show() -> None:
1990
+ """Show the configured sampling parameters."""
1991
+ _print_sampling(get_sampling())
1992
+
1993
+
1994
+ @sampling_app.command("set")
1995
+ def sampling_set(
1996
+ values: Annotated[
1997
+ list[str],
1998
+ typer.Argument(help="key=value pairs, e.g. temperature=0.2 max_tokens=4096"),
1999
+ ],
2000
+ ) -> None:
2001
+ """Set sampling parameters (temperature, top_p, max_tokens, reasoning_effort)."""
2002
+ updates: dict[str, str] = {}
2003
+ for item in values:
2004
+ if "=" not in item:
2005
+ console.print(f"[red]Expected key=value, got '{escape(str(item))}'.[/red]")
2006
+ raise typer.Exit(1)
2007
+ key, value = item.split("=", 1)
2008
+ updates[key.strip()] = value.strip()
2009
+ try:
2010
+ effective = set_sampling(updates)
2011
+ except ValueError as exc:
2012
+ console.print(f"[red]{escape(str(exc))}[/red]")
2013
+ raise typer.Exit(1)
2014
+ console.print(f"[green]{_check()} Sampling set:[/green] {escape(str(effective))}")
2015
+
2016
+
2017
+ @sampling_app.command("reset")
2018
+ def sampling_reset() -> None:
2019
+ """Reset sampling parameters to provider defaults."""
2020
+ reset_sampling()
2021
+ console.print(f"[green]{_check()} Sampling reset to provider defaults.[/green]")
2022
+
2023
+
2024
+ # --- canonical `config style ...` and `config prompt ...` -------------------
2025
+
2026
+ style_app = typer.Typer(help="Show or set the output style.")
2027
+ config_app.add_typer(style_app, name="style")
2028
+
2029
+
2030
+ @style_app.command("show")
2031
+ def style_show() -> None:
2032
+ """Show the current output style."""
2033
+ console.print(f"Output style: [cyan]{get_output_style()}[/cyan]")
2034
+
2035
+
2036
+ @style_app.command("set")
2037
+ def style_set(
2038
+ style: Annotated[str, typer.Argument(help="default, concise, explanatory, or code")],
2039
+ ) -> None:
2040
+ """Set the output style."""
2041
+ try:
2042
+ effective = set_output_style(style)
2043
+ except ValueError as exc:
2044
+ console.print(f"[red]{escape(str(exc))}[/red]")
2045
+ raise typer.Exit(1)
2046
+ console.print(f"[green]{_check()} Output style set to [cyan]{effective}[/cyan].[/green]")
2047
+
2048
+
2049
+ prompt_app = typer.Typer(help="Show, set, or clear a custom system prompt.")
2050
+ config_app.add_typer(prompt_app, name="prompt")
2051
+
2052
+
2053
+ @prompt_app.command("show")
2054
+ def prompt_show() -> None:
2055
+ """Show the custom system-prompt addition."""
2056
+ text = get_system_prompt()
2057
+ if text:
2058
+ console.print(escape(text))
2059
+ else:
2060
+ console.print("[dim]No custom system prompt set.[/dim]")
2061
+
2062
+
2063
+ @prompt_app.command("set")
2064
+ def prompt_set(
2065
+ text: Annotated[list[str], typer.Argument(help="Prompt text (quote it)")],
2066
+ ) -> None:
2067
+ """Set a custom system-prompt addition."""
2068
+ joined = " ".join(text).strip()
2069
+ if not joined:
2070
+ console.print("[red]Prompt text is required.[/red]")
2071
+ raise typer.Exit(1)
2072
+ set_system_prompt(joined)
2073
+ console.print(f"[green]{_check()} Custom system prompt saved.[/green]")
2074
+
2075
+
2076
+ @prompt_app.command("clear")
2077
+ def prompt_clear() -> None:
2078
+ """Clear the custom system-prompt addition."""
2079
+ set_system_prompt(None)
2080
+ console.print(f"[green]{_check()} Custom system prompt cleared.[/green]")
2081
+
2082
+
2083
+ # --- canonical `config ui ...` ----------------------------------------------
2084
+
2085
+ ui_app = typer.Typer(
2086
+ help="Themes, output modes, color control, and accessibility settings."
2087
+ )
2088
+ config_app.add_typer(ui_app, name="ui")
2089
+
2090
+
2091
+ @ui_app.command("show")
2092
+ def ui_show() -> None:
2093
+ """Show the theme, color, output, and ASCII settings."""
2094
+ _print_ui(get_ui())
2095
+
2096
+
2097
+ @ui_app.command("color")
2098
+ def ui_color(
2099
+ value: Annotated[str, typer.Argument(help="auto, always, or never")],
2100
+ ) -> None:
2101
+ """Control color output: auto, always, or never."""
2102
+ try:
2103
+ current = set_ui(color=value)
2104
+ except ValueError as exc:
2105
+ console.print(f"[red]{escape(str(exc))}[/red]")
2106
+ raise typer.Exit(1)
2107
+ console.print(
2108
+ f"[green]{_check()} Color mode set to [cyan]{current['color']}[/cyan].[/green]"
2109
+ )
2110
+
2111
+
2112
+ @ui_app.command("output")
2113
+ def ui_output(
2114
+ value: Annotated[str, typer.Argument(help="normal, compact, or verbose")],
2115
+ ) -> None:
2116
+ """Set how much the agent prints as tools run."""
2117
+ try:
2118
+ current = set_ui(output_mode=value)
2119
+ except ValueError as exc:
2120
+ console.print(f"[red]{escape(str(exc))}[/red]")
2121
+ raise typer.Exit(1)
2122
+ console.print(
2123
+ f"[green]{_check()} Output mode set to "
2124
+ f"[cyan]{current['output_mode']}[/cyan].[/green]"
2125
+ )
2126
+
2127
+
2128
+ @ui_app.command("ascii")
2129
+ def ui_ascii(
2130
+ state: Annotated[str, typer.Argument(help="'on' or 'off'")],
2131
+ ) -> None:
2132
+ """Enable or disable ASCII mode for screen readers and plain terminals."""
2133
+ token = state.strip().lower()
2134
+ if token in {"on", "true", "yes", "enable", "enabled"}:
2135
+ enabled = True
2136
+ elif token in {"off", "false", "no", "disable", "disabled"}:
2137
+ enabled = False
2138
+ else:
2139
+ console.print("[red]Expected 'on' or 'off'.[/red]")
2140
+ raise typer.Exit(1)
2141
+ set_ui(ascii=enabled)
2142
+ console.print(
2143
+ f"[green]{_check()} ASCII mode "
2144
+ f"{'on' if enabled else 'off'}.[/green]"
2145
+ )
2146
+
2147
+
2148
+ @ui_app.command("theme")
2149
+ def ui_theme(
2150
+ value: Annotated[str, typer.Argument(help="default, ocean, magenta, or mono")],
2151
+ ) -> None:
2152
+ """Set the visual theme used for accents and the banner."""
2153
+ try:
2154
+ current = set_ui(theme=value)
2155
+ except ValueError as exc:
2156
+ console.print(f"[red]{escape(str(exc))}[/red]")
2157
+ raise typer.Exit(1)
2158
+ console.print(
2159
+ f"[green]{_check()} Theme set to [cyan]{current['theme']}[/cyan].[/green]"
2160
+ )
2161
+
2162
+
2163
+ @ui_app.command("locale")
2164
+ def ui_locale(
2165
+ value: Annotated[str, typer.Argument(help="en, de, or es")],
2166
+ ) -> None:
2167
+ """Set the interface language for translated strings."""
2168
+ try:
2169
+ current = set_ui(locale=value)
2170
+ except ValueError as exc:
2171
+ console.print(f"[red]{escape(str(exc))}[/red]")
2172
+ raise typer.Exit(1)
2173
+ from kiwimatecoder.i18n import set_locale
2174
+
2175
+ set_locale(current["locale"])
2176
+ console.print(
2177
+ f"[green]{_check()} Locale set to [cyan]{current['locale']}[/cyan].[/green]"
2178
+ )
2179
+
2180
+
2181
+ @ui_app.command("keybindings")
2182
+ def ui_keybindings(
2183
+ value: Annotated[str, typer.Argument(help="emacs or vim")],
2184
+ ) -> None:
2185
+ """Use Emacs (default) or Vim editing keys in the prompt."""
2186
+ try:
2187
+ current = set_ui(keybindings=value)
2188
+ except ValueError as exc:
2189
+ console.print(f"[red]{escape(str(exc))}[/red]")
2190
+ raise typer.Exit(1)
2191
+ console.print(
2192
+ f"[green]{_check()} Keybindings set to "
2193
+ f"[cyan]{current['keybindings']}[/cyan].[/green]"
2194
+ )
2195
+
2196
+
2197
+ @ui_app.command("notify")
2198
+ def ui_notify(
2199
+ value: Annotated[str, typer.Argument(help="off, bell, or desktop")],
2200
+ ) -> None:
2201
+ """Notify when a turn finishes: off, terminal bell, or desktop."""
2202
+ try:
2203
+ current = set_ui(notify=value)
2204
+ except ValueError as exc:
2205
+ console.print(f"[red]{escape(str(exc))}[/red]")
2206
+ raise typer.Exit(1)
2207
+ console.print(
2208
+ f"[green]{_check()} Notify mode set to "
2209
+ f"[cyan]{current['notify']}[/cyan].[/green]"
2210
+ )
2211
+
2212
+
2213
+ @ui_app.command("notify-after")
2214
+ def ui_notify_after(
2215
+ seconds: Annotated[str, typer.Argument(help="Only notify after this many seconds")],
2216
+ ) -> None:
2217
+ """Only notify for turns that take at least this many seconds (0 = always)."""
2218
+ try:
2219
+ current = set_ui(notify_after_seconds=seconds)
2220
+ except ValueError as exc:
2221
+ console.print(f"[red]{escape(str(exc))}[/red]")
2222
+ raise typer.Exit(1)
2223
+ console.print(
2224
+ f"[green]{_check()} Notify threshold set to "
2225
+ f"[cyan]{current['notify_after_seconds']}s[/cyan].[/green]"
2226
+ )
2227
+
2228
+
2229
+ @ui_app.command("spinner")
2230
+ def ui_spinner(
2231
+ value: Annotated[str, typer.Argument(help="auto, on, or off")],
2232
+ ) -> None:
2233
+ """Control animated status spinners: auto, on, or off."""
2234
+ try:
2235
+ current = set_ui(spinner=value)
2236
+ except ValueError as exc:
2237
+ console.print(f"[red]{escape(str(exc))}[/red]")
2238
+ raise typer.Exit(1)
2239
+ console.print(
2240
+ f"[green]{_check()} Spinner set to "
2241
+ f"[cyan]{current['spinner']}[/cyan].[/green]"
2242
+ )
2243
+
2244
+
2245
+ # --- canonical `config profile ...` -----------------------------------------
2246
+
2247
+ profile_app = typer.Typer(help="Save, apply, or remove named configuration profiles.")
2248
+ config_app.add_typer(profile_app, name="profile")
2249
+
2250
+
2251
+ @profile_app.command("list")
2252
+ def profile_list() -> None:
2253
+ """List saved configuration profiles."""
2254
+ profiles = get_profiles()
2255
+ if not profiles:
2256
+ console.print(
2257
+ "[dim]No profiles saved. Capture one with "
2258
+ "`config profile save <name>`.[/dim]"
2259
+ )
2260
+ return
2261
+ table = Table(title="Profiles", show_header=True)
2262
+ table.add_column("name", style="cyan")
2263
+ table.add_column("provider")
2264
+ table.add_column("model")
2265
+ table.add_column("mode")
2266
+ for name in sorted(profiles):
2267
+ values = profiles[name]
2268
+ table.add_row(
2269
+ escape(str(name)),
2270
+ escape(str(str(values.get("provider") or ""))),
2271
+ escape(str(str(values.get("model") or "(provider's chosen model)"))),
2272
+ escape(str(str(values.get("mode") or ""))),
2273
+ )
2274
+ console.print(table)
2275
+
2276
+
2277
+ @profile_app.command("show")
2278
+ def profile_show(name: Annotated[str, typer.Argument(help="Profile name")]) -> None:
2279
+ """Show one profile's settings."""
2280
+ profile = get_profile(name)
2281
+ if profile is None:
2282
+ console.print(f"[red]{escape(str(t('error.unknown_profile', name=name)))}[/red]")
2283
+ raise typer.Exit(1)
2284
+ console.print(f"[bold]{escape(str(name))}[/bold]")
2285
+ console.print_json(data=profile)
2286
+
2287
+
2288
+ @profile_app.command("save")
2289
+ def profile_save_cmd(
2290
+ name: Annotated[str, typer.Argument(help="Profile name")],
2291
+ ) -> None:
2292
+ """Capture the current effective settings under ``name``."""
2293
+ try:
2294
+ profile = save_profile(name)
2295
+ except (ValueError, KeyError) as exc:
2296
+ console.print(f"[red]{escape(str(exc))}[/red]")
2297
+ raise typer.Exit(1)
2298
+ console.print(
2299
+ f"[green]{_check()} Saved profile [cyan]{escape(str(name))}[/cyan][/green] "
2300
+ f"({len(profile)} setting(s))."
2301
+ )
2302
+
2303
+
2304
+ @profile_app.command("use")
2305
+ def profile_use(name: Annotated[str, typer.Argument(help="Profile name")]) -> None:
2306
+ """Apply a profile's settings to the global config."""
2307
+ try:
2308
+ apply_profile(name)
2309
+ except (ValueError, KeyError) as exc:
2310
+ console.print(f"[red]{escape(str(exc))}[/red]")
2311
+ raise typer.Exit(1)
2312
+ cfg = load_config()
2313
+ provider_id = get_selected_provider_id(cfg)
2314
+ console.print(
2315
+ f"[green]{_check()} Applied profile [cyan]{escape(str(name))}[/cyan][/green] — "
2316
+ f"provider: [cyan]{escape(str(provider_id))}[/cyan], "
2317
+ f"model: [cyan]{escape(str(get_provider_model(provider_id, cfg) or '(none chosen)'))}[/cyan], "
2318
+ f"mode: [cyan]{escape(get_default_mode(cfg))}[/cyan]."
2319
+ )
2320
+
2321
+
2322
+ @profile_app.command("remove")
2323
+ def profile_remove(name: Annotated[str, typer.Argument(help="Profile name")]) -> None:
2324
+ """Remove a saved profile."""
2325
+ if remove_profile(name):
2326
+ console.print(f"[green]{_check()} Removed profile {escape(str(name))}.[/green]")
2327
+ else:
2328
+ console.print(f"[dim]No profile named {escape(str(name))}.[/dim]")
2329
+
2330
+
2331
+ @profile_app.command("rename")
2332
+ def profile_rename(
2333
+ old: Annotated[str, typer.Argument(help="Current name")],
2334
+ new: Annotated[str, typer.Argument(help="New name")],
2335
+ ) -> None:
2336
+ """Rename a saved profile."""
2337
+ try:
2338
+ renamed = rename_profile(old, new)
2339
+ except ValueError as exc:
2340
+ console.print(f"[red]{escape(str(exc))}[/red]")
2341
+ raise typer.Exit(1)
2342
+ if renamed:
2343
+ console.print(f"[green]{_check()} Renamed profile {escape(str(old))} to {escape(str(new))}.[/green]")
2344
+ else:
2345
+ console.print(f"[dim]No profile named {escape(str(old))}.[/dim]")
2346
+
2347
+
2348
+ @config_app.command("validate")
2349
+ def validate_cmd() -> None:
2350
+ """Validate the stored configuration; exit 1 when errors are found."""
2351
+ issues = validate_config()
2352
+ if not issues:
2353
+ console.print(f"[green]{_check()} Configuration is valid.[/green]")
2354
+ return
2355
+ table = Table(title="Configuration issues", show_header=True)
2356
+ table.add_column("level", style="cyan")
2357
+ table.add_column("key")
2358
+ table.add_column("message")
2359
+ for issue in issues:
2360
+ color = "red" if issue["level"] == "error" else "yellow"
2361
+ table.add_row(
2362
+ f"[{color}]{issue['level']}[/{color}]", escape(str(issue["key"])), escape(str(issue["message"]))
2363
+ )
2364
+ console.print(table)
2365
+ errors = sum(1 for issue in issues if issue["level"] == "error")
2366
+ warnings = len(issues) - errors
2367
+ console.print(f"[dim]{errors} error(s), {escape(str(warnings))} warning(s).[/dim]")
2368
+ if errors:
2369
+ raise typer.Exit(1)
2370
+
2371
+
2372
+ # --- `config show` ----------------------------------------------------------
2373
+
2374
+
2375
+ @config_app.command("show")
2376
+ def config_show() -> None:
2377
+ """Show the active providers, model, key, and model filter."""
2378
+ cfg = load_config()
2379
+ provider_id = get_selected_provider_id(cfg)
2380
+ provider = get_provider_config(provider_id, cfg)
2381
+ active_ids = get_active_provider_ids(cfg)
2382
+ active_line = ", ".join(
2383
+ f"{pid}" + (" (primary)" if pid == active_ids[0] else "") for pid in active_ids
2384
+ )
2385
+ console.print(
2386
+ f"Provider: [cyan]{escape(str(provider.id))}[/cyan] ({escape(str(provider.name))})\n"
2387
+ + f"Active providers: [cyan]{escape(str(active_line))}[/cyan]\n"
2388
+ + "Model: [cyan]"
2389
+ + escape(
2390
+ get_provider_model(provider_id, cfg)
2391
+ or ("(from server)" if provider.is_local else "(none chosen)")
2392
+ )
2393
+ + "[/cyan]\n"
2394
+ + f"Mode: [cyan]{escape(get_default_mode(cfg))}[/cyan]\n"
2395
+ + f"Key: [cyan]{escape(str(describe_key(provider_id)))}[/cyan] ({escape(str(provider.key_env))})\n"
2396
+ + f"Model visibility: [cyan]{get_model_filter(provider_id)['mode']}[/cyan]"
2397
+ )
2398
+ sampling = get_sampling(cfg)
2399
+ sampling_line = (
2400
+ ", ".join(f"{key}={value}" for key, value in sampling.items())
2401
+ or "provider defaults"
2402
+ )
2403
+ subagents = get_subagents(cfg)
2404
+ console.print(
2405
+ f"Output style: [cyan]{get_output_style(cfg)}[/cyan] "
2406
+ + f"(custom prompt: {'set' if get_system_prompt(cfg) else 'none'})\n"
2407
+ + f"Sampling: [cyan]{escape(str(sampling_line))}[/cyan]\n"
2408
+ + "Always-allowed tools: "
2409
+ + f"[cyan]{escape(str(', '.join(get_always_allowed_tools(cfg)) or 'none'))}[/cyan]\n"
2410
+ + f"Trusted workspace: [cyan]{'on' if get_trusted_workspace(cfg) else 'off'}[/cyan]\n"
2411
+ + f"Auto-verify: [cyan]{escape(str(get_verify_command(cfg) or 'off'))}[/cyan]\n"
2412
+ + "Budget: "
2413
+ + (
2414
+ f"[cyan]{get_budget(cfg)}[/cyan]"
2415
+ if get_budget(cfg)
2416
+ else "[cyan]none[/cyan]"
2417
+ )
2418
+ + "\nPrompt caching: "
2419
+ + f"[cyan]{'on' if get_prompt_cache(cfg) else 'off'}[/cyan]"
2420
+ + "\nSubagents: "
2421
+ + f"[cyan]{'on' if subagents['enabled'] else 'off'}[/cyan] "
2422
+ + f"(max steps {subagents['max_steps']})"
2423
+ )
2424
+ ui_config = get_ui(cfg)
2425
+ console.print(
2426
+ "UI: "
2427
+ + f"[cyan]{escape(str(ui_config['theme']))}[/cyan] theme, "
2428
+ + f"[cyan]{escape(str(ui_config['color']))}[/cyan] color, "
2429
+ + f"[cyan]{escape(str(ui_config['output_mode']))}[/cyan] output, "
2430
+ + f"ascii [cyan]{'on' if ui_config['ascii'] else 'off'}[/cyan], "
2431
+ + f"locale [cyan]{escape(str(ui_config['locale']))}[/cyan], "
2432
+ + f"keys [cyan]{escape(str(ui_config['keybindings']))}[/cyan], "
2433
+ + f"notify [cyan]{escape(str(ui_config['notify']))}[/cyan], "
2434
+ + f"spinner [cyan]{escape(str(ui_config['spinner']))}[/cyan]"
2435
+ )
2436
+ network_config = get_network(cfg)
2437
+ console.print(
2438
+ "Network: proxy "
2439
+ + f"[cyan]{escape(str(network_config['proxy'] or 'none'))}[/cyan], "
2440
+ + f"CA [cyan]{escape(str(network_config['ca_bundle'] or 'system'))}[/cyan], "
2441
+ + f"offline [cyan]{'on' if network_config['offline'] else 'off'}[/cyan]"
2442
+ )
2443
+ remote_config = get_remote(cfg)
2444
+ console.print(
2445
+ "Remote: "
2446
+ + f"[cyan]{'on' if remote_config['enabled'] else 'off'}[/cyan] "
2447
+ + f"(host [cyan]{escape(_remote_address(remote_config))}[/cyan], "
2448
+ + f"devcontainer [cyan]{escape(remote_config['devcontainer'])}[/cyan])"
2449
+ )
2450
+ media_config = get_media(cfg)
2451
+ console.print(
2452
+ "Media: "
2453
+ + f"[cyan]{'on' if media_config['enabled'] else 'off'}[/cyan] "
2454
+ + f"(provider [cyan]{escape(str(media_config['provider']))}[/cyan], "
2455
+ + f"image model [cyan]{escape(str(media_config['model'] or 'none chosen'))}[/cyan], "
2456
+ + f"size [cyan]{media_config['size']}[/cyan], "
2457
+ + f"video model [cyan]{escape(str(media_config['video_model'] or 'none chosen'))}[/cyan], "
2458
+ + f"output [cyan]{escape(str(media_config['output_dir']))}[/cyan])"
2459
+ )
2460
+ telemetry_config = get_telemetry(cfg)
2461
+ console.print(
2462
+ "Telemetry: "
2463
+ + f"[cyan]{'on' if telemetry_config['enabled'] else 'off'}[/cyan] "
2464
+ + f"(level [cyan]{telemetry_config['level']}[/cyan])"
2465
+ )
2466
+ project_path = project_config_path()
2467
+ if project_path is not None:
2468
+ console.print(f"Project config: [cyan]{escape(str(project_path))}[/cyan] (overrides global)")
2469
+
2470
+
2471
+ def _print_models(provider: str | None, *, refresh: bool) -> None:
2472
+ cfg = load_config()
2473
+ provider_id = provider or get_selected_provider_id(cfg)
2474
+ try:
2475
+ provider_cfg = get_provider_config(provider_id, cfg)
2476
+ except KeyError as exc:
2477
+ console.print(f"[red]{escape(str(exc))}[/red]")
2478
+ raise typer.Exit(1)
2479
+
2480
+ catalog = get_model_catalog(provider_id, force=refresh, cfg=cfg)
2481
+ if catalog.error:
2482
+ console.print(f"[yellow]Could not refresh models: {escape(str(catalog.error))}[/yellow]")
2483
+ if catalog.added:
2484
+ console.print(f"[green]New:[/green] {escape(str(summarize_ids(catalog.added)))}")
2485
+ if catalog.removed:
2486
+ console.print(
2487
+ f"[yellow]Deprecated, removed:[/yellow] {escape(str(summarize_ids(catalog.removed)))}"
2488
+ )
2489
+
2490
+ source = {
2491
+ "live": "live from provider",
2492
+ "cache": "cached",
2493
+ "curated": "built-in list",
2494
+ }[catalog.source]
2495
+ console.print(f"[cyan]{escape(str(provider_cfg.name))}[/cyan] models ({source}):")
2496
+ for model in apply_model_filter(provider_id, catalog.models):
2497
+ console.print(f" {escape(str(model))}")
2498
+
2499
+
2500
+ def _set_filter(provider: str | None, mode: str, models: list[str]) -> None:
2501
+ pid = provider or get_selected_provider_id()
2502
+ _resolve_provider(pid)
2503
+ try:
2504
+ set_model_filter(pid, mode, models)
2505
+ except ValueError as exc:
2506
+ console.print(f"[red]{escape(str(exc))}[/red]")
2507
+ raise typer.Exit(1)
2508
+ verb = "showing" if mode == "allow" else "hiding"
2509
+ console.print(
2510
+ f"[green]{_check()} Now {escape(str(verb))} these models for {escape(str(pid))}:[/green] "
2511
+ + escape(", ".join(models))
2512
+ )
2513
+
2514
+
2515
+ # --- `config media ...` -----------------------------------------------------
2516
+
2517
+
2518
+ def _print_media(settings: dict[str, Any]) -> None:
2519
+ from kiwimatecoder import media as media_module
2520
+
2521
+ console.print(
2522
+ f"Media: [cyan]{'on' if settings['enabled'] else 'off'}[/cyan]\n"
2523
+ f"Provider: [cyan]{escape(str(settings['provider']))}[/cyan]\n"
2524
+ f"Image model: [cyan]{escape(str(settings['model'] or 'none chosen'))}[/cyan]\n"
2525
+ f"Image size: [cyan]{settings['size']}[/cyan]\n"
2526
+ f"Video model: [cyan]{escape(settings['video_model'] or 'none chosen')}[/cyan]\n"
2527
+ f"Video length: [cyan]{settings['video_duration']}s[/cyan]\n"
2528
+ f"Video size: [cyan]{escape(settings['video_size'] or 'provider default')}[/cyan]\n"
2529
+ f"Output dir: [cyan]{escape(str(settings['output_dir']))}[/cyan]"
2530
+ )
2531
+ for line in media_module.format_models(settings):
2532
+ console.print(escape(line))
2533
+
2534
+
2535
+ @media_app.command("show")
2536
+ def media_show() -> None:
2537
+ """Show media settings and readiness."""
2538
+ from kiwimatecoder import media as media_module
2539
+
2540
+ _print_media(get_media())
2541
+ for title, video in (("Images", False), ("Video", True)):
2542
+ problems = media_module.readiness(video=video)
2543
+ if problems:
2544
+ console.print(f"[red]{_cross()} {title}:[/red] {escape(problems[0])}")
2545
+ else:
2546
+ console.print(f"[green]{_check()} {title}: ready[/green]")
2547
+
2548
+
2549
+ @media_app.command("enable")
2550
+ def media_enable(
2551
+ mode: Annotated[str, typer.Argument(help="on or off")] = "on",
2552
+ ) -> None:
2553
+ """Enable or disable image generation (on|off)."""
2554
+ token = mode.strip().lower()
2555
+ if token in {"on", "true", "yes", "enable", "enabled"}:
2556
+ enabled = True
2557
+ elif token in {"off", "false", "no", "disable", "disabled"}:
2558
+ enabled = False
2559
+ else:
2560
+ console.print("[red]Usage: config media enable <on|off>[/red]")
2561
+ raise typer.Exit(1)
2562
+ try:
2563
+ settings = set_media(enabled=enabled)
2564
+ except ValueError as exc:
2565
+ console.print(f"[red]{escape(str(exc))}[/red]")
2566
+ raise typer.Exit(1)
2567
+ console.print(
2568
+ f"[green]{_check()} Image generation "
2569
+ f"{'enabled' if settings['enabled'] else 'disabled'}.[/green]"
2570
+ )
2571
+
2572
+
2573
+ @media_app.command("model")
2574
+ def media_model(
2575
+ model: Annotated[str, typer.Argument(help="Image model id, or 'none' to clear")],
2576
+ provider: Annotated[
2577
+ str | None, typer.Option("--provider", help="Provider id (default: the media provider)")
2578
+ ] = None,
2579
+ ) -> None:
2580
+ """Set or clear the image model for a provider.
2581
+
2582
+ The choice is remembered per provider; 'none' (or clear, default, -)
2583
+ forgets it.
2584
+ """
2585
+ cleared = model.strip().lower() in {"none", "clear", "default", "-"}
2586
+ try:
2587
+ provider_id = provider or get_media()["provider"]
2588
+ set_media_model(provider_id, "image", "" if cleared else model)
2589
+ except ValueError as exc:
2590
+ console.print(f"[red]{escape(str(exc))}[/red]")
2591
+ raise typer.Exit(1)
2592
+ if cleared:
2593
+ console.print(
2594
+ f"[green]{_check()} Image model for {escape(provider_id)} cleared.[/green]"
2595
+ )
2596
+ else:
2597
+ console.print(
2598
+ f"[green]{_check()} Image model for {escape(provider_id)} set to[/green] "
2599
+ f"[cyan]{escape(str(model))}[/cyan]."
2600
+ )
2601
+
2602
+
2603
+ @media_app.command("provider")
2604
+ def media_provider(
2605
+ provider: Annotated[str, typer.Argument(help="Provider id")],
2606
+ ) -> None:
2607
+ """Set the media provider for images and video."""
2608
+ try:
2609
+ settings = set_media(provider=provider)
2610
+ except (ValueError, KeyError) as exc:
2611
+ console.print(f"[red]{escape(str(exc))}[/red]")
2612
+ raise typer.Exit(1)
2613
+ provider_id = str(settings["provider"])
2614
+ console.print(
2615
+ f"[green]{_check()} Media provider set to[/green] [cyan]{escape(provider_id)}[/cyan]."
2616
+ )
2617
+ for kind, command in (("image", "model"), ("video", "video-model")):
2618
+ if not settings["models"].get(provider_id, {}).get(kind):
2619
+ console.print(
2620
+ f"[yellow]No {kind} model chosen for {escape(provider_id)} — choose "
2621
+ f"one with /{kind} model, or `config media {command} <id>`.[/yellow]"
2622
+ )
2623
+
2624
+
2625
+ @media_app.command("size")
2626
+ def media_size(size: Annotated[str, typer.Argument(help="Image size as WxH")]) -> None:
2627
+ """Set the requested image size (WxH)."""
2628
+ try:
2629
+ set_media(size=size)
2630
+ except ValueError as exc:
2631
+ console.print(f"[red]{escape(str(exc))}[/red]")
2632
+ raise typer.Exit(1)
2633
+ console.print(
2634
+ f"[green]{_check()} Media size set to[/green] [cyan]{size}[/cyan]."
2635
+ )
2636
+
2637
+
2638
+ @media_app.command("video-model")
2639
+ def media_video_model(
2640
+ model: Annotated[str, typer.Argument(help="Video model id, or 'none' to clear")],
2641
+ provider: Annotated[
2642
+ str | None, typer.Option("--provider", help="Provider id (default: the media provider)")
2643
+ ] = None,
2644
+ ) -> None:
2645
+ """Set or clear the video model for a provider.
2646
+
2647
+ The choice is remembered per provider; 'none' (or clear, default, -)
2648
+ forgets it.
2649
+ """
2650
+ cleared = model.strip().lower() in {"none", "clear", "default", "-"}
2651
+ try:
2652
+ provider_id = provider or get_media()["provider"]
2653
+ set_media_model(provider_id, "video", "" if cleared else model)
2654
+ except ValueError as exc:
2655
+ console.print(f"[red]{escape(str(exc))}[/red]")
2656
+ raise typer.Exit(1)
2657
+ if cleared:
2658
+ console.print(
2659
+ f"[green]{_check()} Video model for {escape(provider_id)} cleared.[/green]"
2660
+ )
2661
+ else:
2662
+ console.print(
2663
+ f"[green]{_check()} Video model for {escape(provider_id)} set to[/green] "
2664
+ f"[cyan]{escape(model)}[/cyan]."
2665
+ )
2666
+
2667
+
2668
+ @media_app.command("duration")
2669
+ def media_duration(
2670
+ seconds: Annotated[str, typer.Argument(help="Video length in whole seconds")],
2671
+ ) -> None:
2672
+ """Set the default video length (seconds)."""
2673
+ try:
2674
+ settings = set_media(video_duration=seconds)
2675
+ except ValueError as exc:
2676
+ console.print(f"[red]{escape(str(exc))}[/red]")
2677
+ raise typer.Exit(1)
2678
+ console.print(
2679
+ f"[green]{_check()} Video length set to[/green] "
2680
+ f"[cyan]{settings['video_duration']}s[/cyan]."
2681
+ )
2682
+
2683
+
2684
+ @media_app.command("video-size")
2685
+ def media_video_size(
2686
+ size: Annotated[str, typer.Argument(help="WxH or a tier like 720p; 'default' clears")],
2687
+ ) -> None:
2688
+ """Set the default video size."""
2689
+ cleared = size.strip().lower() in {"none", "clear", "default", "-"}
2690
+ try:
2691
+ settings = set_media(video_size="" if cleared else size)
2692
+ except ValueError as exc:
2693
+ console.print(f"[red]{escape(str(exc))}[/red]")
2694
+ raise typer.Exit(1)
2695
+ console.print(
2696
+ f"[green]{_check()} Video size set to[/green] "
2697
+ f"[cyan]{escape(settings['video_size'] or 'provider default')}[/cyan]."
2698
+ )
2699
+
2700
+
2701
+ @media_app.command("output-dir")
2702
+ def media_output_dir(
2703
+ path: Annotated[str, typer.Argument(help="Directory inside the workspace")],
2704
+ ) -> None:
2705
+ """Set the media output directory."""
2706
+ try:
2707
+ settings = set_media(output_dir=path)
2708
+ except ValueError as exc:
2709
+ console.print(f"[red]{escape(str(exc))}[/red]")
2710
+ raise typer.Exit(1)
2711
+ console.print(
2712
+ f"[green]{_check()} Media output directory set to[/green] "
2713
+ f"[cyan]{escape(str(settings['output_dir']))}[/cyan]."
2714
+ )
2715
+
2716
+
2717
+ # --- `config telemetry ...` -------------------------------------------------
2718
+
2719
+
2720
+ def _print_telemetry(settings: dict[str, Any]) -> None:
2721
+ from kiwimatecoder import telemetry
2722
+
2723
+ console.print(
2724
+ f"Telemetry: [cyan]{'on' if settings['enabled'] else 'off'}[/cyan]\n"
2725
+ f"Level: [cyan]{settings['level']}[/cyan]\n"
2726
+ f"Log file: [cyan]{escape(str(telemetry.current_log_path()))}[/cyan]\n"
2727
+ f"Max log bytes: [cyan]{settings['max_log_bytes']:,}[/cyan]\n"
2728
+ f"Crash reports: [cyan]{len(telemetry.crash_report_paths())}[/cyan]"
2729
+ )
2730
+
2731
+
2732
+ @telemetry_app.command("show")
2733
+ def telemetry_show() -> None:
2734
+ """Show telemetry settings, log path, and crash-report count."""
2735
+ _print_telemetry(get_telemetry())
2736
+
2737
+
2738
+ @telemetry_app.command("enable")
2739
+ def telemetry_enable(
2740
+ mode: Annotated[str, typer.Argument(help="on or off")] = "on",
2741
+ ) -> None:
2742
+ """Enable or disable telemetry (on|off)."""
2743
+ token = mode.strip().lower()
2744
+ if token in {"on", "true", "yes", "enable", "enabled"}:
2745
+ enabled = True
2746
+ elif token in {"off", "false", "no", "disable", "disabled"}:
2747
+ enabled = False
2748
+ else:
2749
+ console.print("[red]Usage: config telemetry enable <on|off>[/red]")
2750
+ raise typer.Exit(1)
2751
+ try:
2752
+ settings = set_telemetry(enabled=enabled)
2753
+ except ValueError as exc:
2754
+ console.print(f"[red]{escape(str(exc))}[/red]")
2755
+ raise typer.Exit(1)
2756
+ console.print(
2757
+ f"[green]{_check()} Telemetry "
2758
+ f"{'enabled' if settings['enabled'] else 'disabled'}.[/green]"
2759
+ )
2760
+ if settings["enabled"] and settings["level"] == "off":
2761
+ console.print(
2762
+ "[dim]Set a level with `config telemetry level error|info|debug` "
2763
+ "to start recording.[/dim]"
2764
+ )
2765
+
2766
+
2767
+ @telemetry_app.command("level")
2768
+ def telemetry_level(
2769
+ level: Annotated[str, typer.Argument(help="off, error, info, or debug")],
2770
+ ) -> None:
2771
+ """Set the telemetry level (off|error|info|debug)."""
2772
+ try:
2773
+ settings = set_telemetry(level=level)
2774
+ except ValueError as exc:
2775
+ console.print(f"[red]{escape(str(exc))}[/red]")
2776
+ raise typer.Exit(1)
2777
+ console.print(
2778
+ f"[green]{_check()} Telemetry level set to[/green] "
2779
+ f"[cyan]{settings['level']}[/cyan]."
2780
+ )
2781
+
2782
+
2783
+ # --- legacy aliases (kept for backward compatibility) -----------------------
2784
+
2785
+
2786
+ @config_app.command("set-key", hidden=True, deprecated=True)
2787
+ def set_key_cmd(
2788
+ key: Annotated[str, typer.Argument(help="Your API key")],
2789
+ provider: Annotated[
2790
+ str,
2791
+ typer.Option("--provider", "-p", help="Provider id this key belongs to"),
2792
+ ] = "openrouter",
2793
+ ) -> None:
2794
+ """Deprecated: use `config key set <provider> <key>`."""
2795
+ key_set(provider, key)
2796
+
2797
+
2798
+ @config_app.command("set-provider", hidden=True, deprecated=True)
2799
+ def set_provider_cmd(
2800
+ provider: Annotated[str, typer.Argument(help="Provider id")]
2801
+ ) -> None:
2802
+ """Deprecated: use `config provider use <id>`."""
2803
+ provider_use(provider)
2804
+
2805
+
2806
+ @config_app.command("set-model", hidden=True, deprecated=True)
2807
+ def set_model_cmd(model: Annotated[str, typer.Argument(help="Model id")]) -> None:
2808
+ """Deprecated: use `config model set <id>`."""
2809
+ model_set(model)
2810
+
2811
+
2812
+ @config_app.command("check", hidden=True, deprecated=True)
2813
+ def check() -> None:
2814
+ """Deprecated: use `config show`."""
2815
+ config_show()
2816
+
2817
+
2818
+ @config_app.command("list", hidden=True, deprecated=True)
2819
+ def list_cmd() -> None:
2820
+ """Deprecated: use `config provider list`."""
2821
+ provider_list()
2822
+
2823
+
2824
+ HEADLESS_OUTPUT_FORMATS = ("text", "json", "stream-json")
2825
+
2826
+
2827
+ def _headless_error(message: str) -> None:
2828
+ sys.stderr.write(f"error: {message}\n")
2829
+
2830
+
2831
+ def _write_headless_result(output_format: str, payload: dict[str, Any]) -> None:
2832
+ """Write the final record for json/stream-json output (a newline for text)."""
2833
+ if output_format == "json":
2834
+ sys.stdout.write(json.dumps(payload, separators=(",", ":"), default=str) + "\n")
2835
+ elif output_format == "stream-json":
2836
+ record: dict[str, Any] = {"type": "result"}
2837
+ record.update(payload)
2838
+ sys.stdout.write(json.dumps(record, separators=(",", ":"), default=str) + "\n")
2839
+ else:
2840
+ sys.stdout.write("\n")
2841
+
2842
+
2843
+ def _headless_model_missing(
2844
+ exc: ModelNotChosenError, output_format: str, provider_id: str, mode: str
2845
+ ) -> int:
2846
+ """Report a missing model like other runtime failures: exit 1 with a record."""
2847
+ _headless_error(str(exc))
2848
+ if output_format != "text":
2849
+ _write_headless_result(
2850
+ output_format,
2851
+ {
2852
+ "result": "",
2853
+ "usage": {"prompt_tokens": 0, "completion_tokens": 0},
2854
+ "cost_usd": 0.0,
2855
+ "provider": provider_id,
2856
+ "model": None,
2857
+ "mode": mode,
2858
+ "tools_used": [],
2859
+ "messages": 0,
2860
+ "success": False,
2861
+ },
2862
+ )
2863
+ return 1
2864
+
2865
+
2866
+ def _run_headless(
2867
+ prompt: str,
2868
+ *,
2869
+ output_format: str,
2870
+ mode: str | None,
2871
+ yes: bool,
2872
+ max_turns: int,
2873
+ provider: str | None,
2874
+ model: str | None,
2875
+ workspace: Path | None,
2876
+ quiet: bool,
2877
+ ) -> int:
2878
+ """Run one headless agent turn; returns the process exit code (0/1/2)."""
2879
+ if output_format not in HEADLESS_OUTPUT_FORMATS:
2880
+ _headless_error(
2881
+ f"Invalid --output-format '{output_format}'. "
2882
+ "Choose: text, json, stream-json."
2883
+ )
2884
+ return 2
2885
+ if max_turns < 1:
2886
+ _headless_error("--max-turns must be at least 1.")
2887
+ return 2
2888
+ if not prompt.strip():
2889
+ _headless_error("Prompt is empty.")
2890
+ return 2
2891
+ try:
2892
+ resolved_mode: PermissionMode | None = (
2893
+ PermissionMode.from_str(mode) if mode is not None else None
2894
+ )
2895
+ except ValueError as exc:
2896
+ _headless_error(str(exc))
2897
+ return 2
2898
+ if resolved_mode is None and yes:
2899
+ # --yes is shorthand for auto-accept when --mode is not explicit.
2900
+ resolved_mode = PermissionMode.AUTO
2901
+
2902
+ if provider is not None:
2903
+ try:
2904
+ get_provider_config(provider)
2905
+ except KeyError:
2906
+ _headless_error(f"Unknown provider '{provider}'.")
2907
+ return 2
2908
+
2909
+ try:
2910
+ workspace_root = (workspace or Path.cwd()).expanduser()
2911
+ except (OSError, RuntimeError) as exc:
2912
+ _headless_error(str(exc))
2913
+ return 2
2914
+ if not workspace_root.is_dir():
2915
+ _headless_error(f"Workspace is not a directory: {workspace_root}")
2916
+ return 2
2917
+
2918
+ # There are no default models. After the usage checks (exit 2), fail fast
2919
+ # (exit 1, like a missing key) when no model was passed or chosen.
2920
+ cfg = load_config()
2921
+ provider_id = provider or get_selected_provider_id(cfg)
2922
+ run_mode = resolved_mode.value if resolved_mode is not None else get_default_mode(cfg)
2923
+ try:
2924
+ require_model(get_provider_config(provider_id, cfg), cfg, override=model)
2925
+ except ModelNotChosenError as exc:
2926
+ return _headless_model_missing(exc, output_format, provider_id, run_mode)
2927
+
2928
+ from kiwimatecoder import headless
2929
+
2930
+ stderr_console = Console(file=sys.stderr, no_color=True, highlight=False)
2931
+ on_event: Callable[[str, dict[str, Any]], None] | None = None
2932
+ if output_format == "text":
2933
+ console = stderr_console
2934
+
2935
+ def render_text(name: str, payload: dict[str, Any]) -> None:
2936
+ if name == "text_delta":
2937
+ sys.stdout.write(str(payload.get("text") or ""))
2938
+ sys.stdout.flush()
2939
+
2940
+ on_event = render_text
2941
+
2942
+ elif output_format == "stream-json":
2943
+ console = None
2944
+
2945
+ def render_stream_json(name: str, payload: dict[str, Any]) -> None:
2946
+ record: dict[str, Any] = {"type": name}
2947
+ record.update(payload)
2948
+ sys.stdout.write(
2949
+ json.dumps(record, separators=(",", ":"), default=str) + "\n"
2950
+ )
2951
+ sys.stdout.flush()
2952
+
2953
+ on_event = render_stream_json
2954
+
2955
+ else:
2956
+ console = None
2957
+
2958
+ def deny(summary: str, preview: str | None) -> bool:
2959
+ """Deny approvals that cannot be prompted for, with a visible note."""
2960
+ sys.stderr.write(
2961
+ f"denied (headless mode has no approval prompt; pass --yes to "
2962
+ f"allow): {summary}\n"
2963
+ )
2964
+ return False
2965
+
2966
+ try:
2967
+ outcome = asyncio.run(
2968
+ headless.run_agent_once(
2969
+ prompt,
2970
+ workspace=workspace_root,
2971
+ provider=provider,
2972
+ model=model,
2973
+ mode=resolved_mode,
2974
+ confirm=deny,
2975
+ on_event=on_event,
2976
+ max_turns=max_turns,
2977
+ console=console,
2978
+ render_text=False,
2979
+ output_mode="compact" if quiet and output_format == "text" else None,
2980
+ )
2981
+ )
2982
+ except ModelNotChosenError as exc:
2983
+ return _headless_model_missing(exc, output_format, provider_id, run_mode)
2984
+
2985
+ payload = {
2986
+ "result": outcome.text,
2987
+ "usage": outcome.usage,
2988
+ "cost_usd": outcome.cost_usd,
2989
+ "provider": outcome.provider,
2990
+ "model": outcome.model,
2991
+ "mode": outcome.mode,
2992
+ "tools_used": outcome.tools_used,
2993
+ "messages": outcome.messages,
2994
+ "success": outcome.success,
2995
+ }
2996
+ _write_headless_result(output_format, payload)
2997
+ if outcome.error and output_format != "text":
2998
+ sys.stderr.write(f"error: {outcome.error}\n")
2999
+ return 0 if outcome.success else 1
3000
+
3001
+
3002
+ @app.callback()
3003
+ def main(
3004
+ ctx: typer.Context,
3005
+ version: Annotated[
3006
+ bool,
3007
+ typer.Option(
3008
+ "--version",
3009
+ "-V",
3010
+ help="Show the installed KiwiMateCoder version and exit.",
3011
+ is_eager=True,
3012
+ ),
3013
+ ] = False,
3014
+ update: Annotated[
3015
+ bool,
3016
+ typer.Option(
3017
+ "-update",
3018
+ "--update",
3019
+ help="Update KiwiMateCoder in the current Python environment.",
3020
+ ),
3021
+ ] = False,
3022
+ resume: Annotated[
3023
+ str | None,
3024
+ typer.Option(
3025
+ "-resume",
3026
+ "--resume",
3027
+ help="Resume a saved session by name or path ('last' = autosave).",
3028
+ ),
3029
+ ] = None,
3030
+ continue_: Annotated[
3031
+ bool,
3032
+ typer.Option(
3033
+ "--continue",
3034
+ "-c",
3035
+ help="Continue the most recent session (same as --resume last).",
3036
+ ),
3037
+ ] = False,
3038
+ profile: Annotated[
3039
+ str | None,
3040
+ typer.Option(
3041
+ "--profile",
3042
+ help="Apply a saved config profile for this session (not persisted).",
3043
+ ),
3044
+ ] = None,
3045
+ print_prompt: Annotated[
3046
+ str | None,
3047
+ typer.Option(
3048
+ "--print",
3049
+ "-p",
3050
+ help="Run one headless agent turn with this prompt ('-' reads stdin).",
3051
+ ),
3052
+ ] = None,
3053
+ output_format: Annotated[
3054
+ str,
3055
+ typer.Option(
3056
+ "--output-format",
3057
+ help="Headless output: text, json, or stream-json.",
3058
+ ),
3059
+ ] = "text",
3060
+ mode_override: Annotated[
3061
+ str | None,
3062
+ typer.Option(
3063
+ "--mode",
3064
+ help="Override the permission mode: ask, auto-accept, or plan.",
3065
+ ),
3066
+ ] = None,
3067
+ yes: Annotated[
3068
+ bool,
3069
+ typer.Option(
3070
+ "--yes",
3071
+ "-y",
3072
+ help="Treat every approval as granted (auto-accept) without prompting.",
3073
+ ),
3074
+ ] = False,
3075
+ max_turns: Annotated[
3076
+ int,
3077
+ typer.Option(
3078
+ "--max-turns",
3079
+ help="Maximum tool-loop iterations before stopping (default 30).",
3080
+ ),
3081
+ ] = 30,
3082
+ provider_override: Annotated[
3083
+ str | None,
3084
+ typer.Option("--provider", help="Provider id override for this run."),
3085
+ ] = None,
3086
+ model_override: Annotated[
3087
+ str | None,
3088
+ typer.Option("--model", help="Model id override for this run."),
3089
+ ] = None,
3090
+ workspace: Annotated[
3091
+ Path | None,
3092
+ typer.Option(
3093
+ "--workspace",
3094
+ help="Workspace root for this run (default: current directory).",
3095
+ ),
3096
+ ] = None,
3097
+ quiet: Annotated[
3098
+ bool,
3099
+ typer.Option(
3100
+ "--quiet",
3101
+ "-q",
3102
+ help="Suppress tool/progress lines on stderr in headless text mode.",
3103
+ ),
3104
+ ] = False,
3105
+ plain: Annotated[
3106
+ bool,
3107
+ typer.Option(
3108
+ "--plain",
3109
+ help=(
3110
+ "Plain output for this run: ASCII glyphs, no color, compact "
3111
+ "tool log, and no spinner (not persisted)."
3112
+ ),
3113
+ ),
3114
+ ] = False,
3115
+ ) -> None:
3116
+ """Launch the interactive session when run with no subcommand."""
3117
+ # Process-wide plain mode (not persisted) applies before anything renders.
3118
+ ui.apply_plain_mode(plain)
3119
+ global console
3120
+ console = ui.make_console()
3121
+ # Locale applies to every run path, including management subcommands.
3122
+ i18n.apply_config_locale()
3123
+
3124
+ if version:
3125
+ console.print(f"kiwimatecoder {__version__}")
3126
+ raise typer.Exit(0)
3127
+
3128
+ if update:
3129
+ raise typer.Exit(run_update(console))
3130
+
3131
+ from kiwimatecoder import telemetry
3132
+
3133
+ # Opt-in telemetry configures logging and crash capture for every run path
3134
+ # (interactive, headless, and management commands); disabled is a no-op.
3135
+ telemetry.configure()
3136
+
3137
+ if ctx.invoked_subcommand is not None:
3138
+ return
3139
+
3140
+ if print_prompt is not None:
3141
+ prompt = print_prompt
3142
+ if prompt == "-":
3143
+ prompt = sys.stdin.read()
3144
+ raise typer.Exit(
3145
+ _run_headless(
3146
+ prompt,
3147
+ output_format=output_format,
3148
+ mode=mode_override,
3149
+ yes=yes,
3150
+ max_turns=max_turns,
3151
+ provider=provider_override,
3152
+ model=model_override,
3153
+ workspace=workspace,
3154
+ quiet=quiet,
3155
+ )
3156
+ )
3157
+
3158
+ from kiwimatecoder import repl
3159
+
3160
+ session: Session | None = None
3161
+ resumed = False
3162
+ if resume or continue_:
3163
+ from kiwimatecoder.session import load_session
3164
+
3165
+ target = resume or "last"
3166
+ try:
3167
+ session = load_session(
3168
+ target, workspace_root=Path.cwd(), require_provider=True
3169
+ )
3170
+ resumed = True
3171
+ console.print(
3172
+ f"[bold green]{escape(str(t('cli.session_resumed', name=target)))}[/bold green] "
3173
+ + f"([dim]{len(session.messages)} messages, {session.total_tokens:,} tokens[/dim])"
3174
+ )
3175
+ except Exception as exc:
3176
+ if resume:
3177
+ console.print(f"[red]Could not resume session '{escape(str(resume))}': {escape(str(exc))}[/red]")
3178
+ raise typer.Exit(1)
3179
+ console.print(
3180
+ f"[dim]No previous session to continue ({escape(str(exc))}). Starting fresh.[/dim]"
3181
+ )
3182
+
3183
+ if session is None:
3184
+ cfg = load_config()
3185
+ provider_id = get_selected_provider_id(cfg)
3186
+ provider = get_provider_config(provider_id, cfg)
3187
+
3188
+ if not get_key(provider_id) and provider.needs_key:
3189
+ console.print(
3190
+ Panel(
3191
+ f"[yellow]No API key set for {escape(provider.name)}.[/yellow]\n"
3192
+ + "Run [cyan]kiwimatecoder setup[/cyan] to choose a provider and "
3193
+ + f"enter a key, or export [cyan]{escape(provider.key_env)}[/cyan].",
3194
+ title="Quick start",
3195
+ )
3196
+ )
3197
+ if _stdin_is_tty() and _prompt_yes_no("Run setup now?"):
3198
+ _run_setup(provider_id, key=None)
3199
+ # Setup may have saved a model and reset the roster.
3200
+ cfg = load_config()
3201
+
3202
+ model = resolve_model(provider, cfg)
3203
+ try:
3204
+ mode = PermissionMode.from_str(str(cfg.get("default_mode", "ask")))
3205
+ except ValueError:
3206
+ mode = PermissionMode.ASK
3207
+
3208
+ session = Session(
3209
+ provider_id=provider_id,
3210
+ model=model,
3211
+ mode=mode,
3212
+ workspace_root=Path.cwd(),
3213
+ active_provider_ids=get_active_provider_ids(cfg),
3214
+ always_allowed=set(get_always_allowed_tools(cfg)),
3215
+ output_style=get_output_style(cfg),
3216
+ custom_system_prompt=get_system_prompt(cfg),
3217
+ command_rules=get_command_rules(cfg),
3218
+ trusted_workspace=get_trusted_workspace(cfg),
3219
+ verify_command=get_verify_command(cfg),
3220
+ compact_at_tokens=get_compact_at_tokens(cfg),
3221
+ context_window=get_context_window(cfg),
3222
+ )
3223
+
3224
+ # Persisted approvals are user preferences, so a resumed session picks up
3225
+ # anything granted since its last save. Command rules come from config too.
3226
+ session.always_allowed.update(get_always_allowed_tools())
3227
+ session.command_rules = get_command_rules()
3228
+ session.trusted_workspace = get_trusted_workspace()
3229
+ session.verify_command = get_verify_command()
3230
+ session.compact_at_tokens = get_compact_at_tokens()
3231
+ session.context_window = get_context_window()
3232
+
3233
+ # A --profile overlay is session-local: it never writes config. On resume
3234
+ # only the mode and model are overlaid so the saved conversation keeps its
3235
+ # original provider.
3236
+ if profile is not None:
3237
+ profile_values = get_profile(profile)
3238
+ if profile_values is None:
3239
+ console.print(f"[red]{escape(str(t('error.unknown_profile', name=profile)))}[/red]")
3240
+ raise typer.Exit(1)
3241
+ if resumed:
3242
+ profile_values = {
3243
+ key: value
3244
+ for key, value in profile_values.items()
3245
+ if key in {"mode", "model"}
3246
+ }
3247
+ apply_session_profile(session, profile_values)
3248
+
3249
+ # The REPL needs a real terminal: prompt_toolkit cannot read piped stdin.
3250
+ # Point scripts and CI at the headless path instead of crashing or hanging.
3251
+ if not _stdin_is_tty():
3252
+ sys.stderr.write(t("cli.no_tty") + "\n")
3253
+ raise typer.Exit(2)
3254
+
3255
+ if not session.model:
3256
+ _ensure_session_model(session)
3257
+ _ensure_fallback_models(session)
3258
+
3259
+ # repl.run loads user (and opted-in project) plugins before the agent is
3260
+ # constructed, turning any failure into a dim warning rather than a crash.
3261
+ repl.run(session)
3262
+
3263
+
3264
+ def _stdin_is_tty() -> bool:
3265
+ """Whether interactive prompts can be safely shown on stdin."""
3266
+ try:
3267
+ return bool(sys.stdin.isatty())
3268
+ except (AttributeError, OSError):
3269
+ return False
3270
+
3271
+
3272
+ def _prompt_yes_no(question: str) -> bool:
3273
+ """Ask a yes/no question on the console; false on cancel/EOF/unknown."""
3274
+ try:
3275
+ answer = console.input(f"{question} \\[y/N]: ").strip().lower()
3276
+ except (EOFError, KeyboardInterrupt):
3277
+ console.print()
3278
+ return False
3279
+ return answer in ("y", "yes")
3280
+
3281
+
3282
+ def _interactive_select_provider(
3283
+ providers: Sequence[ProviderConfig],
3284
+ selected: str,
3285
+ local_status: dict[str, bool] | None = None,
3286
+ ) -> str | None:
3287
+ """Keyboard-driven provider picker, matching the REPL's selector style.
3288
+
3289
+ Experimental providers (KiwiMate) sit in their own "Experimental" box at
3290
+ the top.
3291
+ """
3292
+ from kiwimatecoder.pickers import grouped_choice
3293
+
3294
+ def _label(p: ProviderConfig) -> str:
3295
+ # Status first: a narrow terminal truncates the description instead.
3296
+ details: list[str] = []
3297
+ if p.is_local:
3298
+ status = (local_status or {}).get(p.id)
3299
+ if status is None:
3300
+ note = "key required" if p.requires_key else "no key needed"
3301
+ elif status:
3302
+ note = "running"
3303
+ else:
3304
+ note = "not detected"
3305
+ details.append(f"{note} ({p.base_url})")
3306
+ else:
3307
+ details.append("key set" if get_key(p.id) else "needs an API key")
3308
+ chosen = get_provider_model(p.id)
3309
+ if chosen:
3310
+ details.append(chosen)
3311
+ if p.description:
3312
+ details.append(p.description)
3313
+ return f"{p.name} — {' · '.join(details)}"
3314
+
3315
+ try:
3316
+ return grouped_choice(
3317
+ message="Choose a provider to configure",
3318
+ groups=[
3319
+ ("Experimental", [(p.id, _label(p)) for p in providers if p.experimental]),
3320
+ (None, [(p.id, _label(p)) for p in providers if not p.experimental]),
3321
+ ],
3322
+ default=selected,
3323
+ show_frame=True,
3324
+ bottom_toolbar="↑/↓ move • Enter select • Ctrl-C cancel",
3325
+ )
3326
+ except (EOFError, KeyboardInterrupt):
3327
+ return None
3328
+
3329
+
3330
+ # Picker value for "the model I want is not listed".
3331
+ _TYPE_MODEL_ID = "\0type-model-id"
3332
+
3333
+
3334
+ def _interactive_select_model(provider: ProviderConfig, current: str = "") -> str | None:
3335
+ """Ask which model to use with ``provider``; None when cancelled.
3336
+
3337
+ Offers the provider's live listing (refetched now that a key may have just
3338
+ been saved, falling back to its suggested models) plus a way to type any
3339
+ other id.
3340
+ """
3341
+ from prompt_toolkit.shortcuts import choice
3342
+
3343
+ from kiwimatecoder.commands import model_selection_prompt
3344
+
3345
+ prompt = model_selection_prompt(provider, current, console, force=True)
3346
+ # First, so an unlisted id is one keypress away even with a long listing.
3347
+ options = [(_TYPE_MODEL_ID, "Type a model id…")]
3348
+ options.extend((option.value, option.label) for option in prompt.options)
3349
+ try:
3350
+ picked = choice(
3351
+ message=f"{prompt.title}\n{prompt.text}",
3352
+ options=options,
3353
+ # Start on the current model, else the top of the listing.
3354
+ default=prompt.selected or (options[1][0] if len(options) > 1 else None),
3355
+ show_frame=True,
3356
+ bottom_toolbar="↑/↓ move • Enter select • Ctrl-C cancel",
3357
+ )
3358
+ except (EOFError, KeyboardInterrupt):
3359
+ return None
3360
+ if picked != _TYPE_MODEL_ID:
3361
+ return picked
3362
+ try:
3363
+ typed = console.input("model> ").strip()
3364
+ except (EOFError, KeyboardInterrupt):
3365
+ console.print()
3366
+ return None
3367
+ return typed or None
3368
+
3369
+
3370
+ def _setup_model(
3371
+ provider: ProviderConfig, model: str | None, *, interactive: bool = True
3372
+ ) -> None:
3373
+ """Record the model to use with ``provider`` — the last step of setup.
3374
+
3375
+ There are no default models: ``--model`` wins, otherwise the user picks
3376
+ from the provider's list (an earlier choice is kept if they cancel).
3377
+ Without a terminal, say how to choose one later.
3378
+ """
3379
+ current = get_provider_model(provider.id)
3380
+ chosen = (model or "").strip() or None
3381
+ if chosen is None and interactive and _stdin_is_tty():
3382
+ chosen = _interactive_select_model(provider, current)
3383
+ if chosen:
3384
+ set_provider_model(provider.id, chosen)
3385
+ console.print(f"[green]{_check()} Model set to[/green] [cyan]{escape(str(chosen))}[/cyan].")
3386
+ elif current:
3387
+ console.print(f"Keeping model [cyan]{escape(str(current))}[/cyan].")
3388
+ elif provider.is_local:
3389
+ console.print(
3390
+ "[dim]No model chosen — sessions use the first model the server lists.[/dim]"
3391
+ )
3392
+ else:
3393
+ console.print(
3394
+ "[yellow]No model chosen yet.[/yellow] Pick one when the session "
3395
+ + "starts, or run "
3396
+ + f"[cyan]kiwimatecoder config model set <model> --provider {escape(str(provider.id))}[/cyan]."
3397
+ )
3398
+ if provider.experimental:
3399
+ note = (
3400
+ " It is chat only for now: it cannot read or edit files or run commands."
3401
+ if not provider.supports_tools
3402
+ else ""
3403
+ )
3404
+ console.print(f"[yellow]{escape(str(provider.name))} is experimental.{note}[/yellow]")
3405
+
3406
+
3407
+ def _ensure_session_model(session: Session) -> None:
3408
+ """Ask for a model at launch when the session's provider has none chosen."""
3409
+ provider = session.provider
3410
+ console.print(f"[yellow]No model chosen for {escape(str(provider.name))} yet.[/yellow]")
3411
+ chosen = _interactive_select_model(provider)
3412
+ if chosen:
3413
+ set_provider_model(provider.id, chosen)
3414
+ session.model = chosen
3415
+ return
3416
+ console.print("[yellow]Choose a model with /model before chatting.[/yellow]")
3417
+
3418
+
3419
+ def _ensure_fallback_models(session: Session) -> None:
3420
+ """Ask at launch for each fallback provider that has no chosen model.
3421
+
3422
+ Failover skips a fallback without a model, so an upgraded roster (or one
3423
+ edited by hand) would otherwise fail silently when the primary goes down.
3424
+ """
3425
+ for provider in session.active_providers[1:]:
3426
+ if session.model_for(provider.id):
3427
+ continue
3428
+ console.print(
3429
+ f"[yellow]No model chosen for {escape(str(provider.name))} (fallback).[/yellow]"
3430
+ )
3431
+ chosen = _interactive_select_model(provider)
3432
+ if chosen:
3433
+ set_provider_model(provider.id, chosen)
3434
+ continue
3435
+ console.print(
3436
+ f"[yellow]{escape(str(provider.name))} is skipped during failover until you choose "
3437
+ + f"one with /config model set <model> {escape(str(provider.id))}.[/yellow]"
3438
+ )
3439
+
3440
+
3441
+ def _interactive_api_key() -> str | None:
3442
+ """Prompt for an API key, returning None on cancel/EOF."""
3443
+ try:
3444
+ console.print("[bold]Enter the API key for the provider:[/bold]")
3445
+ return console.input("key> ", password=ui.hide_typed_secrets()).strip()
3446
+ except (EOFError, KeyboardInterrupt):
3447
+ console.print()
3448
+ return None
3449
+
3450
+
3451
+ def _run_setup(provider_id: str, key: str | None, model: str | None = None) -> bool:
3452
+ """Store an API key for a provider, switch to it, and choose its model.
3453
+
3454
+ This is the setup wizard body. Returns True on success and False when the
3455
+ provider is unknown or the key entry was cancelled/empty. Callers decide
3456
+ how to treat a failure. Cancelling the model choice is not a failure: the
3457
+ session asks again at launch.
3458
+ """
3459
+ try:
3460
+ provider = get_provider_config(provider_id)
3461
+ except KeyError as exc:
3462
+ console.print(f"[red]{escape(str(exc))}[/red]")
3463
+ return False
3464
+ if key is None and provider.is_local and not provider.requires_key:
3465
+ # Keyless local servers (Ollama, LM Studio) — just select the provider
3466
+ # and offer the models the server has loaded. Key-enforcing locals
3467
+ # (Unsloth) fall through to the normal key prompt.
3468
+ set_selected_provider(provider_id)
3469
+ console.print(
3470
+ f"[green]{_check()} {escape(str(provider.name))} needs no API key[/green] — "
3471
+ + f"models are read from the server at {escape(str(provider.base_url))}."
3472
+ )
3473
+ running = probe(provider)
3474
+ if not running:
3475
+ console.print(
3476
+ f"[yellow]No server answered at {escape(str(provider.base_url))} — "
3477
+ + "start it before chatting.[/yellow]"
3478
+ )
3479
+ # A stopped server has nothing real to list, so only --model applies.
3480
+ _setup_model(provider, model, interactive=running)
3481
+ console.print("Ready to go. Run [cyan]kiwimatecoder[/cyan] to start a session.")
3482
+ return True
3483
+ if key is None:
3484
+ key = _interactive_api_key()
3485
+ if key is None:
3486
+ console.print("[yellow]Setup cancelled; nothing changed.[/yellow]")
3487
+ return False
3488
+ if not key:
3489
+ console.print("[red]No key entered; nothing changed.[/red]")
3490
+ return False
3491
+ try:
3492
+ warning = set_key(provider_id, key)
3493
+ except KeyError as exc:
3494
+ console.print(f"[red]{escape(str(exc))}[/red]")
3495
+ return False
3496
+ set_selected_provider(provider_id)
3497
+ console.print(
3498
+ f"[green]{_check()} API key saved for {escape(str(provider_id))}[/green] "
3499
+ + f"— {escape(str(describe_key(provider_id)))}."
3500
+ )
3501
+ if warning:
3502
+ console.print(f"[yellow]{escape(str(warning))}[/yellow]")
3503
+ _setup_model(provider, model)
3504
+ console.print("Ready to go. Run [cyan]kiwimatecoder[/cyan] to start a session.")
3505
+ return True
3506
+
3507
+
3508
+ @app.command("setup")
3509
+ def setup(
3510
+ provider: Annotated[
3511
+ str | None,
3512
+ typer.Option(
3513
+ "--provider", "-p", help="Provider id (default: configured provider)"
3514
+ ),
3515
+ ] = None,
3516
+ key: Annotated[
3517
+ str | None,
3518
+ typer.Option("--key", "-k", help="API key (skips the interactive prompt)"),
3519
+ ] = None,
3520
+ model: Annotated[
3521
+ str | None,
3522
+ typer.Option("--model", "-m", help="Model to use (skips the model picker)"),
3523
+ ] = None,
3524
+ ) -> None:
3525
+ """Choose a provider, save its API key, and pick its model."""
3526
+ cfg = load_config()
3527
+ provider_id = provider or get_selected_provider_id(cfg)
3528
+ if provider is None and key is None and _stdin_is_tty():
3529
+ providers = list_provider_configs(cfg)
3530
+ # Detect which local servers are actually up; localhost refuses fast,
3531
+ # so this costs ~nothing when they are not running. Providers that
3532
+ # enforce auth get their key when one is stored, so the probe sees a
3533
+ # true 200 instead of their 401.
3534
+ local_status = {
3535
+ p.id: probe(p, api_key=get_key(p.id)) for p in providers if p.is_local
3536
+ }
3537
+ chosen = _interactive_select_provider(providers, provider_id, local_status)
3538
+ if chosen is None:
3539
+ console.print("[yellow]Setup cancelled.[/yellow]")
3540
+ raise typer.Exit(1)
3541
+ provider_id = chosen
3542
+ if not _run_setup(provider_id, key, model):
3543
+ raise typer.Exit(1)
3544
+
3545
+
3546
+ @app.command()
3547
+ def ask(
3548
+ prompt: Annotated[str, typer.Argument(help="Your coding question")],
3549
+ file: Annotated[
3550
+ Path | None, typer.Option("--file", "-f", help="Path to a code file to include")
3551
+ ] = None,
3552
+ model: Annotated[
3553
+ str | None,
3554
+ typer.Option("--model", "-m", help="Model to use (default: the provider's choice)"),
3555
+ ] = None,
3556
+ provider: Annotated[
3557
+ str | None,
3558
+ typer.Option(
3559
+ "--provider", "-p", help="Provider id (default: configured provider)"
3560
+ ),
3561
+ ] = None,
3562
+ ) -> None:
3563
+ """Ask KiwiMateCoder a one-shot coding question."""
3564
+ cfg = load_config()
3565
+ provider_id = provider or get_selected_provider_id(cfg)
3566
+ try:
3567
+ provider_cfg = get_provider_config(provider_id, cfg)
3568
+ except KeyError as exc:
3569
+ console.print(f"[red]{escape(str(exc))}[/red]")
3570
+ raise typer.Exit(1)
3571
+
3572
+ api_key = get_key(provider_id)
3573
+ if not api_key and provider_cfg.needs_key:
3574
+ console.print(
3575
+ f"[red]No API key for {escape(str(provider_cfg.name))}. "
3576
+ + f"Run: kiwimatecoder setup --provider {escape(str(provider_id))}[/red]"
3577
+ )
3578
+ raise typer.Exit(1)
3579
+
3580
+ full_prompt = prompt
3581
+ if file:
3582
+ if not file.exists():
3583
+ console.print(f"[red]File not found: {escape(str(file))}[/red]")
3584
+ raise typer.Exit(1)
3585
+ full_prompt = f"{prompt}\n\n```\n{file.read_text()}\n```"
3586
+
3587
+ try:
3588
+ resolved_model = require_model(provider_cfg, cfg, override=model)
3589
+ except ModelNotChosenError as exc:
3590
+ console.print(f"[red]{escape(str(exc))}[/red]")
3591
+ raise typer.Exit(1)
3592
+
3593
+ asyncio.run(
3594
+ stream_response(
3595
+ full_prompt,
3596
+ api_key or "",
3597
+ model=resolved_model,
3598
+ provider=provider_cfg,
3599
+ )
3600
+ )
3601
+ console.print()
3602
+
3603
+
3604
+ # --- background / scheduled agent jobs --------------------------------------
3605
+
3606
+
3607
+ def _short_prompt(prompt: str, limit: int = 60) -> str:
3608
+ text = prompt.replace("\n", " ").strip()
3609
+ return text if len(text) <= limit else f"{text[: limit - 3]}..."
3610
+
3611
+
3612
+ def _print_jobs(records: Sequence[Any]) -> None:
3613
+ table = Table(title="Background jobs", show_header=True)
3614
+ table.add_column("id", style="cyan", no_wrap=True)
3615
+ table.add_column("status")
3616
+ table.add_column("created", no_wrap=True)
3617
+ table.add_column("pid", no_wrap=True)
3618
+ table.add_column("prompt", overflow="fold")
3619
+ for record in records:
3620
+ style = {
3621
+ "running": "yellow",
3622
+ "succeeded": "green",
3623
+ "failed": "red",
3624
+ "cancelled": "dim",
3625
+ }.get(record.status, "white")
3626
+ table.add_row(
3627
+ escape(str(record.id)),
3628
+ f"[{style}]{record.status}[/{style}]",
3629
+ escape(str(record.created_at)),
3630
+ escape(str(str(record.pid or ""))),
3631
+ escape(_short_prompt(record.prompt)),
3632
+ )
3633
+ console.print(table)
3634
+
3635
+
3636
+ def _print_job(record: Any) -> None:
3637
+ console.print(f"ID: [cyan]{escape(str(record.id))}[/cyan]")
3638
+ console.print(f"Status: [bold]{record.status}[/bold]")
3639
+ console.print(f"Prompt: {escape(record.prompt)}")
3640
+ console.print(f"Workspace: {escape(record.workspace)}")
3641
+ if record.provider:
3642
+ console.print(f"Provider: {escape(str(record.provider))}")
3643
+ if record.model:
3644
+ console.print(f"Model: {escape(str(record.model))}")
3645
+ console.print(f"Mode: {escape(str(record.mode))}")
3646
+ console.print(f"PID: {escape(str(record.pid or '-'))}")
3647
+ console.print(f"Created: {record.created_at}")
3648
+ if record.finished_at:
3649
+ console.print(f"Finished: {record.finished_at}")
3650
+ if record.exit_code is not None:
3651
+ console.print(f"Exit code: {record.exit_code}")
3652
+ if record.interval is not None:
3653
+ console.print(f"Every: {record.interval:g}s (next: {record.next_run_at})")
3654
+ if record.error:
3655
+ console.print(f"[red]Error: {escape(record.error)}[/red]")
3656
+ if record.result is not None:
3657
+ console.print(Panel(escape(record.result or "(empty result)"), title="Result"))
3658
+ if record.output_path:
3659
+ console.print(f"[dim]Output: {escape(str(record.output_path))}[/dim]")
3660
+
3661
+
3662
+ def _job_start_error(exc: Exception) -> None:
3663
+ console.print(f"[red]{escape(str(exc))}[/red]")
3664
+ raise typer.Exit(1)
3665
+
3666
+
3667
+ @jobs_app.command("run")
3668
+ def jobs_run(
3669
+ prompt: Annotated[str, typer.Argument(help="Prompt for the detached agent run")],
3670
+ workspace: Annotated[
3671
+ Path | None,
3672
+ typer.Option("--workspace", help="Workspace root (default: current directory)"),
3673
+ ] = None,
3674
+ provider: Annotated[
3675
+ str | None, typer.Option("--provider", help="Provider id override")
3676
+ ] = None,
3677
+ model: Annotated[
3678
+ str | None, typer.Option("--model", help="Model id override")
3679
+ ] = None,
3680
+ mode: Annotated[
3681
+ str,
3682
+ typer.Option(
3683
+ "--mode",
3684
+ help="Permission mode (default: auto-accept, i.e. unattended).",
3685
+ ),
3686
+ ] = "auto-accept",
3687
+ ) -> None:
3688
+ """Start a detached agent job and return immediately."""
3689
+ from kiwimatecoder import jobs as jobs_module
3690
+
3691
+ try:
3692
+ record = jobs_module.start_job(
3693
+ prompt,
3694
+ workspace=workspace or Path.cwd(),
3695
+ provider=provider,
3696
+ model=model,
3697
+ mode=mode,
3698
+ )
3699
+ except (ValueError, KeyError, jobs_module.JobError) as exc:
3700
+ _job_start_error(exc)
3701
+ return
3702
+ console.print(
3703
+ f"[green]{_check()} Started job[/green] [cyan]{escape(str(record.id))}[/cyan] "
3704
+ f"([dim]{escape(str(_short_prompt(record.prompt)))}[/dim])"
3705
+ )
3706
+ console.print(
3707
+ f"[dim]Watch with `kiwimatecoder jobs show {escape(str(record.id))}` or "
3708
+ f"`kiwimatecoder jobs output {escape(str(record.id))}`.[/dim]"
3709
+ )
3710
+
3711
+
3712
+ @jobs_app.command("schedule")
3713
+ def jobs_schedule(
3714
+ prompt: Annotated[str, typer.Argument(help="Prompt for the scheduled run")],
3715
+ every: Annotated[
3716
+ float, typer.Option("--every", help="Seconds between runs")
3717
+ ],
3718
+ workspace: Annotated[
3719
+ Path | None,
3720
+ typer.Option("--workspace", help="Workspace root (default: current directory)"),
3721
+ ] = None,
3722
+ provider: Annotated[
3723
+ str | None, typer.Option("--provider", help="Provider id override")
3724
+ ] = None,
3725
+ model: Annotated[
3726
+ str | None, typer.Option("--model", help="Model id override")
3727
+ ] = None,
3728
+ mode: Annotated[
3729
+ str,
3730
+ typer.Option("--mode", help="Permission mode (default: auto-accept)."),
3731
+ ] = "auto-accept",
3732
+ ) -> None:
3733
+ """Start a job now and re-run it whenever `jobs tick` finds it due."""
3734
+ from kiwimatecoder import jobs as jobs_module
3735
+
3736
+ try:
3737
+ record = jobs_module.schedule_job(
3738
+ prompt,
3739
+ every,
3740
+ workspace=workspace or Path.cwd(),
3741
+ provider=provider,
3742
+ model=model,
3743
+ mode=mode,
3744
+ )
3745
+ except (ValueError, KeyError, jobs_module.JobError) as exc:
3746
+ _job_start_error(exc)
3747
+ return
3748
+ console.print(
3749
+ f"[green]{_check()} Scheduled job[/green] [cyan]{escape(str(record.id))}[/cyan] "
3750
+ f"every {record.interval:g}s (next: {record.next_run_at})."
3751
+ )
3752
+ console.print(
3753
+ "[dim]Run `kiwimatecoder jobs tick` (e.g. from cron) to start due runs.[/dim]"
3754
+ )
3755
+
3756
+
3757
+ @jobs_app.command("list")
3758
+ def jobs_list() -> None:
3759
+ """List tracked jobs, refreshing running ones first."""
3760
+ from kiwimatecoder import jobs as jobs_module
3761
+
3762
+ records = jobs_module.list_jobs(refresh=True)
3763
+ if not records:
3764
+ console.print("[dim]No background jobs.[/dim]")
3765
+ return
3766
+ _print_jobs(records)
3767
+
3768
+
3769
+ @jobs_app.command("show")
3770
+ def jobs_show(job_id: Annotated[str, typer.Argument(help="Job id")]) -> None:
3771
+ """Show one job's record and result."""
3772
+ from kiwimatecoder import jobs as jobs_module
3773
+
3774
+ record = jobs_module.refresh_job(job_id) or jobs_module.get_job(job_id)
3775
+ if record is None:
3776
+ console.print(f"[red]Unknown job '{escape(str(job_id))}'.[/red]")
3777
+ raise typer.Exit(1)
3778
+ _print_job(record)
3779
+
3780
+
3781
+ @jobs_app.command("output")
3782
+ def jobs_output(job_id: Annotated[str, typer.Argument(help="Job id")]) -> None:
3783
+ """Print a job's combined output (bounded)."""
3784
+ from kiwimatecoder import jobs as jobs_module
3785
+
3786
+ text = jobs_module.read_job_output(job_id)
3787
+ if text is None:
3788
+ console.print(f"[red]Unknown job '{escape(str(job_id))}'.[/red]")
3789
+ raise typer.Exit(1)
3790
+ console.print(text or "(no output yet)", markup=False, highlight=False)
3791
+
3792
+
3793
+ @jobs_app.command("cancel")
3794
+ def jobs_cancel(job_id: Annotated[str, typer.Argument(help="Job id")]) -> None:
3795
+ """Terminate a running job (SIGTERM, then SIGKILL after a grace period)."""
3796
+ from kiwimatecoder import jobs as jobs_module
3797
+
3798
+ record = jobs_module.cancel_job(job_id)
3799
+ if record is None:
3800
+ console.print(f"[red]Unknown job '{escape(str(job_id))}'.[/red]")
3801
+ raise typer.Exit(1)
3802
+ console.print(
3803
+ f"[green]{_check()} Job[/green] [cyan]{escape(str(record.id))}[/cyan] is now "
3804
+ f"[bold]{record.status}[/bold]."
3805
+ )
3806
+
3807
+
3808
+ @jobs_app.command("clear")
3809
+ def jobs_clear(
3810
+ all_: Annotated[
3811
+ bool,
3812
+ typer.Option("--all", help="Also clear running jobs (terminating them)."),
3813
+ ] = False,
3814
+ ) -> None:
3815
+ """Delete finished job records and their output logs."""
3816
+ from kiwimatecoder import jobs as jobs_module
3817
+
3818
+ removed = jobs_module.clear_jobs(finished_only=not all_)
3819
+ console.print(f"[green]{_check()} Cleared {removed} job(s).[/green]")
3820
+
3821
+
3822
+ @jobs_app.command("tick")
3823
+ def jobs_tick() -> None:
3824
+ """Start every scheduled job whose interval has elapsed."""
3825
+ from kiwimatecoder import jobs as jobs_module
3826
+
3827
+ skipped: list[tuple[Any, str]] = []
3828
+ started = jobs_module.run_due_jobs(skipped=skipped)
3829
+ if not started and not skipped:
3830
+ console.print("[dim]No jobs are due.[/dim]")
3831
+ return
3832
+ for record in started:
3833
+ console.print(
3834
+ f"[green]{_check()} Started[/green] [cyan]{escape(str(record.id))}[/cyan] "
3835
+ f"([dim]{escape(str(_short_prompt(record.prompt)))}[/dim])"
3836
+ )
3837
+ for record, reason in skipped:
3838
+ console.print(
3839
+ f"[yellow]Skipped[/yellow] [cyan]{escape(str(record.id))}[/cyan]: {escape(reason)}"
3840
+ )
3841
+ if skipped:
3842
+ raise typer.Exit(1)
3843
+
3844
+
3845
+ @app.command("acp")
3846
+ def acp_serve(
3847
+ workspace: Annotated[
3848
+ Path | None,
3849
+ typer.Option(
3850
+ "--workspace",
3851
+ help="Default workspace root for new sessions (default: cwd).",
3852
+ ),
3853
+ ] = None,
3854
+ ) -> None:
3855
+ """Run the Agent Client Protocol server over stdio for editor integration."""
3856
+ from kiwimatecoder.acp.stdio import serve_stdio
3857
+
3858
+ raise typer.Exit(serve_stdio(workspace=workspace))
3859
+
3860
+
3861
+ @app.command("doctor")
3862
+ def doctor_cmd() -> None:
3863
+ """Run environment, config, provider, and workspace diagnostics."""
3864
+ from kiwimatecoder import diagnostics
3865
+
3866
+ cfg = load_config()
3867
+ provider_id = get_selected_provider_id(cfg)
3868
+ provider = get_provider_config(provider_id, cfg)
3869
+ model = resolve_model(provider, cfg)
3870
+ try:
3871
+ mode = PermissionMode.from_str(str(cfg.get("default_mode", "ask")))
3872
+ except ValueError:
3873
+ mode = PermissionMode.ASK
3874
+ session = Session(
3875
+ provider_id=provider_id,
3876
+ model=model,
3877
+ mode=mode,
3878
+ workspace_root=Path.cwd(),
3879
+ active_provider_ids=get_active_provider_ids(cfg),
3880
+ always_allowed=set(get_always_allowed_tools(cfg)),
3881
+ output_style=get_output_style(cfg),
3882
+ custom_system_prompt=get_system_prompt(cfg),
3883
+ )
3884
+ diagnostics.render(diagnostics.run_checks(session), console)
3885
+
3886
+
3887
+ # --- cross-machine session sync ---------------------------------------------
3888
+
3889
+
3890
+ def _print_sync_report(report: Any) -> None:
3891
+ for line in report.lines:
3892
+ console.print(line, markup=False, highlight=False)
3893
+ for error in report.errors:
3894
+ console.print(f"[yellow]{escape(str(error))}[/yellow]")
3895
+ console.print(report.summary())
3896
+
3897
+
3898
+ @sync_app.command("status")
3899
+ def sync_status_cmd() -> None:
3900
+ """Show local/remote session counts and pending changes."""
3901
+ from kiwimatecoder import sync as sync_module
3902
+
3903
+ console.print(sync_module.status().summary(), markup=False, highlight=False)
3904
+
3905
+
3906
+ @sync_app.command("push")
3907
+ def sync_push_cmd(
3908
+ force: Annotated[
3909
+ bool,
3910
+ typer.Option(
3911
+ "--force",
3912
+ help="Overwrite shared sessions with local copies, even on conflict.",
3913
+ ),
3914
+ ] = False,
3915
+ ) -> None:
3916
+ """Copy local sessions into the shared folder."""
3917
+ from kiwimatecoder import sync as sync_module
3918
+
3919
+ try:
3920
+ report = sync_module.push(force=force)
3921
+ except sync_module.SyncError as exc:
3922
+ console.print(f"[yellow]{escape(str(exc))}[/yellow]")
3923
+ raise typer.Exit(1)
3924
+ _print_sync_report(report)
3925
+
3926
+
3927
+ @sync_app.command("pull")
3928
+ def sync_pull_cmd(
3929
+ force: Annotated[
3930
+ bool,
3931
+ typer.Option(
3932
+ "--force",
3933
+ help="Overwrite local sessions with shared copies, even on conflict.",
3934
+ ),
3935
+ ] = False,
3936
+ ) -> None:
3937
+ """Copy newer shared sessions into the local sessions directory."""
3938
+ from kiwimatecoder import sync as sync_module
3939
+
3940
+ try:
3941
+ report = sync_module.pull(force=force)
3942
+ except sync_module.SyncError as exc:
3943
+ console.print(f"[yellow]{escape(str(exc))}[/yellow]")
3944
+ raise typer.Exit(1)
3945
+ _print_sync_report(report)
3946
+
3947
+
3948
+ @sync_app.command("enable")
3949
+ def sync_enable_cmd(
3950
+ path: Annotated[
3951
+ Path,
3952
+ typer.Argument(help="Existing folder to share (Dropbox/iCloud/git/...)."),
3953
+ ],
3954
+ ) -> None:
3955
+ """Enable session sync through an existing folder."""
3956
+ try:
3957
+ settings = set_sync(enabled=True, path=str(path))
3958
+ except ValueError as exc:
3959
+ console.print(f"[red]{escape(str(exc))}[/red]")
3960
+ raise typer.Exit(1)
3961
+ console.print(
3962
+ f"[green]{_check()} Session sync enabled[/green] for machine "
3963
+ f"[cyan]{escape(settings['machine'])}[/cyan] in "
3964
+ f"[cyan]{escape(settings['path'])}[/cyan]."
3965
+ )
3966
+
3967
+
3968
+ @sync_app.command("disable")
3969
+ def sync_disable_cmd() -> None:
3970
+ """Disable session sync (the shared folder is left untouched)."""
3971
+ set_sync(enabled=False)
3972
+ console.print(
3973
+ f"[green]{_check()} Session sync disabled.[/green] "
3974
+ "The shared folder was not modified."
3975
+ )
3976
+
3977
+
3978
+ # --- shared team policy ------------------------------------------------------
3979
+
3980
+
3981
+ @team_app.command("show")
3982
+ def team_show_cmd() -> None:
3983
+ """Show the shared policy file, its state, keys, and any issues."""
3984
+ from kiwimatecoder import team as team_module
3985
+
3986
+ settings = get_team()
3987
+ if not settings["policy_path"]:
3988
+ console.print("Team policy: [cyan]none[/cyan] (optional)")
3989
+ console.print("Set one with [cyan]config team set-policy <path>[/cyan].")
3990
+ return
3991
+ state = "enforced" if settings["enforce"] else "advisory"
3992
+ console.print(
3993
+ f"Team policy: [cyan]{escape(settings['policy_path'])}[/cyan] ({state})"
3994
+ )
3995
+ for issue in team_module.policy_issues():
3996
+ color = "red" if issue["level"] == "error" else "yellow"
3997
+ console.print(f"[{color}]{escape(str(issue['message']))}[/{color}]")
3998
+ try:
3999
+ policy = team_module.load_policy()
4000
+ except team_module.PolicyError:
4001
+ return
4002
+ console.print(
4003
+ "Policy keys: [cyan]" + (", ".join(sorted(policy)) or "none") + "[/cyan]"
4004
+ )
4005
+
4006
+
4007
+ @team_app.command("set-policy")
4008
+ def team_set_policy_cmd(
4009
+ path: Annotated[
4010
+ str,
4011
+ typer.Argument(help="Shared policy JSON file (empty string clears it)."),
4012
+ ],
4013
+ ) -> None:
4014
+ """Point the team policy at a shared JSON file."""
4015
+ try:
4016
+ settings = set_team(policy_path=path)
4017
+ except ValueError as exc:
4018
+ console.print(f"[red]{escape(str(exc))}[/red]")
4019
+ raise typer.Exit(1)
4020
+ if settings["policy_path"]:
4021
+ console.print(
4022
+ f"[green]{_check()} Team policy set[/green] to "
4023
+ f"[cyan]{escape(settings['policy_path'])}[/cyan]."
4024
+ )
4025
+ else:
4026
+ console.print(f"[green]{_check()} Team policy cleared.[/green]")
4027
+
4028
+
4029
+ @team_app.command("enforce")
4030
+ def team_enforce_cmd(
4031
+ state: Annotated[str, typer.Argument(help="on or off")],
4032
+ ) -> None:
4033
+ """Make the team policy authoritative (on) or merely advisory (off)."""
4034
+ normalized = state.strip().lower()
4035
+ if normalized not in {"on", "off", "true", "false", "enable", "disable"}:
4036
+ console.print("[red]Usage: config team enforce on|off[/red]")
4037
+ raise typer.Exit(1)
4038
+ try:
4039
+ settings = set_team(enforce=normalized in {"on", "true", "enable"})
4040
+ except ValueError as exc:
4041
+ console.print(f"[red]{escape(str(exc))}[/red]")
4042
+ raise typer.Exit(1)
4043
+ console.print(
4044
+ f"[green]{_check()} Team policy "
4045
+ + ("enforced" if settings["enforce"] else "advisory")
4046
+ + ".[/green]"
4047
+ )
4048
+
4049
+
4050
+ # --- session sharing ---------------------------------------------------------
4051
+
4052
+
4053
+ @share_app.command("create")
4054
+ def share_create_cmd(
4055
+ session_name: Annotated[
4056
+ str,
4057
+ typer.Argument(help="Saved session name (default: last)."),
4058
+ ] = "last",
4059
+ gist: Annotated[
4060
+ bool,
4061
+ typer.Option("--gist", help="Upload as a secret gist via the gh CLI."),
4062
+ ] = False,
4063
+ output: Annotated[
4064
+ Path | None,
4065
+ typer.Option(
4066
+ "--output",
4067
+ "-o",
4068
+ help="Write the bundle here (a directory uses the default name).",
4069
+ ),
4070
+ ] = None,
4071
+ include_tool_output: Annotated[
4072
+ bool,
4073
+ typer.Option(
4074
+ "--include-tool-output",
4075
+ help="Include redacted, 2 KB-truncated tool output.",
4076
+ ),
4077
+ ] = False,
4078
+ ) -> None:
4079
+ """Write a redacted share bundle for a saved session."""
4080
+ from kiwimatecoder import share as share_module
4081
+ from kiwimatecoder.session import load_session
4082
+
4083
+ try:
4084
+ session = load_session(session_name, workspace_root=Path.cwd())
4085
+ except (FileNotFoundError, OSError, ValueError) as exc:
4086
+ console.print(f"[red]{escape(str(exc))}[/red]")
4087
+ raise typer.Exit(1)
4088
+ if gist:
4089
+ ok, detail = share_module.share_to_gist(
4090
+ session, include_tool_output=include_tool_output, dest=output
4091
+ )
4092
+ if not ok:
4093
+ console.print(f"[red]{escape(str(detail))}[/red]")
4094
+ raise typer.Exit(1)
4095
+ console.print(f"[green]{_check()} Shared as a secret gist:[/green] {escape(str(detail))}")
4096
+ return
4097
+ try:
4098
+ bundle_path = share_module.write_share(
4099
+ session, dest=output, include_tool_output=include_tool_output
4100
+ )
4101
+ except OSError as exc:
4102
+ console.print(f"[red]Failed to write share bundle: {escape(str(exc))}[/red]")
4103
+ raise typer.Exit(1)
4104
+ console.print(
4105
+ f"[green]{_check()} Wrote redacted share bundle[/green] to "
4106
+ f"[cyan]{escape(str(bundle_path))}[/cyan]."
4107
+ )
4108
+ console.print(
4109
+ "[dim]Tool output is omitted by default; pass --include-tool-output "
4110
+ "to include it (redacted and truncated).[/dim]"
4111
+ )
4112
+
4113
+
4114
+ @share_app.command("import")
4115
+ def share_import_cmd(
4116
+ path: Annotated[Path, typer.Argument(help="Share bundle to import.")],
4117
+ name: Annotated[
4118
+ str | None,
4119
+ typer.Option("--name", "-n", help="Name for the new saved session."),
4120
+ ] = None,
4121
+ ) -> None:
4122
+ """Import a share bundle as a normal saved session."""
4123
+ from kiwimatecoder import share as share_module
4124
+
4125
+ try:
4126
+ saved = share_module.import_share(path, name)
4127
+ except (share_module.ShareError, OSError, ValueError) as exc:
4128
+ console.print(f"[red]{escape(str(exc))}[/red]")
4129
+ raise typer.Exit(1)
4130
+ console.print(
4131
+ f"[green]{_check()} Imported session[/green] as [cyan]{escape(str(saved.stem))}[/cyan]."
4132
+ )
4133
+ console.print(
4134
+ f"[dim]Resume it with kiwimatecoder --resume {escape(str(saved.stem))}.[/dim]"
4135
+ )
4136
+
4137
+
4138
+ @eval_app.command("list")
4139
+ def eval_list_cmd(
4140
+ cases_dir: Annotated[
4141
+ Path | None,
4142
+ typer.Option(
4143
+ "--dir",
4144
+ help="Directory of *.json eval cases (default: evals/cases).",
4145
+ ),
4146
+ ] = None,
4147
+ ) -> None:
4148
+ """List the discovered eval cases without running them."""
4149
+ from kiwimatecoder.evals import cases as eval_cases
4150
+
4151
+ try:
4152
+ discovered = eval_cases.discover_cases(cases_dir)
4153
+ except eval_cases.CaseError as exc:
4154
+ console.print(f"[red]{escape(str(exc))}[/red]")
4155
+ raise typer.Exit(2)
4156
+ if not discovered:
4157
+ console.print("[yellow]No eval cases found.[/yellow]")
4158
+ return
4159
+ table = Table(title="Eval cases")
4160
+ table.add_column("Case", style="cyan", no_wrap=True)
4161
+ table.add_column("Description")
4162
+ for case in discovered:
4163
+ table.add_row(escape(case.name), escape(case.description))
4164
+ console.print(table)
4165
+
4166
+
4167
+ def _eval_failures_mention_credentials(report: Any) -> bool:
4168
+ """Best-effort detection of provider/key failures for extra CLI guidance."""
4169
+ hints = ("key", "unauthorized", "authentication", "credential")
4170
+ for result in report.results:
4171
+ for reason in result.reasons:
4172
+ lowered = reason.lower()
4173
+ if any(hint in lowered for hint in hints):
4174
+ return True
4175
+ return False
4176
+
4177
+
4178
+ @eval_app.command("run")
4179
+ def eval_run_cmd(
4180
+ cases_dir: Annotated[
4181
+ Path | None,
4182
+ typer.Option(
4183
+ "--dir",
4184
+ help="Directory of *.json eval cases (default: evals/cases).",
4185
+ ),
4186
+ ] = None,
4187
+ provider: Annotated[
4188
+ str | None,
4189
+ typer.Option("--provider", help="Provider id override for every case."),
4190
+ ] = None,
4191
+ model: Annotated[
4192
+ str | None,
4193
+ typer.Option("--model", help="Model id override for every case."),
4194
+ ] = None,
4195
+ filter: Annotated[
4196
+ str | None,
4197
+ typer.Option("--filter", help="Only run cases whose name contains this text."),
4198
+ ] = None,
4199
+ json_output: Annotated[
4200
+ bool,
4201
+ typer.Option("--json", help="Print the report as JSON."),
4202
+ ] = False,
4203
+ report_path: Annotated[
4204
+ Path | None,
4205
+ typer.Option("--report", help="Write the JSON report to this path."),
4206
+ ] = None,
4207
+ ) -> None:
4208
+ """Run eval cases: exit 0 when all pass, 1 on failure, 2 on invalid input."""
4209
+ from kiwimatecoder.evals import render, runner
4210
+
4211
+ if provider is not None:
4212
+ try:
4213
+ get_provider_config(provider)
4214
+ except KeyError as exc:
4215
+ console.print(f"[red]{escape(str(exc))}[/red]")
4216
+ raise typer.Exit(2)
4217
+ try:
4218
+ report = runner.run_suite(
4219
+ cases_dir,
4220
+ provider=provider,
4221
+ model=model,
4222
+ filter=filter,
4223
+ report_path=report_path,
4224
+ )
4225
+ except (runner.CaseError, OSError) as exc:
4226
+ console.print(f"[red]{escape(str(exc))}[/red]")
4227
+ raise typer.Exit(2)
4228
+ if json_output:
4229
+ console.print_json(render.report_to_json(report))
4230
+ else:
4231
+ render.print_report(report, console)
4232
+ if not report.success and _eval_failures_mention_credentials(report):
4233
+ console.print(
4234
+ "[yellow]Some failures look provider-related. Configure a key with "
4235
+ "[cyan]kiwimatecoder setup[/cyan] or the provider's environment "
4236
+ "variable, then rerun.[/yellow]"
4237
+ )
4238
+ raise typer.Exit(0 if report.success else 1)
4239
+
4240
+
4241
+ @app.command("update")
4242
+ def update_cmd(
4243
+ ref: Annotated[
4244
+ str | None,
4245
+ typer.Option(
4246
+ "--ref",
4247
+ help="Git ref to install: branch, tag, or commit SHA (default: current branch).",
4248
+ ),
4249
+ ] = None,
4250
+ ) -> None:
4251
+ """Update KiwiMateCoder in the current Python environment."""
4252
+ raise typer.Exit(run_update(console, ref=ref))
4253
+
4254
+
4255
+ @app.command("version")
4256
+ def version_cmd() -> None:
4257
+ """Show the installed KiwiMateCoder version."""
4258
+ console.print(f"kiwimatecoder {__version__}")