strip = False
dest.mkdir(parents=True, exist_ok=True)
for info, name, p in entries:
- rel = name[len(top) + 1:] if (strip and top) else name
+ # 仅对确实以顶层目录开头的条目去掉前缀;根级文件(如 SKILL.md)原样保留,
+ # 避免“根级文件 + 单个子目录”混合结构时被错误地裁掉前缀(SKILL.md → .md)。
+ if strip and top and name.startswith(top + "/"):
+ rel = name[len(top) + 1:]
+ else:
+ rel = name
rel = rel.rstrip("/")
if rel == "":
continue
raise ValueError("不是有效的 zip 文件")
+# ---- 共享逻辑:任务级与全局 skill 管理都调用这些 helper ----
+def _do_list_skills():
+ return JSONResponse({"ok": True, "skills": _list_skills(), "root": str(SKILLS_ROOT)})
+
+
+def _do_upload_skill(body: dict):
+ try:
+ zf = _decode_zip_body(body)
+ except ValueError as e:
+ return JSONResponse({"error": str(e)}, status_code=400)
+ entries = _real_entries(zf)
+ tops = set(p[0] for info, name, p in entries if len(p) > 1 and p[0])
+ provided = body.get("name")
+ if provided:
+ name = provided
+ else:
+ # 优先用根目录 SKILL.md frontmatter 的 name(最权威);
+ # 根级无 SKILL.md 时(如整目录打包 my-skill/SKILL.md)才退回用顶层目录名,
+ # 否则退回上传文件名。这样“根级 SKILL.md + 单个子目录”的混合结构也能正确命名。
+ fm_name = _skill_name_from_zip(zf)
+ if fm_name:
+ name = fm_name
+ elif len(tops) == 1:
+ name = tops.pop()
+ else:
+ fallback = (body.get("filename") or "skill").rsplit(".", 1)[0]
+ name = fallback
+ 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)})
+
+
+def _do_update_skill(name: str, body: dict):
+ p = _safe_skill_path(name)
+ if p is None:
+ return JSONResponse({"error": "skill 名称非法"}, 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"})
+ 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"})
+
+
+def _do_delete_skill(name: str):
+ 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.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)})
+ return _do_list_skills()
@app.get("/api/tasks/{task_id}/skills/{name}/content")
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)
- entries = _real_entries(zf)
- tops = set(p[0] for info, name, p in entries if len(p) > 1 and p[0])
- provided = body.get("name")
- if provided:
- name = provided
- elif len(tops) == 1:
- name = tops.pop()
- else:
- # 扁平结构(0 个或多个顶层目录,如技能内容直接压缩):优先用 SKILL.md frontmatter 的 name
- fallback = (body.get("filename") or "skill").rsplit(".", 1)[0]
- name = _skill_name_from_zip(zf) or fallback
- 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)})
+ return _do_upload_skill(body)
@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)
+ try:
+ body = await request.json()
+ except Exception:
+ return JSONResponse({"error": "invalid json body"}, status_code=400)
+ return _do_update_skill(name, body)
@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)
+ return _do_delete_skill(name)
+
+
+# ---- 全局接口(任务列表页使用,不依赖某个任务)----
+@app.get("/api/skills")
+async def api_global_list_skills(request: Request):
+ if not _auth_ok(request):
+ return JSONResponse({"error": "unauthorized"}, status_code=401)
+ return _do_list_skills()
+
+
+@app.get("/api/skills/{name}/content")
+async def api_global_skill_content(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})
+ 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/skills")
+async def api_global_upload_skill(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)
+ return _do_upload_skill(body)
+
+
+@app.put("/api/skills/{name}")
+async def api_global_update_skill(name: 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)
+ return _do_update_skill(name, body)
+
+
+@app.delete("/api/skills/{name}")
+async def api_global_delete_skill(name: str, request: Request):
+ if not _auth_ok(request):
+ return JSONResponse({"error": "unauthorized"}, status_code=401)
+ return _do_delete_skill(name)
@app.post("/api/tasks/{task_id}/skills/{name}/notify")
<h2>任务清单 <small id="picker-subtitle"></small></h2>
<div style="display:flex; gap:16px; align-items:center;">
<label class="picker-toggle"><input type="checkbox" id="show-all-tasks"> 显示全部任务</label>
+ <button class="action-btn" id="btn-skills-picker" title="管理自定义 skill(全局)">🧩 技能</button>
<button class="action-btn" id="btn-logout-picker">退出登录</button>
</div>
</div>
<input type="file" id="paste-file-input" accept="image/*" style="display:none;">
</div>
</div>
- <!-- 技能管理 modal -->
+ <!-- 技能管理 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;">ä¸\8aä¼ æ\95´ç\9b®å½\95 zipï¼\88须以å\8d\95个 skill ç\9b®å½\95为顶å±\82ï¼\8cå\86\85å\90« <code style="color:#4ecca3;">SKILL.md</code>ï¼\89ã\80\82æ\96°æ\8a\80è\83½å¯¹æ\89\80æ\9c\89ä¼\9aè¯\9då\8f¯è§\81ï¼\9bå½\93å\89\8dè¿\90è¡\8cä¸ç\9a\84ä¼\9aè¯\9då\8f¯ç\82¹ã\80\8cé\80\9aç\9f¥å\8a è½½ã\80\8d热加载,否则重启/重连任务后由 codebuddy 自动发现。</div>
+ <div style="font-size:12px; color:#999; margin-bottom:10px; line-height:1.5;">ä¸\8aä¼ æ\95´ç\9b®å½\95 zipï¼\88须以å\8d\95个 skill ç\9b®å½\95为顶å±\82ï¼\8cå\86\85å\90« <code style="color:#4ecca3;">SKILL.md</code>ï¼\89ã\80\82æ\96°æ\8a\80è\83½å¯¹æ\89\80æ\9c\89ä¼\9aè¯\9då\85¨å±\80å\8f¯è§\81ï¼\9bè¿\90è¡\8cä¸ç\9a\84ä¼\9aè¯\9då\8f¯ç\82¹ã\80\8cå\8a è½½ã\80\8dé\80\89æ\8b©ä¼\9aè¯\9d热加载,否则重启/重连任务后由 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>
<button class="send-btn" id="skills-edit-save">保存</button>
</div>
</div>
+ <div id="skills-load" 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-load-name" style="color:#4ecca3;"></code></div>
+ <div id="skills-load-list" style="display:flex; flex-direction:column; gap:6px; max-height:200px; overflow:auto;"></div>
+ <div style="display:flex; gap:8px; justify-content:flex-end; margin-top:10px;">
+ <button class="del-btn del-cancel" id="skills-load-cancel">取消</button>
+ <button class="send-btn" id="skills-load-confirm">确定加载</button>
+ </div>
+ </div>
</div>
</div>
</div>
const skillsEditArea = document.getElementById('skills-edit-area');
const skillsEditName = document.getElementById('skills-edit-name');
let skillsEditTarget = null;
+ const skillsLoad = document.getElementById('skills-load');
+ const skillsLoadList = document.getElementById('skills-load-list');
+ const skillsLoadName = document.getElementById('skills-load-name');
+ let skillsLoadTarget = null;
function openSkillsModal() {
skillsEdit.style.display = 'none';
+ skillsLoad.style.display = 'none';
skillsModal.style.display = 'flex';
loadSkills();
}
if (term && term.focus) term.focus();
}
document.getElementById('btn-skills').onclick = openSkillsModal;
+ document.getElementById('btn-skills-picker').onclick = openSkillsModal;
document.getElementById('skills-close').onclick = closeSkillsModal;
skillsModal.addEventListener('click', (e) => { if (e.target === skillsModal) closeSkillsModal(); });
document.addEventListener('keydown', (e) => {
async function loadSkills() {
try {
- const res = await fetch(API + '/api/tasks/' + TASK_ID + '/skills', { headers: authHeaders() });
+ const res = await fetch(API + '/api/skills', { headers: authHeaders() });
if (!res.ok) return;
const data = await res.json();
const skills = (data.skills || []);
+ (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>'
+ + (MODE === 'terminal'
+ ? '<button class="del-btn del-cancel" data-act="notify" data-name="' + escHtml(s.dir_name) + '">通知加载</button>'
+ : '<button class="del-btn del-cancel" data-act="load" 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('');
if (act === 'edit') editSkill(n);
else if (act === 'del') deleteSkill(n);
else if (act === 'notify') notifySkill(n);
+ else if (act === 'load') loadSkillToSession(n);
};
});
} catch (e) { console.warn('loadSkills failed', e); }
if (!f) { alert('请先选择一个 .zip 文件'); return; }
try {
const b64 = await readFileAsBase64(f);
- const res = await fetch(API + '/api/tasks/' + TASK_ID + '/skills', {
+ const res = await fetch(API + '/api/skills', {
method: 'POST',
headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()),
body: JSON.stringify({ data: b64 })
async function editSkill(name) {
try {
- const res = await fetch(API + '/api/tasks/' + TASK_ID + '/skills', { headers: authHeaders() });
+ const res = await fetch(API + '/api/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 res2 = await fetch(API + '/api/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;
if (!skillsEditTarget) return;
const name = skillsEditTarget;
try {
- const res = await fetch(API + '/api/tasks/' + TASK_ID + '/skills/' + encodeURIComponent(name), {
+ const res = await fetch(API + '/api/skills/' + encodeURIComponent(name), {
method: 'PUT',
headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()),
body: JSON.stringify({ content: skillsEditArea.value })
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 res = await fetch(API + '/api/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');
} catch (e) { alert('热加载失败: ' + e.message); }
}
+ // 首页“加载”:选择运行中的会话热加载
+ async function loadSkillToSession(name) {
+ try {
+ const res = await fetch(API + '/api/tasks', { headers: authHeaders() });
+ const data = await res.json();
+ const tasks = data.tasks || [];
+ const living = tasks.filter(t => ['connected', 'background', 'background_idle'].includes(t.live_status));
+ if (!living.length) {
+ alert('当前没有运行中的会话,技能已全局就位,所有会话下次打开时将自动发现此技能。');
+ return;
+ }
+ skillsLoadTarget = name;
+ skillsLoadName.textContent = name;
+ skillsLoadList.innerHTML = living.map(t =>
+ '<label style="display:flex; gap:8px; align-items:center; font-size:13px; color:#e0e0e0;">'
+ + '<input type="checkbox" class="skills-load-cb" value="' + escHtml(t.id) + '">'
+ + escHtml(t.name) + ' <span style="color:#888;">[' + escHtml(t.live_status) + ']</span></label>'
+ ).join('')
+ + '<label style="display:flex; gap:8px; align-items:center; font-size:13px; color:#4ecca3; margin-top:4px;"><input type="checkbox" id="skills-load-all"> 全部运行中的会话</label>';
+ skillsEdit.style.display = 'none';
+ skillsLoad.style.display = 'block';
+ } catch (e) { alert('加载失败: ' + e.message); }
+ }
+
+ document.getElementById('skills-load-cancel').onclick = () => { skillsLoad.style.display = 'none'; skillsLoadTarget = null; };
+ document.getElementById('skills-load-confirm').onclick = async () => {
+ if (!skillsLoadTarget) return;
+ const name = skillsLoadTarget;
+ const all = document.getElementById('skills-load-all').checked;
+ const cbs = Array.from(skillsLoadList.querySelectorAll('.skills-load-cb'));
+ const ids = all ? cbs.map(c => c.value) : cbs.filter(c => c.checked).map(c => c.value);
+ if (!ids.length) { alert('请至少选择一个会话'); return; }
+ let ok = 0, fail = 0;
+ for (const tid of ids) {
+ try {
+ const res = await fetch(API + '/api/tasks/' + tid + '/skills/' + encodeURIComponent(name) + '/notify', { method: 'POST', headers: authHeaders() });
+ const d = await res.json();
+ if (res.ok && d.ok && d.notified) ok++; else fail++;
+ } catch (e) { fail++; }
+ }
+ alert('已向 ' + ok + ' 个会话注入热加载通知' + (fail ? (',' + fail + ' 个失败') : '') + '。');
+ skillsLoad.style.display = 'none'; skillsLoadTarget = null;
+ loadSkills();
+ };
+
// ✕ 清空
pasteClear.onclick = () => {
pasteInput.value = '';