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.
- kiwimatecoder/__init__.py +8 -0
- kiwimatecoder/__main__.py +12 -0
- kiwimatecoder/acp/__init__.py +13 -0
- kiwimatecoder/acp/protocol.py +115 -0
- kiwimatecoder/acp/server.py +823 -0
- kiwimatecoder/acp/stdio.py +32 -0
- kiwimatecoder/agent.py +1162 -0
- kiwimatecoder/ai.py +68 -0
- kiwimatecoder/audit.py +65 -0
- kiwimatecoder/browser.py +261 -0
- kiwimatecoder/catalog.py +445 -0
- kiwimatecoder/checkpoints.py +103 -0
- kiwimatecoder/client.py +820 -0
- kiwimatecoder/clipboard.py +99 -0
- kiwimatecoder/commands.py +6757 -0
- kiwimatecoder/config.py +5335 -0
- kiwimatecoder/diagnostics.py +295 -0
- kiwimatecoder/documents.py +299 -0
- kiwimatecoder/evals/__init__.py +39 -0
- kiwimatecoder/evals/cases.py +230 -0
- kiwimatecoder/evals/render.py +46 -0
- kiwimatecoder/evals/runner.py +319 -0
- kiwimatecoder/events.py +95 -0
- kiwimatecoder/forge.py +172 -0
- kiwimatecoder/git.py +164 -0
- kiwimatecoder/headless.py +255 -0
- kiwimatecoder/hooks.py +170 -0
- kiwimatecoder/hunks.py +260 -0
- kiwimatecoder/i18n.py +368 -0
- kiwimatecoder/images.py +195 -0
- kiwimatecoder/index/__init__.py +14 -0
- kiwimatecoder/index/builder.py +376 -0
- kiwimatecoder/index/embeddings.py +151 -0
- kiwimatecoder/index/search.py +247 -0
- kiwimatecoder/index/store.py +251 -0
- kiwimatecoder/instructions.py +83 -0
- kiwimatecoder/jobs.py +594 -0
- kiwimatecoder/lsp/__init__.py +56 -0
- kiwimatecoder/lsp/client.py +548 -0
- kiwimatecoder/lsp/manager.py +353 -0
- kiwimatecoder/lsp/protocol.py +79 -0
- kiwimatecoder/main.py +4258 -0
- kiwimatecoder/mcp/__init__.py +45 -0
- kiwimatecoder/mcp/client.py +385 -0
- kiwimatecoder/mcp/errors.py +7 -0
- kiwimatecoder/mcp/manager.py +253 -0
- kiwimatecoder/mcp/protocol.py +132 -0
- kiwimatecoder/media.py +904 -0
- kiwimatecoder/memory.py +141 -0
- kiwimatecoder/network.py +112 -0
- kiwimatecoder/notify.py +139 -0
- kiwimatecoder/permissions.py +171 -0
- kiwimatecoder/pickers.py +417 -0
- kiwimatecoder/plugins.py +245 -0
- kiwimatecoder/pricing.py +167 -0
- kiwimatecoder/prompts.py +213 -0
- kiwimatecoder/providers.py +342 -0
- kiwimatecoder/redaction.py +66 -0
- kiwimatecoder/remote.py +294 -0
- kiwimatecoder/repl.py +1388 -0
- kiwimatecoder/routing.py +65 -0
- kiwimatecoder/sandbox.py +169 -0
- kiwimatecoder/sdk.py +146 -0
- kiwimatecoder/session.py +574 -0
- kiwimatecoder/share.py +386 -0
- kiwimatecoder/shell.py +660 -0
- kiwimatecoder/skills.py +138 -0
- kiwimatecoder/sync.py +513 -0
- kiwimatecoder/team.py +249 -0
- kiwimatecoder/telemetry.py +392 -0
- kiwimatecoder/templates.py +108 -0
- kiwimatecoder/tools/__init__.py +190 -0
- kiwimatecoder/tools/ask.py +62 -0
- kiwimatecoder/tools/base.py +69 -0
- kiwimatecoder/tools/browser.py +261 -0
- kiwimatecoder/tools/edit_file.py +143 -0
- kiwimatecoder/tools/forge.py +207 -0
- kiwimatecoder/tools/git.py +158 -0
- kiwimatecoder/tools/image.py +300 -0
- kiwimatecoder/tools/io.py +346 -0
- kiwimatecoder/tools/list_dir.py +67 -0
- kiwimatecoder/tools/lsp.py +254 -0
- kiwimatecoder/tools/memory.py +115 -0
- kiwimatecoder/tools/paths.py +234 -0
- kiwimatecoder/tools/read_file.py +113 -0
- kiwimatecoder/tools/registry.py +91 -0
- kiwimatecoder/tools/run_bash.py +189 -0
- kiwimatecoder/tools/search.py +224 -0
- kiwimatecoder/tools/selection.py +108 -0
- kiwimatecoder/tools/shell.py +158 -0
- kiwimatecoder/tools/skill.py +46 -0
- kiwimatecoder/tools/task.py +61 -0
- kiwimatecoder/tools/todo.py +77 -0
- kiwimatecoder/tools/web.py +146 -0
- kiwimatecoder/tools/write_file.py +83 -0
- kiwimatecoder/ui.py +189 -0
- kiwimatecoder/updater.py +502 -0
- kiwimatecoder/web.py +499 -0
- kiwimatecoder-0.1.0.dist-info/METADATA +2016 -0
- kiwimatecoder-0.1.0.dist-info/RECORD +104 -0
- kiwimatecoder-0.1.0.dist-info/WHEEL +5 -0
- kiwimatecoder-0.1.0.dist-info/entry_points.txt +2 -0
- kiwimatecoder-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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__}")
|