feat: add OpenCode Windows setup script
This commit is contained in:
521
setup-windows-opencode.ps1
Normal file
521
setup-windows-opencode.ps1
Normal file
@@ -0,0 +1,521 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
KiCAD MCP Server - Windows OpenCode Configuration Script
|
||||
|
||||
.DESCRIPTION
|
||||
Verifies the local KiCAD MCP Server setup and creates or updates an
|
||||
OpenCode configuration with a local MCP server entry.
|
||||
|
||||
The MCP server path and OpenCode project path are intentionally separate:
|
||||
- McpServerPath is this KiCAD MCP Server repository, where dist/index.js lives.
|
||||
- ProjectPath is the project that should receive opencode.json.
|
||||
|
||||
The script writes OpenCode's config shape, not Claude Desktop's:
|
||||
- top-level key: mcp
|
||||
- server type: local
|
||||
- command: array containing executable and arguments
|
||||
- environment: object containing environment variables
|
||||
|
||||
.PARAMETER Apply
|
||||
Write the generated MCP entry to the selected OpenCode config file.
|
||||
|
||||
.PARAMETER DryRun
|
||||
Print the generated OpenCode config without writing files.
|
||||
|
||||
.PARAMETER Verify
|
||||
Run detection and validation only. No files are modified.
|
||||
|
||||
.PARAMETER Scope
|
||||
Target OpenCode config scope when applying: project or global.
|
||||
project writes opencode.json in ProjectPath.
|
||||
global writes ~/.config/opencode/opencode.json.
|
||||
|
||||
.PARAMETER Name
|
||||
MCP server name to use in OpenCode config. Default: kicad.
|
||||
|
||||
.PARAMETER KiCadRoot
|
||||
Explicit KiCAD installation root, for example C:\Program Files\KiCad\9.0.
|
||||
|
||||
.PARAMETER ProjectPath
|
||||
Project directory that should receive opencode.json for project-only setup.
|
||||
Defaults to the current working directory.
|
||||
|
||||
.PARAMETER McpServerPath
|
||||
KiCAD MCP Server repository directory. Defaults to the directory containing
|
||||
this script.
|
||||
|
||||
.PARAMETER ConfigPath
|
||||
Explicit OpenCode config path. Overrides Scope.
|
||||
|
||||
.PARAMETER SkipInstall
|
||||
Skip npm install.
|
||||
|
||||
.PARAMETER SkipBuild
|
||||
Skip npm run build.
|
||||
|
||||
.PARAMETER Force
|
||||
Allow config generation even when KiCAD Python/pcbnew validation fails.
|
||||
|
||||
.EXAMPLE
|
||||
.\setup-windows-opencode.ps1 -Verify
|
||||
|
||||
.EXAMPLE
|
||||
.\setup-windows-opencode.ps1 -DryRun
|
||||
|
||||
.EXAMPLE
|
||||
.\setup-windows-opencode.ps1 -Apply -Scope project -ProjectPath C:\path\to\your-project
|
||||
|
||||
.EXAMPLE
|
||||
.\setup-windows-opencode.ps1 -Apply -Scope global -Name kicad-dev
|
||||
#>
|
||||
|
||||
param(
|
||||
[switch]$Apply,
|
||||
[switch]$DryRun,
|
||||
[switch]$Verify,
|
||||
[ValidateSet('project', 'global')]
|
||||
[string]$Scope = 'project',
|
||||
[string]$Name = 'kicad',
|
||||
[string]$KiCadRoot = '',
|
||||
[string]$ProjectPath = '',
|
||||
[string]$McpServerPath = '',
|
||||
[string]$ConfigPath = '',
|
||||
[switch]$SkipInstall,
|
||||
[switch]$SkipBuild,
|
||||
[switch]$Force
|
||||
)
|
||||
|
||||
Set-StrictMode -Version 2.0
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Write-Success { param([string]$Message) Write-Host "[OK] $Message" -ForegroundColor Green }
|
||||
function Write-Error-Custom { param([string]$Message) Write-Host "[ERROR] $Message" -ForegroundColor Red }
|
||||
function Write-Warning-Custom { param([string]$Message) Write-Host "[WARN] $Message" -ForegroundColor Yellow }
|
||||
function Write-Info { param([string]$Message) Write-Host "[INFO] $Message" -ForegroundColor Cyan }
|
||||
function Write-Step { param([string]$Message) Write-Host "`n=== $Message ===" -ForegroundColor Magenta }
|
||||
|
||||
function ConvertTo-HashtableDeep {
|
||||
param([Parameter(ValueFromPipeline = $true)]$InputObject)
|
||||
|
||||
if ($null -eq $InputObject) {
|
||||
return $null
|
||||
}
|
||||
|
||||
if ($InputObject -is [System.Collections.IEnumerable] -and $InputObject -isnot [string] -and $InputObject -isnot [System.Collections.IDictionary] -and $InputObject -isnot [pscustomobject]) {
|
||||
$items = @()
|
||||
foreach ($item in $InputObject) {
|
||||
$items += ConvertTo-HashtableDeep $item
|
||||
}
|
||||
return $items
|
||||
}
|
||||
|
||||
if ($InputObject -is [System.Collections.IDictionary]) {
|
||||
$hash = [ordered]@{}
|
||||
foreach ($key in $InputObject.Keys) {
|
||||
$hash[$key] = ConvertTo-HashtableDeep $InputObject[$key]
|
||||
}
|
||||
return $hash
|
||||
}
|
||||
|
||||
if ($InputObject -is [pscustomobject]) {
|
||||
$hash = [ordered]@{}
|
||||
foreach ($property in $InputObject.PSObject.Properties) {
|
||||
$hash[$property.Name] = ConvertTo-HashtableDeep $property.Value
|
||||
}
|
||||
return $hash
|
||||
}
|
||||
|
||||
return $InputObject
|
||||
}
|
||||
|
||||
function Find-KiCadInstallation {
|
||||
param([string]$ExplicitRoot)
|
||||
|
||||
if ($ExplicitRoot) {
|
||||
$resolved = [System.Environment]::ExpandEnvironmentVariables($ExplicitRoot)
|
||||
if (Test-Path -LiteralPath $resolved) {
|
||||
return Get-KiCadInfo -Root $resolved
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
$basePaths = @(
|
||||
'C:\Program Files\KiCad',
|
||||
'C:\Program Files (x86)\KiCad',
|
||||
(Join-Path $env:LOCALAPPDATA 'Programs\KiCad')
|
||||
)
|
||||
|
||||
$preferredVersions = @('10.0', '9.1', '9.0', '8.0')
|
||||
foreach ($basePath in $basePaths) {
|
||||
foreach ($version in $preferredVersions) {
|
||||
$root = Join-Path $basePath $version
|
||||
$info = Get-KiCadInfo -Root $root
|
||||
if ($info) {
|
||||
return $info
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($basePath in $basePaths) {
|
||||
if (-not (Test-Path -LiteralPath $basePath)) {
|
||||
continue
|
||||
}
|
||||
|
||||
$children = Get-ChildItem -LiteralPath $basePath -Directory -ErrorAction SilentlyContinue | Sort-Object Name -Descending
|
||||
foreach ($child in $children) {
|
||||
$info = Get-KiCadInfo -Root $child.FullName
|
||||
if ($info) {
|
||||
return $info
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
function Get-KiCadInfo {
|
||||
param([string]$Root)
|
||||
|
||||
if (-not $Root -or -not (Test-Path -LiteralPath $Root)) {
|
||||
return $null
|
||||
}
|
||||
|
||||
$pythonExeCandidates = @(
|
||||
(Join-Path $Root 'bin\python.exe'),
|
||||
(Join-Path $Root 'bin\Python.exe')
|
||||
)
|
||||
$pythonExe = $pythonExeCandidates | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1
|
||||
if (-not $pythonExe) {
|
||||
return $null
|
||||
}
|
||||
|
||||
$pythonLibCandidates = @(
|
||||
(Join-Path $Root 'lib\python3\dist-packages'),
|
||||
(Join-Path $Root 'bin\Lib\site-packages')
|
||||
)
|
||||
$pythonLib = $pythonLibCandidates | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1
|
||||
if (-not $pythonLib) {
|
||||
$pythonLib = $pythonLibCandidates[0]
|
||||
}
|
||||
|
||||
return [ordered]@{
|
||||
Root = $Root
|
||||
Version = Split-Path -Leaf $Root
|
||||
PythonExe = $pythonExe
|
||||
PythonLib = $pythonLib
|
||||
}
|
||||
}
|
||||
|
||||
function Get-TargetConfigPath {
|
||||
param(
|
||||
[string]$ExplicitPath,
|
||||
[string]$SelectedScope,
|
||||
[string]$TargetProjectPath
|
||||
)
|
||||
|
||||
if ($ExplicitPath) {
|
||||
return [System.Environment]::ExpandEnvironmentVariables($ExplicitPath)
|
||||
}
|
||||
|
||||
if ($SelectedScope -eq 'global') {
|
||||
return Join-Path $env:USERPROFILE '.config\opencode\opencode.json'
|
||||
}
|
||||
|
||||
return Join-Path $TargetProjectPath 'opencode.json'
|
||||
}
|
||||
|
||||
function Resolve-DirectoryPath {
|
||||
param(
|
||||
[string]$Path,
|
||||
[string]$FallbackPath,
|
||||
[string]$Label
|
||||
)
|
||||
|
||||
$candidate = if ($Path) { [System.Environment]::ExpandEnvironmentVariables($Path) } else { $FallbackPath }
|
||||
if (-not (Test-Path -LiteralPath $candidate -PathType Container)) {
|
||||
throw "$Label directory does not exist: $candidate"
|
||||
}
|
||||
|
||||
return (Resolve-Path -LiteralPath $candidate).Path
|
||||
}
|
||||
|
||||
function Read-OpenCodeConfig {
|
||||
param([string]$Path)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $Path)) {
|
||||
return [ordered]@{
|
||||
'$schema' = 'https://opencode.ai/config.json'
|
||||
}
|
||||
}
|
||||
|
||||
$raw = Get-Content -LiteralPath $Path -Raw
|
||||
if ([string]::IsNullOrWhiteSpace($raw)) {
|
||||
return [ordered]@{
|
||||
'$schema' = 'https://opencode.ai/config.json'
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$parsed = $raw | ConvertFrom-Json
|
||||
$config = ConvertTo-HashtableDeep $parsed
|
||||
if (-not $config.Contains('$schema')) {
|
||||
$schemaConfig = [ordered]@{ '$schema' = 'https://opencode.ai/config.json' }
|
||||
foreach ($key in $config.Keys) {
|
||||
$schemaConfig[$key] = $config[$key]
|
||||
}
|
||||
return $schemaConfig
|
||||
}
|
||||
return $config
|
||||
} catch {
|
||||
throw "Failed to parse existing OpenCode config '$Path': $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
function Set-McpEntry {
|
||||
param(
|
||||
[System.Collections.IDictionary]$Config,
|
||||
[string]$ServerName,
|
||||
[string]$DistPath,
|
||||
[string]$PythonPath
|
||||
)
|
||||
|
||||
if (-not $Config.Contains('mcp') -or $null -eq $Config['mcp']) {
|
||||
$Config['mcp'] = [ordered]@{}
|
||||
}
|
||||
|
||||
$environment = [ordered]@{
|
||||
NODE_ENV = 'production'
|
||||
LOG_LEVEL = 'info'
|
||||
KICAD_AUTO_LAUNCH = 'false'
|
||||
KICAD_MCP_DEV = '0'
|
||||
}
|
||||
|
||||
if ($PythonPath) {
|
||||
$environment['PYTHONPATH'] = $PythonPath
|
||||
}
|
||||
|
||||
$Config['mcp'][$ServerName] = [ordered]@{
|
||||
type = 'local'
|
||||
command = @('node', $DistPath)
|
||||
environment = $environment
|
||||
enabled = $true
|
||||
timeout = 30000
|
||||
}
|
||||
}
|
||||
|
||||
function Write-OpenCodeConfig {
|
||||
param(
|
||||
[System.Collections.IDictionary]$Config,
|
||||
[string]$Path
|
||||
)
|
||||
|
||||
$parent = Split-Path -Parent $Path
|
||||
if ($parent -and -not (Test-Path -LiteralPath $parent)) {
|
||||
New-Item -ItemType Directory -Path $parent -Force | Out-Null
|
||||
}
|
||||
|
||||
if (Test-Path -LiteralPath $Path) {
|
||||
$timestamp = Get-Date -Format 'yyyyMMdd-HHmmss'
|
||||
$backupPath = "$Path.bak-$timestamp"
|
||||
Copy-Item -LiteralPath $Path -Destination $backupPath
|
||||
Write-Success "Backup written: $backupPath"
|
||||
}
|
||||
|
||||
$json = $Config | ConvertTo-Json -Depth 20
|
||||
$json | Out-File -LiteralPath $Path -Encoding UTF8
|
||||
}
|
||||
|
||||
if (-not $Apply -and -not $DryRun -and -not $Verify) {
|
||||
$Verify = $true
|
||||
}
|
||||
|
||||
if (($Apply.IsPresent -and $DryRun.IsPresent) -or ($Apply.IsPresent -and $Verify.IsPresent) -or ($DryRun.IsPresent -and $Verify.IsPresent)) {
|
||||
throw 'Choose only one of -Apply, -DryRun, or -Verify.'
|
||||
}
|
||||
|
||||
Write-Host @"
|
||||
============================================================
|
||||
KiCAD MCP Server - OpenCode Windows Setup
|
||||
============================================================
|
||||
"@ -ForegroundColor Cyan
|
||||
|
||||
$script:Results = [ordered]@{
|
||||
KiCadFound = $false
|
||||
PcbnewImport = $false
|
||||
NodeFound = $false
|
||||
NpmInstall = $false
|
||||
ProjectBuilt = $false
|
||||
DistFound = $false
|
||||
ConfigReady = $false
|
||||
Errors = @()
|
||||
}
|
||||
|
||||
$ScriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$McpServerRoot = Resolve-DirectoryPath -Path $McpServerPath -FallbackPath $ScriptRoot -Label 'MCP server'
|
||||
$TargetProjectRoot = Resolve-DirectoryPath -Path $ProjectPath -FallbackPath (Get-Location).Path -Label 'Project'
|
||||
$DistPath = Join-Path $McpServerRoot 'dist\index.js'
|
||||
$TargetConfigPath = Get-TargetConfigPath -ExplicitPath $ConfigPath -SelectedScope $Scope -TargetProjectPath $TargetProjectRoot
|
||||
|
||||
Write-Info "MCP server path: $McpServerRoot"
|
||||
if ($Scope -eq 'project') {
|
||||
Write-Info "OpenCode project path: $TargetProjectRoot"
|
||||
}
|
||||
|
||||
Write-Step 'Step 1: Detecting KiCAD Installation'
|
||||
$kicad = Find-KiCadInstallation -ExplicitRoot $KiCadRoot
|
||||
if ($kicad) {
|
||||
$script:Results.KiCadFound = $true
|
||||
Write-Success "Found KiCAD at: $($kicad.Root)"
|
||||
Write-Info "KiCAD Python: $($kicad.PythonExe)"
|
||||
Write-Info "KiCAD PYTHONPATH: $($kicad.PythonLib)"
|
||||
} else {
|
||||
Write-Error-Custom 'KiCAD was not found in standard Windows locations.'
|
||||
Write-Warning-Custom 'Use -KiCadRoot "C:\Path\To\KiCad\9.0" if KiCAD is installed elsewhere.'
|
||||
$script:Results.Errors += 'KiCAD not found'
|
||||
}
|
||||
|
||||
Write-Step 'Step 2: Testing KiCAD pcbnew Module'
|
||||
if ($kicad) {
|
||||
$testScript = "import pcbnew; print('SUCCESS:' + pcbnew.GetBuildVersion())"
|
||||
$pcbnewResult = & $kicad.PythonExe -c $testScript 2>&1
|
||||
if ($LASTEXITCODE -eq 0 -and $pcbnewResult -match 'SUCCESS:(.+)') {
|
||||
$script:Results.PcbnewImport = $true
|
||||
Write-Success "pcbnew imported successfully: $($matches[1])"
|
||||
} else {
|
||||
Write-Error-Custom 'KiCAD Python could not import pcbnew.'
|
||||
Write-Warning-Custom "Output: $pcbnewResult"
|
||||
$script:Results.Errors += 'pcbnew import failed'
|
||||
}
|
||||
} else {
|
||||
Write-Warning-Custom 'Skipping pcbnew test because KiCAD was not found.'
|
||||
}
|
||||
|
||||
Write-Step 'Step 3: Checking Node.js'
|
||||
try {
|
||||
$nodeVersion = node --version 2>$null
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
$script:Results.NodeFound = $true
|
||||
Write-Success "Node.js found: $nodeVersion"
|
||||
$major = [int]($nodeVersion -replace '^v(\d+)\..*$', '$1')
|
||||
if ($major -lt 18) {
|
||||
Write-Warning-Custom "Node.js 18+ is recommended. Found $nodeVersion."
|
||||
}
|
||||
} else {
|
||||
throw 'node command failed'
|
||||
}
|
||||
} catch {
|
||||
Write-Error-Custom 'Node.js was not found. Install Node.js 18+ from https://nodejs.org/.'
|
||||
$script:Results.Errors += 'Node.js not found'
|
||||
}
|
||||
|
||||
Write-Step 'Step 4: Installing Node Dependencies'
|
||||
if ($SkipInstall) {
|
||||
Write-Info 'Skipping npm install because -SkipInstall was specified.'
|
||||
$script:Results.NpmInstall = $true
|
||||
} elseif ($script:Results.NodeFound) {
|
||||
Push-Location $McpServerRoot
|
||||
try {
|
||||
npm install
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
$script:Results.NpmInstall = $true
|
||||
Write-Success 'npm install completed.'
|
||||
} else {
|
||||
Write-Error-Custom 'npm install failed.'
|
||||
$script:Results.Errors += 'npm install failed'
|
||||
}
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
} else {
|
||||
Write-Warning-Custom 'Skipping npm install because Node.js was not found.'
|
||||
}
|
||||
|
||||
Write-Step 'Step 5: Building TypeScript Project'
|
||||
if ($SkipBuild) {
|
||||
Write-Info 'Skipping npm run build because -SkipBuild was specified.'
|
||||
} elseif ($script:Results.NodeFound) {
|
||||
Push-Location $McpServerRoot
|
||||
try {
|
||||
npm run build
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
$script:Results.ProjectBuilt = $true
|
||||
Write-Success 'npm run build completed.'
|
||||
} else {
|
||||
Write-Error-Custom 'npm run build failed.'
|
||||
$script:Results.Errors += 'TypeScript build failed'
|
||||
}
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
} else {
|
||||
Write-Warning-Custom 'Skipping build because Node.js was not found.'
|
||||
}
|
||||
|
||||
if (Test-Path -LiteralPath $DistPath) {
|
||||
$script:Results.DistFound = $true
|
||||
Write-Success "Found MCP server entrypoint: $DistPath"
|
||||
} else {
|
||||
Write-Error-Custom "MCP server entrypoint was not found: $DistPath"
|
||||
$script:Results.Errors += 'dist/index.js not found'
|
||||
}
|
||||
|
||||
Write-Step 'Step 6: Preparing OpenCode Configuration'
|
||||
$canGenerate = $script:Results.NodeFound -and $script:Results.DistFound
|
||||
if (-not $Force) {
|
||||
$canGenerate = $canGenerate -and $script:Results.KiCadFound -and $script:Results.PcbnewImport
|
||||
}
|
||||
|
||||
if ($canGenerate) {
|
||||
$config = Read-OpenCodeConfig -Path $TargetConfigPath
|
||||
$pythonPath = if ($kicad) { $kicad.PythonLib } else { '' }
|
||||
Set-McpEntry -Config $config -ServerName $Name -DistPath $DistPath -PythonPath $pythonPath
|
||||
$script:Results.ConfigReady = $true
|
||||
|
||||
Write-Success "OpenCode MCP entry prepared for server name '$Name'."
|
||||
Write-Info "Target config: $TargetConfigPath"
|
||||
Write-Host ''
|
||||
Write-Host ($config | ConvertTo-Json -Depth 20) -ForegroundColor Gray
|
||||
|
||||
if ($Apply) {
|
||||
Write-OpenCodeConfig -Config $config -Path $TargetConfigPath
|
||||
Write-Success "OpenCode config written: $TargetConfigPath"
|
||||
} elseif ($DryRun) {
|
||||
Write-Info 'Dry run only. No files were modified.'
|
||||
} else {
|
||||
Write-Info 'Verify mode only. No files were modified.'
|
||||
}
|
||||
} else {
|
||||
Write-Warning-Custom 'OpenCode config was not generated because required checks failed.'
|
||||
Write-Warning-Custom 'Use -Force to generate config anyway, or resolve the errors above and run again.'
|
||||
}
|
||||
|
||||
Write-Step 'Setup Summary'
|
||||
Write-Host " KiCAD Installation: $(if ($script:Results.KiCadFound) { '[OK] Found' } else { '[ERROR] Not Found' })"
|
||||
Write-Host " pcbnew Module: $(if ($script:Results.PcbnewImport) { '[OK] Working' } else { '[ERROR] Failed' })"
|
||||
Write-Host " Node.js: $(if ($script:Results.NodeFound) { '[OK] Found' } else { '[ERROR] Not Found' })"
|
||||
Write-Host " npm install: $(if ($script:Results.NpmInstall) { '[OK] Complete/Skipped' } else { '[WARN] Not Complete' })"
|
||||
Write-Host " TypeScript Build: $(if ($script:Results.ProjectBuilt -or $SkipBuild) { '[OK] Complete/Skipped' } else { '[WARN] Not Complete' })"
|
||||
Write-Host " dist/index.js: $(if ($script:Results.DistFound) { '[OK] Found' } else { '[ERROR] Missing' })"
|
||||
Write-Host " OpenCode Config: $(if ($script:Results.ConfigReady) { '[OK] Ready' } else { '[ERROR] Not Ready' })"
|
||||
|
||||
if ($script:Results.Errors.Count -gt 0) {
|
||||
Write-Host ''
|
||||
Write-Host 'Errors:' -ForegroundColor Red
|
||||
foreach ($item in $script:Results.Errors) {
|
||||
Write-Host " - $item" -ForegroundColor Red
|
||||
}
|
||||
}
|
||||
|
||||
if ($Apply -and $script:Results.ConfigReady) {
|
||||
Write-Host ''
|
||||
Write-Success 'Next steps:'
|
||||
Write-Host ' 1. Fully quit and restart OpenCode.' -ForegroundColor Green
|
||||
Write-Host " 2. Ask OpenCode to use the '$Name' MCP server and run check_kicad_ui." -ForegroundColor Green
|
||||
} elseif (-not $Apply) {
|
||||
Write-Host ''
|
||||
Write-Info 'Run with -Apply to write the config after checks pass.'
|
||||
}
|
||||
|
||||
if (-not $script:Results.ConfigReady -and -not $Verify) {
|
||||
exit 1
|
||||
}
|
||||
Reference in New Issue
Block a user