htmlgraph 0.26.22__py3-none-any.whl → 0.26.24__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/db/schema.py CHANGED
@@ -163,6 +163,12 @@ class HtmlGraphDB:
163
163
  ("completed_at", "DATETIME"),
164
164
  ("last_user_query_at", "DATETIME"),
165
165
  ("last_user_query", "TEXT"),
166
+ # Phase 2 Feature 3: Cross-Session Continuity handoff fields
167
+ ("handoff_notes", "TEXT"),
168
+ ("recommended_next", "TEXT"),
169
+ ("blockers", "TEXT"), # JSON array of blocker strings
170
+ ("recommended_context", "TEXT"), # JSON array of file paths
171
+ ("continued_from", "TEXT"), # Previous session ID
166
172
  ]
167
173
 
168
174
  # Refresh columns after potential rename
@@ -291,8 +297,14 @@ class HtmlGraphDB:
291
297
  metadata JSON,
292
298
  last_user_query_at DATETIME,
293
299
  last_user_query TEXT,
300
+ handoff_notes TEXT,
301
+ recommended_next TEXT,
302
+ blockers JSON,
303
+ recommended_context JSON,
304
+ continued_from TEXT,
294
305
  FOREIGN KEY (parent_session_id) REFERENCES sessions(session_id) ON DELETE SET NULL ON UPDATE CASCADE,
295
- FOREIGN KEY (parent_event_id) REFERENCES agent_events(event_id) ON DELETE SET NULL ON UPDATE CASCADE
306
+ FOREIGN KEY (parent_event_id) REFERENCES agent_events(event_id) ON DELETE SET NULL ON UPDATE CASCADE,
307
+ FOREIGN KEY (continued_from) REFERENCES sessions(session_id) ON DELETE SET NULL ON UPDATE CASCADE
296
308
  )
297
309
  """)
298
310
 
@@ -407,6 +419,23 @@ class HtmlGraphDB:
407
419
  )
408
420
  """)
409
421
 
422
+ # 10. HANDOFF_TRACKING TABLE - Phase 2 Feature 3: Track handoff effectiveness
423
+ cursor.execute("""
424
+ CREATE TABLE IF NOT EXISTS handoff_tracking (
425
+ handoff_id TEXT PRIMARY KEY,
426
+ from_session_id TEXT NOT NULL,
427
+ to_session_id TEXT,
428
+ items_in_context INTEGER DEFAULT 0,
429
+ items_accessed INTEGER DEFAULT 0,
430
+ time_to_resume_seconds INTEGER DEFAULT 0,
431
+ user_rating INTEGER CHECK(user_rating BETWEEN 1 AND 5),
432
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
433
+ resumed_at DATETIME,
434
+ FOREIGN KEY (from_session_id) REFERENCES sessions(session_id) ON DELETE CASCADE,
435
+ FOREIGN KEY (to_session_id) REFERENCES sessions(session_id) ON DELETE SET NULL
436
+ )
437
+ """)
438
+
410
439
  # 9. Create indexes for performance
411
440
  self._create_indexes(cursor)
412
441
 
@@ -496,6 +525,10 @@ class HtmlGraphDB:
496
525
  # live_events indexes - optimized for real-time WebSocket streaming
497
526
  "CREATE INDEX IF NOT EXISTS idx_live_events_pending ON live_events(broadcast_at) WHERE broadcast_at IS NULL",
498
527
  "CREATE INDEX IF NOT EXISTS idx_live_events_created ON live_events(created_at DESC)",
528
+ # handoff_tracking indexes - optimized for handoff effectiveness queries
529
+ "CREATE INDEX IF NOT EXISTS idx_handoff_from_session ON handoff_tracking(from_session_id, created_at DESC)",
530
+ "CREATE INDEX IF NOT EXISTS idx_handoff_to_session ON handoff_tracking(to_session_id, resumed_at DESC)",
531
+ "CREATE INDEX IF NOT EXISTS idx_handoff_rating ON handoff_tracking(user_rating, created_at DESC)",
499
532
  ]
500
533
 
501
534
  for index_sql in indexes:
@@ -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/models.py CHANGED
@@ -975,10 +975,13 @@ class Session(BaseModel):
975
975
  parent_activity: str | None = None # Parent activity ID
976
976
  nesting_depth: int = 0 # Depth of nesting (0 = top-level)
977
977
 
978
- # Handoff context
978
+ # Handoff context (Phase 2 Feature 3: Cross-Session Continuity)
979
979
  handoff_notes: str | None = None
980
980
  recommended_next: str | None = None
981
981
  blockers: list[str] = Field(default_factory=list)
982
+ recommended_context: list[str] = Field(
983
+ default_factory=list
984
+ ) # File paths to keep context for
982
985
 
983
986
  # High-frequency activity log
984
987
  activity_log: list[ActivityEntry] = Field(default_factory=list)
@@ -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
+ }