mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-06-02 00:57:41 +08:00
fix(cli): add security hardening for skill install and process management
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
if _is_pid_alive(pid):
|
||||
return pid
|
||||
except (ValueError, ProcessLookupError, PermissionError):
|
||||
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...")
|
||||
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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
13
run.sh
13
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
|
||||
|
||||
447
scripts/run.ps1
Normal file
447
scripts/run.ps1
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user