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,98 @@
|
|
|
1
|
+
"""CLI configuration and workspace detection.
|
|
2
|
+
|
|
3
|
+
Provides configuration handling for the SDD CLI, leveraging the
|
|
4
|
+
shared foundry_mcp.config module and core spec utilities.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
from foundry_mcp.config import ServerConfig, get_config as get_server_config
|
|
11
|
+
from foundry_mcp.core.spec import find_specs_directory
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CLIContext:
|
|
15
|
+
"""CLI execution context with resolved configuration.
|
|
16
|
+
|
|
17
|
+
Holds the effective configuration for a CLI command, including
|
|
18
|
+
any overrides from command-line options.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
specs_dir: Optional[str] = None,
|
|
24
|
+
server_config: Optional[ServerConfig] = None,
|
|
25
|
+
):
|
|
26
|
+
"""Initialize CLI context.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
specs_dir: Explicit specs directory override from --specs-dir.
|
|
30
|
+
server_config: Optional server config (uses global if not provided).
|
|
31
|
+
"""
|
|
32
|
+
self._specs_dir_override = specs_dir
|
|
33
|
+
self._config = server_config or get_server_config()
|
|
34
|
+
self._resolved_specs_dir: Optional[Path] = None
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def specs_dir(self) -> Optional[Path]:
|
|
38
|
+
"""Get the resolved specs directory.
|
|
39
|
+
|
|
40
|
+
Resolution order:
|
|
41
|
+
1. CLI --specs-dir option (highest priority)
|
|
42
|
+
2. ServerConfig.specs_dir (from env/TOML)
|
|
43
|
+
3. Auto-detected via find_specs_directory()
|
|
44
|
+
"""
|
|
45
|
+
if self._resolved_specs_dir is not None:
|
|
46
|
+
return self._resolved_specs_dir
|
|
47
|
+
|
|
48
|
+
# CLI override takes priority
|
|
49
|
+
if self._specs_dir_override:
|
|
50
|
+
self._resolved_specs_dir = Path(self._specs_dir_override).resolve()
|
|
51
|
+
return self._resolved_specs_dir
|
|
52
|
+
|
|
53
|
+
# Server config next
|
|
54
|
+
if self._config.specs_dir:
|
|
55
|
+
self._resolved_specs_dir = self._config.specs_dir.resolve()
|
|
56
|
+
return self._resolved_specs_dir
|
|
57
|
+
|
|
58
|
+
# Auto-detect
|
|
59
|
+
detected = find_specs_directory()
|
|
60
|
+
if detected:
|
|
61
|
+
self._resolved_specs_dir = detected
|
|
62
|
+
return self._resolved_specs_dir
|
|
63
|
+
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def config(self) -> ServerConfig:
|
|
68
|
+
"""Get the underlying server configuration."""
|
|
69
|
+
return self._config
|
|
70
|
+
|
|
71
|
+
def require_specs_dir(self) -> Path:
|
|
72
|
+
"""Get specs directory, raising if not found.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
Resolved specs directory path.
|
|
76
|
+
|
|
77
|
+
Raises:
|
|
78
|
+
FileNotFoundError: If no specs directory could be resolved.
|
|
79
|
+
"""
|
|
80
|
+
specs = self.specs_dir
|
|
81
|
+
if specs is None:
|
|
82
|
+
raise FileNotFoundError(
|
|
83
|
+
"No specs directory found. "
|
|
84
|
+
"Use --specs-dir or set SDD_SPECS_DIR environment variable."
|
|
85
|
+
)
|
|
86
|
+
return specs
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def create_context(specs_dir: Optional[str] = None) -> CLIContext:
|
|
90
|
+
"""Create a CLI context with optional overrides.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
specs_dir: Optional specs directory override.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
Configured CLIContext instance.
|
|
97
|
+
"""
|
|
98
|
+
return CLIContext(specs_dir=specs_dir)
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""Context tracking for SDD CLI sessions.
|
|
2
|
+
|
|
3
|
+
Provides session markers, consultation limits, and context window tracking
|
|
4
|
+
for CLI-driven LLM workflows.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from typing import Any, Dict, Optional
|
|
10
|
+
import os
|
|
11
|
+
import uuid
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class SessionLimits:
|
|
16
|
+
"""Configurable limits for a CLI session."""
|
|
17
|
+
max_consultations: int = 50 # Max LLM consultations per session
|
|
18
|
+
max_context_tokens: int = 100000 # Approximate token budget
|
|
19
|
+
warn_at_percentage: float = 0.8 # Warn at 80% usage
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class SessionStats:
|
|
24
|
+
"""Runtime statistics for a CLI session."""
|
|
25
|
+
consultation_count: int = 0
|
|
26
|
+
estimated_tokens_used: int = 0
|
|
27
|
+
commands_executed: int = 0
|
|
28
|
+
errors_encountered: int = 0
|
|
29
|
+
last_activity: Optional[str] = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class AutonomousSession:
|
|
34
|
+
"""
|
|
35
|
+
Ephemeral state for autonomous task execution mode.
|
|
36
|
+
|
|
37
|
+
This tracks whether the agent is running in autonomous mode where
|
|
38
|
+
it continues to the next task without explicit user confirmation.
|
|
39
|
+
|
|
40
|
+
NOTE: This state is EPHEMERAL - it exists only in memory and does
|
|
41
|
+
not persist across CLI restarts. Each new CLI session starts with
|
|
42
|
+
autonomous mode disabled.
|
|
43
|
+
"""
|
|
44
|
+
enabled: bool = False
|
|
45
|
+
tasks_completed: int = 0
|
|
46
|
+
pause_reason: Optional[str] = None # Why auto-mode paused: "limit", "error", "user", "context"
|
|
47
|
+
started_at: Optional[str] = None # ISO timestamp when auto-mode was enabled
|
|
48
|
+
|
|
49
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
50
|
+
"""Convert to dictionary for JSON output."""
|
|
51
|
+
return {
|
|
52
|
+
"enabled": self.enabled,
|
|
53
|
+
"tasks_completed": self.tasks_completed,
|
|
54
|
+
"pause_reason": self.pause_reason,
|
|
55
|
+
"started_at": self.started_at,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
def from_dict(cls, data: Dict[str, Any]) -> "AutonomousSession":
|
|
60
|
+
"""Create from dictionary (for in-session use only)."""
|
|
61
|
+
return cls(
|
|
62
|
+
enabled=data.get("enabled", False),
|
|
63
|
+
tasks_completed=data.get("tasks_completed", 0),
|
|
64
|
+
pause_reason=data.get("pause_reason"),
|
|
65
|
+
started_at=data.get("started_at"),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class ContextSession:
|
|
71
|
+
"""
|
|
72
|
+
Tracks a CLI session with limits and markers.
|
|
73
|
+
|
|
74
|
+
Used for:
|
|
75
|
+
- Session identification across CLI invocations
|
|
76
|
+
- Tracking consultation usage against limits
|
|
77
|
+
- Providing context budget information
|
|
78
|
+
"""
|
|
79
|
+
session_id: str
|
|
80
|
+
started_at: str
|
|
81
|
+
limits: SessionLimits = field(default_factory=SessionLimits)
|
|
82
|
+
stats: SessionStats = field(default_factory=SessionStats)
|
|
83
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
84
|
+
autonomous: Optional[AutonomousSession] = None
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def consultations_remaining(self) -> int:
|
|
88
|
+
"""Number of consultations remaining."""
|
|
89
|
+
return max(0, self.limits.max_consultations - self.stats.consultation_count)
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def tokens_remaining(self) -> int:
|
|
93
|
+
"""Estimated tokens remaining in budget."""
|
|
94
|
+
return max(0, self.limits.max_context_tokens - self.stats.estimated_tokens_used)
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def consultation_usage_percentage(self) -> float:
|
|
98
|
+
"""Percentage of consultation limit used."""
|
|
99
|
+
if self.limits.max_consultations == 0:
|
|
100
|
+
return 0.0
|
|
101
|
+
return (self.stats.consultation_count / self.limits.max_consultations) * 100
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def token_usage_percentage(self) -> float:
|
|
105
|
+
"""Percentage of token budget used."""
|
|
106
|
+
if self.limits.max_context_tokens == 0:
|
|
107
|
+
return 0.0
|
|
108
|
+
return (self.stats.estimated_tokens_used / self.limits.max_context_tokens) * 100
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def should_warn(self) -> bool:
|
|
112
|
+
"""Whether to warn about approaching limits."""
|
|
113
|
+
return (
|
|
114
|
+
self.consultation_usage_percentage >= self.limits.warn_at_percentage * 100 or
|
|
115
|
+
self.token_usage_percentage >= self.limits.warn_at_percentage * 100
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def at_limit(self) -> bool:
|
|
120
|
+
"""Whether session has reached its limits."""
|
|
121
|
+
return (
|
|
122
|
+
self.stats.consultation_count >= self.limits.max_consultations or
|
|
123
|
+
self.stats.estimated_tokens_used >= self.limits.max_context_tokens
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
127
|
+
"""Convert to dictionary for JSON output."""
|
|
128
|
+
return {
|
|
129
|
+
"session_id": self.session_id,
|
|
130
|
+
"started_at": self.started_at,
|
|
131
|
+
"limits": {
|
|
132
|
+
"max_consultations": self.limits.max_consultations,
|
|
133
|
+
"max_context_tokens": self.limits.max_context_tokens,
|
|
134
|
+
"warn_at_percentage": self.limits.warn_at_percentage,
|
|
135
|
+
},
|
|
136
|
+
"stats": {
|
|
137
|
+
"consultation_count": self.stats.consultation_count,
|
|
138
|
+
"estimated_tokens_used": self.stats.estimated_tokens_used,
|
|
139
|
+
"commands_executed": self.stats.commands_executed,
|
|
140
|
+
"errors_encountered": self.stats.errors_encountered,
|
|
141
|
+
"last_activity": self.stats.last_activity,
|
|
142
|
+
},
|
|
143
|
+
"derived": {
|
|
144
|
+
"consultations_remaining": self.consultations_remaining,
|
|
145
|
+
"tokens_remaining": self.tokens_remaining,
|
|
146
|
+
"consultation_usage_percentage": round(self.consultation_usage_percentage, 1),
|
|
147
|
+
"token_usage_percentage": round(self.token_usage_percentage, 1),
|
|
148
|
+
"should_warn": self.should_warn,
|
|
149
|
+
"at_limit": self.at_limit,
|
|
150
|
+
},
|
|
151
|
+
"metadata": self.metadata,
|
|
152
|
+
"autonomous": self.autonomous.to_dict() if self.autonomous else None,
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class ContextTracker:
|
|
157
|
+
"""
|
|
158
|
+
Tracks CLI session context across command invocations.
|
|
159
|
+
|
|
160
|
+
Provides:
|
|
161
|
+
- Session markers for correlation
|
|
162
|
+
- Consultation counting and limits
|
|
163
|
+
- Context budget tracking
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
def __init__(self):
|
|
167
|
+
self._session: Optional[ContextSession] = None
|
|
168
|
+
self._load_from_env()
|
|
169
|
+
|
|
170
|
+
def _load_from_env(self) -> None:
|
|
171
|
+
"""Load limits from environment variables."""
|
|
172
|
+
self._default_limits = SessionLimits(
|
|
173
|
+
max_consultations=int(os.environ.get("SDD_MAX_CONSULTATIONS", "50")),
|
|
174
|
+
max_context_tokens=int(os.environ.get("SDD_MAX_CONTEXT_TOKENS", "100000")),
|
|
175
|
+
warn_at_percentage=float(os.environ.get("SDD_WARN_PERCENTAGE", "0.8")),
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
def start_session(
|
|
179
|
+
self,
|
|
180
|
+
session_id: Optional[str] = None,
|
|
181
|
+
limits: Optional[SessionLimits] = None,
|
|
182
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
183
|
+
) -> ContextSession:
|
|
184
|
+
"""
|
|
185
|
+
Start a new tracking session.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
session_id: Custom session ID (auto-generated if not provided)
|
|
189
|
+
limits: Custom limits (uses defaults if not provided)
|
|
190
|
+
metadata: Optional session metadata
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
The created ContextSession
|
|
194
|
+
"""
|
|
195
|
+
self._session = ContextSession(
|
|
196
|
+
session_id=session_id or self._generate_session_id(),
|
|
197
|
+
started_at=datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
|
198
|
+
limits=limits or SessionLimits(
|
|
199
|
+
max_consultations=self._default_limits.max_consultations,
|
|
200
|
+
max_context_tokens=self._default_limits.max_context_tokens,
|
|
201
|
+
warn_at_percentage=self._default_limits.warn_at_percentage,
|
|
202
|
+
),
|
|
203
|
+
metadata=metadata or {},
|
|
204
|
+
)
|
|
205
|
+
return self._session
|
|
206
|
+
|
|
207
|
+
def get_session(self) -> Optional[ContextSession]:
|
|
208
|
+
"""Get the current session, if any."""
|
|
209
|
+
return self._session
|
|
210
|
+
|
|
211
|
+
def get_or_create_session(self) -> ContextSession:
|
|
212
|
+
"""Get existing session or create a new one."""
|
|
213
|
+
if self._session is None:
|
|
214
|
+
return self.start_session()
|
|
215
|
+
return self._session
|
|
216
|
+
|
|
217
|
+
def record_consultation(self, estimated_tokens: int = 0) -> Dict[str, Any]:
|
|
218
|
+
"""
|
|
219
|
+
Record an LLM consultation.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
estimated_tokens: Estimated tokens used in this consultation
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
Status dictionary with current usage info
|
|
226
|
+
"""
|
|
227
|
+
session = self.get_or_create_session()
|
|
228
|
+
session.stats.consultation_count += 1
|
|
229
|
+
session.stats.estimated_tokens_used += estimated_tokens
|
|
230
|
+
session.stats.last_activity = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
"consultation_number": session.stats.consultation_count,
|
|
234
|
+
"consultations_remaining": session.consultations_remaining,
|
|
235
|
+
"tokens_used": estimated_tokens,
|
|
236
|
+
"tokens_remaining": session.tokens_remaining,
|
|
237
|
+
"should_warn": session.should_warn,
|
|
238
|
+
"at_limit": session.at_limit,
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
def record_command(self, error: bool = False) -> None:
|
|
242
|
+
"""Record a CLI command execution."""
|
|
243
|
+
session = self.get_or_create_session()
|
|
244
|
+
session.stats.commands_executed += 1
|
|
245
|
+
if error:
|
|
246
|
+
session.stats.errors_encountered += 1
|
|
247
|
+
session.stats.last_activity = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
248
|
+
|
|
249
|
+
def get_status(self) -> Dict[str, Any]:
|
|
250
|
+
"""Get current session status."""
|
|
251
|
+
session = self._session
|
|
252
|
+
if session is None:
|
|
253
|
+
return {
|
|
254
|
+
"active": False,
|
|
255
|
+
"message": "No active session",
|
|
256
|
+
}
|
|
257
|
+
return {
|
|
258
|
+
"active": True,
|
|
259
|
+
**session.to_dict(),
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
def reset(self) -> None:
|
|
263
|
+
"""Reset the current session."""
|
|
264
|
+
self._session = None
|
|
265
|
+
|
|
266
|
+
def _generate_session_id(self) -> str:
|
|
267
|
+
"""Generate a unique session ID."""
|
|
268
|
+
return f"sdd_{uuid.uuid4().hex[:12]}"
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
# Global context tracker
|
|
272
|
+
_tracker: Optional[ContextTracker] = None
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def get_context_tracker() -> ContextTracker:
|
|
276
|
+
"""Get the global context tracker."""
|
|
277
|
+
global _tracker
|
|
278
|
+
if _tracker is None:
|
|
279
|
+
_tracker = ContextTracker()
|
|
280
|
+
return _tracker
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def start_cli_session(
|
|
284
|
+
session_id: Optional[str] = None,
|
|
285
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
286
|
+
) -> ContextSession:
|
|
287
|
+
"""Start a new CLI session."""
|
|
288
|
+
return get_context_tracker().start_session(session_id=session_id, metadata=metadata)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def get_session_status() -> Dict[str, Any]:
|
|
292
|
+
"""Get current session status."""
|
|
293
|
+
return get_context_tracker().get_status()
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def record_consultation(estimated_tokens: int = 0) -> Dict[str, Any]:
|
|
297
|
+
"""Record an LLM consultation."""
|
|
298
|
+
return get_context_tracker().record_consultation(estimated_tokens)
|
|
@@ -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()
|