@yottameta/yotta-dev-mcp-plugin 0.0.0 → 0.1.1

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.
@@ -0,0 +1,1014 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """Deterministic development tools for yotta-dev-mcp.
4
+
5
+ The engine is intentionally small and self-contained: Python 3.8+ standard
6
+ library only, offline by default, deterministic output, read-only unless a
7
+ tool explicitly documents a write.
8
+ """
9
+
10
+ import argparse
11
+ import ast
12
+ import json
13
+ import math
14
+ import os
15
+ import re
16
+ import shutil
17
+ import subprocess
18
+ import sys
19
+ from pathlib import Path
20
+
21
+ from dev_rules import REVIEW_RULES
22
+
23
+ VERSION = "0.1.1"
24
+
25
+ IGNORE_DIRS = {
26
+ ".git", ".hg", ".svn", "node_modules", "__pycache__", ".venv", "venv",
27
+ "dist", "build", ".next", ".nuxt", ".cache", ".tmp", ".tmp2",
28
+ }
29
+ SOURCE_EXTS = {
30
+ ".py", ".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".sh", ".ps1",
31
+ ".go", ".rs", ".java", ".kt", ".kts", ".rb", ".php",
32
+ }
33
+ TEXT_EXTS = SOURCE_EXTS | {".json", ".md", ".txt", ".yml", ".yaml", ".toml", ".ini", ".cfg"}
34
+ MAX_FILE_BYTES = 2 * 1024 * 1024
35
+
36
+ ANSI_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
37
+ ERROR_RE = re.compile(
38
+ r"(?i)(traceback|exception|\berror\b|\bfailed\b|\bfail\b|fatal|panic|"
39
+ r"assertion|npm err!|\berr\b|\bwarn(?:ing)?\b)"
40
+ )
41
+
42
+
43
+ def _json_safe(value):
44
+ if isinstance(value, Path):
45
+ return str(value)
46
+ if isinstance(value, dict):
47
+ return {str(k): _json_safe(v) for k, v in value.items()}
48
+ if isinstance(value, list):
49
+ return [_json_safe(v) for v in value]
50
+ return value
51
+
52
+
53
+ def _read_text(path):
54
+ path = Path(path)
55
+ if path.stat().st_size > MAX_FILE_BYTES:
56
+ raise ValueError("文件超过大小上限: %s" % path)
57
+ data = path.read_bytes()
58
+ if b"\x00" in data[:4096]:
59
+ raise ValueError("二进制文件不参与文本扫描: %s" % path)
60
+ return data.decode("utf-8", errors="replace")
61
+
62
+
63
+ def _iter_files(root, extensions=None, max_files=5000, all_files=False):
64
+ root = Path(root)
65
+ extensions = set(extensions or TEXT_EXTS)
66
+ count = 0
67
+ for current, dirs, files in os.walk(str(root)):
68
+ dirs[:] = sorted(d for d in dirs if d not in IGNORE_DIRS)
69
+ for name in sorted(files):
70
+ path = Path(current) / name
71
+ if path.is_symlink():
72
+ continue
73
+ if not all_files and extensions and path.suffix.lower() not in extensions:
74
+ continue
75
+ if path.stat().st_size > MAX_FILE_BYTES:
76
+ continue
77
+ yield path
78
+ count += 1
79
+ if count >= max_files:
80
+ return
81
+
82
+
83
+ def _rel(root, path):
84
+ try:
85
+ return str(Path(path).resolve().relative_to(Path(root).resolve())).replace("\\", "/")
86
+ except ValueError:
87
+ return str(Path(path)).replace("\\", "/")
88
+
89
+
90
+ def _language(path):
91
+ ext = Path(path).suffix.lower()
92
+ if ext == ".py":
93
+ return "python"
94
+ if ext in (".js", ".jsx", ".mjs", ".cjs"):
95
+ return "javascript"
96
+ if ext in (".ts", ".tsx"):
97
+ return "typescript"
98
+ if ext in (".sh", ".ps1"):
99
+ return "shell"
100
+ return ext.lstrip(".") or "text"
101
+
102
+
103
+ def _resolve_python_import(source_rel, node):
104
+ source = Path(source_rel)
105
+ if isinstance(node, ast.Import):
106
+ return [alias.name for alias in node.names]
107
+ if not isinstance(node, ast.ImportFrom):
108
+ return []
109
+ if node.level:
110
+ parts = list(source.with_suffix("").parts[:-1])
111
+ if node.level > 1:
112
+ parts = parts[:-(node.level - 1)] if len(parts) >= node.level - 1 else []
113
+ prefix = "/".join(parts)
114
+ module = node.module or ""
115
+ target = (prefix + "/" + module.replace(".", "/")).strip("/")
116
+ return [target + ".py" if target else source_rel]
117
+ if node.module:
118
+ return [node.module]
119
+ return []
120
+
121
+
122
+ def _repo_map_python(path, rel):
123
+ text = _read_text(path)
124
+ imports = []
125
+ try:
126
+ tree = ast.parse(text)
127
+ except SyntaxError:
128
+ return imports
129
+ for node in ast.walk(tree):
130
+ if isinstance(node, (ast.Import, ast.ImportFrom)):
131
+ for target in _resolve_python_import(rel, node):
132
+ imports.append({"source": rel, "target": target, "line": getattr(node, "lineno", 1)})
133
+ return imports
134
+
135
+
136
+ def _repo_map_js(path, rel):
137
+ text = _read_text(path)
138
+ imports = []
139
+ patterns = [
140
+ re.compile(r"""(?:from\s+|import\s*\()\s*['"]([^'"]+)['"]"""),
141
+ re.compile(r"""require\s*\(\s*['"]([^'"]+)['"]\s*\)"""),
142
+ ]
143
+ for lineno, line in enumerate(text.splitlines(), 1):
144
+ for pattern in patterns:
145
+ for match in pattern.finditer(line):
146
+ imports.append({"source": rel, "target": match.group(1), "line": lineno})
147
+ return imports
148
+
149
+
150
+ def repo_map(path, max_files=2000):
151
+ root = Path(path)
152
+ if not root.exists():
153
+ raise ValueError("路径不存在: %s" % path)
154
+ if not root.is_dir():
155
+ raise ValueError("repo_map 需要目录: %s" % path)
156
+ modules = []
157
+ imports = []
158
+ entrypoints = []
159
+ truncated = False
160
+ files = list(_iter_files(root, source_exts(), max_files=max_files + 1))
161
+ if len(files) > max_files:
162
+ truncated = True
163
+ files = files[:max_files]
164
+ for file_path in files:
165
+ rel = _rel(root, file_path)
166
+ try:
167
+ text = _read_text(file_path)
168
+ except (OSError, ValueError):
169
+ continue
170
+ language = _language(file_path)
171
+ modules.append({"path": rel, "language": language, "lines": len(text.splitlines())})
172
+ if file_path.suffix.lower() == ".py":
173
+ imports.extend(_repo_map_python(file_path, rel))
174
+ elif file_path.suffix.lower() in (".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx"):
175
+ imports.extend(_repo_map_js(file_path, rel))
176
+ if (
177
+ file_path.name in ("main.py", "cli.py", "app.py", "index.js", "index.ts")
178
+ or "if __name__ == '__main__'" in text
179
+ or 'if __name__ == "__main__"' in text
180
+ ):
181
+ entrypoints.append(rel)
182
+ modules.sort(key=lambda item: item["path"])
183
+ imports.sort(key=lambda item: (item["source"], item["line"], item["target"]))
184
+ entrypoints = sorted(set(entrypoints))
185
+ return {
186
+ "root": str(root.resolve()),
187
+ "modules": modules,
188
+ "imports": imports,
189
+ "entrypoints": entrypoints,
190
+ "truncated": truncated,
191
+ }
192
+
193
+
194
+ def source_exts():
195
+ return set(SOURCE_EXTS)
196
+
197
+
198
+ def _classify_match(line, query):
199
+ escaped = re.escape(query)
200
+ if re.search(r"^\s*(?:async\s+)?def\s+%s\b" % escaped, line):
201
+ return "definition"
202
+ if re.search(r"^\s*class\s+%s\b" % escaped, line):
203
+ return "definition"
204
+ if re.search(r"^\s*(?:export\s+)?(?:async\s+)?function\s+%s\b" % escaped, line):
205
+ return "definition"
206
+ if re.search(r"^\s*(?:export\s+)?(?:const|let|var)\s+%s\b" % escaped, line):
207
+ return "definition"
208
+ return "reference"
209
+
210
+
211
+ def find_code(path, query, extensions=None, max_results=100, context_lines=0):
212
+ root = Path(path)
213
+ if not root.exists():
214
+ raise ValueError("路径不存在: %s" % path)
215
+ if not query:
216
+ raise ValueError("find_code 需要 query")
217
+ if max_results < 1:
218
+ raise ValueError("max_results 必须大于 0")
219
+ context_lines = max(0, min(int(context_lines), 5))
220
+ matches = []
221
+ truncated = False
222
+ for file_path in _iter_files(root if root.is_dir() else root.parent, extensions=extensions):
223
+ if root.is_file() and file_path.resolve() != root.resolve():
224
+ continue
225
+ try:
226
+ lines = _read_text(file_path).splitlines()
227
+ except (OSError, ValueError):
228
+ continue
229
+ for index, line in enumerate(lines):
230
+ if query not in line:
231
+ continue
232
+ if len(matches) >= max_results:
233
+ truncated = True
234
+ break
235
+ start = max(0, index - context_lines)
236
+ end = min(len(lines), index + context_lines + 1)
237
+ matches.append({
238
+ "path": _rel(root if root.is_dir() else root.parent, file_path),
239
+ "line": index + 1,
240
+ "kind": _classify_match(line, query),
241
+ "text": line.strip()[:240],
242
+ "context": lines[start:end] if context_lines else [],
243
+ })
244
+ if truncated:
245
+ break
246
+ return {"query": query, "matches": matches, "truncated": truncated}
247
+
248
+
249
+ def _dedupe_lines(lines):
250
+ output = []
251
+ index = 0
252
+ while index < len(lines):
253
+ line = lines[index]
254
+ count = 1
255
+ while index + count < len(lines) and lines[index + count] == line:
256
+ count += 1
257
+ output.append(line + (" [x%d]" % count if count > 1 else ""))
258
+ index += count
259
+ return output
260
+
261
+
262
+ def _fit_text(lines, max_chars):
263
+ text = "\n".join(lines)
264
+ if len(text) <= max_chars:
265
+ return text
266
+ if max_chars < 20:
267
+ return text[:max_chars]
268
+ output = []
269
+ for line in lines:
270
+ candidate = "\n".join(output + [line])
271
+ if len(candidate) > max_chars - 20:
272
+ break
273
+ output.append(line)
274
+ if output and output[-1] != "... [truncated]":
275
+ output.append("... [truncated]")
276
+ return "\n".join(output)[:max_chars]
277
+
278
+
279
+ def compress_output(text=None, file=None, max_chars=4000, head_lines=40, tail_lines=40):
280
+ if file and text is None:
281
+ text = _read_text(file)
282
+ if text is None:
283
+ raise ValueError("compress_output 需要 text 或 file")
284
+ text = ANSI_RE.sub("", str(text)).replace("\r\n", "\n").replace("\r", "\n")
285
+ raw_lines = [line.rstrip() for line in text.splitlines()]
286
+ deduped = _dedupe_lines(raw_lines)
287
+ priority = [line for line in deduped if ERROR_RE.search(line)]
288
+ head = deduped[:max(0, int(head_lines))]
289
+ tail = deduped[-max(0, int(tail_lines)):] if tail_lines else []
290
+ chosen = []
291
+ for line in priority + head + tail:
292
+ if line not in chosen:
293
+ chosen.append(line)
294
+ result_text = _fit_text(chosen, int(max_chars))
295
+ return {
296
+ "text": result_text,
297
+ "original_lines": len(raw_lines),
298
+ "kept_lines": len(result_text.splitlines()),
299
+ "error_lines": len(priority),
300
+ "truncated": len(result_text) < len("\n".join(deduped)),
301
+ }
302
+
303
+
304
+ def _review_line(rel, line_no, line_text):
305
+ findings = []
306
+ for rule, severity, pattern, suggestion in REVIEW_RULES:
307
+ if pattern.search(line_text):
308
+ findings.append({
309
+ "path": rel,
310
+ "line": line_no,
311
+ "rule": rule,
312
+ "severity": severity,
313
+ "evidence": line_text.strip()[:240],
314
+ "suggestion": suggestion,
315
+ })
316
+ return findings
317
+
318
+
319
+ def review_code(path=None, text=None, max_findings=200):
320
+ findings = []
321
+ if text is not None:
322
+ for line_no, line in enumerate(str(text).splitlines(), 1):
323
+ findings.extend(_review_line("<text>", line_no, line))
324
+ else:
325
+ if not path:
326
+ raise ValueError("review_code 需要 path 或 text")
327
+ root = Path(path)
328
+ if not root.exists():
329
+ raise ValueError("路径不存在: %s" % path)
330
+ files = [root] if root.is_file() else list(_iter_files(root, extensions=source_exts()))
331
+ base = root.parent if root.is_file() else root
332
+ for file_path in files:
333
+ try:
334
+ lines = _read_text(file_path).splitlines()
335
+ except (OSError, ValueError):
336
+ continue
337
+ rel = _rel(base, file_path)
338
+ for line_no, line in enumerate(lines, 1):
339
+ findings.extend(_review_line(rel, line_no, line))
340
+ findings.sort(key=lambda item: (item["path"], item["line"], item["rule"]))
341
+ truncated = len(findings) > max_findings
342
+ return {"findings": findings[:max_findings], "truncated": truncated}
343
+
344
+
345
+ def _parse_added_lines(diff_text):
346
+ current_file = None
347
+ line_no = 0
348
+ for raw in diff_text.splitlines():
349
+ if raw.startswith("+++ "):
350
+ current_file = raw[4:].strip()
351
+ if current_file.startswith("b/"):
352
+ current_file = current_file[2:]
353
+ continue
354
+ match = re.match(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@", raw)
355
+ if match:
356
+ line_no = int(match.group(1))
357
+ continue
358
+ if current_file and raw.startswith("+") and not raw.startswith("+++"):
359
+ yield current_file, line_no, raw[1:]
360
+ line_no += 1
361
+ elif current_file and not raw.startswith("-") and not raw.startswith("\\"):
362
+ line_no += 1
363
+
364
+
365
+ def review_diff(diff_text=None, path=None, base=None, max_findings=200):
366
+ if diff_text is None:
367
+ if not path:
368
+ raise ValueError("review_diff 需要 diff_text 或 path")
369
+ command = ["git", "-C", str(path), "diff", "--no-ext-diff", "--unified=0"]
370
+ if base:
371
+ command.append(str(base))
372
+ proc = subprocess.run(command, capture_output=True, text=True)
373
+ if proc.returncode != 0:
374
+ raise ValueError("git diff 失败: %s" % (proc.stderr.strip() or proc.stdout.strip()))
375
+ diff_text = proc.stdout
376
+ findings = []
377
+ files = []
378
+ for rel, line_no, line in _parse_added_lines(diff_text):
379
+ if rel not in files:
380
+ files.append(rel)
381
+ findings.extend(_review_line(rel, line_no, line))
382
+ findings.sort(key=lambda item: (item["path"], item["line"], item["rule"]))
383
+ truncated = len(findings) > max_findings
384
+ return {"files": files, "findings": findings[:max_findings], "truncated": truncated}
385
+
386
+
387
+ def _frontmatter_version(text):
388
+ match = re.search(r"(?m)^version:\s*[\"']?([^\"'\r\n]+)", text)
389
+ return match.group(1).strip() if match else None
390
+
391
+
392
+ def _frontmatter_name(text):
393
+ match = re.search(r"(?m)^name:\s*[\"']?([^\"'\r\n]+)", text)
394
+ return match.group(1).strip() if match else None
395
+
396
+
397
+ def _default_skill_dirs():
398
+ home = Path.home()
399
+ candidates = [
400
+ home / ".codex" / "skills",
401
+ home / ".claude" / "skills",
402
+ home / ".cursor" / "skills",
403
+ home / ".config" / "opencode" / "skills",
404
+ ]
405
+ codex_home = os.environ.get("CODEX_HOME")
406
+ if codex_home:
407
+ candidates.insert(0, Path(codex_home) / "skills")
408
+ claude_home = os.environ.get("CLAUDE_CONFIG_DIR")
409
+ if claude_home:
410
+ candidates.insert(0, Path(claude_home) / "skills")
411
+ xdg_home = os.environ.get("XDG_CONFIG_HOME")
412
+ if xdg_home:
413
+ candidates.insert(0, Path(xdg_home) / "opencode" / "skills")
414
+ return candidates
415
+
416
+
417
+ def _default_config_paths():
418
+ home = Path.home()
419
+ return [
420
+ home / ".codex" / "config.json",
421
+ home / ".codex" / "mcp.json",
422
+ home / ".config" / "opencode" / "opencode.json",
423
+ home / ".claude" / "settings.json",
424
+ home / ".cursor" / "mcp.json",
425
+ ]
426
+
427
+
428
+ def mcp_doctor(skills_dirs=None, config_paths=None):
429
+ skills = []
430
+ issues = []
431
+ for directory in (skills_dirs or _default_skill_dirs()):
432
+ root = Path(directory)
433
+ if not root.is_dir():
434
+ continue
435
+ for child in sorted(root.iterdir(), key=lambda item: item.name):
436
+ skill_file = child / "SKILL.md"
437
+ if not child.is_dir() or not skill_file.is_file():
438
+ continue
439
+ try:
440
+ text = _read_text(skill_file)
441
+ except (OSError, ValueError) as exc:
442
+ issues.append("%s: %s" % (skill_file, exc))
443
+ continue
444
+ skills.append({
445
+ "name": _frontmatter_name(text) or child.name,
446
+ "version": _frontmatter_version(text),
447
+ "path": str(skill_file),
448
+ })
449
+ mcp_configs = []
450
+ for config_path in (config_paths or _default_config_paths()):
451
+ path = Path(config_path)
452
+ if not path.is_file():
453
+ continue
454
+ try:
455
+ payload = json.loads(_read_text(path))
456
+ except Exception as exc: # noqa: BLE001
457
+ issues.append("%s: %s" % (path, exc))
458
+ continue
459
+ servers = payload.get("mcpServers") if isinstance(payload, dict) else None
460
+ mcp_configs.append({
461
+ "path": str(path),
462
+ "servers": sorted(servers.keys()) if isinstance(servers, dict) else [],
463
+ })
464
+ skills.sort(key=lambda item: (item["name"], item["path"]))
465
+ mcp_configs.sort(key=lambda item: item["path"])
466
+ return {
467
+ "skills": skills,
468
+ "mcp_configs": mcp_configs,
469
+ "issues": issues,
470
+ "checked_skills": len(skills),
471
+ "checked_configs": len(mcp_configs),
472
+ }
473
+
474
+
475
+ SECRET_KEY_RE = re.compile(
476
+ r"""(?i)\b(api[_-]?key|access[_-]?key|secret|token|password|passwd|pwd)\b\s*[:=]\s*["']?([^"'\s#]{8,})"""
477
+ )
478
+ AWS_KEY_RE = re.compile(r"\bAKIA[0-9A-Z]{16}\b")
479
+ PRIVATE_KEY_RE = re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----")
480
+ HIGH_ENTROPY_RE = re.compile(r"[A-Za-z0-9_+/=\-]{32,}")
481
+
482
+
483
+ def _entropy(value):
484
+ if not value:
485
+ return 0.0
486
+ counts = {}
487
+ for char in value:
488
+ counts[char] = counts.get(char, 0) + 1
489
+ length = float(len(value))
490
+ return -sum((count / length) * math.log(count / length, 2) for count in counts.values())
491
+
492
+
493
+ def _redact(value):
494
+ if len(value) <= 8:
495
+ return "[REDACTED]"
496
+ return value[:4] + "...[REDACTED]"
497
+
498
+
499
+ def scan_secrets(path=None, text=None, max_findings=200, include_git_history=False):
500
+ entries = []
501
+ if text is not None:
502
+ entries.append(("<text>", str(text)))
503
+ else:
504
+ if not path:
505
+ raise ValueError("scan_secrets 需要 path 或 text")
506
+ root = Path(path)
507
+ if not root.exists():
508
+ raise ValueError("路径不存在: %s" % path)
509
+ files = [root] if root.is_file() else list(_iter_files(root, all_files=True))
510
+ base = root.parent if root.is_file() else root
511
+ for file_path in files:
512
+ try:
513
+ entries.append((_rel(base, file_path), _read_text(file_path)))
514
+ except (OSError, ValueError):
515
+ continue
516
+ if include_git_history:
517
+ entries.append(("git-history", _git_history_text(root)))
518
+ findings = []
519
+ for rel, content in entries:
520
+ for line_no, line in enumerate(content.splitlines(), 1):
521
+ if PRIVATE_KEY_RE.search(line):
522
+ findings.append({
523
+ "path": rel, "line": line_no, "rule": "private-key",
524
+ "severity": "critical", "evidence": "[REDACTED private key marker]",
525
+ "suggestion": "Remove the private key from source control and rotate it.",
526
+ })
527
+ for match in AWS_KEY_RE.finditer(line):
528
+ findings.append({
529
+ "path": rel, "line": line_no, "rule": "aws-access-key",
530
+ "severity": "critical", "evidence": _redact(match.group(0)),
531
+ "suggestion": "Rotate the key and move it to a secret manager.",
532
+ })
533
+ for match in SECRET_KEY_RE.finditer(line):
534
+ name = match.group(1).lower()
535
+ rule = "api-key" if "api" in name or "access" in name else (
536
+ "token" if "token" in name else "credential"
537
+ )
538
+ findings.append({
539
+ "path": rel, "line": line_no, "rule": rule,
540
+ "severity": "high", "evidence": "%s=%s" % (match.group(1), _redact(match.group(2))),
541
+ "suggestion": "Remove the literal credential and load it from the environment or a secret store.",
542
+ })
543
+ for match in HIGH_ENTROPY_RE.finditer(line):
544
+ token = match.group(0)
545
+ if _entropy(token) >= 4.0 and not PRIVATE_KEY_RE.search(line):
546
+ findings.append({
547
+ "path": rel, "line": line_no, "rule": "high-entropy-token",
548
+ "severity": "medium", "evidence": _redact(token),
549
+ "suggestion": "Verify whether this is a credential; if so, remove and rotate it.",
550
+ })
551
+ unique = {}
552
+ for item in findings:
553
+ key = (item["path"], item["line"], item["rule"], item["evidence"])
554
+ unique[key] = item
555
+ ordered = sorted(unique.values(), key=lambda item: (item["path"], item["line"], item["rule"], item["evidence"]))
556
+ return {"findings": ordered[:max_findings], "truncated": len(ordered) > max_findings}
557
+
558
+
559
+ def _git_history_text(root):
560
+ """Return bounded git diff text for secret scanning; never fail the scan."""
561
+ try:
562
+ process = subprocess.run(
563
+ ["git", "-C", str(root), "log", "-p", "--all", "--no-ext-diff", "--max-count=50"],
564
+ capture_output=True, text=True, timeout=30,
565
+ )
566
+ if process.returncode != 0:
567
+ return ""
568
+ return process.stdout[:MAX_FILE_BYTES]
569
+ except Exception: # noqa: BLE001
570
+ return ""
571
+
572
+
573
+ DEPENDENCY_LOCKFILES = {
574
+ "package-lock.json", "npm-shrinkwrap.json", "yarn.lock", "pnpm-lock.yaml",
575
+ "bun.lock", "poetry.lock", "uv.lock", "Pipfile.lock",
576
+ }
577
+
578
+
579
+ def _dependency_spec_issue(spec):
580
+ value = str(spec).strip()
581
+ if value in ("", "*", "latest") or value.startswith((">=", ">", "http://", "git+http://")):
582
+ return "unpinned-dependency"
583
+ if value.startswith(("file:", "link:")):
584
+ return "local-dependency"
585
+ return None
586
+
587
+
588
+ POPULAR_PACKAGES = (
589
+ "requests", "numpy", "pytest", "lodash", "express", "react", "vue",
590
+ "typescript", "fastapi", "pydantic", "openai", "axios",
591
+ )
592
+
593
+
594
+ def _edit_distance_one(left, right):
595
+ if abs(len(left) - len(right)) > 1:
596
+ return False
597
+ if left == right:
598
+ return False
599
+ if len(left) == len(right):
600
+ return sum(1 for a, b in zip(left, right) if a != b) == 1
601
+ short, long = (left, right) if len(left) < len(right) else (right, left)
602
+ index = 0
603
+ skipped = False
604
+ for char in long:
605
+ if index < len(short) and char == short[index]:
606
+ index += 1
607
+ elif skipped:
608
+ return False
609
+ else:
610
+ skipped = True
611
+ return True
612
+
613
+
614
+ def _typosquat_suspicion(name):
615
+ low = str(name).lower()
616
+ return any(_edit_distance_one(low, known) for known in POPULAR_PACKAGES)
617
+
618
+
619
+ def scan_dependencies(path):
620
+ root = Path(path)
621
+ if not root.exists():
622
+ raise ValueError("路径不存在: %s" % path)
623
+ if not root.is_dir():
624
+ raise ValueError("scan_dependencies 需要目录: %s" % path)
625
+ manifests = []
626
+ lockfiles = []
627
+ issues = []
628
+ for file_path in _iter_files(root, extensions={".json", ".txt", ".toml", ".lock"}, max_files=500):
629
+ rel = _rel(root, file_path)
630
+ if file_path.name in DEPENDENCY_LOCKFILES:
631
+ lockfiles.append(rel)
632
+ package_file = root / "package.json"
633
+ if package_file.is_file():
634
+ manifests.append("package.json")
635
+ try:
636
+ package = json.loads(_read_text(package_file))
637
+ except Exception as exc: # noqa: BLE001
638
+ issues.append({"code": "invalid-manifest", "severity": "high",
639
+ "manifest": "package.json", "message": str(exc)})
640
+ package = {}
641
+ groups = ("dependencies", "devDependencies", "optionalDependencies", "peerDependencies")
642
+ has_deps = False
643
+ for group in groups:
644
+ dependencies = package.get(group) if isinstance(package, dict) else None
645
+ if not isinstance(dependencies, dict):
646
+ continue
647
+ has_deps = has_deps or bool(dependencies)
648
+ for name, spec in sorted(dependencies.items()):
649
+ code = _dependency_spec_issue(spec)
650
+ if code:
651
+ issues.append({
652
+ "code": code, "severity": "medium", "manifest": "package.json",
653
+ "dependency": name, "message": "%s uses %s" % (name, spec),
654
+ })
655
+ if _typosquat_suspicion(name):
656
+ issues.append({
657
+ "code": "typosquat-suspicion", "severity": "medium",
658
+ "manifest": "package.json", "dependency": name,
659
+ "message": "%s is one edit away from a popular package; verify the name" % name,
660
+ "requires_manual_review": True,
661
+ })
662
+ if has_deps and not lockfiles:
663
+ issues.append({
664
+ "code": "missing-lockfile", "severity": "medium", "manifest": "package.json",
665
+ "message": "Dependencies exist but no lockfile was found.",
666
+ })
667
+ for requirement in sorted(root.glob("requirements*.txt")):
668
+ manifests.append(_rel(root, requirement))
669
+ for line_no, line in enumerate(_read_text(requirement).splitlines(), 1):
670
+ stripped = line.strip()
671
+ if not stripped or stripped.startswith("#"):
672
+ continue
673
+ if stripped.startswith(("git+http://", "http://")):
674
+ issues.append({"code": "insecure-source", "severity": "high",
675
+ "manifest": _rel(root, requirement), "line": line_no,
676
+ "message": stripped})
677
+ elif "@" in stripped and "==" not in stripped and ">=" not in stripped:
678
+ issues.append({"code": "unpinned-dependency", "severity": "medium",
679
+ "manifest": _rel(root, requirement), "line": line_no,
680
+ "message": stripped})
681
+ pyproject = root / "pyproject.toml"
682
+ if pyproject.is_file():
683
+ manifests.append("pyproject.toml")
684
+ text = _read_text(pyproject)
685
+ if "dependencies" in text and not (root / "poetry.lock").is_file() and not (root / "uv.lock").is_file():
686
+ issues.append({
687
+ "code": "missing-lockfile", "severity": "medium",
688
+ "manifest": "pyproject.toml",
689
+ "message": "Python dependencies exist but no poetry.lock / uv.lock was found.",
690
+ })
691
+ pipfile = root / "Pipfile"
692
+ if pipfile.is_file():
693
+ manifests.append("Pipfile")
694
+ if not (root / "Pipfile.lock").is_file():
695
+ issues.append({
696
+ "code": "missing-lockfile", "severity": "medium",
697
+ "manifest": "Pipfile",
698
+ "message": "Pipfile exists but Pipfile.lock was not found.",
699
+ })
700
+ issues.sort(key=lambda item: (item.get("manifest", ""), item.get("line", 0), item["code"], item.get("dependency", "")))
701
+ return {
702
+ "manifests": sorted(manifests),
703
+ "lockfiles": sorted(lockfiles),
704
+ "issues": issues,
705
+ "checked_manifests": len(manifests),
706
+ "checked_lockfiles": len(lockfiles),
707
+ }
708
+
709
+
710
+ def check_publish_readiness(path):
711
+ root = Path(path)
712
+ if not root.exists() or not root.is_dir():
713
+ raise ValueError("check_publish_readiness 需要目录: %s" % path)
714
+ required = ["package.json", "SKILL.md", "README.md", "LICENSE", "CHANGELOG.md"]
715
+ files = {}
716
+ issues = []
717
+ for name in required:
718
+ file_path = root / name
719
+ files[name] = file_path.is_file()
720
+ if not file_path.is_file():
721
+ issues.append({"code": "missing-file", "severity": "high",
722
+ "message": "Missing required file: %s" % name})
723
+ versions = {}
724
+ package = {}
725
+ package_path = root / "package.json"
726
+ if package_path.is_file():
727
+ try:
728
+ package = json.loads(_read_text(package_path))
729
+ except Exception as exc: # noqa: BLE001
730
+ issues.append({"code": "invalid-package-json", "severity": "high", "message": str(exc)})
731
+ package = {}
732
+ versions["package.json"] = package.get("version")
733
+ skill_path = root / "SKILL.md"
734
+ if skill_path.is_file():
735
+ versions["SKILL.md"] = _frontmatter_version(_read_text(skill_path))
736
+ changelog_path = root / "CHANGELOG.md"
737
+ if changelog_path.is_file():
738
+ match = re.search(r"(?m)^##\s+v?(\d+\.\d+\.\d+)", _read_text(changelog_path))
739
+ versions["CHANGELOG.md"] = match.group(1) if match else None
740
+ script_versions = []
741
+ for script in sorted(root.glob("scripts/*.py")):
742
+ try:
743
+ match = re.search(r'(?m)^VERSION\s*=\s*["\']([^"\']+)["\']', _read_text(script))
744
+ except (OSError, ValueError):
745
+ continue
746
+ if match:
747
+ script_versions.append(match.group(1))
748
+ if script_versions:
749
+ versions["engine"] = script_versions[0]
750
+ values = [value for value in versions.values() if value]
751
+ if len(set(values)) > 1:
752
+ issues.append({
753
+ "code": "version-mismatch", "severity": "high",
754
+ "message": "Version mismatch: %s" % json.dumps(versions, ensure_ascii=False, sort_keys=True),
755
+ })
756
+ repository = package.get("repository") if isinstance(package, dict) else None
757
+ repository_url = repository.get("url") if isinstance(repository, dict) else repository
758
+ if not repository_url:
759
+ issues.append({"code": "missing-repository", "severity": "medium",
760
+ "message": "package.json repository.url is missing."})
761
+ publish_config = package.get("publishConfig") if isinstance(package, dict) else None
762
+ if not isinstance(publish_config, dict) or publish_config.get("access") != "public":
763
+ issues.append({"code": "publish-access", "severity": "medium",
764
+ "message": "package.json publishConfig.access should be public."})
765
+ issues.sort(key=lambda item: (item["code"], item.get("message", "")))
766
+ return {
767
+ "ok": not any(item["severity"] == "high" for item in issues),
768
+ "root": str(root.resolve()),
769
+ "files": files,
770
+ "versions": versions,
771
+ "issues": issues,
772
+ }
773
+
774
+
775
+ CHECK_COMMANDS = {
776
+ "python-unittest": lambda: [sys.executable, "-m", "unittest", "discover", "-v"],
777
+ "pytest": lambda: [sys.executable, "-m", "pytest", "-q"],
778
+ "python-compile": lambda: [sys.executable, "-m", "compileall", "-q", "."],
779
+ "npm-test": lambda: ["npm", "test", "--silent"],
780
+ "npm-lint": lambda: ["npm", "run", "lint", "--silent"],
781
+ }
782
+
783
+
784
+ def run_checks(kind, cwd, timeout=120, allow_execute=False):
785
+ if kind not in CHECK_COMMANDS:
786
+ raise ValueError("unsupported check kind: %s" % kind)
787
+ if not allow_execute:
788
+ raise PermissionError("run_checks is disabled by default; pass allow_execute=true explicitly")
789
+ root = Path(cwd)
790
+ if not root.is_dir():
791
+ raise ValueError("cwd is not a directory: %s" % cwd)
792
+ command = CHECK_COMMANDS[kind]()
793
+ if os.name == "nt" and command[0] == "npm":
794
+ command = ["cmd", "/c"] + command
795
+ try:
796
+ process = subprocess.run(
797
+ command, cwd=str(root), capture_output=True, text=True, timeout=timeout
798
+ )
799
+ output = (process.stdout or "") + (process.stderr or "")
800
+ result = compress_output(output, max_chars=4000)
801
+ summary_lines = [
802
+ line.strip() for line in output.splitlines()
803
+ if re.search(r"(?i)(ran \d+ test|ok\b|failed\b|error\b|\d+ passed)", line)
804
+ ]
805
+ return {
806
+ "kind": kind,
807
+ "cwd": str(root.resolve()),
808
+ "exit_code": process.returncode,
809
+ "passed": process.returncode == 0,
810
+ "summary": "\n".join(summary_lines[:8]) or "no summary matched",
811
+ "output": result["text"],
812
+ "timed_out": False,
813
+ }
814
+ except subprocess.TimeoutExpired:
815
+ return {
816
+ "kind": kind, "cwd": str(root.resolve()), "exit_code": 124,
817
+ "passed": False, "summary": "timeout after %ss" % timeout,
818
+ "output": "", "timed_out": True,
819
+ }
820
+
821
+
822
+ def _license_text():
823
+ return (
824
+ "MIT License\n\nCopyright (c) 2026 YottaMeta\n\n"
825
+ "Permission is hereby granted, free of charge, to any person obtaining a copy\n"
826
+ "of this software and associated documentation files (the \"Software\"), to deal\n"
827
+ "in the Software without restriction, including without limitation the rights\n"
828
+ "to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n"
829
+ "copies of the Software, and to permit persons to whom the Software is\n"
830
+ "furnished to do so, subject to the following conditions:\n\n"
831
+ "The above copyright notice and this permission notice shall be included in all\n"
832
+ "copies or substantial portions of the Software.\n\n"
833
+ "THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n"
834
+ "IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n"
835
+ "FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n"
836
+ "AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n"
837
+ "LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n"
838
+ "OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n"
839
+ "SOFTWARE.\n"
840
+ )
841
+
842
+
843
+ def scaffold_skill(name, output_dir, apply=False, description=None):
844
+ if not re.match(r"^[a-z0-9][a-z0-9-]*$", str(name)):
845
+ raise ValueError("skill name must match ^[a-z0-9][a-z0-9-]*$")
846
+ target = Path(output_dir) / name
847
+ description = description or "Deterministic local helper skill."
848
+ files = {
849
+ "SKILL.md": (
850
+ "---\nname: %s\ndescription: %s\nversion: 0.1.0\nlicense: MIT\n---\n\n"
851
+ "# %s\n\n%s\n" % (name, description, name, description)
852
+ ),
853
+ "package.json": json.dumps({
854
+ "name": "@yottameta/%s" % name,
855
+ "version": "0.1.0",
856
+ "description": description,
857
+ "license": "MIT",
858
+ "repository": {"type": "git", "url": "git+https://github.com/YottaMeta/%s.git" % name},
859
+ "publishConfig": {"access": "public"},
860
+ "files": ["SKILL.md", "README.md", "scripts", "LICENSE", "NOTICE"],
861
+ }, ensure_ascii=False, indent=2) + "\n",
862
+ "README.md": "# %s\n\n%s\n" % (name, description),
863
+ "CHANGELOG.md": "# Changelog\n\n## v0.1.0 (2026-09-25)\n\n- Initial scaffold.\n",
864
+ "NOTICE": "# NOTICE\n\nGenerated by yotta-dev-mcp scaffold_skill.\n",
865
+ "LICENSE": _license_text(),
866
+ "scripts/%s.py" % name: (
867
+ "#!/usr/bin/env python3\n"
868
+ "# -*- coding: utf-8 -*-\n"
869
+ "\"\"\"%s.\"\"\"\n\n"
870
+ "def main():\n"
871
+ " return 0\n\n"
872
+ "if __name__ == '__main__':\n"
873
+ " raise SystemExit(main())\n" % description
874
+ ),
875
+ }
876
+ if apply and target.exists() and any(target.iterdir()):
877
+ raise ValueError("target already exists and is not empty: %s" % target)
878
+ if apply:
879
+ for rel, content in files.items():
880
+ file_path = target / rel
881
+ file_path.parent.mkdir(parents=True, exist_ok=True)
882
+ file_path.write_text(content, encoding="utf-8")
883
+ return {
884
+ "name": name,
885
+ "target": str(target),
886
+ "applied": bool(apply),
887
+ "files": [{"path": rel, "bytes": len(content.encode("utf-8"))}
888
+ for rel, content in sorted(files.items())],
889
+ }
890
+
891
+
892
+ def _atomic_write(path, content):
893
+ path = Path(path)
894
+ path.parent.mkdir(parents=True, exist_ok=True)
895
+ backup = None
896
+ if path.exists():
897
+ backup = path.with_suffix(path.suffix + ".bak")
898
+ shutil.copy2(str(path), str(backup))
899
+ temporary = path.with_suffix(path.suffix + ".tmp")
900
+ temporary.write_text(content, encoding="utf-8")
901
+ os.replace(str(temporary), str(path))
902
+ return str(backup) if backup else None
903
+
904
+
905
+ def workflow_state(root, action="read", date=None, text=None, file=None, apply=False):
906
+ workflow = Path(root) / ".workflow"
907
+ files = ["STATE.md", "TASKS.md", "DECISIONS.md", "ROADMAP.md"]
908
+ if action == "read":
909
+ existing = [name for name in files if (workflow / name).is_file()]
910
+ missing = [name for name in files if name not in existing]
911
+ excerpts = {}
912
+ for name in existing:
913
+ try:
914
+ content = _read_text(workflow / name)
915
+ except (OSError, ValueError):
916
+ content = ""
917
+ excerpts[name] = content[:1200]
918
+ return {
919
+ "ok": not missing,
920
+ "workflow": str(workflow.resolve()),
921
+ "files": existing,
922
+ "missing": missing,
923
+ "excerpts": excerpts,
924
+ "applied": False,
925
+ }
926
+ if action == "append-log":
927
+ if not date or not re.match(r"^\d{4}-\d{2}-\d{2}$", str(date)):
928
+ raise ValueError("append-log requires date=YYYY-MM-DD")
929
+ target = workflow / "logs" / ("%s.md" % date)
930
+ elif action == "append-file":
931
+ if file not in files:
932
+ raise ValueError("append-file requires one of: %s" % ", ".join(files))
933
+ target = workflow / file
934
+ else:
935
+ raise ValueError("unsupported workflow action: %s" % action)
936
+ body = (text or "").rstrip() + "\n"
937
+ preview = body
938
+ if apply:
939
+ target.parent.mkdir(parents=True, exist_ok=True)
940
+ if target.exists():
941
+ existing = _read_text(target)
942
+ if existing and not existing.endswith("\n"):
943
+ existing += "\n"
944
+ _atomic_write(target, existing + "\n" + body)
945
+ else:
946
+ header = ""
947
+ if action == "append-log":
948
+ header = "# 流水日志 %s\n\n" % date
949
+ _atomic_write(target, header + body)
950
+ return {
951
+ "ok": True,
952
+ "action": action,
953
+ "target": str(target.resolve()),
954
+ "applied": bool(apply),
955
+ "preview": preview,
956
+ }
957
+
958
+
959
+ def dispatch(name, arguments):
960
+ handlers = {
961
+ "repo_map": repo_map,
962
+ "find_code": find_code,
963
+ "compress_output": compress_output,
964
+ "review_code": review_code,
965
+ "review_diff": review_diff,
966
+ "mcp_doctor": mcp_doctor,
967
+ "scan_secrets": scan_secrets,
968
+ "scan_dependencies": scan_dependencies,
969
+ "check_publish_readiness": check_publish_readiness,
970
+ "run_checks": run_checks,
971
+ "scaffold_skill": scaffold_skill,
972
+ "workflow_state": workflow_state,
973
+ }
974
+ if name not in handlers:
975
+ raise ValueError("未知工具: %s" % name)
976
+ return handlers[name](**(arguments or {}))
977
+
978
+
979
+ def main():
980
+ parser = argparse.ArgumentParser(description="Deterministic development tools for yotta-dev-mcp")
981
+ parser.add_argument("--version", action="version", version=VERSION)
982
+ sub = parser.add_subparsers(dest="command")
983
+ repo = sub.add_parser("repo-map")
984
+ repo.add_argument("path")
985
+ find = sub.add_parser("find-code")
986
+ find.add_argument("path")
987
+ find.add_argument("query")
988
+ compress = sub.add_parser("compress-output")
989
+ compress.add_argument("file")
990
+ review = sub.add_parser("review-code")
991
+ review.add_argument("path")
992
+ doctor = sub.add_parser("mcp-doctor")
993
+ doctor.add_argument("--skills-dir", action="append")
994
+ doctor.add_argument("--config", action="append")
995
+ args = parser.parse_args()
996
+ if args.command == "repo-map":
997
+ result = repo_map(args.path)
998
+ elif args.command == "find-code":
999
+ result = find_code(args.path, args.query)
1000
+ elif args.command == "compress-output":
1001
+ result = compress_output(file=args.file)
1002
+ elif args.command == "review-code":
1003
+ result = review_code(args.path)
1004
+ elif args.command == "mcp-doctor":
1005
+ result = mcp_doctor(skills_dirs=args.skills_dir, config_paths=args.config)
1006
+ else:
1007
+ parser.print_help()
1008
+ return 2
1009
+ sys.stdout.write(json.dumps(_json_safe(result), ensure_ascii=False, indent=2) + "\n")
1010
+ return 0
1011
+
1012
+
1013
+ if __name__ == "__main__":
1014
+ sys.exit(main())