ai features

Lintro includes optional AI-powered features that provide actionable insights and interactive fix suggestions on top of standard linting results. > **Requirements:** Python package extra + an API key.

AI-Powered Features

Lintro includes optional AI-powered features that provide actionable insights and interactive fix suggestions on top of standard linting results.

Requirements: Python package extra + an API key.

pip install -e '.[ai]'
export ANTHROPIC_API_KEY=sk-ant-...   # or OPENAI_API_KEY for OpenAI

Quick Start

# Enable AI in your config
# .lintro-config.yaml
# ai:
#   enabled: true
#   provider: anthropic

# Run check — AI summary is generated automatically (1 API call)
lintro check

# Add interactive fix suggestions
lintro check --fix

# Auto-fix with AI post-fix summary
lintro format

Features Overview

AI Summary (default with check / chk)

When AI is enabled, every lintro check run generates a single-call AI summary that provides:

  • Overview — high-level assessment of code quality
  • Key patterns — systemic issues, not individual occurrences
  • Priority actions — ordered by impact (fixes that resolve the most issues first)
  • Estimated effort — rough time estimate to address all issues

This costs one API call regardless of how many issues exist.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
AI SUMMARY — actionable insights
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  Code quality is generally good but 12 type annotation issues
  in src/utils/ suggest a systematic gap in utility functions.

  Key Patterns:
  • Missing type annotations in 8 utility functions in src/utils/
  • Unused imports in 4 test files (likely copy-paste artifacts)

  Priority Actions:
  1. Add return type annotations to src/utils/ (resolves 8 issues)
  2. Remove unused imports in test files (resolves 4 issues)

  Estimated effort: 20-30 minutes of focused cleanup

Interactive Fix Suggestions (--fix)

The --fix flag generates AI-powered code diffs and presents them for interactive review:

lintro check --fix

For each group of issues, you’re prompted:

[y]accept group / [a]accept group + remaining / [r]reject / [d]iffs / [s]kip / [v]validate-after-group / [q]quit
  • Accept group — applies only the current group
  • Accept group + remaining — applies current group, then auto-accepts the rest
  • Reject — skips this group
  • Diffs — shows the unified diff before deciding
  • Skip — moves to the next group
  • Validate-after-group — toggles immediate tool validation after each accepted group (does not accept/apply fixes by itself)
  • Quit — stops the review

Each group now includes:

  • Risk labelsafe-style vs behavioral-risk (classified by the AI model)
  • Patch stats — files touched, +/- lines, and hunk count

Risk classification is AI-driven: the model self-reports whether each fix is purely cosmetic (safe-style) or affects behavior (behavioral-risk). Unknown or empty classifications default to behavioral-risk for safety.

For safe-style groups, pressing Enter defaults to accepting the group.

After the review session, a post-fix AI summary contextualizes what was fixed and what remains.

Git Checkpoints & Rollback

Before an AI fix batch mutates files, lintro captures a snapshot under refs/lintro/checkpoints/<run-id> using git plumbing on a temporary index (GIT_INDEX_FILE). This does not touch your index, stash, or HEAD.

  • Rollback restores only the files lintro targeted from that checkpoint tree. Every blob is read before anything is written, each file is replaced atomically and keeps its mode, and paths that were not part of the snapshot are never touched.
  • Diff against the checkpoint shows how the targeted files differ from their pre-batch state. When a run changed anything, lintro prints the ref so you can run git diff <ref> or git restore --source=<ref> --worktree -- <path> after it exits. restore --worktree is deliberate: git checkout <ref> -- <path> would also rewrite your index. This is a diff of the files, not an audit trail of lintro alone — an edit you made to a targeted file after capture appears in it too.
  • Interactive reject restores rejected files from the checkpoint tree, not from in-memory copies. Files an earlier accepted group already changed are left alone, so rejecting one group never discards fixes you just accepted.
  • Retention keeps the last N checkpoint refs (default 10), pruning older ones via git update-ref -d before the new ref is written. 0 keeps only the current run’s checkpoint.
  • Fallback: outside a git work tree (or in a bare repo), lintro falls back to file-content snapshots / the legacy reverse patch under .lintro-cache/ai.

Optional: set ai.checkpoint_fmt: true to capture the same style of checkpoint before lintro format mutates files. This one is git-only — an in-process snapshot would not outlive the run, so nothing is captured outside a git work tree.

Semantics: Rolling back a lintro target overwrites that file with the pre-batch snapshot — including any edits you made to that same file between capture and rollback. Non-target files and your staged/unstaged index state are left alone.

AI Fixes in format

When running lintro format, tools auto-fix what they can. For remaining unfixable issues, the AI generates fix suggestions and presents them interactively (same UX as --fix in check). After the session, a post-fix summary wraps up what was accomplished.

Advisory AI finders (idiom-review)

Unlike the AI summary and --fix flows (where AI explains or fixes issues that linters found), an AI finder uses AI to find issues that syntax-matching linters structurally cannot. idiom-review is the first one. It has no external binary — it runs through the existing AI provider abstraction, respecting the same retry, fallback, and cost-budget controls.

Migration (#1308). AI finders used to run under lintro chk. They now run under lintro review only. Every tool declares an execution class — deterministic (all classic linters) or advisory (AI finders) — and chk runs deterministic tools exclusively. Running lintro chk --tools idiom-review is now an error that points at the review verb.

# before
lintro chk --tools idiom-review

# now (both forms still require the tool's own `enabled` opt-in, from
# `tools.idiom-review` config or `--tool-options`)
lintro review --advisory-only --advisory-tools idiom-review

Why: advisory findings are opinions produced by a nondeterministic model. Letting them share chk meant two identical runs could disagree, the --fail-under health-score gate could move on model mood rather than on regressions, and every contributor paid API latency and dollars on a command meant to be reflexive and offline. Your tools.idiom-review config section is unchanged — only the invoking verb moved.

Advisory tools under lintro review:

FlagMeaning
(default)Advisory tools run alongside the diff review, over the changed files
--advisory-tools <names>Comma-separated advisory tools, all (default), or none
--advisory-onlySkip the diff review; scan --path values (default .)
--fail-on-findingsExit 1 when advisory tools report findings (default: exit 0)
--tool-optionstool:option=value overrides, as in chk

Advisory findings never affect the exit code unless --fail-on-findings is passed, and they never contribute to the chk health score. With --output json, they appear under an additive advisory key so existing consumers of the review JSON keep working.

idiom-review offers two modes:

  • per-file (Mode 1) — flags idiomatic misses: code that is correct but verbose, e.g. found = False; for x in items: ... instead of any(cond for x in items).
  • duplication (Mode 2) — flags the same utility logic reimplemented across files, invisible to any per-file linter, with a suggested extraction point.

The tool ships disabled by default and is a no-op until you opt in. Findings are cached by a content hash under .lintro-cache/idiom, so unchanged files cost nothing on repeat runs. When no AI provider is available (missing SDK, key, or credits), the tool degrades gracefully to a skipped result rather than failing the run.

# .lintro-config.yaml
ai:
  enabled: true
  provider: anthropic
  transport: api
tools:
  idiom-review:
    options:
      enabled: true # opt-in gate (default: false)
      mode: per-file # per-file | duplication | both
      min_confidence: medium # drop findings below this confidence
      max_files: 25 # cap files reviewed per run (cost bound)

Or enable it ad hoc from the CLI:

lintro review --advisory-only --tool-options idiom-review:enabled=true

Custom Review Agents (.lintro/review-agents/*.md)

lintro review ships a built-in checklist corpus, but house rules (“no raw SQL outside the repository layer”, “every Effect service follows X”) are prose, not YAML. Write them as markdown files under .lintro/review-agents/ — YAML front matter carries the machine-readable scope and policy, and the body carries the review instruction.

---
name: no-raw-sql
description: SQL must go through the repository layer
include:
  - 'src/**/*.py'
exclude:
  - 'src/repositories/**'
severity: high
strictness: focused
model: default
enabled: true
---

Review the changed code for raw SQL strings executed outside the repository layer. Flag
any direct `cursor.execute` / `connection.execute` call with a string literal, and point
at the repository method that should be used instead.

Front-matter fields:

FieldTypeDefaultDescription
namestringrequiredUnique agent id; becomes the source attribution on findings
includelist[str]requiredGlobs selecting the changed files the agent reviews
descriptionstring""One-line summary shown by --list-agents
excludelist[str][]Globs removing files from the include set
severitystringP2P1/P2/P3 or high/medium/low; applied to all findings
strictnessstringbalancedfocused · balanced · thorough
modelstringdefaultOptional per-agent model override
enabledbooltrueSet false to keep the file but stop running it

Behavior:

  • Agents are enabled by default. Control them with review.custom_agents in .lintro-config.yaml: true (run alongside the built-in checklist), false (skip discovery entirely), or only (run agents instead of the built-in checklist).
  • An agent runs only when at least one changed file matches its include globs after exclude is applied. Agents that match nothing — and agents with enabled: false — are reported as skipped and cost nothing.
  • Findings merge into the normal terminal, JSON, and PR-comment output, attributed with source: <agent-name>. The agent’s declared severity is the severity its findings carry, so a severity: high agent produces P1 findings that fail the exit gate.
  • Each scoped agent is one extra provider call and counts against ai.max_cost_usd exactly like a built-in checklist chunk.
  • A file with invalid front matter is reported with the offending field and skipped — never fatal, and the rest of the review still runs.

Safety: agent bodies are maintainer-authored workspace content, so they are treated as untrusted data. A body never becomes the system prompt; it is redacted for secrets, sanitized, and embedded in the user prompt inside a per-call unique boundary marker with explicit instructions that nothing inside it can change the model’s role or output contract.

List what would run without spending anything:

lintro review --list-agents
# .lintro-config.yaml
review:
  custom_agents: true # true | false | only

Addressed lifecycle on inline threads

When lintro review --post runs again on a PR, every finding the new round no longer reproduces is stamped on its own inline comment rather than only in the sticky summary:

  • Addressed — the comment gets a ✔ Addressed in <sha> · round N banner, its copy-paste agent prompt is retitled (historical), and the thread is resolved when review.auto_resolve is true — which it is by default; set it to false to opt out and resolve the thread by hand.
  • Partially addressed — a finding reported at several locations resolves only when the whole pattern is gone. Progress shows as ✔ 14/20 addressed in <sha> · round N and the thread stays open.
  • Regressed — a finding that comes back is re-raised on a fresh thread carrying regression · first raised round X, fixed round Y plus a link to the original. The old thread is stamped ↩ Regressed in <sha> and is never reopened.

Thread resolution is the only configurable half; the banner is always written.

# .lintro-config.yaml
review:
  auto_resolve: true # default; set false to resolve threads by hand

Configuration

Basic Setup

Add the ai section to .lintro-config.yaml:

ai:
  enabled: true # master switch (AND-ed with the toggles below)
  lint: true # AI lint summaries during chk/fmt
  review: true # the `lintro review` AI diff review
  provider: anthropic # or "openai" / "cursor" ("cursor" needs transport: cli)
  transport: api # "api" (SDK) or "cli" (local agent binary); no default
  # model: claude-sonnet-4-6  # uses provider default if omitted
  # api_key_env: ANTHROPIC_API_KEY   # uses provider default if omitted

Feature Toggles

AI features are gated by a master switch plus two per-feature toggles, all off by default:

  • ai.enabled — master switch for all AI features.
  • ai.lint — AI lint summarization injected after chk/fmt runs.
  • ai.review — the lintro review AI diff-review command.

A feature is active only when the master switch and its own toggle are true (enabled AND lint, enabled AND review). This lets you, for example, enable AI diff review without adding AI summaries to every lint run:

ai:
  enabled: true
  lint: false
  review: true

Backward compatibility: a legacy config that sets ai.enabled: true without either sub-toggle keeps the old behaviour — both lint and review are switched on — and emits a deprecation warning. Set ai.lint and/or ai.review explicitly to silence it.

Full Configuration Reference

Every key below maps 1:1 to a field on AIConfig in lintro/ai/config.py, which is the source of truth. All fields are optional; each is shown with its type, default, and accepted range. Unknown keys under ai: are dropped with a warning rather than rejected, so a stale key never breaks a run — but a typo never takes effect either.

The grouping below (provider, budget, safety, output, cache, advanced) is for readability only; the loader expects the flat key layout shown.

ai:
  # ── Provider & transport ──────────────────────────────────────
  # Master switch — all AI features are disabled when false. AND-ed with the
  # per-feature toggles below. (bool, default: false)
  enabled: true

  # Per-feature toggles (both default to false). Effective only when
  # enabled is also true.
  lint: true # AI lint summaries during chk/fmt
  review: true # the `lintro review` AI diff review

  # Provider: "anthropic", "openai" or "cursor" ("cursor" is CLI-only).
  # (default: anthropic)
  provider: anthropic

  # How to invoke the provider: "api" (SDK) or "cli" (local agent binary).
  # No default — set it explicitly whenever ai.lint or ai.review is enabled.
  # "cursor" requires "cli". See "Transports".
  transport: api

  # Model override (uses provider default if omitted). (str, default: none)
  # model: claude-sonnet-4-6

  # Custom env var for API key (uses provider default if omitted).
  # (str, default: none)
  # api_key_env: MY_CUSTOM_KEY

  # Custom API base URL — enables Ollama, vLLM, Azure OpenAI, or any other
  # OpenAI-compatible endpoint. (str, default: none)
  # api_base_url: http://localhost:11434/v1

  # Provider region hint for data residency; used together with api_base_url
  # for region-specific endpoints. (str, default: none)
  # api_region: eu

  # Ordered fallback model chain — each entry is tried in turn if the primary
  # model fails. (list[str], default: [])
  fallback_models: []

  # Max tokens per API request. (int 1–128000, default: 4096)
  max_tokens: 4096

  # Max retries for transient API errors. (int 0–10, default: 2)
  max_retries: 2

  # API request timeout in seconds. (float >= 1.0, default: 60.0)
  api_timeout: 60.0

  # Retry backoff parameters.
  retry_base_delay: 1.0 # initial delay, seconds (float >= 0.1)
  retry_max_delay: 30.0 # max delay, seconds (float >= 1.0, must be >= base)
  retry_backoff_factor: 2.0 # multiplier per retry (float >= 1.0)

  # ── Budget & cost caps ────────────────────────────────────────
  # Max issues to attempt fixing per run. Counts API calls made, not
  # suggestions returned. (int >= 1, default: 20)
  max_fix_attempts: 20

  # Concurrent AI provider calls (fixes and review chunk fan-out).
  # Honored even when max_cost_usd is set. (int 1–20, default: 5)
  max_parallel_calls: 5

  # Spend ceiling per AI session, in USD; the run stops
  # scheduling new calls once spent+reserved reaches the cap. null disables
  # it. A cost cap does NOT force serial execution — chunk reviews still
  # fan out up to max_parallel_calls. Trade-off: calls already in flight
  # when the ceiling is hit still finish, so the final total may overshoot
  # by up to (max_parallel_calls − 1) in-flight calls' cost.
  # (float >= 0 | null, default: null)
  max_cost_usd: null

  # Token budget for a fix prompt before context is trimmed — a soft budget,
  # see "Data & Privacy". (int >= 1000, default: 12000)
  max_prompt_tokens: 12000

  # ── CLI-transport review limits (#1967) ───────────────────────
  # Per-chunk diff token budget under --transport cli; forces the semantic
  # chunker to split diffs a single CLI turn cannot finish.
  # (int >= 1000, default: 24000)
  cli_max_diff_tokens: 24000

  # Hard ceiling on the full unified-diff byte size under --transport cli;
  # larger diffs fail fast with a --paths / --transport api advisory.
  # (int >= 10000, default: 1500000)
  cli_max_diff_bytes: 1500000

  # Max findings one CLI review call may emit. The cap is a prompt contract
  # (the model is instructed to stop at the cap and summarize overflow), not
  # a post-parse truncation; a chunk that still exhausts the 32k output cap
  # retries once with a tighter cap, and truncated responses fall back to
  # the schema-retry / unstructured-recovery ladder.
  # (int 1–50, default: 12)
  cli_max_findings_per_call: 12

  # Re-prompt to refine a fix that failed verification. (int 0–3, default: 1)
  max_refinement_attempts: 1

  # ── Safety & filtering ────────────────────────────────────────
  # How to handle prompt-injection patterns detected in source files or
  # diagnostics: "warn" logs and continues, "block" skips the affected file,
  # "off" disables detection. (one of: off | warn | block, default: warn)
  sanitize_mode: warn

  # Minimum confidence for AI fix suggestions; anything below the threshold is
  # discarded. (one of: low | medium | high, default: low)
  min_confidence: low

  # Restrict AI processing to matching paths / rules (glob patterns).
  # Empty means "no filter". (list[str], default: [])
  include_paths: []
  exclude_paths: []
  include_rules: []
  exclude_rules: []

  # ── Output & apply behaviour ──────────────────────────────────
  # Set true to always run --fix in chk without the CLI flag.
  # (bool, default: false)
  default_fix: false

  # Auto-apply fixes without interactive review (use with caution).
  # (bool, default: false)
  auto_apply: false

  # Auto-apply deterministic style fixes (e.g. E501) in non-interactive/json
  # runs. (bool, default: true)
  auto_apply_safe_fixes: true

  # Preview mode: show AI fix suggestions without applying them.
  # (bool, default: false)
  dry_run: false

  # Interactive mode: validate immediately after each accepted group.
  # (bool, default: false)
  validate_after_group: false

  # Show token count and cost estimate in output. (bool, default: true)
  show_cost_estimate: true

  # Extra diagnostic logging for AI operations. (bool, default: false)
  verbose: false

  # Stream AI responses token-by-token in interactive mode.
  # (bool, default: false)
  stream: false

  # Post AI summaries and inline fix suggestions as PR review comments when
  # running in GitHub Actions. (bool, default: false)
  github_pr_comments: false

  # CI exit-code control: when true, an AI error (fail_on_ai_error) or an
  # unfixed/failed AI fix (fail_on_unfixed) contributes to a non-zero exit
  # code. (bool, default: false)
  fail_on_ai_error: false
  fail_on_unfixed: false

  # Lines of surrounding context sent to AI for fix generation.
  # (int 1–100, default: 15)
  context_lines: 15

  # Max lines above/below target for line-targeted fix search.
  # (int 1–50, default: 5)
  fix_search_radius: 5

  # ── Git checkpoints ───────────────────────────────────────────
  # Refs kept under refs/lintro/checkpoints/, this run included.
  # 0 keeps only the current run. (int >= 0, default: 10)
  checkpoint_retention: 10

  # Also checkpoint before `lintro format` mutates files.
  # (bool, default: false)
  checkpoint_fmt: false

  # ── Suggestion cache ──────────────────────────────────────────
  # Deduplicate identical fix requests across runs. (bool, default: false)
  enable_cache: false

  # Cache entry time-to-live, seconds. (int >= 60, default: 3600)
  cache_ttl: 3600

  # Max cached entries before eviction. (int >= 1, default: 1000)
  cache_max_entries: 1000

  # ── Anthropic CLI transport ───────────────────────────────────
  # Whether to pass "--bare" to the "claude" binary. "--bare" drops the CLI's
  # agentic tool surface but also disables OAuth session login, so it only
  # authenticates against an API key. "auto" sends it only when a key is
  # reachable (ANTHROPIC_API_KEY or an apiKeyHelper), so a subscription login
  # keeps working. Override per run with LINTRO_CLI_BARE.
  # (auto | always | never, default: auto)
  cli_bare: auto

  # ── Cursor workspace trust ──
  # Choosing provider: cursor grants workspace trust (passes "--trust" to the
  # agent CLI). Set false to restore the agent's interactive trust prompt.
  # (bool, default: true)
  cursor_trust_workspace: true

  # ── Advanced / trust (leave off unless you understand the risk) ──
  # Let the git-native (CLI transport) review path delegate diff retrieval to
  # the provider instead of embedding a redacted diff. Security risk: a
  # delegated diff bypasses lintro's secret-redaction choke point — see the
  # warning under "Data & Privacy". (bool, default: false)
  review_allow_unredacted_git_native: false

Config Defaults for CLI Flags

If you always want --fix without typing it, set the default in config:

ai:
  enabled: true
  transport: api
  default_fix: true # equivalent to always passing --fix

CLI flags always override config: passing --fix on the CLI turns it on even if default_fix: false, and omitting it falls back to the config value.

Providers

Anthropic (default)

export ANTHROPIC_API_KEY=sk-ant-...
ai:
  provider: anthropic
  # model: claude-sonnet-4-6  # default

See the Anthropic API docs for model options and pricing.

OpenAI

export OPENAI_API_KEY=sk-...
ai:
  provider: openai
  # model: gpt-4o  # default

See the OpenAI API docs for model options and pricing.

Transports

Lintro reaches a provider one of two ways, selected by ai.transport:

  • api — the provider’s Python SDK over HTTPS. Requires the ai extra.
  • cli — a subprocess call to a locally installed agent binary (claude, codex, Cursor’s agent).

Timeouts, cost caps, failure vocabulary, and the meaning of reported $ figures are transport-scoped — see AI review transports for the decision table and ai.transports.* profiles (#1923).

ai.transport has no default, so set it explicitly whenever ai.lint or ai.review is enabled. Omitting it is not fatal: lintro doctor reports the config as incompatible, and the provider factory falls back to api so an existing run keeps working. That fallback exists for backward compatibility — legacy configs that set only ai.enabled: true (which implicitly switches lint and review on) rely on it — and is not something to depend on in new config.

cursor is a CLI-only provider: pair it with transport: cli. anthropic and openai support both transports.

ai:
  enabled: true
  review: true
  provider: anthropic
  transport: api # or "cli"

Both lintro check and lintro review accept --transport api|cli to override the config for a single invocation. lintro review also accepts --provider, --model, and --max-cost-usd. Environment variables (LINTRO_AI_PROVIDER, LINTRO_AI_MODEL, LINTRO_AI_TRANSPORT, LINTRO_AI_ENABLED, LINTRO_AI_MAX_COST_USD) apply to every AI surface and lose to CLI flags. There is no --enabled flag.

Invocation overrides

Resolution order for provider, model, transport, enabled, and max_cost_usd is:

CLI flag > environment variable > .lintro-config.yaml > built-in default

Overlays replace the active transport profile’s cost cap (ai.transports.api.max_cost_usd / ai.transports.cli.max_cost_usd_advisory) as well as the legacy ai.max_cost_usd scalar.

Variable / flagOverridesNotes
LINTRO_AI_PROVIDER / lintro review --providerai.provideranthropic, openai, or cursor
LINTRO_AI_MODEL / lintro review --modelai.modelany model id; empty env falls through
LINTRO_AI_TRANSPORT / --transportai.transportapi or cli
LINTRO_AI_ENABLEDai.enabled1/0/true/false. =1 does not turn on ai.review or ai.lint. No --enabled flag.
LINTRO_AI_MAX_COST_USD / lintro review --max-cost-usdai.max_cost_usdPositive float = USD cap. Overlay 0 = uncapped (YAML 0 is $0). Invalid fails loud.

Unset variables are absent (fall through). Invalid values fail at resolution with a message naming the variable and the accepted values — they never silently use the config default. Review output annotates each resolved field with its source (provider: cursor (env), max cost: uncapped (env)).

# Try Cursor locally without dirtying .lintro-config.yaml
LINTRO_AI_PROVIDER=cursor LINTRO_AI_TRANSPORT=cli lintro review --uncommitted

# Same thing with flags (flags win if both are set)
lintro review --uncommitted --provider cursor --model cursor-grok-4.6-high --transport cli

# Lift the committed cost cap for this run (0 = uncapped, not a $0 cap)
LINTRO_AI_MAX_COST_USD=0 lintro review --uncommitted
lintro review --uncommitted --max-cost-usd 0

# Kill switch for this environment
LINTRO_AI_ENABLED=0 lintro check .

Transport authentication

Every transport needs a credential of its own — CLI transport is not credential-free.

ProviderTransportCredential
anthropicapiANTHROPIC_API_KEY
anthropiccliclaude login session, ANTHROPIC_API_KEY, or an apiKeyHelper
openaiapiOPENAI_API_KEY
openaiclicodex login session (~/.codex/auth.json) or CODEX_API_KEY
cursorcliagent login session or CURSOR_API_KEY (CLI-only provider)

ai.api_key_env overrides the API-transport variable name if you keep the key somewhere else.

Anthropic --transport cli and the --bare flag.

claude --bare runs the CLI without its agentic tool surface, but it also disables OAuth session login — in bare mode the binary authenticates only against an API key. Lintro therefore chooses the flag per invocation (ai.cli_bare, default auto):

  • An API key is reachable (ANTHROPIC_API_KEY is set, or a Claude Code settings file declares an apiKeyHelper) → lintro sends --bare, and the call bills that key exactly like --transport api.
  • No API key is reachable → lintro omits --bare, and the call uses your claude login session, billed to that subscription.

Force either mode explicitly with ai.cli_bare: always|never in config, or with the LINTRO_CLI_BARE=always|never environment variable (the environment wins). Codex and Cursor are unaffected — both accept a CLI login session. See #1838.

Failures are visible, never a green no-op

A missing, rejected, or depleted credential is reported, not swallowed. Lintro walks a presence → liveness → invoke chain, and each step’s failure short-circuits to a visible skip or failure:

  • Presence — is the SDK importable, the binary on PATH, the key variable set?
  • Liveness — under api, a minimal one-token real call, because a valid key with a depleted balance authenticates and lists models but cannot serve a review. Under cli, presence plus the free --version / --help capability gate (no quota spent, so the result is reported as unverified quota).
  • Invoke — an auth or quota error at call time is classified through the same taxonomy: auth_failed, no_quota, rate_limited, unreachable, incompatible_cli, missing_credential.

Probe it directly:

lintro doctor              # presence checks (free)
lintro doctor --ai-liveness # adds the liveness probe (one minimal API call on `api`)

lintro review distinguishes the two red states by exit code, so a wrapper can never mistake one for the other:

ExitMeaning
0A review was produced (clean, or findings below P1)
1A review was produced and contains P1 findings
2No review was produced — missing/dead credential, depleted balance, unreachable provider, or a lintro-side failure

CLI compatibility floors

Each CLI transport declares a minimum supported version of the agent binary. These are known-incompatible-below floors, not known-good pins: a binary below the floor predates the flag surface lintro drives, so lintro fails with an actionable upgrade hint instead of a confusing runtime error.

ProviderBinaryMinimum versionUpgrade
anthropicclaude2.0.0npm install -g @anthropic-ai/claude-code@latest
openaicodex0.20.0npm install -g @openai/codex@latest
cursoragent2025.1.1curl https://cursor.com/install -fsS | bash

Source of truth: lintro/ai/providers/cli_contracts.py.

Above the floor, lintro tolerates flag-surface drift with a three-part guard:

  1. Version floor — refuse binaries known to be too old.
  2. Proactive gate — optional flags (--json-schema-name, --resume, --output-schema, --trust) are checked against the binary’s --help before being sent, and simply dropped when unsupported. Only their extra capability is lost.
  3. Reactive backstop — a call that still fails with unknown option drops the offending optional flag and retries.

Required flags are not gated — dropping them would degrade a review silently. Instead, CI’s contract tests assert the installed binaries still advertise them, so drift breaks CI rather than a user’s review.

Transports in CI

Both transports need their credential injected explicitly; nothing is inherited from a developer’s login.

  • Fork PRs cannot read secrets. GitHub withholds repository secrets from pull_request runs originating in a fork, so any AI job must either skip on forks or degrade to a visible skip. Lintro’s own ai-review.yml requires head.repo.full_name == github.repository, so the keyed job never runs for a fork.
  • Never default an unset secret. Forwarding ${{ secrets.X }} unset lets the run report a visible skip naming the missing credential; substituting a placeholder turns it into a false pass.
  • Trusted install. Lintro installs itself from the PR’s base ref before the step that holds the provider credential, so PR-controlled code never executes with the secret in scope. The PR is reviewed as data (the diff is fetched through the GitHub API).
  • A subscription works in CI, on the cli transport. Lintro’s own ai-review.yml runs ai.transport: cli against a version-pinned claude CLI (npm install -g @anthropic-ai/claude-code@<pin>) authenticated by a CLAUDE_CODE_OAUTH_TOKEN secret — no API key involved. Two things make that work: keep ANTHROPIC_API_KEY out of the step env, and set LINTRO_CLI_BARE: never, because --bare disables OAuth session login (see the --bare note above). Set CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 and DISABLE_AUTOUPDATER=1 to keep the binary’s egress and version predictable under an egress allowlist.
  • ai.max_cost_usd is API-path accounting. Lintro prices the tokens it billed itself, so under the cli transport the cap is advisory — the call bills the subscription (or, in bare mode with a reachable API key, that key — see the billing note above). Setting a cap does not serialize provider calls: review chunks still fan out up to ai.max_parallel_calls. In-flight calls that started before the ceiling was hit still finish, so the session may overshoot by up to (max_parallel_calls − 1) calls’ cost. Review metadata records per-phase timings (context_collection, provider, parse_merge) so wall-clock regressions are visible in JSON / MCP output.
  • Two tiers of contract testing. The flag-surface tier runs --version / --help only — no credential, no quota — on every PR. The real-invocation tier spends quota and runs weekly, gated behind the free tier.

Security notes: provider API keys are secrets — store them in the repository/org secret store (or a local shell profile), never in .lintro-config.yaml, which only names the variable through ai.api_key_env. A key with billing attached should be scoped and rotatable; lintro doctor --ai-liveness is the cheap way to confirm a rotation took effect.

Environment Support

AI output adapts to the environment:

EnvironmentRendering
TerminalRich Panels with color and structure
GitHub Actions::group:: / ::endgroup:: collapsible
Markdown<details> / </details> collapsible
JSONai_summary and metadata fields

JSON Output

When using --output-format json, AI data is included in the output.

Note: the per-tool key is metadata. It is deliberately not named ai_metadata because it is not AI-specific: osv-scanner writes its suppression classifications there with AI fully disabled. The old ai_metadata key was removed after its deprecation cycle — consumers must read metadata.

{
  "results": [
    {
      "tool": "ruff",
      "issues": [...],
      "metadata": {
        "summary": {
          "overview": "Code quality assessment...",
          "key_patterns": ["Pattern 1", "Pattern 2"],
          "priority_actions": ["Action 1", "Action 2"],
          "estimated_effort": "20-30 minutes"
        },
        "fix_suggestions": [...]
      }
    }
  ],
  "summary": {...},
  "ai_summary": {
    "overview": "Code quality assessment...",
    "key_patterns": ["Pattern 1", "Pattern 2"],
    "priority_actions": ["Action 1", "Action 2"],
    "estimated_effort": "20-30 minutes"
  }
}

GitHub Actions

In CI, AI summary appears as a collapsible group in the workflow log. No special configuration needed — Lintro auto-detects GITHUB_ACTIONS=true.

Cost Control

Estimated Costs

AI features use minimal API calls:

FeatureAPI CallsTypical Cost
AI Summary1 per run~$0.01
Fix suggestions1 per issue (up to limit)~$0.01 each
Post-fix summary1 after fix review~$0.01

Reducing Costs

  1. Limitsmax_fix_attempts (default 20) caps API calls
  2. Opt-in flags--fix is opt-in; only the summary runs by default (1 call)
  3. Cost displayshow_cost_estimate: true shows token usage and estimated cost after each AI operation

Disabling AI

ai:
  enabled: false # disables all AI features

Or simply don’t install the extra:

uv pip install lintro  # no AI support

Retry and Error Handling

AI API calls use exponential backoff retry:

  • Max retries: 2 (3 total attempts)
  • Backoff: 1s, 2s (capped at 30s)
  • Retried errors: rate limits, transient provider errors
  • Not retried: authentication errors (fail immediately)

AI failures never break the main linting flow. If the provider is unavailable, you get your normal linting results with a one-line notice:

AI: enhancement unavailable

Non-JSON review responses

lintro review asks the model for a JSON object. When an answer comes back as prose instead, the findings it contains are recovered rather than discarded:

  1. The response is parsed, including JSON embedded in surrounding prose.
  2. If that fails, lintro makes exactly one retry asking the model to re-emit its answer in the required schema. The retry is charged against the same per-call ai.api_timeout budget as the original call (capped at half of it), and is skipped entirely when too little of that budget remains.
  3. If the retry also fails, the prose is reported as a single low-severity “unstructured review output” finding carrying the complete answer, and the chunk completes instead of aborting.

Every response that fails to parse — at the CLI-envelope layer or the review layer — is written in full to .lintro-cache/ai/raw-responses/, so nothing the model produced is lost to truncation.

Pre-Execution Summary

When AI is enabled, the pre-execution summary table includes AI configuration:

┌───────────────┬──────────────────────────────────┐
│ Setting       │ Value                            │
├───────────────┼──────────────────────────────────┤
│ AI            │ enabled                          │
│               │   provider: anthropic            │
│               │   model: claude-sonnet-4-...     │
│               │   parallel: 5 workers            │
│               │   safe-auto-apply: on            │
│               │   verify-fixes: off               │
└───────────────┴──────────────────────────────────┘

This shows provider status, SDK availability, API key presence, and operational settings at a glance.

Docker with AI

Two images are published. Which one you need depends on the transport.

ImageContainsUse when
ghcr.io/lgtm-hq/py-lintroThe lint toolchain. Lean; no provider SDKs, no agent CLIs.Linting only, or AI off.
ghcr.io/lgtm-hq/py-lintro-aiEverything above plus the ai extra and the baked claude, codex and Cursor agent CLIs.--transport api or --transport cli in a container.

Both carry the same tag scheme (latest, 0.94, 0.94.2, sha-<commit>) and are cosign-signed. The ai variant is a strict superset built FROM the base image’s full stage, so nothing is lost by using it — it is simply larger, which is why the lint image stays free of it.

To use AI features, pass your API key as an environment variable. The provider defaults to ai.provider in the mounted .lintro-config.yaml, and can be overridden per run with LINTRO_AI_PROVIDER or lintro review --provider without editing that file. --transport (and LINTRO_AI_TRANSPORT) override the invocation path. The examples pass the key by name (-e VAR, no =value), so the secret is inherited from the shell’s environment instead of appearing in the container’s argument list.

# API transport, with `ai: {provider: anthropic}` in the mounted config
docker run --rm \
  -e ANTHROPIC_API_KEY \
  -v $(pwd):/code \
  ghcr.io/lgtm-hq/py-lintro-ai:latest check . --transport api

# API transport, with `ai: {provider: openai}` in the mounted config
docker run --rm \
  -e OPENAI_API_KEY \
  -v $(pwd):/code \
  ghcr.io/lgtm-hq/py-lintro-ai:latest check . --transport api

# CLI transport — the agent binaries are already on PATH in this image.
# The credential is still required (see "Transport authentication" above).
docker run --rm \
  -e ANTHROPIC_API_KEY \
  -v $(pwd):/code \
  ghcr.io/lgtm-hq/py-lintro-ai:latest check . --transport cli

The agent CLIs live in /opt/ai-tools, installed from the digest-pinned ghcr.io/lgtm-hq/lintro-ai-tools base image and refreshed weekly. Only /opt/ai-tools/bin goes on PATH, so the lint toolchain’s own runtimes are untouched. Because the bundled CLIs are rebuilt on a weekly cadence while the vendors release far more often, the capability guard above is what absorbs the lag.

Building locally, the base image adds the AI extras (SDKs, not CLIs) with WITH_AI=true, and the full ai variant is its own build target:

docker build --build-arg WITH_AI=true -t lintro-ai .   # API transport only
docker build --target ai -t lintro-ai-full .           # + baked agent CLIs

Outside Docker, the agent CLIs are bring-your-own: install them yourself (pip, Homebrew, npm, the vendor installer) and keep them at or above the compatibility floors.

Troubleshooting

AI: enhancement unavailable

SDK not installed:

uv pip install 'lintro[ai]'

API key missing:

# Anthropic
export ANTHROPIC_API_KEY=sk-ant-...

# OpenAI
export OPENAI_API_KEY=sk-...

# Or use a custom env var
# .lintro-config.yaml
# ai:
#   api_key_env: MY_CUSTOM_API_KEY

Unknown provider:

Only anthropic, openai and cursor are supported (cursor is CLI-transport only). Check your ai.provider value.

Rate Limits

If you hit rate limits, the retry logic handles transient 429 errors automatically. For persistent rate limiting:

  • Reduce max_parallel_calls (e.g., from 5 to 2)
  • Reduce max_fix_attempts

High Costs

  • Check show_cost_estimate: true is set to monitor usage
  • Lower max_fix_attempts (default 20) if fix generation is too expensive
  • Avoid default_fix: true in config unless you want fixes every run
  • Use --fix only when needed

Data & Privacy

What is sent to the AI provider

  • Summary mode (lintro check): An issue digest containing error codes, counts, issue messages, and workspace-relative file paths. No source code is sent.
  • Fix mode (--fix or lintro format): The source of the file carrying the issue, plus the issue message and error code. How much of that file is sent depends on its size — often all of it. See below.
  • Review mode (lintro review): The unified diff under review — changed lines with their surrounding hunk context — the workspace-relative paths of the changed files, and, when lint results are available, a digest of them. The diff passes through lintro’s secret-redaction step first.

Warning — ai.review_allow_unredacted_git_native sends unredacted diffs.

With this option enabled, the CLI transport asks the provider to run git diff itself instead of embedding lintro’s redacted diff. The result never crosses lintro’s redaction choke point, so any secret, token or other sensitive content present in the diff reaches the provider’s backend verbatim. It defaults to false. Enable it only in a controlled, trusted environment, on diffs you have confirmed carry no secrets, and only when delegated retrieval is needed for a very large diff.

How much source code fix mode sends

Fix mode is the one path that can send a whole source file. The amount is size-dependent, governed by FULL_FILE_THRESHOLD (500 lines) in lintro/ai/fix_context.py:

  • Files at or under 500 lines — the entire file is attempted first, so the model can reason about the whole file when generating a fix. It is sent in full only if the resulting prompt fits within max_prompt_tokens; otherwise lintro falls back to the window below.
  • Files over 500 lines — only a window around the issue line is sent (context_lines, default 15 lines either side). The same windowing catches a small file whose full contents would blow the prompt token budget.
  • Batch path — when several fixable issues share one file, the batch prompt embeds the sanitized full file regardless of line count, falling back to per-issue prompts only when the estimated batch prompt exceeds max_prompt_tokens.
  • Token budgetmax_prompt_tokens (default 12000) is a soft budget. The single-issue path halves the window down to a 3-line floor and then sends the prompt anyway if it is still over; refinement prompts build a fixed window with no budget check at all. Very wide lines or refinement retries can therefore exceed the cap.

Secret redaction and prompt-injection scanning are applied to every prompt lintro assembles, regardless of context size. The one documented exception is ai.review_allow_unredacted_git_native, which delegates diff retrieval to the provider and so bypasses redaction entirely (see the warning above). If sending whole files is unacceptable in your environment, keep fix mode off — max_prompt_tokens alone is not a guarantee.

What is NOT sent

  • Absolute paths — all paths are made relative to the workspace root before sending
  • Other project files — in summary and fix modes, only files with reported issues are read; in review mode, only files that the diff under review touches
  • Detected secrets — recognized secret patterns are redacted from the file content, the issue message, and any context window before the prompt leaves lintro

Workspace boundary enforcement

AI fix suggestions are validated against the workspace root. Fixes targeting files outside the workspace are rejected and never applied.

Local transcript logging (opt-in)

Raw provider request/response traffic can be written as NDJSON under .lintro-cache/ai/transcripts/ for debugging. This is off by default. Enable with ai.transcript_logging: true or LINTRO_AI_TRANSCRIPT=1.

  • Transcripts stay on the local machine only (not uploaded by lintro)
  • Payloads are secret-redacted before write; API keys and auth headers are never logged
  • Older transcript files are pruned (default: keep last 10 runs via ai.transcript_retention)

Important notes

  • AI suggestions can hallucinate incorrect fixes — always review before accepting
  • See your provider’s privacy policy for data retention: Anthropic, OpenAI