Spaces:
Running
feat: add GitHub project import + fix Agent tab scroll (shallow clone, heavy-dir stripping, /github slash command, UI box)
Browse filesAdds GitHub import + Agent tab scroll fix:
- New tool: code/tools/github.py β import_github_repo() shallow-clones any
public GitHub repo into the sandboxed workspace, strips .git/node_modules/
__pycache__/.venv/dist/build/etc., and supports branch + subdir + target_subdir.
- New API: import_github(url, branch, subdir, target_subdir, depth, timeout)
- New API: github_url_examples() β return accepted URL formats
- New slash command: /github <url> [subdir] [--branch X] [--into Y]
- New UI: 'Import Project from GitHub' box at the top of the Agent tab.
Paste a URL β click Import β workspace tree refreshes β agent mode auto-enables.
- CSS fix: #pane-agent and #pane-deploy now use display:block !important
so the single .deploy-section child can grow and trigger overflow scrolling.
- Updated README.md and CLAUDE.md with new feature docs and module tree.
- CLAUDE.md +45 -2
- README.md +37 -1
- code/commands/builtins/github.md +64 -0
- code/server/routes.py +73 -0
- code/tools/__init__.py +7 -0
- code/tools/github.py +396 -0
- index.html +161 -3
|
@@ -26,7 +26,8 @@ code/
|
|
| 26 |
βββ tools/
|
| 27 |
β βββ fs.py β read_file, write_file, edit_file, glob, grep, list_dir
|
| 28 |
β βββ bash.py β Sandboxed shell execution
|
| 29 |
-
β
|
|
|
|
| 30 |
βββ skills/
|
| 31 |
β βββ __init__.py β Skill discovery + loading
|
| 32 |
β βββ builtins/ β Built-in skills (markdown)
|
|
@@ -35,7 +36,7 @@ code/
|
|
| 35 |
β βββ builtins/ β Built-in agents (code-reviewer, test-writer)
|
| 36 |
βββ commands/
|
| 37 |
β βββ __init__.py β Slash command parser + expander
|
| 38 |
-
β βββ builtins/ β Built-in commands (markdown, includes /agent)
|
| 39 |
βββ hooks/
|
| 40 |
β βββ __init__.py β Hook rule engine
|
| 41 |
β βββ builtins/ β Built-in hook rules (markdown)
|
|
@@ -115,6 +116,7 @@ content: |
|
|
| 115 |
| `/agent show <name>` | Show an agent's full definition |
|
| 116 |
| `/agent delete <name>` | Delete a user-defined agent |
|
| 117 |
| `/agent reset` | Reset to default SoniCoder persona |
|
|
|
|
| 118 |
| `/help` | Show available commands and skills |
|
| 119 |
|
| 120 |
## Custom Agents
|
|
@@ -172,11 +174,52 @@ and any hard rules.
|
|
| 172 |
| `save_agent(...)` | Create or overwrite a user agent (manual save) |
|
| 173 |
| `delete_agent(name)` | Delete a user agent (built-ins protected) |
|
| 174 |
| `set_active_agent(name)` | Set/clear the active agent for subsequent prompts |
|
|
|
|
|
|
|
| 175 |
|
| 176 |
The `agent_run` endpoint also intercepts `/agent use|reset|delete|list` and
|
| 177 |
dispatches them directly to the agents module, bypassing the model entirely
|
| 178 |
for instant session-state updates.
|
| 179 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
## Skills
|
| 181 |
|
| 182 |
| Skill | Description |
|
|
|
|
| 26 |
βββ tools/
|
| 27 |
β βββ fs.py β read_file, write_file, edit_file, glob, grep, list_dir
|
| 28 |
β βββ bash.py β Sandboxed shell execution
|
| 29 |
+
β βββ todos.py β Todo list management
|
| 30 |
+
β βββ github.py β GitHub repo import (shallow clone + strip heavy dirs)
|
| 31 |
βββ skills/
|
| 32 |
β βββ __init__.py β Skill discovery + loading
|
| 33 |
β βββ builtins/ β Built-in skills (markdown)
|
|
|
|
| 36 |
β βββ builtins/ β Built-in agents (code-reviewer, test-writer)
|
| 37 |
βββ commands/
|
| 38 |
β βββ __init__.py β Slash command parser + expander
|
| 39 |
+
β βββ builtins/ β Built-in commands (markdown, includes /agent and /github)
|
| 40 |
βββ hooks/
|
| 41 |
β βββ __init__.py β Hook rule engine
|
| 42 |
β βββ builtins/ β Built-in hook rules (markdown)
|
|
|
|
| 116 |
| `/agent show <name>` | Show an agent's full definition |
|
| 117 |
| `/agent delete <name>` | Delete a user-defined agent |
|
| 118 |
| `/agent reset` | Reset to default SoniCoder persona |
|
| 119 |
+
| `/github <url> [subdir] [--branch <name>] [--into <path>]` | Import a GitHub repo into the workspace |
|
| 120 |
| `/help` | Show available commands and skills |
|
| 121 |
|
| 122 |
## Custom Agents
|
|
|
|
| 174 |
| `save_agent(...)` | Create or overwrite a user agent (manual save) |
|
| 175 |
| `delete_agent(name)` | Delete a user agent (built-ins protected) |
|
| 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
|
| 182 |
for instant session-state updates.
|
| 183 |
|
| 184 |
+
## GitHub Import
|
| 185 |
+
|
| 186 |
+
SoniCoder can clone any public GitHub repo into the workspace, allowing the
|
| 187 |
+
agent to read, edit, extend, or refactor real-world code.
|
| 188 |
+
|
| 189 |
+
### How it works
|
| 190 |
+
|
| 191 |
+
1. User submits a GitHub URL via the Agent tab UI box (or via `/github <url>`
|
| 192 |
+
slash command in chat while Agent mode is ON).
|
| 193 |
+
2. The backend (`code/tools/github.py::import_github_repo`) parses the URL
|
| 194 |
+
(supports HTTPS, SSH, and `/tree/<branch>/<subdir>` forms) and validates
|
| 195 |
+
that the host is `github.com`.
|
| 196 |
+
3. The repo is shallow-cloned (`git clone --depth 1 --single-branch`) into a
|
| 197 |
+
temp directory.
|
| 198 |
+
4. Files are *copied* into the workspace (root or `target_subdir`) with these
|
| 199 |
+
directories stripped: `.git`, `.hg`, `.svn`, `node_modules`, `__pycache__`,
|
| 200 |
+
`.venv`, `venv`, `env`, `.tox`, `.mypy_cache`, `.pytest_cache`,
|
| 201 |
+
`.ruff_cache`, `dist`, `build`, `.next`, `.nuxt`, `.cache`, `.gradle`,
|
| 202 |
+
`target`, `Pods`. `.DS_Store` and `Thumbs.db` are also dropped.
|
| 203 |
+
5. The workspace tree refreshes; Agent mode auto-enables if it wasn't already.
|
| 204 |
+
|
| 205 |
+
### Security
|
| 206 |
+
|
| 207 |
+
- Only `github.com` URLs are accepted (HTTPS or SSH form).
|
| 208 |
+
- `target_subdir` is sanitized β no path escapes.
|
| 209 |
+
- The upstream repo is never modified (clone happens in temp dir, then
|
| 210 |
+
copied). The `.git` directory is stripped so the agent doesn't walk it.
|
| 211 |
+
- Default clone timeout: 120s (UI uses 180s). Max: 600s.
|
| 212 |
+
|
| 213 |
+
### Slash command
|
| 214 |
+
|
| 215 |
+
```
|
| 216 |
+
/github <url> [subdir] [--branch <name>] [--into <path>] [--depth <N>] [--timeout <s>]
|
| 217 |
+
```
|
| 218 |
+
|
| 219 |
+
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 |
|
|
@@ -23,8 +23,9 @@ Inspired by [Claude Code](https://github.com/anthropics/claude-code), SoniCoder
|
|
| 23 |
|
| 24 |
- π€ **Agent Loop** β model calls tools (read/write/edit/glob/grep/bash/todos) in a feedback loop
|
| 25 |
- π― **Skills System** β load markdown skill files at runtime (frontend-design, feature-dev, code-review, debugging, fullstack-scaffold, commit-workflow)
|
| 26 |
-
- β‘ **Slash Commands** β `/commit`, `/review`, `/feature`, `/design`, `/explain`, `/test`, `/refactor`, `/skill`, `/agent`, `/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 |
- πͺ **Hooks System** β pre/post tool execution rules (block dangerous commands, warn on debug code/secrets)
|
| 29 |
- π **Sandboxed Workspace** β agent manipulates files in `./workspace/` (path-escape protected)
|
| 30 |
- β
**Todo Lists** β track multi-step tasks Claude Code-style
|
|
@@ -93,8 +94,43 @@ The agent can call these tools (Claude Code-style):
|
|
| 93 |
| `/agent show <name>` | Display an agent's full definition |
|
| 94 |
| `/agent delete <name>` | Delete a user-defined agent |
|
| 95 |
| `/agent reset` | Reset to default SoniCoder persona |
|
|
|
|
| 96 |
| `/help` | Show available commands and skills |
|
| 97 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
### Custom Agents
|
| 99 |
|
| 100 |
Custom agents are AI-generated personas that layer on top of the base SoniCoder system prompt. Each agent defines:
|
|
|
|
| 23 |
|
| 24 |
- π€ **Agent Loop** β model calls tools (read/write/edit/glob/grep/bash/todos) in a feedback loop
|
| 25 |
- π― **Skills System** β load markdown skill files at runtime (frontend-design, feature-dev, code-review, debugging, fullstack-scaffold, commit-workflow)
|
| 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
|
|
|
|
| 94 |
| `/agent show <name>` | Display an agent's full definition |
|
| 95 |
| `/agent delete <name>` | Delete a user-defined agent |
|
| 96 |
| `/agent reset` | Reset to default SoniCoder persona |
|
| 97 |
+
| `/github <url> [subdir] [--branch <name>] [--into <path>]` | **Import a GitHub repo into the workspace** (shallow clone, heavy dirs stripped) |
|
| 98 |
| `/help` | Show available commands and skills |
|
| 99 |
|
| 100 |
+
### GitHub Import
|
| 101 |
+
|
| 102 |
+
SoniCoder can clone any public GitHub repository into the sandboxed workspace so the agent can read, edit, extend, or refactor it. Imported repos are shallow-cloned (depth 1) and stripped of heavy directories (`.git`, `node_modules`, `__pycache__`, `.venv`, `dist`, `build`, etc.) to keep the workspace lean.
|
| 103 |
+
|
| 104 |
+
**Accepted URL formats:**
|
| 105 |
+
|
| 106 |
+
- `https://github.com/<owner>/<repo>`
|
| 107 |
+
- `https://github.com/<owner>/<repo>.git`
|
| 108 |
+
- `https://github.com/<owner>/<repo>/tree/<branch>` β checkout a specific branch
|
| 109 |
+
- `https://github.com/<owner>/<repo>/tree/<branch>/<subdir>` β import only a sub-directory
|
| 110 |
+
- `git@github.com:<owner>/<repo>.git` β SSH form (rewritten to HTTPS internally)
|
| 111 |
+
|
| 112 |
+
**Two ways to import:**
|
| 113 |
+
|
| 114 |
+
1. **Via the Agent tab UI**: Open the **Agent** tab, paste the GitHub URL into the "Import Project from GitHub" box at the top, optionally specify `branch`, `subdir`, or `into path`, then click **β¬ Import**. The workspace tree and todo list refresh automatically, and agent mode is enabled if it wasn't already.
|
| 115 |
+
2. **Via the `/github` slash command** (Agent mode): Type `/github https://github.com/owner/repo` in chat. The agent runs the import, lists the top-level files, and suggests next steps. Flags: `--branch <name>`, `--into <path>`, `--depth <N>`.
|
| 116 |
+
|
| 117 |
+
**Examples:**
|
| 118 |
+
|
| 119 |
+
```
|
| 120 |
+
/github https://github.com/fastapi/fastapi
|
| 121 |
+
/github https://github.com/vercel/next.js examples/with-typescript --into next-ts-demo
|
| 122 |
+
/github https://github.com/pallets/flask --branch 2.3.x
|
| 123 |
+
```
|
| 124 |
+
|
| 125 |
+
**API endpoints:**
|
| 126 |
+
|
| 127 |
+
| Endpoint | Description |
|
| 128 |
+
|----------|-------------|
|
| 129 |
+
| `import_github(url, branch, subdir, target_subdir, depth, timeout)` | Clone a GitHub repo into the workspace |
|
| 130 |
+
| `github_url_examples()` | Return accepted URL formats and notes |
|
| 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:
|
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
name: github
|
| 3 |
+
description: Import a GitHub repository into the workspace so the agent can edit it
|
| 4 |
+
argument-hint: <github-url> [subdir] [--branch <name>] [--into <path>]
|
| 5 |
+
allowed-tools: read_file, write_file, edit_file, multi_edit, list_dir, glob, grep, bash, todo_read, todo_write, todo_update
|
| 6 |
+
---
|
| 7 |
+
# GitHub Import Command
|
| 8 |
+
|
| 9 |
+
The user invoked `/github` with arguments: `$ARGUMENTS`
|
| 10 |
+
|
| 11 |
+
## Parse the arguments
|
| 12 |
+
|
| 13 |
+
The argument string after `/github ` is `$ARGUMENTS`. Parse it as:
|
| 14 |
+
|
| 15 |
+
* A required **GitHub URL** (first token starting with `https://github.com/` or `git@github.com:`).
|
| 16 |
+
* An optional **subdir** β the second positional token if it doesn't start with `--`.
|
| 17 |
+
* Optional flags:
|
| 18 |
+
* `--branch <name>` β checkout this branch/tag instead of the URL's default.
|
| 19 |
+
* `--into <path>` β place the import under `<path>/` inside the workspace instead of the root.
|
| 20 |
+
* `--depth <N>` β git clone depth (default 1).
|
| 21 |
+
* `--timeout <s>` β clone timeout in seconds (default 120).
|
| 22 |
+
|
| 23 |
+
## Execute the import
|
| 24 |
+
|
| 25 |
+
Use the `bash` tool to call the Python helper directly:
|
| 26 |
+
|
| 27 |
+
```bash
|
| 28 |
+
python -c "from code.tools.github import import_github_repo; import json; print(json.dumps(import_github_repo(url='<URL>', branch='<BRANCH>', subdir='<SUBDIR>', target_subdir='<INTO>', depth=<DEPTH>, timeout=<TIMEOUT>), default=str))"
|
| 29 |
+
```
|
| 30 |
+
|
| 31 |
+
(Quote arguments correctly; if any are empty, pass the empty string.)
|
| 32 |
+
|
| 33 |
+
If the bash tool is unavailable, use `write_file` to drop a small script and then run it with `bash`.
|
| 34 |
+
|
| 35 |
+
## After the import
|
| 36 |
+
|
| 37 |
+
1. Read the JSON output of the command above.
|
| 38 |
+
2. If `success` is `true`:
|
| 39 |
+
* Use `list_dir` on the workspace root (or the `--into` path) to confirm what landed.
|
| 40 |
+
* Read the top-level files you expect to find: `README.md`, `package.json`, `requirements.txt`, `pyproject.toml`, `app.py`, `index.html` β whichever exist.
|
| 41 |
+
* Summarize for the user:
|
| 42 |
+
- Repo imported: `owner/repo` (branch: X)
|
| 43 |
+
- Files imported: N
|
| 44 |
+
- Heavy dirs stripped: M
|
| 45 |
+
- Workspace location: `path/`
|
| 46 |
+
- A 5-10 line preview of what's in the workspace now.
|
| 47 |
+
* Suggest next steps based on the project type (e.g. "Want me to add tests? Modernize the build? Add a Dockerfile?").
|
| 48 |
+
3. If `success` is `false`:
|
| 49 |
+
* Surface the `message` to the user verbatim.
|
| 50 |
+
* If the error mentions `subdir_not_found`, list the top-level directories of the repo (you can re-run the import with `--depth 1` and no subdir to discover what's there, or use `git ls-tree` via bash).
|
| 51 |
+
* If the error mentions `git binary not found`, tell the user git must be installed in the SoniCoder runtime.
|
| 52 |
+
|
| 53 |
+
## Examples the user might type
|
| 54 |
+
|
| 55 |
+
* `/github https://github.com/fastapi/fastapi`
|
| 56 |
+
* `/github https://github.com/vercel/next.js examples/with-typescript --into next-ts-demo`
|
| 57 |
+
* `/github https://github.com/pallets/flask --branch 2.3.x`
|
| 58 |
+
* `/github git@github.com:owner/repo.git`
|
| 59 |
+
|
| 60 |
+
## Rules
|
| 61 |
+
|
| 62 |
+
* NEVER push to or modify the upstream repo. The clone is local-only and stripped of `.git`.
|
| 63 |
+
* NEVER include secrets, API keys, or `.env` files in your summary β if you spot any, warn the user.
|
| 64 |
+
* If the workspace already has files, warn the user that the import will overwrite the destination directory before running.
|
|
@@ -21,6 +21,8 @@ Defines all HTTP and API endpoints:
|
|
| 21 |
- API workspace_bashβ run a bash command in workspace
|
| 22 |
- API todo_read β read current todo list
|
| 23 |
- API todo_write β update todo list
|
|
|
|
|
|
|
| 24 |
"""
|
| 25 |
|
| 26 |
from __future__ import annotations
|
|
@@ -1112,3 +1114,74 @@ def handle_set_active_agent(name: str = "") -> str:
|
|
| 1112 |
"agents": list_agents(),
|
| 1113 |
"active_agent": get_active_agent(),
|
| 1114 |
}, default=str)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
- API workspace_bashβ run a bash command in workspace
|
| 22 |
- API todo_read β read current todo list
|
| 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
|
|
|
|
| 1114 |
"agents": list_agents(),
|
| 1115 |
"active_agent": get_active_agent(),
|
| 1116 |
}, default=str)
|
| 1117 |
+
|
| 1118 |
+
|
| 1119 |
+
# βββ GitHub Import Endpoint ββββββββββββββββββββββββββββββββββββββββββββ
|
| 1120 |
+
|
| 1121 |
+
|
| 1122 |
+
@app.api(name="import_github", concurrency_limit=1)
|
| 1123 |
+
def handle_import_github(
|
| 1124 |
+
url: str,
|
| 1125 |
+
branch: str = "",
|
| 1126 |
+
subdir: str = "",
|
| 1127 |
+
target_subdir: str = "",
|
| 1128 |
+
depth: str = "1",
|
| 1129 |
+
timeout: str = "120",
|
| 1130 |
+
) -> str:
|
| 1131 |
+
"""Clone a GitHub repo into the sandboxed workspace.
|
| 1132 |
+
|
| 1133 |
+
Parameters
|
| 1134 |
+
----------
|
| 1135 |
+
url : str
|
| 1136 |
+
GitHub URL. Accepts:
|
| 1137 |
+
- https://github.com/<owner>/<repo>[.git]
|
| 1138 |
+
- https://github.com/<owner>/<repo>/tree/<branch>[/<subdir>]
|
| 1139 |
+
- git@github.com:<owner>/<repo>.git
|
| 1140 |
+
branch : str
|
| 1141 |
+
Optional branch/tag override. If empty, uses URL's branch or the
|
| 1142 |
+
repo's default branch.
|
| 1143 |
+
subdir : str
|
| 1144 |
+
Optional sub-directory inside the repo to import.
|
| 1145 |
+
target_subdir : str
|
| 1146 |
+
Where inside the workspace to place the import. Empty = root.
|
| 1147 |
+
depth : str
|
| 1148 |
+
Git clone depth (default "1" for shallow clone).
|
| 1149 |
+
timeout : str
|
| 1150 |
+
Git clone timeout in seconds (default "120").
|
| 1151 |
+
|
| 1152 |
+
Yields
|
| 1153 |
+
------
|
| 1154 |
+
JSON dict with keys: success, message, url, owner, repo, branch,
|
| 1155 |
+
subdir, files_imported, dirs_skipped, workspace_path, tree_preview.
|
| 1156 |
+
"""
|
| 1157 |
+
from code.tools.github import import_github_repo
|
| 1158 |
+
|
| 1159 |
+
try:
|
| 1160 |
+
depth_int = int(depth) if str(depth).strip() else 1
|
| 1161 |
+
depth_int = max(1, min(50, depth_int))
|
| 1162 |
+
except (ValueError, TypeError):
|
| 1163 |
+
depth_int = 1
|
| 1164 |
+
|
| 1165 |
+
try:
|
| 1166 |
+
timeout_int = int(timeout) if str(timeout).strip() else 120
|
| 1167 |
+
timeout_int = max(10, min(600, timeout_int))
|
| 1168 |
+
except (ValueError, TypeError):
|
| 1169 |
+
timeout_int = 120
|
| 1170 |
+
|
| 1171 |
+
result = import_github_repo(
|
| 1172 |
+
url=url,
|
| 1173 |
+
branch=branch,
|
| 1174 |
+
subdir=subdir,
|
| 1175 |
+
target_subdir=target_subdir,
|
| 1176 |
+
depth=depth_int,
|
| 1177 |
+
timeout=timeout_int,
|
| 1178 |
+
)
|
| 1179 |
+
yield json.dumps(result, default=str)
|
| 1180 |
+
|
| 1181 |
+
|
| 1182 |
+
@app.api(name="github_url_examples", concurrency_limit=4)
|
| 1183 |
+
def handle_github_url_examples() -> str:
|
| 1184 |
+
"""Return example GitHub URL formats accepted by import_github."""
|
| 1185 |
+
from code.tools.github import list_github_url_examples
|
| 1186 |
+
result = list_github_url_examples()
|
| 1187 |
+
yield json.dumps(result, default=str)
|
|
@@ -6,6 +6,7 @@ Inspired by Claude Code's tool set:
|
|
| 6 |
- bash (subprocess)
|
| 7 |
- list_dir
|
| 8 |
- todo_write / todo_read
|
|
|
|
| 9 |
"""
|
| 10 |
from code.tools.fs import (
|
| 11 |
read_file,
|
|
@@ -22,6 +23,10 @@ from code.tools.todos import (
|
|
| 22 |
todo_write,
|
| 23 |
todo_update,
|
| 24 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
__all__ = [
|
| 27 |
"read_file",
|
|
@@ -35,4 +40,6 @@ __all__ = [
|
|
| 35 |
"todo_read",
|
| 36 |
"todo_write",
|
| 37 |
"todo_update",
|
|
|
|
|
|
|
| 38 |
]
|
|
|
|
| 6 |
- bash (subprocess)
|
| 7 |
- list_dir
|
| 8 |
- todo_write / todo_read
|
| 9 |
+
- import_github_repo (clone a GitHub repo into the workspace)
|
| 10 |
"""
|
| 11 |
from code.tools.fs import (
|
| 12 |
read_file,
|
|
|
|
| 23 |
todo_write,
|
| 24 |
todo_update,
|
| 25 |
)
|
| 26 |
+
from code.tools.github import (
|
| 27 |
+
import_github_repo,
|
| 28 |
+
list_github_url_examples,
|
| 29 |
+
)
|
| 30 |
|
| 31 |
__all__ = [
|
| 32 |
"read_file",
|
|
|
|
| 40 |
"todo_read",
|
| 41 |
"todo_write",
|
| 42 |
"todo_update",
|
| 43 |
+
"import_github_repo",
|
| 44 |
+
"list_github_url_examples",
|
| 45 |
]
|
|
@@ -0,0 +1,396 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""GitHub import tool β clone a repo into the sandboxed workspace.
|
| 2 |
+
|
| 3 |
+
This module lets the user (or the agent, via the `/github` slash command)
|
| 4 |
+
import an existing GitHub repository into the SoniCoder workspace so the
|
| 5 |
+
agent can read, edit, and build on top of real code instead of starting
|
| 6 |
+
from scratch.
|
| 7 |
+
|
| 8 |
+
Security model
|
| 9 |
+
--------------
|
| 10 |
+
* Only `https://github.com/...` URLs are accepted (no SSH, no file://, no
|
| 11 |
+
other hosts). This prevents the clone URL from being used to exfiltrate
|
| 12 |
+
data via a malicious git config.
|
| 13 |
+
* The repo is cloned into a temp directory first, then *copied* into the
|
| 14 |
+
workspace root with `.git/`, `node_modules/`, `__pycache__/`, and other
|
| 15 |
+
heavy / unnecessary directories stripped. This:
|
| 16 |
+
- avoids polluting the workspace with a `.git` subdir the agent would
|
| 17 |
+
otherwise try to walk,
|
| 18 |
+
- caps the size of the import (clone-depth 1 + ignored dirs),
|
| 19 |
+
- sidesteps git's "destination exists" failure mode.
|
| 20 |
+
* Optional `subdir` argument lets the user import only a sub-directory of
|
| 21 |
+
the repo (e.g. `examples/quickstart`). This is useful for monorepos.
|
| 22 |
+
|
| 23 |
+
The function returns a JSON-serializable dict that the frontend / agent
|
| 24 |
+
loop can consume directly.
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
from __future__ import annotations
|
| 28 |
+
|
| 29 |
+
import os
|
| 30 |
+
import re
|
| 31 |
+
import shutil
|
| 32 |
+
import subprocess
|
| 33 |
+
import tempfile
|
| 34 |
+
from pathlib import Path
|
| 35 |
+
from typing import Any
|
| 36 |
+
|
| 37 |
+
from code.tools.fs import get_workspace_root
|
| 38 |
+
|
| 39 |
+
# βββ URL validation ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 40 |
+
|
| 41 |
+
# Accept:
|
| 42 |
+
# https://github.com/owner/repo
|
| 43 |
+
# https://github.com/owner/repo.git
|
| 44 |
+
# https://github.com/owner/repo/tree/main
|
| 45 |
+
# https://github.com/owner/repo/tree/main/subdir
|
| 46 |
+
# https://github.com/owner/repo/branches/main/subdir (older form)
|
| 47 |
+
# git@github.com:owner/repo.git β rewritten to https
|
| 48 |
+
_GITHUB_HTTPS = re.compile(
|
| 49 |
+
r"^https://github\.com/(?P<owner>[^/\s]+)/(?P<repo>[^/\s]+?)(?:\.git)?(?:/(?:tree|blob|branches)/([^/\s]+)(?:/(.*))?)?/?$",
|
| 50 |
+
re.IGNORECASE,
|
| 51 |
+
)
|
| 52 |
+
_GITHUB_SSH = re.compile(
|
| 53 |
+
r"^git@github\.com:(?P<owner>[^/\s]+)/(?P<repo>[^/\s]+?)(?:\.git)?/?$",
|
| 54 |
+
re.IGNORECASE,
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
# Directories that are usually huge / not useful in the agent workspace
|
| 58 |
+
_STRIP_DIRS = {
|
| 59 |
+
".git",
|
| 60 |
+
".hg",
|
| 61 |
+
".svn",
|
| 62 |
+
"node_modules",
|
| 63 |
+
"__pycache__",
|
| 64 |
+
".venv",
|
| 65 |
+
"venv",
|
| 66 |
+
"env",
|
| 67 |
+
".tox",
|
| 68 |
+
".mypy_cache",
|
| 69 |
+
".pytest_cache",
|
| 70 |
+
".ruff_cache",
|
| 71 |
+
"dist",
|
| 72 |
+
"build",
|
| 73 |
+
".next",
|
| 74 |
+
".nuxt",
|
| 75 |
+
".cache",
|
| 76 |
+
".gradle",
|
| 77 |
+
"target",
|
| 78 |
+
"Pods",
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
# Files we never want to land in the workspace
|
| 82 |
+
_STRIP_FILES = {
|
| 83 |
+
".DS_Store",
|
| 84 |
+
"Thumbs.db",
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def _parse_github_url(url: str) -> dict[str, str | None]:
|
| 89 |
+
"""Parse a GitHub URL into owner, repo, branch, subdir.
|
| 90 |
+
|
| 91 |
+
Returns a dict with keys: owner, repo, branch, subdir, clone_url.
|
| 92 |
+
Raises ValueError if the URL is not a valid GitHub URL.
|
| 93 |
+
"""
|
| 94 |
+
url = (url or "").strip()
|
| 95 |
+
if not url:
|
| 96 |
+
raise ValueError("Empty GitHub URL")
|
| 97 |
+
|
| 98 |
+
m = _GITHUB_HTTPS.match(url)
|
| 99 |
+
if m:
|
| 100 |
+
owner = m.group("owner")
|
| 101 |
+
repo = m.group("repo")
|
| 102 |
+
branch = m.group(3)
|
| 103 |
+
subdir = m.group(4) or ""
|
| 104 |
+
clone_url = f"https://github.com/{owner}/{repo}.git"
|
| 105 |
+
return {
|
| 106 |
+
"owner": owner,
|
| 107 |
+
"repo": repo,
|
| 108 |
+
"branch": branch,
|
| 109 |
+
"subdir": subdir.strip("/"),
|
| 110 |
+
"clone_url": clone_url,
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
m = _GITHUB_SSH.match(url)
|
| 114 |
+
if m:
|
| 115 |
+
owner = m.group("owner")
|
| 116 |
+
repo = m.group("repo")
|
| 117 |
+
clone_url = f"https://github.com/{owner}/{repo}.git"
|
| 118 |
+
return {
|
| 119 |
+
"owner": owner,
|
| 120 |
+
"repo": repo,
|
| 121 |
+
"branch": None,
|
| 122 |
+
"subdir": "",
|
| 123 |
+
"clone_url": clone_url,
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
raise ValueError(
|
| 127 |
+
f"Invalid GitHub URL: {url!r}. "
|
| 128 |
+
"Expected https://github.com/<owner>/<repo> or git@github.com:<owner>/<repo>.git"
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def _git_available() -> bool:
|
| 133 |
+
"""Check whether the `git` binary is on PATH."""
|
| 134 |
+
try:
|
| 135 |
+
result = subprocess.run(
|
| 136 |
+
["git", "--version"],
|
| 137 |
+
capture_output=True,
|
| 138 |
+
timeout=5,
|
| 139 |
+
)
|
| 140 |
+
return result.returncode == 0
|
| 141 |
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
| 142 |
+
return False
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def _safe_relpath(root: str, full: str) -> str:
|
| 146 |
+
"""Return path relative to root, or empty if equal to root."""
|
| 147 |
+
return os.path.relpath(full, root)
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def _copy_filtered(src: str, dst: str) -> tuple[int, int]:
|
| 151 |
+
"""Copy `src` (a directory) into `dst`, skipping _STRIP_DIRS/_STRIP_FILES.
|
| 152 |
+
|
| 153 |
+
Returns (files_copied, dirs_skipped).
|
| 154 |
+
"""
|
| 155 |
+
files_copied = 0
|
| 156 |
+
dirs_skipped = 0
|
| 157 |
+
|
| 158 |
+
for dirpath, dirnames, fnames in os.walk(src, topdown=True):
|
| 159 |
+
# In-place mutate dirnames so os.walk skips them
|
| 160 |
+
keep_dirs = []
|
| 161 |
+
for d in dirnames:
|
| 162 |
+
if d in _STRIP_DIRS:
|
| 163 |
+
dirs_skipped += 1
|
| 164 |
+
else:
|
| 165 |
+
keep_dirs.append(d)
|
| 166 |
+
dirnames[:] = keep_dirs
|
| 167 |
+
|
| 168 |
+
rel = _safe_relpath(src, dirpath)
|
| 169 |
+
target_dir = dst if rel == "." else os.path.join(dst, rel)
|
| 170 |
+
os.makedirs(target_dir, exist_ok=True)
|
| 171 |
+
|
| 172 |
+
for fname in fnames:
|
| 173 |
+
if fname in _STRIP_FILES:
|
| 174 |
+
continue
|
| 175 |
+
try:
|
| 176 |
+
shutil.copy2(os.path.join(dirpath, fname), os.path.join(target_dir, fname))
|
| 177 |
+
files_copied += 1
|
| 178 |
+
except (OSError, shutil.SpecialFileError):
|
| 179 |
+
# Skip files we can't copy (sockets, fifos, etc.)
|
| 180 |
+
continue
|
| 181 |
+
|
| 182 |
+
return files_copied, dirs_skipped
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
# βββ Public API ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def import_github_repo(
|
| 189 |
+
url: str,
|
| 190 |
+
branch: str = "",
|
| 191 |
+
subdir: str = "",
|
| 192 |
+
target_subdir: str = "",
|
| 193 |
+
depth: int = 1,
|
| 194 |
+
timeout: int = 120,
|
| 195 |
+
) -> dict[str, Any]:
|
| 196 |
+
"""Clone a GitHub repo into the workspace.
|
| 197 |
+
|
| 198 |
+
Args:
|
| 199 |
+
url: GitHub URL (https or SSH form). May include /tree/<branch>/<subdir>.
|
| 200 |
+
branch: Optional branch/tag to checkout. If empty, uses the URL's
|
| 201 |
+
embedded branch (if any) or the repo's default branch.
|
| 202 |
+
subdir: Optional sub-directory inside the repo to import. If the URL
|
| 203 |
+
already includes /tree/<branch>/<subdir>, this is added on
|
| 204 |
+
top (rare).
|
| 205 |
+
target_subdir: Where inside the workspace to place the import. If
|
| 206 |
+
empty, places at workspace root. If non-empty and the
|
| 207 |
+
directory exists, it will be overwritten.
|
| 208 |
+
depth: Git clone depth. Default 1 (shallow).
|
| 209 |
+
timeout: Git clone timeout in seconds.
|
| 210 |
+
|
| 211 |
+
Returns:
|
| 212 |
+
dict with keys:
|
| 213 |
+
success: bool
|
| 214 |
+
message: str
|
| 215 |
+
url: original URL
|
| 216 |
+
owner, repo, branch, subdir: parsed
|
| 217 |
+
files_imported: int
|
| 218 |
+
dirs_skipped: int
|
| 219 |
+
workspace_path: relative path inside the workspace where files landed
|
| 220 |
+
tree: dict (workspace tree summary, optional)
|
| 221 |
+
error: str (on failure)
|
| 222 |
+
"""
|
| 223 |
+
try:
|
| 224 |
+
parsed = _parse_github_url(url)
|
| 225 |
+
except ValueError as exc:
|
| 226 |
+
return {
|
| 227 |
+
"success": False,
|
| 228 |
+
"message": str(exc),
|
| 229 |
+
"url": url,
|
| 230 |
+
"error": str(exc),
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
# Resolve effective branch / subdir
|
| 234 |
+
effective_branch = branch.strip() or parsed.get("branch") or ""
|
| 235 |
+
effective_subdir = subdir.strip() or parsed.get("subdir") or ""
|
| 236 |
+
|
| 237 |
+
if not _git_available():
|
| 238 |
+
return {
|
| 239 |
+
"success": False,
|
| 240 |
+
"message": "`git` is not installed in this environment. Cannot clone.",
|
| 241 |
+
"url": url,
|
| 242 |
+
"error": "git binary not found",
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
workspace_root = get_workspace_root()
|
| 246 |
+
os.makedirs(workspace_root, exist_ok=True)
|
| 247 |
+
|
| 248 |
+
# Decide destination inside workspace
|
| 249 |
+
if target_subdir.strip():
|
| 250 |
+
# Sanitize target_subdir β no path escapes
|
| 251 |
+
target_rel = target_subdir.strip().strip("/").lstrip(".")
|
| 252 |
+
if not target_rel or target_rel.startswith("/"):
|
| 253 |
+
return {
|
| 254 |
+
"success": False,
|
| 255 |
+
"message": f"Invalid target_subdir: {target_subdir!r}",
|
| 256 |
+
"url": url,
|
| 257 |
+
"error": "invalid target_subdir",
|
| 258 |
+
}
|
| 259 |
+
dest_root = os.path.join(workspace_root, target_rel)
|
| 260 |
+
workspace_rel = target_rel
|
| 261 |
+
else:
|
| 262 |
+
dest_root = workspace_root
|
| 263 |
+
workspace_rel = ""
|
| 264 |
+
|
| 265 |
+
# If dest_root already has content, clear it first so the import is clean.
|
| 266 |
+
# (We never touch anything outside dest_root.)
|
| 267 |
+
if os.path.isdir(dest_root):
|
| 268 |
+
for entry in os.listdir(dest_root):
|
| 269 |
+
full = os.path.join(dest_root, entry)
|
| 270 |
+
try:
|
| 271 |
+
if os.path.isdir(full):
|
| 272 |
+
shutil.rmtree(full)
|
| 273 |
+
else:
|
| 274 |
+
os.remove(full)
|
| 275 |
+
except OSError as exc:
|
| 276 |
+
# Don't fail the whole import over a single file
|
| 277 |
+
pass
|
| 278 |
+
|
| 279 |
+
# Clone into a temp dir
|
| 280 |
+
with tempfile.TemporaryDirectory(prefix="sonicoder_gh_") as tmp:
|
| 281 |
+
clone_target = os.path.join(tmp, parsed["repo"])
|
| 282 |
+
cmd = [
|
| 283 |
+
"git",
|
| 284 |
+
"clone",
|
| 285 |
+
"--depth",
|
| 286 |
+
str(int(depth)),
|
| 287 |
+
"--single-branch",
|
| 288 |
+
]
|
| 289 |
+
if effective_branch:
|
| 290 |
+
cmd.extend(["--branch", effective_branch])
|
| 291 |
+
cmd.extend([parsed["clone_url"], clone_target])
|
| 292 |
+
|
| 293 |
+
try:
|
| 294 |
+
proc = subprocess.run(
|
| 295 |
+
cmd,
|
| 296 |
+
capture_output=True,
|
| 297 |
+
timeout=timeout,
|
| 298 |
+
text=True,
|
| 299 |
+
)
|
| 300 |
+
except subprocess.TimeoutExpired:
|
| 301 |
+
return {
|
| 302 |
+
"success": False,
|
| 303 |
+
"message": f"Git clone timed out after {timeout}s",
|
| 304 |
+
"url": url,
|
| 305 |
+
"error": "clone_timeout",
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
if proc.returncode != 0:
|
| 309 |
+
stderr = (proc.stderr or "").strip()
|
| 310 |
+
return {
|
| 311 |
+
"success": False,
|
| 312 |
+
"message": f"git clone failed: {stderr[:500]}",
|
| 313 |
+
"url": url,
|
| 314 |
+
"error": stderr[:500],
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
if not os.path.isdir(clone_target):
|
| 318 |
+
return {
|
| 319 |
+
"success": False,
|
| 320 |
+
"message": "git clone appeared to succeed but the target directory is missing",
|
| 321 |
+
"url": url,
|
| 322 |
+
"error": "clone_dir_missing",
|
| 323 |
+
}
|
| 324 |
+
|
| 325 |
+
# If subdir is requested, validate it
|
| 326 |
+
src_root = clone_target
|
| 327 |
+
if effective_subdir:
|
| 328 |
+
candidate = os.path.join(clone_target, effective_subdir)
|
| 329 |
+
if not os.path.isdir(candidate):
|
| 330 |
+
return {
|
| 331 |
+
"success": False,
|
| 332 |
+
"message": (
|
| 333 |
+
f"Subdirectory '{effective_subdir}' not found in repo "
|
| 334 |
+
f"{parsed['owner']}/{parsed['repo']}"
|
| 335 |
+
),
|
| 336 |
+
"url": url,
|
| 337 |
+
"error": "subdir_not_found",
|
| 338 |
+
}
|
| 339 |
+
src_root = candidate
|
| 340 |
+
|
| 341 |
+
# Copy filtered files into dest_root
|
| 342 |
+
files_imported, dirs_skipped = _copy_filtered(src_root, dest_root)
|
| 343 |
+
|
| 344 |
+
# Build a brief tree summary (top 2 levels) for the response
|
| 345 |
+
tree_summary: list[str] = []
|
| 346 |
+
try:
|
| 347 |
+
for name in sorted(os.listdir(dest_root))[:30]:
|
| 348 |
+
full = os.path.join(dest_root, name)
|
| 349 |
+
tree_summary.append(f"{name}/" if os.path.isdir(full) else name)
|
| 350 |
+
except OSError:
|
| 351 |
+
pass
|
| 352 |
+
|
| 353 |
+
msg = (
|
| 354 |
+
f"Imported {files_imported} file(s) from {parsed['owner']}/{parsed['repo']}"
|
| 355 |
+
+ (f" (branch: {effective_branch})" if effective_branch else "")
|
| 356 |
+
+ (f" subdir: {effective_subdir}" if effective_subdir else "")
|
| 357 |
+
+ (f" into {workspace_rel}/" if workspace_rel else " into workspace root")
|
| 358 |
+
+ f". Skipped {dirs_skipped} heavy dir(s) (.git, node_modules, etc.)."
|
| 359 |
+
)
|
| 360 |
+
|
| 361 |
+
return {
|
| 362 |
+
"success": True,
|
| 363 |
+
"message": msg,
|
| 364 |
+
"url": url,
|
| 365 |
+
"owner": parsed["owner"],
|
| 366 |
+
"repo": parsed["repo"],
|
| 367 |
+
"branch": effective_branch or None,
|
| 368 |
+
"subdir": effective_subdir or None,
|
| 369 |
+
"files_imported": files_imported,
|
| 370 |
+
"dirs_skipped": dirs_skipped,
|
| 371 |
+
"workspace_path": workspace_rel or ".",
|
| 372 |
+
"tree_preview": tree_summary,
|
| 373 |
+
}
|
| 374 |
+
|
| 375 |
+
|
| 376 |
+
def list_github_url_examples() -> dict[str, Any]:
|
| 377 |
+
"""Return example GitHub URL formats accepted by import_github_repo.
|
| 378 |
+
|
| 379 |
+
Useful for surfacing help in the UI / agent.
|
| 380 |
+
"""
|
| 381 |
+
return {
|
| 382 |
+
"success": True,
|
| 383 |
+
"examples": [
|
| 384 |
+
"https://github.com/owner/repo",
|
| 385 |
+
"https://github.com/owner/repo.git",
|
| 386 |
+
"https://github.com/owner/repo/tree/main",
|
| 387 |
+
"https://github.com/owner/repo/tree/main/examples/quickstart",
|
| 388 |
+
"git@github.com:owner/repo.git",
|
| 389 |
+
],
|
| 390 |
+
"notes": [
|
| 391 |
+
"Only github.com URLs are accepted (HTTPS or SSH form).",
|
| 392 |
+
"Shallow clone (depth=1) is used by default to keep imports fast.",
|
| 393 |
+
"Directories like .git, node_modules, __pycache__, .venv, dist, build are stripped.",
|
| 394 |
+
"If a /tree/<branch>/<subdir> path is included, only that subdir is imported.",
|
| 395 |
+
],
|
| 396 |
+
}
|
|
@@ -672,8 +672,18 @@ body.hide-thinking .think-block { display: none; }
|
|
| 672 |
.tab-pane { display: none; height: 100%; min-height: 0; }
|
| 673 |
.tab-pane.active { display: flex; flex-direction: column; }
|
| 674 |
|
| 675 |
-
/* Agent tab β scrollable like the Deploy tab
|
| 676 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 677 |
|
| 678 |
/* Preview tab */
|
| 679 |
#pane-preview {
|
|
@@ -860,7 +870,14 @@ body.hide-thinking .think-block { display: none; }
|
|
| 860 |
/* βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 861 |
DEPLOY TAB
|
| 862 |
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 863 |
-
#pane-deploy {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 864 |
|
| 865 |
.deploy-section {
|
| 866 |
border: 1px solid var(--border);
|
|
@@ -1224,6 +1241,28 @@ body.hide-thinking .think-block { display: none; }
|
|
| 1224 |
<div class="deploy-section">
|
| 1225 |
<div class="deploy-title">🤖 Agent Mode (Claude Code-style)</div>
|
| 1226 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1227 |
<!-- Agent mode toggle -->
|
| 1228 |
<div class="deploy-field">
|
| 1229 |
<label>Agent Mode</label>
|
|
@@ -2623,6 +2662,123 @@ function createAgentViaAI() {
|
|
| 2623 |
document.getElementById('agent-create-input').value = '';
|
| 2624 |
}
|
| 2625 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2626 |
async function refreshTodos() {
|
| 2627 |
try {
|
| 2628 |
const es = await callAgentApi('todo_read', ['default']);
|
|
@@ -2953,6 +3109,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
| 2953 |
refreshAgents();
|
| 2954 |
renderAgentsList();
|
| 2955 |
renderActiveAgentBadge();
|
|
|
|
|
|
|
| 2956 |
}, 100);
|
| 2957 |
});
|
| 2958 |
|
|
|
|
| 672 |
.tab-pane { display: none; height: 100%; min-height: 0; }
|
| 673 |
.tab-pane.active { display: flex; flex-direction: column; }
|
| 674 |
|
| 675 |
+
/* Agent tab β scrollable like the Deploy tab.
|
| 676 |
+
Override .tab-pane.active's `display:flex` so the single .deploy-section
|
| 677 |
+
child does not get squished by flex shrink, which was preventing the
|
| 678 |
+
pane from scrolling. */
|
| 679 |
+
#pane-agent {
|
| 680 |
+
display: block !important;
|
| 681 |
+
padding: 16px;
|
| 682 |
+
overflow-y: auto;
|
| 683 |
+
overflow-x: hidden;
|
| 684 |
+
}
|
| 685 |
+
#pane-agent.active { display: block !important; }
|
| 686 |
+
#pane-agent > .deploy-section { flex: 0 0 auto; }
|
| 687 |
|
| 688 |
/* Preview tab */
|
| 689 |
#pane-preview {
|
|
|
|
| 870 |
/* βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 871 |
DEPLOY TAB
|
| 872 |
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 873 |
+
#pane-deploy {
|
| 874 |
+
display: block !important;
|
| 875 |
+
padding: 16px;
|
| 876 |
+
overflow-y: auto;
|
| 877 |
+
overflow-x: hidden;
|
| 878 |
+
}
|
| 879 |
+
#pane-deploy.active { display: block !important; }
|
| 880 |
+
#pane-deploy > .deploy-section { flex: 0 0 auto; }
|
| 881 |
|
| 882 |
.deploy-section {
|
| 883 |
border: 1px solid var(--border);
|
|
|
|
| 1241 |
<div class="deploy-section">
|
| 1242 |
<div class="deploy-title">🤖 Agent Mode (Claude Code-style)</div>
|
| 1243 |
|
| 1244 |
+
<!-- GitHub Import -->
|
| 1245 |
+
<div class="deploy-field" style="border-left:3px solid var(--cyan, #00d4ff);padding-left:10px;margin-bottom:14px;">
|
| 1246 |
+
<label>📢 Import Project from GitHub</label>
|
| 1247 |
+
<div style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:6px;">
|
| 1248 |
+
<input type="text" id="github-url-input" placeholder="https://github.com/owner/repo (or owner/repo/tree/main/subdir)" style="flex:1;min-width:220px;background:var(--bg-deep,#0d1117);color:var(--text);border:1px solid var(--border);border-radius:3px;padding:6px 10px;font-family:var(--font-mono);font-size:11px;">
|
| 1249 |
+
<button onclick="importGithub()" id="btn-import-github" style="font-size:11px;background:var(--cyan, #00d4ff);color:#000;border:none;padding:6px 14px;cursor:pointer;border-radius:3px;font-weight:600;">⬇ Import</button>
|
| 1250 |
+
</div>
|
| 1251 |
+
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:4px;font-size:10px;color:var(--gray-dim);">
|
| 1252 |
+
<input type="text" id="github-branch-input" placeholder="branch (optional)" style="flex:1;min-width:120px;max-width:200px;background:var(--bg-deep,#0d1117);color:var(--green);border:1px solid var(--border);border-radius:3px;padding:4px 8px;font-family:var(--font-mono);font-size:10px;">
|
| 1253 |
+
<input type="text" id="github-subdir-input" placeholder="subdir (optional)" style="flex:1;min-width:120px;max-width:200px;background:var(--bg-deep,#0d1117);color:var(--green);border:1px solid var(--border);border-radius:3px;padding:4px 8px;font-family:var(--font-mono);font-size:10px;">
|
| 1254 |
+
<input type="text" id="github-into-input" placeholder="into path (optional)" style="flex:1;min-width:120px;max-width:200px;background:var(--bg-deep,#0d1117);color:var(--green);border:1px solid var(--border);border-radius:3px;padding:4px 8px;font-family:var(--font-mono);font-size:10px;">
|
| 1255 |
+
</div>
|
| 1256 |
+
<div class="deploy-hint">
|
| 1257 |
+
Paste any GitHub URL. Accepts <code>https://github.com/owner/repo</code>,
|
| 1258 |
+
<code>/tree/branch/subdir</code> URLs, and SSH form.
|
| 1259 |
+
The repo is shallow-cloned and copied into the workspace with
|
| 1260 |
+
<code>.git</code>, <code>node_modules</code>, <code>__pycache__</code>,
|
| 1261 |
+
<code>.venv</code> stripped. Or type <code>/github <url></code> in chat for the AI to do it.
|
| 1262 |
+
</div>
|
| 1263 |
+
<div id="github-import-status" style="font-size:10px;margin-top:4px;color:var(--gray-dim);"></div>
|
| 1264 |
+
</div>
|
| 1265 |
+
|
| 1266 |
<!-- Agent mode toggle -->
|
| 1267 |
<div class="deploy-field">
|
| 1268 |
<label>Agent Mode</label>
|
|
|
|
| 2662 |
document.getElementById('agent-create-input').value = '';
|
| 2663 |
}
|
| 2664 |
|
| 2665 |
+
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 2666 |
+
// GITHUB IMPORT
|
| 2667 |
+
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 2668 |
+
|
| 2669 |
+
async function importGithub() {
|
| 2670 |
+
const urlEl = document.getElementById('github-url-input');
|
| 2671 |
+
const branchEl = document.getElementById('github-branch-input');
|
| 2672 |
+
const subdirEl = document.getElementById('github-subdir-input');
|
| 2673 |
+
const intoEl = document.getElementById('github-into-input');
|
| 2674 |
+
const statusEl = document.getElementById('github-import-status');
|
| 2675 |
+
const btn = document.getElementById('btn-import-github');
|
| 2676 |
+
|
| 2677 |
+
if (!urlEl) return;
|
| 2678 |
+
const url = urlEl.value.trim();
|
| 2679 |
+
if (!url) {
|
| 2680 |
+
if (statusEl) {
|
| 2681 |
+
statusEl.style.color = 'var(--red)';
|
| 2682 |
+
statusEl.textContent = 'Please paste a GitHub URL first.';
|
| 2683 |
+
}
|
| 2684 |
+
return;
|
| 2685 |
+
}
|
| 2686 |
+
|
| 2687 |
+
// Basic URL sanity check (server validates too, but quick client-side check first)
|
| 2688 |
+
if (!/^https:\/\/github\.com\//i.test(url) && !/^git@github\.com:/i.test(url)) {
|
| 2689 |
+
if (statusEl) {
|
| 2690 |
+
statusEl.style.color = 'var(--red)';
|
| 2691 |
+
statusEl.textContent = 'URL must start with https://github.com/ or git@github.com:';
|
| 2692 |
+
}
|
| 2693 |
+
return;
|
| 2694 |
+
}
|
| 2695 |
+
|
| 2696 |
+
const branch = (branchEl && branchEl.value.trim()) || '';
|
| 2697 |
+
const subdir = (subdirEl && subdirEl.value.trim()) || '';
|
| 2698 |
+
const into = (intoEl && intoEl.value.trim()) || '';
|
| 2699 |
+
|
| 2700 |
+
// Disable button + show working state
|
| 2701 |
+
if (btn) { btn.disabled = true; btn.textContent = 'β³ Cloning...'; }
|
| 2702 |
+
if (statusEl) {
|
| 2703 |
+
statusEl.style.color = 'var(--cyan, #00d4ff)';
|
| 2704 |
+
statusEl.textContent = `Cloning ${url} (depth=1)... this may take 10-60 seconds for large repos.`;
|
| 2705 |
+
}
|
| 2706 |
+
|
| 2707 |
+
try {
|
| 2708 |
+
const es = await callAgentApi('import_github', [url, branch, subdir, into, '1', '180']);
|
| 2709 |
+
es.addEventListener('complete', (e) => {
|
| 2710 |
+
try {
|
| 2711 |
+
const data = JSON.parse(e.data);
|
| 2712 |
+
const result = JSON.parse(data[0]);
|
| 2713 |
+
if (result.success) {
|
| 2714 |
+
if (statusEl) {
|
| 2715 |
+
statusEl.style.color = 'var(--green)';
|
| 2716 |
+
const treePreview = (result.tree_preview || []).slice(0, 12).join(', ');
|
| 2717 |
+
statusEl.innerHTML =
|
| 2718 |
+
`β ${result.message}` +
|
| 2719 |
+
(treePreview ? `<br><span style="color:var(--gray-dim);">Top-level: ${treePreview}</span>` : '');
|
| 2720 |
+
}
|
| 2721 |
+
addSystemMessage(`π₯ Imported ${result.owner}/${result.repo} β ${result.files_imported} files. Workspace refreshed.`);
|
| 2722 |
+
// Clear inputs on success
|
| 2723 |
+
urlEl.value = '';
|
| 2724 |
+
if (branchEl) branchEl.value = '';
|
| 2725 |
+
if (subdirEl) subdirEl.value = '';
|
| 2726 |
+
if (intoEl) intoEl.value = '';
|
| 2727 |
+
// Refresh workspace tree + todos so the new files are visible
|
| 2728 |
+
refreshWorkspace();
|
| 2729 |
+
refreshTodos();
|
| 2730 |
+
// Optionally auto-enable agent mode so the user can immediately edit
|
| 2731 |
+
const toggle = document.getElementById('agent-mode-toggle');
|
| 2732 |
+
if (toggle && !toggle.checked) {
|
| 2733 |
+
toggle.checked = true;
|
| 2734 |
+
toggleAgentMode();
|
| 2735 |
+
}
|
| 2736 |
+
} else {
|
| 2737 |
+
if (statusEl) {
|
| 2738 |
+
statusEl.style.color = 'var(--red)';
|
| 2739 |
+
statusEl.textContent = `β ${result.message || result.error || 'Import failed.'}`;
|
| 2740 |
+
}
|
| 2741 |
+
addSystemMessage(`GitHub import failed: ${result.message || result.error}`);
|
| 2742 |
+
}
|
| 2743 |
+
} catch (err) {
|
| 2744 |
+
console.error('import_github parse error:', err);
|
| 2745 |
+
if (statusEl) {
|
| 2746 |
+
statusEl.style.color = 'var(--red)';
|
| 2747 |
+
statusEl.textContent = 'β Unexpected response from server.';
|
| 2748 |
+
}
|
| 2749 |
+
}
|
| 2750 |
+
if (btn) { btn.disabled = false; btn.textContent = 'β¬ Import'; }
|
| 2751 |
+
es.close();
|
| 2752 |
+
});
|
| 2753 |
+
es.addEventListener('error', () => {
|
| 2754 |
+
if (btn) { btn.disabled = false; btn.textContent = 'β¬ Import'; }
|
| 2755 |
+
if (statusEl) {
|
| 2756 |
+
statusEl.style.color = 'var(--red)';
|
| 2757 |
+
statusEl.textContent = 'β Network error during clone.';
|
| 2758 |
+
}
|
| 2759 |
+
es.close();
|
| 2760 |
+
});
|
| 2761 |
+
} catch (err) {
|
| 2762 |
+
console.error('importGithub failed:', err);
|
| 2763 |
+
if (btn) { btn.disabled = false; btn.textContent = 'β¬ Import'; }
|
| 2764 |
+
if (statusEl) {
|
| 2765 |
+
statusEl.style.color = 'var(--red)';
|
| 2766 |
+
statusEl.textContent = `β ${err.message || err}`;
|
| 2767 |
+
}
|
| 2768 |
+
}
|
| 2769 |
+
}
|
| 2770 |
+
|
| 2771 |
+
// Allow pressing Enter in the URL input to trigger import
|
| 2772 |
+
function setupGithubImportEnterKey() {
|
| 2773 |
+
const urlEl = document.getElementById('github-url-input');
|
| 2774 |
+
if (urlEl && !urlEl.dataset.enterBound) {
|
| 2775 |
+
urlEl.dataset.enterBound = '1';
|
| 2776 |
+
urlEl.addEventListener('keydown', (ev) => {
|
| 2777 |
+
if (ev.key === 'Enter') { ev.preventDefault(); importGithub(); }
|
| 2778 |
+
});
|
| 2779 |
+
}
|
| 2780 |
+
}
|
| 2781 |
+
|
| 2782 |
async function refreshTodos() {
|
| 2783 |
try {
|
| 2784 |
const es = await callAgentApi('todo_read', ['default']);
|
|
|
|
| 3109 |
refreshAgents();
|
| 3110 |
renderAgentsList();
|
| 3111 |
renderActiveAgentBadge();
|
| 3112 |
+
// Wire up Enter-key handler on the GitHub import URL input
|
| 3113 |
+
setupGithubImportEnterKey();
|
| 3114 |
}, 100);
|
| 3115 |
});
|
| 3116 |
|