Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
169 changes: 132 additions & 37 deletions install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
# PowerShell installation script

$ErrorActionPreference = "Stop"
$MinPythonMajor = 3
$MinPythonMinor = 10
$MaxPythonMajor = 3
$MaxPythonMinor = 14

Write-Host "========================================" -ForegroundColor Cyan
Write-Host " Codex SEO - Installer" -ForegroundColor Cyan
Expand All @@ -10,14 +14,47 @@ Write-Host "========================================" -ForegroundColor Cyan
Write-Host ""

function Resolve-Python {
$pythonCmd = Get-Command -Name python -ErrorAction SilentlyContinue
if ($null -ne $pythonCmd) {
return @{ Exe = "python"; Args = @() }
}
$candidates = @()

$pyCmd = Get-Command -Name py -ErrorAction SilentlyContinue
if ($null -ne $pyCmd) {
return @{ Exe = "py"; Args = @("-3") }
foreach ($minor in @(13, 12, 11, 10)) {
$candidates += @{ Exe = "py"; Args = @("-3.$minor") }
}
$candidates += @{ Exe = "py"; Args = @("-3") }
}

foreach ($name in @("python", "python3")) {
$cmd = Get-Command -Name $name -ErrorAction SilentlyContinue
if ($null -ne $cmd) {
$candidates += @{ Exe = $name; Args = @() }
}
}

foreach ($candidate in $candidates) {
try {
$versionOutput = (& $candidate.Exe @($candidate.Args + @("--version")) 2>&1 | Select-Object -Last 1).ToString().Trim()
} catch {
continue
}

if ($versionOutput -notmatch "Python\s+(\d+)\.(\d+)") {
continue
}

$major = [int]$Matches[1]
$minor = [int]$Matches[2]
$meetsMinimum = $major -gt $MinPythonMajor -or ($major -eq $MinPythonMajor -and $minor -ge $MinPythonMinor)
$belowMaximum = $major -lt $MaxPythonMajor -or ($major -eq $MaxPythonMajor -and $minor -lt $MaxPythonMinor)
if ($meetsMinimum -and $belowMaximum) {
return @{
Exe = $candidate.Exe
Args = $candidate.Args
VersionOutput = $versionOutput
Major = $major
Minor = $minor
}
}
}

return $null
Expand Down Expand Up @@ -102,12 +139,24 @@ import sys

payload = json.loads(sys.stdin.read())
verification = payload.get("verification") or {}
failed_step = None
for step in payload.get("steps") or []:
if step.get("required") and not step.get("ok"):
failed_step = {
"group": step.get("group") or "unknown",
"stdout": step.get("stdout") or "",
"stderr": step.get("stderr") or "",
"ok": False,
"required": True,
}
break
summary = {
"ok": bool(payload.get("ok")),
"full_ready": bool(payload.get("full_ready")),
"python": payload.get("python") or "",
"optional_failed_groups": payload.get("optional_failed_groups") or [],
"verification": {"notes": verification.get("notes") or []},
"steps": [failed_step] if failed_step else [],
"error": payload.get("error") or "",
}
print(json.dumps(summary))
Expand Down Expand Up @@ -136,6 +185,63 @@ print(json.dumps(summary))
}
}

function Write-BootstrapDiagnostics {
param([object]$Payload)

if ($null -eq $Payload) {
return
}

if (-not [string]::IsNullOrWhiteSpace($Payload.error)) {
Write-Host "[ERROR] $($Payload.error)" -ForegroundColor Red
}

$verificationNotes = @()
if ($null -ne $Payload.verification -and $null -ne $Payload.verification.notes) {
$verificationNotes = @($Payload.verification.notes)
}
$verificationNotes | Select-Object -First 5 | ForEach-Object {
Write-Host "[ERROR] $_" -ForegroundColor Red
}

$failedStep = $null
if ($null -ne $Payload.steps) {
$failedStep = @($Payload.steps) | Where-Object { $_.required -and -not $_.ok } | Select-Object -First 1
}
if ($null -eq $failedStep) {
return
}

$group = if ($failedStep.group) { $failedStep.group } else { "unknown" }
Write-Host "[ERROR] Failed bootstrap step: $group." -ForegroundColor Red
$diagnostic = if ($failedStep.stderr) { $failedStep.stderr } else { $failedStep.stdout }
if (-not [string]::IsNullOrWhiteSpace($diagnostic)) {
if ($group -eq "verification") {
try {
$verificationPayload = $diagnostic | ConvertFrom-Json
if ($null -ne $verificationPayload.missing_required -and @($verificationPayload.missing_required).Count -gt 0) {
Write-Host "[ERROR] Missing required packages: $(@($verificationPayload.missing_required) -join ', ')." -ForegroundColor Red
}
if ($null -ne $verificationPayload.paths) {
foreach ($pathName in @("cache", "output")) {
$pathCheck = $verificationPayload.paths.$pathName
if ($null -ne $pathCheck -and -not $pathCheck.ok) {
Write-Host "[ERROR] Cannot write $pathName path $($pathCheck.path): $($pathCheck.error)" -ForegroundColor Red
}
}
}
return
} catch {
# Fall back to a compact stderr/stdout tail below.
}
}
$lines = $diagnostic.Trim() -split "`r?`n"
if ($lines.Count -gt 0) {
Write-Host "[ERROR] $($lines[-1])" -ForegroundColor Red
}
}
}

function Remove-PathIfExists {
param([string]$Path)

Expand All @@ -146,29 +252,14 @@ function Remove-PathIfExists {

$python = Resolve-Python
if ($null -eq $python) {
Write-Host "[ERROR] Python 3 is required but was not found in PATH." -ForegroundColor Red
exit 1
}

try {
$pythonVersionOutput = (& $python.Exe @($python.Args + @("--version")) 2>&1 | Select-Object -Last 1).ToString().Trim()
} catch {
Write-Host "[ERROR] Failed to determine Python version." -ForegroundColor Red
exit 1
}

if ($pythonVersionOutput -notmatch "Python\s+(\d+)\.(\d+)") {
Write-Host "[ERROR] Failed to parse Python version from: $pythonVersionOutput" -ForegroundColor Red
Write-Host "[ERROR] Python 3.10-3.13 is required but was not found in PATH." -ForegroundColor Red
Write-Host "[ERROR] Python 3.14 is not supported yet by all Codex SEO dependencies." -ForegroundColor Red
exit 1
}

$pythonMajor = [int]$Matches[1]
$pythonMinor = [int]$Matches[2]
$pythonMajor = $python.Major
$pythonMinor = $python.Minor
$pythonVersion = "$pythonMajor.$pythonMinor"
if ($pythonMajor -lt 3 -or ($pythonMajor -eq 3 -and $pythonMinor -lt 10)) {
Write-Host "[ERROR] Python 3.10+ is required but $pythonVersion was found." -ForegroundColor Red
exit 1
}
Write-Host "[OK] Python $pythonVersion detected" -ForegroundColor Green

$gitCmd = Get-Command -Name git -ErrorAction SilentlyContinue
Expand All @@ -183,6 +274,11 @@ $agentDir = Join-Path $codexRoot "agents"
$skillDir = Join-Path $skillsRoot "seo"
$repoUrl = if ($env:CODEX_SEO_REPO) { $env:CODEX_SEO_REPO } else { "https://github.com/AgriciDaniel/codex-seo" }
$repoRef = if ($env:CODEX_SEO_REF) { $env:CODEX_SEO_REF } else { "v1.9.6-codex.5" }
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$localSource = if (
(Test-Path (Join-Path $scriptDir ".codex-plugin\plugin.json")) -and
(Test-Path (Join-Path $scriptDir "skills\seo\SKILL.md"))
) { $scriptDir } else { "" }
$skipPlaywrightBrowser = Test-Truthy $env:CODEX_SEO_SKIP_PLAYWRIGHT_BROWSER
$playwrightWithDeps = Test-Truthy $env:CODEX_SEO_PLAYWRIGHT_WITH_DEPS
$suiteSkillDirs = @(
Expand Down Expand Up @@ -221,12 +317,17 @@ New-Item -ItemType Directory -Force -Path $agentDir | Out-Null
$tempDir = Join-Path ([System.IO.Path]::GetTempPath()) ([System.Guid]::NewGuid().ToString())
New-Item -ItemType Directory -Force -Path $tempDir | Out-Null
try {
$checkoutDir = Join-Path $tempDir "codex-seo"

Write-Host "[INFO] Downloading Codex SEO ($repoRef)..." -ForegroundColor Yellow
$cloneResult = Invoke-External -Exe "git" -Args @("clone", "--depth", "1", "--branch", $repoRef, $repoUrl, $checkoutDir)
if ($cloneResult.ExitCode -ne 0) {
throw "Unable to download ref $repoRef. Confirm the branch/tag exists and your Git credentials can access $repoUrl."
if (-not [string]::IsNullOrWhiteSpace($localSource)) {
$checkoutDir = $localSource
Write-Host "[INFO] Installing Codex SEO from local checkout..." -ForegroundColor Yellow
} else {
$checkoutDir = Join-Path $tempDir "codex-seo"

Write-Host "[INFO] Downloading Codex SEO ($repoRef)..." -ForegroundColor Yellow
$cloneResult = Invoke-External -Exe "git" -Args @("clone", "--depth", "1", "--branch", $repoRef, $repoUrl, $checkoutDir)
if ($cloneResult.ExitCode -ne 0) {
throw "Unable to download ref $repoRef. Confirm the branch/tag exists and your Git credentials can access $repoUrl."
}
}

$commitResult = Invoke-External -Exe "git" -Args @("-C", $checkoutDir, "rev-parse", "HEAD") -Quiet
Expand Down Expand Up @@ -318,13 +419,7 @@ try {
$bootstrapPayload = ConvertFrom-JsonCompat -Json $bootstrapJson -ErrorMessage "Bootstrap script produced invalid JSON output."

if ($bootstrapResult.ExitCode -ne 0 -or -not $bootstrapPayload.ok) {
$verificationNotes = @()
if ($null -ne $bootstrapPayload.verification -and $null -ne $bootstrapPayload.verification.notes) {
$verificationNotes = @($bootstrapPayload.verification.notes)
}
if ($verificationNotes.Count -gt 0) {
$verificationNotes | ForEach-Object { Write-Host "[ERROR] $_" -ForegroundColor Red }
}
Write-BootstrapDiagnostics -Payload $bootstrapPayload
throw "Codex SEO runtime bootstrap failed."
}

Expand Down
66 changes: 41 additions & 25 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@
set -euo pipefail

resolve_python() {
if command -v python3 >/dev/null 2>&1; then
printf '%s\n' "python3"
return
fi
if command -v python >/dev/null 2>&1; then
printf '%s\n' "python"
return
fi
for candidate in python3.13 python3.12 python3.11 python3.10 python3 python; do
if ! command -v "${candidate}" >/dev/null 2>&1; then
continue
fi
if "${candidate}" -c 'import sys; raise SystemExit(0 if (3, 10) <= sys.version_info[:2] < (3, 14) else 1)' >/dev/null 2>&1; then
printf '%s\n' "${candidate}"
return
fi
done
return 1
}

Expand Down Expand Up @@ -78,7 +79,16 @@ main() {
SKILL_DIR="${SKILLS_ROOT}/seo"
REPO_URL="${CODEX_SEO_REPO:-https://github.com/AgriciDaniel/codex-seo}"
REPO_REF="${CODEX_SEO_REF:-v1.9.6-codex.5}"
PYTHON_BIN="$(resolve_python)" || { echo "[ERROR] Python 3 is required but not installed."; exit 1; }
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LOCAL_SOURCE=""
if [ -f "${SCRIPT_DIR}/.codex-plugin/plugin.json" ] && [ -f "${SCRIPT_DIR}/skills/seo/SKILL.md" ]; then
LOCAL_SOURCE="${SCRIPT_DIR}"
fi
PYTHON_BIN="$(resolve_python)" || {
echo "[ERROR] Python 3.10-3.13 is required but not installed."
echo "[ERROR] Python 3.14 is not supported yet by all Codex SEO dependencies."
exit 1
}
SUITE_SKILL_DIRS=(
seo
seo-audit
Expand Down Expand Up @@ -118,9 +128,9 @@ main() {
command -v git >/dev/null 2>&1 || { echo "[ERROR] Git is required but not installed."; exit 1; }

PYTHON_VERSION="$("${PYTHON_BIN}" -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')"
PYTHON_OK="$("${PYTHON_BIN}" -c 'import sys; print(1 if sys.version_info >= (3, 10) else 0)')"
PYTHON_OK="$("${PYTHON_BIN}" -c 'import sys; print(1 if (3, 10) <= sys.version_info[:2] < (3, 14) else 0)')"
if [ "${PYTHON_OK}" = "0" ]; then
echo "[ERROR] Python 3.10+ is required but ${PYTHON_VERSION} was found."
echo "[ERROR] Python 3.10-3.13 is required but ${PYTHON_VERSION} was found."
exit 1
fi
echo "[OK] Python ${PYTHON_VERSION} detected"
Expand All @@ -130,13 +140,19 @@ main() {
TEMP_DIR="$(make_temp_dir)" || { echo "[ERROR] Unable to create a temporary directory."; exit 1; }
trap 'rm -rf "${TEMP_DIR}"' EXIT

echo "[INFO] Downloading Codex SEO (${REPO_REF})..."
if ! git clone --depth 1 --branch "${REPO_REF}" "${REPO_URL}" "${TEMP_DIR}/codex-seo" 2>/dev/null; then
echo "[ERROR] Unable to download ref ${REPO_REF}. Confirm the branch/tag exists and your Git credentials can access ${REPO_URL}."
exit 1
if [ -n "${LOCAL_SOURCE}" ]; then
echo "[INFO] Installing Codex SEO from local checkout..."
CHECKOUT_DIR="${LOCAL_SOURCE}"
else
echo "[INFO] Downloading Codex SEO (${REPO_REF})..."
CHECKOUT_DIR="${TEMP_DIR}/codex-seo"
if ! git clone --depth 1 --branch "${REPO_REF}" "${REPO_URL}" "${CHECKOUT_DIR}" 2>/dev/null; then
echo "[ERROR] Unable to download ref ${REPO_REF}. Confirm the branch/tag exists and your Git credentials can access ${REPO_URL}."
exit 1
fi
fi

INSTALLED_COMMIT="$(git -C "${TEMP_DIR}/codex-seo" rev-parse HEAD)"
INSTALLED_COMMIT="$(git -C "${CHECKOUT_DIR}" rev-parse HEAD)"

echo "[INFO] Resetting existing Codex SEO install..."
for skill_name in "${SUITE_SKILL_DIRS[@]}"; do
Expand All @@ -145,8 +161,8 @@ main() {
rm -f "${AGENT_DIR}/seo-"*.md "${AGENT_DIR}/seo-"*.toml 2>/dev/null || true

echo "[INFO] Installing skill files..."
if [ -d "${TEMP_DIR}/codex-seo/skills" ]; then
for skill_dir in "${TEMP_DIR}/codex-seo/skills"/*/; do
if [ -d "${CHECKOUT_DIR}/skills" ]; then
for skill_dir in "${CHECKOUT_DIR}/skills"/*/; do
[ -d "${skill_dir}" ] || continue
skill_name="$(basename "${skill_dir}")"
target="${SKILLS_ROOT}/${skill_name}"
Expand All @@ -156,26 +172,26 @@ main() {
fi

for dir_name in scripts schema pdf hooks extensions; do
if [ -d "${TEMP_DIR}/codex-seo/${dir_name}" ]; then
if [ -d "${CHECKOUT_DIR}/${dir_name}" ]; then
mkdir -p "${SKILL_DIR}/${dir_name}"
cp -r "${TEMP_DIR}/codex-seo/${dir_name}/." "${SKILL_DIR}/${dir_name}/"
cp -r "${CHECKOUT_DIR}/${dir_name}/." "${SKILL_DIR}/${dir_name}/"
fi
done

for requirements_file in "${TEMP_DIR}/codex-seo"/requirements*.txt; do
for requirements_file in "${CHECKOUT_DIR}"/requirements*.txt; do
[ -f "${requirements_file}" ] || continue
cp "${requirements_file}" "${SKILL_DIR}/$(basename "${requirements_file}")"
done

for doc_name in CHANGELOG.md README.md; do
if [ -f "${TEMP_DIR}/codex-seo/${doc_name}" ]; then
cp "${TEMP_DIR}/codex-seo/${doc_name}" "${SKILL_DIR}/${doc_name}"
if [ -f "${CHECKOUT_DIR}/${doc_name}" ]; then
cp "${CHECKOUT_DIR}/${doc_name}" "${SKILL_DIR}/${doc_name}"
fi
done

echo "[INFO] Installing agent profiles..."
if [ -d "${TEMP_DIR}/codex-seo/agents" ]; then
cp "${TEMP_DIR}/codex-seo/agents/"*.toml "${AGENT_DIR}/"
if [ -d "${CHECKOUT_DIR}/agents" ]; then
cp "${CHECKOUT_DIR}/agents/"*.toml "${AGENT_DIR}/"
fi

BOOTSTRAP_SCRIPT="${SKILL_DIR}/scripts/bootstrap_environment.py"
Expand Down
12 changes: 10 additions & 2 deletions scripts/bootstrap_environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,13 @@ def parse_json_stdout(step: dict[str, Any]) -> dict[str, Any] | None:
"""Parse a subprocess stdout payload as JSON when possible."""
if not step["ok"] or not step["stdout"].strip():
return None
text = step["stdout"].strip()
start = text.find("{")
end = text.rfind("}")
if start >= 0 and end > start:
text = text[start : end + 1]
try:
return json.loads(step["stdout"])
return json.loads(text)
except json.JSONDecodeError:
return None

Expand Down Expand Up @@ -233,7 +238,10 @@ def main() -> int:
result = exception_payload(exc)

if args.json:
write_json_output(args.json_output, result)
try:
write_json_output(args.json_output, result)
except Exception as exc: # pragma: no cover - depends on filesystem permissions
result = exception_payload(exc)
print(json.dumps(result, indent=2))
return 0 if result["ok"] else 1

Expand Down
Loading