
Ast Grep
- 1 installs
- 5 repo stars
- Updated June 12, 2026
- code-yeongyu/ast-grep-skill
Find and rewrite code by AST structure across 25 languages using structural patterns
About
ast-grep (sg) searches and rewrites code by abstract syntax tree structure across 25 languages. Use it to find patterns like "every function call shaped like X" or migrate require() to import.
- AST-based code search across 25 languages
- Structural rewrite patterns
Ast Grep by the numbers
- 1 all-time installs (skills.sh)
- Ranked #984 of 1,352 Code Review & Quality skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/code-yeongyu/ast-grep-skill --skill ast-grepAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 5 |
| Last updated | June 12, 2026 |
| Repository | code-yeongyu/ast-grep-skill ↗ |
What it does
Find and rewrite code by AST structure across 25 languages using structural patterns
Files
ast-grep
sg (also installed as ast-grep) is an AST-aware search and rewrite tool across 25 languages. It treats your pattern as code, parses it the same way it parses your project, and matches structurally. It is the right tool whenever your question depends on code shape rather than text bytes.
This 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.
---
When to use this skill
Use it whenever the user's question is about code structure, not bytes:
- "Find every function that takes a
Requestparameter." - "Rewrite every
console.log(x)tologger.info(x)." - "Strip every
as anycast." - "Replace
require(...)withimportacross the repo." - "Find empty catch blocks."
- "Migrate
Optional[X]toX | None." - "Apply this codemod across these 200 files."
- "Run our YAML lint rules and surface violations."
Switch to plain grep / rg when the question is text-shaped (string literal contents, comments, license headers, file names, cross-language regex). When in doubt, ask: "does the answer depend on the language's syntax tree, or just on the file's bytes?" If the former, ast-grep. If the latter, grep.
---
Three things the agent must internalize
1. ast-grep is NOT regex
The wildcards are $VAR (one AST node) and $$$ (zero or more nodes). Regex syntax fails silently:
| You wrote | What ast-grep saw | What you wanted |
|---|---|---|
| `foo\ | bar` | bitwise-or of foo and bar |
.*foo | not parseable | $$$ foo (if $$$ is a list of nodes) or use rg |
\w+ | not parseable | $VAR to capture any identifier |
[a-z] | character class, not parseable | switch to rg |
The full anti-pattern table is in references/pitfalls.md §1. The helper's validate subcommand catches these mechanically — call it before debugging "no matches" by hand.
2. Patterns must be valid code
The pattern itself must parse. def $FN($$$): fails because the trailing : makes it incomplete; use def $FN($$$). function $NAME without params/body fails; use function $NAME($$$) { $$$ }. Full table per language in references/pitfalls.md §2.
3. --update-all and --json are mutually exclusive (silently)
This is the single biggest gotcha when scripting. sg run -p P -r R --json --update-all returns the JSON but does not mutate files. To both preview AND apply, run two passes:
sg run -p P -r R --json=compact . # pass 1: see what would change
sg run -p P -r R --update-all . # pass 2: actually applyThe helper does this automatically when you call replace --apply. Read references/pitfalls.md §9.
---
The helper script — scripts/ast_grep_helper.py
A single-file Python 3 stdlib wrapper. Same on every OS. The agent's default entry point.
search — find all matches of a pattern
python3 scripts/ast_grep_helper.py search 'console.log($MSG)' --lang ts src/Validates the pattern offline first. If the pattern looks like regex (\w, .*, |, etc.) the helper exits with a hint and never calls sg — saves a round-trip. Pass --force to skip validation.
Flags:
--lang ts(or any of the 25 languages; aliases likejs,py,rs,ktaccepted)--globs '!**/*.test.ts'(repeatable; prefix!to exclude)-C 3(context lines)--json-out(raw JSON instead of human format)
replace — rewrite by pattern, dry-run by default
# Dry-run preview (default — no files mutated)
python3 scripts/ast_grep_helper.py replace 'console.log($MSG)' 'logger.info($MSG)' --lang ts src/
# Actually apply
python3 scripts/ast_grep_helper.py replace 'console.log($MSG)' 'logger.info($MSG)' --lang ts src/ --applyThe helper: 1. Validates both pattern and rewrite for hint-detectable mistakes. 2. Runs pass 1 with --json=compact to collect matches and show a preview. 3. If --apply is set, runs pass 2 with --update-all to mutate files.
scan — run YAML rules
# Discover sgconfig.yml from cwd and run all rules
python3 scripts/ast_grep_helper.py scan src/
# Run a single rule file
python3 scripts/ast_grep_helper.py scan -r rules/no-console.yml src/
# Apply auto-fixes
python3 scripts/ast_grep_helper.py scan -U src/
# CI-friendly GitHub annotations
python3 scripts/ast_grep_helper.py scan --report-style short src/validate — offline pattern check (no sg call)
Useful for CI lints, pre-commit hooks, and quick sanity checks:
python3 scripts/ast_grep_helper.py validate '\w+' --lang ts
# → exit 2: regex \w not supported. Use $VAR for identifiers.
python3 scripts/ast_grep_helper.py validate 'console.log($MSG)' --lang ts
# → exit 0: pattern looks plausible for ast-grep.langs / doctor / install
python3 scripts/ast_grep_helper.py langs # list 25 supported languages and aliases
python3 scripts/ast_grep_helper.py doctor # check ast-grep binary availability
python3 scripts/ast_grep_helper.py install # delegate to install.sh / install.ps1new and test subcommands proxy directly to sg new and sg test.
---
Direct sg use (when the helper isn't enough)
The helper is opinionated. For full control, drop to sg. The skill ships a CLI cheat sheet in references/cli.md. The minimal idioms:
# Search
sg run -p 'console.log($MSG)' --lang ts src/
# Search with JSON for scripting
sg run -p 'console.log($MSG)' --lang ts --json=compact src/ | jq '.[] | .file'
# Rewrite, dry-run
sg run -p 'console.log($MSG)' -r 'logger.info($MSG)' --lang ts --json=compact src/
# Rewrite, apply
sg run -p 'console.log($MSG)' -r 'logger.info($MSG)' --lang ts --update-all src/
# Pattern from stdin (great for ad-hoc experiments)
echo 'console.log("hi")' | sg run -p 'console.log($MSG)' --lang js --stdin
# Debug a pattern that returns 0 matches
sg run -p '<your pattern>' --lang <lang> --debug-query=ast --stdin <<< '<sample-code>'
# Run YAML rules
sg scan src/
# Inline YAML rule (one-off)
sg scan --inline-rules '
id: no-todo
language: TypeScript
severity: warning
rule: { pattern: TODO }' src/When using sg directly in a shell, always single-quote patterns so $VAR is not expanded by the shell.
---
Decision tree — what to use, when
USER asks for "find/rewrite/codemod"
│
├─ structural pattern (function shape, call, class, import, control flow)
│ └→ ast-grep (this skill)
│
├─ text pattern (regex, alternation, character classes, file names)
│ └→ rg / grep
│
├─ semantic question (what variable does this refer to? does this throw?)
│ └→ LSP tools, TypeScript compiler, Pyright, Semgrep with type inference
│
└─ multiple repos / federated search
└→ a search engine + then ast-grep / rg / LSP per-repoIf the user says "find all" or "every", default to ast-grep when the target is shaped (function, class, call, import, statement). Default to rg when the target is text (string content, comment, license header, file name, identifier substring).
---
Always run dry-run first when rewriting
A bad pattern silently rewrites the wrong thing. The helper's replace defaults to dry-run for this reason. The flow is:
1. Search to confirm matches: helper search '<pattern>' --lang X . 2. Dry-run rewrite: helper replace '<pattern>' '<rewrite>' --lang X . (no --apply) 3. Inspect the dry-run summary: number of matches, files affected, the per-location preview. 4. If wrong: refine pattern, go back to step 1. 5. If right: helper replace '<pattern>' '<rewrite>' --lang X . --apply.
Never apply a rewrite that you have not first dry-run.
---
When sg returns 0 matches but you know the code is there
In priority order:
1. Run `helper validate '<pattern>' --lang <lang>` — catches regex misuse, missing function bodies, Python trailing colons. 2. Check `--lang` — sg infers from extension; if you pass a .tsx file with --lang ts (not tsx), JSX won't parse. 3. Inspect the parsed pattern: sg run -p '<pattern>' --lang <lang> --debug-query=ast --stdin <<< '<sample>'. If it shows ERROR nodes, the pattern is malformed. 4. Check the AST of the target file: sg run -p '$_' --lang <lang> --debug-query=cst path/to/file | head -40 — find the kind you're trying to match. 5. Try the playground: <https://ast-grep.github.io/playground.html> — paste code + pattern, see what's happening.
Do not blindly retry with variations. Each failure has a reason; surface it.
---
When to use YAML rules vs inline -p patterns
Use inline `-p` when:
- One-off ad-hoc query.
- The pattern is simple (no constraints, no fix template).
- You're exploring.
Use YAML rules (file under rules/, run via sg scan) when:
- The pattern is reused (lint rule, codemod that runs in CI).
- You need
constraints,transform, complexinside/has, or composite logic. - You want auto-fix (
fix:field). - You want to test the rule (snapshot tests via
sg test).
The full YAML rule schema is in references/yaml-rules.md. Project setup (sgconfig.yml, ruleDirs, utilDirs) is in references/sgconfig.md.
---
Output discipline
sg run --json=compactproduces an array of match objects:{ file, range: {start, end}, text, replacement?, lines, language, ... }. Pipe throughjqfor further processing.- Without
--json,sgproduces human-readable colored output suitable for terminals. - The helper's default output is human-readable (file:line:column + match preview). Pass
--json-outfor raw JSON. - The helper's
replacealways summarizes: number of matches, number of files, per-location preview.
When summarizing for the user, always include the count of files affected, not just the count of matches. Users care about blast radius.
---
Required reading (in order of priority)
1. references/patterns.md — meta-variables, naming rules, strictness levels. Read when you're unsure why a pattern doesn't match. 2. references/pitfalls.md — the failure-mode field guide. Read when 0 matches surprises you. 3. references/recipes.md — copy-paste patterns by language. Read first when you start a new task. 4. references/cli.md — sg run, sg scan, sg test, sg new, sg lsp. Read when the helper isn't enough. 5. references/yaml-rules.md — YAML rule schema. Read when you outgrow inline patterns. 6. references/sgconfig.md — project-level configuration. Read when you set up sg scan for a real project. 7. references/install.md — per-OS install methods. Read only if install.sh / install.ps1 fail.
---
Invariants (do not break)
- Validate before searching. When emitting a pattern programmatically, call
helper validatefirst. It catches the regex-misuse class of mistakes that account for ~70% of "0 matches" debug sessions. - Dry-run before applying. Never run
sg run -r ... --update-allwithout first inspecting the matches. The helper'sreplaceenforces this by default. - Two-pass writes. When using
sgdirectly to both preview and apply, run two invocations —--jsonignores--update-all. - Single-quote patterns in shell.
'$VAR'not"$VAR". The shell expands$VARto the empty string in double quotes, breaking the pattern. - Pattern is code, not regex. When the pattern would need
|,.*,\w, or[a-z], switch torginstead. Don't try to force ast-grep into a regex shape. - `--lang` is required for stdin. When piping with
--stdin, set--langexplicitly;sgcannot infer from extension. - Linux: prefer `ast-grep` over `sg` because
sgcollides withsetgroups. The helper handles this; if you callsgdirectly, alias it:alias sg=ast-grep.
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
smoke:
name: smoke (${{ matrix.os }} / py${{ matrix.python-version }})
runs-on: ${{ matrix.os }}
timeout-minutes: 5
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, ubuntu-22.04, macos-latest, windows-latest]
python-version: ['3.9', '3.10', '3.11', '3.12', '3.13']
steps:
- uses: actions/checkout@v5
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
- name: Show toolchain versions
shell: bash
run: |
python --version
echo "OS: ${{ matrix.os }}"
- name: Run smoke tests (POSIX)
if: runner.os != 'Windows'
run: bash tests/smoke.sh
- name: Run smoke tests (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: ./tests/smoke.ps1
integration:
name: integration (${{ matrix.os }}) - real ast-grep binary
runs-on: ${{ matrix.os }}
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- uses: actions/checkout@v5
- name: Set up Python 3.12
uses: actions/setup-python@v6
with:
python-version: '3.12'
- name: Install ast-grep via npm
shell: bash
run: npm install -g @ast-grep/cli
- name: Verify install
shell: bash
run: |
ast-grep --version || sg --version
python scripts/ast_grep_helper.py doctor
- name: Search a known pattern
shell: bash
run: |
mkdir -p /tmp/ag-int
cat > /tmp/ag-int/sample.ts <<'TS'
console.log("hi");
console.log("there");
logger.info("ok");
TS
OUT=$(python scripts/ast_grep_helper.py search 'console.log($MSG)' --lang ts /tmp/ag-int/)
echo "$OUT"
echo "$OUT" | grep -q 'sample.ts' || { echo "FAIL: search missed sample.ts"; exit 1; }
# Two console.log matches expected
COUNT=$(echo "$OUT" | grep -c 'console.log' || true)
[ "$COUNT" -ge 2 ] || { echo "FAIL: expected >=2 matches, got $COUNT"; exit 1; }
- name: Replace dry-run does NOT mutate
shell: bash
run: |
cat > /tmp/ag-int/dryrun.ts <<'TS'
console.log("hi");
TS
python scripts/ast_grep_helper.py replace 'console.log($MSG)' 'logger.info($MSG)' --lang ts /tmp/ag-int/dryrun.ts >/dev/null
grep -q 'console.log' /tmp/ag-int/dryrun.ts || { echo "FAIL: dry-run mutated the file!"; exit 1; }
- name: Replace --apply mutates files (two-pass write)
shell: bash
run: |
cat > /tmp/ag-int/apply.ts <<'TS'
console.log("hi");
TS
python scripts/ast_grep_helper.py replace 'console.log($MSG)' 'logger.info($MSG)' --lang ts /tmp/ag-int/apply.ts --apply
grep -q 'logger.info' /tmp/ag-int/apply.ts || { echo "FAIL: --apply did not rewrite!"; cat /tmp/ag-int/apply.ts; exit 1; }
grep -q 'console.log' /tmp/ag-int/apply.ts && { echo "FAIL: console.log still present after --apply!"; cat /tmp/ag-int/apply.ts; exit 1; } || true
syntax-check-old-python:
name: syntax check (Python ${{ matrix.floor }} floor)
runs-on: ubuntu-latest
timeout-minutes: 2
strategy:
matrix:
floor: ['3.9', '3.10']
steps:
- uses: actions/checkout@v5
- name: Set up Python ${{ matrix.floor }}
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.floor }}
- name: Compile-check the helper targets ${{ matrix.floor }}
run: |
python -c "
import ast
src = open('scripts/ast_grep_helper.py').read()
ast.parse(src, feature_version=tuple(map(int, '${{ matrix.floor }}'.split('.'))))
print('parses on ${{ matrix.floor }}: OK')
"
python -m py_compile scripts/ast_grep_helper.py
echo 'py_compile passed on ${{ matrix.floor }}'
install-script:
name: install.sh - ${{ matrix.os }} - method=${{ matrix.method }}
runs-on: ${{ matrix.os }}
timeout-minutes: 8
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
method: [npm, github]
steps:
- uses: actions/checkout@v5
- name: Run install.sh --method=${{ matrix.method }}
run: bash install.sh --method=${{ matrix.method }}
- name: Verify ast-grep available after install
run: |
export PATH="$PWD/bin:$PATH"
ast-grep --version || sg --version
install-script-windows:
name: install.ps1 - windows - method=${{ matrix.method }}
runs-on: windows-latest
timeout-minutes: 8
strategy:
fail-fast: false
matrix:
method: [npm, github]
steps:
- uses: actions/checkout@v5
- name: Run install.ps1 -Method ${{ matrix.method }}
shell: pwsh
run: ./install.ps1 -Method ${{ matrix.method }}
- name: Verify ast-grep available after install
shell: pwsh
run: |
$env:Path = "$PWD/bin;" + $env:Path
$cmd = Get-Command ast-grep -ErrorAction SilentlyContinue
if (-not $cmd) { $cmd = Get-Command sg -ErrorAction SilentlyContinue }
if (-not $cmd) {
$cmd = Get-Command "$PWD/bin/sg.exe" -ErrorAction SilentlyContinue
}
if (-not $cmd) { throw "ast-grep / sg not on PATH after install" }
& $cmd.Source --version
lint:
name: lint and structure
runs-on: ubuntu-latest
timeout-minutes: 2
steps:
- uses: actions/checkout@v5
- name: Set up Python 3.12
uses: actions/setup-python@v6
with:
python-version: '3.12'
- name: Verify SKILL.md frontmatter shape
run: |
python - <<'PY'
import re
src = open('SKILL.md').read()
assert src.startswith('---\n'), 'SKILL.md must start with YAML frontmatter'
end = src.find('\n---\n', 4)
assert end > 0, 'SKILL.md missing closing ---'
fm = src[4:end]
assert re.search(r'^name:\s*ast-grep\s*$', fm, re.M), 'frontmatter missing name: ast-grep'
assert re.search(r'^description:', fm, re.M), 'frontmatter missing description'
print('frontmatter OK')
PY
- name: Verify required files exist
run: |
for f in SKILL.md README.md LICENSE \
scripts/ast_grep_helper.py \
install.sh install.ps1 \
tests/smoke.sh tests/smoke.ps1 \
references/install.md references/patterns.md \
references/pitfalls.md references/recipes.md \
references/cli.md references/yaml-rules.md \
references/sgconfig.md; do
test -f "$f" || { echo "FAIL: missing $f"; exit 1; }
done
echo 'all required files present'
- name: Verify no Korean in skill content
run: |
python - <<'PY'
import pathlib, re, sys
hangul = re.compile(r"[\uac00-\ud7a3]")
targets = [pathlib.Path("SKILL.md"), pathlib.Path("README.md"),
pathlib.Path("install.sh"), pathlib.Path("install.ps1")]
for d in ("references", "scripts", "tests", ".github"):
targets.extend(pathlib.Path(d).rglob("*"))
hits = 0
for p in targets:
if not p.is_file():
continue
try:
text = p.read_text(encoding="utf-8")
except (UnicodeDecodeError, OSError):
continue
for n, line in enumerate(text.splitlines(), 1):
if hangul.search(line):
print(f"{p}:{n}: {line.rstrip()}")
hits += 1
if hits:
print(f"FAIL: {hits} Korean line(s) found", file=sys.stderr)
sys.exit(1)
print("no Korean")
PY
- name: Bash syntax check (install.sh, smoke.sh)
run: |
bash -n install.sh
bash -n tests/smoke.sh
echo 'bash scripts: syntax OK'
- name: PowerShell parse check (install.ps1, smoke.ps1)
shell: pwsh
run: |
$tokens = $null; $errors = $null
foreach ($f in 'install.ps1','tests/smoke.ps1') {
[System.Management.Automation.Language.Parser]::ParseFile($f, [ref]$tokens, [ref]$errors) | Out-Null
if ($errors -and $errors.Count -gt 0) {
Write-Host "FAIL: $f has parse errors:"
$errors | ForEach-Object { Write-Host " $_" }
exit 1
}
}
Write-Host 'powershell scripts: parse OK'
# Python bytecode
__pycache__/
*.py[cod]
*$py.class
# Editor / OS
.DS_Store
.vscode/
.idea/
*.swp
*.swo
# Test / scratch artifacts
scratch/
.ast-grep-helper-cache/
/tmp/ast-grep-skill-*
# Local config
.ast-grep-skill.json
.ast-grep-skill/
# Cached binary (from install.sh fallback download)
bin/sg
bin/ast-grep
bin/sg.exe
bin/ast-grep.exe
#Requires -Version 5.1
<#
.SYNOPSIS
Install the ast-grep binary on Windows.
.DESCRIPTION
Tries package managers in priority order, then falls back to downloading a
pinned release zip from GitHub into <skill_root>/bin/sg.exe.
Order:
1. Already installed? -> nothing to do
2. Scoop (most common Windows dev tool installer)
3. Winget (Microsoft built-in)
4. Chocolatey (choco)
5. npm (@ast-grep/cli)
6. cargo binstall / cargo install
7. pip (ast-grep-cli)
8. GitHub release zip -> <skill_root>/bin/sg.exe
.PARAMETER Method
Force one method: scoop | winget | choco | npm | cargo | pip | github
.PARAMETER Version
Pin a specific version when downloading from GitHub. Default: 0.42.1
.PARAMETER NoFallback
Don't fall back to GitHub zip; fail if all package managers miss
.PARAMETER Quiet
Suppress non-error output
.EXAMPLE
.\install.ps1
.\install.ps1 -Method scoop
.\install.ps1 -Version 0.42.0 -Method github
#>
param(
[string]$Method = "",
[string]$Version = "0.42.1",
[switch]$NoFallback,
[switch]$Quiet
)
$ErrorActionPreference = 'Stop'
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$CacheBinDir = Join-Path $ScriptDir "bin"
function Log([string]$msg) {
if (-not $Quiet) {
[Console]::Error.WriteLine("[install.ps1] $msg")
}
}
function Err([string]$msg) {
[Console]::Error.WriteLine("[install.ps1] error: $msg")
}
function Has-Cmd([string]$name) {
$null -ne (Get-Command $name -ErrorAction SilentlyContinue)
}
function Test-AstGrep {
if (Has-Cmd 'ast-grep') { return $true }
if (Has-Cmd 'sg') { return $true }
if (Test-Path (Join-Path $CacheBinDir 'sg.exe')) { return $true }
if (Test-Path (Join-Path $CacheBinDir 'ast-grep.exe')) { return $true }
return $false
}
if (-not $Method -and (Test-AstGrep)) {
Log "ast-grep already installed"
exit 0
}
function Detect-Arch {
$a = $env:PROCESSOR_ARCHITECTURE
switch -Wildcard ($a) {
'AMD64' { return 'x86_64' }
'ARM64' { return 'aarch64' }
default { return 'unknown' }
}
}
$Arch = Detect-Arch
function Try-Scoop {
if (-not (Has-Cmd 'scoop')) { return $false }
Log "trying: scoop install main/ast-grep"
try { scoop install main/ast-grep; return $LASTEXITCODE -eq 0 }
catch { return $false }
}
function Try-Winget {
if (-not (Has-Cmd 'winget')) { return $false }
Log "trying: winget install --id ast-grep.ast-grep"
try { winget install --id ast-grep.ast-grep --silent --accept-package-agreements --accept-source-agreements; return $LASTEXITCODE -eq 0 }
catch { return $false }
}
function Try-Choco {
if (-not (Has-Cmd 'choco')) { return $false }
Log "trying: choco install ast-grep -y"
try { choco install ast-grep -y; return $LASTEXITCODE -eq 0 }
catch { return $false }
}
function Try-Npm {
if (-not (Has-Cmd 'npm')) { return $false }
Log "trying: npm install -g @ast-grep/cli"
try { npm install -g '@ast-grep/cli'; return $LASTEXITCODE -eq 0 }
catch { return $false }
}
function Try-Cargo {
if (Has-Cmd 'cargo-binstall') {
Log "trying: cargo binstall -y ast-grep"
try { cargo binstall -y ast-grep; if ($LASTEXITCODE -eq 0) { return $true } } catch {}
}
if (-not (Has-Cmd 'cargo')) { return $false }
Log "trying: cargo install ast-grep --locked"
try { cargo install ast-grep --locked; return $LASTEXITCODE -eq 0 }
catch { return $false }
}
function Try-Pip {
$pip = $null
foreach ($p in 'pip3','pip','py') {
if (Has-Cmd $p) { $pip = $p; break }
}
if (-not $pip) { return $false }
Log "trying: $pip install --user ast-grep-cli"
try {
if ($pip -eq 'py') { py -m pip install --user ast-grep-cli }
else { & $pip install --user ast-grep-cli }
return $LASTEXITCODE -eq 0
} catch { return $false }
}
function Triple-For-Windows {
switch ($Arch) {
'x86_64' { return 'x86_64-pc-windows-msvc' }
'aarch64' { return 'aarch64-pc-windows-msvc' }
default { return '' }
}
}
function Try-Github {
$triple = Triple-For-Windows
if (-not $triple) {
Err "no GitHub release asset for arch $Arch"
return $false
}
$asset = "ast-grep-$triple.zip"
$url = "https://github.com/ast-grep/ast-grep/releases/download/$Version/$asset"
$tmp = Join-Path $env:TEMP ("ast-grep-install-" + [guid]::NewGuid().ToString('N').Substring(0,8))
New-Item -ItemType Directory -Path $tmp -Force | Out-Null
try {
Log "downloading $url"
Invoke-WebRequest -Uri $url -OutFile (Join-Path $tmp $asset) -UseBasicParsing
Expand-Archive -Path (Join-Path $tmp $asset) -DestinationPath (Join-Path $tmp 'extract') -Force
New-Item -ItemType Directory -Path $CacheBinDir -Force | Out-Null
$candidates = @(
(Join-Path $tmp 'extract/ast-grep.exe'),
(Join-Path $tmp 'extract/sg.exe')
)
$src = $null
foreach ($c in $candidates) {
if (Test-Path $c) { $src = $c; break }
}
if (-not $src) {
Err "no ast-grep.exe or sg.exe found inside $asset"
return $false
}
$dest = Join-Path $CacheBinDir 'sg.exe'
Copy-Item -Path $src -Destination $dest -Force
Log "installed cached binary: $dest"
Log "verify: & '$dest' --version"
Log ""
Log "Add to PATH for direct sg use:"
Log " `$env:Path = '$CacheBinDir;' + `$env:Path"
return $true
} finally {
Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue
}
}
function Run-Method([string]$m) {
switch ($m) {
'scoop' { return Try-Scoop }
'winget' { return Try-Winget }
'choco' { return Try-Choco }
'npm' { return Try-Npm }
'cargo' { return Try-Cargo }
'pip' { return Try-Pip }
'github' { return Try-Github }
default { Err "unknown method: $m"; return $false }
}
}
if ($Method) {
if (Run-Method $Method) { exit 0 }
Err "method '$Method' failed"
exit 2
}
$methods = @('scoop', 'winget', 'choco', 'npm', 'cargo', 'pip')
foreach ($m in $methods) {
if (Run-Method $m) {
Log "installed via $m"
exit 0
}
Log "$m unavailable or failed; trying next"
}
if (-not $NoFallback) {
Log "all package managers failed; falling back to GitHub release"
if (Try-Github) { exit 0 }
}
Err "all install methods failed."
Err ""
Err "Manual options:"
Err " scoop install main/ast-grep # Scoop"
Err " winget install --id ast-grep.ast-grep # Winget"
Err " choco install ast-grep # Chocolatey"
Err " npm install -g @ast-grep/cli # any OS with Node"
Err " cargo install ast-grep --locked # any OS with Rust"
Err " pip install ast-grep-cli # any OS with Python"
Err " https://github.com/ast-grep/ast-grep/releases # manual binary"
exit 2
#!/usr/bin/env bash
#
# install.sh - install the ast-grep binary on POSIX systems (macOS, Linux, WSL, Git Bash).
#
# Tries package managers in priority order, then falls back to downloading a
# pinned release binary from GitHub into <skill_root>/bin/sg.
#
# Order:
# 1. Already installed? -> nothing to do
# 2. Homebrew (brew)
# 3. npm (@ast-grep/cli)
# 4. cargo binstall (faster) or cargo install (slower)
# 5. pip (ast-grep-cli)
# 6. nix-env (NixOS / Nix users)
# 7. mise (asdf successor)
# 8. GitHub release tarball -> <skill_root>/bin/sg
#
# Flags:
# --method=<m> Force one method: brew | npm | cargo | pip | nix | mise | github
# --version=<v> Pin a specific version when downloading from GitHub
# --no-fallback Don't fall back to GitHub tarball; fail if all package managers miss
# --quiet, -q Suppress non-error output
#
# Exit codes:
# 0 Installed (or already present)
# 1 Argument error
# 2 All install methods failed
# 3 Network failure during GitHub fallback
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SKILL_ROOT="$SCRIPT_DIR"
CACHE_BIN_DIR="$SKILL_ROOT/bin"
PINNED_VERSION="0.42.1"
FORCED_METHOD=""
USE_FALLBACK=1
QUIET=0
log() {
if [ "$QUIET" -eq 0 ]; then
printf '[install.sh] %s\n' "$*" >&2
fi
}
err() {
printf '[install.sh] error: %s\n' "$*" >&2
}
usage() {
sed -n '2,/^set -/p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//;/^set -/d'
exit "${1:-0}"
}
while [ "$#" -gt 0 ]; do
case "$1" in
--method=*) FORCED_METHOD="${1#*=}" ;;
--version=*) PINNED_VERSION="${1#*=}" ;;
--no-fallback) USE_FALLBACK=0 ;;
--quiet|-q) QUIET=1 ;;
--help|-h) usage 0 ;;
*) err "unknown argument: $1"; usage 1 ;;
esac
shift
done
# --- detect platform -----------------------------------------------------
detect_os() {
case "$(uname -s)" in
Darwin) echo "darwin" ;;
Linux) echo "linux" ;;
MINGW*|MSYS*|CYGWIN*) echo "windows" ;;
*) echo "unknown" ;;
esac
}
detect_arch() {
case "$(uname -m)" in
arm64|aarch64) echo "aarch64" ;;
x86_64|amd64) echo "x86_64" ;;
*) echo "unknown" ;;
esac
}
OS="$(detect_os)"
ARCH="$(detect_arch)"
# --- already installed? --------------------------------------------------
ast_grep_present() {
if command -v ast-grep >/dev/null 2>&1; then
return 0
fi
if command -v sg >/dev/null 2>&1; then
if [ "$OS" = "linux" ]; then
if "$(command -v sg)" --version 2>/dev/null | grep -qi 'ast-grep'; then
return 0
fi
return 1
fi
return 0
fi
if [ -x "$CACHE_BIN_DIR/sg" ] || [ -x "$CACHE_BIN_DIR/ast-grep" ]; then
return 0
fi
return 1
}
if [ -z "$FORCED_METHOD" ] && ast_grep_present; then
log "ast-grep already installed: $(command -v ast-grep 2>/dev/null || command -v sg)"
exit 0
fi
# --- per-method installers -----------------------------------------------
try_brew() {
command -v brew >/dev/null 2>&1 || return 1
log "trying: brew install ast-grep"
brew install ast-grep && return 0 || return 1
}
try_npm() {
command -v npm >/dev/null 2>&1 || return 1
log "trying: npm install -g @ast-grep/cli"
npm install -g @ast-grep/cli && return 0 || return 1
}
try_cargo() {
if command -v cargo-binstall >/dev/null 2>&1; then
log "trying: cargo binstall ast-grep"
cargo binstall -y ast-grep && return 0 || true
fi
if command -v cargo >/dev/null 2>&1; then
log "trying: cargo install ast-grep --locked"
cargo install ast-grep --locked && return 0 || return 1
fi
return 1
}
try_pip() {
if command -v pipx >/dev/null 2>&1; then
log "trying: pipx install ast-grep-cli"
pipx install ast-grep-cli && return 0 || true
fi
command -v pip3 >/dev/null 2>&1 || command -v pip >/dev/null 2>&1 || return 1
PIP="$(command -v pip3 || command -v pip)"
log "trying: $PIP install --user ast-grep-cli"
$PIP install --user ast-grep-cli && return 0 || return 1
}
try_nix() {
command -v nix-env >/dev/null 2>&1 || return 1
log "trying: nix-env -iA nixpkgs.ast-grep"
nix-env -iA nixpkgs.ast-grep && return 0 || return 1
}
try_mise() {
command -v mise >/dev/null 2>&1 || return 1
log "trying: mise use -g ast-grep"
mise use -g ast-grep && return 0 || return 1
}
# Tarball assets are named like:
# ast-grep-aarch64-apple-darwin.zip
# ast-grep-x86_64-apple-darwin.zip
# ast-grep-aarch64-unknown-linux-gnu.zip
# ast-grep-x86_64-unknown-linux-gnu.zip
# ast-grep-x86_64-unknown-linux-musl.zip
# ast-grep-x86_64-pc-windows-msvc.zip (.zip only on windows)
triple_for() {
case "$OS-$ARCH" in
darwin-aarch64) echo "aarch64-apple-darwin" ;;
darwin-x86_64) echo "x86_64-apple-darwin" ;;
linux-aarch64) echo "aarch64-unknown-linux-gnu" ;;
linux-x86_64) echo "x86_64-unknown-linux-gnu" ;;
*) echo "" ;;
esac
}
try_github() {
TRIPLE="$(triple_for)"
if [ -z "$TRIPLE" ]; then
err "no GitHub release asset for $OS-$ARCH; install via package manager or build from source."
return 1
fi
command -v curl >/dev/null 2>&1 || command -v wget >/dev/null 2>&1 || {
err "need curl or wget for GitHub fallback"
return 1
}
ASSET="ast-grep-${TRIPLE}.zip"
URL="https://github.com/ast-grep/ast-grep/releases/download/${PINNED_VERSION}/${ASSET}"
TMP="$(mktemp -d -t ast-grep-install-XXXXXX)"
trap 'rm -rf "$TMP"' RETURN
log "downloading $URL"
if command -v curl >/dev/null 2>&1; then
curl -fsSL "$URL" -o "$TMP/$ASSET" || return 3
else
wget -q "$URL" -O "$TMP/$ASSET" || return 3
fi
command -v unzip >/dev/null 2>&1 || {
err "need 'unzip' to extract GitHub release archives"
return 1
}
unzip -q "$TMP/$ASSET" -d "$TMP/extract"
mkdir -p "$CACHE_BIN_DIR"
if [ -f "$TMP/extract/ast-grep" ]; then
mv "$TMP/extract/ast-grep" "$CACHE_BIN_DIR/sg"
elif [ -f "$TMP/extract/sg" ]; then
mv "$TMP/extract/sg" "$CACHE_BIN_DIR/sg"
else
err "no ast-grep or sg binary found inside $ASSET"
return 1
fi
chmod +x "$CACHE_BIN_DIR/sg"
log "installed cached binary: $CACHE_BIN_DIR/sg"
log "verify: $CACHE_BIN_DIR/sg --version"
log ""
log "Add to PATH for direct sg use:"
log " export PATH=\"$CACHE_BIN_DIR:\$PATH\""
return 0
}
# --- dispatch ------------------------------------------------------------
run_method() {
case "$1" in
brew) try_brew ;;
npm) try_npm ;;
cargo) try_cargo ;;
pip) try_pip ;;
nix) try_nix ;;
mise) try_mise ;;
github) try_github ;;
*) err "unknown method: $1"; return 1 ;;
esac
}
if [ -n "$FORCED_METHOD" ]; then
if run_method "$FORCED_METHOD"; then
exit 0
else
err "method '$FORCED_METHOD' failed"
exit 2
fi
fi
# Try methods in OS-aware priority order.
case "$OS" in
darwin) METHODS=(brew npm cargo pip mise) ;;
linux) METHODS=(npm cargo pip nix mise brew) ;;
windows) METHODS=(npm cargo pip mise) ;;
*) METHODS=(npm cargo pip) ;;
esac
for m in "${METHODS[@]}"; do
if run_method "$m"; then
log "installed via $m"
exit 0
fi
log "$m unavailable or failed; trying next"
done
if [ "$USE_FALLBACK" -eq 1 ]; then
log "all package managers failed; falling back to GitHub release"
if try_github; then
exit 0
fi
fi
err "all install methods failed."
err ""
err "Manual options:"
err " brew install ast-grep # macOS / linuxbrew"
err " npm install -g @ast-grep/cli # any OS with Node"
err " cargo install ast-grep --locked # any OS with Rust"
err " pip install ast-grep-cli # any OS with Python"
err " https://github.com/ast-grep/ast-grep/releases # manual binary"
exit 2
MIT License
Copyright (c) 2026 Yeongyu Kim
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
ast-grep-skill
LLM-neutral skill for AST-aware search and rewrite across 25 languages. Wraps the `ast-grep` (sg) CLI with offline pattern validation, the two-pass write trick, binary auto-resolution, and a per-OS installer.
Same shape as `web-fetch` and `web-search`, packaged as a standalone skill that any Bash-capable agent (Claude Code, OpenCode, pi, hermes, openclaw) can load.
Install
git clone https://github.com/code-yeongyu/ast-grep-skill ~/.agents/skills/ast-grep
bash ~/.agents/skills/ast-grep/install.sh # installs the ast-grep binaryThat is it. The wrapper script is single-file Python 3 stdlib; no pip install needed. The installer tries brew → npm → cargo → pip → nix → mise → GitHub release in priority order, picks the first that works, and falls back to a cached binary at <skill>/bin/sg.
Symlink for active development
ln -s /path/to/your/clone ~/.agents/skills/ast-grepOther agents
- Claude Code / OpenCode: drop the directory under
~/.agents/skills/(or~/.config/opencode/skills/) and the skill auto-registers via thename+descriptioninSKILL.mdfrontmatter. - pi (`~/.senpi/agent`): not a
piextension — this is a skill. Pi consumes skills via~/.agents/skills/symlinks; the actualpi-ast-grepextension is at <https://github.com/code-yeongyu/pi-extensions>. - Direct CLI use:
python3 ~/.agents/skills/ast-grep/scripts/ast_grep_helper.py <subcommand>.
Usage
# Search by AST pattern (the helper validates patterns offline first)
python3 scripts/ast_grep_helper.py search 'console.log($MSG)' --lang ts src/
# Rewrite (dry-run preview by default)
python3 scripts/ast_grep_helper.py replace 'console.log($MSG)' 'logger.info($MSG)' --lang ts src/
# Apply the rewrite (two-pass: preview JSON + then --update-all)
python3 scripts/ast_grep_helper.py replace 'console.log($MSG)' 'logger.info($MSG)' --lang ts src/ --apply
# Run YAML lint rules from sgconfig.yml
python3 scripts/ast_grep_helper.py scan src/
# Validate a pattern OFFLINE (no sg call, no filesystem)
python3 scripts/ast_grep_helper.py validate '\w+' --lang ts
# → exit 2: regex \w not supported. Use $VAR for identifiers.
# Doctor: check ast-grep binary availability
python3 scripts/ast_grep_helper.py doctor
# List 25 supported languages
python3 scripts/ast_grep_helper.py langs
# Install / re-install the ast-grep binary
python3 scripts/ast_grep_helper.py installSee SKILL.md for full agent-facing usage and the `references/` directory for deep dives.
Project layout
ast-grep-skill/
├── SKILL.md agent-facing skill (loaded by Claude Code, OpenCode, pi, etc.)
├── README.md this file
├── LICENSE MIT
├── install.sh POSIX installer (macOS / Linux / WSL / Git Bash)
├── install.ps1 Windows PowerShell installer
├── scripts/
│ └── ast_grep_helper.py single-file Python 3 stdlib wrapper
├── references/
│ ├── install.md per-OS install methods + manual fallback
│ ├── patterns.md meta-variables ($VAR, $$$) and pattern syntax
│ ├── pitfalls.md regex anti-patterns + language-specific traps
│ ├── recipes.md copy-paste patterns by language (TS/JS/Py/Go/Rust/...)
│ ├── cli.md sg run / scan / test / new / lsp reference
│ ├── yaml-rules.md YAML rule schema (atomic / relational / composite / transform / fix)
│ └── sgconfig.md project configuration (ruleDirs, testConfigs, utilDirs)
├── tests/
│ ├── smoke.sh POSIX self-test
│ └── smoke.ps1 PowerShell self-test (Windows CI)
└── .github/workflows/ci.yml matrix CI: macos / ubuntu / windows × py 3.9-3.13What it does
1. Wraps `sg` with a single Python 3 stdlib script that works the same on macOS, Linux, Windows, WSL, Git Bash. 2. Validates patterns offline before calling sg — catches the regex-misuse class of mistakes (\w, .*, |, [a-z]) plus language-specific traps (Python trailing colons, JS/Go/Rust missing function bodies). 3. Resolves the binary through 6 candidate paths: cached → PATH (with Linux setgroups collision detection) → Homebrew. Falls through to a clear install hint with copy-paste commands. 4. Runs the two-pass write trick when applying rewrites — sg run silently ignores --update-all when --json is set, so replace --apply runs two invocations: pass 1 collects JSON matches, pass 2 mutates files. 5. Ships per-OS installers that try every reasonable package manager and fall back to a GitHub release tarball. 6. Documents the failure modes the model will hit (regex misuse, incomplete patterns, --update-all + --json trap, scope/type questions ast-grep can't answer) in references/pitfalls.md.
What it does NOT do
- No type inference, scope analysis, or data flow. ast-grep is a structural matcher; for type-aware questions use TypeScript LSP, Pyright, Semgrep with type inference, or CodeQL.
- No multi-repo federation. Run the helper once per repo.
- No automatic
sgconfig.ymldiscovery — it does whatsg scandoes (walk up from cwd looking for one). - No JS/Python rewriter authoring environment — for that, write YAML rules and use
sg testfor snapshot testing (see `references/yaml-rules.md`).
Limits
- 5-minute timeout per
sginvocation (configurable in the helper). - ast-grep itself supports 25 languages out-of-the-box. For anything else, use `customLanguages` in
sgconfig.yml. - Pattern hint detection is heuristic; pass
--forceto skip validation when you know the pattern is correct.
Requirements
- Python ≥ 3.9 (stdlib only — no pip install).
ast-grepbinary, installed viainstall.sh/install.ps1or one of:brew install ast-grep(macOS / linuxbrew)npm install -g @ast-grep/cli(any OS with Node)cargo install ast-grep --locked(any OS with Rust)pip install ast-grep-cli(any OS with Python)scoop install main/ast-grep(Windows)
For older systems and Windows-specific setup, see `references/install.md`.
Testing
bash tests/smoke.sh # POSIX (macOS / Linux / WSL / Git Bash)
pwsh tests/smoke.ps1 # Windows (PowerShell 5.1+ or 7+)CI runs the matrix on every push: {macos-latest, ubuntu-latest, ubuntu-22.04, windows-latest} × {Python 3.9, 3.10, 3.11, 3.12, 3.13} plus a syntax-floor check on Python 3.9 and 3.10.
License
MIT.
Acknowledgments
- `omo` (oh-my-opencode) —
src/tools/ast-grep/is the original tool implementation; this skill is a port of its pattern-hint detection and two-pass-write strategy. - `pi-extensions/pi-ast-grep` — sibling Node port; the helper's binary-resolution cascade is modelled on it.
- ast-grep — the CLI. All structural matching power comes from it.
- Anthropic skills — the
SKILL.md+references/packaging convention.
CLI reference — sg / ast-grep
Compact reference for the underlying sg binary that the helper wraps. Use this when the helper isn't enough or when you want to invoke sg directly.
Binary name on Linux: preferast-grepoversgbecausesgcollides withsetgroupsfromutil-linux.
---
sg run — one-shot search/rewrite
The default subcommand. sg -p 'foo' is shorthand for sg run -p 'foo'.
sg run [OPTIONS] --pattern <PATTERN> [PATHS...]| Flag | Purpose |
|---|---|
-p, --pattern <P> | AST pattern to match. Always single-quote in shell to prevent $VAR expansion. |
-r, --rewrite <R> | Replacement pattern. Used with -U to apply. |
-l, --lang <LANG> | Language. Inferred from path extension if omitted. |
--selector <KIND> | When the pattern is ambiguous, extract only this AST kind. |
--strictness <S> | cst \ |
--debug-query[=<F>] | Print parsed pattern. F: pattern \ |
--stdin | Read code from stdin instead of files. Lang must be set. |
--globs <G> | Include/exclude glob (repeatable; prefix ! to exclude). |
--follow | Follow symlinks. |
--no-ignore <T> | Disable a class of ignore: hidden, dot, exclude, global, parent, vcs. |
-i, --interactive | Step through matches and confirm each rewrite. |
-U, --update-all | Apply all rewrites without confirmation. Mutually exclusive with `--json` (silently). |
--json[=<S>] | Emit JSON. S: pretty \ |
--color <W> | auto \ |
--inspect <G> | Detail level: nothing \ |
-A, -B, -C <N> | Context lines after / before / around each match. |
-j, --threads <N> | Thread count (default: heuristic; 0 = auto). |
--update-all + --json — the trap
sg silently ignores --update-all when --json is set. To preview AND apply, run two passes:
# Pass 1: preview
sg run -p 'foo()' -r 'bar()' --json=compact src/
# Pass 2: apply
sg run -p 'foo()' -r 'bar()' --update-all src/The ast_grep_helper.py replace --apply subcommand does this automatically.
Examples
# Basic search
sg run -p 'console.log($MSG)' --lang ts src/
# Search with context lines
sg run -p 'eval($CODE)' --lang js -C 3 .
# Rewrite, dry-run preview as JSON
sg run -p 'console.log($MSG)' -r 'logger.info($MSG)' --json=compact --lang ts src/
# Rewrite, apply
sg run -p 'console.log($MSG)' -r 'logger.info($MSG)' --update-all --lang ts src/
# Pattern from stdin
echo 'console.log("x")' | sg run -p 'console.log($MSG)' --lang js --stdin
# Limit to specific files
sg run -p 'foo()' --lang ts --globs 'src/**/*.ts' --globs '!**/*.test.ts' .
# Debug a pattern that returns 0 matches
sg run -p 'def $F($$$):' --lang py --debug-query=ast --stdin <<< 'def foo(): pass'---
sg scan — YAML rule scanner
Run a configuration of YAML rules across files. Used for project-wide lints and codemods.
sg scan [OPTIONS] [PATHS...]| Flag | Purpose |
|---|---|
-c, --config <C> | Path to sgconfig.yml (default: walk up from cwd looking for one). |
-r, --rule <F> | Run a single rule file. Mutually exclusive with --config. |
--inline-rules <Y> | Pass YAML rule text inline. Use --- to separate multiple rules. |
--filter <RE> | Only run rules whose id matches this regex. |
--include-metadata | Include rule metadata field in JSON output. |
-U, --update-all | Apply fixes from fix: automatically. |
--report-style <S> | rich \ |
--format <F> | github \ |
--error[=ID], --warning[=ID], --info[=ID], --hint[=ID], --off[=ID] | Promote/demote severity. |
-i, --interactive | Confirm each fix interactively. |
--json[=<S>] | JSON output. |
Examples
# Run all rules in sgconfig.yml-discovered ruleDirs
sg scan src/
# Run a single rule file (no sgconfig.yml needed)
sg scan -r rules/no-console.yml src/
# Inline rule (great for one-offs and CI)
sg scan --inline-rules '
id: no-todo
language: TypeScript
severity: warning
rule: { pattern: TODO }' src/
# Apply all auto-fixes
sg scan -U src/
# CI-friendly GitHub annotations
sg scan --format github src/
# SARIF for security scanners
sg scan --format sarif src/ > sarif.json---
sg test — run rule snapshot tests
sg test [OPTIONS]| Flag | Purpose |
|---|---|
-c, --config <C> | Path to sgconfig.yml. |
-t, --test-dir <D> | Test directory. |
--snapshot-dir <D> | Snapshot directory (default: __snapshots__). |
--skip-snapshot-tests | Validate test code parses; don't compare snapshots. |
-U, --update-all | Update all changed snapshots. |
-f, --filter <G> | Filter test cases by glob on rule id. |
--include-off | Include rules with severity off. |
-i, --interactive | Step through changed snapshots and accept/reject each. |
A test directory looks like:
test/
├── no-console.yml # `valid:` and `invalid:` snippets
└── no-console-test.yml # alternative test file format
__snapshots__/
└── no-console-snapshot.yml # expected match locations---
sg new — scaffold
sg new <COMMAND> [NAME] [OPTIONS]| Subcommand | Creates |
|---|---|
project | sgconfig.yml, rules/, utils/, __snapshots__/ directory tree |
rule | A new YAML rule file in the first ruleDirs entry |
test | A new test file in testConfigs[0].testDir |
util | A new utility rule in the first utilDirs entry |
# New project in current dir
sg new project --yes
# New rule
sg new rule no-console --lang typescript
# New test
sg new test no-console --yes---
sg lsp — language server
sg lsp -c sgconfig.ymlSpeak LSP over stdin/stdout. Configure your editor (VS Code extension, Neovim nvim-lspconfig, Helix languages.toml) to spawn this command for live diagnostics.
---
sg completions — shell completions
sg completions bash >> ~/.bashrc
sg completions zsh > "${fpath[1]}/_sg"
sg completions fish > ~/.config/fish/completions/sg.fish
sg completions powershell >> $PROFILE---
Useful one-liners
# Count matches per file
sg run -p 'console.log($_)' --lang ts --json=compact . \
| jq -r '.[].file' | sort | uniq -c | sort -rn
# Find all unique kinds in a file (great for figuring out kind names)
sg run -p '$_' --lang ts --debug-query=cst src/foo.ts \
| grep -oE 'kind: [a-z_]+' | sort -u
# Rewrite only in a subset of files
sg run -p 'foo()' -r 'bar()' --update-all --globs 'src/**/*.ts' --globs '!src/legacy/**' .
# Apply fixes from many rules but only ones matching a pattern in their id
sg scan --filter 'no-' -U src/
# Use ast-grep as a linter in pre-commit
sg scan --format github src/ || exit 1---
See also
references/yaml-rules.md— rule schema (pattern,kind,regex,inside,has,all,any,not,matches,transform,fix).references/sgconfig.md— project configuration.- Official: <https://ast-grep.github.io/reference/cli.html>
Install ast-grep
The skill ships an install.sh (POSIX) and install.ps1 (Windows) that try every reasonable method in priority order and fall back to a GitHub release download as a last resort. You usually do not need to read this page. Run the installer:
bash install.sh # macOS / Linux / WSL / Git Bash
pwsh -File install.ps1 # Windows PowerShellThis page exists for the (rare) case the installer cannot find a working method, or you want to install ast-grep manually.
---
Per-OS install commands (verbatim, copy-paste)
macOS
brew install ast-grep # Homebrew - the primary path
sudo port install ast-grep # MacPorts
npm install -g @ast-grep/cli # if you have Node already
cargo install ast-grep --locked # if you have Rust alreadyLinux
# Universal (works on every distro)
npm install -g @ast-grep/cli
cargo install ast-grep --locked
pip install ast-grep-cli
# Distro-specific
nix-env -iA nixpkgs.ast-grep # NixOS / Nix
brew install ast-grep # Linuxbrew
# NixOS shell.nix
nix-shell -p ast-grepLinux gotcha: the binary is namedsg, but on most Linux systemssgis also the `setgroups` command fromutil-linux. The shell seessetgroupsfirst and ignores ast-grep. Two options:
>
1. Always invoke ast-grep (full name).2. Add an alias:alias sg=ast-grepin your~/.bashrc/~/.zshrc.
>
Theast_grep_helper.pyscript inscripts/already handles this — when it seessgon PATH on Linux, it runs--versionand rejects the binary if it isn't ast-grep.
Windows
scoop install main/ast-grep # Scoop (most common on dev machines)
winget install --id ast-grep.ast-grep # Winget (Microsoft built-in)
choco install ast-grep # Chocolatey
npm install -g @ast-grep/cli # any OS with Node
cargo install ast-grep --locked # any OS with RustWSL / Git Bash on Windows
Treat as Linux. Use npm, cargo, pip, or bash install.sh.
---
Cross-platform / language-ecosystem methods
These work on every OS:
| Method | Command | Pros | Cons |
|---|---|---|---|
| npm | npm install -g @ast-grep/cli | Fast, prebuilt platform binaries | Needs Node 18+ |
| cargo | cargo install ast-grep --locked | Always builds latest from source | Slow (~3-5 min compile) |
| cargo binstall | cargo binstall ast-grep | Fast (downloads release binary) | Needs cargo-binstall first |
| pip | pip install ast-grep-cli | Works in any Python venv | Needs Python 3.8+ |
| pipx | pipx install ast-grep-cli | Isolated install | Needs pipx |
| mise | mise use -g ast-grep | asdf successor, version-pinning | Needs mise |
| GitHub release | manual download | Pure binary, no toolchain | Manual PATH setup |
---
GitHub release manual install
If every package manager fails:
# 1. Pick the right asset for your OS+arch from the latest release:
# https://github.com/ast-grep/ast-grep/releases/latest
#
# Naming pattern:
# ast-grep-aarch64-apple-darwin.zip macOS Apple Silicon
# ast-grep-x86_64-apple-darwin.zip macOS Intel
# ast-grep-aarch64-unknown-linux-gnu.zip Linux ARM64 (glibc)
# ast-grep-x86_64-unknown-linux-gnu.zip Linux x86_64 (glibc)
# ast-grep-x86_64-unknown-linux-musl.zip Linux x86_64 (musl, e.g. Alpine)
# ast-grep-x86_64-pc-windows-msvc.zip Windows x86_64
# ast-grep-aarch64-pc-windows-msvc.zip Windows ARM64
# 2. Download and extract:
VERSION=0.42.1
TRIPLE=aarch64-apple-darwin
curl -fsSL "https://github.com/ast-grep/ast-grep/releases/download/${VERSION}/ast-grep-${TRIPLE}.zip" -o /tmp/ast-grep.zip
unzip /tmp/ast-grep.zip -d /tmp/ast-grep
sudo mv /tmp/ast-grep/ast-grep /usr/local/bin/sg
sudo chmod +x /usr/local/bin/sg
# 3. Verify:
sg --versionThe skill's install.sh does steps 1-3 automatically and drops the binary in <skill_root>/bin/sg so you can use it without sudo.
---
Build from source
git clone https://github.com/ast-grep/ast-grep.git
cd ast-grep
cargo install --path ./crates/cli --lockedRequires Rust 1.74+. Slowest path; only useful when you need a specific commit or unreleased fix.
---
Verifying the install
ast-grep --version # or `sg --version`
# ast-grep 0.42.1Then sanity-check a real query:
echo 'console.log("hello")' | sg run -p 'console.log($MSG)' --lang js --stdinExpected: a single match with the console.log("hello") call highlighted.
---
Editor integration
After installing the CLI, set up your editor:
- VS Code: install the `ast-grep` extension. Requires
sgconfig.ymlin workspace root for live diagnostics. - Neovim: configure
nvim-lspconfigwithast_grepserver, or install `telescope-ast-grep.nvim`. - Helix: add
ast-grep lspas a language server inlanguages.toml. - Emacs: install `ast-grep.el`.
See references/cli.md for ast-grep lsp flags.
---
Uninstall
| Method | Command |
|---|---|
| brew | brew uninstall ast-grep |
| npm | npm uninstall -g @ast-grep/cli |
| cargo | cargo uninstall ast-grep |
| pip | pip uninstall ast-grep-cli |
| pipx | pipx uninstall ast-grep-cli |
| scoop | scoop uninstall ast-grep |
| winget | winget uninstall --id ast-grep.ast-grep |
| choco | choco uninstall ast-grep |
| GitHub binary | rm <skill_root>/bin/sg |
Pattern syntax — meta-variables and how patterns parse
ast-grep is not regex. Patterns are written in the same syntax as the target language (TypeScript, Python, Go, etc.), and ast-grep matches them against the AST of every file. The wildcards are called meta-variables.
This page is the canonical primer. If a pattern fails, 90% of the time it is one of the issues on this page.
---
The three meta-variables
| Syntax | Matches | Capture |
|---|---|---|
$VAR | exactly one AST node | yes, by name |
$$$ | zero or more AST nodes (a list) | no (anonymous) |
$$$VAR | zero or more AST nodes | yes, by name |
$_ | one AST node | no (anonymous) |
$$_ | one or more AST nodes (rare; for unnamed-node lists) | no |
A meta-variable always replaces a whole AST node, never a substring of a node. $VAR cannot match the first three characters of an identifier, only an entire identifier (or expression, or statement, depending on context).
Naming rules
- Must start with
$. - Then uppercase letters
A-Z, digits, or underscores. - Valid:
$X,$VAR,$VAR_1,$_,$_VAR,$ARG1. - Invalid:
$lower,$kebab-case,$1(digit first),$$single(use$_for anonymous).
Same-name = same content
Two occurrences of the same metavariable in a pattern must capture identical text:
// Pattern
$X === $X
// Matches
a === a
foo.bar === foo.bar
// Does NOT match
a === b
foo === foo.barUseful for finding redundant comparisons, double assignments, etc.
$$$ is lazy
When you write foo($$$A, b, $$$C), the matcher does not try every possible split. It greedily fills $$$A until the pattern can match b, then everything left goes into $$$C.
// Pattern
foo($$$A, b, $$$C)
// Input
foo(a, c, b, b, c)
// Capture
$$$A = [a, c]
$$$C = [b, c]If you need a different split, restructure the pattern (e.g. add a constraint).
---
Patterns must be valid code
The pattern itself must parse with the target language's grammar. ast-grep treats $VAR and $$$ as identifiers/argument lists during parsing, then matches structurally.
What goes wrong
| Bad pattern | Why it fails | Fix |
|---|---|---|
function $NAME | Function declaration without body — not a valid AST node in JS/TS/Go/Rust. | function $NAME($$$) { $$$ } |
def $FN($$$): | Trailing colon. ast-grep parses as a complete function definition; the colon makes it a statement. | def $FN($$$) |
class Foo: | Same — Python class without body. | class Foo($$$) |
fn $NAME | Rust fn without signature. | fn $NAME($$$) -> $RET { $$$ } |
if x | Incomplete if — most languages require the body. | if x { $$$ } (curly-brace languages) or if x: $$$ (Python uses pattern.context/selector instead) |
"key": "$VAL" | JSON pattern — a key/value pair on its own isn't valid JSON. | Use pattern: { context: '{"key": "$VAL"}', selector: pair } |
When a sub-expression isn't valid on its own
Sometimes you want to match an expression that the language only allows inside a larger context. Use the pattern object form:
pattern:
context: 'class A { $FIELD = $INIT }'
selector: field_definitionThis says: parse class A { $FIELD = $INIT } as a whole, then keep only the field_definition sub-tree as the actual pattern.
---
Strictness levels
When CST nodes don't match exactly (extra whitespace, different unnamed punctuation), ast-grep can be more or less forgiving. Pass --strictness <LEVEL> on the CLI, or set it in a YAML rule:
| Level | Matches |
|---|---|
cst | Every node, including unnamed (commas, parens, etc.) |
smart (default) | All except unnamed nodes in the target that aren't in the pattern |
ast | Only named AST nodes |
relaxed | Named AST nodes, ignoring comments |
signature | Only node kinds — text and unnamed nodes ignored |
smart is almost always what you want. Reach for signature when you want to match "any function called foo" regardless of arguments.
---
Testing a pattern
Two tools help you confirm a pattern parses the way you expect:
# Print the AST of the pattern itself
sg run -p 'console.log($MSG)' --lang ts --debug-query=ast
# Print the parsed CST of a file (great for figuring out kind names)
sg run -p '$_' --lang ts --debug-query=cst src/example.ts | head -40--debug-query=ast shows the named AST nodes only (cleaner). --debug-query=cst shows everything including punctuation. Both go to stderr, so they don't interfere with stdout JSON.
The web playground is also fast: <https://ast-grep.github.io/playground.html>.
---
When ast-grep is the wrong tool
If your pattern is fundamentally text-shaped, switch to grep / rg:
- Match across multiple files for any text →
rg - Cross-language regex with alternation →
rg -e foo -e bar - Match comments only →
rg --type ts '^\s*//.*TODO' - Match URLs, emails, license headers →
rg
ast-grep is for code structure: function shapes, call patterns, control flow, type annotations, imports, error handling. If your "pattern" only depends on the bytes of the file and not on the syntax, regex is the right tool.
---
See also
references/pitfalls.md— concrete regex anti-patterns and language-specific traps.references/recipes.md— copy-paste-ready patterns for TS/JS/Py/Go/Rust.references/yaml-rules.md—kind,regex,inside,has,all,any,not,matches.- Official: <https://ast-grep.github.io/guide/pattern-syntax.html>
Pitfalls — what breaks patterns and how to fix them
This is the failure-mode field guide. The scripts/ast_grep_helper.py validate subcommand mechanically checks for the items in §1 before calling sg; the rest are lower-frequency but still common.
---
1. Regex syntax does not work
ast-grep does not interpret regex inside patterns. The following all fail:
| Bad | Why | Use instead |
|---|---|---|
| `foo\ | bar` | `\ |
foo.*bar | .* is a regex wildcard. | foo($$$) bar if the gap is a list of nodes; otherwise switch to rg. |
\w+, \d+, \s | Regex character classes. | $VAR to capture any identifier. For digits-only, use kind: number_literal. |
[a-z]+ | Regex character class. | No AST equivalent — switch to rg. |
^foo$ | Regex anchors. | Anchor by AST: use kind: program > expression_statement or use inside/not has. |
Why this happens: LLMs default to regex thinking. The mental switch is "ast-grep patterns are code, not strings."
When you genuinely need regex, use the regex rule field in YAML (matches node text with Rust regex):
rule:
all:
- kind: identifier
- regex: '^[A-Z][a-z]+$' # CamelCase identifiers onlyNote: regex matches the whole node text — no partial matches. Combine with kind or pattern for performance.
---
2. Incomplete AST nodes
Patterns must be valid code that the parser accepts as a complete node. Common mistakes:
# JS/TS
function foo ❌ no params, no body
function $NAME($$$) { $$$ } ✅
async function $NAME ❌
async function $NAME($$$) { $$$ } ✅
# Python
def foo: ❌ trailing colon makes it a statement
def $FN($$$) ✅
class Foo: ❌
class $C($$$) ✅
# Go
func foo ❌
func $NAME($$$) { $$$ } ✅
# Rust
fn foo ❌
fn $NAME($$$) -> $RET { $$$ } ✅
fn $NAME($$$) { $$$ } ✅ (-> () inferred)
# Java
public void foo ❌
public void $NAME($$$) { $$$ } ✅If a pattern returns 0 matches and looks correct, run sg run -p '<pattern>' --lang <lang> --debug-query=ast --stdin <<< 'echo' and see what the parser thinks the pattern is. If it returns an ERROR node, the pattern is malformed.
---
3. Pattern parses as the wrong kind
A class field initializer a = 123 also parses as an assignment expression. If you want only field definitions, you must disambiguate:
# WRONG — pattern parses as assignment_expression, not field_definition
pattern: a = 123
kind: field_definition
# CORRECT — use pattern object with context + selector
pattern:
context: 'class C { a = 123 }'
selector: field_definitionkind and pattern are independent constraints, not modifiers of each other. ast-grep does not change how it parses based on kind.
---
4. The | ambiguity
A bare | in a pattern is interpreted as bitwise-or in most languages, not alternation. So:
pattern: foo | bar # parses as: foo bitwise-or'd with bar…matches expressions like x | y, not "either foo or bar". To get alternation, use any:
rule:
any:
- pattern: foo
- pattern: barIn TypeScript union types (A | B), | is part of the type syntax — pattern: A | B correctly parses as a union type and matches that.
---
5. Same-name metavars collide
// Pattern: $X = $X
// Captures only when both sides are TEXTUALLY identical.
// Matches:
a = a
foo.bar = foo.bar
// Does NOT match:
a = b
let x = compute() // because $X needs to bind once and re-useIf you actually want two independent captures, name them differently: $X = $Y.
---
6. $$$ is greedy then commits
$$$ does not backtrack. It captures as much as possible, then commits. If your pattern needs a non-greedy match, structure it differently:
// You want "match foo($X), where $X is any single arg"
// BAD: foo($$$X) // matches foo(a), foo(a, b), foo(a, b, c) - too broad
// GOOD: foo($X) // matches only single-arg calls
// You want "match foo() with at least one arg"
// BAD: foo($$$X) // also matches foo()
// GOOD: foo($X, $$$REST) // forces at least one arg---
7. kind names depend on tree-sitter grammar
kind: function_declaration works for JavaScript, but Python uses function_definition, Rust uses function_item, Go uses function_declaration (same as JS by coincidence). To find the right name, parse a known-good file:
sg run -p '$_' --lang python --debug-query=cst path/to/example.py | grep -i functionOr open <https://ast-grep.github.io/playground.html> and click on a node to see its kind.
---
8. inside / has defaults to stopBy: neighbor
inside:
kind: function_declaration # only checks the IMMEDIATE parentIf you want "anywhere inside a function (any depth)":
inside:
kind: function_declaration
stopBy: end # walks up to the file rootSame for has (descendants):
has:
kind: return_statement
stopBy: end # walks down the whole subtreeWithout stopBy: end, has only matches direct children.
---
9. CLI silently ignores --update-all when --json is set
This is the single biggest gotcha when scripting ast-grep. If you run:
sg run -p 'foo()' -r 'bar()' --json=compact --update-all .…you get the JSON output but no files are mutated. ast-grep silently drops --update-all when --json is on. To both preview and apply, run two passes:
# Pass 1: preview as JSON
sg run -p 'foo()' -r 'bar()' --json=compact .
# Pass 2: actually apply
sg run -p 'foo()' -r 'bar()' --update-all .scripts/ast_grep_helper.py replace does this automatically when --apply is set.
---
10. Composite rules apply to a single node
all and any evaluate against one target node at a time:
# WRONG — wants "node has BOTH a number child AND a string child"
has:
all:
- kind: number # impossible: one node cannot be both at once
- kind: string
# CORRECT
all:
- has: { kind: number }
- has: { kind: string }Lift relational rules out of composites when the relation is "the surrounding node has X children matching Y."
---
11. Field order is not guaranteed
When a rule object has multiple fields:
rule:
pattern: $X = compute()
has: { kind: number }…ast-grep evaluates them as an implicit all, but the order in which metavariables are captured is not guaranteed. If your transform or fix depends on capture order, use an explicit all array:
rule:
all:
- pattern: function $F() { $$$ }
- has: { pattern: $F() } # $F captured by pattern first; here we just check---
12. regex without kind is slow
regex alone scans every node text in the file. On large repos this is noticeably slow. Always combine:
# Slow
rule:
regex: '^TODO'
# Fast
rule:
all:
- kind: comment
- regex: '^//\s*TODO'---
13. No scope / type / data-flow analysis
ast-grep is a structural matcher. It does NOT know:
- Whether two
fooreferences point to the same variable. - Whether a variable is shadowed.
- Whether a function is async, throws, returns a Promise.
- Whether a value flows from input to output.
For those questions, use a real type-aware tool: TypeScript LSP, Pyright, Semgrep with type inference, CodeQL, etc.
ast-grep is great when the syntactic shape is what you care about: "find every call to eval(...)", "find every as any", "find every empty catch block." It is weak for "find every variable that's never used."
---
14. Pattern testing is the fastest debugger
When a pattern returns 0 matches and you can't see why:
1. Open <https://ast-grep.github.io/playground.html>. 2. Paste your code into the left pane, your pattern into the top-right. 3. The bottom-right shows the parsed AST and which nodes matched (highlighted) or failed.
Or locally:
sg run -p '<pattern>' --lang <lang> --debug-query=ast --stdin <<< '<sample-code>'stderr shows the parsed pattern; stdout shows the JSON match result. If the pattern shows up as ERROR (XXX), it doesn't parse.
---
See also
references/patterns.md— meta-variables, strictness, naming rules.references/recipes.md— known-good patterns by language.references/cli.md—--debug-query,--strictness,--update-all.
Recipes — copy-paste patterns by language
Every pattern in this file has been verified against the canonical syntax. They are starting points; tweak metavariable names and constraints to fit your case.
Use them with the helper:
ast-grep-helper search '<PATTERN>' --lang <LANG> [path]
ast-grep-helper replace '<PATTERN>' '<REWRITE>' --lang <LANG> [path] # dry-run
ast-grep-helper replace '<PATTERN>' '<REWRITE>' --lang <LANG> [path] --applyOr directly:
sg run -p '<PATTERN>' --lang <LANG> [path]
sg run -p '<PATTERN>' -r '<REWRITE>' --update-all --lang <LANG> [path]---
TypeScript / TSX / JavaScript
Find structural patterns
// Every function declaration
function $NAME($$$PARAMS) { $$$BODY }
// Every async function
async function $NAME($$$PARAMS) { $$$BODY }
// Every arrow function (any param shape)
($$$PARAMS) => $$$BODY
// Every method on a class
class $C { $$$ $METHOD($$$P) { $$$B } $$$ }
// Every import statement
import { $$$NAMES } from '$MOD'
import $DEFAULT from '$MOD'
import * as $NS from '$MOD'
// Every console.* call
console.$METHOD($$$ARGS)
// Every JSX element of a given name
<MyComponent $$$PROPS>$$$CHILDREN</MyComponent>
// Every try/catch
try { $$$BODY } catch ($E) { $$$HANDLER }
// Every throw
throw $EXPR
// Every new expression
new $CLASS($$$ARGS)
// Every type assertion to any (anti-pattern!)
$EXPR as any
$EXPR as unknown as $TCommon rewrites
# console.log -> logger.info
sg run -p 'console.log($$$A)' -r 'logger.info($$$A)' --lang ts --update-all .
# require -> import (one-arg case)
sg run -p 'const $V = require($M)' -r 'import $V from $M' --lang ts --update-all .
# .then(callback) -> await on the same line (use with caution; needs async function context)
sg run -p '$P.then($CB)' -r 'const $TMP = await $P; $CB($TMP)' --lang ts --update-all .
# Strip `as any`
sg run -p '$E as any' -r '$E' --lang ts --update-all .
# Rename a function call site
sg run -p 'oldName($$$A)' -r 'newName($$$A)' --lang ts --update-all .---
Python
Reminder: never end a Python pattern with:. Patterns parse as a complete statement, sodef foo($$$):is invalid.
# Every function definition
def $FN($$$PARAMS)
# Every class definition
class $C($$$BASES)
# Every decorator usage
@$DEC
def $FN($$$P)
# Every print call (Python 3)
print($$$ARGS)
# Every f-string
f"$STR"
# Every with-statement
with $CTX as $VAR: $$$BODY
# Every try/except
try: $$$BODY
except $EXC: $$$HANDLER
# Every list comprehension
[$EXPR for $VAR in $ITER]
# Every async def
async def $FN($$$PARAMS)
# Type hints — Optional[X]
Optional[$T]
# Type hints — X | None (PEP 604)
$T | NoneCommon rewrites
# print(...) -> logger.info(...)
sg run -p 'print($$$A)' -r 'logger.info($$$A)' --lang py --update-all .
# Optional[X] -> X | None
sg run -p 'Optional[$T]' -r '$T | None' --lang py --update-all .
# from typing import List -> remove (built-in list works in 3.9+)
sg run -p 'from typing import List' -r 'from typing import List # TODO: remove, use list' --lang py --update-all .---
Go
// Every function
func $NAME($$$PARAMS) $$$RET { $$$BODY }
// Every method
func ($RECV $TYPE) $NAME($$$PARAMS) $$$RET { $$$BODY }
// The classic err nil-check
if err != nil { $$$BODY }
// Every fmt.Println / fmt.Printf / fmt.Sprintf
fmt.$METHOD($$$ARGS)
// Every defer
defer $EXPR
// Every goroutine
go $EXPR
// Every channel send/recv
$CH <- $VAL
$VAL := <-$CH
// Every type assertion
$EXPR.($TYPE)Common rewrites
# fmt.Println -> log.Println
sg run -p 'fmt.Println($$$A)' -r 'log.Println($$$A)' --lang go --update-all .
# Add error wrapping
sg run -p 'return $ERR' -r 'return fmt.Errorf("operation failed: %w", $ERR)' --lang go --update-all .---
Rust
// Every fn
fn $NAME($$$PARAMS) -> $RET { $$$BODY }
fn $NAME($$$PARAMS) { $$$BODY } // no return type
// Every async fn
async fn $NAME($$$PARAMS) -> $RET { $$$BODY }
// Every method on impl
impl $TYPE { fn $METHOD($$$P) -> $R { $$$B } }
// Every trait impl
impl $TRAIT for $TYPE { $$$ITEMS }
// Every match expression
match $EXPR { $$$ARMS }
// Every Result-returning fn that uses ?
fn $N($$$P) -> Result<$T, $E> { $$$ }
// .unwrap() / .expect() (anti-patterns)
$EXPR.unwrap()
$EXPR.expect($MSG)
// Every println!/eprintln!/format!
println!($$$ARGS)
format!($$$ARGS)Common rewrites
# unwrap() -> ? in Result-returning fns (caution: needs context)
sg run -p '$E.unwrap()' -r '$E?' --lang rust --update-all .
# eprintln! -> log::error!
sg run -p 'eprintln!($$$A)' -r 'log::error!($$$A)' --lang rust --update-all .---
Java
// Every public class
public class $NAME { $$$BODY }
// Every method (any modifier)
$$$MOD $RET $NAME($$$P) { $$$BODY }
// Every System.out.println / System.err.println
System.$STREAM.println($$$ARGS)
// Every try-with-resources
try ($$$RES) { $$$BODY } catch ($EXC $E) { $$$HANDLER }
// Every annotation usage
@$ANNOTATION
$DECL---
C / C++
// Every printf-family call
printf($$$ARGS)
sprintf($$$ARGS)
fprintf($$$ARGS)
// Every malloc / free pair (find-only — pairing requires data flow)
malloc($SIZE)
free($PTR)
// Every for-loop
for ($INIT; $COND; $POST) { $$$BODY }
// C++ smart pointer make
std::make_shared<$T>($$$ARGS)
std::make_unique<$T>($$$ARGS)Rewrites
# malloc(N * sizeof(T)) -> calloc(N, sizeof(T)) - safer
sg run -p 'malloc($N * sizeof($T))' -r 'calloc($N, sizeof($T))' --lang c --update-all .---
CSS
/* Every rule with a specific property */
{ $$$ color: $VAL; $$$ }
/* Every @media query */
@media $QUERY { $$$BODY }
/* Every var() reference */
var($NAME)---
HTML
<!-- Every img without alt -->
<img $$$ />
<!-- Every script tag -->
<script $$$>$$$BODY</script>
<!-- Every link to stylesheet -->
<link rel="stylesheet" href=$URL />---
Bash / Shell
# Every for-loop
for $VAR in $$$LIST; do $$$BODY; done
# Every if-statement
if $$$COND; then $$$BODY; fi
# Every function definition
$NAME() { $$$BODY }
# Every subshell call
$( $$$CMD )---
YAML rule recipes (for sg scan)
These are full YAML rules you can drop in rules/*.yml and run via sg scan. See references/yaml-rules.md for the full schema.
no-console (TypeScript)
id: no-console
language: TypeScript
severity: warning
message: "Avoid console.* in production"
rule:
pattern: console.$METHOD($$$ARGS)
fix: logger.$METHOD($$$ARGS)no-as-any (TypeScript)
id: no-as-any
language: TypeScript
severity: error
message: "`as any` defeats type safety. Use a proper type."
rule:
pattern: $EXPR as any
fix: $EXPRempty-catch (JavaScript)
id: empty-catch
language: JavaScript
severity: error
message: "Empty catch swallows errors silently."
rule:
all:
- pattern: try { $$$T } catch ($E) { $$$H }
- has:
kind: catch_clause
has:
kind: statement_block
not:
has:
kind: statement
stopBy: endprint-to-logger (Python)
id: print-to-logger
language: Python
severity: hint
message: "Use logger.info instead of print"
rule:
pattern: print($$$ARGS)
fix: logger.info($$$ARGS)no-unwrap (Rust)
id: no-unwrap
language: Rust
severity: warning
message: "Avoid .unwrap() in production code; propagate or handle the error."
rule:
pattern: $EXPR.unwrap()---
See also
references/patterns.md— meta-variable rules.references/yaml-rules.md— full YAML rule schema (atomic / relational / composite / transform / fix).references/cli.md—sg run,sg scan,sg test,sg new.- Official catalog: <https://ast-grep.github.io/catalog/> (community-maintained, browse by language).
sgconfig.yml — project configuration
sgconfig.yml lives at your project root (the same place as package.json, Cargo.toml, pyproject.toml, etc.) and tells sg scan/sg test where to find rules and tests.
sg walks upward from the current directory until it finds an sgconfig.yml. You can also pass --config <path> explicitly.
---
Minimal project layout
my-project/
├── sgconfig.yml
├── rules/
│ ├── no-console.yml
│ └── no-as-any.yml
├── utils/
│ └── is-literal.yml
├── tests/
│ ├── no-console.yml
│ └── __snapshots__/
│ └── no-console-snapshot.yml
└── src/
└── ...# sgconfig.yml
ruleDirs:
- rules
testConfigs:
- testDir: tests
snapshotDir: __snapshots__
utilDirs:
- utilsThat's it. sg scan src/ will load every .yml in rules/, find every .ts/.py/whatever matching the rule's language, and report violations.
---
Full schema
# Rule directories — required
ruleDirs:
- rules
- team-rules
- vendor/sg-rules
# Test directories — optional
testConfigs:
- testDir: tests
snapshotDir: __snapshots__
- testDir: integration-tests
# Utility rule directories — optional
# Files here become global utilities accessible via `matches: <id>` from any rule.
utilDirs:
- utils
- team-utils
# Override file-extension -> language mapping — optional
# Useful when your code uses non-standard extensions.
languageGlobs:
html:
- '*.vue'
- '*.svelte'
- '*.astro'
json:
- '.eslintrc'
- '.prettierrc'
cpp:
- '*.c' # treat C as C++
tsx:
- '*.ts' # treat all .ts as TSX (so TSX rules work everywhere)
# Custom tree-sitter languages (experimental) — optional
customLanguages:
mojo:
libraryPath: tree-sitter-mojo.so
extensions: [mojo, '🔥']
expandoChar: _ # Replace $ in patterns when language uses $ syntactically
languageSymbol: tree_sitter_mojo
# Language injection — embedded code in another language (experimental) — optional
# Example: CSS inside styled-components template literals.
languageInjections:
- hostLanguage: js
rule:
pattern: 'styled.$TAG`$CONTENT`'
injected: css---
Field-by-field
ruleDirs (required)
Array<string> — directories containing rule YAML files. Resolved relative to sgconfig.yml.
Each .yml/.yaml file in these directories is loaded as a rule. One file can contain multiple rules separated by ---.
testConfigs
Array<TestConfig> where each entry has:
testDir(required): directory of test YAML files.snapshotDir(optional, default__snapshots__): directory for snapshots.
Each test file looks like:
id: no-console
valid:
- 'logger.info("hi")'
invalid:
- 'console.log("hi")'sg test runs every test, compares matches against the snapshot, and fails on diff. Snapshots are created on first run with -U.
utilDirs
Array<string> — directories with global utility rules. Each util file must have id and language. Utils become referenceable via matches: <id> from any rule in the project.
languageGlobs
HashMap<string, Array<string>> — override which extensions map to which language. Takes precedence over the built-in defaults.
Useful for:
- Custom file extensions (
.eslintrcis JSON). - Force-treating
.tsfiles as TSX (so JSX-shaped patterns work). - Vue/Svelte/Astro files (HTML host language).
customLanguages (experimental)
Register a tree-sitter parser that ast-grep doesn't ship with. Requires:
libraryPath: path to a built.so/.dylib/.dllcontaining the grammar.extensions: file extensions to recognize.languageSymbol: the C symbol exported by the grammar (typicallytree_sitter_<name>).expandoChar(optional): character to substitute for$in patterns when the host language uses$syntactically (PHP, jQuery, etc.).
This is rarely needed — ast-grep already supports 25 languages out of the box.
languageInjections (experimental)
Match patterns inside embedded languages. Example: CSS inside JS template literals (styled-components, emotion).
languageInjections:
- hostLanguage: js
rule:
pattern: 'styled.$TAG`$CONTENT`'
injected: cssAfter this, a css rule with pattern color: $C will match $CONTENT strings.
---
Common configurations
Monorepo with shared rules
monorepo/
├── sgconfig.yml # root config — applies to entire monorepo
├── shared-rules/
│ ├── no-todo.yml
│ └── no-as-any.yml
└── packages/
├── frontend/
│ ├── sgconfig.yml # extends root with frontend-specific rules
│ └── rules/
└── backend/
├── sgconfig.yml # extends root with backend-specific rules
└── rules/Each package's sgconfig.yml references both the package-local rules and the shared ones:
# packages/frontend/sgconfig.yml
ruleDirs:
- rules
- ../../shared-rulesSingle rule file (no project)
For one-offs, skip sgconfig.yml entirely:
sg scan -r path/to/single-rule.yml src/Inline rule (no file)
sg scan --inline-rules '
id: no-todo
language: TypeScript
severity: warning
rule: { pattern: TODO }' src/Multiple rules separated by ---:
sg scan --inline-rules '
id: no-todo
language: TypeScript
rule: { pattern: TODO }
---
id: no-fixme
language: TypeScript
rule: { pattern: FIXME }' src/---
Editor integration
VS Code / Neovim / Helix detect sgconfig.yml automatically and surface diagnostics from every rule. Without sgconfig.yml, the LSP runs without any rules loaded.
To enable schema validation in your editor, add a header to each rule file:
# yaml-language-server: $schema=https://raw.githubusercontent.com/ast-grep/ast-grep/main/schemas/rule.json
id: no-console
language: TypeScript
rule:
pattern: console.log($_)---
See also
references/yaml-rules.md— rule schema (atomic / relational / composite / transform / fix).references/cli.md—sg scan,sg test,sg new project.- Official: <https://ast-grep.github.io/reference/sgconfig.html>, <https://ast-grep.github.io/guide/project/project-config.html>
YAML rule reference — atomic, relational, composite, transform, fix
Use this when you outgrow inline sg run -p ... patterns and need a reusable, testable rule. A YAML rule is the unit of work for sg scan. Drop one or more files in ruleDirs/ (configured via sgconfig.yml) and they get loaded automatically.
This page is the practical reference. The full upstream docs live at:
- <https://ast-grep.github.io/reference/yaml.html>
- <https://ast-grep.github.io/reference/rule.html>
- <https://ast-grep.github.io/cheatsheet/rule.html>
---
Skeleton
A single YAML file can hold multiple rules separated by ---.
id: no-console
language: TypeScript
severity: warning
message: "Avoid console.* in production"
note: |
Use a proper logger so we can route logs to stderr in production
and silence them in tests.
url: https://internal.docs/rules/no-console
rule:
pattern: console.$METHOD($$$ARGS)
fix: logger.$METHOD($$$ARGS)
constraints:
METHOD:
not:
regex: '^(error|warn)$'
files:
- 'src/**/*.ts'
ignores:
- 'src/**/*.test.ts'
metadata:
category: logging---
Top-level fields
| Field | Required | Description |
|---|---|---|
id | yes | Unique identifier. Use kebab-case. |
language | yes | One of: Bash, C, Cpp, CSharp, Css, Elixir, Go, Haskell, Html, Java, JavaScript, Json, Kotlin, Lua, Nix, Php, Python, Ruby, Rust, Scala, Solidity, Swift, TypeScript, Tsx, Yaml. Capitalized PascalCase is canonical, but lowercase often works. |
rule | yes | The matching logic. Object containing one or more atomic / relational / composite rules. |
constraints | no | Filter on captured single-metavariables ($VAR, not $$$). |
utils | no | Local utility rules referenced by matches: in this file. |
transform | no | Manipulate metavariable strings before fix. |
fix | no | String or FixConfig for auto-rewrite. |
rewriters | no | Rewriter rules for the rewrite transform. |
severity | no | hint \ |
message | no | Concise lint message. May reference $VAR capture text. |
note | no | Detailed markdown explanation (no $VAR interpolation). |
labels | no | Custom diagnostic highlighting per-metavariable. |
files | no | Glob include list. |
ignores | no | Glob exclude list. |
url | no | Doc link shown in editor diagnostics. |
metadata | no | Free-form data ignored by sg, useful for external tooling. |
---
Atomic rules — match a single node
pattern
Match by structural pattern. The most common rule.
# String form
rule:
pattern: console.log($MSG)
# Object form (when context is needed)
rule:
pattern:
context: 'class C { $FIELD = $INIT }'
selector: field_definition
strictness: relaxed # optional, default: smartkind
Match by AST node type name. Tree-sitter grammar-specific.
rule:
kind: call_expressionast-grep 0.39+ supports limited ESQuery selectors:
rule:
kind: call_expression > identifier # direct child
kind: call_expression + identifier # next sibling
kind: call_expression ~ identifier # following sibling
kind: call_expression identifier # descendantTo find the right kind, parse a known-good file:
sg run -p '$_' --lang ts --debug-query=cst src/foo.ts | head -40regex
Match node text against a Rust regex. Whole-text match (no partial). Always combine with kind or pattern for performance.
rule:
all:
- kind: identifier
- regex: '^[A-Z][a-z]+$' # PascalCaseInline flags work: (?i)apple, (?m)^foo. No look-around, no backreferences.
nthChild
Match by 1-based index among named siblings. Inspired by CSS :nth-child.
rule:
nthChild: 1 # first sibling
# Functional form
rule:
nthChild: 2n+1 # odd siblings
# With reverse and ofRule
rule:
nthChild:
position: 1
reverse: true # last
ofRule:
kind: function_declarationrange
Match by character range. Useful for tooling that pinpoints a known location.
rule:
range:
start: { line: 0, column: 0 }
end: { line: 0, column: 11 }---
Relational rules — match by relation to other nodes
All four take a sub-rule object plus optional stopBy and (for inside/has) field.
inside — target is inside parent/ancestor matching sub-rule
rule:
pattern: this.$PROP
inside:
kind: class_body
stopBy: end # walk up to file root, default: neighborhas — target has child/descendant matching sub-rule
rule:
kind: function_declaration
has:
kind: throw_statement
stopBy: endprecedes — target appears before sibling matching sub-rule
rule:
kind: import_statement
precedes:
kind: function_declarationfollows — target appears after sibling matching sub-rule
rule:
pattern: super($$$)
follows:
pattern: $X = $YstopBy
| Value | Behavior |
|---|---|
"neighbor" (default) | Stop at immediate parent/child/sibling. |
"end" | Walk all the way to root / leaf / sequence boundary. |
| Rule object | Stop when sub-rule matches (inclusive). |
field
Specify the semantic role of the target inside its parent (e.g. name, body, value, key).
rule:
kind: pair
has:
field: key
regex: '^password$'---
Composite rules — combine sub-rules
| Rule | Meaning |
|---|---|
all | All sub-rules must match the same target node. Metavariables from all sub-rules merge. |
any | At least one sub-rule must match. Only metavars from the matched branch survive. |
not | Inverse: target must NOT match the sub-rule. |
matches | Reference a utility rule by id. |
rule:
all:
- kind: call_expression
- pattern: $FN($$$ARGS)
- inside:
kind: function_declaration
stopBy: end
rule:
any:
- pattern: console.log($X)
- pattern: console.warn($X)
- pattern: console.error($X)
rule:
all:
- pattern: $E.unwrap()
- not:
inside:
kind: function_item
has:
kind: result_type
stopBy: end
rule:
matches: is-react-componentComposites apply to a single target. To express "node X has BOTH a number child AND a string child," use two relational rules at the top level, notallinsidehas. Seereferences/pitfalls.md§10.
---
Implicit all — multiple rule fields
A rule object with multiple fields is treated as an implicit all:
# These two are equivalent
rule:
pattern: this.$PROP
inside: { kind: class_body }
rule:
all:
- pattern: this.$PROP
- inside: { kind: class_body }Use the explicit all array when capture order matters (rare, but possible with downstream transform).
---
constraints — post-match metavariable filtering
After the main rule matches, additional checks on captured single metavariables:
rule:
pattern: function $NAME($$$P) { $$$B }
constraints:
NAME:
regex: '^[a-z][a-zA-Z0-9]*$' # camelCase only
not:
regex: '^_' # not starting with _Constraints only apply to single metavars ($VAR), not multi ($$$VAR).
---
utils — local reusable sub-rules
utils:
is-literal:
any:
- kind: number
- kind: string
- kind: 'true'
- kind: 'false'
rule:
all:
- pattern: $X = $Y
- has:
matches: is-literal # references utils.is-literalFor utils accessible across multiple rule files, use utilDirs in sgconfig.yml and put each util in its own YAML file with id and language.
---
transform — manipulate captures before fix
Operations: replace, substring, convert, rewrite.
replace — regex search/replace on a captured string
rule:
pattern: $OLD_FN($$$A)
constraints:
OLD_FN:
regex: '^debug_'
transform:
NEW_FN:
replace:
source: $OLD_FN
replace: '^debug_'
by: 'release_'
fix: $NEW_FN($$$A)substring — character slicing (negative indices supported)
transform:
INNER:
substring:
source: $WRAPPED
startChar: 1
endChar: -1convert — case conversion
transform:
KEBAB:
convert:
source: $CAMEL
toCase: kebabCase # camelCase | snakeCase | kebabCase | pascalCase | upperCase | lowerCase | capitalize
separatedBy: [underscore] # optional: dash | dot | space | slash | underscore | caseChangerewrite — apply other rewriter rules (experimental)
rewriters:
- id: stringify
rule: { pattern: "'' + $A" }
fix: "String($A)"
rule:
pattern: stringify-all($EXPR)
transform:
REWRITTEN:
rewrite:
source: $EXPR
rewriters: [stringify]
joinBy: "\n"
fix: $REWRITTENTransforms can chain
Later transforms can reference variables produced by earlier ones:
transform:
KEBABED:
convert: { source: $X, toCase: kebabCase }
PREFIXED:
replace:
source: $KEBABED
replace: '^'
by: 'css-'
fix: $PREFIXED---
fix — auto-rewrite
String form
fix: logger.log($$$ARGS)
# Empty string deletes the match
fix: ""FixConfig form (for list-item deletion that needs to expand the range)
When deleting one item from a comma-separated list, you also need to remove the trailing comma. Use expandEnd:
rule:
kind: pair
has:
field: key
regex: '^password$'
fix:
template: ''
expandEnd:
regex: ','expandStart and expandEnd accept regex matching characters that should be absorbed into the rewrite range.
---
rewriters — sub-rule library for rewrite transform
Top-level field defining one or more named rewriters:
rewriters:
- id: nullable-to-optional
rule: { pattern: $X | null }
fix: '$X | undefined'
- id: stringify
rule: { pattern: "'' + $A" }
fix: 'String($A)'Used inside transform via the rewrite operation (see above).
---
labels — custom diagnostic highlighting
rule:
pattern: $FN($$$ARGS)
labels:
FN:
style: primary
message: "this function shouldn't be called"
ARGS:
style: secondary
message: "with these arguments"Editor extensions render the diagnostic with these labels. Defaults are usually fine.
---
files and ignores — file selection per-rule
files:
- 'src/**/*.ts'
- 'lib/**/*.ts'
ignores:
- 'src/**/*.test.ts'
- '**/__generated__/**'If omitted, the rule runs on every file matching its language. These globs override sgconfig.yml-level globs for this rule only.
Object form (rare):
files:
- pattern: 'src/**/*.ts'
case_sensitive: true---
See also
references/recipes.md— copy-paste rules by language.references/cli.md—sg scan,sg test.references/sgconfig.md— project-level configuration.- Official rule reference: <https://ast-grep.github.io/reference/rule.html>
- Cheat sheets: <https://ast-grep.github.io/cheatsheet/rule.html>, <https://ast-grep.github.io/cheatsheet/yaml.html>
#!/usr/bin/env python3
"""ast-grep-helper: a thin LLM-friendly wrapper around `sg` (ast-grep).
Single-file Python 3 stdlib. No deps. Works on macOS, Linux, Windows, WSL.
WHAT IT ADDS over plain `sg`:
1. Binary auto-resolution: cached -> @ast-grep/cli -> PATH -> Homebrew -> error with install hint
2. Pattern hint validation: detects regex misuse (\\w, .*, |, [a-z]) and language-specific
mistakes (Python trailing colon, JS/Go/Rust missing function body) BEFORE calling sg
3. Two-pass replace: ast-grep silently ignores --update-all when --json is set, so we run
a JSON pass to collect matches, then a separate --update-all pass to mutate files
4. Stable JSON output: parses sg --json=compact, salvages truncated output, normalizes shape
5. Cross-OS path handling: works the same on POSIX and Windows (uses pathlib + shutil)
USAGE
ast_grep_helper.py search PATTERN [PATH...] [--lang LANG] [--globs GLOB ...] [-C N]
ast_grep_helper.py replace PATTERN REWRITE [PATH...] [--lang LANG] [--apply] [--globs GLOB ...]
ast_grep_helper.py scan RULE_FILE [PATH...] [--apply] [--report-style STYLE]
ast_grep_helper.py test [-c CONFIG] [-t TEST_DIR] [-U]
ast_grep_helper.py new {project,rule,test,util} [NAME] [--lang LANG]
ast_grep_helper.py langs # list 25 supported languages
ast_grep_helper.py doctor # check binary availability + version
ast_grep_helper.py install # delegate to ../install.sh / install.ps1
ast_grep_helper.py validate PATTERN [--lang LANG] # offline pattern hint check only
ast_grep_helper.py --version
ast_grep_helper.py --help
EXAMPLES
# Find all console.log calls in TypeScript
ast_grep_helper.py search 'console.log($MSG)' --lang ts src/
# Migrate console.log -> logger.info (dry-run preview)
ast_grep_helper.py replace 'console.log($MSG)' 'logger.info($MSG)' --lang ts src/
# Apply the same replacement
ast_grep_helper.py replace 'console.log($MSG)' 'logger.info($MSG)' --lang ts src/ --apply
# Validate a pattern offline (no sg call, no filesystem access)
ast_grep_helper.py validate '\\w+' --lang ts
# -> exit 2, hint: "regex \\w not supported. Use $VAR for identifiers."
EXIT CODES
0 Success (matches found OR replacement applied OR validation passed)
1 Argument error
2 Pattern hint failure (regex misuse, missing body, etc.) - call would have failed
3 ast-grep binary not found and auto-install declined
4 ast-grep call failed (returned non-zero, with stderr forwarded)
5 Timeout (5 minutes per call by default)
"""
from __future__ import annotations
import argparse
import json
import os
import platform
import re
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Optional
VERSION = "0.1.0"
# 25 CLI languages supported by ast-grep, with their aliases (mirrors official docs)
LANGUAGES: dict[str, list[str]] = {
"bash": [".bash", ".sh", ".zsh"],
"c": [".c", ".h"],
"cpp": [".cc", ".cpp", ".cxx", ".hpp", ".hxx"],
"csharp": [".cs"],
"css": [".css"],
"elixir": [".ex", ".exs"],
"go": [".go"],
"haskell": [".hs"],
"html": [".html", ".htm"],
"java": [".java"],
"javascript": [".js", ".jsx", ".cjs", ".mjs"],
"json": [".json"],
"kotlin": [".kt", ".kts"],
"lua": [".lua"],
"nix": [".nix"],
"php": [".php"],
"python": [".py", ".pyi"],
"ruby": [".rb"],
"rust": [".rs"],
"scala": [".scala"],
"solidity": [".sol"],
"swift": [".swift"],
"typescript": [".ts", ".cts", ".mts"],
"tsx": [".tsx"],
"yaml": [".yml", ".yaml"],
}
# Aliases that ast-grep CLI accepts; we normalize to the canonical name.
LANG_ALIASES: dict[str, str] = {
"js": "javascript", "jsx": "javascript",
"ts": "typescript",
"py": "python", "py3": "python",
"rb": "ruby",
"rs": "rust",
"kt": "kotlin",
"ex": "elixir",
"hs": "haskell",
"sh": "bash", "zsh": "bash",
"cc": "cpp", "c++": "cpp", "cxx": "cpp",
"cs": "csharp",
"yml": "yaml",
"sol": "solidity",
"golang": "go",
}
# Default search timeout (5 min). ast-grep calls can be slow on huge repos.
DEFAULT_TIMEOUT_S = 300
# ---------- logging ----------
def trace(msg: str) -> None:
"""Print a trace line to stderr (suppressible via --quiet, default off)."""
if not _QUIET:
print(f"[ast-grep-helper] {msg}", file=sys.stderr, flush=True)
def err(msg: str) -> None:
"""Print an error line to stderr (always shown)."""
print(f"[ast-grep-helper] error: {msg}", file=sys.stderr, flush=True)
_QUIET = False
# ---------- binary resolution ----------
def script_dir() -> Path:
return Path(__file__).resolve().parent
def skill_root() -> Path:
return script_dir().parent
def cached_binary() -> Optional[Path]:
"""Look in <skill_root>/bin/ for a previously downloaded binary."""
binname = "sg.exe" if os.name == "nt" else "sg"
altname = "ast-grep.exe" if os.name == "nt" else "ast-grep"
for name in (binname, altname):
p = skill_root() / "bin" / name
if p.is_file() and os.access(p, os.X_OK):
return p
return None
def npm_binary() -> Optional[Path]:
"""If @ast-grep/cli is installed globally via npm, find its binary."""
# `sg` shipped by @ast-grep/cli is on PATH when npm prefix bin is on PATH.
# We rely on shutil.which for that case.
return None # handled by which_binary
def which_binary() -> Optional[Path]:
"""Use shutil.which to find sg or ast-grep on PATH.
On Linux, plain `sg` collides with the setgroups command from util-linux
(sometimes called via /usr/bin/sg) which has flag --version that returns
non-zero, so we prefer `ast-grep` when both are on PATH and the `sg` we find
is the wrong one.
"""
for name in ("ast-grep", "sg"):
found = shutil.which(name)
if found:
p = Path(found)
# On Linux, double-check by trying --version. The util-linux `sg`
# rejects --version, while ast-grep prints "ast-grep <version>".
if name == "sg" and platform.system() == "Linux":
try:
out = subprocess.run(
[str(p), "--version"],
capture_output=True,
text=True,
timeout=5,
)
if out.returncode != 0 or "ast-grep" not in (out.stdout + out.stderr).lower():
continue
except Exception:
continue
return p
return None
def homebrew_binary() -> Optional[Path]:
"""Common Homebrew install paths."""
candidates = [
Path("/opt/homebrew/bin/ast-grep"),
Path("/opt/homebrew/bin/sg"),
Path("/usr/local/bin/ast-grep"),
Path("/usr/local/bin/sg"),
]
for p in candidates:
if p.is_file() and os.access(p, os.X_OK):
return p
return None
def resolve_binary() -> Optional[Path]:
"""Resolve the ast-grep binary in priority order.
1. Cached binary in <skill>/bin/
2. PATH (via shutil.which)
3. Homebrew default paths
"""
for fn in (cached_binary, which_binary, homebrew_binary):
result = fn()
if result:
return result
return None
def require_binary() -> Path:
"""Resolve binary, or print an actionable install hint and exit 3."""
p = resolve_binary()
if p:
return p
err("ast-grep binary not found.")
err("")
err("Install via one of:")
err(f" bash {skill_root()}/install.sh # POSIX (auto-detects best method)")
err(f" pwsh {skill_root()}/install.ps1 # Windows")
err("")
err("Or manually:")
err(" brew install ast-grep # macOS / linuxbrew")
err(" npm install -g @ast-grep/cli # any OS with Node")
err(" cargo install ast-grep --locked # any OS with Rust")
err(" pip install ast-grep-cli # any OS with Python")
err(" scoop install main/ast-grep # Windows / Scoop")
err("")
err("See references/install.md for the full table.")
sys.exit(3)
# ---------- pattern hint validation ----------
# Regex anti-patterns that ast-grep does NOT support but LLMs frequently emit.
# Each tuple: (regex_to_detect, hint_message)
REGEX_ANTIPATTERNS: list[tuple[re.Pattern[str], str]] = [
(re.compile(r"\\w|\\d|\\s|\\b"),
"Backslash escapes (\\w, \\d, \\s, \\b) are regex syntax, not ast-grep. "
"Use $VAR to capture any identifier, or switch to grep for text patterns."),
(re.compile(r"(?<!\$)\.\*|(?<!\$)\.\+"),
"'.*' and '.+' are regex wildcards, not ast-grep. "
"Use $$$ between AST fragments to match many nodes, or $VAR for one node."),
(re.compile(r"\[[a-zA-Z0-9-]+\]"),
"Character classes like '[a-z]' are regex syntax. "
"ast-grep has no AST equivalent - use grep for character-level patterns."),
]
def find_alternation(pattern: str) -> bool:
"""Detect a literal '|' that is not inside a string/template literal.
Heuristic - mark as alternation if `|` appears outside obvious string contexts.
"""
# Strip simple string contents to reduce false positives in patterns like
# `'a|b'` or `"x|y"`. This is a heuristic, not a parser.
stripped = re.sub(r"'[^']*'|\"[^\"]*\"|`[^`]*`", "", pattern)
# Require word chars on both sides to avoid catching bitwise or ||
return bool(re.search(r"\w\s*\|\s*\w", stripped)) and "||" not in stripped
def lang_specific_hints(pattern: str, lang: Optional[str]) -> list[str]:
"""Return a list of hints for language-specific common mistakes."""
if not lang:
return []
canonical = LANG_ALIASES.get(lang.lower(), lang.lower())
hints: list[str] = []
if canonical == "python":
# def foo($$$): <-- trailing colon breaks the parse
if re.search(r"^\s*(def|class)\s+\$?\w+[^:]*:\s*$", pattern, re.MULTILINE):
hints.append(
"Python pattern has trailing ':'. ast-grep parses pattern as a complete "
"definition - drop the trailing colon. Try: 'def $FUNC($$$)' or 'class $C($$$)'."
)
if canonical in ("javascript", "typescript", "tsx"):
if re.search(r"^\s*(async\s+)?function\s+\$?\w+\s*$", pattern):
hints.append(
"JS/TS function pattern is incomplete. Add params and body: "
"'function $NAME($$$) { $$$ }'."
)
if canonical == "go":
if re.search(r"^\s*func\s+\$?\w+\s*$", pattern):
hints.append(
"Go function pattern is incomplete. Add params and body: "
"'func $NAME($$$) { $$$ }'."
)
if canonical == "rust":
if re.search(r"^\s*fn\s+\$?\w+\s*$", pattern):
hints.append(
"Rust fn pattern is incomplete. Add params, return type, and body: "
"'fn $NAME($$$) -> $RET { $$$ }' (or '-> ()' if returning unit)."
)
return hints
def validate_pattern(pattern: str, lang: Optional[str]) -> list[str]:
"""Return a list of hints. Empty list = pattern looks plausible."""
hints: list[str] = []
for rx, msg in REGEX_ANTIPATTERNS:
if rx.search(pattern):
hints.append(msg)
if find_alternation(pattern):
hints.append(
"Literal '|' alternation is regex syntax, not ast-grep. "
"Run two separate ast-grep calls (one per alternative), or switch to grep."
)
hints.extend(lang_specific_hints(pattern, lang))
return hints
def normalize_lang(lang: Optional[str]) -> Optional[str]:
if not lang:
return None
canonical = LANG_ALIASES.get(lang.lower(), lang.lower())
if canonical not in LANGUAGES:
err(f"unknown language '{lang}'. Run 'ast_grep_helper.py langs' for the full list.")
sys.exit(1)
return canonical
# ---------- subprocess helpers ----------
def run_sg(
binary: Path,
args: list[str],
*,
timeout: int = DEFAULT_TIMEOUT_S,
capture: bool = True,
) -> subprocess.CompletedProcess[str]:
"""Spawn `sg <args>` with a hard timeout. Capture stdout/stderr by default."""
cmd = [str(binary), *args]
trace(f"exec: {' '.join(cmd)}")
try:
return subprocess.run(
cmd,
capture_output=capture,
text=True,
timeout=timeout,
)
except subprocess.TimeoutExpired:
err(f"ast-grep call timed out after {timeout}s")
sys.exit(5)
# ---------- subcommands ----------
def cmd_search(args: argparse.Namespace) -> int:
pattern: str = args.pattern
lang = normalize_lang(args.lang)
hints = validate_pattern(pattern, lang)
if hints:
err("pattern looks invalid for ast-grep:")
for h in hints:
err(f" - {h}")
if not args.force:
err("(pass --force to call ast-grep anyway)")
return 2
binary = require_binary()
sg_args = ["run", "-p", pattern, "--json=compact"]
if lang:
sg_args.extend(["--lang", lang])
if args.context:
sg_args.extend(["-C", str(args.context)])
for g in args.globs or []:
sg_args.extend(["--globs", g])
sg_args.extend(args.paths or ["."])
proc = run_sg(binary, sg_args)
if proc.returncode not in (0, 1): # 0=match, 1=no match - both fine
sys.stderr.write(proc.stderr or "")
return 4
matches = parse_compact_json(proc.stdout)
if args.json_out:
json.dump(matches, sys.stdout, indent=2)
print()
else:
format_matches(matches)
if not matches:
# Re-run pattern hints in case empty result was caused by something subtle.
# Already done above; here we just give a generic suggestion.
trace("no matches. If you expected matches, double-check --lang and the pattern shape.")
return 0
def cmd_replace(args: argparse.Namespace) -> int:
pattern: str = args.pattern
rewrite: str = args.rewrite
lang = normalize_lang(args.lang)
pattern_hints = validate_pattern(pattern, lang)
rewrite_hints = validate_pattern(rewrite, lang)
all_hints = []
if pattern_hints:
all_hints.append("pattern issues:")
all_hints.extend(f" - {h}" for h in pattern_hints)
if rewrite_hints:
all_hints.append("rewrite issues:")
all_hints.extend(f" - {h}" for h in rewrite_hints)
if all_hints:
err("input looks invalid for ast-grep:")
for line in all_hints:
err(line)
if not args.force:
err("(pass --force to call ast-grep anyway)")
return 2
binary = require_binary()
# Pass 1: dry-run via JSON to collect what would change.
sg_args1 = ["run", "-p", pattern, "-r", rewrite, "--json=compact"]
if lang:
sg_args1.extend(["--lang", lang])
for g in args.globs or []:
sg_args1.extend(["--globs", g])
sg_args1.extend(args.paths or ["."])
proc1 = run_sg(binary, sg_args1)
if proc1.returncode not in (0, 1):
sys.stderr.write(proc1.stderr or "")
return 4
matches = parse_compact_json(proc1.stdout)
if not matches:
trace("no matches; nothing to replace.")
return 0
if not args.apply:
# Show the dry-run preview and exit.
print(f"DRY-RUN: would rewrite {len(matches)} match(es) across "
f"{len({m['file'] for m in matches})} file(s):")
format_matches(matches, show_replacement=True)
print()
print("Re-run with --apply to mutate files.")
return 0
# Pass 2: apply with --update-all (no --json; sg silently ignores --update-all
# when --json is present, so we MUST run a second invocation).
sg_args2 = ["run", "-p", pattern, "-r", rewrite, "--update-all"]
if lang:
sg_args2.extend(["--lang", lang])
for g in args.globs or []:
sg_args2.extend(["--globs", g])
sg_args2.extend(args.paths or ["."])
proc2 = run_sg(binary, sg_args2)
if proc2.returncode not in (0, 1):
sys.stderr.write(proc2.stderr or "")
return 4
print(f"APPLIED: rewrote {len(matches)} match(es) across "
f"{len({m['file'] for m in matches})} file(s).")
return 0
def cmd_scan(args: argparse.Namespace) -> int:
binary = require_binary()
sg_args = ["scan"]
if args.config:
sg_args.extend(["-c", args.config])
if args.rule:
sg_args.extend(["-r", args.rule])
if args.inline_rules:
sg_args.extend(["--inline-rules", args.inline_rules])
if args.report_style:
sg_args.extend(["--report-style", args.report_style])
if args.apply:
sg_args.append("-U")
sg_args.extend(args.paths or [])
proc = run_sg(binary, sg_args, capture=False)
return proc.returncode
def cmd_test(args: argparse.Namespace) -> int:
binary = require_binary()
sg_args = ["test"]
if args.config:
sg_args.extend(["-c", args.config])
if args.test_dir:
sg_args.extend(["-t", args.test_dir])
if args.update:
sg_args.append("-U")
proc = run_sg(binary, sg_args, capture=False)
return proc.returncode
def cmd_new(args: argparse.Namespace) -> int:
binary = require_binary()
sg_args = ["new", args.what]
if args.name:
sg_args.append(args.name)
if args.lang:
sg_args.extend(["--lang", args.lang])
if args.yes:
sg_args.append("--yes")
proc = run_sg(binary, sg_args, capture=False)
return proc.returncode
def cmd_langs(_args: argparse.Namespace) -> int:
print("ast-grep supported languages (25):")
for lang, exts in sorted(LANGUAGES.items()):
print(f" {lang:<12} {' '.join(exts)}")
print()
print("Aliases accepted by --lang:")
for alias, canonical in sorted(LANG_ALIASES.items()):
print(f" {alias:<8} -> {canonical}")
return 0
def cmd_doctor(_args: argparse.Namespace) -> int:
print(f"ast-grep-helper v{VERSION}")
print(f"Python: {sys.version.split()[0]}")
print(f"Platform: {platform.system()} {platform.release()} ({platform.machine()})")
print(f"Skill: {skill_root()}")
print()
binary = resolve_binary()
if not binary:
print("ast-grep binary: NOT FOUND")
print(" -> run: bash install.sh (POSIX) or pwsh install.ps1 (Windows)")
return 1
print(f"ast-grep binary: {binary}")
proc = run_sg(binary, ["--version"], timeout=5)
if proc.returncode == 0:
print(f" version: {proc.stdout.strip()}")
else:
print(f" --version returned exit {proc.returncode}")
print(f" stderr: {proc.stderr.strip()}")
return 1
return 0
def cmd_install(_args: argparse.Namespace) -> int:
"""Delegate to install.sh / install.ps1 in the skill root."""
if os.name == "nt":
installer = skill_root() / "install.ps1"
cmd = ["pwsh", "-File", str(installer)]
else:
installer = skill_root() / "install.sh"
cmd = ["bash", str(installer)]
if not installer.is_file():
err(f"installer not found: {installer}")
return 1
trace(f"running installer: {' '.join(cmd)}")
return subprocess.run(cmd).returncode
def cmd_validate(args: argparse.Namespace) -> int:
"""Offline pattern validation. No sg call. Useful for CI / quick checks."""
lang = normalize_lang(args.lang) if args.lang else None
hints = validate_pattern(args.pattern, lang)
if hints:
for h in hints:
print(f"hint: {h}")
return 2
print("pattern looks plausible for ast-grep.")
return 0
# ---------- output formatting ----------
def parse_compact_json(text: str) -> list[dict]:
"""Parse `sg --json=compact` output. Salvages partial output when truncated."""
if not text.strip():
return []
try:
data = json.loads(text)
if isinstance(data, list):
return data
return []
except json.JSONDecodeError:
# Try line-by-line salvage for truncated output.
results = []
for line in text.splitlines():
line = line.strip().rstrip(",")
if not line.startswith("{"):
continue
try:
obj = json.loads(line)
if isinstance(obj, dict):
results.append(obj)
except json.JSONDecodeError:
continue
return results
def format_matches(matches: list[dict], *, show_replacement: bool = False) -> None:
if not matches:
print("(no matches)")
return
by_file: dict[str, list[dict]] = {}
for m in matches:
by_file.setdefault(m.get("file", "?"), []).append(m)
for path, items in sorted(by_file.items()):
print(f"{path} ({len(items)} match{'es' if len(items) != 1 else ''})")
for m in items:
r = m.get("range", {})
start = r.get("start", {})
line = start.get("line", "?")
col = start.get("column", "?")
text = (m.get("text") or "").splitlines()
preview = text[0] if text else ""
print(f" {path}:{line}:{col} {preview}")
if show_replacement and "replacement" in m:
rep = (m.get("replacement") or "").splitlines()
rep_preview = rep[0] if rep else ""
print(f" -> {rep_preview}")
# ---------- argparse ----------
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
prog="ast-grep-helper",
description="LLM-friendly wrapper around ast-grep (sg).",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
p.add_argument("--version", action="version", version=f"ast-grep-helper {VERSION}")
p.add_argument("--quiet", "-q", action="store_true", help="Suppress trace lines on stderr.")
sub = p.add_subparsers(dest="cmd", required=True, metavar="COMMAND")
s = sub.add_parser("search", help="Search code by AST pattern.")
s.add_argument("pattern", help="AST pattern, e.g. 'console.log($MSG)'")
s.add_argument("paths", nargs="*", help="Paths to search (default: '.')")
s.add_argument("--lang", "-l", help="Language (e.g. ts, py, go, rust). See: langs subcommand.")
s.add_argument("--globs", action="append", help="Include/exclude glob (repeat; prefix '!' to exclude).")
s.add_argument("--context", "-C", type=int, help="Lines of context around each match.")
s.add_argument("--json-out", action="store_true", help="Emit raw JSON instead of human format.")
s.add_argument("--force", action="store_true", help="Skip pattern hint validation.")
s.set_defaults(func=cmd_search)
r = sub.add_parser("replace", help="Rewrite code by AST pattern (dry-run by default).")
r.add_argument("pattern", help="AST pattern.")
r.add_argument("rewrite", help="Replacement pattern (can reuse $VAR from pattern).")
r.add_argument("paths", nargs="*", help="Paths (default: '.')")
r.add_argument("--lang", "-l", help="Language.")
r.add_argument("--globs", action="append", help="Include/exclude glob.")
r.add_argument("--apply", action="store_true", help="Mutate files (default: dry-run preview).")
r.add_argument("--force", action="store_true", help="Skip pattern hint validation.")
r.set_defaults(func=cmd_replace)
sc = sub.add_parser("scan", help="Run YAML-rule-based scan.")
sc.add_argument("paths", nargs="*", help="Paths to scan.")
sc.add_argument("--config", "-c", help="Path to sgconfig.yml.")
sc.add_argument("--rule", "-r", help="Single rule file.")
sc.add_argument("--inline-rules", help="Inline YAML rule string.")
sc.add_argument("--report-style", choices=["rich", "medium", "short"], help="Report style.")
sc.add_argument("--apply", "-U", action="store_true", help="Apply fixes (default: report only).")
sc.set_defaults(func=cmd_scan)
t = sub.add_parser("test", help="Run ast-grep snapshot tests.")
t.add_argument("--config", "-c", help="Path to sgconfig.yml.")
t.add_argument("--test-dir", "-t", help="Test directory.")
t.add_argument("--update", "-U", action="store_true", help="Update snapshots.")
t.set_defaults(func=cmd_test)
n = sub.add_parser("new", help="Scaffold a new project / rule / test / util.")
n.add_argument("what", choices=["project", "rule", "test", "util"], help="What to create.")
n.add_argument("name", nargs="?", help="Name of the artifact.")
n.add_argument("--lang", "-l", help="Language.")
n.add_argument("--yes", "-y", action="store_true", help="Accept defaults.")
n.set_defaults(func=cmd_new)
sub.add_parser("langs", help="List supported languages.").set_defaults(func=cmd_langs)
sub.add_parser("doctor", help="Check ast-grep binary availability.").set_defaults(func=cmd_doctor)
sub.add_parser("install", help="Run the install script for this OS.").set_defaults(func=cmd_install)
v = sub.add_parser("validate", help="Validate a pattern offline (pattern hint check only).")
v.add_argument("pattern", help="AST pattern.")
v.add_argument("--lang", "-l", help="Language for language-specific hints.")
v.set_defaults(func=cmd_validate)
return p
def main(argv: Optional[list[str]] = None) -> int:
global _QUIET
parser = build_parser()
args = parser.parse_args(argv)
_QUIET = bool(getattr(args, "quiet", False))
return args.func(args)
if __name__ == "__main__":
sys.exit(main())
#Requires -Version 5.1
# Smoke test for the ast-grep skill on Windows (PowerShell 5.1+).
$ErrorActionPreference = 'Stop'
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$SkillDir = Split-Path -Parent $ScriptDir
$Helper = Join-Path $SkillDir 'scripts/ast_grep_helper.py'
$Python = if (Get-Command py -ErrorAction SilentlyContinue) { 'py' } else { 'python' }
$Output = Join-Path $env:TEMP ("ast-grep-skill-smoke-" + [guid]::NewGuid().ToString('N').Substring(0,8))
New-Item -ItemType Directory -Path $Output -Force | Out-Null
function Pass([string]$msg) { Write-Host "PASS: $msg" }
function Fail([string]$msg) { Write-Host "FAIL: $msg" -ForegroundColor Red; Remove-Item -Recurse -Force $Output -ErrorAction SilentlyContinue; exit 1 }
function Run([string[]]$Args) {
$stdoutFile = Join-Path $Output ("out-" + [guid]::NewGuid().ToString('N').Substring(0,8) + ".txt")
$proc = Start-Process -FilePath $Python -ArgumentList (@($Helper) + $Args) -NoNewWindow -PassThru -Wait -RedirectStandardOutput $stdoutFile -RedirectStandardError "$stdoutFile.err"
$stdout = if (Test-Path $stdoutFile) { Get-Content $stdoutFile -Raw } else { '' }
$stderr = if (Test-Path "$stdoutFile.err") { Get-Content "$stdoutFile.err" -Raw } else { '' }
return [pscustomobject]@{
ExitCode = $proc.ExitCode
Stdout = $stdout
Stderr = $stderr
Combined = "$stdout`n$stderr"
}
}
try {
# 1. --version
$r = Run @('--version')
if ($r.Combined -notmatch 'ast-grep-helper') { Fail '--version output missing' }
Pass '--version'
# 2. langs (must list >=25)
$r = Run @('langs')
$langCount = ($r.Stdout -split "`n" | Where-Object { $_ -match '^ [a-z]' }).Count
if ($langCount -lt 25) { Fail "langs listed only $langCount (expected >=25)" }
Pass 'langs lists at least 25 languages'
# 3. regex misuse: \w+
$r = Run @('validate', '\w+', '--lang', 'ts')
if ($r.ExitCode -ne 2) { Fail "validate '\w+' should exit 2, got $($r.ExitCode)" }
if ($r.Combined -notmatch 'regex') { Fail "validate '\w+' should mention regex" }
Pass 'validate detects \w regex misuse'
# 4. valid pattern
$r = Run @('validate', 'console.log($MSG)', '--lang', 'ts')
if ($r.ExitCode -ne 0) { Fail "validate 'console.log(`$MSG)' should exit 0, got $($r.ExitCode)" }
Pass 'validate accepts plausible pattern'
# 5. Python trailing colon
$r = Run @('validate', 'def $F($$$):', '--lang', 'py')
if ($r.ExitCode -ne 2) { Fail "validate 'def `$F(`$`$`$):' should exit 2, got $($r.ExitCode)" }
if ($r.Combined -notmatch 'colon|trailing') { Fail 'validate should mention trailing colon' }
Pass 'validate detects Python trailing colon'
# 6. Incomplete TS function
$r = Run @('validate', 'function $N', '--lang', 'ts')
if ($r.ExitCode -ne 2) { Fail "validate 'function `$N' should exit 2, got $($r.ExitCode)" }
if ($r.Combined -notmatch 'incomplete|params|body') { Fail 'validate should hint about params/body' }
Pass 'validate detects incomplete TS function'
# 7. Alternation pipe
$r = Run @('validate', 'foo|bar', '--lang', 'ts')
if ($r.ExitCode -ne 2) { Fail "validate 'foo|bar' should exit 2, got $($r.ExitCode)" }
if ($r.Combined -notmatch 'alternation|regex') { Fail 'validate should mention alternation' }
Pass 'validate detects literal | alternation'
# 8. doctor
$r = Run @('doctor')
if ($r.Combined -notmatch 'ast-grep-helper') { Fail 'doctor missing helper version line' }
Pass 'doctor produces output'
# 9. search w/o binary
$r = Run @('-q', 'search', 'foo()', '--lang', 'ts', 'C:/nonexistent-path-xyzzy')
switch ($r.ExitCode) {
{ $_ -in 0,1,4 } { Pass "search runs (rc=$($r.ExitCode), ast-grep available)" }
3 {
if ($r.Combined -notmatch 'install') { Fail 'search rc=3 should print install hint' }
Pass 'search without binary prints install hint'
}
default { Fail "search returned unexpected rc=$($r.ExitCode): $($r.Combined)" }
}
# 10. install.ps1 syntax (parse-check via PowerShell tokenizer)
$tokens = $null
$errors = $null
[System.Management.Automation.Language.Parser]::ParseFile(
(Join-Path $SkillDir 'install.ps1'), [ref]$tokens, [ref]$errors) | Out-Null
if ($errors -and $errors.Count -gt 0) { Fail "install.ps1 has parse errors: $($errors -join '; ')" }
Pass 'install.ps1 parses cleanly'
# 11. SKILL.md frontmatter
$skill = Get-Content (Join-Path $SkillDir 'SKILL.md') -Raw
if (-not $skill.StartsWith("---`n") -and -not $skill.StartsWith("---`r`n")) {
Fail 'SKILL.md must start with YAML frontmatter'
}
$endIdx = $skill.IndexOf("`n---`n", 4)
if ($endIdx -lt 0) { $endIdx = $skill.IndexOf("`r`n---`r`n", 4) }
if ($endIdx -lt 0) { Fail 'SKILL.md missing closing ---' }
$fm = $skill.Substring(4, $endIdx - 4)
if ($fm -notmatch '(?m)^name:\s*ast-grep\s*$') { Fail 'frontmatter missing name: ast-grep' }
if ($fm -notmatch '(?m)^description:') { Fail 'frontmatter missing description' }
Pass 'SKILL.md frontmatter shape'
# 12. All required reference files exist
$required = @(
'references/install.md', 'references/patterns.md', 'references/pitfalls.md',
'references/recipes.md', 'references/cli.md', 'references/yaml-rules.md',
'references/sgconfig.md'
)
foreach ($f in $required) {
if (-not (Test-Path (Join-Path $SkillDir $f))) { Fail "missing $f" }
}
Pass 'all references present'
Write-Host ''
Write-Host 'all smoke tests passed'
}
finally {
Remove-Item -Recurse -Force $Output -ErrorAction SilentlyContinue
}