Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 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
8 changes: 4 additions & 4 deletions PRD.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ The installer supports configuring the Gentleman ecosystem into ANY AI coding ag
| Agent | Config Location | Ecosystem Support | Priority |
|-------|-----------------|-------------------|----------|
| Claude Code (Anthropic) | `~/.claude/` | Full: plugins, skills, MCP, CLAUDE.md, settings, hooks, theme, statusline | P0 |
| OpenCode | `~/.config/opencode/` | Full: plugins, skills, MCP, agents, commands, theme | P0 |
| OpenCode | `~/.config/opencode/` | Full: plugins, skills, MCP, agents, commands (no theme: opencode.json's strict schema rejects a top-level theme key, #497) | P0 |
| Gemini CLI (Google) | `~/.gemini/` | Partial: MCP, system instructions, skills via system.md | P1 |
| Codex (OpenAI) | `~/.codex/` | Partial: MCP, instructions, config.toml | P1 |
| Aider | `~/.aider/` or `.aider.conf.yml` | Partial: conventions via config, limited MCP | P2 |
Expand All @@ -231,7 +231,7 @@ The installer supports configuring the Gentleman ecosystem into ANY AI coding ag

| Tier | What Gets Configured | Agents |
|------|---------------------|--------|
| **Full** | Engram plugin + MCP servers + skills + SDD orchestrator + GGA integration + persona + theme + permissions + statusline + hooks | Claude Code, OpenCode |
| **Full** | Engram plugin + MCP servers + skills + SDD orchestrator + GGA integration + persona + theme (Claude only; OpenCode's strict schema rejects a top-level theme key, #497) + permissions + statusline + hooks | Claude Code, OpenCode |
| **Good** | Skills + MCP servers + SDD (inline mode, no sub-agents) + GGA as review provider + persona rules | Cursor, VSCode |
| **Partial** | Skills via system instructions + MCP where supported + GGA provider config + persona | Gemini CLI, Codex, Windsurf, JetBrains, Zed |
| **Minimal** | Persona and coding conventions via project/workspace rules | Xcode, Antigravity, any emerging agent |
Expand Down Expand Up @@ -827,7 +827,7 @@ graph TD
end

subgraph OC_CONFIG["OpenCode (~/.config/opencode/)"]
OC_JSON[opencode.json<br/>Agents, MCP servers,<br/>Engram plugin, theme]
OC_JSON[opencode.json<br/>Agents, MCP servers,<br/>Engram plugin<br/>no theme: strict schema rejects it]
OC_SKILLS_DIR[skill/<br/>SDD skills + coding skills]
OC_COMMANDS[commands/<br/>SDD slash commands]
OC_PLUGINS[plugins/<br/>engram.ts]
Expand Down Expand Up @@ -1244,7 +1244,7 @@ When the installer completes with "Dev Stack + Polish" (`full-gentleman`) preset
- `~/.claude.json` β€” Context7 MCP server configured

**OpenCode:**
- `~/.config/opencode/opencode.json` β€” Agents (gentleman, sdd-orchestrator), MCP servers (engram, context7), Engram plugin, Gentleman theme
- `~/.config/opencode/opencode.json` β€” Agents (gentleman, sdd-orchestrator), MCP servers (engram, context7), Engram plugin (OpenCode's strict schema rejects a top-level theme key, so no theme is written here)
- `~/.config/opencode/skills/` β€” All selected skills mirrored
- `~/.config/opencode/commands/` β€” SDD slash commands
- `~/.config/opencode/plugins/` β€” Engram TypeScript plugin
Expand Down
44 changes: 26 additions & 18 deletions e2e/e2e_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1024,15 +1024,16 @@ test_oc_permissions_injection() {
}

test_oc_theme_injection() {
log_test "OpenCode: theme injection"
log_test "OpenCode: theme injection skipped (opencode.json rejects top-level theme)"
cleanup_test_env

if $BINARY install --agent opencode --component theme --persona neutral 2>&1; then
local settings="$HOME/.config/opencode/opencode.json"
assert_file_exists "$settings" "OpenCode opencode.json"
assert_file_contains "$settings" '"theme"' "Has theme key"
assert_file_contains "$settings" 'gentleman-kanagawa' "Has gentleman-kanagawa theme"
assert_valid_json "$settings" "opencode.json is valid JSON"
# OpenCode's opencode.json schema is strict and rejects an unrecognized
# top-level "theme" key (ConfigInvalidError), so Gentle AI must not write
# it there. Theme injection is a no-op for OpenCode β€” see issue #497.
assert_file_not_contains "$settings" '"theme"' "opencode.json has no top-level theme key"
assert_file_not_contains "$settings" 'gentleman-kanagawa' "opencode.json has no gentleman-kanagawa theme"
else
log_fail "OpenCode theme install command failed"
fi
Expand Down Expand Up @@ -1093,7 +1094,9 @@ test_full_preset_opencode() {
# opencode.json should have all overlays merged
assert_file_exists "$settings" "OpenCode opencode.json"
assert_file_contains "$settings" '"permission"' "Has permission config"
assert_file_contains "$settings" '"theme"' "Has theme"
# Theme injection is a no-op for OpenCode: opencode.json's strict schema
# rejects a top-level "theme" key (#497), so it must never be merged here.
assert_file_not_contains "$settings" '"theme"' "opencode.json has no top-level theme key"
assert_file_contains "$settings" '"mcp"' "Has MCP servers"
assert_file_contains "$settings" '"context7"' "Has context7 MCP"
assert_valid_json "$settings" "opencode.json is valid JSON"
Expand Down Expand Up @@ -1471,21 +1474,24 @@ test_idempotent_skills_claude() {
}

test_idempotent_theme_opencode() {
log_test "Idempotency: theme on OpenCode (run twice, same result)"
log_test "Idempotency: theme on OpenCode is a stable no-op"
cleanup_test_env

$BINARY install --agent opencode --component theme --persona neutral 2>&1 || true
local first_hash
first_hash=$(md5sum "$HOME/.config/opencode/opencode.json" 2>/dev/null | cut -d' ' -f1)

$BINARY install --agent opencode --component theme --persona neutral 2>&1 || true
local second_hash
second_hash=$(md5sum "$HOME/.config/opencode/opencode.json" 2>/dev/null | cut -d' ' -f1)
local settings="$HOME/.config/opencode/opencode.json"
# Theme injection is a no-op for OpenCode (opencode.json's strict schema
# rejects a top-level "theme" key β€” #497). Both runs must SUCCEED (exit 0 β€”
# the regression guard: a no-op must not fail post-apply verification) and
# leave the file without a theme key.
if $BINARY install --agent opencode --component theme --persona neutral 2>&1; then
assert_file_not_contains "$settings" '"theme"' "No theme key after first run"
else
log_fail "First OpenCode theme install exited non-zero (should be a clean no-op)"
fi

if [ "$first_hash" = "$second_hash" ] && [ -n "$first_hash" ]; then
log_pass "Idempotent: same theme config after two runs"
if $BINARY install --agent opencode --component theme --persona neutral 2>&1; then
assert_file_not_contains "$settings" '"theme"' "No theme key after second run"
else
log_fail "Theme config changed between runs ($first_hash vs $second_hash)"
log_fail "Second OpenCode theme install exited non-zero (should be a clean no-op)"
fi
}

Expand Down Expand Up @@ -1629,7 +1635,9 @@ test_edge_multiple_json_overlays() {

local settings="$HOME/.config/opencode/opencode.json"
assert_file_contains "$settings" '"permission"' "Permission config present after 3 merges"
assert_file_contains "$settings" '"theme"' "Theme present after 3 merges"
# Theme is a no-op for OpenCode (#497): the theme install must not add a
# top-level "theme" key while the permission/context7 overlays still merge.
assert_file_not_contains "$settings" '"theme"' "Theme not merged into OpenCode (no-op)"
assert_file_contains "$settings" '"mcp"' "MCP servers present after 3 merges"
assert_file_contains "$settings" '"context7"' "Context7 present after 3 merges"
assert_valid_json "$settings" "Final merged JSON is valid"
Expand Down
25 changes: 25 additions & 0 deletions internal/agents/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,28 @@ type Adapter interface {
type EffectiveCodeGraphWiringDetector interface {
EffectiveCodeGraphWiring(homeDir string) (path string, configured bool)
}

// ThemeInjectionController is an optional adapter capability. Agents whose
// settings schema rejects a top-level "theme" key implement it and return
// false to opt out of theme injection into their settings file. Adapters that
// do not implement it are treated as supporting theme injection.
type ThemeInjectionController interface {
SupportsThemeInjection() bool
}

// ThemeSettingsMigrator is an optional adapter capability for repairing legacy
// theme settings before current injection behavior is applied.
type ThemeSettingsMigrator interface {
MigrateThemeSettings(homeDir string) (path string, changed bool, err error)
}

// SupportsThemeInjection reports whether theme injection into the adapter's
// settings file is permitted. An adapter opts out by implementing
// ThemeInjectionController and returning false; adapters that do not implement
// it support theme injection by default. This is the single source of truth for
// the opt-out so that every consumer (injection and post-apply verification)
// agrees on which adapters receive a theme.
func SupportsThemeInjection(adapter Adapter) bool {
controller, ok := adapter.(ThemeInjectionController)
return !ok || controller.SupportsThemeInjection()
}
37 changes: 37 additions & 0 deletions internal/agents/opencode/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package opencode

import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
Expand Down Expand Up @@ -158,6 +159,42 @@ func isEffectiveCodeGraphEntry(value any) bool {

// --- Optional capabilities ---

// SupportsThemeInjection reports whether Gentle AI may write a top-level "theme"
// key into opencode.json. OpenCode's opencode.json schema is strict and rejects
// an unrecognized top-level "theme" key (ConfigInvalidError), which breaks
// runtime config updates such as profile activation, so theme injection is
// skipped for OpenCode.
func (a *Adapter) SupportsThemeInjection() bool {
return false
}

func (a *Adapter) MigrateThemeSettings(homeDir string) (string, bool, error) {
settingsPath := a.SettingsPath(homeDir)
info, err := os.Stat(settingsPath)
if err != nil {
if os.IsNotExist(err) {
return "", false, nil
}
return "", false, fmt.Errorf("stat OpenCode settings %q: %w", settingsPath, err)
}
raw, err := os.ReadFile(settingsPath)
if err != nil {
return "", false, fmt.Errorf("read OpenCode settings %q: %w", settingsPath, err)
}
updated, changed, err := filemerge.RemoveTopLevelJSONKey(raw, "theme")
if err != nil {
return "", false, fmt.Errorf("migrate OpenCode settings %q: %w", settingsPath, err)
}
if !changed {
return settingsPath, false, nil
}
result, err := filemerge.WriteFileAtomic(settingsPath, updated, info.Mode().Perm())
if err != nil {
return "", false, fmt.Errorf("write migrated OpenCode settings %q: %w", settingsPath, err)
}
return settingsPath, result.Changed, nil
}

func (a *Adapter) SupportsOutputStyles() bool {
return false
}
Expand Down
6 changes: 6 additions & 0 deletions internal/agents/opencode/adapter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,12 @@ func TestEffectiveCodeGraphWiring(t *testing.T) {
}
}

func TestSupportsThemeInjection(t *testing.T) {
if NewAdapter().SupportsThemeInjection() {
t.Fatalf("SupportsThemeInjection() = true, want false; opencode.json schema rejects a top-level theme key")
}
}

func TestConfigPathIgnoresRelativeXDGConfigHome(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
Expand Down
6 changes: 5 additions & 1 deletion internal/cli/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -1561,8 +1561,12 @@ func componentPathsWithWorkspaceScoped(homeDir, workspaceDir string, scope Insta
paths = append(paths, gga.ConfigPath(homeDir))
paths = append(paths, gga.AgentsTemplatePath(homeDir))
case model.ComponentTheme:
// Opted-out adapters may still migrate an existing settings file. Include
// it in backup and verification, but never require or create a missing file.
if p := adapter.SettingsPath(homeDir); p != "" {
paths = append(paths, p)
if _, err := os.Stat(p); agents.SupportsThemeInjection(adapter) || err == nil {
paths = append(paths, p)
}
}
case model.ComponentClaudeTheme:
if adapter.Agent() == model.AgentClaudeCode {
Expand Down
24 changes: 24 additions & 0 deletions internal/cli/run_component_paths_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cli

import (
"os"
"path/filepath"
"testing"

Expand Down Expand Up @@ -44,6 +45,29 @@ func TestComponentPathsSDDIncludesOpenCodeSettingsAndCommands(t *testing.T) {
}
}

func TestComponentPathsThemeOpenCodeTracksOnlyExistingSettings(t *testing.T) {
home := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", "")
adapters := resolveAdapters([]model.AgentID{model.AgentOpenCode})
settingsPath := filepath.Join(home, ".config", "opencode", "opencode.json")

paths := componentPaths(home, model.Selection{}, adapters, model.ComponentTheme)
if containsPath(paths, settingsPath) {
t.Fatalf("componentPaths(theme) includes missing OpenCode settings %q", settingsPath)
}
if err := os.MkdirAll(filepath.Dir(settingsPath), 0o755); err != nil {
t.Fatalf("MkdirAll(settings dir) error = %v", err)
}
if err := os.WriteFile(settingsPath, []byte("{}\n"), 0o600); err != nil {
t.Fatalf("WriteFile(settings) error = %v", err)
}

paths = componentPaths(home, model.Selection{}, adapters, model.ComponentTheme)
if !containsPath(paths, settingsPath) {
t.Fatalf("componentPaths(theme) missing existing OpenCode settings %q", settingsPath)
}
}

func TestComponentPathsSDDIncludesClaudeLazyWorkflow(t *testing.T) {
home := t.TempDir()
adapters := resolveAdapters([]model.AgentID{model.AgentClaudeCode})
Expand Down
135 changes: 135 additions & 0 deletions internal/components/filemerge/json_remove.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
package filemerge

import (
"bytes"
"fmt"
"strconv"
)

// RemoveTopLevelJSONKey removes root keys without rewriting unrelated bytes.
func RemoveTopLevelJSONKey(raw []byte, key string) ([]byte, bool, error) {
if _, err := UnmarshalJSONObject(raw); err != nil {
return nil, false, fmt.Errorf("unmarshal json object: %w", err)
}
i := skipJSONTrivia(raw, 0)
if i >= len(raw) || raw[i] != '{' {
return nil, false, fmt.Errorf("json root is not an object")
}
updated := raw
changed := false
for {
next, removed := removeTopLevelJSONKeyOnce(updated, []byte(strconv.Quote(key)))
if !removed {
return updated, changed, nil
}
updated = next
changed = true
}
}

func removeTopLevelJSONKeyOnce(raw, quotedKey []byte) ([]byte, bool) {
i := skipJSONTrivia(raw, 0) + 1
previousComma := -1
for {
memberStart := i
i = skipJSONTrivia(raw, i)
if i >= len(raw) || raw[i] == '}' {
return raw, false
}
keyStart := i
keyEnd := scanJSONString(raw, keyStart)
i = skipJSONTrivia(raw, keyEnd) + 1 // validated input guarantees the colon
delimiter := scanJSONValueDelimiter(raw, skipJSONTrivia(raw, i))
if bytes.Equal(raw[keyStart:keyEnd], quotedKey) {
removeStart, removeEnd := memberStart, delimiter
if raw[delimiter] == ',' {
removeEnd++
} else if previousComma >= 0 {
removeStart = previousComma
}
updated := make([]byte, 0, len(raw)-(removeEnd-removeStart))
updated = append(updated, raw[:removeStart]...)
updated = append(updated, raw[removeEnd:]...)
return updated, true
}
if raw[delimiter] == '}' {
return raw, false
}
previousComma, i = delimiter, delimiter+1
}
}
func scanJSONString(raw []byte, start int) int {
escaped := false
for i := start + 1; i < len(raw); i++ {
if escaped {
escaped = false
} else if raw[i] == '\\' {
escaped = true
} else if raw[i] == '"' {
return i + 1
}
}
return len(raw)
}

func scanJSONValueDelimiter(raw []byte, start int) int {
depth, inString, escaped := 0, false, false
for i := start; i < len(raw); i++ {
if inString {
if escaped {
escaped = false
} else if raw[i] == '\\' {
escaped = true
} else if raw[i] == '"' {
inString = false
}
continue
}
if raw[i] == '"' {
inString = true
} else if raw[i] == '/' {
i = skipJSONComment(raw, i) - 1
} else if raw[i] == '{' || raw[i] == '[' {
depth++
} else if raw[i] == ']' || (raw[i] == '}' && depth > 0) {
depth--
} else if depth == 0 && (raw[i] == ',' || raw[i] == '}') {
return i
}
}
return len(raw) - 1
}

func skipJSONTrivia(raw []byte, start int) int {
for start < len(raw) {
if bytes.ContainsRune([]byte(" \t\r\n"), rune(raw[start])) {
start++
} else if raw[start] == '/' {
start = skipJSONComment(raw, start)
} else {
break
}
}
return start
}

func skipJSONComment(raw []byte, start int) int {
if start+1 >= len(raw) || raw[start] != '/' {
return start
}
if raw[start+1] == '/' {
start += 2
for start < len(raw) && raw[start] != '\n' {
start++
}
return start
}
if raw[start+1] == '*' {
start += 2
for start+1 < len(raw) && !(raw[start] == '*' && raw[start+1] == '/') {
start++
}
return start + 2
}
return start
}
Loading
Loading