import re
import time
import threading
+import io
+import zipfile
import uvicorn
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request
return {"path": str(fpath)}
+# ==================== 自定义 Skill 管理(全局 ~/.codebuddy/skills) ====================
+SKILLS_ROOT = Path.home() / ".codebuddy" / "skills"
+SKILLS_ROOT.mkdir(parents=True, exist_ok=True)
+_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$")
+MAX_SKILL_ZIP = 5 * 1024 * 1024 # 5MB
+
+
+def _safe_skill_path(name: str):
+ """校验 skill 名称并返回其绝对目录;非法或越界返回 None。"""
+ if not _NAME_RE.match(name or ""):
+ return None
+ root = SKILLS_ROOT.resolve()
+ p = (root / name).resolve()
+ if p == root or root not in p.parents:
+ return None
+ return p
+
+
+def _parse_skill_frontmatter(text: str):
+ """极简解析 SKILL.md frontmatter,提取 name / description。"""
+ name = description = None
+ if not text.startswith("---"):
+ return name, description
+ lines = text.splitlines()
+ end = None
+ for i in range(1, len(lines)):
+ if lines[i].strip() == "---":
+ end = i
+ break
+ if end is None:
+ return name, description
+ for line in lines[1:end]:
+ s = line.strip()
+ if s.startswith("name:"):
+ name = s[len("name:"):].strip().strip('"').strip("'")
+ elif s.startswith("description:"):
+ description = s[len("description:"):].strip().strip('"').strip("'")
+ return name, description
+
+
+def _list_skills():
+ out = []
+ root = SKILLS_ROOT.resolve()
+ if not root.exists():
+ return out
+ for d in sorted(root.iterdir()):
+ if not d.is_dir():
+ continue
+ skill_md = d / "SKILL.md"
+ name = description = None
+ if skill_md.is_file():
+ try:
+ name, description = _parse_skill_frontmatter(
+ skill_md.read_text(encoding="utf-8", errors="replace"))
+ except OSError:
+ pass
+ out.append({
+ "name": name or d.name,
+ "dir_name": d.name,
+ "description": description or "",
+ "has_skill_md": skill_md.is_file(),
+ "mtime": int(d.stat().st_mtime),
+ })
+ return out
+
+
+def _extract_skill_zip(zf: zipfile.ZipFile, dest: Path):
+ """把 zip 内容(去掉单个顶层目录)解压到 dest,防 zip-slip。"""
+ dest.mkdir(parents=True, exist_ok=True)
+ tops = set()
+ for n in zf.namelist():
+ p = n.split("/")[0]
+ if p:
+ tops.add(p)
+ if len(tops) != 1:
+ raise ValueError("zip 须以单个 skill 目录为顶层(如 my-skill/...)")
+ top = tops.pop()
+ for info in zf.infolist():
+ rel = info.filename
+ if rel == top or rel.startswith(top + "/"):
+ rel = rel[len(top) + 1:]
+ if not rel:
+ continue
+ target = (dest / rel).resolve()
+ if dest.resolve() not in target.parents and target != dest.resolve():
+ raise ValueError("zip 含非法路径(zip-slip): " + info.filename)
+ if info.is_dir():
+ target.mkdir(parents=True, exist_ok=True)
+ else:
+ target.parent.mkdir(parents=True, exist_ok=True)
+ with zf.open(info) as src, open(target, "wb") as fdst:
+ shutil.copyfileobj(src, fdst, 8192)
+
+
+def _install_skill_zip(zf: zipfile.ZipFile, name: str):
+ """校验名称,解压 zip 到 SKILLS_ROOT/<name>/(临时目录交换,避免损坏已有 skill)。"""
+ if not _NAME_RE.match(name):
+ raise ValueError("skill 名称非法(仅允许字母数字 - _,长度 1-64)")
+ root = SKILLS_ROOT.resolve()
+ dest = root / name
+ tmp = root / (".tmp_" + name + "_" + secrets.token_hex(4))
+ try:
+ _extract_skill_zip(zf, tmp)
+ if not (tmp / "SKILL.md").is_file():
+ raise ValueError("zip 内未找到 SKILL.md")
+ if dest.exists():
+ shutil.rmtree(dest)
+ tmp.rename(dest)
+ finally:
+ if tmp.exists():
+ shutil.rmtree(tmp, ignore_errors=True)
+ return dest
+
+
+def _decode_zip_body(body: dict):
+ import base64
+ b64 = body.get("data") or ""
+ try:
+ raw = base64.b64decode(b64)
+ except Exception:
+ raise ValueError("无效的 base64 数据")
+ if len(raw) == 0:
+ raise ValueError("zip 内容为空")
+ if len(raw) > MAX_SKILL_ZIP:
+ raise ValueError(f"zip 过大(上限 {MAX_SKILL_ZIP // 1024 // 1024}MB)")
+ try:
+ return zipfile.ZipFile(io.BytesIO(raw))
+ except Exception:
+ raise ValueError("不是有效的 zip 文件")
+
+
+@app.get("/api/tasks/{task_id}/skills")
+async def api_list_skills(task_id: str, request: Request):
+ if not _auth_ok(request):
+ return JSONResponse({"error": "unauthorized"}, status_code=401)
+ return JSONResponse({"ok": True, "skills": _list_skills(), "root": str(SKILLS_ROOT)})
+
+
+@app.get("/api/tasks/{task_id}/skills/{name}/content")
+async def api_skill_content(task_id: str, name: str, request: Request):
+ if not _auth_ok(request):
+ return JSONResponse({"error": "unauthorized"}, status_code=401)
+ p = _safe_skill_path(name)
+ if p is None:
+ return JSONResponse({"error": "skill 名称非法"}, status_code=400)
+ skill_md = p / "SKILL.md"
+ if not skill_md.is_file():
+ return JSONResponse({"error": "该 skill 不存在 SKILL.md"}, status_code=404)
+ return JSONResponse({"ok": True, "content": skill_md.read_text(encoding="utf-8", errors="replace")})
+
+
+@app.post("/api/tasks/{task_id}/skills")
+async def api_upload_skill(task_id: str, request: Request):
+ if not _auth_ok(request):
+ return JSONResponse({"error": "unauthorized"}, status_code=401)
+ try:
+ body = await request.json()
+ except Exception:
+ return JSONResponse({"error": "invalid json body"}, status_code=400)
+ try:
+ zf = _decode_zip_body(body)
+ except ValueError as e:
+ return JSONResponse({"error": str(e)}, status_code=400)
+ tops = set(n.split("/")[0] for n in zf.namelist() if n.split("/")[0])
+ name = body.get("name") or (tops.pop() if len(tops) == 1 else None)
+ if not name:
+ zf.close()
+ return JSONResponse({"error": "无法确定 skill 名称:zip 需以单个目录为顶层,或提供 name 字段"}, status_code=400)
+ if not _NAME_RE.match(name):
+ zf.close()
+ return JSONResponse({"error": f"skill 名称非法: {name}(仅允许字母数字 - _,长度 1-64)"}, status_code=400)
+ try:
+ with zf:
+ dest = _install_skill_zip(zf, name)
+ except ValueError as e:
+ return JSONResponse({"error": f"解压失败: {e}"}, status_code=400)
+ logger.info(f"上传 skill: {name} -> {dest}")
+ return JSONResponse({"ok": True, "name": name, "path": str(dest)})
+
+
+@app.put("/api/tasks/{task_id}/skills/{name}")
+async def api_update_skill(task_id: str, name: str, request: Request):
+ if not _auth_ok(request):
+ return JSONResponse({"error": "unauthorized"}, status_code=401)
+ p = _safe_skill_path(name)
+ if p is None:
+ return JSONResponse({"error": "skill 名称非法"}, status_code=400)
+ ctype = request.headers.get("content-type", "")
+ # 模式1:zip 覆盖
+ if "multipart/form-data" in ctype or "application/json" in ctype:
+ try:
+ body = await request.json()
+ except Exception:
+ return JSONResponse({"error": "invalid json body"}, status_code=400)
+ if body.get("data"):
+ try:
+ zf = _decode_zip_body(body)
+ except ValueError as e:
+ return JSONResponse({"error": str(e)}, status_code=400)
+ try:
+ with zf:
+ dest = _install_skill_zip(zf, name)
+ except ValueError as e:
+ return JSONResponse({"error": f"解压失败: {e}"}, status_code=400)
+ return JSONResponse({"ok": True, "name": name, "path": str(dest), "mode": "zip"})
+ # 模式2:直接更新 SKILL.md 内容
+ content = body.get("content")
+ if content is None:
+ return JSONResponse({"error": "需提供 data(zip) 或 content 字段"}, status_code=400)
+ skill_md = p / "SKILL.md"
+ if not skill_md.is_file():
+ return JSONResponse({"error": "该 skill 不存在 SKILL.md,无法更新内容"}, status_code=404)
+ skill_md.write_text(content, encoding="utf-8")
+ return JSONResponse({"ok": True, "name": name, "mode": "content"})
+ return JSONResponse({"error": "不支持的 content-type"}, status_code=400)
+
+
+@app.delete("/api/tasks/{task_id}/skills/{name}")
+async def api_delete_skill(task_id: str, name: str, request: Request):
+ if not _auth_ok(request):
+ return JSONResponse({"error": "unauthorized"}, status_code=401)
+ p = _safe_skill_path(name)
+ if p is None:
+ return JSONResponse({"error": "skill 名称非法"}, status_code=400)
+ if not p.exists():
+ return JSONResponse({"error": "skill 不存在"}, status_code=404)
+ shutil.rmtree(p)
+ logger.info(f"删除 skill: {name}")
+ return JSONResponse({"ok": True, "name": name})
+
+
+@app.post("/api/tasks/{task_id}/skills/{name}/notify")
+async def api_notify_skill(task_id: str, name: str, request: Request):
+ if not _auth_ok(request):
+ return JSONResponse({"error": "unauthorized"}, status_code=401)
+ p = _safe_skill_path(name)
+ if p is None:
+ return JSONResponse({"error": "skill 名称非法"}, status_code=400)
+ if not p.exists():
+ return JSONResponse({"error": "skill 不存在"}, status_code=404)
+ entry = RUNNING.get(task_id)
+ if (not entry or entry.get("proc") is None or entry["proc"].poll() is not None
+ or entry.get("master_fd") is None):
+ return JSONResponse({"ok": False, "notified": False,
+ "error": "该任务当前未在运行,无法热加载;请重启/重连任务后由 codebuddy 自动发现该 skill"})
+ msg = ("\n【系统通知】用户刚上传/更新了 skill:" + name +
+ ",路径 " + str(p) + "/SKILL.md。" +
+ "如适用,请用 Read 工具载入该 SKILL.md 并按其指引执行。\n")
+ try:
+ os.write(entry["master_fd"], msg.encode("utf-8"))
+ except Exception as e:
+ return JSONResponse({"ok": False, "notified": False, "error": f"注入失败: {e}"})
+ return JSONResponse({"ok": True, "notified": True})
+
+
# ==================== WebSocket 终端 ====================
@app.websocket("/ws")
async def websocket_terminal(websocket: WebSocket):
<button class="action-btn" id="btn-restart" title="重启会话(续上次历史)">↻ 重启会话</button>
<button class="action-btn" id="btn-list" title="打开任务列表">≡ 列表</button>
<button class="action-btn" id="btn-paste" title="粘贴文字或图片">📋 粘贴</button>
+ <button class="action-btn" id="btn-skills" title="管理自定义 skill">🧩 技能</button>
<button class="action-btn" id="btn-close" title="关闭窗口">✕ 关闭</button>
</div>
</div>
<input type="file" id="paste-file-input" accept="image/*" style="display:none;">
</div>
</div>
+ <!-- 技能管理 modal -->
+ <div id="skills-modal" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,0.7); z-index:200; align-items:center; justify-content:center;">
+ <div style="background:#1a1a2e; border:1px solid #74c0fc; border-radius:12px; padding:18px; width:640px; max-width:92vw; max-height:86vh; overflow:auto;">
+ <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;">
+ <div style="color:#74c0fc; font-size:16px;">🧩 会话技能(全局 ~/.codebuddy/skills)</div>
+ <button class="icon-btn" id="skills-close" title="关闭">✕</button>
+ </div>
+ <div style="font-size:12px; color:#999; margin-bottom:10px; line-height:1.5;">上传整目录 zip(须以单个 skill 目录为顶层,内含 <code style="color:#4ecca3;">SKILL.md</code>)。新技能对所有会话可见;当前运行中的会话可点「通知加载」热加载,否则重启/重连任务后由 codebuddy 自动发现。</div>
+ <div style="display:flex; gap:8px; margin-bottom:12px;">
+ <input type="file" id="skills-file" accept=".zip" style="flex:1; color:#ccc; font-size:13px;">
+ <button class="send-btn" id="skills-upload">上传</button>
+ </div>
+ <div id="skills-list" style="display:flex; flex-direction:column; gap:8px;"></div>
+ <div id="skills-edit" style="display:none; margin-top:14px; border-top:1px solid rgba(255,255,255,0.1); padding-top:12px;">
+ <div style="color:#e0e0e0; font-size:13px; margin-bottom:6px;">编辑 <code id="skills-edit-name" style="color:#4ecca3;"></code> 的 SKILL.md</div>
+ <textarea id="skills-edit-area" rows="14" style="width:100%; box-sizing:border-box; background:#0f0f1e; color:#e0e0e0; border:1px solid rgba(255,255,255,0.15); border-radius:8px; padding:10px; font-family:'Cascadia Code',Menlo,monospace; font-size:13px; line-height:1.5; resize:vertical;"></textarea>
+ <div style="display:flex; gap:8px; justify-content:flex-end; margin-top:8px;">
+ <button class="del-btn del-cancel" id="skills-edit-cancel">取消</button>
+ <button class="send-btn" id="skills-edit-save">保存</button>
+ </div>
+ </div>
+ </div>
+ </div>
</div>
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js"></script>
renderPasteImgChips();
};
+ // ========== 技能管理 ==========
+ const skillsModal = document.getElementById('skills-modal');
+ const skillsList = document.getElementById('skills-list');
+ const skillsFile = document.getElementById('skills-file');
+ const skillsEdit = document.getElementById('skills-edit');
+ const skillsEditArea = document.getElementById('skills-edit-area');
+ const skillsEditName = document.getElementById('skills-edit-name');
+ let skillsEditTarget = null;
+
+ function openSkillsModal() {
+ skillsEdit.style.display = 'none';
+ skillsModal.style.display = 'flex';
+ loadSkills();
+ }
+ function closeSkillsModal() {
+ skillsModal.style.display = 'none';
+ if (term && term.focus) term.focus();
+ }
+ document.getElementById('btn-skills').onclick = openSkillsModal;
+ document.getElementById('skills-close').onclick = closeSkillsModal;
+ skillsModal.addEventListener('click', (e) => { if (e.target === skillsModal) closeSkillsModal(); });
+ document.addEventListener('keydown', (e) => {
+ if (e.key === 'Escape' && skillsModal.style.display === 'flex') { e.preventDefault(); closeSkillsModal(); }
+ });
+
+ function escHtml(x) { return (x || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); }
+
+ async function loadSkills() {
+ try {
+ const res = await fetch(API + '/api/tasks/' + TASK_ID + '/skills', { headers: authHeaders() });
+ if (!res.ok) return;
+ const data = await res.json();
+ const skills = (data.skills || []);
+ if (!skills.length) {
+ skillsList.innerHTML = '<div style="color:#777; font-size:13px;">暂无自定义 skill。上传一个 zip 开始。</div>';
+ return;
+ }
+ skillsList.innerHTML = skills.map(s =>
+ '<div style="background:rgba(255,255,255,0.05); border:1px solid rgba(255,255,255,0.1); border-radius:8px; padding:10px 12px;">'
+ + '<div style="color:#e0e0e0; font-size:14px; font-weight:600;">' + escHtml(s.name)
+ + (s.has_skill_md ? '' : ' <span style="color:#ff6b6b; font-size:12px;">(缺 SKILL.md)</span>') + '</div>'
+ + (s.description ? '<div style="color:#aaa; font-size:12px; margin:4px 0 8px; line-height:1.4;">' + escHtml(s.description) + '</div>' : '<div style="margin:4px 0 8px;"></div>')
+ + '<div style="display:flex; gap:8px;">'
+ + '<button class="del-btn del-cancel" data-act="edit" data-name="' + escHtml(s.dir_name) + '">编辑</button>'
+ + '<button class="del-btn del-cancel" data-act="notify" data-name="' + escHtml(s.dir_name) + '">通知加载</button>'
+ + '<button class="del-btn del-confirm" data-act="del" data-name="' + escHtml(s.dir_name) + '">删除</button>'
+ + '</div></div>'
+ ).join('');
+ skillsList.querySelectorAll('button[data-act]').forEach(btn => {
+ btn.onclick = () => {
+ const act = btn.dataset.act, n = btn.dataset.name;
+ if (act === 'edit') editSkill(n);
+ else if (act === 'del') deleteSkill(n);
+ else if (act === 'notify') notifySkill(n);
+ };
+ });
+ } catch (e) { console.warn('loadSkills failed', e); }
+ }
+
+ function readFileAsBase64(file) {
+ return new Promise((resolve, reject) => {
+ const r = new FileReader();
+ r.onload = () => { const i = r.result.indexOf(','); resolve(i >= 0 ? r.result.slice(i + 1) : ''); };
+ r.onerror = () => reject(r.error);
+ r.readAsDataURL(file);
+ });
+ }
+
+ document.getElementById('skills-upload').onclick = async () => {
+ const f = skillsFile.files && skillsFile.files[0];
+ if (!f) { alert('请先选择一个 .zip 文件'); return; }
+ try {
+ const b64 = await readFileAsBase64(f);
+ const res = await fetch(API + '/api/tasks/' + TASK_ID + '/skills', {
+ method: 'POST',
+ headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()),
+ body: JSON.stringify({ data: b64 })
+ });
+ const d = await res.json();
+ if (!res.ok || !d.ok) throw new Error(d.error || ('HTTP ' + res.status));
+ term && term.writeln('\x1b[32m✓ 已上传 skill: ' + d.name + '\x1b[0m');
+ skillsFile.value = '';
+ loadSkills();
+ } catch (e) { alert('上传失败: ' + e.message); }
+ };
+
+ async function editSkill(name) {
+ try {
+ const res = await fetch(API + '/api/tasks/' + TASK_ID + '/skills', { headers: authHeaders() });
+ const data = await res.json();
+ const s = (data.skills || []).find(x => x.dir_name === name);
+ if (!s) throw new Error('未找到 skill');
+ const res2 = await fetch(API + '/api/tasks/' + TASK_ID + '/skills/' + encodeURIComponent(name) + '/content', { headers: authHeaders() });
+ const d2 = await res2.json();
+ if (!res2.ok || !d2.ok) throw new Error(d2.error || '读取失败');
+ skillsEditName.textContent = s.name || name;
+ skillsEditTarget = name;
+ skillsEditArea.value = d2.content;
+ skillsEdit.style.display = 'block';
+ } catch (e) { alert('打开编辑失败: ' + e.message); }
+ }
+
+ document.getElementById('skills-edit-cancel').onclick = () => { skillsEdit.style.display = 'none'; skillsEditTarget = null; };
+ document.getElementById('skills-edit-save').onclick = async () => {
+ if (!skillsEditTarget) return;
+ const name = skillsEditTarget;
+ try {
+ const res = await fetch(API + '/api/tasks/' + TASK_ID + '/skills/' + encodeURIComponent(name), {
+ method: 'PUT',
+ headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()),
+ body: JSON.stringify({ content: skillsEditArea.value })
+ });
+ const d = await res.json();
+ if (!res.ok || !d.ok) throw new Error(d.error || ('HTTP ' + res.status));
+ term && term.writeln('\x1b[32m✓ 已更新 skill: ' + name + '\x1b[0m');
+ skillsEdit.style.display = 'none'; skillsEditTarget = null;
+ loadSkills();
+ } catch (e) { alert('保存失败: ' + e.message); }
+ };
+
+ async function deleteSkill(name) {
+ if (!confirm('确认删除 skill: ' + name + '?该目录将被永久移除。')) return;
+ try {
+ const res = await fetch(API + '/api/tasks/' + TASK_ID + '/skills/' + encodeURIComponent(name), { method: 'DELETE', headers: authHeaders() });
+ const d = await res.json();
+ if (!res.ok || !d.ok) throw new Error(d.error || ('HTTP ' + res.status));
+ term && term.writeln('\x1b[33m🗑 已删除 skill: ' + name + '\x1b[0m');
+ loadSkills();
+ } catch (e) { alert('删除失败: ' + e.message); }
+ }
+
+ async function notifySkill(name) {
+ try {
+ const res = await fetch(API + '/api/tasks/' + TASK_ID + '/skills/' + encodeURIComponent(name) + '/notify', { method: 'POST', headers: authHeaders() });
+ const d = await res.json();
+ if (!res.ok || !d.ok || !d.notified) throw new Error(d.error || '通知失败');
+ term && term.writeln('\x1b[32m✓ 已向当前会话注入 skill 通知: ' + name + '\x1b[0m');
+ closeSkillsModal();
+ } catch (e) { alert('热加载失败: ' + e.message); }
+ }
+
// ✕ 清空
pasteClear.onclick = () => {
pasteInput.value = '';