luckyd-code 1.2.2__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 (127) hide show
  1. luckyd_code/__init__.py +54 -0
  2. luckyd_code/__main__.py +5 -0
  3. luckyd_code/_agent_loop.py +551 -0
  4. luckyd_code/_data_dir.py +73 -0
  5. luckyd_code/agent.py +38 -0
  6. luckyd_code/analytics/__init__.py +18 -0
  7. luckyd_code/analytics/reporter.py +195 -0
  8. luckyd_code/analytics/scanner.py +443 -0
  9. luckyd_code/analytics/smells.py +316 -0
  10. luckyd_code/analytics/trends.py +303 -0
  11. luckyd_code/api.py +473 -0
  12. luckyd_code/audit_daemon.py +845 -0
  13. luckyd_code/autonomous_fixer.py +473 -0
  14. luckyd_code/background.py +159 -0
  15. luckyd_code/backup.py +237 -0
  16. luckyd_code/brain/__init__.py +84 -0
  17. luckyd_code/brain/assembler.py +100 -0
  18. luckyd_code/brain/chunker.py +345 -0
  19. luckyd_code/brain/constants.py +73 -0
  20. luckyd_code/brain/embedder.py +163 -0
  21. luckyd_code/brain/graph.py +311 -0
  22. luckyd_code/brain/indexer.py +316 -0
  23. luckyd_code/brain/parser.py +140 -0
  24. luckyd_code/brain/retriever.py +234 -0
  25. luckyd_code/cli.py +894 -0
  26. luckyd_code/cli_commands/__init__.py +1 -0
  27. luckyd_code/cli_commands/audit.py +120 -0
  28. luckyd_code/cli_commands/background.py +83 -0
  29. luckyd_code/cli_commands/brain.py +87 -0
  30. luckyd_code/cli_commands/config.py +75 -0
  31. luckyd_code/cli_commands/dispatcher.py +695 -0
  32. luckyd_code/cli_commands/sessions.py +41 -0
  33. luckyd_code/cli_entry.py +147 -0
  34. luckyd_code/cli_utils.py +112 -0
  35. luckyd_code/config.py +205 -0
  36. luckyd_code/context.py +214 -0
  37. luckyd_code/cost_tracker.py +209 -0
  38. luckyd_code/error_reporter.py +508 -0
  39. luckyd_code/exceptions.py +39 -0
  40. luckyd_code/export.py +126 -0
  41. luckyd_code/feedback_analyzer.py +290 -0
  42. luckyd_code/file_watcher.py +258 -0
  43. luckyd_code/git/__init__.py +11 -0
  44. luckyd_code/git/auto_commit.py +157 -0
  45. luckyd_code/git/tools.py +85 -0
  46. luckyd_code/hooks.py +236 -0
  47. luckyd_code/indexer.py +280 -0
  48. luckyd_code/init.py +39 -0
  49. luckyd_code/keybindings.py +77 -0
  50. luckyd_code/log.py +55 -0
  51. luckyd_code/mcp/__init__.py +6 -0
  52. luckyd_code/mcp/client.py +184 -0
  53. luckyd_code/memory/__init__.py +19 -0
  54. luckyd_code/memory/manager.py +339 -0
  55. luckyd_code/metrics/__init__.py +5 -0
  56. luckyd_code/model_registry.py +131 -0
  57. luckyd_code/orchestrator.py +204 -0
  58. luckyd_code/permissions/__init__.py +1 -0
  59. luckyd_code/permissions/manager.py +103 -0
  60. luckyd_code/planner.py +361 -0
  61. luckyd_code/plugins.py +91 -0
  62. luckyd_code/py.typed +0 -0
  63. luckyd_code/retry.py +57 -0
  64. luckyd_code/router.py +417 -0
  65. luckyd_code/sandbox.py +156 -0
  66. luckyd_code/self_critique.py +2 -0
  67. luckyd_code/self_improve.py +274 -0
  68. luckyd_code/sessions.py +114 -0
  69. luckyd_code/settings.py +72 -0
  70. luckyd_code/skills/__init__.py +8 -0
  71. luckyd_code/skills/review.py +22 -0
  72. luckyd_code/skills/security.py +17 -0
  73. luckyd_code/tasks/__init__.py +1 -0
  74. luckyd_code/tasks/manager.py +102 -0
  75. luckyd_code/templates/icon-192.png +0 -0
  76. luckyd_code/templates/icon-512.png +0 -0
  77. luckyd_code/templates/index.html +1965 -0
  78. luckyd_code/templates/manifest.json +14 -0
  79. luckyd_code/templates/src/app.js +694 -0
  80. luckyd_code/templates/src/body.html +767 -0
  81. luckyd_code/templates/src/cdn.txt +2 -0
  82. luckyd_code/templates/src/style.css +474 -0
  83. luckyd_code/templates/sw.js +31 -0
  84. luckyd_code/templates/test.html +6 -0
  85. luckyd_code/themes.py +48 -0
  86. luckyd_code/tools/__init__.py +97 -0
  87. luckyd_code/tools/agent_tools.py +65 -0
  88. luckyd_code/tools/bash.py +360 -0
  89. luckyd_code/tools/brain_tools.py +137 -0
  90. luckyd_code/tools/browser.py +369 -0
  91. luckyd_code/tools/datetime_tool.py +34 -0
  92. luckyd_code/tools/dockerfile_gen.py +212 -0
  93. luckyd_code/tools/file_ops.py +381 -0
  94. luckyd_code/tools/game_gen.py +360 -0
  95. luckyd_code/tools/git_tools.py +130 -0
  96. luckyd_code/tools/git_worktree.py +63 -0
  97. luckyd_code/tools/path_validate.py +64 -0
  98. luckyd_code/tools/project_gen.py +187 -0
  99. luckyd_code/tools/readme_gen.py +227 -0
  100. luckyd_code/tools/registry.py +157 -0
  101. luckyd_code/tools/shell_detect.py +109 -0
  102. luckyd_code/tools/web.py +89 -0
  103. luckyd_code/tools/youtube.py +187 -0
  104. luckyd_code/tools_bridge.py +144 -0
  105. luckyd_code/undo.py +126 -0
  106. luckyd_code/update.py +60 -0
  107. luckyd_code/verify.py +360 -0
  108. luckyd_code/web_app.py +176 -0
  109. luckyd_code/web_routes/__init__.py +23 -0
  110. luckyd_code/web_routes/background.py +73 -0
  111. luckyd_code/web_routes/brain.py +109 -0
  112. luckyd_code/web_routes/cost.py +12 -0
  113. luckyd_code/web_routes/files.py +133 -0
  114. luckyd_code/web_routes/memories.py +94 -0
  115. luckyd_code/web_routes/misc.py +67 -0
  116. luckyd_code/web_routes/project.py +48 -0
  117. luckyd_code/web_routes/review.py +20 -0
  118. luckyd_code/web_routes/sessions.py +44 -0
  119. luckyd_code/web_routes/settings.py +43 -0
  120. luckyd_code/web_routes/static.py +70 -0
  121. luckyd_code/web_routes/update.py +19 -0
  122. luckyd_code/web_routes/ws.py +237 -0
  123. luckyd_code-1.2.2.dist-info/METADATA +297 -0
  124. luckyd_code-1.2.2.dist-info/RECORD +127 -0
  125. luckyd_code-1.2.2.dist-info/WHEEL +4 -0
  126. luckyd_code-1.2.2.dist-info/entry_points.txt +3 -0
  127. luckyd_code-1.2.2.dist-info/licenses/LICENSE +21 -0
luckyd_code/indexer.py ADDED
@@ -0,0 +1,280 @@
1
+ """Smart project indexing — scan project structure and inject into context."""
2
+
3
+ import os
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Any, Optional
7
+
8
+ from .log import get_logger
9
+
10
+
11
+ def _load_gitignore(path: Path) -> list[str]:
12
+ """Load .gitignore patterns."""
13
+ patterns = []
14
+ gitignore = path / ".gitignore"
15
+ if gitignore.exists():
16
+ try:
17
+ for line in gitignore.read_text(encoding="utf-8").splitlines():
18
+ line = line.strip()
19
+ if line and not line.startswith("#"):
20
+ patterns.append(line)
21
+ except Exception:
22
+ get_logger().warning("Could not load .gitignore", exc_info=True)
23
+ return patterns
24
+
25
+
26
+ def _is_ignored(name: str, patterns: list[str]) -> bool:
27
+ """Check if a name matches gitignore patterns."""
28
+ for p in patterns:
29
+ if p.endswith("/") and name == p.rstrip("/"):
30
+ return True
31
+ if name == p:
32
+ return True
33
+ if p.startswith("*.") and name.endswith(p[1:]):
34
+ return True
35
+ return False
36
+
37
+
38
+ IGNORED_DIRS = {
39
+ ".git", "__pycache__", "node_modules", ".venv", "venv", "env",
40
+ ".tox", ".eggs", "dist", "build", ".next", ".nuxt",
41
+ "target", "vendor", ".bundle", ".claude", ".vscode", ".idea",
42
+ ".mypy_cache", ".pytest_cache", ".ruff_cache",
43
+ }
44
+
45
+ IGNORED_EXTS = {
46
+ ".pyc", ".pyo", ".so", ".o", ".class", ".jar",
47
+ ".png", ".jpg", ".jpeg", ".gif", ".ico", ".svg",
48
+ ".woff", ".woff2", ".ttf", ".eot",
49
+ ".zip", ".tar", ".gz", ".rar", ".7z",
50
+ ".exe", ".msi", ".dmg", ".pkg",
51
+ ".log", ".tmp",
52
+ }
53
+
54
+
55
+ def scan_project(root: Path, max_depth: int = 3, max_items: int = 80) -> dict:
56
+ """Scan a project directory and return structured metadata."""
57
+ root = root.resolve()
58
+ gitignore_patterns = _load_gitignore(root)
59
+
60
+ info: dict[str, Any] = {
61
+ "name": root.name,
62
+ "path": str(root),
63
+ "languages": set(),
64
+ "frameworks": [],
65
+ "config_files": {},
66
+ "file_tree": [],
67
+ "dependency_files": [],
68
+ "entry_points": [],
69
+ "total_files": 0,
70
+ }
71
+
72
+ dirs_to_scan = [(root, 0)]
73
+ scanned = 0
74
+
75
+ while dirs_to_scan and scanned < max_items:
76
+ current, depth = dirs_to_scan.pop(0)
77
+
78
+ if depth > max_depth:
79
+ continue
80
+
81
+ try:
82
+ entries = sorted(current.iterdir(), key=lambda x: (not x.is_dir(), x.name))
83
+ except PermissionError:
84
+ continue
85
+
86
+ indent = " " * depth
87
+ for entry in entries:
88
+ if scanned >= max_items:
89
+ break
90
+
91
+ name = entry.name
92
+ if name in IGNORED_DIRS or _is_ignored(name, gitignore_patterns):
93
+ continue
94
+
95
+ if entry.is_dir():
96
+ if not name.startswith(".") and not name.endswith(".egg-info"):
97
+ info["file_tree"].append(f"{indent}{name}/")
98
+ dirs_to_scan.append((entry, depth + 1))
99
+ scanned += 1
100
+ else:
101
+ ext = entry.suffix.lower()
102
+ if ext in IGNORED_EXTS:
103
+ continue
104
+
105
+ info["file_tree"].append(f"{indent}{name}")
106
+ info["total_files"] += 1
107
+ scanned += 1
108
+
109
+ # Detect language
110
+ lang = _detect_language(ext)
111
+ if lang:
112
+ info["languages"].add(lang)
113
+
114
+ # Detect dependency files
115
+ if name in ("package.json", "pyproject.toml", "Cargo.toml",
116
+ "requirements.txt", "Gemfile", "go.mod", "CMakeLists.txt",
117
+ "composer.json", "Pipfile", "build.gradle", "pom.xml"):
118
+ info["dependency_files"].append(name)
119
+ deps = _extract_deps(entry, name)
120
+ if deps:
121
+ info["config_files"][name] = deps
122
+
123
+ # Detect entry points
124
+ if name in ("main.py", "index.js", "app.js", "cli.py",
125
+ "main.go", "main.rs", "index.ts", "app.ts"):
126
+ info["entry_points"].append(str(entry.relative_to(root)))
127
+
128
+ # Detect framework
129
+ info["frameworks"] = _detect_frameworks(info["config_files"], info["dependency_files"])
130
+
131
+ # Convert languages set to sorted list
132
+ info["languages"] = sorted(info["languages"])
133
+
134
+ return info
135
+
136
+
137
+ def _detect_language(ext: str) -> Optional[str]:
138
+ mapping = {
139
+ ".py": "Python", ".pyi": "Python",
140
+ ".js": "JavaScript", ".jsx": "JavaScript/JSX",
141
+ ".ts": "TypeScript", ".tsx": "TypeScript/TSX",
142
+ ".rs": "Rust",
143
+ ".go": "Go",
144
+ ".java": "Java",
145
+ ".kt": "Kotlin",
146
+ ".rb": "Ruby",
147
+ ".php": "PHP",
148
+ ".c": "C", ".h": "C",
149
+ ".cpp": "C++", ".hpp": "C++", ".cc": "C++",
150
+ ".cs": "C#",
151
+ ".swift": "Swift",
152
+ ".toml": "TOML", ".yaml": "YAML", ".yml": "YAML",
153
+ ".json": "JSON", ".html": "HTML", ".css": "CSS",
154
+ ".md": "Markdown",
155
+ ".sql": "SQL",
156
+ ".sh": "Shell", ".bat": "Batch",
157
+ ".pl": "Perl",
158
+ ".lua": "Lua",
159
+ ".r": "R",
160
+ ".scala": "Scala",
161
+ }
162
+ return mapping.get(ext)
163
+
164
+
165
+ def _detect_frameworks(config_files: dict, dep_files: list) -> list[str]:
166
+ frameworks = []
167
+ for fname, deps in config_files.items():
168
+ deps_lower = {d.lower() for d in deps}
169
+ # Python
170
+ if "django" in deps_lower:
171
+ frameworks.append("Django")
172
+ if "flask" in deps_lower:
173
+ frameworks.append("Flask")
174
+ if "fastapi" in deps_lower:
175
+ frameworks.append("FastAPI")
176
+ # JS/TS
177
+ if "react" in deps_lower or "next" in deps_lower:
178
+ frameworks.append("React/Next.js")
179
+ if "vue" in deps_lower:
180
+ frameworks.append("Vue")
181
+ if "express" in deps_lower or "@nestjs/core" in deps_lower:
182
+ frameworks.append("Express/NestJS")
183
+ if "tailwindcss" in deps_lower:
184
+ frameworks.append("Tailwind CSS")
185
+ # Rust
186
+ if "actix-web" in deps_lower or "axum" in deps_lower:
187
+ frameworks.append("Actix/Axum")
188
+ # Other
189
+ if "spring-boot" in deps_lower:
190
+ frameworks.append("Spring Boot")
191
+
192
+ return list(set(frameworks))
193
+
194
+
195
+ def _extract_deps(filepath: Path, name: str) -> list[str]:
196
+ """Extract dependency names from a config file."""
197
+ try:
198
+ if name == "package.json":
199
+ data = json.loads(filepath.read_text(encoding="utf-8"))
200
+ return list(data.get("dependencies", {}).keys()) + list(data.get("devDependencies", {}).keys())
201
+ elif name == "pyproject.toml":
202
+ content = filepath.read_text(encoding="utf-8")
203
+ deps = []
204
+ for line in content.splitlines():
205
+ line = line.strip()
206
+ if "=" in line and not line.startswith("[") and not line.startswith("#"):
207
+ parts = line.split("=")
208
+ key = parts[0].strip().strip('"').strip("'")
209
+ if key and not key.startswith("_"):
210
+ deps.append(key)
211
+ return deps[:30]
212
+ elif name == "requirements.txt":
213
+ content = filepath.read_text(encoding="utf-8")
214
+ deps = []
215
+ for line in content.splitlines():
216
+ line = line.strip()
217
+ if line and not line.startswith("#") and not line.startswith("-"):
218
+ dep = line.split(">=")[0].split("==")[0].split("~=")[0].strip()
219
+ deps.append(dep)
220
+ return deps[:30]
221
+ elif name == "Cargo.toml":
222
+ content = filepath.read_text(encoding="utf-8")
223
+ deps = []
224
+ in_deps = False
225
+ for line in content.splitlines():
226
+ line = line.strip()
227
+ if line == "[dependencies]":
228
+ in_deps = True
229
+ continue
230
+ if in_deps and line.startswith("["):
231
+ break
232
+ if in_deps and "=" in line:
233
+ dep = line.split("=")[0].strip().strip('"').strip("'")
234
+ if dep:
235
+ deps.append(dep)
236
+ return deps[:20]
237
+ except Exception:
238
+ get_logger().warning("Could not extract dependencies from %s", name, exc_info=True)
239
+ return []
240
+
241
+
242
+ def format_project_context(info: dict) -> str:
243
+ """Format project info into a concise context string for the AI."""
244
+ parts = [f"# Project: {info['name']}"]
245
+
246
+ if info["languages"]:
247
+ parts.append(f"Languages: {', '.join(info['languages'])}")
248
+
249
+ if info["frameworks"]:
250
+ parts.append(f"Frameworks: {', '.join(info['frameworks'])}")
251
+
252
+ if info["entry_points"]:
253
+ parts.append(f"Entry points: {', '.join(info['entry_points'])}")
254
+
255
+ if info["dependency_files"]:
256
+ parts.append(f"Config files: {', '.join(info['dependency_files'])}")
257
+
258
+ if info["total_files"]:
259
+ parts.append(f"Total source files: {info['total_files']}")
260
+
261
+ # File tree
262
+ if info["file_tree"]:
263
+ parts.append("\nFile tree:")
264
+ parts.append("\n".join(info["file_tree"]))
265
+
266
+ return "\n".join(parts)
267
+
268
+
269
+ def index_project(project_dir: str = None) -> str:
270
+ """Scan a project and return formatted context. Runs in <1s."""
271
+ if project_dir:
272
+ root = Path(project_dir).resolve()
273
+ else:
274
+ root = Path(os.getcwd()).resolve()
275
+
276
+ if not root.exists():
277
+ return ""
278
+
279
+ info = scan_project(root)
280
+ return format_project_context(info)
luckyd_code/init.py ADDED
@@ -0,0 +1,39 @@
1
+ """Project initialization tool."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+
7
+ MEMORY_FILENAMES = ["MEMORY.md", "CLAUDE.md"] # check both for backward compat
8
+
9
+ DEFAULT_MEMORY_MD = """# MEMORY.md
10
+
11
+ ## Project Overview
12
+ <!-- Describe what this project does -->
13
+
14
+ ## Tech Stack
15
+ <!-- List the key technologies used -->
16
+
17
+ ## Development Setup
18
+ <!-- How to get started -->
19
+
20
+ ## Commands
21
+ <!-- Common commands for dev, test, build, lint -->
22
+
23
+ ## Guidelines
24
+ <!-- Project-specific coding conventions -->
25
+ """
26
+
27
+
28
+ def init_project():
29
+ """Initialize the project memory file (MEMORY.md).
30
+
31
+ Creates MEMORY.md if neither MEMORY.md nor CLAUDE.md exists.
32
+ """
33
+ cwd = Path(os.getcwd())
34
+ for name in MEMORY_FILENAMES:
35
+ if (cwd / name).exists():
36
+ return f"{name} already exists."
37
+ path = cwd / "MEMORY.md"
38
+ path.write_text(DEFAULT_MEMORY_MD, encoding="utf-8")
39
+ return "Created MEMORY.md. Edit it with project-specific instructions."
@@ -0,0 +1,77 @@
1
+ """Custom keybinding support."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ from .log import get_logger
7
+
8
+ from prompt_toolkit.key_binding import KeyBindings
9
+
10
+
11
+ DEFAULT_BINDINGS = {
12
+ "submit": "enter",
13
+ "newline": "alt-enter",
14
+ "cancel": "ctrl-c",
15
+ "history-up": "ctrl-p",
16
+ "history-down": "ctrl-n",
17
+ }
18
+
19
+
20
+ from ._data_dir import data_path
21
+
22
+
23
+ def get_keybindings_path() -> Path:
24
+ return data_path("keybindings.json")
25
+
26
+
27
+ def load_keybindings() -> dict:
28
+ path = get_keybindings_path()
29
+ if path.exists():
30
+ try:
31
+ data = json.loads(path.read_text())
32
+ if isinstance(data, dict):
33
+ return data
34
+ except Exception:
35
+ get_logger().warning("Could not load keybindings from %s", path, exc_info=True)
36
+ return {}
37
+
38
+
39
+ def _parse_key_sequence(key: str) -> tuple:
40
+ """Convert a key string into a tuple of prompt_toolkit key names.
41
+
42
+ prompt_toolkit does not understand 'alt-X' — alt combos must be passed
43
+ as the two-key sequence ('escape', 'X').
44
+ """
45
+ if key.startswith("alt-"):
46
+ remainder = key[4:] # e.g. 'enter', 'a', etc.
47
+ return ("escape", remainder)
48
+ return (key,)
49
+
50
+
51
+ def apply_keybindings() -> KeyBindings:
52
+ """Create KeyBindings from config file, falling back to defaults."""
53
+ user = load_keybindings()
54
+ bindings = {**DEFAULT_BINDINGS, **user}
55
+
56
+ kb = KeyBindings()
57
+
58
+ submit_key = bindings.get("submit", "enter")
59
+ newline_key = bindings.get("newline", "alt-enter")
60
+
61
+ # Enter submits the prompt (works even in multiline mode)
62
+ try:
63
+ @kb.add(*_parse_key_sequence(submit_key))
64
+ def _submit(event):
65
+ event.current_buffer.validate_and_handle()
66
+ except Exception:
67
+ get_logger().warning("Failed to register submit keybinding '%s'", submit_key, exc_info=True)
68
+
69
+ # Alt+Enter inserts a newline
70
+ try:
71
+ @kb.add(*_parse_key_sequence(newline_key))
72
+ def _newline(event):
73
+ event.current_buffer.insert_text("\n")
74
+ except Exception:
75
+ get_logger().warning("Failed to register newline keybinding '%s'", newline_key, exc_info=True)
76
+
77
+ return kb
luckyd_code/log.py ADDED
@@ -0,0 +1,55 @@
1
+ """Structured logging for DeepSeek Code."""
2
+
3
+ import sys
4
+ import logging
5
+ from pathlib import Path
6
+ from datetime import datetime
7
+
8
+ from ._data_dir import data_path
9
+
10
+ _LOG_DIR = data_path("logs")
11
+ _initialized = False
12
+
13
+
14
+ def setup_logging(level: str = "INFO", log_file: str | None = None) -> logging.Logger:
15
+ """Initialize logging system. Safe to call multiple times."""
16
+ global _initialized
17
+
18
+ logger = logging.getLogger("luckyd_code")
19
+ if _initialized:
20
+ return logger
21
+
22
+ logger.setLevel(getattr(logging, level.upper(), logging.INFO))
23
+
24
+ # Console handler (warnings and above)
25
+ console = logging.StreamHandler(sys.stderr)
26
+ console.setLevel(logging.WARNING)
27
+ console.setFormatter(logging.Formatter("%(levelname)s: %(message)s"))
28
+ logger.addHandler(console)
29
+
30
+ # File handler (all levels)
31
+ if log_file:
32
+ log_path = Path(log_file)
33
+ else:
34
+ _LOG_DIR.mkdir(parents=True, exist_ok=True)
35
+ log_path = _LOG_DIR / f"session_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log"
36
+
37
+ try:
38
+ fh = logging.FileHandler(str(log_path), encoding="utf-8")
39
+ fh.setLevel(getattr(logging, level.upper(), logging.INFO))
40
+ fh.setFormatter(logging.Formatter(
41
+ "%(asctime)s [%(levelname)s] %(message)s",
42
+ datefmt="%H:%M:%S",
43
+ ))
44
+ logger.addHandler(fh)
45
+ logger.info(f"Logging initialized: {log_path}")
46
+ except Exception as e:
47
+ logger.warning(f"Could not create log file: {e}")
48
+
49
+ _initialized = True
50
+ return logger
51
+
52
+
53
+ def get_logger() -> logging.Logger:
54
+ """Get the project logger. Initializes with defaults if not already set up."""
55
+ return setup_logging()
@@ -0,0 +1,6 @@
1
+ """Model Context Protocol (MCP) support — extend DeepSeek Code with custom tools."""
2
+ from .client import MCPManager
3
+
4
+ __all__ = [
5
+ "MCPManager",
6
+ ]
@@ -0,0 +1,184 @@
1
+ """Basic MCP (Model Context Protocol) client.
2
+
3
+ Connects to MCP servers via stdio JSON-RPC and registers their tools.
4
+ MCP spec: https://modelcontextprotocol.io
5
+ """
6
+
7
+ import json
8
+ import logging
9
+ import subprocess
10
+ import time
11
+ from typing import Any, Optional, cast
12
+
13
+ logger = logging.getLogger("luckyd_code.mcp")
14
+
15
+
16
+ class MCPServer:
17
+ """A connected MCP server with health checks and reconnection."""
18
+
19
+ def __init__(self, name: str, command: str, args: list[str] | None = None):
20
+ self.name = name
21
+ self.command = command
22
+ self.args = args or []
23
+ self.process: Optional[subprocess.Popen] = None
24
+ self.tools: list[dict] = []
25
+ self._request_id = 0
26
+ self._max_retries = 2
27
+
28
+ def connect(self):
29
+ """Start the MCP server process."""
30
+ try:
31
+ import sys
32
+ # On Windows, use shell=True for .cmd/.bat files (like npx, which is npx.cmd)
33
+ use_shell = sys.platform == "win32"
34
+ self.process = subprocess.Popen(
35
+ [self.command] + self.args,
36
+ stdin=subprocess.PIPE,
37
+ stdout=subprocess.PIPE,
38
+ stderr=subprocess.PIPE,
39
+ text=True,
40
+ shell=use_shell,
41
+ )
42
+ # Poll stderr in a non-blocking way — log any startup errors
43
+ import threading
44
+ def _log_stderr(proc):
45
+ for line in iter(proc.stderr.readline, ""):
46
+ if line:
47
+ logger.debug("[mcp:%s] %s", self.name, line.rstrip())
48
+ t = threading.Thread(target=_log_stderr, args=(self.process,), daemon=True)
49
+ t.start()
50
+ except Exception as e:
51
+ return f"Failed to start MCP server '{self.name}': {e}"
52
+ return None
53
+
54
+ def _ensure_running(self) -> bool:
55
+ """Check if the server is running and attempt reconnect if not."""
56
+ if self.process and self.process.poll() is None:
57
+ return True
58
+ # Attempt reconnection
59
+ for attempt in range(self._max_retries):
60
+ logger.info("Reconnecting MCP server '%s' (attempt %d/%d)",
61
+ self.name, attempt + 1, self._max_retries)
62
+ self.process = None
63
+ error = self.connect()
64
+ if error is None:
65
+ # Rediscover tools
66
+ self.tools = self.list_tools()
67
+ return True
68
+ time.sleep(1)
69
+ return False
70
+
71
+ def _send_request(self, method: str, params: dict[str, object] | None = None) -> dict[str, object]:
72
+ """Send a JSON-RPC request and get response."""
73
+ if not self._ensure_running():
74
+ return {"error": "Server not running"}
75
+
76
+ self._request_id += 1
77
+ request = {
78
+ "jsonrpc": "2.0",
79
+ "id": self._request_id,
80
+ "method": method,
81
+ "params": params or {},
82
+ }
83
+
84
+ try:
85
+ if self.process is None or self.process.stdin is None or self.process.stdout is None:
86
+ return {"error": "Server process not available"}
87
+ self.process.stdin.write(json.dumps(request) + "\n")
88
+ self.process.stdin.flush()
89
+ line = self.process.stdout.readline()
90
+ if not line:
91
+ return {"error": "Empty response from server"}
92
+ result: dict[str, object] = json.loads(line)
93
+ return result
94
+ except Exception as e:
95
+ logger.warning("MCP request error for '%s': %s", self.name, e)
96
+ return {"error": str(e)}
97
+
98
+ def list_tools(self) -> list[dict[str, object]]:
99
+ """List available tools from this server."""
100
+ response = self._send_request("tools/list")
101
+ if "error" in response:
102
+ return []
103
+ result: dict[str, object] = cast(dict[str, object], response.get("result", {}))
104
+ tools: list[dict[str, object]] = cast(list[dict[str, object]], result.get("tools", []))
105
+ return tools
106
+
107
+ def call_tool(self, name: str, arguments: dict[str, Any]) -> str:
108
+ """Call a tool on this server."""
109
+ response = self._send_request("tools/call", {
110
+ "name": name,
111
+ "arguments": arguments,
112
+ })
113
+ if "error" in response:
114
+ return f"MCP error: {response['error']}"
115
+ result: dict[str, object] = cast(dict[str, object], response.get("result", {}))
116
+ content: list[dict[str, object]] = cast(list[dict[str, object]], result.get("content", []))
117
+ return "\n".join(
118
+ c.get("text", "") for c in content if c.get("type") == "text"
119
+ ) or json.dumps(content)
120
+
121
+ def close(self):
122
+ if self.process:
123
+ try:
124
+ self.process.terminate()
125
+ self.process.wait(timeout=5)
126
+ except Exception:
127
+ self.process.kill()
128
+ self.process = None
129
+
130
+
131
+ class MCPManager:
132
+ """Manages MCP server connections."""
133
+
134
+ def __init__(self):
135
+ self.servers: list[MCPServer] = []
136
+
137
+ def load_from_config(self, config: dict):
138
+ """Load MCP servers from settings config."""
139
+ servers_config = config.get("mcpServers", {})
140
+ if not servers_config:
141
+ servers_config = config.get("mcp_servers", {})
142
+
143
+ for name, cfg in servers_config.items():
144
+ server = MCPServer(
145
+ name=name,
146
+ command=cfg.get("command", ""),
147
+ args=cfg.get("args", []),
148
+ )
149
+ error = server.connect()
150
+ if error:
151
+ logger.warning("Failed to start MCP server '%s': %s", name, error)
152
+ continue
153
+ server.tools = server.list_tools()
154
+ if server.tools:
155
+ self.servers.append(server)
156
+ logger.info("MCP server '%s' loaded with %d tools", name, len(server.tools))
157
+
158
+ def get_all_tools(self) -> list[dict]:
159
+ """Get all tools from all servers as OpenAI tool definitions."""
160
+ tools = []
161
+ for server in self.servers:
162
+ for tool in server.tools:
163
+ tools.append({
164
+ "type": "function",
165
+ "function": {
166
+ "name": f"mcp_{tool['name']}",
167
+ "description": tool.get("description", f"MCP tool: {tool['name']}"),
168
+ "parameters": tool.get("inputSchema", {}),
169
+ },
170
+ })
171
+ return tools
172
+
173
+ def execute(self, tool_name: str, arguments: dict) -> str:
174
+ """Execute a tool by its full name (mcp_<name>)."""
175
+ name = tool_name[len("mcp_"):] if tool_name.startswith("mcp_") else tool_name
176
+ for server in self.servers:
177
+ for tool in server.tools:
178
+ if tool["name"] == name:
179
+ return server.call_tool(name, arguments)
180
+ return f"MCP tool '{name}' not found"
181
+
182
+ def close_all(self):
183
+ for server in self.servers:
184
+ server.close()
@@ -0,0 +1,19 @@
1
+ from .manager import (
2
+ MemoryManager,
3
+ get_project_memory_dir,
4
+ load_claude_md,
5
+ save_claude_md,
6
+ load_memory_index,
7
+ save_memory,
8
+ list_memories,
9
+ )
10
+
11
+ __all__ = [
12
+ "MemoryManager",
13
+ "get_project_memory_dir",
14
+ "load_claude_md",
15
+ "save_claude_md",
16
+ "load_memory_index",
17
+ "save_memory",
18
+ "list_memories",
19
+ ]