DocsGuides
Scripting & automation
shoots is built to be driven by scripts. Clean stdout, --json everywhere, meaningful exit codes, no GUI dependency, no interactive prompts.
The three guarantees
1. stdout is the result, stderr is the noise
| Stream | Carries |
|---|---|
| stdout | The command's result: human table, or the --json document |
| stderr | Logs, warnings, errors, progress bars, --verbose output |
In --json mode, human lines are suppressed entirely, so stdout holds exactly
one JSON document. Piping is always safe:
shoots cull ./raw --json | jq '.summary'
2. Exit codes are meaningful
| Code | Meaning |
|---|---|
0 |
Success |
1 |
One or more per-file operations failed |
2 |
Bad usage — invalid flag, unknown profile, missing prerequisite |
A batch that fails on 3 of 500 files exits 1 and still reports the 497
successes. It does not abort on the first error.
shoots import E:/DCIM --dest ./raw --json || notify-failure
3. No TTY, no interactive UI
The Ink progress view activates only on a TTY. In cron, CI or a pipe you get plain log lines on stderr. Nothing ever waits for input.
JSON shapes
Every command follows the same skeleton:
{
"command": "cull",
"dryRun": false,
"results": [ /* or "files" for import/rename/exif */ ],
"errors": [ { "file": "...", "error": "..." } ],
"summary": { "total": 482, "failed": 0 }
}
errors is always present and always an array. summary always carries total
and failed.
Per-command shapes are documented on each command page.
jq recipes
# ── cull ──────────────────────────────────────────────────────────────────────
shoots cull ./raw --json | jq -r '.results[] | select(.verdict=="blurry") | .file'
shoots cull ./raw --json | jq -r '.results[] | select(.rescued) | "\(.score)\t\(.focusPeak)\t\(.file)"'
shoots cull ./raw --json | jq '.summary.blurry / .summary.total'
# ── rate ──────────────────────────────────────────────────────────────────────
shoots rate ./raw --json | jq -r '.results[] | select(.stars >= 4) | .file'
shoots rate ./raw --json | jq -r '.results[].stars' | sort | uniq -c
shoots rate ./raw --json | jq -r '.results[] | "\(.stars)\t\(.keywords|join(","))\t\(.file)"'
# ── exif ──────────────────────────────────────────────────────────────────────
shoots exif ./raw --json | jq -r '.files[].LensModel' | sort | uniq -c | sort -rn
shoots exif ./raw --json | jq -r '.files[] | select(.FNumber <= 2.0) | .SourceFile'
shoots exif ./raw --json | jq -r '.files[].ISO' | sort -n | uniq -c
# ── import ────────────────────────────────────────────────────────────────────
shoots import E:/DCIM --dest ./raw --json | jq -r '.files[] | select(.dateSource=="mtime") | .source'
shoots import E:/DCIM --dest ./raw --json | jq '.summary'
# ── doctor ────────────────────────────────────────────────────────────────────
shoots doctor --json | jq -r '.checks[] | select(.status != "ok") | "\(.status)\t\(.name)\t\(.detail)"'
shoots doctor --json | jq -e '.summary.failed == 0' > /dev/null
Composing commands
Copy the selects out
shoots rate ./raw --profile wedding --json \
| jq -r '.results[] | select(.stars >= 4) | .file' \
| xargs -I{} cp {} ./selects/
Tag only the rejects
shoots cull ./raw --json \
| jq -r '.results[] | select(.verdict=="blurry") | .file' \
> blurry.txt
while read -r f; do
shoots exif "$f" --set-keywords reject,soft-focus
done < blurry.txt
Cull, then rate only the keepers
Because cull --dest moves rejects out, the keepers are simply what is left:
shoots cull ./catalog --dest ./rejects
shoots rate ./catalog --profile wedding --write-xmp
That ordering matters — it keeps the expensive ML step off frames you have already rejected.
Cron
# Nightly cull of an incoming watch folder
0 2 * * * /usr/local/bin/shoots cull /mnt/incoming --dest /mnt/rejects --json >> /var/log/shoots-cull.log 2>&1
# Weekly update check
0 6 * * 1 /usr/local/bin/shoots update --check --json >> /var/log/shoots-update.log 2>&1
Two cron-specific notes:
- Use absolute paths. cron's
PATHis minimal. - Set
SHOOTS_HOMEif the cron user differs from the user who ransetup— otherwise it resolves to a different home and re-downloads everything.
SHOOTS_HOME=/opt/shoots-home
0 2 * * * /usr/local/bin/shoots cull /mnt/incoming --dest /mnt/rejects --json >> /var/log/shoots.log 2>&1
The develop refine loop has its own installer
Do not hand-write a crontab entry for develop refine. It needs a shoot path,
and which paths are still valid changes every time you move, re-edit or clean a
shoot — plus re-running it on an unchanged shoot is actively harmful (see
shoots schedule).
shoots schedule install registers one entry that resolves the paths at run time
and skips whatever has not changed, on cron and on the Windows Task Scheduler
alike:
shoots schedule install --at 02:30
shoots schedule status
Watch folders
#!/usr/bin/env bash
# Poll an ingest folder; process anything that arrives.
set -euo pipefail
WATCH=/mnt/ingest
CATALOG=/mnt/catalog
REJECTS=/mnt/rejects
while true; do
if [ -n "$(find "$WATCH" -mindepth 1 -maxdepth 1 -print -quit)" ]; then
# Let a slow card finish writing
sleep 30
shoots import "$WATCH" --dest "$CATALOG" --move --json >> /var/log/shoots.log
shoots cull "$CATALOG" --dest "$REJECTS" --json >> /var/log/shoots.log
shoots rate "$CATALOG" --profile wedding --write-xmp --json >> /var/log/shoots.log
fi
sleep 60
done
On Linux, inotifywait is a better trigger than polling. On macOS, fswatch.
CI
GitHub Actions
name: ingest
on:
workflow_dispatch:
jobs:
ingest:
runs-on: ubuntu-latest
steps:
- name: Install shoots
run: curl -fsSL https://raw.githubusercontent.com/stefanopascazi/shoots/main/install.sh | bash
- name: Cache ~/.shoots
uses: actions/cache@v4
with:
path: ~/.shoots
key: shoots-tools-v1
- name: Provision
run: |
shoots setup --json
shoots doctor --json
- name: Cull
run: shoots cull ./incoming --dest ./rejects --json > cull.json
- name: Gate on reject rate
run: |
rate=$(jq '.summary.blurry / .summary.total' cull.json)
echo "reject rate: $rate"
awk -v r="$rate" 'BEGIN { exit (r > 0.4) }' || {
echo "::error::reject rate above 40%"; exit 1;
}
- uses: actions/upload-artifact@v4
with:
name: cull-report
path: cull.json
Caching ~/.shoots is the important bit — otherwise every run re-downloads
exiftool and the model.
Windows / PowerShell
PowerShell has no jq, but ConvertFrom-Json is native:
# The blurry frames
$report = shoots cull .\raw --json | ConvertFrom-Json
$report.results | Where-Object { $_.verdict -eq 'blurry' } | ForEach-Object { $_.file }
# Copy the 4+ star selects
$rated = shoots rate .\raw --profile wedding --json | ConvertFrom-Json
$rated.results |
Where-Object { $_.stars -ge 4 } |
ForEach-Object { Copy-Item $_.file -Destination .\selects\ }
# Star distribution
$rated.results | Group-Object stars | Select-Object Name, Count
# Gate on exit code
shoots import E:\DCIM --dest .\raw --json | Out-Null
if ($LASTEXITCODE -ne 0) { throw "import failed with $LASTEXITCODE" }
Note that PowerShell 5.1 has no && / ||; use ; if ($?) { ... }.
Error handling patterns
Fail fast
set -euo pipefail
shoots import E:/DCIM --dest ./raw --json > import.json
shoots cull ./raw --dest ./rejects --json > cull.json
shoots rate ./raw --write-xmp --json > rate.json
Tolerate partial failures, but record them
shoots import E:/DCIM --dest ./raw --json > import.json
code=$?
failed=$(jq '.summary.failed' import.json)
if [ "$failed" -gt 0 ]; then
echo "warning: $failed files failed" >&2
jq -r '.errors[] | "\(.source): \(.error)"' import.json >&2
fi
[ "$code" -le 1 ] || exit "$code" # 2 = usage error, always fatal
Distinguish usage errors from data errors
shoots rate ./raw --profile "$PROFILE" --json > rate.json
case $? in
0) echo "all rated" ;;
1) echo "some files failed — see .errors" >&2 ;;
2) echo "configuration error: bad profile or missing model" >&2; exit 2 ;;
esac
Performance
| Lever | Effect |
|---|---|
--concurrency <n> |
Default 4. Raise for CPU-bound work (cull, rate) on many cores; lower on constrained machines. |
cull before rate |
Keeps expensive ML off frames you have already rejected. |
--edited-only |
On develop export, skips the expensive work on unedited files. Minutes instead of hours. |
Cache ~/.shoots |
In CI, avoids re-downloading exiftool and the model on every run. |
shoots setup up front |
Moves the download out of the timed part of a pipeline. |
Raising --concurrency for import off a slow card rarely helps — the card is
the bottleneck.
See also
- Core concepts — the conventions this page relies on
- Recipes — complete end-to-end workflows
- Pipelines — the declarative alternative to shell scripts