foundry-mcp 0.8.22__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.

Potentially problematic release.


This version of foundry-mcp might be problematic. Click here for more details.

Files changed (153) hide show
  1. foundry_mcp/__init__.py +13 -0
  2. foundry_mcp/cli/__init__.py +67 -0
  3. foundry_mcp/cli/__main__.py +9 -0
  4. foundry_mcp/cli/agent.py +96 -0
  5. foundry_mcp/cli/commands/__init__.py +37 -0
  6. foundry_mcp/cli/commands/cache.py +137 -0
  7. foundry_mcp/cli/commands/dashboard.py +148 -0
  8. foundry_mcp/cli/commands/dev.py +446 -0
  9. foundry_mcp/cli/commands/journal.py +377 -0
  10. foundry_mcp/cli/commands/lifecycle.py +274 -0
  11. foundry_mcp/cli/commands/modify.py +824 -0
  12. foundry_mcp/cli/commands/plan.py +640 -0
  13. foundry_mcp/cli/commands/pr.py +393 -0
  14. foundry_mcp/cli/commands/review.py +667 -0
  15. foundry_mcp/cli/commands/session.py +472 -0
  16. foundry_mcp/cli/commands/specs.py +686 -0
  17. foundry_mcp/cli/commands/tasks.py +807 -0
  18. foundry_mcp/cli/commands/testing.py +676 -0
  19. foundry_mcp/cli/commands/validate.py +982 -0
  20. foundry_mcp/cli/config.py +98 -0
  21. foundry_mcp/cli/context.py +298 -0
  22. foundry_mcp/cli/logging.py +212 -0
  23. foundry_mcp/cli/main.py +44 -0
  24. foundry_mcp/cli/output.py +122 -0
  25. foundry_mcp/cli/registry.py +110 -0
  26. foundry_mcp/cli/resilience.py +178 -0
  27. foundry_mcp/cli/transcript.py +217 -0
  28. foundry_mcp/config.py +1454 -0
  29. foundry_mcp/core/__init__.py +144 -0
  30. foundry_mcp/core/ai_consultation.py +1773 -0
  31. foundry_mcp/core/batch_operations.py +1202 -0
  32. foundry_mcp/core/cache.py +195 -0
  33. foundry_mcp/core/capabilities.py +446 -0
  34. foundry_mcp/core/concurrency.py +898 -0
  35. foundry_mcp/core/context.py +540 -0
  36. foundry_mcp/core/discovery.py +1603 -0
  37. foundry_mcp/core/error_collection.py +728 -0
  38. foundry_mcp/core/error_store.py +592 -0
  39. foundry_mcp/core/health.py +749 -0
  40. foundry_mcp/core/intake.py +933 -0
  41. foundry_mcp/core/journal.py +700 -0
  42. foundry_mcp/core/lifecycle.py +412 -0
  43. foundry_mcp/core/llm_config.py +1376 -0
  44. foundry_mcp/core/llm_patterns.py +510 -0
  45. foundry_mcp/core/llm_provider.py +1569 -0
  46. foundry_mcp/core/logging_config.py +374 -0
  47. foundry_mcp/core/metrics_persistence.py +584 -0
  48. foundry_mcp/core/metrics_registry.py +327 -0
  49. foundry_mcp/core/metrics_store.py +641 -0
  50. foundry_mcp/core/modifications.py +224 -0
  51. foundry_mcp/core/naming.py +146 -0
  52. foundry_mcp/core/observability.py +1216 -0
  53. foundry_mcp/core/otel.py +452 -0
  54. foundry_mcp/core/otel_stubs.py +264 -0
  55. foundry_mcp/core/pagination.py +255 -0
  56. foundry_mcp/core/progress.py +387 -0
  57. foundry_mcp/core/prometheus.py +564 -0
  58. foundry_mcp/core/prompts/__init__.py +464 -0
  59. foundry_mcp/core/prompts/fidelity_review.py +691 -0
  60. foundry_mcp/core/prompts/markdown_plan_review.py +515 -0
  61. foundry_mcp/core/prompts/plan_review.py +627 -0
  62. foundry_mcp/core/providers/__init__.py +237 -0
  63. foundry_mcp/core/providers/base.py +515 -0
  64. foundry_mcp/core/providers/claude.py +472 -0
  65. foundry_mcp/core/providers/codex.py +637 -0
  66. foundry_mcp/core/providers/cursor_agent.py +630 -0
  67. foundry_mcp/core/providers/detectors.py +515 -0
  68. foundry_mcp/core/providers/gemini.py +426 -0
  69. foundry_mcp/core/providers/opencode.py +718 -0
  70. foundry_mcp/core/providers/opencode_wrapper.js +308 -0
  71. foundry_mcp/core/providers/package-lock.json +24 -0
  72. foundry_mcp/core/providers/package.json +25 -0
  73. foundry_mcp/core/providers/registry.py +607 -0
  74. foundry_mcp/core/providers/test_provider.py +171 -0
  75. foundry_mcp/core/providers/validation.py +857 -0
  76. foundry_mcp/core/rate_limit.py +427 -0
  77. foundry_mcp/core/research/__init__.py +68 -0
  78. foundry_mcp/core/research/memory.py +528 -0
  79. foundry_mcp/core/research/models.py +1234 -0
  80. foundry_mcp/core/research/providers/__init__.py +40 -0
  81. foundry_mcp/core/research/providers/base.py +242 -0
  82. foundry_mcp/core/research/providers/google.py +507 -0
  83. foundry_mcp/core/research/providers/perplexity.py +442 -0
  84. foundry_mcp/core/research/providers/semantic_scholar.py +544 -0
  85. foundry_mcp/core/research/providers/tavily.py +383 -0
  86. foundry_mcp/core/research/workflows/__init__.py +25 -0
  87. foundry_mcp/core/research/workflows/base.py +298 -0
  88. foundry_mcp/core/research/workflows/chat.py +271 -0
  89. foundry_mcp/core/research/workflows/consensus.py +539 -0
  90. foundry_mcp/core/research/workflows/deep_research.py +4142 -0
  91. foundry_mcp/core/research/workflows/ideate.py +682 -0
  92. foundry_mcp/core/research/workflows/thinkdeep.py +405 -0
  93. foundry_mcp/core/resilience.py +600 -0
  94. foundry_mcp/core/responses.py +1624 -0
  95. foundry_mcp/core/review.py +366 -0
  96. foundry_mcp/core/security.py +438 -0
  97. foundry_mcp/core/spec.py +4119 -0
  98. foundry_mcp/core/task.py +2463 -0
  99. foundry_mcp/core/testing.py +839 -0
  100. foundry_mcp/core/validation.py +2357 -0
  101. foundry_mcp/dashboard/__init__.py +32 -0
  102. foundry_mcp/dashboard/app.py +119 -0
  103. foundry_mcp/dashboard/components/__init__.py +17 -0
  104. foundry_mcp/dashboard/components/cards.py +88 -0
  105. foundry_mcp/dashboard/components/charts.py +177 -0
  106. foundry_mcp/dashboard/components/filters.py +136 -0
  107. foundry_mcp/dashboard/components/tables.py +195 -0
  108. foundry_mcp/dashboard/data/__init__.py +11 -0
  109. foundry_mcp/dashboard/data/stores.py +433 -0
  110. foundry_mcp/dashboard/launcher.py +300 -0
  111. foundry_mcp/dashboard/views/__init__.py +12 -0
  112. foundry_mcp/dashboard/views/errors.py +217 -0
  113. foundry_mcp/dashboard/views/metrics.py +164 -0
  114. foundry_mcp/dashboard/views/overview.py +96 -0
  115. foundry_mcp/dashboard/views/providers.py +83 -0
  116. foundry_mcp/dashboard/views/sdd_workflow.py +255 -0
  117. foundry_mcp/dashboard/views/tool_usage.py +139 -0
  118. foundry_mcp/prompts/__init__.py +9 -0
  119. foundry_mcp/prompts/workflows.py +525 -0
  120. foundry_mcp/resources/__init__.py +9 -0
  121. foundry_mcp/resources/specs.py +591 -0
  122. foundry_mcp/schemas/__init__.py +38 -0
  123. foundry_mcp/schemas/intake-schema.json +89 -0
  124. foundry_mcp/schemas/sdd-spec-schema.json +414 -0
  125. foundry_mcp/server.py +150 -0
  126. foundry_mcp/tools/__init__.py +10 -0
  127. foundry_mcp/tools/unified/__init__.py +92 -0
  128. foundry_mcp/tools/unified/authoring.py +3620 -0
  129. foundry_mcp/tools/unified/context_helpers.py +98 -0
  130. foundry_mcp/tools/unified/documentation_helpers.py +268 -0
  131. foundry_mcp/tools/unified/environment.py +1341 -0
  132. foundry_mcp/tools/unified/error.py +479 -0
  133. foundry_mcp/tools/unified/health.py +225 -0
  134. foundry_mcp/tools/unified/journal.py +841 -0
  135. foundry_mcp/tools/unified/lifecycle.py +640 -0
  136. foundry_mcp/tools/unified/metrics.py +777 -0
  137. foundry_mcp/tools/unified/plan.py +876 -0
  138. foundry_mcp/tools/unified/pr.py +294 -0
  139. foundry_mcp/tools/unified/provider.py +589 -0
  140. foundry_mcp/tools/unified/research.py +1283 -0
  141. foundry_mcp/tools/unified/review.py +1042 -0
  142. foundry_mcp/tools/unified/review_helpers.py +314 -0
  143. foundry_mcp/tools/unified/router.py +102 -0
  144. foundry_mcp/tools/unified/server.py +565 -0
  145. foundry_mcp/tools/unified/spec.py +1283 -0
  146. foundry_mcp/tools/unified/task.py +3846 -0
  147. foundry_mcp/tools/unified/test.py +431 -0
  148. foundry_mcp/tools/unified/verification.py +520 -0
  149. foundry_mcp-0.8.22.dist-info/METADATA +344 -0
  150. foundry_mcp-0.8.22.dist-info/RECORD +153 -0
  151. foundry_mcp-0.8.22.dist-info/WHEEL +4 -0
  152. foundry_mcp-0.8.22.dist-info/entry_points.txt +3 -0
  153. foundry_mcp-0.8.22.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,122 @@
1
+ """JSON output helpers for SDD CLI.
2
+
3
+ This module provides the sole output mechanism for the CLI.
4
+ The CLI is JSON-first and currently emits JSON envelopes only.
5
+
6
+ Design rationale:
7
+ - Primary consumers are AI coding assistants (Claude, Cursor, etc.)
8
+ - AI agents parse structured data best - no regex/pattern matching needed
9
+ - Consistent output format = reliable integration
10
+ - Humans can pipe through `jq` if needed
11
+
12
+ This module wraps the canonical response helpers from foundry_mcp.core.responses
13
+ to ensure CLI output matches the response-v2 schema used by MCP tools.
14
+ """
15
+
16
+ import json
17
+ import sys
18
+ from dataclasses import asdict
19
+ from typing import Any, Mapping, Sequence, NoReturn
20
+
21
+ from foundry_mcp.cli.logging import generate_request_id, get_request_id, set_request_id
22
+ from foundry_mcp.core.responses import error_response, success_response
23
+
24
+
25
+ def _ensure_request_id() -> str:
26
+ request_id = get_request_id()
27
+ if request_id:
28
+ return request_id
29
+ request_id = generate_request_id()
30
+ set_request_id(request_id)
31
+ return request_id
32
+
33
+
34
+ def emit(data: Any) -> None:
35
+ """Emit JSON to stdout.
36
+
37
+ This is the single output function for all CLI commands.
38
+ Data is serialized in minified format for smaller payloads.
39
+
40
+ Args:
41
+ data: Any JSON-serializable data structure.
42
+ """
43
+ print(json.dumps(data, separators=(",", ":"), default=str))
44
+
45
+
46
+ def emit_error(
47
+ message: str,
48
+ code: str = "INTERNAL_ERROR",
49
+ *,
50
+ error_type: str = "internal",
51
+ remediation: str | None = None,
52
+ details: Mapping[str, Any] | None = None,
53
+ ) -> NoReturn:
54
+ """Emit error JSON to stderr and exit with code 1.
55
+
56
+ Uses foundry_mcp.core.responses.error_response to ensure response-v2 compliance.
57
+ Error info is structured in the `data` field with `error_code`, `error_type`,
58
+ and `remediation` fields. The `error` field contains the human-readable message.
59
+
60
+ Args:
61
+ message: Human-readable error description.
62
+ code: Error code in SCREAMING_SNAKE_CASE (e.g., VALIDATION_ERROR, NOT_FOUND).
63
+ error_type: Error category for routing (validation, not_found, internal, etc.).
64
+ remediation: Actionable guidance for resolving the error.
65
+ details: Optional additional error context.
66
+
67
+ Raises:
68
+ SystemExit: Always exits with code 1.
69
+ """
70
+ response = error_response(
71
+ message=message,
72
+ error_code=code,
73
+ error_type=error_type,
74
+ remediation=remediation,
75
+ details=details,
76
+ request_id=_ensure_request_id(),
77
+ )
78
+ print(json.dumps(asdict(response), separators=(",", ":"), default=str), file=sys.stderr)
79
+ sys.exit(1)
80
+
81
+
82
+ def emit_success(
83
+ data: Any,
84
+ *,
85
+ warnings: Sequence[str] | None = None,
86
+ pagination: Mapping[str, Any] | None = None,
87
+ telemetry: Mapping[str, Any] | None = None,
88
+ meta: Mapping[str, Any] | None = None,
89
+ ) -> None:
90
+ """Emit success response envelope to stdout.
91
+
92
+ Uses foundry_mcp.core.responses.success_response to ensure response-v2 compliance.
93
+ All responses include meta.version: "response-v2".
94
+
95
+ Args:
96
+ data: The operation-specific payload.
97
+ warnings: Non-fatal issues to surface in meta.warnings.
98
+ pagination: Cursor metadata for list results.
99
+ telemetry: Timing/performance metadata.
100
+ meta: Additional metadata to merge into meta object.
101
+ """
102
+ # Handle both dict and non-dict data
103
+ if isinstance(data, dict):
104
+ response = success_response(
105
+ data=data,
106
+ warnings=warnings,
107
+ pagination=pagination,
108
+ telemetry=telemetry,
109
+ meta=meta,
110
+ request_id=_ensure_request_id(),
111
+ )
112
+ else:
113
+ # Wrap non-dict data in a result key
114
+ response = success_response(
115
+ data={"result": data},
116
+ warnings=warnings,
117
+ pagination=pagination,
118
+ telemetry=telemetry,
119
+ meta=meta,
120
+ request_id=_ensure_request_id(),
121
+ )
122
+ emit(asdict(response))
@@ -0,0 +1,110 @@
1
+ """Command registry for SDD CLI.
2
+
3
+ Centralized registration of all command groups.
4
+ Commands are organized by domain (specs, tasks, journal, etc.).
5
+ """
6
+
7
+ from typing import Optional
8
+
9
+ import click
10
+
11
+ from foundry_mcp.cli.config import CLIContext
12
+
13
+ # Module-level storage for CLI context (for testing)
14
+ _cli_context: Optional[CLIContext] = None
15
+
16
+
17
+ def set_context(ctx: CLIContext) -> None:
18
+ """Set the CLI context at module level.
19
+
20
+ Primarily used for testing when not using Click's context.
21
+
22
+ Args:
23
+ ctx: The CLIContext to store.
24
+ """
25
+ global _cli_context
26
+ _cli_context = ctx
27
+
28
+
29
+ def get_context(ctx: Optional[click.Context] = None) -> CLIContext:
30
+ """Get CLI context from Click context or module-level storage.
31
+
32
+ Args:
33
+ ctx: Optional Click context with cli_context stored in obj.
34
+ If None, returns module-level context.
35
+
36
+ Returns:
37
+ The CLIContext instance.
38
+
39
+ Raises:
40
+ RuntimeError: If no context is available.
41
+ """
42
+ if ctx is not None:
43
+ return ctx.obj["cli_context"]
44
+
45
+ if _cli_context is not None:
46
+ return _cli_context
47
+
48
+ raise RuntimeError("No CLI context available. Call set_context() first.")
49
+
50
+
51
+ def register_all_commands(cli: click.Group) -> None:
52
+ """Register all command groups with the CLI.
53
+
54
+ Command groups are lazily imported to avoid circular dependencies
55
+ and improve startup time.
56
+
57
+ Args:
58
+ cli: The main Click group to register commands with.
59
+ """
60
+ # Import and register command groups
61
+ from foundry_mcp.cli.commands import (
62
+ cache,
63
+ dashboard_group,
64
+ dev_group,
65
+ journal,
66
+ lifecycle,
67
+ modify_group,
68
+ plan_group,
69
+ pr_group,
70
+ review_group,
71
+ session,
72
+ specs,
73
+ tasks,
74
+ test_group,
75
+ validate_group,
76
+ )
77
+
78
+ cli.add_command(specs)
79
+ cli.add_command(tasks)
80
+ cli.add_command(lifecycle)
81
+ cli.add_command(session)
82
+ cli.add_command(cache)
83
+ cli.add_command(journal)
84
+ cli.add_command(validate_group)
85
+ cli.add_command(review_group)
86
+ cli.add_command(pr_group)
87
+ cli.add_command(modify_group)
88
+ cli.add_command(test_group)
89
+ cli.add_command(dev_group)
90
+ cli.add_command(dashboard_group)
91
+ cli.add_command(plan_group)
92
+
93
+ # Placeholder: version command for testing the scaffold
94
+ @cli.command("version")
95
+ @click.pass_context
96
+ def version(ctx: click.Context) -> None:
97
+ """Show CLI version information."""
98
+ from foundry_mcp.cli.output import emit
99
+
100
+ cli_ctx = get_context(ctx)
101
+ specs_dir = cli_ctx.specs_dir
102
+
103
+ emit(
104
+ {
105
+ "version": "0.1.0",
106
+ "name": "foundry-cli",
107
+ "json_only": True,
108
+ "specs_dir": str(specs_dir) if specs_dir else None,
109
+ }
110
+ )
@@ -0,0 +1,178 @@
1
+ """CLI resilience wrappers for timeout and cancellation.
2
+
3
+ Provides synchronous timeout and retry decorators for CLI commands,
4
+ wrapping the core resilience primitives from foundry_mcp.core.resilience.
5
+ """
6
+
7
+ import signal
8
+ import sys
9
+ from functools import wraps
10
+ from typing import Any, Callable, Optional, TypeVar
11
+
12
+ from foundry_mcp.core.resilience import (
13
+ FAST_TIMEOUT,
14
+ MEDIUM_TIMEOUT,
15
+ SLOW_TIMEOUT,
16
+ BACKGROUND_TIMEOUT,
17
+ TimeoutException,
18
+ retry_with_backoff,
19
+ )
20
+
21
+ # Re-export constants for CLI usage
22
+ __all__ = [
23
+ "FAST_TIMEOUT",
24
+ "MEDIUM_TIMEOUT",
25
+ "SLOW_TIMEOUT",
26
+ "BACKGROUND_TIMEOUT",
27
+ "TimeoutException",
28
+ "with_sync_timeout",
29
+ "cli_retryable",
30
+ "handle_keyboard_interrupt",
31
+ ]
32
+
33
+ T = TypeVar("T")
34
+
35
+
36
+ class _TimeoutHandler:
37
+ """Context manager for signal-based timeout on Unix systems."""
38
+
39
+ def __init__(self, seconds: float, error_message: str):
40
+ self.seconds = int(seconds) # signal.alarm requires int
41
+ self.error_message = error_message
42
+ self._old_handler = None
43
+
44
+ def _timeout_handler(self, signum: int, frame: Any) -> None:
45
+ raise TimeoutException(
46
+ self.error_message,
47
+ timeout_seconds=float(self.seconds),
48
+ operation="cli_command",
49
+ )
50
+
51
+ def __enter__(self) -> "_TimeoutHandler":
52
+ if sys.platform != "win32":
53
+ self._old_handler = signal.signal(signal.SIGALRM, self._timeout_handler)
54
+ signal.alarm(self.seconds)
55
+ return self
56
+
57
+ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
58
+ if sys.platform != "win32":
59
+ signal.alarm(0)
60
+ if self._old_handler is not None:
61
+ signal.signal(signal.SIGALRM, self._old_handler)
62
+
63
+
64
+ def with_sync_timeout(
65
+ seconds: float = MEDIUM_TIMEOUT,
66
+ error_message: Optional[str] = None,
67
+ ) -> Callable[[Callable[..., T]], Callable[..., T]]:
68
+ """Decorator to add timeout to synchronous CLI commands.
69
+
70
+ Uses signal.SIGALRM on Unix systems. On Windows, timeout is not
71
+ enforced (function runs without timeout).
72
+
73
+ Args:
74
+ seconds: Timeout duration in seconds (default: MEDIUM_TIMEOUT).
75
+ error_message: Custom error message on timeout.
76
+
77
+ Returns:
78
+ Decorated function with timeout enforcement.
79
+
80
+ Example:
81
+ >>> @with_sync_timeout(FAST_TIMEOUT, "Query timed out")
82
+ ... def fetch_data():
83
+ ... return expensive_operation()
84
+
85
+ Note:
86
+ This uses SIGALRM which only works on Unix. Windows has no
87
+ signal-based timeout mechanism for synchronous code.
88
+ """
89
+
90
+ def decorator(func: Callable[..., T]) -> Callable[..., T]:
91
+ @wraps(func)
92
+ def wrapper(*args: Any, **kwargs: Any) -> T:
93
+ msg = error_message or f"{func.__name__} timed out after {seconds}s"
94
+
95
+ if sys.platform == "win32":
96
+ # Windows: run without timeout (signal.alarm not available)
97
+ return func(*args, **kwargs)
98
+
99
+ with _TimeoutHandler(seconds, msg):
100
+ return func(*args, **kwargs)
101
+
102
+ return wrapper
103
+
104
+ return decorator
105
+
106
+
107
+ def cli_retryable(
108
+ max_retries: int = 3,
109
+ delay: float = 1.0,
110
+ exceptions: tuple[type[Exception], ...] = (Exception,),
111
+ ) -> Callable[[Callable[..., T]], Callable[..., T]]:
112
+ """Decorator for automatic retries with exponential backoff.
113
+
114
+ Wraps core retry_with_backoff for CLI command usage.
115
+
116
+ Args:
117
+ max_retries: Maximum retry attempts (default 3).
118
+ delay: Base delay in seconds (default 1.0).
119
+ exceptions: Tuple of exceptions to retry on.
120
+
121
+ Returns:
122
+ Decorated function with retry logic.
123
+
124
+ Example:
125
+ >>> @cli_retryable(max_retries=3, exceptions=(IOError,))
126
+ ... def read_spec_file(path):
127
+ ... return load_json(path)
128
+ """
129
+
130
+ def decorator(func: Callable[..., T]) -> Callable[..., T]:
131
+ @wraps(func)
132
+ def wrapper(*args: Any, **kwargs: Any) -> T:
133
+ return retry_with_backoff(
134
+ lambda: func(*args, **kwargs),
135
+ max_retries=max_retries,
136
+ base_delay=delay,
137
+ retryable_exceptions=list(exceptions),
138
+ )
139
+
140
+ return wrapper
141
+
142
+ return decorator
143
+
144
+
145
+ def handle_keyboard_interrupt(
146
+ cleanup: Optional[Callable[[], None]] = None,
147
+ ) -> Callable[[Callable[..., T]], Callable[..., T]]:
148
+ """Decorator to gracefully handle Ctrl+C in CLI commands.
149
+
150
+ Catches KeyboardInterrupt, optionally runs cleanup, and exits
151
+ with appropriate code.
152
+
153
+ Args:
154
+ cleanup: Optional cleanup function to run on interrupt.
155
+
156
+ Returns:
157
+ Decorated function with interrupt handling.
158
+
159
+ Example:
160
+ >>> @handle_keyboard_interrupt(cleanup=lambda: print("Cancelled"))
161
+ ... def long_running_task():
162
+ ... # ... work ...
163
+ """
164
+
165
+ def decorator(func: Callable[..., T]) -> Callable[..., T]:
166
+ @wraps(func)
167
+ def wrapper(*args: Any, **kwargs: Any) -> T:
168
+ try:
169
+ return func(*args, **kwargs)
170
+ except KeyboardInterrupt:
171
+ if cleanup:
172
+ cleanup()
173
+ # Exit with 130 (128 + SIGINT signal number 2)
174
+ sys.exit(130)
175
+
176
+ return wrapper
177
+
178
+ return decorator
@@ -0,0 +1,217 @@
1
+ """Parse Claude Code transcript files to extract token usage metrics.
2
+
3
+ Ported from claude-sdd-toolkit context_tracker module.
4
+ """
5
+
6
+ import json
7
+ import time
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import Optional, Sequence
11
+
12
+
13
+ @dataclass
14
+ class TokenMetrics:
15
+ """Token usage metrics extracted from a transcript."""
16
+
17
+ input_tokens: int
18
+ output_tokens: int
19
+ cached_tokens: int
20
+ total_tokens: int
21
+ context_length: int
22
+
23
+ def context_percentage(self, max_context: int = 155000) -> float:
24
+ """Calculate context usage percentage."""
25
+ return (self.context_length / max_context) * 100 if max_context > 0 else 0.0
26
+
27
+
28
+ def is_clear_command(entry: dict) -> bool:
29
+ """
30
+ Check if a transcript entry is a /clear command.
31
+
32
+ The /clear command resets the conversation context, so we should
33
+ reset token counters when we encounter it.
34
+
35
+ Args:
36
+ entry: A parsed JSONL entry from the transcript
37
+
38
+ Returns:
39
+ True if this entry represents a /clear command
40
+ """
41
+ if entry.get("type") != "user":
42
+ return False
43
+
44
+ message = entry.get("message", {})
45
+ content = message.get("content", "")
46
+
47
+ # Handle both string content and list content
48
+ if isinstance(content, str):
49
+ return "<command-name>/clear</command-name>" in content
50
+
51
+ if isinstance(content, list):
52
+ for item in content:
53
+ if isinstance(item, dict):
54
+ text = item.get("text", "")
55
+ if "<command-name>/clear</command-name>" in text:
56
+ return True
57
+
58
+ return False
59
+
60
+
61
+ def parse_transcript(transcript_path: str | Path) -> Optional[TokenMetrics]:
62
+ """
63
+ Parse a Claude Code transcript JSONL file and extract token metrics.
64
+
65
+ Args:
66
+ transcript_path: Path to the transcript JSONL file
67
+
68
+ Returns:
69
+ TokenMetrics object with aggregated token data, or None if parsing fails
70
+ """
71
+ transcript_path = Path(transcript_path)
72
+
73
+ if not transcript_path.exists():
74
+ return None
75
+
76
+ input_tokens = 0
77
+ output_tokens = 0
78
+ cached_tokens = 0
79
+ context_length = 0
80
+
81
+ try:
82
+ with open(transcript_path, "r", encoding="utf-8") as f:
83
+ for line in f:
84
+ line = line.strip()
85
+ if not line:
86
+ continue
87
+
88
+ try:
89
+ entry = json.loads(line)
90
+ except json.JSONDecodeError:
91
+ continue
92
+
93
+ # Check for /clear command - reset all counters
94
+ if is_clear_command(entry):
95
+ input_tokens = 0
96
+ output_tokens = 0
97
+ cached_tokens = 0
98
+ context_length = 0
99
+ continue # Don't process /clear entry itself
100
+
101
+ # Skip sidechain and error messages
102
+ if entry.get("isSidechain") or entry.get("isApiErrorMessage"):
103
+ continue
104
+
105
+ # Extract usage data
106
+ message = entry.get("message", {})
107
+ usage = message.get("usage", {})
108
+
109
+ if usage:
110
+ # Accumulate token counts
111
+ input_tokens += usage.get("input_tokens", 0)
112
+ output_tokens += usage.get("output_tokens", 0)
113
+
114
+ # Cached tokens come from both read and creation
115
+ cache_read = usage.get("cache_read_input_tokens", 0)
116
+ cache_creation = usage.get("cache_creation_input_tokens", 0)
117
+ cached_tokens += cache_read + cache_creation
118
+
119
+ # Context length is from the most recent valid entry
120
+ # (input tokens + cached tokens, excluding output)
121
+ context_length = (
122
+ usage.get("input_tokens", 0)
123
+ + usage.get("cache_read_input_tokens", 0)
124
+ + usage.get("cache_creation_input_tokens", 0)
125
+ )
126
+
127
+ except Exception:
128
+ return None
129
+
130
+ total_tokens = input_tokens + output_tokens + cached_tokens
131
+
132
+ return TokenMetrics(
133
+ input_tokens=input_tokens,
134
+ output_tokens=output_tokens,
135
+ cached_tokens=cached_tokens,
136
+ total_tokens=total_tokens,
137
+ context_length=context_length,
138
+ )
139
+
140
+
141
+ def find_transcript_by_marker(
142
+ cwd: Path,
143
+ marker: str,
144
+ max_retries: int = 10,
145
+ search_dirs: Optional[Sequence[Path]] = None,
146
+ allow_home_search: bool = False,
147
+ ) -> Optional[Path]:
148
+ """
149
+ Search transcripts for a specific SESSION_MARKER to identify current session.
150
+
151
+ Args:
152
+ cwd: Current working directory (used to derive default project path)
153
+ marker: Specific marker to search for (e.g., "SESSION_MARKER_abc12345")
154
+ max_retries: Maximum number of retry attempts (default: 10)
155
+ search_dirs: Explicit directories to search (takes precedence over defaults)
156
+ allow_home_search: Whether to scan ~/.claude/projects derived paths
157
+
158
+ Returns:
159
+ Path to transcript containing the marker, or None if not found
160
+ """
161
+ candidate_dirs: list[Path] = []
162
+
163
+ if search_dirs:
164
+ for directory in search_dirs:
165
+ resolved = Path(directory).expanduser().resolve()
166
+ if resolved.is_dir() and resolved not in candidate_dirs:
167
+ candidate_dirs.append(resolved)
168
+
169
+ if allow_home_search:
170
+ current_path = cwd.resolve()
171
+ while True:
172
+ project_dir_name = str(current_path).replace("/", "-").replace("_", "-")
173
+ transcript_dir = Path.home() / ".claude" / "projects" / project_dir_name
174
+ if transcript_dir.exists() and transcript_dir not in candidate_dirs:
175
+ candidate_dirs.append(transcript_dir)
176
+
177
+ if current_path.parent == current_path or len(candidate_dirs) >= 5:
178
+ break
179
+ current_path = current_path.parent
180
+
181
+ if not candidate_dirs:
182
+ return None
183
+
184
+ delays = [min(0.1 * (2**i), 10.0) for i in range(max_retries)]
185
+
186
+ for attempt in range(max_retries):
187
+ current_time = time.time()
188
+
189
+ for transcript_dir in candidate_dirs:
190
+ try:
191
+ transcript_files = []
192
+ for transcript_path in transcript_dir.glob("*.jsonl"):
193
+ try:
194
+ mtime = transcript_path.stat().st_mtime
195
+ if (current_time - mtime) > 86400:
196
+ continue
197
+ transcript_files.append((transcript_path, mtime))
198
+ except (OSError, IOError):
199
+ continue
200
+
201
+ transcript_files.sort(key=lambda x: x[1], reverse=True)
202
+
203
+ for transcript_path, _ in transcript_files:
204
+ try:
205
+ with open(transcript_path, "r", encoding="utf-8") as f:
206
+ for line in f:
207
+ if marker in line:
208
+ return transcript_path
209
+ except (OSError, IOError, UnicodeDecodeError):
210
+ continue
211
+ except (OSError, IOError):
212
+ continue
213
+
214
+ if attempt < max_retries - 1:
215
+ time.sleep(delays[attempt])
216
+
217
+ return None