cwarm 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.
@@ -0,0 +1,21 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ strategy:
12
+ matrix:
13
+ python-version: ["3.12", "3.13"]
14
+ steps:
15
+ - uses: actions/checkout@v5
16
+ - uses: actions/setup-python@v6
17
+ with:
18
+ python-version: ${{ matrix.python-version }}
19
+ - run: pip install -e ".[dev]"
20
+ - run: ruff check .
21
+ - run: python -m pytest -q
@@ -0,0 +1,108 @@
1
+ name: Release & Publish
2
+
3
+ # Run from the Actions tab ("Run workflow"). In one run this:
4
+ # 1. lints + tests,
5
+ # 2. bumps the version and updates CHANGELOG.md with Commitizen,
6
+ # 3. creates the git tag + GitHub Release,
7
+ # 4. builds and publishes to PyPI via Trusted Publishing (OIDC, no token).
8
+ #
9
+ # The file is named publish.yml on purpose: the PyPI Trusted Publisher is
10
+ # registered against this filename. Doing bump+release+publish in one workflow
11
+ # avoids the GitHub limitation where a release created by GITHUB_TOKEN does not
12
+ # trigger a separate publish workflow.
13
+ on:
14
+ workflow_dispatch:
15
+ inputs:
16
+ increment:
17
+ description: "Version bump (auto = infer from Conventional Commits)"
18
+ type: choice
19
+ default: auto
20
+ options: [auto, PATCH, MINOR, MAJOR]
21
+
22
+ jobs:
23
+ release:
24
+ runs-on: ubuntu-latest
25
+ permissions:
26
+ contents: write # push the bump commit/tag and create the release
27
+ outputs:
28
+ version: ${{ steps.bump.outputs.version }}
29
+ released: ${{ steps.bump.outputs.released }}
30
+ steps:
31
+ - uses: actions/checkout@v5
32
+ with:
33
+ fetch-depth: 0 # commitizen needs full history + tags
34
+ - uses: actions/setup-python@v6
35
+ with:
36
+ python-version: "3.12"
37
+ - run: pip install -e ".[dev]"
38
+ - name: Lint and test
39
+ run: |
40
+ ruff check .
41
+ python -m pytest -q
42
+ - name: Configure git identity
43
+ run: |
44
+ git config user.name "github-actions[bot]"
45
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
46
+ - name: Bump version, changelog, and tag
47
+ id: bump
48
+ run: |
49
+ set -euo pipefail
50
+ increment="${{ inputs.increment }}"
51
+ if git describe --tags --abbrev=0 >/dev/null 2>&1; then
52
+ extra=""
53
+ if [ "$increment" != "auto" ]; then extra="--increment $increment"; fi
54
+ if cz bump --yes --changelog --changelog-to-stdout $extra > notes.md; then
55
+ echo "released=true" >> "$GITHUB_OUTPUT"
56
+ else
57
+ echo "No eligible commits since the last release — nothing to publish."
58
+ echo "released=false" >> "$GITHUB_OUTPUT"
59
+ exit 0
60
+ fi
61
+ else
62
+ # First release: tag the current version as-is (no bump).
63
+ version="$(cz version -p)"
64
+ cz changelog "$version" --dry-run > notes.md || true
65
+ git tag -a "v$version" -m "Release v$version"
66
+ echo "released=true" >> "$GITHUB_OUTPUT"
67
+ fi
68
+ echo "version=v$(cz version -p)" >> "$GITHUB_OUTPUT"
69
+ - name: Push commit and tag
70
+ if: steps.bump.outputs.released == 'true'
71
+ run: |
72
+ git push origin HEAD:${{ github.ref_name }}
73
+ git push origin "${{ steps.bump.outputs.version }}"
74
+ - name: Create GitHub Release
75
+ if: steps.bump.outputs.released == 'true'
76
+ env:
77
+ GH_TOKEN: ${{ github.token }}
78
+ run: |
79
+ if [ -s notes.md ]; then
80
+ gh release create "${{ steps.bump.outputs.version }}" \
81
+ --title "${{ steps.bump.outputs.version }}" --notes-file notes.md
82
+ else
83
+ gh release create "${{ steps.bump.outputs.version }}" \
84
+ --title "${{ steps.bump.outputs.version }}" --generate-notes
85
+ fi
86
+
87
+ publish:
88
+ needs: release
89
+ if: needs.release.outputs.released == 'true'
90
+ runs-on: ubuntu-latest
91
+ environment:
92
+ name: pypi
93
+ url: https://pypi.org/p/cwarm
94
+ permissions:
95
+ id-token: write # required for Trusted Publishing
96
+ steps:
97
+ - uses: actions/checkout@v5
98
+ with:
99
+ ref: ${{ needs.release.outputs.version }} # build the freshly-tagged version
100
+ - uses: actions/setup-python@v6
101
+ with:
102
+ python-version: "3.12"
103
+ - name: Build sdist and wheel
104
+ run: |
105
+ python -m pip install --upgrade build
106
+ python -m build
107
+ - name: Publish to PyPI
108
+ uses: pypa/gh-action-pypi-publish@release/v1
cwarm-0.1.0/.gitignore ADDED
@@ -0,0 +1,13 @@
1
+ # Local config holds real account ids — keep it out of git.
2
+ config.json
3
+
4
+ # Python / venv / build
5
+ .venv/
6
+ __pycache__/
7
+ *.pyc
8
+ dist/
9
+ build/
10
+ *.egg-info/
11
+
12
+ # uv
13
+ uv.lock
@@ -0,0 +1,22 @@
1
+ # Changelog
2
+
3
+ All notable changes are documented here. This file is managed by
4
+ [Commitizen](https://commitizen-tools.github.io/commitizen/) — run `cz bump`
5
+ to cut a version and prepend the next entry from Conventional Commits.
6
+
7
+ ## v0.1.0 (2026-06-21)
8
+
9
+ Initial release.
10
+
11
+ ### Feat
12
+
13
+ - Stagger-warm multiple coding-agent accounts on per-account cron schedules, so
14
+ their rolling usage windows open early and reset at different times.
15
+ - Generic agent layer (a small data table): Claude Code via claude-swap today,
16
+ with a pluggable account-switcher seam for other CLIs. Selected per account.
17
+ - Commands: `init`, `validate`, `list`, `run`, `daemon`.
18
+ - APScheduler daemon with correct crontab day-of-week handling, multi-schedule
19
+ accounts, `skip_if_warm`, and save/restore of the active account (even on
20
+ failure).
21
+ - systemd unit, structured per-attempt logging, PyPI trusted-publishing and CI
22
+ workflows.
cwarm-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 wonderbyte
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
cwarm-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,277 @@
1
+ Metadata-Version: 2.4
2
+ Name: cwarm
3
+ Version: 0.1.0
4
+ Summary: Stagger-warm coding-agent accounts (Claude Code, Codex, ...) so their usage windows open early and reset at different times.
5
+ License-File: LICENSE
6
+ Requires-Python: >=3.12
7
+ Requires-Dist: apscheduler<4,>=3.10
8
+ Requires-Dist: tzdata; platform_system == 'Windows'
9
+ Provides-Extra: dev
10
+ Requires-Dist: commitizen>=3; extra == 'dev'
11
+ Requires-Dist: pytest>=8; extra == 'dev'
12
+ Requires-Dist: ruff>=0.6; extra == 'dev'
13
+ Description-Content-Type: text/markdown
14
+
15
+ # cwarm
16
+
17
+ [![CI](https://github.com/wonderbyte/cwarm/actions/workflows/ci.yml/badge.svg)](https://github.com/wonderbyte/cwarm/actions/workflows/ci.yml)
18
+ [![PyPI](https://img.shields.io/pypi/v/cwarm.svg)](https://pypi.org/project/cwarm/)
19
+ [![Python](https://img.shields.io/pypi/pyversions/cwarm.svg)](https://pypi.org/project/cwarm/)
20
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
21
+
22
+ Stagger-warm multiple **coding-agent accounts** so their rolling usage windows
23
+ open early in the day and reset at different times. Today it warms **Claude Code**
24
+ accounts (paired with [`claude-swap`](https://pypi.org/project/claude-swap/),
25
+ `cswap`); the agent layer is generic, so other CLIs (e.g. Codex) can be added.
26
+ When the active account exhausts its window, another is already warm to switch
27
+ into.
28
+
29
+ This tool **stores no credentials** — the account-switcher (`claude-swap`) is the
30
+ source of truth for accounts and tokens. cwarm only orchestrates it.
31
+
32
+ ## How it works
33
+
34
+ Claude Code's 5-hour window starts on an account's first message and resets
35
+ exactly 5 hours later. It's a fixed budget, not free capacity — warming only
36
+ *relocates* the dead/regeneration time so it lands outside your working hours.
37
+ Staggering the warmups (e.g. 05:00, 07:30, 10:00) keeps at least one fresh
38
+ account available through the day.
39
+
40
+ For each due account, cwarm:
41
+
42
+ 1. switches to it (for Claude: `cswap --switch-to <id>`),
43
+ 2. waits `settle_seconds` for the credential swap to land,
44
+ 3. sends one minimal message via the agent's command (for Claude: `claude -p "Hi"`)
45
+ — anchoring that account's window,
46
+
47
+ then restores whichever account you had active before the batch (always, even
48
+ if a warmup fails).
49
+
50
+ > A **weekly cap** is shared across web, app, and Claude Code. Warming several
51
+ > accounts daily consumes some of it. Tracking that cap is out of scope.
52
+
53
+ ## Agents
54
+
55
+ An "agent" is just two things: a **command** that sends a non-interactive prompt
56
+ (`<cli> -p "Hi"`) and an optional **account-switcher**. They live as a small data
57
+ table in `cwarm/agent.py` — not a class per agent:
58
+
59
+ | agent | command | switcher |
60
+ | --- | --- | --- |
61
+ | `claude` | `claude -p` | `cswap` (claude-swap) |
62
+
63
+ Each account picks its agent via the `agent` field (default `claude`). To add a
64
+ coding agent, add one row. Only a *new* switcher (something other than `cswap`)
65
+ needs code — a sibling module to `cwarm/cswap.py`. An agent with no switcher
66
+ warms whatever account that CLI currently has active (no multi-account swapping).
67
+
68
+ ## Requirements
69
+
70
+ - For the `claude` agent: `claude-swap` installed and configured with every
71
+ target account added (`cswap --add-account` / `cswap --add-token sk-ant-oat01-…`),
72
+ and Claude Code (`claude`) runnable non-interactively.
73
+ - Python 3.12+.
74
+
75
+ ## Platform support
76
+
77
+ cwarm is pure Python and runs anywhere the agent's CLIs (`claude`, `cswap`) do:
78
+
79
+ - **Linux** — fully supported, with the bundled `systemd` user service.
80
+ - **macOS** — the tool and `cwarm daemon` work the same; for boot persistence
81
+ use `launchd` or `cron` instead of systemd.
82
+ - **Windows** — works too; `tzdata` is pulled in automatically (Windows has no
83
+ system IANA tz database). Use Task Scheduler or run `cwarm daemon` as a
84
+ service instead of systemd.
85
+
86
+ `cwarm run`/`daemon` are cross-platform; only the deployment recipe differs.
87
+
88
+ ## Install
89
+
90
+ ```bash
91
+ cd ~/workspace/apps/cwarm
92
+ uv venv
93
+ uv pip install -e .
94
+ cp config.example.json config.json # then edit ids/schedules
95
+ ```
96
+
97
+ `config.json` is gitignored — it holds your real account ids.
98
+
99
+ ## Configuration (`config.json`)
100
+
101
+ No tokens. Accounts are referenced by the handle their agent uses — for Claude, a
102
+ `cswap` **slot number** or **email**.
103
+
104
+ ```json
105
+ {
106
+ "defaults": {
107
+ "agent": "claude",
108
+ "message": "Hi",
109
+ "timezone": "Asia/Kolkata",
110
+ "settle_seconds": 3,
111
+ "skip_if_warm": true
112
+ },
113
+ "accounts": [
114
+ { "id": "work@example.com", "enabled": true, "schedules": ["0 5 * * 1-5", "0 11 * * 1-5", "0 21 * * 1-5"] },
115
+ { "id": "2", "enabled": true, "schedule": "30 7 * * 1-5" },
116
+ { "id": "personal@x.com", "enabled": true, "schedules": ["0 10 * * *", "0 18 * * *"] },
117
+ { "id": "4", "enabled": false, "schedule": "30 12 * * *" }
118
+ ]
119
+ }
120
+ ```
121
+
122
+ An account can warm at **several times a day** — give it a `schedules` array
123
+ (e.g. 05:00, 11:00, 21:00). Use the singular `schedule` string for a single
124
+ time. Both keys may be present; their union (de-duplicated) is used. Each cron
125
+ time becomes its own daemon job, fired in the account's `timezone`.
126
+
127
+ | Field | Required | Default | Notes |
128
+ | --- | --- | --- | --- |
129
+ | `id` | yes | — | unique; the agent's account handle (cswap slot or email) |
130
+ | `schedule` / `schedules` | yes | — | one (string) or many (array) 5-field cron times, read in the account's `timezone` |
131
+ | `agent` | no | `defaults` / `claude` | which coding agent warms this account |
132
+ | `enabled` | no | `true` | `false` skips the account entirely |
133
+ | `message` | no | `defaults` / `"Hi"` | the warmup message |
134
+ | `timezone` | no | `defaults` / `Asia/Kolkata` | IANA tz name |
135
+ | `settle_seconds` | no | `defaults` / `3` | delay after switching before sending |
136
+ | `skip_if_warm` | no | `defaults` / `false` | skip if the window is already open |
137
+
138
+ ## Usage
139
+
140
+ ```bash
141
+ cwarm init # write a starter config.json
142
+ cwarm validate # check config + agents; sends nothing
143
+ cwarm list # show accounts, live window state, next run
144
+ cwarm run # warm all enabled accounts now
145
+ cwarm run --account work@example.com # warm just one
146
+ cwarm daemon # long-lived; fires each account on its cron
147
+
148
+ # global flags
149
+ cwarm --config /path/to/config.json --log-file /path/to/cwarm.log <command>
150
+ ```
151
+
152
+ - **`init`** — writes a starter `config.json` (won't clobber an existing one
153
+ without `--force`).
154
+ - **`validate`** — confirms the JSON matches the schema, each account's agent CLI
155
+ (and its switcher) is installed, and every configured `id` exists. Exits
156
+ non-zero and sends nothing on any problem.
157
+ - **`list`** — read-only table of every account: agent, enabled, live window
158
+ state (warm/cold), next scheduled run, and its cron times. Sends nothing.
159
+ - **`run`** — warms enabled accounts immediately (one batch, one save/restore per
160
+ switcher). Good for testing or a system `crontab`. Non-zero if any warmup failed.
161
+ - **`daemon`** — schedules one job per account per cron time, in the account's
162
+ timezone. Warmups are always serial.
163
+
164
+ ### Logging
165
+
166
+ Every attempt emits one structured line to stderr (and the log file if set):
167
+
168
+ ```
169
+ 2026-06-21T05:00:03+0530 INFO save-active switcher=cswap account=work@example.com ref=1
170
+ 2026-06-21T05:00:09+0530 INFO warmup account=work@example.com outcome=ok reset=10:00
171
+ 2026-06-21T05:00:10+0530 INFO restore-active switcher=cswap account=work@example.com ref=1 outcome=ok
172
+ ```
173
+
174
+ Outcomes: `ok` (with the window `reset` time), `failed` (with an `error`
175
+ summary), `skipped` (`skip_if_warm` and already warm).
176
+
177
+ ## Deployment
178
+
179
+ ### systemd (recommended)
180
+
181
+ ```bash
182
+ mkdir -p ~/.config/systemd/user ~/.local/state/cwarm
183
+ cp systemd/cwarm.service ~/.config/systemd/user/
184
+ systemctl --user daemon-reload
185
+ systemctl --user enable --now cwarm
186
+ loginctl enable-linger "$USER" # run without an active login session
187
+ journalctl --user -u cwarm -f
188
+ ```
189
+
190
+ ### Alternative: system crontab
191
+
192
+ One line per warmup time invoking the one-shot mode (repeat a line per account
193
+ to warm it several times a day):
194
+
195
+ ```cron
196
+ 0 5 * * 1-5 cd ~/workspace/apps/cwarm && .venv/bin/cwarm run --account work@example.com
197
+ 0 11 * * 1-5 cd ~/workspace/apps/cwarm && .venv/bin/cwarm run --account work@example.com
198
+ 0 21 * * 1-5 cd ~/workspace/apps/cwarm && .venv/bin/cwarm run --account work@example.com
199
+ 30 7 * * 1-5 cd ~/workspace/apps/cwarm && .venv/bin/cwarm run --account 2
200
+ ```
201
+
202
+ ## Energy
203
+
204
+ The daemon does **not** poll — it sleeps on an event until the next scheduled
205
+ warmup, so idle cost is ~25 MB RAM and effectively 0% CPU (measured: 1 voluntary
206
+ context switch over 3 s idle). There is no busy-loop to optimise.
207
+
208
+ The real per-warmup energy is the `claude -p "Hi"` call: it boots the Node
209
+ Claude Code CLI **and** sends a real LLM inference request to anchor the window.
210
+ That's irreducible — anchoring *requires* a server-side message. So the only
211
+ meaningful lever is **not sending redundant ones**:
212
+
213
+ - **`skip_if_warm: true`** (the example default) — before switching, parse
214
+ `cswap --list`; if the account's window is already open, log `skipped` and send
215
+ nothing. This skips the entire heavy `claude -p` call, the single biggest
216
+ energy saving available.
217
+ - **Stagger, don't stack** — overlapping schedules waste warmups (and the shared
218
+ weekly cap). One warmup per window per account is enough to anchor it.
219
+
220
+ For literally zero idle footprint, use systemd timers or cron instead of the
221
+ daemon — no process is resident between warmups — but the saving over the
222
+ sleeping daemon is marginal.
223
+
224
+ ## System restart
225
+
226
+ Yes. Via the systemd **user** service plus linger:
227
+
228
+ - `systemctl --user enable` + `loginctl enable-linger "$USER"` → the daemon
229
+ **starts on boot**, with no login session required.
230
+ - `Restart=always` (RestartSec=10) → if the process ever exits — crash or
231
+ otherwise — systemd brings it straight back.
232
+ - On every (re)start the schedule is **rebuilt fresh from `config.json`**
233
+ (in-memory jobstore; the config is the single source of truth — no stale
234
+ persisted state to reconcile).
235
+ - **Short downtime is tolerated:** `misfire_grace_time=3600` + `coalesce=True`
236
+ mean a warmup missed by under an hour still fires once on recovery.
237
+ - **Long power-off is *not* caught up by design:** a 05:00 warmup missed because
238
+ the machine was off until noon is skipped, not fired late — firing it hours
239
+ late would defeat the staggering. Edit `config.json` and
240
+ `systemctl --user restart cwarm` to re-plan.
241
+
242
+ ## Safety
243
+
244
+ - No credentials stored or logged — the switcher (`claude-swap`) owns them.
245
+ - Switching changes your local active account; run warmups at off-hours. The
246
+ save/restore guarantees your default is unchanged after a batch.
247
+ - Only configure accounts you legitimately own or are authorised to use.
248
+
249
+ ## Contributing / releasing
250
+
251
+ Commits follow [Conventional Commits](https://www.conventionalcommits.org/)
252
+ (`feat:`, `fix:`, `docs:`, `ci:`, …). Lint and tests run in CI:
253
+
254
+ ```bash
255
+ ruff check .
256
+ pytest
257
+ ```
258
+
259
+ Versioning and the changelog are managed by
260
+ [Commitizen](https://commitizen-tools.github.io/commitizen/).
261
+
262
+ **To release:** run the **Release & Publish** workflow from the Actions tab
263
+ ("Run workflow", optionally choosing the bump size). In one run it bumps the
264
+ version (`pyproject.toml` + `cwarm/__init__.py`), updates `CHANGELOG.md`, tags
265
+ and creates the GitHub Release, then builds and publishes to PyPI via Trusted
266
+ Publishing — no token stored. The next version is inferred from the Conventional
267
+ Commits since the last release.
268
+
269
+ Or do it locally:
270
+
271
+ ```bash
272
+ cz bump # bump version + changelog + create the vX.Y.Z tag
273
+ git push --follow-tags
274
+ ```
275
+
276
+ The project is in `0.x` (`major_version_zero`), so breaking changes bump the
277
+ minor until `1.0.0`.