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

feat: add GitHub project import + fix Agent tab scroll (shallow clone, heavy-dir stripping, /github slash command, UI box)

Browse files

Adds 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 CHANGED
@@ -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
- β”‚ └── todos.py ← Todo list management
 
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 |
README.md CHANGED
@@ -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:
code/commands/builtins/github.md ADDED
@@ -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.
code/server/routes.py CHANGED
@@ -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)
code/tools/__init__.py CHANGED
@@ -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
  ]
code/tools/github.py ADDED
@@ -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
+ }
index.html CHANGED
@@ -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
- #pane-agent { padding: 16px; gap: 14px; overflow-y: auto; }
 
 
 
 
 
 
 
 
 
 
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 { padding: 16px; gap: 14px; overflow-y: auto; }
 
 
 
 
 
 
 
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">&#129302; 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">&#129302; 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>&#128226; 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;">&#11015; 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 &lt;url&gt;</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