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