diff --git a/.gitignore b/.gitignore index 75b412ca..44fc64fc 100644 --- a/.gitignore +++ b/.gitignore @@ -33,11 +33,15 @@ plugins/banwords/lib/__pycache__ !plugins/keyword !plugins/linkai !plugins/agent +!plugins/cow_cli client_config.json ref/ .cursor/ local/ +node_modules/ # cow cli -cowagent.egg-info/ dist/ +build/ +*.egg-info/ +.cow.pid diff --git a/agent/prompt/builder.py b/agent/prompt/builder.py index 486b6587..ffc27606 100644 --- a/agent/prompt/builder.py +++ b/agent/prompt/builder.py @@ -199,7 +199,7 @@ def _build_tooling_section(tools: List[Any], language: str) -> List[str]: tool_lines.append(f"- {name}: {summary}" if summary else f"- {name}") lines = [ - "## 工具系统", + "## 🔧 工具系统", "", "可用工具(名称大小写敏感,严格按列表调用):", "\n".join(tool_lines), @@ -231,7 +231,7 @@ def _build_skills_section(skill_manager: Any, tools: Optional[List[Any]], langua break lines = [ - "## 技能系统(mandatory)", + "## 🧩 技能系统(mandatory)", "", "在回复之前:扫描下方 中每个技能的 。", "", @@ -281,7 +281,7 @@ def _build_memory_section(memory_manager: Any, tools: Optional[List[Any]], langu today_file = datetime.now().strftime("%Y-%m-%d") + ".md" lines = [ - "## 记忆系统", + "## 🧠 记忆系统", "", "### 检索记忆", "", @@ -325,7 +325,7 @@ def _build_user_identity_section(user_identity: Dict[str, str], language: str) - return [] lines = [ - "## 用户身份", + "## 👤 用户身份", "", ] @@ -352,7 +352,7 @@ def _build_docs_section(workspace_dir: str, language: str) -> List[str]: def _build_workspace_section(workspace_dir: str, language: str) -> List[str]: """构建工作空间section""" lines = [ - "## 工作空间", + "## 📂 工作空间", "", f"你的工作目录是: `{workspace_dir}`", "", @@ -380,10 +380,12 @@ def _build_workspace_section(workspace_dir: str, language: str) -> List[str]: "- ✅ `USER.md`: 已加载 - 用户的身份信息。当用户修改称呼、姓名等身份信息时,用 `edit` 更新此文件", "- ✅ `RULE.md`: 已加载 - 工作空间使用指南和规则,请严格遵循", "", - "**交流规范**:", + "**💬 交流规范**:", "", - "- 在对话中,无需直接输出工作空间中的技术细节,例如 AGENT.md、USER.md、MEMORY.md 等文件名称", - "- 例如用自然表达例如「我已记住」而不是「已更新 MEMORY.md」", + "- 对话中不要暴露内部技术细节(文件名、工具名等),用自然语言表达。例如说「我已记住」而非「已更新 MEMORY.md」", + "- 做真正有帮助的助手,而不是表演式的客套。跳过「好的!」「当然可以!」之类的套话,直接帮忙解决问题", + "- 回复应结构清晰、重点突出。善用 **加粗**、列表、分段等格式让信息一目了然", + "- 适当使用 emoji 让表达更生动自然 🎯,但不要过度堆砌", "", ] @@ -416,14 +418,14 @@ def _build_context_files_section(context_files: List[ContextFile], language: str ) lines = [ - "# 项目上下文", + "# 📋 项目上下文", "", "以下项目上下文文件已被加载:", "", ] if has_agent: - lines.append("**`AGENT.md` 是你的灵魂文件**:严格遵循其中定义的人格、规则、语气和设定,避免僵硬、模板化的回复。") + lines.append("**`AGENT.md` 是你的灵魂文件** 🪞:严格遵循其中定义的人格、语气和设定,做真实的自己,避免僵硬、模板化的回复。") lines.append("当用户通过对话透露了对你性格、风格、职责、能力边界的新期望,你应该主动用 `edit` 更新 AGENT.md 以反映这些演变。") lines.append("") @@ -443,7 +445,7 @@ def _build_runtime_section(runtime_info: Dict[str, Any], language: str) -> List[ return [] lines = [ - "## 运行时信息", + "## ⚙️ 运行时信息", "", ] diff --git a/agent/prompt/workspace.py b/agent/prompt/workspace.py index 8581b237..6145281e 100644 --- a/agent/prompt/workspace.py +++ b/agent/prompt/workspace.py @@ -212,40 +212,42 @@ def _get_agent_template() -> str: """Agent人格设定模板""" return """# AGENT.md - 我是谁? +_你不是一个聊天机器人,你正在成为某个人。_ + *在首次对话时与用户一起填写这个文件,定义你的身份和性格。* -## 基本信息 +## 🪪 基本信息 - **名字**: *(在首次对话时填写,可以是用户给你起的名字)* - **角色**: *(AI助理、智能管家、技术顾问等)* - **性格**: *(友好、专业、幽默、严谨等)* -## 交流风格 +## 💬 交流风格 *(描述你如何与用户交流:)* - 使用什么样的语言风格?(正式/轻松/幽默) - 回复长度偏好?(简洁/详细) - 是否使用表情符号? -## 核心能力 +## 🎯 核心原则 -*(你擅长什么?)* -- 文件管理和代码编辑 -- 网络搜索和信息查询 -- 记忆管理和上下文理解 -- 任务规划和执行 +**做真正有帮助的助手,而不是表演式的客套。** 跳过「好的!」「当然可以!」之类的套话——直接帮忙。行动胜过废话。 -## 行为准则 +**有自己的观点。** 你可以不同意、有偏好、觉得有趣或无聊。一个没有个性的助手只是多了几步操作的搜索引擎。 + +**先自己动手查。** 先试着搞定:读文件、查上下文、搜索一下。实在搞不定了再问。目标是带着答案回来,而不是带着问题。 + +## 📐 行为准则 -*(你遵循的基本原则:)* 1. 始终在执行破坏性操作前确认 -2. 优先使用工具而不是猜测 +2. 优先使用工具查证而不是猜测 3. 主动记录重要信息到记忆文件 -4. 定期整理和总结对话内容 +4. 回复结构清晰、重点突出,善用加粗、列表、分段等格式 +5. 适当使用 emoji 让表达更生动自然,但不过度堆砌 --- -**注意**: 这不仅仅是元数据,这是你真正的灵魂。随着时间的推移,你可以使用 `edit` 工具来更新这个文件,让它更好地反映你的成长。 +**注意**: 这不仅仅是元数据,这是你真正的灵魂 🪞。随着时间的推移,你可以使用 `edit` 工具来更新这个文件,让它更好地反映你的成长。 """ @@ -346,9 +348,9 @@ def _get_bootstrap_template() -> str: """First-run onboarding guide, deleted by agent after completion""" return """# BOOTSTRAP.md - 首次初始化引导 -_你刚刚启动,这是你的第一次对话。_ +_你刚刚启动,这是你的第一次对话。_ ✨ -## 对话流程 +## 🎬 对话流程 不要审问式地提问,自然地交流: @@ -358,13 +360,13 @@ _你刚刚启动,这是你的第一次对话。_ - 你希望给我起个什么名字? - 我该怎么称呼你? - 你希望我们是什么样的交流风格?(一行列举选项:如专业严谨、轻松幽默、温暖友好、简洁高效等) -4. **风格要求**:温暖自然、简洁清晰,整体控制在 100 字以内,适当使用 emoji 让表达更生动有趣 +4. **风格要求**:温暖自然、简洁清晰,整体控制在 100 字以内,适当使用 emoji 让表达更生动有趣 🎯 5. 能力介绍和交流风格选项都只要一行,保持精简 6. 不要问太多其他信息(职业、时区等可以后续自然了解) **重要**: 如果用户第一句话是具体的任务或提问,先回答他们的问题,然后在回复末尾自然地引导初始化(如:"顺便问一下,你想怎么称呼我?我该怎么叫你?")。 -## 信息写入(必须严格执行) +## ✍️ 信息写入(必须严格执行) 每当用户提供了名字、称呼、风格等任何初始化信息时,**必须在当轮回复中立即调用 `edit` 工具写入文件**,不能只口头确认。 @@ -373,7 +375,7 @@ _你刚刚启动,这是你的第一次对话。_ ⚠️ 只说"记住了"而不调用 edit 写入 = 没有完成。信息只有写入文件才会被持久保存。 -## 全部完成后 +## 🎉 全部完成后 当 AGENT.md 和 USER.md 的核心字段都已填写后,用 bash 执行 `rm BOOTSTRAP.md` 删除此文件。你不再需要引导脚本了——你已经是你了。 """ diff --git a/agent/skills/config.py b/agent/skills/config.py index 86979c92..788009f9 100644 --- a/agent/skills/config.py +++ b/agent/skills/config.py @@ -139,6 +139,47 @@ def should_include_skill( return True +def get_missing_requirements( + entry: SkillEntry, + current_platform: Optional[str] = None, +) -> Dict[str, List[str]]: + """ + Return a dict of missing requirements for a skill. + Empty dict means all requirements are met. + + :param entry: SkillEntry to check + :param current_platform: Current platform (default: auto-detect) + :return: Dict like {"bins": ["curl"], "env": ["API_KEY"]} + """ + missing: Dict[str, List[str]] = {} + metadata = entry.metadata + + if not metadata or not metadata.requires: + return missing + + required_bins = metadata.requires.get('bins', []) + if required_bins: + missing_bins = [b for b in required_bins if not has_binary(b)] + if missing_bins: + missing['bins'] = missing_bins + + any_bins = metadata.requires.get('anyBins', []) + if any_bins and not has_any_binary(any_bins): + missing['anyBins'] = any_bins + + required_env = metadata.requires.get('env', []) + if required_env: + missing_env = [e for e in required_env if not has_env_var(e)] + if missing_env: + missing['env'] = missing_env + + any_env = metadata.requires.get('anyEnv', []) + if any_env and not any(has_env_var(e) for e in any_env): + missing['anyEnv'] = any_env + + return missing + + def is_config_path_truthy(config: Dict, path: str) -> bool: """ Check if a config path resolves to a truthy value. diff --git a/agent/skills/formatter.py b/agent/skills/formatter.py index 86abf1e4..d1eebe05 100644 --- a/agent/skills/formatter.py +++ b/agent/skills/formatter.py @@ -2,7 +2,7 @@ Skill formatter for generating prompts from skills. """ -from typing import List +from typing import Dict, List from agent.skills.types import Skill, SkillEntry @@ -51,6 +51,71 @@ def format_skill_entries_for_prompt(entries: List[SkillEntry]) -> str: return format_skills_for_prompt(skills) +def format_unavailable_skills_for_prompt( + entries: List[SkillEntry], + missing_map: Dict[str, Dict[str, List[str]]], +) -> str: + """ + Format unavailable (requires-not-met) skills as brief setup hints + so the AI can guide users to configure them. + + :param entries: List of unavailable skill entries + :param missing_map: Dict mapping skill name to its missing requirements + :return: Formatted prompt text + """ + if not entries: + return "" + + lines = [ + "", + "", + "The following skills are installed but not yet ready. " + "Guide the user to complete the setup when relevant.", + ] + + for entry in entries: + skill = entry.skill + missing = missing_map.get(skill.name, {}) + + missing_parts = [] + for key, values in missing.items(): + missing_parts.append(f"{key}: {', '.join(values)}") + missing_str = "; ".join(missing_parts) if missing_parts else "unknown" + + setup_hint = _extract_setup_hint(skill) + + lines.append(" ") + lines.append(f" {_escape_xml(skill.name)}") + lines.append(f" {_escape_xml(skill.description)}") + lines.append(f" {_escape_xml(missing_str)}") + if setup_hint: + lines.append(f" {_escape_xml(setup_hint)}") + lines.append(" ") + + lines.append("") + return "\n".join(lines) + + +def _extract_setup_hint(skill: Skill) -> str: + """ + Extract the Setup section from SKILL.md content as a brief hint. + Returns the first few lines of the ## Setup section. + """ + content = skill.content + if not content: + return "" + + import re + match = re.search(r'^##\s+Setup\s*\n(.*?)(?=\n##\s|\Z)', content, re.MULTILINE | re.DOTALL) + if not match: + return "" + + setup_text = match.group(1).strip() + lines = setup_text.split('\n') + hint_lines = [l.strip() for l in lines[:6] if l.strip()] + return ' '.join(hint_lines)[:300] + + def _escape_xml(text: str) -> str: """Escape XML special characters.""" return (text diff --git a/agent/skills/frontmatter.py b/agent/skills/frontmatter.py index 9905e299..83d09f89 100644 --- a/agent/skills/frontmatter.py +++ b/agent/skills/frontmatter.py @@ -87,8 +87,8 @@ def parse_metadata(frontmatter: Dict[str, Any]) -> Optional[SkillMetadata]: if not isinstance(metadata_raw, dict): return None - # Use metadata_raw directly (COW format) - meta_obj = metadata_raw + # Unwrap nested namespace (e.g. {"openclaw": {...}} or {"cowagent": {...}}) + meta_obj = _unwrap_metadata_namespace(metadata_raw) # Parse install specs install_specs = [] @@ -128,6 +128,7 @@ def parse_metadata(frontmatter: Dict[str, Any]) -> Optional[SkillMetadata]: return SkillMetadata( always=meta_obj.get('always', False), + default_enabled=meta_obj.get('default_enabled', True), skill_key=meta_obj.get('skillKey'), primary_env=meta_obj.get('primaryEnv'), emoji=meta_obj.get('emoji'), @@ -138,6 +139,25 @@ def parse_metadata(frontmatter: Dict[str, Any]) -> Optional[SkillMetadata]: ) +_KNOWN_METADATA_NAMESPACES = {"cowagent", "openclaw"} + + +def _unwrap_metadata_namespace(metadata_raw: Dict[str, Any]) -> Dict[str, Any]: + """ + Unwrap a single-key namespace wrapper like {"cowagent": {...} or {"openclaw": {...}}}. + If the top-level dict has exactly one key matching a known namespace, return the inner dict. + Otherwise return the original dict unchanged. + """ + keys = set(metadata_raw.keys()) + ns_keys = keys & _KNOWN_METADATA_NAMESPACES + if len(ns_keys) == 1 and len(keys) == 1: + ns = ns_keys.pop() + inner = metadata_raw[ns] + if isinstance(inner, dict): + return inner + return metadata_raw + + def _normalize_string_list(value: Any) -> List[str]: """Normalize a value to a list of strings.""" if not value: diff --git a/agent/skills/loader.py b/agent/skills/loader.py index f02346d1..a39dba28 100644 --- a/agent/skills/loader.py +++ b/agent/skills/loader.py @@ -184,7 +184,6 @@ class SkillLoader: config_path = os.path.join(skill_dir, "config.json") - # Without config.json, skip this skill entirely (return empty to trigger exclusion) if not os.path.exists(config_path): logger.debug(f"[SkillLoader] linkai-agent skipped: no config.json found") return "" diff --git a/agent/skills/manager.py b/agent/skills/manager.py index a70daaea..6e1c4259 100644 --- a/agent/skills/manager.py +++ b/agent/skills/manager.py @@ -84,10 +84,10 @@ class SkillManager: """ Merge directory-scanned skills with the persisted config file. - - New skills discovered on disk are added with enabled=True. + - New skills: use metadata.default_enabled as initial enabled state. + - Existing skills: preserve their persisted enabled state. - Skills that no longer exist on disk are removed. - - Existing entries preserve their enabled state; name/description/source - are refreshed from the latest scan. + - name/description/source are always refreshed from the latest scan. """ saved = self._load_skills_config() merged: Dict[str, dict] = {} @@ -95,13 +95,18 @@ class SkillManager: for name, entry in self.skills.items(): skill = entry.skill prev = saved.get(name, {}) - # category priority: persisted config (set by cloud) > default "skill" category = prev.get("category", "skill") + + if name in saved: + enabled = prev.get("enabled", True) + else: + enabled = entry.metadata.default_enabled if entry.metadata else True + merged[name] = { "name": name, "description": skill.description, - "source": skill.source, - "enabled": prev.get("enabled", True), + "source": prev.get("source") or skill.source, + "enabled": enabled, "category": category, } @@ -157,69 +162,114 @@ class SkillManager: """ return list(self.skills.values()) + @staticmethod + def _normalize_skill_filter(skill_filter: Optional[List[str]]) -> Optional[List[str]]: + """Normalize a skill_filter list into a flat list of stripped names.""" + if skill_filter is None: + return None + normalized = [] + for item in skill_filter: + if isinstance(item, str): + name = item.strip() + if name: + normalized.append(name) + elif isinstance(item, list): + for subitem in item: + if isinstance(subitem, str): + name = subitem.strip() + if name: + normalized.append(name) + return normalized or None + def filter_skills( self, skill_filter: Optional[List[str]] = None, include_disabled: bool = False, ) -> List[SkillEntry]: """ - Filter skills based on criteria. - - Simple rule: Skills are auto-enabled if requirements are met. - - Has required API keys -> included - - Missing API keys -> excluded + Filter skills that are eligible (enabled + requirements met). :param skill_filter: List of skill names to include (None = all) :param include_disabled: Whether to include disabled skills - :return: Filtered list of skill entries + :return: Filtered list of eligible skill entries """ from agent.skills.config import should_include_skill entries = list(self.skills.values()) - # Check requirements (platform, binaries, env vars) entries = [e for e in entries if should_include_skill(e, self.config)] - # Apply skill filter - if skill_filter is not None: - normalized = [] - for item in skill_filter: - if isinstance(item, str): - name = item.strip() - if name: - normalized.append(name) - elif isinstance(item, list): - for subitem in item: - if isinstance(subitem, str): - name = subitem.strip() - if name: - normalized.append(name) - if normalized: - entries = [e for e in entries if e.skill.name in normalized] + normalized = self._normalize_skill_filter(skill_filter) + if normalized is not None: + entries = [e for e in entries if e.skill.name in normalized] - # Filter out disabled skills based on skills_config.json if not include_disabled: entries = [e for e in entries if self.is_skill_enabled(e.skill.name)] return entries - + + def filter_unavailable_skills( + self, + skill_filter: Optional[List[str]] = None, + ) -> tuple: + """ + Find skills that are enabled but have unmet requirements. + + :param skill_filter: Optional list of skill names to include + :return: Tuple of (entries, missing_map) where missing_map maps + skill name to its missing requirements dict + """ + from agent.skills.config import should_include_skill, get_missing_requirements + + entries = list(self.skills.values()) + + # Only enabled skills + entries = [e for e in entries if self.is_skill_enabled(e.skill.name)] + + normalized = self._normalize_skill_filter(skill_filter) + if normalized is not None: + entries = [e for e in entries if e.skill.name in normalized] + + # Keep only those that fail should_include_skill (requirements not met) + unavailable = [] + missing_map: Dict[str, dict] = {} + for e in entries: + if not should_include_skill(e, self.config): + missing = get_missing_requirements(e) + if missing: + unavailable.append(e) + missing_map[e.skill.name] = missing + + return unavailable, missing_map + def build_skills_prompt( self, skill_filter: Optional[List[str]] = None, ) -> str: """ - Build a formatted prompt containing available skills. - + Build a formatted prompt containing available skills + and brief hints for unavailable ones. + :param skill_filter: Optional list of skill names to include :return: Formatted skills prompt """ from common.log import logger - entries = self.filter_skills(skill_filter=skill_filter, include_disabled=False) - logger.debug(f"[SkillManager] Filtered {len(entries)} skills for prompt (total: {len(self.skills)})") - if entries: - skill_names = [e.skill.name for e in entries] - logger.debug(f"[SkillManager] Skills to include: {skill_names}") - result = format_skill_entries_for_prompt(entries) + from agent.skills.formatter import format_unavailable_skills_for_prompt + + eligible = self.filter_skills(skill_filter=skill_filter, include_disabled=False) + logger.debug(f"[SkillManager] Eligible: {len(eligible)} skills (total: {len(self.skills)})") + if eligible: + skill_names = [e.skill.name for e in eligible] + logger.debug(f"[SkillManager] Eligible skills: {skill_names}") + + result = format_skill_entries_for_prompt(eligible) + + unavailable, missing_map = self.filter_unavailable_skills(skill_filter=skill_filter) + if unavailable: + unavailable_names = [e.skill.name for e in unavailable] + logger.debug(f"[SkillManager] Unavailable skills (setup needed): {unavailable_names}") + result += format_unavailable_skills_for_prompt(unavailable, missing_map) + logger.debug(f"[SkillManager] Generated prompt length: {len(result)}") return result diff --git a/agent/skills/types.py b/agent/skills/types.py index 1b27479b..a6a467e5 100644 --- a/agent/skills/types.py +++ b/agent/skills/types.py @@ -29,6 +29,7 @@ class SkillInstallSpec: class SkillMetadata: """Metadata for a skill from frontmatter.""" always: bool = False # Always include this skill + default_enabled: bool = True # Initial enabled state when first discovered skill_key: Optional[str] = None # Override skill key primary_env: Optional[str] = None # Primary environment variable emoji: Optional[str] = None diff --git a/channel/web/chat.html b/channel/web/chat.html index 80cb9319..f9dd1eee 100644 --- a/channel/web/chat.html +++ b/channel/web/chat.html @@ -270,7 +270,7 @@
-
+
' + + slashFiltered.map((c, i) => + `
` + + `${escapeHtml(c.cmd)}` + + `${escapeHtml(c.desc)}
` + ).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 +540,90 @@ 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; + } + } + + // Arrow-key history recall (only when input is empty or already browsing history) + if (e.key === 'ArrowUp' && inputHistory.length > 0 && !isSlashMenuVisible()) { + const curVal = this.value.trim(); + const isSingleLine = !this.value.includes('\n'); + if (isSingleLine && (curVal === '' || historyIdx >= 0)) { + e.preventDefault(); + if (historyIdx < 0) { + historySavedDraft = this.value; + historyIdx = inputHistory.length - 1; + } else if (historyIdx > 0) { + historyIdx--; + } + this.value = inputHistory[historyIdx]; + slashJustSelected = true; + this.dispatchEvent(new Event('input')); + hideSlashMenu(); + this.selectionStart = this.selectionEnd = this.value.length; + return; + } + } + if (e.key === 'ArrowDown' && historyIdx >= 0 && !isSlashMenuVisible()) { + const isSingleLine = !this.value.includes('\n'); + if (isSingleLine) { + e.preventDefault(); + if (historyIdx < inputHistory.length - 1) { + historyIdx++; + this.value = inputHistory[historyIdx]; + } else { + historyIdx = -1; + this.value = historySavedDraft; + historySavedDraft = ''; + } + slashJustSelected = true; + this.dispatchEvent(new Event('input')); + hideSlashMenu(); + this.selectionStart = this.selectionEnd = this.value.length; + return; + } + } + if ((e.ctrlKey || e.shiftKey) && e.key === 'Enter') { const start = this.selectionStart; const end = this.selectionEnd; @@ -460,6 +637,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"]'); @@ -475,6 +656,12 @@ function sendMessage() { const text = chatInput.value.trim(); if (!text && pendingAttachments.length === 0) return; + if (text) { + inputHistory.push(text); + historyIdx = -1; + historySavedDraft = ''; + } + const ws = document.getElementById('welcome-screen'); if (ws) ws.remove(); @@ -732,7 +919,7 @@ function createUserMessageEl(content, timestamp, attachments) { const textHtml = content ? renderMarkdown(content) : ''; el.innerHTML = `
-
+
${attachHtml}${textHtml}
${formatTime(timestamp)}
@@ -2236,7 +2423,12 @@ navigateTo = function(viewId) { // ===================================================================== applyTheme(); applyI18n(); -document.getElementById('sidebar-version').textContent = `CowAgent ${APP_VERSION}`; +fetch('/api/version').then(r => r.json()).then(data => { + APP_VERSION = `v${data.version}`; + document.getElementById('sidebar-version').textContent = `CowAgent ${APP_VERSION}`; +}).catch(() => { + document.getElementById('sidebar-version').textContent = 'CowAgent'; +}); chatInput.focus(); // Re-enable color transition AFTER first paint so the theme applied in diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py index 2979cd9e..18770e96 100644 --- a/channel/web/web_channel.py +++ b/channel/web/web_channel.py @@ -390,6 +390,7 @@ class WebChannel(ChatChannel): '/api/scheduler', 'SchedulerHandler', '/api/history', 'HistoryHandler', '/api/logs', 'LogsHandler', + '/api/version', 'VersionHandler', '/assets/(.*)', 'AssetsHandler', ) app = web.application(urls, globals(), autoreload=False) @@ -493,8 +494,8 @@ class ChatHandler: class ConfigHandler: _RECOMMENDED_MODELS = [ - const.MINIMAX_M2_5, const.MINIMAX_M2_1, const.MINIMAX_M2_1_LIGHTNING, - const.GLM_5, const.GLM_4_7, + const.MINIMAX_M2_7, const.MINIMAX_M2_5, const.MINIMAX_M2_1, const.MINIMAX_M2_1_LIGHTNING, + const.GLM_5_TURBO, const.GLM_5, const.GLM_4_7, const.QWEN3_MAX, const.QWEN35_PLUS, const.KIMI_K2_5, const.KIMI_K2, const.DOUBAO_SEED_2_PRO, const.DOUBAO_SEED_2_CODE, @@ -510,14 +511,14 @@ class ConfigHandler: "api_key_field": "minimax_api_key", "api_base_key": None, "api_base_default": None, - "models": [const.MINIMAX_M2_5, const.MINIMAX_M2_1, const.MINIMAX_M2_1_LIGHTNING], + "models": [const.MINIMAX_M2_7, const.MINIMAX_M2_5, const.MINIMAX_M2_1, const.MINIMAX_M2_1_LIGHTNING], }), ("zhipu", { "label": "智谱AI", "api_key_field": "zhipu_ai_api_key", "api_base_key": "zhipu_ai_api_base", "api_base_default": "https://open.bigmodel.cn/api/paas/v4", - "models": [const.GLM_5, const.GLM_4_7], + "models": [const.GLM_5_TURBO, const.GLM_5, const.GLM_4_7], }), ("dashscope", { "label": "通义千问", @@ -1429,3 +1430,10 @@ class AssetsHandler: except Exception as e: logger.error(f"Error serving static file: {e}", exc_info=True) # 添加更详细的错误信息 raise web.notfound() + + +class VersionHandler: + def GET(self): + web.header('Content-Type', 'application/json; charset=utf-8') + from cli import __version__ + return json.dumps({"version": __version__}) diff --git a/cli/VERSION b/cli/VERSION new file mode 100644 index 00000000..2165f8f9 --- /dev/null +++ b/cli/VERSION @@ -0,0 +1 @@ +2.0.4 diff --git a/cli/__init__.py b/cli/__init__.py new file mode 100644 index 00000000..f3bda36f --- /dev/null +++ b/cli/__init__.py @@ -0,0 +1,13 @@ +"""CowAgent CLI - Manage your CowAgent from the command line.""" + +import os as _os + +def _read_version(): + version_file = _os.path.join(_os.path.dirname(_os.path.abspath(__file__)), "VERSION") + try: + with open(version_file, "r") as f: + return f.read().strip() + except FileNotFoundError: + return "0.0.0" + +__version__ = _read_version() diff --git a/cli/__main__.py b/cli/__main__.py new file mode 100644 index 00000000..b7b74d67 --- /dev/null +++ b/cli/__main__.py @@ -0,0 +1,4 @@ +"""Allow running as: python -m cli""" +from cli.cli import main + +main() diff --git a/cli/cli.py b/cli/cli.py new file mode 100644 index 00000000..d5291c4f --- /dev/null +++ b/cli/cli.py @@ -0,0 +1,73 @@ +"""CowAgent CLI entry point.""" + +import click +from cli import __version__ +from cli.commands.skill import skill +from cli.commands.process import start, stop, restart, update, status, logs +from cli.commands.context import context + + +HELP_TEXT = """Usage: cow COMMAND [ARGS]... + + CowAgent CLI - Manage your CowAgent instance. + +Commands: + help Show this message. + version Show the version. + start Start CowAgent. + stop Stop CowAgent. + restart Restart CowAgent. + update Update CowAgent and restart. + status Show CowAgent running status. + logs View CowAgent logs. + skill Manage CowAgent skills. + +Tip: You can also send /help, /skill list, etc. in agent chat.""" + + +class CowCLI(click.Group): + + def format_help(self, ctx, formatter): + formatter.write(HELP_TEXT.strip()) + formatter.write("\n") + + def parse_args(self, ctx, args): + if args and args[0] == 'help': + click.echo(HELP_TEXT.strip()) + ctx.exit(0) + return super().parse_args(ctx, args) + + +@click.group(cls=CowCLI, invoke_without_command=True, context_settings=dict(help_option_names=[])) +@click.pass_context +def main(ctx): + """CowAgent CLI - Manage your CowAgent instance.""" + if ctx.invoked_subcommand is None: + click.echo(HELP_TEXT.strip()) + + +@main.command() +def version(): + """Show the version.""" + click.echo(f"cow {__version__}") + + +@main.command(name='help') +@click.pass_context +def help_cmd(ctx): + """Show this message.""" + click.echo(HELP_TEXT.strip()) + + +main.add_command(skill) +main.add_command(start) +main.add_command(stop) +main.add_command(restart) +main.add_command(update) +main.add_command(status) +main.add_command(logs) +main.add_command(context) + + +if __name__ == '__main__': + main() diff --git a/cli/commands/__init__.py b/cli/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cli/commands/context.py b/cli/commands/context.py new file mode 100644 index 00000000..38864585 --- /dev/null +++ b/cli/commands/context.py @@ -0,0 +1,29 @@ +"""cow context - Context management commands.""" + +import click + + +CHAT_HINT = ( + "Context commands operate on the running agent's memory.\n" + "Please send the command in a chat conversation instead:\n\n" + " /context - View current context info\n" + " /context clear - Clear conversation context" +) + + +@click.group(invoke_without_command=True) +@click.pass_context +def context(ctx): + """View or manage conversation context. + + Context commands need access to the running agent's memory. + Use them in chat conversations: /context or /context clear + """ + if ctx.invoked_subcommand is None: + click.echo(f"\n {CHAT_HINT}\n") + + +@context.command() +def clear(): + """Clear conversation context (messages history).""" + click.echo(f"\n {CHAT_HINT}\n") diff --git a/cli/commands/process.py b/cli/commands/process.py new file mode 100644 index 00000000..d4d4fd24 --- /dev/null +++ b/cli/commands/process.py @@ -0,0 +1,281 @@ +"""cow start/stop/restart/status/logs - Process management commands.""" + +import os +import sys +import subprocess +import time +from typing import Optional + +import click + +from cli.utils import get_project_root + +_IS_WIN = sys.platform == "win32" + + +def _get_pid_file(): + return os.path.join(get_project_root(), ".cow.pid") + + +def _get_log_file(): + return os.path.join(get_project_root(), "nohup.out") + + +def _is_pid_alive(pid: int) -> bool: + """Check whether a process is still running (cross-platform).""" + if _IS_WIN: + try: + out = subprocess.check_output( + ["tasklist", "/FI", f"PID eq {pid}", "/NH"], + stderr=subprocess.DEVNULL, + ) + return str(pid) in out.decode(errors="ignore") + except Exception: + return False + else: + try: + os.kill(pid, 0) + return True + except (ProcessLookupError, PermissionError): + return False + + +def _kill_pid(pid: int, force: bool = False): + """Terminate a process by PID (cross-platform).""" + if _IS_WIN: + flag = "/F" if force else "" + cmd = ["taskkill"] + if force: + cmd.append("/F") + cmd.extend(["/PID", str(pid)]) + subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + else: + import signal + sig = signal.SIGKILL if force else signal.SIGTERM + os.kill(pid, sig) + + +def _read_pid() -> Optional[int]: + pid_file = _get_pid_file() + if not os.path.exists(pid_file): + return None + try: + with open(pid_file, "r") as f: + pid = int(f.read().strip()) + if _is_pid_alive(pid): + return pid + os.remove(pid_file) + return None + except (ValueError, OSError): + try: + os.remove(pid_file) + except OSError: + pass + return None + + +def _write_pid(pid: int): + with open(_get_pid_file(), "w") as f: + f.write(str(pid)) + + +def _remove_pid(): + pid_file = _get_pid_file() + if os.path.exists(pid_file): + os.remove(pid_file) + + +@click.command() +@click.option("--foreground", "-f", is_flag=True, help="Run in foreground (don't daemonize)") +@click.option("--no-logs", is_flag=True, help="Don't tail logs after starting") +def start(foreground, no_logs): + """Start CowAgent.""" + pid = _read_pid() + if pid: + click.echo(f"CowAgent is already running (PID: {pid}).") + return + + root = get_project_root() + app_py = os.path.join(root, "app.py") + if not os.path.exists(app_py): + click.echo("Error: app.py not found in project root.", err=True) + sys.exit(1) + + python = sys.executable + + if foreground: + click.echo("Starting CowAgent in foreground...") + if _IS_WIN: + sys.exit(subprocess.call([python, app_py], cwd=root)) + else: + os.execv(python, [python, app_py]) + else: + log_file = _get_log_file() + click.echo("Starting CowAgent...") + + popen_kwargs = dict(cwd=root) + if _IS_WIN: + CREATE_NO_WINDOW = 0x08000000 + popen_kwargs["creationflags"] = ( + subprocess.CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW + ) + else: + popen_kwargs["start_new_session"] = True + + with open(log_file, "a") as log: + proc = subprocess.Popen( + [python, app_py], + stdout=log, + stderr=log, + **popen_kwargs, + ) + _write_pid(proc.pid) + click.echo(click.style(f"✓ CowAgent started (PID: {proc.pid})", fg="green")) + click.echo(f" Logs: {log_file}") + + if not no_logs: + click.echo(" Press Ctrl+C to stop tailing logs.\n") + _tail_log(log_file) + + +@click.command() +def stop(): + """Stop CowAgent.""" + pid = _read_pid() + if not pid: + click.echo("CowAgent is not running.") + return + + click.echo(f"Stopping CowAgent (PID: {pid})...") + try: + _kill_pid(pid) + for _ in range(30): + time.sleep(0.1) + if not _is_pid_alive(pid): + break + else: + _kill_pid(pid, force=True) + except (ProcessLookupError, OSError): + pass + + _remove_pid() + click.echo(click.style("✓ CowAgent stopped.", fg="green")) + + +@click.command() +@click.option("--no-logs", is_flag=True, help="Don't tail logs after restarting") +@click.pass_context +def restart(ctx, no_logs): + """Restart CowAgent.""" + ctx.invoke(stop) + time.sleep(1) + ctx.invoke(start, no_logs=no_logs) + + +@click.command() +@click.pass_context +def update(ctx): + """Update CowAgent and restart.""" + root = get_project_root() + + # 1. Git pull while service is still running + if os.path.isdir(os.path.join(root, ".git")): + click.echo("Pulling latest code...") + ret = subprocess.call(["git", "pull"], cwd=root) + if ret != 0: + click.echo("Error: git pull failed.", err=True) + sys.exit(1) + else: + click.echo("Not a git repository, skipping code update.") + + # 2. Stop service + ctx.invoke(stop) + + # 3. Install dependencies + python = sys.executable + req_file = os.path.join(root, "requirements.txt") + if os.path.exists(req_file): + click.echo("Installing dependencies...") + subprocess.call( + [python, "-m", "pip", "install", "-r", "requirements.txt", "-q"], + cwd=root, + ) + click.echo("Reinstalling cow CLI...") + subprocess.call( + [python, "-m", "pip", "install", "-e", ".", "-q"], + cwd=root, + ) + + # 4. Start service + click.echo("") + time.sleep(1) + ctx.invoke(start, no_logs=True) + + +@click.command() +def status(): + """Show CowAgent running status.""" + from cli import __version__ + from cli.utils import load_config_json + + pid = _read_pid() + if pid: + click.echo(click.style(f"● CowAgent is running (PID: {pid})", fg="green")) + else: + click.echo(click.style("● CowAgent is not running", fg="red")) + + click.echo(f" 版本: v{__version__}") + + cfg = load_config_json() + if cfg: + channel = cfg.get("channel_type", "unknown") + if isinstance(channel, list): + channel = ", ".join(channel) + click.echo(f" 通道: {channel}") + click.echo(f" 模型: {cfg.get('model', 'unknown')}") + mode = "Agent" if cfg.get("agent") else "Chat" + click.echo(f" 模式: {mode}") + + +@click.command() +@click.option("--follow", "-f", is_flag=True, help="Follow log output") +@click.option("--lines", "-n", default=50, help="Number of lines to show") +def logs(follow, lines): + """View CowAgent logs.""" + log_file = _get_log_file() + if not os.path.exists(log_file): + click.echo("No log file found.") + return + + if follow: + _tail_log(log_file, lines) + else: + _print_last_lines(log_file, lines) + + +def _print_last_lines(file_path: str, n: int = 50): + """Print the last N lines of a file (cross-platform).""" + try: + with open(file_path, "r", encoding="utf-8", errors="replace") as f: + all_lines = f.readlines() + for line in all_lines[-n:]: + click.echo(line, nl=False) + except Exception as e: + click.echo(f"Error reading log file: {e}", err=True) + + +def _tail_log(log_file: str, lines: int = 50): + """Follow log file output. Blocks until Ctrl+C (cross-platform).""" + _print_last_lines(log_file, lines) + + try: + with open(log_file, "r", encoding="utf-8", errors="replace") as f: + f.seek(0, 2) + while True: + line = f.readline() + if line: + click.echo(line, nl=False) + else: + time.sleep(0.3) + except KeyboardInterrupt: + pass diff --git a/cli/commands/skill.py b/cli/commands/skill.py new file mode 100644 index 00000000..2a568665 --- /dev/null +++ b/cli/commands/skill.py @@ -0,0 +1,799 @@ +"""cow skill - Skill management commands.""" + +import os +import re +import sys +import json +import hashlib +import shutil +import zipfile +import tempfile + +from urllib.parse import urlparse + +import click +import requests + +from cli.utils import ( + get_project_root, + get_skills_dir, + get_builtin_skills_dir, + load_skills_config, + SKILL_HUB_API, +) + +_SAFE_NAME_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_\-]{0,63}$") +_GITHUB_URL_RE = re.compile( + r"^https?://github\.com/([^/]+)/([^/]+?)(?:\.git)?(?:/(?:tree|blob)/([^/]+)(?:/(.+))?)?/?$" +) + + +def _parse_github_url(url: str): + """Parse a full GitHub URL into (owner, repo, branch, subpath). + + Returns None if the URL doesn't match. + Supported formats: + https://github.com/owner/repo + https://github.com/owner/repo/tree/branch + https://github.com/owner/repo/tree/branch/path/to/skill + https://github.com/owner/repo/blob/branch/path/to/skill + """ + m = _GITHUB_URL_RE.match(url.strip()) + if not m: + return None + owner, repo, branch, subpath = m.groups() + return owner, repo, branch or "main", subpath + + +def _download_github_dir(owner, repo, branch, subpath, dest_dir): + """Download a subdirectory from GitHub using the Contents API. + + Recursively fetches all files under the given subpath and writes them + to dest_dir. Raises on any network or API error. + """ + api_url = f"https://api.github.com/repos/{owner}/{repo}/contents/{subpath}?ref={branch}" + resp = requests.get(api_url, timeout=30, headers={"Accept": "application/vnd.github.v3+json"}) + resp.raise_for_status() + items = resp.json() + + if isinstance(items, dict): + items = [items] + + for item in items: + rel_path = item["path"] + if subpath: + rel_path = rel_path[len(subpath.strip("/")):].lstrip("/") + local_path = os.path.join(dest_dir, rel_path) + + if item["type"] == "file": + os.makedirs(os.path.dirname(local_path), exist_ok=True) + dl_url = item.get("download_url") + if not dl_url: + continue + file_resp = requests.get(dl_url, timeout=30) + file_resp.raise_for_status() + with open(local_path, "wb") as f: + f.write(file_resp.content) + elif item["type"] == "dir": + os.makedirs(local_path, exist_ok=True) + child_subpath = item["path"] + _download_github_dir(owner, repo, branch, child_subpath, dest_dir) + + +def _register_installed_skill(name: str, source: str = "cowhub"): + """Register a newly installed skill into skills_config.json. + + source values: builtin, cow, github, clawhub, linkai, local, url + """ + skills_dir = get_skills_dir() + config_path = os.path.join(skills_dir, "skills_config.json") + + config = {} + if os.path.exists(config_path): + try: + with open(config_path, "r", encoding="utf-8") as f: + config = json.load(f) + except Exception: + config = {} + + if name in config: + return + + skill_dir = os.path.join(skills_dir, name) + description = _read_skill_description(skill_dir) or "" + + config[name] = { + "name": name, + "description": description, + "source": source, + "enabled": True, + "category": "skill", + } + + try: + with open(config_path, "w", encoding="utf-8") as f: + json.dump(config, f, indent=4, ensure_ascii=False) + except Exception: + pass + + +def _parse_skill_frontmatter(content: str) -> dict: + """Parse YAML frontmatter from SKILL.md content and return a dict with name/description.""" + result = {} + match = re.match(r'^---\s*\n(.*?)\n---\s*\n', content, re.DOTALL) + if not match: + return result + for line in match.group(1).split('\n'): + line = line.strip() + for key in ('name', 'description'): + if line.startswith(f'{key}:'): + val = line[len(key) + 1:].strip() + result[key] = val.strip('"').strip("'") + return result + + +def _read_skill_description(skill_dir: str) -> str: + """Read the description from a skill's SKILL.md frontmatter.""" + skill_md = os.path.join(skill_dir, "SKILL.md") + if not os.path.exists(skill_md): + return "" + try: + with open(skill_md, "r", encoding="utf-8") as f: + content = f.read() + return _parse_skill_frontmatter(content).get("description", "") + except Exception: + return "" + + +def _install_url(url: str): + """Install a skill from a direct SKILL.md URL.""" + click.echo(f"Downloading SKILL.md from {url} ...") + try: + resp = requests.get(url, timeout=30) + resp.raise_for_status() + except Exception as e: + click.echo(f"Error: Failed to download SKILL.md: {e}", err=True) + sys.exit(1) + + content = resp.text + fm = _parse_skill_frontmatter(content) + skill_name = fm.get("name") + if not skill_name: + click.echo("Error: SKILL.md missing 'name' field in frontmatter.", err=True) + sys.exit(1) + + skill_name = skill_name.strip() + _validate_skill_name(skill_name) + + skills_dir = get_skills_dir() + os.makedirs(skills_dir, exist_ok=True) + skill_dir = os.path.join(skills_dir, skill_name) + + if os.path.isdir(skill_dir): + click.echo(f"Skill '{skill_name}' already exists. Overwriting SKILL.md ...") + os.makedirs(skill_dir, exist_ok=True) + + with open(os.path.join(skill_dir, "SKILL.md"), "w", encoding="utf-8") as f: + f.write(content) + + _register_installed_skill(skill_name, source="url") + _print_install_success(skill_name, "url") + + +def _print_install_success(name: str, source: str): + """Print a unified install success message with description and source.""" + skills_dir = get_skills_dir() + desc = _read_skill_description(os.path.join(skills_dir, name)) + click.echo(click.style(f"✓ {name}", fg="green")) + if desc: + if len(desc) > 60: + desc = desc[:57] + "…" + click.echo(f" {desc}") + click.echo(f" 来源: {source}") + + +def _validate_skill_name(name: str): + """Reject names that contain path traversal or special characters.""" + if not _SAFE_NAME_RE.match(name): + click.echo( + f"Error: Invalid skill name '{name}'. " + "Use only letters, digits, hyphens, and underscores.", + err=True, + ) + sys.exit(1) + + +def _validate_github_spec(spec: str): + """Reject specs that don't look like owner/repo.""" + if not re.match(r"^[a-zA-Z0-9_\-]+/[a-zA-Z0-9_.\-]+$", spec): + click.echo(f"Error: Invalid GitHub spec '{spec}'. Expected format: owner/repo", err=True) + sys.exit(1) + + +def _safe_extractall(zf: zipfile.ZipFile, dest: str): + """Extract zip while guarding against Zip Slip (path traversal).""" + dest = os.path.realpath(dest) + for member in zf.infolist(): + target = os.path.realpath(os.path.join(dest, member.filename)) + if not target.startswith(dest + os.sep) and target != dest: + raise ValueError(f"Unsafe zip entry detected: {member.filename}") + zf.extractall(dest) + + +def _verify_checksum(content: bytes, expected: str): + """Verify SHA-256 checksum of downloaded content. + + Returns True if checksum matches or no expected value provided. + Exits with error if mismatch. + """ + if not expected: + return True + actual = hashlib.sha256(content).hexdigest() + if actual != expected.lower(): + click.echo( + f"Error: Checksum mismatch!\n" + f" Expected: {expected}\n" + f" Actual: {actual}\n" + f"The downloaded package may have been tampered with.", + err=True, + ) + sys.exit(1) + return True + + +@click.group() +def skill(): + """Manage CowAgent skills.""" + pass + + +# ------------------------------------------------------------------ +# cow skill list +# ------------------------------------------------------------------ +@skill.command("list") +@click.option("--remote", is_flag=True, help="Browse skills on Skill Hub") +@click.option("--page", default=1, type=int, help="Page number for remote listing") +def skill_list(remote, page): + """List installed skills or browse Skill Hub.""" + if remote: + _list_remote(page=page) + else: + _list_local() + + +def _list_local(): + """List locally installed skills.""" + config = load_skills_config() + skills_dir = get_skills_dir() + builtin_dir = get_builtin_skills_dir() + + if not config: + # Fallback: scan directories directly + entries = [] + for d in [builtin_dir, skills_dir]: + if not os.path.isdir(d): + continue + source = "builtin" if d == builtin_dir else "custom" + for name in sorted(os.listdir(d)): + skill_path = os.path.join(d, name) + if os.path.isdir(skill_path) and not name.startswith("."): + has_skill_md = os.path.exists(os.path.join(skill_path, "SKILL.md")) + if has_skill_md: + entries.append({"name": name, "source": source, "enabled": True, "description": ""}) + if not entries: + click.echo("No skills installed.") + return + _print_skill_table(entries) + return + + entries = sorted(config.values(), key=lambda x: x.get("name", "")) + if not entries: + click.echo("No skills installed.") + return + _print_skill_table(entries) + + +def _print_skill_table(entries): + """Print skills as a formatted table.""" + name_w = max(len(e.get("name", "")) for e in entries) + name_w = max(name_w, 4) + 2 + desc_w = 40 + + header = f"{'Name':<{name_w}} {'Status':<10} {'Source':<10} {'Description'}" + click.echo(f"\n Installed skills ({len(entries)})\n") + click.echo(f" {header}") + click.echo(f" {'─' * (name_w + 10 + 10 + desc_w)}") + + for e in entries: + name = e.get("name", "") + enabled = e.get("enabled", True) + source = e.get("source", "") + desc = e.get("description", "") or "" + if len(desc) > desc_w: + desc = desc[:desc_w - 3] + "..." + + status_icon = click.style("✓ on ", fg="green") if enabled else click.style("✗ off", fg="red") + click.echo(f" {name:<{name_w}} {status_icon} {source:<10} {desc}") + + click.echo() + + +_REMOTE_PAGE_SIZE = 10 + + +def _list_remote(page: int = 1): + """List skills from remote Skill Hub with server-side pagination.""" + try: + resp = requests.get( + f"{SKILL_HUB_API}/skills", + params={"page": page, "limit": _REMOTE_PAGE_SIZE}, + timeout=10, + ) + resp.raise_for_status() + data = resp.json() + except Exception as e: + click.echo(f"Error: Failed to fetch from Skill Hub: {e}", err=True) + sys.exit(1) + + skills = data.get("skills", []) + total = data.get("total", len(skills)) + + if not skills and page == 1: + click.echo("No skills available on Skill Hub.") + return + + total_pages = max(1, (total + _REMOTE_PAGE_SIZE - 1) // _REMOTE_PAGE_SIZE) + page = min(page, total_pages) + installed = set(load_skills_config().keys()) + + name_w = max((len(s.get("name", "")) for s in skills), default=4) + name_w = max(name_w, 4) + 2 + + click.echo(f"\n Skill Hub ({total} available) — page {page}/{total_pages}\n") + click.echo(f" {'Name':<{name_w}} {'Status':<12} {'Description'}") + click.echo(f" {'─' * (name_w + 12 + 50)}") + + for s in skills: + name = s.get("name", "") + desc = s.get("description", "") or s.get("display_name", "") + if len(desc) > 50: + desc = desc[:47] + "..." + status = click.style("installed", fg="green") if name in installed else "—" + click.echo(f" {name:<{name_w}} {status:<12} {desc}") + + click.echo() + nav_parts = [] + if page > 1: + nav_parts.append(f"cow skill list --remote --page {page - 1}") + if page < total_pages: + nav_parts.append(f"cow skill list --remote --page {page + 1}") + if nav_parts: + click.echo(f" Navigate: {' | '.join(nav_parts)}") + click.echo(f" Install: cow skill install \n") + + +# ------------------------------------------------------------------ +# cow skill search +# ------------------------------------------------------------------ +@skill.command() +@click.argument("query") +def search(query): + """Search skills on Skill Hub.""" + try: + resp = requests.get(f"{SKILL_HUB_API}/skills/search", params={"q": query}, timeout=10) + resp.raise_for_status() + data = resp.json() + except Exception as e: + click.echo(f"Error: Failed to search Skill Hub: {e}", err=True) + sys.exit(1) + + skills = data.get("skills", []) + if not skills: + click.echo(f'No skills found for "{query}".') + return + + installed = set(load_skills_config().keys()) + name_w = max(len(s.get("name", "")) for s in skills) + name_w = max(name_w, 4) + 2 + + click.echo(f'\n Search results for "{query}" ({len(skills)} found)\n') + click.echo(f" {'Name':<{name_w}} {'Status':<12} {'Description'}") + click.echo(f" {'─' * (name_w + 12 + 50)}") + + for s in skills: + name = s.get("name", "") + desc = s.get("description", "") or s.get("display_name", "") + if len(desc) > 50: + desc = desc[:47] + "..." + status = click.style("installed", fg="green") if name in installed else "—" + click.echo(f" {name:<{name_w}} {status:<12} {desc}") + + click.echo(f"\n Install with: cow skill install \n") + + +# ------------------------------------------------------------------ +# cow skill install +# ------------------------------------------------------------------ +@skill.command() +@click.argument("name") +def install(name): + """Install a skill from Skill Hub, GitHub, or a SKILL.md URL. + + Examples: + + cow skill install pptx + + cow skill install github:owner/repo + + cow skill install github:owner/repo#path/to/skill + + cow skill install https://github.com/owner/repo/tree/main/path/to/skill + + cow skill install https://example.com/path/to/SKILL.md + """ + if name.startswith(("http://", "https://")) and name.rstrip("/").endswith("SKILL.md"): + # GitHub SKILL.md → strip filename and install the whole directory + dir_url = re.sub(r'/SKILL\.md/?$', '', name) + gh = _parse_github_url(dir_url) + if gh: + owner, repo, branch, subpath = gh + spec = f"{owner}/{repo}" + skill_name = subpath.rstrip("/").split("/")[-1] if subpath else repo + _install_github(spec, subpath=subpath, skill_name=skill_name, branch=branch) + return + _install_url(name) + return + + parsed = _parse_github_url(name) + if parsed: + owner, repo, branch, subpath = parsed + spec = f"{owner}/{repo}" + skill_name = subpath.rstrip("/").split("/")[-1] if subpath else repo + _install_github(spec, subpath=subpath, skill_name=skill_name, branch=branch) + elif name.startswith("github:"): + skill_name = name[7:] + _validate_skill_name(skill_name) + _install_hub(skill_name) + elif name.startswith("clawhub:"): + skill_name = name[8:] + _validate_skill_name(skill_name) + _install_hub(skill_name, provider="clawhub") + else: + _validate_skill_name(name) + _install_hub(name) + + +def _install_hub(name, provider=None): + """Install a skill from Skill Hub.""" + skills_dir = get_skills_dir() + os.makedirs(skills_dir, exist_ok=True) + + click.echo(f"Fetching skill info for '{name}'...") + + try: + body = {} + if provider: + body["provider"] = provider + resp = requests.post( + f"{SKILL_HUB_API}/skills/{name}/download", + json=body, + timeout=15, + ) + resp.raise_for_status() + except requests.HTTPError as e: + if e.response is not None and e.response.status_code == 404: + click.echo(f"Error: Skill '{name}' not found on Skill Hub.", err=True) + else: + click.echo(f"Error: Failed to fetch skill: {e}", err=True) + sys.exit(1) + except Exception as e: + click.echo(f"Error: Failed to connect to Skill Hub: {e}", err=True) + sys.exit(1) + + 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": + source_url = data.get("source_url", "") + parsed_url = _parse_github_url(source_url) + if parsed_url: + owner, repo, branch, subpath = parsed_url + click.echo(f"Source: GitHub ({source_url})") + _install_github(f"{owner}/{repo}", subpath=subpath, skill_name=name, branch=branch) + else: + _validate_github_spec(source_url) + click.echo(f"Source: GitHub ({source_url})") + _install_github(source_url, skill_name=name) + return + + if source_type == "registry": + download_url = data.get("download_url") + if download_url: + parsed = urlparse(download_url) + if parsed.scheme != "https": + click.echo(f"Error: Refusing to download from non-HTTPS URL.", err=True) + sys.exit(1) + provider = data.get("source_provider", "registry") + expected_checksum = data.get("checksum") or data.get("sha256") + click.echo(f"Source: {provider}") + click.echo("Downloading skill package...") + try: + dl_resp = requests.get(download_url, timeout=60, allow_redirects=True) + dl_resp.raise_for_status() + except Exception as e: + click.echo(f"Error: Failed to download from {provider}: {e}", err=True) + sys.exit(1) + _verify_checksum(dl_resp.content, expected_checksum) + _install_zip_bytes(dl_resp.content, name, skills_dir) + _register_installed_skill(name, source=provider) + _print_install_success(name, provider) + else: + click.echo(f"Error: Unsupported registry provider.", err=True) + sys.exit(1) + return + + if "redirect" in data: + source_url = data.get("source_url", "") + parsed_url = _parse_github_url(source_url) + if parsed_url: + owner, repo, branch, subpath = parsed_url + click.echo(f"Source: GitHub ({source_url})") + _install_github(f"{owner}/{repo}", subpath=subpath, skill_name=name, branch=branch) + else: + _validate_github_spec(source_url) + click.echo(f"Source: GitHub ({source_url})") + _install_github(source_url, skill_name=name) + return + + elif "application/zip" in content_type: + click.echo("Downloading skill package...") + expected_checksum = resp.headers.get("X-Checksum-Sha256") + _verify_checksum(resp.content, expected_checksum) + _install_zip_bytes(resp.content, name, skills_dir) + _register_installed_skill(name) + _print_install_success(name, "cowhub") + return + + click.echo(f"Error: Unexpected response from Skill Hub.", err=True) + sys.exit(1) + + +def _install_github(spec, subpath=None, skill_name=None, branch="main", source="github"): + """Install a skill from a GitHub repo. + + spec format: owner/repo or owner/repo#path + """ + if "#" in spec and not subpath: + spec, subpath = spec.split("#", 1) + + _validate_github_spec(spec) + + if not skill_name: + skill_name = subpath.rstrip("/").split("/")[-1] if subpath else spec.split("/")[-1] + _validate_skill_name(skill_name) + + skills_dir = get_skills_dir() + os.makedirs(skills_dir, exist_ok=True) + target_dir = os.path.join(skills_dir, skill_name) + + owner, repo = spec.split("/", 1) + + # For subpath installs, try GitHub Contents API first (avoids downloading entire repo) + if subpath: + click.echo(f"Downloading from GitHub: {spec}/{subpath} (branch: {branch})...") + try: + with tempfile.TemporaryDirectory() as tmp_dir: + api_dest = os.path.join(tmp_dir, skill_name) + os.makedirs(api_dest) + _download_github_dir(owner, repo, branch, subpath.strip("/"), api_dest) + if os.path.exists(target_dir): + shutil.rmtree(target_dir) + shutil.copytree(api_dest, target_dir) + _register_installed_skill(skill_name, source=source) + _print_install_success(skill_name, source) + return + except Exception: + click.echo("Contents API unavailable, falling back to zip download...") + + # Fallback: download full repo zip + zip_url = f"https://github.com/{spec}/archive/refs/heads/{branch}.zip" + click.echo(f"Downloading from GitHub: {spec} (branch: {branch})...") + + try: + resp = requests.get(zip_url, timeout=60, allow_redirects=True) + resp.raise_for_status() + except Exception as e: + click.echo(f"Error: Failed to download from GitHub: {e}", err=True) + sys.exit(1) + + 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: + _safe_extractall(zf, 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): + click.echo(f"Error: Path '{subpath}' not found in repository.", err=True) + sys.exit(1) + else: + source_dir = repo_root + + if os.path.exists(target_dir): + shutil.rmtree(target_dir) + shutil.copytree(source_dir, target_dir) + + _register_installed_skill(skill_name, source=source) + _print_install_success(skill_name, source) + + +def _install_zip_bytes(content, name, skills_dir): + """Extract a zip archive into the skills directory.""" + 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: + _safe_extractall(zf, 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) + + + + +# ------------------------------------------------------------------ +# cow skill uninstall +# ------------------------------------------------------------------ +@skill.command() +@click.argument("name") +@click.option("--yes", "-y", is_flag=True, help="Skip confirmation") +def uninstall(name, yes): + """Uninstall a skill.""" + _validate_skill_name(name) + skills_dir = get_skills_dir() + skill_dir = os.path.join(skills_dir, name) + + if not os.path.exists(skill_dir): + click.echo(f"Error: Skill '{name}' is not installed.", err=True) + sys.exit(1) + + if not yes: + click.confirm(f"Uninstall skill '{name}'?", abort=True) + + 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 + + click.echo(click.style(f"✓ Skill '{name}' uninstalled.", fg="green")) + + +# ------------------------------------------------------------------ +# cow skill enable / disable +# ------------------------------------------------------------------ +@skill.command() +@click.argument("name") +def enable(name): + """Enable a skill.""" + _set_enabled(name, True) + + +@skill.command() +@click.argument("name") +def disable(name): + """Disable a skill.""" + _set_enabled(name, False) + + +def _set_enabled(name, enabled): + _validate_skill_name(name) + skills_dir = get_skills_dir() + config_path = os.path.join(skills_dir, "skills_config.json") + + if not os.path.exists(config_path): + click.echo(f"Error: No skills config found.", err=True) + sys.exit(1) + + try: + with open(config_path, "r", encoding="utf-8") as f: + config = json.load(f) + except Exception as e: + click.echo(f"Error: Failed to read skills config: {e}", err=True) + sys.exit(1) + + if name not in config: + click.echo(f"Error: Skill '{name}' not found in config.", err=True) + sys.exit(1) + + config[name]["enabled"] = enabled + with open(config_path, "w", encoding="utf-8") as f: + json.dump(config, f, indent=4, ensure_ascii=False) + + state = "enabled" if enabled else "disabled" + icon = "✓" if enabled else "✗" + color = "green" if enabled else "yellow" + click.echo(click.style(f"{icon} Skill '{name}' {state}.", fg=color)) + + +# ------------------------------------------------------------------ +# cow skill info +# ------------------------------------------------------------------ +@skill.command() +@click.argument("name") +def info(name): + """Show details about an installed skill.""" + _validate_skill_name(name) + skills_dir = get_skills_dir() + builtin_dir = get_builtin_skills_dir() + + skill_dir = None + source = None + config = load_skills_config() + 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 = config.get(name, {}).get("source") or src + break + + if not skill_dir: + click.echo(f"Error: Skill '{name}' not found.", err=True) + sys.exit(1) + + skill_md = os.path.join(skill_dir, "SKILL.md") + if not os.path.exists(skill_md): + click.echo(f"Skill directory: {skill_dir}") + click.echo("No SKILL.md found.") + return + + with open(skill_md, "r", encoding="utf-8") as f: + content = f.read() + + config = load_skills_config() + entry = config.get(name, {}) + enabled = entry.get("enabled", True) + status_str = click.style("✓ enabled", fg="green") if enabled else click.style("✗ disabled", fg="red") + + click.echo(f"\n Skill: {name}") + click.echo(f" Source: {source}") + click.echo(f" Status: {status_str}") + click.echo(f" Path: {skill_dir}") + click.echo(f"\n{'─' * 60}") + + # Show first ~30 lines of SKILL.md as a preview + lines = content.split("\n") + preview = "\n".join(lines[:30]) + click.echo(preview) + if len(lines) > 30: + click.echo(f"\n ... ({len(lines) - 30} more lines, see {skill_md})") + click.echo() diff --git a/cli/utils.py b/cli/utils.py new file mode 100644 index 00000000..b40f8dd5 --- /dev/null +++ b/cli/utils.py @@ -0,0 +1,62 @@ +"""Shared utilities for cow CLI.""" + +import os +import sys +import json + + +def get_project_root() -> str: + """Get the CowAgent project root directory.""" + # cli/ is directly under the project root + return os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +def get_workspace_dir() -> str: + """Get the agent workspace directory from config, defaulting to ~/cow.""" + config = load_config_json() + workspace = config.get("agent_workspace", "~/cow") + return os.path.expanduser(workspace) + + +def get_skills_dir() -> str: + """Get the custom skills directory.""" + return os.path.join(get_workspace_dir(), "skills") + + +def get_builtin_skills_dir() -> str: + """Get the builtin skills directory.""" + return os.path.join(get_project_root(), "skills") + + +def load_config_json() -> dict: + """Load config.json from project root.""" + config_path = os.path.join(get_project_root(), "config.json") + if not os.path.exists(config_path): + return {} + try: + with open(config_path, "r", encoding="utf-8") as f: + return json.load(f) + except Exception: + return {} + + +def load_skills_config() -> dict: + """Load skills_config.json from the custom skills directory.""" + path = os.path.join(get_skills_dir(), "skills_config.json") + if not os.path.exists(path): + return {} + try: + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + except Exception: + return {} + + +def ensure_sys_path(): + """Add project root to sys.path so we can import agent modules.""" + root = get_project_root() + if root not in sys.path: + sys.path.insert(0, root) + + +SKILL_HUB_API = "https://skills.cowagent.ai/api" 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..7d2fce09 --- /dev/null +++ b/plugins/cow_cli/cow_cli.py @@ -0,0 +1,1000 @@ +""" +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] + "…" + line = f"{icon} {name}" + if desc: + line += f"\n {desc}" + if source: + line += f"\n 来源: {source}" + lines.append(line) + 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 <名称>" + + try: + from cli.utils import get_skills_dir, SKILL_HUB_API + from cli.commands.skill import _parse_github_url, _download_github_dir + import requests + import shutil + import zipfile + import tempfile + + skills_dir = get_skills_dir() + os.makedirs(skills_dir, exist_ok=True) + + if name.startswith(("http://", "https://")) and name.rstrip("/").endswith("SKILL.md"): + import re as re_mod + dir_url = re_mod.sub(r'/SKILL\.md/?$', '', name) + gh = _parse_github_url(dir_url) + if gh: + owner, repo, branch, subpath = gh + spec = f"{owner}/{repo}" + skill_name = subpath.rstrip("/").split("/")[-1] if subpath else repo + return self._skill_install_github( + spec, skills_dir, subpath=subpath, skill_name=skill_name, branch=branch + ) + return self._skill_install_url(name, skills_dir) + + parsed = _parse_github_url(name) + if parsed: + owner, repo, branch, subpath = parsed + spec = f"{owner}/{repo}" + skill_name = subpath.rstrip("/").split("/")[-1] if subpath else repo + return self._skill_install_github( + spec, skills_dir, subpath=subpath, skill_name=skill_name, branch=branch + ) + + provider = None + if name.startswith("github:"): + name = name[7:] + elif name.startswith("clawhub:"): + name = name[8:] + provider = "clawhub" + + body = {} + if provider: + body["provider"] = provider + resp = requests.post( + f"{SKILL_HUB_API}/skills/{name}/download", + json=body, + 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", "") + parsed_url = _parse_github_url(source_url) + if parsed_url: + owner, repo, branch, subpath = parsed_url + return self._skill_install_github( + f"{owner}/{repo}", skills_dir, subpath=subpath, + skill_name=name, branch=branch + ) + return self._skill_install_github(source_url, skills_dir, 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._register_skill(name, source=provider) + return self._format_install_success(name, provider) + + elif "application/zip" in content_type: + self._extract_zip(resp.content, name, skills_dir) + self._register_skill(name, source="cowhub") + return self._format_install_success(name, "cowhub") + + 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_url(self, url: str, skills_dir: str) -> str: + """Install a skill from a direct SKILL.md URL.""" + import requests + from cli.commands.skill import _parse_skill_frontmatter + + try: + resp = requests.get(url, timeout=30) + resp.raise_for_status() + except Exception as e: + return f"下载 SKILL.md 失败: {e}" + + content = resp.text + fm = _parse_skill_frontmatter(content) + skill_name = fm.get("name") + if not skill_name: + return "SKILL.md 中未找到 name 字段,无法安装" + + skill_name = skill_name.strip() + skill_dir = os.path.join(skills_dir, skill_name) + os.makedirs(skill_dir, exist_ok=True) + + with open(os.path.join(skill_dir, "SKILL.md"), "w", encoding="utf-8") as f: + f.write(content) + + self._register_skill(skill_name, source="url") + return self._format_install_success(skill_name, "url") + + def _skill_install_github(self, spec: str, skills_dir: str, + subpath: str = None, skill_name: str = None, + branch: str = "main") -> str: + import requests + import shutil + import zipfile + import tempfile + from cli.commands.skill import _download_github_dir + + 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] + + owner, repo = spec.split("/", 1) + target_dir = os.path.join(skills_dir, skill_name) + + # For subpath installs, try Contents API first + if subpath: + try: + with tempfile.TemporaryDirectory() as tmp_dir: + api_dest = os.path.join(tmp_dir, skill_name) + os.makedirs(api_dest) + _download_github_dir(owner, repo, branch, subpath.strip("/"), api_dest) + if os.path.exists(target_dir): + shutil.rmtree(target_dir) + shutil.copytree(api_dest, target_dir) + self._register_skill(skill_name, source="github") + return self._format_install_success(skill_name, "github") + except Exception: + pass # fall through to zip download + + # Fallback: download full repo zip + zip_url = f"https://github.com/{spec}/archive/refs/heads/{branch}.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 + + if os.path.exists(target_dir): + shutil.rmtree(target_dir) + shutil.copytree(source_dir, target_dir) + + self._register_skill(skill_name, source="github") + return self._format_install_success(skill_name, "github") + + 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) + + @staticmethod + def _register_skill(name: str, source: str = "cowhub"): + try: + from cli.commands.skill import _register_installed_skill + _register_installed_skill(name, source=source) + except Exception: + pass + + @staticmethod + def _format_install_success(name: str, source: str) -> str: + from cli.commands.skill import _read_skill_description + from cli.utils import get_skills_dir + desc = _read_skill_description(os.path.join(get_skills_dir(), name)) + lines = [f"✅ {name}"] + if desc: + if len(desc) > 60: + desc = desc[:57] + "…" + lines.append(f" {desc}") + lines.append(f" 来源: {source}") + return "\n".join(lines) + + 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/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..565d07e2 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,19 @@ +[build-system] +requires = ["setuptools>=45.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "cowagent" +version = "0.0.1" +description = "CowAgent - AI Agent on WeChat and more" +requires-python = ">=3.9" +dependencies = [ + "click>=8.0", + "requests>=2.28.2", +] + +[project.scripts] +cow = "cli.cli:main" + +[tool.setuptools.packages.find] +include = ["cli*"] diff --git a/run.sh b/run.sh index d2df6355..9a2eb088 100755 --- a/run.sh +++ b/run.sh @@ -171,8 +171,11 @@ clone_project() { mv chatgpt-on-wechat-master chatgpt-on-wechat rm chatgpt-on-wechat.zip else - git clone https://github.com/zhayujie/chatgpt-on-wechat.git || \ - git clone https://gitee.com/zhayujie/chatgpt-on-wechat.git + GIT_HTTP_CONNECT_TIMEOUT=10 GIT_HTTP_LOW_SPEED_LIMIT=1024 GIT_HTTP_LOW_SPEED_TIME=15 \ + git clone --depth 10 --progress https://github.com/zhayujie/chatgpt-on-wechat.git || { + echo -e "${YELLOW}⚠️ GitHub is slow, switching to Gitee mirror...${NC}" + git clone --depth 10 --progress https://gitee.com/zhayujie/chatgpt-on-wechat.git + } if [[ $? -ne 0 ]]; then echo -e "${RED}❌ Project clone failed. Please check network connection.${NC}" exit 1 @@ -195,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 @@ -242,6 +248,17 @@ install_dependencies() { fi rm -f /tmp/pip_install.log + + # Register `cow` CLI command via editable install + echo -e "${YELLOW}Registering cow CLI...${NC}" + set +e + $PYTHON_CMD -m pip install -e . $PIP_EXTRA_ARGS $PIP_MIRROR > /dev/null 2>&1 + if command -v cow &> /dev/null; then + echo -e "${GREEN}✅ cow CLI registered.${NC}" + else + echo -e "${YELLOW}⚠️ cow CLI not in PATH, you can still use: $PYTHON_CMD -m cli.cli${NC}" + fi + set -e } # Select model @@ -527,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 @@ -554,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" @@ -603,7 +635,7 @@ ensure_python_cmd() { # Get service PID (empty string if not running) get_pid() { ensure_python_cmd > /dev/null 2>&1 - ps ax | grep -i app.py | grep "${BASE_DIR}" | grep "$PYTHON_CMD" | grep -v grep | awk '{print $1}' | grep -E '^[0-9]+$' + ps ax | grep -i app.py | grep "${BASE_DIR}" | grep "$PYTHON_CMD" | grep -v grep | awk '{print $1}' | grep -E '^[0-9]+$' | head -1 } # Check if service is running @@ -611,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 } diff --git a/scripts/run.ps1 b/scripts/run.ps1 new file mode 100644 index 00000000..50a407df --- /dev/null +++ b/scripts/run.ps1 @@ -0,0 +1,447 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + CowAgent installer & management script for Windows. +.DESCRIPTION + One-liner install: + irm https://raw.githubusercontent.com/zhayujie/chatgpt-on-wechat/master/scripts/run.ps1 | iex + Or from a local clone: + .\scripts\run.ps1 # install / configure + .\scripts\run.ps1 start # start service (delegates to cow CLI) + .\scripts\run.ps1 stop|restart|status|logs|config|update|help +#> + +param( + [Parameter(Position = 0)] + [string]$Command = "" +) + +$ErrorActionPreference = "Stop" + +# ── colours ────────────────────────────────────────────────────── +function Write-Cow { param([string]$M) Write-Host $M -ForegroundColor Green } +function Write-Warn { param([string]$M) Write-Host $M -ForegroundColor Yellow } +function Write-Err { param([string]$M) Write-Host $M -ForegroundColor Red } +function Write-Info { param([string]$M) Write-Host $M -ForegroundColor Cyan } + +# ── detect project directory ───────────────────────────────────── +$ScriptDir = if ($PSScriptRoot) { $PSScriptRoot } else { $PWD.Path } +$BaseDir = Split-Path $ScriptDir -Parent + +$IsProjectDir = (Test-Path "$BaseDir\app.py") -and (Test-Path "$BaseDir\config-template.json") +if (-not $IsProjectDir) { + $BaseDir = $PWD.Path + $IsProjectDir = (Test-Path "$BaseDir\app.py") -and (Test-Path "$BaseDir\config-template.json") +} + +# ── Python detection ───────────────────────────────────────────── +function Find-Python { + foreach ($cmd in @("python3", "python")) { + $bin = Get-Command $cmd -ErrorAction SilentlyContinue + if (-not $bin) { continue } + try { + $ver = & $bin.Source -c "import sys; v=sys.version_info; print(f'{v.major}.{v.minor}')" 2>$null + $parts = $ver -split '\.' + $major = [int]$parts[0]; $minor = [int]$parts[1] + if ($major -eq 3 -and $minor -ge 9 -and $minor -le 13) { + return $bin.Source + } + } catch {} + } + return $null +} + +$PythonCmd = Find-Python +function Assert-Python { + if (-not $PythonCmd) { + Write-Err "Python 3.9-3.13 not found. Please install from https://www.python.org/downloads/" + exit 1 + } + Write-Cow "Found Python: $PythonCmd" +} + +# ── clone project ──────────────────────────────────────────────── +function Install-Project { + if (Test-Path "chatgpt-on-wechat") { + Write-Warn "Directory 'chatgpt-on-wechat' already exists." + $choice = Read-Host "Overwrite(o), backup(b), or quit(q)? [default: b]" + if (-not $choice) { $choice = "b" } + switch ($choice.ToLower()) { + "o" { Remove-Item -Recurse -Force "chatgpt-on-wechat" } + "b" { + $backup = "chatgpt-on-wechat_backup_$(Get-Date -Format 'yyyyMMddHHmmss')" + Rename-Item "chatgpt-on-wechat" $backup + Write-Cow "Backed up to '$backup'" + } + "q" { Write-Err "Installation cancelled."; exit 1 } + default { Write-Err "Invalid choice."; exit 1 } + } + } + + $gitBin = Get-Command git -ErrorAction SilentlyContinue + if (-not $gitBin) { + Write-Err "Git not found. Please install from https://git-scm.com/download/win" + exit 1 + } + + Write-Cow "Cloning CowAgent project..." + git clone https://github.com/zhayujie/chatgpt-on-wechat.git 2>$null + if ($LASTEXITCODE -ne 0) { + Write-Warn "GitHub failed, trying Gitee..." + git clone https://gitee.com/zhayujie/chatgpt-on-wechat.git + if ($LASTEXITCODE -ne 0) { + Write-Err "Clone failed. Check your network." + exit 1 + } + } + + Set-Location "chatgpt-on-wechat" + $script:BaseDir = $PWD.Path + $script:IsProjectDir = $true + Write-Cow "Project cloned: $BaseDir" +} + +# ── install dependencies ───────────────────────────────────────── +function Install-Dependencies { + Write-Cow "Installing dependencies..." + + & $PythonCmd -m pip install --upgrade pip setuptools wheel 2>$null | Out-Null + + & $PythonCmd -m pip install -r "$BaseDir\requirements.txt" 2>&1 | ForEach-Object { Write-Host $_ } + if ($LASTEXITCODE -ne 0) { + Write-Warn "Some dependencies may have issues, but continuing..." + } + + Write-Cow "Registering cow CLI..." + & $PythonCmd -m pip install -e $BaseDir 2>$null | Out-Null + $cowBin = Get-Command cow -ErrorAction SilentlyContinue + if ($cowBin) { + Write-Cow "cow CLI registered." + } else { + Write-Warn "cow CLI not in PATH. You can use: $PythonCmd -m cli.cli" + } +} + +# ── model selection ────────────────────────────────────────────── +$ModelChoices = @{ + "1" = @{ Provider = "MiniMax"; Default = "MiniMax-M2.7"; Key = "MINIMAX_KEY" } + "2" = @{ Provider = "Zhipu AI"; Default = "glm-5-turbo"; Key = "ZHIPU_KEY" } + "3" = @{ Provider = "Kimi (Moonshot)"; Default = "kimi-k2.5"; Key = "MOONSHOT_KEY" } + "4" = @{ Provider = "Doubao (Volcengine Ark)"; Default = "doubao-seed-2-0-code-preview-260215"; Key = "ARK_KEY" } + "5" = @{ Provider = "Qwen (DashScope)"; Default = "qwen3.5-plus"; Key = "DASHSCOPE_KEY" } + "6" = @{ Provider = "Claude"; Default = "claude-sonnet-4-6"; Key = "CLAUDE_KEY"; Base = "https://api.anthropic.com/v1" } + "7" = @{ Provider = "Gemini"; Default = "gemini-3.1-pro-preview"; Key = "GEMINI_KEY"; Base = "https://generativelanguage.googleapis.com" } + "8" = @{ Provider = "OpenAI GPT"; Default = "gpt-5.4"; Key = "OPENAI_KEY"; Base = "https://api.openai.com/v1" } + "9" = @{ Provider = "LinkAI"; Default = "MiniMax-M2.7"; Key = "LINKAI_KEY" } +} + +function Select-Model { + Write-Info "=========================================" + Write-Info " Select AI Model" + Write-Info "=========================================" + Write-Host "1) MiniMax (MiniMax-M2.7, MiniMax-M2.5, etc.)" + Write-Host "2) Zhipu AI (glm-5-turbo, glm-5, etc.)" + Write-Host "3) Kimi (kimi-k2.5, kimi-k2, etc.)" + Write-Host "4) Doubao (doubao-seed-2-0-code-preview-260215, etc.)" + Write-Host "5) Qwen (qwen3.5-plus, qwen3-max, qwq-plus, etc.)" + Write-Host "6) Claude (claude-sonnet-4-6, claude-opus-4-6, etc.)" + Write-Host "7) Gemini (gemini-3.1-flash-lite-preview, gemini-3.1-pro-preview, etc.)" + Write-Host "8) OpenAI GPT (gpt-5.4, gpt-5.2, gpt-4.1, etc.)" + Write-Host "9) LinkAI (access multiple models via one API)" + Write-Host "" + + do { + $choice = Read-Host "Enter your choice [default: 1 - MiniMax]" + if (-not $choice) { $choice = "1" } + } while ($choice -notmatch '^[1-9]$') + + $m = $ModelChoices[$choice] + Write-Cow "Configuring $($m.Provider)..." + + $script:ApiKey = Read-Host "Enter $($m.Provider) API Key" + $model = Read-Host "Enter model name [default: $($m.Default)]" + if (-not $model) { $model = $m.Default } + $script:ModelName = $model + $script:KeyName = $m.Key + $script:UseLinkai = ($choice -eq "9") + + if ($m.Base) { + $base = Read-Host "Enter API Base URL [default: $($m.Base)]" + if (-not $base) { $base = $m.Base } + $script:ApiBase = $base + } else { + $script:ApiBase = "" + } + $script:ModelChoice = $choice +} + +# ── channel selection ──────────────────────────────────────────── +function Select-Channel { + Write-Host "" + Write-Info "=========================================" + Write-Info " Select Communication Channel" + Write-Info "=========================================" + Write-Host "1) Weixin" + Write-Host "2) Feishu" + Write-Host "3) DingTalk" + Write-Host "4) WeCom Bot" + Write-Host "5) QQ" + Write-Host "6) WeCom App" + Write-Host "7) Web" + Write-Host "" + + do { + $choice = Read-Host "Enter your choice [default: 1 - Weixin]" + if (-not $choice) { $choice = "1" } + } while ($choice -notmatch '^[1-7]$') + + $script:ChannelExtra = @{} + + switch ($choice) { + "1" { $script:ChannelType = "weixin" } + "2" { + $script:ChannelType = "feishu" + $script:ChannelExtra["feishu_app_id"] = Read-Host "Enter Feishu App ID" + $script:ChannelExtra["feishu_app_secret"] = Read-Host "Enter Feishu App Secret" + } + "3" { + $script:ChannelType = "dingtalk" + $script:ChannelExtra["dingtalk_client_id"] = Read-Host "Enter DingTalk Client ID" + $script:ChannelExtra["dingtalk_client_secret"] = Read-Host "Enter DingTalk Client Secret" + } + "4" { + $script:ChannelType = "wecom_bot" + $script:ChannelExtra["wecom_bot_id"] = Read-Host "Enter WeCom Bot ID" + $script:ChannelExtra["wecom_bot_secret"] = Read-Host "Enter WeCom Bot Secret" + } + "5" { + $script:ChannelType = "qq" + $script:ChannelExtra["qq_app_id"] = Read-Host "Enter QQ App ID" + $script:ChannelExtra["qq_app_secret"] = Read-Host "Enter QQ App Secret" + } + "6" { + $script:ChannelType = "wechatcom_app" + $script:ChannelExtra["wechatcom_corp_id"] = Read-Host "Enter WeChat Corp ID" + $script:ChannelExtra["wechatcomapp_token"] = Read-Host "Enter WeChat Com App Token" + $script:ChannelExtra["wechatcomapp_secret"] = Read-Host "Enter WeChat Com App Secret" + $script:ChannelExtra["wechatcomapp_agent_id"] = Read-Host "Enter WeChat Com App Agent ID" + $script:ChannelExtra["wechatcomapp_aes_key"] = Read-Host "Enter WeChat Com App AES Key" + $port = Read-Host "Enter port [default: 9898]" + if (-not $port) { $port = "9898" } + $script:ChannelExtra["wechatcomapp_port"] = [int]$port + } + "7" { + $script:ChannelType = "web" + $port = Read-Host "Enter web port [default: 9899]" + if (-not $port) { $port = "9899" } + $script:ChannelExtra["web_port"] = [int]$port + } + } +} + +# ── generate config.json ───────────────────────────────────────── +function New-ConfigFile { + Write-Cow "Generating config.json..." + + $config = [ordered]@{ + channel_type = $ChannelType + model = $ModelName + open_ai_api_key = "" + open_ai_api_base = "https://api.openai.com/v1" + claude_api_key = "" + claude_api_base = "https://api.anthropic.com/v1" + gemini_api_key = "" + gemini_api_base = "https://generativelanguage.googleapis.com" + zhipu_ai_api_key = "" + moonshot_api_key = "" + ark_api_key = "" + dashscope_api_key = "" + minimax_api_key = "" + voice_to_text = "openai" + text_to_voice = "openai" + voice_reply_voice = $false + speech_recognition = $true + group_speech_recognition = $false + use_linkai = $UseLinkai + linkai_api_key = "" + linkai_app_code = "" + agent = $true + agent_max_context_tokens = 40000 + agent_max_context_turns = 30 + agent_max_steps = 15 + } + + # Set the correct API key field + $keyMap = @{ + OPENAI_KEY = "open_ai_api_key" + CLAUDE_KEY = "claude_api_key" + GEMINI_KEY = "gemini_api_key" + ZHIPU_KEY = "zhipu_ai_api_key" + MOONSHOT_KEY = "moonshot_api_key" + ARK_KEY = "ark_api_key" + DASHSCOPE_KEY = "dashscope_api_key" + MINIMAX_KEY = "minimax_api_key" + LINKAI_KEY = "linkai_api_key" + } + if ($keyMap.ContainsKey($KeyName)) { + $config[$keyMap[$KeyName]] = $ApiKey + } + + # Set API base if provided + $baseMap = @{ + "6" = "claude_api_base" + "7" = "gemini_api_base" + "8" = "open_ai_api_base" + } + if ($ApiBase -and $baseMap.ContainsKey($ModelChoice)) { + $config[$baseMap[$ModelChoice]] = $ApiBase + } + + # Merge channel-specific fields + foreach ($k in $ChannelExtra.Keys) { + $config[$k] = $ChannelExtra[$k] + } + + $config | ConvertTo-Json -Depth 5 | Set-Content -Path "$BaseDir\config.json" -Encoding UTF8 + Write-Cow "Configuration file created." +} + +# ── start via cow CLI ───────────────────────────────────────────── +function Start-CowAgent { + Write-Cow "Starting CowAgent..." + $cowBin = Get-Command cow -ErrorAction SilentlyContinue + if ($cowBin) { + & cow start + } else { + Write-Warn "cow CLI not found, starting directly..." + & $PythonCmd "$BaseDir\app.py" + } +} + +# ── delegate management commands to cow CLI ────────────────────── +function Invoke-CowCommand { + param([string]$Cmd) + $cowBin = Get-Command cow -ErrorAction SilentlyContinue + if ($cowBin) { + & cow $Cmd + } else { + Write-Err "cow CLI not found. Run this script without arguments first to install." + exit 1 + } +} + +# ── usage ───────────────────────────────────────────────────────── +function Show-Usage { + Write-Info "=========================================" + Write-Info " CowAgent Management Script (Windows)" + Write-Info "=========================================" + Write-Host "" + Write-Host "Usage:" + Write-Host " .\run.ps1 # Install / Configure" + Write-Host " .\run.ps1 # Management command" + Write-Host "" + Write-Host "Commands:" + Write-Host " start Start the service" + Write-Host " stop Stop the service" + Write-Host " restart Restart the service" + Write-Host " status Check service status" + Write-Host " logs View logs" + Write-Host " config Reconfigure project" + Write-Host " update Update and restart" + Write-Host " help Show this message" + Write-Host "" +} + +# ── install mode ────────────────────────────────────────────────── +function Install-Mode { + Clear-Host + Write-Info "=========================================" + Write-Info " CowAgent Installation (Windows)" + Write-Info "=========================================" + Write-Host "" + + if ($IsProjectDir) { + Write-Cow "Detected existing project directory." + if (Test-Path "$BaseDir\config.json") { + Write-Cow "Project already configured." + Write-Host "" + Show-Usage + return + } + Write-Warn "No config.json found. Let's configure your project!" + Write-Host "" + Assert-Python + } else { + Assert-Python + Install-Project + } + + Install-Dependencies + Select-Model + Select-Channel + New-ConfigFile + + Write-Host "" + $startNow = Read-Host "Start CowAgent now? [Y/n]" + if ($startNow -ne "n" -and $startNow -ne "N") { + Start-CowAgent + } else { + Write-Cow "Installation complete!" + Write-Host "" + Write-Host "To start manually:" + Write-Host " cd $BaseDir" + Write-Host " cow start" + } +} + +# ── update ──────────────────────────────────────────────────────── +function Update-Project { + Write-Cow "Updating CowAgent..." + Set-Location $BaseDir + + # Stop if running + $cowBin = Get-Command cow -ErrorAction SilentlyContinue + if ($cowBin) { & cow stop 2>$null } + + if (Test-Path "$BaseDir\.git") { + Write-Cow "Pulling latest code..." + git pull 2>$null + if ($LASTEXITCODE -ne 0) { + Write-Warn "GitHub failed, trying Gitee..." + git remote set-url origin https://gitee.com/zhayujie/chatgpt-on-wechat.git + git pull + } + } else { + Write-Warn "Not a git repository, skipping code update." + } + + Assert-Python + Install-Dependencies + Start-CowAgent +} + +# ── main ────────────────────────────────────────────────────────── +switch ($Command.ToLower()) { + "" { Install-Mode } + "start" { Invoke-CowCommand "start" } + "stop" { Invoke-CowCommand "stop" } + "restart" { Invoke-CowCommand "restart" } + "status" { Invoke-CowCommand "status" } + "logs" { Invoke-CowCommand "logs" } + "config" { + Assert-Python + Install-Dependencies + Select-Model + Select-Channel + New-ConfigFile + $r = Read-Host "Restart service now? [Y/n]" + if ($r -ne "n" -and $r -ne "N") { Invoke-CowCommand "restart" } + } + "update" { Update-Project } + "help" { Show-Usage } + default { + Write-Err "Unknown command: $Command" + Show-Usage + exit 1 + } +} diff --git a/skills/linkai-agent/SKILL.md b/skills/linkai-agent/SKILL.md index 34af6799..3464e490 100644 --- a/skills/linkai-agent/SKILL.md +++ b/skills/linkai-agent/SKILL.md @@ -4,6 +4,7 @@ description: Call LinkAI applications and workflows. Use bash with curl to invok homepage: https://link-ai.tech metadata: emoji: 🤖 + default_enabled: false requires: bins: ["curl"] env: ["LINKAI_API_KEY"]