fix(cli): add security hardening for skill install and process management

This commit is contained in:
zhayujie
2026-03-27 17:59:15 +08:00
parent 158510cbbe
commit db16bdf8cb
5 changed files with 635 additions and 39 deletions

View File

@@ -22,7 +22,7 @@ Commands:
context View or manage conversation context. context View or manage conversation context.
skill Manage CowAgent skills. 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): class CowCLI(click.Group):

View File

@@ -2,7 +2,6 @@
import os import os
import sys import sys
import signal
import subprocess import subprocess
import time import time
from typing import Optional from typing import Optional
@@ -11,6 +10,8 @@ import click
from cli.utils import get_project_root from cli.utils import get_project_root
_IS_WIN = sys.platform == "win32"
def _get_pid_file(): def _get_pid_file():
return os.path.join(get_project_root(), ".cow.pid") 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") 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]: def _read_pid() -> Optional[int]:
pid_file = _get_pid_file() pid_file = _get_pid_file()
if not os.path.exists(pid_file): if not os.path.exists(pid_file):
@@ -27,11 +62,16 @@ def _read_pid() -> Optional[int]:
try: try:
with open(pid_file, "r") as f: with open(pid_file, "r") as f:
pid = int(f.read().strip()) pid = int(f.read().strip())
os.kill(pid, 0) if _is_pid_alive(pid):
return pid return pid
except (ValueError, ProcessLookupError, PermissionError):
os.remove(pid_file) os.remove(pid_file)
return None return None
except (ValueError, OSError):
try:
os.remove(pid_file)
except OSError:
pass
return None
def _write_pid(pid: int): def _write_pid(pid: int):
@@ -65,18 +105,29 @@ def start(foreground, no_logs):
if foreground: if foreground:
click.echo("Starting CowAgent in foreground...") click.echo("Starting CowAgent in foreground...")
if _IS_WIN:
sys.exit(subprocess.call([python, app_py], cwd=root))
else:
os.execv(python, [python, app_py]) os.execv(python, [python, app_py])
else: else:
log_file = _get_log_file() log_file = _get_log_file()
click.echo("Starting CowAgent...") 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: with open(log_file, "a") as log:
proc = subprocess.Popen( proc = subprocess.Popen(
[python, app_py], [python, app_py],
cwd=root,
stdout=log, stdout=log,
stderr=log, stderr=log,
start_new_session=True, **popen_kwargs,
) )
_write_pid(proc.pid) _write_pid(proc.pid)
click.echo(click.style(f"✓ CowAgent started (PID: {proc.pid})", fg="green")) 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})...") click.echo(f"Stopping CowAgent (PID: {pid})...")
try: try:
os.kill(pid, signal.SIGTERM) _kill_pid(pid)
for _ in range(30): for _ in range(30):
time.sleep(0.1) time.sleep(0.1)
try: if not _is_pid_alive(pid):
os.kill(pid, 0)
except ProcessLookupError:
break break
else: else:
os.kill(pid, signal.SIGKILL) _kill_pid(pid, force=True)
except ProcessLookupError: except (ProcessLookupError, OSError):
pass pass
_remove_pid() _remove_pid()
@@ -161,21 +210,32 @@ def logs(follow, lines):
if follow: if follow:
_tail_log(log_file, lines) _tail_log(log_file, lines)
else: else:
subprocess.run( _print_last_lines(log_file, lines)
["tail", "-n", str(lines), log_file],
stdout=sys.stdout,
stderr=sys.stderr, 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): 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: try:
proc = subprocess.Popen( with open(log_file, "r", encoding="utf-8", errors="replace") as f:
["tail", "-f", "-n", str(lines), log_file], f.seek(0, 2)
stdout=sys.stdout, while True:
stderr=sys.stderr, line = f.readline()
) if line:
proc.wait() click.echo(line, nl=False)
else:
time.sleep(0.3)
except KeyboardInterrupt: except KeyboardInterrupt:
pass pass

View File

@@ -1,12 +1,16 @@
"""cow skill - Skill management commands.""" """cow skill - Skill management commands."""
import os import os
import re
import sys import sys
import json import json
import hashlib
import shutil import shutil
import zipfile import zipfile
import tempfile import tempfile
from urllib.parse import urlparse
import click import click
import requests import requests
@@ -18,6 +22,57 @@ from cli.utils import (
SKILL_HUB_API, 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() @click.group()
def skill(): def skill():
@@ -208,6 +263,7 @@ def install(name):
if name.startswith("github:"): if name.startswith("github:"):
_install_github(name[7:]) _install_github(name[7:])
else: else:
_validate_skill_name(name)
_install_hub(name) _install_hub(name)
@@ -239,18 +295,40 @@ def _install_hub(name):
if source_type == "github": if source_type == "github":
source_url = data.get("source_url", "") source_url = data.get("source_url", "")
_validate_github_spec(source_url)
source_path = data.get("source_path") source_path = data.get("source_path")
click.echo(f"Source: GitHub ({source_url})") click.echo(f"Source: GitHub ({source_url})")
_install_github(source_url, subpath=source_path, skill_name=name) _install_github(source_url, subpath=source_path, skill_name=name)
return return
if source_type == "registry": if source_type == "registry":
click.echo(f"This skill is from an external registry: {data.get('source_url', '')}") download_url = data.get("download_url")
click.echo("Please install it through the corresponding platform.") 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 return
if "redirect" in data: if "redirect" in data:
source_url = data.get("source_url", "") source_url = data.get("source_url", "")
_validate_github_spec(source_url)
source_path = data.get("source_path") source_path = data.get("source_path")
click.echo(f"Source: GitHub ({source_url})") click.echo(f"Source: GitHub ({source_url})")
_install_github(source_url, subpath=source_path, skill_name=name) _install_github(source_url, subpath=source_path, skill_name=name)
@@ -258,8 +336,9 @@ def _install_hub(name):
elif "application/zip" in content_type: elif "application/zip" in content_type:
click.echo("Downloading skill package...") 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) _install_zip_bytes(resp.content, name, skills_dir)
_report_install(name)
click.echo(click.style(f"✓ Skill '{name}' installed successfully!", fg="green")) click.echo(click.style(f"✓ Skill '{name}' installed successfully!", fg="green"))
return return
@@ -275,8 +354,11 @@ def _install_github(spec, subpath=None, skill_name=None):
if "#" in spec and not subpath: if "#" in spec and not subpath:
spec, subpath = spec.split("#", 1) spec, subpath = spec.split("#", 1)
_validate_github_spec(spec)
if not skill_name: if not skill_name:
skill_name = subpath.rstrip("/").split("/")[-1] if subpath else spec.split("/")[-1] skill_name = subpath.rstrip("/").split("/")[-1] if subpath else spec.split("/")[-1]
_validate_skill_name(skill_name)
skills_dir = get_skills_dir() skills_dir = get_skills_dir()
os.makedirs(skills_dir, exist_ok=True) 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") extract_dir = os.path.join(tmp_dir, "extracted")
with zipfile.ZipFile(zip_path, "r") as zf: 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/" # GitHub archives have a top-level dir like "repo-main/"
top_items = [d for d in os.listdir(extract_dir) if not d.startswith(".")] 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.rmtree(target_dir)
shutil.copytree(source_dir, target_dir) shutil.copytree(source_dir, target_dir)
_report_install(skill_name)
click.echo(click.style(f"✓ Skill '{skill_name}' installed successfully!", fg="green")) 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") extract_dir = os.path.join(tmp_dir, "extracted")
with zipfile.ZipFile(zip_path, "r") as zf: 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(".")] top_items = [d for d in os.listdir(extract_dir) if not d.startswith(".")]
source = extract_dir source = extract_dir
@@ -345,12 +426,6 @@ def _install_zip_bytes(content, name, skills_dir):
shutil.copytree(source, 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
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -361,6 +436,7 @@ def _report_install(name):
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation") @click.option("--yes", "-y", is_flag=True, help="Skip confirmation")
def uninstall(name, yes): def uninstall(name, yes):
"""Uninstall a skill.""" """Uninstall a skill."""
_validate_skill_name(name)
skills_dir = get_skills_dir() skills_dir = get_skills_dir()
skill_dir = os.path.join(skills_dir, name) skill_dir = os.path.join(skills_dir, name)
@@ -405,6 +481,7 @@ def disable(name):
def _set_enabled(name, enabled): def _set_enabled(name, enabled):
_validate_skill_name(name)
skills_dir = get_skills_dir() skills_dir = get_skills_dir()
config_path = os.path.join(skills_dir, "skills_config.json") config_path = os.path.join(skills_dir, "skills_config.json")
@@ -440,6 +517,7 @@ def _set_enabled(name, enabled):
@click.argument("name") @click.argument("name")
def info(name): def info(name):
"""Show details about an installed skill.""" """Show details about an installed skill."""
_validate_skill_name(name)
skills_dir = get_skills_dir() skills_dir = get_skills_dir()
builtin_dir = get_builtin_skills_dir() builtin_dir = get_builtin_skills_dir()

13
run.sh
View File

@@ -242,6 +242,17 @@ install_dependencies() {
fi fi
rm -f /tmp/pip_install.log 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 # Select model
@@ -603,7 +614,7 @@ ensure_python_cmd() {
# Get service PID (empty string if not running) # Get service PID (empty string if not running)
get_pid() { get_pid() {
ensure_python_cmd > /dev/null 2>&1 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 # Check if service is running

447
scripts/run.ps1 Normal file
View File

@@ -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 <command> # 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
}
}