aicodinggym-cli 0.2.0__tar.gz → 0.4.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aicodinggym-cli
3
- Version: 0.2.0
3
+ Version: 0.4.0
4
4
  Summary: CLI tool for AI Coding Gym platform
5
5
  Author-email: AICodingGym Team <datasmithlab@gmail.com>
6
6
  License-Expression: MIT
@@ -20,7 +20,7 @@ Requires-Dist: requests>=2.31.0
20
20
  # aicodinggym-cli
21
21
 
22
22
  CLI tool for the [AI Coding Gym](https://aicodinggym.com) platform.
23
- Supports two benchmarks: **SWE-bench** (code bug fixes) and **MLE-bench** (ML competitions).
23
+ Supports three benchmarks: **SWE-bench** (code bug fixes), **MLE-bench** (ML competitions), and **Code Review** challenges.
24
24
 
25
25
  **Install:** `pip install aicodinggym-cli`
26
26
  **Entry point:** `aicodinggym`
@@ -43,6 +43,11 @@ aicodinggym swe submit django__django-10097
43
43
  aicodinggym mle download spaceship-titanic
44
44
  # ... train model, generate predictions ...
45
45
  aicodinggym mle submit spaceship-titanic -F predictions.csv
46
+
47
+ # 4. Code Review: fetch, review, submit
48
+ aicodinggym cr fetch keycloak-0008
49
+ # ... read diff.patch, write your review in review.md ...
50
+ aicodinggym cr submit keycloak-0008 -f review.md
46
51
  ```
47
52
 
48
53
  ---
@@ -154,6 +159,38 @@ aicodinggym mle submit COMPETITION_ID -F FILE [--user-id ID] [--message MSG]
154
159
 
155
160
  ---
156
161
 
162
+ ### `aicodinggym cr` — Code Review Commands
163
+
164
+ #### `aicodinggym cr fetch PROBLEM_ID`
165
+
166
+ Download the PR diff and create a `review.md` template.
167
+
168
+ ```
169
+ aicodinggym cr fetch PROBLEM_ID [--user-id ID] [--workspace-dir DIR]
170
+ ```
171
+
172
+ Creates in `<workspace>/<problem_id>/`:
173
+ - `diff.patch` — the full diff between base and head branches
174
+ - `review.md` — template to fill in your review (only created if not already present)
175
+
176
+ #### `aicodinggym cr submit PROBLEM_ID`
177
+
178
+ Submit your code review.
179
+
180
+ ```
181
+ aicodinggym cr submit PROBLEM_ID -f review.md [--user-id ID]
182
+ aicodinggym cr submit PROBLEM_ID -m "Inline review text"
183
+ echo "My review" | aicodinggym cr submit PROBLEM_ID
184
+ ```
185
+
186
+ | Option | Description |
187
+ |---|---|
188
+ | `-f, --file` | Path to a file containing your review (e.g. `review.md`) |
189
+ | `-m, --message` | Inline review text |
190
+ | stdin | Pipe review text from stdin |
191
+
192
+ ---
193
+
157
194
  ## File Structure
158
195
 
159
196
  ```
@@ -1,7 +1,7 @@
1
1
  # aicodinggym-cli
2
2
 
3
3
  CLI tool for the [AI Coding Gym](https://aicodinggym.com) platform.
4
- Supports two benchmarks: **SWE-bench** (code bug fixes) and **MLE-bench** (ML competitions).
4
+ Supports three benchmarks: **SWE-bench** (code bug fixes), **MLE-bench** (ML competitions), and **Code Review** challenges.
5
5
 
6
6
  **Install:** `pip install aicodinggym-cli`
7
7
  **Entry point:** `aicodinggym`
@@ -24,6 +24,11 @@ aicodinggym swe submit django__django-10097
24
24
  aicodinggym mle download spaceship-titanic
25
25
  # ... train model, generate predictions ...
26
26
  aicodinggym mle submit spaceship-titanic -F predictions.csv
27
+
28
+ # 4. Code Review: fetch, review, submit
29
+ aicodinggym cr fetch keycloak-0008
30
+ # ... read diff.patch, write your review in review.md ...
31
+ aicodinggym cr submit keycloak-0008 -f review.md
27
32
  ```
28
33
 
29
34
  ---
@@ -135,6 +140,38 @@ aicodinggym mle submit COMPETITION_ID -F FILE [--user-id ID] [--message MSG]
135
140
 
136
141
  ---
137
142
 
143
+ ### `aicodinggym cr` — Code Review Commands
144
+
145
+ #### `aicodinggym cr fetch PROBLEM_ID`
146
+
147
+ Download the PR diff and create a `review.md` template.
148
+
149
+ ```
150
+ aicodinggym cr fetch PROBLEM_ID [--user-id ID] [--workspace-dir DIR]
151
+ ```
152
+
153
+ Creates in `<workspace>/<problem_id>/`:
154
+ - `diff.patch` — the full diff between base and head branches
155
+ - `review.md` — template to fill in your review (only created if not already present)
156
+
157
+ #### `aicodinggym cr submit PROBLEM_ID`
158
+
159
+ Submit your code review.
160
+
161
+ ```
162
+ aicodinggym cr submit PROBLEM_ID -f review.md [--user-id ID]
163
+ aicodinggym cr submit PROBLEM_ID -m "Inline review text"
164
+ echo "My review" | aicodinggym cr submit PROBLEM_ID
165
+ ```
166
+
167
+ | Option | Description |
168
+ |---|---|
169
+ | `-f, --file` | Path to a file containing your review (e.g. `review.md`) |
170
+ | `-m, --message` | Inline review text |
171
+ | stdin | Pipe review text from stdin |
172
+
173
+ ---
174
+
138
175
  ## File Structure
139
176
 
140
177
  ```
@@ -1,3 +1,3 @@
1
1
  """AI Coding Gym CLI."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.3.0"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aicodinggym-cli
3
- Version: 0.2.0
3
+ Version: 0.4.0
4
4
  Summary: CLI tool for AI Coding Gym platform
5
5
  Author-email: AICodingGym Team <datasmithlab@gmail.com>
6
6
  License-Expression: MIT
@@ -20,7 +20,7 @@ Requires-Dist: requests>=2.31.0
20
20
  # aicodinggym-cli
21
21
 
22
22
  CLI tool for the [AI Coding Gym](https://aicodinggym.com) platform.
23
- Supports two benchmarks: **SWE-bench** (code bug fixes) and **MLE-bench** (ML competitions).
23
+ Supports three benchmarks: **SWE-bench** (code bug fixes), **MLE-bench** (ML competitions), and **Code Review** challenges.
24
24
 
25
25
  **Install:** `pip install aicodinggym-cli`
26
26
  **Entry point:** `aicodinggym`
@@ -43,6 +43,11 @@ aicodinggym swe submit django__django-10097
43
43
  aicodinggym mle download spaceship-titanic
44
44
  # ... train model, generate predictions ...
45
45
  aicodinggym mle submit spaceship-titanic -F predictions.csv
46
+
47
+ # 4. Code Review: fetch, review, submit
48
+ aicodinggym cr fetch keycloak-0008
49
+ # ... read diff.patch, write your review in review.md ...
50
+ aicodinggym cr submit keycloak-0008 -f review.md
46
51
  ```
47
52
 
48
53
  ---
@@ -154,6 +159,38 @@ aicodinggym mle submit COMPETITION_ID -F FILE [--user-id ID] [--message MSG]
154
159
 
155
160
  ---
156
161
 
162
+ ### `aicodinggym cr` — Code Review Commands
163
+
164
+ #### `aicodinggym cr fetch PROBLEM_ID`
165
+
166
+ Download the PR diff and create a `review.md` template.
167
+
168
+ ```
169
+ aicodinggym cr fetch PROBLEM_ID [--user-id ID] [--workspace-dir DIR]
170
+ ```
171
+
172
+ Creates in `<workspace>/<problem_id>/`:
173
+ - `diff.patch` — the full diff between base and head branches
174
+ - `review.md` — template to fill in your review (only created if not already present)
175
+
176
+ #### `aicodinggym cr submit PROBLEM_ID`
177
+
178
+ Submit your code review.
179
+
180
+ ```
181
+ aicodinggym cr submit PROBLEM_ID -f review.md [--user-id ID]
182
+ aicodinggym cr submit PROBLEM_ID -m "Inline review text"
183
+ echo "My review" | aicodinggym cr submit PROBLEM_ID
184
+ ```
185
+
186
+ | Option | Description |
187
+ |---|---|
188
+ | `-f, --file` | Path to a file containing your review (e.g. `review.md`) |
189
+ | `-m, --message` | Inline review text |
190
+ | stdin | Pipe review text from stdin |
191
+
192
+ ---
193
+
157
194
  ## File Structure
158
195
 
159
196
  ```
@@ -1,8 +1,10 @@
1
1
  """HTTP API client for the AI Coding Gym backend at aicodinggym.com."""
2
2
 
3
+ import os
4
+
3
5
  import requests
4
6
 
5
- API_BASE = "https://aicodinggym.com/api"
7
+ API_BASE = os.environ.get("AICODINGGYM_API_BASE", "https://aicodinggym.com/api")
6
8
  TIMEOUT = 30
7
9
 
8
10
 
@@ -84,6 +86,20 @@ def submit_notification(problem_id: str, user_id: str, commit_hash: str,
84
86
  })
85
87
 
86
88
 
89
+ def fetch_pr(user_id: str, problem_id: str) -> dict:
90
+ """Fetch CR problem info. Returns {'base_branch': ..., 'head_branch': ..., 'repo_url': ...}."""
91
+ return _post("code-review-fetch", {"user_id": user_id, "problem_id": problem_id})
92
+
93
+
94
+ def cr_submit_review(user_id: str, problem_id: str, review: str) -> dict:
95
+ """Submit a code review."""
96
+ return _post("code-review-submit", {
97
+ "user_id": user_id,
98
+ "problem_id": problem_id,
99
+ "review": review,
100
+ })
101
+
102
+
87
103
  def mlebench_download_info(user_id: str, competition_id: str, dest_path: str) -> None:
88
104
  """Download dataset for an MLE-bench competition directly to dest_path."""
89
105
  resp = _get(f"competitions/{competition_id}/download", stream=True)
@@ -1,7 +1,7 @@
1
1
  """AI Coding Gym CLI - main entry point.
2
2
 
3
3
  A command-line tool for the AI Coding Gym platform (https://aicodinggym.com).
4
- Supports SWE-bench and MLE-bench challenges.
4
+ Supports SWE-bench, MLE-bench, and Code Review challenges.
5
5
 
6
6
  SETUP (required before any other command):
7
7
  aicodinggym configure --user-id YOUR_USER_ID
@@ -15,6 +15,11 @@ MLE-BENCH WORKFLOW:
15
15
  aicodinggym mle download spaceship-titanic
16
16
  # ... train model, generate predictions ...
17
17
  aicodinggym mle submit spaceship-titanic -F submission.csv
18
+
19
+ CODE REVIEW WORKFLOW:
20
+ aicodinggym cr fetch sentry-0001
21
+ # ... review the diff and write your review ...
22
+ aicodinggym cr submit sentry-0001 -f review.md
18
23
  """
19
24
 
20
25
  import os
@@ -31,6 +36,8 @@ from . import __version__
31
36
  from .api import (
32
37
  APIError,
33
38
  configure as api_configure,
39
+ cr_submit_review,
40
+ fetch_pr as api_fetch_pr,
34
41
  fetch_problem as api_fetch_problem,
35
42
  mlebench_download_file,
36
43
  mlebench_download_info,
@@ -47,11 +54,19 @@ from .git_ops import (
47
54
  add_commit_push,
48
55
  check_tool_installed,
49
56
  clone_repo,
57
+ clone_repo_cr,
50
58
  generate_ssh_key_pair,
51
59
  reset_to_setup_commit,
60
+ run_git_command,
52
61
  )
53
62
 
54
63
 
64
+ def _hyperlink(url: str, text: str | None = None) -> str:
65
+ """Return an OSC 8 terminal hyperlink. Falls back to plain URL on unsupported terminals."""
66
+ label = text or url
67
+ return f"\033]8;;{url}\033\\{label}\033]8;;\033\\"
68
+
69
+
55
70
  def _error(msg: str) -> None:
56
71
  """Print an error message to stderr and exit."""
57
72
  click.echo(f"Error: {msg}", err=True)
@@ -228,7 +243,9 @@ def _resolve_key_path(config: dict, creds: dict | None = None) -> Path:
228
243
  " aicodinggym swe fetch django__django-10097\n"
229
244
  " aicodinggym swe submit django__django-10097 --message 'Fix auth bug'\n"
230
245
  " aicodinggym mle download spaceship-titanic\n"
231
- " aicodinggym mle submit spaceship-titanic -F predictions.csv\n\n"
246
+ " aicodinggym mle submit spaceship-titanic -F predictions.csv\n"
247
+ " aicodinggym cr fetch sentry-0001\n"
248
+ " aicodinggym cr submit sentry-0001 -f review.md\n\n"
232
249
  "\b\n"
233
250
  "WEBSITE:\n"
234
251
  " https://aicodinggym.com\n"
@@ -326,7 +343,7 @@ def configure(user_id: str, workspace_dir: str | None):
326
343
  f" SSH Key: {private_key_path}\n"
327
344
  f" Config: ~/.aicodinggym/config.json\n"
328
345
  f"\n"
329
- f"You can now use 'aicodinggym swe' and 'aicodinggym mle' commands."
346
+ f"You can now use 'aicodinggym swe', 'aicodinggym mle', and 'aicodinggym cr' commands."
330
347
  )
331
348
  except APIError as e:
332
349
  _error(str(e))
@@ -548,7 +565,7 @@ def swe_submit(problem_id: str, user_id: str | None, message: str | None,
548
565
  f" Branch: {branch}\n"
549
566
  f" Status: Pushed and backend notified\n"
550
567
  f"\n"
551
- f"Your solution has been submitted for evaluation!"
568
+ f"View results at: {_hyperlink(f'https://aicodinggym.com/challenges/swe/{problem_id}')}"
552
569
  )
553
570
 
554
571
 
@@ -845,6 +862,187 @@ def swe_test(problem_id: str, user_id: str | None, workspace_dir: str | None,
845
862
  sys.exit(proc.returncode)
846
863
 
847
864
 
865
+ # ── cr group ──────────────────────────────────────────────────────────────────
866
+
867
+
868
+ @main.group()
869
+ def cr():
870
+ """Code Review challenges - submit reviews for code diffs.
871
+
872
+ \b
873
+ PREREQUISITE:
874
+ Run 'aicodinggym configure --user-id YOUR_USER_ID' before using these commands.
875
+
876
+ \b
877
+ WORKFLOW:
878
+ 1. aicodinggym cr fetch CR_PROBLEM_ID # Download diff + create review.md
879
+ 2. (edit review.md with your findings)
880
+ 3. aicodinggym cr submit CR_PROBLEM_ID -f review.md # Submit your review
881
+ """
882
+ pass
883
+
884
+
885
+ @cr.command("fetch")
886
+ @click.argument("problem_id")
887
+ @click.option("--user-id", default=None, help="Override configured user ID.")
888
+ @click.option("--workspace-dir", default=None, type=click.Path(),
889
+ help="Directory to clone into. Overrides configured workspace.")
890
+ def cr_fetch(problem_id: str, user_id: str | None, workspace_dir: str | None):
891
+ """Fetch a Code Review problem: downloads the PR diff and creates a review template.
892
+
893
+ Clones the repository, generates diff.patch from base→head, and creates
894
+ review.md as a template to fill in your review.
895
+
896
+ \b
897
+ ARGUMENTS:
898
+ PROBLEM_ID The code review problem identifier (e.g., 'keycloak-0008').
899
+
900
+ \b
901
+ EXAMPLE:
902
+ aicodinggym cr fetch keycloak-0008
903
+ # Edit review.md in the problem directory, then:
904
+ aicodinggym cr submit keycloak-0008 -f review.md
905
+ """
906
+ config = load_config()
907
+ uid = _resolve_user_id(config, user_id)
908
+ workspace = _resolve_workspace(config, workspace_dir)
909
+
910
+ try:
911
+ click.echo(f"Fetching problem '{problem_id}' from server...")
912
+ data = api_fetch_pr(uid, problem_id)
913
+ except APIError as e:
914
+ _error(str(e))
915
+
916
+ base_branch = data.get("base_branch")
917
+ head_branch = data.get("head_branch")
918
+ repo_url = data.get("repo_url")
919
+
920
+ if not (repo_url and repo_url.strip()) or not (base_branch and base_branch.strip()) or not (head_branch and head_branch.strip()):
921
+ _error("Server did not return required fields (repo_url, base_branch, head_branch).")
922
+
923
+ # Save credentials for later submit
924
+ credentials = load_credentials()
925
+ credentials[problem_id] = {
926
+ "repo_url": repo_url,
927
+ "base_branch": base_branch,
928
+ "head_branch": head_branch,
929
+ "user_id": uid,
930
+ "workspace_dir": str(workspace),
931
+ "benchmark": "cr",
932
+ }
933
+ save_credentials(credentials)
934
+
935
+ workspace.mkdir(parents=True, exist_ok=True)
936
+
937
+ click.echo(f"Cloning into {workspace / problem_id}...")
938
+ success, msg = clone_repo_cr(repo_url, base_branch, head_branch,
939
+ problem_id, str(workspace))
940
+ if not success:
941
+ _error(msg)
942
+
943
+ problem_dir = workspace / problem_id
944
+
945
+ # Generate diff.patch
946
+ diff_result = run_git_command(
947
+ ["git", "diff", f"{base_branch}..{head_branch}"], str(problem_dir)
948
+ )
949
+ diff_path = problem_dir / "diff.patch"
950
+ diff_path.write_text(diff_result.stdout)
951
+
952
+ # Create review.md template if it doesn't exist yet
953
+ review_path = problem_dir / "review.md"
954
+ if not review_path.exists():
955
+ review_path.write_text(
956
+ f"# Code Review: {problem_id}\n\n"
957
+ "## Summary\n\n"
958
+ "<!-- Brief summary of what this PR does -->\n\n"
959
+ "## Issues Found\n\n"
960
+ "<!-- List bugs, logic errors, security issues, etc. -->\n\n"
961
+ "## Suggestions\n\n"
962
+ "<!-- Optional improvements, style notes, etc. -->\n\n"
963
+ "## Verdict\n\n"
964
+ "<!-- Approve / Request Changes / Comment -->\n"
965
+ )
966
+
967
+ cat_cmd = "type" if sys.platform == "win32" else "cat"
968
+ click.echo(
969
+ f"\nSuccessfully fetched: {problem_id}\n"
970
+ f"\n"
971
+ f" Diff saved to: {diff_path}\n"
972
+ f" Review template: {review_path}\n"
973
+ f"\n"
974
+ f"Next steps:\n"
975
+ f" 1. Review the diff: {cat_cmd} {diff_path}\n"
976
+ f" 2. Write your review in {review_path}\n"
977
+ f" 3. Submit: aicodinggym cr submit {problem_id} -f review.md\n"
978
+ )
979
+
980
+
981
+ @cr.command("submit")
982
+ @click.argument("problem_id")
983
+ @click.option("--user-id", default=None, help="Override configured user ID.")
984
+ @click.option(
985
+ "-f", "--file", "review_file", type=click.Path(exists=True),
986
+ help="Path to a file containing your review.",
987
+ )
988
+ @click.option(
989
+ "-m", "--message", "review_text",
990
+ help="Inline review text.",
991
+ )
992
+ def cr_submit(problem_id: str, user_id: str | None, review_file: str | None,
993
+ review_text: str | None):
994
+ """Submit a code review for a Code Review challenge.
995
+
996
+ Reads your review from a file (-f), inline text (-m), or piped stdin,
997
+ and submits it to the AI Coding Gym server.
998
+
999
+ \b
1000
+ ARGUMENTS:
1001
+ PROBLEM_ID The code review problem identifier (e.g., 'sentry-0001').
1002
+
1003
+ \b
1004
+ EXAMPLE:
1005
+ aicodinggym cr submit sentry-0001 -f review.md
1006
+ aicodinggym cr submit sentry-0001 -m "Found a null pointer bug on line 42"
1007
+ echo "My review" | aicodinggym cr submit sentry-0001
1008
+ """
1009
+ config = load_config()
1010
+ uid = _resolve_user_id(config, user_id)
1011
+
1012
+ # Collect review text (priority: -f > -m > stdin)
1013
+ review = None
1014
+ if review_file:
1015
+ review = Path(review_file).read_text()
1016
+ elif review_text:
1017
+ review = review_text
1018
+ elif not sys.stdin.isatty():
1019
+ review = sys.stdin.read()
1020
+
1021
+ if not review or not review.strip():
1022
+ _error(
1023
+ "No review text provided.\n\n"
1024
+ "Provide your review using one of:\n"
1025
+ " -f <file> Read review from a file\n"
1026
+ " -m \"text\" Inline review text\n"
1027
+ " echo ... | ... Pipe from stdin\n\n"
1028
+ "Example:\n"
1029
+ f" aicodinggym cr submit {problem_id} -f review.md"
1030
+ )
1031
+
1032
+ try:
1033
+ result = cr_submit_review(uid, problem_id, review.strip())
1034
+ except APIError as e:
1035
+ _error(str(e))
1036
+
1037
+ click.echo(
1038
+ f"\nSuccessfully submitted code review for {problem_id}\n"
1039
+ f"\n"
1040
+ f" Status: {result.get('status', 'COMPLETED')}\n"
1041
+ f"\n"
1042
+ f"View results at: {_hyperlink(f'https://aicodinggym.com/challenges/cr/{problem_id}')}"
1043
+ )
1044
+
1045
+
848
1046
  # ── mle group ────────────────────────────────────────────────────────────────
849
1047
 
850
1048
 
@@ -974,4 +1172,4 @@ def mle_submit(competition_id: str, csv_path: str, user_id: str | None,
974
1172
  )
975
1173
  if score is not None:
976
1174
  click.echo(f" Score: {score}\n")
977
- click.echo("Your prediction has been submitted for scoring!")
1175
+ click.echo(f"View results at: {_hyperlink(f'https://aicodinggym.com/challenges/mle/{competition_id}')}")
@@ -6,6 +6,9 @@ SSH keys are stored in ~/.aicodinggym/{user_id}_id_rsa.
6
6
  """
7
7
 
8
8
  import json
9
+ import os
10
+ import subprocess
11
+ import sys
9
12
  from pathlib import Path
10
13
  from typing import Any
11
14
 
@@ -19,8 +22,22 @@ _CONFIG_FIELDS = ("user_id", "repo_name", "private_key_path", "workspace_dir")
19
22
 
20
23
 
21
24
  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)
25
+ """Create the config directory with secure permissions if it doesn't exist.
26
+
27
+ On Unix/macOS: mode 0o700 (owner-only access).
28
+ On Windows: removes inherited ACLs and grants full control only to the
29
+ current user via icacls.
30
+ """
31
+ created = not CONFIG_DIR.exists()
32
+ CONFIG_DIR.mkdir(mode=0o700, parents=True, exist_ok=True)
33
+ if created and sys.platform == "win32":
34
+ username = os.environ.get("USERNAME", "")
35
+ if username:
36
+ subprocess.run(
37
+ ["icacls", str(CONFIG_DIR), "/inheritance:r",
38
+ "/grant:r", f"{username}:(OI)(CI)(F)"],
39
+ capture_output=True,
40
+ )
24
41
  return CONFIG_DIR
25
42
 
26
43
 
@@ -0,0 +1,309 @@
1
+ """Git and SSH key operations for AI Coding Gym CLI."""
2
+
3
+ import os
4
+ import re
5
+ import shutil
6
+ import subprocess
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Optional
10
+
11
+ from .config import ensure_config_dir
12
+
13
+
14
+ def _find_git_ssh() -> str | None:
15
+ """On Windows, find Git for Windows' bundled ssh.exe.
16
+
17
+ Windows may have two SSH binaries on PATH: the built-in OpenSSH
18
+ (C:\\Windows\\System32\\OpenSSH\\ssh.exe) and Git for Windows' MSYS2
19
+ ssh (C:\\Program Files\\Git\\usr\\bin\\ssh.exe). System32 is usually
20
+ first on PATH, so an unqualified 'ssh' resolves to Windows OpenSSH,
21
+ which can trigger GUI credential dialogs or deadlock when stdout is
22
+ captured. This function returns the full path to Git's bundled ssh
23
+ so we can reference it explicitly in GIT_SSH_COMMAND.
24
+ """
25
+ if sys.platform != "win32":
26
+ return None
27
+ git_path = shutil.which("git")
28
+ if not git_path:
29
+ return None
30
+ # Walk up from git.exe to find the Git root containing usr/bin/ssh.exe.
31
+ # Handles cmd/, bin/, and mingw64/bin/ layouts.
32
+ candidate = Path(git_path).resolve().parent
33
+ for _ in range(4):
34
+ ssh = candidate / "usr" / "bin" / "ssh.exe"
35
+ if ssh.exists():
36
+ return str(ssh).replace("\\", "/")
37
+ candidate = candidate.parent
38
+ return None
39
+
40
+
41
+ def _validate_git_ref(name: str, label: str) -> None:
42
+ """Raise ValueError if name contains suspicious shell metacharacters."""
43
+ if re.search(r'[;&|`$(){}]', name):
44
+ raise ValueError(f"Invalid {label}: {name!r}")
45
+
46
+
47
+ def _restrict_key_permissions(key_path: Path) -> None:
48
+ """Restrict an SSH private key file to owner-only access.
49
+
50
+ On Unix/macOS: chmod 600 (read/write owner only).
51
+ On Windows: uses icacls to remove inherited permissions and grant
52
+ full control only to the current user. SSH clients on both platforms
53
+ refuse to use a key whose permissions are too open.
54
+ """
55
+ if sys.platform == "win32":
56
+ # Remove inherited ACLs, then grant only the current user full control.
57
+ # (F) = Full control, matching chmod 0o600 (owner read+write).
58
+ key_str = str(key_path)
59
+ username = os.environ.get("USERNAME", "")
60
+ if username:
61
+ subprocess.run(
62
+ ["icacls", key_str, "/inheritance:r",
63
+ "/grant:r", f"{username}:(F)"],
64
+ capture_output=True,
65
+ )
66
+ else:
67
+ key_path.chmod(0o600)
68
+
69
+
70
+ def generate_ssh_key_pair(user_id: str) -> tuple[Path, str]:
71
+ """Generate an SSH key pair for the user.
72
+
73
+ First checks ~/.mcp-keys/ for existing keys matching the user_id.
74
+ If found, copies them to ~/.aicodinggym/ and reuses them.
75
+ Otherwise generates new keys in ~/.aicodinggym/{user_id}_id_rsa.
76
+ Returns (private_key_path, public_key_content).
77
+ """
78
+ key_dir = ensure_config_dir()
79
+ key_path = key_dir / f"{user_id}_id_rsa"
80
+
81
+ if not key_path.exists():
82
+ # Check ~/.mcp-keys/ for existing keys matching user_id
83
+ mcp_keys_dir = Path.home() / ".mcp-keys"
84
+ mcp_private = mcp_keys_dir / f"{user_id}_id_rsa"
85
+ mcp_public = mcp_keys_dir / f"{user_id}_id_rsa.pub"
86
+
87
+ if mcp_private.exists() and mcp_public.exists():
88
+ shutil.copy2(mcp_private, key_path)
89
+ shutil.copy2(mcp_public, Path(f"{key_path}.pub"))
90
+ _restrict_key_permissions(key_path)
91
+ else:
92
+ if not shutil.which("ssh-keygen"):
93
+ raise RuntimeError(
94
+ "ssh-keygen is not installed or not on PATH.\n"
95
+ "On Windows, install Git for Windows (https://git-scm.com) "
96
+ "which includes ssh-keygen, or use the OpenSSH optional feature."
97
+ )
98
+ result = subprocess.run(
99
+ ["ssh-keygen", "-t", "rsa", "-b", "4096", "-f", str(key_path),
100
+ "-N", "", "-C", f"aicodinggym-{user_id}"],
101
+ capture_output=True, text=True,
102
+ )
103
+ if result.returncode != 0:
104
+ raise RuntimeError(f"Failed to generate SSH key: {result.stderr}")
105
+
106
+ pub_key_path = Path(f"{key_path}.pub")
107
+ public_key = pub_key_path.read_text().strip()
108
+ return key_path, public_key
109
+
110
+
111
+ def run_git_command(cmd: list[str], cwd: str, key_path: Optional[Path] = None) -> subprocess.CompletedProcess:
112
+ """Execute a git command with optional SSH key configuration.
113
+
114
+ cmd must be a list of arguments (e.g. ["git", "status"]).
115
+ """
116
+ env = os.environ.copy()
117
+ if key_path:
118
+ # Quote the key path in case it contains spaces (common on Windows).
119
+ # Use forward slashes — works on all platforms and avoids backslash escaping.
120
+ quoted_key = str(key_path).replace("\\", "/")
121
+ # On Windows, use Git for Windows' bundled ssh to avoid Windows native
122
+ # OpenSSH which can trigger GUI credential dialogs or deadlock when
123
+ # stdout is captured. Falls back to bare "ssh" if not found.
124
+ ssh_bin = _find_git_ssh() or "ssh"
125
+ # Always use /dev/null for UserKnownHostsFile. On macOS/Linux this is
126
+ # the native null device. On Windows, Git for Windows bundles MSYS2's
127
+ # ssh which translates /dev/null correctly. Using os.devnull ("nul")
128
+ # would break MSYS2's ssh which treats "nul" as a literal filename.
129
+ # BatchMode=yes prevents any interactive prompts (password, passphrase)
130
+ # that would cause a hang when stdout/stderr are captured.
131
+ env["GIT_SSH_COMMAND"] = (
132
+ f'"{ssh_bin}" -i "{quoted_key}" '
133
+ f"-o StrictHostKeyChecking=no "
134
+ f"-o UserKnownHostsFile=/dev/null "
135
+ f"-o BatchMode=yes"
136
+ )
137
+
138
+ return subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, env=env)
139
+
140
+
141
+ def clone_repo(repo_url: str, branch: str, dest_name: str,
142
+ workspace: str, key_path: Path) -> tuple[bool, str]:
143
+ """Clone a repo branch into workspace/dest_name.
144
+
145
+ Returns (success, message).
146
+ """
147
+ problem_dir = Path(workspace) / dest_name
148
+
149
+ if problem_dir.exists():
150
+ # Check if already on the correct branch
151
+ result = run_git_command(["git", "rev-parse", "--abbrev-ref", "HEAD"], str(problem_dir))
152
+ if result.returncode == 0 and result.stdout.strip() == branch:
153
+ pull = run_git_command(["git", "pull", "origin", branch], str(problem_dir), key_path)
154
+ if pull.returncode != 0:
155
+ return False, f"Git pull failed:\n{pull.stderr}"
156
+ return True, f"Already exists. Updated to latest version.\nRepository: {problem_dir}\nBranch: {branch}"
157
+ return False, (
158
+ f"Directory {problem_dir} already exists with different content.\n"
159
+ "Remove it first or use --workspace-dir to specify a different location."
160
+ )
161
+
162
+ cmd = ["git", "clone", "--single-branch", "--branch", branch, "--depth", "1", repo_url, dest_name]
163
+ result = run_git_command(cmd, workspace, key_path)
164
+
165
+ if result.returncode != 0:
166
+ return False, f"Git clone failed:\n{result.stderr}\nMake sure the branch '{branch}' exists in the repository."
167
+
168
+ return True, f"Cloned to: {problem_dir}\nBranch: {branch}"
169
+
170
+
171
+ def clone_repo_cr(repo_url: str, base_branch: str, head_branch: str,
172
+ dest_name: str, workspace: str,
173
+ key_path: Optional[Path] = None) -> tuple[bool, str]:
174
+ """Clone a code review repo with both base and head branches.
175
+
176
+ Clones the base branch first (shallow), then fetches the head branch.
177
+ Returns (success, message).
178
+ """
179
+ _validate_git_ref(base_branch, "base_branch")
180
+ _validate_git_ref(head_branch, "head_branch")
181
+ _validate_git_ref(repo_url, "repo_url")
182
+ _validate_git_ref(dest_name, "dest_name")
183
+
184
+ problem_dir = Path(workspace) / dest_name
185
+
186
+ if problem_dir.exists():
187
+ # Already cloned — fetch latest for both branches
188
+ for branch in (base_branch, head_branch):
189
+ result = run_git_command(["git", "fetch", "origin", branch], str(problem_dir), key_path)
190
+ if result.returncode != 0:
191
+ return False, f"Git fetch failed for {branch}:\n{result.stderr}"
192
+ result = run_git_command(["git", "branch", "-f", branch, "FETCH_HEAD"], str(problem_dir))
193
+ if result.returncode != 0:
194
+ return False, f"Failed to update branch {branch}:\n{result.stderr}"
195
+ result = run_git_command(["git", "checkout", head_branch], str(problem_dir))
196
+ if result.returncode != 0:
197
+ return False, f"Failed to checkout head branch '{head_branch}':\n{result.stderr}"
198
+ return True, (
199
+ f"Already exists. Updated both branches.\n"
200
+ f"Repository: {problem_dir}\n"
201
+ f"Branches: {base_branch}, {head_branch}"
202
+ )
203
+
204
+ # Clone base branch (shallow); depth 50 needed for diffing between branches
205
+ cmd = ["git", "clone", "--single-branch", "--branch", base_branch, "--depth", "50", repo_url, dest_name]
206
+ result = run_git_command(cmd, workspace, key_path)
207
+ if result.returncode != 0:
208
+ return False, f"Git clone failed:\n{result.stderr}"
209
+
210
+ # Fetch head branch
211
+ result = run_git_command(["git", "fetch", "origin", head_branch], str(problem_dir), key_path)
212
+ if result.returncode != 0:
213
+ return False, f"Failed to fetch head branch '{head_branch}':\n{result.stderr}"
214
+
215
+ # Create local head branch tracking the fetched ref
216
+ result = run_git_command(["git", "branch", "-f", head_branch, "FETCH_HEAD"], str(problem_dir))
217
+ if result.returncode != 0:
218
+ return False, f"Failed to create branch {head_branch}:\n{result.stderr}"
219
+
220
+ # Check out head branch so the user starts on the code being reviewed
221
+ result = run_git_command(["git", "checkout", head_branch], str(problem_dir))
222
+ if result.returncode != 0:
223
+ return False, f"Failed to checkout head branch '{head_branch}':\n{result.stderr}"
224
+
225
+ return True, (
226
+ f"Cloned to: {problem_dir}\n"
227
+ f"Branches: {base_branch}, {head_branch}"
228
+ )
229
+
230
+
231
+ def add_commit_push(problem_dir: str, branch: str, key_path: Path,
232
+ message: str, force: bool = False) -> tuple[bool, str, str]:
233
+ """Stage, commit, and push changes.
234
+
235
+ Returns (success, message, commit_hash).
236
+ """
237
+ pdir = Path(problem_dir)
238
+
239
+ # Stage all changes except .github
240
+ result = run_git_command(["git", "add", "-A", "--", ".", ":(exclude).github"], str(pdir))
241
+ if result.returncode != 0:
242
+ return False, f"Git add failed:\n{result.stderr}", ""
243
+
244
+ # Check for staged changes
245
+ status = run_git_command(["git", "diff", "--cached", "--name-only"], str(pdir))
246
+ if not status.stdout.strip():
247
+ return False, "No changes to commit. Your working directory is clean.", ""
248
+
249
+ # Commit — pass message directly as a list arg; no shell escaping needed
250
+ result = run_git_command(["git", "commit", "-m", message], str(pdir))
251
+ if result.returncode != 0:
252
+ return False, f"Git commit failed:\n{result.stderr}", ""
253
+
254
+ # Get commit hash
255
+ hash_result = run_git_command(["git", "rev-parse", "HEAD"], str(pdir))
256
+ commit_hash = hash_result.stdout.strip()
257
+
258
+ # Push
259
+ push_cmd = ["git", "push"]
260
+ if force:
261
+ push_cmd.append("--force-with-lease")
262
+ push_cmd += ["origin", branch]
263
+ result = run_git_command(push_cmd, str(pdir), key_path)
264
+ if result.returncode != 0:
265
+ return False, f"Git push failed:\n{result.stderr}", commit_hash
266
+
267
+ return True, "Committed and pushed successfully.", commit_hash
268
+
269
+
270
+ def reset_to_setup_commit(problem_dir: str) -> tuple[bool, str]:
271
+ """Reset repo to the original 'Setup SWE-bench instance:' commit.
272
+
273
+ Returns (success, message).
274
+ """
275
+ log_result = run_git_command(["git", "log", "--format=%H:%s", "--reverse"], problem_dir)
276
+ if log_result.returncode != 0:
277
+ return False, f"Git log failed:\n{log_result.stderr}"
278
+
279
+ setup_prefix = "Setup SWE-bench instance:"
280
+ setup_commit = None
281
+ for line in log_result.stdout.splitlines():
282
+ parts = line.split(":", 1)
283
+ if len(parts) != 2:
284
+ continue
285
+ commit_hash, subject = parts[0].strip(), parts[1].strip()
286
+ if subject.startswith(setup_prefix):
287
+ setup_commit = commit_hash
288
+ break
289
+
290
+ if not setup_commit:
291
+ return False, (
292
+ f"Could not find the original setup commit.\n"
293
+ f"Expected a commit message starting with '{setup_prefix}'."
294
+ )
295
+
296
+ reset = run_git_command(["git", "reset", "--hard", setup_commit], problem_dir)
297
+ if reset.returncode != 0:
298
+ return False, f"Git reset failed:\n{reset.stderr}"
299
+
300
+ clean = run_git_command(["git", "clean", "-fd"], problem_dir)
301
+ if clean.returncode != 0:
302
+ return False, f"Git clean failed:\n{clean.stderr}"
303
+
304
+ return True, f"Reset to setup commit {setup_commit[:8]}.\nLocal changes discarded and untracked files removed."
305
+
306
+
307
+ def check_tool_installed(tool_name: str) -> bool:
308
+ """Check if a CLI tool is available on PATH."""
309
+ return shutil.which(tool_name) is not None
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "aicodinggym-cli"
3
- version = "0.2.0"
3
+ version = "0.4.0"
4
4
  description = "CLI tool for AI Coding Gym platform"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -1,162 +0,0 @@
1
- """Git and SSH key operations for AI Coding Gym CLI."""
2
-
3
- import os
4
- import shutil
5
- import subprocess
6
- from pathlib import Path
7
- from typing import Optional
8
-
9
- from .config import ensure_config_dir
10
-
11
-
12
- def generate_ssh_key_pair(user_id: str) -> tuple[Path, str]:
13
- """Generate an SSH key pair for the user.
14
-
15
- First checks ~/.mcp-keys/ for existing keys matching the user_id.
16
- If found, copies them to ~/.aicodinggym/ and reuses them.
17
- Otherwise generates new keys in ~/.aicodinggym/{user_id}_id_rsa.
18
- Returns (private_key_path, public_key_content).
19
- """
20
- key_dir = ensure_config_dir()
21
- key_path = key_dir / f"{user_id}_id_rsa"
22
-
23
- if not key_path.exists():
24
- # Check ~/.mcp-keys/ for existing keys matching user_id
25
- mcp_keys_dir = Path.home() / ".mcp-keys"
26
- mcp_private = mcp_keys_dir / f"{user_id}_id_rsa"
27
- mcp_public = mcp_keys_dir / f"{user_id}_id_rsa.pub"
28
-
29
- if mcp_private.exists() and mcp_public.exists():
30
- shutil.copy2(mcp_private, key_path)
31
- shutil.copy2(mcp_public, Path(f"{key_path}.pub"))
32
- key_path.chmod(0o600)
33
- else:
34
- result = subprocess.run(
35
- ["ssh-keygen", "-t", "rsa", "-b", "4096", "-f", str(key_path),
36
- "-N", "", "-C", f"aicodinggym-{user_id}"],
37
- capture_output=True, text=True,
38
- )
39
- if result.returncode != 0:
40
- raise RuntimeError(f"Failed to generate SSH key: {result.stderr}")
41
-
42
- pub_key_path = Path(f"{key_path}.pub")
43
- public_key = pub_key_path.read_text().strip()
44
- return key_path, public_key
45
-
46
-
47
- def run_git_command(cmd: str, cwd: str, key_path: Optional[Path] = None) -> subprocess.CompletedProcess:
48
- """Execute a git command with optional SSH key configuration."""
49
- env = os.environ.copy()
50
- if key_path:
51
- env["GIT_SSH_COMMAND"] = f"ssh -i {key_path} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
52
-
53
- return subprocess.run(cmd, shell=True, cwd=cwd, capture_output=True, text=True, env=env)
54
-
55
-
56
- def clone_repo(repo_url: str, branch: str, dest_name: str,
57
- workspace: str, key_path: Path) -> tuple[bool, str]:
58
- """Clone a repo branch into workspace/dest_name.
59
-
60
- Returns (success, message).
61
- """
62
- problem_dir = Path(workspace) / dest_name
63
-
64
- if problem_dir.exists():
65
- # Check if already on the correct branch
66
- result = run_git_command("git rev-parse --abbrev-ref HEAD", str(problem_dir))
67
- if result.returncode == 0 and result.stdout.strip() == branch:
68
- pull = run_git_command(f"git pull origin {branch}", str(problem_dir), key_path)
69
- if pull.returncode != 0:
70
- return False, f"Git pull failed:\n{pull.stderr}"
71
- return True, f"Already exists. Updated to latest version.\nRepository: {problem_dir}\nBranch: {branch}"
72
- return False, (
73
- f"Directory {problem_dir} already exists with different content.\n"
74
- "Remove it first or use --workspace-dir to specify a different location."
75
- )
76
-
77
- cmd = f"git clone --single-branch --branch {branch} --depth 1 {repo_url} {dest_name}"
78
- result = run_git_command(cmd, workspace, key_path)
79
-
80
- if result.returncode != 0:
81
- return False, f"Git clone failed:\n{result.stderr}\nMake sure the branch '{branch}' exists in the repository."
82
-
83
- return True, f"Cloned to: {problem_dir}\nBranch: {branch}"
84
-
85
-
86
- def add_commit_push(problem_dir: str, branch: str, key_path: Path,
87
- message: str, force: bool = False) -> tuple[bool, str, str]:
88
- """Stage, commit, and push changes.
89
-
90
- Returns (success, message, commit_hash).
91
- """
92
- pdir = Path(problem_dir)
93
-
94
- # Stage all changes except .github
95
- result = run_git_command("git add -A -- . ':(exclude).github'", str(pdir))
96
- if result.returncode != 0:
97
- return False, f"Git add failed:\n{result.stderr}", ""
98
-
99
- # Check for staged changes
100
- status = run_git_command("git diff --cached --name-only", str(pdir))
101
- if not status.stdout.strip():
102
- return False, "No changes to commit. Your working directory is clean.", ""
103
-
104
- # Commit
105
- safe_msg = message.replace('"', '\\"')
106
- result = run_git_command(f'git commit -m "{safe_msg}"', str(pdir))
107
- if result.returncode != 0:
108
- return False, f"Git commit failed:\n{result.stderr}", ""
109
-
110
- # Get commit hash
111
- hash_result = run_git_command("git rev-parse HEAD", str(pdir))
112
- commit_hash = hash_result.stdout.strip()
113
-
114
- # Push
115
- push_flag = "--force-with-lease " if force else ""
116
- result = run_git_command(f"git push {push_flag}origin {branch}", str(pdir), key_path)
117
- if result.returncode != 0:
118
- return False, f"Git push failed:\n{result.stderr}", commit_hash
119
-
120
- return True, "Committed and pushed successfully.", commit_hash
121
-
122
-
123
- def reset_to_setup_commit(problem_dir: str) -> tuple[bool, str]:
124
- """Reset repo to the original 'Setup SWE-bench instance:' commit.
125
-
126
- Returns (success, message).
127
- """
128
- log_result = run_git_command("git log --format=%H:%s --reverse", problem_dir)
129
- if log_result.returncode != 0:
130
- return False, f"Git log failed:\n{log_result.stderr}"
131
-
132
- setup_prefix = "Setup SWE-bench instance:"
133
- setup_commit = None
134
- for line in log_result.stdout.splitlines():
135
- parts = line.split(":", 1)
136
- if len(parts) != 2:
137
- continue
138
- commit_hash, subject = parts[0].strip(), parts[1].strip()
139
- if subject.startswith(setup_prefix):
140
- setup_commit = commit_hash
141
- break
142
-
143
- if not setup_commit:
144
- return False, (
145
- f"Could not find the original setup commit.\n"
146
- f"Expected a commit message starting with '{setup_prefix}'."
147
- )
148
-
149
- reset = run_git_command(f"git reset --hard {setup_commit}", problem_dir)
150
- if reset.returncode != 0:
151
- return False, f"Git reset failed:\n{reset.stderr}"
152
-
153
- clean = run_git_command("git clean -fd", problem_dir)
154
- if clean.returncode != 0:
155
- return False, f"Git clean failed:\n{clean.stderr}"
156
-
157
- return True, f"Reset to setup commit {setup_commit[:8]}.\nLocal changes discarded and untracked files removed."
158
-
159
-
160
- def check_tool_installed(tool_name: str) -> bool:
161
- """Check if a CLI tool is available on PATH."""
162
- return shutil.which(tool_name) is not None