' +
+ slashFiltered.map((c, i) =>
+ ``
+ ).join('');
+
+ slashMenu.querySelectorAll('.slash-menu-item').forEach(el => {
+ el.addEventListener('mouseenter', () => {
+ slashActiveIdx = parseInt(el.dataset.idx);
+ renderSlashItems();
+ });
+ el.addEventListener('mousedown', (e) => {
+ e.preventDefault();
+ selectSlashCommand(parseInt(el.dataset.idx));
+ });
+ });
+
+ const activeEl = slashMenu.querySelector('.slash-menu-item.active');
+ if (activeEl) activeEl.scrollIntoView({ block: 'nearest' });
+}
+
+function selectSlashCommand(idx) {
+ if (idx < 0 || idx >= slashFiltered.length) return;
+ const chosen = slashFiltered[idx].cmd;
+ slashJustSelected = true;
+ chatInput.value = chosen;
+ chatInput.dispatchEvent(new Event('input'));
+ hideSlashMenu();
+ chatInput.focus();
+ chatInput.selectionStart = chatInput.selectionEnd = chosen.length;
+}
+
chatInput.addEventListener('input', function() {
this.style.height = '42px';
const scrollH = this.scrollHeight;
@@ -442,11 +535,50 @@ chatInput.addEventListener('input', function() {
this.style.height = newH + 'px';
this.style.overflowY = scrollH > 180 ? 'auto' : 'hidden';
updateSendBtnState();
+
+ const val = this.value;
+ if (slashJustSelected) {
+ slashJustSelected = false;
+ } else if (val.startsWith('/')) {
+ showSlashMenu(val);
+ } else {
+ hideSlashMenu();
+ }
});
chatInput.addEventListener('keydown', function(e) {
- // keyCode 229 indicates an IME is processing the keystroke (reliable across browsers)
if (e.keyCode === 229 || e.isComposing || isComposing) return;
+
+ if (isSlashMenuVisible()) {
+ if (e.key === 'ArrowDown') {
+ e.preventDefault();
+ slashActiveIdx = Math.min(slashActiveIdx + 1, slashFiltered.length - 1);
+ renderSlashItems();
+ return;
+ }
+ if (e.key === 'ArrowUp') {
+ e.preventDefault();
+ slashActiveIdx = Math.max(slashActiveIdx - 1, 0);
+ renderSlashItems();
+ return;
+ }
+ if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey) {
+ e.preventDefault();
+ selectSlashCommand(slashActiveIdx);
+ return;
+ }
+ if (e.key === 'Escape') {
+ e.preventDefault();
+ hideSlashMenu();
+ return;
+ }
+ if (e.key === 'Tab') {
+ e.preventDefault();
+ selectSlashCommand(slashActiveIdx);
+ return;
+ }
+ }
+
if ((e.ctrlKey || e.shiftKey) && e.key === 'Enter') {
const start = this.selectionStart;
const end = this.selectionEnd;
@@ -460,6 +592,10 @@ chatInput.addEventListener('keydown', function(e) {
}
});
+chatInput.addEventListener('blur', () => {
+ setTimeout(hideSlashMenu, 150);
+});
+
document.querySelectorAll('.example-card').forEach(card => {
card.addEventListener('click', () => {
const textEl = card.querySelector('[data-i18n*="text"]');
diff --git a/cli/cli.py b/cli/cli.py
index 9e01af98..14830747 100644
--- a/cli/cli.py
+++ b/cli/cli.py
@@ -19,7 +19,6 @@ Commands:
restart Restart CowAgent.
status Show CowAgent running status.
logs View CowAgent logs.
- context View or manage conversation context.
skill Manage CowAgent skills.
Tip: You can also send /help, /skill list, etc. in agent chat."""
diff --git a/plugins/cow_cli/__init__.py b/plugins/cow_cli/__init__.py
new file mode 100644
index 00000000..a535239f
--- /dev/null
+++ b/plugins/cow_cli/__init__.py
@@ -0,0 +1 @@
+from .cow_cli import CowCliPlugin
diff --git a/plugins/cow_cli/cow_cli.py b/plugins/cow_cli/cow_cli.py
new file mode 100644
index 00000000..ffbe5a4f
--- /dev/null
+++ b/plugins/cow_cli/cow_cli.py
@@ -0,0 +1,904 @@
+"""
+CowCli plugin - Intercept cow/slash commands in chat messages.
+
+Matches messages like:
+ cow skill list
+ cow context clear
+ /skill list
+ /context clear
+ /status
+
+Does NOT match:
+ cow是什么
+ cow真好用
+ /开头但不是已知命令
+"""
+
+import os
+import threading
+
+import plugins
+from plugins import Plugin, Event, EventContext, EventAction
+from bridge.context import ContextType
+from bridge.reply import Reply, ReplyType
+from common.log import logger
+from cli import __version__
+
+
+# Known top-level subcommands that cow supports
+KNOWN_COMMANDS = {
+ "help", "version", "status", "logs",
+ "start", "stop", "restart",
+ "skill", "context", "config",
+}
+
+# Commands that can only run from the CLI (terminal), not in chat
+CLI_ONLY_COMMANDS = {"start", "stop", "restart"}
+
+# Commands that can only run from chat (need access to in-process memory)
+CHAT_ONLY_COMMANDS = set() # context is allowed in both, but behaves differently
+
+
+@plugins.register(
+ name="cow_cli",
+ desc="Handle cow/slash commands in chat messages",
+ version="0.1.0",
+ author="CowAgent",
+ desire_priority=1000,
+)
+class CowCliPlugin(Plugin):
+
+ def __init__(self):
+ super().__init__()
+ self.handlers[Event.ON_HANDLE_CONTEXT] = self.on_handle_context
+ logger.debug("[CowCli] initialized")
+
+ def on_handle_context(self, e_context: EventContext):
+ if e_context["context"].type != ContextType.TEXT:
+ return
+
+ content = e_context["context"].content.strip()
+ parsed = self._parse_command(content)
+ if not parsed:
+ return
+
+ cmd, args = parsed
+ logger.info(f"[CowCli] intercepted command: {cmd} {args}")
+
+ result = self._dispatch(cmd, args, e_context)
+
+ reply = Reply(ReplyType.TEXT, result)
+ e_context["reply"] = reply
+ e_context.action = EventAction.BREAK_PASS
+
+ def _parse_command(self, content: str):
+ """
+ Parse cow command from message text.
+
+ Supported formats:
+ cow
[args...] e.g. "cow skill list"
+ / [args...] e.g. "/skill list"
+
+ Returns (command, args_string) or None if not a cow command.
+ """
+ parts = None
+
+ if content.startswith("/"):
+ rest = content[1:].strip()
+ if rest:
+ parts = rest.split(None, 1)
+ elif content.startswith("cow "):
+ rest = content[4:].strip()
+ if rest:
+ parts = rest.split(None, 1)
+
+ if not parts:
+ return None
+
+ cmd = parts[0].lower()
+ if cmd not in KNOWN_COMMANDS:
+ return None
+
+ args = parts[1] if len(parts) > 1 else ""
+ return cmd, args
+
+ # ------------------------------------------------------------------
+ # Command dispatch
+ # ------------------------------------------------------------------
+
+ def _dispatch(self, cmd: str, args: str, e_context: EventContext) -> str:
+ if cmd in CLI_ONLY_COMMANDS:
+ return f"⚠️ `cow {cmd}` 只能在命令行终端中执行。\n请在终端运行: cow {cmd}"
+
+ handler = getattr(self, f"_cmd_{cmd}", None)
+ if handler:
+ try:
+ return handler(args, e_context)
+ except Exception as e:
+ logger.error(f"[CowCli] command '{cmd}' failed: {e}")
+ return f"命令执行失败: {e}"
+
+ return f"未知命令: {cmd}"
+
+ # ------------------------------------------------------------------
+ # help / version
+ # ------------------------------------------------------------------
+
+ def _cmd_help(self, args: str, e_context: EventContext) -> str:
+ lines = [
+ "📋 CowAgent 命令列表",
+ "",
+ " /help 显示此帮助",
+ " /version 查看版本",
+ " /status 查看运行状态",
+ " /logs [N] 查看最近N条日志 (默认20)",
+ " /context 查看当前对话上下文信息",
+ " /context clear 清除当前对话上下文",
+ " /skill list 查看已安装的技能",
+ " /skill list --remote 浏览技能广场",
+ " /skill search <关键词> 搜索技能",
+ " /skill install <名称> 安装技能",
+ " /skill info <名称> 查看技能详情",
+ " /config 查看当前配置",
+ " /config 查看某项配置",
+ " /config 修改配置",
+ "",
+ "💡 也可以用 cow 代替 /",
+ ]
+ return "\n".join(lines)
+
+ def _cmd_version(self, args: str, e_context: EventContext) -> str:
+ return f"CowAgent v{__version__}"
+
+ # ------------------------------------------------------------------
+ # status
+ # ------------------------------------------------------------------
+
+ def _cmd_status(self, args: str, e_context: EventContext) -> str:
+ from config import conf
+
+ cfg = conf()
+ lines = ["📊 CowAgent 运行状态", ""]
+
+ lines.append(f" 版本: v{__version__}")
+ lines.append(f" 进程: PID {os.getpid()}")
+
+ channel = cfg.get("channel_type", "unknown")
+ if isinstance(channel, list):
+ channel = ", ".join(channel)
+ lines.append(f" 通道: {channel}")
+
+ model_name = cfg.get("model", "unknown")
+ lines.append(f" 模型: {model_name}")
+
+ mode = "Agent" if cfg.get("agent") else "Chat"
+ lines.append(f" 模式: {mode}")
+
+ session_id = self._get_session_id(e_context)
+ agent = self._get_agent(session_id)
+ if agent:
+ lines.append("")
+ with agent.messages_lock:
+ msg_count = len(agent.messages)
+ lines.append(f" 会话消息数: {msg_count}")
+
+ if agent.skill_manager:
+ total = len(agent.skill_manager.skills)
+ enabled = sum(
+ 1 for v in agent.skill_manager.skills_config.values()
+ if v.get("enabled", True)
+ )
+ lines.append(f" 已加载技能: {enabled}/{total}")
+ else:
+ lines.append("")
+ lines.append(f" Agent: 未初始化 (首次对话后自动创建)")
+
+ return "\n".join(lines)
+
+ # ------------------------------------------------------------------
+ # logs
+ # ------------------------------------------------------------------
+
+ def _cmd_logs(self, args: str, e_context: EventContext) -> str:
+ num_lines = 20
+ if args.strip().isdigit():
+ num_lines = min(int(args.strip()), 50)
+
+ log_file = self._find_log_file()
+ if not log_file:
+ return "未找到日志文件"
+
+ try:
+ with open(log_file, "r", encoding="utf-8", errors="replace") as f:
+ all_lines = f.readlines()
+ tail = all_lines[-num_lines:]
+ content = "".join(tail).strip()
+ if not content:
+ return "日志为空"
+ return f"📄 最近 {len(tail)} 条日志:\n\n{content}"
+ except Exception as e:
+ return f"读取日志失败: {e}"
+
+ def _find_log_file(self) -> str:
+ project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+ candidates = [
+ os.path.join(project_root, "nohup.out"),
+ os.path.join(project_root, "run.log"),
+ ]
+ import glob as glob_mod
+ candidates.extend(sorted(glob_mod.glob(os.path.join(project_root, "logs", "*.log")), reverse=True))
+ for f in candidates:
+ if os.path.isfile(f) and os.path.getsize(f) > 0:
+ return f
+ return ""
+
+ # ------------------------------------------------------------------
+ # context
+ # ------------------------------------------------------------------
+
+ def _cmd_context(self, args: str, e_context: EventContext) -> str:
+ session_id = self._get_session_id(e_context)
+ agent = self._get_agent(session_id)
+
+ sub = args.strip().lower()
+ if sub == "clear":
+ return self._context_clear(agent, session_id)
+ else:
+ return self._context_info(agent, session_id)
+
+ def _context_info(self, agent, session_id: str) -> str:
+ if not agent:
+ return "⚠️ Agent 未初始化,暂无上下文信息"
+
+ with agent.messages_lock:
+ messages = agent.messages.copy()
+
+ if not messages:
+ return "当前对话上下文为空"
+
+ user_msgs = sum(1 for m in messages if m.get("role") == "user")
+ assistant_msgs = sum(1 for m in messages if m.get("role") == "assistant")
+ tool_msgs = sum(1 for m in messages if m.get("role") == "tool")
+
+ total_chars = sum(len(str(m.get("content", ""))) for m in messages)
+
+ lines = [
+ "💬 当前对话上下文",
+ "",
+ f" 会话: {session_id or 'default'}",
+ f" 总消息数: {len(messages)}",
+ f" 用户消息: {user_msgs}",
+ f" 助手回复: {assistant_msgs}",
+ f" 工具调用: {tool_msgs}",
+ f" 内容总长度: ~{total_chars} 字符",
+ "",
+ " 发送 /context clear 可清除对话上下文",
+ ]
+ return "\n".join(lines)
+
+ def _context_clear(self, agent, session_id: str) -> str:
+ if not agent:
+ return "⚠️ Agent 未初始化"
+
+ with agent.messages_lock:
+ count = len(agent.messages)
+ agent.messages.clear()
+
+ return f"✅ 已清除当前对话上下文 ({count} 条消息)"
+
+ # ------------------------------------------------------------------
+ # config
+ # ------------------------------------------------------------------
+
+ _CONFIG_WRITABLE = {
+ "model",
+ "agent_max_context_tokens",
+ "agent_max_context_turns",
+ "agent_max_steps",
+ }
+
+ _CONFIG_READABLE = _CONFIG_WRITABLE | {"channel_type"}
+
+ def _cmd_config(self, args: str, e_context: EventContext) -> str:
+ from config import conf, load_config
+ import json as _json
+
+ parts = args.strip().split(None, 1)
+ if not parts:
+ return self._config_show_all()
+
+ key = parts[0].lower()
+ if len(parts) == 1:
+ return self._config_get(key)
+
+ value_str = parts[1].strip()
+ return self._config_set(key, value_str)
+
+ def _config_show_all(self) -> str:
+ from config import conf
+ cfg = conf()
+ lines = ["⚙️ 当前配置", ""]
+ for key in sorted(self._CONFIG_READABLE):
+ val = cfg.get(key, "")
+ lines.append(f" {key}: {val}")
+ lines.append("")
+ lines.append("━━━━━━━━━━━━━━━━━━━━━━━━━━")
+ lines.append("💡 /config 查看配置")
+ lines.append("💡 /config 修改配置")
+ return "\n".join(lines)
+
+ def _config_get(self, key: str) -> str:
+ from config import conf
+ if key not in self._CONFIG_READABLE:
+ available = ", ".join(sorted(self._CONFIG_READABLE))
+ return f"不支持查看 '{key}'\n\n可查看的配置项: {available}"
+ val = conf().get(key, "")
+ return f"⚙️ {key}: {val}"
+
+ def _config_set(self, key: str, value_str: str) -> str:
+ from config import conf, load_config
+ import json as _json
+
+ if key not in self._CONFIG_WRITABLE:
+ if key in self._CONFIG_READABLE:
+ return f"⚠️ '{key}' 为只读配置,不支持修改"
+ available = ", ".join(sorted(self._CONFIG_WRITABLE))
+ return f"不支持修改 '{key}'\n\n可修改的配置项: {available}"
+
+ old_val = conf().get(key, "")
+
+ try:
+ new_val = _json.loads(value_str)
+ except (_json.JSONDecodeError, ValueError):
+ if value_str.lower() == "true":
+ new_val = True
+ elif value_str.lower() == "false":
+ new_val = False
+ else:
+ new_val = value_str
+
+ updates = {key: new_val}
+
+ if key == "model" and conf().get("bot_type"):
+ resolved = self._resolve_bot_type_for_model(str(new_val))
+ if resolved:
+ updates["bot_type"] = resolved
+
+ project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+ config_path = os.path.join(project_root, "config.json")
+ try:
+ with open(config_path, "r", encoding="utf-8") as f:
+ file_config = _json.load(f)
+ file_config.update(updates)
+ with open(config_path, "w", encoding="utf-8") as f:
+ _json.dump(file_config, f, indent=4, ensure_ascii=False)
+ except Exception as e:
+ return f"写入 config.json 失败: {e}"
+
+ try:
+ load_config()
+ except Exception as e:
+ logger.warning(f"[CowCli] config reload warning: {e}")
+
+ result = f"✅ 配置已更新\n\n {key}: {old_val} → {new_val}"
+ if "bot_type" in updates and updates["bot_type"] != conf().get("bot_type"):
+ result += f"\n bot_type: → {updates['bot_type']}"
+ return result
+
+ @staticmethod
+ def _resolve_bot_type_for_model(model_name: str) -> str:
+ """Resolve bot_type from model name, reusing AgentBridge mapping."""
+ from common import const
+ _EXACT = {
+ "wenxin": const.BAIDU, "wenxin-4": const.BAIDU,
+ "xunfei": const.XUNFEI, const.QWEN: const.QWEN,
+ const.MODELSCOPE: const.MODELSCOPE,
+ const.MOONSHOT: const.MOONSHOT,
+ "moonshot-v1-8k": const.MOONSHOT, "moonshot-v1-32k": const.MOONSHOT,
+ "moonshot-v1-128k": const.MOONSHOT,
+ }
+ _PREFIX = [
+ ("qwen", const.QWEN_DASHSCOPE), ("qwq", const.QWEN_DASHSCOPE),
+ ("qvq", const.QWEN_DASHSCOPE),
+ ("gemini", const.GEMINI), ("glm", const.ZHIPU_AI),
+ ("claude", const.CLAUDEAPI),
+ ("moonshot", const.MOONSHOT), ("kimi", const.MOONSHOT),
+ ("doubao", const.DOUBAO), ("deepseek", const.DEEPSEEK),
+ ]
+ if not model_name:
+ return const.OPENAI
+ if model_name in _EXACT:
+ return _EXACT[model_name]
+ if model_name.lower().startswith("minimax") or model_name in ["abab6.5-chat"]:
+ return const.MiniMax
+ if model_name in [const.QWEN_TURBO, const.QWEN_PLUS, const.QWEN_MAX]:
+ return const.QWEN_DASHSCOPE
+ for prefix, btype in _PREFIX:
+ if model_name.startswith(prefix):
+ return btype
+ return const.OPENAI
+
+ # ------------------------------------------------------------------
+ # skill
+ # ------------------------------------------------------------------
+
+ def _cmd_skill(self, args: str, e_context: EventContext) -> str:
+ parts = args.strip().split(None, 1)
+ sub = parts[0].lower() if parts else ""
+ sub_args = parts[1].strip() if len(parts) > 1 else ""
+
+ if sub == "list":
+ return self._skill_list(sub_args)
+ elif sub == "search":
+ return self._skill_search(sub_args)
+ elif sub == "install":
+ return self._skill_install(sub_args, e_context)
+ elif sub == "uninstall":
+ return self._skill_uninstall(sub_args)
+ elif sub == "info":
+ return self._skill_info(sub_args)
+ elif sub == "enable":
+ return self._skill_set_enabled(sub_args, True)
+ elif sub == "disable":
+ return self._skill_set_enabled(sub_args, False)
+ else:
+ return (
+ "用法: /skill <子命令>\n\n"
+ "子命令:\n"
+ " list [--remote] 查看技能列表\n"
+ " search <关键词> 搜索技能\n"
+ " install <名称> 安装技能\n"
+ " uninstall <名称> 卸载技能\n"
+ " info <名称> 查看技能详情\n"
+ " enable <名称> 启用技能\n"
+ " disable <名称> 禁用技能"
+ )
+
+ def _skill_list_local(self) -> str:
+ from cli.utils import load_skills_config, get_skills_dir, get_builtin_skills_dir
+ config = load_skills_config()
+
+ if not config:
+ skills_dir = get_skills_dir()
+ builtin_dir = get_builtin_skills_dir()
+ entries = []
+ for d, source in [(builtin_dir, "builtin"), (skills_dir, "custom")]:
+ if not os.path.isdir(d):
+ continue
+ for name in sorted(os.listdir(d)):
+ skill_path = os.path.join(d, name)
+ if os.path.isdir(skill_path) and not name.startswith("."):
+ if os.path.exists(os.path.join(skill_path, "SKILL.md")):
+ entries.append({"name": name, "source": source, "enabled": True})
+ if not entries:
+ return "暂无已安装的技能\n\n💡 /skill list --remote 浏览技能广场"
+ config = {e["name"]: e for e in entries}
+
+ sorted_entries = sorted(config.values(), key=lambda e: e.get("name", ""))
+ enabled_count = sum(1 for e in sorted_entries if e.get("enabled", True))
+
+ lines = [f"📦 已安装的技能 ({enabled_count}/{len(sorted_entries)})", ""]
+ for entry in sorted_entries:
+ name = entry.get("name", "")
+ enabled = entry.get("enabled", True)
+ source = entry.get("source", "")
+ icon = "✅" if enabled else "⏸️"
+ desc = entry.get("description", "")
+ if len(desc) > 50:
+ desc = desc[:47] + "…"
+ source_tag = f" · {source}" if source else ""
+ line = f"{icon} {name}{source_tag}"
+ if desc:
+ line += f"\n {desc}"
+ lines.append(line)
+ lines.append("")
+
+ lines.append("")
+ lines.append("━━━━━━━━━━━━━━━━━━━━━━━━━━")
+ lines.append("💡 /skill list --remote 浏览技能广场")
+ lines.append("💡 /skill info <名称> 查看详情")
+ return "\n".join(lines)
+
+ def _skill_list(self, args: str) -> str:
+ parts = args.strip().split()
+ if "--remote" in parts or "-r" in parts:
+ page = 1
+ for i, p in enumerate(parts):
+ if p == "--page" and i + 1 < len(parts) and parts[i + 1].isdigit():
+ page = max(1, int(parts[i + 1]))
+ return self._skill_list_remote(page=page)
+ return self._skill_list_local()
+
+ _REMOTE_PAGE_SIZE = 10
+
+ def _skill_list_remote(self, page: int = 1) -> str:
+ import requests
+ from cli.utils import SKILL_HUB_API, load_skills_config
+ page_size = self._REMOTE_PAGE_SIZE
+ try:
+ resp = requests.get(
+ f"{SKILL_HUB_API}/skills",
+ params={"page": page, "limit": page_size},
+ timeout=10,
+ )
+ resp.raise_for_status()
+ data = resp.json()
+ skills = data.get("skills", [])
+ total = data.get("total", len(skills))
+ except Exception as e:
+ return f"获取技能广场失败: {e}"
+
+ if not skills and page == 1:
+ return "技能广场暂无可用技能"
+
+ total_pages = max(1, (total + page_size - 1) // page_size)
+ page = min(page, total_pages)
+ installed = set(load_skills_config().keys())
+
+ lines = [f"🌐 技能广场 (共 {total} 个技能)", ""]
+ for s in skills:
+ name = s.get("name", "")
+ display = s.get("display_name", "") or name
+ desc = s.get("description", "")
+ if len(desc) > 50:
+ desc = desc[:47] + "…"
+ badge = " [已安装]" if name in installed else ""
+ lines.append(f"📌 {display}{badge}")
+ lines.append(f" 名称: {name}")
+ if desc:
+ lines.append(f" {desc}")
+ lines.append("")
+
+ lines.append("━━━━━━━━━━━━━━━━━━━━━━━━━━")
+ lines.append(f"📄 第 {page}/{total_pages} 页")
+ if page < total_pages:
+ lines.append(f"💡 /skill list --remote --page {page + 1} 下一页")
+ if page > 1:
+ lines.append(f"💡 /skill list --remote --page {page - 1} 上一页")
+ lines.append("💡 /skill install <名称> 安装技能")
+ lines.append("💡 /skill search <关键词> 搜索技能")
+ return "\n".join(lines)
+
+ def _skill_search(self, query: str) -> str:
+ if not query:
+ return "请指定搜索关键词: /skill search <关键词>"
+
+ import requests
+ from cli.utils import SKILL_HUB_API, load_skills_config
+ try:
+ resp = requests.get(f"{SKILL_HUB_API}/skills/search", params={"q": query}, timeout=10)
+ resp.raise_for_status()
+ skills = resp.json().get("skills", [])
+ except Exception as e:
+ return f"搜索失败: {e}"
+
+ if not skills:
+ return f"未找到与「{query}」相关的技能"
+
+ installed = set(load_skills_config().keys())
+ lines = [f"🔍 搜索「{query}」({len(skills)} 个结果)", ""]
+ for s in skills:
+ name = s.get("name", "")
+ display = s.get("display_name", "") or name
+ desc = s.get("description", "")
+ if len(desc) > 50:
+ desc = desc[:47] + "…"
+ badge = " [已安装]" if name in installed else ""
+ lines.append(f"📌 {display}{badge}")
+ lines.append(f" 名称: {name}")
+ if desc:
+ lines.append(f" {desc}")
+ lines.append("")
+
+ lines.append("━━━━━━━━━━━━━━━━━━━━━━━━━━")
+ lines.append("💡 /skill install <名称> 安装技能")
+ return "\n".join(lines)
+
+ def _skill_install(self, name: str, e_context: EventContext) -> str:
+ if not name:
+ return "请指定要安装的技能: /skill install <名称>"
+
+ # Run installation in a thread to avoid blocking
+ # For now, invoke the CLI logic directly
+ try:
+ from cli.utils import get_skills_dir, SKILL_HUB_API
+ import requests
+ import shutil
+ import zipfile
+ import tempfile
+
+ skills_dir = get_skills_dir()
+ os.makedirs(skills_dir, exist_ok=True)
+
+ if name.startswith("github:"):
+ return self._skill_install_github(name[7:], skills_dir)
+
+ resp = requests.get(f"{SKILL_HUB_API}/skills/{name}/download", timeout=15)
+ resp.raise_for_status()
+
+ content_type = resp.headers.get("Content-Type", "")
+
+ if "application/json" in content_type:
+ data = resp.json()
+ source_type = data.get("source_type")
+ if source_type == "github" or "redirect" in data:
+ source_url = data.get("source_url", "")
+ source_path = data.get("source_path")
+ return self._skill_install_github(source_url, skills_dir, subpath=source_path, skill_name=name)
+ if source_type == "registry":
+ download_url = data.get("download_url")
+ if not download_url:
+ return f"此技能来自不支持的注册表,无法自动安装。"
+ from urllib.parse import urlparse
+ if urlparse(download_url).scheme != "https":
+ return "安装失败: 下载地址不安全 (非 HTTPS)"
+ provider = data.get("source_provider", "registry")
+ try:
+ dl_resp = requests.get(download_url, timeout=60, allow_redirects=True)
+ dl_resp.raise_for_status()
+ except Exception as e:
+ return f"从 {provider} 下载失败: {e}"
+ self._extract_zip(dl_resp.content, name, skills_dir)
+ self._report_install(name)
+ return f"✅ 技能 '{name}' 安装成功!"
+
+ elif "application/zip" in content_type:
+ self._extract_zip(resp.content, name, skills_dir)
+ self._report_install(name)
+ return f"✅ 技能 '{name}' 安装成功!"
+
+ return "技能商店返回了未预期的响应格式"
+
+ except requests.HTTPError as e:
+ if e.response is not None and e.response.status_code == 404:
+ return f"技能 '{name}' 未在技能商店中找到"
+ return f"安装失败: {e}"
+ except Exception as e:
+ return f"安装失败: {e}"
+
+ def _skill_install_github(self, spec: str, skills_dir: str,
+ subpath: str = None, skill_name: str = None) -> str:
+ import requests
+ import shutil
+ import zipfile
+ import tempfile
+
+ if "#" in spec and not subpath:
+ spec, subpath = spec.split("#", 1)
+ if not skill_name:
+ skill_name = subpath.rstrip("/").split("/")[-1] if subpath else spec.split("/")[-1]
+
+ zip_url = f"https://github.com/{spec}/archive/refs/heads/main.zip"
+ try:
+ resp = requests.get(zip_url, timeout=60, allow_redirects=True)
+ resp.raise_for_status()
+ except Exception as e:
+ return f"从 GitHub 下载失败: {e}"
+
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ zip_path = os.path.join(tmp_dir, "repo.zip")
+ with open(zip_path, "wb") as f:
+ f.write(resp.content)
+
+ extract_dir = os.path.join(tmp_dir, "extracted")
+ with zipfile.ZipFile(zip_path, "r") as zf:
+ zf.extractall(extract_dir)
+
+ top_items = [d for d in os.listdir(extract_dir) if not d.startswith(".")]
+ repo_root = extract_dir
+ if len(top_items) == 1 and os.path.isdir(os.path.join(extract_dir, top_items[0])):
+ repo_root = os.path.join(extract_dir, top_items[0])
+
+ if subpath:
+ source_dir = os.path.join(repo_root, subpath.strip("/"))
+ if not os.path.isdir(source_dir):
+ return f"路径 '{subpath}' 在仓库中不存在"
+ else:
+ source_dir = repo_root
+
+ target_dir = os.path.join(skills_dir, skill_name)
+ if os.path.exists(target_dir):
+ import shutil
+ shutil.rmtree(target_dir)
+ import shutil
+ shutil.copytree(source_dir, target_dir)
+
+ self._report_install(skill_name)
+ return f"✅ 技能 '{skill_name}' 安装成功!"
+
+ def _extract_zip(self, content: bytes, name: str, skills_dir: str):
+ import zipfile
+ import tempfile
+ import shutil
+
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ zip_path = os.path.join(tmp_dir, "package.zip")
+ with open(zip_path, "wb") as f:
+ f.write(content)
+
+ extract_dir = os.path.join(tmp_dir, "extracted")
+ with zipfile.ZipFile(zip_path, "r") as zf:
+ zf.extractall(extract_dir)
+
+ top_items = [d for d in os.listdir(extract_dir) if not d.startswith(".")]
+ source = extract_dir
+ if len(top_items) == 1 and os.path.isdir(os.path.join(extract_dir, top_items[0])):
+ source = os.path.join(extract_dir, top_items[0])
+
+ target = os.path.join(skills_dir, name)
+ if os.path.exists(target):
+ shutil.rmtree(target)
+ shutil.copytree(source, target)
+
+ def _report_install(self, name: str):
+ try:
+ import requests
+ from cli.utils import SKILL_HUB_API
+ requests.post(f"{SKILL_HUB_API}/skills/{name}/install", json={}, timeout=5)
+ except Exception:
+ pass
+
+ def _skill_uninstall(self, name: str) -> str:
+ if not name:
+ return "请指定要卸载的技能: /skill uninstall <名称>"
+
+ import shutil
+ import json
+ from cli.utils import get_skills_dir
+
+ skills_dir = get_skills_dir()
+ skill_dir = os.path.join(skills_dir, name)
+
+ if not os.path.exists(skill_dir):
+ skill_dir = self._resolve_skill_dir(name, skills_dir)
+
+ if not skill_dir:
+ return f"技能 '{name}' 未安装"
+
+ shutil.rmtree(skill_dir)
+
+ config_path = os.path.join(skills_dir, "skills_config.json")
+ if os.path.exists(config_path):
+ try:
+ with open(config_path, "r", encoding="utf-8") as f:
+ config = json.load(f)
+ config.pop(name, None)
+ with open(config_path, "w", encoding="utf-8") as f:
+ json.dump(config, f, indent=4, ensure_ascii=False)
+ except Exception:
+ pass
+
+ return f"✅ 技能 '{name}' 已卸载"
+
+ @staticmethod
+ def _resolve_skill_dir(name: str, skills_dir: str):
+ """Find actual directory for a skill whose folder name may differ from its config name."""
+ if not os.path.isdir(skills_dir):
+ return None
+ for entry in os.listdir(skills_dir):
+ entry_path = os.path.join(skills_dir, entry)
+ if not os.path.isdir(entry_path) or entry.startswith("."):
+ continue
+ if entry == name or entry.startswith(name + "-") or entry.endswith("-" + name):
+ skill_md = os.path.join(entry_path, "SKILL.md")
+ if os.path.exists(skill_md):
+ return entry_path
+ return None
+
+ @staticmethod
+ def _strip_frontmatter(content: str):
+ """Strip YAML frontmatter and return (metadata_dict, body)."""
+ if not content.startswith("---"):
+ return {}, content
+ end = content.find("\n---", 3)
+ if end == -1:
+ return {}, content
+ fm_text = content[3:end].strip()
+ body = content[end + 4:].lstrip("\n")
+ meta = {}
+ for line in fm_text.split("\n"):
+ if ":" in line:
+ key, _, val = line.partition(":")
+ meta[key.strip()] = val.strip().strip('"').strip("'")
+ return meta, body
+
+ def _skill_info(self, name: str) -> str:
+ if not name:
+ return "请指定技能名称: /skill info <名称>"
+
+ from cli.utils import get_skills_dir, get_builtin_skills_dir
+
+ skills_dir = get_skills_dir()
+ builtin_dir = get_builtin_skills_dir()
+
+ skill_dir = None
+ source = None
+ for d, src in [(skills_dir, "custom"), (builtin_dir, "builtin")]:
+ candidate = os.path.join(d, name)
+ if os.path.isdir(candidate):
+ skill_dir = candidate
+ source = src
+ break
+
+ if not skill_dir:
+ resolved = self._resolve_skill_dir(name, skills_dir)
+ if resolved:
+ skill_dir = resolved
+ source = "custom"
+
+ if not skill_dir:
+ return f"技能 '{name}' 未找到"
+
+ skill_md = os.path.join(skill_dir, "SKILL.md")
+ if not os.path.exists(skill_md):
+ return f"技能 '{name}' 没有 SKILL.md 文件"
+
+ with open(skill_md, "r", encoding="utf-8") as f:
+ content = f.read()
+
+ meta, body = self._strip_frontmatter(content)
+
+ header_lines = [f"📖 技能: {name} [{source}]", ""]
+ desc = meta.get("description", "")
+ if desc:
+ header_lines.append(f" {desc}")
+ header_lines.append("")
+
+ lines = body.split("\n")
+ preview = "\n".join(lines[:30])
+ result = "\n".join(header_lines) + preview
+ if len(lines) > 30:
+ result += f"\n\n... ({len(lines) - 30} more lines)"
+ return result
+
+ def _skill_set_enabled(self, name: str, enabled: bool) -> str:
+ if not name:
+ action = "启用" if enabled else "禁用"
+ return f"请指定技能名称: /skill {'enable' if enabled else 'disable'} <名称>"
+
+ import json
+ from cli.utils import get_skills_dir
+
+ skills_dir = get_skills_dir()
+ config_path = os.path.join(skills_dir, "skills_config.json")
+
+ if not os.path.exists(config_path):
+ return "技能配置文件不存在"
+
+ try:
+ with open(config_path, "r", encoding="utf-8") as f:
+ config = json.load(f)
+ except Exception as e:
+ return f"读取配置失败: {e}"
+
+ if name not in config:
+ return f"技能 '{name}' 未在配置中找到"
+
+ config[name]["enabled"] = enabled
+ with open(config_path, "w", encoding="utf-8") as f:
+ json.dump(config, f, indent=4, ensure_ascii=False)
+
+ action = "启用" if enabled else "禁用"
+ icon = "✅" if enabled else "⬚"
+ return f"{icon} 技能 '{name}' 已{action}"
+
+ # ------------------------------------------------------------------
+ # Helpers
+ # ------------------------------------------------------------------
+
+ def _get_session_id(self, e_context: EventContext) -> str:
+ context = e_context["context"]
+ return context.kwargs.get("session_id") or context.get("session_id", "")
+
+ def _get_agent(self, session_id: str):
+ try:
+ from bridge.bridge import Bridge
+ bridge = Bridge()
+ if not bridge._agent_bridge:
+ return None
+ return bridge._agent_bridge.get_agent(session_id=session_id or None)
+ except Exception:
+ return None
+
+ def get_help_text(self, **kwargs):
+ return "在对话中使用 /help 或 cow help 查看可用命令"
diff --git a/run.sh b/run.sh
index 7fa00c73..9a2eb088 100755
--- a/run.sh
+++ b/run.sh
@@ -198,7 +198,10 @@ clone_project() {
# Install dependencies
install_dependencies() {
echo -e "${GREEN}📦 Installing dependencies...${NC}"
- local PIP_MIRROR="-i https://pypi.tuna.tsinghua.edu.cn/simple"
+ local PIP_MIRROR=""
+ if curl -s --connect-timeout 5 https://pypi.tuna.tsinghua.edu.cn/simple/ > /dev/null 2>&1; then
+ PIP_MIRROR="-i https://pypi.tuna.tsinghua.edu.cn/simple"
+ fi
PIP_EXTRA_ARGS=""
if $PYTHON_CMD -c "import sys; exit(0 if sys.version_info >= (3, 11) else 1)" 2>/dev/null; then
@@ -541,23 +544,31 @@ start_project() {
echo -e "${GREEN}${EMOJI_ROCKET} Starting CowAgent...${NC}"
sleep 1
- if [ ! -f "${BASE_DIR}/nohup.out" ]; then
- touch "${BASE_DIR}/nohup.out"
+ local USE_COW=false
+ if command -v cow &> /dev/null; then
+ USE_COW=true
fi
- OS_TYPE=$(uname)
-
- if [[ "$OS_TYPE" == "Linux" ]]; then
- # Linux: use setsid to detach from terminal
- nohup setsid $PYTHON_CMD "${BASE_DIR}/app.py" > "${BASE_DIR}/nohup.out" 2>&1 &
- echo -e "${GREEN}${EMOJI_COW} CowAgent started on Linux (using $PYTHON_CMD)${NC}"
- elif [[ "$OS_TYPE" == "Darwin" ]]; then
- # macOS: use nohup to prevent SIGHUP
- nohup $PYTHON_CMD "${BASE_DIR}/app.py" > "${BASE_DIR}/nohup.out" 2>&1 &
- echo -e "${GREEN}${EMOJI_COW} CowAgent started on macOS (using $PYTHON_CMD)${NC}"
+ if $USE_COW; then
+ cd "${BASE_DIR}"
+ cow start --no-logs
else
- echo -e "${RED}❌ Unsupported OS: ${OS_TYPE}${NC}"
- exit 1
+ if [ ! -f "${BASE_DIR}/nohup.out" ]; then
+ touch "${BASE_DIR}/nohup.out"
+ fi
+
+ OS_TYPE=$(uname)
+
+ if [[ "$OS_TYPE" == "Linux" ]]; then
+ nohup setsid $PYTHON_CMD "${BASE_DIR}/app.py" > "${BASE_DIR}/nohup.out" 2>&1 &
+ echo -e "${GREEN}${EMOJI_COW} CowAgent started on Linux (using $PYTHON_CMD)${NC}"
+ elif [[ "$OS_TYPE" == "Darwin" ]]; then
+ nohup $PYTHON_CMD "${BASE_DIR}/app.py" > "${BASE_DIR}/nohup.out" 2>&1 &
+ echo -e "${GREEN}${EMOJI_COW} CowAgent started on macOS (using $PYTHON_CMD)${NC}"
+ else
+ echo -e "${RED}❌ Unsupported OS: ${OS_TYPE}${NC}"
+ exit 1
+ fi
fi
sleep 2
@@ -568,14 +579,21 @@ start_project() {
echo -e "${CYAN}$ACCESS_INFO${NC}"
echo ""
echo -e "${CYAN}${BOLD}Management Commands:${NC}"
- echo -e " ${GREEN}./run.sh stop${NC} Stop the service"
- echo -e " ${GREEN}./run.sh restart${NC} Restart the service"
- echo -e " ${GREEN}./run.sh status${NC} Check status"
- echo -e " ${GREEN}./run.sh logs${NC} View logs"
+ if $USE_COW; then
+ echo -e " ${GREEN}cow stop${NC} Stop the service"
+ echo -e " ${GREEN}cow restart${NC} Restart the service"
+ echo -e " ${GREEN}cow status${NC} Check status"
+ echo -e " ${GREEN}cow logs${NC} View logs"
+ else
+ echo -e " ${GREEN}./run.sh stop${NC} Stop the service"
+ echo -e " ${GREEN}./run.sh restart${NC} Restart the service"
+ echo -e " ${GREEN}./run.sh status${NC} Check status"
+ echo -e " ${GREEN}./run.sh logs${NC} View logs"
+ fi
echo -e " ${GREEN}./run.sh update${NC} Update and restart"
echo -e "${CYAN}${BOLD}=========================================${NC}"
echo ""
-
+
echo -e "${YELLOW}Showing recent logs (Ctrl+C to exit, agent keeps running):${NC}"
sleep 2
tail -n 30 -f "${BASE_DIR}/nohup.out"
@@ -625,94 +643,122 @@ is_running() {
[ -n "$(get_pid)" ]
}
+# Check if cow CLI is available
+has_cow() {
+ command -v cow &> /dev/null
+}
+
# Start service
cmd_start() {
- # Check if config.json exists
if [ ! -f "${BASE_DIR}/config.json" ]; then
echo -e "${RED}${EMOJI_CROSS} config.json not found${NC}"
echo -e "${YELLOW}Please run './run.sh' to configure first${NC}"
exit 1
fi
-
- if is_running; then
- echo -e "${YELLOW}${EMOJI_WARN} CowAgent is already running (PID: $(get_pid))${NC}"
- echo -e "${YELLOW}Use './run.sh restart' to restart${NC}"
- return
+
+ if has_cow; then
+ cd "${BASE_DIR}"
+ cow start
+ else
+ if is_running; then
+ echo -e "${YELLOW}${EMOJI_WARN} CowAgent is already running (PID: $(get_pid))${NC}"
+ echo -e "${YELLOW}Use './run.sh restart' to restart${NC}"
+ return
+ fi
+ check_python_version
+ start_project
fi
-
- check_python_version
- start_project
}
# Stop service
cmd_stop() {
- echo -e "${GREEN}${EMOJI_STOP} Stopping CowAgent...${NC}"
+ if has_cow; then
+ cd "${BASE_DIR}"
+ cow stop
+ else
+ echo -e "${GREEN}${EMOJI_STOP} Stopping CowAgent...${NC}"
- if ! is_running; then
- echo -e "${YELLOW}${EMOJI_WARN} CowAgent is not running${NC}"
- return
+ if ! is_running; then
+ echo -e "${YELLOW}${EMOJI_WARN} CowAgent is not running${NC}"
+ return
+ fi
+
+ pid=$(get_pid)
+ if [ -z "$pid" ] || ! echo "$pid" | grep -qE '^[0-9]+$'; then
+ echo -e "${RED}❌ Failed to get valid PID (got: ${pid})${NC}"
+ return 1
+ fi
+
+ echo -e "${GREEN}Found running process (PID: ${pid})${NC}"
+
+ kill ${pid}
+ sleep 3
+
+ if ps -p ${pid} > /dev/null 2>&1; then
+ echo -e "${YELLOW}⚠️ Process not stopped, forcing termination...${NC}"
+ kill -9 ${pid}
+ fi
+
+ echo -e "${GREEN}${EMOJI_CHECK} CowAgent stopped${NC}"
fi
-
- pid=$(get_pid)
- if [ -z "$pid" ] || ! echo "$pid" | grep -qE '^[0-9]+$'; then
- echo -e "${RED}❌ Failed to get valid PID (got: ${pid})${NC}"
- return 1
- fi
-
- echo -e "${GREEN}Found running process (PID: ${pid})${NC}"
-
- kill ${pid}
- sleep 3
-
- if ps -p ${pid} > /dev/null 2>&1; then
- echo -e "${YELLOW}⚠️ Process not stopped, forcing termination...${NC}"
- kill -9 ${pid}
- fi
-
- echo -e "${GREEN}${EMOJI_CHECK} CowAgent stopped${NC}"
}
# Restart service
cmd_restart() {
- cmd_stop
- sleep 1
- cmd_start
+ if has_cow; then
+ cd "${BASE_DIR}"
+ cow restart
+ else
+ cmd_stop
+ sleep 1
+ cmd_start
+ fi
}
# Check status
cmd_status() {
- echo -e "${CYAN}${BOLD}=========================================${NC}"
- echo -e "${CYAN}${BOLD} ${EMOJI_COW} CowAgent Status${NC}"
- echo -e "${CYAN}${BOLD}=========================================${NC}"
-
- if is_running; then
- pid=$(get_pid)
- echo -e "${GREEN}Status:${NC} ✅ Running"
- echo -e "${GREEN}PID:${NC} ${pid}"
- if [ -f "${BASE_DIR}/nohup.out" ]; then
- echo -e "${GREEN}Logs:${NC} ${BASE_DIR}/nohup.out"
- fi
+ if has_cow; then
+ cd "${BASE_DIR}"
+ cow status
else
- echo -e "${YELLOW}Status:${NC} ⭐ Stopped"
+ echo -e "${CYAN}${BOLD}=========================================${NC}"
+ echo -e "${CYAN}${BOLD} ${EMOJI_COW} CowAgent Status${NC}"
+ echo -e "${CYAN}${BOLD}=========================================${NC}"
+
+ if is_running; then
+ pid=$(get_pid)
+ echo -e "${GREEN}Status:${NC} ✅ Running"
+ echo -e "${GREEN}PID:${NC} ${pid}"
+ if [ -f "${BASE_DIR}/nohup.out" ]; then
+ echo -e "${GREEN}Logs:${NC} ${BASE_DIR}/nohup.out"
+ fi
+ else
+ echo -e "${YELLOW}Status:${NC} ⭐ Stopped"
+ fi
+
+ if [ -f "${BASE_DIR}/config.json" ]; then
+ model=$(grep -o '"model"[[:space:]]*:[[:space:]]*"[^"]*"' "${BASE_DIR}/config.json" | cut -d'"' -f4)
+ channel=$(grep -o '"channel_type"[[:space:]]*:[[:space:]]*"[^"]*"' "${BASE_DIR}/config.json" | cut -d'"' -f4)
+ echo -e "${GREEN}Model:${NC} ${model}"
+ echo -e "${GREEN}Channel:${NC} ${channel}"
+ fi
+
+ echo -e "${CYAN}${BOLD}=========================================${NC}"
fi
-
- if [ -f "${BASE_DIR}/config.json" ]; then
- model=$(grep -o '"model"[[:space:]]*:[[:space:]]*"[^"]*"' "${BASE_DIR}/config.json" | cut -d'"' -f4)
- channel=$(grep -o '"channel_type"[[:space:]]*:[[:space:]]*"[^"]*"' "${BASE_DIR}/config.json" | cut -d'"' -f4)
- echo -e "${GREEN}Model:${NC} ${model}"
- echo -e "${GREEN}Channel:${NC} ${channel}"
- fi
-
- echo -e "${CYAN}${BOLD}=========================================${NC}"
}
# View logs
cmd_logs() {
- if [ -f "${BASE_DIR}/nohup.out" ]; then
- echo -e "${YELLOW}Viewing logs (Ctrl+C to exit):${NC}"
- tail -f "${BASE_DIR}/nohup.out"
+ if has_cow; then
+ cd "${BASE_DIR}"
+ cow logs -f
else
- echo -e "${RED}❌ Log file not found: ${BASE_DIR}/nohup.out${NC}"
+ if [ -f "${BASE_DIR}/nohup.out" ]; then
+ echo -e "${YELLOW}Viewing logs (Ctrl+C to exit):${NC}"
+ tail -f "${BASE_DIR}/nohup.out"
+ else
+ echo -e "${RED}❌ Log file not found: ${BASE_DIR}/nohup.out${NC}"
+ fi
fi
}