mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-04-06 04:25:14 +08:00
feat(cli): imporve cow cli and skill hub integration
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 = [
|
||||
"",
|
||||
"<unavailable_skills>",
|
||||
"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(" <skill>")
|
||||
lines.append(f" <name>{_escape_xml(skill.name)}</name>")
|
||||
lines.append(f" <description>{_escape_xml(skill.description)}</description>")
|
||||
lines.append(f" <missing>{_escape_xml(missing_str)}</missing>")
|
||||
if setup_hint:
|
||||
lines.append(f" <setup>{_escape_xml(setup_hint)}</setup>")
|
||||
lines.append(" </skill>")
|
||||
|
||||
lines.append("</unavailable_skills>")
|
||||
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
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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),
|
||||
"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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
===================================================================== */
|
||||
|
||||
// =====================================================================
|
||||
// Version — update this before each release
|
||||
// Version — fetched from backend (single source: /VERSION file)
|
||||
// =====================================================================
|
||||
const APP_VERSION = 'v2.0.4';
|
||||
let APP_VERSION = '';
|
||||
|
||||
// =====================================================================
|
||||
// i18n
|
||||
@@ -2236,7 +2236,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 <head>
|
||||
|
||||
@@ -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)
|
||||
@@ -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__})
|
||||
|
||||
1
cli/VERSION
Normal file
1
cli/VERSION
Normal file
@@ -0,0 +1 @@
|
||||
2.0.4
|
||||
@@ -1,3 +1,13 @@
|
||||
"""CowAgent CLI - Manage your CowAgent from the command line."""
|
||||
|
||||
__version__ = "0.0.1"
|
||||
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()
|
||||
|
||||
53
cli/cli.py
53
cli/cli.py
@@ -7,11 +7,56 @@ from cli.commands.process import start, stop, restart, status, logs
|
||||
from cli.commands.context import context
|
||||
|
||||
|
||||
@click.group()
|
||||
@click.version_option(__version__, '--version', '-v', prog_name='cow')
|
||||
def main():
|
||||
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.
|
||||
status Show CowAgent running status.
|
||||
logs View CowAgent logs.
|
||||
context View or manage conversation context.
|
||||
skill Manage CowAgent skills.
|
||||
|
||||
Tip: You can also send /help, /skill list, etc. in 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."""
|
||||
pass
|
||||
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)
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
"""cow context - Context management commands."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import glob as glob_mod
|
||||
|
||||
import click
|
||||
|
||||
from cli.utils import get_workspace_dir
|
||||
|
||||
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)
|
||||
@@ -15,67 +16,14 @@ from cli.utils import get_workspace_dir
|
||||
def context(ctx):
|
||||
"""View or manage conversation context.
|
||||
|
||||
Without a subcommand, shows context info for the current workspace.
|
||||
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:
|
||||
_show_context_info()
|
||||
click.echo(f"\n {CHAT_HINT}\n")
|
||||
|
||||
|
||||
@context.command()
|
||||
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation")
|
||||
def clear(yes):
|
||||
def clear():
|
||||
"""Clear conversation context (messages history)."""
|
||||
workspace = get_workspace_dir()
|
||||
sessions_dir = os.path.join(workspace, "sessions")
|
||||
|
||||
if not os.path.isdir(sessions_dir):
|
||||
click.echo("No conversation data found.")
|
||||
return
|
||||
|
||||
db_files = glob_mod.glob(os.path.join(sessions_dir, "*.db"))
|
||||
if not db_files:
|
||||
click.echo("No conversation data found.")
|
||||
return
|
||||
|
||||
if not yes:
|
||||
click.confirm("Clear all conversation context? This cannot be undone.", abort=True)
|
||||
|
||||
removed = 0
|
||||
for db_file in db_files:
|
||||
try:
|
||||
os.remove(db_file)
|
||||
removed += 1
|
||||
except Exception as e:
|
||||
click.echo(f"Warning: Failed to remove {db_file}: {e}", err=True)
|
||||
|
||||
click.echo(click.style(f"✓ Cleared {removed} conversation database(s).", fg="green"))
|
||||
|
||||
|
||||
def _show_context_info():
|
||||
"""Display conversation context status."""
|
||||
workspace = get_workspace_dir()
|
||||
sessions_dir = os.path.join(workspace, "sessions")
|
||||
|
||||
click.echo(f"\n Context info")
|
||||
click.echo(f" Workspace: {workspace}")
|
||||
|
||||
if not os.path.isdir(sessions_dir):
|
||||
click.echo(" Sessions: none\n")
|
||||
return
|
||||
|
||||
db_files = glob_mod.glob(os.path.join(sessions_dir, "*.db"))
|
||||
total_size = sum(os.path.getsize(f) for f in db_files if os.path.exists(f))
|
||||
|
||||
click.echo(f" Sessions dir: {sessions_dir}")
|
||||
click.echo(f" Database files: {len(db_files)}")
|
||||
click.echo(f" Total size: {_format_size(total_size)}")
|
||||
click.echo(f"\n Use 'cow context clear' to reset.\n")
|
||||
|
||||
|
||||
def _format_size(size_bytes):
|
||||
if size_bytes < 1024:
|
||||
return f"{size_bytes} B"
|
||||
elif size_bytes < 1024 * 1024:
|
||||
return f"{size_bytes / 1024:.1f} KB"
|
||||
else:
|
||||
return f"{size_bytes / (1024 * 1024):.1f} MB"
|
||||
click.echo(f"\n {CHAT_HINT}\n")
|
||||
|
||||
@@ -47,7 +47,8 @@ def _remove_pid():
|
||||
|
||||
@click.command()
|
||||
@click.option("--foreground", "-f", is_flag=True, help="Run in foreground (don't daemonize)")
|
||||
def start(foreground):
|
||||
@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:
|
||||
@@ -81,6 +82,10 @@ def start(foreground):
|
||||
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():
|
||||
@@ -109,23 +114,39 @@ def stop():
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--no-logs", is_flag=True, help="Don't tail logs after restarting")
|
||||
@click.pass_context
|
||||
def restart(ctx):
|
||||
def restart(ctx, no_logs):
|
||||
"""Restart CowAgent."""
|
||||
ctx.invoke(stop)
|
||||
time.sleep(1)
|
||||
ctx.invoke(start)
|
||||
ctx.invoke(start, no_logs=no_logs)
|
||||
|
||||
|
||||
@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")
|
||||
@@ -138,18 +159,23 @@ def logs(follow, lines):
|
||||
return
|
||||
|
||||
if follow:
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
["tail", "-f", "-n", str(lines), log_file],
|
||||
stdout=sys.stdout,
|
||||
stderr=sys.stderr,
|
||||
)
|
||||
proc.wait()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
_tail_log(log_file, lines)
|
||||
else:
|
||||
proc = subprocess.run(
|
||||
subprocess.run(
|
||||
["tail", "-n", str(lines), log_file],
|
||||
stdout=sys.stdout,
|
||||
stderr=sys.stderr,
|
||||
)
|
||||
|
||||
|
||||
def _tail_log(log_file: str, lines: int = 50):
|
||||
"""Follow log file output. Blocks until Ctrl+C."""
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
["tail", "-f", "-n", str(lines), log_file],
|
||||
stdout=sys.stdout,
|
||||
stderr=sys.stderr,
|
||||
)
|
||||
proc.wait()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
@@ -29,11 +29,12 @@ def skill():
|
||||
# cow skill list
|
||||
# ------------------------------------------------------------------
|
||||
@skill.command("list")
|
||||
@click.option("--remote", is_flag=True, help="List skills available on Skill Hub")
|
||||
def skill_list(remote):
|
||||
"""List installed skills or browse remote Skill Hub."""
|
||||
@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()
|
||||
_list_remote(page=page)
|
||||
else:
|
||||
_list_local()
|
||||
|
||||
@@ -95,10 +96,17 @@ def _print_skill_table(entries):
|
||||
click.echo()
|
||||
|
||||
|
||||
def _list_remote():
|
||||
"""List skills from remote Skill Hub."""
|
||||
_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", timeout=10)
|
||||
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:
|
||||
@@ -106,26 +114,40 @@ def _list_remote():
|
||||
sys.exit(1)
|
||||
|
||||
skills = data.get("skills", [])
|
||||
if not skills:
|
||||
total = data.get("total", len(skills))
|
||||
|
||||
if not skills and page == 1:
|
||||
click.echo("No skills available on Skill Hub.")
|
||||
return
|
||||
|
||||
name_w = max(len(s.get("name", "")) for s in skills)
|
||||
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 ({len(skills)} available)\n")
|
||||
click.echo(f" {'Name':<{name_w}} {'Downloads':<12} {'Description'}")
|
||||
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", "")
|
||||
downloads = s.get("downloads", 0)
|
||||
desc = s.get("description", "") or s.get("display_name", "")
|
||||
if len(desc) > 50:
|
||||
desc = desc[:47] + "..."
|
||||
click.echo(f" {name:<{name_w}} {downloads:<12} {desc}")
|
||||
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 <name>\n")
|
||||
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 <name>\n")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -148,20 +170,21 @@ def search(query):
|
||||
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}} {'Downloads':<12} {'Description'}")
|
||||
click.echo(f" {'Name':<{name_w}} {'Status':<12} {'Description'}")
|
||||
click.echo(f" {'─' * (name_w + 12 + 50)}")
|
||||
|
||||
for s in skills:
|
||||
name = s.get("name", "")
|
||||
downloads = s.get("downloads", 0)
|
||||
desc = s.get("description", "") or s.get("display_name", "")
|
||||
if len(desc) > 50:
|
||||
desc = desc[:47] + "..."
|
||||
click.echo(f" {name:<{name_w}} {downloads:<12} {desc}")
|
||||
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 <name>\n")
|
||||
|
||||
|
||||
@@ -59,4 +59,4 @@ def ensure_sys_path():
|
||||
sys.path.insert(0, root)
|
||||
|
||||
|
||||
SKILL_HUB_API = "https://cow-skill-hub.pages.dev/api"
|
||||
SKILL_HUB_API = "https://skills.cowagent.ai/api"
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user