gd-specflow 0.2.0__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.
- cli.py +577 -0
- gd_specflow-0.2.0.dist-info/METADATA +26 -0
- gd_specflow-0.2.0.dist-info/RECORD +31 -0
- gd_specflow-0.2.0.dist-info/WHEEL +5 -0
- gd_specflow-0.2.0.dist-info/entry_points.txt +3 -0
- gd_specflow-0.2.0.dist-info/top_level.txt +4 -0
- schemas/__init__.py +0 -0
- schemas/gain_json.py +56 -0
- schemas/generation_workflow_enums.py +27 -0
- schemas/specification.py +28 -0
- server.py +653 -0
- services/__init__.py +0 -0
- services/archive_builder.py +153 -0
- services/bundled_skills.py +70 -0
- services/cli_service.py +255 -0
- services/document_reader.py +377 -0
- services/file_sync.py +263 -0
- services/file_sync_orchestrator.py +136 -0
- services/generation_orchestrator.py +221 -0
- services/llm_tiers.py +37 -0
- services/run_generation_precheck.py +211 -0
- services/server_instructions.py +85 -0
- services/session.py +185 -0
- services/skills/__init__.py +0 -0
- services/skills/specflow-analysis/SKILL.md +488 -0
- services/skills/specflow-compare-variants/SKILL.md +255 -0
- services/skills/specflow-diagnose/SKILL.md +156 -0
- services/skills/specflow-planning/SKILL.md +209 -0
- services/specflow_backend.py +194 -0
- services/tool_helpers.py +115 -0
- services/user_response.py +44 -0
cli.py
ADDED
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
"""SpecFlow CLI — local-only, no-auth, localhost-default.
|
|
2
|
+
|
|
3
|
+
A thin client over existing mcp_server service code. Intended for users who
|
|
4
|
+
cannot use the MCP server (Cursor / Claude Code). All backend interaction
|
|
5
|
+
reuses SpecFlowBackendService; all path resolution reuses session.py.
|
|
6
|
+
|
|
7
|
+
Entry point: specflow <command> [options]
|
|
8
|
+
Also runnable as: python -m cli
|
|
9
|
+
|
|
10
|
+
Commands:
|
|
11
|
+
run-generation Upload specs and start code generation
|
|
12
|
+
check-status Poll the status of the current generation
|
|
13
|
+
retry-generation Retry a failed generation
|
|
14
|
+
download-outputs Download and extract completed generation outputs
|
|
15
|
+
clear-workspace Free a CLEANING workspace set early
|
|
16
|
+
sessions List active generation sessions
|
|
17
|
+
|
|
18
|
+
Local-only invariant: refuses to connect to non-localhost URLs unless --force
|
|
19
|
+
is passed. No API key is ever sent in local mode.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import argparse
|
|
23
|
+
import asyncio
|
|
24
|
+
import datetime
|
|
25
|
+
import json
|
|
26
|
+
import logging
|
|
27
|
+
import os
|
|
28
|
+
import sys
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Any
|
|
31
|
+
from urllib.parse import urlparse
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
# Config resolution
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
_MCP_CONFIG_FILENAME = ".specflow-local/mcp-config.json"
|
|
40
|
+
_DEFAULT_BACKEND_URL = "http://127.0.0.1:8000"
|
|
41
|
+
_LOCALHOST_HOSTS = {"localhost", "127.0.0.1"}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _load_mcp_config(root: Path) -> dict[str, Any]:
|
|
45
|
+
"""Load .specflow-local/mcp-config.json from the project root, if present."""
|
|
46
|
+
config_path = root / _MCP_CONFIG_FILENAME
|
|
47
|
+
try:
|
|
48
|
+
if config_path.exists():
|
|
49
|
+
data = json.loads(config_path.read_text())
|
|
50
|
+
env_block = (
|
|
51
|
+
data.get("mcpServers", {})
|
|
52
|
+
.get("specflow", {})
|
|
53
|
+
.get("env", {})
|
|
54
|
+
)
|
|
55
|
+
return env_block if isinstance(env_block, dict) else {}
|
|
56
|
+
except Exception as exc:
|
|
57
|
+
logger.debug("Could not read mcp-config.json: %s", exc)
|
|
58
|
+
return {}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def resolve_backend_config(
|
|
62
|
+
backend_url_flag: str | None,
|
|
63
|
+
user_email_flag: str | None,
|
|
64
|
+
workspace_count_flag: int | None,
|
|
65
|
+
root: Path,
|
|
66
|
+
) -> tuple[str, str | None, int | None]:
|
|
67
|
+
"""Resolve backend URL, user email, and workspace count.
|
|
68
|
+
|
|
69
|
+
Priority: CLI flag → env var → mcp-config.json → default.
|
|
70
|
+
|
|
71
|
+
Returns (backend_url, user_email, workspace_count).
|
|
72
|
+
"""
|
|
73
|
+
mcp_env = _load_mcp_config(root)
|
|
74
|
+
|
|
75
|
+
backend_url = (
|
|
76
|
+
backend_url_flag
|
|
77
|
+
or os.getenv("BACKEND_URL")
|
|
78
|
+
or mcp_env.get("BACKEND_URL")
|
|
79
|
+
or _DEFAULT_BACKEND_URL
|
|
80
|
+
)
|
|
81
|
+
user_email = (
|
|
82
|
+
user_email_flag
|
|
83
|
+
or os.getenv("USER_EMAIL")
|
|
84
|
+
or mcp_env.get("USER_EMAIL")
|
|
85
|
+
or None
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
workspace_count: int | None = None
|
|
89
|
+
if workspace_count_flag is not None:
|
|
90
|
+
workspace_count = workspace_count_flag
|
|
91
|
+
else:
|
|
92
|
+
wc_env = os.getenv("WORKSPACE_COUNT") or mcp_env.get("WORKSPACE_COUNT")
|
|
93
|
+
if wc_env is not None:
|
|
94
|
+
try:
|
|
95
|
+
parsed = int(wc_env)
|
|
96
|
+
if parsed in (1, 2, 3):
|
|
97
|
+
workspace_count = parsed
|
|
98
|
+
except (ValueError, TypeError):
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
return backend_url, user_email, workspace_count
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _is_localhost(url: str) -> bool:
|
|
105
|
+
"""Return True if the URL host is localhost or 127.0.0.1."""
|
|
106
|
+
try:
|
|
107
|
+
host = urlparse(url).hostname or ""
|
|
108
|
+
return host.lower() in _LOCALHOST_HOSTS
|
|
109
|
+
except Exception:
|
|
110
|
+
return False
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def check_localhost_guard(backend_url: str, force: bool) -> None:
|
|
114
|
+
"""Print a warning and exit non-zero if backend_url is not localhost and --force not set."""
|
|
115
|
+
if force or _is_localhost(backend_url):
|
|
116
|
+
return
|
|
117
|
+
print(
|
|
118
|
+
f"ERROR: The CLI is local-only (no API key is sent).\n"
|
|
119
|
+
f" Resolved BACKEND_URL: {backend_url}\n"
|
|
120
|
+
f" This URL is not localhost — it will NOT authenticate against a remote backend.\n"
|
|
121
|
+
f" Use the MCP server for remote deployments.\n"
|
|
122
|
+
f" Pass --force to bypass this check.",
|
|
123
|
+
file=sys.stderr,
|
|
124
|
+
)
|
|
125
|
+
sys.exit(1)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# ---------------------------------------------------------------------------
|
|
129
|
+
# Environment setup — must be called before importing service modules
|
|
130
|
+
# ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _configure_env(backend_url: str, user_email: str | None) -> None:
|
|
134
|
+
"""Push resolved config into the environment so service singletons pick it up."""
|
|
135
|
+
os.environ["BACKEND_URL"] = backend_url
|
|
136
|
+
if user_email:
|
|
137
|
+
os.environ["USER_EMAIL"] = user_email
|
|
138
|
+
else:
|
|
139
|
+
os.environ.pop("USER_EMAIL", None)
|
|
140
|
+
# Never set SPECFLOW_API_KEY in local mode — service omits X-API-Key when unset.
|
|
141
|
+
os.environ.pop("SPECFLOW_API_KEY", None)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
# Root resolution
|
|
146
|
+
# ---------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def resolve_root(root_path_arg: str | None) -> Path:
|
|
150
|
+
"""Return absolute project root. Defaults to cwd; --root-path overrides."""
|
|
151
|
+
if root_path_arg:
|
|
152
|
+
return Path(root_path_arg).expanduser().resolve()
|
|
153
|
+
return Path.cwd().resolve()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# ---------------------------------------------------------------------------
|
|
157
|
+
# Command implementations (async)
|
|
158
|
+
# ---------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
async def cmd_run_generation(args: argparse.Namespace) -> int:
|
|
162
|
+
"""run-generation: upload specs and start generation."""
|
|
163
|
+
from services.session import set_project_root, resolve_generation_id, write_session
|
|
164
|
+
from services.generation_orchestrator import GenerationOrchestrator
|
|
165
|
+
from services.run_generation_precheck import precheck as run_precheck
|
|
166
|
+
from services.tool_helpers import check_status_safe, is_generation_in_progress
|
|
167
|
+
from services.cli_service import fetch_pool_status, render_capacity_message
|
|
168
|
+
|
|
169
|
+
root = resolve_root(args.root_path)
|
|
170
|
+
print(f"Using project root: {root}")
|
|
171
|
+
set_project_root(root)
|
|
172
|
+
|
|
173
|
+
spec_dir = root / args.spec_dir
|
|
174
|
+
src_dir = root / args.src_dir
|
|
175
|
+
outputs_dir = args.outputs_dir
|
|
176
|
+
|
|
177
|
+
# Pre-run notice (FR-8, LV4.2)
|
|
178
|
+
print(
|
|
179
|
+
f"\nNote: outputs land in {root / outputs_dir}/{{generation_id}}/... and are\n"
|
|
180
|
+
f"archived in the artifact store — nothing is lost on workspace recycle.\n"
|
|
181
|
+
f"Retrieve them any time with: specflow download-outputs\n"
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
rejection = run_precheck(root, args.spec_dir, outputs_dir)
|
|
185
|
+
if rejection is not None:
|
|
186
|
+
print(f"ERROR: {rejection.to_dict().get('error', rejection.code.value)}", file=sys.stderr)
|
|
187
|
+
return 1
|
|
188
|
+
|
|
189
|
+
generation_id = resolve_generation_id(None, root)
|
|
190
|
+
if generation_id:
|
|
191
|
+
status_data = await check_status_safe(generation_id)
|
|
192
|
+
if is_generation_in_progress(status_data):
|
|
193
|
+
print(
|
|
194
|
+
"ERROR: A generation is already running. "
|
|
195
|
+
"Wait for the email notification before starting another one.",
|
|
196
|
+
file=sys.stderr,
|
|
197
|
+
)
|
|
198
|
+
return 1
|
|
199
|
+
|
|
200
|
+
workspace_count = args.workspace_count # may be None
|
|
201
|
+
|
|
202
|
+
try:
|
|
203
|
+
response_data = await GenerationOrchestrator.run_generation(
|
|
204
|
+
spec_dir=spec_dir,
|
|
205
|
+
src_dir=src_dir,
|
|
206
|
+
outputs_dir=outputs_dir,
|
|
207
|
+
generation_id=generation_id,
|
|
208
|
+
workspace_count=workspace_count,
|
|
209
|
+
)
|
|
210
|
+
except Exception as exc:
|
|
211
|
+
error_msg = str(exc)
|
|
212
|
+
# Capacity UX (LV4.1): if allocation failed, show cleaning sets
|
|
213
|
+
if "no" in error_msg.lower() and "workspace" in error_msg.lower():
|
|
214
|
+
try:
|
|
215
|
+
pool = await fetch_pool_status()
|
|
216
|
+
cleaning = pool.get("cleaning_sets") or []
|
|
217
|
+
if cleaning:
|
|
218
|
+
print(render_capacity_message(cleaning))
|
|
219
|
+
return 1
|
|
220
|
+
except Exception:
|
|
221
|
+
pass
|
|
222
|
+
print(f"ERROR: {error_msg}", file=sys.stderr)
|
|
223
|
+
return 1
|
|
224
|
+
|
|
225
|
+
new_id = response_data.get("generation_id")
|
|
226
|
+
if new_id:
|
|
227
|
+
write_session(new_id, root)
|
|
228
|
+
|
|
229
|
+
print(json.dumps(response_data, indent=2))
|
|
230
|
+
print(
|
|
231
|
+
"\nFiles uploaded and generation started (usually 2-8 hours).\n"
|
|
232
|
+
"Run `specflow check-status` to poll progress, or `specflow sessions --watch` for\n"
|
|
233
|
+
"continuous monitoring with a desktop notification on completion.\n"
|
|
234
|
+
"(Email/Slack notifications fire only if configured in .env.)"
|
|
235
|
+
)
|
|
236
|
+
return 0
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
async def cmd_check_status(args: argparse.Namespace) -> int:
|
|
240
|
+
"""check-status: poll status of the current generation."""
|
|
241
|
+
from services.session import set_project_root, resolve_generation_id
|
|
242
|
+
from services.specflow_backend import call_backend_endpoint
|
|
243
|
+
|
|
244
|
+
root = resolve_root(args.root_path)
|
|
245
|
+
print(f"Using project root: {root}")
|
|
246
|
+
set_project_root(root)
|
|
247
|
+
|
|
248
|
+
generation_id = resolve_generation_id(None, root)
|
|
249
|
+
if not generation_id:
|
|
250
|
+
print("No active generation in this project. Run `specflow run-generation` to start one.")
|
|
251
|
+
return 0
|
|
252
|
+
|
|
253
|
+
response_text = await call_backend_endpoint(
|
|
254
|
+
endpoint=f"/api/v1/generation-sessions/{generation_id}/status",
|
|
255
|
+
method="GET",
|
|
256
|
+
timeout_seconds=30,
|
|
257
|
+
)
|
|
258
|
+
data = json.loads(response_text)
|
|
259
|
+
print(json.dumps(data, indent=2))
|
|
260
|
+
return 0
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
async def cmd_retry_generation(args: argparse.Namespace) -> int:
|
|
264
|
+
"""retry-generation: retry a failed generation."""
|
|
265
|
+
from services.session import set_project_root, resolve_generation_id
|
|
266
|
+
from services.specflow_backend import call_backend_endpoint
|
|
267
|
+
from services.tool_helpers import check_status_safe, is_generation_in_progress
|
|
268
|
+
|
|
269
|
+
root = resolve_root(args.root_path)
|
|
270
|
+
print(f"Using project root: {root}")
|
|
271
|
+
set_project_root(root)
|
|
272
|
+
|
|
273
|
+
generation_id = resolve_generation_id(None, root)
|
|
274
|
+
if not generation_id:
|
|
275
|
+
print("No previous generation found. Run `specflow run-generation` to start one.")
|
|
276
|
+
return 0
|
|
277
|
+
|
|
278
|
+
status_data = await check_status_safe(generation_id)
|
|
279
|
+
if is_generation_in_progress(status_data):
|
|
280
|
+
print(
|
|
281
|
+
"ERROR: A generation is already running. Wait for it to finish before retrying.",
|
|
282
|
+
file=sys.stderr,
|
|
283
|
+
)
|
|
284
|
+
return 1
|
|
285
|
+
|
|
286
|
+
response_text = await call_backend_endpoint(
|
|
287
|
+
endpoint=f"/api/v1/generation-sessions/{generation_id}/retry",
|
|
288
|
+
method="POST",
|
|
289
|
+
timeout_seconds=30,
|
|
290
|
+
)
|
|
291
|
+
data = json.loads(response_text)
|
|
292
|
+
print(json.dumps(data, indent=2))
|
|
293
|
+
print("\nRetry queued. Generation will resume from the last checkpoint on the same workspaces.")
|
|
294
|
+
return 0
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
async def cmd_download_outputs(args: argparse.Namespace) -> int:
|
|
298
|
+
"""download-outputs: download and extract completed generation outputs."""
|
|
299
|
+
from services.session import set_project_root, resolve_generation_id
|
|
300
|
+
from services.cli_service import download_and_extract_outputs
|
|
301
|
+
|
|
302
|
+
root = resolve_root(args.root_path)
|
|
303
|
+
print(f"Using project root: {root}")
|
|
304
|
+
set_project_root(root)
|
|
305
|
+
|
|
306
|
+
generation_id = resolve_generation_id(args.generation_id, root)
|
|
307
|
+
if not generation_id:
|
|
308
|
+
print("ERROR: No generation_id found. Pass --generation-id or run run-generation first.", file=sys.stderr)
|
|
309
|
+
return 1
|
|
310
|
+
|
|
311
|
+
outputs_dir = args.outputs_dir
|
|
312
|
+
try:
|
|
313
|
+
result = await download_and_extract_outputs(generation_id, outputs_dir)
|
|
314
|
+
except Exception as exc:
|
|
315
|
+
print(f"ERROR: Couldn't download outputs: {exc}", file=sys.stderr)
|
|
316
|
+
return 1
|
|
317
|
+
|
|
318
|
+
if result.get("status") == "success":
|
|
319
|
+
dest = result.get("outputs_dir", "")
|
|
320
|
+
count = result.get("files_extracted", 0)
|
|
321
|
+
print(f"Downloaded {count} files to: {dest}")
|
|
322
|
+
else:
|
|
323
|
+
print(json.dumps(result, indent=2))
|
|
324
|
+
return 0
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _render_sessions_table(sessions: list[dict]) -> None:
|
|
328
|
+
"""Print a sessions table to stdout."""
|
|
329
|
+
if not sessions:
|
|
330
|
+
print("No active generation sessions.")
|
|
331
|
+
return
|
|
332
|
+
header = f"{'GENERATION ID':<40} {'STATUS':<16} {'CHECKPOINT'}"
|
|
333
|
+
print(header)
|
|
334
|
+
print("-" * len(header))
|
|
335
|
+
for s in sessions:
|
|
336
|
+
print(f"{s['generation_id']:<40} {s['status']:<16} {s.get('checkpoint', '')}")
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
async def cmd_sessions(args: argparse.Namespace) -> int:
|
|
340
|
+
"""sessions: list active generation sessions (no new backend route)."""
|
|
341
|
+
from services.cli_service import fetch_sessions, notify_desktop, _TERMINAL_STATUSES
|
|
342
|
+
|
|
343
|
+
watch = getattr(args, "watch", False)
|
|
344
|
+
interval = getattr(args, "interval", 15)
|
|
345
|
+
|
|
346
|
+
if not watch:
|
|
347
|
+
sessions = await fetch_sessions()
|
|
348
|
+
_render_sessions_table(sessions)
|
|
349
|
+
return 0
|
|
350
|
+
|
|
351
|
+
# --watch: poll, refresh screen, notify on terminal state
|
|
352
|
+
notified: set[str] = set()
|
|
353
|
+
print(f"Watching sessions (every {interval}s) — Ctrl+C to stop\n")
|
|
354
|
+
while True:
|
|
355
|
+
try:
|
|
356
|
+
sessions = await fetch_sessions()
|
|
357
|
+
except Exception as exc:
|
|
358
|
+
print(f"Error fetching sessions: {exc}")
|
|
359
|
+
else:
|
|
360
|
+
# Clear terminal and reprint
|
|
361
|
+
print("\033[2J\033[H", end="", flush=True)
|
|
362
|
+
now = datetime.datetime.now().strftime("%H:%M:%S")
|
|
363
|
+
print(f"SpecFlow sessions — every {interval}s — {now} (Ctrl+C to stop)\n")
|
|
364
|
+
_render_sessions_table(sessions)
|
|
365
|
+
|
|
366
|
+
for s in sessions:
|
|
367
|
+
gid = s["generation_id"]
|
|
368
|
+
status = s["status"]
|
|
369
|
+
if status in _TERMINAL_STATUSES and gid not in notified:
|
|
370
|
+
notified.add(gid)
|
|
371
|
+
title = (
|
|
372
|
+
"SpecFlow: generation completed"
|
|
373
|
+
if status == "completed"
|
|
374
|
+
else "SpecFlow: generation failed"
|
|
375
|
+
)
|
|
376
|
+
msg = f"{gid[:12]}... {status}"
|
|
377
|
+
notify_desktop(title=title, message=msg)
|
|
378
|
+
print(f"\n[Desktop notification sent: {gid} → {status}]")
|
|
379
|
+
|
|
380
|
+
try:
|
|
381
|
+
await asyncio.sleep(interval)
|
|
382
|
+
except asyncio.CancelledError:
|
|
383
|
+
break
|
|
384
|
+
return 0
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
async def cmd_clear_workspace(args: argparse.Namespace) -> int:
|
|
388
|
+
"""clear-workspace --set N: clear all 3 members of a CLEANING workspace set."""
|
|
389
|
+
from services.cli_service import clear_workspace_set
|
|
390
|
+
|
|
391
|
+
set_number = args.set
|
|
392
|
+
if not args.yes:
|
|
393
|
+
answer = input(f"Clear all 3 workspaces in set {set_number}? This cannot be undone. [y/N] ")
|
|
394
|
+
if answer.strip().lower() not in ("y", "yes"):
|
|
395
|
+
print("Aborted.")
|
|
396
|
+
return 0
|
|
397
|
+
|
|
398
|
+
results = await clear_workspace_set(set_number)
|
|
399
|
+
all_ok = True
|
|
400
|
+
for r in results:
|
|
401
|
+
status = "OK" if r["success"] else "FAIL"
|
|
402
|
+
print(f" {r['workspace_id']}: {status} — {r['message']}")
|
|
403
|
+
if not r["success"]:
|
|
404
|
+
all_ok = False
|
|
405
|
+
|
|
406
|
+
if all_ok:
|
|
407
|
+
print(f"\nSet {set_number} cleared. Workspaces are available for the next generation.")
|
|
408
|
+
else:
|
|
409
|
+
print("\nSome workspaces failed to clear. See errors above.", file=sys.stderr)
|
|
410
|
+
return 1
|
|
411
|
+
return 0
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
# ---------------------------------------------------------------------------
|
|
415
|
+
# Argument parser
|
|
416
|
+
# ---------------------------------------------------------------------------
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
420
|
+
parser = argparse.ArgumentParser(
|
|
421
|
+
prog="specflow",
|
|
422
|
+
description=(
|
|
423
|
+
"SpecFlow CLI — local-only tool for managing code-generation sessions.\n"
|
|
424
|
+
"Use the MCP server for remote/hosted deployments."
|
|
425
|
+
),
|
|
426
|
+
)
|
|
427
|
+
parser.add_argument(
|
|
428
|
+
"--backend-url",
|
|
429
|
+
default=None,
|
|
430
|
+
help="Backend URL (default: BACKEND_URL env or http://127.0.0.1:8000)",
|
|
431
|
+
)
|
|
432
|
+
parser.add_argument(
|
|
433
|
+
"--user-email",
|
|
434
|
+
default=None,
|
|
435
|
+
help="User email (default: USER_EMAIL env)",
|
|
436
|
+
)
|
|
437
|
+
parser.add_argument(
|
|
438
|
+
"--root-path",
|
|
439
|
+
default=None,
|
|
440
|
+
help="Project root directory (default: current working directory)",
|
|
441
|
+
)
|
|
442
|
+
parser.add_argument(
|
|
443
|
+
"--force",
|
|
444
|
+
action="store_true",
|
|
445
|
+
help="Bypass the localhost guard for non-local backend URLs",
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
subparsers = parser.add_subparsers(dest="command", metavar="COMMAND")
|
|
449
|
+
subparsers.required = True
|
|
450
|
+
|
|
451
|
+
# run-generation
|
|
452
|
+
p_run = subparsers.add_parser("run-generation", help="Upload specs and start code generation")
|
|
453
|
+
p_run.add_argument("--spec-dir", default="specs", help="Spec directory (default: specs)")
|
|
454
|
+
p_run.add_argument("--outputs-dir", default="docs", help="Outputs directory (default: docs)")
|
|
455
|
+
p_run.add_argument("--src-dir", default="src", help="Source directory (default: src)")
|
|
456
|
+
p_run.add_argument(
|
|
457
|
+
"--workspace-count",
|
|
458
|
+
type=int,
|
|
459
|
+
choices=[1, 2, 3],
|
|
460
|
+
default=None,
|
|
461
|
+
dest="workspace_count",
|
|
462
|
+
help="Number of active workspaces (1-3; default from env/config)",
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
# check-status
|
|
466
|
+
subparsers.add_parser("check-status", help="Check progress of a running generation")
|
|
467
|
+
|
|
468
|
+
# retry-generation
|
|
469
|
+
subparsers.add_parser("retry-generation", help="Retry a failed generation")
|
|
470
|
+
|
|
471
|
+
# download-outputs
|
|
472
|
+
p_dl = subparsers.add_parser(
|
|
473
|
+
"download-outputs", help="Download and extract completed generation outputs"
|
|
474
|
+
)
|
|
475
|
+
p_dl.add_argument(
|
|
476
|
+
"--generation-id",
|
|
477
|
+
default=None,
|
|
478
|
+
dest="generation_id",
|
|
479
|
+
help="Generation ID (default: from specflow_session.json)",
|
|
480
|
+
)
|
|
481
|
+
p_dl.add_argument(
|
|
482
|
+
"--outputs-dir",
|
|
483
|
+
default="docs",
|
|
484
|
+
help="Local directory to extract outputs into (default: docs)",
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
# clear-workspace
|
|
488
|
+
p_clear = subparsers.add_parser(
|
|
489
|
+
"clear-workspace",
|
|
490
|
+
help="Free a CLEANING workspace set early (all 3 members)",
|
|
491
|
+
)
|
|
492
|
+
p_clear.add_argument("--set", type=int, required=True, dest="set", help="Set number to clear")
|
|
493
|
+
p_clear.add_argument("--yes", action="store_true", help="Skip confirmation prompt")
|
|
494
|
+
|
|
495
|
+
# sessions
|
|
496
|
+
p_sessions = subparsers.add_parser("sessions", help="List active generation sessions")
|
|
497
|
+
p_sessions.add_argument(
|
|
498
|
+
"--watch",
|
|
499
|
+
action="store_true",
|
|
500
|
+
help="Poll continuously and send a desktop notification on completion",
|
|
501
|
+
)
|
|
502
|
+
p_sessions.add_argument(
|
|
503
|
+
"--interval",
|
|
504
|
+
type=int,
|
|
505
|
+
default=15,
|
|
506
|
+
metavar="SECONDS",
|
|
507
|
+
help="Polling interval in seconds for --watch mode (default: 15)",
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
return parser
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
# ---------------------------------------------------------------------------
|
|
514
|
+
# Entry point
|
|
515
|
+
# ---------------------------------------------------------------------------
|
|
516
|
+
|
|
517
|
+
_COMMAND_MAP = {
|
|
518
|
+
"run-generation": cmd_run_generation,
|
|
519
|
+
"check-status": cmd_check_status,
|
|
520
|
+
"retry-generation": cmd_retry_generation,
|
|
521
|
+
"download-outputs": cmd_download_outputs,
|
|
522
|
+
"clear-workspace": cmd_clear_workspace,
|
|
523
|
+
"sessions": cmd_sessions,
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
# Commands that operate on files and print the project root
|
|
527
|
+
_FILE_OPERATING_COMMANDS = {
|
|
528
|
+
"run-generation",
|
|
529
|
+
"check-status",
|
|
530
|
+
"retry-generation",
|
|
531
|
+
"download-outputs",
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def main() -> None:
|
|
536
|
+
"""CLI entry point."""
|
|
537
|
+
logging.basicConfig(
|
|
538
|
+
level=os.getenv("LOG_LEVEL", "WARNING").upper(),
|
|
539
|
+
format="%(levelname)s - %(message)s",
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
parser = _build_parser()
|
|
543
|
+
args = parser.parse_args()
|
|
544
|
+
|
|
545
|
+
# Determine root early so config resolution can read mcp-config.json
|
|
546
|
+
root = resolve_root(getattr(args, "root_path", None))
|
|
547
|
+
|
|
548
|
+
# Resolve backend config (flag → env → mcp-config.json → default)
|
|
549
|
+
workspace_count_flag = getattr(args, "workspace_count", None)
|
|
550
|
+
backend_url, user_email, workspace_count = resolve_backend_config(
|
|
551
|
+
backend_url_flag=getattr(args, "backend_url", None),
|
|
552
|
+
user_email_flag=getattr(args, "user_email", None),
|
|
553
|
+
workspace_count_flag=workspace_count_flag,
|
|
554
|
+
root=root,
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
# Propagate resolved workspace_count back to args so cmd_run_generation picks it up
|
|
558
|
+
if not hasattr(args, "workspace_count") or args.workspace_count is None:
|
|
559
|
+
args.workspace_count = workspace_count
|
|
560
|
+
|
|
561
|
+
# Localhost guard
|
|
562
|
+
check_localhost_guard(backend_url, force=args.force)
|
|
563
|
+
|
|
564
|
+
# Push config into env before importing service singletons
|
|
565
|
+
_configure_env(backend_url, user_email)
|
|
566
|
+
|
|
567
|
+
handler = _COMMAND_MAP[args.command]
|
|
568
|
+
try:
|
|
569
|
+
exit_code = asyncio.run(handler(args))
|
|
570
|
+
except KeyboardInterrupt:
|
|
571
|
+
print("\nStopped.")
|
|
572
|
+
exit_code = 0
|
|
573
|
+
sys.exit(exit_code)
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
if __name__ == "__main__":
|
|
577
|
+
main()
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gd-specflow
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: SpecFlow MCP Server and CLI — spec-driven AI code generation
|
|
5
|
+
Author-email: Adam Wrobel <awrobel@griddynamics.com>, Artsiom Kozak <akozak@griddynamics.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/griddynamics/gd-specflow
|
|
8
|
+
Project-URL: Repository, https://github.com/griddynamics/gd-specflow
|
|
9
|
+
Keywords: mcp,ai,code-generation,specflow,llm,claude
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Topic :: Software Development :: Code Generators
|
|
16
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
17
|
+
Requires-Python: ==3.13.*
|
|
18
|
+
Requires-Dist: fastmcp>=0.2.0
|
|
19
|
+
Requires-Dist: httpx>=0.26.0
|
|
20
|
+
Requires-Dist: pydantic>=2.5.3
|
|
21
|
+
Requires-Dist: python-dotenv>=1.0.0
|
|
22
|
+
Requires-Dist: PyMuPDF>=1.25.0
|
|
23
|
+
Requires-Dist: python-docx>=1.1.0
|
|
24
|
+
Requires-Dist: python-pptx>=1.0.0
|
|
25
|
+
Requires-Dist: openpyxl>=3.1.0
|
|
26
|
+
Requires-Dist: plyer>=2.1.0
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
cli.py,sha256=NxcMAHEnM0umpq5_ZbODCLyAkT7bEtjpUERZ5WsFvAk,20270
|
|
2
|
+
server.py,sha256=sx3b1f8Wjy4Ct3ggjYzvCE7ftJ8z30TDt_-WWSTeSws,24713
|
|
3
|
+
schemas/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
schemas/gain_json.py,sha256=7TBwMGpkhXWhgBGRuLV0kZ7XRyGDHnp-7XZubP6ebio,1809
|
|
5
|
+
schemas/generation_workflow_enums.py,sha256=a45hLJbgmeebwGUllmulOudLcfrc0C3ngpwrzMur3dM,855
|
|
6
|
+
schemas/specification.py,sha256=MBa8Hn4dlKAKzlEBqtMfhfQwh1dBCQ1tlRPel1471UY,777
|
|
7
|
+
services/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
services/archive_builder.py,sha256=fW7ACDKIPGugvjktkJC4pBmY8LKjO9gBjYgg_kha2bA,6498
|
|
9
|
+
services/bundled_skills.py,sha256=WxzknWX7_7Ad3D5tYCaJseePXV2TU9NvntU8MoYWVHo,2790
|
|
10
|
+
services/cli_service.py,sha256=TMF4hP57gCY8jlo6zybgDlHkwcVOHiHqnZOnZrlAiKg,9131
|
|
11
|
+
services/document_reader.py,sha256=myET7r57vH4ibm0Xmc9SDGh30SN9NAFXzx1y0mi0mmU,13926
|
|
12
|
+
services/file_sync.py,sha256=s_Ad12T_Rk_HIy3RQWLJvh1Y0Nzlhov21-Yf5ynoQLI,8269
|
|
13
|
+
services/file_sync_orchestrator.py,sha256=VIG0oasX8EAYbuZynyiVR0VWjeioghsFzxhJvIAPYkQ,5561
|
|
14
|
+
services/generation_orchestrator.py,sha256=00SozQNhQzonZFBAdK6wjIlYXgIDd9QV9prsA-md6ow,9049
|
|
15
|
+
services/llm_tiers.py,sha256=Z2RAAlhETCi_efSyTHU1-Y3UOcMjVg3RcwtLTidNVG8,1368
|
|
16
|
+
services/run_generation_precheck.py,sha256=gxz2elZR-TvDrU1FTyeMyAZDSGqcRxyLTl_1IUOUZzY,8309
|
|
17
|
+
services/server_instructions.py,sha256=DsotO4oHoY5eBA9ofzKazlKQD7QLQU4OW7QjiNTkiZw,4261
|
|
18
|
+
services/session.py,sha256=9zHIdjqC_FiJO4hcN8AY3Mra3vHZrba2lvdipE7dDJE,7761
|
|
19
|
+
services/specflow_backend.py,sha256=kNtlXrNjRkJnr272rzb6uvOGNu3Ng2vG-PY0SIF6zEk,7965
|
|
20
|
+
services/tool_helpers.py,sha256=s5yxVPDjlNC6zGzW-elcWMdHTj6ihJJ5PjiFBcDEss4,4096
|
|
21
|
+
services/user_response.py,sha256=tQ2txv8TIyBL09IMjyJtfxFoPVnI8VE8nH8pLf13D4w,1235
|
|
22
|
+
services/skills/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
23
|
+
services/skills/specflow-analysis/SKILL.md,sha256=e-gB_kDWFX8uQgajthPxps4nwe0VTevk-m-pK1_VGCY,27872
|
|
24
|
+
services/skills/specflow-compare-variants/SKILL.md,sha256=Qip8lKxiub_uy9M18P_qjOWeyL2y8wgT7_FVTgJt8hk,9719
|
|
25
|
+
services/skills/specflow-diagnose/SKILL.md,sha256=uYyZtyZ_qenZv_9avky9kQAUzucJy5Oe2JV5ygSeM3A,7224
|
|
26
|
+
services/skills/specflow-planning/SKILL.md,sha256=MA6WJ-BXgpuxWVUlz2lgehp1TCXKxfwyuX0K9o6IRF4,14015
|
|
27
|
+
gd_specflow-0.2.0.dist-info/METADATA,sha256=XqmLJMmHZcakYWN9qA3UUfVjVk79yGSD4tdOQhU9LqY,1098
|
|
28
|
+
gd_specflow-0.2.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
29
|
+
gd_specflow-0.2.0.dist-info/entry_points.txt,sha256=l5523E6i-dE7vtK3WoTorbZTXkSQbzrD1mLy9JB3xJk,65
|
|
30
|
+
gd_specflow-0.2.0.dist-info/top_level.txt,sha256=FdqXan9EmfUaoz3P8q-vS939F4pSAY5H03ABRdnwZ_Y,28
|
|
31
|
+
gd_specflow-0.2.0.dist-info/RECORD,,
|
schemas/__init__.py
ADDED
|
File without changes
|