bone-agent 1.4.0 → 2.0.1
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.
- package/bin/bone.js +39 -0
- package/package.json +25 -39
- package/LICENSE +0 -21
- package/README.md +0 -201
- package/bin/npm-wrapper.js +0 -235
- package/bin/rg +0 -0
- package/bin/rg.exe +0 -0
- package/config.yaml.example +0 -144
- package/prompts/main/ask_questions.md +0 -31
- package/prompts/main/batch_independent_calls.md +0 -5
- package/prompts/main/casual_interactions.md +0 -11
- package/prompts/main/code_references.md +0 -8
- package/prompts/main/communication_style.md +0 -12
- package/prompts/main/context_reliability.md +0 -12
- package/prompts/main/conversational_tool_calling.md +0 -15
- package/prompts/main/dream.md +0 -50
- package/prompts/main/editing_pattern.md +0 -13
- package/prompts/main/error_handling.md +0 -6
- package/prompts/main/exploration_pattern.md +0 -21
- package/prompts/main/intro.md +0 -1
- package/prompts/main/obsidian.md +0 -16
- package/prompts/main/obsidian_project.md +0 -79
- package/prompts/main/professional_objectivity.md +0 -3
- package/prompts/main/skills.md +0 -3
- package/prompts/main/targeted_searching.md +0 -10
- package/prompts/main/task_lists_pattern.md +0 -8
- package/prompts/main/temp_folder.md +0 -9
- package/prompts/main/think_before_acting.md +0 -10
- package/prompts/main/tone_and_style.md +0 -4
- package/prompts/main/tool_preferences.md +0 -24
- package/prompts/main/trust_subagent_context.md +0 -21
- package/prompts/main/when_to_use_sub_agent.md +0 -7
- package/prompts/micro/ask_questions.md +0 -1
- package/prompts/micro/batch_independent_calls.md +0 -1
- package/prompts/micro/casual_interactions.md +0 -1
- package/prompts/micro/code_references.md +0 -1
- package/prompts/micro/communication_style.md +0 -1
- package/prompts/micro/context_reliability.md +0 -1
- package/prompts/micro/conversational_tool_calling.md +0 -1
- package/prompts/micro/editing_pattern.md +0 -1
- package/prompts/micro/error_handling.md +0 -1
- package/prompts/micro/exploration_pattern.md +0 -1
- package/prompts/micro/intro.md +0 -1
- package/prompts/micro/obsidian.md +0 -4
- package/prompts/micro/obsidian_project.md +0 -5
- package/prompts/micro/professional_objectivity.md +0 -1
- package/prompts/micro/skills.md +0 -1
- package/prompts/micro/targeted_searching.md +0 -1
- package/prompts/micro/task_lists_pattern.md +0 -1
- package/prompts/micro/temp_folder.md +0 -1
- package/prompts/micro/think_before_acting.md +0 -5
- package/prompts/micro/tone_and_style.md +0 -1
- package/prompts/micro/tool_preferences.md +0 -1
- package/prompts/micro/trust_subagent_context.md +0 -1
- package/prompts/micro/when_to_use_sub_agent.md +0 -1
- package/requirements.txt +0 -9
- package/src/__init__.py +0 -11
- package/src/core/__init__.py +0 -1
- package/src/core/agentic.py +0 -1085
- package/src/core/chat_manager.py +0 -1577
- package/src/core/config_manager.py +0 -260
- package/src/core/cron.py +0 -578
- package/src/core/cron_allowlist.py +0 -118
- package/src/core/memory.py +0 -145
- package/src/core/metadata.py +0 -75
- package/src/core/retry.py +0 -71
- package/src/core/skills.py +0 -463
- package/src/core/sub_agent.py +0 -376
- package/src/core/tool_approval.py +0 -220
- package/src/core/tool_feedback.py +0 -789
- package/src/exceptions.py +0 -79
- package/src/llm/__init__.py +0 -1
- package/src/llm/client.py +0 -176
- package/src/llm/codex_provider.py +0 -350
- package/src/llm/config.py +0 -536
- package/src/llm/prompts.py +0 -494
- package/src/llm/providers.py +0 -438
- package/src/llm/streaming.py +0 -163
- package/src/llm/token_tracker.py +0 -399
- package/src/tools/__init__.py +0 -151
- package/src/tools/constants.py +0 -59
- package/src/tools/create_file.py +0 -136
- package/src/tools/directory.py +0 -389
- package/src/tools/edit.py +0 -549
- package/src/tools/file_reader.py +0 -322
- package/src/tools/helpers/__init__.py +0 -99
- package/src/tools/helpers/base.py +0 -599
- package/src/tools/helpers/converters.py +0 -44
- package/src/tools/helpers/file_helpers.py +0 -189
- package/src/tools/helpers/formatters.py +0 -411
- package/src/tools/helpers/loader.py +0 -145
- package/src/tools/helpers/parallel_executor.py +0 -231
- package/src/tools/helpers/path_resolver.py +0 -283
- package/src/tools/helpers/plugin_manifest.py +0 -185
- package/src/tools/obsidian.py +0 -96
- package/src/tools/review_sub_agent.py +0 -190
- package/src/tools/rg_search.py +0 -477
- package/src/tools/search_plugins.py +0 -177
- package/src/tools/select_option.py +0 -600
- package/src/tools/shell.py +0 -302
- package/src/tools/sub_agent.py +0 -139
- package/src/tools/task_list.py +0 -269
- package/src/tools/web_search.py +0 -61
- package/src/ui/__init__.py +0 -1
- package/src/ui/banner.py +0 -87
- package/src/ui/commands.py +0 -3131
- package/src/ui/displays.py +0 -239
- package/src/ui/loader.py +0 -284
- package/src/ui/main.py +0 -643
- package/src/ui/prompt_utils.py +0 -113
- package/src/ui/setting_selector.py +0 -590
- package/src/ui/setup_wizard.py +0 -294
- package/src/ui/sub_agent_panel.py +0 -234
- package/src/ui/tool_confirmation.py +0 -226
- package/src/utils/__init__.py +0 -1
- package/src/utils/citation_parser.py +0 -199
- package/src/utils/editor.py +0 -207
- package/src/utils/gitignore_filter.py +0 -149
- package/src/utils/logger.py +0 -254
- package/src/utils/paths.py +0 -30
- package/src/utils/result_parsers.py +0 -108
- package/src/utils/safe_commands.py +0 -243
- package/src/utils/settings.py +0 -195
- package/src/utils/user_message_logger.py +0 -120
- package/src/utils/validation.py +0 -201
- package/src/utils/web_search.py +0 -173
package/src/ui/commands.py
DELETED
|
@@ -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)
|