aicodinggym-cli 0.1.0__py3-none-any.whl
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.
- aicodinggym/__init__.py +3 -0
- aicodinggym/api.py +132 -0
- aicodinggym/cli.py +641 -0
- aicodinggym/config.py +80 -0
- aicodinggym/git_ops.py +175 -0
- aicodinggym_cli-0.1.0.dist-info/METADATA +217 -0
- aicodinggym_cli-0.1.0.dist-info/RECORD +10 -0
- aicodinggym_cli-0.1.0.dist-info/WHEEL +5 -0
- aicodinggym_cli-0.1.0.dist-info/entry_points.txt +2 -0
- aicodinggym_cli-0.1.0.dist-info/top_level.txt +1 -0
aicodinggym/__init__.py
ADDED
aicodinggym/api.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""HTTP API client for the AI Coding Gym backend at aicodinggym.com."""
|
|
2
|
+
|
|
3
|
+
import requests
|
|
4
|
+
|
|
5
|
+
API_BASE = "https://aicodinggym.com/api"
|
|
6
|
+
TIMEOUT = 30
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class APIError(Exception):
|
|
10
|
+
"""Raised when an API call fails."""
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _post(endpoint: str, payload: dict, timeout: int = TIMEOUT) -> dict:
|
|
15
|
+
"""Make a POST request to the API and return parsed JSON."""
|
|
16
|
+
url = f"{API_BASE}/{endpoint}"
|
|
17
|
+
try:
|
|
18
|
+
resp = requests.post(url, json=payload, timeout=timeout)
|
|
19
|
+
resp.raise_for_status()
|
|
20
|
+
return resp.json()
|
|
21
|
+
except requests.ConnectionError:
|
|
22
|
+
raise APIError(
|
|
23
|
+
f"Cannot connect to {API_BASE}.\n"
|
|
24
|
+
"Check your internet connection and try again."
|
|
25
|
+
)
|
|
26
|
+
except requests.Timeout:
|
|
27
|
+
raise APIError(f"Request to {url} timed out after {timeout}s.")
|
|
28
|
+
except requests.HTTPError as e:
|
|
29
|
+
body = ""
|
|
30
|
+
try:
|
|
31
|
+
body = e.response.json().get("detail", e.response.text)
|
|
32
|
+
except Exception:
|
|
33
|
+
body = e.response.text
|
|
34
|
+
raise APIError(f"API error (HTTP {e.response.status_code}): {body}")
|
|
35
|
+
except requests.RequestException as e:
|
|
36
|
+
raise APIError(f"Request failed: {e}")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _get(endpoint: str, timeout: int = TIMEOUT, stream: bool = False) -> requests.Response:
|
|
40
|
+
"""Make a GET request to the API and return the raw response."""
|
|
41
|
+
url = f"{API_BASE}/{endpoint}"
|
|
42
|
+
try:
|
|
43
|
+
resp = requests.get(url, timeout=timeout, stream=stream)
|
|
44
|
+
resp.raise_for_status()
|
|
45
|
+
return resp
|
|
46
|
+
except requests.ConnectionError:
|
|
47
|
+
raise APIError(
|
|
48
|
+
f"Cannot connect to {API_BASE}.\n"
|
|
49
|
+
"Check your internet connection and try again."
|
|
50
|
+
)
|
|
51
|
+
except requests.Timeout:
|
|
52
|
+
raise APIError(f"Request to {url} timed out after {timeout}s.")
|
|
53
|
+
except requests.HTTPError as e:
|
|
54
|
+
body = ""
|
|
55
|
+
try:
|
|
56
|
+
body = e.response.json().get("detail", e.response.text)
|
|
57
|
+
except Exception:
|
|
58
|
+
body = e.response.text
|
|
59
|
+
raise APIError(f"API error (HTTP {e.response.status_code}): {body}")
|
|
60
|
+
except requests.RequestException as e:
|
|
61
|
+
raise APIError(f"Request failed: {e}")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def configure(user_id: str, public_key: str) -> dict:
|
|
65
|
+
"""Register public key with server. Returns {'repo_name': ...}."""
|
|
66
|
+
return _post("configure", {"user_id": user_id, "public_key": public_key})
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def fetch_problem(user_id: str, problem_id: str) -> dict:
|
|
70
|
+
"""Fetch problem info. Returns {'branch_name': ..., 'repo_url': ..., 'message': ...}."""
|
|
71
|
+
return _post("fetch-problem", {"user_id": user_id, "problem_id": problem_id})
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def submit_notification(problem_id: str, user_id: str, commit_hash: str,
|
|
75
|
+
branch: str, commit_message: str, timestamp: str) -> dict:
|
|
76
|
+
"""Notify backend of a submission."""
|
|
77
|
+
return _post("submissions", {
|
|
78
|
+
"problem_id": problem_id,
|
|
79
|
+
"user_id": user_id,
|
|
80
|
+
"commit_hash": commit_hash,
|
|
81
|
+
"branch": branch,
|
|
82
|
+
"commit_message": commit_message,
|
|
83
|
+
"timestamp": timestamp,
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def mlebench_download_info(user_id: str, competition_id: str, dest_path: str) -> None:
|
|
88
|
+
"""Download dataset for an MLE-bench competition directly to dest_path."""
|
|
89
|
+
resp = _get(f"competitions/{competition_id}/download", stream=True)
|
|
90
|
+
with open(dest_path, "wb") as f:
|
|
91
|
+
for chunk in resp.iter_content(chunk_size=8192):
|
|
92
|
+
f.write(chunk)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def mlebench_download_file(url: str, dest_path: str, timeout: int = 300) -> None:
|
|
96
|
+
"""Download a file from the given URL to dest_path with progress."""
|
|
97
|
+
try:
|
|
98
|
+
resp = requests.get(url, stream=True, timeout=timeout)
|
|
99
|
+
resp.raise_for_status()
|
|
100
|
+
with open(dest_path, "wb") as f:
|
|
101
|
+
for chunk in resp.iter_content(chunk_size=8192):
|
|
102
|
+
f.write(chunk)
|
|
103
|
+
except requests.RequestException as e:
|
|
104
|
+
raise APIError(f"Download failed: {e}")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def mlebench_submit_csv(user_id: str, competition_id: str, csv_path: str) -> dict:
|
|
108
|
+
"""Upload a prediction CSV for an MLE-bench competition."""
|
|
109
|
+
try:
|
|
110
|
+
with open(csv_path, "rb") as f:
|
|
111
|
+
resp = requests.post(
|
|
112
|
+
f"{API_BASE}/competitions/{competition_id}/submit",
|
|
113
|
+
data={"user_id": user_id, "competition_id": competition_id},
|
|
114
|
+
files={"file": (f.name, f, "text/csv")},
|
|
115
|
+
timeout=60,
|
|
116
|
+
)
|
|
117
|
+
resp.raise_for_status()
|
|
118
|
+
return resp.json()
|
|
119
|
+
except requests.ConnectionError:
|
|
120
|
+
raise APIError(
|
|
121
|
+
f"Cannot connect to {API_BASE}.\n"
|
|
122
|
+
"Check your internet connection and try again."
|
|
123
|
+
)
|
|
124
|
+
except requests.HTTPError as e:
|
|
125
|
+
body = ""
|
|
126
|
+
try:
|
|
127
|
+
body = e.response.json().get("detail", e.response.text)
|
|
128
|
+
except Exception:
|
|
129
|
+
body = e.response.text
|
|
130
|
+
raise APIError(f"API error (HTTP {e.response.status_code}): {body}")
|
|
131
|
+
except requests.RequestException as e:
|
|
132
|
+
raise APIError(f"Request failed: {e}")
|
aicodinggym/cli.py
ADDED
|
@@ -0,0 +1,641 @@
|
|
|
1
|
+
"""AI Coding Gym CLI - main entry point.
|
|
2
|
+
|
|
3
|
+
A command-line tool for the AI Coding Gym platform (https://aicodinggym.com).
|
|
4
|
+
Supports SWE-bench and MLE-bench challenges.
|
|
5
|
+
|
|
6
|
+
SETUP (required before any other command):
|
|
7
|
+
aicodinggym configure --user-id YOUR_USER_ID
|
|
8
|
+
|
|
9
|
+
SWE-BENCH WORKFLOW:
|
|
10
|
+
aicodinggym swe fetch django__django-10097
|
|
11
|
+
# ... edit code to fix the issue ...
|
|
12
|
+
aicodinggym swe submit django__django-10097
|
|
13
|
+
|
|
14
|
+
MLE-BENCH WORKFLOW:
|
|
15
|
+
aicodinggym mle download spaceship-titanic
|
|
16
|
+
# ... train model, generate predictions ...
|
|
17
|
+
aicodinggym mle submit spaceship-titanic -F submission.csv
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import sys
|
|
21
|
+
from datetime import datetime
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
import click
|
|
25
|
+
|
|
26
|
+
from . import __version__
|
|
27
|
+
from .api import (
|
|
28
|
+
APIError,
|
|
29
|
+
configure as api_configure,
|
|
30
|
+
fetch_problem as api_fetch_problem,
|
|
31
|
+
mlebench_download_file,
|
|
32
|
+
mlebench_download_info,
|
|
33
|
+
mlebench_submit_csv,
|
|
34
|
+
submit_notification,
|
|
35
|
+
)
|
|
36
|
+
from .config import (
|
|
37
|
+
load_config,
|
|
38
|
+
load_credentials,
|
|
39
|
+
save_config,
|
|
40
|
+
save_credentials,
|
|
41
|
+
)
|
|
42
|
+
from .git_ops import (
|
|
43
|
+
add_commit_push,
|
|
44
|
+
clone_repo,
|
|
45
|
+
generate_ssh_key_pair,
|
|
46
|
+
reset_to_setup_commit,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _error(msg: str) -> None:
|
|
51
|
+
"""Print an error message to stderr and exit."""
|
|
52
|
+
click.echo(f"Error: {msg}", err=True)
|
|
53
|
+
sys.exit(1)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _warn(msg: str) -> None:
|
|
57
|
+
"""Print a warning message to stderr."""
|
|
58
|
+
click.echo(f"Warning: {msg}", err=True)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _resolve_user_id(config: dict, user_id: str | None) -> str:
|
|
62
|
+
"""Resolve user_id from argument or config, with helpful error."""
|
|
63
|
+
if user_id:
|
|
64
|
+
return user_id
|
|
65
|
+
uid = config.get("user_id")
|
|
66
|
+
if not uid:
|
|
67
|
+
_error(
|
|
68
|
+
"User ID is not configured.\n\n"
|
|
69
|
+
"Run 'aicodinggym configure --user-id YOUR_USER_ID' first.\n"
|
|
70
|
+
"This generates an SSH key and registers it with aicodinggym.com."
|
|
71
|
+
)
|
|
72
|
+
return uid
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _resolve_workspace(config: dict, workspace_dir: str | None) -> Path:
|
|
76
|
+
"""Resolve workspace directory from argument or config."""
|
|
77
|
+
if workspace_dir:
|
|
78
|
+
return Path(workspace_dir).resolve()
|
|
79
|
+
configured = config.get("workspace_dir")
|
|
80
|
+
if configured:
|
|
81
|
+
return Path(configured).resolve()
|
|
82
|
+
return Path.cwd().resolve()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _resolve_key_path(config: dict, creds: dict | None = None) -> Path:
|
|
86
|
+
"""Resolve SSH private key path from credentials or config."""
|
|
87
|
+
path_str = None
|
|
88
|
+
if creds:
|
|
89
|
+
path_str = creds.get("private_key_path")
|
|
90
|
+
if not path_str:
|
|
91
|
+
path_str = config.get("private_key_path")
|
|
92
|
+
if not path_str:
|
|
93
|
+
_error(
|
|
94
|
+
"SSH key path not found.\n\n"
|
|
95
|
+
"Run 'aicodinggym configure --user-id YOUR_USER_ID' to generate a key.\n"
|
|
96
|
+
"If you previously configured, your config may be corrupted.\n"
|
|
97
|
+
"Config location: ~/.aicodinggym/config.json"
|
|
98
|
+
)
|
|
99
|
+
key_path = Path(path_str)
|
|
100
|
+
if not key_path.exists():
|
|
101
|
+
_error(
|
|
102
|
+
f"SSH key file not found at: {key_path}\n\n"
|
|
103
|
+
"Run 'aicodinggym configure --user-id YOUR_USER_ID' to regenerate.\n"
|
|
104
|
+
"This will create a new SSH key pair and register it with the server."
|
|
105
|
+
)
|
|
106
|
+
return key_path
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ── Top-level group ──────────────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@click.group(
|
|
113
|
+
epilog=(
|
|
114
|
+
"\b\n"
|
|
115
|
+
"SETUP (run once before using other commands):\n"
|
|
116
|
+
" aicodinggym configure --user-id YOUR_USER_ID\n"
|
|
117
|
+
" (user_id is required — get yours at https://aicodinggym.com)\n\n"
|
|
118
|
+
"\b\n"
|
|
119
|
+
"EXAMPLES:\n"
|
|
120
|
+
" aicodinggym swe fetch django__django-10097\n"
|
|
121
|
+
" aicodinggym swe submit django__django-10097 --message 'Fix auth bug'\n"
|
|
122
|
+
" aicodinggym mle download spaceship-titanic\n"
|
|
123
|
+
" aicodinggym mle submit spaceship-titanic -F predictions.csv\n\n"
|
|
124
|
+
"\b\n"
|
|
125
|
+
"WEBSITE:\n"
|
|
126
|
+
" https://aicodinggym.com\n"
|
|
127
|
+
),
|
|
128
|
+
)
|
|
129
|
+
@click.version_option(__version__, prog_name="aicodinggym")
|
|
130
|
+
def main():
|
|
131
|
+
"""AI Coding Gym CLI.
|
|
132
|
+
|
|
133
|
+
A command-line interface for the AI Coding Gym platform
|
|
134
|
+
(https://aicodinggym.com). Provides tools to fetch coding problems,
|
|
135
|
+
download datasets, and submit solutions.
|
|
136
|
+
|
|
137
|
+
Designed for use by both humans and LLM/AI agents.
|
|
138
|
+
|
|
139
|
+
\b
|
|
140
|
+
QUICK START:
|
|
141
|
+
1. Configure: aicodinggym configure --user-id YOUR_USER_ID
|
|
142
|
+
2. Fetch: aicodinggym swe fetch PROBLEM_ID
|
|
143
|
+
3. Solve: (edit code in the cloned repository)
|
|
144
|
+
4. Submit: aicodinggym swe submit PROBLEM_ID
|
|
145
|
+
"""
|
|
146
|
+
pass
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# ── configure ────────────────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@main.command()
|
|
153
|
+
@click.option(
|
|
154
|
+
"--user-id", required=True,
|
|
155
|
+
help="Your AI Coding Gym user ID. Get one at https://aicodinggym.com.",
|
|
156
|
+
)
|
|
157
|
+
@click.option(
|
|
158
|
+
"--workspace-dir", default=None, type=click.Path(),
|
|
159
|
+
help="Default workspace directory for cloning repositories. "
|
|
160
|
+
"Defaults to the current working directory.",
|
|
161
|
+
)
|
|
162
|
+
def configure(user_id: str, workspace_dir: str | None):
|
|
163
|
+
"""Configure credentials and register SSH key with aicodinggym.com.
|
|
164
|
+
|
|
165
|
+
Generates an SSH key pair locally (stored in ~/.aicodinggym/),
|
|
166
|
+
sends the public key to the server, and saves your configuration.
|
|
167
|
+
|
|
168
|
+
\b
|
|
169
|
+
This command must be run once before using any other commands.
|
|
170
|
+
If you've already configured, running again will reuse your existing key.
|
|
171
|
+
|
|
172
|
+
\b
|
|
173
|
+
WHAT IT DOES:
|
|
174
|
+
1. Generates SSH key pair in ~/.aicodinggym/
|
|
175
|
+
2. Registers your public key with the AI Coding Gym server
|
|
176
|
+
3. Receives your assigned repository name
|
|
177
|
+
4. Saves all settings to ~/.aicodinggym/config.json
|
|
178
|
+
|
|
179
|
+
\b
|
|
180
|
+
EXAMPLE:
|
|
181
|
+
aicodinggym configure --user-id alice123
|
|
182
|
+
aicodinggym configure --user-id alice123 --workspace-dir ~/gym-workspace
|
|
183
|
+
"""
|
|
184
|
+
try:
|
|
185
|
+
click.echo(f"Generating SSH key for user '{user_id}'...")
|
|
186
|
+
private_key_path, public_key = generate_ssh_key_pair(user_id)
|
|
187
|
+
|
|
188
|
+
click.echo("Registering public key with aicodinggym.com...")
|
|
189
|
+
try:
|
|
190
|
+
data = api_configure(user_id, public_key)
|
|
191
|
+
repo_name = data.get("repo_name")
|
|
192
|
+
if not repo_name:
|
|
193
|
+
_error("Server did not return a repository name. Please try again or contact support.")
|
|
194
|
+
except APIError as e:
|
|
195
|
+
if "409" in str(e):
|
|
196
|
+
click.echo("Key already registered, reusing existing configuration.")
|
|
197
|
+
existing = load_config()
|
|
198
|
+
repo_name = existing.get("repo_name", f"submission-{user_id}")
|
|
199
|
+
else:
|
|
200
|
+
raise
|
|
201
|
+
|
|
202
|
+
resolved_workspace = str(Path(workspace_dir).resolve()) if workspace_dir else str(Path.cwd().resolve())
|
|
203
|
+
|
|
204
|
+
config = {
|
|
205
|
+
"user_id": user_id,
|
|
206
|
+
"repo_name": repo_name,
|
|
207
|
+
"private_key_path": str(private_key_path),
|
|
208
|
+
"workspace_dir": resolved_workspace,
|
|
209
|
+
}
|
|
210
|
+
save_config(config)
|
|
211
|
+
|
|
212
|
+
click.echo(
|
|
213
|
+
f"\nConfiguration saved successfully!\n"
|
|
214
|
+
f"\n"
|
|
215
|
+
f" User ID: {user_id}\n"
|
|
216
|
+
f" Repository: {repo_name}\n"
|
|
217
|
+
f" Workspace: {resolved_workspace}\n"
|
|
218
|
+
f" SSH Key: {private_key_path}\n"
|
|
219
|
+
f" Config: ~/.aicodinggym/config.json\n"
|
|
220
|
+
f"\n"
|
|
221
|
+
f"You can now use 'aicodinggym swe' and 'aicodinggym mle' commands."
|
|
222
|
+
)
|
|
223
|
+
except APIError as e:
|
|
224
|
+
_error(str(e))
|
|
225
|
+
except Exception as e:
|
|
226
|
+
_error(f"Configuration failed: {e}")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# ── swe group ────────────────────────────────────────────────────────────────
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@main.group()
|
|
233
|
+
def swe():
|
|
234
|
+
"""SWE-bench coding challenges - fetch, solve, and submit bug fixes.
|
|
235
|
+
|
|
236
|
+
\b
|
|
237
|
+
PREREQUISITE:
|
|
238
|
+
Run 'aicodinggym configure --user-id YOUR_USER_ID' before using these commands.
|
|
239
|
+
|
|
240
|
+
\b
|
|
241
|
+
WORKFLOW:
|
|
242
|
+
1. aicodinggym swe fetch PROBLEM_ID # Clone the problem repo
|
|
243
|
+
2. (edit code to fix the issue) # Work on your solution
|
|
244
|
+
3. aicodinggym swe submit PROBLEM_ID # Submit your fix
|
|
245
|
+
4. aicodinggym swe reset PROBLEM_ID # (optional) Start over
|
|
246
|
+
|
|
247
|
+
\b
|
|
248
|
+
PROBLEM IDS:
|
|
249
|
+
Problem IDs follow the format: <project>__<repo>-<number>
|
|
250
|
+
Examples: django__django-10097, sympy__sympy-13043, scikit-learn__scikit-learn-11578
|
|
251
|
+
"""
|
|
252
|
+
pass
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@swe.command("fetch")
|
|
256
|
+
@click.argument("problem_id")
|
|
257
|
+
@click.option("--user-id", default=None, help="Override configured user ID.")
|
|
258
|
+
@click.option(
|
|
259
|
+
"--workspace-dir", default=None, type=click.Path(),
|
|
260
|
+
help="Directory to clone into. Overrides configured workspace.",
|
|
261
|
+
)
|
|
262
|
+
def swe_fetch(problem_id: str, user_id: str | None, workspace_dir: str | None):
|
|
263
|
+
"""Fetch a SWE-bench problem and clone its repository locally.
|
|
264
|
+
|
|
265
|
+
Contacts the AI Coding Gym server to set up the problem branch,
|
|
266
|
+
then clones the repository into your workspace directory.
|
|
267
|
+
|
|
268
|
+
\b
|
|
269
|
+
PREREQUISITE:
|
|
270
|
+
You must run 'aicodinggym configure --user-id YOUR_USER_ID' first.
|
|
271
|
+
If you haven't configured yet, this command will fail with instructions.
|
|
272
|
+
|
|
273
|
+
\b
|
|
274
|
+
ARGUMENTS:
|
|
275
|
+
PROBLEM_ID The unique problem identifier (e.g., 'django__django-10097').
|
|
276
|
+
Get problem IDs from https://aicodinggym.com.
|
|
277
|
+
|
|
278
|
+
\b
|
|
279
|
+
WHAT IT DOES:
|
|
280
|
+
1. Requests the problem branch from the server
|
|
281
|
+
2. Clones the repository (shallow clone for efficiency)
|
|
282
|
+
3. Sets up your local workspace at <workspace>/<problem_id>/
|
|
283
|
+
|
|
284
|
+
\b
|
|
285
|
+
EXAMPLE:
|
|
286
|
+
aicodinggym swe fetch django__django-10097
|
|
287
|
+
aicodinggym swe fetch django__django-10097 --workspace-dir ~/projects
|
|
288
|
+
"""
|
|
289
|
+
config = load_config()
|
|
290
|
+
uid = _resolve_user_id(config, user_id)
|
|
291
|
+
workspace = _resolve_workspace(config, workspace_dir)
|
|
292
|
+
key_path = _resolve_key_path(config)
|
|
293
|
+
|
|
294
|
+
try:
|
|
295
|
+
click.echo(f"Fetching problem '{problem_id}' from server...")
|
|
296
|
+
data = api_fetch_problem(uid, problem_id)
|
|
297
|
+
except APIError as e:
|
|
298
|
+
_error(str(e))
|
|
299
|
+
|
|
300
|
+
branch = data.get("branch_name", problem_id)
|
|
301
|
+
repo_url = data.get("repo_url")
|
|
302
|
+
server_msg = data.get("message", "")
|
|
303
|
+
|
|
304
|
+
if not repo_url:
|
|
305
|
+
_error("Server did not return a repository URL. The problem may not exist.")
|
|
306
|
+
|
|
307
|
+
# Save credentials for later submit
|
|
308
|
+
credentials = load_credentials()
|
|
309
|
+
credentials[problem_id] = {
|
|
310
|
+
"repo_url": repo_url,
|
|
311
|
+
"branch": branch,
|
|
312
|
+
"user_id": uid,
|
|
313
|
+
"private_key_path": str(key_path),
|
|
314
|
+
"workspace_dir": str(workspace),
|
|
315
|
+
"benchmark": "swe",
|
|
316
|
+
}
|
|
317
|
+
save_credentials(credentials)
|
|
318
|
+
|
|
319
|
+
workspace.mkdir(parents=True, exist_ok=True)
|
|
320
|
+
|
|
321
|
+
click.echo(f"Cloning branch '{branch}' into {workspace / problem_id}...")
|
|
322
|
+
success, msg = clone_repo(repo_url, branch, problem_id, str(workspace), key_path)
|
|
323
|
+
|
|
324
|
+
if not success:
|
|
325
|
+
_error(msg)
|
|
326
|
+
|
|
327
|
+
click.echo(
|
|
328
|
+
f"\nSuccessfully fetched problem: {problem_id}\n"
|
|
329
|
+
f"\n"
|
|
330
|
+
f" {msg}\n"
|
|
331
|
+
)
|
|
332
|
+
if server_msg:
|
|
333
|
+
click.echo(f" Server: {server_msg}\n")
|
|
334
|
+
click.echo("You can now start working on the solution!")
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
@swe.command("submit")
|
|
338
|
+
@click.argument("problem_id")
|
|
339
|
+
@click.option("--user-id", default=None, help="Override configured user ID.")
|
|
340
|
+
@click.option(
|
|
341
|
+
"--message", "-m", default=None,
|
|
342
|
+
help="Commit message. Auto-generated if not provided.",
|
|
343
|
+
)
|
|
344
|
+
@click.option(
|
|
345
|
+
"--force", is_flag=True, default=False,
|
|
346
|
+
help="Force push (--force-with-lease). Use with caution.",
|
|
347
|
+
)
|
|
348
|
+
@click.option(
|
|
349
|
+
"--workspace-dir", default=None, type=click.Path(),
|
|
350
|
+
help="Workspace directory. Overrides configured/cached value.",
|
|
351
|
+
)
|
|
352
|
+
def swe_submit(problem_id: str, user_id: str | None, message: str | None,
|
|
353
|
+
force: bool, workspace_dir: str | None):
|
|
354
|
+
"""Submit your SWE-bench solution by committing and pushing changes.
|
|
355
|
+
|
|
356
|
+
Stages all changes, commits them, pushes to the remote, and notifies
|
|
357
|
+
the AI Coding Gym server that your submission is ready for evaluation.
|
|
358
|
+
|
|
359
|
+
\b
|
|
360
|
+
PREREQUISITE:
|
|
361
|
+
You must run 'aicodinggym swe fetch PROBLEM_ID' first.
|
|
362
|
+
The fetch command sets up the repository and caches credentials
|
|
363
|
+
needed for submission.
|
|
364
|
+
|
|
365
|
+
\b
|
|
366
|
+
ARGUMENTS:
|
|
367
|
+
PROBLEM_ID The problem identifier you fetched earlier.
|
|
368
|
+
|
|
369
|
+
\b
|
|
370
|
+
WHAT IT DOES:
|
|
371
|
+
1. Stages all changed files (git add)
|
|
372
|
+
2. Commits with your message (or auto-generated one)
|
|
373
|
+
3. Pushes to the remote branch
|
|
374
|
+
4. Notifies the backend for evaluation
|
|
375
|
+
|
|
376
|
+
\b
|
|
377
|
+
EXAMPLE:
|
|
378
|
+
aicodinggym swe submit django__django-10097
|
|
379
|
+
aicodinggym swe submit django__django-10097 -m "Fix auth validation bug"
|
|
380
|
+
aicodinggym swe submit django__django-10097 --force
|
|
381
|
+
"""
|
|
382
|
+
config = load_config()
|
|
383
|
+
uid = _resolve_user_id(config, user_id)
|
|
384
|
+
|
|
385
|
+
credentials = load_credentials()
|
|
386
|
+
if problem_id not in credentials:
|
|
387
|
+
_error(
|
|
388
|
+
f"No credentials found for '{problem_id}'.\n\n"
|
|
389
|
+
f"You must fetch the problem first:\n"
|
|
390
|
+
f" aicodinggym swe fetch {problem_id}\n\n"
|
|
391
|
+
f"This clones the repository and saves the credentials needed for submission."
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
creds = credentials[problem_id]
|
|
395
|
+
|
|
396
|
+
if creds.get("user_id") and creds["user_id"] != uid:
|
|
397
|
+
_error(
|
|
398
|
+
f"User ID mismatch. Problem was fetched by '{creds['user_id']}', not '{uid}'.\n"
|
|
399
|
+
f"Either use --user-id {creds['user_id']} or re-fetch the problem."
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
workspace = _resolve_workspace(config, workspace_dir or creds.get("workspace_dir"))
|
|
403
|
+
problem_dir = workspace / problem_id
|
|
404
|
+
|
|
405
|
+
if not problem_dir.exists():
|
|
406
|
+
_error(
|
|
407
|
+
f"Problem directory not found at: {problem_dir}\n\n"
|
|
408
|
+
f"You must fetch the problem first:\n"
|
|
409
|
+
f" aicodinggym swe fetch {problem_id}\n\n"
|
|
410
|
+
f"Or specify the correct workspace with --workspace-dir."
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
key_path = _resolve_key_path(config, creds)
|
|
414
|
+
branch = creds["branch"]
|
|
415
|
+
commit_msg = message or f"Solution submission for {problem_id} at {datetime.now().isoformat()}"
|
|
416
|
+
|
|
417
|
+
click.echo(f"Submitting solution for '{problem_id}'...")
|
|
418
|
+
success, msg, commit_hash = add_commit_push(str(problem_dir), branch, key_path, commit_msg, force)
|
|
419
|
+
|
|
420
|
+
if not success:
|
|
421
|
+
_error(msg)
|
|
422
|
+
|
|
423
|
+
# Notify backend
|
|
424
|
+
try:
|
|
425
|
+
submit_notification(
|
|
426
|
+
problem_id=problem_id,
|
|
427
|
+
user_id=uid,
|
|
428
|
+
commit_hash=commit_hash,
|
|
429
|
+
branch=branch,
|
|
430
|
+
commit_message=commit_msg,
|
|
431
|
+
timestamp=datetime.now().isoformat(),
|
|
432
|
+
)
|
|
433
|
+
except APIError as e:
|
|
434
|
+
_warn(f"Changes pushed, but failed to notify backend: {e}")
|
|
435
|
+
|
|
436
|
+
click.echo(
|
|
437
|
+
f"\nSuccessfully submitted solution for {problem_id}\n"
|
|
438
|
+
f"\n"
|
|
439
|
+
f" Commit: {commit_hash[:8]}\n"
|
|
440
|
+
f" Branch: {branch}\n"
|
|
441
|
+
f" Status: Pushed and backend notified\n"
|
|
442
|
+
f"\n"
|
|
443
|
+
f"Your solution has been submitted for evaluation!"
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
@swe.command("reset")
|
|
448
|
+
@click.argument("problem_id")
|
|
449
|
+
@click.option("--user-id", default=None, help="Override configured user ID.")
|
|
450
|
+
@click.option(
|
|
451
|
+
"--workspace-dir", default=None, type=click.Path(),
|
|
452
|
+
help="Workspace directory. Overrides configured/cached value.",
|
|
453
|
+
)
|
|
454
|
+
def swe_reset(problem_id: str, user_id: str | None, workspace_dir: str | None):
|
|
455
|
+
"""Reset a SWE-bench problem to its original state.
|
|
456
|
+
|
|
457
|
+
Discards all local changes and resets the repository back to the
|
|
458
|
+
original setup commit. Use this to start over on a problem.
|
|
459
|
+
|
|
460
|
+
\b
|
|
461
|
+
WARNING: This is destructive. All your local changes will be lost.
|
|
462
|
+
|
|
463
|
+
\b
|
|
464
|
+
PREREQUISITE:
|
|
465
|
+
You must run 'aicodinggym swe fetch PROBLEM_ID' first.
|
|
466
|
+
|
|
467
|
+
\b
|
|
468
|
+
ARGUMENTS:
|
|
469
|
+
PROBLEM_ID The problem identifier to reset.
|
|
470
|
+
|
|
471
|
+
\b
|
|
472
|
+
WHAT IT DOES:
|
|
473
|
+
1. Finds the original 'Setup SWE-bench instance:' commit
|
|
474
|
+
2. Runs git reset --hard to that commit
|
|
475
|
+
3. Removes untracked files (git clean -fd)
|
|
476
|
+
|
|
477
|
+
\b
|
|
478
|
+
EXAMPLE:
|
|
479
|
+
aicodinggym swe reset django__django-10097
|
|
480
|
+
"""
|
|
481
|
+
config = load_config()
|
|
482
|
+
uid = _resolve_user_id(config, user_id)
|
|
483
|
+
|
|
484
|
+
credentials = load_credentials()
|
|
485
|
+
if problem_id not in credentials:
|
|
486
|
+
_error(
|
|
487
|
+
f"No credentials found for '{problem_id}'.\n\n"
|
|
488
|
+
f"You must fetch the problem first:\n"
|
|
489
|
+
f" aicodinggym swe fetch {problem_id}"
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
creds = credentials[problem_id]
|
|
493
|
+
|
|
494
|
+
if creds.get("user_id") and creds["user_id"] != uid:
|
|
495
|
+
_error(f"User ID mismatch. Problem was fetched by '{creds['user_id']}', not '{uid}'.")
|
|
496
|
+
|
|
497
|
+
workspace = _resolve_workspace(config, workspace_dir or creds.get("workspace_dir"))
|
|
498
|
+
problem_dir = workspace / problem_id
|
|
499
|
+
|
|
500
|
+
if not problem_dir.exists():
|
|
501
|
+
_error(f"Problem directory not found at: {problem_dir}")
|
|
502
|
+
|
|
503
|
+
click.echo(f"Resetting '{problem_id}' to original state...")
|
|
504
|
+
success, msg = reset_to_setup_commit(str(problem_dir))
|
|
505
|
+
|
|
506
|
+
if not success:
|
|
507
|
+
_error(msg)
|
|
508
|
+
|
|
509
|
+
click.echo(f"\n{msg}")
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
# ── mle group ────────────────────────────────────────────────────────────────
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
@main.group()
|
|
516
|
+
def mle():
|
|
517
|
+
"""MLE-bench ML competitions - download data and submit predictions.
|
|
518
|
+
|
|
519
|
+
\b
|
|
520
|
+
PREREQUISITE:
|
|
521
|
+
Run 'aicodinggym configure --user-id YOUR_USER_ID' before using these commands.
|
|
522
|
+
|
|
523
|
+
\b
|
|
524
|
+
WORKFLOW:
|
|
525
|
+
1. aicodinggym mle download COMPETITION_ID # Download dataset
|
|
526
|
+
2. (train your model and generate predictions) # Work on your solution
|
|
527
|
+
3. aicodinggym mle submit COMPETITION_ID -F submission.csv # Submit predictions
|
|
528
|
+
|
|
529
|
+
\b
|
|
530
|
+
COMPETITION IDS:
|
|
531
|
+
Examples: spaceship-titanic, house-prices, digit-recognizer
|
|
532
|
+
Browse competitions at https://aicodinggym.com
|
|
533
|
+
"""
|
|
534
|
+
pass
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
@mle.command("download")
|
|
538
|
+
@click.argument("competition_id")
|
|
539
|
+
@click.option("--user-id", default=None, help="Override configured user ID.")
|
|
540
|
+
@click.option(
|
|
541
|
+
"--workspace-dir", default=None, type=click.Path(),
|
|
542
|
+
help="Workspace directory. Defaults to configured workspace.",
|
|
543
|
+
)
|
|
544
|
+
def mle_download(competition_id: str, user_id: str | None, workspace_dir: str | None):
|
|
545
|
+
"""Download dataset files for an MLE-bench competition.
|
|
546
|
+
|
|
547
|
+
\b
|
|
548
|
+
EXAMPLE:
|
|
549
|
+
aicodinggym mle download spaceship-titanic
|
|
550
|
+
aicodinggym mle download spaceship-titanic --workspace-dir ~/workspace
|
|
551
|
+
"""
|
|
552
|
+
config = load_config()
|
|
553
|
+
uid = _resolve_user_id(config, user_id)
|
|
554
|
+
|
|
555
|
+
workspace = _resolve_workspace(config, workspace_dir)
|
|
556
|
+
dest_dir = workspace / competition_id / "data"
|
|
557
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
558
|
+
|
|
559
|
+
filename = f"{competition_id}.zip"
|
|
560
|
+
dest_path = dest_dir / filename
|
|
561
|
+
|
|
562
|
+
try:
|
|
563
|
+
click.echo(f"Downloading dataset for '{competition_id}'...")
|
|
564
|
+
mlebench_download_info(uid, competition_id, str(dest_path))
|
|
565
|
+
except APIError as e:
|
|
566
|
+
_error(str(e))
|
|
567
|
+
|
|
568
|
+
click.echo(
|
|
569
|
+
f"\nDataset downloaded to: {dest_path}\n"
|
|
570
|
+
f"\nNext step: train your model and submit predictions with:\n"
|
|
571
|
+
f" aicodinggym mle submit {competition_id} -F your_predictions.csv"
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
@mle.command("submit")
|
|
576
|
+
@click.argument("competition_id")
|
|
577
|
+
@click.option(
|
|
578
|
+
"-F", "csv_path", required=True, type=click.Path(exists=True),
|
|
579
|
+
help="Path to your prediction CSV file (required).",
|
|
580
|
+
)
|
|
581
|
+
@click.option("--user-id", default=None, help="Override configured user ID.")
|
|
582
|
+
@click.option(
|
|
583
|
+
"--message", "-m", default=None,
|
|
584
|
+
help="Description of your submission (optional).",
|
|
585
|
+
)
|
|
586
|
+
def mle_submit(competition_id: str, csv_path: str, user_id: str | None,
|
|
587
|
+
message: str | None):
|
|
588
|
+
"""Submit a prediction CSV for an MLE-bench competition.
|
|
589
|
+
|
|
590
|
+
Uploads your prediction CSV directly to the AI Coding Gym server
|
|
591
|
+
for scoring.
|
|
592
|
+
|
|
593
|
+
\b
|
|
594
|
+
PREREQUISITE:
|
|
595
|
+
You must run 'aicodinggym configure --user-id YOUR_USER_ID' first.
|
|
596
|
+
|
|
597
|
+
\b
|
|
598
|
+
ARGUMENTS:
|
|
599
|
+
COMPETITION_ID The competition identifier (e.g., 'spaceship-titanic').
|
|
600
|
+
|
|
601
|
+
\b
|
|
602
|
+
OPTIONS:
|
|
603
|
+
-F FILE Path to your prediction CSV file. This is REQUIRED.
|
|
604
|
+
The file must exist and be a valid CSV matching the
|
|
605
|
+
competition's expected format (see sample_submission.csv).
|
|
606
|
+
|
|
607
|
+
\b
|
|
608
|
+
WHAT IT DOES:
|
|
609
|
+
1. Validates that the CSV file exists
|
|
610
|
+
2. Uploads the CSV to the AI Coding Gym server
|
|
611
|
+
3. Server scores your predictions and returns results
|
|
612
|
+
|
|
613
|
+
\b
|
|
614
|
+
EXAMPLE:
|
|
615
|
+
aicodinggym mle submit spaceship-titanic -F predictions.csv
|
|
616
|
+
aicodinggym mle submit spaceship-titanic -F ./output/pred.csv -m "XGBoost v2"
|
|
617
|
+
"""
|
|
618
|
+
config = load_config()
|
|
619
|
+
uid = _resolve_user_id(config, user_id)
|
|
620
|
+
|
|
621
|
+
csv_src = Path(csv_path).resolve()
|
|
622
|
+
|
|
623
|
+
click.echo(f"Uploading {csv_src.name} for '{competition_id}'...")
|
|
624
|
+
|
|
625
|
+
try:
|
|
626
|
+
result = mlebench_submit_csv(uid, competition_id, str(csv_src))
|
|
627
|
+
except APIError as e:
|
|
628
|
+
_error(str(e))
|
|
629
|
+
|
|
630
|
+
score_msg = result.get("message", "Submission received for scoring.")
|
|
631
|
+
score = result.get("score")
|
|
632
|
+
|
|
633
|
+
click.echo(
|
|
634
|
+
f"\nSuccessfully submitted prediction for {competition_id}\n"
|
|
635
|
+
f"\n"
|
|
636
|
+
f" CSV: {csv_src.name}\n"
|
|
637
|
+
f" Status: {score_msg}\n"
|
|
638
|
+
)
|
|
639
|
+
if score is not None:
|
|
640
|
+
click.echo(f" Score: {score}\n")
|
|
641
|
+
click.echo("Your prediction has been submitted for scoring!")
|
aicodinggym/config.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Configuration and credentials management for AI Coding Gym CLI.
|
|
2
|
+
|
|
3
|
+
Stores configuration in ~/.aicodinggym/config.json and per-problem
|
|
4
|
+
credentials in ~/.aicodinggym/credentials.json.
|
|
5
|
+
SSH keys are stored in ~/.aicodinggym/{user_id}_id_rsa.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
CONFIG_DIR = Path.home() / ".aicodinggym"
|
|
14
|
+
CONFIG_PATH = CONFIG_DIR / "config.json"
|
|
15
|
+
CREDENTIALS_PATH = CONFIG_DIR / "credentials.json"
|
|
16
|
+
|
|
17
|
+
# Fields persisted in config.json
|
|
18
|
+
_CONFIG_FIELDS = ("user_id", "repo_name", "private_key_path", "workspace_dir")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def ensure_config_dir() -> Path:
|
|
22
|
+
"""Create the config directory with secure permissions if it doesn't exist."""
|
|
23
|
+
CONFIG_DIR.mkdir(mode=0o700, exist_ok=True)
|
|
24
|
+
return CONFIG_DIR
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def load_config() -> dict[str, str]:
|
|
28
|
+
"""Load global configuration from ~/.aicodinggym/config.json."""
|
|
29
|
+
if not CONFIG_PATH.exists():
|
|
30
|
+
return {}
|
|
31
|
+
try:
|
|
32
|
+
data = json.loads(CONFIG_PATH.read_text())
|
|
33
|
+
if not isinstance(data, dict):
|
|
34
|
+
return {}
|
|
35
|
+
return {k: v for k, v in data.items() if k in _CONFIG_FIELDS and isinstance(v, str) and v}
|
|
36
|
+
except (json.JSONDecodeError, OSError):
|
|
37
|
+
return {}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def save_config(config: dict[str, str]) -> None:
|
|
41
|
+
"""Persist global configuration to ~/.aicodinggym/config.json."""
|
|
42
|
+
ensure_config_dir()
|
|
43
|
+
data = {k: v for k, v in config.items() if k in _CONFIG_FIELDS}
|
|
44
|
+
CONFIG_PATH.write_text(json.dumps(data, indent=2) + "\n")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def load_credentials() -> dict[str, dict[str, Any]]:
|
|
48
|
+
"""Load per-problem credentials from ~/.aicodinggym/credentials.json."""
|
|
49
|
+
if not CREDENTIALS_PATH.exists():
|
|
50
|
+
return {}
|
|
51
|
+
try:
|
|
52
|
+
data = json.loads(CREDENTIALS_PATH.read_text())
|
|
53
|
+
if not isinstance(data, dict):
|
|
54
|
+
return {}
|
|
55
|
+
return data
|
|
56
|
+
except (json.JSONDecodeError, OSError):
|
|
57
|
+
return {}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def save_credentials(credentials: dict[str, dict[str, Any]]) -> None:
|
|
61
|
+
"""Persist per-problem credentials to ~/.aicodinggym/credentials.json."""
|
|
62
|
+
ensure_config_dir()
|
|
63
|
+
CREDENTIALS_PATH.write_text(json.dumps(credentials, indent=2) + "\n")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def require_config(config: dict[str, str], field: str, label: str) -> str:
|
|
67
|
+
"""Get a required config field or raise a descriptive error."""
|
|
68
|
+
value = config.get(field)
|
|
69
|
+
if not value:
|
|
70
|
+
raise ConfigError(
|
|
71
|
+
f"{label} is not configured.\n\n"
|
|
72
|
+
f"Run 'aicodinggym configure --user-id YOUR_USER_ID' first to set up your credentials.\n"
|
|
73
|
+
f"This generates an SSH key and registers it with the AI Coding Gym server."
|
|
74
|
+
)
|
|
75
|
+
return value
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class ConfigError(Exception):
|
|
79
|
+
"""Raised when required configuration is missing."""
|
|
80
|
+
pass
|
aicodinggym/git_ops.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Git and SSH key operations for AI Coding Gym CLI."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shlex
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
from .config import ensure_config_dir
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def generate_ssh_key_pair(user_id: str) -> tuple[Path, str]:
|
|
14
|
+
"""Generate an SSH key pair for the user.
|
|
15
|
+
|
|
16
|
+
First checks ~/.mcp-keys/ for existing keys matching the user_id.
|
|
17
|
+
If found, copies them to ~/.aicodinggym/ and reuses them.
|
|
18
|
+
Otherwise generates new keys in ~/.aicodinggym/{user_id}_id_rsa.
|
|
19
|
+
Returns (private_key_path, public_key_content).
|
|
20
|
+
"""
|
|
21
|
+
key_dir = ensure_config_dir()
|
|
22
|
+
key_path = key_dir / f"{user_id}_id_rsa"
|
|
23
|
+
|
|
24
|
+
if not key_path.exists():
|
|
25
|
+
# Check ~/.mcp-keys/ for existing keys matching user_id
|
|
26
|
+
mcp_keys_dir = Path.home() / ".mcp-keys"
|
|
27
|
+
mcp_private = mcp_keys_dir / f"{user_id}_id_rsa"
|
|
28
|
+
mcp_public = mcp_keys_dir / f"{user_id}_id_rsa.pub"
|
|
29
|
+
|
|
30
|
+
if mcp_private.exists() and mcp_public.exists():
|
|
31
|
+
shutil.copy2(mcp_private, key_path)
|
|
32
|
+
shutil.copy2(mcp_public, Path(f"{key_path}.pub"))
|
|
33
|
+
key_path.chmod(0o600)
|
|
34
|
+
else:
|
|
35
|
+
result = subprocess.run(
|
|
36
|
+
["ssh-keygen", "-t", "rsa", "-b", "4096", "-f", str(key_path),
|
|
37
|
+
"-N", "", "-C", f"aicodinggym-{user_id}"],
|
|
38
|
+
capture_output=True, text=True,
|
|
39
|
+
)
|
|
40
|
+
if result.returncode != 0:
|
|
41
|
+
raise RuntimeError(f"Failed to generate SSH key: {result.stderr}")
|
|
42
|
+
|
|
43
|
+
pub_key_path = Path(f"{key_path}.pub")
|
|
44
|
+
public_key = pub_key_path.read_text().strip()
|
|
45
|
+
return key_path, public_key
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def run_git_command(cmd: str, cwd: str, key_path: Optional[Path] = None) -> subprocess.CompletedProcess:
|
|
49
|
+
"""Execute a git command with optional SSH key configuration."""
|
|
50
|
+
env = os.environ.copy()
|
|
51
|
+
if key_path:
|
|
52
|
+
env["GIT_SSH_COMMAND"] = f"ssh -i {key_path} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
|
|
53
|
+
|
|
54
|
+
return subprocess.run(cmd, shell=True, cwd=cwd, capture_output=True, text=True, env=env)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def clone_repo(repo_url: str, branch: str, dest_name: str,
|
|
58
|
+
workspace: str, key_path: Path) -> tuple[bool, str]:
|
|
59
|
+
"""Clone a repo branch into workspace/dest_name.
|
|
60
|
+
|
|
61
|
+
Returns (success, message).
|
|
62
|
+
"""
|
|
63
|
+
problem_dir = Path(workspace) / dest_name
|
|
64
|
+
|
|
65
|
+
if problem_dir.exists():
|
|
66
|
+
# Check if already on the correct branch
|
|
67
|
+
result = run_git_command("git rev-parse --abbrev-ref HEAD", str(problem_dir))
|
|
68
|
+
if result.returncode == 0 and result.stdout.strip() == branch:
|
|
69
|
+
pull = run_git_command(f"git pull origin {branch}", str(problem_dir), key_path)
|
|
70
|
+
if pull.returncode != 0:
|
|
71
|
+
return False, f"Git pull failed:\n{pull.stderr}"
|
|
72
|
+
_remove_github_dir(problem_dir)
|
|
73
|
+
return True, f"Already exists. Updated to latest version.\nRepository: {problem_dir}\nBranch: {branch}"
|
|
74
|
+
return False, (
|
|
75
|
+
f"Directory {problem_dir} already exists with different content.\n"
|
|
76
|
+
"Remove it first or use --workspace-dir to specify a different location."
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
cmd = f"git clone --single-branch --branch {branch} --depth 1 {repo_url} {dest_name}"
|
|
80
|
+
result = run_git_command(cmd, workspace, key_path)
|
|
81
|
+
|
|
82
|
+
if result.returncode != 0:
|
|
83
|
+
return False, f"Git clone failed:\n{result.stderr}\nMake sure the branch '{branch}' exists in the repository."
|
|
84
|
+
|
|
85
|
+
_remove_github_dir(problem_dir)
|
|
86
|
+
return True, f"Cloned to: {problem_dir}\nBranch: {branch}"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def add_commit_push(problem_dir: str, branch: str, key_path: Path,
|
|
90
|
+
message: str, force: bool = False) -> tuple[bool, str, str]:
|
|
91
|
+
"""Stage, commit, and push changes.
|
|
92
|
+
|
|
93
|
+
Returns (success, message, commit_hash).
|
|
94
|
+
"""
|
|
95
|
+
pdir = Path(problem_dir)
|
|
96
|
+
|
|
97
|
+
# Stage all changes except .github
|
|
98
|
+
result = run_git_command("git add -A -- . ':(exclude).github'", str(pdir))
|
|
99
|
+
if result.returncode != 0:
|
|
100
|
+
return False, f"Git add failed:\n{result.stderr}", ""
|
|
101
|
+
|
|
102
|
+
# Check for staged changes
|
|
103
|
+
status = run_git_command("git diff --cached --name-only", str(pdir))
|
|
104
|
+
if not status.stdout.strip():
|
|
105
|
+
return False, "No changes to commit. Your working directory is clean.", ""
|
|
106
|
+
|
|
107
|
+
# Commit
|
|
108
|
+
safe_msg = message.replace('"', '\\"')
|
|
109
|
+
result = run_git_command(f'git commit -m "{safe_msg}"', str(pdir))
|
|
110
|
+
if result.returncode != 0:
|
|
111
|
+
return False, f"Git commit failed:\n{result.stderr}", ""
|
|
112
|
+
|
|
113
|
+
# Get commit hash
|
|
114
|
+
hash_result = run_git_command("git rev-parse HEAD", str(pdir))
|
|
115
|
+
commit_hash = hash_result.stdout.strip()
|
|
116
|
+
|
|
117
|
+
# Push
|
|
118
|
+
push_flag = "--force-with-lease " if force else ""
|
|
119
|
+
result = run_git_command(f"git push {push_flag}origin {branch}", str(pdir), key_path)
|
|
120
|
+
if result.returncode != 0:
|
|
121
|
+
return False, f"Git push failed:\n{result.stderr}", commit_hash
|
|
122
|
+
|
|
123
|
+
return True, "Committed and pushed successfully.", commit_hash
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def reset_to_setup_commit(problem_dir: str) -> tuple[bool, str]:
|
|
127
|
+
"""Reset repo to the original 'Setup SWE-bench instance:' commit.
|
|
128
|
+
|
|
129
|
+
Returns (success, message).
|
|
130
|
+
"""
|
|
131
|
+
log_result = run_git_command("git log --format=%H:%s --reverse", problem_dir)
|
|
132
|
+
if log_result.returncode != 0:
|
|
133
|
+
return False, f"Git log failed:\n{log_result.stderr}"
|
|
134
|
+
|
|
135
|
+
setup_prefix = "Setup SWE-bench instance:"
|
|
136
|
+
setup_commit = None
|
|
137
|
+
for line in log_result.stdout.splitlines():
|
|
138
|
+
parts = line.split(":", 1)
|
|
139
|
+
if len(parts) != 2:
|
|
140
|
+
continue
|
|
141
|
+
commit_hash, subject = parts[0].strip(), parts[1].strip()
|
|
142
|
+
if subject.startswith(setup_prefix):
|
|
143
|
+
setup_commit = commit_hash
|
|
144
|
+
break
|
|
145
|
+
|
|
146
|
+
if not setup_commit:
|
|
147
|
+
return False, (
|
|
148
|
+
f"Could not find the original setup commit.\n"
|
|
149
|
+
f"Expected a commit message starting with '{setup_prefix}'."
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
reset = run_git_command(f"git reset --hard {setup_commit}", problem_dir)
|
|
153
|
+
if reset.returncode != 0:
|
|
154
|
+
return False, f"Git reset failed:\n{reset.stderr}"
|
|
155
|
+
|
|
156
|
+
clean = run_git_command("git clean -fd", problem_dir)
|
|
157
|
+
if clean.returncode != 0:
|
|
158
|
+
return False, f"Git clean failed:\n{clean.stderr}"
|
|
159
|
+
|
|
160
|
+
return True, f"Reset to setup commit {setup_commit[:8]}.\nLocal changes discarded and untracked files removed."
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _remove_github_dir(problem_dir: Path) -> None:
|
|
164
|
+
"""Remove .github directory and mark files as skip-worktree."""
|
|
165
|
+
github_dir = problem_dir / ".github"
|
|
166
|
+
if not github_dir.exists():
|
|
167
|
+
return
|
|
168
|
+
|
|
169
|
+
list_result = run_git_command("git ls-files .github", str(problem_dir))
|
|
170
|
+
if list_result.returncode == 0:
|
|
171
|
+
for rel_path in filter(None, list_result.stdout.splitlines()):
|
|
172
|
+
quoted = shlex.quote(rel_path)
|
|
173
|
+
run_git_command(f"git update-index --skip-worktree -- {quoted}", str(problem_dir))
|
|
174
|
+
|
|
175
|
+
shutil.rmtree(github_dir, ignore_errors=True)
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: aicodinggym-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CLI tool for AI Coding Gym platform
|
|
5
|
+
Author-email: AICodingGym Team <datasmithlab@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://aicodinggym.com
|
|
8
|
+
Keywords: cli,coding-gym,swebench,mlebench,ai
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
Requires-Dist: click>=8.0.0
|
|
18
|
+
Requires-Dist: requests>=2.31.0
|
|
19
|
+
|
|
20
|
+
# aicodinggym-cli API Design
|
|
21
|
+
|
|
22
|
+
## Overview
|
|
23
|
+
|
|
24
|
+
CLI tool for the AI Coding Gym platform (`https://aicodinggym.com`).
|
|
25
|
+
Supports two benchmarks: **SWE-bench** (code bug fixes) and **MLE-bench** (ML competitions).
|
|
26
|
+
|
|
27
|
+
**Install:** `pip install aicodinggym-cli`
|
|
28
|
+
**Entry point:** `aicodinggym`
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## Commands
|
|
33
|
+
|
|
34
|
+
### `aicodinggym configure`
|
|
35
|
+
|
|
36
|
+
One-time setup. Generates SSH key, registers with server.
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
aicodinggym configure --user-id USER_ID [--workspace-dir DIR]
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
| Option | Required | Description |
|
|
43
|
+
|---|---|---|
|
|
44
|
+
| `--user-id` | Yes | Your AI Coding Gym user ID |
|
|
45
|
+
| `--workspace-dir` | No | Default workspace directory (default: cwd) |
|
|
46
|
+
|
|
47
|
+
**Backend API:** `POST /api/configure`
|
|
48
|
+
```json
|
|
49
|
+
// Request
|
|
50
|
+
{"user_id": "alice123", "public_key": "ssh-rsa AAAA..."}
|
|
51
|
+
// Response
|
|
52
|
+
{"repo_name": "alice123-swebench"}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
**Local storage:** `~/.aicodinggym/config.json`, `~/.aicodinggym/{user_id}_id_rsa`
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
### `aicodinggym swe` — SWE-bench Commands
|
|
60
|
+
|
|
61
|
+
#### `aicodinggym swe fetch PROBLEM_ID`
|
|
62
|
+
|
|
63
|
+
Fetch a problem and clone the repo locally (shallow clone).
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
aicodinggym swe fetch PROBLEM_ID [--user-id ID] [--workspace-dir DIR]
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
**Backend API:** `POST /api/fetch-problem`
|
|
70
|
+
```json
|
|
71
|
+
// Request
|
|
72
|
+
{"user_id": "alice123", "problem_id": "django__django-10097"}
|
|
73
|
+
// Response
|
|
74
|
+
{"branch_name": "django__django-10097-alice123", "repo_url": "git@...", "message": "..."}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
**Local effect:** Clones `<workspace>/<problem_id>/` via SSH.
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
#### `aicodinggym swe submit PROBLEM_ID`
|
|
82
|
+
|
|
83
|
+
Commit all changes and push to remote. Notifies backend.
|
|
84
|
+
|
|
85
|
+
```
|
|
86
|
+
aicodinggym swe submit PROBLEM_ID [--message MSG] [--force] [--user-id ID] [--workspace-dir DIR]
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
| Option | Description |
|
|
90
|
+
|---|---|
|
|
91
|
+
| `--message, -m` | Commit message (auto-generated if omitted) |
|
|
92
|
+
| `--force` | Force push with `--force-with-lease` |
|
|
93
|
+
|
|
94
|
+
**Local effect:** `git add -A`, `git commit`, `git push`
|
|
95
|
+
|
|
96
|
+
**Backend API:** `POST /api/submissions`
|
|
97
|
+
```json
|
|
98
|
+
// Request
|
|
99
|
+
{
|
|
100
|
+
"problem_id": "django__django-10097",
|
|
101
|
+
"user_id": "alice123",
|
|
102
|
+
"commit_hash": "abc123...",
|
|
103
|
+
"branch": "django__django-10097-alice123",
|
|
104
|
+
"commit_message": "Fix auth bug",
|
|
105
|
+
"timestamp": "2026-03-05T10:30:00"
|
|
106
|
+
}
|
|
107
|
+
// Response
|
|
108
|
+
{"status": "success", "message": "Submission received"}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
#### `aicodinggym swe reset PROBLEM_ID`
|
|
114
|
+
|
|
115
|
+
Reset repo to original setup commit. Destructive — discards all local changes.
|
|
116
|
+
|
|
117
|
+
```
|
|
118
|
+
aicodinggym swe reset PROBLEM_ID [--user-id ID] [--workspace-dir DIR]
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
**Local effect:** `git reset --hard <setup_commit>`, `git clean -fd`
|
|
122
|
+
|
|
123
|
+
---
|
|
124
|
+
|
|
125
|
+
### `aicodinggym mle` — MLE-bench Commands
|
|
126
|
+
|
|
127
|
+
#### `aicodinggym mle download COMPETITION_ID`
|
|
128
|
+
|
|
129
|
+
Download dataset files (train/test/sample_submission CSVs).
|
|
130
|
+
|
|
131
|
+
```
|
|
132
|
+
aicodinggym mle download COMPETITION_ID [--user-id ID] [--output-dir DIR]
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
| Option | Description |
|
|
136
|
+
|---|---|
|
|
137
|
+
| `--output-dir` | Save location (default: `./<competition_id>/data/`) |
|
|
138
|
+
|
|
139
|
+
**Backend API:** `POST /api/mlebench/download`
|
|
140
|
+
```json
|
|
141
|
+
// Request
|
|
142
|
+
{"user_id": "alice123", "competition_id": "spaceship-titanic"}
|
|
143
|
+
// Response (single archive)
|
|
144
|
+
{"download_url": "https://...", "filename": "spaceship-titanic_data.zip", "message": "..."}
|
|
145
|
+
// Response (multiple files)
|
|
146
|
+
{"files": [{"name": "train.csv", "url": "https://..."}, ...], "message": "..."}
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
---
|
|
150
|
+
|
|
151
|
+
#### `aicodinggym mle submit COMPETITION_ID -F FILE`
|
|
152
|
+
|
|
153
|
+
Upload prediction CSV for scoring.
|
|
154
|
+
|
|
155
|
+
```
|
|
156
|
+
aicodinggym mle submit COMPETITION_ID -F FILE [--user-id ID] [--message MSG]
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
| Option | Required | Description |
|
|
160
|
+
|---|---|---|
|
|
161
|
+
| `-F` | Yes | Path to prediction CSV file |
|
|
162
|
+
| `--message, -m` | No | Submission description |
|
|
163
|
+
|
|
164
|
+
**Backend API:** `POST /api/mlebench/submit` (multipart form)
|
|
165
|
+
```
|
|
166
|
+
POST /api/mlebench/submit
|
|
167
|
+
Content-Type: multipart/form-data
|
|
168
|
+
|
|
169
|
+
user_id=alice123
|
|
170
|
+
competition_id=spaceship-titanic
|
|
171
|
+
file=@predictions.csv
|
|
172
|
+
```
|
|
173
|
+
```json
|
|
174
|
+
// Response
|
|
175
|
+
{"message": "Submission received", "score": 0.85}
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
---
|
|
179
|
+
|
|
180
|
+
## File Structure
|
|
181
|
+
|
|
182
|
+
```
|
|
183
|
+
src/aicodinggym/
|
|
184
|
+
├── __init__.py # Version
|
|
185
|
+
├── cli.py # Click CLI commands (entry point)
|
|
186
|
+
├── config.py # Config + credentials persistence (~/.aicodinggym/)
|
|
187
|
+
├── api.py # HTTP client for aicodinggym.com/api
|
|
188
|
+
└── git_ops.py # SSH key generation, git clone/commit/push/reset
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
## Configuration Files
|
|
192
|
+
|
|
193
|
+
| File | Purpose |
|
|
194
|
+
|---|---|
|
|
195
|
+
| `~/.aicodinggym/config.json` | Global config (user_id, repo_name, key path, workspace) |
|
|
196
|
+
| `~/.aicodinggym/credentials.json` | Per-problem credentials (repo_url, branch, cached after fetch) |
|
|
197
|
+
| `~/.aicodinggym/{user_id}_id_rsa` | SSH private key |
|
|
198
|
+
| `~/.aicodinggym/{user_id}_id_rsa.pub` | SSH public key |
|
|
199
|
+
|
|
200
|
+
## Error Handling
|
|
201
|
+
|
|
202
|
+
All errors print actionable messages guiding the user (or LLM agent) to the correct next step:
|
|
203
|
+
|
|
204
|
+
- **Not configured** → "Run 'aicodinggym configure --user-id YOUR_USER_ID' first."
|
|
205
|
+
- **Not fetched** → "You must fetch the problem first: aicodinggym swe fetch PROBLEM_ID"
|
|
206
|
+
- **API unreachable** → "Cannot connect to https://aicodinggym.com/api. Check your internet connection."
|
|
207
|
+
- **User ID mismatch** → "Problem was fetched by 'X', not 'Y'. Either use --user-id X or re-fetch."
|
|
208
|
+
|
|
209
|
+
## Backend API Summary
|
|
210
|
+
|
|
211
|
+
| Endpoint | Method | Used By |
|
|
212
|
+
|---|---|---|
|
|
213
|
+
| `/api/configure` | POST | `configure` |
|
|
214
|
+
| `/api/fetch-problem` | POST | `swe fetch` |
|
|
215
|
+
| `/api/submissions` | POST | `swe submit` |
|
|
216
|
+
| `/api/mlebench/download` | POST | `mle download` |
|
|
217
|
+
| `/api/mlebench/submit` | POST | `mle submit` |
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
aicodinggym/__init__.py,sha256=UJpUmTsBbxwk9-wc0hE9Z8AmBjM06zjL5IY_YfHNdeU,48
|
|
2
|
+
aicodinggym/api.py,sha256=Fxf_W8Loz2R4mlCx4UamQJq7JrxNVpwpXrUWSIvrbZ0,4948
|
|
3
|
+
aicodinggym/cli.py,sha256=QX-tBzF0ReIunl2GuJTNk1ZUiNT0u91GPvaqMaPWn8U,21387
|
|
4
|
+
aicodinggym/config.py,sha256=9jlHh7VQnLKEBxnh8OL0KiN5-SMzI4rZ9sl-HN4P8JU,2684
|
|
5
|
+
aicodinggym/git_ops.py,sha256=NVNdpp_X2yKlKabH8honQ5dbA_4h82ZoNV2eKumDCXs,6831
|
|
6
|
+
aicodinggym_cli-0.1.0.dist-info/METADATA,sha256=2ObygkkQ7Lk0e2x5JlWVhA_Oliw8nIlyGKFzB9tkLbE,6011
|
|
7
|
+
aicodinggym_cli-0.1.0.dist-info/WHEEL,sha256=YCfwYGOYMi5Jhw2fU4yNgwErybb2IX5PEwBKV4ZbdBo,91
|
|
8
|
+
aicodinggym_cli-0.1.0.dist-info/entry_points.txt,sha256=IT2fCKaEBbaBggkEVid2lMBtOsxc_xKDWl55jagRjGs,53
|
|
9
|
+
aicodinggym_cli-0.1.0.dist-info/top_level.txt,sha256=U7rm2fKrhxYDwglsV3qYwok7DiqyO4iTLXyDE-2rDNc,12
|
|
10
|
+
aicodinggym_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
aicodinggym
|