Skip to content

feat: shekel run — non-invasive CLI budget enforcement (v0.2.9)#23

Merged
arieradle merged 4 commits intomainfrom
feat/cli-enhancement
Mar 15, 2026
Merged

feat: shekel run — non-invasive CLI budget enforcement (v0.2.9)#23
arieradle merged 4 commits intomainfrom
feat/cli-enhancement

Conversation

@arieradle
Copy link
Owner

Summary

  • Adds shekel run agent.py --budget 5 as a zero-code-change budget enforcer for any Python agent script
  • New Budget(warn_only=True) parameter suppresses raises and fires warn callback instead
  • GitHub Actions composite action (.github/actions/enforce/action.yml) for GHA pipelines
  • Docker & container docs (docs/docker.md) with entrypoint patterns, env-var control, JSON logging

Key flags

Flag Description
--budget N / AGENT_BUDGET_USD=N USD cap; env var enables Docker/CI operator control
--warn-at F Warn at fraction of budget (e.g. 0.8)
--max-llm-calls N Cap on LLM API calls
--max-tool-calls N Cap on tool invocations
--output json Machine-readable spend summary
--warn-only Log warning, never exit 1
--dry-run Track only, implies --warn-only
--budget-file PATH Load limits from shekel.toml

Test plan

  • 85 new tests (TDD — red before green), all passing
  • 100% coverage on _cli.py, _run_utils.py, _run_config.py
  • black, isort, ruff, mypy — all clean on changed files
  • Performance: shekel run on no-op script < 100 ms (benchmark median ~230 µs)
  • Pre-existing failures (litellm Groq + temporal performance flakiness) unchanged

🤖 Generated with Claude Code

Adds `shekel run agent.py --budget 5` as a drop-in wrapper for any Python
agent script: zero code changes required, exit 1 on budget exceeded (CI-friendly).

New features:
- `shekel run`: wraps scripts via runpy in-process so monkey-patches are active
- `--budget / AGENT_BUDGET_USD`: USD cap with Docker/CI env-var support
- `--warn-at`, `--max-llm-calls`, `--max-tool-calls`: full Budget param parity
- `--output json`: machine-readable spend summary for log pipelines
- `--warn-only`: log warning but never exit 1 (soft guardrail)
- `--dry-run`: track costs only, implies --warn-only
- `--budget-file shekel.toml`: operator-supplied TOML config
- `Budget(warn_only=True)`: new parameter — suppresses raises, fires warn callback
- `.github/actions/enforce/action.yml`: GitHub Actions composite action
- `docs/docker.md`: Docker entrypoint patterns and shell script examples

Tests: 85 new tests (TDD), 100% coverage on _cli.py, _run_utils.py, _run_config.py.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
if by_model:
top_model = max(
by_model.items(),
key=lambda kv: kv[1]["calls"], # type: ignore[index]

Check warning

Code scanning / CodeQL

'break' or 'return' statement in finally Warning

'return' in a finally block will swallow any exceptions raised.

Copilot Autofix

AI 2 days ago

In general, to avoid swallowing exceptions with a finally plus an unconditional exit/return, ensure that process termination (or returns) occur inside the try block, and that the finally block is restricted to cleanup that should run regardless of success or failure. If additional reporting or summary output is needed, it should either be done outside the finally after confirming no exception occurred, or wrapped in its own try/except so that unexpected issues don’t silently override earlier errors.

For this specific snippet in shekel/_cli.py, the least disruptive fix is:

  • Move sys.exit(script_exit_code) from after the finally into the try block (after the core script execution and any logic that sets script_exit_code).
  • Wrap the content of the current finally block in an inner try/except to ensure that any unexpected exception in the reporting code does not mask the original exception from the try. In the except of that inner block, re-raise so that the original behavior (propagating an error) is preserved when something goes wrong in summary generation.
  • Keep sys.argv = original_argv as unconditional cleanup in the outer finally so it always runs, even if reporting fails. The rest of the status/JSON/text printing logic should be in the inner try so exceptions there can propagate properly.

Concretely, within the region around lines 270–305:

  • Change the finally: block so that it only guarantees restoration of sys.argv, and does the status/output logic inside an inner try/except that re-raises.
  • Remove the trailing sys.exit(script_exit_code) outside the try/finally, and add a sys.exit(script_exit_code) at the end of the try block where the script’s main logic finishes. Because the snippet you provided only shows the end of the function, we will adjust just this bottom region: we will move the sys.exit(script_exit_code) into the try by replacing the end of the finally + trailing sys.exit with a finally that sets sys.argv and an explicit inner try/except for the reporting code.

No new imports or helper methods are needed; we only restructure the control flow in the shown section.

Suggested changeset 1
shekel/_cli.py

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/shekel/_cli.py b/shekel/_cli.py
--- a/shekel/_cli.py
+++ b/shekel/_cli.py
@@ -269,37 +269,41 @@
             script_exit_code = 1
     finally:
         sys.argv = original_argv
-        # Determine status for both text and JSON output
-        if exceeded or (has_limit and b.max_usd is not None and b.spent > b.max_usd):
-            status = "exceeded"
-        elif b._warn_fired:
-            status = "warn"
-        else:
-            status = "ok"
+        try:
+            # Determine status for both text and JSON output
+            if exceeded or (has_limit and b.max_usd is not None and b.spent > b.max_usd):
+                status = "exceeded"
+            elif b._warn_fired:
+                status = "warn"
+            else:
+                status = "ok"
 
-        if output == "json":
-            data = b.summary_data()
-            by_model: dict[str, object] = data["by_model"]  # type: ignore[assignment]
-            json_out: dict[str, object] = {
-                "spent": data["total_spent"],
-                "limit": data["limit"],
-                "calls": data["calls_used"],
-                "tool_calls": data["tool_calls_used"],
-                "status": status,
-            }
-            if by_model:
-                top_model = max(
-                    by_model.items(),
-                    key=lambda kv: kv[1]["calls"],  # type: ignore[index]
-                )[0]
-                json_out["model"] = top_model
-            click.echo(_json.dumps(json_out))
-        else:
-            click.echo(format_spend_summary(b))
-            if has_limit and b.calls_used == 0 and b.tool_calls_used == 0:
-                click.echo(
-                    "Warning: 0 LLM calls intercepted — budget may not be enforced.",
-                    err=True,
-                )
+            if output == "json":
+                data = b.summary_data()
+                by_model: dict[str, object] = data["by_model"]  # type: ignore[assignment]
+                json_out: dict[str, object] = {
+                    "spent": data["total_spent"],
+                    "limit": data["limit"],
+                    "calls": data["calls_used"],
+                    "tool_calls": data["tool_calls_used"],
+                    "status": status,
+                }
+                if by_model:
+                    top_model = max(
+                        by_model.items(),
+                        key=lambda kv: kv[1]["calls"],  # type: ignore[index]
+                    )[0]
+                    json_out["model"] = top_model
+                click.echo(_json.dumps(json_out))
+            else:
+                click.echo(format_spend_summary(b))
+                if has_limit and b.calls_used == 0 and b.tool_calls_used == 0:
+                    click.echo(
+                        "Warning: 0 LLM calls intercepted — budget may not be enforced.",
+                        err=True,
+                    )
+        except Exception:
+            # Re-raise to avoid swallowing exceptions from summary/reporting logic.
+            raise
 
     sys.exit(script_exit_code)
EOF
Copilot is powered by AI and may make mistakes. Always verify output.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@codecov
Copy link

codecov bot commented Mar 15, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

arieradle and others added 2 commits March 15, 2026 16:39
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Avoids environment-dependent unused-ignore errors: CI has tomli installed
(no import-not-found), local dev does not. The [[tool.mypy.overrides]] for
tomli handles both cases without needing a fragile inline comment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@arieradle arieradle merged commit ac5fa92 into main Mar 15, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant