Add skill sync action
This commit is contained in:
@@ -13,6 +13,7 @@ import queue
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import venv
|
||||
@@ -632,6 +633,69 @@ class AgentState:
|
||||
if key.startswith(account_prefix):
|
||||
self._agents.pop(key, None)
|
||||
|
||||
def _clear_agents_for_account(self, account_id: str | None) -> None:
|
||||
account_prefix = f'{self._account_key(account_id)}:'
|
||||
for key in list(self._agents):
|
||||
if key.startswith(account_prefix):
|
||||
self._agents.pop(key, None)
|
||||
|
||||
def sync_skills_from_git(self, account_id: str | None) -> dict[str, Any]:
|
||||
"""Pull the current git branch and refresh account-scoped skill state."""
|
||||
with self._lock:
|
||||
config = self._config_for(account_id)
|
||||
repo = config.cwd
|
||||
if not (repo / '.git').exists():
|
||||
raise ValueError(f'当前工作目录不是 git 仓库:{repo}')
|
||||
|
||||
def run_git(args: list[str], *, timeout: int = 60) -> subprocess.CompletedProcess[str]:
|
||||
env = {
|
||||
**os.environ,
|
||||
'GIT_TERMINAL_PROMPT': '0',
|
||||
}
|
||||
proc = subprocess.run(
|
||||
['git', *args],
|
||||
cwd=repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
env=env,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
detail = (proc.stderr or proc.stdout or '').strip()
|
||||
raise ValueError(f'git {" ".join(args)} 失败:{detail}')
|
||||
return proc
|
||||
|
||||
dirty = run_git(['status', '--porcelain', '--untracked-files=no']).stdout.strip()
|
||||
if dirty:
|
||||
raise ValueError('工作区存在未提交的 tracked 改动,已停止同步 Skill。')
|
||||
|
||||
branch = run_git(['branch', '--show-current']).stdout.strip() or 'main'
|
||||
before = run_git(['rev-parse', 'HEAD']).stdout.strip()
|
||||
run_git(['fetch', 'origin'], timeout=120)
|
||||
pull = run_git(['pull', '--ff-only', 'origin', branch], timeout=120)
|
||||
after = run_git(['rev-parse', 'HEAD']).stdout.strip()
|
||||
changed_files: list[str] = []
|
||||
if before != after:
|
||||
changed_files = [
|
||||
line.strip()
|
||||
for line in run_git(
|
||||
['diff', '--name-only', f'{before}..{after}'],
|
||||
timeout=60,
|
||||
).stdout.splitlines()
|
||||
if line.strip()
|
||||
]
|
||||
self._clear_agents_for_account(account_id)
|
||||
skills = get_bundled_skills(config.cwd)
|
||||
return {
|
||||
'branch': branch,
|
||||
'before': before,
|
||||
'after': after,
|
||||
'updated': before != after,
|
||||
'changed_files': changed_files,
|
||||
'skill_count': sum(1 for skill in skills if skill.user_invocable),
|
||||
'message': pull.stdout.strip() or pull.stderr.strip(),
|
||||
}
|
||||
|
||||
def _build_agent(self, account_id: str | None = None) -> LocalCodingAgent:
|
||||
config = self._config_for(account_id)
|
||||
paths = self.account_paths(account_id)
|
||||
@@ -797,6 +861,10 @@ class SkillPreferenceUpdate(BaseModel):
|
||||
account_id: str | None = None
|
||||
|
||||
|
||||
class SkillSyncRequest(BaseModel):
|
||||
account_id: str | None = None
|
||||
|
||||
|
||||
class SessionTitleUpdate(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=80)
|
||||
|
||||
@@ -879,6 +947,18 @@ def create_app(state: AgentState) -> FastAPI:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
return await list_skills(payload.account_id)
|
||||
|
||||
@app.post('/api/skills/sync')
|
||||
async def sync_skills(payload: SkillSyncRequest) -> dict[str, Any]:
|
||||
try:
|
||||
result = await asyncio.to_thread(
|
||||
state.sync_skills_from_git,
|
||||
payload.account_id,
|
||||
)
|
||||
except (ValueError, subprocess.TimeoutExpired) as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
result['skills'] = await list_skills(payload.account_id)
|
||||
return result
|
||||
|
||||
@app.get('/api/models')
|
||||
async def list_models_get(account_id: str | None = None) -> dict[str, Any]:
|
||||
config = state.config_for(account_id)
|
||||
|
||||
Reference in New Issue
Block a user