← cd /blog

Article

Scheduling Gemini CLI Prompts With Cron and a Wrapper Script

·
buildstools

Sixteen of 50 scheduled Gemini CLI runs on my laptop failed on every model and still exited 0. Nothing recorded why, because the jobs sent Gemini's stderr to /dev/null. Under cron, a gemini -p script also starts without your Homebrew PATH or your API key, so start every job from a wrapper that loads both.

The top of runner.sh from gemini-jobs:

#!/usr/bin/env bash
set -euo pipefail

# Load Homebrew (macOS)
if [[ -f /opt/homebrew/bin/brew ]]; then
    eval "$(/opt/homebrew/bin/brew shellenv)"
elif [[ -f /home/linuxbrew/.linuxbrew/bin/brew ]]; then
    eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)"
fi

# Load Gemini API key
if [[ -f "$HOME/.gemini/.env" ]]; then
    set -a
    source "$HOME/.gemini/.env"
    set +a
fi

brew shellenv adds Homebrew's directories to the PATH, which finds node and gemini if Homebrew installed them. If they live somewhere else, add that directory to the PATH here too. set -a exports every variable the .env file sets, the API key included.

A job is a bash script that ends in gemini -p

Gather input with shell commands, put it in the prompt, print the answer. The summarize-folder template from gemini-jobs 0.2.0:

#!/usr/bin/env bash
# @name summarize-folder
# @description Summarizes the contents of a directory — file counts, sizes, recent changes
# @schedule 0 9 * * *

FOLDER="${1:-$HOME/Downloads}"

FILE_COUNT=$(ls -1 "$FOLDER" 2>/dev/null | wc -l | tr -d ' ')
TOTAL_SIZE=$(du -sh "$FOLDER" 2>/dev/null | awk '{print $1}')
LISTING=$(ls -lhS "$FOLDER" 2>/dev/null | head -50)

gemini -p "Summarize this directory ($FILE_COUNT files, $TOTAL_SIZE total).

File listing (sorted by size):
$LISTING

Provide:
- Overview (file count, total size, dominant file types)
- Notable items (largest files, recent additions)
- Suggestions (files to clean up, organize, or archive)

Format as markdown bullet points." 2>/dev/null

The # @ lines are metadata. The CLI reads them to list a job and to suggest its schedule, and a script without them still runs. 2>/dev/null keeps Gemini CLI's stderr out of the result, error messages included. In a scheduled job, send it to the log instead.

Point cron at the runner, not the job

0 9 * * * ~/.gemini-jobs/runner.sh summarize-folder.sh

The runner writes each run to a timestamped log in ~/.gemini-jobs/logs/. Before each run it deletes all but the newest 30 logs for that job. Then it runs the job from inside ~/.gemini-jobs.

Note: Gemini CLI reads @file references only inside its working directory. A job that passes its prompt as @prompt.txt from anywhere else fails with a permission error.

Set it up with npx gemini-jobs

# Set up (installs Gemini CLI if needed, configures API key)
npx gemini-jobs init

# Create a job from a built-in template
npx gemini-jobs create --from summarize-folder

# Schedule it
npx gemini-jobs schedule summarize-folder "0 9 * * *"

# Check on things
npx gemini-jobs list
npx gemini-jobs logs summarize-folder
npx gemini-jobs run summarize-folder    # test run
npx gemini-jobs doctor                  # system health check

Three templates ship with it: summarize-folder, daily-briefing and file-watcher. doctor checks Node.js, Gemini CLI, the API key, the directories and the cron entries, then tests the API connection.

npx installs 0.2.0, the last version on npm.

On macOS, cron can't read ~/Documents or ~/Downloads

macOS privacy controls (TCC) keep cron out of ~/Documents, ~/Desktop and ~/Downloads. Reads there fail with Operation not permitted. summarize-folder reads ~/Downloads by default and sends ls errors to /dev/null. If the read is denied, the error goes there too, and Gemini is handed an empty listing.

Moving to a LaunchAgent doesn't help: one hung on ~/Documents with no error on macOS Sequoia, with Full Disk Access granted to Terminal. Terminal's grant does not carry over to processes launchd starts.

Move the data out of ~/Documents and symlink it back. My vault lives at ~/ObsidianVault now, with a symlink in ~/Documents.

Collect the data in bash and make one Gemini call

My Mac Mini ran a vault audit at 3am and saved the report into the vault as a note. A cut-down version, with the vault path outside ~/Documents, which cron cannot read:

VAULT="$HOME/ObsidianVault"
REPORT="$VAULT/Meta/vault-health-report.md"

FILE_COUNT=$(find "$VAULT" -name "*.md" | wc -l | tr -d ' ')
MISSING_FRONTMATTER=$(find "$VAULT" -name "*.md" -exec sh -c \
  'head -1 "$1" | grep -qv "^---" && echo "$1"' _ {} \; | head -20)
BAD_NAMES=$(find "$VAULT" -name "*.md" \
  -not -regex '.*/[a-z0-9-]+\.md' | head -20)
BROKEN_LINKS=$(grep -roh '\[\[.*\]\]' "$VAULT"/*.md 2>/dev/null \
  | sort -u | head -30)

# ... the prompt lists all four and asks for a graded report

RESULT=$(gemini -p "$PROMPT" 2>/dev/null)

cat > "$REPORT" << EOF
---
title: Vault Health Report
date: $(date '+%Y-%m-%d')
type: meta
---

$RESULT
EOF

Two lines misbehave on macOS even with access. The system find reads -regex as a basic regex, where + is a literal character, so BAD_NAMES flags every note. Use find -E. The link grep reads only top-level notes, and its greedy .* returns two links on one line as a single match.

Pick the model on each call

gemini -m gemini-2.5-flash -p "Your prompt" 2>/dev/null

Without -m, the CLI picks the model. Gemini CLI v0.28.2 defaults to gemini-2.5-pro, which had a smaller free quota than Flash in the last table Google published. v0.39.0 defaults to an auto mode that resolves to a Gemini 3 Pro preview. Google stopped publishing per-model free-tier numbers in December 2025, so read your key's limits in AI Studio.

Logging in with a Google account instead of a key gives 1,000 requests a day across the Gemini models. There is no gemini auth command (checked on v0.28.2 and v0.39.0): run gemini and pick Login with Google.

Note: gemini-2.0-flash lost its free tier in December 2025, and requests to it return limit: 0. The daily-briefing template on npm still calls gemini -m gemini-2.0-flash, with a comment that says it has 1,500 free requests a day. Change the model before you schedule it.

The CLI's list command prints a quota estimate:

  Estimated daily usage: ~3+ scheduled job(s) per day (free tier: 25 req/day)

The 25 is hard-coded in list.ts and doctor.ts. It matches none of the free-tier figures in Google's last published table. The estimate also counts daily cron entries, not calls: file-watcher, every six hours, counts as one.

Log Gemini's stderr and fail on an empty answer

The fallback below is in version 0.3.0 of the runner, which never reached npm. A job names a model and a fallback list in its header (# @model, # @fallback) and calls gemini_call instead of gemini -p:

# gemini_call — calls Gemini CLI with automatic model fallback
# Usage: gemini_call "your prompt here"
# Tries primary model first, then each fallback until one succeeds.
gemini_call() {
    local prompt="$1"
    local models=("$GEMINI_PRIMARY_MODEL")
    IFS=',' read -ra fallbacks <<< "$GEMINI_FALLBACK_MODELS"
    models+=("${fallbacks[@]}")

    for model in "${models[@]}"; do
        model=$(echo "$model" | xargs)  # trim whitespace
        [[ -z "$model" ]] && continue

        local output
        if output=$(gemini --model "$model" -p "$prompt" 2>/dev/null) && [[ -n "$output" ]]; then
            echo "[gemini_call] Success with model: $model" >&2
            echo "$output"
            return 0
        fi
        echo "[gemini_call] Failed with model: $model — trying next..." >&2
    done

    echo "[gemini_call] ERROR: All models exhausted" >&2
    return 1
}
export -f gemini_call

Empty output counts as a failure. The default chain is gemini-2.5-pro, gemini-2.0-flash, gemini-2.5-flash. Remove gemini-2.0-flash from the chain: it returns limit: 0.

My three daily jobs on the laptop, scheduled by LaunchAgents, ran through this runner. Of their 50 runs that called Gemini, 16 failed on every model, all between 26 February and 8 March. One of them:

=== AI Job: news-briefing ===
Started: Fri Feb 27 05:13:45 PST 2026
---
Model: gemini-2.5-flash (fallback: gemini-2.0-flash)
...
Generating briefing...
[gemini_call] Failed with model: gemini-2.5-flash — trying next...
[gemini_call] Failed with model: gemini-2.0-flash — trying next...
[gemini_call] ERROR: All models exhausted
WARNING: Gemini returned empty. All models failed.
...
*News briefing unavailable (all models failed)*
---
Finished: Fri Feb 27 07:25:02 PST 2026
Exit code: 0

The log does not record why each call failed. Each of the 16 ran for between 63 and 206 minutes, wrote a placeholder, and exited 0. Send Gemini's stderr to the log instead of /dev/null. Exit non-zero on an empty answer, as the daily-briefing template does.