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.
- foundry_mcp/__init__.py +7 -0
- foundry_mcp/cli/__init__.py +80 -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 +633 -0
- foundry_mcp/cli/commands/pr.py +393 -0
- foundry_mcp/cli/commands/review.py +652 -0
- foundry_mcp/cli/commands/session.py +479 -0
- foundry_mcp/cli/commands/specs.py +856 -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 +259 -0
- foundry_mcp/cli/flags.py +266 -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 +850 -0
- foundry_mcp/core/__init__.py +144 -0
- foundry_mcp/core/ai_consultation.py +1636 -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/feature_flags.py +592 -0
- foundry_mcp/core/health.py +749 -0
- foundry_mcp/core/journal.py +694 -0
- foundry_mcp/core/lifecycle.py +412 -0
- foundry_mcp/core/llm_config.py +1350 -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 +123 -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 +317 -0
- foundry_mcp/core/prometheus.py +577 -0
- foundry_mcp/core/prompts/__init__.py +464 -0
- foundry_mcp/core/prompts/fidelity_review.py +546 -0
- foundry_mcp/core/prompts/markdown_plan_review.py +511 -0
- foundry_mcp/core/prompts/plan_review.py +623 -0
- foundry_mcp/core/providers/__init__.py +225 -0
- foundry_mcp/core/providers/base.py +476 -0
- foundry_mcp/core/providers/claude.py +460 -0
- foundry_mcp/core/providers/codex.py +619 -0
- foundry_mcp/core/providers/cursor_agent.py +642 -0
- foundry_mcp/core/providers/detectors.py +488 -0
- foundry_mcp/core/providers/gemini.py +405 -0
- foundry_mcp/core/providers/opencode.py +616 -0
- foundry_mcp/core/providers/opencode_wrapper.js +302 -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 +729 -0
- foundry_mcp/core/rate_limit.py +427 -0
- foundry_mcp/core/resilience.py +600 -0
- foundry_mcp/core/responses.py +934 -0
- foundry_mcp/core/review.py +366 -0
- foundry_mcp/core/security.py +438 -0
- foundry_mcp/core/spec.py +1650 -0
- foundry_mcp/core/task.py +1289 -0
- foundry_mcp/core/testing.py +450 -0
- foundry_mcp/core/validation.py +2081 -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 +234 -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 +289 -0
- foundry_mcp/dashboard/views/__init__.py +12 -0
- foundry_mcp/dashboard/views/errors.py +217 -0
- foundry_mcp/dashboard/views/metrics.py +174 -0
- foundry_mcp/dashboard/views/overview.py +160 -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/sdd-spec-schema.json +386 -0
- foundry_mcp/server.py +164 -0
- foundry_mcp/tools/__init__.py +10 -0
- foundry_mcp/tools/unified/__init__.py +71 -0
- foundry_mcp/tools/unified/authoring.py +1487 -0
- foundry_mcp/tools/unified/context_helpers.py +98 -0
- foundry_mcp/tools/unified/documentation_helpers.py +198 -0
- foundry_mcp/tools/unified/environment.py +939 -0
- foundry_mcp/tools/unified/error.py +462 -0
- foundry_mcp/tools/unified/health.py +225 -0
- foundry_mcp/tools/unified/journal.py +841 -0
- foundry_mcp/tools/unified/lifecycle.py +632 -0
- foundry_mcp/tools/unified/metrics.py +777 -0
- foundry_mcp/tools/unified/plan.py +745 -0
- foundry_mcp/tools/unified/pr.py +294 -0
- foundry_mcp/tools/unified/provider.py +629 -0
- foundry_mcp/tools/unified/review.py +685 -0
- foundry_mcp/tools/unified/review_helpers.py +299 -0
- foundry_mcp/tools/unified/router.py +102 -0
- foundry_mcp/tools/unified/server.py +580 -0
- foundry_mcp/tools/unified/spec.py +808 -0
- foundry_mcp/tools/unified/task.py +2202 -0
- foundry_mcp/tools/unified/test.py +370 -0
- foundry_mcp/tools/unified/verification.py +520 -0
- foundry_mcp-0.3.3.dist-info/METADATA +337 -0
- foundry_mcp-0.3.3.dist-info/RECORD +135 -0
- foundry_mcp-0.3.3.dist-info/WHEEL +4 -0
- foundry_mcp-0.3.3.dist-info/entry_points.txt +3 -0
- 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
|
foundry_mcp/cli/main.py
ADDED
|
@@ -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
|