Real bug: `agnes push` was reading `<workspace>/user/sessions/`, but Claude Code writes session jsonls to `~/.claude/projects/<encoded-cwd>/` and nothing on the analyst side ever copies them across. The SessionEnd hook ran `agnes push` happily and uploaded zero sessions every time. `cli/lib/claude_sessions.py` probes both Claude Code encoding variants (older `/`→`-` keeping spaces+tildes; newer all-non-alphanumeric→`-` with collapsed runs) and unions whichever exist. Users who upgraded Claude Code mid-project end up with both encoded dirs side-by-side on disk; the union ensures no session is left behind. Same-named jsonl in both dirs → newest mtime wins. `<workspace>/user/sessions/` survives as a fallback for any setup that explicitly mirrors sessions there. Verified on real disk: helper returns 2 dirs + 8 unioned session files for the Agnes-test workspace where the previous code returned 0.
158 lines
5.9 KiB
Python
158 lines
5.9 KiB
Python
"""`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 <URL> --token <PAT>",
|
|
}}),
|
|
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 <PAT>",
|
|
}}),
|
|
err=True,
|
|
)
|
|
raise typer.Exit(1)
|
|
|
|
workspace = Path(os.environ.get("AGNES_LOCAL_DIR", ".")).resolve()
|
|
local_md = workspace / ".claude" / "CLAUDE.local.md"
|
|
|
|
# Claude Code writes session jsonls to ~/.claude/projects/<encoded-cwd>/
|
|
# — the encoding varies by Claude Code version (older: `/` -> `-`,
|
|
# newer: all non-alphanumeric -> `-`). The helper tries both encodings
|
|
# and also falls back to the legacy <workspace>/user/sessions/ for
|
|
# setups that mirror sessions there explicitly. See
|
|
# cli/lib/claude_sessions.py for details.
|
|
from cli.lib.claude_sessions import list_session_files
|
|
session_files = list_session_files(workspace)
|
|
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)
|