foundry-mcp 0.3.3__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.
Files changed (135) hide show
  1. foundry_mcp/__init__.py +7 -0
  2. foundry_mcp/cli/__init__.py +80 -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 +633 -0
  13. foundry_mcp/cli/commands/pr.py +393 -0
  14. foundry_mcp/cli/commands/review.py +652 -0
  15. foundry_mcp/cli/commands/session.py +479 -0
  16. foundry_mcp/cli/commands/specs.py +856 -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 +259 -0
  22. foundry_mcp/cli/flags.py +266 -0
  23. foundry_mcp/cli/logging.py +212 -0
  24. foundry_mcp/cli/main.py +44 -0
  25. foundry_mcp/cli/output.py +122 -0
  26. foundry_mcp/cli/registry.py +110 -0
  27. foundry_mcp/cli/resilience.py +178 -0
  28. foundry_mcp/cli/transcript.py +217 -0
  29. foundry_mcp/config.py +850 -0
  30. foundry_mcp/core/__init__.py +144 -0
  31. foundry_mcp/core/ai_consultation.py +1636 -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/feature_flags.py +592 -0
  40. foundry_mcp/core/health.py +749 -0
  41. foundry_mcp/core/journal.py +694 -0
  42. foundry_mcp/core/lifecycle.py +412 -0
  43. foundry_mcp/core/llm_config.py +1350 -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 +123 -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 +317 -0
  57. foundry_mcp/core/prometheus.py +577 -0
  58. foundry_mcp/core/prompts/__init__.py +464 -0
  59. foundry_mcp/core/prompts/fidelity_review.py +546 -0
  60. foundry_mcp/core/prompts/markdown_plan_review.py +511 -0
  61. foundry_mcp/core/prompts/plan_review.py +623 -0
  62. foundry_mcp/core/providers/__init__.py +225 -0
  63. foundry_mcp/core/providers/base.py +476 -0
  64. foundry_mcp/core/providers/claude.py +460 -0
  65. foundry_mcp/core/providers/codex.py +619 -0
  66. foundry_mcp/core/providers/cursor_agent.py +642 -0
  67. foundry_mcp/core/providers/detectors.py +488 -0
  68. foundry_mcp/core/providers/gemini.py +405 -0
  69. foundry_mcp/core/providers/opencode.py +616 -0
  70. foundry_mcp/core/providers/opencode_wrapper.js +302 -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 +729 -0
  76. foundry_mcp/core/rate_limit.py +427 -0
  77. foundry_mcp/core/resilience.py +600 -0
  78. foundry_mcp/core/responses.py +934 -0
  79. foundry_mcp/core/review.py +366 -0
  80. foundry_mcp/core/security.py +438 -0
  81. foundry_mcp/core/spec.py +1650 -0
  82. foundry_mcp/core/task.py +1289 -0
  83. foundry_mcp/core/testing.py +450 -0
  84. foundry_mcp/core/validation.py +2081 -0
  85. foundry_mcp/dashboard/__init__.py +32 -0
  86. foundry_mcp/dashboard/app.py +119 -0
  87. foundry_mcp/dashboard/components/__init__.py +17 -0
  88. foundry_mcp/dashboard/components/cards.py +88 -0
  89. foundry_mcp/dashboard/components/charts.py +234 -0
  90. foundry_mcp/dashboard/components/filters.py +136 -0
  91. foundry_mcp/dashboard/components/tables.py +195 -0
  92. foundry_mcp/dashboard/data/__init__.py +11 -0
  93. foundry_mcp/dashboard/data/stores.py +433 -0
  94. foundry_mcp/dashboard/launcher.py +289 -0
  95. foundry_mcp/dashboard/views/__init__.py +12 -0
  96. foundry_mcp/dashboard/views/errors.py +217 -0
  97. foundry_mcp/dashboard/views/metrics.py +174 -0
  98. foundry_mcp/dashboard/views/overview.py +160 -0
  99. foundry_mcp/dashboard/views/providers.py +83 -0
  100. foundry_mcp/dashboard/views/sdd_workflow.py +255 -0
  101. foundry_mcp/dashboard/views/tool_usage.py +139 -0
  102. foundry_mcp/prompts/__init__.py +9 -0
  103. foundry_mcp/prompts/workflows.py +525 -0
  104. foundry_mcp/resources/__init__.py +9 -0
  105. foundry_mcp/resources/specs.py +591 -0
  106. foundry_mcp/schemas/__init__.py +38 -0
  107. foundry_mcp/schemas/sdd-spec-schema.json +386 -0
  108. foundry_mcp/server.py +164 -0
  109. foundry_mcp/tools/__init__.py +10 -0
  110. foundry_mcp/tools/unified/__init__.py +71 -0
  111. foundry_mcp/tools/unified/authoring.py +1487 -0
  112. foundry_mcp/tools/unified/context_helpers.py +98 -0
  113. foundry_mcp/tools/unified/documentation_helpers.py +198 -0
  114. foundry_mcp/tools/unified/environment.py +939 -0
  115. foundry_mcp/tools/unified/error.py +462 -0
  116. foundry_mcp/tools/unified/health.py +225 -0
  117. foundry_mcp/tools/unified/journal.py +841 -0
  118. foundry_mcp/tools/unified/lifecycle.py +632 -0
  119. foundry_mcp/tools/unified/metrics.py +777 -0
  120. foundry_mcp/tools/unified/plan.py +745 -0
  121. foundry_mcp/tools/unified/pr.py +294 -0
  122. foundry_mcp/tools/unified/provider.py +629 -0
  123. foundry_mcp/tools/unified/review.py +685 -0
  124. foundry_mcp/tools/unified/review_helpers.py +299 -0
  125. foundry_mcp/tools/unified/router.py +102 -0
  126. foundry_mcp/tools/unified/server.py +580 -0
  127. foundry_mcp/tools/unified/spec.py +808 -0
  128. foundry_mcp/tools/unified/task.py +2202 -0
  129. foundry_mcp/tools/unified/test.py +370 -0
  130. foundry_mcp/tools/unified/verification.py +520 -0
  131. foundry_mcp-0.3.3.dist-info/METADATA +337 -0
  132. foundry_mcp-0.3.3.dist-info/RECORD +135 -0
  133. foundry_mcp-0.3.3.dist-info/WHEEL +4 -0
  134. foundry_mcp-0.3.3.dist-info/entry_points.txt +3 -0
  135. foundry_mcp-0.3.3.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,212 @@
1
+ """Structured logging hooks for CLI commands.
2
+
3
+ Provides context ID generation, metrics emission, and structured
4
+ logging for CLI command execution. Wraps core observability primitives
5
+ for CLI-specific use cases.
6
+ """
7
+
8
+ import logging
9
+ import time
10
+ import uuid
11
+ from contextvars import ContextVar
12
+ from functools import wraps
13
+ from typing import Any, Callable, Optional, TypeVar
14
+
15
+ from foundry_mcp.core.observability import (
16
+ get_metrics,
17
+ redact_sensitive_data,
18
+ )
19
+
20
+ __all__ = [
21
+ "get_request_id",
22
+ "set_request_id",
23
+ "cli_command",
24
+ "get_cli_logger",
25
+ "CLILogContext",
26
+ ]
27
+
28
+ T = TypeVar("T")
29
+
30
+ # Context variable for request/correlation ID
31
+ _request_id: ContextVar[str] = ContextVar("request_id", default="")
32
+
33
+
34
+ def generate_request_id() -> str:
35
+ """Generate a unique request ID for CLI command tracking.
36
+
37
+ Returns:
38
+ Short UUID suitable for log correlation.
39
+ """
40
+ return f"cli_{uuid.uuid4().hex[:12]}"
41
+
42
+
43
+ def get_request_id() -> str:
44
+ """Get the current request ID for this execution context.
45
+
46
+ Returns:
47
+ The request ID, or empty string if not set.
48
+ """
49
+ return _request_id.get()
50
+
51
+
52
+ def set_request_id(request_id: str) -> None:
53
+ """Set the request ID for this execution context.
54
+
55
+ Args:
56
+ request_id: The request ID to set.
57
+ """
58
+ _request_id.set(request_id)
59
+
60
+
61
+ class CLILogContext:
62
+ """Context manager for CLI command logging context.
63
+
64
+ Automatically generates and sets a request ID for the duration
65
+ of the context, enabling log correlation.
66
+
67
+ Example:
68
+ >>> with CLILogContext() as ctx:
69
+ ... logger.info("Processing", extra={"request_id": ctx.request_id})
70
+ """
71
+
72
+ def __init__(self, request_id: Optional[str] = None):
73
+ """Initialize logging context.
74
+
75
+ Args:
76
+ request_id: Optional custom request ID (auto-generated if None).
77
+ """
78
+ self.request_id = request_id or generate_request_id()
79
+ self._token = None
80
+
81
+ def __enter__(self) -> "CLILogContext":
82
+ self._token = _request_id.set(self.request_id)
83
+ return self
84
+
85
+ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
86
+ if self._token is not None:
87
+ _request_id.reset(self._token)
88
+
89
+
90
+ class CLILogger:
91
+ """Structured logger for CLI commands.
92
+
93
+ Provides JSON-formatted logging with automatic request ID inclusion
94
+ and sensitive data redaction.
95
+ """
96
+
97
+ def __init__(self, name: str = "foundry_mcp.cli"):
98
+ self._logger = logging.getLogger(name)
99
+
100
+ def _log(
101
+ self,
102
+ level: int,
103
+ message: str,
104
+ **extra: Any,
105
+ ) -> None:
106
+ """Log with structured context."""
107
+ request_id = get_request_id()
108
+ context = {
109
+ "request_id": request_id,
110
+ **redact_sensitive_data(extra),
111
+ }
112
+ self._logger.log(level, message, extra={"cli_context": context})
113
+
114
+ def debug(self, message: str, **extra: Any) -> None:
115
+ """Log debug message."""
116
+ self._log(logging.DEBUG, message, **extra)
117
+
118
+ def info(self, message: str, **extra: Any) -> None:
119
+ """Log info message."""
120
+ self._log(logging.INFO, message, **extra)
121
+
122
+ def warning(self, message: str, **extra: Any) -> None:
123
+ """Log warning message."""
124
+ self._log(logging.WARNING, message, **extra)
125
+
126
+ def error(self, message: str, **extra: Any) -> None:
127
+ """Log error message."""
128
+ self._log(logging.ERROR, message, **extra)
129
+
130
+
131
+ # Global CLI logger
132
+ _cli_logger = CLILogger()
133
+
134
+
135
+ def get_cli_logger() -> CLILogger:
136
+ """Get the global CLI logger."""
137
+ return _cli_logger
138
+
139
+
140
+ def cli_command(
141
+ command_name: Optional[str] = None,
142
+ emit_metrics: bool = True,
143
+ ) -> Callable[[Callable[..., T]], Callable[..., T]]:
144
+ """Decorator for CLI commands with observability.
145
+
146
+ Automatically:
147
+ - Generates request ID for correlation
148
+ - Logs command start/end
149
+ - Emits latency and status metrics
150
+
151
+ Args:
152
+ command_name: Override command name (defaults to function name).
153
+ emit_metrics: Whether to emit metrics (default: True).
154
+
155
+ Returns:
156
+ Decorated function with observability.
157
+
158
+ Example:
159
+ >>> @cli_command("fetch-spec")
160
+ ... def fetch_spec(spec_id: str):
161
+ ... return load_spec(spec_id)
162
+ """
163
+
164
+ def decorator(func: Callable[..., T]) -> Callable[..., T]:
165
+ name = command_name or func.__name__
166
+
167
+ @wraps(func)
168
+ def wrapper(*args: Any, **kwargs: Any) -> T:
169
+ with CLILogContext():
170
+ metrics = get_metrics()
171
+ start = time.perf_counter()
172
+ success = True
173
+ error_msg = None
174
+
175
+ _cli_logger.debug(
176
+ f"CLI command started: {name}",
177
+ command=name,
178
+ )
179
+
180
+ try:
181
+ result = func(*args, **kwargs)
182
+ return result
183
+ except Exception as e:
184
+ success = False
185
+ error_msg = str(e)
186
+ raise
187
+ finally:
188
+ duration_ms = (time.perf_counter() - start) * 1000
189
+
190
+ _cli_logger.debug(
191
+ f"CLI command completed: {name}",
192
+ command=name,
193
+ success=success,
194
+ duration_ms=round(duration_ms, 2),
195
+ error=error_msg,
196
+ )
197
+
198
+ if emit_metrics:
199
+ labels = {
200
+ "command": name,
201
+ "status": "success" if success else "error",
202
+ }
203
+ metrics.counter("cli.command.invocations", labels=labels)
204
+ metrics.timer(
205
+ "cli.command.latency",
206
+ duration_ms,
207
+ labels={"command": name},
208
+ )
209
+
210
+ return wrapper
211
+
212
+ return decorator
@@ -0,0 +1,44 @@
1
+ """SDD CLI entry point.
2
+
3
+ JSON-first output for AI coding assistants.
4
+
5
+ The current CLI emits response-v2 JSON envelopes by default; `--json` is
6
+ accepted as an explicit compatibility flag.
7
+ """
8
+
9
+ import click
10
+
11
+ from foundry_mcp.cli.config import create_context
12
+ from foundry_mcp.cli.registry import register_all_commands
13
+
14
+
15
+ @click.group()
16
+ @click.option(
17
+ "--specs-dir",
18
+ envvar="SDD_SPECS_DIR",
19
+ type=click.Path(exists=False),
20
+ help="Override specs directory path",
21
+ )
22
+ @click.option(
23
+ "--json",
24
+ "json_output",
25
+ is_flag=True,
26
+ help="Emit JSON response envelopes (default behavior).",
27
+ )
28
+ @click.pass_context
29
+ def cli(ctx: click.Context, specs_dir: str | None, json_output: bool) -> None:
30
+ """SDD CLI - Spec-Driven Development for AI assistants.
31
+
32
+ All commands output JSON for reliable parsing by AI coding tools.
33
+ """
34
+ ctx.ensure_object(dict)
35
+ ctx.obj["cli_context"] = create_context(specs_dir=specs_dir)
36
+ ctx.obj["json_output_requested"] = bool(json_output)
37
+
38
+
39
+ # Register all command groups
40
+ register_all_commands(cli)
41
+
42
+
43
+ if __name__ == "__main__":
44
+ cli()
@@ -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 with indent=2 for readability when debugging.
39
+
40
+ Args:
41
+ data: Any JSON-serializable data structure.
42
+ """
43
+ print(json.dumps(data, indent=2, 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), indent=2, 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