aicodinggym-cli 0.3.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.3.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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aicodinggym-cli
3
- Version: 0.3.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
@@ -944,7 +944,7 @@ def cr_fetch(problem_id: str, user_id: str | None, workspace_dir: str | None):
944
944
 
945
945
  # Generate diff.patch
946
946
  diff_result = run_git_command(
947
- f"git diff {base_branch}..{head_branch}", str(problem_dir)
947
+ ["git", "diff", f"{base_branch}..{head_branch}"], str(problem_dir)
948
948
  )
949
949
  diff_path = problem_dir / "diff.patch"
950
950
  diff_path.write_text(diff_result.stdout)
@@ -964,6 +964,7 @@ def cr_fetch(problem_id: str, user_id: str | None, workspace_dir: str | None):
964
964
  "<!-- Approve / Request Changes / Comment -->\n"
965
965
  )
966
966
 
967
+ cat_cmd = "type" if sys.platform == "win32" else "cat"
967
968
  click.echo(
968
969
  f"\nSuccessfully fetched: {problem_id}\n"
969
970
  f"\n"
@@ -971,7 +972,7 @@ def cr_fetch(problem_id: str, user_id: str | None, workspace_dir: str | None):
971
972
  f" Review template: {review_path}\n"
972
973
  f"\n"
973
974
  f"Next steps:\n"
974
- f" 1. Review the diff: cat {diff_path}\n"
975
+ f" 1. Review the diff: {cat_cmd} {diff_path}\n"
975
976
  f" 2. Write your review in {review_path}\n"
976
977
  f" 3. Submit: aicodinggym cr submit {problem_id} -f review.md\n"
977
978
  )
@@ -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
 
@@ -160,28 +237,30 @@ def add_commit_push(problem_dir: str, branch: str, key_path: Path,
160
237
  pdir = Path(problem_dir)
161
238
 
162
239
  # Stage all changes except .github
163
- result = run_git_command("git add -A -- . ':(exclude).github'", str(pdir))
240
+ result = run_git_command(["git", "add", "-A", "--", ".", ":(exclude).github"], str(pdir))
164
241
  if result.returncode != 0:
165
242
  return False, f"Git add failed:\n{result.stderr}", ""
166
243
 
167
244
  # Check for staged changes
168
- status = run_git_command("git diff --cached --name-only", str(pdir))
245
+ status = run_git_command(["git", "diff", "--cached", "--name-only"], str(pdir))
169
246
  if not status.stdout.strip():
170
247
  return False, "No changes to commit. Your working directory is clean.", ""
171
248
 
172
- # Commit
173
- safe_msg = message.replace('"', '\\"')
174
- result = run_git_command(f'git commit -m "{safe_msg}"', str(pdir))
249
+ # Commit — pass message directly as a list arg; no shell escaping needed
250
+ result = run_git_command(["git", "commit", "-m", message], str(pdir))
175
251
  if result.returncode != 0:
176
252
  return False, f"Git commit failed:\n{result.stderr}", ""
177
253
 
178
254
  # Get commit hash
179
- hash_result = run_git_command("git rev-parse HEAD", str(pdir))
255
+ hash_result = run_git_command(["git", "rev-parse", "HEAD"], str(pdir))
180
256
  commit_hash = hash_result.stdout.strip()
181
257
 
182
258
  # 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)
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)
185
264
  if result.returncode != 0:
186
265
  return False, f"Git push failed:\n{result.stderr}", commit_hash
187
266
 
@@ -193,7 +272,7 @@ def reset_to_setup_commit(problem_dir: str) -> tuple[bool, str]:
193
272
 
194
273
  Returns (success, message).
195
274
  """
196
- log_result = run_git_command("git log --format=%H:%s --reverse", problem_dir)
275
+ log_result = run_git_command(["git", "log", "--format=%H:%s", "--reverse"], problem_dir)
197
276
  if log_result.returncode != 0:
198
277
  return False, f"Git log failed:\n{log_result.stderr}"
199
278
 
@@ -214,11 +293,11 @@ def reset_to_setup_commit(problem_dir: str) -> tuple[bool, str]:
214
293
  f"Expected a commit message starting with '{setup_prefix}'."
215
294
  )
216
295
 
217
- reset = run_git_command(f"git reset --hard {setup_commit}", problem_dir)
296
+ reset = run_git_command(["git", "reset", "--hard", setup_commit], problem_dir)
218
297
  if reset.returncode != 0:
219
298
  return False, f"Git reset failed:\n{reset.stderr}"
220
299
 
221
- clean = run_git_command("git clean -fd", problem_dir)
300
+ clean = run_git_command(["git", "clean", "-fd"], problem_dir)
222
301
  if clean.returncode != 0:
223
302
  return False, f"Git clean failed:\n{clean.stderr}"
224
303
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "aicodinggym-cli"
3
- version = "0.3.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"
File without changes