From 60b6fbed975a70ca26d75f957468acda65d45b0d Mon Sep 17 00:00:00 2001 From: ZdenekSrotyr Date: Mon, 4 May 2026 18:09:57 +0200 Subject: [PATCH] feat(cli): agnes push command (extracted from sync --upload-only) --- cli/commands/push.py | 158 +++++++++++++++++++++++++++++++++++++++++ tests/test_cli_push.py | 45 ++++++++++++ 2 files changed, 203 insertions(+) create mode 100644 cli/commands/push.py create mode 100644 tests/test_cli_push.py diff --git a/cli/commands/push.py b/cli/commands/push.py new file mode 100644 index 0000000..b3464f0 --- /dev/null +++ b/cli/commands/push.py @@ -0,0 +1,158 @@ +"""`agnes push` - upload session jsonl + CLAUDE.local.md to the server. + +Thin Typer wrapper extracted from the legacy `agnes sync --upload-only` +path in `cli/commands/sync.py`. Used by: +- Manual invocation: analyst types `agnes push` to force an upload. +- SessionEnd hook: `agnes push --quiet 2>/dev/null || true` runs at the + end of every Claude Code session in this workspace. + +Lazy on-disk contract: when there are no `user/sessions/*.jsonl` files +and no `.claude/CLAUDE.local.md`, this command must NOT create +`user/sessions/` (or any other directory). Tests pin the lazy mkdir +contract so the empty-workspace case stays a true no-op on disk. + +Errors render via `cli/error_render.py:render_error()` for typed-error +shape consistency with `agnes pull`. + +Task 18 will register `push_app` on the root Typer app and delete the +legacy `agnes sync --upload-only` flag. Until then this module is +callable only via direct import (which is exactly what the test does). +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path + +import typer + +from cli.client import api_post +from cli.config import get_server_url, get_token +from cli.error_render import render_error + + +push_app = typer.Typer(help="Upload sessions and CLAUDE.local.md to the server") + + +@push_app.callback(invoke_without_command=True) +def push( + quiet: bool = typer.Option(False, "--quiet", help="Suppress success stdout (errors still surface on stderr)"), + as_json: bool = typer.Option(False, "--json", help="Emit a single JSON object summarizing the upload"), + dry_run: bool = typer.Option(False, "--dry-run", help="List what would be uploaded without sending anything"), +): + """Upload session jsonl + CLAUDE.local.md from ./user/sessions and ./.claude.""" + server_url = get_server_url() + if not server_url: + # `get_server_url()` falls back to a localhost default today, so this + # branch is mostly a defensive guard for a future config change that + # might return an empty string. + typer.echo( + render_error(0, {"detail": { + "kind": "server_unreachable", + "hint": "No server configured. Run: agnes init --server-url --token ", + }}), + err=True, + ) + raise typer.Exit(1) + + token = get_token() + if not token: + typer.echo( + render_error(0, {"detail": { + "kind": "auth_failed", + "hint": "No token. Run: agnes auth import-token --token ", + }}), + err=True, + ) + raise typer.Exit(1) + + workspace = Path(os.environ.get("AGNES_LOCAL_DIR", ".")).resolve() + sessions_dir = workspace / "user" / "sessions" + local_md = workspace / ".claude" / "CLAUDE.local.md" + + # Lazy: only enumerate when the directory actually exists. We must not + # mkdir here - the empty-workspace case must leave disk untouched so + # the SessionEnd hook stays a true no-op for analysts who haven't + # produced any sessions yet. + session_files = ( + sorted(sessions_dir.glob("*.jsonl")) if sessions_dir.exists() else [] + ) + has_local_md = local_md.exists() + + if dry_run: + plan = { + "dry_run": True, + "would_upload": { + "sessions": [str(f) for f in session_files], + "local_md": str(local_md) if has_local_md else None, + }, + "summary": { + "sessions_count": len(session_files), + "local_md_present": has_local_md, + }, + } + if as_json: + typer.echo(json.dumps(plan, indent=2)) + return + if quiet: + return + typer.echo(f"Dry run - would upload {len(session_files)} session file(s)") + for f in session_files: + typer.echo(f" {f}") + if has_local_md: + typer.echo(f"Would upload CLAUDE.local.md ({local_md})") + else: + typer.echo("No CLAUDE.local.md to upload") + return + + results = {"sessions": 0, "local_md": False, "errors": []} + + # Upload sessions. Per-file failures are recorded into `errors` and the + # loop continues - one corrupt jsonl mustn't block the rest, and a + # transient 5xx on one file shouldn't poison the whole upload. + for f in session_files: + try: + with open(f, "rb") as fh: + resp = api_post("/api/upload/sessions", files={"file": (f.name, fh)}) + if resp.status_code == 200: + results["sessions"] += 1 + else: + results["errors"].append( + {"file": f.name, "status": resp.status_code} + ) + except Exception as exc: + results["errors"].append({"file": f.name, "error": str(exc)}) + + # Upload CLAUDE.local.md + if has_local_md: + try: + content = local_md.read_text(encoding="utf-8") + resp = api_post("/api/upload/local-md", json={"content": content}) + if resp.status_code == 200: + results["local_md"] = True + else: + results["errors"].append( + {"file": "CLAUDE.local.md", "status": resp.status_code} + ) + except Exception as exc: + results["errors"].append({"file": "CLAUDE.local.md", "error": str(exc)}) + + if as_json: + typer.echo(json.dumps(results)) + return + + if quiet: + # Quiet mode is for the SessionEnd hook - silent on success so + # Claude Code's stdout stays clean. Errors still flow to stderr. + if results["errors"]: + for e in results["errors"]: + typer.echo(f"warn: {e}", err=True) + return + + typer.echo(f"Uploaded {results['sessions']} sessions") + if results["local_md"]: + typer.echo("Uploaded CLAUDE.local.md") + if results["errors"]: + for e in results["errors"]: + typer.echo(f"warn: {e}", err=True) diff --git a/tests/test_cli_push.py b/tests/test_cli_push.py new file mode 100644 index 0000000..17cdec3 --- /dev/null +++ b/tests/test_cli_push.py @@ -0,0 +1,45 @@ +"""Tests for agnes push command (SessionEnd uploader).""" + +from typer.testing import CliRunner + +from cli.commands.push import push_app + +runner = CliRunner() + + +def test_push_help(): + result = runner.invoke(push_app, ["--help"]) + assert result.exit_code == 0 + assert "--quiet" in result.output + assert "--json" in result.output + assert "--dry-run" in result.output + + +def test_push_no_sessions_no_mkdir(tmp_path, monkeypatch): + """Empty workspace -> push exits 0, doesn't create user/sessions/.""" + monkeypatch.setenv("AGNES_LOCAL_DIR", str(tmp_path)) + monkeypatch.setattr("cli.commands.push.get_server_url", lambda: "http://x") + monkeypatch.setattr("cli.commands.push.get_token", lambda: "test-pat") + result = runner.invoke(push_app, ["--quiet"]) + assert result.exit_code == 0 + assert not (tmp_path / "user" / "sessions").exists(), \ + "lazy mkdir: nothing to upload must not create user/sessions/" + + +def test_push_dry_run_no_writes(tmp_path, monkeypatch): + """--dry-run lists what would upload but sends nothing.""" + monkeypatch.setenv("AGNES_LOCAL_DIR", str(tmp_path)) + monkeypatch.setattr("cli.commands.push.get_server_url", lambda: "http://x") + monkeypatch.setattr("cli.commands.push.get_token", lambda: "test-pat") + # Create a session file + sessions_dir = tmp_path / "user" / "sessions" + sessions_dir.mkdir(parents=True) + (sessions_dir / "abc.jsonl").write_text('{"event":"test"}\n') + + # No api_post should be called - patch it to fail loudly if invoked + def _raise(*a, **kw): + raise AssertionError("api_post was called during --dry-run") + monkeypatch.setattr("cli.commands.push.api_post", _raise) + + result = runner.invoke(push_app, ["--dry-run"]) + assert result.exit_code == 0