feat(tools): add grep and workspace profile, fix write path

- Add grep_tool: ripgrep with Python fallback; avoid --json with -l/-c.
- Add workspace_profile_tool: scan, languages, tests, packages, git, CI hints; tests.
- write_file: resolve relative paths via workspace root (no data/workspace prefix).
- Update path guard and local public tool tests for new write behavior.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-05-13 23:02:52 +08:00
parent 48190afbc1
commit 2b32d11f43
6 changed files with 1019 additions and 21 deletions

View file

@ -0,0 +1,569 @@
from __future__ import annotations
import os
import subprocess
from pathlib import Path
from typing import Any
from runtime.tools.base import ToolSpec
from runtime.tools.path_guard import resolve_workspace_path
# ── Language extension map ────────────────────────────────────────
LANGUAGE_MAP: dict[str, str] = {
".py": "Python",
".js": "JavaScript",
".mjs": "JavaScript",
".cjs": "JavaScript",
".jsx": "JavaScript React",
".ts": "TypeScript",
".tsx": "TypeScript React",
".go": "Go",
".java": "Java",
".rs": "Rust",
".rb": "Ruby",
".php": "PHP",
".swift": "Swift",
".kt": "Kotlin",
".kts": "Kotlin",
".scala": "Scala",
".cs": "C#",
".cpp": "C++",
".cc": "C++",
".cxx": "C++",
".c": "C",
".h": "C/C++ Header",
".hpp": "C++ Header",
".sh": "Shell",
".bash": "Shell",
".zsh": "Shell",
".bat": "Batch",
".cmd": "Batch",
".ps1": "PowerShell",
".md": "Markdown",
".json": "JSON",
".yaml": "YAML",
".yml": "YAML",
".toml": "TOML",
".xml": "XML",
".html": "HTML",
".htm": "HTML",
".css": "CSS",
".scss": "SCSS",
".sass": "Sass",
".less": "Less",
".sql": "SQL",
".env": "Environment Variables",
".ini": "INI Config",
".cfg": "Config",
".txt": "Text",
".vue": "Vue",
".svelte": "Svelte",
".ex": "Elixir",
".exs": "Elixir",
".erl": "Erlang",
".dart": "Dart",
".lua": "Lua",
".r": "R",
".jl": "Julia",
".hs": "Haskell",
".zig": "Zig",
".nim": "Nim",
".pl": "Perl",
".pm": "Perl Module",
".tcl": "Tcl",
".clj": "Clojure",
".cljs": "ClojureScript",
}
# ── Entry point patterns ──────────────────────────────────────────
ENTRY_PATTERNS = frozenset({
"main.py", "app.py", "index.py", "cli.py", "manage.py",
"wsgi.py", "asgi.py", "run.py", "server.py",
"index.js", "index.ts", "app.js", "app.ts", "server.js", "server.ts",
"main.go", "main.rs", "main.java", "Main.java",
"main.kt", "main.scala",
})
# ── Test framework indicators ─────────────────────────────────────
TEST_FILES: dict[str, list[str]] = {
"pytest": ["pytest.ini", "conftest.py", "pyproject.toml"],
"jest": ["jest.config.js", "jest.config.ts", "jest.config.json", "jest.config.mjs"],
"vitest": ["vitest.config.ts", "vitest.config.js"],
"mocha": [".mocharc.yml", ".mocharc.js", ".mocharc.json"],
"playwright": ["playwright.config.ts", "playwright.config.js"],
"cypress": ["cypress.config.ts", "cypress.config.js"],
"rspec": [".rspec"],
}
# ── Package manager indicators ────────────────────────────────────
PKG_FILES: dict[str, list[str]] = {
"pip": ["requirements.txt", "setup.py", "setup.cfg"],
"poetry": ["pyproject.toml"],
"uv": ["uv.lock"],
"npm": ["package.json", "package-lock.json"],
"yarn": ["yarn.lock"],
"pnpm": ["pnpm-lock.yaml"],
"bun": ["bun.lockb"],
"cargo": ["Cargo.toml", "Cargo.lock"],
"go_modules": ["go.mod", "go.sum"],
"maven": ["pom.xml"],
"gradle": ["build.gradle", "build.gradle.kts"],
"composer": ["composer.json", "composer.lock"],
"gem": ["Gemfile", "Gemfile.lock"],
"swiftpm": ["Package.swift"],
"mix": ["mix.exs"],
}
# Directories to skip during scanning
SKIP_DIRS = frozenset({
".git", "__pycache__", "node_modules", ".venv", "venv",
".tox", ".eggs", "eggs", ".mypy_cache", ".pytest_cache",
".ruff_cache", ".hypothesis", ".coverage", "htmlcov",
"dist", "build", ".next", ".nuxt", ".output",
"target", "vendor", ".bundle", ".gradle", ".idea", ".vscode",
".svn", ".hg", ".DS_Store",
})
# Hidden dirs we still descend into (CI, hooks, local toolchain metadata)
KEEP_DOT_DIRS = frozenset({
".github", ".husky", ".cargo", ".gitea", ".gitlab", ".buildkite", ".woodpecker",
})
# Hard cap so callers cannot request an unbounded walk
_MAX_FILES_CAP = 50_000
def _walkable_dir(name: str) -> bool:
if name in SKIP_DIRS:
return False
if name.startswith(".") and name not in KEEP_DOT_DIRS:
return False
return True
def _scan_workspace(root: Path, max_files: int = 5000) -> tuple[list[dict[str, Any]], int]:
"""Scan workspace files, returning (file_infos, skipped_dir_entries)."""
files: list[dict[str, Any]] = []
skipped_dirs_count = 0
try:
for dirpath_str, dirnames, filenames in os.walk(root, followlinks=False):
if len(files) >= max_files:
dirnames[:] = []
continue
dirpath = Path(dirpath_str)
before = list(dirnames)
dirnames[:] = [d for d in dirnames if _walkable_dir(d)]
skipped_dirs_count += len(before) - len(dirnames)
rel = dirpath.relative_to(root) if dirpath != root else Path(".")
for fname in filenames:
if len(files) >= max_files:
break
if fname.startswith(".") and fname not in (
".env", ".gitignore", ".dockerignore", ".editorconfig", ".prettierrc",
):
continue
try:
fp = dirpath / fname
st = fp.stat()
except OSError:
continue
suffix = fp.suffix.lower()
ext = suffix or (fname if "." not in fname else "")
if st.st_size == 0 and suffix not in (".env", ".gitignore"):
continue
files.append({
"path": str(rel / fname) if str(rel) != "." else fname,
"size": st.st_size,
"ext": ext,
"suffix": suffix,
})
if len(files) >= max_files:
dirnames[:] = []
except PermissionError:
pass
return files, skipped_dirs_count
def _build_language_stats(
files: list[dict[str, Any]],
) -> dict[str, Any]:
"""Build language breakdown from scanned files."""
ext_counter: dict[str, int] = {}
ext_bytes: dict[str, int] = {}
lang_files: dict[str, int] = {}
lang_bytes: dict[str, int] = {}
for f in files:
ext = f["ext"]
ext_counter[ext] = ext_counter.get(ext, 0) + 1
ext_bytes[ext] = ext_bytes.get(ext, 0) + f["size"]
for ext, count in ext_counter.items():
lang = LANGUAGE_MAP.get(ext, ext.lstrip(".").capitalize() if ext else "Other")
lang_files[lang] = lang_files.get(lang, 0) + count
lang_bytes[lang] = lang_bytes.get(lang, 0) + ext_bytes.get(ext, 0)
total_files = sum(lang_files.values())
total_bytes = sum(lang_bytes.values())
sorted_langs = sorted(lang_files.items(), key=lambda x: -x[1])
breakdown = []
for lang, count in sorted_langs:
b = lang_bytes[lang]
breakdown.append({
"language": lang,
"files": count,
"bytes": b,
"pct": round(count / total_files * 100, 1) if total_files else 0,
})
return {
"total_files": total_files,
"total_bytes": total_bytes,
"languages": breakdown,
"top_language": breakdown[0]["language"] if breakdown else None,
}
def _find_largest_files(
files: list[dict[str, Any]], top_n: int = 10,
) -> list[dict[str, Any]]:
"""Find the largest files by byte size."""
sorted_files = sorted(files, key=lambda x: -x["size"])
result = []
for f in sorted_files[:top_n]:
result.append({
"path": f["path"],
"bytes": f["size"],
"ext": f["suffix"] or f["ext"],
})
return result
def _looks_like_test_file(rel: str) -> bool:
pp = Path(rel)
name = pp.name
if name.startswith("test_") or name.endswith("_test.py"):
return True
parts = pp.parts
if "tests" in parts:
return True
return any(part.startswith("test_") for part in parts)
def _detect_test_framework(files: list[dict[str, Any]]) -> dict[str, Any]:
"""Detect test frameworks used in the project."""
detected: list[str] = []
configs: list[str] = []
test_file_count = 0
file_names = {Path(f["path"]).name for f in files}
for framework, indicators in TEST_FILES.items():
for ind in indicators:
if ind in file_names:
detected.append(framework)
configs.append(ind)
break
for f in files:
if _looks_like_test_file(f["path"]):
test_file_count += 1
return {
"detected": len(detected) > 0 or test_file_count > 0,
"frameworks": detected if detected else (["unittest"] if test_file_count > 0 else []),
"config_files": configs,
"test_file_count": test_file_count,
}
def _detect_package_manager(files: list[dict[str, Any]]) -> dict[str, Any]:
"""Detect package managers used in the project."""
detected: list[str] = []
configs: list[str] = []
file_names = {Path(f["path"]).name for f in files}
for manager, indicators in PKG_FILES.items():
for ind in indicators:
if ind in file_names:
detected.append(manager)
configs.append(ind)
break
if any(Path(f["path"]).suffix.lower() == ".csproj" for f in files):
detected.append("nuget")
first = next((f["path"] for f in files if Path(f["path"]).suffix.lower() == ".csproj"), "")
if first:
configs.append(Path(first).name)
return {
"detected": len(detected) > 0,
"managers": detected,
"config_files": configs,
}
def _find_entry_points(files: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Find common project entry points."""
entries: list[dict[str, Any]] = []
file_names = {Path(f["path"]).name for f in files}
for pattern in ENTRY_PATTERNS:
if pattern in file_names:
entries.append({
"path": pattern,
"type": _classify_entry(pattern),
})
for f in files:
parts = Path(f["path"]).parts
if len(parts) >= 2 and parts[0] in ("cmd", "bin"):
name = Path(f["path"]).name
if name not in {e["path"] for e in entries}:
entries.append({
"path": f["path"],
"type": "cli_entry",
})
return entries
def _classify_entry(name: str) -> str:
if name.endswith(".py"):
return "python_entry"
elif name.endswith((".js", ".mjs", ".cjs")):
return "js_entry"
elif name.endswith((".ts", ".mts", ".cts")):
return "ts_entry"
elif name.endswith(".go"):
return "go_entry"
elif name.endswith(".rs"):
return "rust_entry"
elif name.endswith((".java", ".kt", ".scala")):
return "jvm_entry"
return "unknown"
def _get_git_status(root: Path) -> dict[str, Any]:
"""Get git status summary."""
git_root = root / ".git"
if not git_root.exists():
return {"detected": False}
result: dict[str, Any] = {"detected": True}
try:
# Branch
branch_proc = subprocess.run(
["git", "-C", str(root), "rev-parse", "--abbrev-ref", "HEAD"],
capture_output=True, text=True, timeout=5,
)
result["branch"] = branch_proc.stdout.strip() if branch_proc.returncode == 0 else "unknown"
# Status - porcelain
status_proc = subprocess.run(
["git", "-C", str(root), "status", "--porcelain"],
capture_output=True, text=True, timeout=5,
)
lines = [l.strip() for l in status_proc.stdout.strip().split("\n") if l.strip()]
modified = sum(1 for l in lines if l.startswith(" M") or l.startswith("M ") or l.startswith("MM"))
added = sum(1 for l in lines if l.startswith("A "))
deleted = sum(1 for l in lines if l.startswith(" D") or l.startswith("D "))
renamed = sum(1 for l in lines if l.startswith("R "))
untracked = sum(1 for l in lines if l.startswith("??"))
result["modified"] = modified
result["added"] = added
result["deleted"] = deleted
result["renamed"] = renamed
result["untracked"] = untracked
result["total_uncommitted"] = len(lines)
# Recent commits
log_proc = subprocess.run(
["git", "-C", str(root), "log", "--oneline", "-5"],
capture_output=True, text=True, timeout=5,
)
if log_proc.returncode == 0:
recent = [l.strip() for l in log_proc.stdout.strip().split("\n") if l.strip()]
result["recent_commits"] = recent
# Last commit
last_proc = subprocess.run(
["git", "-C", str(root), "log", "-1", "--format=%h %s (%ai)"],
capture_output=True, text=True, timeout=5,
)
if last_proc.returncode == 0:
result["last_commit"] = last_proc.stdout.strip()
except Exception as exc:
result["error"] = str(exc)
return result
def _path_suggests_ci(rel: str) -> bool:
p = rel.replace("\\", "/")
pl = p.lower()
if ".github/workflows/" in pl:
return True
name = Path(p).name.lower()
if name in (
"jenkinsfile",
".gitlab-ci.yml",
"gitlab-ci.yml",
"azure-pipelines.yml",
".travis.yml",
"appveyor.yml",
"buildkite.yml",
"bitbucket-pipelines.yml",
):
return True
parts = tuple(Path(pl).parts)
if ".circleci" in parts or ".woodpecker" in parts:
return True
return any(seg == "jenkinsfile" for seg in parts)
def _generate_suggestions(
lang_stats: dict[str, Any],
test: dict[str, Any],
pkg: dict[str, Any],
entries: list[dict[str, Any]],
git: dict[str, Any],
file_count: int,
files: list[dict[str, Any]],
) -> list[str]:
"""Generate actionable suggestions based on profile."""
suggestions: list[str] = []
top_lang = lang_stats.get("top_language")
# Missing test framework
if not test.get("detected"):
suggestions.append("未检测到测试框架。建议引入 pytest 并为关键模块编写测试。")
# Missing package manager
if not pkg.get("detected"):
if top_lang == "Python":
suggestions.append("未检测到包管理器配置文件。建议添加 pyproject.toml 或 requirements.txt。")
elif top_lang in ("JavaScript", "TypeScript"):
suggestions.append("未检测到包管理器。建议初始化 package.json(npm init)。")
elif top_lang == "Go":
suggestions.append("未检测到 Go module。建议运行 go mod init <module-name>。")
# Many uncommitted changes
uncommitted = git.get("total_uncommitted", 0)
if git.get("detected") and uncommitted > 10:
suggestions.append(f"有 {uncommitted} 个未提交的变更。建议分批次提交以保持历史清晰。")
# No entry points found
if not entries:
suggestions.append("未检测到常见入口文件。项目结构可能需要明确的主入口。")
if file_count > 200:
has_ci = any(_path_suggests_ci(f["path"]) for f in files)
if not has_ci:
suggestions.append(
"文件量较多但未发现常见 CI 配置(如 .github/workflows、Jenkinsfile、"
".gitlab-ci.yml、Azure Pipelines、CircleCI、Bitbucket Pipelines)。可考虑补充自动化流水线。"
)
return suggestions
def workspace_profile_tool() -> ToolSpec:
"""Workspace profile — one-shot codebase overview."""
def _handler(args: dict[str, Any]) -> dict[str, Any]:
root_raw = str(args.get("root") or ".").strip()
max_files = int(args.get("max_files") or 5000)
if max_files <= 0:
max_files = 1
if max_files > _MAX_FILES_CAP:
max_files = _MAX_FILES_CAP
try:
root = resolve_workspace_path(root_raw)
except Exception as exc:
return {"ok": False, "error_code": "invalid_root", "detail": str(exc)}
if not root.is_dir():
return {"ok": False, "error_code": "not_a_directory", "detail": str(root)}
# ── Scan ──────────────────────────────────────────────────
files, skipped = _scan_workspace(root, max_files=max_files)
# ── Profile sections ──────────────────────────────────────
lang_stats = _build_language_stats(files)
largest = _find_largest_files(files, top_n=10)
test_info = _detect_test_framework(files)
pkg_info = _detect_package_manager(files)
entries = _find_entry_points(files)
git_info = _get_git_status(root)
suggestions = _generate_suggestions(
lang_stats, test_info, pkg_info, entries, git_info, len(files), files,
)
# ── Assemble ──────────────────────────────────────────────
profile = {
"project_name": root.name,
"total_files_scanned": len(files),
"skipped_dirs": skipped,
"truncated": len(files) >= max_files,
"languages": lang_stats,
"largest_files": largest,
"test_framework": test_info,
"package_manager": pkg_info,
"entry_points": entries,
"git": git_info,
"suggestions": suggestions,
}
return {"ok": True, "profile": profile}
return ToolSpec(
name="workspace_profile",
description=(
"Quick codebase profiling: language breakdown, largest files, test framework, package manager, "
"entry points, git status, and actionable suggestions. Filesystem walk with common vendor/build "
"directories skipped; not .gitignore-aware (unlike ripgrep). One-shot overview for unfamiliar projects."
),
parameters={
"type": "object",
"properties": {
"root": {
"type": "string",
"default": ".",
"description": "Directory to profile (relative to workspace root).",
},
"max_files": {
"type": "integer",
"default": 5000,
"description": "Maximum files to collect (1–50000; prevents hangs on large repos).",
},
},
"additionalProperties": False,
},
handler=_handler,
tags=frozenset({"public", "profile", "workspace", "analysis", "readonly"}),
risk_level="low",
read_only=True,
timeout_s=60.0,
)
__all__ = ["workspace_profile_tool"]