feat(cli): imporve cow cli and skill hub integration

This commit is contained in:
zhayujie
2026-03-26 16:49:42 +08:00
parent 8fd029a4a1
commit 158510cbbe
16 changed files with 367 additions and 143 deletions

1
cli/VERSION Normal file
View File

@@ -0,0 +1 @@
2.0.4

View File

@@ -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()

View File

@@ -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)

View File

@@ -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")

View File

@@ -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

View File

@@ -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")

View File

@@ -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"