bone-agent 1.4.0 → 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 (126) 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 -201
  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 -144
  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 -50
  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/skills.md +0 -3
  25. package/prompts/main/targeted_searching.md +0 -10
  26. package/prompts/main/task_lists_pattern.md +0 -8
  27. package/prompts/main/temp_folder.md +0 -9
  28. package/prompts/main/think_before_acting.md +0 -10
  29. package/prompts/main/tone_and_style.md +0 -4
  30. package/prompts/main/tool_preferences.md +0 -24
  31. package/prompts/main/trust_subagent_context.md +0 -21
  32. package/prompts/main/when_to_use_sub_agent.md +0 -7
  33. package/prompts/micro/ask_questions.md +0 -1
  34. package/prompts/micro/batch_independent_calls.md +0 -1
  35. package/prompts/micro/casual_interactions.md +0 -1
  36. package/prompts/micro/code_references.md +0 -1
  37. package/prompts/micro/communication_style.md +0 -1
  38. package/prompts/micro/context_reliability.md +0 -1
  39. package/prompts/micro/conversational_tool_calling.md +0 -1
  40. package/prompts/micro/editing_pattern.md +0 -1
  41. package/prompts/micro/error_handling.md +0 -1
  42. package/prompts/micro/exploration_pattern.md +0 -1
  43. package/prompts/micro/intro.md +0 -1
  44. package/prompts/micro/obsidian.md +0 -4
  45. package/prompts/micro/obsidian_project.md +0 -5
  46. package/prompts/micro/professional_objectivity.md +0 -1
  47. package/prompts/micro/skills.md +0 -1
  48. package/prompts/micro/targeted_searching.md +0 -1
  49. package/prompts/micro/task_lists_pattern.md +0 -1
  50. package/prompts/micro/temp_folder.md +0 -1
  51. package/prompts/micro/think_before_acting.md +0 -5
  52. package/prompts/micro/tone_and_style.md +0 -1
  53. package/prompts/micro/tool_preferences.md +0 -1
  54. package/prompts/micro/trust_subagent_context.md +0 -1
  55. package/prompts/micro/when_to_use_sub_agent.md +0 -1
  56. package/requirements.txt +0 -9
  57. package/src/__init__.py +0 -11
  58. package/src/core/__init__.py +0 -1
  59. package/src/core/agentic.py +0 -1085
  60. package/src/core/chat_manager.py +0 -1577
  61. package/src/core/config_manager.py +0 -260
  62. package/src/core/cron.py +0 -578
  63. package/src/core/cron_allowlist.py +0 -118
  64. package/src/core/memory.py +0 -145
  65. package/src/core/metadata.py +0 -75
  66. package/src/core/retry.py +0 -71
  67. package/src/core/skills.py +0 -463
  68. package/src/core/sub_agent.py +0 -376
  69. package/src/core/tool_approval.py +0 -220
  70. package/src/core/tool_feedback.py +0 -789
  71. package/src/exceptions.py +0 -79
  72. package/src/llm/__init__.py +0 -1
  73. package/src/llm/client.py +0 -176
  74. package/src/llm/codex_provider.py +0 -350
  75. package/src/llm/config.py +0 -536
  76. package/src/llm/prompts.py +0 -494
  77. package/src/llm/providers.py +0 -438
  78. package/src/llm/streaming.py +0 -163
  79. package/src/llm/token_tracker.py +0 -399
  80. package/src/tools/__init__.py +0 -151
  81. package/src/tools/constants.py +0 -59
  82. package/src/tools/create_file.py +0 -136
  83. package/src/tools/directory.py +0 -389
  84. package/src/tools/edit.py +0 -549
  85. package/src/tools/file_reader.py +0 -322
  86. package/src/tools/helpers/__init__.py +0 -99
  87. package/src/tools/helpers/base.py +0 -599
  88. package/src/tools/helpers/converters.py +0 -44
  89. package/src/tools/helpers/file_helpers.py +0 -189
  90. package/src/tools/helpers/formatters.py +0 -411
  91. package/src/tools/helpers/loader.py +0 -145
  92. package/src/tools/helpers/parallel_executor.py +0 -231
  93. package/src/tools/helpers/path_resolver.py +0 -283
  94. package/src/tools/helpers/plugin_manifest.py +0 -185
  95. package/src/tools/obsidian.py +0 -96
  96. package/src/tools/review_sub_agent.py +0 -190
  97. package/src/tools/rg_search.py +0 -477
  98. package/src/tools/search_plugins.py +0 -177
  99. package/src/tools/select_option.py +0 -600
  100. package/src/tools/shell.py +0 -302
  101. package/src/tools/sub_agent.py +0 -139
  102. package/src/tools/task_list.py +0 -269
  103. package/src/tools/web_search.py +0 -61
  104. package/src/ui/__init__.py +0 -1
  105. package/src/ui/banner.py +0 -87
  106. package/src/ui/commands.py +0 -3131
  107. package/src/ui/displays.py +0 -239
  108. package/src/ui/loader.py +0 -284
  109. package/src/ui/main.py +0 -643
  110. package/src/ui/prompt_utils.py +0 -113
  111. package/src/ui/setting_selector.py +0 -590
  112. package/src/ui/setup_wizard.py +0 -294
  113. package/src/ui/sub_agent_panel.py +0 -234
  114. package/src/ui/tool_confirmation.py +0 -226
  115. package/src/utils/__init__.py +0 -1
  116. package/src/utils/citation_parser.py +0 -199
  117. package/src/utils/editor.py +0 -207
  118. package/src/utils/gitignore_filter.py +0 -149
  119. package/src/utils/logger.py +0 -254
  120. package/src/utils/paths.py +0 -30
  121. package/src/utils/result_parsers.py +0 -108
  122. package/src/utils/safe_commands.py +0 -243
  123. package/src/utils/settings.py +0 -195
  124. package/src/utils/user_message_logger.py +0 -120
  125. package/src/utils/validation.py +0 -201
  126. package/src/utils/web_search.py +0 -173
@@ -1,3131 +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, show_skills_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_activity_read_pct = (
595
- conv_cache_read / total_cached * 100
596
- ) if total_cached > 0 else 0
597
- cache_coverage_pct = (
598
- conv_cache_read / conv_in * 100
599
- ) if conv_in > 0 else 0
600
- console.print(f" Cache read: {conv_cache_read:,} tokens")
601
- console.print(f" Cache write: {conv_cache_creation:,} tokens")
602
- console.print(f" ({cache_coverage_pct:.0f}% input cached, {cache_activity_read_pct:.0f}% cache reads)")
603
-
604
- # Display cost — combined actual + estimated, with config-based fallback
605
- tracker_conv = chat_manager.token_tracker
606
- if tracker_conv.has_actual_cost():
607
- conv_cost = tracker_conv.conv_actual_cost + tracker_conv.conv_estimated_cost
608
- else:
609
- conv_cost = tracker_conv.get_conversation_display_cost(costs['in'], costs['out'])
610
- if conv_cost > 0:
611
- console.print(f" Cost: ${conv_cost:.4f}")
612
-
613
- console.print()
614
-
615
- chat_manager.reset_session()
616
- display_startup_banner(chat_manager.approve_mode, clear_screen=True)
617
- return CommandResult(status="handled")
618
-
619
-
620
- def _open_provider_editor(chat_manager, console, provider):
621
- """Open interactive setting editor for a specific provider.
622
-
623
- Args:
624
- chat_manager: ChatManager instance
625
- console: Rich console for output
626
- provider: Provider name (e.g. 'openrouter', 'glm')
627
-
628
- Returns:
629
- True if settings were saved, False if cancelled
630
- """
631
- from ui.setting_selector import SettingOption, SettingCategory, SettingSelector
632
-
633
- cfg = config.get_provider_config(provider)
634
- config_data = config_manager.load()
635
- settings = []
636
-
637
- # Model setting
638
- current_model = cfg.get('model') or cfg.get('api_model') or ''
639
- model_label = "Model path" if provider == "local" else "Model"
640
- settings.append(SettingOption(
641
- key="model", text=model_label,
642
- value=current_model, input_type="text",
643
- ))
644
-
645
- # API key (not for local or bone — bone manages its own key via /signup)
646
- if provider not in ("local", "bone"):
647
- current_key = cfg.get('api_key', '')
648
- # Show masked value, store actual in description for comparison
649
- masked = (current_key[:8] + "...") if len(current_key) > 8 else (current_key or "")
650
- settings.append(SettingOption(
651
- key="api_key", text="API Key",
652
- value=masked, input_type="text",
653
- description=current_key,
654
- ))
655
-
656
- # Cost in/out (not for local or bone — costs are server-side)
657
- if provider not in ("local", "bone"):
658
- model_prices = config_data.get("MODEL_PRICES", {})
659
- existing = model_prices.get(current_model, {})
660
- settings.append(SettingOption(
661
- key="cost_in", text="Cost in ($/1M tokens)",
662
- value=existing.get('cost_in', 0.0), input_type="float",
663
- min_val=0.0, step=0.01,
664
- ))
665
- settings.append(SettingOption(
666
- key="cost_out", text="Cost out ($/1M tokens)",
667
- value=existing.get('cost_out', 0.0), input_type="float",
668
- min_val=0.0, step=0.01,
669
- ))
670
-
671
- provider_label = config.get_provider_display_name(provider)
672
- category = SettingCategory(title=f"{provider_label} Settings", settings=settings)
673
-
674
- selector = SettingSelector(
675
- categories=[category],
676
- title=f"Configure {provider_label}",
677
- )
678
-
679
- changes = selector.run()
680
-
681
- if changes is None:
682
- console.print("[dim]No changes made.[/dim]")
683
- return False
684
-
685
- # Apply changes
686
- change_lines = []
687
-
688
- if "model" in changes and changes["model"]:
689
- try:
690
- config_manager.set_model(provider, changes["model"])
691
- change_lines.append(f" Model: {changes['model']}")
692
- except Exception as e:
693
- console.print(f"[red]Failed to set model: {e}[/red]")
694
-
695
- if "api_key" in changes and changes["api_key"]:
696
- # Don't re-save if the user didn't actually change it (masked display)
697
- api_key_input = changes["api_key"]
698
- original_key = cfg.get('api_key', '')
699
- # Detect if user typed a real key (longer than masked display or different)
700
- if api_key_input != original_key and not api_key_input.endswith("..."):
701
- try:
702
- config_manager.set_api_key(provider, api_key_input)
703
- masked = (api_key_input[:8] + "...") if len(api_key_input) > 8 else api_key_input
704
- change_lines.append(f" API Key: {masked}")
705
- except Exception as e:
706
- console.print(f"[red]Failed to set API key: {e}[/red]")
707
-
708
- if "cost_in" in changes or "cost_out" in changes:
709
- model_name = changes.get("model") or current_model
710
- if model_name:
711
- # Use changed values, falling back to originals (not 0.0)
712
- existing_prices = config_data.get("MODEL_PRICES", {}).get(model_name, {})
713
- cost_in = changes.get("cost_in", existing_prices.get("cost_in", 0.0))
714
- cost_out = changes.get("cost_out", existing_prices.get("cost_out", 0.0))
715
- try:
716
- config_manager.set_model_price(model_name, cost_in, cost_out)
717
- change_lines.append(f" Cost: ${cost_in:.2f}/${cost_out:.2f} per 1M tokens")
718
- except Exception as e:
719
- console.print(f"[red]Failed to set pricing: {e}[/red]")
720
-
721
- # Reload config and switch provider
722
- config_manager.set_provider(provider)
723
- chat_manager.reload_config()
724
- result = chat_manager.switch_provider(provider)
725
-
726
- if change_lines:
727
- console.print(f"[green]{provider} updated:[/green]")
728
- for line in change_lines:
729
- console.print(line)
730
- else:
731
- console.print(f"[green]{provider} activated.[/green]")
732
-
733
- if "Failed" not in result and "failed" not in result:
734
- console.print(f"[dim]{result}[/dim]")
735
-
736
- return True
737
-
738
-
739
- def _handle_provider(chat_manager, console, debug_mode_container, args, cron_scheduler=None):
740
- """Handle provider switching and configuration command."""
741
- current = getattr(chat_manager.client, 'provider', 'unknown')
742
-
743
- if args:
744
- provider = args.strip().lower()
745
-
746
- # Validate provider name
747
- if provider not in config.get_providers():
748
- console.print(f"[red]Error: Unknown provider '{provider}'[/red]")
749
- available = ', '.join(config.get_provider_display_name(prov) for prov in config.get_providers())
750
- console.print(f"[dim]Available providers: {available}[/dim]")
751
- return CommandResult(status="handled")
752
-
753
- # Switch directly to the named provider
754
- if provider == current:
755
- console.print(f"[dim]Already on {provider}[/dim]")
756
- return CommandResult(status="handled")
757
-
758
- config_manager.set_provider(provider)
759
- chat_manager.reload_config()
760
- result = chat_manager.switch_provider(provider)
761
-
762
- cfg = config.get_provider_config(provider)
763
- model = cfg.get('model') or cfg.get('api_model') or ''
764
- label = config.get_provider_display_name(provider)
765
- if model:
766
- label += f" ({model})"
767
- console.print(f"[green]Switched to {label}[/green]")
768
- if "Failed" not in result and "failed" not in result:
769
- console.print(f"[dim]{result}[/dim]")
770
-
771
- return CommandResult(status="handled")
772
- else:
773
- # Show all providers as a radio-button list (same style as model selector)
774
- from ui.setting_selector import SettingOption, SettingCategory, SettingSelector
775
-
776
- provider_options = []
777
- for prov in config.get_providers():
778
- cfg = config.get_provider_config(prov)
779
- model = cfg.get('model') or cfg.get('api_model') or ''
780
- entry = {"value": prov, "text": config.get_provider_display_name(prov)}
781
- if model:
782
- entry["description"] = model[:40]
783
- provider_options.append(entry)
784
-
785
- provider_setting = SettingOption(
786
- key="provider",
787
- text="Select Provider",
788
- value=current,
789
- input_type="options",
790
- options=provider_options,
791
- )
792
-
793
- selector = SettingSelector(
794
- categories=[SettingCategory(title="", settings=[provider_setting])],
795
- title="",
796
- show_save=False,
797
- )
798
- result = selector.run()
799
-
800
- if result is None:
801
- console.print("[dim]Cancelled.[/dim]")
802
- return CommandResult(status="handled")
803
-
804
- # Get selected provider (from changes, or current if unchanged)
805
- provider = result.get('provider', current)
806
-
807
- # Open interactive editor for the selected provider
808
- _open_provider_editor(chat_manager, console, provider)
809
-
810
- return CommandResult(status="handled")
811
-
812
-
813
- def _handle_model(chat_manager, console, debug_mode_container, args, cron_scheduler=None):
814
- """Handle model setting command."""
815
- current_provider = getattr(chat_manager.client, 'provider', 'unknown')
816
-
817
- # For bone provider, show interactive model selection
818
- if current_provider == "bone" and not args:
819
- from ui.setting_selector import SettingOption, SettingCategory, SettingSelector
820
-
821
- cfg = config.get_provider_config(current_provider)
822
- current_model = cfg.get('model') or cfg.get('api_model') or ''
823
-
824
- # Models available via bone proxy (OpenRouter-compatible)
825
- # Format: (display_name, openrouter_model_id)
826
- bone_models = [
827
- # DeepSeek
828
- ("DeepSeek-V3.2 1×", "deepseek/deepseek-v3.2"),
829
- # MiniMax
830
- ("MiniMax-M2.5 1×", "minimax/minimax-m2.5"),
831
- ("MiniMax-M2.7 1.5×", "minimax/minimax-m2.7"),
832
- # Moonshot AI
833
- ("Kimi-K2.5 3×", "moonshotai/kimi-k2.5"),
834
- # xAI
835
- ("Grok-Code-Fast-1 1.5×", "x-ai/grok-code-fast-1"),
836
- ("Grok-4.1-Fast 1×", "x-ai/grok-4.1-fast"),
837
- # Z-AI
838
- ("GLM-4.5-Air (Free) 0×", "z-ai/glm-4.5-air:free"),
839
- ("GLM-4.7 3×", "z-ai/glm-4.7"),
840
- ("GLM-5 5×", "z-ai/glm-5"),
841
- ("GLM-5-Turbo 10×", "z-ai/glm-5-turbo"),
842
- ("GLM-5.1 10×", "z-ai/glm-5.1"),
843
- ]
844
-
845
- model_options = []
846
- active_value = current_model
847
- for display_name, model_id in bone_models:
848
- if model_id == current_model or display_name.lower() == current_model.lower():
849
- active_value = model_id
850
- model_options.append({
851
- "value": model_id,
852
- "text": display_name,
853
- })
854
-
855
- model_setting = SettingOption(
856
- key="model",
857
- text="Select Model",
858
- value=active_value,
859
- input_type="options",
860
- options=model_options,
861
- )
862
-
863
- selector = SettingSelector(
864
- categories=[SettingCategory(title="", settings=[model_setting])],
865
- title="",
866
- show_save=False,
867
- )
868
- result = selector.run()
869
-
870
- if result is None or not isinstance(result, dict) or 'model' not in result:
871
- console.print("[dim]Cancelled.[/dim]")
872
- return CommandResult(status="handled")
873
-
874
- model = result['model']
875
- elif not args:
876
- # Show current model for current provider
877
- cfg = config.get_provider_config(current_provider)
878
- model = cfg.get('model') or cfg.get('api_model') or 'Not set'
879
- console.print(f"[bold #5F9EA0]Current provider:[/bold #5F9EA0] {current_provider}")
880
- console.print(f"[bold #5F9EA0]Current model:[/bold #5F9EA0] {model}")
881
- return CommandResult(status="handled")
882
- else:
883
- model = args.strip()
884
-
885
- # Set model for current provider
886
- try:
887
- backup_path = config_manager.set_model(current_provider, model)
888
- console.print(f"[green]Model set to '{model}' for {current_provider} provider[/green]")
889
- if backup_path:
890
- console.print(f"[dim]Saved to config.json (backup: {backup_path.name})[/dim]")
891
-
892
- # Reload config and update client
893
- chat_manager.reload_config()
894
- except ValueError as e:
895
- console.print(f"[red]Error: {e}[/red]")
896
- except Exception as e:
897
- console.print(f"[red]Failed to set model: {e}[/red]")
898
-
899
- return CommandResult(status="handled")
900
-
901
-
902
- def _handle_key(chat_manager, console, debug_mode_container, args, cron_scheduler=None):
903
- """Handle API key setting command."""
904
- if not args:
905
- # Show current API key status for current provider
906
- current_provider = getattr(chat_manager.client, 'provider', 'unknown')
907
- cfg = config.get_provider_config(current_provider)
908
-
909
- if current_provider == "local":
910
- console.print("[yellow]Local provider doesn't use API keys[/yellow]")
911
- else:
912
- api_key = cfg.get('api_key', '')
913
- if api_key:
914
- # Show masked API key
915
- masked = api_key[:8] + "..." if len(api_key) > 8 else "***"
916
- console.print(f"[bold #5F9EA0]Current provider:[/bold #5F9EA0] {current_provider}")
917
- console.print(f"[bold #5F9EA0]API key:[/bold #5F9EA0] {masked}")
918
- else:
919
- console.print(f"[bold #5F9EA0]Current provider:[/bold #5F9EA0] {current_provider}")
920
- console.print("[yellow]API key not set[/yellow]")
921
- return CommandResult(status="handled")
922
-
923
- api_key = args.strip()
924
-
925
- # Set API key for current provider
926
- current_provider = getattr(chat_manager.client, 'provider', 'unknown')
927
-
928
- if current_provider == "local":
929
- console.print("[yellow]Local provider doesn't use API keys[/yellow]")
930
- return CommandResult(status="handled")
931
-
932
- try:
933
- backup_path = config_manager.set_api_key(current_provider, api_key)
934
- console.print(f"[green]API key set for {current_provider} provider[/green]")
935
- if backup_path:
936
- console.print(f"[dim]Saved to config.json (backup: {backup_path.name})[/dim]")
937
-
938
- # Reload config and update client
939
- chat_manager.reload_config()
940
- except ValueError as e:
941
- console.print(f"[red]Error: {e}[/red]")
942
- except Exception as e:
943
- console.print(f"[red]Failed to set API key: {e}[/red]")
944
-
945
- return CommandResult(status="handled")
946
-
947
-
948
-
949
- def _handle_edit(chat_manager, console, debug_mode_container, args, cron_scheduler=None):
950
- """Handle external editor command for multi-line input.
951
-
952
- Opens an external editor for composing prompts. After the editor closes,
953
- the content is sent to the LLM.
954
-
955
- Returns:
956
- CommandResult: status="handled" if cancelled/failed
957
- status="continue" with replacement_input to send to LLM
958
- """
959
- from utils.editor import open_editor_for_input
960
-
961
- success, content = open_editor_for_input(
962
- console,
963
- debug_mode_container['debug']
964
- )
965
-
966
- if not success:
967
- # Error already displayed by open_editor_for_input
968
- return CommandResult(status="handled")
969
-
970
- # Check if content is empty
971
- if not content or not content.strip():
972
- console.print("[yellow]Editor closed with no content - cancelling[/yellow]")
973
- return CommandResult(status="handled")
974
-
975
- # Show summary
976
- lines = [line for line in content.split('\n') if line.strip()]
977
- word_count = len(content.split())
978
- console.print(f"[green]Received {len(lines)} lines ({word_count} words) from editor[/green]")
979
-
980
- # Return continue status to pass content to LLM
981
- return CommandResult(status="continue", replacement_input=content)
982
-
983
-
984
-
985
-
986
-
987
- def _handle_usage(chat_manager, console, debug_mode_container, args, cron_scheduler=None):
988
- """Handle usage command - show/calculate token costs or set cost rates."""
989
- console.print()
990
-
991
- # Get current model
992
- current_model = getattr(chat_manager.client, 'model', '')
993
-
994
- if args:
995
- # Parse setting command: in|out <value>
996
- parts = args.split()
997
-
998
- if len(parts) != 2 or parts[0].lower() not in ['in', 'out']:
999
- console.print("[red]Usage: /usage in|out <cost>[/red]")
1000
- console.print("[dim]Cost is per 1M tokens (e.g., 0.5 = $0.50 per 1M tokens)[/dim]")
1001
- console.print("[dim]Examples:[/dim]")
1002
- console.print(f"[dim] /usage in 1.00 - Set input cost for current model ({current_model})[/dim]")
1003
- console.print(f"[dim] /usage out 3.20 - Set output cost for current model ({current_model})[/dim]")
1004
- console.print()
1005
- return CommandResult(status="handled")
1006
-
1007
- direction, value = parts
1008
- direction = direction.lower()
1009
-
1010
- try:
1011
- cost = float(value)
1012
- if cost < 0:
1013
- console.print("[red]Error: Cost must be non-negative[/red]")
1014
- console.print()
1015
- return CommandResult(status="handled")
1016
- except ValueError:
1017
- console.print("[red]Error: Cost must be a valid number[/red]")
1018
- console.print()
1019
- return CommandResult(status="handled")
1020
-
1021
- # Set appropriate cost for current model
1022
- # Get existing prices for the model
1023
- existing_prices = config_manager.get_model_price(current_model)
1024
- cost_in = existing_prices['in']
1025
- cost_out = existing_prices['out']
1026
-
1027
- if direction == 'in':
1028
- cost_in = cost
1029
- elif direction == 'out':
1030
- cost_out = cost
1031
-
1032
- backup_path = config_manager.set_model_price(current_model, cost_in, cost_out)
1033
-
1034
- if direction == 'in':
1035
- console.print(f"[green]Model '{current_model}' input token cost set to ${cost:.6f} per 1M tokens[/green]")
1036
- else:
1037
- console.print(f"[green]Model '{current_model}' output token cost set to ${cost:.6f} per 1M tokens[/green]")
1038
-
1039
- if backup_path:
1040
- console.print(f"[dim]Saved to config.json (backup: {backup_path.name})[/dim]")
1041
-
1042
- console.print()
1043
- return CommandResult(status="handled")
1044
-
1045
- # No args - show current usage stats
1046
- current_provider = getattr(chat_manager.client, 'provider', 'unknown')
1047
-
1048
- # bone: fetch from proxy API
1049
- if current_provider == "bone":
1050
- cfg = config.get_provider_config("bone")
1051
- api_key = cfg.get('api_key', '')
1052
- api_base = cfg.get('api_base', 'https://api.vmcode.dev')
1053
-
1054
- if not api_key:
1055
- console.print("[yellow]No API key set for bone. Use /key to set one.[/yellow]")
1056
- console.print()
1057
- return CommandResult(status="handled")
1058
-
1059
- # Fetch usage from proxy and render percentage bars
1060
- status_code, usage = _call_proxy_api("GET", "/v1/usage", api_base, api_key=api_key)
1061
- if status_code == 0 or usage is None:
1062
- console.print("[red]Failed to fetch usage from bone.[/red]")
1063
- console.print("[dim]Check your API key and network connection.[/dim]")
1064
- console.print()
1065
- return CommandResult(status="handled")
1066
-
1067
- plan_label = usage.get("plan", "unknown").capitalize()
1068
- console.print(f"[bold #5F9EA0]Usage -- {plan_label} Plan[/bold #5F9EA0]")
1069
- console.print()
1070
-
1071
- for period in ("daily", "weekly"):
1072
- data = usage.get(period, {})
1073
- pct = data.get("pct_used", 0)
1074
- label = period.capitalize()
1075
- filled = int(round(pct / 100 * 20))
1076
- bar = "\u2588" * filled + "\u2591" * (20 - filled)
1077
- reset_at = data.get("reset_at", "")
1078
- if pct >= 90:
1079
- indicator = "[bold red]![/bold red]"
1080
- elif pct >= 70:
1081
- indicator = "[bold yellow]~[/bold yellow]"
1082
- else:
1083
- indicator = "[bold green]+[/bold green]"
1084
- reset_str = f" [dim]resets {reset_at}[/dim]" if reset_at else ""
1085
- console.print(f" {indicator} [bold]{label:7s}[/bold] {bar} [bold]{pct:.1f}%[/bold]{reset_str}")
1086
-
1087
- console.print()
1088
- return CommandResult(status="handled")
1089
-
1090
- # All other providers: show local session stats
1091
- costs = config_manager.get_model_price(current_model)
1092
- tracker = chat_manager.token_tracker
1093
-
1094
- # Display token counts
1095
- console.print(f"[#5F9EA0]Session Token Usage ({current_model}):[/#5F9EA0]")
1096
- console.print(f" Input tokens: {tracker.total_prompt_tokens:,}")
1097
- console.print(f" Output tokens: {tracker.total_completion_tokens:,}")
1098
- console.print(f" Total tokens: {tracker.total_tokens:,}")
1099
-
1100
- # Display cache token breakdown when cache tokens were recorded.
1101
- # Codex can report an explicit 0 cached_tokens value, which is still useful
1102
- # confirmation that prompt-cache usage data is flowing through.
1103
- show_cache = (
1104
- tracker.total_cache_read_tokens > 0
1105
- or tracker.total_cache_creation_tokens > 0
1106
- or current_provider == "codex"
1107
- )
1108
- if show_cache:
1109
- total_cached = tracker.total_cache_read_tokens + tracker.total_cache_creation_tokens
1110
- cache_activity_read_pct = (
1111
- tracker.total_cache_read_tokens
1112
- / total_cached * 100
1113
- ) if total_cached > 0 else 0
1114
- cache_coverage_pct = (
1115
- tracker.total_cache_read_tokens
1116
- / tracker.total_prompt_tokens * 100
1117
- ) if tracker.total_prompt_tokens > 0 else 0
1118
- console.print()
1119
- console.print(
1120
- f"[#5F9EA0]Input Cache ({cache_coverage_pct:.0f}% input cached, "
1121
- f"{cache_activity_read_pct:.0f}% cache reads):[/#5F9EA0]"
1122
- )
1123
- console.print(f" Cache read: {tracker.total_cache_read_tokens:,} tokens")
1124
- console.print(f" Cache write: {tracker.total_cache_creation_tokens:,} tokens")
1125
- if current_provider == "codex" and total_cached == 0:
1126
- if tracker.last_cache_metrics_reported is False:
1127
- keys = ", ".join(tracker.last_usage_keys) if tracker.last_usage_keys else "none"
1128
- console.print(" [dim]Codex did not report any cache-token fields in the last usage payload.[/dim]")
1129
- console.print(f" [dim]Last usage keys: {keys}[/dim]")
1130
- elif tracker.last_cache_metrics_reported is None:
1131
- console.print(" [dim]No usage payload has been recorded yet for this Codex session.[/dim]")
1132
- console.print()
1133
-
1134
-
1135
- # Display costs — combined upstream-reported + estimated
1136
- display_cost = tracker.get_display_cost(current_model)
1137
- if display_cost > 0:
1138
- console.print(f"[#5F9EA0]Session Cost ({current_model}):[/#5F9EA0]")
1139
- console.print(f" Total: ${display_cost:.6f}")
1140
- console.print()
1141
- if tracker.has_actual_cost():
1142
- console.print(f"[dim]Note: Includes ${tracker.total_actual_cost:.6f} provider-reported "
1143
- f"+ ${tracker.total_estimated_cost:.6f} locally estimated.[/dim]")
1144
- else:
1145
- console.print(f"[dim]Note: Cost estimated from token counts × static rates.[/dim]")
1146
- console.print()
1147
- else:
1148
- if costs['in'] > 0 or costs['out'] > 0:
1149
- console.print(" No cost data available (no tokens used yet).")
1150
- console.print(f"[dim]Rates: ${costs['in']:.6f}/1M in, ${costs['out']:.6f}/1M out[/dim]")
1151
- console.print()
1152
- else:
1153
- console.print(f"[yellow]Cost not configured for model '{current_model}'. Set with:[/yellow]")
1154
- console.print(f" [bold #5F9EA0]/usage[/bold #5F9EA0] in <cost> - Set input token cost per 1M tokens")
1155
- console.print(f" [bold #5F9EA0]/usage[/bold #5F9EA0] out <cost> - Set output token cost per 1M tokens")
1156
- console.print(f"[dim]Example: [bold #5F9EA0]/usage[/bold #5F9EA0] in 2.50[/dim]")
1157
- console.print()
1158
-
1159
- return CommandResult(status="handled")
1160
-
1161
-
1162
- def _handle_review(chat_manager, console, debug_mode_container, args, cron_scheduler=None):
1163
- """Handle review command - run code review on git changes."""
1164
- import subprocess
1165
- import os
1166
- import sys
1167
-
1168
- from tools.review_sub_agent import review_changes
1169
-
1170
- # Parse args: separate git diff flags from user intent
1171
- # Format: /r [git-args] [-- intent description]
1172
- # Examples:
1173
- # /r --staged
1174
- # /r I wanted to reduce the system prompt length
1175
- # /r --staged -- I was refactoring the auth module
1176
- user_intent = None
1177
- git_args = ""
1178
-
1179
- if args and args.strip():
1180
- raw_args = args.strip()
1181
- # Explicit delimiter: " -- " splits git args from intent
1182
- if " -- " in raw_args:
1183
- parts = raw_args.split(" -- ", 1)
1184
- git_args = parts[0].strip()
1185
- user_intent = parts[1].strip()
1186
- else:
1187
- # Heuristic: tokens starting with '-' are git flags, rest is intent
1188
- tokens = raw_args.split()
1189
- git_tokens = []
1190
- intent_tokens = []
1191
- in_intent = False
1192
- for token in tokens:
1193
- if in_intent or not token.startswith("-"):
1194
- in_intent = True
1195
- intent_tokens.append(token)
1196
- else:
1197
- git_tokens.append(token)
1198
- git_args = " ".join(git_tokens)
1199
- if intent_tokens:
1200
- user_intent = " ".join(intent_tokens)
1201
-
1202
- # Build git diff argument list (no shell=True to prevent command injection)
1203
- git_argv = ["git", "diff"] + git_args.split()
1204
-
1205
- # Reject shell metacharacters as defense-in-depth
1206
- import re
1207
- dangerous = re.compile(r'[;&|`$(){}<>!]')
1208
- for arg in git_argv[2:]:
1209
- if dangerous.search(arg):
1210
- console.print(f"[red]Rejected dangerous character in argument: {arg}[/red]")
1211
- return CommandResult(status="handled")
1212
-
1213
- if user_intent:
1214
- console.print(f"[#5F9EA0]Running: {' '.join(git_argv)}[/#5F9EA0]")
1215
- console.print(f"[dim]Intent: {user_intent}[/dim]")
1216
- else:
1217
- console.print(f"[#5F9EA0]Running: {' '.join(git_argv)}[/#5F9EA0]")
1218
-
1219
- # Run git diff
1220
- result = subprocess.run(
1221
- git_argv,
1222
- shell=False,
1223
- capture_output=True,
1224
- text=True,
1225
- )
1226
-
1227
- if result.returncode != 0:
1228
- console.print(f"[red]git diff failed:[/red]")
1229
- console.print(f"[dim]{result.stderr.strip()}[/dim]")
1230
- return CommandResult(status="handled")
1231
-
1232
- diff_output = result.stdout.strip()
1233
- if not diff_output:
1234
- console.print("[yellow]No changes to review.[/yellow]")
1235
- return CommandResult(status="handled")
1236
-
1237
- # Count changed files for summary
1238
- file_count = diff_output.count("diff --git ")
1239
- console.print(f"[dim]Reviewing {file_count} changed file(s)...[/dim]")
1240
- console.print()
1241
-
1242
- # Compute paths from shared module
1243
- from utils.paths import REPO_ROOT, RG_EXE_PATH as _RG_EXE_PATH
1244
- repo_root = REPO_ROOT
1245
- rg_exe_path = str(_RG_EXE_PATH)
1246
-
1247
- # Create a live panel for the review sub-agent
1248
- panel = SubAgentPanel("Reviewing git diff", console)
1249
-
1250
- # Run the review
1251
- review_result = review_changes(
1252
- diff_output=diff_output,
1253
- repo_root=repo_root,
1254
- rg_exe_path=rg_exe_path,
1255
- console=console,
1256
- chat_manager=chat_manager,
1257
- panel_updater=panel,
1258
- user_intent=user_intent,
1259
- )
1260
-
1261
- display_text = review_result["display"]
1262
- history_text = review_result["history"]
1263
-
1264
- # Display clean result as rendered Markdown (no injected file contents)
1265
- if display_text:
1266
- console.print()
1267
- md = Markdown(left_align_headings(display_text), code_theme=MonokaiDarkBGStyle, justify="left")
1268
- console.print(md)
1269
- console.print()
1270
-
1271
- # Inject review (with file contents) into chat history for follow-up context
1272
- if history_text:
1273
- review_cmd = "/review"
1274
- if user_intent:
1275
- review_cmd += f"\n\nUser intent: {user_intent}"
1276
- chat_manager.messages.append({
1277
- "role": "user",
1278
- "content": review_cmd
1279
- })
1280
- chat_manager.messages.append({
1281
- "role": "assistant",
1282
- "content": f"Here is the code review of the current git diff:\n\n{history_text}"
1283
- })
1284
-
1285
- # Update context token tracker so compaction timing stays accurate
1286
- injected_tokens = chat_manager.token_tracker.estimate_tokens(
1287
- f"{review_cmd}\n\n{history_text}"
1288
- )
1289
- chat_manager.token_tracker.current_context_tokens += injected_tokens
1290
-
1291
- return CommandResult(status="handled")
1292
-
1293
-
1294
- # ============================================
1295
- # Shared proxy API helper
1296
- # ============================================
1297
-
1298
- def _call_proxy_api(
1299
- method: str,
1300
- path: str,
1301
- api_base: str,
1302
- body: dict | None = None,
1303
- api_key: str | None = None,
1304
- timeout: int = 10,
1305
- ) -> tuple[int, dict | None]:
1306
- """Call a bone-proxy API endpoint.
1307
-
1308
- Returns (status_code, parsed_json_or_None).
1309
- Returns (0, None) on network/parse failures.
1310
- """
1311
- # Validate endpoint uses HTTPS (or localhost HTTP)
1312
- full_url = f"{api_base.rstrip('/')}{path}"
1313
- valid, err = validate_api_url(full_url)
1314
- if not valid:
1315
- logger.warning("Proxy API call rejected: %s", err)
1316
- return (0, None)
1317
-
1318
- # Enforce TLS regardless of global settings
1319
- ssl_ctx = ssl.create_default_context()
1320
-
1321
- try:
1322
- data = None
1323
- if body is not None:
1324
- data = json.dumps(body).encode("utf-8")
1325
-
1326
- req = urllib.request.Request(full_url, data=data, method=method)
1327
- req.add_header("Content-Type", "application/json")
1328
- if api_key:
1329
- req.add_header("Authorization", f"Bearer {api_key}")
1330
-
1331
- with urllib.request.urlopen(req, timeout=timeout, context=ssl_ctx) as resp:
1332
- return (resp.status, json.loads(resp.read().decode()))
1333
- except urllib.error.HTTPError as e:
1334
- try:
1335
- return (e.code, json.loads(e.read().decode()))
1336
- except Exception:
1337
- return (e.code, None)
1338
- except Exception as e:
1339
- logger.debug("Proxy API call failed: %s", e)
1340
- return (0, None)
1341
-
1342
-
1343
- def _get_proxy_config(chat_manager):
1344
- """Get bone api_key and api_base from current config.
1345
-
1346
- Returns (api_key, api_base) tuple. api_key may be empty string.
1347
- """
1348
- cfg = config.get_provider_config("bone")
1349
- api_key = cfg.get("api_key", "")
1350
- api_base = cfg.get("api_base", "https://api.vmcode.dev")
1351
- return api_key, api_base
1352
-
1353
-
1354
- def _require_proxy_provider(chat_manager, console):
1355
- """Check that bone is the current provider.
1356
-
1357
- Returns True if on bone, prints error and returns False otherwise.
1358
- """
1359
- current_provider = getattr(chat_manager.client, "provider", "unknown")
1360
- if current_provider != "bone":
1361
- console.print(
1362
- "[yellow]This command requires the bone provider.[/yellow]"
1363
- )
1364
- console.print("[dim]Run [bold #5F9EA0]/provider bone[/bold #5F9EA0] first.[/dim]")
1365
- console.print()
1366
- return False
1367
- return True
1368
-
1369
-
1370
- # ============================================
1371
- # Account command handlers
1372
- # ============================================
1373
-
1374
- def _handle_plan(chat_manager, console, debug_mode_container, args, cron_scheduler=None):
1375
- """Handle /plan — show available plans."""
1376
- _, api_base = _get_proxy_config(chat_manager)
1377
-
1378
- # Try the API first
1379
- status, data = _call_proxy_api("GET", "/v1/billing/plans", api_base)
1380
-
1381
- if status == 200 and data and "plans" in data:
1382
- plans = data["plans"]
1383
- else:
1384
- # Fallback to hardcoded defaults
1385
- plans = [
1386
- {"id": "free", "name": "Free", "price": 0, "tokens": 0, "rate_limit": 0},
1387
- {"id": "lite", "name": "Lite", "price": 10, "tokens": 2_000_000, "rate_limit": 60},
1388
- {"id": "pro", "name": "Pro", "price": 50, "tokens": 15_000_000, "rate_limit": 300},
1389
- ]
1390
-
1391
- # Determine current plan
1392
- current_provider = getattr(chat_manager.client, "provider", "unknown")
1393
- current_plan = None
1394
- if current_provider == "bone":
1395
- api_key, _ = _get_proxy_config(chat_manager)
1396
- if api_key:
1397
- acct_status, acct_data = _call_proxy_api("GET", "/v1/auth/account", api_base, api_key=api_key)
1398
- if acct_status == 200 and acct_data:
1399
- current_plan = acct_data.get("plan")
1400
-
1401
- table = Table("Plan", "Price", "Rate Limit (req/min)", title="Available Plans", box=box.SIMPLE_HEAD)
1402
- for plan in plans:
1403
- is_current = current_plan and plan["id"] == current_plan
1404
- name = f"[bold green]{plan['name']} (current)[/bold green]" if is_current else plan["name"]
1405
- if plan["id"] == "free":
1406
- price = "Free model only"
1407
- rate = "N/A"
1408
- else:
1409
- price = f"${plan.get('price', 0)}/mo" if plan.get("price", 0) > 0 else "Free"
1410
- rate = str(plan["rate_limit"]) if plan.get("rate_limit") is not None else "N/A"
1411
- table.add_row(name, price, rate)
1412
-
1413
- console.print(table)
1414
- console.print("[dim]Upgrade: [bold #5F9EA0]/upgrade pro[/bold #5F9EA0] | Manage: [bold #5F9EA0]/account[/bold #5F9EA0][/dim]")
1415
- console.print()
1416
- return CommandResult(status="handled")
1417
-
1418
-
1419
- def _handle_signup(chat_manager, console, debug_mode_container, args, cron_scheduler=None):
1420
- """Handle /signup <email> — create account and switch to bone."""
1421
- if not args or not args.strip():
1422
- console.print("[red]Usage: /signup <email>[/red]")
1423
- console.print("[dim]Creates a bone-agent account and generates an API key.[/dim]")
1424
- console.print()
1425
- return CommandResult(status="handled")
1426
-
1427
- email = args.strip()
1428
-
1429
- # Basic client-side email validation
1430
- if "@" not in email or "." not in email.split("@")[-1]:
1431
- console.print("[red]Invalid email address.[/red]")
1432
- console.print()
1433
- return CommandResult(status="handled")
1434
-
1435
- _, api_base = _get_proxy_config(chat_manager)
1436
- console.print(f"[#5F9EA0]Creating account for {email}...[/#5F9EA0]")
1437
-
1438
- status, data = _call_proxy_api("POST", "/v1/auth/signup", api_base, body={"email": email})
1439
-
1440
- if status == 409:
1441
- console.print("[yellow]Account already exists for that email.[/yellow]")
1442
- console.print("[dim]Use [bold #5F9EA0]/login {email}[/bold #5F9EA0] to log in on this device.[/dim]")
1443
- console.print()
1444
- return CommandResult(status="handled")
1445
-
1446
- if status != 201 and status != 200:
1447
- detail = (data or {}).get("detail", "Unknown error") if data else "Network error"
1448
- console.print(f"[red]Signup failed: {detail}[/red]")
1449
- console.print()
1450
- return CommandResult(status="handled")
1451
-
1452
- if not data or "api_key" not in data:
1453
- console.print("[red]Signup failed: unexpected response from server.[/red]")
1454
- console.print()
1455
- return CommandResult(status="handled")
1456
-
1457
- api_key = data["api_key"]
1458
-
1459
- # Display the API key prominently
1460
- console.print()
1461
- console.print("[bold green]Account created successfully![/bold green]")
1462
- console.print("[dim]Check your inbox for a verification email. Use [bold #5F9EA0]/resend[/bold #5F9EA0] if it doesn't arrive.[/dim]")
1463
- console.print()
1464
- console.print("[bold #5F9EA0]Your API key (save this — it won't be shown again):[/bold #5F9EA0]")
1465
- console.print(f"[bold white on grey23] {api_key} [/bold white on grey23]")
1466
- console.print()
1467
-
1468
- # Save backup to ~/.bone/api_key.txt
1469
- try:
1470
- key_path = Path.home() / ".bone" / "api_key.txt"
1471
- key_path.parent.mkdir(parents=True, exist_ok=True)
1472
- key_path.write_text(api_key)
1473
- key_path.chmod(0o600)
1474
- console.print(f"[dim]Key backed up to {key_path}[/dim]")
1475
- except Exception as e:
1476
- console.print(f"[yellow]Could not save key backup: {e}[/yellow]")
1477
-
1478
- # Persist API key to config (always succeeds or warns — never blocks)
1479
- try:
1480
- config_manager.set_api_key("bone", api_key)
1481
- except Exception as e:
1482
- console.print(f"[yellow]Could not save API key to config: {e}[/yellow]")
1483
- console.print("[dim]Use [bold #5F9EA0]/key {api_key}[/bold #5F9EA0] to set it manually.[/dim]")
1484
-
1485
- # Switch to bone provider (best-effort)
1486
- try:
1487
- config_manager.set_provider("bone")
1488
- chat_manager.reload_config()
1489
- chat_manager.switch_provider("bone")
1490
- console.print("[green]Switched to bone provider.[/green]")
1491
- except Exception as e:
1492
- console.print(f"[yellow]Could not auto-switch to bone: {e}[/yellow]")
1493
- console.print("[dim]Run [bold #5F9EA0]/provider bone[/bold #5F9EA0] to switch manually.[/dim]")
1494
-
1495
- console.print()
1496
- return CommandResult(status="handled")
1497
-
1498
-
1499
- def _handle_account(chat_manager, console, debug_mode_container, args, cron_scheduler=None):
1500
- """Handle /account — show account info."""
1501
- if not _require_proxy_provider(chat_manager, console):
1502
- return CommandResult(status="handled")
1503
-
1504
- api_key, api_base = _get_proxy_config(chat_manager)
1505
- if not api_key:
1506
- console.print("[yellow]No API key set for bone. Use /key to set one.[/yellow]")
1507
- console.print()
1508
- return CommandResult(status="handled")
1509
-
1510
- console.print("[#5F9EA0]Fetching account info...[/#5F9EA0]")
1511
- status, data = _call_proxy_api("GET", "/v1/auth/account", api_base, api_key=api_key)
1512
-
1513
- if status != 200 or not data:
1514
- detail = (data or {}).get("detail", "Unknown error") if data else "Network error"
1515
- console.print(f"[red]Failed to fetch account: {detail}[/red]")
1516
- console.print()
1517
- return CommandResult(status="handled")
1518
-
1519
- console.print()
1520
- console.print(f"[bold #5F9EA0]Account:[/bold #5F9EA0] {data.get('email', 'N/A')}")
1521
- plan = data.get("plan", "lite").capitalize()
1522
- sub_status = data.get("subscription_status", "none")
1523
- console.print(f"[bold #5F9EA0]Plan:[/bold #5F9EA0] {plan}")
1524
-
1525
- if sub_status and sub_status != "none":
1526
- console.print(f"[bold #5F9EA0]Status:[/bold #5F9EA0] {sub_status}")
1527
- period_end = data.get("current_period_end")
1528
- if period_end:
1529
- console.print(f"[bold #5F9EA0]Renews:[/bold #5F9EA0] {period_end}")
1530
- else:
1531
- console.print("[dim]No active subscription[/dim]")
1532
-
1533
- prefix = data.get("api_key_prefix")
1534
- if prefix:
1535
- console.print(f"[bold #5F9EA0]API key:[/bold #5F9EA0] {prefix}...")
1536
- key_count = len(data.get("keys", []))
1537
- console.print(f"[bold #5F9EA0]Keys:[/bold #5F9EA0] {key_count}")
1538
- console.print()
1539
- console.print("[dim]Manage subscription: [bold #5F9EA0]/upgrade[/bold #5F9EA0] or [bold #5F9EA0]/manage[/bold #5F9EA0][/dim]")
1540
- console.print()
1541
- return CommandResult(status="handled")
1542
-
1543
-
1544
- def _send_reset_key_email(console, api_base, email):
1545
- """Shared logic for sending a new API key via email.
1546
-
1547
- Used by both /login (path 2: user lost key) and /reset-key.
1548
- Returns CommandResult.
1549
- """
1550
- console.print(f"[#5F9EA0]Sending new API key to {email}...[/#5F9EA0]")
1551
- console.print("[dim]This will create a new key and email it to you. Old keys remain valid.[/dim]")
1552
- console.print()
1553
-
1554
- from rich.prompt import Confirm
1555
- if not Confirm.ask("Send a new API key to this email?", default=False):
1556
- console.print("[dim]Cancelled.[/dim]")
1557
- console.print()
1558
- return CommandResult(status="handled")
1559
-
1560
- status, data = _call_proxy_api("POST", "/v1/auth/reset-key", api_base, body={"email": email})
1561
-
1562
- if status == 429:
1563
- detail = (data or {}).get("detail", "Too many requests.") if data else "Too many requests."
1564
- console.print(f"[yellow]{detail}[/yellow]")
1565
- console.print()
1566
- return CommandResult(status="handled")
1567
-
1568
- if status != 200 and status != 201:
1569
- detail = (data or {}).get("detail", "Unknown error") if data else "Network error"
1570
- console.print(f"[red]Failed to send: {detail}[/red]")
1571
- console.print()
1572
- return CommandResult(status="handled")
1573
-
1574
- message = (data or {}).get("message", "Check your email for the new API key.")
1575
- console.print(f"[green]{message}[/green]")
1576
- console.print("[dim]Once you receive the key, run: [bold #5F9EA0]/key <your-new-key>[/bold #5F9EA0][/dim]")
1577
- console.print()
1578
- return CommandResult(status="handled")
1579
-
1580
-
1581
- def _handle_login(chat_manager, console, debug_mode_container, args, cron_scheduler=None):
1582
- """Handle /login <email> — log in to an existing bone-agent account on this device.
1583
-
1584
- Two paths:
1585
- - User has their API key: validate it, save to config, switch provider.
1586
- - User lost their key: email a new one via /reset-key endpoint.
1587
- """
1588
- if not args or not args.strip():
1589
- console.print("[red]Usage: /login <email>[/red]")
1590
- console.print("[dim]Log in to an existing bone-agent account on this device.[/dim]")
1591
- console.print()
1592
- return CommandResult(status="handled")
1593
-
1594
- email = args.strip()
1595
-
1596
- # Basic client-side email validation
1597
- if "@" not in email or "." not in email.split("@")[-1]:
1598
- console.print("[red]Invalid email address.[/red]")
1599
- console.print()
1600
- return CommandResult(status="handled")
1601
-
1602
- if not _require_proxy_provider(chat_manager, console):
1603
- return CommandResult(status="handled")
1604
-
1605
- # Check if already logged in to a different account
1606
- api_key, api_base = _get_proxy_config(chat_manager)
1607
- if api_key:
1608
- try:
1609
- acct_status, acct_data = _call_proxy_api("GET", "/v1/auth/account", api_base, api_key=api_key)
1610
- if acct_status == 200 and acct_data:
1611
- current_email = acct_data.get("email", "")
1612
- if current_email and current_email.lower() != email.lower():
1613
- from rich.prompt import Confirm
1614
- console.print(f"[yellow]Already logged in as {current_email}[/yellow]")
1615
- if not Confirm.ask(f"Switch to {email}?", default=False):
1616
- console.print("[dim]Cancelled.[/dim]")
1617
- console.print()
1618
- return CommandResult(status="handled")
1619
- except Exception:
1620
- pass # If we can't check, just proceed
1621
-
1622
- console.print()
1623
- console.print(f"[bold #5F9EA0]bone-agent Login[/bold #5F9EA0]")
1624
- console.print(f"[dim]Logging in as {email}[/dim]")
1625
- console.print()
1626
-
1627
- from rich.prompt import Confirm, Prompt
1628
-
1629
- if Confirm.ask("Do you have your API key?", default=True):
1630
- # Path 1: user has their key — validate and save
1631
- raw_key = Prompt.ask("API key")
1632
-
1633
- if not raw_key.strip():
1634
- console.print("[yellow]No key entered. Aborted.[/yellow]")
1635
- console.print()
1636
- return CommandResult(status="handled")
1637
-
1638
- raw_key = raw_key.strip()
1639
-
1640
- console.print("[#5F9EA0]Validating API key...[/#5F9EA0]")
1641
- status, data = _call_proxy_api("GET", "/v1/auth/account", api_base, api_key=raw_key)
1642
-
1643
- if status == 200 and data and data.get("email", "").lower() == email.lower():
1644
- # Valid key — save and switch
1645
- try:
1646
- config_manager.set_api_key("bone", raw_key)
1647
- except Exception as e:
1648
- console.print(f"[yellow]Could not save API key to config: {e}[/yellow]")
1649
- console.print(f"[dim]Use [bold #5F9EA0]/key {raw_key}[/bold #5F9EA0] to set it manually.[/dim]")
1650
-
1651
- try:
1652
- config_manager.set_provider("bone")
1653
- chat_manager.reload_config()
1654
- chat_manager.switch_provider("bone")
1655
- console.print("[green]Switched to bone provider.[/green]")
1656
- except Exception as e:
1657
- console.print(f"[yellow]Could not auto-switch to bone: {e}[/yellow]")
1658
- console.print("[dim]Run [bold #5F9EA0]/provider bone[/bold #5F9EA0] to switch manually.[/dim]")
1659
-
1660
- plan = data.get("plan", "free")
1661
- verified = "yes" if data.get("verified") else "no"
1662
- console.print(f"[green]Logged in as {email}[/green] (plan: {plan}, verified: {verified})")
1663
- console.print()
1664
- return CommandResult(status="handled")
1665
-
1666
- if status in (401, 403):
1667
- console.print("[red]Invalid API key.[/red]")
1668
- console.print("[dim]Double-check your key and try again, or say 'no' to get a new one emailed.[/dim]")
1669
- else:
1670
- detail = (data or {}).get("detail", "Unknown error") if data else "Network error"
1671
- console.print(f"[red]Validation failed: {detail}[/red]")
1672
- console.print()
1673
- return CommandResult(status="handled")
1674
-
1675
- # Path 2: user lost their key — email a new one
1676
- return _send_reset_key_email(console, api_base, email)
1677
-
1678
-
1679
-
1680
- def _handle_resend(chat_manager, console, debug_mode_container, args, cron_scheduler=None):
1681
- """Handle /resend [email] — resend verification email.
1682
-
1683
- If no email is given, fetches it from the account endpoint.
1684
- """
1685
- if not _require_proxy_provider(chat_manager, console):
1686
- return CommandResult(status="handled")
1687
-
1688
- api_key, api_base = _get_proxy_config(chat_manager)
1689
- if not api_key:
1690
- console.print("[yellow]No API key set for bone. Use /key to set one.[/yellow]")
1691
- console.print()
1692
- return CommandResult(status="handled")
1693
-
1694
- # Resolve email: use arg, or fetch from account
1695
- email = args.strip() if args and args.strip() else None
1696
- if not email:
1697
- acct_status, acct_data = _call_proxy_api("GET", "/v1/auth/account", api_base, api_key=api_key)
1698
- if acct_status == 200 and acct_data:
1699
- email = acct_data.get("email")
1700
- if acct_data.get("verified"):
1701
- console.print("[green]Email is already verified.[/green]")
1702
- console.print()
1703
- return CommandResult(status="handled")
1704
-
1705
- if not email:
1706
- console.print("[red]Could not determine your email. Usage: /resend <email>[/red]")
1707
- console.print()
1708
- return CommandResult(status="handled")
1709
-
1710
- console.print(f"[#5F9EA0]Sending verification email to {email}...[/#5F9EA0]")
1711
- status, data = _call_proxy_api("POST", "/v1/auth/resend", api_base, body={"email": email})
1712
-
1713
- if status == 429:
1714
- detail = (data or {}).get("detail", "Too many requests.") if data else "Too many requests."
1715
- console.print(f"[yellow]{detail}[/yellow]")
1716
- console.print()
1717
- return CommandResult(status="handled")
1718
-
1719
- if status != 200 and status != 201:
1720
- detail = (data or {}).get("detail", "Unknown error") if data else "Network error"
1721
- console.print(f"[red]Failed to send: {detail}[/red]")
1722
- console.print()
1723
- return CommandResult(status="handled")
1724
-
1725
- message = (data or {}).get("message", "Verification email sent.")
1726
- console.print(f"[green]{message}[/green]")
1727
- console.print()
1728
- return CommandResult(status="handled")
1729
-
1730
-
1731
- def _handle_reset_key(chat_manager, console, debug_mode_container, args, cron_scheduler=None):
1732
- """Handle /reset-key [email] — request a new API key via email.
1733
-
1734
- If no email is given, fetches it from the account endpoint (requires API key).
1735
- If email is given, works without an API key (for users who lost everything).
1736
- """
1737
- if not _require_proxy_provider(chat_manager, console):
1738
- return CommandResult(status="handled")
1739
-
1740
- api_key, api_base = _get_proxy_config(chat_manager)
1741
-
1742
- # Resolve email: use arg, or fetch from account
1743
- email = args.strip() if args and args.strip() else None
1744
- if not email and api_key:
1745
- acct_status, acct_data = _call_proxy_api("GET", "/v1/auth/account", api_base, api_key=api_key)
1746
- if acct_status == 200 and acct_data:
1747
- email = acct_data.get("email")
1748
-
1749
- if not email:
1750
- console.print("[red]Could not determine your email. Usage: /reset-key <email>[/red]")
1751
- console.print()
1752
- return CommandResult(status="handled")
1753
-
1754
- return _send_reset_key_email(console, api_base, email)
1755
-
1756
-
1757
- def _handle_manage(chat_manager, console, debug_mode_container, args, cron_scheduler=None):
1758
- """Handle /manage — open Stripe Customer Portal for subscription management."""
1759
- if not _require_proxy_provider(chat_manager, console):
1760
- return CommandResult(status="handled")
1761
-
1762
- api_key, api_base = _get_proxy_config(chat_manager)
1763
- if not api_key:
1764
- console.print("[yellow]No API key set for bone. Use /key to set one.[/yellow]")
1765
- console.print()
1766
- return CommandResult(status="handled")
1767
-
1768
- console.print("[#5F9EA0]Opening billing portal...[/#5F9EA0]")
1769
- status, data = _call_proxy_api(
1770
- "POST", "/v1/billing/portal", api_base,
1771
- body={"return_url": "https://vmcode.dev"},
1772
- api_key=api_key,
1773
- )
1774
-
1775
- if status == 400:
1776
- detail = (data or {}).get("detail", "No subscription found.") if data else "No subscription found."
1777
- console.print(f"[yellow]{detail}[/yellow]")
1778
- console.print("[dim]Subscribe to a plan first with [bold #5F9EA0]/upgrade[/bold #5F9EA0].[/dim]")
1779
- console.print()
1780
- return CommandResult(status="handled")
1781
-
1782
- if status != 200 or not data or "url" not in data:
1783
- detail = (data or {}).get("detail", "Unknown error") if data else "Network error"
1784
- console.print(f"[red]Failed to open billing portal: {detail}[/red]")
1785
- console.print()
1786
- return CommandResult(status="handled")
1787
-
1788
- url = data["url"]
1789
-
1790
- try:
1791
- import webbrowser
1792
- webbrowser.open(url)
1793
- console.print("[green]Opened in browser[/green]")
1794
- except Exception:
1795
- pass
1796
-
1797
- console.print()
1798
- console.print("[#5F9EA0]Or copy this link:[/#5F9EA0]")
1799
- console.print(f" [bold]{url}[/bold]")
1800
- console.print()
1801
- return CommandResult(status="handled")
1802
-
1803
-
1804
- def _handle_upgrade(chat_manager, console, debug_mode_container, args, cron_scheduler=None):
1805
- """Handle /upgrade — show plan selector, then open checkout or billing portal."""
1806
- if not _require_proxy_provider(chat_manager, console):
1807
- return CommandResult(status="handled")
1808
-
1809
- api_key, api_base = _get_proxy_config(chat_manager)
1810
- if not api_key:
1811
- console.print("[yellow]No API key set for bone. Use /key to set one.[/yellow]")
1812
- console.print()
1813
- return CommandResult(status="handled")
1814
-
1815
- # Check current plan for showing the current selection
1816
- current_plan = "free"
1817
- acct_status, acct_data = _call_proxy_api("GET", "/v1/auth/account", api_base, api_key=api_key)
1818
- if acct_status == 200 and acct_data:
1819
- current_plan = acct_data.get("plan", "free")
1820
-
1821
- # Get available plans from API
1822
- status, data = _call_proxy_api("GET", "/v1/billing/plans", api_base)
1823
- if status == 200 and data and "plans" in data:
1824
- plans = data["plans"]
1825
- else:
1826
- # Fallback to hardcoded defaults
1827
- plans = [
1828
- {"id": "free", "name": "Free", "price": 0, "tokens": 0, "rate_limit": 0},
1829
- {"id": "lite", "name": "Lite", "price": 10, "tokens": 2_000_000, "rate_limit": 60},
1830
- {"id": "pro", "name": "Pro", "price": 50, "tokens": 15_000_000, "rate_limit": 300},
1831
- ]
1832
-
1833
- # Only show plans the user can upgrade to (current plan excluded)
1834
- # Tier ordering: free < lite < pro
1835
- _TIER_ORDER = {"free": 0, "lite": 1, "pro": 2}
1836
- current_tier = _TIER_ORDER.get(current_plan, 0)
1837
-
1838
- upgradeable_plans = [
1839
- p for p in plans
1840
- if _TIER_ORDER.get(p["id"], 0) > current_tier
1841
- ]
1842
-
1843
- if not upgradeable_plans:
1844
- # Pro user — no upgrades available
1845
- console.print()
1846
- console.print(f"[bold green]You're on the {current_plan.capitalize()} plan — the highest tier.[/bold green]")
1847
- console.print("[dim]Use [bold #5F9EA0]/manage[/bold #5F9EA0] to cancel or change your subscription.[/dim]")
1848
- console.print()
1849
- return CommandResult(status="handled")
1850
-
1851
- # Build plan options from upgradeable plans only
1852
- plan_options = []
1853
- for plan in upgradeable_plans:
1854
- price_desc = f"${plan['price']}/mo" if plan.get("price", 0) > 0 else "Free"
1855
- plan_options.append({
1856
- "value": plan["id"],
1857
- "text": plan["name"],
1858
- "description": price_desc,
1859
- })
1860
-
1861
- # Default selection to the first upgradeable plan
1862
- first_upgrade = upgradeable_plans[0]["id"]
1863
-
1864
- # Show plan selector — title includes current plan
1865
- selector = SettingSelector(
1866
- categories=[
1867
- SettingCategory(
1868
- title="Select Plan",
1869
- settings=[
1870
- SettingOption(
1871
- key="plan",
1872
- text=f"Select a plan (current: {current_plan.capitalize()}):",
1873
- value=first_upgrade,
1874
- input_type="options",
1875
- options=plan_options,
1876
- )
1877
- ]
1878
- )
1879
- ],
1880
- title="Upgrade Your Plan",
1881
- show_save=False,
1882
- )
1883
-
1884
- result = selector.run()
1885
-
1886
- if result is None:
1887
- console.print("[dim]Cancelled.[/dim]")
1888
- console.print()
1889
- return CommandResult(status="handled")
1890
-
1891
- target = result.get("plan", first_upgrade)
1892
-
1893
- # Upgrade: open Stripe Checkout
1894
- console.print(f"[#5F9EA0]Opening checkout for {target.capitalize()}...[/#5F9EA0]")
1895
-
1896
- status, data = _call_proxy_api(
1897
- "POST", "/v1/billing/checkout", api_base,
1898
- body={
1899
- "plan": target,
1900
- "success_url": "https://vmcode.dev",
1901
- "cancel_url": "https://vmcode.dev",
1902
- },
1903
- api_key=api_key,
1904
- )
1905
- action = "create checkout session"
1906
-
1907
- if status == 200 and data and "url" in data:
1908
- url = data["url"]
1909
- else:
1910
- detail = (data or {}).get("detail", "Unknown error") if data else "Network error"
1911
- console.print(f"[red]Failed to {action}: {detail}[/red]")
1912
- console.print()
1913
- return CommandResult(status="handled")
1914
-
1915
- # Open in browser
1916
- try:
1917
- import webbrowser
1918
- webbrowser.open(url)
1919
- console.print("[green]Opened in browser[/green]")
1920
- except Exception:
1921
- pass
1922
-
1923
- console.print()
1924
- console.print("[#5F9EA0]Or copy this link:[/#5F9EA0]")
1925
- console.print(f" [bold]{url}[/bold]")
1926
- console.print()
1927
- return CommandResult(status="handled")
1928
-
1929
-
1930
- def _handle_rotate_key(chat_manager, console, debug_mode_container, args, cron_scheduler=None):
1931
- """Handle /rotate-key — invalidate current API key and generate a new one."""
1932
- if not _require_proxy_provider(chat_manager, console):
1933
- return CommandResult(status="handled")
1934
-
1935
- api_key, api_base = _get_proxy_config(chat_manager)
1936
- if not api_key:
1937
- console.print("[yellow]No API key set for bone. Use /key to set one.[/yellow]")
1938
- console.print()
1939
- return CommandResult(status="handled")
1940
-
1941
- # Warn user
1942
- console.print("[bold yellow]This will invalidate your current API key and generate a new one.[/bold yellow]")
1943
- console.print("[dim]Make sure you can save the new key before proceeding.[/dim]")
1944
- console.print()
1945
-
1946
- from rich.prompt import Confirm
1947
- if not Confirm.ask("Rotate your API key?", default=False):
1948
- console.print("[dim]Cancelled.[/dim]")
1949
- console.print()
1950
- return CommandResult(status="handled")
1951
-
1952
- console.print("[#5F9EA0]Rotating API key...[/#5F9EA0]")
1953
- status, data = _call_proxy_api(
1954
- "POST", "/v1/auth/rotate-key", api_base,
1955
- body={},
1956
- api_key=api_key,
1957
- )
1958
-
1959
- if status != 200 or not data or "api_key" not in data:
1960
- detail = (data or {}).get("detail", "Unknown error") if data else "Network error"
1961
- console.print(f"[red]Failed to rotate key: {detail}[/red]")
1962
- console.print()
1963
- return CommandResult(status="handled")
1964
-
1965
- new_key = data["api_key"]
1966
-
1967
- # Display new key
1968
- console.print()
1969
- console.print("[bold green]API key rotated successfully.[/bold green]")
1970
- console.print("[bold red]Your old key is no longer valid.[/bold red]")
1971
- console.print()
1972
- console.print("[bold #5F9EA0]Your new API key (save this — it won't be shown again):[/bold #5F9EA0]")
1973
- console.print(f"[bold white on grey23] {new_key} [/bold white on grey23]")
1974
- console.print()
1975
-
1976
- # Save to config
1977
- try:
1978
- config_manager.set_api_key("bone", new_key)
1979
- console.print("[green]New key saved to config.[/green]")
1980
- except Exception as e:
1981
- console.print(f"[yellow]Could not save to config: {e}[/yellow]")
1982
- console.print(f"[dim]Use [bold #5F9EA0]/key {new_key}[/bold #5F9EA0] to set it manually.[/dim]")
1983
-
1984
- # Backup to file
1985
- try:
1986
- key_path = Path.home() / ".bone" / "api_key.txt"
1987
- key_path.parent.mkdir(parents=True, exist_ok=True)
1988
- key_path.write_text(new_key)
1989
- key_path.chmod(0o600)
1990
- console.print(f"[dim]Key backed up to {key_path}[/dim]")
1991
- except Exception:
1992
- pass
1993
-
1994
- console.print()
1995
- return CommandResult(status="handled")
1996
-
1997
-
1998
- def _persist_obsidian_config(console, **kwargs):
1999
- """Persist Obsidian settings to config file.
2000
-
2001
- Args:
2002
- console: Rich console for output
2003
- **kwargs: OBSIDIAN_SETTINGS fields to persist
2004
- """
2005
- try:
2006
- config_data = config_manager.load(force_reload=True)
2007
- if "OBSIDIAN_SETTINGS" not in config_data:
2008
- config_data["OBSIDIAN_SETTINGS"] = {}
2009
- config_data["OBSIDIAN_SETTINGS"].update(kwargs)
2010
- config_manager.save(config_data)
2011
- except Exception as e:
2012
- console.print(f"[yellow]Saved to session but could not persist to config: {e}[/yellow]")
2013
- console.print("[dim]Settings will reset on restart.[/dim]")
2014
-
2015
-
2016
- def _apply_obsidian_changes(chat_manager, console, obsidian_settings, changes):
2017
- """Apply Obsidian setting changes, register/unregister tools, persist config.
2018
-
2019
- Args:
2020
- chat_manager: ChatManager instance
2021
- console: Rich console for output
2022
- obsidian_settings: ObsidianSettings instance
2023
- changes: dict of {key: new_value} from SettingSelector
2024
-
2025
- Returns:
2026
- list of change description strings
2027
- """
2028
- change_lines = []
2029
- was_active = obsidian_settings.is_active()
2030
-
2031
- for key, value in changes.items():
2032
- if key == "vault_path":
2033
- old_path = obsidian_settings.vault_path
2034
- new_path = value.strip() if value else ""
2035
- if new_path and new_path != old_path:
2036
- # Validate path
2037
- vault_path = Path(new_path).resolve()
2038
- if not vault_path.is_dir():
2039
- console.print(f"[red]Not a directory: {vault_path}[/red]")
2040
- continue
2041
- if not (vault_path / ".obsidian").is_dir():
2042
- console.print(f"[red]No .obsidian/ directory found in: {vault_path}[/red]")
2043
- console.print("[dim]Make sure this is a valid Obsidian vault.[/dim]")
2044
- continue
2045
- obsidian_settings.update(vault_path=str(vault_path))
2046
- change_lines.append(f" Vault path: {vault_path}")
2047
- elif not new_path and old_path:
2048
- obsidian_settings.update(vault_path="")
2049
- change_lines.append(" Vault path: (cleared)")
2050
- elif key == "enabled":
2051
- obsidian_settings.update(enabled=value)
2052
- state = "enabled" if value else "disabled"
2053
- change_lines.append(f" Integration: {state}")
2054
- elif key == "exclude_folders":
2055
- obsidian_settings.update(exclude_folders=value)
2056
- change_lines.append(f" Exclude folders: {value}")
2057
- elif key == "project_base":
2058
- obsidian_settings.update(project_base=value.strip() if value else "Dev")
2059
- change_lines.append(f" Project base: {obsidian_settings.project_base}")
2060
-
2061
- # Note: vault session is initialized lazily by init_session() in agentic.py
2062
- # No tool registration needed — vault utilities are used internally
2063
-
2064
- # Persist all settings to config
2065
- if changes:
2066
- _persist_obsidian_config(
2067
- console,
2068
- vault_path=obsidian_settings.vault_path,
2069
- enabled=obsidian_settings.enabled,
2070
- exclude_folders=obsidian_settings.exclude_folders,
2071
- project_base=obsidian_settings.project_base,
2072
- )
2073
-
2074
- return change_lines
2075
-
2076
-
2077
- def _handle_obsidian(chat_manager, console, debug_mode_container, args, cron_scheduler=None):
2078
- """Handle /obsidian command — manage vault integration.
2079
-
2080
- No args: Launch interactive SettingSelector UI (same UX as /config).
2081
- Subcommands: set <path>, enable, disable, status — quick shortcuts.
2082
- """
2083
- from ui.setting_selector import SettingOption, SettingCategory, SettingSelector
2084
- from utils.settings import obsidian_settings
2085
-
2086
- # Text subcommands (quick shortcuts)
2087
- if args:
2088
- args_clean = args.strip()
2089
-
2090
- if args_clean.lower() == "status":
2091
- active = obsidian_settings.is_active()
2092
- configured = obsidian_settings.is_configured()
2093
- if active:
2094
- console.print("[green]Obsidian integration: ACTIVE[/green]")
2095
- elif configured:
2096
- console.print("[yellow]Obsidian integration: ENABLED but vault invalid[/yellow]")
2097
- else:
2098
- console.print("[dim]Obsidian integration: DISABLED[/dim]")
2099
- console.print(f" Vault path: {obsidian_settings.vault_path or '(not set)'}")
2100
- console.print(f" Enabled: {obsidian_settings.enabled}")
2101
- console.print(f" Exclude folders: {obsidian_settings.exclude_folders}")
2102
- console.print(f" Project base: {obsidian_settings.project_base}")
2103
- console.print()
2104
- console.print("[dim]Run [bold #5F9EA0]/obsidian[/bold #5F9EA0] (no args) for interactive settings.[/dim]")
2105
- return CommandResult(status="handled")
2106
-
2107
- if args_clean.lower().startswith("set "):
2108
- path = args_clean[4:].strip().strip('"').strip("'")
2109
- if not path:
2110
- console.print("[red]Usage: [bold #5F9EA0]/obsidian set /path/to/your/vault[/bold #5F9EA0]")
2111
- return CommandResult(status="handled")
2112
- vault_path = Path(path).resolve()
2113
- if not vault_path.is_dir():
2114
- console.print(f"[red]Not a directory: {vault_path}[/red]")
2115
- return CommandResult(status="handled")
2116
- if not (vault_path / ".obsidian").is_dir():
2117
- console.print(f"[red]No .obsidian/ directory found in: {vault_path}[/red]")
2118
- return CommandResult(status="handled")
2119
- changes = {"vault_path": str(vault_path), "enabled": True}
2120
- change_lines = _apply_obsidian_changes(chat_manager, console, obsidian_settings, changes)
2121
- console.print(f"[green]Obsidian vault set:[/green]")
2122
- for line in change_lines:
2123
- console.print(line)
2124
- return CommandResult(status="handled")
2125
-
2126
- if args_clean.lower() == "enable":
2127
- if not obsidian_settings.vault_path:
2128
- console.print("[red]No vault path set. Use [bold #5F9EA0]/obsidian set <path>[/bold #5F9EA0] first.[/red]")
2129
- return CommandResult(status="handled")
2130
- changes = _apply_obsidian_changes(chat_manager, console, obsidian_settings, {"enabled": True})
2131
- console.print("[green]Obsidian integration enabled.[/green]")
2132
- return CommandResult(status="handled")
2133
-
2134
- if args_clean.lower() == "disable":
2135
- _apply_obsidian_changes(chat_manager, console, obsidian_settings, {"enabled": False})
2136
- console.print("[yellow]Obsidian integration disabled. Tools unregistered.[/yellow]")
2137
- return CommandResult(status="handled")
2138
-
2139
- if args_clean.lower() == "init":
2140
- return _handle_obsidian_init(console, obsidian_settings)
2141
-
2142
- console.print(f"[red]Unknown subcommand: {args}[/red]")
2143
- console.print("Usage: [bold #5F9EA0]/obsidian[/bold #5F9EA0] [set <path> | enable | disable | status | init]")
2144
- return CommandResult(status="handled")
2145
-
2146
- # No args — launch interactive SettingSelector UI
2147
- vault_settings = [
2148
- SettingOption(
2149
- key="vault_path", text="Vault Path",
2150
- value=obsidian_settings.vault_path or "",
2151
- input_type="text",
2152
- description="Absolute path to your Obsidian vault (.obsidian/ must exist)",
2153
- ),
2154
- SettingOption(
2155
- key="enabled", text="Enable Integration",
2156
- value=obsidian_settings.enabled,
2157
- input_type="boolean",
2158
- on_text="ON", off_text="OFF",
2159
- ),
2160
- ]
2161
-
2162
- behavior_settings = [
2163
- SettingOption(
2164
- key="exclude_folders", text="Exclude Folders",
2165
- value=obsidian_settings.exclude_folders,
2166
- input_type="text",
2167
- description="Comma-separated folder names to skip during vault scans",
2168
- ),
2169
- SettingOption(
2170
- key="project_base", text="Project Base",
2171
- value=obsidian_settings.project_base,
2172
- input_type="text",
2173
- description="Base folder within vault for project notes (default: Dev)",
2174
- ),
2175
- ]
2176
-
2177
- categories = [
2178
- SettingCategory(title="Vault", settings=vault_settings),
2179
- SettingCategory(title="Behavior", settings=behavior_settings),
2180
- ]
2181
-
2182
- selector = SettingSelector(
2183
- categories=categories,
2184
- title="Obsidian Integration",
2185
- )
2186
-
2187
- changes = selector.run()
2188
-
2189
- if changes is None:
2190
- console.print("[dim]Cancelled.[/dim]")
2191
- return CommandResult(status="handled")
2192
-
2193
- if not changes:
2194
- console.print("[dim]No changes made.[/dim]")
2195
- return CommandResult(status="handled")
2196
-
2197
- change_lines = _apply_obsidian_changes(chat_manager, console, obsidian_settings, changes)
2198
-
2199
- if change_lines:
2200
- # Show active status after changes
2201
- is_active = obsidian_settings.is_active()
2202
- status_label = "[green]ACTIVE[/green]" if is_active else "[dim]inactive[/dim]"
2203
- console.print(f"[green]Obsidian settings updated:[/green] ({status_label})")
2204
- for line in change_lines:
2205
- console.print(line)
2206
- else:
2207
- console.print("[dim]No changes applied.[/dim]")
2208
-
2209
- return CommandResult(status="handled")
2210
-
2211
-
2212
- def _persist_tool_visibility(console):
2213
- """Persist tool and skill visibility state to config file.
2214
-
2215
- Returns True on success, False on failure.
2216
- """
2217
- try:
2218
- cfg_data = config_manager.load(force_reload=True)
2219
- if "TOOL_SETTINGS" not in cfg_data:
2220
- cfg_data["TOOL_SETTINGS"] = {}
2221
- cfg_data["TOOL_SETTINGS"]["disabled_tools"] = list(tool_settings.disabled_tools)
2222
- cfg_data["TOOL_SETTINGS"]["hidden_skills"] = list(tool_settings.hidden_skills)
2223
- config_manager.save(cfg_data)
2224
- return True
2225
- except Exception as e:
2226
- console.print(f"[yellow]Could not persist to config: {e}[/yellow]")
2227
- return False
2228
-
2229
-
2230
- def _handle_tools(chat_manager, console, debug_mode_container, args, cron_scheduler=None):
2231
- """Handle /tools command — manage tool availability and skill discovery visibility.
2232
-
2233
- No args: Launch interactive SettingSelector with tools/plugins grouped by category
2234
- and a skills section for discovery visibility.
2235
- Subcommands:
2236
- list — show all tools, plugins, and skills with status
2237
- enable <name> — enable a core tool or plugin
2238
- disable <name> — disable a core tool or plugin
2239
- show-skill <name> — make a skill visible in discovery surfaces
2240
- hide-skill <name> — hide a skill from discovery surfaces
2241
- enable-group <key> — enable all tools in a group (e.g. file_ops, task_mgmt)
2242
- disable-group <key> — disable all tools in a group
2243
- """
2244
- from core.skills import iter_skill_summaries, validate_skill_name
2245
- from ui.setting_selector import SettingOption, SettingCategory, SettingSelector
2246
- from tools.helpers.base import ToolRegistry, TOOL_GROUPS
2247
- from tools.helpers.plugin_manifest import plugin_manifest
2248
-
2249
- # Text subcommands
2250
- if args:
2251
- args_clean = args.strip()
2252
-
2253
- if args_clean.lower() in ("list", "status"):
2254
- all_tools = sorted(ToolRegistry._tools.values(), key=lambda t: t.name)
2255
- plugin_defs = sorted(plugin_manifest.get_all(), key=lambda t: t.name)
2256
- skills = sorted(iter_skill_summaries(), key=lambda s: s.name)
2257
- disabled = ToolRegistry.get_disabled()
2258
- hidden_skills = set(tool_settings.hidden_skills)
2259
- disabled_tools = {t.name for t in all_tools if t.name in disabled}
2260
- console.print(
2261
- f"[bold #5F9EA0]Tools: {len(all_tools) - len(disabled_tools)} enabled, {len(disabled_tools)} disabled[/bold #5F9EA0]"
2262
- )
2263
- console.print(
2264
- f"[bold #5F9EA0]User plugins: {sum(1 for p in plugin_defs if p.name not in disabled)} enabled, {sum(1 for p in plugin_defs if p.name in disabled)} disabled[/bold #5F9EA0]"
2265
- )
2266
- console.print(
2267
- f"[bold #5F9EA0]Skills: {len(skills) - len(hidden_skills)} visible, {len(hidden_skills)} hidden[/bold #5F9EA0]"
2268
- )
2269
- console.print()
2270
-
2271
- tool_to_group = {}
2272
- for gkey, gdef in TOOL_GROUPS.items():
2273
- for tname in gdef["tools"]:
2274
- tool_to_group.setdefault(tname, []).append(gdef["label"])
2275
-
2276
- current_group = None
2277
- for t in all_tools:
2278
- groups = tool_to_group.get(t.name, [])
2279
- group_label = groups[0] if groups else "Other"
2280
- is_off = t.name in disabled
2281
- if group_label != current_group:
2282
- current_group = group_label
2283
- console.print(f" [bold]{group_label}[/bold]")
2284
- status = "[red]off[/red]" if is_off else "[green]on[/green] "
2285
- console.print(f" {status} {t.name}")
2286
-
2287
- console.print()
2288
- console.print(" [bold]User plugins[/bold]")
2289
- for plugin in plugin_defs:
2290
- status = "[red]off[/red]" if plugin.name in disabled else "[green]on[/green] "
2291
- console.print(f" {status} {plugin.name}")
2292
-
2293
- console.print()
2294
- console.print(" [bold]Skills[/bold]")
2295
- for skill in skills:
2296
- status = "[red]hidden[/red]" if skill.name in hidden_skills else "[green]visible[/green]"
2297
- console.print(f" {status} {skill.name}")
2298
-
2299
- console.print()
2300
- console.print("[dim]Groups:[/dim] " + ", ".join(
2301
- f"[bold]{k}[/bold] ({v['label']})" for k, v in TOOL_GROUPS.items()
2302
- ))
2303
- console.print()
2304
- return CommandResult(status="handled")
2305
-
2306
- # Parse: enable/disable <name> or enable-group/disable-group <key>
2307
- parts = args_clean.split(maxsplit=1)
2308
- if len(parts) == 2:
2309
- action = parts[0].lower()
2310
- target = parts[1].strip()
2311
-
2312
- # Group operations
2313
- if action in ("enable-group", "disable-group"):
2314
- group_key = target.lower()
2315
- if group_key not in TOOL_GROUPS:
2316
- console.print(f"[red]Unknown group: {group_key}[/red]")
2317
- console.print("[dim]Groups: " + ", ".join(TOOL_GROUPS.keys()) + "[/dim]")
2318
- console.print()
2319
- return CommandResult(status="handled")
2320
-
2321
- group_label = TOOL_GROUPS[group_key]["label"]
2322
- if action == "disable-group":
2323
- changed = ToolRegistry.disable_group(group_key)
2324
- if changed:
2325
- console.print(f"[yellow]Disabled {group_label}:[/yellow] {', '.join(changed)}")
2326
- else:
2327
- console.print(f"[dim]All {group_label} tools already disabled.[/dim]")
2328
- else:
2329
- changed = ToolRegistry.enable_group(group_key)
2330
- if changed:
2331
- console.print(f"[green]Enabled {group_label}:[/green] {', '.join(changed)}")
2332
- else:
2333
- console.print(f"[dim]All {group_label} tools already enabled.[/dim]")
2334
-
2335
- # Sync and persist
2336
- tool_settings.disabled_tools = sorted(ToolRegistry.get_disabled())
2337
- _persist_tool_visibility(console)
2338
- console.print()
2339
- return CommandResult(status="handled")
2340
-
2341
- # Single tool/plugin operations
2342
- if action in ("enable", "disable"):
2343
- all_registered_lower = {t.name.lower(): t.name for t in ToolRegistry._tools.values()}
2344
- all_registered_lower.update({t.name.lower(): t.name for t in plugin_manifest.get_all()})
2345
- matched = all_registered_lower.get(target.lower())
2346
- if not matched:
2347
- console.print(f"[red]Unknown tool or plugin: {target}[/red]")
2348
- console.print(f"[dim]Run [bold #5F9EA0]/tools list[/bold #5F9EA0] to see all tools.[/dim]")
2349
- return CommandResult(status="handled")
2350
-
2351
- if action == "enable":
2352
- ToolRegistry.enable(matched)
2353
- tool_settings.disabled_tools = [n for n in tool_settings.disabled_tools if n != matched]
2354
- console.print(f"[green]Enabled: {matched}[/green]")
2355
- else:
2356
- ToolRegistry.disable(matched)
2357
- if matched not in tool_settings.disabled_tools:
2358
- tool_settings.disabled_tools.append(matched)
2359
- console.print(f"[yellow]Disabled: {matched}[/yellow]")
2360
-
2361
- _persist_tool_visibility(console)
2362
- console.print()
2363
- return CommandResult(status="handled")
2364
-
2365
- if action in ("show-skill", "hide-skill"):
2366
- try:
2367
- skill_name = validate_skill_name(target)
2368
- except Exception as e:
2369
- console.print(f"[red]{e}[/red]")
2370
- return CommandResult(status="handled")
2371
-
2372
- known_skills = {skill.name for skill in iter_skill_summaries()}
2373
- if skill_name not in known_skills:
2374
- console.print(f"[red]Unknown skill: {skill_name}[/red]")
2375
- return CommandResult(status="handled")
2376
-
2377
- if action == "show-skill":
2378
- tool_settings.hidden_skills = [n for n in tool_settings.hidden_skills if n != skill_name]
2379
- console.print(f"[green]Skill visible in discovery: {skill_name}[/green]")
2380
- else:
2381
- if skill_name not in tool_settings.hidden_skills:
2382
- tool_settings.hidden_skills.append(skill_name)
2383
- console.print(f"[yellow]Skill hidden from discovery: {skill_name}[/yellow]")
2384
-
2385
- tool_settings.hidden_skills = sorted(set(tool_settings.hidden_skills))
2386
- _persist_tool_visibility(console)
2387
- console.print()
2388
- return CommandResult(status="handled")
2389
-
2390
- console.print(f"[red]Unknown subcommand: {args}[/red]")
2391
- console.print("Usage: [bold #5F9EA0]/tools[/bold #5F9EA0] [list | enable <name> | disable <name> | show-skill <name> | hide-skill <name> | enable-group <key> | disable-group <key>]")
2392
- return CommandResult(status="handled")
2393
-
2394
- # No args — interactive toggle UI, organized by groups
2395
- all_tools_map = {t.name: t for t in ToolRegistry._tools.values()}
2396
- plugin_tools = {t.name: t for t in plugin_manifest.get_all()}
2397
- disabled = ToolRegistry.get_disabled()
2398
- hidden_skills = set(tool_settings.hidden_skills)
2399
-
2400
- categories = []
2401
- for gkey, gdef in TOOL_GROUPS.items():
2402
- group_options = []
2403
- for tname in gdef["tools"]:
2404
- t = all_tools_map.get(tname)
2405
- if not t:
2406
- continue
2407
- is_off = tname in disabled
2408
- group_options.append(SettingOption(
2409
- key=tname,
2410
- text=tname,
2411
- value=not is_off,
2412
- input_type="boolean",
2413
- on_text="ON",
2414
- off_text="OFF",
2415
- ))
2416
- if group_options:
2417
- categories.append(SettingCategory(title=gdef["label"], settings=group_options))
2418
-
2419
- # Catch any tools not in a group
2420
- grouped_names = set()
2421
- for gdef in TOOL_GROUPS.values():
2422
- grouped_names.update(gdef["tools"])
2423
- ungrouped = [
2424
- t for t in sorted(all_tools_map.values(), key=lambda x: x.name)
2425
- if t.name not in grouped_names
2426
- ]
2427
- if ungrouped:
2428
- other_options = []
2429
- for t in ungrouped:
2430
- is_off = t.name in disabled
2431
- other_options.append(SettingOption(
2432
- key=t.name,
2433
- text=t.name,
2434
- value=not is_off,
2435
- input_type="boolean",
2436
- on_text="ON",
2437
- off_text="OFF",
2438
- ))
2439
- categories.append(SettingCategory(title="Other", settings=other_options))
2440
-
2441
- plugin_options = []
2442
- for name in sorted(plugin_tools):
2443
- is_off = name in disabled
2444
- plugin_options.append(SettingOption(
2445
- key=name,
2446
- text=name,
2447
- value=not is_off,
2448
- input_type="boolean",
2449
- on_text="ON",
2450
- off_text="OFF",
2451
- ))
2452
- if plugin_options:
2453
- categories.append(SettingCategory(title="User plugins", settings=plugin_options))
2454
-
2455
- skill_options = []
2456
- for skill in sorted(iter_skill_summaries(), key=lambda s: s.name):
2457
- skill_options.append(SettingOption(
2458
- key=f"skill:{skill.name}",
2459
- text=skill.name,
2460
- value=skill.name not in hidden_skills,
2461
- input_type="boolean",
2462
- on_text="VISIBLE",
2463
- off_text="HIDDEN",
2464
- ))
2465
- if skill_options:
2466
- categories.append(SettingCategory(title="Skills", settings=skill_options))
2467
-
2468
- selector = SettingSelector(
2469
- categories=categories,
2470
- title="Tool Settings",
2471
- )
2472
-
2473
- changes = selector.run()
2474
-
2475
- if changes is None:
2476
- console.print("[dim]Cancelled.[/dim]")
2477
- return CommandResult(status="handled")
2478
-
2479
- if not changes:
2480
- console.print("[dim]No changes made.[/dim]")
2481
- return CommandResult(status="handled")
2482
-
2483
- # Apply changes
2484
- newly_disabled = []
2485
- newly_enabled = []
2486
- newly_hidden_skills = []
2487
- newly_visible_skills = []
2488
- for name, enabled in changes.items():
2489
- if name.startswith("skill:"):
2490
- skill_name = name.split(":", 1)[1]
2491
- if enabled and skill_name in hidden_skills:
2492
- newly_visible_skills.append(skill_name)
2493
- elif not enabled and skill_name not in hidden_skills:
2494
- newly_hidden_skills.append(skill_name)
2495
- continue
2496
-
2497
- if not enabled and name not in disabled:
2498
- ToolRegistry.disable(name)
2499
- newly_disabled.append(name)
2500
- elif enabled and name in disabled:
2501
- ToolRegistry.enable(name)
2502
- newly_enabled.append(name)
2503
-
2504
- tool_settings.disabled_tools = sorted(ToolRegistry.get_disabled())
2505
- next_hidden_skills = set(hidden_skills)
2506
- next_hidden_skills.update(newly_hidden_skills)
2507
- next_hidden_skills.difference_update(newly_visible_skills)
2508
- tool_settings.hidden_skills = sorted(next_hidden_skills)
2509
-
2510
- _persist_tool_visibility(console)
2511
-
2512
- # Summary
2513
- change_lines = []
2514
- for name in newly_disabled:
2515
- change_lines.append(f" [yellow]Disabled:[/yellow] {name}")
2516
- for name in newly_enabled:
2517
- change_lines.append(f" [green]Enabled:[/green] {name}")
2518
- for name in newly_hidden_skills:
2519
- change_lines.append(f" [yellow]Hidden skill:[/yellow] {name}")
2520
- for name in newly_visible_skills:
2521
- change_lines.append(f" [green]Visible skill:[/green] {name}")
2522
-
2523
- if change_lines:
2524
- total_enabled = len(ToolRegistry.get_all())
2525
- total_disabled = len(ToolRegistry.get_disabled())
2526
- console.print(f"[green]Tools updated:[/green] ({total_enabled} enabled, {total_disabled} disabled)")
2527
- for line in change_lines:
2528
- console.print(line)
2529
- else:
2530
- console.print("[dim]No changes applied.[/dim]")
2531
-
2532
- return CommandResult(status="handled")
2533
-
2534
-
2535
- def _handle_cd(chat_manager, console, debug_mode_container, args, cron_scheduler=None):
2536
- """Handle /cd command — change working directory.
2537
-
2538
- Usage: /cd <path>
2539
- Examples:
2540
- /cd /home/user/projects
2541
- /cd ..
2542
- /cd ~/Documents
2543
- """
2544
- import os
2545
-
2546
- if not args or not args.strip():
2547
- # Show current working directory
2548
- cwd = os.getcwd()
2549
- console.print(f"[bold #5F9EA0]Current directory:[/bold #5F9EA0] {cwd}")
2550
- return CommandResult(status="handled")
2551
-
2552
- path = args.strip()
2553
-
2554
- # Expand ~ to home directory
2555
- path = os.path.expanduser(path)
2556
-
2557
- # Resolve to absolute path
2558
- try:
2559
- target_path = Path(path).resolve()
2560
- except Exception as e:
2561
- console.print(f"[red]Invalid path: {e}[/red]")
2562
- return CommandResult(status="handled")
2563
-
2564
- # Check if path exists and is a directory
2565
- if not target_path.exists():
2566
- console.print(f"[red]Directory not found: {target_path}[/red]")
2567
- return CommandResult(status="handled")
2568
-
2569
- if not target_path.is_dir():
2570
- console.print(f"[red]Not a directory: {target_path}[/red]")
2571
- return CommandResult(status="handled")
2572
-
2573
- # Change directory
2574
- try:
2575
- os.chdir(target_path)
2576
- console.print(f"[green]Changed directory to: {target_path}[/green]")
2577
- except Exception as e:
2578
- console.print(f"[red]Failed to change directory: {e}[/red]")
2579
-
2580
- # Reset memory system singleton (project memory is per-repo)
2581
- try:
2582
- from core.memory import MemoryManager
2583
- MemoryManager.reset()
2584
- except Exception:
2585
- pass
2586
-
2587
- # Rebuild system prompt so project root stays current
2588
- try:
2589
- chat_manager.update_system_prompt()
2590
- except Exception:
2591
- pass
2592
-
2593
- return CommandResult(status="handled")
2594
-
2595
-
2596
- def _handle_prompt(chat_manager, console, debug_mode_container, args, cron_scheduler=None):
2597
- """Handle /prompt command — show/swap prompt variants."""
2598
- from utils.settings import prompt_settings
2599
- from llm.prompts import _variant_available, _list_variants
2600
-
2601
- cfg_manager = config_manager
2602
-
2603
- if not args or args.strip() == "list":
2604
- variants = _list_variants()
2605
- current = prompt_settings.variant
2606
- console.print()
2607
- console.print(f"[bold #5F9EA0]Prompt Variants[/bold #5F9EA0] (current: [bold]{current}[/bold])")
2608
- console.print()
2609
- for v in variants:
2610
- marker = "[bold green]active[/bold green]" if v == current else ""
2611
- console.print(f" [bold]{v}[/bold] {marker}")
2612
- console.print()
2613
- console.print("[dim]Switch with: [bold #5F9EA0]/prompt main[/bold #5F9EA0] or [bold #5F9EA0]/prompt micro[/bold #5F9EA0][/dim]")
2614
- return CommandResult(status="handled")
2615
-
2616
- # Single arg: variant name to switch to
2617
- target = args.strip().lower()
2618
-
2619
- if not _variant_available(target):
2620
- variants = _list_variants()
2621
- console.print(f"[red]Unknown variant: '{target}'[/red]")
2622
- console.print(f"[dim]Available: {', '.join(variants)}[/dim]")
2623
- return CommandResult(status="handled")
2624
-
2625
- # Update settings
2626
- prompt_settings.variant = target
2627
-
2628
- # Persist to config
2629
- try:
2630
- cfg_data = cfg_manager.load(force_reload=True)
2631
- if "PROMPT_SETTINGS" not in cfg_data:
2632
- cfg_data["PROMPT_SETTINGS"] = {}
2633
- cfg_data["PROMPT_SETTINGS"]["variant"] = target
2634
- cfg_manager.save(cfg_data)
2635
- except Exception as e:
2636
- console.print(f"[red]Failed to save variant to config: {e}[/red]")
2637
- console.print("[yellow]Variant applied for this session only — it will revert on restart.[/yellow]")
2638
-
2639
- # Rebuild system prompt in-place (no restart)
2640
- chat_manager.update_system_prompt(variant=target)
2641
- console.print(f"[green]Switched to '{target}' variant[/green]")
2642
- console.print("[dim]System prompt rebuilt in-place.[/dim]")
2643
-
2644
- return CommandResult(status="handled")
2645
-
2646
-
2647
- def _print_skills_usage(console):
2648
- show_skills_help_table(console)
2649
-
2650
-
2651
- def _skills_list(console, query=None):
2652
- from datetime import datetime
2653
- from core.skills import get_skills_dir, list_skills
2654
-
2655
- skills = list_skills(query=query)
2656
- if not skills:
2657
- console.print("[dim]No skills found.[/dim]")
2658
- console.print(f"[dim]Directory: {get_skills_dir()}[/dim]")
2659
- console.print("[dim]Create one with: [bold #5F9EA0]/skills add frontend_design[/bold #5F9EA0][/dim]")
2660
- return
2661
-
2662
- table = Table(show_header=True, box=box.SIMPLE_HEAD)
2663
- table.add_column("Skill", no_wrap=True)
2664
- table.add_column("Preview")
2665
- table.add_column("Modified", no_wrap=True)
2666
- for skill in skills:
2667
- modified = datetime.fromtimestamp(skill.modified).strftime("%Y-%m-%d %H:%M")
2668
- table.add_row(f"[bold]{skill.name}[/bold]", skill.preview, modified)
2669
- console.print(table)
2670
- console.print("[dim]Load with: [bold #5F9EA0]/skills load <name>[/bold #5F9EA0][/dim]")
2671
-
2672
-
2673
- def _open_skill_editor(console, debug_mode_container, initial_content):
2674
- from utils.editor import open_editor_for_content
2675
-
2676
- return open_editor_for_content(
2677
- console,
2678
- initial_content=initial_content,
2679
- debug_mode=debug_mode_container["debug"],
2680
- )
2681
-
2682
-
2683
- def _write_skill_from_editor(console, debug_mode_container, name, initial_content, *, overwrite, verb):
2684
- from core.skills import write_skill
2685
-
2686
- success, content = _open_skill_editor(console, debug_mode_container, initial_content)
2687
- if not success or not content or not content.strip():
2688
- console.print("[dim]Cancelled.[/dim]")
2689
- return None
2690
- path = write_skill(name, content, overwrite=overwrite)
2691
- console.print(f"[green]{verb} skill '{name}'.[/green] [dim]{path}[/dim]")
2692
- return path
2693
-
2694
-
2695
- def _handle_skills(chat_manager, console, debug_mode_container, args, cron_sched=None):
2696
- """Handle /skills command — manage reusable prompt snippets."""
2697
- from core.skills import (
2698
- SkillError,
2699
- activate_skill,
2700
- get_skills_dir,
2701
- read_skill,
2702
- remove_skill,
2703
- validate_skill_name,
2704
- write_skill,
2705
- )
2706
-
2707
- if not args or not args.strip():
2708
- _skills_list(console)
2709
- return CommandResult(status="handled")
2710
-
2711
- args_clean = args.strip()
2712
- parts = args_clean.split(maxsplit=2)
2713
- subcmd = parts[0].lower()
2714
-
2715
- try:
2716
- if subcmd in ("help", "-h", "--help"):
2717
- _print_skills_usage(console)
2718
- return CommandResult(status="handled")
2719
-
2720
- if subcmd in ("list", "ls"):
2721
- list_parts = args_clean.split(maxsplit=1)
2722
- query = list_parts[1] if len(list_parts) > 1 else None
2723
- _skills_list(console, query=query)
2724
- return CommandResult(status="handled")
2725
-
2726
- if subcmd == "dir":
2727
- console.print(str(get_skills_dir()))
2728
- return CommandResult(status="handled")
2729
-
2730
- if subcmd == "show":
2731
- if len(parts) < 2:
2732
- console.print("[red]Usage: /skills show <name>[/red]")
2733
- return CommandResult(status="handled")
2734
- name = validate_skill_name(parts[1])
2735
- content = read_skill(name, strip_heading=False)
2736
- console.print(Markdown(left_align_headings(content), code_theme=MonokaiDarkBGStyle, justify="left"))
2737
- return CommandResult(status="handled")
2738
-
2739
- if subcmd in ("load", "use"):
2740
- if len(parts) < 2:
2741
- console.print(f"[red]Usage: /skills {subcmd} <name>[/red]")
2742
- return CommandResult(status="handled")
2743
- name = validate_skill_name(parts[1])
2744
- tokens = activate_skill(chat_manager, name, read_skill(name))
2745
- console.print(f"[green]Activated skill '{name}' for this chat.[/green] [dim](~{tokens:,} tokens)[/dim]")
2746
- return CommandResult(status="handled")
2747
-
2748
- if subcmd in ("remove", "rm", "delete"):
2749
- if len(parts) < 2:
2750
- console.print("[red]Usage: /skills remove <name>[/red]")
2751
- return CommandResult(status="handled")
2752
- name = validate_skill_name(parts[1])
2753
- from rich.prompt import Confirm
2754
- if not Confirm.ask(f"Remove skill '{name}'?", default=False):
2755
- console.print("[dim]Cancelled.[/dim]")
2756
- return CommandResult(status="handled")
2757
- remove_skill(name)
2758
- console.print(f"[green]Removed skill '{name}'.[/green]")
2759
- return CommandResult(status="handled")
2760
-
2761
- if subcmd == "edit":
2762
- if len(parts) < 2:
2763
- console.print("[red]Usage: /skills edit <name>[/red]")
2764
- return CommandResult(status="handled")
2765
- name = validate_skill_name(parts[1])
2766
- try:
2767
- initial_content = read_skill(name, strip_heading=False)
2768
- except SkillError:
2769
- initial_content = f"# {name}\n\n"
2770
- _write_skill_from_editor(
2771
- console,
2772
- debug_mode_container,
2773
- name,
2774
- initial_content,
2775
- overwrite=True,
2776
- verb="Saved",
2777
- )
2778
- return CommandResult(status="handled")
2779
-
2780
- if subcmd in ("add", "create", "new"):
2781
- if len(parts) < 2:
2782
- console.print(f"[red]Usage: /skills {subcmd} <name> [prompt][/red]")
2783
- return CommandResult(status="handled")
2784
- name = validate_skill_name(parts[1])
2785
- if len(parts) >= 3:
2786
- path = write_skill(name, parts[2], overwrite=False)
2787
- console.print(f"[green]Created skill '{name}'.[/green] [dim]{path}[/dim]")
2788
- return CommandResult(status="handled")
2789
- _write_skill_from_editor(
2790
- console,
2791
- debug_mode_container,
2792
- name,
2793
- f"# {name}\n\n",
2794
- overwrite=False,
2795
- verb="Created",
2796
- )
2797
- return CommandResult(status="handled")
2798
-
2799
- if subcmd == "modify":
2800
- cmd_parts = args_clean.split(maxsplit=2)
2801
- if len(cmd_parts) < 2:
2802
- console.print("[red]Usage: /skills modify <name> [prompt][/red]")
2803
- return CommandResult(status="handled")
2804
- name = validate_skill_name(cmd_parts[1])
2805
- if len(cmd_parts) < 3:
2806
- _write_skill_from_editor(
2807
- console,
2808
- debug_mode_container,
2809
- name,
2810
- read_skill(name, strip_heading=False),
2811
- overwrite=True,
2812
- verb="Updated",
2813
- )
2814
- return CommandResult(status="handled")
2815
- read_skill(name)
2816
- path = write_skill(name, cmd_parts[2], overwrite=True)
2817
- console.print(f"[green]Updated skill '{name}'.[/green] [dim]{path}[/dim]")
2818
- return CommandResult(status="handled")
2819
-
2820
- if len(parts) >= 2:
2821
- name = validate_skill_name(parts[0])
2822
- _, body = args_clean.split(maxsplit=1)
2823
- path = write_skill(name, body, overwrite=False)
2824
- console.print(f"[green]Created skill '{name}'.[/green] [dim]{path}[/dim]")
2825
- return CommandResult(status="handled")
2826
-
2827
- console.print("[red]Usage: /skills add <name> [prompt][/red]")
2828
- console.print("[dim]Run [bold #5F9EA0]/skills help[/bold #5F9EA0] for all commands.[/dim]")
2829
- return CommandResult(status="handled")
2830
-
2831
- except SkillError as e:
2832
- console.print(f"[red]{e}[/red]")
2833
- return CommandResult(status="handled")
2834
- except Exception as e:
2835
- console.print(f"[red]Skills command failed: {e}[/red]")
2836
- return CommandResult(status="handled")
2837
-
2838
-
2839
- def _handle_obsidian_init(console, obsidian_settings):
2840
- """Handle /obsidian init — scaffold project folder structure in vault."""
2841
- if not obsidian_settings.is_active():
2842
- console.print("[yellow]Obsidian vault is not configured or inactive.[/yellow]")
2843
- console.print("[dim]Run [bold #5F9EA0]/obsidian set <path>[/bold #5F9EA0] to configure your vault.[/dim]")
2844
- console.print()
2845
- return CommandResult(status="handled")
2846
-
2847
- # subcmd is always "init" — just do the init
2848
- from tools.obsidian import get_vault_session
2849
-
2850
- session = get_vault_session()
2851
- if not session:
2852
- console.print("[red]Could not determine project folder.[/red]")
2853
- return CommandResult(status="handled")
2854
-
2855
- project_folder = session.project_folder
2856
-
2857
- # Check if already exists
2858
- if project_folder.is_dir():
2859
- console.print(f"[yellow]Project folder already exists: {project_folder}[/yellow]")
2860
- console.print("[dim]No changes made. Delete the folder manually if you want to re-initialize.[/dim]")
2861
- console.print()
2862
- return CommandResult(status="handled")
2863
-
2864
- # Define folder structure and templates
2865
- folders = {
2866
- "Bugs": (
2867
- "---\n"
2868
- "title: {title}\n"
2869
- "type: bug\n"
2870
- "status: reported\n"
2871
- "priority: medium\n"
2872
- "date_created: {date}\n"
2873
- "date_modified: {date}\n"
2874
- "tags: [bug]\n"
2875
- "---\n"
2876
- "\n"
2877
- "# {title}\n"
2878
- "\n"
2879
- "**Description:**\n"
2880
- "\n"
2881
- "**Steps to reproduce:**\n"
2882
- "\n"
2883
- "**Expected behavior:**\n"
2884
- "\n"
2885
- "**Actual behavior:**\n"
2886
- "\n"
2887
- "Statuses: `reported` → `in-progress` → `fixed` → `verified`\n"
2888
- ),
2889
- "Tasks": (
2890
- "---\n"
2891
- "title: {title}\n"
2892
- "type: task\n"
2893
- "status: todo\n"
2894
- "priority: medium\n"
2895
- "date_created: {date}\n"
2896
- "date_modified: {date}\n"
2897
- "tags: [task]\n"
2898
- "---\n"
2899
- "\n"
2900
- "# {title}\n"
2901
- "\n"
2902
- "**Description:**\n"
2903
- "\n"
2904
- "**Acceptance criteria:**\n"
2905
- "\n"
2906
- "Statuses: `todo` → `in-progress` → `done`\n"
2907
- ),
2908
- "Docs": (
2909
- "---\n"
2910
- "title: {title}\n"
2911
- "type: doc\n"
2912
- "date_created: {date}\n"
2913
- "date_modified: {date}\n"
2914
- "tags: [docs]\n"
2915
- "---\n"
2916
- "\n"
2917
- "# {title}\n"
2918
- "\n"
2919
- ),
2920
- }
2921
-
2922
- from datetime import date
2923
-
2924
- today = date.today().isoformat()
2925
- created_folders = []
2926
-
2927
- for folder_rel, template in folders.items():
2928
- folder_path = project_folder / folder_rel
2929
- folder_path.mkdir(parents=True, exist_ok=True)
2930
- created_folders.append(folder_rel)
2931
-
2932
- # Write template
2933
- template_path = folder_path / "_Template.md"
2934
- if not template_path.exists():
2935
- title = folder_rel.split("/")[-1].rstrip("s")
2936
- content = template.format(date=today, title=title)
2937
- template_path.write_text(content, encoding="utf-8")
2938
-
2939
- # Create Done/ subfolders for archiving completed notes
2940
- for folder_rel in ("Bugs", "Tasks"):
2941
- done_path = project_folder / folder_rel / "Done"
2942
- done_path.mkdir(parents=True, exist_ok=True)
2943
- created_folders.append(f"{folder_rel}/Done")
2944
-
2945
- # Create Dashboard
2946
- dashboard_path = project_folder / "Dashboard.md"
2947
- repo_name = project_folder.name
2948
- dv_tasks = (
2949
- f'```dataview\n'
2950
- f"TABLE status, priority, date_created\n"
2951
- f'FROM "{session.project_folder_relative}/Tasks"\n'
2952
- f'WHERE type = "task" AND status != "done"\n'
2953
- f"SORT date_created DESC\n"
2954
- f"```\n"
2955
- )
2956
- dv_bugs = (
2957
- f'```dataview\n'
2958
- f"TABLE status, priority, date_created\n"
2959
- f'FROM "{session.project_folder_relative}/Bugs"\n'
2960
- f'WHERE type = "bug" AND status != "fixed" AND status != "verified"\n'
2961
- f"SORT date_created DESC\n"
2962
- f"```\n"
2963
- )
2964
- dv_completed = (
2965
- f'```dataview\n'
2966
- f"TABLE type, status, date_modified\n"
2967
- f'FROM "{session.project_folder_relative}"\n'
2968
- f'WHERE (type = "task" AND status = "done")\n'
2969
- f' OR (type = "bug" AND (status = "fixed" OR status = "verified"))\n'
2970
- f"SORT date_modified DESC\n"
2971
- f"```\n"
2972
- )
2973
- dashboard_content = (
2974
- "---\n"
2975
- "type: dashboard\n"
2976
- "date_created: {date}\n"
2977
- "date_modified: {date}\n"
2978
- "tags: [dashboard]\n"
2979
- "---\n"
2980
- "\n"
2981
- "# {title} Dashboard\n"
2982
- "\n"
2983
- "> [!summary] Project Overview\n"
2984
- "> Check the Bugs/ and Tasks/ folders for issue tracking.\n"
2985
- "\n"
2986
- "## Open Tasks\n"
2987
- "\n"
2988
- f"{dv_tasks}\n"
2989
- "## Open Bugs\n"
2990
- "\n"
2991
- f"{dv_bugs}\n"
2992
- "## Recently Completed\n"
2993
- "\n"
2994
- f"{dv_completed}\n"
2995
- )
2996
- dashboard_content = dashboard_content.format(date=today, title=repo_name)
2997
- dashboard_path.write_text(dashboard_content, encoding="utf-8")
2998
- created_folders.append("Dashboard.md")
2999
-
3000
- console.print(f"[green]Project initialized: {project_folder.name}[/green]")
3001
- for folder in created_folders:
3002
- console.print(f" [dim]Created: {folder}/ (_Template.md)[/dim]")
3003
- console.print()
3004
-
3005
- # Check if Dataview plugin is installed and enabled
3006
- vault_root = session.vault_root
3007
- community_plugins = vault_root / ".obsidian" / "community-plugins.json"
3008
- dataview_dir = vault_root / ".obsidian" / "plugins" / "dataview"
3009
- has_plugin_entry = (
3010
- community_plugins.is_file()
3011
- and "dataview" in community_plugins.read_text(encoding="utf-8")
3012
- )
3013
- has_plugin_files = (
3014
- dataview_dir.is_dir()
3015
- and (dataview_dir / "main.js").is_file()
3016
- and (dataview_dir / "manifest.json").is_file()
3017
- )
3018
- if not has_plugin_entry or not has_plugin_files:
3019
- console.print("[yellow]Dataview plugin not detected — dashboard tables won't render.[/yellow]")
3020
- console.print("[dim]Install the Dataview community plugin in Obsidian:[/dim]")
3021
- console.print("[dim] Settings → Community plugins → Browse → search 'Dataview' → Install & Enable[/dim]")
3022
- console.print("[dim]Or download from: https://github.com/blacksmithgu/obsidian-dataview[/dim]")
3023
- console.print()
3024
-
3025
- console.print("[dim]Create issues with [bold #5F9EA0]/obsidian init[/bold #5F9EA0] to set up the project folder.[/dim]")
3026
- console.print()
3027
- return CommandResult(status="handled")
3028
-
3029
-
3030
- # Command registry - maps command names to their handlers
3031
- _COMMAND_HANDLERS = {
3032
- "/exit": _handle_exit,
3033
- "/quit": _handle_exit,
3034
- "/help": _handle_help,
3035
- "/h": _handle_help,
3036
- "/compact": _handle_compact,
3037
- "/clear": _handle_clear,
3038
- "/new": _handle_clear,
3039
- "/reset": _handle_clear,
3040
- "/provider": _handle_provider,
3041
- "/config": _handle_config,
3042
-
3043
- "/edit": _handle_edit,
3044
- "/e": _handle_edit,
3045
- "/usage": _handle_usage,
3046
- "/model": _handle_model,
3047
- "/key": _handle_key,
3048
- "/review": _handle_review,
3049
- "/r": _handle_review,
3050
- "/signup": _handle_signup,
3051
- "/login": _handle_login,
3052
- "/resend": _handle_resend,
3053
- "/reset-key": _handle_reset_key,
3054
- "/account": _handle_account,
3055
- "/plan": _handle_plan,
3056
- "/manage": _handle_manage,
3057
- "/upgrade": _handle_upgrade,
3058
- "/rotate-key": _handle_rotate_key,
3059
- "/obsidian": _handle_obsidian,
3060
- "/tools": _handle_tools,
3061
- "/cd": _handle_cd,
3062
- "/setup": _handle_setup,
3063
- "/cron": _handle_cron,
3064
- "/prompt": _handle_prompt,
3065
- "/skills": _handle_skills,
3066
- }
3067
-
3068
-
3069
- def _handle_shell_command(console, command):
3070
- """Execute a shell command prefixed with : and display output."""
3071
- from utils.settings import tool_settings
3072
- try:
3073
- result = subprocess.run(
3074
- ["/bin/sh", "-c", command], capture_output=True, text=True,
3075
- encoding="utf-8", errors="replace", timeout=tool_settings.command_timeout_sec,
3076
- )
3077
- output = ((result.stdout or "") + (result.stderr or "")).strip() or "(no output)"
3078
- lines = output.splitlines()
3079
- if len(lines) > 200:
3080
- output = "\n".join(lines[:100]) + f"\n\n... ({len(lines) - 200} lines omitted) ...\n\n" + "\n".join(lines[-100:])
3081
- console.print()
3082
- if result.returncode != 0:
3083
- console.print(f"[red]exit code: {result.returncode}[/red]")
3084
- console.print(output)
3085
- console.print()
3086
- except subprocess.TimeoutExpired:
3087
- console.print(f"[red]Command timed out after {tool_settings.command_timeout_sec}s[/red]")
3088
- except Exception as e:
3089
- console.print(f"[red]Error: {e}[/red]")
3090
- return CommandResult(status="handled")
3091
-
3092
-
3093
- def process_command(chat_manager, user_input, console, debug_mode_container, cron_scheduler=None):
3094
- """Process command and optionally return replacement content.
3095
-
3096
- Args:
3097
- chat_manager: ChatManager instance
3098
- user_input: User's input string
3099
- console: Rich console for output
3100
- debug_mode_container: Dict with 'debug' key for debug mode state
3101
- cron_scheduler: Optional CronScheduler instance for immediate reload
3102
-
3103
- Returns:
3104
- tuple: (status, replacement_content)
3105
- status: "exit" | "handled" | None
3106
- replacement_content: str to replace user_input, or None
3107
- """
3108
- # Parse command and arguments
3109
- parts = user_input.split(maxsplit=1)
3110
- cmd = parts[0].lower()
3111
- args = parts[1] if len(parts) > 1 else None
3112
-
3113
- # Shell command prefix (:command)
3114
- if user_input.startswith(":"):
3115
- shell_cmd = user_input[1:].strip()
3116
- if shell_cmd:
3117
- result = _handle_shell_command(console, shell_cmd)
3118
- return (result.status, result.replacement_input)
3119
- return ("handled", None)
3120
-
3121
- # Look up handler in registry
3122
- handler = _COMMAND_HANDLERS.get(cmd)
3123
- if handler:
3124
- result = handler(chat_manager, console, debug_mode_container, args, cron_scheduler)
3125
- return (result.status, result.replacement_input)
3126
- elif cmd.startswith('/'):
3127
- console.print(f"[red]Unknown command: {user_input}[/red]")
3128
- console.print("[dim]Type [bold #5F9EA0]/help[/bold #5F9EA0] for available commands[/dim]")
3129
- return ("handled", None)
3130
-
3131
- return (None, None)