contexthub-cli 0.1.0

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,221 @@
1
+ """Cloudflare R2 upload client (S3-compatible via boto3)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+
8
+ import boto3
9
+ from botocore.exceptions import ClientError
10
+
11
+ BUCKET_NAME = "ch-context"
12
+
13
+ REQUIRED_ENV_VARS = ("R2_ACCOUNT_ID", "R2_ACCESS_KEY_ID", "R2_SECRET_ACCESS_KEY")
14
+
15
+
16
+ class R2Error(Exception):
17
+ pass
18
+
19
+
20
+ def _get_client():
21
+ # Fall back to CLOUDFLARE_ACCOUNT_ID if R2_ACCOUNT_ID isn't set
22
+ account_id = os.environ.get("R2_ACCOUNT_ID") or os.environ.get("CLOUDFLARE_ACCOUNT_ID")
23
+ access_key = os.environ.get("R2_ACCESS_KEY_ID")
24
+ secret_key = os.environ.get("R2_SECRET_ACCESS_KEY")
25
+
26
+ if not account_id or not access_key or not secret_key:
27
+ raise R2Error(
28
+ "R2 credentials not configured. Set R2_ACCOUNT_ID (or CLOUDFLARE_ACCOUNT_ID), "
29
+ "R2_ACCESS_KEY_ID, and R2_SECRET_ACCESS_KEY."
30
+ )
31
+ endpoint_url = f"https://{account_id}.r2.cloudflarestorage.com"
32
+
33
+ return boto3.client(
34
+ "s3",
35
+ endpoint_url=endpoint_url,
36
+ aws_access_key_id=access_key,
37
+ aws_secret_access_key=secret_key,
38
+ region_name="auto",
39
+ )
40
+
41
+
42
+ def upload_context(owner: str, repo: str, commit_hash: str, payload: dict) -> None:
43
+ """Upload a commit context JSON payload to R2.
44
+
45
+ Object key: {owner}/{repo}/commits/{commit_hash}.json
46
+ """
47
+ client = _get_client()
48
+ key = f"{owner}/{repo}/commits/{commit_hash}.json"
49
+ body = json.dumps(payload, indent=2)
50
+
51
+ try:
52
+ client.put_object(
53
+ Bucket=BUCKET_NAME,
54
+ Key=key,
55
+ Body=body.encode("utf-8"),
56
+ ContentType="application/json",
57
+ )
58
+ except ClientError as e:
59
+ raise R2Error(f"Failed to upload context: {e}")
60
+
61
+
62
+ def upload_session_transcript(
63
+ owner: str, repo: str, session_id: str, payload: dict
64
+ ) -> None:
65
+ """Upload a session transcript snapshot to R2.
66
+
67
+ Object key: {owner}/{repo}/sessions/{session_id}/transcript.json
68
+ Overwrites on each call (latest state).
69
+ """
70
+ client = _get_client()
71
+ key = f"{owner}/{repo}/sessions/{session_id}/transcript.json"
72
+ body = json.dumps(payload, indent=2)
73
+
74
+ try:
75
+ client.put_object(
76
+ Bucket=BUCKET_NAME,
77
+ Key=key,
78
+ Body=body.encode("utf-8"),
79
+ ContentType="application/json",
80
+ )
81
+ except ClientError as e:
82
+ raise R2Error(f"Failed to upload session transcript: {e}")
83
+
84
+
85
+ def upload_session_meta(
86
+ owner: str, repo: str, session_id: str, payload: dict
87
+ ) -> None:
88
+ """Upload session metadata to R2.
89
+
90
+ Object key: {owner}/{repo}/sessions/{session_id}/meta.json
91
+ Written once at session end.
92
+ """
93
+ client = _get_client()
94
+ key = f"{owner}/{repo}/sessions/{session_id}/meta.json"
95
+ body = json.dumps(payload, indent=2)
96
+
97
+ try:
98
+ client.put_object(
99
+ Bucket=BUCKET_NAME,
100
+ Key=key,
101
+ Body=body.encode("utf-8"),
102
+ ContentType="application/json",
103
+ )
104
+ except ClientError as e:
105
+ raise R2Error(f"Failed to upload session meta: {e}")
106
+
107
+
108
+ def get_commit_context(owner: str, repo: str, commit_hash: str) -> dict:
109
+ """Read a commit context record from R2.
110
+
111
+ Tries full hash first, then prefix match.
112
+ Returns the parsed JSON dict (direct read, nothing written to disk).
113
+ """
114
+ client = _get_client()
115
+
116
+ # Try exact key first
117
+ key = f"{owner}/{repo}/commits/{commit_hash}.json"
118
+ try:
119
+ resp = client.get_object(Bucket=BUCKET_NAME, Key=key)
120
+ return json.loads(resp["Body"].read().decode("utf-8"))
121
+ except ClientError:
122
+ pass
123
+
124
+ # Try prefix match (short SHA)
125
+ prefix = f"{owner}/{repo}/commits/{commit_hash}"
126
+ try:
127
+ resp = client.list_objects_v2(Bucket=BUCKET_NAME, Prefix=prefix, MaxKeys=10)
128
+ contents = resp.get("Contents", [])
129
+ # Filter to .json files (not directories)
130
+ matches = [c for c in contents if c["Key"].endswith(".json")]
131
+ if len(matches) == 1:
132
+ resp = client.get_object(Bucket=BUCKET_NAME, Key=matches[0]["Key"])
133
+ return json.loads(resp["Body"].read().decode("utf-8"))
134
+ elif len(matches) > 1:
135
+ keys = [c["Key"].split("/")[-1].replace(".json", "") for c in matches]
136
+ raise R2Error(f"Ambiguous SHA prefix. Matches: {', '.join(keys)}")
137
+ except ClientError as e:
138
+ raise R2Error(f"Failed to download context: {e}")
139
+
140
+ raise R2Error(f"No commit context found for {commit_hash}")
141
+
142
+
143
+ def upload_resolution(owner: str, repo: str, resolution_id: str, payload: dict) -> None:
144
+ """Upload a resolution record to R2.
145
+
146
+ Object key: {owner}/{repo}/resolutions/{resolution_id}.json
147
+ """
148
+ client = _get_client()
149
+ key = f"{owner}/{repo}/resolutions/{resolution_id}.json"
150
+ body = json.dumps(payload, indent=2)
151
+
152
+ try:
153
+ client.put_object(
154
+ Bucket=BUCKET_NAME,
155
+ Key=key,
156
+ Body=body.encode("utf-8"),
157
+ ContentType="application/json",
158
+ )
159
+ except ClientError as e:
160
+ raise R2Error(f"Failed to upload resolution: {e}")
161
+
162
+
163
+ def update_resolution(owner: str, repo: str, resolution_id: str, updates: dict) -> dict:
164
+ """Read-modify-write a resolution record in R2. Returns the updated record."""
165
+ client = _get_client()
166
+ key = f"{owner}/{repo}/resolutions/{resolution_id}.json"
167
+
168
+ try:
169
+ resp = client.get_object(Bucket=BUCKET_NAME, Key=key)
170
+ record = json.loads(resp["Body"].read().decode("utf-8"))
171
+ except ClientError as e:
172
+ raise R2Error(f"Failed to read resolution for update: {e}")
173
+
174
+ record.update(updates)
175
+ body = json.dumps(record, indent=2)
176
+
177
+ try:
178
+ client.put_object(
179
+ Bucket=BUCKET_NAME,
180
+ Key=key,
181
+ Body=body.encode("utf-8"),
182
+ ContentType="application/json",
183
+ )
184
+ except ClientError as e:
185
+ raise R2Error(f"Failed to update resolution: {e}")
186
+
187
+ return record
188
+
189
+
190
+ def get_resolution(owner: str, repo: str, resolution_id: str) -> dict:
191
+ """Read a resolution record from R2."""
192
+ client = _get_client()
193
+ key = f"{owner}/{repo}/resolutions/{resolution_id}.json"
194
+
195
+ try:
196
+ resp = client.get_object(Bucket=BUCKET_NAME, Key=key)
197
+ return json.loads(resp["Body"].read().decode("utf-8"))
198
+ except ClientError as e:
199
+ raise R2Error(f"Failed to read resolution: {e}")
200
+
201
+
202
+ def list_resolutions(owner: str, repo: str) -> list[dict]:
203
+ """List all resolution records for a repo. Returns parsed JSON dicts."""
204
+ client = _get_client()
205
+ prefix = f"{owner}/{repo}/resolutions/"
206
+
207
+ try:
208
+ resp = client.list_objects_v2(Bucket=BUCKET_NAME, Prefix=prefix)
209
+ contents = resp.get("Contents", [])
210
+ except ClientError as e:
211
+ raise R2Error(f"Failed to list resolutions: {e}")
212
+
213
+ results = []
214
+ for obj in contents:
215
+ if obj["Key"].endswith(".json"):
216
+ try:
217
+ resp = client.get_object(Bucket=BUCKET_NAME, Key=obj["Key"])
218
+ results.append(json.loads(resp["Body"].read().decode("utf-8")))
219
+ except (ClientError, json.JSONDecodeError):
220
+ pass
221
+ return results
@@ -0,0 +1,154 @@
1
+ """Hook handlers for Claude Code Stop/SessionEnd events.
2
+
3
+ Called via `ch _hook stream` and `ch _hook finalize`.
4
+ All exceptions are caught silently — hooks must never crash the agent.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from datetime import datetime, timezone
11
+ from pathlib import Path
12
+
13
+ from contexthub.core.context import parse_session_jsonl
14
+ from contexthub.core.models import RepoConfig
15
+ from contexthub.core.r2 import upload_session_meta, upload_session_transcript, update_resolution
16
+
17
+
18
+ def find_config_from_cwd(cwd: str) -> tuple[Path, RepoConfig] | None:
19
+ """Walk up from cwd to find .ch/config.json and return (git_root, config)."""
20
+ current = Path(cwd).resolve()
21
+ while True:
22
+ config_path = current / ".ch" / "config.json"
23
+ if config_path.is_file():
24
+ try:
25
+ data = json.loads(config_path.read_text(encoding="utf-8"))
26
+ config = RepoConfig.from_dict(data)
27
+ return current, config
28
+ except (json.JSONDecodeError, KeyError):
29
+ return None
30
+ parent = current.parent
31
+ if parent == current:
32
+ return None
33
+ current = parent
34
+
35
+
36
+ def handle_stream(hook_input: dict) -> None:
37
+ """Handle a Stop event: parse the transcript and upload to R2.
38
+
39
+ hook_input keys: session_id, transcript_path, cwd
40
+ """
41
+ session_id = hook_input.get("session_id")
42
+ transcript_path = hook_input.get("transcript_path")
43
+ cwd = hook_input.get("cwd")
44
+
45
+ if not session_id or not transcript_path or not cwd:
46
+ return
47
+
48
+ result = find_config_from_cwd(cwd)
49
+ if result is None:
50
+ return
51
+ git_root, config = result
52
+
53
+ path = Path(transcript_path)
54
+ if not path.is_file():
55
+ return
56
+
57
+ transcript = parse_session_jsonl(path)
58
+
59
+ from contexthub.core import git as gitops
60
+
61
+ try:
62
+ branch = gitops.get_current_branch(cwd=git_root)
63
+ except Exception:
64
+ branch = None
65
+
66
+ payload = {
67
+ "session_id": session_id,
68
+ "owner": config.owner,
69
+ "repo": config.repo,
70
+ "branch": branch,
71
+ "updated_at": datetime.now(timezone.utc).isoformat(),
72
+ "status": "active",
73
+ "transcript": transcript,
74
+ }
75
+
76
+ upload_session_transcript(config.owner, config.repo, session_id, payload)
77
+
78
+ # Link session_id to active resolution (write locally for mark-resolved to pick up)
79
+ active_res_file = git_root / ".ch" / "active_resolution"
80
+ if active_res_file.is_file():
81
+ content = active_res_file.read_text(encoding="utf-8").strip()
82
+ # Only write session_id if not already linked (file has no newline yet)
83
+ if "\n" not in content:
84
+ active_res_file.write_text(f"{content}\n{session_id}", encoding="utf-8")
85
+
86
+
87
+ def handle_finalize(hook_input: dict) -> None:
88
+ """Handle a SessionEnd event: final transcript upload + session metadata.
89
+
90
+ hook_input keys: session_id, transcript_path, cwd
91
+ """
92
+ session_id = hook_input.get("session_id")
93
+ transcript_path = hook_input.get("transcript_path")
94
+ cwd = hook_input.get("cwd")
95
+
96
+ if not session_id or not transcript_path or not cwd:
97
+ return
98
+
99
+ result = find_config_from_cwd(cwd)
100
+ if result is None:
101
+ return
102
+ git_root, config = result
103
+
104
+ path = Path(transcript_path)
105
+ if not path.is_file():
106
+ return
107
+
108
+ transcript = parse_session_jsonl(path)
109
+
110
+ from contexthub.core import git as gitops
111
+
112
+ try:
113
+ branch = gitops.get_current_branch(cwd=git_root)
114
+ except Exception:
115
+ branch = None
116
+
117
+ # Upload final transcript snapshot
118
+ transcript_payload = {
119
+ "session_id": session_id,
120
+ "owner": config.owner,
121
+ "repo": config.repo,
122
+ "branch": branch,
123
+ "updated_at": datetime.now(timezone.utc).isoformat(),
124
+ "status": "completed",
125
+ "transcript": transcript,
126
+ }
127
+ upload_session_transcript(
128
+ config.owner, config.repo, session_id, transcript_payload
129
+ )
130
+
131
+ # Upload session metadata
132
+ meta_payload = {
133
+ "session_id": session_id,
134
+ "owner": config.owner,
135
+ "repo": config.repo,
136
+ "branch": branch,
137
+ "ended_at": datetime.now(timezone.utc).isoformat(),
138
+ "transcript_size_bytes": len(transcript.encode("utf-8")),
139
+ }
140
+ upload_session_meta(config.owner, config.repo, session_id, meta_payload)
141
+
142
+ # If this was a resolver session, link the session_id to the resolution
143
+ active_res_file = git_root / ".ch" / "active_resolution"
144
+ if active_res_file.is_file():
145
+ content = active_res_file.read_text(encoding="utf-8").strip()
146
+ resolution_id = content.split("\n")[0]
147
+ if resolution_id:
148
+ try:
149
+ update_resolution(config.owner, config.repo, resolution_id, {
150
+ "session_id": session_id,
151
+ })
152
+ except Exception:
153
+ pass
154
+ active_res_file.unlink(missing_ok=True)
package/install.sh ADDED
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env bash
2
+ # postinstall script — creates a venv and installs Python dependencies
3
+ set -euo pipefail
4
+
5
+ PACKAGE_DIR="$(cd "$(dirname "$0")" && pwd)"
6
+
7
+ # ── Find Python 3.10+ ───────────────────────────────────────────────
8
+ PYTHON=""
9
+ for candidate in python3.13 python3.12 python3.11 python3.10 python3; do
10
+ if command -v "$candidate" &>/dev/null; then
11
+ # Verify version >= 3.10
12
+ version=$("$candidate" -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')" 2>/dev/null || true)
13
+ major="${version%%.*}"
14
+ minor="${version#*.}"
15
+ if [ "$major" = "3" ] && [ "$minor" -ge 10 ] 2>/dev/null; then
16
+ PYTHON="$(command -v "$candidate")"
17
+ break
18
+ fi
19
+ fi
20
+ done
21
+
22
+ if [ -z "$PYTHON" ]; then
23
+ echo "error: Python 3.10+ is required but not found." >&2
24
+ echo "Install Python from https://python.org and try again." >&2
25
+ exit 1
26
+ fi
27
+
28
+ echo "contexthub: using $PYTHON ($("$PYTHON" --version 2>&1))"
29
+
30
+ # ── Check that venv module is available ──────────────────────────────
31
+ if ! "$PYTHON" -m venv --help &>/dev/null; then
32
+ echo "error: Python venv module not available." >&2
33
+ echo "On Debian/Ubuntu, install it with: sudo apt install python3-venv" >&2
34
+ exit 1
35
+ fi
36
+
37
+ # ── Create venv and install deps ─────────────────────────────────────
38
+ echo "contexthub: creating venv..."
39
+ "$PYTHON" -m venv "$PACKAGE_DIR/.venv"
40
+
41
+ echo "contexthub: installing dependencies..."
42
+ "$PACKAGE_DIR/.venv/bin/pip" install --quiet --disable-pip-version-check -r "$PACKAGE_DIR/requirements.txt"
43
+
44
+ echo "contexthub: installed successfully."
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "contexthub-cli",
3
+ "version": "0.1.0",
4
+ "description": "ContextHub — intent-first version control on top of git",
5
+ "bin": {
6
+ "ch": "./bin/ch"
7
+ },
8
+ "files": [
9
+ "bin/",
10
+ "contexthub/**/*.py",
11
+ "install.sh",
12
+ "requirements.txt"
13
+ ],
14
+ "scripts": {
15
+ "postinstall": "bash install.sh"
16
+ },
17
+ "os": [
18
+ "darwin",
19
+ "linux"
20
+ ],
21
+ "license": "MIT"
22
+ }
@@ -0,0 +1,2 @@
1
+ click>=8.0
2
+ boto3>=1.28