@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.
- package/.agents/plugins/marketplace.json +25 -0
- package/.claude-plugin/marketplace.json +25 -0
- package/LICENSE +21 -0
- package/README.md +107 -0
- package/mcp.json +12 -0
- package/package.json +13 -10
- package/plugin.json +20 -0
- package/skills/yotta-dev-mcp/LICENSE +21 -0
- package/skills/yotta-dev-mcp/NOTICE +7 -0
- package/skills/yotta-dev-mcp/SKILL.md +67 -0
- package/skills/yotta-dev-mcp/assets/banner.png +0 -0
- package/skills/yotta-dev-mcp/bin/yotta-dev-mcp.js +57 -0
- package/skills/yotta-dev-mcp/references/tools.md +111 -0
- package/skills/yotta-dev-mcp/scripts/dev_engine.py +1014 -0
- package/skills/yotta-dev-mcp/scripts/dev_rules.py +25 -0
- package/skills/yotta-dev-mcp/scripts/yotta_dev_mcp.py +430 -0
- package/skills/yotta-dev-mcp/server.json +20 -0
|
@@ -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())
|