R-Kentaren commited on
Commit
b87f702
Β·
verified Β·
1 Parent(s): 873734c

fix: agent_run param mismatch (send agent_name) + add GitHub push-update (3 inputs: repo name, token, username; --force-with-lease)

Browse files

Fixes agent_run + adds GitHub push-update:

FIX: handle_agent_run param mismatch
- Backend declared 8 params (prompt, target_language, target_framework,
history_json, skills_json, search_enabled, image_url, agent_name)
but frontend POST body only sent 7 β€” Gradio raised
'needed: 8, got: 7'. Frontend now sends state.activeAgent as the
8th value, restoring agent_run for ALL prompts (the bug broke agent
mode entirely after the Custom Agents feature shipped).

FEAT: Push Update to GitHub (3 inputs only)
- New backend: code/tools/github.py::push_to_github(repo_name,
github_token, username, branch?, commit_message?, timeout?)
- Workflow: snapshot workspace β†’ fresh git repo in temp dir β†’
git add -A + git commit β†’ git push --force-with-lease
https://<user>:<token>@github.com/<owner>/<repo>.git <branch>.
- Falls back to plain push if --force-with-lease fails on a brand-new
empty repo (no refs to lease against).
- Token is scrubbed from error messages before being returned.
- New API: push_github(repo_name, github_token, username, ...)
- New UI: 'Push Update to GitHub' section in the Deploy tab with 3
required inputs (repo name, GitHub token, username) + an Advanced
<details> for branch/commit_message.
- Updated README.md and CLAUDE.md with new feature docs.

Files changed (6) hide show
  1. CLAUDE.md +45 -0
  2. README.md +39 -0
  3. code/server/routes.py +46 -0
  4. code/tools/__init__.py +2 -0
  5. code/tools/github.py +224 -0
  6. index.html +188 -1
CLAUDE.md CHANGED
@@ -176,6 +176,7 @@ and any hard rules.
176
  | `set_active_agent(name)` | Set/clear the active agent for subsequent prompts |
177
  | `import_github(url, branch, subdir, target_subdir, depth, timeout)` | Clone a GitHub repo into the workspace (shallow, heavy dirs stripped) |
178
  | `github_url_examples()` | Return accepted GitHub URL formats |
 
179
 
180
  The `agent_run` endpoint also intercepts `/agent use|reset|delete|list` and
181
  dispatches them directly to the agents module, bypassing the model entirely
@@ -220,6 +221,50 @@ The slash command (defined in `code/commands/builtins/github.md`) instructs
220
  the agent to invoke `import_github_repo` via bash, then list the top-level
221
  files and suggest next steps based on what was imported.
222
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
223
  ## Skills
224
 
225
  | Skill | Description |
 
176
  | `set_active_agent(name)` | Set/clear the active agent for subsequent prompts |
177
  | `import_github(url, branch, subdir, target_subdir, depth, timeout)` | Clone a GitHub repo into the workspace (shallow, heavy dirs stripped) |
178
  | `github_url_examples()` | Return accepted GitHub URL formats |
179
+ | `push_github(repo_name, github_token, username, branch?, commit_message?, timeout?)` | Snapshot workspace β†’ commit β†’ push to a GitHub repo |
180
 
181
  The `agent_run` endpoint also intercepts `/agent use|reset|delete|list` and
182
  dispatches them directly to the agents module, bypassing the model entirely
 
221
  the agent to invoke `import_github_repo` via bash, then list the top-level
222
  files and suggest next steps based on what was imported.
223
 
224
+ ## GitHub Push
225
+
226
+ Push the current workspace back to a GitHub repo as a commit. Only 3 inputs
227
+ are required: repo name, GitHub token, username.
228
+
229
+ ### How it works (`code/tools/github.py::push_to_github`)
230
+
231
+ 1. Snapshot the workspace via `snapshot_workspace()` (returns a
232
+ `{relative_path: content}` dict).
233
+ 2. Create a temp dir, `git init -b <branch>` inside it, write the snapshot
234
+ files in, `git add -A` + `git commit -m <message>`.
235
+ 3. Build a push URL of the form
236
+ `https://<username>:<token>@github.com/<owner>/<repo>.git` and run
237
+ `git push --force-with-lease <url> <branch>`.
238
+ 4. If `--force-with-lease` fails because the remote has no refs yet
239
+ (brand-new empty repo), retry with a plain `git push`.
240
+ 5. Delete the temp dir. The token is never logged; error messages scrub it
241
+ before being returned.
242
+
243
+ ### Why `--force-with-lease`
244
+
245
+ SoniCoder treats the workspace as the source of truth. `--force-with-lease`
246
+ replaces the remote tip with the workspace snapshot, but fails loudly (rather
247
+ than silently clobbering) if someone else pushed commits in the meantime β€”
248
+ because the temp repo has no reflog of the remote tip, the lease fails and
249
+ the user is told to pull first.
250
+
251
+ ### UI
252
+
253
+ Located in the **Deploy** tab, in a "Push Update to GitHub" section below the
254
+ HuggingFace section. Only 3 fields are required: repo name, token, username.
255
+ An "Advanced" `<details>` exposes optional `branch` and `commit_message`
256
+ fields.
257
+
258
+ ### Security
259
+
260
+ - Token is sent over HTTPS to the SoniCoder backend, used once for the push,
261
+ then dropped (not stored, not logged).
262
+ - Error messages are scrubbed to remove the token before being returned.
263
+ - The temp repo is deleted at the end of the call (context manager).
264
+ - The local SoniCoder workspace is never turned into a git repo; the
265
+ workspace's `.git` (if any, e.g. after an import β€” though imports strip
266
+ `.git`) is never read.
267
+
268
  ## Skills
269
 
270
  | Skill | Description |
README.md CHANGED
@@ -26,6 +26,7 @@ Inspired by [Claude Code](https://github.com/anthropics/claude-code), SoniCoder
26
  - ⚑ **Slash Commands** β€” `/commit`, `/review`, `/feature`, `/design`, `/explain`, `/test`, `/refactor`, `/skill`, `/agent`, `/github`, `/help`
27
  - 🧠 **Custom Agents** β€” describe a specialized agent in natural language and the AI generates a full persona (system prompt + tool whitelist + auto-loaded skills + temperature + max iterations). Activate via `/agent use <name>` or the Agents panel. Built-ins: `code-reviewer`, `test-writer`.
28
  - πŸ“₯ **GitHub Import** β€” paste any GitHub URL (or use `/github <url>`) to shallow-clone a repo into the workspace. Heavy dirs (`.git`, `node_modules`, `__pycache__`, `.venv`, `dist`) are stripped automatically. Supports `branch`, `subdir`, and `target_subdir` options.
 
29
  - πŸͺ **Hooks System** β€” pre/post tool execution rules (block dangerous commands, warn on debug code/secrets)
30
  - πŸ“ **Sandboxed Workspace** β€” agent manipulates files in `./workspace/` (path-escape protected)
31
  - βœ… **Todo Lists** β€” track multi-step tasks Claude Code-style
@@ -131,6 +132,44 @@ SoniCoder can clone any public GitHub repository into the sandboxed workspace so
131
 
132
  **Security:** Only `github.com` URLs are accepted (HTTPS or SSH form). The clone happens in a temp directory and is then *copied* into the workspace β€” the upstream repo is never modified. Path-escape protection on `target_subdir` prevents writing outside the workspace.
133
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134
  ### Custom Agents
135
 
136
  Custom agents are AI-generated personas that layer on top of the base SoniCoder system prompt. Each agent defines:
 
26
  - ⚑ **Slash Commands** β€” `/commit`, `/review`, `/feature`, `/design`, `/explain`, `/test`, `/refactor`, `/skill`, `/agent`, `/github`, `/help`
27
  - 🧠 **Custom Agents** β€” describe a specialized agent in natural language and the AI generates a full persona (system prompt + tool whitelist + auto-loaded skills + temperature + max iterations). Activate via `/agent use <name>` or the Agents panel. Built-ins: `code-reviewer`, `test-writer`.
28
  - πŸ“₯ **GitHub Import** β€” paste any GitHub URL (or use `/github <url>`) to shallow-clone a repo into the workspace. Heavy dirs (`.git`, `node_modules`, `__pycache__`, `.venv`, `dist`) are stripped automatically. Supports `branch`, `subdir`, and `target_subdir` options.
29
+ - πŸ“¦ **GitHub Push** β€” push the current workspace to any GitHub repo with just 3 inputs: repo name, GitHub API token, and username. Uses `--force-with-lease` so the workspace is the source of truth. Available in the Deploy tab.
30
  - πŸͺ **Hooks System** β€” pre/post tool execution rules (block dangerous commands, warn on debug code/secrets)
31
  - πŸ“ **Sandboxed Workspace** β€” agent manipulates files in `./workspace/` (path-escape protected)
32
  - βœ… **Todo Lists** β€” track multi-step tasks Claude Code-style
 
132
 
133
  **Security:** Only `github.com` URLs are accepted (HTTPS or SSH form). The clone happens in a temp directory and is then *copied* into the workspace β€” the upstream repo is never modified. Path-escape protection on `target_subdir` prevents writing outside the workspace.
134
 
135
+ ### GitHub Push (Update a GitHub repo)
136
+
137
+ Push the current SoniCoder workspace back to a GitHub repo as a commit. Designed to be minimal β€” only **3 required inputs**:
138
+
139
+ 1. **Repository name** β€” either `my-app` (combined with your username) or `username/my-app`.
140
+ 2. **GitHub API token** β€” a Personal Access Token (PAT) with `repo` scope. [Create one here](https://github.com/settings/tokens/new?scopes=repo&description=SoniCoder).
141
+ 3. **Username** β€” the GitHub user (or org) that owns the repo and matches the token.
142
+
143
+ **How to use:**
144
+
145
+ 1. Open the **Deploy** tab.
146
+ 2. Scroll to the **"Push Update to GitHub"** section (below the HuggingFace section).
147
+ 3. Fill in the 3 required fields (optionally expand "Advanced" to set `branch` or `commit message`).
148
+ 4. Click **πŸ“¦ Push to GitHub**. A confirmation dialog shows the target repo + branch.
149
+ 5. On success, the status box shows the commit SHA, commit URL, and repo URL.
150
+
151
+ **How it works (under the hood):**
152
+
153
+ 1. The workspace is snapshotted (via `snapshot_workspace()` β€” same function used for HuggingFace deploy).
154
+ 2. A fresh git repo is created in a temp dir; the snapshot files are written in.
155
+ 3. `git init -b <branch>` β†’ `git add -A` β†’ `git commit -m <message>`.
156
+ 4. `git push --force-with-lease https://<username>:<token>@github.com/<owner>/<repo>.git <branch>`.
157
+ 5. If `--force-with-lease` fails because the remote has no refs yet (brand-new empty repo), it retries with a plain `git push`.
158
+ 6. The temp dir is deleted. The token is never logged; error messages scrub it before being returned to the UI.
159
+
160
+ **API endpoint:**
161
+
162
+ | Endpoint | Description |
163
+ |----------|-------------|
164
+ | `push_github(repo_name, github_token, username, branch?, commit_message?, timeout?)` | Snapshot workspace β†’ commit β†’ push to GitHub |
165
+
166
+ **Security notes:**
167
+
168
+ - The token is sent over HTTPS to the SoniCoder backend, used once for the push, then dropped (not stored, not logged).
169
+ - Error messages are scrubbed to remove the token before being returned to the frontend.
170
+ - `--force-with-lease` is used instead of `--force` so the push fails loudly if the remote moved (rather than silently overwriting someone else's commits). For a brand-new empty repo, it falls back to a plain push.
171
+ - The push happens from a temp dir β€” your local SoniCoder workspace is never turned into a git repo, and the workspace's `.git` (if any) is never read.
172
+
173
  ### Custom Agents
174
 
175
  Custom agents are AI-generated personas that layer on top of the base SoniCoder system prompt. Each agent defines:
code/server/routes.py CHANGED
@@ -23,6 +23,7 @@ Defines all HTTP and API endpoints:
23
  - API todo_write β†’ update todo list
24
  - API import_github β†’ clone a GitHub repo into the workspace
25
  - API github_url_examples β†’ return accepted GitHub URL formats
 
26
  """
27
 
28
  from __future__ import annotations
@@ -1185,3 +1186,48 @@ def handle_github_url_examples() -> str:
1185
  from code.tools.github import list_github_url_examples
1186
  result = list_github_url_examples()
1187
  yield json.dumps(result, default=str)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  - API todo_write β†’ update todo list
24
  - API import_github β†’ clone a GitHub repo into the workspace
25
  - API github_url_examples β†’ return accepted GitHub URL formats
26
+ - API push_github β†’ push the current workspace to a GitHub repo
27
  """
28
 
29
  from __future__ import annotations
 
1186
  from code.tools.github import list_github_url_examples
1187
  result = list_github_url_examples()
1188
  yield json.dumps(result, default=str)
1189
+
1190
+
1191
+ @app.api(name="push_github", concurrency_limit=1)
1192
+ def handle_push_github(
1193
+ repo_name: str,
1194
+ github_token: str,
1195
+ username: str,
1196
+ branch: str = "main",
1197
+ commit_message: str = "",
1198
+ timeout: str = "120",
1199
+ ) -> str:
1200
+ """Push the current workspace to a GitHub repo.
1201
+
1202
+ Requires only 3 user inputs (repo_name, github_token, username) plus
1203
+ optional branch / commit_message / timeout. The workspace is snapshotted
1204
+ (via `snapshot_workspace`), written into a fresh git repo in a temp dir,
1205
+ committed, and pushed to `https://github.com/<username>/<repo_name>.git`
1206
+ using HTTPS basic auth with the token.
1207
+
1208
+ The push uses `--force-with-lease` so it replaces the remote tip with the
1209
+ SoniCoder workspace contents. If the remote doesn't exist yet (no refs
1210
+ to lease against), it retries with a plain push.
1211
+
1212
+ Yields
1213
+ ------
1214
+ JSON dict with keys: success, message, repo_full_name, branch,
1215
+ commit_sha, commit_url, repo_url, files_pushed, error (on failure).
1216
+ """
1217
+ from code.tools.github import push_to_github
1218
+
1219
+ try:
1220
+ timeout_int = int(timeout) if str(timeout).strip() else 120
1221
+ timeout_int = max(10, min(600, timeout_int))
1222
+ except (ValueError, TypeError):
1223
+ timeout_int = 120
1224
+
1225
+ result = push_to_github(
1226
+ repo_name=repo_name,
1227
+ github_token=github_token,
1228
+ username=username,
1229
+ branch=branch or "main",
1230
+ commit_message=commit_message or "",
1231
+ timeout=timeout_int,
1232
+ )
1233
+ yield json.dumps(result, default=str)
code/tools/__init__.py CHANGED
@@ -26,6 +26,7 @@ from code.tools.todos import (
26
  from code.tools.github import (
27
  import_github_repo,
28
  list_github_url_examples,
 
29
  )
30
 
31
  __all__ = [
@@ -42,4 +43,5 @@ __all__ = [
42
  "todo_update",
43
  "import_github_repo",
44
  "list_github_url_examples",
 
45
  ]
 
26
  from code.tools.github import (
27
  import_github_repo,
28
  list_github_url_examples,
29
+ push_to_github,
30
  )
31
 
32
  __all__ = [
 
43
  "todo_update",
44
  "import_github_repo",
45
  "list_github_url_examples",
46
+ "push_to_github",
47
  ]
code/tools/github.py CHANGED
@@ -394,3 +394,227 @@ def list_github_url_examples() -> dict[str, Any]:
394
  "If a /tree/<branch>/<subdir> path is included, only that subdir is imported.",
395
  ],
396
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
394
  "If a /tree/<branch>/<subdir> path is included, only that subdir is imported.",
395
  ],
396
  }
397
+
398
+
399
+ # ─── Push workspace to GitHub ────────────────────────────────────────────
400
+
401
+
402
+ def push_to_github(
403
+ repo_name: str,
404
+ github_token: str,
405
+ username: str,
406
+ branch: str = "main",
407
+ commit_message: str = "",
408
+ timeout: int = 120,
409
+ ) -> dict[str, Any]:
410
+ """Push the current SoniCoder workspace to a GitHub repo.
411
+
412
+ Workflow:
413
+ 1. Snapshot the workspace (call `snapshot_workspace()`).
414
+ 2. Create a fresh git repo in a temp dir, copy the files in.
415
+ 3. `git commit -m <message>`.
416
+ 4. `git push https://<token>@github.com/<username>/<repo>.git <branch>`.
417
+
418
+ If the remote repo already exists with history, the push uses
419
+ `--force-with-lease` so the new commit replaces the remote tip. This
420
+ matches the SoniCoder mental model: "the workspace IS the source of
421
+ truth; overwrite whatever's on GitHub with my latest".
422
+
423
+ Parameters
424
+ ----------
425
+ repo_name : str
426
+ Repo name. Either "repo" (combined with `username` as
427
+ `username/repo`) or "username/repo" (username arg then ignored).
428
+ github_token : str
429
+ GitHub Personal Access Token (PAT) with `repo` scope.
430
+ username : str
431
+ GitHub username (or org) to push as. Required.
432
+ branch : str
433
+ Target branch (default "main"). Will be created on push if missing.
434
+ commit_message : str
435
+ Commit message. Defaults to a timestamped message.
436
+ timeout : int
437
+ Per-git-command timeout in seconds (default 120).
438
+
439
+ Returns
440
+ -------
441
+ dict with keys:
442
+ success, message, repo_full_name, branch, commit_sha,
443
+ commit_url, repo_url, files_pushed, error (on failure)
444
+ """
445
+ import datetime
446
+ import subprocess
447
+
448
+ # ── Validate inputs ──────────────────────────────────────────────
449
+ repo_name = (repo_name or "").strip()
450
+ github_token = (github_token or "").strip()
451
+ username = (username or "").strip()
452
+ branch = (branch or "main").strip() or "main"
453
+ if not commit_message:
454
+ commit_message = (
455
+ f"Update from SoniCoder at "
456
+ f"{datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
457
+ )
458
+
459
+ if not repo_name:
460
+ return _gh_err("Repo name is required.")
461
+ if not github_token:
462
+ return _gh_err("GitHub token is required.")
463
+ if not username:
464
+ return _gh_err("Username (or org) is required.")
465
+
466
+ # Normalize repo_name into owner/repo
467
+ if "/" in repo_name:
468
+ owner, name = repo_name.split("/", 1)
469
+ owner = owner.strip() or username
470
+ name = name.strip()
471
+ else:
472
+ owner = username
473
+ name = repo_name
474
+
475
+ if not name:
476
+ return _gh_err(f"Invalid repo_name: {repo_name!r}")
477
+
478
+ repo_full_name = f"{owner}/{name}"
479
+
480
+ if not _git_available():
481
+ return _gh_err("`git` is not installed in this environment. Cannot push.")
482
+
483
+ # ── Snapshot workspace ───────────────────────────────────────────
484
+ from code.tools.fs import snapshot_workspace
485
+
486
+ files = snapshot_workspace()
487
+ if not files:
488
+ return _gh_err(
489
+ "Workspace is empty. Generate or import some code first."
490
+ )
491
+
492
+ # ── Set up a fresh git repo in temp dir ──────────────────────────
493
+ with tempfile.TemporaryDirectory(prefix="sonicoder_gh_push_") as tmp:
494
+ repo_dir = os.path.join(tmp, name)
495
+ os.makedirs(repo_dir, exist_ok=True)
496
+
497
+ def _run_git(args: list[str], cwd: str = repo_dir) -> tuple[int, str, str]:
498
+ try:
499
+ proc = subprocess.run(
500
+ ["git", *args],
501
+ cwd=cwd,
502
+ capture_output=True,
503
+ text=True,
504
+ timeout=timeout,
505
+ )
506
+ return proc.returncode, proc.stdout, proc.stderr
507
+ except subprocess.TimeoutExpired:
508
+ return 124, "", f"git {' '.join(args)} timed out after {timeout}s"
509
+
510
+ # Init repo
511
+ rc, _, err = _run_git(["init", "-b", branch])
512
+ if rc != 0:
513
+ # Older git doesn't support -b; fall back
514
+ rc, _, err = _run_git(["init"])
515
+ if rc != 0:
516
+ return _gh_err(f"git init failed: {err}")
517
+ # Then checkout/create the branch
518
+ _run_git(["checkout", "-b", branch])
519
+
520
+ # Set committer identity (required for commit)
521
+ _run_git(["config", "user.email", f"{username}@users.noreply.github.com"])
522
+ _run_git(["config", "user.name", username])
523
+
524
+ # Write all snapshot files into the repo dir
525
+ for rel_path, content in files.items():
526
+ # Safety: skip absolute paths and parent-escape attempts
527
+ if os.path.isabs(rel_path) or rel_path.startswith(".."):
528
+ continue
529
+ target = os.path.join(repo_dir, rel_path)
530
+ os.makedirs(os.path.dirname(target) or repo_dir, exist_ok=True)
531
+ try:
532
+ with open(target, "w", encoding="utf-8") as f:
533
+ f.write(content)
534
+ except (OSError, UnicodeEncodeError):
535
+ continue
536
+
537
+ # Stage everything
538
+ rc, _, err = _run_git(["add", "-A"])
539
+ if rc != 0:
540
+ return _gh_err(f"git add failed: {err}")
541
+
542
+ # Check if there's anything to commit
543
+ rc, out, _ = _run_git(["status", "--porcelain"])
544
+ has_changes = bool(out.strip())
545
+ if not has_changes:
546
+ return {
547
+ "success": True,
548
+ "message": "No changes to commit (workspace matches last commit).",
549
+ "repo_full_name": repo_full_name,
550
+ "branch": branch,
551
+ "commit_sha": None,
552
+ "commit_url": None,
553
+ "repo_url": f"https://github.com/{repo_full_name}",
554
+ "files_pushed": 0,
555
+ }
556
+
557
+ # Commit
558
+ rc, out, err = _run_git(["commit", "-m", commit_message])
559
+ if rc != 0:
560
+ return _gh_err(f"git commit failed: {err}")
561
+
562
+ # Get commit SHA
563
+ rc, sha, _ = _run_git(["rev-parse", "HEAD"])
564
+ commit_sha = sha.strip()
565
+
566
+ # Build the push URL with token embedded (HTTPS basic auth).
567
+ # Token is URL-safe enough for typical PATs (alphanumeric).
568
+ push_url = (
569
+ f"https://{username}:{github_token}@github.com/{repo_full_name}.git"
570
+ )
571
+
572
+ # Push with --force-with-lease so we don't silently overwrite
573
+ # someone else's commits if the remote moved.
574
+ rc, out, err = _run_git(
575
+ ["push", "--force-with-lease", push_url, branch]
576
+ )
577
+ if rc != 0:
578
+ # If --force-with-lease fails because the remote doesn't exist
579
+ # yet (no refs to lease against), retry with a plain push.
580
+ if "no matching references" in err.lower() or "delete" in err.lower():
581
+ rc, out, err = _run_git(["push", push_url, branch])
582
+ if rc != 0:
583
+ # Don't leak the token in the error
584
+ safe_err = err.replace(github_token, "***").replace(push_url, "https://github.com/{repo_full_name}.git")
585
+ return _gh_err(
586
+ f"git push failed: {safe_err[:400]}. "
587
+ "Check that the token has `repo` scope and the repo exists.",
588
+ repo_full_name=repo_full_name,
589
+ branch=branch,
590
+ commit_sha=commit_sha,
591
+ )
592
+
593
+ # Count files pushed
594
+ rc, count_out, _ = _run_git(["ls-files"])
595
+ files_pushed = len([l for l in count_out.splitlines() if l.strip()])
596
+
597
+ return {
598
+ "success": True,
599
+ "message": (
600
+ f"Pushed {files_pushed} file(s) to {repo_full_name} "
601
+ f"on branch `{branch}` (commit {commit_sha[:8]})."
602
+ ),
603
+ "repo_full_name": repo_full_name,
604
+ "branch": branch,
605
+ "commit_sha": commit_sha,
606
+ "commit_url": f"https://github.com/{repo_full_name}/commit/{commit_sha}",
607
+ "repo_url": f"https://github.com/{repo_full_name}",
608
+ "files_pushed": files_pushed,
609
+ }
610
+
611
+
612
+ def _gh_err(message: str, **extras: Any) -> dict[str, Any]:
613
+ """Build a standard error response for push_to_github."""
614
+ result: dict[str, Any] = {
615
+ "success": False,
616
+ "message": message,
617
+ "error": message,
618
+ }
619
+ result.update(extras)
620
+ return result
index.html CHANGED
@@ -1418,6 +1418,55 @@ body.hide-thinking .think-block { display: none; }
1418
  <div class="deploy-status" id="deploy-status"></div>
1419
  <div class="project-files" id="project-files"></div>
1420
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1421
  </div>
1422
  </div>
1423
  </div>
@@ -2894,7 +2943,16 @@ async function sendMessageAgent(prompt) {
2894
  method: 'POST',
2895
  headers: { 'Content-Type': 'application/json' },
2896
  body: JSON.stringify({
2897
- data: [prompt, state.targetLanguage, framework, historyJSON, skillsJSON, state.searchEnabled ? 'true' : 'false', state.uploadedImageFileUrl || '']
 
 
 
 
 
 
 
 
 
2898
  })
2899
  });
2900
 
@@ -3630,6 +3688,135 @@ async function pushToHuggingFace() {
3630
  btn.disabled = false;
3631
  }
3632
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3633
  </script>
3634
  </body>
3635
  </html>
 
1418
  <div class="deploy-status" id="deploy-status"></div>
1419
  <div class="project-files" id="project-files"></div>
1420
  </div>
1421
+
1422
+ <!-- ─── GitHub Push Section ────────────────────────────────── -->
1423
+ <div class="deploy-section" style="margin-top:14px;">
1424
+ <div class="deploy-title" style="color:var(--cyan, #00d4ff);text-shadow:0 0 6px rgba(0,212,255,0.4);">&#128230; Push Update to GitHub</div>
1425
+ <div class="deploy-hint" style="margin-bottom:10px;">
1426
+ Snapshots the current workspace and pushes it as a commit to a
1427
+ GitHub repo. Uses <code>--force-with-lease</code> so the latest
1428
+ workspace contents overwrite the remote tip.
1429
+ </div>
1430
+
1431
+ <!-- Only 3 required inputs -->
1432
+ <div class="deploy-field">
1433
+ <label for="gh-repo-name">1. Repository Name</label>
1434
+ <input type="text" id="gh-repo-name" placeholder="my-app (or username/my-app)" autocomplete="off">
1435
+ <div class="deploy-hint">If you omit the owner prefix, your username is used.</div>
1436
+ </div>
1437
+
1438
+ <div class="deploy-field">
1439
+ <label for="gh-token">2. GitHub API Token</label>
1440
+ <input type="password" id="gh-token" placeholder="ghp_xxxxxxxxxxxxxxxxxxxxx" autocomplete="off">
1441
+ <div class="deploy-hint">
1442
+ Needs <code>repo</code> scope.
1443
+ <a href="https://github.com/settings/tokens/new?scopes=repo&description=SoniCoder" target="_blank" rel="noopener">Create a token &nearr;</a>
1444
+ </div>
1445
+ </div>
1446
+
1447
+ <div class="deploy-field">
1448
+ <label for="gh-username">3. Username (for push)</label>
1449
+ <input type="text" id="gh-username" placeholder="your-github-username" autocomplete="off">
1450
+ <div class="deploy-hint">The GitHub user (or org) that owns the repo and matches the token.</div>
1451
+ </div>
1452
+
1453
+ <!-- Optional advanced settings (collapsed) -->
1454
+ <details style="margin-top:8px;">
1455
+ <summary style="font-size:10px;color:var(--gray-dim);cursor:pointer;letter-spacing:1px;text-transform:uppercase;">Advanced (optional)</summary>
1456
+ <div class="deploy-field" style="margin-top:8px;">
1457
+ <label for="gh-branch">Branch</label>
1458
+ <input type="text" id="gh-branch" placeholder="main" value="main" autocomplete="off">
1459
+ </div>
1460
+ <div class="deploy-field">
1461
+ <label for="gh-commit-msg">Commit message</label>
1462
+ <input type="text" id="gh-commit-msg" placeholder="Update from SoniCoder" autocomplete="off">
1463
+ <div class="deploy-hint">If empty, a timestamped message is used.</div>
1464
+ </div>
1465
+ </details>
1466
+
1467
+ <button id="btn-push-github" onclick="pushToGithub()" style="margin-top:10px;">&#128230; Push to GitHub</button>
1468
+ <div class="deploy-status" id="github-deploy-status"></div>
1469
+ </div>
1470
  </div>
1471
  </div>
1472
  </div>
 
2943
  method: 'POST',
2944
  headers: { 'Content-Type': 'application/json' },
2945
  body: JSON.stringify({
2946
+ data: [
2947
+ prompt,
2948
+ state.targetLanguage,
2949
+ framework,
2950
+ historyJSON,
2951
+ skillsJSON,
2952
+ state.searchEnabled ? 'true' : 'false',
2953
+ state.uploadedImageFileUrl || '',
2954
+ state.activeAgent || ''
2955
+ ]
2956
  })
2957
  });
2958
 
 
3688
  btn.disabled = false;
3689
  }
3690
  }
3691
+
3692
+ // ═══════════════════════════════════════════════════════
3693
+ // PUSH TO GITHUB (3 inputs: repo name, token, username)
3694
+ // ═══════════════════════════════════════════════════════
3695
+
3696
+ async function pushToGithub() {
3697
+ const repoNameEl = document.getElementById('gh-repo-name');
3698
+ const tokenEl = document.getElementById('gh-token');
3699
+ const usernameEl = document.getElementById('gh-username');
3700
+ const branchEl = document.getElementById('gh-branch');
3701
+ const msgEl = document.getElementById('gh-commit-msg');
3702
+ const statusEl = document.getElementById('github-deploy-status');
3703
+ const btn = document.getElementById('btn-push-github');
3704
+
3705
+ if (!repoNameEl || !tokenEl || !usernameEl || !statusEl || !btn) return;
3706
+
3707
+ const repoName = repoNameEl.value.trim();
3708
+ const token = tokenEl.value.trim();
3709
+ const username = usernameEl.value.trim();
3710
+ const branch = (branchEl && branchEl.value.trim()) || 'main';
3711
+ const commitMsg = (msgEl && msgEl.value.trim()) || '';
3712
+
3713
+ // ── Validate the 3 required inputs ───────────────────────────────
3714
+ if (!repoName) {
3715
+ statusEl.className = 'deploy-status error';
3716
+ statusEl.textContent = 'Please enter the repository name.';
3717
+ statusEl.style.display = 'block';
3718
+ repoNameEl.focus();
3719
+ return;
3720
+ }
3721
+ if (!token) {
3722
+ statusEl.className = 'deploy-status error';
3723
+ statusEl.textContent = 'Please enter your GitHub API token.';
3724
+ statusEl.style.display = 'block';
3725
+ tokenEl.focus();
3726
+ return;
3727
+ }
3728
+ if (!username) {
3729
+ statusEl.className = 'deploy-status error';
3730
+ statusEl.textContent = 'Please enter your GitHub username.';
3731
+ statusEl.style.display = 'block';
3732
+ usernameEl.focus();
3733
+ return;
3734
+ }
3735
+
3736
+ // Quick client-side token sanity check (server validates too)
3737
+ if (token.length < 20) {
3738
+ statusEl.className = 'deploy-status error';
3739
+ statusEl.textContent = 'Token looks too short β€” expected a GitHub PAT (starts with ghp_, github_pat_, etc.).';
3740
+ statusEl.style.display = 'block';
3741
+ return;
3742
+ }
3743
+
3744
+ // Confirm push β€” it overwrites the remote tip
3745
+ const fullRepo = repoName.includes('/') ? repoName : `${username}/${repoName}`;
3746
+ if (!confirm(
3747
+ `Push workspace to github.com/${fullRepo} on branch "${branch}"?\n\n` +
3748
+ `This will overwrite the remote tip (--force-with-lease).\n` +
3749
+ `Token will be sent to the SoniCoder backend only (not stored).`
3750
+ )) {
3751
+ return;
3752
+ }
3753
+
3754
+ // ── Disable + show working state ─────────────────────────────────
3755
+ btn.disabled = true;
3756
+ btn.textContent = '⏳ Pushing...';
3757
+ statusEl.className = 'deploy-status working';
3758
+ statusEl.textContent = `Pushing to github.com/${fullRepo} on "${branch}"... (this may take 10-60 seconds)`;
3759
+ statusEl.style.display = 'block';
3760
+
3761
+ try {
3762
+ const resp = await fetch('/gradio_api/call/push_github', {
3763
+ method: 'POST',
3764
+ headers: { 'Content-Type': 'application/json' },
3765
+ body: JSON.stringify({
3766
+ data: [repoName, token, username, branch, commitMsg, '180']
3767
+ })
3768
+ });
3769
+
3770
+ if (!resp.ok) throw new Error(`API error: ${resp.status} ${resp.statusText}`);
3771
+
3772
+ const { event_id } = await resp.json();
3773
+ const eventSource = new EventSource(`/gradio_api/call/push_github/${event_id}`);
3774
+
3775
+ eventSource.addEventListener('complete', (e) => {
3776
+ try {
3777
+ const dataArray = JSON.parse(e.data);
3778
+ const result = JSON.parse(dataArray[0]);
3779
+
3780
+ if (result.success) {
3781
+ statusEl.className = 'deploy-status success';
3782
+ const sha = result.commit_sha ? ` (commit ${result.commit_sha.slice(0,8)})` : '';
3783
+ const commitLink = result.commit_url
3784
+ ? `<br><a href="${result.commit_url}" target="_blank" rel="noopener">view commit ${result.commit_sha ? result.commit_sha.slice(0,8) : ''} &nearr;</a>`
3785
+ : '';
3786
+ const repoLink = result.repo_url
3787
+ ? `<br><a href="${result.repo_url}" target="_blank" rel="noopener">${result.repo_url} &nearr;</a>`
3788
+ : '';
3789
+ statusEl.innerHTML =
3790
+ `βœ“ ${result.message}${sha}${commitLink}${repoLink}`;
3791
+ addSystemMessage(`πŸ“¦ Pushed ${result.files_pushed} file(s) to ${result.repo_full_name}.`);
3792
+ } else {
3793
+ statusEl.className = 'deploy-status error';
3794
+ statusEl.textContent = `βœ— ${result.message || result.error || 'Push failed.'}`;
3795
+ }
3796
+ } catch (err) {
3797
+ statusEl.className = 'deploy-status error';
3798
+ statusEl.textContent = `Parse error: ${err.message}`;
3799
+ }
3800
+ eventSource.close();
3801
+ btn.disabled = false;
3802
+ btn.textContent = 'πŸ“¦ Push to GitHub';
3803
+ });
3804
+
3805
+ eventSource.addEventListener('error', (e) => {
3806
+ statusEl.className = 'deploy-status error';
3807
+ statusEl.textContent = `Push failed: ${e.data || 'Network error. Check console.'}`;
3808
+ eventSource.close();
3809
+ btn.disabled = false;
3810
+ btn.textContent = 'πŸ“¦ Push to GitHub';
3811
+ });
3812
+
3813
+ } catch (err) {
3814
+ statusEl.className = 'deploy-status error';
3815
+ statusEl.textContent = `Push failed: ${err.message}`;
3816
+ btn.disabled = false;
3817
+ btn.textContent = 'πŸ“¦ Push to GitHub';
3818
+ }
3819
+ }
3820
  </script>
3821
  </body>
3822
  </html>