personal memory agent
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]}")