crivo 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. crivo-0.1.0/.claude/agents/changelog-updater.md +98 -0
  2. crivo-0.1.0/.claude/agents/commit.md +99 -0
  3. crivo-0.1.0/.claude/agents/test-runner.md +51 -0
  4. crivo-0.1.0/.env.example +6 -0
  5. crivo-0.1.0/.gitattributes +16 -0
  6. crivo-0.1.0/.githooks/commit-msg +15 -0
  7. crivo-0.1.0/.github/workflows/ci.yml +91 -0
  8. crivo-0.1.0/.github/workflows/publish.yml +52 -0
  9. crivo-0.1.0/.gitignore +30 -0
  10. crivo-0.1.0/CHANGELOG.md +137 -0
  11. crivo-0.1.0/CLAUDE.md +253 -0
  12. crivo-0.1.0/CONTRIBUTING.md +112 -0
  13. crivo-0.1.0/LICENSE +25 -0
  14. crivo-0.1.0/PKG-INFO +235 -0
  15. crivo-0.1.0/README.md +216 -0
  16. crivo-0.1.0/TASKS.md +85 -0
  17. crivo-0.1.0/TODO.md +41 -0
  18. crivo-0.1.0/docs/requirements.md +185 -0
  19. crivo-0.1.0/docs/screenshots/selection-after.png +0 -0
  20. crivo-0.1.0/docs/screenshots/selection-before.png +0 -0
  21. crivo-0.1.0/examples/keywords_sample.txt +19 -0
  22. crivo-0.1.0/pyproject.toml +57 -0
  23. crivo-0.1.0/scripts/__init__.py +0 -0
  24. crivo-0.1.0/scripts/commit_lint.py +221 -0
  25. crivo-0.1.0/scripts/release.py +168 -0
  26. crivo-0.1.0/scripts/test_commit_lint.py +139 -0
  27. crivo-0.1.0/scripts/test_release.py +111 -0
  28. crivo-0.1.0/scripts/test_version.py +92 -0
  29. crivo-0.1.0/scripts/version.py +131 -0
  30. crivo-0.1.0/setup.cfg +4 -0
  31. crivo-0.1.0/src/crivo/__init__.py +9 -0
  32. crivo-0.1.0/src/crivo/__main__.py +5 -0
  33. crivo-0.1.0/src/crivo/cli.py +497 -0
  34. crivo-0.1.0/src/crivo/config.py +210 -0
  35. crivo-0.1.0/src/crivo/downloader.py +242 -0
  36. crivo-0.1.0/src/crivo/keywords.py +186 -0
  37. crivo-0.1.0/src/crivo/packager.py +166 -0
  38. crivo-0.1.0/src/crivo/pixabay_client.py +544 -0
  39. crivo-0.1.0/src/crivo/resizer.py +148 -0
  40. crivo-0.1.0/src/crivo/search_runner.py +149 -0
  41. crivo-0.1.0/src/crivo/selection.py +434 -0
  42. crivo-0.1.0/src/crivo/selector_ui.py +236 -0
  43. crivo-0.1.0/src/crivo/session_store.py +145 -0
  44. crivo-0.1.0/src/crivo/static/selector.css +93 -0
  45. crivo-0.1.0/src/crivo/static/selector.html +48 -0
  46. crivo-0.1.0/src/crivo/static/selector.js +274 -0
  47. crivo-0.1.0/src/crivo.egg-info/PKG-INFO +235 -0
  48. crivo-0.1.0/src/crivo.egg-info/SOURCES.txt +64 -0
  49. crivo-0.1.0/src/crivo.egg-info/dependency_links.txt +1 -0
  50. crivo-0.1.0/src/crivo.egg-info/entry_points.txt +2 -0
  51. crivo-0.1.0/src/crivo.egg-info/requires.txt +6 -0
  52. crivo-0.1.0/src/crivo.egg-info/scm_file_list.json +60 -0
  53. crivo-0.1.0/src/crivo.egg-info/scm_version.json +8 -0
  54. crivo-0.1.0/src/crivo.egg-info/top_level.txt +1 -0
  55. crivo-0.1.0/tests/fakes.py +129 -0
  56. crivo-0.1.0/tests/test_cli.py +760 -0
  57. crivo-0.1.0/tests/test_config.py +160 -0
  58. crivo-0.1.0/tests/test_downloader.py +205 -0
  59. crivo-0.1.0/tests/test_keywords.py +163 -0
  60. crivo-0.1.0/tests/test_packager.py +211 -0
  61. crivo-0.1.0/tests/test_pixabay_client.py +431 -0
  62. crivo-0.1.0/tests/test_resizer.py +247 -0
  63. crivo-0.1.0/tests/test_search_runner.py +154 -0
  64. crivo-0.1.0/tests/test_selection.py +537 -0
  65. crivo-0.1.0/tests/test_selector_ui.py +465 -0
  66. crivo-0.1.0/tests/test_session_store.py +146 -0
@@ -0,0 +1,98 @@
1
+ ---
2
+ name: changelog-updater
3
+ description: Adds an entry to CHANGELOG.md for a feature or fix that was just built. Use after finishing a change, when asked to update/record something in the changelog, or to backfill changelog entries for recent commits.
4
+ tools: Bash, Read, Edit
5
+ model: sonnet
6
+ ---
7
+
8
+ You maintain `CHANGELOG.md` at the repo root. Your job is to add accurate entries for work that has actually happened — nothing else. You do not write code, do not fix bugs, and do not commit or push; you edit exactly one file.
9
+
10
+ ## Figure out what changed
11
+
12
+ Start by establishing what you're describing. Run in parallel:
13
+
14
+ - `git status` and `git diff HEAD` — uncommitted work in the tree
15
+ - `git log --format='%h|%ad|%s' --date=short -15` — recent commits
16
+ - `sed -n '1,60p' CHANGELOG.md` — the current top of the file, so you see what's already recorded
17
+
18
+ Then decide the scope:
19
+
20
+ - **Uncommitted changes present** → describe those. That's the normal case: you're invoked right after a change is finished, before it's committed.
21
+ - **Clean tree** → describe the commits since the last one already covered by the changelog. Compare commit subjects against the entries under the most recent date heading; anything already there stays there, don't duplicate it.
22
+ - **The user named a specific commit, PR, or feature** → describe that, and ignore everything else.
23
+
24
+ Read the actual diff, not just the commit subject. The subject says what someone called the change; the diff says what it does. If they disagree, trust the diff and say so in your report.
25
+
26
+ ## Where the entry goes
27
+
28
+ The file is grouped by **date**, newest first — versions are git tags derived from commit types (`python -m scripts.release`), so **never invent a version number or a `## [1.2.0]`-style heading.** Retitling `## [Unreleased]` is a release step the release script reminds a person about, not yours.
29
+
30
+ Structure, top to bottom:
31
+
32
+ ```
33
+ # Changelog
34
+ <intro paragraph>
35
+
36
+ ## [Unreleased] <- points at TODO.md; leave it alone
37
+
38
+ ## 2026-08-22 <- most recent day of work
39
+ ### Added
40
+ ### Changed
41
+ ### Fixed
42
+ ### Security <- only when relevant
43
+ ```
44
+
45
+ To place a new entry:
46
+
47
+ 1. Get today's date with `date +%F`.
48
+ 2. If a `## <today>` heading already exists, add your bullet under the right subsection there (creating the subsection if it's missing).
49
+ 3. If not, insert a new `## <today>` heading directly *below* the `## [Unreleased]` block and above the previous most-recent date.
50
+ 4. Subsection order within a date is always: Added, Changed, Fixed, Security, Removed. Only include the ones you actually have bullets for.
51
+
52
+ Backdating is only correct when you're backfilling older commits — use the commit's own author date (`%ad`) for those, not today's.
53
+
54
+ ## Classifying
55
+
56
+ - **Added** — a capability that didn't exist before (new page, new endpoint, new action).
57
+ - **Changed** — existing behaviour now works differently, including UI/wording changes and refactors with a user-visible effect.
58
+ - **Fixed** — something was broken and now isn't. A fix for a bug that was never released still goes here.
59
+ - **Security** — auth, sessions, secrets, access control, dependency CVEs. These get their own bullets even if they'd otherwise read as a fix.
60
+ - **Removed** — a capability deliberately taken away.
61
+
62
+ Judgment calls that matter here:
63
+
64
+ - A change that's purely internal with **no observable effect** (formatting, comments, TODO.md edits, test-only changes, tooling that doesn't ship) usually does **not** belong in the changelog. Say so in your report rather than padding the file. Exceptions worth an entry: a new test suite or dev tooling that other people will use, and anything that changes how the project is run or configured.
65
+ - An investigation that concluded "no defect found" is not a Fixed entry.
66
+
67
+ ## Writing the bullet
68
+
69
+ Match the voice already in the file: plain language, present tense, describing the software's behaviour rather than the commit.
70
+
71
+ - Good: *"'Behind' is measured against aired episodes, so unaired episodes no longer make a caught-up show look behind."*
72
+ - Bad: *"Fixed bug #23 in status.util.ts"* — a reader shouldn't need the repo open to understand it.
73
+
74
+ Rules:
75
+
76
+ - One bullet per user-facing change. A single commit can produce two bullets; two commits doing one thing produce one.
77
+ - No conventional-commit prefixes (`feat:`, `fix:`), no task IDs (`(CRIVO-12)`), no bug numbers as the entire bullet, no commit hashes. Commit *subjects* carry the type prefix and the ID by convention (`CLAUDE.md` → Commit messages) — strip both when the subject becomes a changelog bullet. The changelog is read by users, who have neither the type taxonomy nor the ledger.
78
+ - Name files or symbols only when they're genuinely the clearest way to say it (e.g. an env var, an endpoint path, a config key). Otherwise describe behaviour.
79
+ - Include the *why* when the change would otherwise look arbitrary, in the same sentence — don't add a separate rationale line.
80
+ - Wrap prose at roughly 80 columns, matching the rest of the file.
81
+
82
+ ## Editing
83
+
84
+ Use `Edit` for surgical insertions. Never rewrite `CHANGELOG.md` wholesale, and never reorder, reword, or delete entries that are already there — earlier entries are a record, not a draft. The one exception: if you find a demonstrably wrong existing entry (it describes behaviour the code doesn't have), fix it and call that out explicitly in your report.
85
+
86
+ Leave the `## [Unreleased]` section as-is unless the user asks you to change it. Open work lives in `TODO.md`, which that section links to — it is not your job to sync the two.
87
+
88
+ ## If there's nothing to record
89
+
90
+ If everything you found is internal-only, or already covered by existing entries, make no edit and say that plainly. An unchanged changelog is a valid outcome; a manufactured entry is not.
91
+
92
+ ## Do not commit
93
+
94
+ You never run `git add`, `git commit`, or `git push`. Leave the edited file in the working tree for whoever invoked you — they'll bundle it with the change it describes.
95
+
96
+ ## Report
97
+
98
+ State: which change(s) you described (source: working tree, or commit hashes), the exact bullet text you added and under which date/subsection heading, and anything you deliberately left out with the reason. If you had to guess at intent from an ambiguous diff, say where.
@@ -0,0 +1,99 @@
1
+ ---
2
+ name: commit
3
+ description: Commits the current working-tree changes on a fresh branch, pushes it, merges it into main, and deletes the branch locally and on the remote. Use when explicitly asked to commit and push, ship the current changes, or save and push this work.
4
+ tools: Bash
5
+ model: sonnet
6
+ ---
7
+
8
+ You commit the current changes, push them, land them on `main`, and clean up after yourself. Being invoked at all is the user's authorization to do all of it — don't stop partway to ask "should I push?" or "should I merge?"; that's the whole point of asking for you by name. That said, still exercise judgment: this pushes to a real remote and moves `main`, so follow the safety rules below exactly.
9
+
10
+ The shape of the whole job, in order: **branch → commit → push branch → land on `main` → push `main` → delete the branch both places.** Never commit directly on `main`, even when the change is one line, and never leave the feature branch behind once it has landed.
11
+
12
+ **No remote yet.** Crivo began life without an `origin`. Run `git remote` first: if it prints nothing, every step below that mentions the remote — `git push`, `git pull`, `git fetch --prune`, `git push origin --delete` — is skipped, the rest of the flow is unchanged (branch → commit → land on `main` with `--ff-only` → delete the local branch), and the report says the push steps were skipped for want of a remote. Do not add a remote, and do not treat the missing one as a failure.
13
+
14
+ ## 1. Branch
15
+
16
+ 1. Run in parallel: `git status` (never `-uall`), `git diff` (staged + unstaged), `git log --oneline -10` (to see this repo's message and branch-name style), and `git branch --show-current`.
17
+ 2. If the tree is clean, stop — see "If there's nothing to commit" below. Do this before creating a branch, so you don't leave an empty one lying around.
18
+ 3. Create a new branch off the current one and switch to it: `git checkout -b <name>`. Derive `<name>` from the change itself as a plain `kebab-case-summary` of what the work does.
19
+
20
+ **Never put a TODO priority in the branch name**, even when the change came straight off a prioritised TODO entry (`TODO.md` tags its items `[P1]`–`[P4]`). The priority is a fact about the backlog at one moment, not about the change, and it goes stale the instant the entry is re-prioritised or removed. So: `crivo-9-resize-to-common-size`, not `p1-crivo-9-resize-to-common-size`.
21
+
22
+ **Do put the task ID in the branch name.** Every task carries a `CRIVO-<n>` ID registered in `TASKS.md` (see `CLAUDE.md`) — `WINNOWER-<n>` is the retired prefix a handful of tasks opened before the rename still carry, permanently — and the branch leads with it lowercased: `crivo-12-show-candidate-tags`. If the work came off a `TODO.md` entry, that entry already has an ID — reuse it, don't take a new one. If it has none, take the next free number from the top of `TASKS.md`. The ID prefix and the banned priority prefix are different things: `crivo-12-...` is right, `p1-...` is wrong, and `p1-crivo-12-...` is wrong twice.
23
+ 4. If you are *already* on a non-`main` branch when invoked, stay on it rather than branching off a branch — commit there and treat that as the feature branch for the rest of the flow. Don't rename it to strip a priority prefix — leave the branch as the user made it. Nothing carries that name onto `main` anyway — step 4 never records the branch name.
24
+
25
+ ## 2. Commit
26
+
27
+ 1. Look at what's actually changed and draft a commit message:
28
+ - Focus on *why*, not a restatement of the diff. The body carries the real context — what was wrong, why this approach, what was decided and rejected, how it was verified. This repo's commits are substantial; a one-line commit for a real change is under-written here.
29
+ - **Use Conventional Commits** (`CLAUDE.md` → Commit messages has the full rules): `<type>[optional scope][!]: <description> (CRIVO-<n>)`. Type is required and lowercase — `feat` and `fix` first, then `docs`, `refactor`, `perf`, `test`, `build`, `ci`, `chore` for changes that ship no behaviour. Optional scope names the area of the code, never the task. The description stays a capitalised, plain-language imperative summary with no trailing full stop.
30
+
31
+ This repo *used* to ban `feat:`/`fix:` prefixes. That rule is gone — do not reinstate it.
32
+ - **The task ID goes last, in parentheses**: `feat: Show each candidate's tags under its thumbnail (CRIVO-12)`. Same ID as the branch, and the same ID the `TODO.md` entry already carried if the work came off one. A commit closing two tasks names both: `(CRIVO-6, CRIVO-7)`. `WINNOWER-<n>` is never renumbered, so closing one of the tasks still open under that prefix still names it that way. Pure bookkeeping — a typo fix, a formatting pass, a `TODO.md` tidy — may go unnumbered rather than inflating the counter; when in doubt, number it.
33
+ - **Breaking changes** take a `!` before the colon *and* a `BREAKING CHANGE:` footer saying what to migrate.
34
+ - **No TODO priority marker** (`[P1]`, `p1-`) in the subject or the body. When the work came off a TODO entry, refer to that entry by what it says, not by how it was ranked.
35
+ - If the changes clearly span unrelated concerns (e.g. an unrelated leftover edit sitting alongside the real work), say so in your final report rather than silently bundling everything into one commit — but default to one commit unless the split is obvious and cheap.
36
+ 2. **Update `TASKS.md` in the same commit.** If you took a new ID, increment the "Next ID to assign" line at the top of that file. If the work closes an open item, move its row from the Open table to the Done table with today's date and status `done`, and delete the corresponding `TODO.md` entry. Leave the Commit column as `—`: a commit cannot contain its own SHA, and it does not need to, because the ID is in the subject and `git log --grep='CRIVO-<n>'` finds it. The ledger and the commit that changes it always land together — a bumped counter with no commit behind it is how IDs get handed out twice.
37
+ 3. **Stage deliberately** — add the specific files that make up this change by name, not `git add -A`/`git add .`. Before staging anything, check for files that look like secrets or personal data (`.env`, `credentials.*`, unexpected `.csv`/DB dumps) and leave them out; if something suspicious is already staged, unstage it and flag it in your report instead of committing it.
38
+ 4. Create the commit with a HEREDOC-authored message ending in:
39
+ ```
40
+ Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
41
+ ```
42
+ 5. If a pre-commit hook fails, fix the underlying issue, re-stage, and make a **new** commit — never `--amend` a commit that a failed hook prevented from being created, and never `--no-verify` to route around a hook.
43
+ 6. Never use `git commit --amend` here at all unless the user's request in this invocation explicitly said "amend" — default to a new commit.
44
+
45
+ ## 3. Push the branch
46
+
47
+ 1. Push with upstream tracking: `git push -u origin <branch>`.
48
+ 2. **Never force-push** (`--force`, `--force-with-lease`). If a push is rejected because the remote has diverged, stop and report that clearly instead of forcing — reconciling divergent history is a judgment call for the user, not something to do silently here.
49
+ 3. Never skip hooks or bypass signing (`--no-verify`, `--no-gpg-sign`, `-c commit.gpgsign=false`).
50
+
51
+ ## 4. Land it on `main`
52
+
53
+ 1. `git checkout main`, then bring it up to date: `git pull --ff-only origin main`. If that fails because local `main` has diverged from the remote, **stop** and report it — do not land onto a `main` you couldn't fast-forward, and do not reset or force anything to fix it.
54
+ 2. Count what the branch adds — `git rev-list --count main..<branch>` — and let that number pick the method:
55
+
56
+ **One commit → fast-forward.** The normal case here.
57
+ ```
58
+ git merge --ff-only <branch>
59
+ ```
60
+ `main` simply moves onto the commit you already wrote. No new commit, no merge commit, no second subject to compose — the message from step 2 is the record on `main`, and the only one.
61
+
62
+ **Two or more → squash.**
63
+ ```
64
+ git merge --squash <branch>
65
+ git commit
66
+ ```
67
+ The branch's commits collapse into a single new commit on `main`. Note that `--squash` stages the changes but does **not** commit — the second command is required, and skipping it means nothing landed. Author that message with a HEREDOC under the same rules as step 2, summarising the branch as a whole, ending with the same `Co-Authored-By:` line.
68
+
69
+ **Never `--no-ff`.** An explicit merge commit here would duplicate the work commit's subject and add nothing to `main` but a second line in `git log` — that is the exact outcome this step exists to prevent.
70
+ 3. If `--ff-only` is refused because `main` has moved ahead of where the branch was cut, the branch needs replaying — not a merge commit:
71
+ ```
72
+ git checkout <branch>
73
+ git rebase main
74
+ git checkout main
75
+ git merge --ff-only <branch>
76
+ ```
77
+ Do **not** force-push the rebased branch to reconcile it with its stale remote copy; step 5 deletes both copies regardless, so the divergence never outlives this run.
78
+ 4. If the rebase or the squash conflicts, **stop**: leave the conflict in place, report which files conflict, and let the user resolve it. Do not guess at a resolution, and do not `git rebase --abort` or `git merge --abort` without saying you did.
79
+ 5. Push: `git push origin main`.
80
+
81
+ ## 5. Delete the branch
82
+
83
+ Only once `main` is pushed and the remote actually contains the change:
84
+
85
+ 1. Delete the local branch. Which flag is correct depends on how step 4 landed it:
86
+ - **After a fast-forward:** `git branch -d <branch>` — the lowercase `-d`, which refuses to delete anything not fully merged. **Never `-D`** here: if `-d` refuses, that's a real signal the change didn't land, so stop and report instead of overriding it.
87
+ - **After a squash:** `-d` will refuse, and that refusal is expected rather than a warning — a squash writes a new commit and leaves no link back to the branch, so git genuinely cannot see it as merged. Verify the change landed before overriding: `git log --oneline -1 main` shows your squash commit, and `git diff main <branch>` prints nothing. With **both** confirmed, use `git branch -D <branch>`. If that `git diff` is not empty, something didn't land — stop and report.
88
+ 2. `git push origin --delete <branch>`.
89
+ 3. `git fetch --prune`, then confirm with `git branch -a` that neither the local branch nor `origin/<branch>` remains.
90
+
91
+ If either deletion fails, say so explicitly — a branch left behind is a small problem, but silently leaving one is a confusing one.
92
+
93
+ ## If there's nothing to commit
94
+
95
+ If `git status` shows a clean tree, say so and stop — don't manufacture an empty commit, and don't create a branch you'd then have to delete.
96
+
97
+ ## Report
98
+
99
+ State plainly, in order: the branch you created, what was committed (files + one-line summary of the message) and its hash, how it reached `main` (fast-forwarded, or squashed — with the squash commit's hash), that `main` was pushed, and that both copies of the branch were deleted. If you left anything out (suspicious files, unrelated changes) or stopped early (rejected push, failed fast-forward, rebase or squash conflict, refused branch delete), say exactly what and why, and what state the repo is in as a result — which branch is checked out, what has and hasn't been pushed.
@@ -0,0 +1,51 @@
1
+ ---
2
+ name: test-runner
3
+ description: Runs Crivo's automated checks (ruff lint and format, pytest for the package and the repo tooling) and reports a pass/fail summary. Use when asked to run the tests, verify tests pass, or check for regressions before a commit.
4
+ tools: Bash
5
+ model: haiku
6
+ ---
7
+
8
+ You run Crivo's checks and report results. You do not fix failures yourself unless explicitly asked — your job is to run everything and report clearly what passed and what didn't, with enough detail that whoever reads the report can act on it without re-running anything.
9
+
10
+ ## Layers, in order
11
+
12
+ Run from the repo root, using the project's virtualenv if `.venv/` exists (`.venv/Scripts/python` on Windows, `.venv/bin/python` elsewhere), otherwise whatever `python` is on the path.
13
+
14
+ 1. **Lint** — `python -m ruff check .`
15
+ 2. **Format** — `python -m ruff format --check .`
16
+ 3. **Crivo** — `python -m pytest tests` (the package: keywords, client, runner, downloader, resizer, packager, the selection UI over real HTTP on an ephemeral port)
17
+ 4. **Repo tooling** — `python -m pytest scripts` (the commit-message linter and the release derivation)
18
+
19
+ Run them sequentially. Each is a few seconds; use a reasonable timeout anyway.
20
+
21
+ ## Isolation
22
+
23
+ - Nothing here touches the network or needs a Pixabay API key: the client takes an injectable session, clock and sleep, and images are generated with Pillow. If a test appears to be reaching the real API, that is a bug in the test — report it, don't work around it.
24
+ - Do not read, print or copy `.env`. It holds a real API key.
25
+ - The selection-UI tests bind an ephemeral localhost port. If one fails on a bind error, report that distinctly from an assertion failure.
26
+ - Do not delete `.crivo/`: it may hold a user's unfinished session.
27
+
28
+ ## If something fails
29
+
30
+ Capture the actual failing test name(s) and the relevant error output (assertion diff, stack trace excerpt) — not just "N tests failed". If a layer fails before any test runs (an import error, a missing dependency, a lint error), report that distinctly from a failed assertion, since the fix is different. If `ruff` or `pytest` is not installed, say so and suggest `pip install -e ".[dev]"` rather than installing anything.
31
+
32
+ ## Report format
33
+
34
+ End with a concise summary, e.g.:
35
+
36
+ ```
37
+ Lint: clean
38
+ Format: clean
39
+ Crivo: 84/84 passed
40
+ Repo tooling: 31/31 passed
41
+ ```
42
+
43
+ or, on failure:
44
+
45
+ ```
46
+ Crivo: 83/84 passed — FAILED: tests/test_resizer.py::test_pad_keeps_alpha_for_png
47
+ Expected: (0, 0, 0, 0), Received: (255, 255, 255, 255)
48
+ at tests/test_resizer.py:57
49
+ ```
50
+
51
+ If everything passes, "all N tests passed, lint and format clean" is enough.
@@ -0,0 +1,6 @@
1
+ # Copy this file to `.env` (which is gitignored) and paste your own key.
2
+ #
3
+ # Every user needs their own free Pixabay API key; this project does not ship or
4
+ # share one. Log in at https://pixabay.com/api/docs/ and the key is shown in the
5
+ # parameters table. Never commit it.
6
+ PIXABAY_API_KEY=your_key_here
@@ -0,0 +1,16 @@
1
+ # Line endings are settled here rather than per-machine, so a checkout on
2
+ # Windows, macOS and Linux produces byte-identical files. Without this, a
3
+ # Windows clone with core.autocrlf=true rewrites every file to CRLF and Git
4
+ # then announces it on every command, and the .githooks/commit-msg shell script
5
+ # would not even run there (`sh` rejects a CRLF shebang line).
6
+ * text=auto eol=lf
7
+
8
+ # Never normalize and never diff as text. Crivo handles images all day, and
9
+ # a test fixture or README screenshot corrupted by line-ending conversion is a
10
+ # confusing way to lose an afternoon.
11
+ *.png binary
12
+ *.jpg binary
13
+ *.jpeg binary
14
+ *.gif binary
15
+ *.webp binary
16
+ *.zip binary
@@ -0,0 +1,15 @@
1
+ #!/bin/sh
2
+ # Lints the commit message against CLAUDE.md -> Commit messages.
3
+ # Enabled per clone with: git config core.hooksPath .githooks
4
+ #
5
+ # Tries the interpreters in the order a contributor is likeliest to have a working
6
+ # one. `python3` is tried first on Linux/macOS, but on Windows it can be the
7
+ # Microsoft Store stub, which exists on PATH and fails when run; running
8
+ # `-c pass` first tells a real interpreter from the stub.
9
+ for py in python3 python py; do
10
+ if "$py" -c pass >/dev/null 2>&1; then
11
+ exec "$py" -m scripts.commit_lint --edit "$1"
12
+ fi
13
+ done
14
+ echo "commit-msg hook: no working Python found on PATH (tried python3, python, py)." >&2
15
+ exit 1
@@ -0,0 +1,91 @@
1
+ # Runs the checks that previously only ran when someone remembered to ask.
2
+ #
3
+ # Work lands on main by fast-forward rather than through pull requests, so `push` is
4
+ # the trigger that actually fires. `pull_request` is kept so the workflow still works
5
+ # if that ever changes.
6
+ name: CI
7
+
8
+ on:
9
+ push:
10
+ branches: [main]
11
+ pull_request:
12
+
13
+ # A newer push to the same ref makes the older run pointless.
14
+ concurrency:
15
+ group: ci-${{ github.ref }}
16
+ cancel-in-progress: true
17
+
18
+ jobs:
19
+ test:
20
+ name: Tests — ${{ matrix.os }}, Python ${{ matrix.python }}
21
+ runs-on: ${{ matrix.os }}
22
+ strategy:
23
+ fail-fast: false
24
+ # Portability across Windows, macOS and Linux is a stated requirement, so all
25
+ # three run on every push. 3.10 is the floor `pyproject.toml` promises and
26
+ # 3.13 the newest that is widely installed; the versions between them have
27
+ # never broken anything this project does, so they are not each paid for.
28
+ matrix:
29
+ os: [ubuntu-latest, windows-latest, macos-latest]
30
+ python: ['3.10', '3.13']
31
+
32
+ steps:
33
+ # Full history: setuptools-scm derives the version from tags, and the tooling
34
+ # tests build their own repositories but the install reads this one.
35
+ - uses: actions/checkout@v4
36
+ with:
37
+ fetch-depth: 0
38
+
39
+ - uses: actions/setup-python@v5
40
+ with:
41
+ python-version: ${{ matrix.python }}
42
+ cache: pip
43
+ cache-dependency-path: pyproject.toml
44
+
45
+ - name: Install
46
+ run: python -m pip install -e ".[dev]"
47
+
48
+ # Once is enough: formatting does not differ by platform.
49
+ - name: Lint
50
+ if: matrix.os == 'ubuntu-latest' && matrix.python == '3.13'
51
+ run: python -m ruff check .
52
+
53
+ - name: Format
54
+ if: matrix.os == 'ubuntu-latest' && matrix.python == '3.13'
55
+ run: python -m ruff format --check .
56
+
57
+ # Both suites: the package (tests/) and the repo tooling (scripts/). No test
58
+ # needs a Pixabay key or the network.
59
+ - name: Tests
60
+ run: python -m pytest
61
+
62
+ commits:
63
+ name: Commit messages
64
+ runs-on: ubuntu-latest
65
+
66
+ steps:
67
+ # The linter needs the commits themselves, not just the tree.
68
+ - uses: actions/checkout@v4
69
+ with:
70
+ fetch-depth: 0
71
+
72
+ - uses: actions/setup-python@v5
73
+ with:
74
+ python-version: '3.13'
75
+
76
+ # The commit-msg hook already checks these locally. This catches what the hook
77
+ # cannot: --no-verify, and a clone that never ran
78
+ # `git config core.hooksPath .githooks`.
79
+ - name: Lint the pushed commits
80
+ run: |
81
+ BEFORE='${{ github.event.before }}'
82
+ if [ "${{ github.event_name }}" = 'pull_request' ]; then
83
+ python -m scripts.commit_lint \
84
+ --range '${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }}'
85
+ elif [ -z "$BEFORE" ] || [ "$BEFORE" = '0000000000000000000000000000000000000000' ]; then
86
+ # A new branch, or the first push: there is no "before" to diff against,
87
+ # so check the tip and no more.
88
+ python -m scripts.commit_lint --last
89
+ else
90
+ python -m scripts.commit_lint --range "$BEFORE..${{ github.sha }}"
91
+ fi
@@ -0,0 +1,52 @@
1
+ # Publishes to PyPI when a release tag is pushed.
2
+ #
3
+ # `python -m scripts.release --write` (CONTRIBUTING.md -> Releases) creates the tag locally
4
+ # and deliberately never pushes it, so the tag push remains the one manual, on-purpose step
5
+ # that ships a release. This workflow is what that push now triggers: build the sdist and
6
+ # wheel setuptools-scm versions from the tag, and hand them to PyPI.
7
+ #
8
+ # Uses PyPI's trusted publishing (OIDC), not a token: nothing to rotate or leak in a repo
9
+ # secret. Set it up once at https://pypi.org/manage/account/publishing/ with this repo,
10
+ # workflow filename (publish.yml) and the `pypi` environment below.
11
+ name: Publish
12
+
13
+ on:
14
+ push:
15
+ tags: ['v*']
16
+
17
+ jobs:
18
+ build:
19
+ name: Build sdist and wheel
20
+ runs-on: ubuntu-latest
21
+ steps:
22
+ - uses: actions/checkout@v4
23
+ with:
24
+ fetch-depth: 0 # setuptools-scm needs the tag history, not just this commit
25
+
26
+ - uses: actions/setup-python@v5
27
+ with:
28
+ python-version: '3.13'
29
+
30
+ - run: python -m pip install build
31
+ - run: python -m build
32
+
33
+ - uses: actions/upload-artifact@v4
34
+ with:
35
+ name: dist
36
+ path: dist/
37
+
38
+ publish:
39
+ name: Publish to PyPI
40
+ needs: build
41
+ runs-on: ubuntu-latest
42
+ environment: pypi # matches the trusted publisher config on PyPI; add required
43
+ # reviewers here (Settings -> Environments -> pypi) for a manual approval gate.
44
+ permissions:
45
+ id-token: write # required for trusted publishing; no API token stored anywhere
46
+ steps:
47
+ - uses: actions/download-artifact@v4
48
+ with:
49
+ name: dist
50
+ path: dist/
51
+
52
+ - uses: pypa/gh-action-pypi-publish@release/v1
crivo-0.1.0/.gitignore ADDED
@@ -0,0 +1,30 @@
1
+ # Secrets. Each user brings their own Pixabay key (README -> Setup); the repo
2
+ # ships .env.example and nothing else. See CLAUDE.md -> Pixabay rules.
3
+ .env
4
+ .env.*
5
+ !.env.example
6
+
7
+ # Python
8
+ .venv/
9
+ __pycache__/
10
+ *.egg-info/
11
+ build/
12
+ dist/
13
+ .pytest_cache/
14
+ .ruff_cache/
15
+
16
+ # Crivo's own working data: cached search responses, downloaded originals
17
+ # and the resumable session. Pixabay images must never be committed
18
+ # (CLAUDE.md -> Pixabay rules), so this is ignored wholesale rather than by
19
+ # file type.
20
+ .crivo/
21
+ crivo-output/
22
+ *.zip
23
+ # Pre-rename (CRIVO-1) default work-dir name; ignored so a checkout that still
24
+ # has one lying around from before the rename doesn't show up as untracked.
25
+ .winnower/
26
+ winnower-output/
27
+
28
+ # Claude Code: per-machine state, not project configuration.
29
+ .claude/settings.local.json
30
+ .claude/scheduled_tasks.lock
@@ -0,0 +1,137 @@
1
+ # Changelog
2
+
3
+ All notable changes to Crivo are documented in this file.
4
+
5
+ The format is loosely based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
+ Entries below are grouped by date of the work rather than by version number.
7
+ Versions are git tags derived from commit types (see `CLAUDE.md`), and a release
8
+ is cut by retitling the `## [Unreleased]` section, not by generating this file.
9
+
10
+ ## [Unreleased]
11
+
12
+ Open work is tracked in [TODO.md](TODO.md), and registered by ID in
13
+ [TASKS.md](TASKS.md).
14
+
15
+ ## 2026-09-27
16
+
17
+ ### Added
18
+
19
+ - `winnower run` (also `python -m winnower`) takes a list of keywords, searches
20
+ Pixabay for each, opens a page in your browser to pick images, and writes the
21
+ picks, resized to a common size, to a zip. This is the first version that can
22
+ be used from start to finish. It needs your own Pixabay API key.
23
+ - Keywords come from a `.txt` file (one per line), a `.csv` file with a header
24
+ row, standard input (`-`), or `-k` on the command line, repeated for several.
25
+ Blank lines and lines starting with `#` are ignored. Write `label | search
26
+ term` to name the result after one thing and search for another. For a CSV,
27
+ the label is read from a column headed `keyword` or `label` (else the first
28
+ column) and the search term from one headed `term` or `search` (else the
29
+ label), or name the columns with `--column` and `--term-column`. Comma,
30
+ semicolon and tab separators are all understood. A label repeated in a
31
+ different case is dropped and reported, because result files are named after
32
+ labels.
33
+ - Searching shows progress as `n / total`. A keyword with no results, or whose
34
+ search failed, is flagged and shown on the page for you to retry with another
35
+ term, instead of stopping the run. The run pauses, and says why, when Pixabay
36
+ refuses the API key, when the rate limit is used up, or when Pixabay cannot be
37
+ reached three times in a row. Everything found so far is kept, and it never
38
+ loops on its own.
39
+ - Search filters: `--image-type`, `--orientation`, `--category`, `--colors`,
40
+ `--min-width`, `--min-height`, `--safesearch`, `--editors-choice`, `--order`
41
+ and `--lang`, plus `--term-template` (for example `'{term} icon'`) to wrap
42
+ every search term. Options are checked before the first request is made, so a
43
+ typo does not cost a search.
44
+ - Search results are cached for 24 hours, so running the same list again does
45
+ not spend requests, and requests are held to Pixabay's limit of 100 per
46
+ minute.
47
+ - The API key is read only from the `PIXABAY_API_KEY` environment variable or a
48
+ `.env` file, never from a command-line flag, where it would end up in shell
49
+ history and the process list. It is not written to the cache and does not
50
+ appear in error messages.
51
+ - The selection page shows each candidate with its contributor's name and a link
52
+ to its Pixabay page. For each keyword you can pick an image, skip it, search
53
+ again with a different term, or load more results without losing your picks.
54
+ `--multiple` allows more than one pick per keyword, and `--no-browser` prints
55
+ the address instead of opening it. The page is served on this computer only
56
+ and needs a random token that is different on every run, so other programs and
57
+ other websites cannot use it.
58
+ - Picked images are downloaded once the page is finished, and each one is
59
+ checked to be a complete, readable image before it is used, so a dropped
60
+ connection or an error page never ends up as a result. Downloads are kept in
61
+ the work directory (`.winnower` by default, or `--work-dir`) and reused.
62
+ - Picks are resized to `--size` (default 512x512) by `--mode`: `crop` fills the
63
+ size and cuts the overflow, `pad` fits inside and fills the rest, and `fit`
64
+ fits inside without padding, so sizes are not uniform. Output is PNG, JPEG or
65
+ WebP (`--format`). `--background #RRGGBB` sets the fill for `pad`, and what a
66
+ JPEG is flattened onto; the default is transparent, or white for JPEG.
67
+ - The result is a zip (`winnower.zip`, or `-o`) of the resized images, named
68
+ after each keyword's label, with a `CREDITS.txt` naming each image's
69
+ contributor and Pixabay page. An existing zip is not replaced unless you pass
70
+ `--overwrite`, and this is checked before searching, so you are not asked to
71
+ pick images and then told the file exists.
72
+ - Exit codes: 0 when done; 1 for something you can fix, such as a bad option,
73
+ a missing keyword file or API key, an existing output file, or no image being
74
+ produced; 3 when the zip was written but some picked images are missing from
75
+ it, which are listed above the result; 130 when interrupted with Ctrl-C.
76
+ Searches and downloads stay cached after an interruption, and your picks are
77
+ saved too (see the session entries below).
78
+ - Your searches and picks are saved to `session.json` in the work directory
79
+ after every click, so a long run can be put down and picked up later. `winnower
80
+ run --resume` continues it: it needs no keyword file, does not search again
81
+ for anything already searched, and brings the page back as you left it. The
82
+ options that shape the output (`--size`, `--mode`, `--format`, `-o`) are taken
83
+ from the new command, so you can change your mind about them. The file holds
84
+ Pixabay's image addresses and the keywords, and never the API key.
85
+ - Starting a new run while an unfinished session is saved is refused, so a long
86
+ afternoon of picking is not thrown away by accident. This is checked before
87
+ any request is spent. Pass `--restart` to discard it and start over. A session
88
+ whose zip has been written is finished with, and no longer blocks the next
89
+ run.
90
+ - Resuming a session more than a day old still works, but warns that Pixabay's
91
+ image addresses are meant for short-term use, so some thumbnails or downloads
92
+ may no longer load; a keyword can be searched again from the page. A session
93
+ file that cannot be read is reported with `--restart` as the way out, and
94
+ `--resume` is refused, with a reason, when there is nothing saved or the
95
+ saved session is already complete.
96
+ - If the session file cannot be saved (a full disk, a read-only folder), this is
97
+ reported once at the end and the run carries on, since the picks are still
98
+ held in memory.
99
+ - `winnower run` with no keywords at all opens the page with a box to type or
100
+ paste them into, in the same format as a `.txt` file (`label | search term`,
101
+ `#` comments). The search then runs behind the page: it shows how far it has
102
+ got, each keyword appears as soon as it is searched, and you can start
103
+ choosing before the rest are done. A list that cannot be used is refused with
104
+ the reason next to the box, and you can try again. If you close the terminal
105
+ mid-search, `winnower run --resume` brings back everything found so far, with
106
+ the keywords not yet reached marked as not searched.
107
+ - Crivo can now be installed with `pipx install crivo` (or `pip install
108
+ --user crivo`), instead of only from a git clone with a hand-built virtual
109
+ environment — the previous route only made sense for a developer. Pushing a
110
+ release tag (`python -m scripts.release --write`, then `git push --tags`)
111
+ now builds the package and publishes it to PyPI through PyPI's trusted
112
+ publishing, so no API token is stored in the repository; creating the tag
113
+ still never pushes it, so that remains the one deliberate step, now the one
114
+ that also ships the release. Crivo's PyPI page also links back to this
115
+ repository, its issue tracker and this changelog.
116
+
117
+ ### Changed
118
+
119
+ - The number of images shown per keyword (`-n`, `--candidates`) must be between
120
+ 3 and 200, and defaults to 5, not the 1 to 10 of the original idea. Pixabay
121
+ refuses fewer than 3 results per page, and showing fewer than were fetched
122
+ would make "more results" skip some.
123
+ - Pressing Ctrl-C during `winnower run` now says that your picks are saved and
124
+ that `winnower run --resume` continues them, instead of only mentioning the
125
+ cache.
126
+
127
+ ### Fixed
128
+
129
+ - Cropping (`--mode crop`, the default) no longer fails on some real photographs
130
+ with "box offset can't be negative". A rounding error far too small to see, in
131
+ working out which part of the picture to keep, made two of the three
132
+ photographs in the first run against Pixabay fail to resize, so their picks
133
+ were left out of the zip.
134
+ - The selection page no longer occasionally shows a network error instead of a
135
+ clear message when a request is refused (a wrong token or address). On a busy
136
+ computer the refusal could be lost when the connection was closed with the
137
+ request's body still unread; the body is now always read first.