taskill 0.1.1__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.
taskill/git_state.py ADDED
@@ -0,0 +1,147 @@
1
+ """Read project state from git, filesystem, and (optionally) pyqual reports."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import json
6
+ import re
7
+ import subprocess
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+
11
+
12
+ @dataclass
13
+ class Commit:
14
+ sha: str
15
+ short_sha: str
16
+ author: str
17
+ date: str
18
+ subject: str
19
+ body: str = ""
20
+
21
+ @property
22
+ def conventional_type(self) -> str | None:
23
+ """Parse 'feat(scope): xxx' → 'feat'. Returns None if not conventional."""
24
+ m = re.match(r"^(feat|fix|docs|chore|refactor|test|perf|ci|build|style|revert)(\([^)]+\))?!?:\s",
25
+ self.subject)
26
+ return m.group(1) if m else None
27
+
28
+ @property
29
+ def is_breaking(self) -> bool:
30
+ return "!" in self.subject.split(":")[0] or "BREAKING CHANGE" in self.body
31
+
32
+
33
+ @dataclass
34
+ class ProjectSnapshot:
35
+ head_sha: str | None
36
+ commits_since_last_run: list[Commit] = field(default_factory=list)
37
+ changed_files: list[str] = field(default_factory=list)
38
+ coverage_pct: float | None = None
39
+ failed_tests: int | None = None
40
+ sumd_hash: str | None = None
41
+ sumd_text: str | None = None
42
+ sumr_text: str | None = None
43
+
44
+
45
+ def _run(cmd: list[str], cwd: Path) -> str:
46
+ try:
47
+ out = subprocess.run(
48
+ cmd, cwd=cwd, capture_output=True, text=True, check=False, timeout=30
49
+ )
50
+ return out.stdout.strip()
51
+ except (subprocess.TimeoutExpired, FileNotFoundError):
52
+ return ""
53
+
54
+
55
+ def head_sha(project_root: Path) -> str | None:
56
+ sha = _run(["git", "rev-parse", "HEAD"], project_root)
57
+ return sha or None
58
+
59
+
60
+ def commits_since(project_root: Path, since_sha: str | None) -> list[Commit]:
61
+ """Get commits since a SHA (exclusive). If since_sha is None, return last 50."""
62
+ rng = f"{since_sha}..HEAD" if since_sha else "-50"
63
+ sep = "\x1e" # record sep
64
+ fmt = f"%H{sep}%h{sep}%an{sep}%aI{sep}%s{sep}%b\x1f"
65
+ raw = _run(["git", "log", rng, f"--pretty=format:{fmt}"], project_root)
66
+ if not raw:
67
+ return []
68
+ commits: list[Commit] = []
69
+ for record in raw.split("\x1f"):
70
+ record = record.strip("\n")
71
+ if not record:
72
+ continue
73
+ parts = record.split(sep)
74
+ if len(parts) < 5:
75
+ continue
76
+ sha, short, author, date, subject = parts[:5]
77
+ body = parts[5] if len(parts) > 5 else ""
78
+ commits.append(Commit(sha=sha, short_sha=short, author=author,
79
+ date=date, subject=subject, body=body))
80
+ return commits
81
+
82
+
83
+ def changed_files_since(project_root: Path, since_sha: str | None) -> list[str]:
84
+ if not since_sha:
85
+ return []
86
+ out = _run(["git", "diff", "--name-only", f"{since_sha}..HEAD"], project_root)
87
+ return [line for line in out.splitlines() if line]
88
+
89
+
90
+ def read_coverage(project_root: Path) -> float | None:
91
+ """Look for common coverage report locations."""
92
+ for candidate in ("coverage.json", ".coverage.json", "htmlcov/coverage.json"):
93
+ p = project_root / candidate
94
+ if p.exists():
95
+ try:
96
+ data = json.loads(p.read_text(encoding="utf-8"))
97
+ pct = data.get("totals", {}).get("percent_covered")
98
+ if pct is not None:
99
+ return float(pct)
100
+ except (json.JSONDecodeError, KeyError, TypeError):
101
+ pass
102
+ # coverage.xml — minimal extraction
103
+ xml = project_root / "coverage.xml"
104
+ if xml.exists():
105
+ m = re.search(r'line-rate="([0-9.]+)"', xml.read_text(encoding="utf-8"))
106
+ if m:
107
+ return float(m.group(1)) * 100
108
+ return None
109
+
110
+
111
+ def read_failed_tests(project_root: Path) -> int | None:
112
+ """Look for pytest junit xml or pyqual report."""
113
+ for candidate in ("junit.xml", "test-results.xml", "reports/junit.xml"):
114
+ p = project_root / candidate
115
+ if p.exists():
116
+ xml = p.read_text(encoding="utf-8")
117
+ m = re.search(r'failures="(\d+)"', xml)
118
+ e = re.search(r'errors="(\d+)"', xml)
119
+ if m:
120
+ return int(m.group(1)) + (int(e.group(1)) if e else 0)
121
+ return None
122
+
123
+
124
+ def file_hash(path: Path) -> str | None:
125
+ if not path.exists():
126
+ return None
127
+ return hashlib.sha256(path.read_bytes()).hexdigest()[:16]
128
+
129
+
130
+ def collect_snapshot(
131
+ project_root: Path,
132
+ files: dict[str, str],
133
+ last_commit_sha: str | None,
134
+ ) -> ProjectSnapshot:
135
+ sumd_path = project_root / files.get("sumd", "SUMD.md")
136
+ sumr_path = project_root / files.get("sumr", "SUMR.md")
137
+
138
+ return ProjectSnapshot(
139
+ head_sha=head_sha(project_root),
140
+ commits_since_last_run=commits_since(project_root, last_commit_sha),
141
+ changed_files=changed_files_since(project_root, last_commit_sha),
142
+ coverage_pct=read_coverage(project_root),
143
+ failed_tests=read_failed_tests(project_root),
144
+ sumd_hash=file_hash(sumd_path),
145
+ sumd_text=sumd_path.read_text(encoding="utf-8") if sumd_path.exists() else None,
146
+ sumr_text=sumr_path.read_text(encoding="utf-8") if sumr_path.exists() else None,
147
+ )
@@ -0,0 +1,37 @@
1
+ """Provider chain. First successful provider wins.
2
+
3
+ Order is determined by config (taskill.yaml: providers: [...]).
4
+ Default order: windsurf_mcp → openrouter → algorithmic.
5
+ """
6
+ from taskill.providers.base import Provider, ProviderError, GeneratedDocs
7
+ from taskill.providers.algorithmic import AlgorithmicProvider
8
+ from taskill.providers.openrouter import OpenRouterProvider
9
+ from taskill.providers.windsurf_mcp import WindsurfMcpProvider
10
+
11
+ __all__ = [
12
+ "Provider",
13
+ "ProviderError",
14
+ "GeneratedDocs",
15
+ "AlgorithmicProvider",
16
+ "OpenRouterProvider",
17
+ "WindsurfMcpProvider",
18
+ "build_chain",
19
+ ]
20
+
21
+
22
+ def build_chain(provider_configs):
23
+ """Materialize provider config list into provider instances (only enabled)."""
24
+ registry = {
25
+ "windsurf_mcp": WindsurfMcpProvider,
26
+ "openrouter": OpenRouterProvider,
27
+ "algorithmic": AlgorithmicProvider,
28
+ }
29
+ chain = []
30
+ for cfg in provider_configs:
31
+ if not cfg.enabled:
32
+ continue
33
+ cls = registry.get(cfg.name)
34
+ if cls is None:
35
+ continue
36
+ chain.append(cls(options=cfg.options))
37
+ return chain
@@ -0,0 +1,155 @@
1
+ """Algorithmic fallback — no LLM required.
2
+
3
+ Strategy:
4
+ - Categorize commits via Conventional Commits prefixes.
5
+ - Move TODO lines that match commit subjects (fuzzy) to changelog.
6
+ - Detect '[x]' checkboxes in TODO and treat as completed.
7
+ - For new TODO items: scan commits for 'TODO:' / 'FIXME:' markers in body.
8
+
9
+ This is the safety net. Always available, always deterministic, ~zero deps.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ from collections import defaultdict
15
+ from typing import Any
16
+
17
+ from taskill.providers.base import GeneratedDocs, Provider
18
+
19
+
20
+ CATEGORY_HEADINGS = {
21
+ "feat": "### Added",
22
+ "fix": "### Fixed",
23
+ "perf": "### Performance",
24
+ "refactor": "### Changed",
25
+ "docs": "### Documentation",
26
+ "test": "### Tests",
27
+ "build": "### Build",
28
+ "ci": "### CI",
29
+ "chore": "### Chore",
30
+ "style": "### Style",
31
+ "revert": "### Reverted",
32
+ }
33
+
34
+
35
+ class AlgorithmicProvider(Provider):
36
+ name = "algorithmic"
37
+
38
+ def is_available(self) -> bool:
39
+ return True # always
40
+
41
+ def generate(self, context: dict[str, Any]) -> GeneratedDocs:
42
+ snap = context["snapshot"]
43
+ existing_todo = context.get("existing_todo") or ""
44
+
45
+ # 1. group commits by conventional type
46
+ by_type: dict[str, list[str]] = defaultdict(list)
47
+ breaking: list[str] = []
48
+ uncategorized: list[str] = []
49
+ for c in snap.commits_since_last_run:
50
+ ctype = c.conventional_type
51
+ line = self._format_commit(c)
52
+ if c.is_breaking:
53
+ breaking.append(line)
54
+ if ctype:
55
+ by_type[ctype].append(line)
56
+ else:
57
+ uncategorized.append(line)
58
+
59
+ # 2. build changelog entries grouped by category
60
+ entries: list[str] = []
61
+ if breaking:
62
+ entries.append("### ⚠ BREAKING CHANGES")
63
+ entries.extend(breaking)
64
+ for ctype in ["feat", "fix", "perf", "refactor", "docs", "test",
65
+ "build", "ci", "chore", "style", "revert"]:
66
+ if by_type.get(ctype):
67
+ entries.append(CATEGORY_HEADINGS[ctype])
68
+ entries.extend(by_type[ctype])
69
+ if uncategorized:
70
+ entries.append("### Other")
71
+ entries.extend(uncategorized)
72
+
73
+ # 3. detect completed TODOs
74
+ todo_completed = self._find_completed_todos(existing_todo, snap.commits_since_last_run)
75
+
76
+ # 4. extract TODO/FIXME from commit bodies
77
+ todo_new = self._extract_new_todos(snap.commits_since_last_run)
78
+
79
+ n_total = len(snap.commits_since_last_run)
80
+ summary = (
81
+ f"{n_total} commit(s); "
82
+ f"{len(by_type.get('feat', []))} features, "
83
+ f"{len(by_type.get('fix', []))} fixes."
84
+ ) if n_total else "No new commits — refreshing docs only."
85
+
86
+ return GeneratedDocs(
87
+ changelog_entries=entries,
88
+ todo_completed=todo_completed,
89
+ todo_new=todo_new,
90
+ summary=summary,
91
+ provider_name=self.name,
92
+ )
93
+
94
+ @staticmethod
95
+ def _format_commit(commit) -> str: # type: ignore[no-untyped-def]
96
+ # strip the conventional prefix from the bullet text — heading already conveys it
97
+ subject = re.sub(
98
+ r"^(feat|fix|docs|chore|refactor|test|perf|ci|build|style|revert)(\([^)]+\))?!?:\s*",
99
+ "",
100
+ commit.subject,
101
+ )
102
+ return f"- {subject} ({commit.short_sha})"
103
+
104
+ @staticmethod
105
+ def _find_completed_todos(todo_text: str, commits) -> list[str]: # type: ignore[no-untyped-def]
106
+ """Return TODO lines that look completed.
107
+
108
+ Two signals:
109
+ 1. Line starts with `- [x]` or `* [x]` → explicitly checked off.
110
+ 2. Line content has high token overlap with a commit subject.
111
+ """
112
+ completed: list[str] = []
113
+ for raw_line in todo_text.splitlines():
114
+ line = raw_line.rstrip()
115
+ if not line.strip():
116
+ continue
117
+
118
+ # explicit checkbox
119
+ if re.match(r"^\s*[-*+]\s*\[[xX]\]", line):
120
+ completed.append(line)
121
+ continue
122
+
123
+ # only check unchecked todo bullets
124
+ m = re.match(r"^\s*[-*+]\s*(\[\s\]\s*)?(.+)$", line)
125
+ if not m:
126
+ continue
127
+ text = m.group(2).lower()
128
+ text_tokens = set(re.findall(r"\w{4,}", text))
129
+ if len(text_tokens) < 2:
130
+ continue
131
+ for c in commits:
132
+ commit_tokens = set(re.findall(r"\w{4,}", c.subject.lower()))
133
+ if not commit_tokens:
134
+ continue
135
+ overlap = text_tokens & commit_tokens
136
+ # need ≥2 shared significant tokens AND ≥40% of TODO tokens covered.
137
+ # 40% (not 50%) accommodates plurals/stems without proper stemming —
138
+ # e.g. "refresh tokens" in TODO vs "refresh token" in commit.
139
+ if len(overlap) >= 2 and len(overlap) / len(text_tokens) >= 0.4:
140
+ completed.append(line)
141
+ break
142
+ return completed
143
+
144
+ @staticmethod
145
+ def _extract_new_todos(commits) -> list[str]: # type: ignore[no-untyped-def]
146
+ new: list[str] = []
147
+ pat = re.compile(r"\b(TODO|FIXME|XXX)[:\s]+([^\n]+)", re.IGNORECASE)
148
+ seen: set[str] = set()
149
+ for c in commits:
150
+ for m in pat.finditer(c.body or ""):
151
+ text = m.group(2).strip().rstrip(".")[:160]
152
+ if text and text.lower() not in seen:
153
+ seen.add(text.lower())
154
+ new.append(f"- [ ] {text}")
155
+ return new
@@ -0,0 +1,105 @@
1
+ """Provider abstraction + shared types."""
2
+ from __future__ import annotations
3
+
4
+ from abc import ABC, abstractmethod
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+
9
+ class ProviderError(Exception):
10
+ """Provider failed — chain falls through to next provider."""
11
+
12
+
13
+ @dataclass
14
+ class GeneratedDocs:
15
+ """What every provider returns."""
16
+ readme: str | None = None
17
+ changelog_entries: list[str] = None # type: ignore[assignment] # markdown bullets
18
+ todo_completed: list[str] = None # type: ignore[assignment] # items to remove from TODO
19
+ todo_new: list[str] = None # type: ignore[assignment] # new items detected
20
+ summary: str = ""
21
+ provider_name: str = "unknown"
22
+
23
+ def __post_init__(self) -> None:
24
+ if self.changelog_entries is None:
25
+ self.changelog_entries = []
26
+ if self.todo_completed is None:
27
+ self.todo_completed = []
28
+ if self.todo_new is None:
29
+ self.todo_new = []
30
+
31
+
32
+ class Provider(ABC):
33
+ """Abstract provider — produces docs from a project snapshot."""
34
+
35
+ name: str = "abstract"
36
+
37
+ def __init__(self, options: dict[str, Any] | None = None) -> None:
38
+ self.options = options or {}
39
+
40
+ @abstractmethod
41
+ def is_available(self) -> bool:
42
+ """Quick self-check. Should NOT raise — just return False."""
43
+
44
+ @abstractmethod
45
+ def generate(self, context: dict[str, Any]) -> GeneratedDocs:
46
+ """Generate docs.
47
+
48
+ `context` keys (see core.build_context):
49
+ - snapshot: ProjectSnapshot
50
+ - existing_readme, existing_changelog, existing_todo: str
51
+ - sumd, sumr: str | None
52
+ - project_name: str
53
+ - pyqual_report: dict | None
54
+ """
55
+
56
+
57
+ # ─── shared prompt template (used by LLM providers) ──────────────────────
58
+
59
+ SYSTEM_PROMPT = """You are taskill, a release-notes / docs janitor.
60
+ Given a project snapshot (commits, changed files, coverage, SUMD), output STRICT JSON:
61
+
62
+ {
63
+ "summary": "1-2 sentence high-level description of what changed",
64
+ "changelog_entries": ["- feat: thing one", "- fix: thing two"],
65
+ "todo_completed": ["- exact text of TODO line that is now done"],
66
+ "todo_new": ["- new thing discovered from commits"],
67
+ "readme_patches": null
68
+ }
69
+
70
+ Rules:
71
+ - changelog_entries: markdown bullets, conventional-commits style if possible
72
+ - todo_completed: copy line text VERBATIM from the existing TODO; don't paraphrase
73
+ - Be conservative — when unsure, omit. False positives are worse than misses.
74
+ - Output ONLY JSON. No prose. No code fences.
75
+ """
76
+
77
+
78
+ def build_user_prompt(context: dict[str, Any]) -> str:
79
+ snap = context["snapshot"]
80
+ commits = "\n".join(
81
+ f" {c.short_sha} {c.subject}" for c in snap.commits_since_last_run[:50]
82
+ ) or " (no new commits)"
83
+ changed = "\n".join(f" {f}" for f in snap.changed_files[:50]) or " (none)"
84
+ todo = context.get("existing_todo") or "(empty)"
85
+ sumd = context.get("sumd") or "(no SUMD.md)"
86
+ coverage = f"{snap.coverage_pct:.1f}%" if snap.coverage_pct else "n/a"
87
+ failed = snap.failed_tests if snap.failed_tests is not None else "n/a"
88
+
89
+ return f"""Project: {context.get('project_name', 'unknown')}
90
+
91
+ # Commits since last taskill run
92
+ {commits}
93
+
94
+ # Changed files
95
+ {changed}
96
+
97
+ # Coverage: {coverage} Failed tests: {failed}
98
+
99
+ # Current TODO.md (truncated)
100
+ {todo[:4000]}
101
+
102
+ # SUMD.md (project structure summary, truncated)
103
+ {sumd[:3000]}
104
+
105
+ Now produce the JSON described in the system prompt."""
@@ -0,0 +1,114 @@
1
+ """OpenRouter provider.
2
+
3
+ Reads OPENROUTER_API_KEY and LLM_MODEL from env (loaded via .env in config).
4
+ Uses HTTP directly to keep deps minimal — no openai SDK needed.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import os
10
+ import re
11
+ from typing import Any
12
+
13
+ import httpx
14
+
15
+ from taskill.providers.base import (
16
+ GeneratedDocs,
17
+ Provider,
18
+ ProviderError,
19
+ SYSTEM_PROMPT,
20
+ build_user_prompt,
21
+ )
22
+
23
+
24
+ # OpenRouter model strings often arrive as "openrouter/qwen/qwen3-coder-next" — the leading
25
+ # "openrouter/" segment is litellm-style. The OpenRouter REST API itself wants just
26
+ # "qwen/qwen3-coder-next". We strip the prefix automatically.
27
+ def _normalize_model(model: str) -> str:
28
+ if model.startswith("openrouter/"):
29
+ return model[len("openrouter/"):]
30
+ return model
31
+
32
+
33
+ class OpenRouterProvider(Provider):
34
+ name = "openrouter"
35
+
36
+ def is_available(self) -> bool:
37
+ return bool(os.getenv("OPENROUTER_API_KEY"))
38
+
39
+ def generate(self, context: dict[str, Any]) -> GeneratedDocs:
40
+ api_key = os.getenv("OPENROUTER_API_KEY")
41
+ if not api_key:
42
+ raise ProviderError("OPENROUTER_API_KEY not set")
43
+
44
+ model = _normalize_model(os.getenv("LLM_MODEL", "qwen/qwen3-coder-next"))
45
+ base_url = self.options.get("base_url", "https://openrouter.ai/api/v1")
46
+ timeout = self.options.get("timeout", 120)
47
+
48
+ payload = {
49
+ "model": model,
50
+ "temperature": self.options.get("temperature", 0.2),
51
+ "max_tokens": self.options.get("max_tokens", 4096),
52
+ "messages": [
53
+ {"role": "system", "content": SYSTEM_PROMPT},
54
+ {"role": "user", "content": build_user_prompt(context)},
55
+ ],
56
+ # JSON mode if model supports it; harmless if it doesn't
57
+ "response_format": {"type": "json_object"},
58
+ }
59
+
60
+ headers = {
61
+ "Authorization": f"Bearer {api_key}",
62
+ "Content-Type": "application/json",
63
+ # OpenRouter recommends these for analytics / better routing
64
+ "HTTP-Referer": self.options.get("referer", "https://github.com/oqlos/taskill"),
65
+ "X-Title": "taskill",
66
+ }
67
+
68
+ try:
69
+ with httpx.Client(timeout=timeout) as client:
70
+ resp = client.post(f"{base_url}/chat/completions", json=payload, headers=headers)
71
+ except httpx.HTTPError as e:
72
+ raise ProviderError(f"OpenRouter request failed: {e}") from e
73
+
74
+ if resp.status_code != 200:
75
+ raise ProviderError(
76
+ f"OpenRouter returned {resp.status_code}: {resp.text[:300]}"
77
+ )
78
+
79
+ try:
80
+ data = resp.json()
81
+ content = data["choices"][0]["message"]["content"]
82
+ except (KeyError, IndexError, json.JSONDecodeError) as e:
83
+ raise ProviderError(f"Malformed OpenRouter response: {e}") from e
84
+
85
+ parsed = self._parse_json_loosely(content)
86
+ if parsed is None:
87
+ raise ProviderError("Model did not return valid JSON")
88
+
89
+ return GeneratedDocs(
90
+ changelog_entries=list(parsed.get("changelog_entries", [])),
91
+ todo_completed=list(parsed.get("todo_completed", [])),
92
+ todo_new=list(parsed.get("todo_new", [])),
93
+ summary=str(parsed.get("summary", "")),
94
+ provider_name=f"{self.name}:{model}",
95
+ )
96
+
97
+ @staticmethod
98
+ def _parse_json_loosely(text: str) -> dict[str, Any] | None:
99
+ """Be forgiving: strip ```json fences, extract first {...} block."""
100
+ text = text.strip()
101
+ # strip fences
102
+ text = re.sub(r"^```(?:json)?\s*|\s*```$", "", text, flags=re.MULTILINE)
103
+ try:
104
+ return json.loads(text)
105
+ except json.JSONDecodeError:
106
+ pass
107
+ # find first balanced { ... }
108
+ m = re.search(r"\{.*\}", text, re.DOTALL)
109
+ if not m:
110
+ return None
111
+ try:
112
+ return json.loads(m.group(0))
113
+ except json.JSONDecodeError:
114
+ return None
@@ -0,0 +1,135 @@
1
+ """Windsurf MCP provider — first in the chain.
2
+
3
+ Why first: the user runs the Windsurf plugin in JetBrains, which exposes
4
+ an MCP server with rich workspace context (open files, diagnostics, etc.).
5
+ If reachable, it produces better results than a cold OpenRouter call.
6
+
7
+ Discovery (in order):
8
+ 1. options.endpoint == "stdio" → spawn `windsurf mcp` (or path from options.command)
9
+ 2. options.endpoint starts with "ws://" or "http://" → connect remotely
10
+ 3. WINDSURF_MCP_ENDPOINT env var
11
+ 4. Probe known JetBrains plugin sockets at ~/.codeium/windsurf/mcp.sock
12
+ and ~/.windsurf/mcp.sock
13
+
14
+ If the optional `mcp` package is not installed, this provider is unavailable
15
+ (returns False from is_available) and the chain falls through cleanly to
16
+ OpenRouter. This is intentional — taskill should not require MCP to work.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import os
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ from taskill.providers.base import (
26
+ GeneratedDocs,
27
+ Provider,
28
+ ProviderError,
29
+ SYSTEM_PROMPT,
30
+ build_user_prompt,
31
+ )
32
+
33
+
34
+ def _mcp_lib_present() -> bool:
35
+ try:
36
+ import mcp # noqa: F401
37
+ return True
38
+ except ImportError:
39
+ return False
40
+
41
+
42
+ def _candidate_endpoints(options: dict[str, Any]) -> list[str]:
43
+ cands: list[str] = []
44
+ ep = options.get("endpoint")
45
+ if ep:
46
+ cands.append(ep)
47
+ env_ep = os.getenv("WINDSURF_MCP_ENDPOINT")
48
+ if env_ep:
49
+ cands.append(env_ep)
50
+ home = Path.home()
51
+ for sock in (home / ".codeium" / "windsurf" / "mcp.sock",
52
+ home / ".windsurf" / "mcp.sock"):
53
+ if sock.exists():
54
+ cands.append(f"unix://{sock}")
55
+ return cands
56
+
57
+
58
+ class WindsurfMcpProvider(Provider):
59
+ name = "windsurf_mcp"
60
+
61
+ def is_available(self) -> bool:
62
+ if not _mcp_lib_present():
63
+ return False
64
+ # consider available if at least one candidate endpoint resolves
65
+ return bool(_candidate_endpoints(self.options))
66
+
67
+ def generate(self, context: dict[str, Any]) -> GeneratedDocs:
68
+ if not _mcp_lib_present():
69
+ raise ProviderError(
70
+ "MCP library not installed (pip install 'taskill[mcp]')"
71
+ )
72
+
73
+ # Lazy import — keep MCP truly optional
74
+ try:
75
+ from mcp import ClientSession # type: ignore
76
+ from mcp.client.stdio import stdio_client, StdioServerParameters # type: ignore
77
+ except ImportError as e:
78
+ raise ProviderError(f"MCP import failed: {e}") from e
79
+
80
+ endpoints = _candidate_endpoints(self.options)
81
+ if not endpoints:
82
+ raise ProviderError("No Windsurf MCP endpoint configured or discovered")
83
+
84
+ # NOTE: full MCP plumbing is async; for the working version we delegate
85
+ # to a sync helper that picks the simplest viable path (stdio command).
86
+ # Remote ws/unix transports land in the refactor (see ROADMAP.md).
87
+ cmd = self.options.get("command")
88
+ if not cmd:
89
+ raise ProviderError(
90
+ "Windsurf MCP stdio requires options.command (e.g. 'windsurf mcp serve')"
91
+ )
92
+
93
+ try:
94
+ import asyncio
95
+
96
+ async def _run() -> str:
97
+ params = StdioServerParameters(
98
+ command=cmd[0] if isinstance(cmd, list) else cmd,
99
+ args=cmd[1:] if isinstance(cmd, list) else [],
100
+ )
101
+ async with stdio_client(params) as (read, write):
102
+ async with ClientSession(read, write) as session:
103
+ await session.initialize()
104
+ # Use a generic 'chat' or 'complete' tool exposed by the server.
105
+ tool_name = self.options.get("tool_name", "complete")
106
+ result = await session.call_tool(
107
+ tool_name,
108
+ arguments={
109
+ "system": SYSTEM_PROMPT,
110
+ "prompt": build_user_prompt(context),
111
+ },
112
+ )
113
+ # MCP tool result content blocks
114
+ for block in result.content:
115
+ if hasattr(block, "text"):
116
+ return block.text # type: ignore[no-any-return]
117
+ raise ProviderError("Empty response from MCP tool")
118
+
119
+ content = asyncio.run(_run())
120
+ except Exception as e: # broad — MCP can fail many ways; surface cleanly
121
+ raise ProviderError(f"Windsurf MCP call failed: {e}") from e
122
+
123
+ # parse same JSON contract as OpenRouter
124
+ from taskill.providers.openrouter import OpenRouterProvider
125
+ parsed = OpenRouterProvider._parse_json_loosely(content)
126
+ if parsed is None:
127
+ raise ProviderError("Windsurf MCP did not return valid JSON")
128
+
129
+ return GeneratedDocs(
130
+ changelog_entries=list(parsed.get("changelog_entries", [])),
131
+ todo_completed=list(parsed.get("todo_completed", [])),
132
+ todo_new=list(parsed.get("todo_new", [])),
133
+ summary=str(parsed.get("summary", "")),
134
+ provider_name=self.name,
135
+ )