personal memory agent
at main 414 lines 14 kB view raw
1# SPDX-License-Identifier: AGPL-3.0-only 2# Copyright (c) 2026 sol pbc 3 4"""CLI commands for solstone support. 5 6Auto-discovered by ``think.call`` and mounted as ``sol call support ...``. 7 8Subcommands provide full access to the support portal: registration, KB search, 9ticket management, feedback, announcements, and local diagnostics. 10""" 11 12from __future__ import annotations 13 14import json 15from pathlib import Path 16 17import typer 18 19app = typer.Typer(help="Support tools — file tickets, search KB, give feedback.") 20 21 22# --------------------------------------------------------------------------- 23# Helpers 24# --------------------------------------------------------------------------- 25 26 27def _json_out(data: object) -> None: 28 """Pretty-print JSON to stdout.""" 29 typer.echo(json.dumps(data, indent=2, default=str)) 30 31 32def _check_enabled() -> None: 33 """Exit early if support is disabled in settings.""" 34 from apps.support.portal import is_enabled 35 36 if not is_enabled(): 37 typer.echo("Support agent is disabled in settings.", err=True) 38 raise typer.Exit(1) 39 40 41# --------------------------------------------------------------------------- 42# Commands 43# --------------------------------------------------------------------------- 44 45 46@app.command("register") 47def register() -> None: 48 """(Re-)register with the support portal.""" 49 _check_enabled() 50 from apps.support.portal import get_client 51 52 client = get_client() 53 result = client.register() 54 typer.echo(f"Registered as: {result.get('handle', '?')}") 55 56 57@app.command("search") 58def search( 59 query: str = typer.Argument(..., help="Search query for KB articles."), 60) -> None: 61 """Search knowledge base articles.""" 62 _check_enabled() 63 from apps.support.tools import support_search 64 65 articles = support_search(query) 66 if not articles: 67 typer.echo("No articles found.") 68 return 69 70 for a in articles: 71 typer.echo(f" [{a.get('slug', '?')}] {a.get('title', 'Untitled')}") 72 typer.echo( 73 f"\n{len(articles)} article(s) found. Use `sol call support article <slug>` to read." 74 ) 75 76 77@app.command("article") 78def article( 79 slug: str = typer.Argument(..., help="Article slug."), 80 as_json: bool = typer.Option(False, "--json", help="Output raw JSON."), 81) -> None: 82 """Read a KB article.""" 83 _check_enabled() 84 from apps.support.tools import support_article 85 86 try: 87 data = support_article(slug) 88 except Exception as exc: 89 typer.echo(f"Error: {exc}", err=True) 90 raise typer.Exit(1) from None 91 92 if as_json: 93 _json_out(data) 94 else: 95 typer.echo(f"# {data.get('title', 'Untitled')}\n") 96 typer.echo(data.get("content", "(no content)")) 97 98 99@app.command("create") 100def create( 101 subject: str = typer.Option(..., "--subject", "-s", help="Ticket subject."), 102 description: str = typer.Option( 103 ..., "--description", "-d", help="Ticket description." 104 ), 105 product: str = typer.Option("solstone", "--product", "-p", help="Product name."), 106 severity: str = typer.Option( 107 "medium", "--severity", help="low, medium, high, critical." 108 ), 109 category: str | None = typer.Option( 110 None, "--category", help="bug, feature, question, account." 111 ), 112 skip_kb: bool = typer.Option( 113 False, "--skip-kb", help="Skip KB search before filing." 114 ), 115 yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt."), 116 anonymous: bool = typer.Option( 117 False, "--anonymous", help="Strip installation identifiers." 118 ), 119) -> None: 120 """File a support ticket (KB-first flow with consent gate).""" 121 _check_enabled() 122 from apps.support.diagnostics import collect_all 123 from apps.support.tools import support_create, support_search 124 125 # Step 1: KB-first — search before filing 126 if not skip_kb: 127 typer.echo("Searching knowledge base...") 128 articles = support_search(subject) 129 if articles: 130 typer.echo(f"\nFound {len(articles)} related article(s):") 131 for a in articles: 132 typer.echo(f" [{a.get('slug', '?')}] {a.get('title', '')}") 133 typer.echo( 134 "\nThese may answer your question. " 135 "Use `sol call support article <slug>` to read." 136 ) 137 if not yes: 138 proceed = typer.confirm("Still want to file a ticket?") 139 if not proceed: 140 typer.echo("Cancelled.") 141 return 142 143 # Step 2: Collect diagnostics 144 diagnostics = collect_all() 145 146 # Step 3: Present draft for review (consent gate) 147 typer.echo("\n--- Ticket Draft ---") 148 typer.echo(f"Subject: {subject}") 149 typer.echo(f"Product: {product}") 150 typer.echo(f"Severity: {severity}") 151 if category: 152 typer.echo(f"Category: {category}") 153 typer.echo(f"Description: {description}") 154 typer.echo(f"\nDiagnostic data ({len(json.dumps(diagnostics))} bytes):") 155 typer.echo(json.dumps(diagnostics, indent=2, default=str)) 156 typer.echo("--- End Draft ---\n") 157 158 if not yes: 159 approved = typer.confirm("Submit this ticket?") 160 if not approved: 161 typer.echo("Cancelled — nothing was sent.") 162 return 163 164 # Step 4: Submit 165 try: 166 result = support_create( 167 subject=subject, 168 description=description, 169 product=product, 170 severity=severity, 171 category=category, 172 user_context=diagnostics, 173 auto_context=False, 174 anonymous=anonymous, 175 ) 176 typer.echo(f"Ticket created: #{result.get('id', '?')}") 177 except Exception as exc: 178 typer.echo(f"Error submitting ticket: {exc}", err=True) 179 raise typer.Exit(1) from None 180 181 182@app.command("list") 183def list_tickets( 184 status: str | None = typer.Option(None, "--status", help="Filter by status."), 185 as_json: bool = typer.Option(False, "--json", help="Output raw JSON."), 186) -> None: 187 """List your support tickets.""" 188 _check_enabled() 189 from apps.support.tools import support_list 190 191 tickets = support_list(status=status) 192 if as_json: 193 _json_out(tickets) 194 return 195 196 if not tickets: 197 typer.echo("No tickets found.") 198 return 199 200 for t in tickets: 201 status_str = t.get("status", "?") 202 typer.echo( 203 f" #{t.get('id', '?'):>4} [{status_str:<12}] {t.get('subject', 'Untitled')}" 204 ) 205 typer.echo(f"\n{len(tickets)} ticket(s).") 206 207 208@app.command("show") 209def show( 210 ticket_id: int = typer.Argument(..., help="Ticket ID."), 211 as_json: bool = typer.Option(False, "--json", help="Output raw JSON."), 212) -> None: 213 """View a ticket with its message thread.""" 214 _check_enabled() 215 from apps.support.tools import support_check 216 217 try: 218 data = support_check(ticket_id) 219 except Exception as exc: 220 typer.echo(f"Error: {exc}", err=True) 221 raise typer.Exit(1) from None 222 223 if as_json: 224 _json_out(data) 225 return 226 227 typer.echo(f"# Ticket #{data.get('id', '?')}: {data.get('subject', '')}") 228 typer.echo( 229 f"Status: {data.get('status', '?')} | Severity: {data.get('severity', '?')}" 230 ) 231 typer.echo(f"Created: {data.get('created_at', '?')}") 232 typer.echo(f"\n{data.get('description', '')}") 233 234 messages = data.get("messages", []) 235 if messages: 236 typer.echo(f"\n--- {len(messages)} message(s) ---") 237 for msg in messages: 238 handle = msg.get("handle", "?") 239 typer.echo(f"\n[{handle}] {msg.get('created_at', '')}") 240 typer.echo(msg.get("content", "")) 241 attachments = msg.get("attachments", []) 242 if attachments: 243 for att in attachments: 244 size = att.get("size_bytes", 0) 245 if size >= 1024 * 1024: 246 size_str = f"{size / 1024 / 1024:.1f} MB" 247 elif size >= 1024: 248 size_str = f"{size / 1024:.0f} KB" 249 else: 250 size_str = f"{size} bytes" 251 typer.echo(f" 📎 {att.get('filename', '?')} ({size_str})") 252 253 254@app.command("reply") 255def reply( 256 ticket_id: int = typer.Argument(..., help="Ticket ID."), 257 body: str = typer.Option(..., "--body", "-b", help="Reply content."), 258 yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation."), 259) -> None: 260 """Reply to a ticket.""" 261 _check_enabled() 262 from apps.support.tools import support_reply 263 264 if not yes: 265 typer.echo(f"Reply to ticket #{ticket_id}:\n{body}\n") 266 if not typer.confirm("Send this reply?"): 267 typer.echo("Cancelled.") 268 return 269 270 try: 271 support_reply(ticket_id, body) 272 typer.echo(f"Reply sent to ticket #{ticket_id}.") 273 except Exception as exc: 274 typer.echo(f"Error: {exc}", err=True) 275 raise typer.Exit(1) from None 276 277 278@app.command("attach") 279def attach( 280 ticket_id: int = typer.Argument(..., help="Ticket ID to attach files to."), 281 files: list[Path] = typer.Argument(..., help="File(s) to attach."), 282 yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation."), 283) -> None: 284 """Attach file(s) to a ticket.""" 285 _check_enabled() 286 from apps.support.portal import PortalClient 287 from apps.support.tools import support_attach 288 289 # Validate files up front 290 for f in files: 291 if not f.is_file(): 292 typer.echo(f"Error: file not found: {f}", err=True) 293 raise typer.Exit(1) 294 295 if len(files) > PortalClient.MAX_ATTACHMENTS_PER_MESSAGE: 296 typer.echo( 297 f"Error: max {PortalClient.MAX_ATTACHMENTS_PER_MESSAGE} files per upload.", 298 err=True, 299 ) 300 raise typer.Exit(1) 301 302 # Consent gate — show what will be uploaded 303 typer.echo(f"\n--- Attachment Review (ticket #{ticket_id}) ---") 304 for f in files: 305 size = f.stat().st_size 306 if size >= 1024 * 1024: 307 size_str = f"{size / 1024 / 1024:.1f} MB" 308 elif size >= 1024: 309 size_str = f"{size / 1024:.0f} KB" 310 else: 311 size_str = f"{size} bytes" 312 typer.echo(f" {f.name} ({size_str})") 313 typer.echo("--- End Review ---\n") 314 315 if not yes: 316 approved = typer.confirm("Upload these files?") 317 if not approved: 318 typer.echo("Cancelled — nothing was sent.") 319 return 320 321 for f in files: 322 try: 323 result = support_attach(ticket_id, str(f)) 324 typer.echo(f"Attached: {f.name} (id: {result.get('id', '?')})") 325 except ValueError as exc: 326 typer.echo(f"Skipped {f.name}: {exc}", err=True) 327 except Exception as exc: 328 typer.echo(f"Error uploading {f.name}: {exc}", err=True) 329 raise typer.Exit(1) from None 330 331 332@app.command("feedback") 333def feedback( 334 body: str = typer.Option(..., "--body", "-b", help="Your feedback."), 335 product: str = typer.Option("solstone", "--product", "-p", help="Product name."), 336 anonymous: bool = typer.Option(False, "--anonymous", help="Submit anonymously."), 337 yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation."), 338) -> None: 339 """Submit feedback (lower friction than a full ticket).""" 340 _check_enabled() 341 from apps.support.tools import support_feedback 342 343 if not yes: 344 typer.echo(f"Feedback:\n{body}\n") 345 anon_note = " (anonymous)" if anonymous else "" 346 if not typer.confirm(f"Submit this feedback{anon_note}?"): 347 typer.echo("Cancelled.") 348 return 349 350 try: 351 result = support_feedback(body=body, product=product, anonymous=anonymous) 352 typer.echo(f"Feedback submitted: #{result.get('id', '?')}") 353 except Exception as exc: 354 typer.echo(f"Error: {exc}", err=True) 355 raise typer.Exit(1) from None 356 357 358@app.command("announcements") 359def announcements( 360 as_json: bool = typer.Option(False, "--json", help="Output raw JSON."), 361) -> None: 362 """Check for product updates and known issues.""" 363 _check_enabled() 364 from apps.support.tools import support_announcements 365 366 items = support_announcements() 367 if as_json: 368 _json_out(items) 369 return 370 371 if not items: 372 typer.echo("No active announcements.") 373 return 374 375 for a in items: 376 icon = {"known-issue": "⚠️", "maintenance": "🔧"}.get(a.get("type", ""), "📢") 377 typer.echo(f" {icon} {a.get('title', 'Untitled')}") 378 if a.get("content"): 379 typer.echo(f" {a['content'][:120]}") 380 typer.echo(f"\n{len(items)} announcement(s).") 381 382 383@app.command("diagnose") 384def diagnose( 385 as_json: bool = typer.Option(False, "--json", help="Output raw JSON."), 386) -> None: 387 """Run local diagnostics (no network).""" 388 from apps.support.tools import support_diagnose 389 390 data = support_diagnose() 391 if as_json: 392 _json_out(data) 393 else: 394 typer.echo("# Local Diagnostics\n") 395 typer.echo(f"Version: {data.get('version', 'unknown')}") 396 plat = data.get("platform", {}) 397 typer.echo( 398 f"Platform: {plat.get('system', '?')} {plat.get('release', '')} " 399 f"({plat.get('machine', '')})" 400 ) 401 typer.echo(f"Python: {plat.get('python', '?')}") 402 403 services = data.get("services", {}) 404 if services: 405 typer.echo("\nServices:") 406 for name, status in sorted(services.items()): 407 icon = "" if status == "running" else "" 408 typer.echo(f" {icon} {name}: {status}") 409 410 errors = data.get("recent_errors", []) 411 if errors: 412 typer.echo(f"\nRecent errors ({len(errors)}):") 413 for e in errors[:5]: 414 typer.echo(f" [{e.get('service', '?')}] {e.get('message', '')[:100]}")