htmlgraph 0.26.21__py3-none-any.whl → 0.26.23__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.
htmlgraph/__init__.py CHANGED
@@ -19,6 +19,7 @@ from htmlgraph.atomic_ops import (
19
19
  from htmlgraph.builders import BaseBuilder, FeatureBuilder, SpikeBuilder
20
20
  from htmlgraph.collections import BaseCollection, FeatureCollection, SpikeCollection
21
21
  from htmlgraph.context_analytics import ContextAnalytics, ContextUsage
22
+ from htmlgraph.decorators import RetryError, retry, retry_async
22
23
  from htmlgraph.edge_index import EdgeIndex, EdgeRef
23
24
  from htmlgraph.exceptions import (
24
25
  ClaimConflictError,
@@ -95,7 +96,7 @@ from htmlgraph.types import (
95
96
  )
96
97
  from htmlgraph.work_type_utils import infer_work_type, infer_work_type_from_id
97
98
 
98
- __version__ = "0.26.21"
99
+ __version__ = "0.26.23"
99
100
  __all__ = [
100
101
  # Exceptions
101
102
  "HtmlGraphError",
@@ -103,6 +104,10 @@ __all__ = [
103
104
  "SessionNotFoundError",
104
105
  "ClaimConflictError",
105
106
  "ValidationError",
107
+ "RetryError",
108
+ # Decorators
109
+ "retry",
110
+ "retry_async",
106
111
  # Core models
107
112
  "Node",
108
113
  "Edge",
@@ -12,6 +12,7 @@ from __future__ import annotations
12
12
  import argparse
13
13
  import json
14
14
  import webbrowser
15
+ from datetime import datetime
15
16
  from pathlib import Path
16
17
  from typing import TYPE_CHECKING
17
18
 
@@ -69,6 +70,9 @@ def register_commands(subparsers: _SubParsersAction) -> None:
69
70
  # Sync docs command
70
71
  _register_sync_docs_command(subparsers)
71
72
 
73
+ # Costs command
74
+ _register_costs_command(subparsers)
75
+
72
76
 
73
77
  def _register_cigs_commands(subparsers: _SubParsersAction) -> None:
74
78
  """Register CIGS (Cost Intelligence & Governance System) commands."""
@@ -190,6 +194,48 @@ def _register_sync_docs_command(subparsers: _SubParsersAction) -> None:
190
194
  sync_docs.set_defaults(func=SyncDocsCommand.from_args)
191
195
 
192
196
 
197
+ def _register_costs_command(subparsers: _SubParsersAction) -> None:
198
+ """Register cost visibility and analysis command."""
199
+ costs_parser = subparsers.add_parser(
200
+ "costs",
201
+ help="View token cost breakdown and analytics",
202
+ )
203
+ costs_parser.add_argument(
204
+ "--graph-dir", "-g", default=DEFAULT_GRAPH_DIR, help="Graph directory"
205
+ )
206
+ costs_parser.add_argument(
207
+ "--period",
208
+ choices=["today", "day", "week", "month", "all"],
209
+ default="week",
210
+ help="Time period to analyze (default: week)",
211
+ )
212
+ costs_parser.add_argument(
213
+ "--by",
214
+ choices=["session", "feature", "tool", "agent"],
215
+ default="session",
216
+ help="Group costs by (default: session)",
217
+ )
218
+ costs_parser.add_argument(
219
+ "--format",
220
+ choices=["terminal", "csv"],
221
+ default="terminal",
222
+ help="Output format (default: terminal)",
223
+ )
224
+ costs_parser.add_argument(
225
+ "--model",
226
+ choices=["opus", "sonnet", "haiku", "auto"],
227
+ default="auto",
228
+ help="Claude model to assume for pricing (default: auto-detect)",
229
+ )
230
+ costs_parser.add_argument(
231
+ "--limit",
232
+ type=int,
233
+ default=10,
234
+ help="Maximum number of rows to display (default: 10)",
235
+ )
236
+ costs_parser.set_defaults(func=CostsCommand.from_args)
237
+
238
+
193
239
  # ============================================================================
194
240
  # Pydantic Models for Cost Analytics
195
241
  # ============================================================================
@@ -956,3 +1002,422 @@ class SyncDocsCommand(BaseCommand):
956
1002
  text="Synchronization complete",
957
1003
  exit_code=1 if has_errors else 0,
958
1004
  )
1005
+
1006
+
1007
+ # ============================================================================
1008
+ # Cost Command Implementation
1009
+ # ============================================================================
1010
+
1011
+
1012
+ class CostsCommand(BaseCommand):
1013
+ """View token cost breakdown and analytics by session, feature, or tool."""
1014
+
1015
+ def __init__(
1016
+ self,
1017
+ *,
1018
+ period: str,
1019
+ by: str,
1020
+ format: str,
1021
+ model: str,
1022
+ limit: int,
1023
+ ) -> None:
1024
+ super().__init__()
1025
+ self.period = period
1026
+ self.by = by
1027
+ self.format = format
1028
+ self.model = model
1029
+ self.limit = limit
1030
+
1031
+ @classmethod
1032
+ def from_args(cls, args: argparse.Namespace) -> CostsCommand:
1033
+ return cls(
1034
+ period=getattr(args, "period", "week"),
1035
+ by=getattr(args, "by", "session"),
1036
+ format=getattr(args, "format", "terminal"),
1037
+ model=getattr(args, "model", "auto"),
1038
+ limit=getattr(args, "limit", 10),
1039
+ )
1040
+
1041
+ def execute(self) -> CommandResult:
1042
+ """Execute cost analysis and display results."""
1043
+
1044
+ if not self.graph_dir:
1045
+ raise CommandError("Graph directory not specified")
1046
+
1047
+ graph_dir = Path(self.graph_dir)
1048
+ db_path = graph_dir / "htmlgraph.db"
1049
+
1050
+ if not db_path.exists():
1051
+ console.print(
1052
+ "[yellow]No HtmlGraph database found. Run some work to generate cost data![/yellow]"
1053
+ )
1054
+ return CommandResult(text="No database", exit_code=1)
1055
+
1056
+ # Query costs from database
1057
+ with console.status("[blue]Analyzing costs...[/blue]", spinner="dots"):
1058
+ try:
1059
+ cost_data = self._query_costs(db_path)
1060
+ except Exception as e:
1061
+ raise CommandError(f"Failed to query costs: {e}")
1062
+
1063
+ if not cost_data:
1064
+ console.print(
1065
+ "[yellow]No cost data found for the specified period.[/yellow]"
1066
+ )
1067
+ return CommandResult(text="No cost data")
1068
+
1069
+ # Calculate USD costs based on model pricing
1070
+ cost_data = self._add_usd_costs(cost_data)
1071
+
1072
+ # Display results
1073
+ if self.format == "csv":
1074
+ self._display_csv(cost_data)
1075
+ else:
1076
+ self._display_terminal(cost_data)
1077
+
1078
+ # Display insights
1079
+ self._display_insights(cost_data)
1080
+
1081
+ return CommandResult(text="Cost analysis complete")
1082
+
1083
+ def _query_costs(self, db_path: Path) -> list[dict]:
1084
+ """Query costs from the database based on period and grouping."""
1085
+ import sqlite3
1086
+ from datetime import datetime, timezone
1087
+
1088
+ conn = sqlite3.connect(str(db_path))
1089
+ conn.row_factory = sqlite3.Row
1090
+ cursor = conn.cursor()
1091
+
1092
+ # Calculate time filter
1093
+ now = datetime.now(timezone.utc)
1094
+ time_filter = self._get_time_filter(now)
1095
+
1096
+ # Build the query based on grouping
1097
+ if self.by == "session":
1098
+ query = """
1099
+ SELECT
1100
+ session_id as group_id,
1101
+ session_id as name,
1102
+ 'session' as type,
1103
+ COUNT(*) as event_count,
1104
+ SUM(cost_tokens) as total_tokens,
1105
+ MIN(timestamp) as start_time,
1106
+ MAX(timestamp) as end_time
1107
+ FROM agent_events
1108
+ WHERE event_type IN ('tool_call', 'tool_result')
1109
+ AND cost_tokens > 0
1110
+ AND timestamp >= ?
1111
+ GROUP BY session_id
1112
+ ORDER BY total_tokens DESC
1113
+ LIMIT ?
1114
+ """
1115
+ cursor.execute(query, (time_filter, self.limit))
1116
+
1117
+ elif self.by == "feature":
1118
+ query = """
1119
+ SELECT
1120
+ feature_id as group_id,
1121
+ COALESCE(feature_id, 'unlinked') as name,
1122
+ 'feature' as type,
1123
+ COUNT(*) as event_count,
1124
+ SUM(cost_tokens) as total_tokens,
1125
+ MIN(timestamp) as start_time,
1126
+ MAX(timestamp) as end_time
1127
+ FROM agent_events
1128
+ WHERE event_type IN ('tool_call', 'tool_result')
1129
+ AND cost_tokens > 0
1130
+ AND timestamp >= ?
1131
+ GROUP BY feature_id
1132
+ ORDER BY total_tokens DESC
1133
+ LIMIT ?
1134
+ """
1135
+ cursor.execute(query, (time_filter, self.limit))
1136
+
1137
+ elif self.by == "tool":
1138
+ query = """
1139
+ SELECT
1140
+ tool_name as group_id,
1141
+ tool_name as name,
1142
+ 'tool' as type,
1143
+ COUNT(*) as event_count,
1144
+ SUM(cost_tokens) as total_tokens,
1145
+ MIN(timestamp) as start_time,
1146
+ MAX(timestamp) as end_time
1147
+ FROM agent_events
1148
+ WHERE event_type IN ('tool_call', 'tool_result')
1149
+ AND cost_tokens > 0
1150
+ AND timestamp >= ?
1151
+ GROUP BY tool_name
1152
+ ORDER BY total_tokens DESC
1153
+ LIMIT ?
1154
+ """
1155
+ cursor.execute(query, (time_filter, self.limit))
1156
+
1157
+ elif self.by == "agent":
1158
+ query = """
1159
+ SELECT
1160
+ agent as group_id,
1161
+ agent as name,
1162
+ 'agent' as type,
1163
+ COUNT(*) as event_count,
1164
+ SUM(cost_tokens) as total_tokens,
1165
+ MIN(timestamp) as start_time,
1166
+ MAX(timestamp) as end_time
1167
+ FROM agent_events
1168
+ WHERE event_type IN ('tool_call', 'tool_result')
1169
+ AND cost_tokens > 0
1170
+ AND timestamp >= ?
1171
+ GROUP BY agent
1172
+ ORDER BY total_tokens DESC
1173
+ LIMIT ?
1174
+ """
1175
+ cursor.execute(query, (time_filter, self.limit))
1176
+
1177
+ results = []
1178
+ for row in cursor.fetchall():
1179
+ results.append(dict(row))
1180
+
1181
+ conn.close()
1182
+ return results
1183
+
1184
+ def _get_time_filter(self, now: datetime) -> str:
1185
+ """Get ISO format timestamp for time filtering."""
1186
+ from datetime import timedelta
1187
+
1188
+ if self.period == "today":
1189
+ delta = timedelta(hours=24)
1190
+ elif self.period == "day":
1191
+ delta = timedelta(days=1)
1192
+ elif self.period == "week":
1193
+ delta = timedelta(days=7)
1194
+ elif self.period == "month":
1195
+ delta = timedelta(days=30)
1196
+ else: # "all"
1197
+ delta = timedelta(days=36500) # ~100 years
1198
+
1199
+ cutoff = now - delta
1200
+ return cutoff.isoformat()
1201
+
1202
+ def _add_usd_costs(self, cost_data: list[dict]) -> list[dict]:
1203
+ """Add USD cost estimates to cost data."""
1204
+ for item in cost_data:
1205
+ item["cost_usd"] = self._calculate_usd(item["total_tokens"])
1206
+ return cost_data
1207
+
1208
+ def _calculate_usd(self, tokens: int) -> float:
1209
+ """Calculate USD cost from tokens based on model pricing."""
1210
+ # Claude pricing (per 1M tokens):
1211
+ # Opus: $15 input, $45 output
1212
+ # Sonnet: $3 input, $15 output
1213
+ # Haiku: $0.80 input, $4 output
1214
+
1215
+ # Assume ~90% input, 10% output ratio
1216
+ input_ratio = 0.9
1217
+ output_ratio = 0.1
1218
+
1219
+ if self.model == "opus" or (self.model == "auto"):
1220
+ # Default to Opus for conservative estimate
1221
+ input_cost = 15 / 1_000_000
1222
+ output_cost = 45 / 1_000_000
1223
+ elif self.model == "sonnet":
1224
+ input_cost = 3 / 1_000_000
1225
+ output_cost = 15 / 1_000_000
1226
+ elif self.model == "haiku":
1227
+ input_cost = 0.80 / 1_000_000
1228
+ output_cost = 4 / 1_000_000
1229
+ else:
1230
+ # Fallback to Opus
1231
+ input_cost = 15 / 1_000_000
1232
+ output_cost = 45 / 1_000_000
1233
+
1234
+ cost = (tokens * input_ratio * input_cost) + (
1235
+ tokens * output_ratio * output_cost
1236
+ )
1237
+ return cost
1238
+
1239
+ def _display_terminal(self, cost_data: list[dict]) -> None:
1240
+ """Display costs in terminal with rich formatting."""
1241
+ from htmlgraph.cli.base import TableBuilder
1242
+
1243
+ # Period label
1244
+ period_label = self.period.upper()
1245
+ if self.period == "today":
1246
+ period_label = "TODAY"
1247
+ elif self.period == "day":
1248
+ period_label = "LAST 24 HOURS"
1249
+ elif self.period == "week":
1250
+ period_label = "LAST 7 DAYS"
1251
+ elif self.period == "month":
1252
+ period_label = "LAST 30 DAYS"
1253
+
1254
+ console.print(f"\n[bold cyan]{period_label} - COST SUMMARY[/bold cyan]")
1255
+ console.print("[dim]═" * 60 + "[/dim]\n")
1256
+
1257
+ # Build table
1258
+ table_builder = TableBuilder.create_list_table(title=None)
1259
+ table_builder.add_column("Name", style="cyan")
1260
+ table_builder.add_numeric_column("Events", style="green")
1261
+ table_builder.add_numeric_column("Tokens", style="yellow")
1262
+ table_builder.add_numeric_column("Estimated Cost", style="magenta")
1263
+
1264
+ total_tokens = 0
1265
+ total_usd = 0.0
1266
+
1267
+ for item in cost_data:
1268
+ name = item["name"] or "(unlinked)"
1269
+ if len(name) > 30:
1270
+ name = name[:27] + "..."
1271
+
1272
+ events = f"{item['event_count']:,}"
1273
+ tokens = f"{item['total_tokens']:,}"
1274
+ cost_str = f"${item['cost_usd']:.2f}"
1275
+
1276
+ table_builder.add_row(name, events, tokens, cost_str)
1277
+
1278
+ total_tokens += item["total_tokens"]
1279
+ total_usd += item["cost_usd"]
1280
+
1281
+ console.print(table_builder.table)
1282
+
1283
+ # Summary
1284
+ console.print("\n[dim]─" * 60 + "[/dim]")
1285
+ console.print(
1286
+ f"[bold]Total Tokens:[/bold] {total_tokens:,} [dim]({self._format_duration(cost_data)})[/dim]"
1287
+ )
1288
+ console.print(
1289
+ f"[bold]Estimated Cost:[/bold] ${total_usd:.2f} ({self.model.upper() if self.model != 'auto' else 'Opus'})"
1290
+ )
1291
+
1292
+ # Insights
1293
+ if len(cost_data) > 0:
1294
+ top_item = cost_data[0]
1295
+ pct = (
1296
+ (top_item["total_tokens"] / total_tokens * 100)
1297
+ if total_tokens > 0
1298
+ else 0
1299
+ )
1300
+ console.print(
1301
+ f"\n[dim]Most expensive:[/dim] [yellow]{top_item['name']}[/yellow] "
1302
+ f"[dim]({pct:.0f}% of total)[/dim]"
1303
+ )
1304
+
1305
+ def _display_csv(self, cost_data: list[dict]) -> None:
1306
+ """Display costs in CSV format for spreadsheet analysis."""
1307
+ import csv
1308
+ import io
1309
+
1310
+ output = io.StringIO()
1311
+ writer = csv.writer(output)
1312
+
1313
+ # Header
1314
+ if self.by == "session":
1315
+ writer.writerow(["Session ID", "Events", "Tokens", "Estimated Cost (USD)"])
1316
+ else:
1317
+ writer.writerow(
1318
+ [
1319
+ self.by.capitalize(),
1320
+ "Events",
1321
+ "Tokens",
1322
+ "Estimated Cost (USD)",
1323
+ ]
1324
+ )
1325
+
1326
+ # Data rows
1327
+ for item in cost_data:
1328
+ writer.writerow(
1329
+ [
1330
+ item["name"],
1331
+ item["event_count"],
1332
+ item["total_tokens"],
1333
+ f"{item['cost_usd']:.2f}",
1334
+ ]
1335
+ )
1336
+
1337
+ # Totals
1338
+ total_tokens = sum(item["total_tokens"] for item in cost_data)
1339
+ total_usd = sum(item["cost_usd"] for item in cost_data)
1340
+ writer.writerow(["TOTAL", "", total_tokens, f"{total_usd:.2f}"])
1341
+
1342
+ csv_content = output.getvalue()
1343
+ console.print(csv_content)
1344
+
1345
+ def _display_insights(self, cost_data: list[dict]) -> None:
1346
+ """Display cost optimization insights."""
1347
+ if not cost_data:
1348
+ return
1349
+
1350
+ console.print("\n[bold cyan]Insights & Recommendations[/bold cyan]")
1351
+ console.print("[dim]─" * 60 + "[/dim]\n")
1352
+
1353
+ total_tokens = sum(item["total_tokens"] for item in cost_data)
1354
+
1355
+ # Insight 1: Top cost driver
1356
+ top_item = cost_data[0]
1357
+ top_pct = (
1358
+ (top_item["total_tokens"] / total_tokens * 100) if total_tokens > 0 else 0
1359
+ )
1360
+ console.print(
1361
+ f"[blue]→ Highest cost:[/blue] {top_item['name']} "
1362
+ f"[yellow]({top_pct:.0f}% of total)[/yellow]"
1363
+ )
1364
+
1365
+ # Insight 2: Concentration
1366
+ if len(cost_data) > 1:
1367
+ top_3_pct = (
1368
+ sum(item["total_tokens"] for item in cost_data[:3])
1369
+ / (total_tokens if total_tokens > 0 else 1)
1370
+ * 100
1371
+ )
1372
+ console.print(
1373
+ f"[blue]→ Cost concentration:[/blue] Top 3 account for [yellow]{top_3_pct:.0f}%[/yellow]"
1374
+ )
1375
+
1376
+ # Insight 3: Recommendations
1377
+ if self.by == "tool" and top_item["name"] in ["Read", "Bash", "Grep"]:
1378
+ console.print(
1379
+ f"[yellow]→ Tip:[/yellow] {top_item['name']} is expensive. Consider batching operations "
1380
+ "or using more efficient approaches."
1381
+ )
1382
+ elif self.by == "session" and len(cost_data) > 5:
1383
+ console.print(
1384
+ "[yellow]→ Tip:[/yellow] Many sessions with costs. Consider consolidating work "
1385
+ "to fewer, focused sessions."
1386
+ )
1387
+
1388
+ console.print()
1389
+
1390
+ def _format_duration(self, cost_data: list[dict]) -> str:
1391
+ """Format duration from start/end times."""
1392
+ if not cost_data or "start_time" not in cost_data[0]:
1393
+ return "unknown"
1394
+
1395
+ try:
1396
+ from datetime import datetime
1397
+
1398
+ start_times = [
1399
+ datetime.fromisoformat(item["start_time"])
1400
+ for item in cost_data
1401
+ if item.get("start_time")
1402
+ ]
1403
+ end_times = [
1404
+ datetime.fromisoformat(item["end_time"])
1405
+ for item in cost_data
1406
+ if item.get("end_time")
1407
+ ]
1408
+
1409
+ if not start_times or not end_times:
1410
+ return "unknown"
1411
+
1412
+ earliest = min(start_times)
1413
+ latest = max(end_times)
1414
+ duration = latest - earliest
1415
+
1416
+ hours = duration.total_seconds() / 3600
1417
+ if hours > 1:
1418
+ return f"{hours:.1f}h"
1419
+ else:
1420
+ minutes = duration.total_seconds() / 60
1421
+ return f"{minutes:.0f}m"
1422
+ except Exception:
1423
+ return "unknown"
htmlgraph/cli/core.py CHANGED
@@ -37,6 +37,22 @@ def register_commands(subparsers: _SubParsersAction) -> None:
37
37
  Args:
38
38
  subparsers: Subparser action from ArgumentParser.add_subparsers()
39
39
  """
40
+ # bootstrap
41
+ bootstrap_parser = subparsers.add_parser(
42
+ "bootstrap", help="One-command setup: Initialize HtmlGraph in under 60 seconds"
43
+ )
44
+ bootstrap_parser.add_argument(
45
+ "--project-path",
46
+ default=".",
47
+ help="Directory to bootstrap (default: current directory)",
48
+ )
49
+ bootstrap_parser.add_argument(
50
+ "--no-plugins",
51
+ action="store_true",
52
+ help="Skip plugin installation",
53
+ )
54
+ bootstrap_parser.set_defaults(func=BootstrapCommand.from_args)
55
+
40
56
  # serve
41
57
  serve_parser = subparsers.add_parser("serve", help="Start the HtmlGraph server")
42
58
  serve_parser.add_argument(
@@ -854,3 +870,84 @@ class InstallHooksCommand(BaseCommand):
854
870
  },
855
871
  },
856
872
  )
873
+
874
+
875
+ class BootstrapCommand(BaseCommand):
876
+ """Bootstrap HtmlGraph in under 60 seconds."""
877
+
878
+ def __init__(self, *, project_path: str, no_plugins: bool) -> None:
879
+ super().__init__()
880
+ self.project_path = project_path
881
+ self.no_plugins = no_plugins
882
+
883
+ @classmethod
884
+ def from_args(cls, args: argparse.Namespace) -> BootstrapCommand:
885
+ return cls(
886
+ project_path=args.project_path,
887
+ no_plugins=args.no_plugins,
888
+ )
889
+
890
+ def execute(self) -> CommandResult:
891
+ """Bootstrap HtmlGraph setup."""
892
+ from rich.console import Console
893
+ from rich.panel import Panel
894
+
895
+ from htmlgraph.cli.models import BootstrapConfig
896
+ from htmlgraph.operations.bootstrap import bootstrap_htmlgraph
897
+
898
+ console = Console()
899
+
900
+ # Create config
901
+ config = BootstrapConfig(
902
+ project_path=self.project_path,
903
+ no_plugins=self.no_plugins,
904
+ )
905
+
906
+ # Run bootstrap
907
+ console.print()
908
+ console.print("[bold cyan]Bootstrapping HtmlGraph...[/bold cyan]")
909
+ console.print()
910
+
911
+ result = bootstrap_htmlgraph(config)
912
+
913
+ if not result["success"]:
914
+ raise CommandError(result.get("message", "Bootstrap failed"))
915
+
916
+ # Display success message
917
+ console.print()
918
+ console.print(
919
+ Panel.fit(
920
+ "[bold green]✓ HtmlGraph initialized successfully![/bold green]",
921
+ border_style="green",
922
+ )
923
+ )
924
+ console.print()
925
+
926
+ # Show project info
927
+ console.print(f"[cyan]Project type:[/cyan] {result['project_type']}")
928
+ console.print(f"[cyan]Location:[/cyan] {result['graph_dir']}")
929
+ console.print()
930
+
931
+ # Show next steps
932
+ console.print("[bold yellow]Next steps:[/bold yellow]")
933
+ for step in result["next_steps"]:
934
+ console.print(f" {step}")
935
+ console.print()
936
+
937
+ # Show documentation link
938
+ console.print(
939
+ "[dim]📚 Learn more: https://github.com/Shakes-tzd/htmlgraph[/dim]"
940
+ )
941
+ console.print()
942
+
943
+ return CommandResult(
944
+ text="Bootstrap completed successfully",
945
+ json_data={
946
+ "project_type": result["project_type"],
947
+ "graph_dir": result["graph_dir"],
948
+ "directories_created": len(result["directories_created"]),
949
+ "files_created": len(result["files_created"]),
950
+ "has_claude": result["has_claude"],
951
+ "plugin_installed": result["plugin_installed"],
952
+ },
953
+ )
htmlgraph/cli/main.py CHANGED
@@ -28,6 +28,9 @@ def create_parser() -> argparse.ArgumentParser:
28
28
  description="HtmlGraph - HTML is All You Need",
29
29
  formatter_class=argparse.RawDescriptionHelpFormatter,
30
30
  epilog="""
31
+ Quick Start:
32
+ htmlgraph bootstrap # One-command setup (< 60 seconds)
33
+
31
34
  Examples:
32
35
  htmlgraph init # Initialize .htmlgraph in current dir
33
36
  htmlgraph serve # Start server on port 8080
htmlgraph/cli/models.py CHANGED
@@ -460,3 +460,15 @@ class TrackDisplay(BaseModel):
460
460
  priority_order = {"high": 0, "medium": 1, "low": 2}
461
461
  priority_rank = priority_order.get(self.priority, 99)
462
462
  return (priority_rank, self.id)
463
+
464
+
465
+ class BootstrapConfig(BaseModel):
466
+ """Configuration for htmlgraph bootstrap command.
467
+
468
+ Attributes:
469
+ project_path: Directory to bootstrap (default: .)
470
+ no_plugins: Skip plugin installation
471
+ """
472
+
473
+ project_path: str = Field(default=".")
474
+ no_plugins: bool = Field(default=False)
@@ -29,6 +29,7 @@ def register_commands(subparsers: _SubParsersAction) -> None:
29
29
  register_claude_commands,
30
30
  register_orchestrator_commands,
31
31
  )
32
+ from htmlgraph.cli.work.report import register_report_commands
32
33
  from htmlgraph.cli.work.sessions import register_session_commands
33
34
  from htmlgraph.cli.work.tracks import register_track_commands
34
35
 
@@ -39,6 +40,7 @@ def register_commands(subparsers: _SubParsersAction) -> None:
39
40
  register_archive_commands(subparsers)
40
41
  register_orchestrator_commands(subparsers)
41
42
  register_claude_commands(subparsers)
43
+ register_report_commands(subparsers)
42
44
 
43
45
 
44
46
  # Re-export all command classes for backward compatibility
@@ -61,6 +63,7 @@ from htmlgraph.cli.work.orchestration import (
61
63
  OrchestratorSetLevelCommand,
62
64
  OrchestratorStatusCommand,
63
65
  )
66
+ from htmlgraph.cli.work.report import SessionReportCommand
64
67
  from htmlgraph.cli.work.sessions import (
65
68
  SessionEndCommand,
66
69
  SessionHandoffCommand,
@@ -84,6 +87,8 @@ __all__ = [
84
87
  "SessionListCommand",
85
88
  "SessionHandoffCommand",
86
89
  "SessionStartInfoCommand",
90
+ # Report commands
91
+ "SessionReportCommand",
87
92
  # Feature commands
88
93
  "FeatureListCommand",
89
94
  "FeatureCreateCommand",