diff --git a/cli/cli.py b/cli/cli.py index d6f1349a..9e01af98 100644 --- a/cli/cli.py +++ b/cli/cli.py @@ -22,7 +22,7 @@ Commands: context View or manage conversation context. skill Manage CowAgent skills. -Tip: You can also send /help, /skill list, etc. in chat.""" +Tip: You can also send /help, /skill list, etc. in agent chat.""" class CowCLI(click.Group): diff --git a/cli/commands/process.py b/cli/commands/process.py index 01748bef..39e3840a 100644 --- a/cli/commands/process.py +++ b/cli/commands/process.py @@ -2,7 +2,6 @@ import os import sys -import signal import subprocess import time from typing import Optional @@ -11,6 +10,8 @@ import click from cli.utils import get_project_root +_IS_WIN = sys.platform == "win32" + def _get_pid_file(): return os.path.join(get_project_root(), ".cow.pid") @@ -20,6 +21,40 @@ def _get_log_file(): return os.path.join(get_project_root(), "nohup.out") +def _is_pid_alive(pid: int) -> bool: + """Check whether a process is still running (cross-platform).""" + if _IS_WIN: + try: + out = subprocess.check_output( + ["tasklist", "/FI", f"PID eq {pid}", "/NH"], + stderr=subprocess.DEVNULL, + ) + return str(pid) in out.decode(errors="ignore") + except Exception: + return False + else: + try: + os.kill(pid, 0) + return True + except (ProcessLookupError, PermissionError): + return False + + +def _kill_pid(pid: int, force: bool = False): + """Terminate a process by PID (cross-platform).""" + if _IS_WIN: + flag = "/F" if force else "" + cmd = ["taskkill"] + if force: + cmd.append("/F") + cmd.extend(["/PID", str(pid)]) + subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + else: + import signal + sig = signal.SIGKILL if force else signal.SIGTERM + os.kill(pid, sig) + + def _read_pid() -> Optional[int]: pid_file = _get_pid_file() if not os.path.exists(pid_file): @@ -27,11 +62,16 @@ def _read_pid() -> Optional[int]: try: with open(pid_file, "r") as f: pid = int(f.read().strip()) - os.kill(pid, 0) - return pid - except (ValueError, ProcessLookupError, PermissionError): + if _is_pid_alive(pid): + return pid os.remove(pid_file) return None + except (ValueError, OSError): + try: + os.remove(pid_file) + except OSError: + pass + return None def _write_pid(pid: int): @@ -65,18 +105,29 @@ def start(foreground, no_logs): if foreground: click.echo("Starting CowAgent in foreground...") - os.execv(python, [python, app_py]) + if _IS_WIN: + sys.exit(subprocess.call([python, app_py], cwd=root)) + else: + os.execv(python, [python, app_py]) else: log_file = _get_log_file() click.echo("Starting CowAgent...") + popen_kwargs = dict(cwd=root) + if _IS_WIN: + CREATE_NO_WINDOW = 0x08000000 + popen_kwargs["creationflags"] = ( + subprocess.CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW + ) + else: + popen_kwargs["start_new_session"] = True + with open(log_file, "a") as log: proc = subprocess.Popen( [python, app_py], - cwd=root, stdout=log, stderr=log, - start_new_session=True, + **popen_kwargs, ) _write_pid(proc.pid) click.echo(click.style(f"✓ CowAgent started (PID: {proc.pid})", fg="green")) @@ -97,16 +148,14 @@ def stop(): click.echo(f"Stopping CowAgent (PID: {pid})...") try: - os.kill(pid, signal.SIGTERM) + _kill_pid(pid) for _ in range(30): time.sleep(0.1) - try: - os.kill(pid, 0) - except ProcessLookupError: + if not _is_pid_alive(pid): break else: - os.kill(pid, signal.SIGKILL) - except ProcessLookupError: + _kill_pid(pid, force=True) + except (ProcessLookupError, OSError): pass _remove_pid() @@ -161,21 +210,32 @@ def logs(follow, lines): if follow: _tail_log(log_file, lines) else: - subprocess.run( - ["tail", "-n", str(lines), log_file], - stdout=sys.stdout, - stderr=sys.stderr, - ) + _print_last_lines(log_file, lines) + + +def _print_last_lines(file_path: str, n: int = 50): + """Print the last N lines of a file (cross-platform).""" + try: + with open(file_path, "r", encoding="utf-8", errors="replace") as f: + all_lines = f.readlines() + for line in all_lines[-n:]: + click.echo(line, nl=False) + except Exception as e: + click.echo(f"Error reading log file: {e}", err=True) def _tail_log(log_file: str, lines: int = 50): - """Follow log file output. Blocks until Ctrl+C.""" + """Follow log file output. Blocks until Ctrl+C (cross-platform).""" + _print_last_lines(log_file, lines) + try: - proc = subprocess.Popen( - ["tail", "-f", "-n", str(lines), log_file], - stdout=sys.stdout, - stderr=sys.stderr, - ) - proc.wait() + with open(log_file, "r", encoding="utf-8", errors="replace") as f: + f.seek(0, 2) + while True: + line = f.readline() + if line: + click.echo(line, nl=False) + else: + time.sleep(0.3) except KeyboardInterrupt: pass diff --git a/cli/commands/skill.py b/cli/commands/skill.py index c4378e2d..73b929bd 100644 --- a/cli/commands/skill.py +++ b/cli/commands/skill.py @@ -1,12 +1,16 @@ """cow skill - Skill management commands.""" import os +import re import sys import json +import hashlib import shutil import zipfile import tempfile +from urllib.parse import urlparse + import click import requests @@ -18,6 +22,57 @@ from cli.utils import ( SKILL_HUB_API, ) +_SAFE_NAME_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_\-]{0,63}$") + + +def _validate_skill_name(name: str): + """Reject names that contain path traversal or special characters.""" + if not _SAFE_NAME_RE.match(name): + click.echo( + f"Error: Invalid skill name '{name}'. " + "Use only letters, digits, hyphens, and underscores.", + err=True, + ) + sys.exit(1) + + +def _validate_github_spec(spec: str): + """Reject specs that don't look like owner/repo.""" + if not re.match(r"^[a-zA-Z0-9_\-]+/[a-zA-Z0-9_.\-]+$", spec): + click.echo(f"Error: Invalid GitHub spec '{spec}'. Expected format: owner/repo", err=True) + sys.exit(1) + + +def _safe_extractall(zf: zipfile.ZipFile, dest: str): + """Extract zip while guarding against Zip Slip (path traversal).""" + dest = os.path.realpath(dest) + for member in zf.infolist(): + target = os.path.realpath(os.path.join(dest, member.filename)) + if not target.startswith(dest + os.sep) and target != dest: + raise ValueError(f"Unsafe zip entry detected: {member.filename}") + zf.extractall(dest) + + +def _verify_checksum(content: bytes, expected: str): + """Verify SHA-256 checksum of downloaded content. + + Returns True if checksum matches or no expected value provided. + Exits with error if mismatch. + """ + if not expected: + return True + actual = hashlib.sha256(content).hexdigest() + if actual != expected.lower(): + click.echo( + f"Error: Checksum mismatch!\n" + f" Expected: {expected}\n" + f" Actual: {actual}\n" + f"The downloaded package may have been tampered with.", + err=True, + ) + sys.exit(1) + return True + @click.group() def skill(): @@ -208,6 +263,7 @@ def install(name): if name.startswith("github:"): _install_github(name[7:]) else: + _validate_skill_name(name) _install_hub(name) @@ -239,18 +295,40 @@ def _install_hub(name): if source_type == "github": source_url = data.get("source_url", "") + _validate_github_spec(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.") + download_url = data.get("download_url") + if download_url: + parsed = urlparse(download_url) + if parsed.scheme != "https": + click.echo(f"Error: Refusing to download from non-HTTPS URL.", err=True) + sys.exit(1) + provider = data.get("source_provider", "registry") + expected_checksum = data.get("checksum") or data.get("sha256") + click.echo(f"Source: {provider}") + click.echo("Downloading skill package...") + try: + dl_resp = requests.get(download_url, timeout=60, allow_redirects=True) + dl_resp.raise_for_status() + except Exception as e: + click.echo(f"Error: Failed to download from {provider}: {e}", err=True) + sys.exit(1) + _verify_checksum(dl_resp.content, expected_checksum) + _install_zip_bytes(dl_resp.content, name, skills_dir) + click.echo(click.style(f"✓ Skill '{name}' installed successfully!", fg="green")) + else: + click.echo(f"Error: Unsupported registry provider.", err=True) + sys.exit(1) return if "redirect" in data: source_url = data.get("source_url", "") + _validate_github_spec(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) @@ -258,8 +336,9 @@ def _install_hub(name): elif "application/zip" in content_type: click.echo("Downloading skill package...") + expected_checksum = resp.headers.get("X-Checksum-Sha256") + _verify_checksum(resp.content, expected_checksum) _install_zip_bytes(resp.content, name, skills_dir) - _report_install(name) click.echo(click.style(f"✓ Skill '{name}' installed successfully!", fg="green")) return @@ -275,8 +354,11 @@ def _install_github(spec, subpath=None, skill_name=None): if "#" in spec and not subpath: spec, subpath = spec.split("#", 1) + _validate_github_spec(spec) + if not skill_name: skill_name = subpath.rstrip("/").split("/")[-1] if subpath else spec.split("/")[-1] + _validate_skill_name(skill_name) skills_dir = get_skills_dir() os.makedirs(skills_dir, exist_ok=True) @@ -298,7 +380,7 @@ def _install_github(spec, subpath=None, skill_name=None): extract_dir = os.path.join(tmp_dir, "extracted") with zipfile.ZipFile(zip_path, "r") as zf: - zf.extractall(extract_dir) + _safe_extractall(zf, 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(".")] @@ -319,7 +401,6 @@ def _install_github(spec, subpath=None, skill_name=None): 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")) @@ -332,7 +413,7 @@ def _install_zip_bytes(content, name, skills_dir): extract_dir = os.path.join(tmp_dir, "extracted") with zipfile.ZipFile(zip_path, "r") as zf: - zf.extractall(extract_dir) + _safe_extractall(zf, extract_dir) top_items = [d for d in os.listdir(extract_dir) if not d.startswith(".")] source = extract_dir @@ -345,12 +426,6 @@ def _install_zip_bytes(content, name, skills_dir): 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 # ------------------------------------------------------------------ @@ -361,6 +436,7 @@ def _report_install(name): @click.option("--yes", "-y", is_flag=True, help="Skip confirmation") def uninstall(name, yes): """Uninstall a skill.""" + _validate_skill_name(name) skills_dir = get_skills_dir() skill_dir = os.path.join(skills_dir, name) @@ -405,6 +481,7 @@ def disable(name): def _set_enabled(name, enabled): + _validate_skill_name(name) skills_dir = get_skills_dir() config_path = os.path.join(skills_dir, "skills_config.json") @@ -440,6 +517,7 @@ def _set_enabled(name, enabled): @click.argument("name") def info(name): """Show details about an installed skill.""" + _validate_skill_name(name) skills_dir = get_skills_dir() builtin_dir = get_builtin_skills_dir() diff --git a/run.sh b/run.sh index d2df6355..42a3b5bb 100755 --- a/run.sh +++ b/run.sh @@ -242,6 +242,17 @@ install_dependencies() { fi rm -f /tmp/pip_install.log + + # Register `cow` CLI command via editable install + echo -e "${YELLOW}Registering cow CLI...${NC}" + set +e + $PYTHON_CMD -m pip install -e . $PIP_EXTRA_ARGS $PIP_MIRROR > /dev/null 2>&1 + if command -v cow &> /dev/null; then + echo -e "${GREEN}✅ cow CLI registered.${NC}" + else + echo -e "${YELLOW}⚠️ cow CLI not in PATH, you can still use: $PYTHON_CMD -m cli.cli${NC}" + fi + set -e } # Select model @@ -603,7 +614,7 @@ ensure_python_cmd() { # Get service PID (empty string if not running) get_pid() { ensure_python_cmd > /dev/null 2>&1 - ps ax | grep -i app.py | grep "${BASE_DIR}" | grep "$PYTHON_CMD" | grep -v grep | awk '{print $1}' | grep -E '^[0-9]+$' + ps ax | grep -i app.py | grep "${BASE_DIR}" | grep "$PYTHON_CMD" | grep -v grep | awk '{print $1}' | grep -E '^[0-9]+$' | head -1 } # Check if service is running diff --git a/scripts/run.ps1 b/scripts/run.ps1 new file mode 100644 index 00000000..50a407df --- /dev/null +++ b/scripts/run.ps1 @@ -0,0 +1,447 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + CowAgent installer & management script for Windows. +.DESCRIPTION + One-liner install: + irm https://raw.githubusercontent.com/zhayujie/chatgpt-on-wechat/master/scripts/run.ps1 | iex + Or from a local clone: + .\scripts\run.ps1 # install / configure + .\scripts\run.ps1 start # start service (delegates to cow CLI) + .\scripts\run.ps1 stop|restart|status|logs|config|update|help +#> + +param( + [Parameter(Position = 0)] + [string]$Command = "" +) + +$ErrorActionPreference = "Stop" + +# ── colours ────────────────────────────────────────────────────── +function Write-Cow { param([string]$M) Write-Host $M -ForegroundColor Green } +function Write-Warn { param([string]$M) Write-Host $M -ForegroundColor Yellow } +function Write-Err { param([string]$M) Write-Host $M -ForegroundColor Red } +function Write-Info { param([string]$M) Write-Host $M -ForegroundColor Cyan } + +# ── detect project directory ───────────────────────────────────── +$ScriptDir = if ($PSScriptRoot) { $PSScriptRoot } else { $PWD.Path } +$BaseDir = Split-Path $ScriptDir -Parent + +$IsProjectDir = (Test-Path "$BaseDir\app.py") -and (Test-Path "$BaseDir\config-template.json") +if (-not $IsProjectDir) { + $BaseDir = $PWD.Path + $IsProjectDir = (Test-Path "$BaseDir\app.py") -and (Test-Path "$BaseDir\config-template.json") +} + +# ── Python detection ───────────────────────────────────────────── +function Find-Python { + foreach ($cmd in @("python3", "python")) { + $bin = Get-Command $cmd -ErrorAction SilentlyContinue + if (-not $bin) { continue } + try { + $ver = & $bin.Source -c "import sys; v=sys.version_info; print(f'{v.major}.{v.minor}')" 2>$null + $parts = $ver -split '\.' + $major = [int]$parts[0]; $minor = [int]$parts[1] + if ($major -eq 3 -and $minor -ge 9 -and $minor -le 13) { + return $bin.Source + } + } catch {} + } + return $null +} + +$PythonCmd = Find-Python +function Assert-Python { + if (-not $PythonCmd) { + Write-Err "Python 3.9-3.13 not found. Please install from https://www.python.org/downloads/" + exit 1 + } + Write-Cow "Found Python: $PythonCmd" +} + +# ── clone project ──────────────────────────────────────────────── +function Install-Project { + if (Test-Path "chatgpt-on-wechat") { + Write-Warn "Directory 'chatgpt-on-wechat' already exists." + $choice = Read-Host "Overwrite(o), backup(b), or quit(q)? [default: b]" + if (-not $choice) { $choice = "b" } + switch ($choice.ToLower()) { + "o" { Remove-Item -Recurse -Force "chatgpt-on-wechat" } + "b" { + $backup = "chatgpt-on-wechat_backup_$(Get-Date -Format 'yyyyMMddHHmmss')" + Rename-Item "chatgpt-on-wechat" $backup + Write-Cow "Backed up to '$backup'" + } + "q" { Write-Err "Installation cancelled."; exit 1 } + default { Write-Err "Invalid choice."; exit 1 } + } + } + + $gitBin = Get-Command git -ErrorAction SilentlyContinue + if (-not $gitBin) { + Write-Err "Git not found. Please install from https://git-scm.com/download/win" + exit 1 + } + + Write-Cow "Cloning CowAgent project..." + git clone https://github.com/zhayujie/chatgpt-on-wechat.git 2>$null + if ($LASTEXITCODE -ne 0) { + Write-Warn "GitHub failed, trying Gitee..." + git clone https://gitee.com/zhayujie/chatgpt-on-wechat.git + if ($LASTEXITCODE -ne 0) { + Write-Err "Clone failed. Check your network." + exit 1 + } + } + + Set-Location "chatgpt-on-wechat" + $script:BaseDir = $PWD.Path + $script:IsProjectDir = $true + Write-Cow "Project cloned: $BaseDir" +} + +# ── install dependencies ───────────────────────────────────────── +function Install-Dependencies { + Write-Cow "Installing dependencies..." + + & $PythonCmd -m pip install --upgrade pip setuptools wheel 2>$null | Out-Null + + & $PythonCmd -m pip install -r "$BaseDir\requirements.txt" 2>&1 | ForEach-Object { Write-Host $_ } + if ($LASTEXITCODE -ne 0) { + Write-Warn "Some dependencies may have issues, but continuing..." + } + + Write-Cow "Registering cow CLI..." + & $PythonCmd -m pip install -e $BaseDir 2>$null | Out-Null + $cowBin = Get-Command cow -ErrorAction SilentlyContinue + if ($cowBin) { + Write-Cow "cow CLI registered." + } else { + Write-Warn "cow CLI not in PATH. You can use: $PythonCmd -m cli.cli" + } +} + +# ── model selection ────────────────────────────────────────────── +$ModelChoices = @{ + "1" = @{ Provider = "MiniMax"; Default = "MiniMax-M2.7"; Key = "MINIMAX_KEY" } + "2" = @{ Provider = "Zhipu AI"; Default = "glm-5-turbo"; Key = "ZHIPU_KEY" } + "3" = @{ Provider = "Kimi (Moonshot)"; Default = "kimi-k2.5"; Key = "MOONSHOT_KEY" } + "4" = @{ Provider = "Doubao (Volcengine Ark)"; Default = "doubao-seed-2-0-code-preview-260215"; Key = "ARK_KEY" } + "5" = @{ Provider = "Qwen (DashScope)"; Default = "qwen3.5-plus"; Key = "DASHSCOPE_KEY" } + "6" = @{ Provider = "Claude"; Default = "claude-sonnet-4-6"; Key = "CLAUDE_KEY"; Base = "https://api.anthropic.com/v1" } + "7" = @{ Provider = "Gemini"; Default = "gemini-3.1-pro-preview"; Key = "GEMINI_KEY"; Base = "https://generativelanguage.googleapis.com" } + "8" = @{ Provider = "OpenAI GPT"; Default = "gpt-5.4"; Key = "OPENAI_KEY"; Base = "https://api.openai.com/v1" } + "9" = @{ Provider = "LinkAI"; Default = "MiniMax-M2.7"; Key = "LINKAI_KEY" } +} + +function Select-Model { + Write-Info "=========================================" + Write-Info " Select AI Model" + Write-Info "=========================================" + Write-Host "1) MiniMax (MiniMax-M2.7, MiniMax-M2.5, etc.)" + Write-Host "2) Zhipu AI (glm-5-turbo, glm-5, etc.)" + Write-Host "3) Kimi (kimi-k2.5, kimi-k2, etc.)" + Write-Host "4) Doubao (doubao-seed-2-0-code-preview-260215, etc.)" + Write-Host "5) Qwen (qwen3.5-plus, qwen3-max, qwq-plus, etc.)" + Write-Host "6) Claude (claude-sonnet-4-6, claude-opus-4-6, etc.)" + Write-Host "7) Gemini (gemini-3.1-flash-lite-preview, gemini-3.1-pro-preview, etc.)" + Write-Host "8) OpenAI GPT (gpt-5.4, gpt-5.2, gpt-4.1, etc.)" + Write-Host "9) LinkAI (access multiple models via one API)" + Write-Host "" + + do { + $choice = Read-Host "Enter your choice [default: 1 - MiniMax]" + if (-not $choice) { $choice = "1" } + } while ($choice -notmatch '^[1-9]$') + + $m = $ModelChoices[$choice] + Write-Cow "Configuring $($m.Provider)..." + + $script:ApiKey = Read-Host "Enter $($m.Provider) API Key" + $model = Read-Host "Enter model name [default: $($m.Default)]" + if (-not $model) { $model = $m.Default } + $script:ModelName = $model + $script:KeyName = $m.Key + $script:UseLinkai = ($choice -eq "9") + + if ($m.Base) { + $base = Read-Host "Enter API Base URL [default: $($m.Base)]" + if (-not $base) { $base = $m.Base } + $script:ApiBase = $base + } else { + $script:ApiBase = "" + } + $script:ModelChoice = $choice +} + +# ── channel selection ──────────────────────────────────────────── +function Select-Channel { + Write-Host "" + Write-Info "=========================================" + Write-Info " Select Communication Channel" + Write-Info "=========================================" + Write-Host "1) Weixin" + Write-Host "2) Feishu" + Write-Host "3) DingTalk" + Write-Host "4) WeCom Bot" + Write-Host "5) QQ" + Write-Host "6) WeCom App" + Write-Host "7) Web" + Write-Host "" + + do { + $choice = Read-Host "Enter your choice [default: 1 - Weixin]" + if (-not $choice) { $choice = "1" } + } while ($choice -notmatch '^[1-7]$') + + $script:ChannelExtra = @{} + + switch ($choice) { + "1" { $script:ChannelType = "weixin" } + "2" { + $script:ChannelType = "feishu" + $script:ChannelExtra["feishu_app_id"] = Read-Host "Enter Feishu App ID" + $script:ChannelExtra["feishu_app_secret"] = Read-Host "Enter Feishu App Secret" + } + "3" { + $script:ChannelType = "dingtalk" + $script:ChannelExtra["dingtalk_client_id"] = Read-Host "Enter DingTalk Client ID" + $script:ChannelExtra["dingtalk_client_secret"] = Read-Host "Enter DingTalk Client Secret" + } + "4" { + $script:ChannelType = "wecom_bot" + $script:ChannelExtra["wecom_bot_id"] = Read-Host "Enter WeCom Bot ID" + $script:ChannelExtra["wecom_bot_secret"] = Read-Host "Enter WeCom Bot Secret" + } + "5" { + $script:ChannelType = "qq" + $script:ChannelExtra["qq_app_id"] = Read-Host "Enter QQ App ID" + $script:ChannelExtra["qq_app_secret"] = Read-Host "Enter QQ App Secret" + } + "6" { + $script:ChannelType = "wechatcom_app" + $script:ChannelExtra["wechatcom_corp_id"] = Read-Host "Enter WeChat Corp ID" + $script:ChannelExtra["wechatcomapp_token"] = Read-Host "Enter WeChat Com App Token" + $script:ChannelExtra["wechatcomapp_secret"] = Read-Host "Enter WeChat Com App Secret" + $script:ChannelExtra["wechatcomapp_agent_id"] = Read-Host "Enter WeChat Com App Agent ID" + $script:ChannelExtra["wechatcomapp_aes_key"] = Read-Host "Enter WeChat Com App AES Key" + $port = Read-Host "Enter port [default: 9898]" + if (-not $port) { $port = "9898" } + $script:ChannelExtra["wechatcomapp_port"] = [int]$port + } + "7" { + $script:ChannelType = "web" + $port = Read-Host "Enter web port [default: 9899]" + if (-not $port) { $port = "9899" } + $script:ChannelExtra["web_port"] = [int]$port + } + } +} + +# ── generate config.json ───────────────────────────────────────── +function New-ConfigFile { + Write-Cow "Generating config.json..." + + $config = [ordered]@{ + channel_type = $ChannelType + model = $ModelName + open_ai_api_key = "" + open_ai_api_base = "https://api.openai.com/v1" + claude_api_key = "" + claude_api_base = "https://api.anthropic.com/v1" + gemini_api_key = "" + gemini_api_base = "https://generativelanguage.googleapis.com" + zhipu_ai_api_key = "" + moonshot_api_key = "" + ark_api_key = "" + dashscope_api_key = "" + minimax_api_key = "" + voice_to_text = "openai" + text_to_voice = "openai" + voice_reply_voice = $false + speech_recognition = $true + group_speech_recognition = $false + use_linkai = $UseLinkai + linkai_api_key = "" + linkai_app_code = "" + agent = $true + agent_max_context_tokens = 40000 + agent_max_context_turns = 30 + agent_max_steps = 15 + } + + # Set the correct API key field + $keyMap = @{ + OPENAI_KEY = "open_ai_api_key" + CLAUDE_KEY = "claude_api_key" + GEMINI_KEY = "gemini_api_key" + ZHIPU_KEY = "zhipu_ai_api_key" + MOONSHOT_KEY = "moonshot_api_key" + ARK_KEY = "ark_api_key" + DASHSCOPE_KEY = "dashscope_api_key" + MINIMAX_KEY = "minimax_api_key" + LINKAI_KEY = "linkai_api_key" + } + if ($keyMap.ContainsKey($KeyName)) { + $config[$keyMap[$KeyName]] = $ApiKey + } + + # Set API base if provided + $baseMap = @{ + "6" = "claude_api_base" + "7" = "gemini_api_base" + "8" = "open_ai_api_base" + } + if ($ApiBase -and $baseMap.ContainsKey($ModelChoice)) { + $config[$baseMap[$ModelChoice]] = $ApiBase + } + + # Merge channel-specific fields + foreach ($k in $ChannelExtra.Keys) { + $config[$k] = $ChannelExtra[$k] + } + + $config | ConvertTo-Json -Depth 5 | Set-Content -Path "$BaseDir\config.json" -Encoding UTF8 + Write-Cow "Configuration file created." +} + +# ── start via cow CLI ───────────────────────────────────────────── +function Start-CowAgent { + Write-Cow "Starting CowAgent..." + $cowBin = Get-Command cow -ErrorAction SilentlyContinue + if ($cowBin) { + & cow start + } else { + Write-Warn "cow CLI not found, starting directly..." + & $PythonCmd "$BaseDir\app.py" + } +} + +# ── delegate management commands to cow CLI ────────────────────── +function Invoke-CowCommand { + param([string]$Cmd) + $cowBin = Get-Command cow -ErrorAction SilentlyContinue + if ($cowBin) { + & cow $Cmd + } else { + Write-Err "cow CLI not found. Run this script without arguments first to install." + exit 1 + } +} + +# ── usage ───────────────────────────────────────────────────────── +function Show-Usage { + Write-Info "=========================================" + Write-Info " CowAgent Management Script (Windows)" + Write-Info "=========================================" + Write-Host "" + Write-Host "Usage:" + Write-Host " .\run.ps1 # Install / Configure" + Write-Host " .\run.ps1 # Management command" + Write-Host "" + Write-Host "Commands:" + Write-Host " start Start the service" + Write-Host " stop Stop the service" + Write-Host " restart Restart the service" + Write-Host " status Check service status" + Write-Host " logs View logs" + Write-Host " config Reconfigure project" + Write-Host " update Update and restart" + Write-Host " help Show this message" + Write-Host "" +} + +# ── install mode ────────────────────────────────────────────────── +function Install-Mode { + Clear-Host + Write-Info "=========================================" + Write-Info " CowAgent Installation (Windows)" + Write-Info "=========================================" + Write-Host "" + + if ($IsProjectDir) { + Write-Cow "Detected existing project directory." + if (Test-Path "$BaseDir\config.json") { + Write-Cow "Project already configured." + Write-Host "" + Show-Usage + return + } + Write-Warn "No config.json found. Let's configure your project!" + Write-Host "" + Assert-Python + } else { + Assert-Python + Install-Project + } + + Install-Dependencies + Select-Model + Select-Channel + New-ConfigFile + + Write-Host "" + $startNow = Read-Host "Start CowAgent now? [Y/n]" + if ($startNow -ne "n" -and $startNow -ne "N") { + Start-CowAgent + } else { + Write-Cow "Installation complete!" + Write-Host "" + Write-Host "To start manually:" + Write-Host " cd $BaseDir" + Write-Host " cow start" + } +} + +# ── update ──────────────────────────────────────────────────────── +function Update-Project { + Write-Cow "Updating CowAgent..." + Set-Location $BaseDir + + # Stop if running + $cowBin = Get-Command cow -ErrorAction SilentlyContinue + if ($cowBin) { & cow stop 2>$null } + + if (Test-Path "$BaseDir\.git") { + Write-Cow "Pulling latest code..." + git pull 2>$null + if ($LASTEXITCODE -ne 0) { + Write-Warn "GitHub failed, trying Gitee..." + git remote set-url origin https://gitee.com/zhayujie/chatgpt-on-wechat.git + git pull + } + } else { + Write-Warn "Not a git repository, skipping code update." + } + + Assert-Python + Install-Dependencies + Start-CowAgent +} + +# ── main ────────────────────────────────────────────────────────── +switch ($Command.ToLower()) { + "" { Install-Mode } + "start" { Invoke-CowCommand "start" } + "stop" { Invoke-CowCommand "stop" } + "restart" { Invoke-CowCommand "restart" } + "status" { Invoke-CowCommand "status" } + "logs" { Invoke-CowCommand "logs" } + "config" { + Assert-Python + Install-Dependencies + Select-Model + Select-Channel + New-ConfigFile + $r = Read-Host "Restart service now? [Y/n]" + if ($r -ne "n" -and $r -ne "N") { Invoke-CowCommand "restart" } + } + "update" { Update-Project } + "help" { Show-Usage } + default { + Write-Err "Unknown command: $Command" + Show-Usage + exit 1 + } +}