bone-agent 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +184 -0
  3. package/bin/npm-wrapper.js +235 -0
  4. package/bin/rg +0 -0
  5. package/bin/rg.exe +0 -0
  6. package/config.yaml.example +133 -0
  7. package/package.json +53 -0
  8. package/requirements.txt +9 -0
  9. package/src/__init__.py +11 -0
  10. package/src/core/__init__.py +1 -0
  11. package/src/core/agentic.py +1054 -0
  12. package/src/core/chat_manager.py +1552 -0
  13. package/src/core/config_manager.py +247 -0
  14. package/src/core/cron.py +527 -0
  15. package/src/core/cron_allowlist.py +118 -0
  16. package/src/core/memory.py +232 -0
  17. package/src/core/retry.py +71 -0
  18. package/src/core/sub_agent.py +326 -0
  19. package/src/core/tool_approval.py +220 -0
  20. package/src/core/tool_feedback.py +778 -0
  21. package/src/exceptions.py +79 -0
  22. package/src/llm/__init__.py +1 -0
  23. package/src/llm/client.py +171 -0
  24. package/src/llm/config.py +466 -0
  25. package/src/llm/prompts.py +735 -0
  26. package/src/llm/providers.py +417 -0
  27. package/src/llm/streaming.py +163 -0
  28. package/src/llm/token_tracker.py +368 -0
  29. package/src/tools/__init__.py +212 -0
  30. package/src/tools/constants.py +59 -0
  31. package/src/tools/create_file.py +136 -0
  32. package/src/tools/directory.py +389 -0
  33. package/src/tools/edit.py +543 -0
  34. package/src/tools/file_reader.py +322 -0
  35. package/src/tools/helpers/__init__.py +105 -0
  36. package/src/tools/helpers/base.py +550 -0
  37. package/src/tools/helpers/converters.py +44 -0
  38. package/src/tools/helpers/file_helpers.py +189 -0
  39. package/src/tools/helpers/formatters.py +411 -0
  40. package/src/tools/helpers/loader.py +231 -0
  41. package/src/tools/helpers/parallel_executor.py +231 -0
  42. package/src/tools/helpers/path_resolver.py +226 -0
  43. package/src/tools/helpers/plugin_manifest.py +156 -0
  44. package/src/tools/obsidian.py +96 -0
  45. package/src/tools/review_sub_agent.py +189 -0
  46. package/src/tools/rg_search.py +393 -0
  47. package/src/tools/search_plugins.py +109 -0
  48. package/src/tools/select_option.py +593 -0
  49. package/src/tools/shell.py +302 -0
  50. package/src/tools/sub_agent.py +139 -0
  51. package/src/tools/task_list.py +269 -0
  52. package/src/tools/web_search.py +61 -0
  53. package/src/ui/__init__.py +1 -0
  54. package/src/ui/banner.py +87 -0
  55. package/src/ui/commands.py +2694 -0
  56. package/src/ui/displays.py +213 -0
  57. package/src/ui/loader.py +284 -0
  58. package/src/ui/main.py +646 -0
  59. package/src/ui/prompt_utils.py +113 -0
  60. package/src/ui/setting_selector.py +590 -0
  61. package/src/ui/setup_wizard.py +294 -0
  62. package/src/ui/sub_agent_panel.py +234 -0
  63. package/src/ui/tool_confirmation.py +215 -0
  64. package/src/utils/__init__.py +1 -0
  65. package/src/utils/citation_parser.py +199 -0
  66. package/src/utils/editor.py +158 -0
  67. package/src/utils/gitignore_filter.py +149 -0
  68. package/src/utils/logger.py +254 -0
  69. package/src/utils/paths.py +30 -0
  70. package/src/utils/result_parsers.py +108 -0
  71. package/src/utils/safe_commands.py +243 -0
  72. package/src/utils/settings.py +174 -0
  73. package/src/utils/validation.py +191 -0
  74. package/src/utils/web_search.py +173 -0
@@ -0,0 +1,2694 @@
1
+ """Command routing and help display."""
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+ from typing import Optional
6
+ from llm import config
7
+
8
+ from core.config_manager import ConfigManager as ConfigManagerClass
9
+ from ui.displays import show_help_table, show_cron_help_table
10
+ from ui.banner import display_startup_banner
11
+ from core.agentic import SubAgentPanel
12
+ from ui.setting_selector import SettingSelector, SettingCategory, SettingOption
13
+
14
+ from utils.settings import MonokaiDarkBGStyle, context_settings, left_align_headings, tool_settings
15
+ from rich.markdown import Markdown
16
+ from rich.table import Table
17
+
18
+ from rich import box
19
+ from pathlib import Path
20
+ import json
21
+ import logging
22
+ import ssl
23
+ import urllib.request
24
+ import urllib.error
25
+ from utils.validation import validate_api_url
26
+
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ # Global ConfigManager instance
31
+ config_manager = ConfigManagerClass()
32
+
33
+
34
+ @dataclass
35
+ class CommandResult:
36
+ """Standardized command return type."""
37
+ status: str # "exit", "handled", or "continue"
38
+ replacement_input: Optional[str] = None # For /edit command
39
+
40
+ # Command handler functions
41
+
42
+ def _handle_exit(chat_manager, console, debug_mode_container, args, cron_scheduler=None):
43
+ """Handle exit command."""
44
+ return CommandResult(status="exit")
45
+
46
+
47
+ def _handle_setup(chat_manager, console, debug_mode_container, args, cron_scheduler=None):
48
+ """Re-run the first-run setup wizard."""
49
+ from ui.setup_wizard import run_wizard as _run_setup_wizard
50
+ _run_setup_wizard(console)
51
+ # Reload config so changes take effect immediately
52
+ try:
53
+ from llm import config as llm_config
54
+ llm_config.reload_config()
55
+ except Exception:
56
+ pass
57
+ return CommandResult(status="handled")
58
+
59
+
60
+ _CRON_ADD_EXAMPLES = [
61
+ "[dim] /cron add morning_brief weekdays at 8am Give me a morning briefing[/dim]",
62
+ "[dim] /cron add cleanup every 1 hour Clean up temp files[/dim]",
63
+ "[dim] /cron add news daily at 5am Draft an email with AI news[/dim]",
64
+ ]
65
+
66
+
67
+ def _print_cron_add_usage(console, prefix=None):
68
+ """Print /cron add usage and examples."""
69
+ if prefix:
70
+ console.print(prefix)
71
+ console.print("[red]Usage: /cron add <id> <schedule> <command>[/red]")
72
+ console.print("[dim]Examples:[/dim]")
73
+ for line in _CRON_ADD_EXAMPLES:
74
+ console.print(line)
75
+
76
+
77
+ def _cron_list(console, cron_config):
78
+ """Display all cron jobs in a table."""
79
+ if not cron_config.jobs:
80
+ console.print("[dim]No cron jobs defined. Use [bold #5F9EA0]/cron help[/bold #5F9EA0] to get started.[/dim]")
81
+ return CommandResult(status="handled")
82
+
83
+ table = Table(
84
+ title="Cron Jobs",
85
+ box=box.SIMPLE_HEAVY,
86
+ title_style="bold #5F9EA0",
87
+ border_style="grey23",
88
+ show_header=True,
89
+ header_style="bold",
90
+ )
91
+ table.add_column("ID", style="bold white")
92
+ table.add_column("Schedule")
93
+ table.add_column("Command", max_width=40, no_wrap=False)
94
+ table.add_column("Status", justify="center")
95
+ table.add_column("Last Run", justify="right")
96
+
97
+ for job in cron_config.jobs.values():
98
+ status = "[green]ON[/green]" if job.enabled else "[red]OFF[/red]"
99
+ last = job.last_run or "[dim]never[/dim]"
100
+ cmd_display = job.command[:37] + "..." if len(job.command) > 40 else job.command
101
+ table.add_row(job.id, job.schedule, cmd_display, status, last)
102
+
103
+ console.print(table)
104
+ return CommandResult(status="handled")
105
+
106
+
107
+ def _cron_add(console, sub_args, cron_config, notify_scheduler):
108
+ """Add a new cron job."""
109
+ from core.cron import CronJob, parse_schedule
110
+
111
+ tokens = sub_args.strip().split()
112
+ if len(tokens) < 3:
113
+ _print_cron_add_usage(console)
114
+ return CommandResult(status="handled")
115
+
116
+ job_id = tokens[0]
117
+ # Greedily consume tokens for the schedule (min 1, max 6 words)
118
+ schedule = None
119
+ cmd_start = 2
120
+ for end in range(2, min(len(tokens) + 1, 8)):
121
+ candidate = " ".join(tokens[1:end])
122
+ try:
123
+ parse_schedule(candidate)
124
+ schedule = candidate
125
+ cmd_start = end
126
+ break
127
+ except ValueError:
128
+ continue
129
+
130
+ if schedule is None:
131
+ attempted = " ".join(tokens[1:min(len(tokens), 7)])
132
+ _print_cron_add_usage(console, prefix=f"[red]Cannot parse schedule: '{attempted}'[/red]")
133
+ return CommandResult(status="handled")
134
+
135
+ command = " ".join(tokens[cmd_start:])
136
+
137
+ if not re.match(r'^[a-zA-Z0-9_-]+$', job_id):
138
+ console.print(f"[red]Invalid job ID '{job_id}'. Use only letters, numbers, underscores, and hyphens.[/red]")
139
+ return CommandResult(status="handled")
140
+
141
+ if job_id in cron_config.jobs:
142
+ console.print(f"[red]Job '{job_id}' already exists. Remove it first or use a different ID.[/red]")
143
+ return CommandResult(status="handled")
144
+
145
+ job = CronJob(id=job_id, schedule=schedule, command=command)
146
+ cron_config.add_job(job)
147
+ notify_scheduler()
148
+ console.print(f"[green]Added cron job '{job_id}' (schedule: {schedule})[/green]")
149
+ return CommandResult(status="handled")
150
+
151
+
152
+ def _cron_remove(console, sub_args, cron_config, notify_scheduler):
153
+ """Remove a cron job by ID."""
154
+ job_id = sub_args.strip()
155
+ if not job_id:
156
+ console.print("[red]Usage: /cron remove <id>[/red]")
157
+ return CommandResult(status="handled")
158
+ if cron_config.remove_job(job_id):
159
+ notify_scheduler()
160
+ console.print(f"[green]Removed cron job '{job_id}'[/green]")
161
+ else:
162
+ console.print(f"[red]Job '{job_id}' not found[/red]")
163
+ return CommandResult(status="handled")
164
+
165
+
166
+ def _cron_toggle(console, sub_args, cron_config, notify_scheduler, enable):
167
+ """Enable or disable a cron job."""
168
+ verb = "enable" if enable else "disable"
169
+ style = "green" if enable else "yellow"
170
+ job_id = sub_args.strip()
171
+ if not job_id:
172
+ console.print(f"[red]Usage: /cron {verb} <id>[/red]")
173
+ return CommandResult(status="handled")
174
+ if job_id in cron_config.jobs:
175
+ cron_config.update_job(job_id, enabled=enable)
176
+ notify_scheduler()
177
+ console.print(f"[{style}]{verb.title()}d '{job_id}'[/{style}]")
178
+ else:
179
+ console.print(f"[red]Job '{job_id}' not found[/red]")
180
+ return CommandResult(status="handled")
181
+
182
+
183
+ def _cron_run(console, sub_args, cron_config):
184
+ """Manually trigger a cron job immediately."""
185
+ from core.cron import run_single_job
186
+ from datetime import datetime
187
+
188
+ job_id = sub_args.strip()
189
+ if not job_id:
190
+ console.print("[red]Usage: /cron run <id>[/red]")
191
+ return CommandResult(status="handled")
192
+ job = cron_config.get_job(job_id)
193
+ if not job:
194
+ console.print(f"[red]Job '{job_id}' not found[/red]")
195
+ return CommandResult(status="handled")
196
+ console.print(f"[#5F9EA0]Running cron job '{job_id}'...[/#5F9EA0]")
197
+ try:
198
+ run_single_job(job, console=console, interactive=True)
199
+ cron_config.update_job(job_id, last_run=datetime.now().isoformat(), last_status="ok")
200
+ console.print(f"[green]Job '{job_id}' completed.[/green]")
201
+ except Exception as e:
202
+ cron_config.update_job(job_id, last_status="error")
203
+ console.print(f"[red]Job '{job_id}' failed: {e}[/red]")
204
+ return CommandResult(status="handled")
205
+
206
+
207
+ def _cron_allowlist(console, sub_args):
208
+ """Manage cron command allowlists."""
209
+ from core.cron_allowlist import CronAllowlist
210
+ allowlist = CronAllowlist()
211
+
212
+ allow_parts = sub_args.strip().split(maxsplit=1)
213
+ allow_sub = allow_parts[0].lower() if allow_parts else ""
214
+ allow_rest = allow_parts[1] if len(allow_parts) > 1 else ""
215
+
216
+ if allow_sub == "list" and allow_rest:
217
+ jid = allow_rest.strip()
218
+ cmds = allowlist.get_commands(jid)
219
+ if not cmds:
220
+ console.print(f"[dim]No allowed commands for '{jid}'.[/dim]")
221
+ return CommandResult(status="handled")
222
+ console.print(f"[bold]{jid}[/bold] ({len(cmds)} command{'s' if len(cmds) != 1 else ''})")
223
+ for cmd in cmds:
224
+ console.print(f" [green]✓[/green] {cmd}")
225
+ return CommandResult(status="handled")
226
+
227
+ if not allow_sub or allow_sub == "list":
228
+ all_jobs = allowlist.all_jobs()
229
+ if not all_jobs:
230
+ console.print("[dim]No cron command allow lists. Run '/cron run <id>' to build one.[/dim]")
231
+ return CommandResult(status="handled")
232
+ for jid, cmds in all_jobs.items():
233
+ console.print(f"[bold]{jid}[/bold] ({len(cmds)} command{'s' if len(cmds) != 1 else ''})")
234
+ if cmds:
235
+ for cmd in cmds:
236
+ console.print(f" [green]✓[/green] {cmd}")
237
+ else:
238
+ console.print(" [dim](empty)[/dim]")
239
+ return CommandResult(status="handled")
240
+
241
+ if allow_sub == "add":
242
+ tokens = allow_rest.strip().split(maxsplit=1)
243
+ if len(tokens) < 2:
244
+ console.print("[red]Usage: /cron allowlist add <job_id> <command>[/red]")
245
+ return CommandResult(status="handled")
246
+ jid, cmd = tokens[0], tokens[1]
247
+ if allowlist.add_command(jid, cmd):
248
+ console.print(f"[green]Added '{cmd}' to '{jid}' allow list.[/green]")
249
+ else:
250
+ console.print(f"[dim]Command already in '{jid}' allow list.[/dim]")
251
+ return CommandResult(status="handled")
252
+
253
+ if allow_sub in ("remove", "rm"):
254
+ tokens = allow_rest.strip().split(maxsplit=1)
255
+ if len(tokens) < 2:
256
+ console.print("[red]Usage: /cron allowlist remove <job_id> <command>[/red]")
257
+ return CommandResult(status="handled")
258
+ jid, cmd = tokens[0], tokens[1]
259
+ if allowlist.remove_command(jid, cmd):
260
+ console.print(f"[green]Removed '{cmd}' from '{jid}' allow list.[/green]")
261
+ else:
262
+ console.print(f"[red]Command not found in '{jid}' allow list.[/red]")
263
+ return CommandResult(status="handled")
264
+
265
+ if allow_sub == "clear":
266
+ jid = allow_rest.strip()
267
+ if not jid:
268
+ console.print("[red]Usage: /cron allowlist clear <job_id>[/red]")
269
+ return CommandResult(status="handled")
270
+ count = allowlist.clear_job(jid)
271
+ console.print(f"[green]Cleared {count} command{'s' if count != 1 else ''} from '{jid}' allow list.[/green]")
272
+ return CommandResult(status="handled")
273
+
274
+ console.print(f"[red]Unknown allowlist sub-command: '{allow_sub}'[/red]")
275
+ console.print("[dim]Sub-commands: list [job_id], add, remove, clear[/dim]")
276
+ return CommandResult(status="handled")
277
+
278
+
279
+ def _handle_cron(chat_manager, console, debug_mode_container, args, cron_scheduler=None):
280
+ """Manage cron jobs: list, add, remove, enable, disable."""
281
+ from core.cron import CronConfig
282
+
283
+ if cron_scheduler is not None:
284
+ cron_config = cron_scheduler.config
285
+ else:
286
+ cron_config = CronConfig()
287
+
288
+ def notify_scheduler():
289
+ """Immediately reload scheduler config after a mutation."""
290
+ if cron_scheduler is not None:
291
+ cron_scheduler.reload()
292
+
293
+ if not args or args.strip() == "list":
294
+ return _cron_list(console, cron_config)
295
+
296
+ parts = args.strip().split(maxsplit=1)
297
+ sub_cmd = parts[0].lower()
298
+ sub_args = parts[1] if len(parts) > 1 else ""
299
+
300
+ dispatch = {
301
+ "add": lambda: _cron_add(console, sub_args, cron_config, notify_scheduler),
302
+ "remove": lambda: _cron_remove(console, sub_args, cron_config, notify_scheduler),
303
+ "rm": lambda: _cron_remove(console, sub_args, cron_config, notify_scheduler),
304
+ "enable": lambda: _cron_toggle(console, sub_args, cron_config, notify_scheduler, enable=True),
305
+ "disable": lambda: _cron_toggle(console, sub_args, cron_config, notify_scheduler, enable=False),
306
+ "run": lambda: _cron_run(console, sub_args, cron_config),
307
+ "allowlist": lambda: _cron_allowlist(console, sub_args),
308
+ "help": lambda: show_cron_help_table(console) or CommandResult(status="handled"),
309
+ }
310
+
311
+ handler = dispatch.get(sub_cmd)
312
+ if handler:
313
+ return handler()
314
+
315
+ console.print(f"[red]Unknown cron sub-command: '{sub_cmd}'[/red]")
316
+ console.print("[dim]Sub-commands: list, add, remove, enable, disable, run, allowlist, help[/dim]")
317
+ return CommandResult(status="handled")
318
+
319
+
320
+ def _handle_help(chat_manager, console, debug_mode_container, args, cron_scheduler=None):
321
+ """Handle help command."""
322
+ show_help_table(console)
323
+ return CommandResult(status="handled")
324
+
325
+
326
+ def _handle_compact(chat_manager, console, debug_mode_container, args, cron_scheduler=None):
327
+ """Handle manual context compaction."""
328
+ # Show current context summary immediately using the same format as the status bar
329
+ num_messages = len(chat_manager.messages)
330
+ tokens_curr = chat_manager.token_tracker.current_context_tokens
331
+ console.print(
332
+ "Current context summary:"
333
+ f"\n Messages: {num_messages}"
334
+ f"\n Curr: {tokens_curr:,}"
335
+ )
336
+ console.print() # Spacer line
337
+
338
+ result = chat_manager.compact_history(console=console, trigger="manual")
339
+ if not result:
340
+ console.print("[yellow]Nothing to compact.[/yellow]")
341
+ return CommandResult(status="handled")
342
+
343
+ console.print(
344
+ f"[green]Session reset: "
345
+ f"{result['before_tokens']:,} -> {result['after_tokens']:,} tokens[/green]"
346
+ )
347
+
348
+ # Show the compacted summary in debug mode
349
+ if debug_mode_container.get('debug') and 'summary' in result:
350
+ console.print()
351
+ console.print("[#5F9EA0]Compacted summary:[/#5F9EA0]")
352
+ console.print(f"[dim]{result['summary']}[/dim]")
353
+ return CommandResult(status="handled")
354
+
355
+
356
+ def _handle_config(chat_manager, console, debug_mode_container, args, cron_scheduler=None):
357
+ """Handle config command - interactive runtime settings editor."""
358
+ from ui.setting_selector import SettingOption, SettingCategory, SettingSelector
359
+
360
+ # Build runtime settings from current state
361
+ runtime_settings = [
362
+ SettingOption(
363
+ key="debug", text="Debug Mode",
364
+ value=bool(debug_mode_container.get("debug")),
365
+ input_type="boolean",
366
+ on_text="ON", off_text="OFF",
367
+ ),
368
+ SettingOption(
369
+ key="logging", text="Conversation Logging",
370
+ value=bool(chat_manager.markdown_logger),
371
+ input_type="boolean",
372
+ on_text="ON", off_text="OFF",
373
+ ),
374
+ SettingOption(
375
+ key="approve", text="Approval Mode",
376
+ value=chat_manager.approve_mode,
377
+ input_type="select",
378
+ options=[
379
+ {"value": "safe", "text": "SAFE"},
380
+ {"value": "accept_edits", "text": "ACCEPT EDITS"},
381
+ {"value": "danger", "text": "DANGER"},
382
+ ],
383
+ ),
384
+ ]
385
+
386
+ # Build status bar settings
387
+ sb_config = config.STATUS_BAR_SETTINGS
388
+ sb_settings = [
389
+ SettingOption(
390
+ key="show_curr_tokens", text="Current context tokens",
391
+ value=sb_config.get("show_curr_tokens", True), input_type="boolean",
392
+ ),
393
+ SettingOption(
394
+ key="show_in_tokens", text="Total prompt tokens",
395
+ value=sb_config.get("show_in_tokens", True), input_type="boolean",
396
+ ),
397
+ SettingOption(
398
+ key="show_out_tokens", text="Total completion tokens",
399
+ value=sb_config.get("show_out_tokens", True), input_type="boolean",
400
+ ),
401
+ SettingOption(
402
+ key="show_total_tokens", text="Total session tokens",
403
+ value=sb_config.get("show_total_tokens", True), input_type="boolean",
404
+ ),
405
+ SettingOption(
406
+ key="show_cost", text="Session cost",
407
+ value=sb_config.get("show_cost", True), input_type="boolean",
408
+ ),
409
+ ]
410
+
411
+ # Build context/compaction settings
412
+ ctx_settings = [
413
+ SettingOption(
414
+ key="compact_trigger_tokens", text="Compaction Threshold",
415
+ value=context_settings.compact_trigger_tokens,
416
+ input_type="number",
417
+ ),
418
+ SettingOption(
419
+ key="enable_tool_compaction", text="Per-Message Tool Compaction",
420
+ value=context_settings.tool_compaction.enable_per_message_compaction,
421
+ input_type="boolean",
422
+ on_text="ON", off_text="OFF",
423
+ ),
424
+ SettingOption(
425
+ key="uncompacted_tail_tokens", text="Uncompacted Tail Tokens",
426
+ value=context_settings.tool_compaction.uncompacted_tail_tokens,
427
+ input_type="number",
428
+ ),
429
+ SettingOption(
430
+ key="min_tool_blocks", text="Min Tool Blocks Preserved",
431
+ value=context_settings.tool_compaction.min_tool_blocks,
432
+ input_type="number",
433
+ ),
434
+ ]
435
+
436
+ categories = [
437
+ SettingCategory(title="Runtime Settings", settings=runtime_settings),
438
+ SettingCategory(title="Context Settings", settings=ctx_settings),
439
+ SettingCategory(title="Status Bar Items", settings=sb_settings),
440
+ ]
441
+ selector = SettingSelector(
442
+ categories=categories,
443
+ title="Configuration",
444
+ )
445
+
446
+ changes = selector.run()
447
+
448
+ # Selector manages its own rendering; just print a separator on dismissal
449
+ console.print()
450
+
451
+ if changes is None:
452
+ console.print("[dim]Cancelled.[/dim]")
453
+ return CommandResult(status="handled")
454
+
455
+ if not changes:
456
+ console.print("[dim]No changes made.[/dim]")
457
+ return CommandResult(status="handled")
458
+
459
+ # Apply changes
460
+ change_lines = []
461
+ sb_changes = {}
462
+ sb_labels = {s.key: s.text for s in sb_settings}
463
+ for key, value in changes.items():
464
+ if key == "debug":
465
+ debug_mode_container['debug'] = value
466
+ state = "enabled" if value else "disabled"
467
+ change_lines.append(f" Debug Mode: {state}")
468
+ elif key == "logging":
469
+ chat_manager.set_logging(value)
470
+ state = "enabled" if value else "disabled"
471
+ change_lines.append(f" Conversation Logging: {state}")
472
+ elif key == "approve":
473
+ chat_manager.approve_mode = value
474
+ labels = {"safe": "SAFE", "accept_edits": "ACCEPT EDITS", "danger": "DANGER"}
475
+ change_lines.append(f" Approval Mode: {labels.get(value, value.upper())}")
476
+ if value == "danger":
477
+ console.print()
478
+ console.print("[bold red on default] WARNING: DANGER MODE ENABLED[/bold red on default]")
479
+ console.print("[bold red on default] All commands will be auto-approved.[/bold red on default]")
480
+ console.print("[bold red on default] Dangerous git commands are still blocked.[/bold red on default]")
481
+ console.print("[bold yellow on default] Use at your own risk![/bold yellow on default]")
482
+ console.print()
483
+ elif key == "compact_trigger_tokens":
484
+ context_settings.compact_trigger_tokens = int(value)
485
+ change_lines.append(f" Compaction Threshold: {value:,} tokens")
486
+ elif key == "enable_tool_compaction":
487
+ context_settings.tool_compaction.enable_per_message_compaction = value
488
+ state = "enabled" if value else "disabled"
489
+ change_lines.append(f" Per-Message Tool Compaction: {state}")
490
+ elif key == "uncompacted_tail_tokens":
491
+ context_settings.tool_compaction.uncompacted_tail_tokens = int(value)
492
+ change_lines.append(f" Uncompacted Tail Tokens: {value:,}")
493
+ elif key == "min_tool_blocks":
494
+ context_settings.tool_compaction.min_tool_blocks = int(value)
495
+ change_lines.append(f" Min Tool Blocks Preserved: {value}")
496
+ elif key in sb_labels:
497
+ sb_changes[key] = value
498
+ state = "ON" if value else "OFF"
499
+ change_lines.append(f" {sb_labels[key]}: {state}")
500
+
501
+ # Persist context setting changes to config
502
+ ctx_changes = {k: v for k, v in changes.items() if k in ("compact_trigger_tokens", "enable_tool_compaction", "uncompacted_tail_tokens", "min_tool_blocks")}
503
+ if ctx_changes:
504
+ try:
505
+ cfg_data = config_manager.load(force_reload=True)
506
+ if "CONTEXT_SETTINGS" not in cfg_data:
507
+ cfg_data["CONTEXT_SETTINGS"] = {}
508
+ if "tool_compaction" not in cfg_data["CONTEXT_SETTINGS"]:
509
+ cfg_data["CONTEXT_SETTINGS"]["tool_compaction"] = {}
510
+ if "compact_trigger_tokens" in ctx_changes:
511
+ cfg_data["CONTEXT_SETTINGS"]["compact_trigger_tokens"] = int(ctx_changes["compact_trigger_tokens"])
512
+ if "enable_tool_compaction" in ctx_changes:
513
+ cfg_data["CONTEXT_SETTINGS"]["tool_compaction"]["enable_per_message_compaction"] = ctx_changes["enable_tool_compaction"]
514
+ if "uncompacted_tail_tokens" in ctx_changes:
515
+ cfg_data["CONTEXT_SETTINGS"]["tool_compaction"]["uncompacted_tail_tokens"] = int(ctx_changes["uncompacted_tail_tokens"])
516
+ if "min_tool_blocks" in ctx_changes:
517
+ cfg_data["CONTEXT_SETTINGS"]["tool_compaction"]["min_tool_blocks"] = int(ctx_changes["min_tool_blocks"])
518
+ config_manager.save(cfg_data)
519
+ except Exception as e:
520
+ console.print(f"[red]Failed to save context settings: {e}[/red]")
521
+
522
+ # Persist status bar changes to config
523
+ if sb_changes:
524
+ config.update_status_bar_settings(sb_changes)
525
+ try:
526
+ cfg_data = config_manager.load(force_reload=True)
527
+ if "STATUS_BAR_SETTINGS" not in cfg_data:
528
+ cfg_data["STATUS_BAR_SETTINGS"] = {}
529
+ cfg_data["STATUS_BAR_SETTINGS"].update(sb_changes)
530
+ config_manager.save(cfg_data)
531
+ except Exception as e:
532
+ console.print(f"[red]Failed to save status bar settings: {e}[/red]")
533
+
534
+ # Display summary
535
+ console.print(f"[green]Settings updated:[/green]")
536
+ for line in change_lines:
537
+ console.print(line)
538
+
539
+ return CommandResult(status="handled")
540
+
541
+
542
+ def _handle_clear(chat_manager, console, debug_mode_container, args, cron_scheduler=None):
543
+ """Handle clear/reset command."""
544
+ # Display conversation cost for the previous chat
545
+ costs = config_manager.get_usage_costs()
546
+
547
+ # Display token summary for the previous chat
548
+ current_tokens = chat_manager.token_tracker.current_context_tokens
549
+ conv_in = chat_manager.token_tracker.conv_prompt_tokens
550
+ conv_out = chat_manager.token_tracker.conv_completion_tokens
551
+ conv_total = chat_manager.token_tracker.conv_total_tokens
552
+
553
+ console.print()
554
+ console.print("Conversation Summary:")
555
+ console.print(f" Current Context: {current_tokens:,} tokens")
556
+ console.print(f" In: {conv_in:,} tokens")
557
+ console.print(f" Out: {conv_out:,} tokens")
558
+ console.print(f" Total: {conv_total:,} tokens")
559
+
560
+ # Show cache token breakdown if any cache was used
561
+ conv_cache_read = chat_manager.token_tracker.conv_cache_read_tokens
562
+ conv_cache_creation = chat_manager.token_tracker.conv_cache_creation_tokens
563
+ if conv_cache_read > 0 or conv_cache_creation > 0:
564
+ cache_hit_pct = (
565
+ conv_cache_read / conv_in * 100
566
+ ) if conv_in > 0 else 0
567
+ console.print(f" Cache read: {conv_cache_read:,} tokens")
568
+ console.print(f" Cache write: {conv_cache_creation:,} tokens")
569
+ console.print(f" ({cache_hit_pct:.0f}% of input served from cache)")
570
+
571
+ # Display cost — combined actual + estimated, with config-based fallback
572
+ tracker_conv = chat_manager.token_tracker
573
+ if tracker_conv.has_actual_cost():
574
+ conv_cost = tracker_conv.conv_actual_cost + tracker_conv.conv_estimated_cost
575
+ else:
576
+ conv_cost = tracker_conv.get_conversation_display_cost(costs['in'], costs['out'])
577
+ if conv_cost > 0:
578
+ console.print(f" Cost: ${conv_cost:.4f}")
579
+
580
+ console.print()
581
+
582
+ chat_manager.reset_session()
583
+ display_startup_banner(chat_manager.approve_mode, clear_screen=True)
584
+ return CommandResult(status="handled")
585
+
586
+
587
+ def _open_provider_editor(chat_manager, console, provider):
588
+ """Open interactive setting editor for a specific provider.
589
+
590
+ Args:
591
+ chat_manager: ChatManager instance
592
+ console: Rich console for output
593
+ provider: Provider name (e.g. 'openrouter', 'glm')
594
+
595
+ Returns:
596
+ True if settings were saved, False if cancelled
597
+ """
598
+ from ui.setting_selector import SettingOption, SettingCategory, SettingSelector
599
+
600
+ cfg = config.get_provider_config(provider)
601
+ config_data = config_manager.load()
602
+ settings = []
603
+
604
+ # Model setting
605
+ current_model = cfg.get('model') or cfg.get('api_model') or ''
606
+ model_label = "Model path" if provider == "local" else "Model"
607
+ settings.append(SettingOption(
608
+ key="model", text=model_label,
609
+ value=current_model, input_type="text",
610
+ ))
611
+
612
+ # API key (not for local or bone — bone manages its own key via /signup)
613
+ if provider not in ("local", "bone"):
614
+ current_key = cfg.get('api_key', '')
615
+ # Show masked value, store actual in description for comparison
616
+ masked = (current_key[:8] + "...") if len(current_key) > 8 else (current_key or "")
617
+ settings.append(SettingOption(
618
+ key="api_key", text="API Key",
619
+ value=masked, input_type="text",
620
+ description=current_key,
621
+ ))
622
+
623
+ # Cost in/out (not for local or bone — costs are server-side)
624
+ if provider not in ("local", "bone"):
625
+ model_prices = config_data.get("MODEL_PRICES", {})
626
+ existing = model_prices.get(current_model, {})
627
+ settings.append(SettingOption(
628
+ key="cost_in", text="Cost in ($/1M tokens)",
629
+ value=existing.get('cost_in', 0.0), input_type="float",
630
+ min_val=0.0, step=0.01,
631
+ ))
632
+ settings.append(SettingOption(
633
+ key="cost_out", text="Cost out ($/1M tokens)",
634
+ value=existing.get('cost_out', 0.0), input_type="float",
635
+ min_val=0.0, step=0.01,
636
+ ))
637
+
638
+ category = SettingCategory(title=f"{provider.capitalize()} Settings", settings=settings)
639
+
640
+ selector = SettingSelector(
641
+ categories=[category],
642
+ title=f"Configure {provider.capitalize()}",
643
+ )
644
+
645
+ changes = selector.run()
646
+
647
+ if changes is None:
648
+ console.print("[dim]No changes made.[/dim]")
649
+ return False
650
+
651
+ # Apply changes
652
+ change_lines = []
653
+
654
+ if "model" in changes and changes["model"]:
655
+ try:
656
+ config_manager.set_model(provider, changes["model"])
657
+ change_lines.append(f" Model: {changes['model']}")
658
+ except Exception as e:
659
+ console.print(f"[red]Failed to set model: {e}[/red]")
660
+
661
+ if "api_key" in changes and changes["api_key"]:
662
+ # Don't re-save if the user didn't actually change it (masked display)
663
+ api_key_input = changes["api_key"]
664
+ original_key = cfg.get('api_key', '')
665
+ # Detect if user typed a real key (longer than masked display or different)
666
+ if api_key_input != original_key and not api_key_input.endswith("..."):
667
+ try:
668
+ config_manager.set_api_key(provider, api_key_input)
669
+ masked = (api_key_input[:8] + "...") if len(api_key_input) > 8 else api_key_input
670
+ change_lines.append(f" API Key: {masked}")
671
+ except Exception as e:
672
+ console.print(f"[red]Failed to set API key: {e}[/red]")
673
+
674
+ if "cost_in" in changes or "cost_out" in changes:
675
+ model_name = changes.get("model") or current_model
676
+ if model_name:
677
+ # Use changed values, falling back to originals (not 0.0)
678
+ existing_prices = config_data.get("MODEL_PRICES", {}).get(model_name, {})
679
+ cost_in = changes.get("cost_in", existing_prices.get("cost_in", 0.0))
680
+ cost_out = changes.get("cost_out", existing_prices.get("cost_out", 0.0))
681
+ try:
682
+ config_manager.set_model_price(model_name, cost_in, cost_out)
683
+ change_lines.append(f" Cost: ${cost_in:.2f}/${cost_out:.2f} per 1M tokens")
684
+ except Exception as e:
685
+ console.print(f"[red]Failed to set pricing: {e}[/red]")
686
+
687
+ # Reload config and switch provider
688
+ config_manager.set_provider(provider)
689
+ chat_manager.reload_config()
690
+ result = chat_manager.switch_provider(provider)
691
+
692
+ if change_lines:
693
+ console.print(f"[green]{provider} updated:[/green]")
694
+ for line in change_lines:
695
+ console.print(line)
696
+ else:
697
+ console.print(f"[green]{provider} activated.[/green]")
698
+
699
+ if "Failed" not in result and "failed" not in result:
700
+ console.print(f"[dim]{result}[/dim]")
701
+
702
+ return True
703
+
704
+
705
+ def _handle_provider(chat_manager, console, debug_mode_container, args, cron_scheduler=None):
706
+ """Handle provider switching and configuration command."""
707
+ current = getattr(chat_manager.client, 'provider', 'unknown')
708
+
709
+ if args:
710
+ provider = args.strip().lower()
711
+
712
+ # Validate provider name
713
+ if provider not in config.get_providers():
714
+ console.print(f"[red]Error: Unknown provider '{provider}'[/red]")
715
+ console.print(f"[dim]Available providers: {', '.join(config.get_providers())}[/dim]")
716
+ return CommandResult(status="handled")
717
+
718
+ # Switch directly to the named provider
719
+ if provider == current:
720
+ console.print(f"[dim]Already on {provider}[/dim]")
721
+ return CommandResult(status="handled")
722
+
723
+ config_manager.set_provider(provider)
724
+ chat_manager.reload_config()
725
+ result = chat_manager.switch_provider(provider)
726
+
727
+ cfg = config.get_provider_config(provider)
728
+ model = cfg.get('model') or cfg.get('api_model') or ''
729
+ label = f"{provider.capitalize()}"
730
+ if model:
731
+ label += f" ({model})"
732
+ console.print(f"[green]Switched to {label}[/green]")
733
+ if "Failed" not in result and "failed" not in result:
734
+ console.print(f"[dim]{result}[/dim]")
735
+
736
+ return CommandResult(status="handled")
737
+ else:
738
+ # Show all providers as a radio-button list (same style as model selector)
739
+ from ui.setting_selector import SettingOption, SettingCategory, SettingSelector
740
+
741
+ provider_options = []
742
+ for prov in config.get_providers():
743
+ cfg = config.get_provider_config(prov)
744
+ model = cfg.get('model') or cfg.get('api_model') or ''
745
+ entry = {"value": prov, "text": prov.capitalize()}
746
+ if model:
747
+ entry["description"] = model[:40]
748
+ provider_options.append(entry)
749
+
750
+ provider_setting = SettingOption(
751
+ key="provider",
752
+ text="Select Provider",
753
+ value=current,
754
+ input_type="options",
755
+ options=provider_options,
756
+ )
757
+
758
+ selector = SettingSelector(
759
+ categories=[SettingCategory(title="", settings=[provider_setting])],
760
+ title="",
761
+ show_save=False,
762
+ )
763
+ result = selector.run()
764
+
765
+ if result is None:
766
+ console.print("[dim]Cancelled.[/dim]")
767
+ return CommandResult(status="handled")
768
+
769
+ # Get selected provider (from changes, or current if unchanged)
770
+ provider = result.get('provider', current)
771
+
772
+ # Open interactive editor for the selected provider
773
+ _open_provider_editor(chat_manager, console, provider)
774
+
775
+ return CommandResult(status="handled")
776
+
777
+
778
+ def _handle_model(chat_manager, console, debug_mode_container, args, cron_scheduler=None):
779
+ """Handle model setting command."""
780
+ current_provider = getattr(chat_manager.client, 'provider', 'unknown')
781
+
782
+ # For bone provider, show interactive model selection
783
+ if current_provider == "bone" and not args:
784
+ from ui.setting_selector import SettingOption, SettingCategory, SettingSelector
785
+
786
+ cfg = config.get_provider_config(current_provider)
787
+ current_model = cfg.get('model') or cfg.get('api_model') or ''
788
+
789
+ # Models available via bone proxy (OpenRouter-compatible)
790
+ # Format: (display_name, openrouter_model_id)
791
+ bone_models = [
792
+ # DeepSeek
793
+ ("DeepSeek-V3.2 1×", "deepseek/deepseek-v3.2"),
794
+ # MiniMax
795
+ ("MiniMax-M2.5 1×", "minimax/minimax-m2.5"),
796
+ ("MiniMax-M2.7 1.5×", "minimax/minimax-m2.7"),
797
+ # Moonshot AI
798
+ ("Kimi-K2.5 3×", "moonshotai/kimi-k2.5"),
799
+ # xAI
800
+ ("Grok-Code-Fast-1 1.5×", "x-ai/grok-code-fast-1"),
801
+ ("Grok-4.1-Fast 1×", "x-ai/grok-4.1-fast"),
802
+ # Z-AI
803
+ ("GLM-4.5-Air (Free) 0×", "z-ai/glm-4.5-air:free"),
804
+ ("GLM-4.7 3×", "z-ai/glm-4.7"),
805
+ ("GLM-5 5×", "z-ai/glm-5"),
806
+ ("GLM-5-Turbo 10×", "z-ai/glm-5-turbo"),
807
+ ("GLM-5.1 10×", "z-ai/glm-5.1"),
808
+ ]
809
+
810
+ model_options = []
811
+ active_value = current_model
812
+ for display_name, model_id in bone_models:
813
+ if model_id == current_model or display_name.lower() == current_model.lower():
814
+ active_value = model_id
815
+ model_options.append({
816
+ "value": model_id,
817
+ "text": display_name,
818
+ })
819
+
820
+ model_setting = SettingOption(
821
+ key="model",
822
+ text="Select Model",
823
+ value=active_value,
824
+ input_type="options",
825
+ options=model_options,
826
+ )
827
+
828
+ selector = SettingSelector(
829
+ categories=[SettingCategory(title="", settings=[model_setting])],
830
+ title="",
831
+ show_save=False,
832
+ )
833
+ result = selector.run()
834
+
835
+ if result is None or not isinstance(result, dict) or 'model' not in result:
836
+ console.print("[dim]Cancelled.[/dim]")
837
+ return CommandResult(status="handled")
838
+
839
+ model = result['model']
840
+ elif not args:
841
+ # Show current model for current provider
842
+ cfg = config.get_provider_config(current_provider)
843
+ model = cfg.get('model') or cfg.get('api_model') or 'Not set'
844
+ console.print(f"[bold #5F9EA0]Current provider:[/bold #5F9EA0] {current_provider}")
845
+ console.print(f"[bold #5F9EA0]Current model:[/bold #5F9EA0] {model}")
846
+ return CommandResult(status="handled")
847
+ else:
848
+ model = args.strip()
849
+
850
+ # Set model for current provider
851
+ try:
852
+ backup_path = config_manager.set_model(current_provider, model)
853
+ console.print(f"[green]Model set to '{model}' for {current_provider} provider[/green]")
854
+ if backup_path:
855
+ console.print(f"[dim]Saved to config.json (backup: {backup_path.name})[/dim]")
856
+
857
+ # Reload config and update client
858
+ chat_manager.reload_config()
859
+ except ValueError as e:
860
+ console.print(f"[red]Error: {e}[/red]")
861
+ except Exception as e:
862
+ console.print(f"[red]Failed to set model: {e}[/red]")
863
+
864
+ return CommandResult(status="handled")
865
+
866
+
867
+ def _handle_key(chat_manager, console, debug_mode_container, args, cron_scheduler=None):
868
+ """Handle API key setting command."""
869
+ if not args:
870
+ # Show current API key status for current provider
871
+ current_provider = getattr(chat_manager.client, 'provider', 'unknown')
872
+ cfg = config.get_provider_config(current_provider)
873
+
874
+ if current_provider == "local":
875
+ console.print("[yellow]Local provider doesn't use API keys[/yellow]")
876
+ else:
877
+ api_key = cfg.get('api_key', '')
878
+ if api_key:
879
+ # Show masked API key
880
+ masked = api_key[:8] + "..." if len(api_key) > 8 else "***"
881
+ console.print(f"[bold #5F9EA0]Current provider:[/bold #5F9EA0] {current_provider}")
882
+ console.print(f"[bold #5F9EA0]API key:[/bold #5F9EA0] {masked}")
883
+ else:
884
+ console.print(f"[bold #5F9EA0]Current provider:[/bold #5F9EA0] {current_provider}")
885
+ console.print("[yellow]API key not set[/yellow]")
886
+ return CommandResult(status="handled")
887
+
888
+ api_key = args.strip()
889
+
890
+ # Set API key for current provider
891
+ current_provider = getattr(chat_manager.client, 'provider', 'unknown')
892
+
893
+ if current_provider == "local":
894
+ console.print("[yellow]Local provider doesn't use API keys[/yellow]")
895
+ return CommandResult(status="handled")
896
+
897
+ try:
898
+ backup_path = config_manager.set_api_key(current_provider, api_key)
899
+ console.print(f"[green]API key set for {current_provider} provider[/green]")
900
+ if backup_path:
901
+ console.print(f"[dim]Saved to config.json (backup: {backup_path.name})[/dim]")
902
+
903
+ # Reload config and update client
904
+ chat_manager.reload_config()
905
+ except ValueError as e:
906
+ console.print(f"[red]Error: {e}[/red]")
907
+ except Exception as e:
908
+ console.print(f"[red]Failed to set API key: {e}[/red]")
909
+
910
+ return CommandResult(status="handled")
911
+
912
+
913
+
914
+ def _handle_edit(chat_manager, console, debug_mode_container, args, cron_scheduler=None):
915
+ """Handle external editor command for multi-line input.
916
+
917
+ Opens an external editor for composing prompts. After the editor closes,
918
+ the content is sent to the LLM.
919
+
920
+ Returns:
921
+ CommandResult: status="handled" if cancelled/failed
922
+ status="continue" with replacement_input to send to LLM
923
+ """
924
+ from utils.editor import open_editor_for_input
925
+
926
+ success, content = open_editor_for_input(
927
+ console,
928
+ debug_mode_container['debug']
929
+ )
930
+
931
+ if not success:
932
+ # Error already displayed by open_editor_for_input
933
+ return CommandResult(status="handled")
934
+
935
+ # Check if content is empty
936
+ if not content or not content.strip():
937
+ console.print("[yellow]Editor closed with no content - cancelling[/yellow]")
938
+ return CommandResult(status="handled")
939
+
940
+ # Show summary
941
+ lines = [line for line in content.split('\n') if line.strip()]
942
+ word_count = len(content.split())
943
+ console.print(f"[green]Received {len(lines)} lines ({word_count} words) from editor[/green]")
944
+
945
+ # Return continue status to pass content to LLM
946
+ return CommandResult(status="continue", replacement_input=content)
947
+
948
+
949
+
950
+
951
+
952
+ def _handle_usage(chat_manager, console, debug_mode_container, args, cron_scheduler=None):
953
+ """Handle usage command - show/calculate token costs or set cost rates."""
954
+ console.print()
955
+
956
+ # Get current model
957
+ current_model = getattr(chat_manager.client, 'model', '')
958
+
959
+ if args:
960
+ # Parse setting command: in|out <value>
961
+ parts = args.split()
962
+
963
+ if len(parts) != 2 or parts[0].lower() not in ['in', 'out']:
964
+ console.print("[red]Usage: /usage in|out <cost>[/red]")
965
+ console.print("[dim]Cost is per 1M tokens (e.g., 0.5 = $0.50 per 1M tokens)[/dim]")
966
+ console.print("[dim]Examples:[/dim]")
967
+ console.print(f"[dim] /usage in 1.00 - Set input cost for current model ({current_model})[/dim]")
968
+ console.print(f"[dim] /usage out 3.20 - Set output cost for current model ({current_model})[/dim]")
969
+ console.print()
970
+ return CommandResult(status="handled")
971
+
972
+ direction, value = parts
973
+ direction = direction.lower()
974
+
975
+ try:
976
+ cost = float(value)
977
+ if cost < 0:
978
+ console.print("[red]Error: Cost must be non-negative[/red]")
979
+ console.print()
980
+ return CommandResult(status="handled")
981
+ except ValueError:
982
+ console.print("[red]Error: Cost must be a valid number[/red]")
983
+ console.print()
984
+ return CommandResult(status="handled")
985
+
986
+ # Set appropriate cost for current model
987
+ # Get existing prices for the model
988
+ existing_prices = config_manager.get_model_price(current_model)
989
+ cost_in = existing_prices['in']
990
+ cost_out = existing_prices['out']
991
+
992
+ if direction == 'in':
993
+ cost_in = cost
994
+ elif direction == 'out':
995
+ cost_out = cost
996
+
997
+ backup_path = config_manager.set_model_price(current_model, cost_in, cost_out)
998
+
999
+ if direction == 'in':
1000
+ console.print(f"[green]Model '{current_model}' input token cost set to ${cost:.6f} per 1M tokens[/green]")
1001
+ else:
1002
+ console.print(f"[green]Model '{current_model}' output token cost set to ${cost:.6f} per 1M tokens[/green]")
1003
+
1004
+ if backup_path:
1005
+ console.print(f"[dim]Saved to config.json (backup: {backup_path.name})[/dim]")
1006
+
1007
+ console.print()
1008
+ return CommandResult(status="handled")
1009
+
1010
+ # No args - show current usage stats
1011
+ current_provider = getattr(chat_manager.client, 'provider', 'unknown')
1012
+
1013
+ # bone: fetch from proxy API
1014
+ if current_provider == "bone":
1015
+ cfg = config.get_provider_config("bone")
1016
+ api_key = cfg.get('api_key', '')
1017
+ api_base = cfg.get('api_base', 'https://api.vmcode.dev')
1018
+
1019
+ if not api_key:
1020
+ console.print("[yellow]No API key set for bone. Use /key to set one.[/yellow]")
1021
+ console.print()
1022
+ return CommandResult(status="handled")
1023
+
1024
+ # Fetch usage from proxy and render percentage bars
1025
+ status_code, usage = _call_proxy_api("GET", "/v1/usage", api_base, api_key=api_key)
1026
+ if status_code == 0 or usage is None:
1027
+ console.print("[red]Failed to fetch usage from bone.[/red]")
1028
+ console.print("[dim]Check your API key and network connection.[/dim]")
1029
+ console.print()
1030
+ return CommandResult(status="handled")
1031
+
1032
+ plan_label = usage.get("plan", "unknown").capitalize()
1033
+ console.print(f"[bold #5F9EA0]Usage -- {plan_label} Plan[/bold #5F9EA0]")
1034
+ console.print()
1035
+
1036
+ for period in ("daily", "weekly"):
1037
+ data = usage.get(period, {})
1038
+ pct = data.get("pct_used", 0)
1039
+ label = period.capitalize()
1040
+ filled = int(round(pct / 100 * 20))
1041
+ bar = "\u2588" * filled + "\u2591" * (20 - filled)
1042
+ reset_at = data.get("reset_at", "")
1043
+ if pct >= 90:
1044
+ indicator = "[bold red]![/bold red]"
1045
+ elif pct >= 70:
1046
+ indicator = "[bold yellow]~[/bold yellow]"
1047
+ else:
1048
+ indicator = "[bold green]+[/bold green]"
1049
+ reset_str = f" [dim]resets {reset_at}[/dim]" if reset_at else ""
1050
+ console.print(f" {indicator} [bold]{label:7s}[/bold] {bar} [bold]{pct:.1f}%[/bold]{reset_str}")
1051
+
1052
+ console.print()
1053
+ return CommandResult(status="handled")
1054
+
1055
+ # All other providers: show local session stats
1056
+ costs = config_manager.get_model_price(current_model)
1057
+ tracker = chat_manager.token_tracker
1058
+
1059
+ # Display token counts
1060
+ console.print(f"[#5F9EA0]Session Token Usage ({current_model}):[/#5F9EA0]")
1061
+ console.print(f" Input tokens: {tracker.total_prompt_tokens:,}")
1062
+ console.print(f" Output tokens: {tracker.total_completion_tokens:,}")
1063
+ console.print(f" Total tokens: {tracker.total_tokens:,}")
1064
+
1065
+ # Display cache token breakdown (if any cache tokens were recorded)
1066
+ has_cache = tracker.total_cache_read_tokens > 0 or tracker.total_cache_creation_tokens > 0
1067
+ if has_cache:
1068
+ cache_hit_pct = (
1069
+ tracker.total_cache_read_tokens
1070
+ / tracker.total_prompt_tokens * 100
1071
+ ) if tracker.total_prompt_tokens > 0 else 0
1072
+ console.print()
1073
+ console.print(f"[#5F9EA0]Input Cache ({cache_hit_pct:.0f}% hit rate):[/#5F9EA0]")
1074
+ console.print(f" Cache read: {tracker.total_cache_read_tokens:,} tokens")
1075
+ console.print(f" Cache write: {tracker.total_cache_creation_tokens:,} tokens")
1076
+ console.print()
1077
+
1078
+
1079
+ # Display costs — combined upstream-reported + estimated
1080
+ display_cost = tracker.get_display_cost(current_model)
1081
+ if display_cost > 0:
1082
+ console.print(f"[#5F9EA0]Session Cost ({current_model}):[/#5F9EA0]")
1083
+ console.print(f" Total: ${display_cost:.6f}")
1084
+ console.print()
1085
+ if tracker.has_actual_cost():
1086
+ console.print(f"[dim]Note: Includes ${tracker.total_actual_cost:.6f} provider-reported "
1087
+ f"+ ${tracker.total_estimated_cost:.6f} locally estimated.[/dim]")
1088
+ else:
1089
+ console.print(f"[dim]Note: Cost estimated from token counts × static rates.[/dim]")
1090
+ console.print()
1091
+ else:
1092
+ if costs['in'] > 0 or costs['out'] > 0:
1093
+ console.print(" No cost data available (no tokens used yet).")
1094
+ console.print(f"[dim]Rates: ${costs['in']:.6f}/1M in, ${costs['out']:.6f}/1M out[/dim]")
1095
+ console.print()
1096
+ else:
1097
+ console.print(f"[yellow]Cost not configured for model '{current_model}'. Set with:[/yellow]")
1098
+ console.print(f" [bold #5F9EA0]/usage[/bold #5F9EA0] in <cost> - Set input token cost per 1M tokens")
1099
+ console.print(f" [bold #5F9EA0]/usage[/bold #5F9EA0] out <cost> - Set output token cost per 1M tokens")
1100
+ console.print(f"[dim]Example: [bold #5F9EA0]/usage[/bold #5F9EA0] in 2.50[/dim]")
1101
+ console.print()
1102
+
1103
+ return CommandResult(status="handled")
1104
+
1105
+
1106
+ def _handle_review(chat_manager, console, debug_mode_container, args, cron_scheduler=None):
1107
+ """Handle review command - run code review on git changes."""
1108
+ import subprocess
1109
+ import os
1110
+ import sys
1111
+
1112
+ from tools.review_sub_agent import review_changes
1113
+
1114
+ # Parse args: separate git diff flags from user intent
1115
+ # Format: /r [git-args] [-- intent description]
1116
+ # Examples:
1117
+ # /r --staged
1118
+ # /r I wanted to reduce the system prompt length
1119
+ # /r --staged -- I was refactoring the auth module
1120
+ user_intent = None
1121
+ git_args = ""
1122
+
1123
+ if args and args.strip():
1124
+ raw_args = args.strip()
1125
+ # Explicit delimiter: " -- " splits git args from intent
1126
+ if " -- " in raw_args:
1127
+ parts = raw_args.split(" -- ", 1)
1128
+ git_args = parts[0].strip()
1129
+ user_intent = parts[1].strip()
1130
+ else:
1131
+ # Heuristic: tokens starting with '-' are git flags, rest is intent
1132
+ tokens = raw_args.split()
1133
+ git_tokens = []
1134
+ intent_tokens = []
1135
+ in_intent = False
1136
+ for token in tokens:
1137
+ if in_intent or not token.startswith("-"):
1138
+ in_intent = True
1139
+ intent_tokens.append(token)
1140
+ else:
1141
+ git_tokens.append(token)
1142
+ git_args = " ".join(git_tokens)
1143
+ if intent_tokens:
1144
+ user_intent = " ".join(intent_tokens)
1145
+
1146
+ # Build git diff argument list (no shell=True to prevent command injection)
1147
+ git_argv = ["git", "diff"] + git_args.split()
1148
+
1149
+ # Reject shell metacharacters as defense-in-depth
1150
+ import re
1151
+ dangerous = re.compile(r'[;&|`$(){}<>!]')
1152
+ for arg in git_argv[2:]:
1153
+ if dangerous.search(arg):
1154
+ console.print(f"[red]Rejected dangerous character in argument: {arg}[/red]")
1155
+ return CommandResult(status="handled")
1156
+
1157
+ if user_intent:
1158
+ console.print(f"[#5F9EA0]Running: {' '.join(git_argv)}[/#5F9EA0]")
1159
+ console.print(f"[dim]Intent: {user_intent}[/dim]")
1160
+ else:
1161
+ console.print(f"[#5F9EA0]Running: {' '.join(git_argv)}[/#5F9EA0]")
1162
+
1163
+ # Run git diff
1164
+ result = subprocess.run(
1165
+ git_argv,
1166
+ shell=False,
1167
+ capture_output=True,
1168
+ text=True,
1169
+ )
1170
+
1171
+ if result.returncode != 0:
1172
+ console.print(f"[red]git diff failed:[/red]")
1173
+ console.print(f"[dim]{result.stderr.strip()}[/dim]")
1174
+ return CommandResult(status="handled")
1175
+
1176
+ diff_output = result.stdout.strip()
1177
+ if not diff_output:
1178
+ console.print("[yellow]No changes to review.[/yellow]")
1179
+ return CommandResult(status="handled")
1180
+
1181
+ # Count changed files for summary
1182
+ file_count = diff_output.count("diff --git ")
1183
+ console.print(f"[dim]Reviewing {file_count} changed file(s)...[/dim]")
1184
+ console.print()
1185
+
1186
+ # Compute paths from shared module
1187
+ from utils.paths import REPO_ROOT, RG_EXE_PATH as _RG_EXE_PATH
1188
+ repo_root = REPO_ROOT
1189
+ rg_exe_path = str(_RG_EXE_PATH)
1190
+
1191
+ # Create a live panel for the review sub-agent
1192
+ panel = SubAgentPanel("Reviewing git diff", console)
1193
+
1194
+ # Run the review
1195
+ review_result = review_changes(
1196
+ diff_output=diff_output,
1197
+ repo_root=repo_root,
1198
+ rg_exe_path=rg_exe_path,
1199
+ console=console,
1200
+ chat_manager=chat_manager,
1201
+ panel_updater=panel,
1202
+ user_intent=user_intent,
1203
+ )
1204
+
1205
+ display_text = review_result["display"]
1206
+ history_text = review_result["history"]
1207
+
1208
+ # Display clean result as rendered Markdown (no injected file contents)
1209
+ if display_text:
1210
+ console.print()
1211
+ md = Markdown(left_align_headings(display_text), code_theme=MonokaiDarkBGStyle, justify="left")
1212
+ console.print(md)
1213
+ console.print()
1214
+
1215
+ # Inject review (with file contents) into chat history for follow-up context
1216
+ if history_text:
1217
+ review_cmd = "/review"
1218
+ if user_intent:
1219
+ review_cmd += f"\n\nUser intent: {user_intent}"
1220
+ chat_manager.messages.append({
1221
+ "role": "user",
1222
+ "content": review_cmd
1223
+ })
1224
+ chat_manager.messages.append({
1225
+ "role": "assistant",
1226
+ "content": f"Here is the code review of the current git diff:\n\n{history_text}"
1227
+ })
1228
+
1229
+ # Update context token tracker so compaction timing stays accurate
1230
+ injected_tokens = chat_manager.token_tracker.estimate_tokens(
1231
+ f"{review_cmd}\n\n{history_text}"
1232
+ )
1233
+ chat_manager.token_tracker.current_context_tokens += injected_tokens
1234
+
1235
+ return CommandResult(status="handled")
1236
+
1237
+
1238
+ # ============================================
1239
+ # Shared proxy API helper
1240
+ # ============================================
1241
+
1242
+ def _call_proxy_api(
1243
+ method: str,
1244
+ path: str,
1245
+ api_base: str,
1246
+ body: dict | None = None,
1247
+ api_key: str | None = None,
1248
+ timeout: int = 10,
1249
+ ) -> tuple[int, dict | None]:
1250
+ """Call a bone-proxy API endpoint.
1251
+
1252
+ Returns (status_code, parsed_json_or_None).
1253
+ Returns (0, None) on network/parse failures.
1254
+ """
1255
+ # Validate endpoint uses HTTPS (or localhost HTTP)
1256
+ full_url = f"{api_base.rstrip('/')}{path}"
1257
+ valid, err = validate_api_url(full_url)
1258
+ if not valid:
1259
+ logger.warning("Proxy API call rejected: %s", err)
1260
+ return (0, None)
1261
+
1262
+ # Enforce TLS regardless of global settings
1263
+ ssl_ctx = ssl.create_default_context()
1264
+
1265
+ try:
1266
+ data = None
1267
+ if body is not None:
1268
+ data = json.dumps(body).encode("utf-8")
1269
+
1270
+ req = urllib.request.Request(full_url, data=data, method=method)
1271
+ req.add_header("Content-Type", "application/json")
1272
+ if api_key:
1273
+ req.add_header("Authorization", f"Bearer {api_key}")
1274
+
1275
+ with urllib.request.urlopen(req, timeout=timeout, context=ssl_ctx) as resp:
1276
+ return (resp.status, json.loads(resp.read().decode()))
1277
+ except urllib.error.HTTPError as e:
1278
+ try:
1279
+ return (e.code, json.loads(e.read().decode()))
1280
+ except Exception:
1281
+ return (e.code, None)
1282
+ except Exception as e:
1283
+ logger.debug("Proxy API call failed: %s", e)
1284
+ return (0, None)
1285
+
1286
+
1287
+ def _get_proxy_config(chat_manager):
1288
+ """Get bone api_key and api_base from current config.
1289
+
1290
+ Returns (api_key, api_base) tuple. api_key may be empty string.
1291
+ """
1292
+ cfg = config.get_provider_config("bone")
1293
+ api_key = cfg.get("api_key", "")
1294
+ api_base = cfg.get("api_base", "https://api.vmcode.dev")
1295
+ return api_key, api_base
1296
+
1297
+
1298
+ def _require_proxy_provider(chat_manager, console):
1299
+ """Check that bone is the current provider.
1300
+
1301
+ Returns True if on bone, prints error and returns False otherwise.
1302
+ """
1303
+ current_provider = getattr(chat_manager.client, "provider", "unknown")
1304
+ if current_provider != "bone":
1305
+ console.print(
1306
+ "[yellow]This command requires the bone provider.[/yellow]"
1307
+ )
1308
+ console.print("[dim]Run [bold #5F9EA0]/provider bone[/bold #5F9EA0] first.[/dim]")
1309
+ console.print()
1310
+ return False
1311
+ return True
1312
+
1313
+
1314
+ # ============================================
1315
+ # Account command handlers
1316
+ # ============================================
1317
+
1318
+ def _handle_plan(chat_manager, console, debug_mode_container, args, cron_scheduler=None):
1319
+ """Handle /plan — show available plans."""
1320
+ _, api_base = _get_proxy_config(chat_manager)
1321
+
1322
+ # Try the API first
1323
+ status, data = _call_proxy_api("GET", "/v1/billing/plans", api_base)
1324
+
1325
+ if status == 200 and data and "plans" in data:
1326
+ plans = data["plans"]
1327
+ else:
1328
+ # Fallback to hardcoded defaults
1329
+ plans = [
1330
+ {"id": "free", "name": "Free", "price": 0, "tokens": 0, "rate_limit": 0},
1331
+ {"id": "lite", "name": "Lite", "price": 10, "tokens": 2_000_000, "rate_limit": 60},
1332
+ {"id": "pro", "name": "Pro", "price": 50, "tokens": 15_000_000, "rate_limit": 300},
1333
+ ]
1334
+
1335
+ # Determine current plan
1336
+ current_provider = getattr(chat_manager.client, "provider", "unknown")
1337
+ current_plan = None
1338
+ if current_provider == "bone":
1339
+ api_key, _ = _get_proxy_config(chat_manager)
1340
+ if api_key:
1341
+ acct_status, acct_data = _call_proxy_api("GET", "/v1/auth/account", api_base, api_key=api_key)
1342
+ if acct_status == 200 and acct_data:
1343
+ current_plan = acct_data.get("plan")
1344
+
1345
+ table = Table("Plan", "Price", "Rate Limit (req/min)", title="Available Plans", box=box.SIMPLE_HEAD)
1346
+ for plan in plans:
1347
+ is_current = current_plan and plan["id"] == current_plan
1348
+ name = f"[bold green]{plan['name']} (current)[/bold green]" if is_current else plan["name"]
1349
+ if plan["id"] == "free":
1350
+ price = "Free model only"
1351
+ rate = "N/A"
1352
+ else:
1353
+ price = f"${plan.get('price', 0)}/mo" if plan.get("price", 0) > 0 else "Free"
1354
+ rate = str(plan["rate_limit"]) if plan.get("rate_limit") is not None else "N/A"
1355
+ table.add_row(name, price, rate)
1356
+
1357
+ console.print(table)
1358
+ console.print("[dim]Upgrade: [bold #5F9EA0]/upgrade pro[/bold #5F9EA0] | Manage: [bold #5F9EA0]/account[/bold #5F9EA0][/dim]")
1359
+ console.print()
1360
+ return CommandResult(status="handled")
1361
+
1362
+
1363
+ def _handle_signup(chat_manager, console, debug_mode_container, args, cron_scheduler=None):
1364
+ """Handle /signup <email> — create account and switch to bone."""
1365
+ if not args or not args.strip():
1366
+ console.print("[red]Usage: /signup <email>[/red]")
1367
+ console.print("[dim]Creates a bone-agent account and generates an API key.[/dim]")
1368
+ console.print()
1369
+ return CommandResult(status="handled")
1370
+
1371
+ email = args.strip()
1372
+
1373
+ # Basic client-side email validation
1374
+ if "@" not in email or "." not in email.split("@")[-1]:
1375
+ console.print("[red]Invalid email address.[/red]")
1376
+ console.print()
1377
+ return CommandResult(status="handled")
1378
+
1379
+ _, api_base = _get_proxy_config(chat_manager)
1380
+ console.print(f"[#5F9EA0]Creating account for {email}...[/#5F9EA0]")
1381
+
1382
+ status, data = _call_proxy_api("POST", "/v1/auth/signup", api_base, body={"email": email})
1383
+
1384
+ if status == 409:
1385
+ console.print("[yellow]Account already exists for that email.[/yellow]")
1386
+ console.print("[dim]Use [bold #5F9EA0]/login {email}[/bold #5F9EA0] to log in on this device.[/dim]")
1387
+ console.print()
1388
+ return CommandResult(status="handled")
1389
+
1390
+ if status != 201 and status != 200:
1391
+ detail = (data or {}).get("detail", "Unknown error") if data else "Network error"
1392
+ console.print(f"[red]Signup failed: {detail}[/red]")
1393
+ console.print()
1394
+ return CommandResult(status="handled")
1395
+
1396
+ if not data or "api_key" not in data:
1397
+ console.print("[red]Signup failed: unexpected response from server.[/red]")
1398
+ console.print()
1399
+ return CommandResult(status="handled")
1400
+
1401
+ api_key = data["api_key"]
1402
+
1403
+ # Display the API key prominently
1404
+ console.print()
1405
+ console.print("[bold green]Account created successfully![/bold green]")
1406
+ console.print("[dim]Check your inbox for a verification email. Use [bold #5F9EA0]/resend[/bold #5F9EA0] if it doesn't arrive.[/dim]")
1407
+ console.print()
1408
+ console.print("[bold #5F9EA0]Your API key (save this — it won't be shown again):[/bold #5F9EA0]")
1409
+ console.print(f"[bold white on grey23] {api_key} [/bold white on grey23]")
1410
+ console.print()
1411
+
1412
+ # Save backup to ~/.bone/api_key.txt
1413
+ try:
1414
+ key_path = Path.home() / ".bone" / "api_key.txt"
1415
+ key_path.parent.mkdir(parents=True, exist_ok=True)
1416
+ key_path.write_text(api_key)
1417
+ key_path.chmod(0o600)
1418
+ console.print(f"[dim]Key backed up to {key_path}[/dim]")
1419
+ except Exception as e:
1420
+ console.print(f"[yellow]Could not save key backup: {e}[/yellow]")
1421
+
1422
+ # Persist API key to config (always succeeds or warns — never blocks)
1423
+ try:
1424
+ config_manager.set_api_key("bone", api_key)
1425
+ except Exception as e:
1426
+ console.print(f"[yellow]Could not save API key to config: {e}[/yellow]")
1427
+ console.print("[dim]Use [bold #5F9EA0]/key {api_key}[/bold #5F9EA0] to set it manually.[/dim]")
1428
+
1429
+ # Switch to bone provider (best-effort)
1430
+ try:
1431
+ config_manager.set_provider("bone")
1432
+ chat_manager.reload_config()
1433
+ chat_manager.switch_provider("bone")
1434
+ console.print("[green]Switched to bone provider.[/green]")
1435
+ except Exception as e:
1436
+ console.print(f"[yellow]Could not auto-switch to bone: {e}[/yellow]")
1437
+ console.print("[dim]Run [bold #5F9EA0]/provider bone[/bold #5F9EA0] to switch manually.[/dim]")
1438
+
1439
+ console.print()
1440
+ return CommandResult(status="handled")
1441
+
1442
+
1443
+ def _handle_account(chat_manager, console, debug_mode_container, args, cron_scheduler=None):
1444
+ """Handle /account — show account info."""
1445
+ if not _require_proxy_provider(chat_manager, console):
1446
+ return CommandResult(status="handled")
1447
+
1448
+ api_key, api_base = _get_proxy_config(chat_manager)
1449
+ if not api_key:
1450
+ console.print("[yellow]No API key set for bone. Use /key to set one.[/yellow]")
1451
+ console.print()
1452
+ return CommandResult(status="handled")
1453
+
1454
+ console.print("[#5F9EA0]Fetching account info...[/#5F9EA0]")
1455
+ status, data = _call_proxy_api("GET", "/v1/auth/account", api_base, api_key=api_key)
1456
+
1457
+ if status != 200 or not data:
1458
+ detail = (data or {}).get("detail", "Unknown error") if data else "Network error"
1459
+ console.print(f"[red]Failed to fetch account: {detail}[/red]")
1460
+ console.print()
1461
+ return CommandResult(status="handled")
1462
+
1463
+ console.print()
1464
+ console.print(f"[bold #5F9EA0]Account:[/bold #5F9EA0] {data.get('email', 'N/A')}")
1465
+ plan = data.get("plan", "lite").capitalize()
1466
+ sub_status = data.get("subscription_status", "none")
1467
+ console.print(f"[bold #5F9EA0]Plan:[/bold #5F9EA0] {plan}")
1468
+
1469
+ if sub_status and sub_status != "none":
1470
+ console.print(f"[bold #5F9EA0]Status:[/bold #5F9EA0] {sub_status}")
1471
+ period_end = data.get("current_period_end")
1472
+ if period_end:
1473
+ console.print(f"[bold #5F9EA0]Renews:[/bold #5F9EA0] {period_end}")
1474
+ else:
1475
+ console.print("[dim]No active subscription[/dim]")
1476
+
1477
+ prefix = data.get("api_key_prefix")
1478
+ if prefix:
1479
+ console.print(f"[bold #5F9EA0]API key:[/bold #5F9EA0] {prefix}...")
1480
+ key_count = len(data.get("keys", []))
1481
+ console.print(f"[bold #5F9EA0]Keys:[/bold #5F9EA0] {key_count}")
1482
+ console.print()
1483
+ console.print("[dim]Manage subscription: [bold #5F9EA0]/upgrade[/bold #5F9EA0] or [bold #5F9EA0]/manage[/bold #5F9EA0][/dim]")
1484
+ console.print()
1485
+ return CommandResult(status="handled")
1486
+
1487
+
1488
+ def _send_reset_key_email(console, api_base, email):
1489
+ """Shared logic for sending a new API key via email.
1490
+
1491
+ Used by both /login (path 2: user lost key) and /reset-key.
1492
+ Returns CommandResult.
1493
+ """
1494
+ console.print(f"[#5F9EA0]Sending new API key to {email}...[/#5F9EA0]")
1495
+ console.print("[dim]This will create a new key and email it to you. Old keys remain valid.[/dim]")
1496
+ console.print()
1497
+
1498
+ from rich.prompt import Confirm
1499
+ if not Confirm.ask("Send a new API key to this email?", default=False):
1500
+ console.print("[dim]Cancelled.[/dim]")
1501
+ console.print()
1502
+ return CommandResult(status="handled")
1503
+
1504
+ status, data = _call_proxy_api("POST", "/v1/auth/reset-key", api_base, body={"email": email})
1505
+
1506
+ if status == 429:
1507
+ detail = (data or {}).get("detail", "Too many requests.") if data else "Too many requests."
1508
+ console.print(f"[yellow]{detail}[/yellow]")
1509
+ console.print()
1510
+ return CommandResult(status="handled")
1511
+
1512
+ if status != 200 and status != 201:
1513
+ detail = (data or {}).get("detail", "Unknown error") if data else "Network error"
1514
+ console.print(f"[red]Failed to send: {detail}[/red]")
1515
+ console.print()
1516
+ return CommandResult(status="handled")
1517
+
1518
+ message = (data or {}).get("message", "Check your email for the new API key.")
1519
+ console.print(f"[green]{message}[/green]")
1520
+ console.print("[dim]Once you receive the key, run: [bold #5F9EA0]/key <your-new-key>[/bold #5F9EA0][/dim]")
1521
+ console.print()
1522
+ return CommandResult(status="handled")
1523
+
1524
+
1525
+ def _handle_login(chat_manager, console, debug_mode_container, args, cron_scheduler=None):
1526
+ """Handle /login <email> — log in to an existing bone-agent account on this device.
1527
+
1528
+ Two paths:
1529
+ - User has their API key: validate it, save to config, switch provider.
1530
+ - User lost their key: email a new one via /reset-key endpoint.
1531
+ """
1532
+ if not args or not args.strip():
1533
+ console.print("[red]Usage: /login <email>[/red]")
1534
+ console.print("[dim]Log in to an existing bone-agent account on this device.[/dim]")
1535
+ console.print()
1536
+ return CommandResult(status="handled")
1537
+
1538
+ email = args.strip()
1539
+
1540
+ # Basic client-side email validation
1541
+ if "@" not in email or "." not in email.split("@")[-1]:
1542
+ console.print("[red]Invalid email address.[/red]")
1543
+ console.print()
1544
+ return CommandResult(status="handled")
1545
+
1546
+ if not _require_proxy_provider(chat_manager, console):
1547
+ return CommandResult(status="handled")
1548
+
1549
+ # Check if already logged in to a different account
1550
+ api_key, api_base = _get_proxy_config(chat_manager)
1551
+ if api_key:
1552
+ try:
1553
+ acct_status, acct_data = _call_proxy_api("GET", "/v1/auth/account", api_base, api_key=api_key)
1554
+ if acct_status == 200 and acct_data:
1555
+ current_email = acct_data.get("email", "")
1556
+ if current_email and current_email.lower() != email.lower():
1557
+ from rich.prompt import Confirm
1558
+ console.print(f"[yellow]Already logged in as {current_email}[/yellow]")
1559
+ if not Confirm.ask(f"Switch to {email}?", default=False):
1560
+ console.print("[dim]Cancelled.[/dim]")
1561
+ console.print()
1562
+ return CommandResult(status="handled")
1563
+ except Exception:
1564
+ pass # If we can't check, just proceed
1565
+
1566
+ console.print()
1567
+ console.print(f"[bold #5F9EA0]bone-agent Login[/bold #5F9EA0]")
1568
+ console.print(f"[dim]Logging in as {email}[/dim]")
1569
+ console.print()
1570
+
1571
+ from rich.prompt import Confirm, Prompt
1572
+
1573
+ if Confirm.ask("Do you have your API key?", default=True):
1574
+ # Path 1: user has their key — validate and save
1575
+ raw_key = Prompt.ask("API key")
1576
+
1577
+ if not raw_key.strip():
1578
+ console.print("[yellow]No key entered. Aborted.[/yellow]")
1579
+ console.print()
1580
+ return CommandResult(status="handled")
1581
+
1582
+ raw_key = raw_key.strip()
1583
+
1584
+ console.print("[#5F9EA0]Validating API key...[/#5F9EA0]")
1585
+ status, data = _call_proxy_api("GET", "/v1/auth/account", api_base, api_key=raw_key)
1586
+
1587
+ if status == 200 and data and data.get("email", "").lower() == email.lower():
1588
+ # Valid key — save and switch
1589
+ try:
1590
+ config_manager.set_api_key("bone", raw_key)
1591
+ except Exception as e:
1592
+ console.print(f"[yellow]Could not save API key to config: {e}[/yellow]")
1593
+ console.print(f"[dim]Use [bold #5F9EA0]/key {raw_key}[/bold #5F9EA0] to set it manually.[/dim]")
1594
+
1595
+ try:
1596
+ config_manager.set_provider("bone")
1597
+ chat_manager.reload_config()
1598
+ chat_manager.switch_provider("bone")
1599
+ console.print("[green]Switched to bone provider.[/green]")
1600
+ except Exception as e:
1601
+ console.print(f"[yellow]Could not auto-switch to bone: {e}[/yellow]")
1602
+ console.print("[dim]Run [bold #5F9EA0]/provider bone[/bold #5F9EA0] to switch manually.[/dim]")
1603
+
1604
+ plan = data.get("plan", "free")
1605
+ verified = "yes" if data.get("verified") else "no"
1606
+ console.print(f"[green]Logged in as {email}[/green] (plan: {plan}, verified: {verified})")
1607
+ console.print()
1608
+ return CommandResult(status="handled")
1609
+
1610
+ if status in (401, 403):
1611
+ console.print("[red]Invalid API key.[/red]")
1612
+ console.print("[dim]Double-check your key and try again, or say 'no' to get a new one emailed.[/dim]")
1613
+ else:
1614
+ detail = (data or {}).get("detail", "Unknown error") if data else "Network error"
1615
+ console.print(f"[red]Validation failed: {detail}[/red]")
1616
+ console.print()
1617
+ return CommandResult(status="handled")
1618
+
1619
+ # Path 2: user lost their key — email a new one
1620
+ return _send_reset_key_email(console, api_base, email)
1621
+
1622
+
1623
+
1624
+ def _handle_resend(chat_manager, console, debug_mode_container, args, cron_scheduler=None):
1625
+ """Handle /resend [email] — resend verification email.
1626
+
1627
+ If no email is given, fetches it from the account endpoint.
1628
+ """
1629
+ if not _require_proxy_provider(chat_manager, console):
1630
+ return CommandResult(status="handled")
1631
+
1632
+ api_key, api_base = _get_proxy_config(chat_manager)
1633
+ if not api_key:
1634
+ console.print("[yellow]No API key set for bone. Use /key to set one.[/yellow]")
1635
+ console.print()
1636
+ return CommandResult(status="handled")
1637
+
1638
+ # Resolve email: use arg, or fetch from account
1639
+ email = args.strip() if args and args.strip() else None
1640
+ if not email:
1641
+ acct_status, acct_data = _call_proxy_api("GET", "/v1/auth/account", api_base, api_key=api_key)
1642
+ if acct_status == 200 and acct_data:
1643
+ email = acct_data.get("email")
1644
+ if acct_data.get("verified"):
1645
+ console.print("[green]Email is already verified.[/green]")
1646
+ console.print()
1647
+ return CommandResult(status="handled")
1648
+
1649
+ if not email:
1650
+ console.print("[red]Could not determine your email. Usage: /resend <email>[/red]")
1651
+ console.print()
1652
+ return CommandResult(status="handled")
1653
+
1654
+ console.print(f"[#5F9EA0]Sending verification email to {email}...[/#5F9EA0]")
1655
+ status, data = _call_proxy_api("POST", "/v1/auth/resend", api_base, body={"email": email})
1656
+
1657
+ if status == 429:
1658
+ detail = (data or {}).get("detail", "Too many requests.") if data else "Too many requests."
1659
+ console.print(f"[yellow]{detail}[/yellow]")
1660
+ console.print()
1661
+ return CommandResult(status="handled")
1662
+
1663
+ if status != 200 and status != 201:
1664
+ detail = (data or {}).get("detail", "Unknown error") if data else "Network error"
1665
+ console.print(f"[red]Failed to send: {detail}[/red]")
1666
+ console.print()
1667
+ return CommandResult(status="handled")
1668
+
1669
+ message = (data or {}).get("message", "Verification email sent.")
1670
+ console.print(f"[green]{message}[/green]")
1671
+ console.print()
1672
+ return CommandResult(status="handled")
1673
+
1674
+
1675
+ def _handle_reset_key(chat_manager, console, debug_mode_container, args, cron_scheduler=None):
1676
+ """Handle /reset-key [email] — request a new API key via email.
1677
+
1678
+ If no email is given, fetches it from the account endpoint (requires API key).
1679
+ If email is given, works without an API key (for users who lost everything).
1680
+ """
1681
+ if not _require_proxy_provider(chat_manager, console):
1682
+ return CommandResult(status="handled")
1683
+
1684
+ api_key, api_base = _get_proxy_config(chat_manager)
1685
+
1686
+ # Resolve email: use arg, or fetch from account
1687
+ email = args.strip() if args and args.strip() else None
1688
+ if not email and api_key:
1689
+ acct_status, acct_data = _call_proxy_api("GET", "/v1/auth/account", api_base, api_key=api_key)
1690
+ if acct_status == 200 and acct_data:
1691
+ email = acct_data.get("email")
1692
+
1693
+ if not email:
1694
+ console.print("[red]Could not determine your email. Usage: /reset-key <email>[/red]")
1695
+ console.print()
1696
+ return CommandResult(status="handled")
1697
+
1698
+ return _send_reset_key_email(console, api_base, email)
1699
+
1700
+
1701
+ def _handle_manage(chat_manager, console, debug_mode_container, args, cron_scheduler=None):
1702
+ """Handle /manage — open Stripe Customer Portal for subscription management."""
1703
+ if not _require_proxy_provider(chat_manager, console):
1704
+ return CommandResult(status="handled")
1705
+
1706
+ api_key, api_base = _get_proxy_config(chat_manager)
1707
+ if not api_key:
1708
+ console.print("[yellow]No API key set for bone. Use /key to set one.[/yellow]")
1709
+ console.print()
1710
+ return CommandResult(status="handled")
1711
+
1712
+ console.print("[#5F9EA0]Opening billing portal...[/#5F9EA0]")
1713
+ status, data = _call_proxy_api(
1714
+ "POST", "/v1/billing/portal", api_base,
1715
+ body={"return_url": "https://vmcode.dev"},
1716
+ api_key=api_key,
1717
+ )
1718
+
1719
+ if status == 400:
1720
+ detail = (data or {}).get("detail", "No subscription found.") if data else "No subscription found."
1721
+ console.print(f"[yellow]{detail}[/yellow]")
1722
+ console.print("[dim]Subscribe to a plan first with [bold #5F9EA0]/upgrade[/bold #5F9EA0].[/dim]")
1723
+ console.print()
1724
+ return CommandResult(status="handled")
1725
+
1726
+ if status != 200 or not data or "url" not in data:
1727
+ detail = (data or {}).get("detail", "Unknown error") if data else "Network error"
1728
+ console.print(f"[red]Failed to open billing portal: {detail}[/red]")
1729
+ console.print()
1730
+ return CommandResult(status="handled")
1731
+
1732
+ url = data["url"]
1733
+
1734
+ try:
1735
+ import webbrowser
1736
+ webbrowser.open(url)
1737
+ console.print("[green]Opened in browser[/green]")
1738
+ except Exception:
1739
+ pass
1740
+
1741
+ console.print()
1742
+ console.print("[#5F9EA0]Or copy this link:[/#5F9EA0]")
1743
+ console.print(f" [bold]{url}[/bold]")
1744
+ console.print()
1745
+ return CommandResult(status="handled")
1746
+
1747
+
1748
+ def _handle_upgrade(chat_manager, console, debug_mode_container, args, cron_scheduler=None):
1749
+ """Handle /upgrade — show plan selector, then open checkout or billing portal."""
1750
+ if not _require_proxy_provider(chat_manager, console):
1751
+ return CommandResult(status="handled")
1752
+
1753
+ api_key, api_base = _get_proxy_config(chat_manager)
1754
+ if not api_key:
1755
+ console.print("[yellow]No API key set for bone. Use /key to set one.[/yellow]")
1756
+ console.print()
1757
+ return CommandResult(status="handled")
1758
+
1759
+ # Check current plan for showing the current selection
1760
+ current_plan = "free"
1761
+ acct_status, acct_data = _call_proxy_api("GET", "/v1/auth/account", api_base, api_key=api_key)
1762
+ if acct_status == 200 and acct_data:
1763
+ current_plan = acct_data.get("plan", "free")
1764
+
1765
+ # Get available plans from API
1766
+ status, data = _call_proxy_api("GET", "/v1/billing/plans", api_base)
1767
+ if status == 200 and data and "plans" in data:
1768
+ plans = data["plans"]
1769
+ else:
1770
+ # Fallback to hardcoded defaults
1771
+ plans = [
1772
+ {"id": "free", "name": "Free", "price": 0, "tokens": 0, "rate_limit": 0},
1773
+ {"id": "lite", "name": "Lite", "price": 10, "tokens": 2_000_000, "rate_limit": 60},
1774
+ {"id": "pro", "name": "Pro", "price": 50, "tokens": 15_000_000, "rate_limit": 300},
1775
+ ]
1776
+
1777
+ # Only show plans the user can upgrade to (current plan excluded)
1778
+ # Tier ordering: free < lite < pro
1779
+ _TIER_ORDER = {"free": 0, "lite": 1, "pro": 2}
1780
+ current_tier = _TIER_ORDER.get(current_plan, 0)
1781
+
1782
+ upgradeable_plans = [
1783
+ p for p in plans
1784
+ if _TIER_ORDER.get(p["id"], 0) > current_tier
1785
+ ]
1786
+
1787
+ if not upgradeable_plans:
1788
+ # Pro user — no upgrades available
1789
+ console.print()
1790
+ console.print(f"[bold green]You're on the {current_plan.capitalize()} plan — the highest tier.[/bold green]")
1791
+ console.print("[dim]Use [bold #5F9EA0]/manage[/bold #5F9EA0] to cancel or change your subscription.[/dim]")
1792
+ console.print()
1793
+ return CommandResult(status="handled")
1794
+
1795
+ # Build plan options from upgradeable plans only
1796
+ plan_options = []
1797
+ for plan in upgradeable_plans:
1798
+ price_desc = f"${plan['price']}/mo" if plan.get("price", 0) > 0 else "Free"
1799
+ plan_options.append({
1800
+ "value": plan["id"],
1801
+ "text": plan["name"],
1802
+ "description": price_desc,
1803
+ })
1804
+
1805
+ # Default selection to the first upgradeable plan
1806
+ first_upgrade = upgradeable_plans[0]["id"]
1807
+
1808
+ # Show plan selector — title includes current plan
1809
+ selector = SettingSelector(
1810
+ categories=[
1811
+ SettingCategory(
1812
+ title="Select Plan",
1813
+ settings=[
1814
+ SettingOption(
1815
+ key="plan",
1816
+ text=f"Select a plan (current: {current_plan.capitalize()}):",
1817
+ value=first_upgrade,
1818
+ input_type="options",
1819
+ options=plan_options,
1820
+ )
1821
+ ]
1822
+ )
1823
+ ],
1824
+ title="Upgrade Your Plan",
1825
+ show_save=False,
1826
+ )
1827
+
1828
+ result = selector.run()
1829
+
1830
+ if result is None:
1831
+ console.print("[dim]Cancelled.[/dim]")
1832
+ console.print()
1833
+ return CommandResult(status="handled")
1834
+
1835
+ target = result.get("plan", first_upgrade)
1836
+
1837
+ # Upgrade: open Stripe Checkout
1838
+ console.print(f"[#5F9EA0]Opening checkout for {target.capitalize()}...[/#5F9EA0]")
1839
+
1840
+ status, data = _call_proxy_api(
1841
+ "POST", "/v1/billing/checkout", api_base,
1842
+ body={
1843
+ "plan": target,
1844
+ "success_url": "https://vmcode.dev",
1845
+ "cancel_url": "https://vmcode.dev",
1846
+ },
1847
+ api_key=api_key,
1848
+ )
1849
+ action = "create checkout session"
1850
+
1851
+ if status == 200 and data and "url" in data:
1852
+ url = data["url"]
1853
+ else:
1854
+ detail = (data or {}).get("detail", "Unknown error") if data else "Network error"
1855
+ console.print(f"[red]Failed to {action}: {detail}[/red]")
1856
+ console.print()
1857
+ return CommandResult(status="handled")
1858
+
1859
+ # Open in browser
1860
+ try:
1861
+ import webbrowser
1862
+ webbrowser.open(url)
1863
+ console.print("[green]Opened in browser[/green]")
1864
+ except Exception:
1865
+ pass
1866
+
1867
+ console.print()
1868
+ console.print("[#5F9EA0]Or copy this link:[/#5F9EA0]")
1869
+ console.print(f" [bold]{url}[/bold]")
1870
+ console.print()
1871
+ return CommandResult(status="handled")
1872
+
1873
+
1874
+ def _handle_rotate_key(chat_manager, console, debug_mode_container, args, cron_scheduler=None):
1875
+ """Handle /rotate-key — invalidate current API key and generate a new one."""
1876
+ if not _require_proxy_provider(chat_manager, console):
1877
+ return CommandResult(status="handled")
1878
+
1879
+ api_key, api_base = _get_proxy_config(chat_manager)
1880
+ if not api_key:
1881
+ console.print("[yellow]No API key set for bone. Use /key to set one.[/yellow]")
1882
+ console.print()
1883
+ return CommandResult(status="handled")
1884
+
1885
+ # Warn user
1886
+ console.print("[bold yellow]This will invalidate your current API key and generate a new one.[/bold yellow]")
1887
+ console.print("[dim]Make sure you can save the new key before proceeding.[/dim]")
1888
+ console.print()
1889
+
1890
+ from rich.prompt import Confirm
1891
+ if not Confirm.ask("Rotate your API key?", default=False):
1892
+ console.print("[dim]Cancelled.[/dim]")
1893
+ console.print()
1894
+ return CommandResult(status="handled")
1895
+
1896
+ console.print("[#5F9EA0]Rotating API key...[/#5F9EA0]")
1897
+ status, data = _call_proxy_api(
1898
+ "POST", "/v1/auth/rotate-key", api_base,
1899
+ body={},
1900
+ api_key=api_key,
1901
+ )
1902
+
1903
+ if status != 200 or not data or "api_key" not in data:
1904
+ detail = (data or {}).get("detail", "Unknown error") if data else "Network error"
1905
+ console.print(f"[red]Failed to rotate key: {detail}[/red]")
1906
+ console.print()
1907
+ return CommandResult(status="handled")
1908
+
1909
+ new_key = data["api_key"]
1910
+
1911
+ # Display new key
1912
+ console.print()
1913
+ console.print("[bold green]API key rotated successfully.[/bold green]")
1914
+ console.print("[bold red]Your old key is no longer valid.[/bold red]")
1915
+ console.print()
1916
+ console.print("[bold #5F9EA0]Your new API key (save this — it won't be shown again):[/bold #5F9EA0]")
1917
+ console.print(f"[bold white on grey23] {new_key} [/bold white on grey23]")
1918
+ console.print()
1919
+
1920
+ # Save to config
1921
+ try:
1922
+ config_manager.set_api_key("bone", new_key)
1923
+ console.print("[green]New key saved to config.[/green]")
1924
+ except Exception as e:
1925
+ console.print(f"[yellow]Could not save to config: {e}[/yellow]")
1926
+ console.print(f"[dim]Use [bold #5F9EA0]/key {new_key}[/bold #5F9EA0] to set it manually.[/dim]")
1927
+
1928
+ # Backup to file
1929
+ try:
1930
+ key_path = Path.home() / ".bone" / "api_key.txt"
1931
+ key_path.parent.mkdir(parents=True, exist_ok=True)
1932
+ key_path.write_text(new_key)
1933
+ key_path.chmod(0o600)
1934
+ console.print(f"[dim]Key backed up to {key_path}[/dim]")
1935
+ except Exception:
1936
+ pass
1937
+
1938
+ console.print()
1939
+ return CommandResult(status="handled")
1940
+
1941
+
1942
+ def _persist_obsidian_config(console, **kwargs):
1943
+ """Persist Obsidian settings to config file.
1944
+
1945
+ Args:
1946
+ console: Rich console for output
1947
+ **kwargs: OBSIDIAN_SETTINGS fields to persist
1948
+ """
1949
+ try:
1950
+ config_data = config_manager.load(force_reload=True)
1951
+ if "OBSIDIAN_SETTINGS" not in config_data:
1952
+ config_data["OBSIDIAN_SETTINGS"] = {}
1953
+ config_data["OBSIDIAN_SETTINGS"].update(kwargs)
1954
+ config_manager.save(config_data)
1955
+ except Exception as e:
1956
+ console.print(f"[yellow]Saved to session but could not persist to config: {e}[/yellow]")
1957
+ console.print("[dim]Settings will reset on restart.[/dim]")
1958
+
1959
+
1960
+ def _apply_obsidian_changes(chat_manager, console, obsidian_settings, changes):
1961
+ """Apply Obsidian setting changes, register/unregister tools, persist config.
1962
+
1963
+ Args:
1964
+ chat_manager: ChatManager instance
1965
+ console: Rich console for output
1966
+ obsidian_settings: ObsidianSettings instance
1967
+ changes: dict of {key: new_value} from SettingSelector
1968
+
1969
+ Returns:
1970
+ list of change description strings
1971
+ """
1972
+ change_lines = []
1973
+ was_active = obsidian_settings.is_active()
1974
+
1975
+ for key, value in changes.items():
1976
+ if key == "vault_path":
1977
+ old_path = obsidian_settings.vault_path
1978
+ new_path = value.strip() if value else ""
1979
+ if new_path and new_path != old_path:
1980
+ # Validate path
1981
+ vault_path = Path(new_path).resolve()
1982
+ if not vault_path.is_dir():
1983
+ console.print(f"[red]Not a directory: {vault_path}[/red]")
1984
+ continue
1985
+ if not (vault_path / ".obsidian").is_dir():
1986
+ console.print(f"[red]No .obsidian/ directory found in: {vault_path}[/red]")
1987
+ console.print("[dim]Make sure this is a valid Obsidian vault.[/dim]")
1988
+ continue
1989
+ obsidian_settings.update(vault_path=str(vault_path))
1990
+ change_lines.append(f" Vault path: {vault_path}")
1991
+ elif not new_path and old_path:
1992
+ obsidian_settings.update(vault_path="")
1993
+ change_lines.append(" Vault path: (cleared)")
1994
+ elif key == "enabled":
1995
+ obsidian_settings.update(enabled=value)
1996
+ state = "enabled" if value else "disabled"
1997
+ change_lines.append(f" Integration: {state}")
1998
+ elif key == "exclude_folders":
1999
+ obsidian_settings.update(exclude_folders=value)
2000
+ change_lines.append(f" Exclude folders: {value}")
2001
+ elif key == "project_base":
2002
+ obsidian_settings.update(project_base=value.strip() if value else "Dev")
2003
+ change_lines.append(f" Project base: {obsidian_settings.project_base}")
2004
+
2005
+ # Note: vault session is initialized lazily by init_session() in agentic.py
2006
+ # No tool registration needed — vault utilities are used internally
2007
+
2008
+ # Persist all settings to config
2009
+ if changes:
2010
+ _persist_obsidian_config(
2011
+ console,
2012
+ vault_path=obsidian_settings.vault_path,
2013
+ enabled=obsidian_settings.enabled,
2014
+ exclude_folders=obsidian_settings.exclude_folders,
2015
+ project_base=obsidian_settings.project_base,
2016
+ )
2017
+
2018
+ return change_lines
2019
+
2020
+
2021
+ def _handle_obsidian(chat_manager, console, debug_mode_container, args, cron_scheduler=None):
2022
+ """Handle /obsidian command — manage vault integration.
2023
+
2024
+ No args: Launch interactive SettingSelector UI (same UX as /config).
2025
+ Subcommands: set <path>, enable, disable, status — quick shortcuts.
2026
+ """
2027
+ from ui.setting_selector import SettingOption, SettingCategory, SettingSelector
2028
+ from utils.settings import obsidian_settings
2029
+
2030
+ # Text subcommands (quick shortcuts)
2031
+ if args:
2032
+ args_clean = args.strip()
2033
+
2034
+ if args_clean.lower() == "status":
2035
+ active = obsidian_settings.is_active()
2036
+ configured = obsidian_settings.is_configured()
2037
+ if active:
2038
+ console.print("[green]Obsidian integration: ACTIVE[/green]")
2039
+ elif configured:
2040
+ console.print("[yellow]Obsidian integration: ENABLED but vault invalid[/yellow]")
2041
+ else:
2042
+ console.print("[dim]Obsidian integration: DISABLED[/dim]")
2043
+ console.print(f" Vault path: {obsidian_settings.vault_path or '(not set)'}")
2044
+ console.print(f" Enabled: {obsidian_settings.enabled}")
2045
+ console.print(f" Exclude folders: {obsidian_settings.exclude_folders}")
2046
+ console.print(f" Project base: {obsidian_settings.project_base}")
2047
+ console.print()
2048
+ console.print("[dim]Run [bold #5F9EA0]/obsidian[/bold #5F9EA0] (no args) for interactive settings.[/dim]")
2049
+ return CommandResult(status="handled")
2050
+
2051
+ if args_clean.lower().startswith("set "):
2052
+ path = args_clean[4:].strip().strip('"').strip("'")
2053
+ if not path:
2054
+ console.print("[red]Usage: [bold #5F9EA0]/obsidian set /path/to/your/vault[/bold #5F9EA0]")
2055
+ return CommandResult(status="handled")
2056
+ vault_path = Path(path).resolve()
2057
+ if not vault_path.is_dir():
2058
+ console.print(f"[red]Not a directory: {vault_path}[/red]")
2059
+ return CommandResult(status="handled")
2060
+ if not (vault_path / ".obsidian").is_dir():
2061
+ console.print(f"[red]No .obsidian/ directory found in: {vault_path}[/red]")
2062
+ return CommandResult(status="handled")
2063
+ changes = {"vault_path": str(vault_path), "enabled": True}
2064
+ change_lines = _apply_obsidian_changes(chat_manager, console, obsidian_settings, changes)
2065
+ console.print(f"[green]Obsidian vault set:[/green]")
2066
+ for line in change_lines:
2067
+ console.print(line)
2068
+ return CommandResult(status="handled")
2069
+
2070
+ if args_clean.lower() == "enable":
2071
+ if not obsidian_settings.vault_path:
2072
+ console.print("[red]No vault path set. Use [bold #5F9EA0]/obsidian set <path>[/bold #5F9EA0] first.[/red]")
2073
+ return CommandResult(status="handled")
2074
+ changes = _apply_obsidian_changes(chat_manager, console, obsidian_settings, {"enabled": True})
2075
+ console.print("[green]Obsidian integration enabled.[/green]")
2076
+ return CommandResult(status="handled")
2077
+
2078
+ if args_clean.lower() == "disable":
2079
+ _apply_obsidian_changes(chat_manager, console, obsidian_settings, {"enabled": False})
2080
+ console.print("[yellow]Obsidian integration disabled. Tools unregistered.[/yellow]")
2081
+ return CommandResult(status="handled")
2082
+
2083
+ if args_clean.lower() == "init":
2084
+ return _handle_obsidian_init(console, obsidian_settings)
2085
+
2086
+ console.print(f"[red]Unknown subcommand: {args}[/red]")
2087
+ console.print("Usage: [bold #5F9EA0]/obsidian[/bold #5F9EA0] [set <path> | enable | disable | status | init]")
2088
+ return CommandResult(status="handled")
2089
+
2090
+ # No args — launch interactive SettingSelector UI
2091
+ vault_settings = [
2092
+ SettingOption(
2093
+ key="vault_path", text="Vault Path",
2094
+ value=obsidian_settings.vault_path or "",
2095
+ input_type="text",
2096
+ description="Absolute path to your Obsidian vault (.obsidian/ must exist)",
2097
+ ),
2098
+ SettingOption(
2099
+ key="enabled", text="Enable Integration",
2100
+ value=obsidian_settings.enabled,
2101
+ input_type="boolean",
2102
+ on_text="ON", off_text="OFF",
2103
+ ),
2104
+ ]
2105
+
2106
+ behavior_settings = [
2107
+ SettingOption(
2108
+ key="exclude_folders", text="Exclude Folders",
2109
+ value=obsidian_settings.exclude_folders,
2110
+ input_type="text",
2111
+ description="Comma-separated folder names to skip during vault scans",
2112
+ ),
2113
+ SettingOption(
2114
+ key="project_base", text="Project Base",
2115
+ value=obsidian_settings.project_base,
2116
+ input_type="text",
2117
+ description="Base folder within vault for project notes (default: Dev)",
2118
+ ),
2119
+ ]
2120
+
2121
+ categories = [
2122
+ SettingCategory(title="Vault", settings=vault_settings),
2123
+ SettingCategory(title="Behavior", settings=behavior_settings),
2124
+ ]
2125
+
2126
+ selector = SettingSelector(
2127
+ categories=categories,
2128
+ title="Obsidian Integration",
2129
+ )
2130
+
2131
+ changes = selector.run()
2132
+
2133
+ if changes is None:
2134
+ console.print("[dim]Cancelled.[/dim]")
2135
+ return CommandResult(status="handled")
2136
+
2137
+ if not changes:
2138
+ console.print("[dim]No changes made.[/dim]")
2139
+ return CommandResult(status="handled")
2140
+
2141
+ change_lines = _apply_obsidian_changes(chat_manager, console, obsidian_settings, changes)
2142
+
2143
+ if change_lines:
2144
+ # Show active status after changes
2145
+ is_active = obsidian_settings.is_active()
2146
+ status_label = "[green]ACTIVE[/green]" if is_active else "[dim]inactive[/dim]"
2147
+ console.print(f"[green]Obsidian settings updated:[/green] ({status_label})")
2148
+ for line in change_lines:
2149
+ console.print(line)
2150
+ else:
2151
+ console.print("[dim]No changes applied.[/dim]")
2152
+
2153
+ return CommandResult(status="handled")
2154
+
2155
+
2156
+ def _persist_disabled_tools(console):
2157
+ """Persist current disabled_tools to config file.
2158
+
2159
+ Returns True on success, False on failure.
2160
+ """
2161
+ try:
2162
+ cfg_data = config_manager.load(force_reload=True)
2163
+ if "TOOL_SETTINGS" not in cfg_data:
2164
+ cfg_data["TOOL_SETTINGS"] = {}
2165
+ cfg_data["TOOL_SETTINGS"]["disabled_tools"] = list(tool_settings.disabled_tools)
2166
+ config_manager.save(cfg_data)
2167
+ return True
2168
+ except Exception as e:
2169
+ console.print(f"[yellow]Could not persist to config: {e}[/yellow]")
2170
+ return False
2171
+
2172
+
2173
+ def _handle_tools(chat_manager, console, debug_mode_container, args, cron_scheduler=None):
2174
+ """Handle /tools command — toggle individual tools or groups on/off.
2175
+
2176
+ No args: Launch interactive SettingSelector with tools grouped by category.
2177
+ Subcommands:
2178
+ list — show all tools with group labels and status
2179
+ enable <name> — enable a single tool
2180
+ disable <name> — disable a single tool
2181
+ enable-group <key> — enable all tools in a group (e.g. file_ops, search, shell)
2182
+ disable-group <key> — disable all tools in a group
2183
+ """
2184
+ from ui.setting_selector import SettingOption, SettingCategory, SettingSelector
2185
+ from tools.helpers.base import ToolRegistry, TOOL_GROUPS
2186
+
2187
+ # Text subcommands
2188
+ if args:
2189
+ args_clean = args.strip()
2190
+
2191
+ if args_clean.lower() in ("list", "status"):
2192
+ all_tools = sorted(ToolRegistry._tools.values(), key=lambda t: t.name)
2193
+ disabled = ToolRegistry.get_disabled()
2194
+ console.print(f"[bold #5F9EA0]Tools: {len(all_tools) - len(disabled)} enabled, {len(disabled)} disabled[/bold #5F9EA0]")
2195
+ console.print()
2196
+
2197
+ # Build reverse lookup: tool_name -> group_label
2198
+ tool_to_group = {}
2199
+ for gkey, gdef in TOOL_GROUPS.items():
2200
+ for tname in gdef["tools"]:
2201
+ tool_to_group.setdefault(tname, []).append(gdef["label"])
2202
+
2203
+ # Group tools for display
2204
+ current_group = None
2205
+ for t in all_tools:
2206
+ groups = tool_to_group.get(t.name, [])
2207
+ group_label = groups[0] if groups else "Other"
2208
+ is_off = t.name in disabled
2209
+ if group_label != current_group:
2210
+ current_group = group_label
2211
+ console.print(f" [bold]{group_label}[/bold]")
2212
+ status = "[red]off[/red]" if is_off else "[green]on[/green] "
2213
+ console.print(f" {status} {t.name}")
2214
+
2215
+ console.print()
2216
+ console.print("[dim]Groups:[/dim] " + ", ".join(
2217
+ f"[bold]{k}[/bold] ({v['label']})" for k, v in TOOL_GROUPS.items()
2218
+ ))
2219
+ console.print()
2220
+ return CommandResult(status="handled")
2221
+
2222
+ # Parse: enable/disable <name> or enable-group/disable-group <key>
2223
+ parts = args_clean.split(maxsplit=1)
2224
+ if len(parts) == 2:
2225
+ action = parts[0].lower()
2226
+ target = parts[1].strip()
2227
+
2228
+ # Group operations
2229
+ if action in ("enable-group", "disable-group"):
2230
+ group_key = target.lower()
2231
+ if group_key not in TOOL_GROUPS:
2232
+ console.print(f"[red]Unknown group: {group_key}[/red]")
2233
+ console.print("[dim]Groups: " + ", ".join(TOOL_GROUPS.keys()) + "[/dim]")
2234
+ console.print()
2235
+ return CommandResult(status="handled")
2236
+
2237
+ group_label = TOOL_GROUPS[group_key]["label"]
2238
+ if action == "disable-group":
2239
+ changed = ToolRegistry.disable_group(group_key)
2240
+ if changed:
2241
+ console.print(f"[yellow]Disabled {group_label}:[/yellow] {', '.join(changed)}")
2242
+ else:
2243
+ console.print(f"[dim]All {group_label} tools already disabled.[/dim]")
2244
+ else:
2245
+ changed = ToolRegistry.enable_group(group_key)
2246
+ if changed:
2247
+ console.print(f"[green]Enabled {group_label}:[/green] {', '.join(changed)}")
2248
+ else:
2249
+ console.print(f"[dim]All {group_label} tools already enabled.[/dim]")
2250
+
2251
+ # Sync and persist
2252
+ tool_settings.disabled_tools = sorted(ToolRegistry.get_disabled())
2253
+ _persist_disabled_tools(console)
2254
+ console.print()
2255
+ return CommandResult(status="handled")
2256
+
2257
+ # Single tool operations
2258
+ if action in ("enable", "disable"):
2259
+ # Match case-insensitively against registered tools
2260
+ all_registered_lower = {t.name.lower(): t.name for t in ToolRegistry._tools.values()}
2261
+ matched = all_registered_lower.get(target.lower())
2262
+ if not matched:
2263
+ console.print(f"[red]Unknown tool: {target}[/red]")
2264
+ console.print(f"[dim]Run [bold #5F9EA0]/tools list[/bold #5F9EA0] to see all tools.[/dim]")
2265
+ return CommandResult(status="handled")
2266
+
2267
+ if action == "enable":
2268
+ ToolRegistry.enable(matched)
2269
+ tool_settings.disabled_tools = [n for n in tool_settings.disabled_tools if n != matched]
2270
+ console.print(f"[green]Enabled: {matched}[/green]")
2271
+ else:
2272
+ ToolRegistry.disable(matched)
2273
+ if matched not in tool_settings.disabled_tools:
2274
+ tool_settings.disabled_tools.append(matched)
2275
+ console.print(f"[yellow]Disabled: {matched}[/yellow]")
2276
+
2277
+ _persist_disabled_tools(console)
2278
+ console.print()
2279
+ return CommandResult(status="handled")
2280
+
2281
+ console.print(f"[red]Unknown subcommand: {args}[/red]")
2282
+ console.print("Usage: [bold #5F9EA0]/tools[/bold #5F9EA0] [list | enable <name> | disable <name> | enable-group <key> | disable-group <key>]")
2283
+ return CommandResult(status="handled")
2284
+
2285
+ # No args — interactive toggle UI, organized by groups
2286
+ all_tools_map = {t.name: t for t in ToolRegistry._tools.values()}
2287
+ disabled = ToolRegistry.get_disabled()
2288
+
2289
+ categories = []
2290
+ for gkey, gdef in TOOL_GROUPS.items():
2291
+ group_options = []
2292
+ for tname in gdef["tools"]:
2293
+ t = all_tools_map.get(tname)
2294
+ if not t:
2295
+ continue
2296
+ is_off = tname in disabled
2297
+ group_options.append(SettingOption(
2298
+ key=tname,
2299
+ text=tname,
2300
+ value=not is_off,
2301
+ input_type="boolean",
2302
+ on_text="ON",
2303
+ off_text="OFF",
2304
+ ))
2305
+ if group_options:
2306
+ categories.append(SettingCategory(title=gdef["label"], settings=group_options))
2307
+
2308
+ # Catch any tools not in a group
2309
+ grouped_names = set()
2310
+ for gdef in TOOL_GROUPS.values():
2311
+ grouped_names.update(gdef["tools"])
2312
+ ungrouped = [
2313
+ t for t in sorted(all_tools_map.values(), key=lambda x: x.name)
2314
+ if t.name not in grouped_names
2315
+ ]
2316
+ if ungrouped:
2317
+ other_options = []
2318
+ for t in ungrouped:
2319
+ is_off = t.name in disabled
2320
+ other_options.append(SettingOption(
2321
+ key=t.name,
2322
+ text=t.name,
2323
+ value=not is_off,
2324
+ input_type="boolean",
2325
+ on_text="ON",
2326
+ off_text="OFF",
2327
+ description=f"Modes: {modes}",
2328
+ ))
2329
+ categories.append(SettingCategory(title="Other", settings=other_options))
2330
+
2331
+ selector = SettingSelector(
2332
+ categories=categories,
2333
+ title="Tool Settings",
2334
+ )
2335
+
2336
+ changes = selector.run()
2337
+
2338
+ if changes is None:
2339
+ console.print("[dim]Cancelled.[/dim]")
2340
+ return CommandResult(status="handled")
2341
+
2342
+ if not changes:
2343
+ console.print("[dim]No changes made.[/dim]")
2344
+ return CommandResult(status="handled")
2345
+
2346
+ # Apply changes
2347
+ newly_disabled = []
2348
+ newly_enabled = []
2349
+ for name, enabled in changes.items():
2350
+ if not enabled and name not in disabled:
2351
+ ToolRegistry.disable(name)
2352
+ newly_disabled.append(name)
2353
+ elif enabled and name in disabled:
2354
+ ToolRegistry.enable(name)
2355
+ newly_enabled.append(name)
2356
+
2357
+ # Sync tool_settings.disabled_tools to be the full current disabled set
2358
+ tool_settings.disabled_tools = sorted(ToolRegistry.get_disabled())
2359
+
2360
+ _persist_disabled_tools(console)
2361
+
2362
+ # Summary
2363
+ change_lines = []
2364
+ for name in newly_disabled:
2365
+ change_lines.append(f" [yellow]Disabled:[/yellow] {name}")
2366
+ for name in newly_enabled:
2367
+ change_lines.append(f" [green]Enabled:[/green] {name}")
2368
+
2369
+ if change_lines:
2370
+ total_enabled = len(ToolRegistry.get_all())
2371
+ total_disabled = len(ToolRegistry.get_disabled())
2372
+ console.print(f"[green]Tools updated:[/green] ({total_enabled} enabled, {total_disabled} disabled)")
2373
+ for line in change_lines:
2374
+ console.print(line)
2375
+ else:
2376
+ console.print("[dim]No changes applied.[/dim]")
2377
+
2378
+ return CommandResult(status="handled")
2379
+
2380
+
2381
+ def _handle_cd(chat_manager, console, debug_mode_container, args, cron_scheduler=None):
2382
+ """Handle /cd command — change working directory.
2383
+
2384
+ Usage: /cd <path>
2385
+ Examples:
2386
+ /cd /home/user/projects
2387
+ /cd ..
2388
+ /cd ~/Documents
2389
+ """
2390
+ import os
2391
+
2392
+ if not args or not args.strip():
2393
+ # Show current working directory
2394
+ cwd = os.getcwd()
2395
+ console.print(f"[bold #5F9EA0]Current directory:[/bold #5F9EA0] {cwd}")
2396
+ return CommandResult(status="handled")
2397
+
2398
+ path = args.strip()
2399
+
2400
+ # Expand ~ to home directory
2401
+ path = os.path.expanduser(path)
2402
+
2403
+ # Resolve to absolute path
2404
+ try:
2405
+ target_path = Path(path).resolve()
2406
+ except Exception as e:
2407
+ console.print(f"[red]Invalid path: {e}[/red]")
2408
+ return CommandResult(status="handled")
2409
+
2410
+ # Check if path exists and is a directory
2411
+ if not target_path.exists():
2412
+ console.print(f"[red]Directory not found: {target_path}[/red]")
2413
+ return CommandResult(status="handled")
2414
+
2415
+ if not target_path.is_dir():
2416
+ console.print(f"[red]Not a directory: {target_path}[/red]")
2417
+ return CommandResult(status="handled")
2418
+
2419
+ # Change directory
2420
+ try:
2421
+ os.chdir(target_path)
2422
+ console.print(f"[green]Changed directory to: {target_path}[/green]")
2423
+ except Exception as e:
2424
+ console.print(f"[red]Failed to change directory: {e}[/red]")
2425
+
2426
+ # Reset memory system singleton (project memory is per-repo)
2427
+ try:
2428
+ from core.memory import MemoryManager
2429
+ MemoryManager.reset()
2430
+ except Exception:
2431
+ pass
2432
+
2433
+ return CommandResult(status="handled")
2434
+
2435
+
2436
+ def _handle_obsidian_init(console, obsidian_settings):
2437
+ """Handle /obsidian init — scaffold project folder structure in vault."""
2438
+ if not obsidian_settings.is_active():
2439
+ console.print("[yellow]Obsidian vault is not configured or inactive.[/yellow]")
2440
+ console.print("[dim]Run [bold #5F9EA0]/obsidian set <path>[/bold #5F9EA0] to configure your vault.[/dim]")
2441
+ console.print()
2442
+ return CommandResult(status="handled")
2443
+
2444
+ # subcmd is always "init" — just do the init
2445
+ from tools.obsidian import get_vault_session
2446
+
2447
+ session = get_vault_session()
2448
+ if not session:
2449
+ console.print("[red]Could not determine project folder.[/red]")
2450
+ return CommandResult(status="handled")
2451
+
2452
+ project_folder = session.project_folder
2453
+
2454
+ # Check if already exists
2455
+ if project_folder.is_dir():
2456
+ console.print(f"[yellow]Project folder already exists: {project_folder}[/yellow]")
2457
+ console.print("[dim]No changes made. Delete the folder manually if you want to re-initialize.[/dim]")
2458
+ console.print()
2459
+ return CommandResult(status="handled")
2460
+
2461
+ # Define folder structure and templates
2462
+ folders = {
2463
+ "Bugs": (
2464
+ "---\n"
2465
+ "title: {title}\n"
2466
+ "type: bug\n"
2467
+ "status: reported\n"
2468
+ "priority: medium\n"
2469
+ "date_created: {date}\n"
2470
+ "date_modified: {date}\n"
2471
+ "tags: [bug]\n"
2472
+ "---\n"
2473
+ "\n"
2474
+ "# {title}\n"
2475
+ "\n"
2476
+ "**Description:**\n"
2477
+ "\n"
2478
+ "**Steps to reproduce:**\n"
2479
+ "\n"
2480
+ "**Expected behavior:**\n"
2481
+ "\n"
2482
+ "**Actual behavior:**\n"
2483
+ "\n"
2484
+ "Statuses: `reported` → `in-progress` → `fixed` → `verified`\n"
2485
+ ),
2486
+ "Tasks": (
2487
+ "---\n"
2488
+ "title: {title}\n"
2489
+ "type: task\n"
2490
+ "status: todo\n"
2491
+ "priority: medium\n"
2492
+ "date_created: {date}\n"
2493
+ "date_modified: {date}\n"
2494
+ "tags: [task]\n"
2495
+ "---\n"
2496
+ "\n"
2497
+ "# {title}\n"
2498
+ "\n"
2499
+ "**Description:**\n"
2500
+ "\n"
2501
+ "**Acceptance criteria:**\n"
2502
+ "\n"
2503
+ "Statuses: `todo` → `in-progress` → `done`\n"
2504
+ ),
2505
+ "Docs": (
2506
+ "---\n"
2507
+ "title: {title}\n"
2508
+ "type: doc\n"
2509
+ "date_created: {date}\n"
2510
+ "date_modified: {date}\n"
2511
+ "tags: [docs]\n"
2512
+ "---\n"
2513
+ "\n"
2514
+ "# {title}\n"
2515
+ "\n"
2516
+ ),
2517
+ }
2518
+
2519
+ from datetime import date
2520
+
2521
+ today = date.today().isoformat()
2522
+ created_folders = []
2523
+
2524
+ for folder_rel, template in folders.items():
2525
+ folder_path = project_folder / folder_rel
2526
+ folder_path.mkdir(parents=True, exist_ok=True)
2527
+ created_folders.append(folder_rel)
2528
+
2529
+ # Write template
2530
+ template_path = folder_path / "_Template.md"
2531
+ if not template_path.exists():
2532
+ title = folder_rel.split("/")[-1].rstrip("s")
2533
+ content = template.format(date=today, title=title)
2534
+ template_path.write_text(content, encoding="utf-8")
2535
+
2536
+ # Create Done/ subfolders for archiving completed notes
2537
+ for folder_rel in ("Bugs", "Tasks"):
2538
+ done_path = project_folder / folder_rel / "Done"
2539
+ done_path.mkdir(parents=True, exist_ok=True)
2540
+ created_folders.append(f"{folder_rel}/Done")
2541
+
2542
+ # Create Dashboard
2543
+ dashboard_path = project_folder / "Dashboard.md"
2544
+ repo_name = project_folder.name
2545
+ dv_tasks = (
2546
+ f'```dataview\n'
2547
+ f"TABLE status, priority, date_created\n"
2548
+ f'FROM "{session.project_folder_relative}/Tasks"\n'
2549
+ f'WHERE type = "task" AND status != "done"\n'
2550
+ f"SORT date_created DESC\n"
2551
+ f"```\n"
2552
+ )
2553
+ dv_bugs = (
2554
+ f'```dataview\n'
2555
+ f"TABLE status, priority, date_created\n"
2556
+ f'FROM "{session.project_folder_relative}/Bugs"\n'
2557
+ f'WHERE type = "bug" AND status != "fixed" AND status != "verified"\n'
2558
+ f"SORT date_created DESC\n"
2559
+ f"```\n"
2560
+ )
2561
+ dv_completed = (
2562
+ f'```dataview\n'
2563
+ f"TABLE type, status, date_modified\n"
2564
+ f'FROM "{session.project_folder_relative}"\n'
2565
+ f'WHERE (type = "task" AND status = "done")\n'
2566
+ f' OR (type = "bug" AND (status = "fixed" OR status = "verified"))\n'
2567
+ f"SORT date_modified DESC\n"
2568
+ f"```\n"
2569
+ )
2570
+ dashboard_content = (
2571
+ "---\n"
2572
+ "type: dashboard\n"
2573
+ "date_created: {date}\n"
2574
+ "date_modified: {date}\n"
2575
+ "tags: [dashboard]\n"
2576
+ "---\n"
2577
+ "\n"
2578
+ "# {title} Dashboard\n"
2579
+ "\n"
2580
+ "> [!summary] Project Overview\n"
2581
+ "> Check the Bugs/ and Tasks/ folders for issue tracking.\n"
2582
+ "\n"
2583
+ "## Open Tasks\n"
2584
+ "\n"
2585
+ f"{dv_tasks}\n"
2586
+ "## Open Bugs\n"
2587
+ "\n"
2588
+ f"{dv_bugs}\n"
2589
+ "## Recently Completed\n"
2590
+ "\n"
2591
+ f"{dv_completed}\n"
2592
+ )
2593
+ dashboard_content = dashboard_content.format(date=today, title=repo_name)
2594
+ dashboard_path.write_text(dashboard_content, encoding="utf-8")
2595
+ created_folders.append("Dashboard.md")
2596
+
2597
+ console.print(f"[green]Project initialized: {project_folder.name}[/green]")
2598
+ for folder in created_folders:
2599
+ console.print(f" [dim]Created: {folder}/ (_Template.md)[/dim]")
2600
+ console.print()
2601
+
2602
+ # Check if Dataview plugin is installed and enabled
2603
+ vault_root = session.vault_root
2604
+ community_plugins = vault_root / ".obsidian" / "community-plugins.json"
2605
+ dataview_dir = vault_root / ".obsidian" / "plugins" / "dataview"
2606
+ has_plugin_entry = (
2607
+ community_plugins.is_file()
2608
+ and "dataview" in community_plugins.read_text(encoding="utf-8")
2609
+ )
2610
+ has_plugin_files = (
2611
+ dataview_dir.is_dir()
2612
+ and (dataview_dir / "main.js").is_file()
2613
+ and (dataview_dir / "manifest.json").is_file()
2614
+ )
2615
+ if not has_plugin_entry or not has_plugin_files:
2616
+ console.print("[yellow]Dataview plugin not detected — dashboard tables won't render.[/yellow]")
2617
+ console.print("[dim]Install the Dataview community plugin in Obsidian:[/dim]")
2618
+ console.print("[dim] Settings → Community plugins → Browse → search 'Dataview' → Install & Enable[/dim]")
2619
+ console.print("[dim]Or download from: https://github.com/blacksmithgu/obsidian-dataview[/dim]")
2620
+ console.print()
2621
+
2622
+ console.print("[dim]Create issues with [bold #5F9EA0]/obsidian init[/bold #5F9EA0] to set up the project folder.[/dim]")
2623
+ console.print()
2624
+ return CommandResult(status="handled")
2625
+
2626
+
2627
+ # Command registry - maps command names to their handlers
2628
+ _COMMAND_HANDLERS = {
2629
+ "/exit": _handle_exit,
2630
+ "/quit": _handle_exit,
2631
+ "/help": _handle_help,
2632
+ "/h": _handle_help,
2633
+ "/compact": _handle_compact,
2634
+ "/clear": _handle_clear,
2635
+ "/new": _handle_clear,
2636
+ "/reset": _handle_clear,
2637
+ "/provider": _handle_provider,
2638
+ "/config": _handle_config,
2639
+
2640
+ "/edit": _handle_edit,
2641
+ "/e": _handle_edit,
2642
+ "/usage": _handle_usage,
2643
+ "/model": _handle_model,
2644
+ "/key": _handle_key,
2645
+ "/review": _handle_review,
2646
+ "/r": _handle_review,
2647
+ "/signup": _handle_signup,
2648
+ "/login": _handle_login,
2649
+ "/resend": _handle_resend,
2650
+ "/reset-key": _handle_reset_key,
2651
+ "/account": _handle_account,
2652
+ "/plan": _handle_plan,
2653
+ "/manage": _handle_manage,
2654
+ "/upgrade": _handle_upgrade,
2655
+ "/rotate-key": _handle_rotate_key,
2656
+ "/obsidian": _handle_obsidian,
2657
+ "/tools": _handle_tools,
2658
+ "/cd": _handle_cd,
2659
+ "/setup": _handle_setup,
2660
+ "/cron": _handle_cron,
2661
+ }
2662
+
2663
+
2664
+ def process_command(chat_manager, user_input, console, debug_mode_container, cron_scheduler=None):
2665
+ """Process command and optionally return replacement content.
2666
+
2667
+ Args:
2668
+ chat_manager: ChatManager instance
2669
+ user_input: User's input string
2670
+ console: Rich console for output
2671
+ debug_mode_container: Dict with 'debug' key for debug mode state
2672
+ cron_scheduler: Optional CronScheduler instance for immediate reload
2673
+
2674
+ Returns:
2675
+ tuple: (status, replacement_content)
2676
+ status: "exit" | "handled" | None
2677
+ replacement_content: str to replace user_input, or None
2678
+ """
2679
+ # Parse command and arguments
2680
+ parts = user_input.split(maxsplit=1)
2681
+ cmd = parts[0].lower()
2682
+ args = parts[1] if len(parts) > 1 else None
2683
+
2684
+ # Look up handler in registry
2685
+ handler = _COMMAND_HANDLERS.get(cmd)
2686
+ if handler:
2687
+ result = handler(chat_manager, console, debug_mode_container, args, cron_scheduler)
2688
+ return (result.status, result.replacement_input)
2689
+ elif cmd.startswith('/'):
2690
+ console.print(f"[red]Unknown command: {user_input}[/red]")
2691
+ console.print("[dim]Type [bold #5F9EA0]/help[/bold #5F9EA0] for available commands[/dim]")
2692
+ return ("handled", None)
2693
+
2694
+ return (None, None)