Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions prompts/agent.system.projects.active.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 47 additions & 0 deletions python/api/projects.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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":
Expand Down Expand Up @@ -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")
Expand Down
106 changes: 105 additions & 1 deletion python/helpers/git.py
Original file line number Diff line number Diff line change
@@ -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()
Expand Down Expand Up @@ -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"
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)}
2 changes: 1 addition & 1 deletion python/helpers/memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down
62 changes: 59 additions & 3 deletions python/helpers/projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,18 +33,29 @@ 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
knowledge_files_count: int
variables: str
secrets: str
subagents: dict[str, SubAgentSettings]
git_status: GitStatusData



Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand All @@ -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", ""),
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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", ""),
}


Expand Down
Loading