htmlgraph 0.26.5__py3-none-any.whl → 0.26.7__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. htmlgraph/.htmlgraph/.session-warning-state.json +1 -1
  2. htmlgraph/__init__.py +1 -1
  3. htmlgraph/api/main.py +50 -10
  4. htmlgraph/api/templates/dashboard-redesign.html +608 -54
  5. htmlgraph/api/templates/partials/activity-feed.html +21 -0
  6. htmlgraph/api/templates/partials/features.html +81 -12
  7. htmlgraph/api/templates/partials/orchestration.html +35 -0
  8. htmlgraph/cli/.htmlgraph/.session-warning-state.json +6 -0
  9. htmlgraph/cli/.htmlgraph/agents.json +72 -0
  10. htmlgraph/cli/__init__.py +42 -0
  11. htmlgraph/cli/__main__.py +6 -0
  12. htmlgraph/cli/analytics.py +939 -0
  13. htmlgraph/cli/base.py +660 -0
  14. htmlgraph/cli/constants.py +206 -0
  15. htmlgraph/cli/core.py +856 -0
  16. htmlgraph/cli/main.py +143 -0
  17. htmlgraph/cli/models.py +462 -0
  18. htmlgraph/cli/templates/__init__.py +1 -0
  19. htmlgraph/cli/templates/cost_dashboard.py +398 -0
  20. htmlgraph/cli/work/__init__.py +159 -0
  21. htmlgraph/cli/work/features.py +567 -0
  22. htmlgraph/cli/work/orchestration.py +675 -0
  23. htmlgraph/cli/work/sessions.py +465 -0
  24. htmlgraph/cli/work/tracks.py +485 -0
  25. htmlgraph/dashboard.html +6414 -634
  26. htmlgraph/db/schema.py +8 -3
  27. htmlgraph/docs/ORCHESTRATION_PATTERNS.md +20 -13
  28. htmlgraph/docs/README.md +2 -3
  29. htmlgraph/hooks/event_tracker.py +355 -26
  30. htmlgraph/hooks/git_commands.py +175 -0
  31. htmlgraph/hooks/orchestrator.py +137 -71
  32. htmlgraph/hooks/orchestrator_reflector.py +23 -0
  33. htmlgraph/hooks/pretooluse.py +29 -6
  34. htmlgraph/hooks/session_handler.py +28 -0
  35. htmlgraph/hooks/session_summary.py +391 -0
  36. htmlgraph/hooks/subagent_detection.py +202 -0
  37. htmlgraph/hooks/subagent_stop.py +71 -12
  38. htmlgraph/hooks/validator.py +192 -79
  39. htmlgraph/operations/__init__.py +18 -0
  40. htmlgraph/operations/initialization.py +596 -0
  41. htmlgraph/operations/initialization.py.backup +228 -0
  42. htmlgraph/orchestration/__init__.py +16 -1
  43. htmlgraph/orchestration/claude_launcher.py +185 -0
  44. htmlgraph/orchestration/command_builder.py +71 -0
  45. htmlgraph/orchestration/headless_spawner.py +72 -1332
  46. htmlgraph/orchestration/plugin_manager.py +136 -0
  47. htmlgraph/orchestration/prompts.py +137 -0
  48. htmlgraph/orchestration/spawners/__init__.py +16 -0
  49. htmlgraph/orchestration/spawners/base.py +194 -0
  50. htmlgraph/orchestration/spawners/claude.py +170 -0
  51. htmlgraph/orchestration/spawners/codex.py +442 -0
  52. htmlgraph/orchestration/spawners/copilot.py +299 -0
  53. htmlgraph/orchestration/spawners/gemini.py +478 -0
  54. htmlgraph/orchestration/subprocess_runner.py +33 -0
  55. htmlgraph/orchestration.md +563 -0
  56. htmlgraph/orchestrator-system-prompt-optimized.txt +620 -55
  57. htmlgraph/orchestrator_config.py +357 -0
  58. htmlgraph/orchestrator_mode.py +45 -12
  59. htmlgraph/transcript.py +16 -4
  60. htmlgraph-0.26.7.data/data/htmlgraph/dashboard.html +6592 -0
  61. {htmlgraph-0.26.5.dist-info → htmlgraph-0.26.7.dist-info}/METADATA +1 -1
  62. {htmlgraph-0.26.5.dist-info → htmlgraph-0.26.7.dist-info}/RECORD +68 -34
  63. {htmlgraph-0.26.5.dist-info → htmlgraph-0.26.7.dist-info}/entry_points.txt +1 -1
  64. htmlgraph/cli.py +0 -7256
  65. htmlgraph-0.26.5.data/data/htmlgraph/dashboard.html +0 -812
  66. {htmlgraph-0.26.5.data → htmlgraph-0.26.7.data}/data/htmlgraph/styles.css +0 -0
  67. {htmlgraph-0.26.5.data → htmlgraph-0.26.7.data}/data/htmlgraph/templates/AGENTS.md.template +0 -0
  68. {htmlgraph-0.26.5.data → htmlgraph-0.26.7.data}/data/htmlgraph/templates/CLAUDE.md.template +0 -0
  69. {htmlgraph-0.26.5.data → htmlgraph-0.26.7.data}/data/htmlgraph/templates/GEMINI.md.template +0 -0
  70. {htmlgraph-0.26.5.dist-info → htmlgraph-0.26.7.dist-info}/WHEEL +0 -0
htmlgraph/cli/base.py ADDED
@@ -0,0 +1,660 @@
1
+ """Base classes and utilities for CLI commands.
2
+
3
+ Provides:
4
+ - BaseCommand: Abstract base class for all commands
5
+ - CommandResult: Structured command output
6
+ - CommandError: User-facing errors
7
+ - Formatters: JSON and text output formatting
8
+ - TableBuilder: Utility for creating Rich tables with consistent styling
9
+ - TextOutputBuilder: Utility for building formatted text output consistently
10
+ - save_traceback: Save full tracebacks to log files instead of console
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import json
17
+ import sys
18
+ import traceback
19
+ from abc import ABC, abstractmethod
20
+ from collections.abc import Iterable
21
+ from dataclasses import dataclass
22
+ from datetime import date, datetime
23
+ from pathlib import Path
24
+ from typing import TYPE_CHECKING, Any, Literal, Protocol
25
+
26
+ from rich import box
27
+ from rich.console import Console
28
+ from rich.table import Table
29
+ from typing_extensions import Self
30
+
31
+ if TYPE_CHECKING:
32
+ from htmlgraph.sdk import SDK
33
+
34
+ _console = Console()
35
+
36
+
37
+ class CommandError(Exception):
38
+ """User-facing CLI error with an exit code."""
39
+
40
+ def __init__(self, message: str, exit_code: int = 1) -> None:
41
+ super().__init__(message)
42
+ self.exit_code = exit_code
43
+
44
+
45
+ # ============================================================================
46
+ # Traceback Logger - Save error tracebacks to log files
47
+ # ============================================================================
48
+
49
+
50
+ def save_traceback(error: Exception, context: dict[str, Any] | None = None) -> Path:
51
+ """Save full traceback to log file instead of printing to console.
52
+
53
+ Args:
54
+ error: The exception that was raised
55
+ context: Optional context dict with command, args, cwd, etc.
56
+
57
+ Returns:
58
+ Path to the saved log file
59
+
60
+ Example:
61
+ try:
62
+ # Some operation
63
+ pass
64
+ except Exception as e:
65
+ log_file = save_traceback(e, context={"command": "serve", "cwd": os.getcwd()})
66
+ console.print(f"[red]Error:[/red] {e}")
67
+ console.print(f"[dim]Full traceback saved to:[/dim] {log_file}")
68
+ """
69
+ # Create logs directory
70
+ log_dir = Path(".htmlgraph/logs/errors")
71
+ log_dir.mkdir(parents=True, exist_ok=True)
72
+
73
+ # Generate filename with timestamp
74
+ timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
75
+ log_file = log_dir / f"error-{timestamp}.log"
76
+
77
+ # Write traceback with context
78
+ with open(log_file, "w") as f:
79
+ f.write(f"Timestamp: {datetime.now().isoformat()}\n")
80
+ if context:
81
+ f.write(f"Context: {context}\n")
82
+ f.write("\n--- Traceback ---\n")
83
+ traceback.print_exc(file=f)
84
+
85
+ return log_file
86
+
87
+
88
+ # ============================================================================
89
+ # TableBuilder - Consistent table styling across CLI
90
+ # ============================================================================
91
+
92
+
93
+ class TableBuilder:
94
+ """Builder for creating Rich tables with consistent styling.
95
+
96
+ Provides factory methods for common table patterns and column types.
97
+ Eliminates duplicated table creation code across CLI modules.
98
+
99
+ Example:
100
+ # List table with standard styling
101
+ builder = TableBuilder.create_list_table("Features")
102
+ builder.add_id_column()
103
+ builder.add_text_column("Title", max_width=40)
104
+ builder.add_status_column()
105
+ builder.add_timestamp_column("Updated")
106
+
107
+ # Add rows
108
+ for feature in features:
109
+ builder.add_row(feature.id, feature.title, feature.status, feature.updated)
110
+
111
+ # Access the table
112
+ console.print(builder.table)
113
+ """
114
+
115
+ def __init__(
116
+ self,
117
+ *,
118
+ title: str | None = None,
119
+ show_header: bool = True,
120
+ header_style: str = "bold magenta",
121
+ box_style: box.Box = box.ROUNDED,
122
+ ) -> None:
123
+ """Initialize TableBuilder with styling options.
124
+
125
+ Args:
126
+ title: Table title
127
+ show_header: Show header row
128
+ header_style: Style for header text
129
+ box_style: Box drawing style from rich.box
130
+ """
131
+ self.table = Table(
132
+ title=title,
133
+ show_header=show_header,
134
+ header_style=header_style,
135
+ box=box_style,
136
+ )
137
+
138
+ @classmethod
139
+ def create_list_table(cls, title: str | None = None) -> TableBuilder:
140
+ """Create a standard list table with rounded box."""
141
+ return cls(title=title, show_header=True, header_style="bold magenta")
142
+
143
+ @classmethod
144
+ def create_status_table(cls, title: str | None = None) -> TableBuilder:
145
+ """Create a key-value status table without header."""
146
+ return cls(title=title, show_header=False, box_style=box.SIMPLE)
147
+
148
+ @classmethod
149
+ def create_compact_table(cls) -> TableBuilder:
150
+ """Create a compact table with no header or box."""
151
+ return cls(title=None, show_header=False, box_style=box.SIMPLE)
152
+
153
+ def add_id_column(
154
+ self,
155
+ name: str = "ID",
156
+ *,
157
+ style: str = "cyan",
158
+ no_wrap: bool = False,
159
+ max_width: int | None = None,
160
+ ) -> TableBuilder:
161
+ """Add an ID column with cyan styling.
162
+
163
+ Args:
164
+ name: Column header name
165
+ style: Text style
166
+ no_wrap: Prevent text wrapping
167
+ max_width: Maximum column width in characters
168
+ """
169
+ self.table.add_column(name, style=style, no_wrap=no_wrap, max_width=max_width)
170
+ return self
171
+
172
+ def add_text_column(
173
+ self,
174
+ name: str,
175
+ *,
176
+ style: str = "yellow",
177
+ max_width: int | None = None,
178
+ no_wrap: bool = False,
179
+ ) -> TableBuilder:
180
+ """Add a text column with yellow styling.
181
+
182
+ Args:
183
+ name: Column header name
184
+ style: Text style
185
+ max_width: Maximum column width in characters
186
+ no_wrap: Prevent text wrapping
187
+ """
188
+ self.table.add_column(name, style=style, max_width=max_width, no_wrap=no_wrap)
189
+ return self
190
+
191
+ def add_status_column(
192
+ self,
193
+ name: str = "Status",
194
+ *,
195
+ style: str = "green",
196
+ width: int | None = None,
197
+ ) -> TableBuilder:
198
+ """Add a status column with green styling.
199
+
200
+ Args:
201
+ name: Column header name
202
+ style: Text style
203
+ width: Fixed column width in characters
204
+ """
205
+ self.table.add_column(name, style=style, width=width)
206
+ return self
207
+
208
+ def add_priority_column(
209
+ self,
210
+ name: str = "Priority",
211
+ *,
212
+ style: str = "blue",
213
+ width: int | None = None,
214
+ ) -> TableBuilder:
215
+ """Add a priority column with blue styling.
216
+
217
+ Args:
218
+ name: Column header name
219
+ style: Text style
220
+ width: Fixed column width in characters
221
+ """
222
+ self.table.add_column(name, style=style, width=width)
223
+ return self
224
+
225
+ def add_timestamp_column(
226
+ self,
227
+ name: str,
228
+ *,
229
+ style: str = "white",
230
+ width: int | None = None,
231
+ ) -> TableBuilder:
232
+ """Add a timestamp column with white styling.
233
+
234
+ Args:
235
+ name: Column header name
236
+ style: Text style
237
+ width: Fixed column width in characters
238
+ """
239
+ self.table.add_column(name, style=style, width=width)
240
+ return self
241
+
242
+ def add_numeric_column(
243
+ self,
244
+ name: str,
245
+ *,
246
+ style: str = "yellow",
247
+ justify: Literal["left", "center", "right"] = "right",
248
+ width: int | None = None,
249
+ ) -> TableBuilder:
250
+ """Add a numeric column with right justification.
251
+
252
+ Args:
253
+ name: Column header name
254
+ style: Text style
255
+ justify: Text alignment
256
+ width: Fixed column width in characters
257
+ """
258
+ self.table.add_column(name, style=style, justify=justify, width=width)
259
+ return self
260
+
261
+ def add_column(
262
+ self,
263
+ name: str,
264
+ *,
265
+ style: str | None = None,
266
+ justify: Literal["left", "center", "right"] = "left",
267
+ width: int | None = None,
268
+ max_width: int | None = None,
269
+ no_wrap: bool = False,
270
+ ) -> TableBuilder:
271
+ """Add a custom column with full control over styling.
272
+
273
+ Args:
274
+ name: Column header name
275
+ style: Text style (e.g., "cyan", "bold red")
276
+ justify: Text alignment
277
+ width: Fixed column width in characters
278
+ max_width: Maximum column width in characters
279
+ no_wrap: Prevent text wrapping
280
+ """
281
+ self.table.add_column(
282
+ name,
283
+ style=style,
284
+ justify=justify,
285
+ width=width,
286
+ max_width=max_width,
287
+ no_wrap=no_wrap,
288
+ )
289
+ return self
290
+
291
+ def add_row(self, *values: str) -> TableBuilder:
292
+ """Add a data row to the table.
293
+
294
+ Args:
295
+ *values: Cell values (converted to strings)
296
+ """
297
+ self.table.add_row(*values)
298
+ return self
299
+
300
+ def add_separator(self, style: str = "dim") -> TableBuilder:
301
+ """Add a separator row.
302
+
303
+ Args:
304
+ style: Style for separator row
305
+ """
306
+ # Add empty row with style
307
+ num_columns = len(self.table.columns)
308
+ self.table.add_row(*[""] * num_columns, style=style)
309
+ return self
310
+
311
+
312
+ # ============================================================================
313
+ # TextOutputBuilder - Consistent text output formatting across CLI
314
+ # ============================================================================
315
+
316
+
317
+ class TextOutputBuilder:
318
+ """Builder for creating formatted text output consistently.
319
+
320
+ Provides fluent API methods for building structured text output with
321
+ Rich console styling. Eliminates duplicated text output building code
322
+ across CLI modules.
323
+
324
+ Example:
325
+ output = TextOutputBuilder()
326
+ output.add_success(f"Session started: {session.id}")
327
+ output.add_field("Agent", session.agent)
328
+ output.add_field("Started", session.started_at.isoformat())
329
+ return CommandResult(text=output.build())
330
+ """
331
+
332
+ def __init__(self) -> None:
333
+ """Initialize TextOutputBuilder with empty lines list."""
334
+ self._lines: list[str] = []
335
+
336
+ def add_success(self, message: str) -> Self:
337
+ """Add success message with green styling.
338
+
339
+ Args:
340
+ message: Success message text
341
+
342
+ Returns:
343
+ Self for method chaining
344
+ """
345
+ from htmlgraph.cli.constants import get_style
346
+
347
+ self._lines.append(f"{get_style('success')}{message}")
348
+ return self
349
+
350
+ def add_error(self, message: str) -> Self:
351
+ """Add error message with red styling.
352
+
353
+ Args:
354
+ message: Error message text
355
+
356
+ Returns:
357
+ Self for method chaining
358
+ """
359
+ from htmlgraph.cli.constants import get_style
360
+
361
+ self._lines.append(f"{get_style('error')}{message}")
362
+ return self
363
+
364
+ def add_warning(self, message: str) -> Self:
365
+ """Add warning message with yellow styling.
366
+
367
+ Args:
368
+ message: Warning message text
369
+
370
+ Returns:
371
+ Self for method chaining
372
+ """
373
+ from htmlgraph.cli.constants import get_style
374
+
375
+ self._lines.append(f"{get_style('warning')}{message}")
376
+ return self
377
+
378
+ def add_info(self, message: str) -> Self:
379
+ """Add info message with cyan styling.
380
+
381
+ Args:
382
+ message: Info message text
383
+
384
+ Returns:
385
+ Self for method chaining
386
+ """
387
+ from htmlgraph.cli.constants import get_style
388
+
389
+ self._lines.append(f"{get_style('info')}{message}")
390
+ return self
391
+
392
+ def add_dim(self, message: str) -> Self:
393
+ """Add dimmed message with dim styling.
394
+
395
+ Args:
396
+ message: Dimmed message text
397
+
398
+ Returns:
399
+ Self for method chaining
400
+ """
401
+ from htmlgraph.cli.constants import get_style
402
+
403
+ self._lines.append(f"{get_style('dim')}{message}")
404
+ return self
405
+
406
+ def add_field(self, label: str, value: str | int | float | None) -> Self:
407
+ """Add indented field in 'Label: value' format.
408
+
409
+ Args:
410
+ label: Field label
411
+ value: Field value (converted to string)
412
+
413
+ Returns:
414
+ Self for method chaining
415
+ """
416
+ value_str = str(value) if value is not None else ""
417
+ self._lines.append(f" {label}: {value_str}")
418
+ return self
419
+
420
+ def add_line(self, text: str) -> Self:
421
+ """Add plain text line without styling.
422
+
423
+ Args:
424
+ text: Plain text to add
425
+
426
+ Returns:
427
+ Self for method chaining
428
+ """
429
+ self._lines.append(text)
430
+ return self
431
+
432
+ def add_blank(self) -> Self:
433
+ """Add blank line.
434
+
435
+ Returns:
436
+ Self for method chaining
437
+ """
438
+ self._lines.append("")
439
+ return self
440
+
441
+ def build(self) -> str:
442
+ """Build final text output by joining all lines.
443
+
444
+ Returns:
445
+ Joined string with newline separators
446
+ """
447
+ return "\n".join(self._lines)
448
+
449
+
450
+ @dataclass
451
+ class CommandResult:
452
+ """Structured command result for flexible output formatting."""
453
+
454
+ data: Any = None
455
+ text: str | Iterable[str] | None = None
456
+ json_data: Any | None = None
457
+ exit_code: int = 0 # Exit code for the command (0 = success)
458
+
459
+
460
+ class Formatter(Protocol):
461
+ """Protocol for output formatters."""
462
+
463
+ def output(self, result: CommandResult) -> None: ...
464
+
465
+
466
+ def _serialize_json(value: Any) -> Any:
467
+ """Recursively serialize value to JSON-compatible types."""
468
+ if value is None:
469
+ return None
470
+ if isinstance(value, (datetime, date)):
471
+ return value.isoformat()
472
+ if hasattr(value, "model_dump") and callable(getattr(value, "model_dump")):
473
+ return _serialize_json(value.model_dump())
474
+ if hasattr(value, "to_dict") and callable(getattr(value, "to_dict")):
475
+ return _serialize_json(value.to_dict())
476
+ if isinstance(value, dict):
477
+ return {key: _serialize_json(val) for key, val in value.items()}
478
+ if isinstance(value, (list, tuple, set)):
479
+ return [_serialize_json(item) for item in value]
480
+ return value
481
+
482
+
483
+ class JsonFormatter:
484
+ """Format command output as JSON."""
485
+
486
+ def output(self, result: CommandResult) -> None:
487
+ payload = result.json_data if result.json_data is not None else result.data
488
+ _console.print(json.dumps(_serialize_json(payload), indent=2))
489
+
490
+
491
+ class TextFormatter:
492
+ """Format command output as plain text."""
493
+
494
+ def output(self, result: CommandResult) -> None:
495
+ # If data is provided and it's a Rich renderable, print it directly
496
+ if result.data is not None:
497
+ from rich.table import Table
498
+
499
+ # Check if data is a Rich renderable (Table, Panel, etc.)
500
+ if isinstance(result.data, (Table,)) or hasattr(result.data, "__rich__"):
501
+ _console.print(result.data)
502
+ return
503
+
504
+ # Fall back to text output
505
+ if result.text is None:
506
+ if result.data is not None:
507
+ _console.print(result.data)
508
+ return
509
+ if isinstance(result.text, str):
510
+ _console.print(result.text)
511
+ return
512
+ _console.print("\n".join(str(line) for line in result.text))
513
+
514
+
515
+ def get_formatter(format_name: str) -> Formatter:
516
+ """Get formatter by name (json, text, plain)."""
517
+ if format_name == "json":
518
+ return JsonFormatter()
519
+ if format_name in ("text", "plain", ""):
520
+ return TextFormatter()
521
+ raise CommandError(f"Unknown output format '{format_name}'")
522
+
523
+
524
+ class BaseCommand(ABC):
525
+ """Abstract base class for all CLI commands.
526
+
527
+ Provides:
528
+ - SDK initialization and caching
529
+ - Structured error handling
530
+ - Validation lifecycle hook
531
+ - Output formatting
532
+
533
+ Subclasses must implement:
534
+ - from_args(): Create command instance from argparse.Namespace
535
+ - execute(): Execute command logic and return CommandResult
536
+ """
537
+
538
+ def __init__(self) -> None:
539
+ self.graph_dir: str | None = None
540
+ self.agent: str | None = None
541
+ self._sdk: SDK | None = None
542
+
543
+ @classmethod
544
+ @abstractmethod
545
+ def from_args(cls, args: argparse.Namespace) -> BaseCommand:
546
+ """Create command instance from argparse arguments.
547
+
548
+ This separates argument parsing from command execution,
549
+ making commands easier to test.
550
+ """
551
+ raise NotImplementedError
552
+
553
+ def validate(self) -> None:
554
+ """Validate command parameters before execution.
555
+
556
+ Raise CommandError if validation fails.
557
+ Default implementation does nothing.
558
+ """
559
+ return None
560
+
561
+ @abstractmethod
562
+ def execute(self) -> CommandResult:
563
+ """Execute the command and return structured result.
564
+
565
+ Raise CommandError for user-facing errors.
566
+ """
567
+ raise NotImplementedError
568
+
569
+ def get_sdk(self) -> SDK:
570
+ """Get or create SDK instance.
571
+
572
+ Caches SDK to avoid repeated initialization.
573
+ """
574
+ if self.graph_dir is None:
575
+ raise CommandError("Missing graph directory for command execution.")
576
+ if self._sdk is None:
577
+ from htmlgraph.sdk import SDK
578
+
579
+ self._sdk = SDK(directory=self.graph_dir, agent=self.agent)
580
+ return self._sdk
581
+
582
+ def require_node(self, node: Any, entity_type: str, entity_id: str) -> None:
583
+ """Validate that a node exists, raising CommandError if None.
584
+
585
+ Args:
586
+ node: The node object to validate
587
+ entity_type: Type of entity (feature, session, track, etc.)
588
+ entity_id: ID of the entity for error message
589
+
590
+ Raises:
591
+ CommandError: If node is None
592
+
593
+ Usage:
594
+ node = collection.get(feature_id)
595
+ self.require_node(node, "feature", feature_id)
596
+ """
597
+ if node is None:
598
+ from htmlgraph.cli.constants import get_error_message
599
+
600
+ error_key = f"{entity_type}_not_found"
601
+ id_key = f"{entity_type}_id"
602
+ raise CommandError(get_error_message(error_key, **{id_key: entity_id}))
603
+
604
+ def require_value(self, value: Any, message: str) -> None:
605
+ """Generic validation helper that raises CommandError if value is falsy.
606
+
607
+ Args:
608
+ value: The value to validate
609
+ message: Error message to raise if validation fails
610
+
611
+ Raises:
612
+ CommandError: If value is falsy (None, False, empty string, etc.)
613
+
614
+ Usage:
615
+ self.require_value(self.title, "Title is required")
616
+ self.require_value(len(items) > 0, "At least one item required")
617
+ """
618
+ if not value:
619
+ raise CommandError(message)
620
+
621
+ def require_collection(self, collection: Any, collection_name: str) -> None:
622
+ """Validate that a collection exists on SDK, raising CommandError if None.
623
+
624
+ Args:
625
+ collection: The collection object to validate
626
+ collection_name: Name of the collection for error message
627
+
628
+ Raises:
629
+ CommandError: If collection is None/falsy
630
+
631
+ Usage:
632
+ collection = getattr(sdk, self.collection, None)
633
+ self.require_collection(collection, self.collection)
634
+ """
635
+ if not collection:
636
+ raise CommandError(f"Collection '{collection_name}' not found in SDK")
637
+
638
+ def run(self, *, graph_dir: str, agent: str | None, output_format: str) -> None:
639
+ """Run command with context.
640
+
641
+ Args:
642
+ graph_dir: Path to .htmlgraph directory
643
+ agent: Agent name (optional)
644
+ output_format: Output format (json, text, plain)
645
+ """
646
+ self.graph_dir = graph_dir
647
+ self.agent = agent
648
+ try:
649
+ self.validate()
650
+ result = self.execute()
651
+ formatter = get_formatter(output_format)
652
+ formatter.output(result)
653
+ except CommandError as exc:
654
+ error_console = Console(file=sys.stderr)
655
+ error_console.print(f"[red]Error: {exc}[/red]")
656
+ sys.exit(exc.exit_code)
657
+ except ValueError as exc:
658
+ error_console = Console(file=sys.stderr)
659
+ error_console.print(f"[red]Error: {exc}[/red]")
660
+ sys.exit(1)