aicodinggym-cli 0.3.0__tar.gz → 0.5.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.3.0
3
+ Version: 0.5.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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aicodinggym-cli
3
- Version: 0.3.0
3
+ Version: 0.5.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
@@ -22,11 +22,13 @@ CODE REVIEW WORKFLOW:
22
22
  aicodinggym cr submit sentry-0001 -f review.md
23
23
  """
24
24
 
25
+ import json
25
26
  import os
26
27
  import platform
27
28
  import re
28
29
  import subprocess
29
30
  import sys
31
+ import urllib.request
30
32
  from datetime import datetime
31
33
  from pathlib import Path
32
34
 
@@ -78,6 +80,81 @@ def _warn(msg: str) -> None:
78
80
  click.echo(f"Warning: {msg}", err=True)
79
81
 
80
82
 
83
+ _GYM_ENV_API = "https://api.github.com/repos/AICodingGym/gym-environment/contents"
84
+ _GYM_ENV_SKIP = {"README.md"}
85
+
86
+
87
+ def _install_gym_environment(dest: Path) -> None:
88
+ """Download gym-environment files into dest and add them to .gitignore."""
89
+ try:
90
+ req = urllib.request.Request(_GYM_ENV_API, headers={"Accept": "application/vnd.github.v3+json"})
91
+ with urllib.request.urlopen(req, timeout=15) as resp:
92
+ entries = json.loads(resp.read())
93
+ except Exception as e:
94
+ _warn(f"Could not fetch gym-environment file list: {e}")
95
+ return
96
+
97
+ downloaded: list[str] = []
98
+
99
+ for entry in entries:
100
+ name = entry.get("name", "")
101
+ if name in _GYM_ENV_SKIP:
102
+ continue
103
+ etype = entry.get("type")
104
+
105
+ if etype == "file":
106
+ url = entry.get("download_url")
107
+ if not url:
108
+ continue
109
+ try:
110
+ with urllib.request.urlopen(url, timeout=15) as r:
111
+ (dest / name).write_bytes(r.read())
112
+ downloaded.append(name)
113
+ except Exception as e:
114
+ _warn(f"Failed to download {name}: {e}")
115
+
116
+ elif etype == "dir":
117
+ # Fetch subdirectory contents recursively (one level deep)
118
+ try:
119
+ sub_req = urllib.request.Request(
120
+ f"{_GYM_ENV_API}/{name}",
121
+ headers={"Accept": "application/vnd.github.v3+json"},
122
+ )
123
+ with urllib.request.urlopen(sub_req, timeout=15) as r:
124
+ sub_entries = json.loads(r.read())
125
+ except Exception as e:
126
+ _warn(f"Failed to list directory {name}: {e}")
127
+ continue
128
+
129
+ sub_dir = dest / name
130
+ sub_dir.mkdir(parents=True, exist_ok=True)
131
+ for sub in sub_entries:
132
+ sub_name = sub.get("name", "")
133
+ sub_url = sub.get("download_url")
134
+ if sub.get("type") != "file" or not sub_url:
135
+ continue
136
+ try:
137
+ with urllib.request.urlopen(sub_url, timeout=15) as r:
138
+ (sub_dir / sub_name).write_bytes(r.read())
139
+ except Exception as e:
140
+ _warn(f"Failed to download {name}/{sub_name}: {e}")
141
+ downloaded.append(name)
142
+
143
+ if not downloaded:
144
+ return
145
+
146
+ # Append to .gitignore
147
+ gitignore = dest / ".gitignore"
148
+ existing = gitignore.read_text() if gitignore.exists() else ""
149
+ existing_lines = set(existing.splitlines())
150
+ new_entries = [f for f in downloaded if f not in existing_lines and f"/{f}" not in existing_lines]
151
+ if new_entries:
152
+ block = "\n# gym-environment\n" + "\n".join(new_entries) + "\n"
153
+ with open(gitignore, "a") as fh:
154
+ fh.write(block)
155
+
156
+
157
+
81
158
  def _resolve_user_id(config: dict, user_id: str | None) -> str:
82
159
  """Resolve user_id from argument or config, with helpful error."""
83
160
  if user_id:
@@ -334,6 +411,8 @@ def configure(user_id: str, workspace_dir: str | None):
334
411
  }
335
412
  save_config(config)
336
413
 
414
+ _install_gym_environment(Path(resolved_workspace))
415
+
337
416
  click.echo(
338
417
  f"\nConfiguration saved successfully!\n"
339
418
  f"\n"
@@ -449,6 +528,8 @@ def swe_fetch(problem_id: str, user_id: str | None, workspace_dir: str | None):
449
528
  if not success:
450
529
  _error(msg)
451
530
 
531
+ _install_gym_environment(workspace / problem_id)
532
+
452
533
  click.echo(
453
534
  f"\nSuccessfully fetched problem: {problem_id}\n"
454
535
  f"\n"
@@ -940,11 +1021,13 @@ def cr_fetch(problem_id: str, user_id: str | None, workspace_dir: str | None):
940
1021
  if not success:
941
1022
  _error(msg)
942
1023
 
1024
+ _install_gym_environment(workspace / problem_id)
1025
+
943
1026
  problem_dir = workspace / problem_id
944
1027
 
945
1028
  # Generate diff.patch
946
1029
  diff_result = run_git_command(
947
- f"git diff {base_branch}..{head_branch}", str(problem_dir)
1030
+ ["git", "diff", f"{base_branch}..{head_branch}"], str(problem_dir)
948
1031
  )
949
1032
  diff_path = problem_dir / "diff.patch"
950
1033
  diff_path.write_text(diff_result.stdout)
@@ -964,6 +1047,7 @@ def cr_fetch(problem_id: str, user_id: str | None, workspace_dir: str | None):
964
1047
  "<!-- Approve / Request Changes / Comment -->\n"
965
1048
  )
966
1049
 
1050
+ cat_cmd = "type" if sys.platform == "win32" else "cat"
967
1051
  click.echo(
968
1052
  f"\nSuccessfully fetched: {problem_id}\n"
969
1053
  f"\n"
@@ -971,7 +1055,7 @@ def cr_fetch(problem_id: str, user_id: str | None, workspace_dir: str | None):
971
1055
  f" Review template: {review_path}\n"
972
1056
  f"\n"
973
1057
  f"Next steps:\n"
974
- f" 1. Review the diff: cat {diff_path}\n"
1058
+ f" 1. Review the diff: {cat_cmd} {diff_path}\n"
975
1059
  f" 2. Write your review in {review_path}\n"
976
1060
  f" 3. Submit: aicodinggym cr submit {problem_id} -f review.md\n"
977
1061
  )
@@ -1098,6 +1182,8 @@ def mle_download(competition_id: str, user_id: str | None, workspace_dir: str |
1098
1182
  except APIError as e:
1099
1183
  _error(str(e))
1100
1184
 
1185
+ _install_gym_environment(workspace / competition_id)
1186
+
1101
1187
  click.echo(
1102
1188
  f"\nDataset downloaded to: {dest_path}\n"
1103
1189
  f"\nNext step: train your model and submit predictions with:\n"
@@ -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
 
@@ -4,18 +4,69 @@ import os
4
4
  import re
5
5
  import shutil
6
6
  import subprocess
7
+ import sys
7
8
  from pathlib import Path
8
9
  from typing import Optional
9
10
 
10
11
  from .config import ensure_config_dir
11
12
 
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
+
13
41
  def _validate_git_ref(name: str, label: str) -> None:
14
42
  """Raise ValueError if name contains suspicious shell metacharacters."""
15
43
  if re.search(r'[;&|`$(){}]', name):
16
44
  raise ValueError(f"Invalid {label}: {name!r}")
17
45
 
18
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
+
19
70
  def generate_ssh_key_pair(user_id: str) -> tuple[Path, str]:
20
71
  """Generate an SSH key pair for the user.
21
72
 
@@ -36,8 +87,14 @@ def generate_ssh_key_pair(user_id: str) -> tuple[Path, str]:
36
87
  if mcp_private.exists() and mcp_public.exists():
37
88
  shutil.copy2(mcp_private, key_path)
38
89
  shutil.copy2(mcp_public, Path(f"{key_path}.pub"))
39
- key_path.chmod(0o600)
90
+ _restrict_key_permissions(key_path)
40
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
+ )
41
98
  result = subprocess.run(
42
99
  ["ssh-keygen", "-t", "rsa", "-b", "4096", "-f", str(key_path),
43
100
  "-N", "", "-C", f"aicodinggym-{user_id}"],
@@ -51,13 +108,34 @@ def generate_ssh_key_pair(user_id: str) -> tuple[Path, str]:
51
108
  return key_path, public_key
52
109
 
53
110
 
54
- def run_git_command(cmd: str, cwd: str, key_path: Optional[Path] = None) -> subprocess.CompletedProcess:
55
- """Execute a git command with optional SSH key configuration."""
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
+ """
56
116
  env = os.environ.copy()
57
117
  if key_path:
58
- env["GIT_SSH_COMMAND"] = f"ssh -i {key_path} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
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
+ )
59
137
 
60
- return subprocess.run(cmd, shell=True, cwd=cwd, capture_output=True, text=True, env=env)
138
+ return subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, env=env)
61
139
 
62
140
 
63
141
  def clone_repo(repo_url: str, branch: str, dest_name: str,
@@ -70,9 +148,9 @@ def clone_repo(repo_url: str, branch: str, dest_name: str,
70
148
 
71
149
  if problem_dir.exists():
72
150
  # Check if already on the correct branch
73
- result = run_git_command("git rev-parse --abbrev-ref HEAD", str(problem_dir))
151
+ result = run_git_command(["git", "rev-parse", "--abbrev-ref", "HEAD"], str(problem_dir))
74
152
  if result.returncode == 0 and result.stdout.strip() == branch:
75
- pull = run_git_command(f"git pull origin {branch}", str(problem_dir), key_path)
153
+ pull = run_git_command(["git", "pull", "origin", branch], str(problem_dir), key_path)
76
154
  if pull.returncode != 0:
77
155
  return False, f"Git pull failed:\n{pull.stderr}"
78
156
  return True, f"Already exists. Updated to latest version.\nRepository: {problem_dir}\nBranch: {branch}"
@@ -81,7 +159,7 @@ def clone_repo(repo_url: str, branch: str, dest_name: str,
81
159
  "Remove it first or use --workspace-dir to specify a different location."
82
160
  )
83
161
 
84
- cmd = f"git clone --single-branch --branch {branch} --depth 1 {repo_url} {dest_name}"
162
+ cmd = ["git", "clone", "--single-branch", "--branch", branch, "--depth", "1", repo_url, dest_name]
85
163
  result = run_git_command(cmd, workspace, key_path)
86
164
 
87
165
  if result.returncode != 0:
@@ -108,13 +186,13 @@ def clone_repo_cr(repo_url: str, base_branch: str, head_branch: str,
108
186
  if problem_dir.exists():
109
187
  # Already cloned — fetch latest for both branches
110
188
  for branch in (base_branch, head_branch):
111
- result = run_git_command(f"git fetch origin {branch}", str(problem_dir), key_path)
189
+ result = run_git_command(["git", "fetch", "origin", branch], str(problem_dir), key_path)
112
190
  if result.returncode != 0:
113
191
  return False, f"Git fetch failed for {branch}:\n{result.stderr}"
114
- result = run_git_command(f"git branch -f {branch} FETCH_HEAD", str(problem_dir))
192
+ result = run_git_command(["git", "branch", "-f", branch, "FETCH_HEAD"], str(problem_dir))
115
193
  if result.returncode != 0:
116
194
  return False, f"Failed to update branch {branch}:\n{result.stderr}"
117
- result = run_git_command(f"git checkout {head_branch}", str(problem_dir))
195
+ result = run_git_command(["git", "checkout", head_branch], str(problem_dir))
118
196
  if result.returncode != 0:
119
197
  return False, f"Failed to checkout head branch '{head_branch}':\n{result.stderr}"
120
198
  return True, (
@@ -124,24 +202,23 @@ def clone_repo_cr(repo_url: str, base_branch: str, head_branch: str,
124
202
  )
125
203
 
126
204
  # Clone base branch (shallow); depth 50 needed for diffing between branches
127
- cmd = f"git clone --single-branch --branch {base_branch} --depth 50 {repo_url} {dest_name}"
205
+ cmd = ["git", "clone", "--single-branch", "--branch", base_branch, "--depth", "50", repo_url, dest_name]
128
206
  result = run_git_command(cmd, workspace, key_path)
129
207
  if result.returncode != 0:
130
208
  return False, f"Git clone failed:\n{result.stderr}"
131
209
 
132
210
  # Fetch head branch
133
- fetch_cmd = f"git fetch origin {head_branch}"
134
- result = run_git_command(fetch_cmd, str(problem_dir), key_path)
211
+ result = run_git_command(["git", "fetch", "origin", head_branch], str(problem_dir), key_path)
135
212
  if result.returncode != 0:
136
213
  return False, f"Failed to fetch head branch '{head_branch}':\n{result.stderr}"
137
214
 
138
215
  # Create local head branch tracking the fetched ref
139
- result = run_git_command(f"git branch -f {head_branch} FETCH_HEAD", str(problem_dir))
216
+ result = run_git_command(["git", "branch", "-f", head_branch, "FETCH_HEAD"], str(problem_dir))
140
217
  if result.returncode != 0:
141
218
  return False, f"Failed to create branch {head_branch}:\n{result.stderr}"
142
219
 
143
220
  # Check out head branch so the user starts on the code being reviewed
144
- result = run_git_command(f"git checkout {head_branch}", str(problem_dir))
221
+ result = run_git_command(["git", "checkout", head_branch], str(problem_dir))
145
222
  if result.returncode != 0:
146
223
  return False, f"Failed to checkout head branch '{head_branch}':\n{result.stderr}"
147
224
 
@@ -159,29 +236,35 @@ def add_commit_push(problem_dir: str, branch: str, key_path: Path,
159
236
  """
160
237
  pdir = Path(problem_dir)
161
238
 
162
- # Stage all changes except .github
163
- result = run_git_command("git add -A -- . ':(exclude).github'", str(pdir))
239
+ # Stage all changes except dotfiles/dotdirs and markdown files
240
+ result = run_git_command([
241
+ "git", "add", "-A", "--", ".",
242
+ ":(exclude).*",
243
+ ":(exclude)*.md",
244
+ ], str(pdir))
164
245
  if result.returncode != 0:
165
246
  return False, f"Git add failed:\n{result.stderr}", ""
166
247
 
167
248
  # Check for staged changes
168
- status = run_git_command("git diff --cached --name-only", str(pdir))
249
+ status = run_git_command(["git", "diff", "--cached", "--name-only"], str(pdir))
169
250
  if not status.stdout.strip():
170
251
  return False, "No changes to commit. Your working directory is clean.", ""
171
252
 
172
- # Commit
173
- safe_msg = message.replace('"', '\\"')
174
- result = run_git_command(f'git commit -m "{safe_msg}"', str(pdir))
253
+ # Commit — pass message directly as a list arg; no shell escaping needed
254
+ result = run_git_command(["git", "commit", "-m", message], str(pdir))
175
255
  if result.returncode != 0:
176
256
  return False, f"Git commit failed:\n{result.stderr}", ""
177
257
 
178
258
  # Get commit hash
179
- hash_result = run_git_command("git rev-parse HEAD", str(pdir))
259
+ hash_result = run_git_command(["git", "rev-parse", "HEAD"], str(pdir))
180
260
  commit_hash = hash_result.stdout.strip()
181
261
 
182
262
  # Push
183
- push_flag = "--force-with-lease " if force else ""
184
- result = run_git_command(f"git push {push_flag}origin {branch}", str(pdir), key_path)
263
+ push_cmd = ["git", "push"]
264
+ if force:
265
+ push_cmd.append("--force-with-lease")
266
+ push_cmd += ["origin", branch]
267
+ result = run_git_command(push_cmd, str(pdir), key_path)
185
268
  if result.returncode != 0:
186
269
  return False, f"Git push failed:\n{result.stderr}", commit_hash
187
270
 
@@ -193,7 +276,7 @@ def reset_to_setup_commit(problem_dir: str) -> tuple[bool, str]:
193
276
 
194
277
  Returns (success, message).
195
278
  """
196
- log_result = run_git_command("git log --format=%H:%s --reverse", problem_dir)
279
+ log_result = run_git_command(["git", "log", "--format=%H:%s", "--reverse"], problem_dir)
197
280
  if log_result.returncode != 0:
198
281
  return False, f"Git log failed:\n{log_result.stderr}"
199
282
 
@@ -214,11 +297,11 @@ def reset_to_setup_commit(problem_dir: str) -> tuple[bool, str]:
214
297
  f"Expected a commit message starting with '{setup_prefix}'."
215
298
  )
216
299
 
217
- reset = run_git_command(f"git reset --hard {setup_commit}", problem_dir)
300
+ reset = run_git_command(["git", "reset", "--hard", setup_commit], problem_dir)
218
301
  if reset.returncode != 0:
219
302
  return False, f"Git reset failed:\n{reset.stderr}"
220
303
 
221
- clean = run_git_command("git clean -fd", problem_dir)
304
+ clean = run_git_command(["git", "clean", "-fd"], problem_dir)
222
305
  if clean.returncode != 0:
223
306
  return False, f"Git clean failed:\n{clean.stderr}"
224
307
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "aicodinggym-cli"
3
- version = "0.3.0"
3
+ version = "0.5.0"
4
4
  description = "CLI tool for AI Coding Gym platform"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
File without changes