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,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,259 @@
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 ContextSession:
34
+ """
35
+ Tracks a CLI session with limits and markers.
36
+
37
+ Used for:
38
+ - Session identification across CLI invocations
39
+ - Tracking consultation usage against limits
40
+ - Providing context budget information
41
+ """
42
+ session_id: str
43
+ started_at: str
44
+ limits: SessionLimits = field(default_factory=SessionLimits)
45
+ stats: SessionStats = field(default_factory=SessionStats)
46
+ metadata: Dict[str, Any] = field(default_factory=dict)
47
+
48
+ @property
49
+ def consultations_remaining(self) -> int:
50
+ """Number of consultations remaining."""
51
+ return max(0, self.limits.max_consultations - self.stats.consultation_count)
52
+
53
+ @property
54
+ def tokens_remaining(self) -> int:
55
+ """Estimated tokens remaining in budget."""
56
+ return max(0, self.limits.max_context_tokens - self.stats.estimated_tokens_used)
57
+
58
+ @property
59
+ def consultation_usage_percentage(self) -> float:
60
+ """Percentage of consultation limit used."""
61
+ if self.limits.max_consultations == 0:
62
+ return 0.0
63
+ return (self.stats.consultation_count / self.limits.max_consultations) * 100
64
+
65
+ @property
66
+ def token_usage_percentage(self) -> float:
67
+ """Percentage of token budget used."""
68
+ if self.limits.max_context_tokens == 0:
69
+ return 0.0
70
+ return (self.stats.estimated_tokens_used / self.limits.max_context_tokens) * 100
71
+
72
+ @property
73
+ def should_warn(self) -> bool:
74
+ """Whether to warn about approaching limits."""
75
+ return (
76
+ self.consultation_usage_percentage >= self.limits.warn_at_percentage * 100 or
77
+ self.token_usage_percentage >= self.limits.warn_at_percentage * 100
78
+ )
79
+
80
+ @property
81
+ def at_limit(self) -> bool:
82
+ """Whether session has reached its limits."""
83
+ return (
84
+ self.stats.consultation_count >= self.limits.max_consultations or
85
+ self.stats.estimated_tokens_used >= self.limits.max_context_tokens
86
+ )
87
+
88
+ def to_dict(self) -> Dict[str, Any]:
89
+ """Convert to dictionary for JSON output."""
90
+ return {
91
+ "session_id": self.session_id,
92
+ "started_at": self.started_at,
93
+ "limits": {
94
+ "max_consultations": self.limits.max_consultations,
95
+ "max_context_tokens": self.limits.max_context_tokens,
96
+ "warn_at_percentage": self.limits.warn_at_percentage,
97
+ },
98
+ "stats": {
99
+ "consultation_count": self.stats.consultation_count,
100
+ "estimated_tokens_used": self.stats.estimated_tokens_used,
101
+ "commands_executed": self.stats.commands_executed,
102
+ "errors_encountered": self.stats.errors_encountered,
103
+ "last_activity": self.stats.last_activity,
104
+ },
105
+ "derived": {
106
+ "consultations_remaining": self.consultations_remaining,
107
+ "tokens_remaining": self.tokens_remaining,
108
+ "consultation_usage_percentage": round(self.consultation_usage_percentage, 1),
109
+ "token_usage_percentage": round(self.token_usage_percentage, 1),
110
+ "should_warn": self.should_warn,
111
+ "at_limit": self.at_limit,
112
+ },
113
+ "metadata": self.metadata,
114
+ }
115
+
116
+
117
+ class ContextTracker:
118
+ """
119
+ Tracks CLI session context across command invocations.
120
+
121
+ Provides:
122
+ - Session markers for correlation
123
+ - Consultation counting and limits
124
+ - Context budget tracking
125
+ """
126
+
127
+ def __init__(self):
128
+ self._session: Optional[ContextSession] = None
129
+ self._load_from_env()
130
+
131
+ def _load_from_env(self) -> None:
132
+ """Load limits from environment variables."""
133
+ self._default_limits = SessionLimits(
134
+ max_consultations=int(os.environ.get("SDD_MAX_CONSULTATIONS", "50")),
135
+ max_context_tokens=int(os.environ.get("SDD_MAX_CONTEXT_TOKENS", "100000")),
136
+ warn_at_percentage=float(os.environ.get("SDD_WARN_PERCENTAGE", "0.8")),
137
+ )
138
+
139
+ def start_session(
140
+ self,
141
+ session_id: Optional[str] = None,
142
+ limits: Optional[SessionLimits] = None,
143
+ metadata: Optional[Dict[str, Any]] = None,
144
+ ) -> ContextSession:
145
+ """
146
+ Start a new tracking session.
147
+
148
+ Args:
149
+ session_id: Custom session ID (auto-generated if not provided)
150
+ limits: Custom limits (uses defaults if not provided)
151
+ metadata: Optional session metadata
152
+
153
+ Returns:
154
+ The created ContextSession
155
+ """
156
+ self._session = ContextSession(
157
+ session_id=session_id or self._generate_session_id(),
158
+ started_at=datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
159
+ limits=limits or SessionLimits(
160
+ max_consultations=self._default_limits.max_consultations,
161
+ max_context_tokens=self._default_limits.max_context_tokens,
162
+ warn_at_percentage=self._default_limits.warn_at_percentage,
163
+ ),
164
+ metadata=metadata or {},
165
+ )
166
+ return self._session
167
+
168
+ def get_session(self) -> Optional[ContextSession]:
169
+ """Get the current session, if any."""
170
+ return self._session
171
+
172
+ def get_or_create_session(self) -> ContextSession:
173
+ """Get existing session or create a new one."""
174
+ if self._session is None:
175
+ return self.start_session()
176
+ return self._session
177
+
178
+ def record_consultation(self, estimated_tokens: int = 0) -> Dict[str, Any]:
179
+ """
180
+ Record an LLM consultation.
181
+
182
+ Args:
183
+ estimated_tokens: Estimated tokens used in this consultation
184
+
185
+ Returns:
186
+ Status dictionary with current usage info
187
+ """
188
+ session = self.get_or_create_session()
189
+ session.stats.consultation_count += 1
190
+ session.stats.estimated_tokens_used += estimated_tokens
191
+ session.stats.last_activity = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
192
+
193
+ return {
194
+ "consultation_number": session.stats.consultation_count,
195
+ "consultations_remaining": session.consultations_remaining,
196
+ "tokens_used": estimated_tokens,
197
+ "tokens_remaining": session.tokens_remaining,
198
+ "should_warn": session.should_warn,
199
+ "at_limit": session.at_limit,
200
+ }
201
+
202
+ def record_command(self, error: bool = False) -> None:
203
+ """Record a CLI command execution."""
204
+ session = self.get_or_create_session()
205
+ session.stats.commands_executed += 1
206
+ if error:
207
+ session.stats.errors_encountered += 1
208
+ session.stats.last_activity = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
209
+
210
+ def get_status(self) -> Dict[str, Any]:
211
+ """Get current session status."""
212
+ session = self._session
213
+ if session is None:
214
+ return {
215
+ "active": False,
216
+ "message": "No active session",
217
+ }
218
+ return {
219
+ "active": True,
220
+ **session.to_dict(),
221
+ }
222
+
223
+ def reset(self) -> None:
224
+ """Reset the current session."""
225
+ self._session = None
226
+
227
+ def _generate_session_id(self) -> str:
228
+ """Generate a unique session ID."""
229
+ return f"sdd_{uuid.uuid4().hex[:12]}"
230
+
231
+
232
+ # Global context tracker
233
+ _tracker: Optional[ContextTracker] = None
234
+
235
+
236
+ def get_context_tracker() -> ContextTracker:
237
+ """Get the global context tracker."""
238
+ global _tracker
239
+ if _tracker is None:
240
+ _tracker = ContextTracker()
241
+ return _tracker
242
+
243
+
244
+ def start_cli_session(
245
+ session_id: Optional[str] = None,
246
+ metadata: Optional[Dict[str, Any]] = None,
247
+ ) -> ContextSession:
248
+ """Start a new CLI session."""
249
+ return get_context_tracker().start_session(session_id=session_id, metadata=metadata)
250
+
251
+
252
+ def get_session_status() -> Dict[str, Any]:
253
+ """Get current session status."""
254
+ return get_context_tracker().get_status()
255
+
256
+
257
+ def record_consultation(estimated_tokens: int = 0) -> Dict[str, Any]:
258
+ """Record an LLM consultation."""
259
+ return get_context_tracker().record_consultation(estimated_tokens)
@@ -0,0 +1,266 @@
1
+ """CLI feature flag bootstrap bridging CLI options and discovery manifest.
2
+
3
+ Provides CLI-specific flag management that wraps the core feature flag
4
+ infrastructure, enabling runtime flag overrides via CLI options and
5
+ exposing flag status for tool discovery.
6
+
7
+ See docs/mcp_best_practices/14-feature-flags.md for guidance.
8
+ """
9
+
10
+ from typing import Any, Callable, Dict, List, Optional, TypeVar
11
+
12
+ import click
13
+
14
+ from foundry_mcp.core.feature_flags import (
15
+ FeatureFlag,
16
+ FeatureFlagRegistry,
17
+ FlagState,
18
+ get_registry,
19
+ )
20
+
21
+ __all__ = [
22
+ "CLIFlagRegistry",
23
+ "get_cli_flags",
24
+ "apply_cli_flag_overrides",
25
+ "flags_for_discovery",
26
+ "with_flag_options",
27
+ ]
28
+
29
+ T = TypeVar("T")
30
+
31
+
32
+ class CLIFlagRegistry:
33
+ """Registry of CLI-specific feature flags.
34
+
35
+ Wraps the core FeatureFlagRegistry and provides CLI-specific
36
+ functionality like mapping command-line options to flag overrides
37
+ and generating discovery manifests.
38
+
39
+ Example:
40
+ >>> registry = CLIFlagRegistry()
41
+ >>> registry.register_cli_flag(
42
+ ... name="experimental_commands",
43
+ ... description="Enable experimental CLI commands",
44
+ ... default_enabled=False,
45
+ ... state=FlagState.BETA,
46
+ ... )
47
+ >>> registry.is_enabled("experimental_commands")
48
+ False
49
+ """
50
+
51
+ def __init__(self, core_registry: Optional[FeatureFlagRegistry] = None):
52
+ """Initialize with optional core registry.
53
+
54
+ Args:
55
+ core_registry: Core feature flag registry. Uses global if None.
56
+ """
57
+ self._core = core_registry or get_registry()
58
+ self._cli_flags: Dict[str, FeatureFlag] = {}
59
+
60
+ def register_cli_flag(
61
+ self,
62
+ name: str,
63
+ description: str,
64
+ default_enabled: bool = False,
65
+ state: FlagState = FlagState.BETA,
66
+ **kwargs: Any,
67
+ ) -> None:
68
+ """Register a CLI-specific feature flag.
69
+
70
+ Creates a flag in both the CLI registry and the core registry
71
+ for unified evaluation.
72
+
73
+ Args:
74
+ name: Unique flag identifier (e.g., "experimental_commands").
75
+ description: Human-readable description for discovery.
76
+ default_enabled: Whether enabled by default.
77
+ state: Flag lifecycle state.
78
+ **kwargs: Additional FeatureFlag parameters.
79
+ """
80
+ flag = FeatureFlag(
81
+ name=name,
82
+ description=description,
83
+ default_enabled=default_enabled,
84
+ state=state,
85
+ **kwargs,
86
+ )
87
+ self._cli_flags[name] = flag
88
+ try:
89
+ self._core.register(flag)
90
+ except ValueError:
91
+ # Flag already registered in core, update our local copy
92
+ existing = self._core.get(name)
93
+ if existing:
94
+ self._cli_flags[name] = existing
95
+
96
+ def is_enabled(self, flag_name: str, default: bool = False) -> bool:
97
+ """Check if a CLI flag is enabled.
98
+
99
+ Args:
100
+ flag_name: Name of the flag to check.
101
+ default: Value if flag doesn't exist.
102
+
103
+ Returns:
104
+ True if flag is enabled, False otherwise.
105
+ """
106
+ return self._core.is_enabled(flag_name, client_id="cli", default=default)
107
+
108
+ def apply_overrides(self, overrides: Dict[str, bool]) -> None:
109
+ """Apply multiple flag overrides.
110
+
111
+ Used to translate CLI options into flag state. Overrides
112
+ persist for the duration of the CLI command execution.
113
+
114
+ Args:
115
+ overrides: Mapping of flag names to enabled/disabled state.
116
+ """
117
+ for flag_name, enabled in overrides.items():
118
+ self._core.set_override("cli", flag_name, enabled)
119
+
120
+ def clear_overrides(self) -> None:
121
+ """Clear all CLI-applied overrides."""
122
+ self._core.clear_all_overrides("cli")
123
+
124
+ def get_discovery_manifest(self) -> Dict[str, Dict[str, Any]]:
125
+ """Generate discovery manifest for CLI flags.
126
+
127
+ Returns flag information suitable for tool discovery responses,
128
+ allowing AI coding assistants to understand available features.
129
+
130
+ Returns:
131
+ Dictionary with flag names as keys and info dicts as values.
132
+ """
133
+ manifest = {}
134
+ for name, flag in self._cli_flags.items():
135
+ manifest[name] = {
136
+ "enabled": self.is_enabled(name),
137
+ "state": flag.state.value,
138
+ "description": flag.description,
139
+ "default": flag.default_enabled,
140
+ }
141
+ if flag.state == FlagState.DEPRECATED and flag.expires_at:
142
+ manifest[name]["expires"] = flag.expires_at.isoformat()
143
+ return manifest
144
+
145
+ def list_flags(self) -> List[str]:
146
+ """List all registered CLI flag names."""
147
+ return list(self._cli_flags.keys())
148
+
149
+
150
+ # Global CLI flag registry
151
+ _cli_registry: Optional[CLIFlagRegistry] = None
152
+
153
+
154
+ def get_cli_flags() -> CLIFlagRegistry:
155
+ """Get the global CLI flag registry."""
156
+ global _cli_registry
157
+ if _cli_registry is None:
158
+ _cli_registry = CLIFlagRegistry()
159
+ return _cli_registry
160
+
161
+
162
+ def apply_cli_flag_overrides(
163
+ enable: Optional[List[str]] = None,
164
+ disable: Optional[List[str]] = None,
165
+ ) -> None:
166
+ """Apply flag overrides from CLI options.
167
+
168
+ Translates --enable-feature and --disable-feature CLI options
169
+ into feature flag overrides.
170
+
171
+ Args:
172
+ enable: List of flag names to enable.
173
+ disable: List of flag names to disable.
174
+ """
175
+ registry = get_cli_flags()
176
+ overrides: Dict[str, bool] = {}
177
+
178
+ if enable:
179
+ for flag_name in enable:
180
+ overrides[flag_name] = True
181
+
182
+ if disable:
183
+ for flag_name in disable:
184
+ overrides[flag_name] = False
185
+
186
+ if overrides:
187
+ registry.apply_overrides(overrides)
188
+
189
+
190
+ def flags_for_discovery() -> Dict[str, Any]:
191
+ """Get flag status for inclusion in discovery responses.
192
+
193
+ Returns:
194
+ Dictionary suitable for JSON serialization in discovery manifest.
195
+ """
196
+ return get_cli_flags().get_discovery_manifest()
197
+
198
+
199
+ def with_flag_options(
200
+ func: Optional[Callable[..., T]] = None,
201
+ ) -> Callable[..., T]:
202
+ """Click decorator that adds --enable-feature/--disable-feature options.
203
+
204
+ Adds common flag override options to a Click command and applies
205
+ them before command execution.
206
+
207
+ Example:
208
+ >>> @cli.command()
209
+ ... @with_flag_options
210
+ ... def my_command():
211
+ ... # flags are already applied
212
+ ... if get_cli_flags().is_enabled("experimental"):
213
+ ... do_experimental_thing()
214
+
215
+ Args:
216
+ func: The Click command function to wrap.
217
+
218
+ Returns:
219
+ Decorated function with flag options.
220
+ """
221
+
222
+ def decorator(f: Callable[..., T]) -> Callable[..., T]:
223
+ # Add the Click options
224
+ f = click.option(
225
+ "--enable-feature",
226
+ "enable_features",
227
+ multiple=True,
228
+ help="Enable feature flag(s) for this command.",
229
+ )(f)
230
+ f = click.option(
231
+ "--disable-feature",
232
+ "disable_features",
233
+ multiple=True,
234
+ help="Disable feature flag(s) for this command.",
235
+ )(f)
236
+
237
+ # Wrap to apply flags before execution
238
+ original = f
239
+
240
+ @click.pass_context
241
+ def wrapper(ctx: click.Context, *args: Any, **kwargs: Any) -> T:
242
+ enable = kwargs.pop("enable_features", ())
243
+ disable = kwargs.pop("disable_features", ())
244
+
245
+ apply_cli_flag_overrides(
246
+ enable=list(enable) if enable else None,
247
+ disable=list(disable) if disable else None,
248
+ )
249
+
250
+ try:
251
+ # Call original with remaining kwargs
252
+ return ctx.invoke(original, *args, **kwargs)
253
+ finally:
254
+ # Clean up overrides after command
255
+ get_cli_flags().clear_overrides()
256
+
257
+ # Preserve function metadata
258
+ wrapper.__name__ = f.__name__
259
+ wrapper.__doc__ = f.__doc__
260
+
261
+ return wrapper # type: ignore[return-value]
262
+
263
+ if func is not None:
264
+ return decorator(func)
265
+
266
+ return decorator # type: ignore[return-value]