mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-06-02 00:57:41 +08:00
feat(cli): support cow cli
This commit is contained in:
8
.gitignore
vendored
8
.gitignore
vendored
@@ -37,3 +37,11 @@ client_config.json
|
||||
ref/
|
||||
.cursor/
|
||||
local/
|
||||
|
||||
# Build artifacts
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
|
||||
# CLI runtime
|
||||
.cow.pid
|
||||
|
||||
3
cli/__init__.py
Normal file
3
cli/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""CowAgent CLI - Manage your CowAgent from the command line."""
|
||||
|
||||
__version__ = "0.0.1"
|
||||
4
cli/__main__.py
Normal file
4
cli/__main__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
"""Allow running as: python -m cli"""
|
||||
from cli.cli import main
|
||||
|
||||
main()
|
||||
27
cli/cli.py
Normal file
27
cli/cli.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""CowAgent CLI entry point."""
|
||||
|
||||
import click
|
||||
from cli import __version__
|
||||
from cli.commands.skill import skill
|
||||
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():
|
||||
"""CowAgent CLI - Manage your CowAgent instance."""
|
||||
pass
|
||||
|
||||
|
||||
main.add_command(skill)
|
||||
main.add_command(start)
|
||||
main.add_command(stop)
|
||||
main.add_command(restart)
|
||||
main.add_command(status)
|
||||
main.add_command(logs)
|
||||
main.add_command(context)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
0
cli/commands/__init__.py
Normal file
0
cli/commands/__init__.py
Normal file
81
cli/commands/context.py
Normal file
81
cli/commands/context.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""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
|
||||
|
||||
|
||||
@click.group(invoke_without_command=True)
|
||||
@click.pass_context
|
||||
def context(ctx):
|
||||
"""View or manage conversation context.
|
||||
|
||||
Without a subcommand, shows context info for the current workspace.
|
||||
"""
|
||||
if ctx.invoked_subcommand is None:
|
||||
_show_context_info()
|
||||
|
||||
|
||||
@context.command()
|
||||
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation")
|
||||
def clear(yes):
|
||||
"""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"
|
||||
155
cli/commands/process.py
Normal file
155
cli/commands/process.py
Normal file
@@ -0,0 +1,155 @@
|
||||
"""cow start/stop/restart/status/logs - Process management commands."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import signal
|
||||
import subprocess
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
import click
|
||||
|
||||
from cli.utils import get_project_root
|
||||
|
||||
|
||||
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 _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())
|
||||
os.kill(pid, 0)
|
||||
return pid
|
||||
except (ValueError, ProcessLookupError, PermissionError):
|
||||
os.remove(pid_file)
|
||||
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)")
|
||||
def start(foreground):
|
||||
"""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...")
|
||||
os.execv(python, [python, app_py])
|
||||
else:
|
||||
log_file = _get_log_file()
|
||||
click.echo("Starting CowAgent...")
|
||||
|
||||
with open(log_file, "a") as log:
|
||||
proc = subprocess.Popen(
|
||||
[python, app_py],
|
||||
cwd=root,
|
||||
stdout=log,
|
||||
stderr=log,
|
||||
start_new_session=True,
|
||||
)
|
||||
_write_pid(proc.pid)
|
||||
click.echo(click.style(f"✓ CowAgent started (PID: {proc.pid})", fg="green"))
|
||||
click.echo(f" Logs: {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:
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
for _ in range(30):
|
||||
time.sleep(0.1)
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except ProcessLookupError:
|
||||
break
|
||||
else:
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
|
||||
_remove_pid()
|
||||
click.echo(click.style("✓ CowAgent stopped.", fg="green"))
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.pass_context
|
||||
def restart(ctx):
|
||||
"""Restart CowAgent."""
|
||||
ctx.invoke(stop)
|
||||
time.sleep(1)
|
||||
ctx.invoke(start)
|
||||
|
||||
|
||||
@click.command()
|
||||
def status():
|
||||
"""Show CowAgent running status."""
|
||||
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.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:
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
["tail", "-f", "-n", str(lines), log_file],
|
||||
stdout=sys.stdout,
|
||||
stderr=sys.stderr,
|
||||
)
|
||||
proc.wait()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
else:
|
||||
proc = subprocess.run(
|
||||
["tail", "-n", str(lines), log_file],
|
||||
stdout=sys.stdout,
|
||||
stderr=sys.stderr,
|
||||
)
|
||||
462
cli/commands/skill.py
Normal file
462
cli/commands/skill.py
Normal file
@@ -0,0 +1,462 @@
|
||||
"""cow skill - Skill management commands."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import shutil
|
||||
import zipfile
|
||||
import tempfile
|
||||
|
||||
import click
|
||||
import requests
|
||||
|
||||
from cli.utils import (
|
||||
get_project_root,
|
||||
get_skills_dir,
|
||||
get_builtin_skills_dir,
|
||||
load_skills_config,
|
||||
SKILL_HUB_API,
|
||||
)
|
||||
|
||||
|
||||
@click.group()
|
||||
def skill():
|
||||
"""Manage CowAgent skills."""
|
||||
pass
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 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."""
|
||||
if remote:
|
||||
_list_remote()
|
||||
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()
|
||||
|
||||
|
||||
def _list_remote():
|
||||
"""List skills from remote Skill Hub."""
|
||||
try:
|
||||
resp = requests.get(f"{SKILL_HUB_API}/skills", 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", [])
|
||||
if not skills:
|
||||
click.echo("No skills available on Skill Hub.")
|
||||
return
|
||||
|
||||
name_w = max(len(s.get("name", "")) for s in skills)
|
||||
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" {'─' * (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}")
|
||||
|
||||
click.echo(f"\n Install with: cow skill install <name>\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
|
||||
|
||||
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_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}")
|
||||
|
||||
click.echo(f"\n Install with: cow skill install <name>\n")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# cow skill install
|
||||
# ------------------------------------------------------------------
|
||||
@skill.command()
|
||||
@click.argument("name")
|
||||
def install(name):
|
||||
"""Install a skill from Skill Hub or GitHub.
|
||||
|
||||
Examples:
|
||||
|
||||
cow skill install pptx
|
||||
|
||||
cow skill install github:owner/repo
|
||||
|
||||
cow skill install github:owner/repo#path/to/skill
|
||||
"""
|
||||
if name.startswith("github:"):
|
||||
_install_github(name[7:])
|
||||
else:
|
||||
_install_hub(name)
|
||||
|
||||
|
||||
def _install_hub(name):
|
||||
"""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:
|
||||
resp = requests.get(f"{SKILL_HUB_API}/skills/{name}/download", 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", "")
|
||||
source_path = data.get("source_path")
|
||||
click.echo(f"Source: GitHub ({source_url})")
|
||||
_install_github(source_url, subpath=source_path, skill_name=name)
|
||||
return
|
||||
|
||||
if source_type == "registry":
|
||||
click.echo(f"This skill is from an external registry: {data.get('source_url', '')}")
|
||||
click.echo("Please install it through the corresponding platform.")
|
||||
return
|
||||
|
||||
if "redirect" in data:
|
||||
source_url = data.get("source_url", "")
|
||||
source_path = data.get("source_path")
|
||||
click.echo(f"Source: GitHub ({source_url})")
|
||||
_install_github(source_url, subpath=source_path, skill_name=name)
|
||||
return
|
||||
|
||||
elif "application/zip" in content_type:
|
||||
click.echo("Downloading skill package...")
|
||||
_install_zip_bytes(resp.content, name, skills_dir)
|
||||
_report_install(name)
|
||||
click.echo(click.style(f"✓ Skill '{name}' installed successfully!", fg="green"))
|
||||
return
|
||||
|
||||
click.echo(f"Error: Unexpected response from Skill Hub.", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _install_github(spec, subpath=None, skill_name=None):
|
||||
"""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)
|
||||
|
||||
if not skill_name:
|
||||
skill_name = subpath.rstrip("/").split("/")[-1] if subpath else spec.split("/")[-1]
|
||||
|
||||
skills_dir = get_skills_dir()
|
||||
os.makedirs(skills_dir, exist_ok=True)
|
||||
|
||||
zip_url = f"https://github.com/{spec}/archive/refs/heads/main.zip"
|
||||
click.echo(f"Downloading from GitHub: {spec}...")
|
||||
|
||||
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:
|
||||
zf.extractall(extract_dir)
|
||||
|
||||
# GitHub archives have a top-level dir like "repo-main/"
|
||||
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
|
||||
|
||||
target_dir = os.path.join(skills_dir, skill_name)
|
||||
if os.path.exists(target_dir):
|
||||
shutil.rmtree(target_dir)
|
||||
shutil.copytree(source_dir, target_dir)
|
||||
|
||||
_report_install(skill_name)
|
||||
click.echo(click.style(f"✓ Skill '{skill_name}' installed successfully!", fg="green"))
|
||||
|
||||
|
||||
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:
|
||||
zf.extractall(extract_dir)
|
||||
|
||||
top_items = [d for d in os.listdir(extract_dir) if not d.startswith(".")]
|
||||
source = extract_dir
|
||||
if len(top_items) == 1 and os.path.isdir(os.path.join(extract_dir, top_items[0])):
|
||||
source = os.path.join(extract_dir, top_items[0])
|
||||
|
||||
target = os.path.join(skills_dir, name)
|
||||
if os.path.exists(target):
|
||||
shutil.rmtree(target)
|
||||
shutil.copytree(source, target)
|
||||
|
||||
|
||||
def _report_install(name):
|
||||
"""Report installation to Skill Hub for download counting."""
|
||||
try:
|
||||
requests.post(f"{SKILL_HUB_API}/skills/{name}/install", json={}, timeout=5)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 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."""
|
||||
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):
|
||||
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."""
|
||||
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:
|
||||
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()
|
||||
62
cli/utils.py
Normal file
62
cli/utils.py
Normal file
@@ -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://cow-skill-hub.pages.dev/api"
|
||||
19
pyproject.toml
Normal file
19
pyproject.toml
Normal file
@@ -0,0 +1,19 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68.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*"]
|
||||
Reference in New Issue
Block a user