scripts/ast_grep_helper.py
scripts/ast_grep_helper.pyBrowse 14 files
7,086 tokens
27,671 bytes
Token encoding: o200k_base
Snapshot 24fd22b
← Back to SKILL.md
1#!/usr/bin/env python32"""ast-grep-helper: a thin LLM-friendly wrapper around `sg` (ast-grep).3 4Single-file Python 3 stdlib. No deps. Works on macOS, Linux, Windows, WSL.5 6WHAT IT ADDS over plain `sg`:7 1. Binary auto-resolution: cached -> @ast-grep/cli -> PATH -> Homebrew -> error with install hint8 2. Pattern hint validation: detects regex misuse (\\w, .*, |, [a-z]) and language-specific9 mistakes (Python trailing colon, JS/Go/Rust missing function body) BEFORE calling sg10 3. Two-pass replace: ast-grep silently ignores --update-all when --json is set, so we run11 a JSON pass to collect matches, then a separate --update-all pass to mutate files12 4. Stable JSON output: parses sg --json=compact, salvages truncated output, normalizes shape13 5. Cross-OS path handling: works the same on POSIX and Windows (uses pathlib + shutil)14 15USAGE16 ast_grep_helper.py search PATTERN [PATH...] [--lang LANG] [--globs GLOB ...] [-C N]17 ast_grep_helper.py replace PATTERN REWRITE [PATH...] [--lang LANG] [--apply] [--globs GLOB ...]18 ast_grep_helper.py scan RULE_FILE [PATH...] [--apply] [--report-style STYLE]19 ast_grep_helper.py test [-c CONFIG] [-t TEST_DIR] [-U]20 ast_grep_helper.py new {project,rule,test,util} [NAME] [--lang LANG]21 ast_grep_helper.py langs # list 25 supported languages22 ast_grep_helper.py doctor # check binary availability + version23 ast_grep_helper.py install # delegate to ../install.sh / install.ps124 ast_grep_helper.py validate PATTERN [--lang LANG] # offline pattern hint check only25 ast_grep_helper.py --version26 ast_grep_helper.py --help27 28EXAMPLES29 # Find all console.log calls in TypeScript30 ast_grep_helper.py search 'console.log($MSG)' --lang ts src/31 32 # Migrate console.log -> logger.info (dry-run preview)33 ast_grep_helper.py replace 'console.log($MSG)' 'logger.info($MSG)' --lang ts src/34 35 # Apply the same replacement36 ast_grep_helper.py replace 'console.log($MSG)' 'logger.info($MSG)' --lang ts src/ --apply37 38 # Validate a pattern offline (no sg call, no filesystem access)39 ast_grep_helper.py validate '\\w+' --lang ts40 # -> exit 2, hint: "regex \\w not supported. Use $VAR for identifiers."41 42EXIT CODES43 0 Success (matches found OR replacement applied OR validation passed)44 1 Argument error45 2 Pattern hint failure (regex misuse, missing body, etc.) - call would have failed46 3 ast-grep binary not found and auto-install declined47 4 ast-grep call failed (returned non-zero, with stderr forwarded)48 5 Timeout (5 minutes per call by default)49"""50 51from __future__ import annotations52 53import argparse54import json55import os56import platform57import re58import shutil59import subprocess60import sys61from pathlib import Path62from typing import Optional63 64VERSION = "0.1.0"65 66# 25 CLI languages supported by ast-grep, with their aliases (mirrors official docs)67LANGUAGES: dict[str, list[str]] = {68 "bash": [".bash", ".sh", ".zsh"],69 "c": [".c", ".h"],70 "cpp": [".cc", ".cpp", ".cxx", ".hpp", ".hxx"],71 "csharp": [".cs"],72 "css": [".css"],73 "elixir": [".ex", ".exs"],74 "go": [".go"],75 "haskell": [".hs"],76 "html": [".html", ".htm"],77 "java": [".java"],78 "javascript": [".js", ".jsx", ".cjs", ".mjs"],79 "json": [".json"],80 "kotlin": [".kt", ".kts"],81 "lua": [".lua"],82 "nix": [".nix"],83 "php": [".php"],84 "python": [".py", ".pyi"],85 "ruby": [".rb"],86 "rust": [".rs"],87 "scala": [".scala"],88 "solidity": [".sol"],89 "swift": [".swift"],90 "typescript": [".ts", ".cts", ".mts"],91 "tsx": [".tsx"],92 "yaml": [".yml", ".yaml"],93}94 95# Aliases that ast-grep CLI accepts; we normalize to the canonical name.96LANG_ALIASES: dict[str, str] = {97 "js": "javascript", "jsx": "javascript",98 "ts": "typescript",99 "py": "python", "py3": "python",100 "rb": "ruby",101 "rs": "rust",102 "kt": "kotlin",103 "ex": "elixir",104 "hs": "haskell",105 "sh": "bash", "zsh": "bash",106 "cc": "cpp", "c++": "cpp", "cxx": "cpp",107 "cs": "csharp",108 "yml": "yaml",109 "sol": "solidity",110 "golang": "go",111}112 113# Default search timeout (5 min). ast-grep calls can be slow on huge repos.114DEFAULT_TIMEOUT_S = 300115 116 117# ---------- logging ----------118 119def trace(msg: str) -> None:120 """Print a trace line to stderr (suppressible via --quiet, default off)."""121 if not _QUIET:122 print(f"[ast-grep-helper] {msg}", file=sys.stderr, flush=True)123 124 125def err(msg: str) -> None:126 """Print an error line to stderr (always shown)."""127 print(f"[ast-grep-helper] error: {msg}", file=sys.stderr, flush=True)128 129 130_QUIET = False131 132 133# ---------- binary resolution ----------134 135def script_dir() -> Path:136 return Path(__file__).resolve().parent137 138 139def skill_root() -> Path:140 return script_dir().parent141 142 143def cached_binary() -> Optional[Path]:144 """Look in <skill_root>/bin/ for a previously downloaded binary."""145 binname = "sg.exe" if os.name == "nt" else "sg"146 altname = "ast-grep.exe" if os.name == "nt" else "ast-grep"147 for name in (binname, altname):148 p = skill_root() / "bin" / name149 if p.is_file() and os.access(p, os.X_OK):150 return p151 return None152 153 154def npm_binary() -> Optional[Path]:155 """If @ast-grep/cli is installed globally via npm, find its binary."""156 # `sg` shipped by @ast-grep/cli is on PATH when npm prefix bin is on PATH.157 # We rely on shutil.which for that case.158 return None # handled by which_binary159 160 161def which_binary() -> Optional[Path]:162 """Use shutil.which to find sg or ast-grep on PATH.163 164 On Linux, plain `sg` collides with the setgroups command from util-linux165 (sometimes called via /usr/bin/sg) which has flag --version that returns166 non-zero, so we prefer `ast-grep` when both are on PATH and the `sg` we find167 is the wrong one.168 """169 for name in ("ast-grep", "sg"):170 found = shutil.which(name)171 if found:172 p = Path(found)173 # On Linux, double-check by trying --version. The util-linux `sg`174 # rejects --version, while ast-grep prints "ast-grep <version>".175 if name == "sg" and platform.system() == "Linux":176 try:177 out = subprocess.run(178 [str(p), "--version"],179 capture_output=True,180 text=True,181 timeout=5,182 )183 if out.returncode != 0 or "ast-grep" not in (out.stdout + out.stderr).lower():184 continue185 except Exception:186 continue187 return p188 return None189 190 191def homebrew_binary() -> Optional[Path]:192 """Common Homebrew install paths."""193 candidates = [194 Path("/opt/homebrew/bin/ast-grep"),195 Path("/opt/homebrew/bin/sg"),196 Path("/usr/local/bin/ast-grep"),197 Path("/usr/local/bin/sg"),198 ]199 for p in candidates:200 if p.is_file() and os.access(p, os.X_OK):201 return p202 return None203 204 205# --- OMO runtime resolution (vendored patch) ---206 207def omo_env_binary() -> Optional[Path]:208 raw_path = os.environ.get("OMO_AST_GREP_SG_PATH")209 if not raw_path:210 return None211 path = Path(raw_path).expanduser()212 if path.is_file() and os.access(path, os.X_OK):213 return path214 return None215 216 217def omo_runtime_slug() -> str:218 if sys.platform.startswith("win"):219 os_slug = "win32"220 elif sys.platform == "darwin":221 os_slug = "darwin"222 else:223 os_slug = "linux"224 225 machine = platform.machine().lower()226 arch_slug = "arm64" if machine in {"arm64", "aarch64"} else "x64"227 return f"{os_slug}-{arch_slug}"228 229 230def omo_runtime_binary() -> Optional[Path]:231 binary_name = "sg.exe" if sys.platform.startswith("win") else "sg"232 slug = omo_runtime_slug()233 candidates: list[Path] = []234 235 codex_home = os.environ.get("CODEX_HOME")236 if codex_home:237 candidates.append(Path(codex_home) / "runtime" / "ast-grep" / slug / binary_name)238 candidates.append(Path.home() / ".omo" / "runtime" / "ast-grep" / slug / binary_name)239 240 for path in candidates:241 if path.is_file() and os.access(path, os.X_OK):242 return path243 return None244 245 246def resolve_binary() -> Optional[Path]:247 """Resolve the ast-grep binary in priority order.248 249 1. OMO_AST_GREP_SG_PATH override250 2. OMO runtime dirs251 3. Cached binary in <skill>/bin/252 4. PATH (via shutil.which)253 5. Homebrew default paths254 """255 for fn in (omo_env_binary, omo_runtime_binary, cached_binary, which_binary, homebrew_binary):256 result = fn()257 if result:258 return result259 return None260 261 262def require_binary() -> Path:263 """Resolve binary, or print an actionable install hint and exit 3."""264 p = resolve_binary()265 if p:266 return p267 err("ast-grep binary not found.")268 err("")269 err("Install via one of:")270 err(f" bash {skill_root()}/install.sh # POSIX (auto-detects best method)")271 err(f" pwsh {skill_root()}/install.ps1 # Windows")272 err("")273 err("Or manually:")274 err(" brew install ast-grep # macOS / linuxbrew")275 err(" npm install -g @ast-grep/cli # any OS with Node")276 err(" cargo install ast-grep --locked # any OS with Rust")277 err(" pip install ast-grep-cli # any OS with Python")278 err(" scoop install main/ast-grep # Windows / Scoop")279 err("")280 err("See references/install.md for the full table.")281 sys.exit(3)282 283 284# ---------- pattern hint validation ----------285 286# Regex anti-patterns that ast-grep does NOT support but LLMs frequently emit.287# Each tuple: (regex_to_detect, hint_message)288REGEX_ANTIPATTERNS: list[tuple[re.Pattern[str], str]] = [289 (re.compile(r"\\w|\\d|\\s|\\b"),290 "Backslash escapes (\\w, \\d, \\s, \\b) are regex syntax, not ast-grep. "291 "Use $VAR to capture any identifier, or switch to grep for text patterns."),292 (re.compile(r"(?<!\$)\.\*|(?<!\$)\.\+"),293 "'.*' and '.+' are regex wildcards, not ast-grep. "294 "Use $$$ between AST fragments to match many nodes, or $VAR for one node."),295 (re.compile(r"\[[a-zA-Z0-9-]+\]"),296 "Character classes like '[a-z]' are regex syntax. "297 "ast-grep has no AST equivalent - use grep for character-level patterns."),298]299 300 301def find_alternation(pattern: str) -> bool:302 """Detect a literal '|' that is not inside a string/template literal.303 304 Heuristic - mark as alternation if `|` appears outside obvious string contexts.305 """306 # Strip simple string contents to reduce false positives in patterns like307 # `'a|b'` or `"x|y"`. This is a heuristic, not a parser.308 stripped = re.sub(r"'[^']*'|\"[^\"]*\"|`[^`]*`", "", pattern)309 # Require word chars on both sides to avoid catching bitwise or ||310 return bool(re.search(r"\w\s*\|\s*\w", stripped)) and "||" not in stripped311 312 313def lang_specific_hints(pattern: str, lang: Optional[str]) -> list[str]:314 """Return a list of hints for language-specific common mistakes."""315 if not lang:316 return []317 canonical = LANG_ALIASES.get(lang.lower(), lang.lower())318 hints: list[str] = []319 320 if canonical == "python":321 # def foo($$$): <-- trailing colon breaks the parse322 if re.search(r"^\s*(def|class)\s+\$?\w+[^:]*:\s*$", pattern, re.MULTILINE):323 hints.append(324 "Python pattern has trailing ':'. ast-grep parses pattern as a complete "325 "definition - drop the trailing colon. Try: 'def $FUNC($$$)' or 'class $C($$$)'."326 )327 328 if canonical in ("javascript", "typescript", "tsx"):329 if re.search(r"^\s*(async\s+)?function\s+\$?\w+\s*$", pattern):330 hints.append(331 "JS/TS function pattern is incomplete. Add params and body: "332 "'function $NAME($$$) { $$$ }'."333 )334 335 if canonical == "go":336 if re.search(r"^\s*func\s+\$?\w+\s*$", pattern):337 hints.append(338 "Go function pattern is incomplete. Add params and body: "339 "'func $NAME($$$) { $$$ }'."340 )341 342 if canonical == "rust":343 if re.search(r"^\s*fn\s+\$?\w+\s*$", pattern):344 hints.append(345 "Rust fn pattern is incomplete. Add params, return type, and body: "346 "'fn $NAME($$$) -> $RET { $$$ }' (or '-> ()' if returning unit)."347 )348 349 return hints350 351 352def validate_pattern(pattern: str, lang: Optional[str]) -> list[str]:353 """Return a list of hints. Empty list = pattern looks plausible."""354 hints: list[str] = []355 356 for rx, msg in REGEX_ANTIPATTERNS:357 if rx.search(pattern):358 hints.append(msg)359 360 if find_alternation(pattern):361 hints.append(362 "Literal '|' alternation is regex syntax, not ast-grep. "363 "Run two separate ast-grep calls (one per alternative), or switch to grep."364 )365 366 hints.extend(lang_specific_hints(pattern, lang))367 368 return hints369 370 371def normalize_lang(lang: Optional[str]) -> Optional[str]:372 if not lang:373 return None374 canonical = LANG_ALIASES.get(lang.lower(), lang.lower())375 if canonical not in LANGUAGES:376 err(f"unknown language '{lang}'. Run 'ast_grep_helper.py langs' for the full list.")377 sys.exit(1)378 return canonical379 380 381# ---------- subprocess helpers ----------382 383def run_sg(384 binary: Path,385 args: list[str],386 *,387 timeout: int = DEFAULT_TIMEOUT_S,388 capture: bool = True,389) -> subprocess.CompletedProcess[str]:390 """Spawn `sg <args>` with a hard timeout. Capture stdout/stderr by default."""391 cmd = [str(binary), *args]392 trace(f"exec: {' '.join(cmd)}")393 try:394 return subprocess.run(395 cmd,396 capture_output=capture,397 text=True,398 timeout=timeout,399 )400 except subprocess.TimeoutExpired:401 err(f"ast-grep call timed out after {timeout}s")402 sys.exit(5)403 404 405# ---------- subcommands ----------406 407def cmd_search(args: argparse.Namespace) -> int:408 pattern: str = args.pattern409 lang = normalize_lang(args.lang)410 hints = validate_pattern(pattern, lang)411 if hints:412 err("pattern looks invalid for ast-grep:")413 for h in hints:414 err(f" - {h}")415 if not args.force:416 err("(pass --force to call ast-grep anyway)")417 return 2418 419 binary = require_binary()420 sg_args = ["run", "-p", pattern, "--json=compact"]421 if lang:422 sg_args.extend(["--lang", lang])423 if args.context:424 sg_args.extend(["-C", str(args.context)])425 for g in args.globs or []:426 sg_args.extend(["--globs", g])427 sg_args.extend(args.paths or ["."])428 429 proc = run_sg(binary, sg_args)430 if proc.returncode not in (0, 1): # 0=match, 1=no match - both fine431 sys.stderr.write(proc.stderr or "")432 return 4433 434 matches = parse_compact_json(proc.stdout)435 if args.json_out:436 json.dump(matches, sys.stdout, indent=2)437 print()438 else:439 format_matches(matches)440 441 if not matches:442 # Re-run pattern hints in case empty result was caused by something subtle.443 # Already done above; here we just give a generic suggestion.444 trace("no matches. If you expected matches, double-check --lang and the pattern shape.")445 return 0446 447 448def cmd_replace(args: argparse.Namespace) -> int:449 pattern: str = args.pattern450 rewrite: str = args.rewrite451 lang = normalize_lang(args.lang)452 453 pattern_hints = validate_pattern(pattern, lang)454 rewrite_hints = validate_pattern(rewrite, lang)455 all_hints = []456 if pattern_hints:457 all_hints.append("pattern issues:")458 all_hints.extend(f" - {h}" for h in pattern_hints)459 if rewrite_hints:460 all_hints.append("rewrite issues:")461 all_hints.extend(f" - {h}" for h in rewrite_hints)462 if all_hints:463 err("input looks invalid for ast-grep:")464 for line in all_hints:465 err(line)466 if not args.force:467 err("(pass --force to call ast-grep anyway)")468 return 2469 470 binary = require_binary()471 472 # Pass 1: dry-run via JSON to collect what would change.473 sg_args1 = ["run", "-p", pattern, "-r", rewrite, "--json=compact"]474 if lang:475 sg_args1.extend(["--lang", lang])476 for g in args.globs or []:477 sg_args1.extend(["--globs", g])478 sg_args1.extend(args.paths or ["."])479 480 proc1 = run_sg(binary, sg_args1)481 if proc1.returncode not in (0, 1):482 sys.stderr.write(proc1.stderr or "")483 return 4484 485 matches = parse_compact_json(proc1.stdout)486 if not matches:487 trace("no matches; nothing to replace.")488 return 0489 490 if not args.apply:491 # Show the dry-run preview and exit.492 print(f"DRY-RUN: would rewrite {len(matches)} match(es) across "493 f"{len({m['file'] for m in matches})} file(s):")494 format_matches(matches, show_replacement=True)495 print()496 print("Re-run with --apply to mutate files.")497 return 0498 499 # Pass 2: apply with --update-all (no --json; sg silently ignores --update-all500 # when --json is present, so we MUST run a second invocation).501 sg_args2 = ["run", "-p", pattern, "-r", rewrite, "--update-all"]502 if lang:503 sg_args2.extend(["--lang", lang])504 for g in args.globs or []:505 sg_args2.extend(["--globs", g])506 sg_args2.extend(args.paths or ["."])507 508 proc2 = run_sg(binary, sg_args2)509 if proc2.returncode not in (0, 1):510 sys.stderr.write(proc2.stderr or "")511 return 4512 513 print(f"APPLIED: rewrote {len(matches)} match(es) across "514 f"{len({m['file'] for m in matches})} file(s).")515 return 0516 517 518def cmd_scan(args: argparse.Namespace) -> int:519 binary = require_binary()520 sg_args = ["scan"]521 if args.config:522 sg_args.extend(["-c", args.config])523 if args.rule:524 sg_args.extend(["-r", args.rule])525 if args.inline_rules:526 sg_args.extend(["--inline-rules", args.inline_rules])527 if args.report_style:528 sg_args.extend(["--report-style", args.report_style])529 if args.apply:530 sg_args.append("-U")531 sg_args.extend(args.paths or [])532 533 proc = run_sg(binary, sg_args, capture=False)534 return proc.returncode535 536 537def cmd_test(args: argparse.Namespace) -> int:538 binary = require_binary()539 sg_args = ["test"]540 if args.config:541 sg_args.extend(["-c", args.config])542 if args.test_dir:543 sg_args.extend(["-t", args.test_dir])544 if args.update:545 sg_args.append("-U")546 proc = run_sg(binary, sg_args, capture=False)547 return proc.returncode548 549 550def cmd_new(args: argparse.Namespace) -> int:551 binary = require_binary()552 sg_args = ["new", args.what]553 if args.name:554 sg_args.append(args.name)555 if args.lang:556 sg_args.extend(["--lang", args.lang])557 if args.yes:558 sg_args.append("--yes")559 proc = run_sg(binary, sg_args, capture=False)560 return proc.returncode561 562 563def cmd_langs(_args: argparse.Namespace) -> int:564 print("ast-grep supported languages (25):")565 for lang, exts in sorted(LANGUAGES.items()):566 print(f" {lang:<12} {' '.join(exts)}")567 print()568 print("Aliases accepted by --lang:")569 for alias, canonical in sorted(LANG_ALIASES.items()):570 print(f" {alias:<8} -> {canonical}")571 return 0572 573 574def cmd_doctor(_args: argparse.Namespace) -> int:575 print(f"ast-grep-helper v{VERSION}")576 print(f"Python: {sys.version.split()[0]}")577 print(f"Platform: {platform.system()} {platform.release()} ({platform.machine()})")578 print(f"Skill: {skill_root()}")579 print()580 binary = resolve_binary()581 if not binary:582 print("ast-grep binary: NOT FOUND")583 print(" -> run: bash install.sh (POSIX) or pwsh install.ps1 (Windows)")584 return 1585 print(f"ast-grep binary: {binary}")586 proc = run_sg(binary, ["--version"], timeout=5)587 if proc.returncode == 0:588 print(f" version: {proc.stdout.strip()}")589 else:590 print(f" --version returned exit {proc.returncode}")591 print(f" stderr: {proc.stderr.strip()}")592 return 1593 return 0594 595 596def cmd_install(_args: argparse.Namespace) -> int:597 """Delegate to install.sh / install.ps1 in the skill root."""598 if os.name == "nt":599 installer = skill_root() / "install.ps1"600 cmd = ["pwsh", "-File", str(installer)]601 else:602 installer = skill_root() / "install.sh"603 cmd = ["bash", str(installer)]604 if not installer.is_file():605 err(f"installer not found: {installer}")606 return 1607 trace(f"running installer: {' '.join(cmd)}")608 return subprocess.run(cmd).returncode609 610 611def cmd_validate(args: argparse.Namespace) -> int:612 """Offline pattern validation. No sg call. Useful for CI / quick checks."""613 lang = normalize_lang(args.lang) if args.lang else None614 hints = validate_pattern(args.pattern, lang)615 if hints:616 for h in hints:617 print(f"hint: {h}")618 return 2619 print("pattern looks plausible for ast-grep.")620 return 0621 622 623# ---------- output formatting ----------624 625def parse_compact_json(text: str) -> list[dict]:626 """Parse `sg --json=compact` output. Salvages partial output when truncated."""627 if not text.strip():628 return []629 try:630 data = json.loads(text)631 if isinstance(data, list):632 return data633 return []634 except json.JSONDecodeError:635 # Try line-by-line salvage for truncated output.636 results = []637 for line in text.splitlines():638 line = line.strip().rstrip(",")639 if not line.startswith("{"):640 continue641 try:642 obj = json.loads(line)643 if isinstance(obj, dict):644 results.append(obj)645 except json.JSONDecodeError:646 continue647 return results648 649 650def format_matches(matches: list[dict], *, show_replacement: bool = False) -> None:651 if not matches:652 print("(no matches)")653 return654 by_file: dict[str, list[dict]] = {}655 for m in matches:656 by_file.setdefault(m.get("file", "?"), []).append(m)657 for path, items in sorted(by_file.items()):658 print(f"{path} ({len(items)} match{'es' if len(items) != 1 else ''})")659 for m in items:660 r = m.get("range", {})661 start = r.get("start", {})662 line = start.get("line", "?")663 col = start.get("column", "?")664 text = (m.get("text") or "").splitlines()665 preview = text[0] if text else ""666 print(f" {path}:{line}:{col} {preview}")667 if show_replacement and "replacement" in m:668 rep = (m.get("replacement") or "").splitlines()669 rep_preview = rep[0] if rep else ""670 print(f" -> {rep_preview}")671 672 673# ---------- argparse ----------674 675def build_parser() -> argparse.ArgumentParser:676 p = argparse.ArgumentParser(677 prog="ast-grep-helper",678 description="LLM-friendly wrapper around ast-grep (sg).",679 formatter_class=argparse.RawDescriptionHelpFormatter,680 )681 p.add_argument("--version", action="version", version=f"ast-grep-helper {VERSION}")682 p.add_argument("--quiet", "-q", action="store_true", help="Suppress trace lines on stderr.")683 sub = p.add_subparsers(dest="cmd", required=True, metavar="COMMAND")684 685 s = sub.add_parser("search", help="Search code by AST pattern.")686 s.add_argument("pattern", help="AST pattern, e.g. 'console.log($MSG)'")687 s.add_argument("paths", nargs="*", help="Paths to search (default: '.')")688 s.add_argument("--lang", "-l", help="Language (e.g. ts, py, go, rust). See: langs subcommand.")689 s.add_argument("--globs", action="append", help="Include/exclude glob (repeat; prefix '!' to exclude).")690 s.add_argument("--context", "-C", type=int, help="Lines of context around each match.")691 s.add_argument("--json-out", action="store_true", help="Emit raw JSON instead of human format.")692 s.add_argument("--force", action="store_true", help="Skip pattern hint validation.")693 s.set_defaults(func=cmd_search)694 695 r = sub.add_parser("replace", help="Rewrite code by AST pattern (dry-run by default).")696 r.add_argument("pattern", help="AST pattern.")697 r.add_argument("rewrite", help="Replacement pattern (can reuse $VAR from pattern).")698 r.add_argument("paths", nargs="*", help="Paths (default: '.')")699 r.add_argument("--lang", "-l", help="Language.")700 r.add_argument("--globs", action="append", help="Include/exclude glob.")701 r.add_argument("--apply", action="store_true", help="Mutate files (default: dry-run preview).")702 r.add_argument("--force", action="store_true", help="Skip pattern hint validation.")703 r.set_defaults(func=cmd_replace)704 705 sc = sub.add_parser("scan", help="Run YAML-rule-based scan.")706 sc.add_argument("paths", nargs="*", help="Paths to scan.")707 sc.add_argument("--config", "-c", help="Path to sgconfig.yml.")708 sc.add_argument("--rule", "-r", help="Single rule file.")709 sc.add_argument("--inline-rules", help="Inline YAML rule string.")710 sc.add_argument("--report-style", choices=["rich", "medium", "short"], help="Report style.")711 sc.add_argument("--apply", "-U", action="store_true", help="Apply fixes (default: report only).")712 sc.set_defaults(func=cmd_scan)713 714 t = sub.add_parser("test", help="Run ast-grep snapshot tests.")715 t.add_argument("--config", "-c", help="Path to sgconfig.yml.")716 t.add_argument("--test-dir", "-t", help="Test directory.")717 t.add_argument("--update", "-U", action="store_true", help="Update snapshots.")718 t.set_defaults(func=cmd_test)719 720 n = sub.add_parser("new", help="Scaffold a new project / rule / test / util.")721 n.add_argument("what", choices=["project", "rule", "test", "util"], help="What to create.")722 n.add_argument("name", nargs="?", help="Name of the artifact.")723 n.add_argument("--lang", "-l", help="Language.")724 n.add_argument("--yes", "-y", action="store_true", help="Accept defaults.")725 n.set_defaults(func=cmd_new)726 727 sub.add_parser("langs", help="List supported languages.").set_defaults(func=cmd_langs)728 sub.add_parser("doctor", help="Check ast-grep binary availability.").set_defaults(func=cmd_doctor)729 sub.add_parser("install", help="Run the install script for this OS.").set_defaults(func=cmd_install)730 731 v = sub.add_parser("validate", help="Validate a pattern offline (pattern hint check only).")732 v.add_argument("pattern", help="AST pattern.")733 v.add_argument("--lang", "-l", help="Language for language-specific hints.")734 v.set_defaults(func=cmd_validate)735 736 return p737 738 739def main(argv: Optional[list[str]] = None) -> int:740 global _QUIET741 parser = build_parser()742 # Accept `search PATTERN --lang js .` — plain parse_args greedily743 # finalizes the nargs='*' paths list before the optional, then errors744 # "unrecognized arguments: ." on the trailing path.745 # parse_intermixed_args cannot be used with subparsers, so collect the746 # leftover non-flag tokens and fold them into `paths` ourselves.747 args, extras = parser.parse_known_args(argv)748 bad = [tok for tok in extras if tok.startswith("-")]749 if bad:750 parser.error(f"unrecognized arguments: {' '.join(bad)}")751 if extras:752 if hasattr(args, "paths"):753 args.paths = list(getattr(args, "paths") or []) + extras754 else:755 parser.error(f"unrecognized arguments: {' '.join(extras)}")756 _QUIET = bool(getattr(args, "quiet", False))757 return args.func(args)758 759 760if __name__ == "__main__":761 sys.exit(main())762 Referenced from SKILL.md
SKILL.mdView in source ↗
Source excerpt starting at line 19.SKILL.mdView in source ↗19This skill ships a Python wrapper at `scripts/ast_grep_helper.py` and platform install scripts at `install.sh` (POSIX) and `install.ps1` (Windows). The helper adds offline pattern validation, the two-pass write trick, and binary auto-resolution. Use it as your default entry point.
Source excerpt starting at line 79.SKILL.mdView in source ↗79## The helper script — `scripts/ast_grep_helper.py`
Source excerpt starting at line 85.SKILL.mdView in source ↗85```bash86python scripts/ast_grep_helper.py search 'console.log($MSG)' --lang ts src/87```
Source excerpt starting at line 100.SKILL.mdView in source ↗100# Dry-run preview (default — no files mutated)101python scripts/ast_grep_helper.py replace 'console.log($MSG)' 'logger.info($MSG)' --lang ts src/
Source excerpt starting at line 103.SKILL.mdView in source ↗103# Actually apply104python scripts/ast_grep_helper.py replace 'console.log($MSG)' 'logger.info($MSG)' --lang ts src/ --apply105```
Source excerpt starting at line 115.SKILL.mdView in source ↗115# Discover sgconfig.yml from cwd and run all rules116python scripts/ast_grep_helper.py scan src/
Source excerpt starting at line 118.SKILL.mdView in source ↗118# Run a single rule file119python scripts/ast_grep_helper.py scan -r rules/no-console.yml src/
Source excerpt starting at line 121.SKILL.mdView in source ↗121# Apply auto-fixes122python scripts/ast_grep_helper.py scan -U src/
Source excerpt starting at line 124.SKILL.mdView in source ↗124# CI-friendly GitHub annotations125python scripts/ast_grep_helper.py scan --report-style short src/126```
Source excerpt starting at line 132.SKILL.mdView in source ↗132```bash133python scripts/ast_grep_helper.py validate '\w+' --lang ts134# → exit 2: regex \w not supported. Use $VAR for identifiers.
Source excerpt starting at line 136.SKILL.mdView in source ↗136python scripts/ast_grep_helper.py validate 'console.log($MSG)' --lang ts137# → exit 0: pattern looks plausible for ast-grep.
Source excerpt starting at line 142.142```bash143python scripts/ast_grep_helper.py langs # list 25 supported languages and aliases144python scripts/ast_grep_helper.py doctor # check ast-grep binary availability145python scripts/ast_grep_helper.py install # delegate to install.sh / install.ps1146```