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 +6 -1
- htmlgraph/cli/analytics.py +465 -0
- htmlgraph/cli/core.py +97 -0
- htmlgraph/cli/main.py +3 -0
- htmlgraph/cli/models.py +12 -0
- htmlgraph/cli/work/__init__.py +5 -0
- htmlgraph/cli/work/report.py +727 -0
- htmlgraph/decorators.py +317 -0
- htmlgraph/hooks/subagent_stop.py +65 -6
- htmlgraph/operations/bootstrap.py +288 -0
- {htmlgraph-0.26.21.dist-info → htmlgraph-0.26.23.dist-info}/METADATA +1 -1
- {htmlgraph-0.26.21.dist-info → htmlgraph-0.26.23.dist-info}/RECORD +19 -16
- {htmlgraph-0.26.21.data → htmlgraph-0.26.23.data}/data/htmlgraph/dashboard.html +0 -0
- {htmlgraph-0.26.21.data → htmlgraph-0.26.23.data}/data/htmlgraph/styles.css +0 -0
- {htmlgraph-0.26.21.data → htmlgraph-0.26.23.data}/data/htmlgraph/templates/AGENTS.md.template +0 -0
- {htmlgraph-0.26.21.data → htmlgraph-0.26.23.data}/data/htmlgraph/templates/CLAUDE.md.template +0 -0
- {htmlgraph-0.26.21.data → htmlgraph-0.26.23.data}/data/htmlgraph/templates/GEMINI.md.template +0 -0
- {htmlgraph-0.26.21.dist-info → htmlgraph-0.26.23.dist-info}/WHEEL +0 -0
- {htmlgraph-0.26.21.dist-info → htmlgraph-0.26.23.dist-info}/entry_points.txt +0 -0
htmlgraph/decorators.py
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
"""Decorators for function enhancement and cross-cutting concerns.
|
|
2
|
+
|
|
3
|
+
This module provides decorators for common patterns like retry logic with
|
|
4
|
+
exponential backoff, caching, timing, and error handling.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import functools
|
|
8
|
+
import logging
|
|
9
|
+
import random
|
|
10
|
+
import time
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from typing import Any, TypeVar
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
T = TypeVar("T")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class RetryError(Exception):
|
|
20
|
+
"""Raised when a function exhausts all retry attempts."""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
function_name: str,
|
|
25
|
+
attempts: int,
|
|
26
|
+
last_exception: Exception,
|
|
27
|
+
):
|
|
28
|
+
self.function_name = function_name
|
|
29
|
+
self.attempts = attempts
|
|
30
|
+
self.last_exception = last_exception
|
|
31
|
+
super().__init__(
|
|
32
|
+
f"Function '{function_name}' failed after {attempts} attempts. "
|
|
33
|
+
f"Last error: {last_exception}"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def retry(
|
|
38
|
+
max_attempts: int = 3,
|
|
39
|
+
initial_delay: float = 1.0,
|
|
40
|
+
max_delay: float = 60.0,
|
|
41
|
+
exponential_base: float = 2.0,
|
|
42
|
+
jitter: bool = True,
|
|
43
|
+
exceptions: tuple[type[Exception], ...] = (Exception,),
|
|
44
|
+
on_retry: Callable[[int, Exception, float], None] | None = None,
|
|
45
|
+
) -> Callable[[Callable[..., T]], Callable[..., T]]:
|
|
46
|
+
"""Decorator adding retry logic with exponential backoff to any function.
|
|
47
|
+
|
|
48
|
+
Implements exponential backoff with optional jitter to gracefully handle
|
|
49
|
+
transient failures. Useful for I/O operations, API calls, and distributed
|
|
50
|
+
system interactions.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
max_attempts: Maximum number of attempts (default: 3). Must be >= 1.
|
|
54
|
+
initial_delay: Initial delay in seconds before first retry (default: 1.0).
|
|
55
|
+
Must be >= 0.
|
|
56
|
+
max_delay: Maximum delay in seconds between retries (default: 60.0).
|
|
57
|
+
Caps the exponential backoff. Must be >= initial_delay.
|
|
58
|
+
exponential_base: Base for exponential backoff calculation (default: 2.0).
|
|
59
|
+
delay = min(initial_delay * (base ** attempt_number), max_delay)
|
|
60
|
+
jitter: Whether to add random jitter to delays (default: True).
|
|
61
|
+
Helps prevent thundering herd problem in distributed systems.
|
|
62
|
+
exceptions: Tuple of exception types to catch and retry on
|
|
63
|
+
(default: (Exception,)). Other exceptions propagate immediately.
|
|
64
|
+
on_retry: Optional callback invoked on each retry with signature:
|
|
65
|
+
on_retry(attempt_number, exception, delay_seconds).
|
|
66
|
+
Useful for logging, metrics, or custom backoff strategies.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
Decorated function that retries on specified exceptions.
|
|
70
|
+
|
|
71
|
+
Raises:
|
|
72
|
+
RetryError: If all retry attempts are exhausted.
|
|
73
|
+
Other exceptions: If exception type is not in the retry list.
|
|
74
|
+
|
|
75
|
+
Examples:
|
|
76
|
+
Basic retry with default parameters:
|
|
77
|
+
>>> @retry()
|
|
78
|
+
... def unstable_api_call():
|
|
79
|
+
... response = requests.get('https://api.example.com/data')
|
|
80
|
+
... response.raise_for_status()
|
|
81
|
+
... return response.json()
|
|
82
|
+
|
|
83
|
+
Retry with custom parameters:
|
|
84
|
+
>>> @retry(
|
|
85
|
+
... max_attempts=5,
|
|
86
|
+
... initial_delay=0.5,
|
|
87
|
+
... max_delay=30.0,
|
|
88
|
+
... exponential_base=1.5,
|
|
89
|
+
... exceptions=(ConnectionError, TimeoutError),
|
|
90
|
+
... )
|
|
91
|
+
... def fetch_with_timeout():
|
|
92
|
+
... return expensive_io_operation()
|
|
93
|
+
|
|
94
|
+
With custom retry callback for logging:
|
|
95
|
+
>>> def log_retry(attempt, exc, delay):
|
|
96
|
+
... logger.warning(
|
|
97
|
+
... f"Retry attempt {attempt} after {delay}s: {exc}"
|
|
98
|
+
... )
|
|
99
|
+
>>> @retry(
|
|
100
|
+
... max_attempts=3,
|
|
101
|
+
... on_retry=log_retry,
|
|
102
|
+
... exceptions=(IOError,),
|
|
103
|
+
... )
|
|
104
|
+
... def read_file(path):
|
|
105
|
+
... with open(path) as f:
|
|
106
|
+
... return f.read()
|
|
107
|
+
|
|
108
|
+
Retry only specific exceptions (fail fast for others):
|
|
109
|
+
>>> @retry(
|
|
110
|
+
... max_attempts=3,
|
|
111
|
+
... exceptions=(ConnectionError, TimeoutError),
|
|
112
|
+
... )
|
|
113
|
+
... def resilient_request(url):
|
|
114
|
+
... # Will retry on connection errors but fail immediately on 404
|
|
115
|
+
... return requests.get(url, timeout=5).json()
|
|
116
|
+
|
|
117
|
+
Using with async functions:
|
|
118
|
+
>>> import asyncio
|
|
119
|
+
>>> @retry(max_attempts=3, initial_delay=0.1)
|
|
120
|
+
... async def async_api_call():
|
|
121
|
+
... async with aiohttp.ClientSession() as session:
|
|
122
|
+
... async with session.get('https://api.example.com') as resp:
|
|
123
|
+
... return await resp.json()
|
|
124
|
+
>>> asyncio.run(async_api_call())
|
|
125
|
+
|
|
126
|
+
Backoff Calculation:
|
|
127
|
+
The delay before retry N is calculated as:
|
|
128
|
+
- exponential: initial_delay * (exponential_base ** (attempt - 1))
|
|
129
|
+
- capped: min(exponential, max_delay)
|
|
130
|
+
- jittered: delay * (0.5 + random(0.0, 1.0)) if jitter=True
|
|
131
|
+
|
|
132
|
+
Example with exponential_base=2.0, initial_delay=1.0, max_delay=60.0:
|
|
133
|
+
- Attempt 1 fails, retry after: 1s
|
|
134
|
+
- Attempt 2 fails, retry after: 2s
|
|
135
|
+
- Attempt 3 fails, retry after: 4s
|
|
136
|
+
- Attempt 4 fails, retry after: 8s
|
|
137
|
+
- Attempt 5 fails, retry after: 16s
|
|
138
|
+
- Attempt 6 fails, retry after: 32s
|
|
139
|
+
- Attempt 7 fails, raise RetryError (max_attempts=3 means 3 total attempts)
|
|
140
|
+
|
|
141
|
+
Notes:
|
|
142
|
+
- If max_attempts=1, no retries occur (function runs once)
|
|
143
|
+
- Jitter is uniformly distributed in range [0.5 * delay, 1.5 * delay]
|
|
144
|
+
- Callbacks (on_retry) are invoked BEFORE sleeping, not after
|
|
145
|
+
- Thread-safe but not async-safe without adaptation
|
|
146
|
+
"""
|
|
147
|
+
if max_attempts < 1:
|
|
148
|
+
raise ValueError("max_attempts must be >= 1")
|
|
149
|
+
if initial_delay < 0:
|
|
150
|
+
raise ValueError("initial_delay must be >= 0")
|
|
151
|
+
if max_delay < initial_delay:
|
|
152
|
+
raise ValueError("max_delay must be >= initial_delay")
|
|
153
|
+
if exponential_base <= 0:
|
|
154
|
+
raise ValueError("exponential_base must be > 0")
|
|
155
|
+
|
|
156
|
+
def decorator(func: Callable[..., T]) -> Callable[..., T]:
|
|
157
|
+
@functools.wraps(func)
|
|
158
|
+
def wrapper(*args: Any, **kwargs: Any) -> T:
|
|
159
|
+
last_exception: Exception | None = None
|
|
160
|
+
|
|
161
|
+
for attempt in range(1, max_attempts + 1):
|
|
162
|
+
try:
|
|
163
|
+
return func(*args, **kwargs)
|
|
164
|
+
except exceptions as e:
|
|
165
|
+
last_exception = e
|
|
166
|
+
|
|
167
|
+
if attempt == max_attempts:
|
|
168
|
+
# Last attempt failed, raise RetryError
|
|
169
|
+
raise RetryError(
|
|
170
|
+
function_name=func.__name__,
|
|
171
|
+
attempts=max_attempts,
|
|
172
|
+
last_exception=e,
|
|
173
|
+
) from e
|
|
174
|
+
|
|
175
|
+
# Calculate backoff with exponential growth and jitter
|
|
176
|
+
exponential_delay = initial_delay * (
|
|
177
|
+
exponential_base ** (attempt - 1)
|
|
178
|
+
)
|
|
179
|
+
delay = min(exponential_delay, max_delay)
|
|
180
|
+
|
|
181
|
+
if jitter:
|
|
182
|
+
# Add jitter: multiply by random value in [0.5, 1.5]
|
|
183
|
+
delay *= 0.5 + random.random()
|
|
184
|
+
|
|
185
|
+
# Invoke callback before sleeping
|
|
186
|
+
if on_retry is not None:
|
|
187
|
+
on_retry(attempt, e, delay)
|
|
188
|
+
else:
|
|
189
|
+
logger.debug(
|
|
190
|
+
f"Retry attempt {attempt}/{max_attempts} for "
|
|
191
|
+
f"{func.__name__} after {delay:.2f}s: {e}"
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
time.sleep(delay)
|
|
195
|
+
|
|
196
|
+
# This should never be reached, but satisfy type checker
|
|
197
|
+
assert last_exception is not None
|
|
198
|
+
raise RetryError(
|
|
199
|
+
function_name=func.__name__,
|
|
200
|
+
attempts=max_attempts,
|
|
201
|
+
last_exception=last_exception,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
return wrapper
|
|
205
|
+
|
|
206
|
+
return decorator
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def retry_async(
|
|
210
|
+
max_attempts: int = 3,
|
|
211
|
+
initial_delay: float = 1.0,
|
|
212
|
+
max_delay: float = 60.0,
|
|
213
|
+
exponential_base: float = 2.0,
|
|
214
|
+
jitter: bool = True,
|
|
215
|
+
exceptions: tuple[type[Exception], ...] = (Exception,),
|
|
216
|
+
on_retry: Callable[[int, Exception, float], None] | None = None,
|
|
217
|
+
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
218
|
+
"""Async version of retry decorator with exponential backoff.
|
|
219
|
+
|
|
220
|
+
Identical to retry() but uses asyncio.sleep instead of time.sleep,
|
|
221
|
+
allowing it to be used with async/await functions without blocking.
|
|
222
|
+
|
|
223
|
+
Args:
|
|
224
|
+
max_attempts: Maximum number of attempts (default: 3). Must be >= 1.
|
|
225
|
+
initial_delay: Initial delay in seconds before first retry (default: 1.0).
|
|
226
|
+
max_delay: Maximum delay in seconds between retries (default: 60.0).
|
|
227
|
+
exponential_base: Base for exponential backoff (default: 2.0).
|
|
228
|
+
jitter: Whether to add random jitter to delays (default: True).
|
|
229
|
+
exceptions: Tuple of exception types to catch and retry on.
|
|
230
|
+
on_retry: Optional callback invoked on each retry.
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
Decorated async function that retries on specified exceptions.
|
|
234
|
+
|
|
235
|
+
Raises:
|
|
236
|
+
RetryError: If all retry attempts are exhausted.
|
|
237
|
+
|
|
238
|
+
Examples:
|
|
239
|
+
>>> import asyncio
|
|
240
|
+
>>> @retry_async(max_attempts=3)
|
|
241
|
+
... async def fetch_data():
|
|
242
|
+
... async with aiohttp.ClientSession() as session:
|
|
243
|
+
... async with session.get('https://api.example.com') as resp:
|
|
244
|
+
... return await resp.json()
|
|
245
|
+
|
|
246
|
+
>>> @retry_async(
|
|
247
|
+
... max_attempts=5,
|
|
248
|
+
... initial_delay=0.1,
|
|
249
|
+
... exceptions=(asyncio.TimeoutError, ConnectionError),
|
|
250
|
+
... )
|
|
251
|
+
... async def resilient_query():
|
|
252
|
+
... return await db.query("SELECT * FROM users")
|
|
253
|
+
"""
|
|
254
|
+
if max_attempts < 1:
|
|
255
|
+
raise ValueError("max_attempts must be >= 1")
|
|
256
|
+
if initial_delay < 0:
|
|
257
|
+
raise ValueError("initial_delay must be >= 0")
|
|
258
|
+
if max_delay < initial_delay:
|
|
259
|
+
raise ValueError("max_delay must be >= initial_delay")
|
|
260
|
+
if exponential_base <= 0:
|
|
261
|
+
raise ValueError("exponential_base must be > 0")
|
|
262
|
+
|
|
263
|
+
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
264
|
+
@functools.wraps(func)
|
|
265
|
+
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
266
|
+
import asyncio
|
|
267
|
+
|
|
268
|
+
last_exception: Exception | None = None
|
|
269
|
+
|
|
270
|
+
for attempt in range(1, max_attempts + 1):
|
|
271
|
+
try:
|
|
272
|
+
return await func(*args, **kwargs)
|
|
273
|
+
except exceptions as e:
|
|
274
|
+
last_exception = e
|
|
275
|
+
|
|
276
|
+
if attempt == max_attempts:
|
|
277
|
+
raise RetryError(
|
|
278
|
+
function_name=func.__name__,
|
|
279
|
+
attempts=max_attempts,
|
|
280
|
+
last_exception=e,
|
|
281
|
+
) from e
|
|
282
|
+
|
|
283
|
+
exponential_delay = initial_delay * (
|
|
284
|
+
exponential_base ** (attempt - 1)
|
|
285
|
+
)
|
|
286
|
+
delay = min(exponential_delay, max_delay)
|
|
287
|
+
|
|
288
|
+
if jitter:
|
|
289
|
+
delay *= 0.5 + random.random()
|
|
290
|
+
|
|
291
|
+
if on_retry is not None:
|
|
292
|
+
on_retry(attempt, e, delay)
|
|
293
|
+
else:
|
|
294
|
+
logger.debug(
|
|
295
|
+
f"Retry attempt {attempt}/{max_attempts} for "
|
|
296
|
+
f"{func.__name__} after {delay:.2f}s: {e}"
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
await asyncio.sleep(delay)
|
|
300
|
+
|
|
301
|
+
assert last_exception is not None
|
|
302
|
+
raise RetryError(
|
|
303
|
+
function_name=func.__name__,
|
|
304
|
+
attempts=max_attempts,
|
|
305
|
+
last_exception=last_exception,
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
return wrapper
|
|
309
|
+
|
|
310
|
+
return decorator
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
__all__ = [
|
|
314
|
+
"retry",
|
|
315
|
+
"retry_async",
|
|
316
|
+
"RetryError",
|
|
317
|
+
]
|
htmlgraph/hooks/subagent_stop.py
CHANGED
|
@@ -204,6 +204,52 @@ def get_parent_event_start_time(db_path: str, parent_event_id: str) -> str | Non
|
|
|
204
204
|
return None
|
|
205
205
|
|
|
206
206
|
|
|
207
|
+
def get_parent_event_from_db(db_path: str) -> str | None:
|
|
208
|
+
"""
|
|
209
|
+
Query database for the most recent task_delegation event.
|
|
210
|
+
|
|
211
|
+
Used when HTMLGRAPH_PARENT_EVENT environment variable is not available
|
|
212
|
+
(due to inter-process communication limitations).
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
db_path: Path to SQLite database
|
|
216
|
+
|
|
217
|
+
Returns:
|
|
218
|
+
Parent event ID (evt-XXXXX) or None if not found
|
|
219
|
+
"""
|
|
220
|
+
try:
|
|
221
|
+
conn = sqlite3.connect(db_path)
|
|
222
|
+
cursor = conn.cursor()
|
|
223
|
+
|
|
224
|
+
# Query for the most recent task_delegation with status='started'
|
|
225
|
+
# This is the task that spawned the current subagent
|
|
226
|
+
query = """
|
|
227
|
+
SELECT event_id FROM agent_events
|
|
228
|
+
WHERE event_type = 'task_delegation'
|
|
229
|
+
AND status = 'started'
|
|
230
|
+
ORDER BY timestamp DESC
|
|
231
|
+
LIMIT 1
|
|
232
|
+
"""
|
|
233
|
+
|
|
234
|
+
cursor.execute(query)
|
|
235
|
+
result = cursor.fetchone()
|
|
236
|
+
conn.close()
|
|
237
|
+
|
|
238
|
+
if result:
|
|
239
|
+
parent_event_id: str = result[0]
|
|
240
|
+
logger.debug(
|
|
241
|
+
f"Found parent task_delegation from database: {parent_event_id}"
|
|
242
|
+
)
|
|
243
|
+
return parent_event_id
|
|
244
|
+
|
|
245
|
+
logger.debug("No active task_delegation found in database")
|
|
246
|
+
return None
|
|
247
|
+
|
|
248
|
+
except Exception as e:
|
|
249
|
+
logger.warning(f"Error querying for parent event: {e}")
|
|
250
|
+
return None
|
|
251
|
+
|
|
252
|
+
|
|
207
253
|
def handle_subagent_stop(hook_input: dict[str, Any]) -> dict[str, Any]:
|
|
208
254
|
"""
|
|
209
255
|
Handle SubagentStop hook event.
|
|
@@ -222,14 +268,13 @@ def handle_subagent_stop(hook_input: dict[str, Any]) -> dict[str, Any]:
|
|
|
222
268
|
Returns:
|
|
223
269
|
Response: {"continue": True} with optional context
|
|
224
270
|
"""
|
|
225
|
-
#
|
|
271
|
+
# Try to get parent event ID from environment (set by PreToolUse hook)
|
|
226
272
|
parent_event_id = get_parent_event_id()
|
|
227
273
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
# Get project directory and database path
|
|
274
|
+
# If not available in environment, query database
|
|
275
|
+
# (environment variables may not be inherited across subagent process boundary)
|
|
276
|
+
# Get project directory and database path (reuse for both env and db lookup)
|
|
277
|
+
db_path = None
|
|
233
278
|
try:
|
|
234
279
|
from htmlgraph.config import get_database_path
|
|
235
280
|
|
|
@@ -244,6 +289,20 @@ def handle_subagent_stop(hook_input: dict[str, Any]) -> dict[str, Any]:
|
|
|
244
289
|
logger.warning(f"Error resolving database path: {e}")
|
|
245
290
|
return {"continue": True}
|
|
246
291
|
|
|
292
|
+
# If parent event ID not in environment, query database
|
|
293
|
+
if not parent_event_id:
|
|
294
|
+
logger.debug("Parent event ID not in environment, querying database...")
|
|
295
|
+
try:
|
|
296
|
+
parent_event_id = get_parent_event_from_db(db_path)
|
|
297
|
+
except Exception as e:
|
|
298
|
+
logger.debug(f"Could not query database for parent event: {e}")
|
|
299
|
+
|
|
300
|
+
if not parent_event_id:
|
|
301
|
+
logger.debug(
|
|
302
|
+
"No parent event ID found (env or db), skipping subagent stop tracking"
|
|
303
|
+
)
|
|
304
|
+
return {"continue": True}
|
|
305
|
+
|
|
247
306
|
# Get parent event start time
|
|
248
307
|
parent_start_time = get_parent_event_start_time(db_path, parent_event_id)
|
|
249
308
|
if not parent_start_time:
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"""HtmlGraph bootstrap operations.
|
|
2
|
+
|
|
3
|
+
One-command setup to go from installation to first value in under 60 seconds.
|
|
4
|
+
This module provides functions for bootstrapping a project with HtmlGraph.
|
|
5
|
+
|
|
6
|
+
The bootstrap process includes:
|
|
7
|
+
1. Auto-detecting project type (Python, Node, etc.)
|
|
8
|
+
2. Creating .htmlgraph directory structure
|
|
9
|
+
3. Initializing database with schema
|
|
10
|
+
4. Installing Claude Code plugin hooks automatically
|
|
11
|
+
5. Printing next steps for the user
|
|
12
|
+
|
|
13
|
+
This is designed for simplicity and speed - the minimal viable setup.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import subprocess
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import TYPE_CHECKING, Any
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from htmlgraph.cli.models import BootstrapConfig
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def detect_project_type(project_dir: Path) -> str:
|
|
28
|
+
"""
|
|
29
|
+
Auto-detect project type from files in directory.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
project_dir: Project directory to inspect
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
Detected project type: "python", "node", "multi", or "unknown"
|
|
36
|
+
"""
|
|
37
|
+
# Check for Python project markers
|
|
38
|
+
has_python = any(
|
|
39
|
+
[
|
|
40
|
+
(project_dir / "pyproject.toml").exists(),
|
|
41
|
+
(project_dir / "setup.py").exists(),
|
|
42
|
+
(project_dir / "requirements.txt").exists(),
|
|
43
|
+
(project_dir / "Pipfile").exists(),
|
|
44
|
+
]
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
# Check for Node project markers
|
|
48
|
+
has_node = (project_dir / "package.json").exists()
|
|
49
|
+
|
|
50
|
+
# Determine project type
|
|
51
|
+
if has_python and has_node:
|
|
52
|
+
return "multi"
|
|
53
|
+
elif has_python:
|
|
54
|
+
return "python"
|
|
55
|
+
elif has_node:
|
|
56
|
+
return "node"
|
|
57
|
+
else:
|
|
58
|
+
return "unknown"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def create_gitignore_template() -> str:
|
|
62
|
+
"""
|
|
63
|
+
Create .gitignore template content for .htmlgraph directory.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
Gitignore template content
|
|
67
|
+
"""
|
|
68
|
+
return """# HtmlGraph cache and regenerable files
|
|
69
|
+
.htmlgraph/htmlgraph.db
|
|
70
|
+
.htmlgraph/sessions/*.jsonl
|
|
71
|
+
.htmlgraph/events/*.jsonl
|
|
72
|
+
.htmlgraph/parent-activity.json
|
|
73
|
+
.htmlgraph/logs/
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def check_already_initialized(project_dir: Path) -> bool:
|
|
78
|
+
"""
|
|
79
|
+
Check if project is already initialized with HtmlGraph.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
project_dir: Project directory to check
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
True if already initialized, False otherwise
|
|
86
|
+
"""
|
|
87
|
+
graph_dir = project_dir / ".htmlgraph"
|
|
88
|
+
return graph_dir.exists()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def create_bootstrap_structure(project_dir: Path) -> dict[str, list[str]]:
|
|
92
|
+
"""
|
|
93
|
+
Create minimal .htmlgraph directory structure for bootstrap.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
project_dir: Project directory
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
Dictionary with lists of created directories and files
|
|
100
|
+
"""
|
|
101
|
+
graph_dir = project_dir / ".htmlgraph"
|
|
102
|
+
created_dirs: list[str] = []
|
|
103
|
+
created_files: list[str] = []
|
|
104
|
+
|
|
105
|
+
# Create main .htmlgraph directory
|
|
106
|
+
if not graph_dir.exists():
|
|
107
|
+
graph_dir.mkdir(parents=True)
|
|
108
|
+
created_dirs.append(str(graph_dir))
|
|
109
|
+
|
|
110
|
+
# Create subdirectories
|
|
111
|
+
subdirs = [
|
|
112
|
+
"sessions",
|
|
113
|
+
"features",
|
|
114
|
+
"spikes",
|
|
115
|
+
"tracks",
|
|
116
|
+
"events",
|
|
117
|
+
"logs",
|
|
118
|
+
"logs/errors",
|
|
119
|
+
]
|
|
120
|
+
|
|
121
|
+
for subdir in subdirs:
|
|
122
|
+
subdir_path = graph_dir / subdir
|
|
123
|
+
if not subdir_path.exists():
|
|
124
|
+
subdir_path.mkdir(parents=True)
|
|
125
|
+
created_dirs.append(str(subdir_path))
|
|
126
|
+
|
|
127
|
+
# Create .gitignore in .htmlgraph
|
|
128
|
+
gitignore = graph_dir / ".gitignore"
|
|
129
|
+
if not gitignore.exists():
|
|
130
|
+
gitignore.write_text(create_gitignore_template())
|
|
131
|
+
created_files.append(str(gitignore))
|
|
132
|
+
|
|
133
|
+
# Create config.json
|
|
134
|
+
config_file = graph_dir / "config.json"
|
|
135
|
+
if not config_file.exists():
|
|
136
|
+
config_data = {
|
|
137
|
+
"bootstrapped": True,
|
|
138
|
+
"version": "1.0",
|
|
139
|
+
}
|
|
140
|
+
config_file.write_text(json.dumps(config_data, indent=2) + "\n")
|
|
141
|
+
created_files.append(str(config_file))
|
|
142
|
+
|
|
143
|
+
return {"directories": created_dirs, "files": created_files}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def initialize_database(graph_dir: Path) -> str:
|
|
147
|
+
"""
|
|
148
|
+
Initialize HtmlGraph database with schema.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
graph_dir: Path to .htmlgraph directory
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
Path to created database file
|
|
155
|
+
"""
|
|
156
|
+
from htmlgraph.db.schema import HtmlGraphDB
|
|
157
|
+
|
|
158
|
+
db_path = graph_dir / "htmlgraph.db"
|
|
159
|
+
|
|
160
|
+
# Create database using HtmlGraphDB (auto-creates tables)
|
|
161
|
+
db = HtmlGraphDB(db_path=str(db_path))
|
|
162
|
+
db.disconnect()
|
|
163
|
+
|
|
164
|
+
return str(db_path)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def check_claude_code_available() -> bool:
|
|
168
|
+
"""
|
|
169
|
+
Check if Claude Code CLI is available.
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
True if claude command is available, False otherwise
|
|
173
|
+
"""
|
|
174
|
+
try:
|
|
175
|
+
result = subprocess.run(
|
|
176
|
+
["claude", "--version"],
|
|
177
|
+
capture_output=True,
|
|
178
|
+
check=False,
|
|
179
|
+
timeout=5,
|
|
180
|
+
)
|
|
181
|
+
return result.returncode == 0
|
|
182
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
183
|
+
return False
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def get_next_steps(
|
|
187
|
+
project_type: str, has_claude: bool, plugin_installed: bool
|
|
188
|
+
) -> list[str]:
|
|
189
|
+
"""
|
|
190
|
+
Generate next steps message based on project state.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
project_type: Detected project type
|
|
194
|
+
has_claude: Whether Claude Code CLI is available
|
|
195
|
+
plugin_installed: Whether plugin hooks were installed
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
List of next step messages
|
|
199
|
+
"""
|
|
200
|
+
steps = []
|
|
201
|
+
|
|
202
|
+
if has_claude:
|
|
203
|
+
if plugin_installed:
|
|
204
|
+
steps.append("1. Use Claude Code: Run 'claude --dev' in this project")
|
|
205
|
+
else:
|
|
206
|
+
steps.append(
|
|
207
|
+
"1. Install HtmlGraph plugin: Run 'claude plugin install htmlgraph'"
|
|
208
|
+
)
|
|
209
|
+
steps.append("2. Use Claude Code: Run 'claude --dev' in this project")
|
|
210
|
+
else:
|
|
211
|
+
steps.append(
|
|
212
|
+
"1. Install Claude Code CLI: Visit https://code.claude.com/docs/installation"
|
|
213
|
+
)
|
|
214
|
+
steps.append(
|
|
215
|
+
"2. Install HtmlGraph plugin: Run 'claude plugin install htmlgraph'"
|
|
216
|
+
)
|
|
217
|
+
steps.append("3. Use Claude Code: Run 'claude --dev' in this project")
|
|
218
|
+
|
|
219
|
+
steps.append(
|
|
220
|
+
f"{len(steps) + 1}. Track work: Create features with 'htmlgraph feature create \"Title\"'"
|
|
221
|
+
)
|
|
222
|
+
steps.append(f"{len(steps) + 1}. View progress: Run 'htmlgraph status'")
|
|
223
|
+
steps.append(
|
|
224
|
+
f"{len(steps) + 1}. See what Claude did: Run 'htmlgraph serve' and open http://localhost:8080"
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
return steps
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def bootstrap_htmlgraph(config: BootstrapConfig) -> dict[str, Any]:
|
|
231
|
+
"""
|
|
232
|
+
Bootstrap HtmlGraph in a project directory.
|
|
233
|
+
|
|
234
|
+
This is the main entry point for the bootstrap command.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
config: BootstrapConfig with bootstrap settings
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
Dictionary with bootstrap results
|
|
241
|
+
"""
|
|
242
|
+
project_dir = Path(config.project_path).resolve()
|
|
243
|
+
|
|
244
|
+
# Check if already initialized
|
|
245
|
+
if check_already_initialized(project_dir):
|
|
246
|
+
# Ask user if they want to overwrite
|
|
247
|
+
print(f"\n⚠️ HtmlGraph already initialized in {project_dir}")
|
|
248
|
+
response = input("Do you want to reinitialize? (y/N): ").strip().lower()
|
|
249
|
+
if response not in ["y", "yes"]:
|
|
250
|
+
return {
|
|
251
|
+
"success": False,
|
|
252
|
+
"message": "Bootstrap cancelled - already initialized",
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
# Detect project type
|
|
256
|
+
project_type = detect_project_type(project_dir)
|
|
257
|
+
|
|
258
|
+
# Create directory structure
|
|
259
|
+
created = create_bootstrap_structure(project_dir)
|
|
260
|
+
graph_dir = project_dir / ".htmlgraph"
|
|
261
|
+
|
|
262
|
+
# Initialize database
|
|
263
|
+
db_path = initialize_database(graph_dir)
|
|
264
|
+
created["files"].append(db_path)
|
|
265
|
+
|
|
266
|
+
# Check for Claude Code
|
|
267
|
+
has_claude = check_claude_code_available()
|
|
268
|
+
|
|
269
|
+
# Check if plugin is already available (skip installation check for now)
|
|
270
|
+
plugin_installed = False
|
|
271
|
+
if not config.no_plugins and has_claude:
|
|
272
|
+
# We'll consider it "installed" if hooks can be configured
|
|
273
|
+
# The actual plugin installation happens via marketplace
|
|
274
|
+
plugin_installed = True
|
|
275
|
+
|
|
276
|
+
# Generate next steps
|
|
277
|
+
next_steps = get_next_steps(project_type, has_claude, plugin_installed)
|
|
278
|
+
|
|
279
|
+
return {
|
|
280
|
+
"success": True,
|
|
281
|
+
"project_type": project_type,
|
|
282
|
+
"graph_dir": str(graph_dir),
|
|
283
|
+
"directories_created": created["directories"],
|
|
284
|
+
"files_created": created["files"],
|
|
285
|
+
"has_claude": has_claude,
|
|
286
|
+
"plugin_installed": plugin_installed,
|
|
287
|
+
"next_steps": next_steps,
|
|
288
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: htmlgraph
|
|
3
|
-
Version: 0.26.
|
|
3
|
+
Version: 0.26.23
|
|
4
4
|
Summary: HTML is All You Need - Graph database on web standards
|
|
5
5
|
Project-URL: Homepage, https://github.com/Shakes-tzd/htmlgraph
|
|
6
6
|
Project-URL: Documentation, https://github.com/Shakes-tzd/htmlgraph#readme
|