Airflow DAGs skill for Claude Code
Airflow 3 DAGs that parse fast and rerun cleanly. Gives Claude the Airflow 3 rules it tends to miss: airflow.sdk imports, removed SLAs and execution_date, and the cron default that makes data_interval_start equal data_interval_end. It writes tasks that read their window from the run, keeps DAG files free of parse-time I/O, makes waits deferrable with explicit timeouts, and ships a stdlib checker that flags Airflow 2 leftovers before they reach the scheduler.
- name
airflow-dags - size318 lines · ≈3,700 tokens
- files2
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Writes, reviews and upgrades Apache Airflow 3.x DAGs - TaskFlow API with airflow.sdk imports, timetables and the CronTriggerTimetable data-interval trap, asset-aware scheduling, idempotent tasks, retries, execution timeouts and Deadline Alerts, deferrable operators and sensors, dynamic task mapping, XCom size limits, pools, backfills, parse-time hygiene and DAG tests. Use when writing or debugging a DAG file, migrating from Airflow 2, a DAG is slow to parse or missing in the UI, sensors hog worker slots, or choosing between Airflow, Dagster and Prefect. Try asking
- Migrate our dags/ folder from Airflow 2 to Airflow 3
- This DAG takes 40 seconds to parse and sometimes disappears from the UI, fix it
- Write an Airflow DAG that loads yesterday's orders from S3 into Snowflake and waits for the file
Use it when
- Writing or reviewing an Airflow DAG file
- Upgrading DAGs from Airflow 2.x to 3.x
- Sensors or long waits are consuming worker slots
- A DAG is slow to parse, missing in the UI, or reruns create duplicates
- Planning a backfill over a date range
Skip it when
- You are designing a pipeline independent of the orchestrator, or using Dagster or Prefect - use data-pipelines
- You only need one script on a cron schedule - use scheduled-jobs
How to check the result
scripts/check_dag_files.py reports no errors, the DagBag integrity test passes, and airflow dags test for one logical date produces the same row counts when run twice.
The common mistake it prevents
Keeping a bare cron schedule after upgrading to Airflow 3: it becomes a trigger timetable where data_interval_start equals data_interval_end, so tasks that query the interval process an empty window.
A DAG is done when the file parses in well under a second with no I/O, every task
reads its window from the run (never the wall clock) and can be rerun without
duplicates, waiting happens in the triggerer instead of a worker slot, every task and
run has a timeout, and an integrity test plus one dags test run pass in CI.
First, identify the setup
airflow version # 3.x? which minor?
pip list 2>/dev/null | grep -E "apache-airflow(-providers-[a-z-]+)? "
airflow config get-value core executor
airflow config get-value scheduler create_cron_data_intervals
airflow dags list-import-errors
Managed services (MWAA, Cloud Composer, Astronomer) pin their own Airflow and provider versions; check the environment’s version before using a 3.x-only feature.
Airflow 3 facts that change how you write DAGs
- Author with
airflow.sdk:dag,task,DAG,Asset,AssetAlias,Variable,Param,TaskGroup,get_current_context. Core operators and sensors moved toairflow.providers.standard(for exampleairflow.providers.standard.operators.python). Old paths warn in 3.1 and will be removed. - Task code runs through the Task SDK and cannot open the metadata database (no
settings.Session, no ORM models in tasks). Use the REST API client instead. - Removed:
schedule_interval(useschedule), SLAs (sla=,sla_miss_callback; use Deadline Alerts, 3.1+), SubDAGs (use TaskGroups or assets), and context keysexecution_date,next_ds,prev_ds,yesterday_ds,tomorrow_dsand their variants. Uselogical_dateanddata_interval_start/data_interval_end. catchupdefaults toFalse.- Bare cron strings and presets now resolve to
CronTriggerTimetablebecause[scheduler] create_cron_data_intervalsdefaults toFalse. For those runsdata_interval_start == data_interval_end. Code that computed “yesterday” from the interval silently processes a zero-length window. See Scheduling. xcom_pull()withouttask_idsreads only the current task.- Datasets are Assets;
@assetdefines a one-task DAG producing an asset. - Airflow 3.2 added asset partitioning (
CronPartitionTimetable,PartitionedAssetTimetable,dag_run.partition_key); check your minor version before relying on it.
Migrating a 2.x DAG folder: ruff check dags/ --select AIR301,AIR302 (breaking) and
AIR311,AIR312 (recommended), ruff 0.13.1 or newer; add --fix after reviewing.
Workflow
- [ ] 1. Define the run unit: what one DAG run processes (a day, an hour, a partition)
- [ ] 2. Pick the timetable or asset trigger that matches that unit
- [ ] 3. Write tasks as idempotent functions of the run's window
- [ ] 4. Set retries, execution_timeout, dagrun_timeout, max_active_runs, pools
- [ ] 5. Make waits deferrable; give every sensor a timeout
- [ ] 6. Keep the file parse-cheap; run scripts/check_dag_files.py
- [ ] 7. Integrity test + `airflow dags test` for one logical date
- [ ] 8. Backfill a small range, verify, then the full range (user confirms range)
Scheduling
| Need | Schedule |
|---|---|
| Process the previous day/hour as a window | CronDataIntervalTimetable("0 2 * * *", timezone="UTC") (interval = previous cron period) |
| Fire at a time, window not needed (reports, pings) | Cron string or CronTriggerTimetable(...) |
| Fire at a time but process a fixed lookback | CronTriggerTimetable("0 2 * * *", timezone="UTC", interval=timedelta(days=1)) |
| Fixed spacing regardless of clock | DeltaTriggerTimetable(timedelta(hours=6)) |
| Run when upstream data lands | schedule=[orders_asset], or (a & b), `(a |
| Both asset updates and a clock | AssetOrTimeSchedule(...) |
| Only manual or API triggered | schedule=None |
- Pass a timetable object rather than relying on the
create_cron_data_intervalssetting, so a config change cannot alterlogical_datesemantics. - Switching an existing DAG from a trigger to a data-interval timetable skips one run.
For big schedule changes, create a new
dag_id. - Asset events are emitted only when the producing task succeeds. Cross-DAG timing by guessing (“run at 03:00, upstream usually finishes by 02:45”) is a bug; use assets.
- Schedules,
start_date, and intervals in UTC. Use a fixedpendulum.datetime(2026, 1, 1, tz="UTC"), neverdatetime.now()ordays_ago().
A reference DAG
from datetime import timedelta
import pendulum
from airflow.sdk import Asset, CronDataIntervalTimetable, dag, task
raw_orders = Asset("s3://lake/raw/orders")
@dag(
schedule=CronDataIntervalTimetable("0 2 * * *", timezone="UTC"),
start_date=pendulum.datetime(2026, 1, 1, tz="UTC"),
catchup=False,
max_active_runs=1,
dagrun_timeout=timedelta(hours=3),
default_args={
"owner": "data-eng",
"retries": 3,
"retry_delay": timedelta(minutes=5),
"retry_exponential_backoff": True,
"execution_timeout": timedelta(minutes=45),
},
tags=["orders", "tier-1"],
)
def orders_daily():
@task(outlets=[raw_orders])
def extract(data_interval_start=None, data_interval_end=None) -> str:
from my_pkg.orders import export_orders # heavy imports inside the task
day = data_interval_start.strftime("%Y-%m-%d")
path = f"s3://lake/raw/orders/dt={day}/part.parquet"
export_orders(start=data_interval_start, end=data_interval_end, dest=path) # overwrite
return path # small reference, not data
@task(pool="warehouse", retries=1)
def load(path: str, data_interval_start=None) -> int:
from my_pkg.warehouse import replace_partition
return replace_partition("raw.orders", path, dt=data_interval_start.date())
@task(retries=0)
def validate(rows: int) -> None:
if rows == 0:
raise ValueError("0 rows loaded") # fail fast; retrying will not help
validate(load(extract()))
orders_daily()
Idempotency patterns (overwrite partition, MERGE on key, delete+insert in one
transaction) are covered tool-agnostically in data-pipelines; in Airflow the key is that
every task derives its window from data_interval_* or logical_date.
Parse-time hygiene
The DAG processor re-executes every file’s top-level code continuously.
- No network, database,
Variable.get(), or cloud SDK calls at module level. Read Variables inside tasks or via templates:"{{ var.value.bucket }}". - Import pandas, Spark clients, and SDKs inside the task function.
- Measure:
time python dags/orders_daily.pyminustime python -c ''. Aim for well under 1 s per file;[core] dagbag_import_timeout(default 30 s) kills slow files and the DAG disappears from the UI. - Dynamic DAG generation from a config file in the repo is fine; generating from an API call at parse time is not.
- Run the bundled checker on every change:
python3 scripts/check_dag_files.py dags/ # errors exit 1
python3 scripts/check_dag_files.py dags/ --strict # warnings also fail (CI)
It flags removed Airflow 2 parameters and context keys, dynamic start_date, legacy
imports, top-level I/O, wall-clock calls inside tasks, sensors without timeout, and
xcom_pull() without task_ids. It parses with ast and never imports the DAG.
Data passing and fan-out
- XCom (including TaskFlow return values) is for small metadata: paths, IDs, row counts.
Large values bloat the metadata DB. For bigger payloads write to object storage and
pass the URI, or configure the object storage XCom backend:
[core] xcom_backend = airflow.providers.common.io.xcom.backend.XComObjectStorageBackendwith[common.io] xcom_objectstorage_pathandxcom_objectstorage_threshold(bytes; smaller values stay in the DB). - Dynamic task mapping:
process.expand(path=list_files()), constants via.partial(...), several args via.expand_kwargs(...).[core] max_map_length(default 1024) caps the fan-out; an empty input marks the mapped task skipped. Limit parallelism withmax_active_tis_per_dagon the mapped task, and setmap_index_templateso UI rows are readable. - Group related tasks with
@task_group; do not nest DAGs.
Retries, timeouts, and deadlines
| Setting | Rule |
|---|---|
retries + retry_exponential_backoff | 2-3 for tasks hitting networks or warehouses; retries=0 for validation and non-idempotent side effects |
execution_timeout | On every task (via default_args); a hung task otherwise holds a slot forever |
dagrun_timeout | Upper bound for the whole run, so a stuck run cannot block max_active_runs=1 |
Sensor timeout | Always explicit; the default is 7 days |
on_failure_callback | Alert after retries are exhausted, with DAG, task, logical date, and log URL |
| Deadline Alerts (3.1+) | Replace SLAs: alert when a run is not done N minutes after it was queued or scheduled |
from datetime import timedelta
from airflow.sdk import DAG, AsyncCallback, DeadlineAlert, DeadlineReference
from airflow.providers.slack.notifications.slack_webhook import SlackWebhookNotifier
with DAG(
dag_id="orders_daily",
deadline=DeadlineAlert(
reference=DeadlineReference.DAGRUN_QUEUED_AT, # or DAGRUN_LOGICAL_DATE, FIXED_DATETIME(...)
interval=timedelta(minutes=90),
callback=AsyncCallback(SlackWebhookNotifier,
kwargs={"text": "{{ dag_run.dag_id }} missed its deadline"}),
),
...
):
...
AsyncCallback runs in the triggerer (restart it when callback code changes);
SyncCallback (3.2+) runs on an executor.
Waiting: sensors and deferrable operators
| Wait type | Use |
|---|---|
| External job, file, or API state for minutes to hours | Deferrable operator or sensor (deferrable=True): frees the worker slot, waits in the triggerer |
| Short check (seconds) | Plain operator with retries |
| Sensor without a deferrable variant | mode="reschedule" with poke_interval of minutes, plus timeout |
| Upstream DAG’s data | Asset schedule, not an ExternalTaskSensor |
- Deferrable requires at least one running
airflow triggerer. Without it, deferred tasks sit in the deferred state forever. [operators] default_deferrable = Trueflips the default for providers that support it.- Custom triggers: subclass
BaseTrigger, implementserialize()and an asyncrun()that yieldsTriggerEvent; no blocking calls and no side effects, because a trigger can run more than once.
Concurrency
max_active_runs=1when runs share mutable state or must apply in order.- Pools (
pool="warehouse",pool_slots=) protect shared systems across DAGs; size the pool to what the downstream system tolerates. max_active_taskscaps running task instances across all active runs of the DAG;priority_weightorders queued tasks when slots are scarce.
Backfills
airflow backfill create --dag-id orders_daily \
--from-date 2026-09-01 --to-date 2026-09-07 \
--reprocess-behavior failed --max-active-runs 2
--reprocess-behavior:none(only missing runs),failed(missing and failed),completed(rerun everything in range). Use the dry-run option first to list dates.- Ask the user to confirm the exact range and reprocess behavior before any backfill that rewrites existing partitions in production.
- Procedure: one date, verify output and row counts, a small range, check warehouse load
and cost, then the full range with a low
--max-active-runs. - Confirm which code version the backfill runs (DAG versioning records the bundle version per run) and that old dates should be processed with that logic.
Testing
# tests/test_dag_integrity.py
import pytest
from airflow.dag_processing.dagbag import DagBag
@pytest.fixture(scope="session")
def dagbag():
return DagBag(dag_folder="dags", include_examples=False)
def test_no_import_errors(dagbag):
assert dagbag.import_errors == {}
def test_dag_conventions(dagbag):
for dag_id, dag in dagbag.dags.items():
assert dag.tags, f"{dag_id}: add tags"
for t in dag.tasks:
assert t.owner != "airflow", f"{dag_id}.{t.task_id}: set a real owner"
assert t.execution_timeout is not None, f"{dag_id}.{t.task_id}: no execution_timeout"
airflow dags test orders_daily 2026-10-18 # full run in one process, real connections
airflow tasks test orders_daily load 2026-10-18 # one task, no state recorded
pytest tests/ # integrity + unit tests of task logic
Unit-test the business logic as plain functions (pass the window explicitly); the DAG
should be thin wiring. dag.test() from __main__ gives the same full run inside an
IDE debugger.
When not to use Airflow
| Situation | Better fit |
|---|---|
| Asset/partition-first modeling, strong local testing, lineage as the core UI | Dagster |
| Python-native dynamic flows, little infrastructure, event-driven runs | Prefect |
| One script on a schedule with no dependencies | A scheduler (cron, cloud scheduler); see scheduled-jobs |
| Continuous event processing | A streaming system; see kafka-streaming |
| Only dbt models on a schedule | dbt’s own scheduler or a minimal job runner |
Stay on Airflow when the team already runs it and the need is covered; migration cost is real. Tool-agnostic pipeline design is in data-pipelines.
Gotchas
- Mixing
airflow.decoratorsandairflow.sdkimports in one repo hides which DAGs are migrated; move a folder at a time and enforce with ruff AIR rules. {{ ds }}is the logical date. WithCronDataIntervalTimetablethat is the interval start; with trigger timetables it is the trigger time. Prefer explicitdata_interval_startin code.start_datein the future means no runs and no error.- Retrying a task that appends rows or sends emails multiplies the side effect.
trigger_rule="all_done"cleanup tasks still need their own timeout.- Top-level
Variable.get()turns every parse into a metadata or secrets-backend call. - Changing
dag_idcreates a new DAG with no history; the old one stays until deleted.
Output
**DAG:** orders_daily (Airflow 3.3), CronDataIntervalTimetable 02:00 UTC, window = previous day
**Tasks:** extract (overwrite S3 partition) -> load (pool=warehouse, replace partition) -> validate (retries=0)
**Reliability:** retries 3 exp backoff, execution_timeout 45m, dagrun_timeout 3h, DeadlineAlert 90m after queue
**Waits:** S3 sensor deferrable=True, timeout 2h
**Checks:** check_dag_files.py clean (--strict); DagBag test green; parse 0.21 s
**Verified:** `airflow dags test orders_daily 2026-10-18` loaded 48,211 rows; rerun left count unchanged Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
scripts/check_dag_files.py
#!/usr/bin/env python3
"""Static checks for Airflow DAG files (Airflow 3.x rules). Standard library only.
Usage:
python3 scripts/check_dag_files.py <dags_dir_or_file> [...] [--strict]
Parses each .py file with `ast` (never imports or executes it) and reports:
ERROR AF001 schedule_interval= (removed in Airflow 3; use schedule=)
ERROR AF002 sla= / sla_miss_callback= (SLAs removed in Airflow 3; use Deadline Alerts)
ERROR AF003 removed context variable (execution_date, next_ds, prev_ds, ...)
ERROR AF004 dynamic start_date (datetime.now(), utcnow(), pendulum.now(), days_ago())
WARN AF101 legacy import path (airflow.decorators, airflow.models.dag, airflow.datasets,
airflow.operators.*, airflow.sensors.*) - move to airflow.sdk / providers.standard
WARN AF102 top-level I/O call that runs on every DAG parse (Variable.get, HTTP, DB, boto3 ...)
WARN AF103 wall-clock time inside a task (datetime.now()/utcnow()/pendulum.now()/today())
WARN AF104 sensor without an explicit timeout= (default is 7 days)
WARN AF105 xcom_pull() without task_ids (Airflow 3 pulls only from the current task)
Exit codes: 0 = clean (warnings allowed unless --strict), 1 = findings, 2 = bad input.
"""
from __future__ import annotations
import ast
import re
import sys
from pathlib import Path
REMOVED_CONTEXT = {
"execution_date", "next_execution_date", "prev_execution_date",
"prev_execution_date_success", "next_ds", "next_ds_nodash", "prev_ds",
"prev_ds_nodash", "yesterday_ds", "yesterday_ds_nodash", "tomorrow_ds",
"tomorrow_ds_nodash",
}
CONTEXT_RE = re.compile(r"\{\{[^}]*\b(" + "|".join(sorted(REMOVED_CONTEXT)) + r")\b[^}]*\}\}")
LEGACY_IMPORTS = (
"airflow.decorators", "airflow.models.dag", "airflow.datasets",
"airflow.operators.", "airflow.sensors.", "airflow.models.baseoperator",
)
LEGACY_EXACT = {"airflow.operators", "airflow.sensors"}
TOP_LEVEL_IO = {
("Variable", "get"), ("requests", "get"), ("requests", "post"), ("requests", "request"),
("boto3", "client"), ("boto3", "resource"), ("psycopg", "connect"), ("psycopg2", "connect"),
("sqlite3", "connect"), ("pd", "read_sql"), ("pd", "read_csv"), ("pandas", "read_sql"),
("urllib.request", "urlopen"), ("httpx", "get"), ("httpx", "post"),
}
TOP_LEVEL_IO_NAMES = {"urlopen", "create_engine", "BaseHook.get_connection"}
NOW_ATTRS = {"now", "utcnow", "today"}
def dotted(node: ast.AST) -> str:
parts = []
while isinstance(node, ast.Attribute):
parts.append(node.attr)
node = node.value
if isinstance(node, ast.Name):
parts.append(node.id)
return ".".join(reversed(parts))
def is_now_call(node: ast.AST) -> bool:
if not isinstance(node, ast.Call):
return False
name = dotted(node.func)
if name.endswith("days_ago"):
return True
last = name.rsplit(".", 1)[-1]
head = name.rsplit(".", 1)[0] if "." in name else ""
return last in NOW_ATTRS and head.split(".")[-1] in {"datetime", "pendulum", "date", "timezone"}
class Checker(ast.NodeVisitor):
def __init__(self, path: Path):
self.path = path
self.findings: list[tuple[int, str, str, str]] = []
self.func_depth = 0
def add(self, node: ast.AST, level: str, code: str, msg: str) -> None:
self.findings.append((getattr(node, "lineno", 0), level, code, msg))
# imports
def visit_Import(self, node: ast.Import) -> None:
for alias in node.names:
self._check_module(node, alias.name)
self.generic_visit(node)
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
if node.module:
self._check_module(node, node.module)
self.generic_visit(node)
def _check_module(self, node: ast.AST, module: str) -> None:
if module in LEGACY_EXACT or module.startswith(LEGACY_IMPORTS):
self.add(node, "WARN", "AF101",
f"legacy import '{module}': use airflow.sdk or airflow.providers.standard.*")
# functions (task bodies)
def _visit_func(self, node):
for arg in node.args.args + node.args.kwonlyargs:
if arg.arg in REMOVED_CONTEXT:
self.add(arg, "ERROR", "AF003",
f"parameter '{arg.arg}' is a removed context variable; use logical_date or data_interval_start/end")
# decorators and defaults are evaluated when the module is parsed, not in the task
for dec in node.decorator_list:
self.visit(dec)
for default in node.args.defaults + [d for d in node.args.kw_defaults if d is not None]:
self.visit(default)
self.func_depth += 1
for stmt in node.body:
self.visit(stmt)
self.func_depth -= 1
visit_FunctionDef = _visit_func
visit_AsyncFunctionDef = _visit_func
def visit_Lambda(self, node: ast.Lambda) -> None:
self.func_depth += 1
self.generic_visit(node)
self.func_depth -= 1
def visit_Constant(self, node: ast.Constant) -> None:
if isinstance(node.value, str):
m = CONTEXT_RE.search(node.value)
if m:
self.add(node, "ERROR", "AF003",
f"template uses removed context variable '{m.group(1)}'")
self.generic_visit(node)
def visit_Subscript(self, node: ast.Subscript) -> None:
sl = node.slice
if isinstance(sl, ast.Constant) and sl.value in REMOVED_CONTEXT and dotted(node.value).endswith("context"):
self.add(node, "ERROR", "AF003", f"context['{sl.value}'] was removed in Airflow 3")
self.generic_visit(node)
def visit_Call(self, node: ast.Call) -> None:
name = dotted(node.func)
short = name.rsplit(".", 1)[-1]
kwargs = {k.arg: k.value for k in node.keywords if k.arg}
if "schedule_interval" in kwargs:
self.add(node, "ERROR", "AF001", "schedule_interval= was removed in Airflow 3; use schedule=")
for key in ("sla", "sla_miss_callback"):
if key in kwargs:
self.add(node, "ERROR", "AF002", f"{key}= was removed in Airflow 3; use a DeadlineAlert on the DAG")
if "start_date" in kwargs and is_now_call(kwargs["start_date"]):
self.add(node, "ERROR", "AF004", "start_date must be a fixed datetime, not now()/days_ago()")
if isinstance(kwargs.get("default_args"), ast.Dict):
d = kwargs["default_args"]
for k, v in zip(d.keys, d.values):
if isinstance(k, ast.Constant) and k.value == "start_date" and is_now_call(v):
self.add(node, "ERROR", "AF004", "default_args start_date must be fixed, not now()")
if isinstance(k, ast.Constant) and k.value in ("sla", "sla_miss_callback"):
self.add(node, "ERROR", "AF002", f"default_args['{k.value}'] was removed in Airflow 3")
if self.func_depth == 0:
parts = name.rsplit(".", 1)
pair = (parts[0], parts[1]) if len(parts) == 2 else ("", name)
if pair in TOP_LEVEL_IO or name in TOP_LEVEL_IO_NAMES or short in {"urlopen", "create_engine"}:
self.add(node, "WARN", "AF102",
f"top-level call {name}() runs on every DAG parse; move it into a task or use a Jinja template")
elif is_now_call(node):
self.add(node, "WARN", "AF103",
f"{name}() inside a task breaks reruns/backfills; use data_interval_start/end or logical_date")
if short.endswith("Sensor") and short[0].isupper() and "timeout" not in kwargs:
self.add(node, "WARN", "AF104", f"{short} has no timeout= (default 7 days); set one and prefer deferrable=True")
if short == "xcom_pull" and "task_ids" not in kwargs and not node.args:
self.add(node, "WARN", "AF105", "xcom_pull() without task_ids reads only the current task in Airflow 3")
self.generic_visit(node)
def iter_files(args: list[str]):
for a in args:
p = Path(a)
if p.is_dir():
yield from sorted(x for x in p.rglob("*.py") if "__pycache__" not in x.parts)
elif p.is_file():
yield p
else:
print(f"error: {a} not found", file=sys.stderr)
sys.exit(2)
def main(argv: list[str]) -> int:
strict = "--strict" in argv
paths = [a for a in argv if a != "--strict"]
if not paths or any(a in ("-h", "--help") for a in paths):
print(__doc__)
return 2
errors = warns = files = 0
for path in iter_files(paths):
files += 1
try:
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
except SyntaxError as exc:
print(f"{path}:{exc.lineno}: ERROR AF000 syntax error: {exc.msg}")
errors += 1
continue
checker = Checker(path)
checker.visit(tree)
for line, level, code, msg in sorted(checker.findings):
print(f"{path}:{line}: {level} {code} {msg}")
if level == "ERROR":
errors += 1
else:
warns += 1
print(f"\n{files} file(s) checked: {errors} error(s), {warns} warning(s)")
if errors or (strict and warns):
return 1
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.