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
@filereferences only inside its working directory. A job that passes its prompt as@prompt.txtfrom 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-flashlost its free tier in December 2025, and requests to it returnlimit: 0. Thedaily-briefingtemplate on npm still callsgemini -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.