Skip to content
Merged
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
30 changes: 28 additions & 2 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,45 @@ on:
push:
pull_request:

permissions:
contents: read

jobs:
package:
name: Package validation
runs-on: ubuntu-latest

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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
57 changes: 56 additions & 1 deletion install-opencode.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down Expand Up @@ -81,7 +135,8 @@ $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
$config = ConvertFrom-OpenCodeJson $content
} else {
$config = [pscustomobject]@{}
}
Expand Down
57 changes: 56 additions & 1 deletion install-opencode.sh
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,63 @@ const [configFile, agentsFile] = process.argv.slice(2)
const skills = ["caveman", "caveman-commit", "caveman-review", "caveman-help", "caveman-compress"]
let config = {}

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)
}
}

if (fs.existsSync(configFile)) {
config = JSON.parse(fs.readFileSync(configFile, "utf8"))
const content = fs.readFileSync(configFile, "utf8")
config = parseConfig(content)
}

config.instructions = Array.isArray(config.instructions) ? config.instructions : []
Expand Down
80 changes: 80 additions & 0 deletions scripts/test-installers.ps1
Original file line number Diff line number Diff line change
@@ -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
}
}
81 changes: 81 additions & 0 deletions scripts/test-installers.sh
Original file line number Diff line number Diff line change
@@ -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
Loading