Scripting the Cursor CLI: git hooks, CI review and scheduled jobs
PLThe Cursor CLI runs Cursor’s agent without the editor: agent -p "<prompt>" answers once and exits, so git hooks, GitHub Actions jobs and scheduled workflows can call it. The safe pattern keeps the agent’s step narrow, gives it no write token, and lets tests, path guards and a human merge decide whether its output ships.
This page is for developers and tech leads who already use Cursor in the editor and want the same agent working in their repository’s plumbing. You tried it once: the “AI review” job posted “Looks good overall!” on a PR that broke checkout, and the weekly dependency bot quietly edited a source file nobody asked it to touch. Both failures have the same fix, and it is not a better model.
What a scripted Cursor setup gives you
Section titled “What a scripted Cursor setup gives you”- A pre-commit hook that sends the staged diff to the agent and blocks on a
FAILverdict, with a warn-only switch. - A GitHub Actions PR review that runs on trusted code, treats the diff as data and posts from a separate job.
- A weekly dependency job whose output is checked by a version-only guard and your test suite, in a job that holds no secrets.
- A decision table for when a script on your runner beats Cursor Automations, the Cursor SDK or Bugbot.
- The failure modes of unattended runs and how to recover from each.
Where should a scripted Cursor job run?
Section titled “Where should a scripted Cursor job run?”The table lists five ways to run Cursor’s agent without you at the keyboard. This page covers the first two rows (the CLI in a git hook and in CI); the other three have their own pages.
| You need | Use | Why |
|---|---|---|
| A check at commit time, on the developer’s machine | agent -p in a git hook | Fast feedback before code leaves the laptop; costs one call per commit |
| A job in your existing CI, on your runner, next to your gates | agent -p in GitHub Actions | Your secrets policy, your test suite and your required checks stay in charge |
| Scheduled or event-driven runs on Cursor’s machines | Cloud agents and Automations | No runner to maintain; triggers from GitHub, Slack, Linear, Sentry and more |
| Agent runs started and followed from your own code | The Cursor SDK | Streaming events, cancellation and artifacts through an API |
| Managed PR review with no workflow file | Bugbot | Cursor maintains the reviewer; you tune rules instead of prompts |
Cursor also has its own Hooks, which run inside the agent loop and exchange JSON over stdio. They are a different feature from the git hooks below.
Install the Cursor CLI and authenticate a script
Section titled “Install the Cursor CLI and authenticate a script”-
Install the CLI on the machine or runner. Cursor’s installation docs (checked 2026-10-12) give
curl https://cursor.com/install -fsS | bashfor macOS, Linux and WSL andirm 'https://cursor.com/install?win32=true' | iexfor Windows. This is the line in Cursor’s own cookbook Dockerfile, which the workflows below use:Terminal window curl -fsSL https://cursor.com/install | bashexport PATH="$HOME/.cursor/bin:$PATH" # in GitHub Actions: echo "$HOME/.cursor/bin" >> "$GITHUB_PATH"Check the install with
agent --version, and update later withagent update. -
Create an API key in the Cursor dashboard under Integrations and store it as a secret named
CURSOR_API_KEY. The CLI reads the key from that environment variable, and Cursor’s SDK examples use the same name, so a script never needs an interactive login. -
Run a read-only smoke test from the repository root:
Terminal window agent -p "List the top-level directories of this repository and what each one holds." --output-format text -
Decide per job whether the agent may change files. Add
--forceonly to jobs that must write, and run those only on a disposable runner or container with restricted network access, never on a workstation:--forceapproves every tool call, shell commands included; review jobs run with--mode ask, the read-only mode public review workflows use, and capture standard output instead. Omit--modelto use your account’s default, and choose models from the model hub rather than hard-coding one in ten workflows.
Block risky commits with a pre-commit review
Section titled “Block risky commits with a pre-commit review”The hook sends the staged diff inside the prompt, so the agent needs no tools and no write access. It fails open when the agent is unreachable, because a local hook is a convenience and CI is the real gate.
First, commit the reviewer prompt as a file, scripts/ai/pre-commit-review.txt, so the whole team reviews against the same text:
Then save the hook as .githooks/pre-commit, make it executable with chmod +x .githooks/pre-commit, and point git at the directory once per clone with git config core.hooksPath .githooks:
#!/usr/bin/env bash# .githooks/pre-commit - Cursor CLI review of the staged diffset -euo pipefail
DIFF=$(git diff --cached --diff-filter=ACMR -U3)[ -z "$DIFF" ] && exit 0
# One command-line argument is capped at ~128 KB on Linux; CI skips such diffs too.if [ "${#DIFF}" -gt 100000 ]; then echo "pre-commit: diff too large for AI review, skipped. CI tests still run; split the change for AI review." exit 0fi
run_agent() { # macOS has no `timeout` unless coreutils is installed if command -v timeout >/dev/null; then timeout 180 agent "$@"; else agent "$@"; fi}
PROMPT="$(cat scripts/ai/pre-commit-review.txt)
<diff>$DIFF</diff>"
# --trust: this is the developer's own working tree. Without it, print mode can# stop on the workspace-trust question on the first commit in a new clone.if ! RESULT=$(run_agent -p "$PROMPT" --mode ask --trust --output-format text); then echo "pre-commit: Cursor agent unavailable, commit allowed. CI still runs." exit 0fi
echo "$RESULT"VERDICT=$(printf '%s\n' "$RESULT" | head -n 1 | tr -d '[:space:]*')[ "$VERDICT" = "PASS" ] && exit 0
if [ "${AI_REVIEW_MODE:-block}" = "warn" ]; then echo "pre-commit: findings above, commit allowed (AI_REVIEW_MODE=warn)." exit 0fiecho "Commit blocked. Fix the findings, or bypass once with: git commit --no-verify"exit 1Three details carry the design. The tr strips Markdown bold, so **PASS** still passes. The verdict must be the literal first line, so a chatty answer blocks instead of slipping through. AI_REVIEW_MODE=warn lets a team start in advisory mode and switch to blocking once the findings prove useful.
Review pull requests in GitHub Actions without handing the agent a token
Section titled “Review pull requests in GitHub Actions without handing the agent a token”A PR review job is where scripted agents most often leak secrets. The pull request’s branch can carry its own .cursor/ rules, MCP config and scripts, and anything the agent loads from that checkout runs next to CURSOR_API_KEY. So this workflow checks out the base commit, fetches the PR diff as plain text, runs the agent in --mode ask without --force, and posts the comment from a second job that never runs the agent.
name: Cursor PR review
on: pull_request: types: [opened, synchronize, ready_for_review]
permissions: {}
jobs: review: # Fork PRs receive no secrets; skip them instead of failing. if: github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest timeout-minutes: 15 permissions: contents: read pull-requests: read steps: - name: Check out the base commit (trusted code only) uses: actions/checkout@v7 with: ref: ${{ github.event.pull_request.base.sha }} persist-credentials: false
- name: Fetch the PR diff as data env: GH_TOKEN: ${{ github.token }} PR: ${{ github.event.pull_request.number }} run: gh pr diff "$PR" --repo "$GITHUB_REPOSITORY" > pr.diff
- name: Install Cursor CLI run: | curl -fsSL https://cursor.com/install | bash echo "$HOME/.cursor/bin" >> "$GITHUB_PATH"
- name: Review (ask mode, no --force, no GitHub token in this step) env: CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }} # --trust answers the headless workspace-trust prompt for this trusted # base checkout; confirm the flag in `agent --help` on your runner. run: | if [ "$(wc -c < pr.diff)" -gt 100000 ]; then echo "Diff is too large for a single-pass review. Split the PR or review it by directory." > review.md exit 0 fi { cat .github/prompts/pr-review.txt; printf '\n<diff>\n'; cat pr.diff; printf '\n</diff>\n'; } > prompt.txt agent -p "$(cat prompt.txt)" --mode ask --trust --output-format text > review.md test -s review.md
- uses: actions/upload-artifact@v7 with: name: cursor-review path: review.md
comment: needs: review runs-on: ubuntu-latest timeout-minutes: 5 permissions: pull-requests: write steps: - uses: actions/download-artifact@v8 with: name: cursor-review - name: Post the review (this job never runs the agent) env: GH_TOKEN: ${{ github.token }} PR: ${{ github.event.pull_request.number }} run: gh pr comment "$PR" --repo "$GITHUB_REPOSITORY" --body-file review.mdThe prompt is read from the base checkout too, so the agent never loads a reviewer prompt or .cursor/ rules that exist only on the branch. That is not tamper-proofing, though. On the pull_request trigger GitHub runs the workflow file from the pull request’s own merge ref, so a same-repository branch can edit this YAML to check out its head, swap the prompt or echo CURSOR_API_KEY, and that edited job runs before anyone reviews it. Anyone with push access can do the same from any branch workflow, so the secret is only as safe as your list of writers. Two controls make the gate real. A CODEOWNERS entry for .github/ plus a branch rule or ruleset that requires code-owner review stops a change to the workflow or the prompt from merging unreviewed. If the job must run from a workflow the branch cannot change, use pull_request_target, which runs the default branch’s workflow; keep this exact shape there (no checkout of the head, the diff fetched as text, the fork guard) because that job holds secrets next to contributor-controlled data. Commit the prompt as .github/prompts/pr-review.txt:
The official GitHub actions/* tags above are the current majors on 2026-09-26. Pin every third-party action to a full commit SHA instead of a tag: a re-pointed tag is how the March 2025 tj-actions/changed-files compromise (CVE-2025-30066) leaked CI secrets.
Run a weekly dependency job with a version-only guard
Section titled “Run a weekly dependency job with a version-only guard”Dependabot and Renovate already open mechanical version bumps well. The agent helps with the judgment calls: which bumps to take together, and where in your code each one deserves a look.
The workflow splits the work across three jobs, as the PR review does, because isolation between steps of one job is not real. An agent running with --force can execute shell, so it can append to $GITHUB_ENV or $GITHUB_PATH (a fake gh or git earlier on the path) and read a token that a later step in the same job receives. It can also rewrite the scripts block of package.json, which npm ci and npm test would then run.
proposeholdsCURSOR_API_KEYand a read-only token. The agent editspackage.jsonand writesCHANGES.md, and only those two files leave the job, as an artifact. Anything else it touched is discarded with the runner.testholds no secrets and starts from a fresh checkout of the default branch. A guard fails the run unless the only changes topackage.jsonare dependency version strings, each a plain semver range. Then npm regenerates the lockfile and runsnpm ci --ignore-scriptsandnpm test.prholds the write token. It runs neither the agent nor your code: it commits the testedpackage.jsonand lockfile and opens the PR.
name: Weekly dependency bumps
on: schedule: - cron: '17 3 * * 1' # Mondays 03:17 UTC workflow_dispatch:
permissions: {}
jobs: propose: # The agent job: CURSOR_API_KEY and a read-only token, nothing else. runs-on: ubuntu-latest timeout-minutes: 20 permissions: contents: read outputs: changed: ${{ steps.collect.outputs.changed }} steps: - uses: actions/checkout@v7 with: persist-credentials: false
- name: List outdated packages and their sources (deterministic input) # Without node_modules, npm outdated lists every package as MISSING. run: | npm ci --ignore-scripts npm outdated --json > outdated.json || true jq -r 'keys[]' outdated.json | while read -r p; do printf '%s %s\n' "$p" "$(npm view "$p" repository.url 2>/dev/null)" done > sources.txt
- name: Install Cursor CLI run: | curl -fsSL https://cursor.com/install | bash echo "$HOME/.cursor/bin" >> "$GITHUB_PATH"
- name: Agent edits package.json and writes CHANGES.md env: CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }} # --trust answers the headless workspace-trust prompt for this trusted # checkout; confirm the flag in `agent --help` on your runner. # --force auto-approves every tool call, shell included: use it only on a # disposable runner like this one, with no write token, and restrict the # runner's network egress to the Cursor API and the npm registry. run: | agent -p "$(cat .github/prompts/deps-bump.txt)
<outdated> $(cat outdated.json) </outdated>
<sources> $(cat sources.txt) </sources>" --force --trust --output-format text
- name: Hand on exactly two files id: collect run: | mkdir -p proposed if git diff --quiet -- package.json; then echo "changed=false" >> "$GITHUB_OUTPUT"; exit 0 fi test -s CHANGES.md || { echo "::error::agent did not write CHANGES.md"; exit 1; } cp package.json CHANGES.md proposed/ echo "changed=true" >> "$GITHUB_OUTPUT"
- if: steps.collect.outputs.changed == 'true' uses: actions/upload-artifact@v7 with: name: deps-proposed path: proposed/
test: # No secrets here. The checks run on a fresh base checkout the agent never saw. needs: propose if: needs.propose.outputs.changed == 'true' runs-on: ubuntu-latest timeout-minutes: 30 permissions: contents: read steps: - uses: actions/checkout@v7 with: persist-credentials: false
- uses: actions/download-artifact@v8 with: name: deps-proposed path: proposed
- name: Guard - only dependency version strings may change run: | shape='del(.dependencies, .devDependencies)' names='{d: (.dependencies // {} | keys), dd: (.devDependencies // {} | keys)}' diff <(jq -S "$shape" package.json) <(jq -S "$shape" proposed/package.json) \ || { echo "::error::package.json changed outside the dependency lists"; exit 1; } diff <(jq -S "$names" package.json) <(jq -S "$names" proposed/package.json) \ || { echo "::error::packages were added, removed or moved"; exit 1; } # Every changed version must be a plain semver range, not a URL, path or git ref. BAD=$(jq -r --slurpfile base package.json ' ["dependencies", "devDependencies"][] as $s | (.[$s] // {}) | to_entries[] | select(.value != $base[0][$s][.key]) | .value' proposed/package.json \ | grep -vE '^[~^]?[0-9]+\.[0-9]+\.[0-9]+$' || true) [ -z "$BAD" ] || { echo "::error::non-semver versions: $BAD"; exit 1; }
- name: Lockfile and tests, with install scripts off run: | cp proposed/package.json package.json npm install --package-lock-only --ignore-scripts npm ci --ignore-scripts npm test mkdir -p tested cp package.json package-lock.json proposed/CHANGES.md tested/
- uses: actions/upload-artifact@v7 with: name: deps-tested path: tested/
pr: # The only job with a write token. It runs neither the agent nor your code. needs: test runs-on: ubuntu-latest timeout-minutes: 5 permissions: contents: write pull-requests: write steps: - uses: actions/checkout@v7 with: persist-credentials: false
- uses: actions/download-artifact@v8 with: name: deps-tested
- name: Open or update the PR env: GH_TOKEN: ${{ github.token }} run: | git config user.name "cursor-deps-bot" git config user.email "cursor-deps-bot@users.noreply.github.com" git checkout -B chore/weekly-deps git add package.json package-lock.json git commit -m "chore: weekly dependency bumps" git push -f "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" chore/weekly-deps gh pr create --head chore/weekly-deps --title "chore: weekly dependency bumps" \ --body-file CHANGES.md || gh pr edit chore/weekly-deps --body-file CHANGES.mdThe guard is the verification: it compares package.json against the base with the two dependency lists removed, so a changed script, a new package or a github: URL fails the job before any install. --ignore-scripts keeps install scripts of newly bumped packages from running; if your tests need a package’s build step, run npm rebuild <package> for that named package only. For GitHub Actions to create the PR, enable Allow GitHub Actions to create and approve pull requests in the repository settings. PRs opened with GITHUB_TOKEN do not trigger other workflows, so your required checks never run on chore/weekly-deps; use a GitHub App token (actions/create-github-app-token) or a fine-grained PAT in the pr job, or close and reopen the PR, so the required checks run.
Commit the agent’s prompt as .github/prompts/deps-bump.txt. The deterministic step before it maps each package to its repository URL, so every line of CHANGES.md points the reviewer at a source instead of an invented summary:
How do you verify a scripted agent’s output without reading every line?
Section titled “How do you verify a scripted agent’s output without reading every line?”Treat the agent’s text as one input to deterministic gates, never as the gate itself. Each workflow above maps to a check a machine enforces and a person who signs off:
| Workflow | What proves the output | Who signs off |
|---|---|---|
| Pre-commit review | Line 1 is exactly PASS; CI’s required checks run again on every push | The committer. git commit --no-verify skips the hook and leaves no record, so CI’s required checks are what catch a skipped review |
| PR review | The review is advisory: it posts a comment and never fails the job. Required status checks (tests, types, lint) gate the merge; review.md is kept as an artifact | A code owner merges; the bot never approves |
| Dependency job | The version-only guard, npm ci --ignore-scripts and npm test pass in a job with no secrets; npm, not the agent, writes the lockfile | The PR reviewer, on the passing tests and the guard. CHANGES.md is an advisory summary whose lines link each package’s source |
Measure the reviewer the way you would a flaky test. Once a month, label a sample of its comments as useful or noise. Git does not record --no-verify, so count bypasses indirectly: have the hook append each verdict to a log file or a metric, or count the commits where the CI review still finds blockers. When bypasses outnumber fixes, tighten the prompt or switch the hook to AI_REVIEW_MODE=warn. Keep the artifacts: they feed the evidence bundle that lets a reviewer approve a change on proof instead of line-by-line reading.
How the same jobs look in Claude Code and Codex
Section titled “How the same jobs look in Claude Code and Codex”The workflow shape is identical in all three tools: deterministic input, a narrow agent step, deterministic gates, and a separate step that holds the write token. Only the flags differ:
| Job need | Cursor CLI | Claude Code (2.1.283) | Codex (0.157.1) |
|---|---|---|---|
| Headless run | agent -p "..." | claude -p "..." | codex exec "..." |
| Read-only step | --mode ask (or at least omit --force); confirm in agent --help | --tools "Read,Grep,Glob" | -c default_permissions=":read-only" (beta) |
| Step that writes files | --force | --permission-mode acceptEdits | -c default_permissions=":workspace" (beta) |
| Machine-readable result | --output-format text plus a strict first line | --output-format json, --json-schema | --output-schema FILE, -o FILE |
| Spend ceiling | job timeout-minutes | --max-budget-usd | job timeout-minutes (no budget flag in codex exec --help, 0.157.1) |
The Codex column uses permission profiles, which OpenAI prefers. The legacy --sandbox read-only and --sandbox workspace-write still work in 0.157.1, but the two systems do not compose: pick one per job, never both.
Full versions of these jobs live in scripting Claude Code and running Codex non-interactively. For the permission models side by side, see permissions, sandboxes and approval modes.