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.
- {aicodinggym_cli-0.3.0 → aicodinggym_cli-0.4.0}/PKG-INFO +1 -1
- {aicodinggym_cli-0.3.0 → aicodinggym_cli-0.4.0}/aicodinggym_cli.egg-info/PKG-INFO +1 -1
- {aicodinggym_cli-0.3.0 → aicodinggym_cli-0.4.0}/cli.py +3 -2
- {aicodinggym_cli-0.3.0 → aicodinggym_cli-0.4.0}/config.py +19 -2
- {aicodinggym_cli-0.3.0 → aicodinggym_cli-0.4.0}/git_ops.py +106 -27
- {aicodinggym_cli-0.3.0 → aicodinggym_cli-0.4.0}/pyproject.toml +1 -1
- {aicodinggym_cli-0.3.0 → aicodinggym_cli-0.4.0}/README.md +0 -0
- {aicodinggym_cli-0.3.0 → aicodinggym_cli-0.4.0}/__init__.py +0 -0
- {aicodinggym_cli-0.3.0 → aicodinggym_cli-0.4.0}/aicodinggym_cli.egg-info/SOURCES.txt +0 -0
- {aicodinggym_cli-0.3.0 → aicodinggym_cli-0.4.0}/aicodinggym_cli.egg-info/dependency_links.txt +0 -0
- {aicodinggym_cli-0.3.0 → aicodinggym_cli-0.4.0}/aicodinggym_cli.egg-info/entry_points.txt +0 -0
- {aicodinggym_cli-0.3.0 → aicodinggym_cli-0.4.0}/aicodinggym_cli.egg-info/requires.txt +0 -0
- {aicodinggym_cli-0.3.0 → aicodinggym_cli-0.4.0}/aicodinggym_cli.egg-info/top_level.txt +0 -0
- {aicodinggym_cli-0.3.0 → aicodinggym_cli-0.4.0}/api.py +0 -0
- {aicodinggym_cli-0.3.0 → aicodinggym_cli-0.4.0}/setup.cfg +0 -0
|
@@ -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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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(
|
|
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 =
|
|
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(
|
|
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(
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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 -- .
|
|
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
|
-
|
|
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
|
-
|
|
184
|
-
|
|
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(
|
|
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
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{aicodinggym_cli-0.3.0 → aicodinggym_cli-0.4.0}/aicodinggym_cli.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|