From 0fc82980c32d461c61b0ea3703082c05298c46f4 Mon Sep 17 00:00:00 2001 From: Kolja Nolte Date: Wed, 5 Aug 2026 18:44:53 +0700 Subject: [PATCH 1/2] PR --- install-opencode.ps1 | 7 ++++++- install-opencode.sh | 7 ++++++- uninstall-opencode.ps1 | 7 ++++++- uninstall-opencode.sh | 8 +++++++- 4 files changed, 25 insertions(+), 4 deletions(-) diff --git a/install-opencode.ps1 b/install-opencode.ps1 index 8ca9cd4..8b96a3d 100644 --- a/install-opencode.ps1 +++ b/install-opencode.ps1 @@ -81,7 +81,12 @@ $agentsText = $agentsText -replace "Default mode: full\.", "Default mode: $defau $agentsText | Set-Content -LiteralPath $agentsFile -Encoding UTF8 if (Test-Path -LiteralPath $configFile) { - $config = Get-Content -LiteralPath $configFile -Raw -Encoding UTF8 | ConvertFrom-Json + $content = Get-Content -LiteralPath $configFile -Raw -Encoding UTF8 + try { + $config = $content | ConvertFrom-Json + } catch { + $config = ($content -replace ',\s*([\]}])', '$1') | ConvertFrom-Json + } } else { $config = [pscustomobject]@{} } diff --git a/install-opencode.sh b/install-opencode.sh index c960b82..7263f05 100755 --- a/install-opencode.sh +++ b/install-opencode.sh @@ -75,7 +75,12 @@ const skills = ["caveman", "caveman-commit", "caveman-review", "caveman-help", " let config = {} if (fs.existsSync(configFile)) { - config = JSON.parse(fs.readFileSync(configFile, "utf8")) + const content = fs.readFileSync(configFile, "utf8") + try { + config = JSON.parse(content) + } catch (e) { + config = JSON.parse(content.replace(/,\s*([\]}])/g, "$1")) + } } config.instructions = Array.isArray(config.instructions) ? config.instructions : [] diff --git a/uninstall-opencode.ps1 b/uninstall-opencode.ps1 index 7de4a47..5ce6fdb 100644 --- a/uninstall-opencode.ps1 +++ b/uninstall-opencode.ps1 @@ -33,7 +33,12 @@ if (Test-Path -LiteralPath $agentsFile) { } if (Test-Path -LiteralPath $configFile) { - $config = Get-Content -LiteralPath $configFile -Raw -Encoding UTF8 | ConvertFrom-Json + $content = Get-Content -LiteralPath $configFile -Raw -Encoding UTF8 + try { + $config = $content | ConvertFrom-Json + } catch { + $config = ($content -replace ',\s*([\]}])', '$1') | ConvertFrom-Json + } if ($config.PSObject.Properties.Name -contains "instructions") { $config.instructions = @(@($config.instructions) | Where-Object { $_ -ne $agentsFile }) } diff --git a/uninstall-opencode.sh b/uninstall-opencode.sh index 4b09d85..262eeb7 100755 --- a/uninstall-opencode.sh +++ b/uninstall-opencode.sh @@ -24,7 +24,13 @@ const fs = require("fs") const [configFile, agentsFile] = process.argv.slice(2) const skills = ["caveman", "caveman-commit", "caveman-review", "caveman-help", "caveman-compress"] -const config = JSON.parse(fs.readFileSync(configFile, "utf8")) +let config = {} +const content = fs.readFileSync(configFile, "utf8") +try { + config = JSON.parse(content) +} catch (e) { + config = JSON.parse(content.replace(/,\s*([\]}])/g, "$1")) +} if (Array.isArray(config.instructions)) { config.instructions = config.instructions.filter((item) => item !== agentsFile) From 7147d5e3f4521ccbb26d187467b668f165351276 Mon Sep 17 00:00:00 2001 From: anthonystepvoy Date: Sun, 9 Aug 2026 15:21:56 -0300 Subject: [PATCH 2/2] fix: safely handle trailing commas in config --- .github/workflows/validate.yml | 30 ++++++++++++- CHANGELOG.md | 2 + install-opencode.ps1 | 60 ++++++++++++++++++++++--- install-opencode.sh | 60 ++++++++++++++++++++++--- scripts/test-installers.ps1 | 80 +++++++++++++++++++++++++++++++++ scripts/test-installers.sh | 81 ++++++++++++++++++++++++++++++++++ uninstall-opencode.ps1 | 60 ++++++++++++++++++++++--- uninstall-opencode.sh | 62 +++++++++++++++++++++++--- 8 files changed, 412 insertions(+), 23 deletions(-) create mode 100644 scripts/test-installers.ps1 create mode 100755 scripts/test-installers.sh diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index d9b6f58..75dbec9 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -4,6 +4,9 @@ on: push: pull_request: +permissions: + contents: read + jobs: package: name: Package validation @@ -11,12 +14,35 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v7 with: python-version: "3.11" - name: Validate package run: python scripts/validate-package.py + + posix-installers: + name: POSIX installers + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Test install and uninstall + run: sh scripts/test-installers.sh + + powershell-installers: + name: Windows PowerShell installers + runs-on: windows-latest + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Test install and uninstall + shell: powershell + run: .\scripts\test-installers.ps1 diff --git a/CHANGELOG.md b/CHANGELOG.md index a18a126..93b6774 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ - Documented global default activation after install. - Added `CAVEMAN_OPENCODE_DEFAULT_LEVEL` install-time override for `lite`, `full`, `ultra`, `wenyan`, `wenyan-lite`, and `wenyan-ultra`. +- Accepted trailing commas in existing OpenCode configuration without changing comma-like text inside JSON strings. +- Added functional install/uninstall tests for POSIX and Windows PowerShell environments. ## 0.2.0 diff --git a/install-opencode.ps1 b/install-opencode.ps1 index 8b96a3d..f63c5b0 100644 --- a/install-opencode.ps1 +++ b/install-opencode.ps1 @@ -38,6 +38,60 @@ function Ensure-Property($Object, [string]$Name, $Value) { } } +function Remove-JsonTrailingCommas([string]$Content) { + $result = [System.Text.StringBuilder]::new($Content.Length) + $inString = $false + $escaped = $false + + for ($index = 0; $index -lt $Content.Length; $index++) { + $character = $Content[$index] + + if ($inString) { + [void]$result.Append($character) + if ($escaped) { + $escaped = $false + } elseif ($character -eq '\') { + $escaped = $true + } elseif ($character -eq '"') { + $inString = $false + } + continue + } + + if ($character -eq '"') { + $inString = $true + [void]$result.Append($character) + continue + } + + if ($character -eq ',') { + $next = $index + 1 + while ($next -lt $Content.Length -and [char]::IsWhiteSpace($Content[$next])) { + $next++ + } + if ($next -lt $Content.Length -and ($Content[$next] -eq '}' -or $Content[$next] -eq ']')) { + continue + } + } + + [void]$result.Append($character) + } + + return $result.ToString() +} + +function ConvertFrom-OpenCodeJson([string]$Content) { + try { + return $Content | ConvertFrom-Json + } catch { + $normalized = Remove-JsonTrailingCommas $Content + if ($normalized -eq $Content) { + throw + } + return $normalized | ConvertFrom-Json + } +} + if (-not (Test-Path -LiteralPath (Join-Path $sourceOpenCode "AGENTS.md"))) { $archiveUrl = $env:CAVEMAN_OPENCODE_ARCHIVE_URL if ([string]::IsNullOrWhiteSpace($archiveUrl)) { @@ -82,11 +136,7 @@ $agentsText | Set-Content -LiteralPath $agentsFile -Encoding UTF8 if (Test-Path -LiteralPath $configFile) { $content = Get-Content -LiteralPath $configFile -Raw -Encoding UTF8 - try { - $config = $content | ConvertFrom-Json - } catch { - $config = ($content -replace ',\s*([\]}])', '$1') | ConvertFrom-Json - } + $config = ConvertFrom-OpenCodeJson $content } else { $config = [pscustomobject]@{} } diff --git a/install-opencode.sh b/install-opencode.sh index 7263f05..55aa275 100755 --- a/install-opencode.sh +++ b/install-opencode.sh @@ -74,15 +74,65 @@ const [configFile, agentsFile] = process.argv.slice(2) const skills = ["caveman", "caveman-commit", "caveman-review", "caveman-help", "caveman-compress"] let config = {} -if (fs.existsSync(configFile)) { - const content = fs.readFileSync(configFile, "utf8") +function stripTrailingCommas(content) { + let result = "" + let inString = false + let escaped = false + + for (let index = 0; index < content.length; index += 1) { + const character = content[index] + + if (inString) { + result += character + if (escaped) { + escaped = false + } else if (character === "\\") { + escaped = true + } else if (character === '"') { + inString = false + } + continue + } + + if (character === '"') { + inString = true + result += character + continue + } + + if (character === ",") { + let next = index + 1 + while (next < content.length && /\s/.test(content[next])) { + next += 1 + } + if (content[next] === "}" || content[next] === "]") { + continue + } + } + + result += character + } + + return result +} + +function parseConfig(content) { try { - config = JSON.parse(content) - } catch (e) { - config = JSON.parse(content.replace(/,\s*([\]}])/g, "$1")) + return JSON.parse(content) + } catch (error) { + const normalized = stripTrailingCommas(content) + if (normalized === content) { + throw error + } + return JSON.parse(normalized) } } +if (fs.existsSync(configFile)) { + const content = fs.readFileSync(configFile, "utf8") + config = parseConfig(content) +} + config.instructions = Array.isArray(config.instructions) ? config.instructions : [] if (!config.instructions.includes(agentsFile)) { config.instructions.push(agentsFile) diff --git a/scripts/test-installers.ps1 b/scripts/test-installers.ps1 new file mode 100644 index 0000000..d4786a0 --- /dev/null +++ b/scripts/test-installers.ps1 @@ -0,0 +1,80 @@ +$ErrorActionPreference = "Stop" + +function Assert-True([bool]$Condition, [string]$Message) { + if (-not $Condition) { + throw "Assertion failed: $Message" + } +} + +$repo = Split-Path -Parent $PSScriptRoot +$testRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("caveman-opencode-test-" + [guid]::NewGuid().ToString("N")) +$originalUserProfile = $env:USERPROFILE +$originalDefaultLevel = $env:CAVEMAN_OPENCODE_DEFAULT_LEVEL + +try { + $env:USERPROFILE = $testRoot + $env:CAVEMAN_OPENCODE_DEFAULT_LEVEL = "ultra" + + $configDir = Join-Path $testRoot ".config\opencode" + $configFile = Join-Path $configDir "opencode.json" + $agentsFile = Join-Path $configDir "AGENTS.caveman.md" + New-Item -ItemType Directory -Force -Path $configDir | Out-Null + + @' +{ + "instructions": [ + "keep.md", + ], + "custom": "literal,}", + "nested": { + "value": "literal,]", + }, + "permission": { + "skill": { + "existing": "allow", + }, + }, +} +'@ | Set-Content -LiteralPath $configFile -Encoding UTF8 + + & (Join-Path $repo "install-opencode.ps1") + + $config = Get-Content -LiteralPath $configFile -Raw -Encoding UTF8 | ConvertFrom-Json + $skills = @("caveman", "caveman-commit", "caveman-review", "caveman-help", "caveman-compress") + + Assert-True (@($config.instructions).Count -eq 2) "installer should preserve and append instructions" + Assert-True (@($config.instructions) -contains "keep.md") "existing instruction should remain" + Assert-True (@($config.instructions) -contains $agentsFile) "Caveman instruction should be added" + Assert-True ($config.custom -eq "literal,}") "comma before a brace inside a string should remain" + Assert-True ($config.nested.value -eq "literal,]") "comma before a bracket inside a string should remain" + Assert-True ($config.permission.skill.existing -eq "allow") "existing skill permission should remain" + foreach ($skill in $skills) { + Assert-True ($config.permission.skill.$skill -eq "allow") "$skill should be allowed" + } + Assert-True ((Get-Content -LiteralPath $agentsFile -Raw -Encoding UTF8) -match "(?m)^Default mode: ultra\.") "default intensity should be applied" + + & (Join-Path $repo "uninstall-opencode.ps1") + + $config = Get-Content -LiteralPath $configFile -Raw -Encoding UTF8 | ConvertFrom-Json + Assert-True (@($config.instructions).Count -eq 1) "uninstaller should remove only the Caveman instruction" + Assert-True (@($config.instructions) -contains "keep.md") "existing instruction should survive uninstall" + Assert-True ($config.custom -eq "literal,}") "custom string should survive uninstall" + Assert-True ($config.nested.value -eq "literal,]") "nested string should survive uninstall" + Assert-True ($config.permission.skill.existing -eq "allow") "existing permission should survive uninstall" + foreach ($skill in $skills) { + Assert-True ($config.permission.skill.PSObject.Properties.Name -notcontains $skill) "$skill permission should be removed" + } + Assert-True (-not (Test-Path -LiteralPath $agentsFile)) "Caveman instruction file should be removed" + + Write-Host "PowerShell installer tests passed." +} finally { + $env:USERPROFILE = $originalUserProfile + if ($null -eq $originalDefaultLevel) { + Remove-Item Env:CAVEMAN_OPENCODE_DEFAULT_LEVEL -ErrorAction SilentlyContinue + } else { + $env:CAVEMAN_OPENCODE_DEFAULT_LEVEL = $originalDefaultLevel + } + if (Test-Path -LiteralPath $testRoot) { + Remove-Item -Recurse -Force -LiteralPath $testRoot + } +} diff --git a/scripts/test-installers.sh b/scripts/test-installers.sh new file mode 100755 index 0000000..f72ad6b --- /dev/null +++ b/scripts/test-installers.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env sh +set -eu + +repo=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +test_root=$(mktemp -d "${TMPDIR:-/tmp}/caveman-opencode-test.XXXXXX") +config_home="$test_root/config" +config_dir="$config_home/opencode" +config_file="$config_dir/opencode.json" +agents_file="$config_dir/AGENTS.caveman.md" + +cleanup() { + rm -rf "$test_root" +} +trap cleanup EXIT + +mkdir -p "$config_dir" + +node - "$config_file" <<'NODE' +const fs = require("fs") + +const [configFile] = process.argv.slice(2) +fs.writeFileSync( + configFile, + `{ + "instructions": [ + "keep.md", + ], + "custom": "literal,}", + "nested": { + "value": "literal,]", + }, + "permission": { + "skill": { + "existing": "allow", + }, + }, +} +`, +) +NODE + +CAVEMAN_OPENCODE_DEFAULT_LEVEL=ultra XDG_CONFIG_HOME="$config_home" sh "$repo/install-opencode.sh" + +node - "$config_file" "$agents_file" <<'NODE' +const assert = require("assert").strict +const fs = require("fs") + +const [configFile, agentsFile] = process.argv.slice(2) +const config = JSON.parse(fs.readFileSync(configFile, "utf8")) +const skills = ["caveman", "caveman-commit", "caveman-review", "caveman-help", "caveman-compress"] + +assert.deepEqual(config.instructions, ["keep.md", agentsFile]) +assert.equal(config.custom, "literal,}") +assert.equal(config.nested.value, "literal,]") +assert.equal(config.permission.skill.existing, "allow") +for (const skill of skills) { + assert.equal(config.permission.skill[skill], "allow") +} +assert.match(fs.readFileSync(agentsFile, "utf8"), /^Default mode: ultra\./m) +NODE + +XDG_CONFIG_HOME="$config_home" sh "$repo/uninstall-opencode.sh" + +node - "$config_file" "$agents_file" <<'NODE' +const assert = require("assert").strict +const fs = require("fs") + +const [configFile, agentsFile] = process.argv.slice(2) +const config = JSON.parse(fs.readFileSync(configFile, "utf8")) +const skills = ["caveman", "caveman-commit", "caveman-review", "caveman-help", "caveman-compress"] + +assert.deepEqual(config.instructions, ["keep.md"]) +assert.equal(config.custom, "literal,}") +assert.equal(config.nested.value, "literal,]") +assert.equal(config.permission.skill.existing, "allow") +for (const skill of skills) { + assert.equal(config.permission.skill[skill], undefined) +} +assert.equal(fs.existsSync(agentsFile), false) +console.log("POSIX installer tests passed.") +NODE diff --git a/uninstall-opencode.ps1 b/uninstall-opencode.ps1 index 5ce6fdb..acff446 100644 --- a/uninstall-opencode.ps1 +++ b/uninstall-opencode.ps1 @@ -14,6 +14,60 @@ $skills = @( "caveman-compress" ) +function Remove-JsonTrailingCommas([string]$Content) { + $result = [System.Text.StringBuilder]::new($Content.Length) + $inString = $false + $escaped = $false + + for ($index = 0; $index -lt $Content.Length; $index++) { + $character = $Content[$index] + + if ($inString) { + [void]$result.Append($character) + if ($escaped) { + $escaped = $false + } elseif ($character -eq '\') { + $escaped = $true + } elseif ($character -eq '"') { + $inString = $false + } + continue + } + + if ($character -eq '"') { + $inString = $true + [void]$result.Append($character) + continue + } + + if ($character -eq ',') { + $next = $index + 1 + while ($next -lt $Content.Length -and [char]::IsWhiteSpace($Content[$next])) { + $next++ + } + if ($next -lt $Content.Length -and ($Content[$next] -eq '}' -or $Content[$next] -eq ']')) { + continue + } + } + + [void]$result.Append($character) + } + + return $result.ToString() +} + +function ConvertFrom-OpenCodeJson([string]$Content) { + try { + return $Content | ConvertFrom-Json + } catch { + $normalized = Remove-JsonTrailingCommas $Content + if ($normalized -eq $Content) { + throw + } + return $normalized | ConvertFrom-Json + } +} + foreach ($skill in $skills) { $skillPath = Join-Path $skillsDir $skill if (Test-Path -LiteralPath $skillPath) { @@ -34,11 +88,7 @@ if (Test-Path -LiteralPath $agentsFile) { if (Test-Path -LiteralPath $configFile) { $content = Get-Content -LiteralPath $configFile -Raw -Encoding UTF8 - try { - $config = $content | ConvertFrom-Json - } catch { - $config = ($content -replace ',\s*([\]}])', '$1') | ConvertFrom-Json - } + $config = ConvertFrom-OpenCodeJson $content if ($config.PSObject.Properties.Name -contains "instructions") { $config.instructions = @(@($config.instructions) | Where-Object { $_ -ne $agentsFile }) } diff --git a/uninstall-opencode.sh b/uninstall-opencode.sh index 262eeb7..97e7f29 100755 --- a/uninstall-opencode.sh +++ b/uninstall-opencode.sh @@ -24,14 +24,64 @@ const fs = require("fs") const [configFile, agentsFile] = process.argv.slice(2) const skills = ["caveman", "caveman-commit", "caveman-review", "caveman-help", "caveman-compress"] -let config = {} -const content = fs.readFileSync(configFile, "utf8") -try { - config = JSON.parse(content) -} catch (e) { - config = JSON.parse(content.replace(/,\s*([\]}])/g, "$1")) + +function stripTrailingCommas(content) { + let result = "" + let inString = false + let escaped = false + + for (let index = 0; index < content.length; index += 1) { + const character = content[index] + + if (inString) { + result += character + if (escaped) { + escaped = false + } else if (character === "\\") { + escaped = true + } else if (character === '"') { + inString = false + } + continue + } + + if (character === '"') { + inString = true + result += character + continue + } + + if (character === ",") { + let next = index + 1 + while (next < content.length && /\s/.test(content[next])) { + next += 1 + } + if (content[next] === "}" || content[next] === "]") { + continue + } + } + + result += character + } + + return result } +function parseConfig(content) { + try { + return JSON.parse(content) + } catch (error) { + const normalized = stripTrailingCommas(content) + if (normalized === content) { + throw error + } + return JSON.parse(normalized) + } +} + +const content = fs.readFileSync(configFile, "utf8") +const config = parseConfig(content) + if (Array.isArray(config.instructions)) { config.instructions = config.instructions.filter((item) => item !== agentsFile) }