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.
- foundry_mcp/__init__.py +13 -0
- foundry_mcp/cli/__init__.py +67 -0
- foundry_mcp/cli/__main__.py +9 -0
- foundry_mcp/cli/agent.py +96 -0
- foundry_mcp/cli/commands/__init__.py +37 -0
- foundry_mcp/cli/commands/cache.py +137 -0
- foundry_mcp/cli/commands/dashboard.py +148 -0
- foundry_mcp/cli/commands/dev.py +446 -0
- foundry_mcp/cli/commands/journal.py +377 -0
- foundry_mcp/cli/commands/lifecycle.py +274 -0
- foundry_mcp/cli/commands/modify.py +824 -0
- foundry_mcp/cli/commands/plan.py +640 -0
- foundry_mcp/cli/commands/pr.py +393 -0
- foundry_mcp/cli/commands/review.py +667 -0
- foundry_mcp/cli/commands/session.py +472 -0
- foundry_mcp/cli/commands/specs.py +686 -0
- foundry_mcp/cli/commands/tasks.py +807 -0
- foundry_mcp/cli/commands/testing.py +676 -0
- foundry_mcp/cli/commands/validate.py +982 -0
- foundry_mcp/cli/config.py +98 -0
- foundry_mcp/cli/context.py +298 -0
- foundry_mcp/cli/logging.py +212 -0
- foundry_mcp/cli/main.py +44 -0
- foundry_mcp/cli/output.py +122 -0
- foundry_mcp/cli/registry.py +110 -0
- foundry_mcp/cli/resilience.py +178 -0
- foundry_mcp/cli/transcript.py +217 -0
- foundry_mcp/config.py +1454 -0
- foundry_mcp/core/__init__.py +144 -0
- foundry_mcp/core/ai_consultation.py +1773 -0
- foundry_mcp/core/batch_operations.py +1202 -0
- foundry_mcp/core/cache.py +195 -0
- foundry_mcp/core/capabilities.py +446 -0
- foundry_mcp/core/concurrency.py +898 -0
- foundry_mcp/core/context.py +540 -0
- foundry_mcp/core/discovery.py +1603 -0
- foundry_mcp/core/error_collection.py +728 -0
- foundry_mcp/core/error_store.py +592 -0
- foundry_mcp/core/health.py +749 -0
- foundry_mcp/core/intake.py +933 -0
- foundry_mcp/core/journal.py +700 -0
- foundry_mcp/core/lifecycle.py +412 -0
- foundry_mcp/core/llm_config.py +1376 -0
- foundry_mcp/core/llm_patterns.py +510 -0
- foundry_mcp/core/llm_provider.py +1569 -0
- foundry_mcp/core/logging_config.py +374 -0
- foundry_mcp/core/metrics_persistence.py +584 -0
- foundry_mcp/core/metrics_registry.py +327 -0
- foundry_mcp/core/metrics_store.py +641 -0
- foundry_mcp/core/modifications.py +224 -0
- foundry_mcp/core/naming.py +146 -0
- foundry_mcp/core/observability.py +1216 -0
- foundry_mcp/core/otel.py +452 -0
- foundry_mcp/core/otel_stubs.py +264 -0
- foundry_mcp/core/pagination.py +255 -0
- foundry_mcp/core/progress.py +387 -0
- foundry_mcp/core/prometheus.py +564 -0
- foundry_mcp/core/prompts/__init__.py +464 -0
- foundry_mcp/core/prompts/fidelity_review.py +691 -0
- foundry_mcp/core/prompts/markdown_plan_review.py +515 -0
- foundry_mcp/core/prompts/plan_review.py +627 -0
- foundry_mcp/core/providers/__init__.py +237 -0
- foundry_mcp/core/providers/base.py +515 -0
- foundry_mcp/core/providers/claude.py +472 -0
- foundry_mcp/core/providers/codex.py +637 -0
- foundry_mcp/core/providers/cursor_agent.py +630 -0
- foundry_mcp/core/providers/detectors.py +515 -0
- foundry_mcp/core/providers/gemini.py +426 -0
- foundry_mcp/core/providers/opencode.py +718 -0
- foundry_mcp/core/providers/opencode_wrapper.js +308 -0
- foundry_mcp/core/providers/package-lock.json +24 -0
- foundry_mcp/core/providers/package.json +25 -0
- foundry_mcp/core/providers/registry.py +607 -0
- foundry_mcp/core/providers/test_provider.py +171 -0
- foundry_mcp/core/providers/validation.py +857 -0
- foundry_mcp/core/rate_limit.py +427 -0
- foundry_mcp/core/research/__init__.py +68 -0
- foundry_mcp/core/research/memory.py +528 -0
- foundry_mcp/core/research/models.py +1234 -0
- foundry_mcp/core/research/providers/__init__.py +40 -0
- foundry_mcp/core/research/providers/base.py +242 -0
- foundry_mcp/core/research/providers/google.py +507 -0
- foundry_mcp/core/research/providers/perplexity.py +442 -0
- foundry_mcp/core/research/providers/semantic_scholar.py +544 -0
- foundry_mcp/core/research/providers/tavily.py +383 -0
- foundry_mcp/core/research/workflows/__init__.py +25 -0
- foundry_mcp/core/research/workflows/base.py +298 -0
- foundry_mcp/core/research/workflows/chat.py +271 -0
- foundry_mcp/core/research/workflows/consensus.py +539 -0
- foundry_mcp/core/research/workflows/deep_research.py +4142 -0
- foundry_mcp/core/research/workflows/ideate.py +682 -0
- foundry_mcp/core/research/workflows/thinkdeep.py +405 -0
- foundry_mcp/core/resilience.py +600 -0
- foundry_mcp/core/responses.py +1624 -0
- foundry_mcp/core/review.py +366 -0
- foundry_mcp/core/security.py +438 -0
- foundry_mcp/core/spec.py +4119 -0
- foundry_mcp/core/task.py +2463 -0
- foundry_mcp/core/testing.py +839 -0
- foundry_mcp/core/validation.py +2357 -0
- foundry_mcp/dashboard/__init__.py +32 -0
- foundry_mcp/dashboard/app.py +119 -0
- foundry_mcp/dashboard/components/__init__.py +17 -0
- foundry_mcp/dashboard/components/cards.py +88 -0
- foundry_mcp/dashboard/components/charts.py +177 -0
- foundry_mcp/dashboard/components/filters.py +136 -0
- foundry_mcp/dashboard/components/tables.py +195 -0
- foundry_mcp/dashboard/data/__init__.py +11 -0
- foundry_mcp/dashboard/data/stores.py +433 -0
- foundry_mcp/dashboard/launcher.py +300 -0
- foundry_mcp/dashboard/views/__init__.py +12 -0
- foundry_mcp/dashboard/views/errors.py +217 -0
- foundry_mcp/dashboard/views/metrics.py +164 -0
- foundry_mcp/dashboard/views/overview.py +96 -0
- foundry_mcp/dashboard/views/providers.py +83 -0
- foundry_mcp/dashboard/views/sdd_workflow.py +255 -0
- foundry_mcp/dashboard/views/tool_usage.py +139 -0
- foundry_mcp/prompts/__init__.py +9 -0
- foundry_mcp/prompts/workflows.py +525 -0
- foundry_mcp/resources/__init__.py +9 -0
- foundry_mcp/resources/specs.py +591 -0
- foundry_mcp/schemas/__init__.py +38 -0
- foundry_mcp/schemas/intake-schema.json +89 -0
- foundry_mcp/schemas/sdd-spec-schema.json +414 -0
- foundry_mcp/server.py +150 -0
- foundry_mcp/tools/__init__.py +10 -0
- foundry_mcp/tools/unified/__init__.py +92 -0
- foundry_mcp/tools/unified/authoring.py +3620 -0
- foundry_mcp/tools/unified/context_helpers.py +98 -0
- foundry_mcp/tools/unified/documentation_helpers.py +268 -0
- foundry_mcp/tools/unified/environment.py +1341 -0
- foundry_mcp/tools/unified/error.py +479 -0
- foundry_mcp/tools/unified/health.py +225 -0
- foundry_mcp/tools/unified/journal.py +841 -0
- foundry_mcp/tools/unified/lifecycle.py +640 -0
- foundry_mcp/tools/unified/metrics.py +777 -0
- foundry_mcp/tools/unified/plan.py +876 -0
- foundry_mcp/tools/unified/pr.py +294 -0
- foundry_mcp/tools/unified/provider.py +589 -0
- foundry_mcp/tools/unified/research.py +1283 -0
- foundry_mcp/tools/unified/review.py +1042 -0
- foundry_mcp/tools/unified/review_helpers.py +314 -0
- foundry_mcp/tools/unified/router.py +102 -0
- foundry_mcp/tools/unified/server.py +565 -0
- foundry_mcp/tools/unified/spec.py +1283 -0
- foundry_mcp/tools/unified/task.py +3846 -0
- foundry_mcp/tools/unified/test.py +431 -0
- foundry_mcp/tools/unified/verification.py +520 -0
- foundry_mcp-0.8.22.dist-info/METADATA +344 -0
- foundry_mcp-0.8.22.dist-info/RECORD +153 -0
- foundry_mcp-0.8.22.dist-info/WHEEL +4 -0
- foundry_mcp-0.8.22.dist-info/entry_points.txt +3 -0
- 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
|