diff --git a/prompts/agent.system.projects.active.md b/prompts/agent.system.projects.active.md
index 3d2ae63a91..9d75f06874 100644
--- a/prompts/agent.system.projects.active.md
+++ b/prompts/agent.system.projects.active.md
@@ -2,6 +2,7 @@
Path: {{project_path}}
Title: {{project_name}}
Description: {{project_description}}
+{% if project_git_url %}Git URL: {{project_git_url}}{% endif %}
### Important project instructions MUST follow
diff --git a/python/api/projects.py b/python/api/projects.py
index 3e06fadcdb..6995d9da7a 100644
--- a/python/api/projects.py
+++ b/python/api/projects.py
@@ -1,5 +1,6 @@
from python.helpers.api import ApiHandler, Input, Output, Request, Response
from python.helpers import projects
+from python.helpers.notification import NotificationManager, NotificationType, NotificationPriority
class Projects(ApiHandler):
@@ -17,6 +18,8 @@ async def process(self, input: Input, request: Request) -> Output:
data = self.load_project(input.get("name", None))
elif action == "create":
data = self.create_project(input.get("project", None))
+ elif action == "clone":
+ data = self.clone_project(input.get("project", None))
elif action == "update":
data = self.update_project(input.get("project", None))
elif action == "delete":
@@ -50,6 +53,50 @@ def create_project(self, project: dict|None):
name = projects.create_project(project["name"], data)
return projects.load_edit_project_data(name)
+ def clone_project(self, project: dict|None):
+ if project is None:
+ raise Exception("Project data is required")
+ git_url = project.get("git_url", "")
+ git_token = project.get("git_token", "")
+ if not git_url:
+ raise Exception("Git URL is required")
+
+ # Progress notification
+ notification = NotificationManager.send_notification(
+ NotificationType.PROGRESS,
+ NotificationPriority.NORMAL,
+ f"Cloning repository...",
+ "Git Clone",
+ display_time=999,
+ group="git_clone"
+ )
+
+ try:
+ data = projects.BasicProjectData(**project)
+ name = projects.clone_git_project(project["name"], git_url, git_token, data)
+
+ # Success notification
+ NotificationManager.send_notification(
+ NotificationType.SUCCESS,
+ NotificationPriority.NORMAL,
+ f"Repository cloned successfully",
+ "Git Clone",
+ display_time=3,
+ group="git_clone"
+ )
+ return projects.load_edit_project_data(name)
+ except Exception as e:
+ # Error notification
+ NotificationManager.send_notification(
+ NotificationType.ERROR,
+ NotificationPriority.HIGH,
+ f"Clone failed: {str(e)}",
+ "Git Clone",
+ display_time=5,
+ group="git_clone"
+ )
+ raise
+
def load_project(self, name: str|None):
if name is None:
raise Exception("Project name is required")
diff --git a/python/helpers/git.py b/python/helpers/git.py
index 33e3bec224..da0e80183b 100644
--- a/python/helpers/git.py
+++ b/python/helpers/git.py
@@ -1,8 +1,25 @@
from git import Repo
from datetime import datetime
import os
+import subprocess
+import base64
+from urllib.parse import urlparse, urlunparse
from python.helpers import files
+
+def strip_auth_from_url(url: str) -> str:
+ """Remove any authentication info from URL."""
+ if not url:
+ return url
+ parsed = urlparse(url)
+ if not parsed.hostname:
+ return url
+ clean_netloc = parsed.hostname
+ if parsed.port:
+ clean_netloc += f":{parsed.port}"
+ return urlunparse((parsed.scheme, clean_netloc, parsed.path, '', '', ''))
+
+
def get_git_info():
# Get the current working directory (assuming the repo is in the same folder as the script)
repo_path = files.get_base_dir()
@@ -54,4 +71,91 @@ def get_version():
git_info = get_git_info()
return str(git_info.get("short_tag", "")).strip() or "unknown"
except Exception:
- return "unknown"
\ No newline at end of file
+ return "unknown"
+
+
+def clone_repo(url: str, dest: str, token: str | None = None):
+ """Clone a git repository. Uses http.extraHeader for token auth (never stored in URL/config)."""
+ cmd = ['git']
+
+ if token:
+ # GitHub Git HTTP requires Basic Auth, not Bearer
+ auth_string = f"x-access-token:{token}"
+ auth_base64 = base64.b64encode(auth_string.encode()).decode()
+ cmd.extend(['-c', f'http.extraHeader=Authorization: Basic {auth_base64}'])
+
+ cmd.extend(['clone', '--progress', '--', url, dest])
+
+ env = os.environ.copy()
+ env['GIT_TERMINAL_PROMPT'] = '0'
+
+ result = subprocess.run(cmd, capture_output=True, text=True, env=env)
+
+ if result.returncode != 0:
+ error_msg = result.stderr.strip() or result.stdout.strip() or 'Unknown error'
+ raise Exception(f"Git clone failed: {error_msg}")
+
+ return Repo(dest)
+
+
+# Files to ignore when checking dirty status (A0 project metadata)
+A0_IGNORE_PATTERNS = {".a0proj", ".a0proj/"}
+
+
+def get_repo_status(repo_path: str) -> dict:
+ """Get Git repository status, ignoring A0 project metadata files."""
+ try:
+ repo = Repo(repo_path)
+ if repo.bare:
+ return {"is_git_repo": False, "error": "Repository is bare"}
+
+ # Remote URL (always strip auth info for security)
+ remote_url = ""
+ try:
+ if repo.remotes:
+ remote_url = strip_auth_from_url(repo.remotes.origin.url)
+ except Exception:
+ pass
+
+ # Current branch
+ try:
+ current_branch = repo.active_branch.name if not repo.head.is_detached else f"HEAD@{repo.head.commit.hexsha[:7]}"
+ except Exception:
+ current_branch = "unknown"
+
+ # Check dirty status, excluding A0 metadata
+ def is_a0_file(path: str) -> bool:
+ return path.startswith(".a0proj") or path == ".a0proj"
+
+ # Filter out A0 files from diff and untracked
+ changed_files = [d.a_path for d in repo.index.diff(None)] + [d.a_path for d in repo.index.diff("HEAD")]
+ untracked = repo.untracked_files
+
+ real_changes = [f for f in changed_files if not is_a0_file(f)]
+ real_untracked = [f for f in untracked if not is_a0_file(f)]
+
+ is_dirty = len(real_changes) > 0 or len(real_untracked) > 0
+ untracked_count = len(real_untracked)
+
+ last_commit = None
+ try:
+ commit = repo.head.commit
+ last_commit = {
+ "hash": commit.hexsha[:7],
+ "message": commit.message.split('\n')[0][:80],
+ "author": str(commit.author),
+ "date": datetime.fromtimestamp(commit.committed_date).strftime('%Y-%m-%d %H:%M')
+ }
+ except Exception:
+ pass
+
+ return {
+ "is_git_repo": True,
+ "remote_url": remote_url,
+ "current_branch": current_branch,
+ "is_dirty": is_dirty,
+ "untracked_count": untracked_count,
+ "last_commit": last_commit
+ }
+ except Exception as e:
+ return {"is_git_repo": False, "error": str(e)}
\ No newline at end of file
diff --git a/python/helpers/memory.py b/python/helpers/memory.py
index e23292c81f..769e6650e1 100644
--- a/python/helpers/memory.py
+++ b/python/helpers/memory.py
@@ -308,7 +308,7 @@ def _preload_knowledge_folders(
log_item,
abs_knowledge_dir(kn_dir),
index,
- {"area": Memory.Area.MAIN},
+ {"area": Memory.Area.MAIN.value},
filename_pattern="*",
recursive=False,
)
diff --git a/python/helpers/projects.py b/python/helpers/projects.py
index 73deeba469..0eef458718 100644
--- a/python/helpers/projects.py
+++ b/python/helpers/projects.py
@@ -33,11 +33,21 @@ class BasicProjectData(TypedDict):
description: str
instructions: str
color: str
+ git_url: str
memory: Literal[
"own", "global"
] # in the future we can add cutom and point to another existing folder
file_structure: FileStructureInjectionSettings
+class GitStatusData(TypedDict, total=False):
+ is_git_repo: bool
+ remote_url: str
+ current_branch: str
+ is_dirty: bool
+ untracked_count: int
+ last_commit: dict
+ error: str
+
class EditProjectData(BasicProjectData):
name: str
instruction_files_count: int
@@ -45,6 +55,7 @@ class EditProjectData(BasicProjectData):
variables: str
secrets: str
subagents: dict[str, SubAgentSettings]
+ git_status: GitStatusData
@@ -77,6 +88,45 @@ def create_project(name: str, data: BasicProjectData):
return name
+def clone_git_project(name: str, git_url: str, git_token: str, data: BasicProjectData):
+ """Clone a git repository as a new A0 project. Token is used only for cloning via http header."""
+ from python.helpers import git
+
+ abs_path = files.create_dir_safe(
+ files.get_abs_path(PROJECTS_PARENT_DIR, name), rename_format="{name}_{number}"
+ )
+ actual_name = files.basename(abs_path)
+
+ try:
+ # Clone with token via http.extraHeader (token never in URL or git config)
+ git.clone_repo(git_url, abs_path, token=git_token)
+ clean_url = git.strip_auth_from_url(git_url)
+
+ # Check if cloned repo already has .a0proj
+ meta_path = os.path.join(abs_path, PROJECT_META_DIR, PROJECT_HEADER_FILE)
+ if os.path.exists(meta_path):
+ # Merge: keep cloned content, override only user-specified fields
+ cloned_header = dirty_json.parse(files.read_file(meta_path))
+ cloned_header["title"] = data.get("title") or cloned_header.get("title", "")
+ cloned_header["color"] = data.get("color") or cloned_header.get("color", "")
+ cloned_header["git_url"] = clean_url
+ save_project_header(actual_name, cloned_header)
+ else:
+ # New project: create meta folders and save header
+ create_project_meta_folders(actual_name)
+ data = _normalizeBasicData(data)
+ data["git_url"] = clean_url
+ save_project_header(actual_name, data)
+
+ return actual_name
+ except Exception as e:
+ try:
+ files.delete_dir(abs_path)
+ except Exception:
+ pass
+ raise e
+
+
def load_project_header(name: str):
abs_path = files.get_abs_path(
PROJECTS_PARENT_DIR, name, PROJECT_META_DIR, PROJECT_HEADER_FILE
@@ -107,6 +157,7 @@ def _normalizeBasicData(data: BasicProjectData):
description=data.get("description", ""),
instructions=data.get("instructions", ""),
color=data.get("color", ""),
+ git_url=data.get("git_url", ""),
memory=data.get("memory", "own"),
file_structure=data.get(
"file_structure",
@@ -123,6 +174,7 @@ def _normalizeEditData(data: EditProjectData):
instructions=data.get("instructions", ""),
variables=data.get("variables", ""),
color=data.get("color", ""),
+ git_status=data.get("git_status", {"is_git_repo": False}),
instruction_files_count=data.get("instruction_files_count", 0),
knowledge_files_count=data.get("knowledge_files_count", 0),
secrets=data.get("secrets", ""),
@@ -169,14 +221,16 @@ def load_basic_project_data(name: str) -> BasicProjectData:
def load_edit_project_data(name: str) -> EditProjectData:
+ from python.helpers import git
+
data = load_basic_project_data(name)
- additional_instructions = get_additional_instructions_files(
- name
- ) # for additional info
+ additional_instructions = get_additional_instructions_files(name)
variables = load_project_variables(name)
secrets = load_project_secrets_masked(name)
subagents = load_project_subagents(name)
knowledge_files_count = get_knowledge_files_count(name)
+ git_status = git.get_repo_status(get_project_folder(name))
+
data = EditProjectData(
**data,
name=name,
@@ -185,6 +239,7 @@ def load_edit_project_data(name: str) -> EditProjectData:
variables=variables,
secrets=secrets,
subagents=subagents,
+ git_status=git_status,
)
data = _normalizeEditData(data)
return data
@@ -308,6 +363,7 @@ def build_system_prompt_vars(name: str):
"project_description": project_data.get("description", ""),
"project_instructions": complete_instructions or "",
"project_path": files.normalize_a0_path(get_project_folder(name)),
+ "project_git_url": project_data.get("git_url", ""),
}
diff --git a/webui/components/projects/project-create.html b/webui/components/projects/project-create.html
index caab8194a1..b8140cd9a3 100644
--- a/webui/components/projects/project-create.html
+++ b/webui/components/projects/project-create.html
@@ -16,12 +16,44 @@