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

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