From 8fd029a4a1612604ed03a13c027de20c483994be Mon Sep 17 00:00:00 2001 From: zhayujie Date: Thu, 26 Mar 2026 10:08:51 +0800 Subject: [PATCH] feat(cli): support cow cli --- .gitignore | 8 + cli/__init__.py | 3 + cli/__main__.py | 4 + cli/cli.py | 27 +++ cli/commands/__init__.py | 0 cli/commands/context.py | 81 +++++++ cli/commands/process.py | 155 +++++++++++++ cli/commands/skill.py | 462 +++++++++++++++++++++++++++++++++++++++ cli/utils.py | 62 ++++++ pyproject.toml | 19 ++ 10 files changed, 821 insertions(+) create mode 100644 cli/__init__.py create mode 100644 cli/__main__.py create mode 100644 cli/cli.py create mode 100644 cli/commands/__init__.py create mode 100644 cli/commands/context.py create mode 100644 cli/commands/process.py create mode 100644 cli/commands/skill.py create mode 100644 cli/utils.py create mode 100644 pyproject.toml diff --git a/.gitignore b/.gitignore index e217c97b..7dd8ce97 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,11 @@ client_config.json ref/ .cursor/ local/ + +# Build artifacts +dist/ +build/ +*.egg-info/ + +# CLI runtime +.cow.pid diff --git a/cli/__init__.py b/cli/__init__.py new file mode 100644 index 00000000..2a3b711e --- /dev/null +++ b/cli/__init__.py @@ -0,0 +1,3 @@ +"""CowAgent CLI - Manage your CowAgent from the command line.""" + +__version__ = "0.0.1" 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..7ea983a5 --- /dev/null +++ b/cli/cli.py @@ -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() 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..41532f2e --- /dev/null +++ b/cli/commands/context.py @@ -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" diff --git a/cli/commands/process.py b/cli/commands/process.py new file mode 100644 index 00000000..03dd7f20 --- /dev/null +++ b/cli/commands/process.py @@ -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, + ) diff --git a/cli/commands/skill.py b/cli/commands/skill.py new file mode 100644 index 00000000..1d622274 --- /dev/null +++ b/cli/commands/skill.py @@ -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 \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 \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() diff --git a/cli/utils.py b/cli/utils.py new file mode 100644 index 00000000..8dc229dd --- /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://cow-skill-hub.pages.dev/api" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..db8aa04d --- /dev/null +++ b/pyproject.toml @@ -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*"]