Skip to content

Commit 28dd676

Browse files
authored
Merge pull request #12 from josephgoksu/feat/opencode-support
Feat/opencode support
2 parents 778223d + 4f16c45 commit 28dd676

16 files changed

Lines changed: 2639 additions & 17 deletions

File tree

.github/workflows/ci.yml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,20 @@ jobs:
4646

4747
- name: Test
4848
run: go test ./...
49+
50+
# OpenCode integration tests - runs on main merges and PRs
51+
integration-opencode:
52+
runs-on: ubuntu-latest
53+
steps:
54+
- uses: actions/checkout@v4
55+
56+
- uses: actions/setup-go@v5
57+
with:
58+
go-version: '1.24'
59+
cache: true
60+
61+
- name: Build binary for integration tests
62+
run: make build
63+
64+
- name: Run OpenCode integration tests
65+
run: go test -v ./tests/integration/... -run "TestOpenCode"

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- **OpenCode Support**: Full integration with OpenCode AI assistant
13+
- Bootstrap creates `opencode.json` at project root with MCP server configuration
14+
- Skills directory `.opencode/skills/` with TaskWing slash commands (tw-next, tw-done, tw-brief, etc.)
15+
- Plugin hooks `.opencode/plugins/taskwing-hooks.js` for autonomous task execution using Bun's ctx.$ API
16+
- Doctor health checks validate OpenCode configuration (MCP, skills, plugins)
17+
- Integration tests and CI job for OpenCode-specific validation
18+
- Documentation in TUTORIAL.md with opencode.json example, skill structure, and plugin format
1219
- **Workspace-Aware Knowledge Scoping**: Full monorepo support for knowledge management
1320
- New `tw workspaces` command to list detected workspaces in a monorepo
1421
- `--workspace` and `--all` flags for `tw list` and `tw context` commands

CLAUDE.md

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -332,8 +332,6 @@ taskwing hook session-end # Cleanup session (SessionEnd hook)
332332
taskwing hook status # View current session state
333333
```
334334

335-
**Note**: `session-init` auto-injects the project knowledge brief (same as `/tw-brief`) at session start.
336-
337335
**Circuit breakers** prevent runaway execution:
338336
- `--max-tasks=5` - Stop after N tasks for human review
339337
- `--max-minutes=30` - Stop after N minutes

Makefile

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,15 @@ test-mcp: build
7272
test-mcp-functional: test-mcp
7373
@echo "✅ MCP functional tests complete (using test-mcp)"
7474

75+
# Run OpenCode integration tests
76+
# IMPORTANT: Uses local binary (./bin/taskwing or make build), NOT system-installed taskwing
77+
.PHONY: test-opencode
78+
test-opencode: build
79+
@echo "🎯 Running OpenCode integration tests..."
80+
mkdir -p $(TEST_DIR)
81+
$(GO) test -v ./tests/integration/... -run "TestOpenCode" | tee $(TEST_DIR)/opencode-integration.log
82+
@echo "✅ OpenCode integration tests complete"
83+
7584

7685
# Generate test coverage
7786
.PHONY: coverage
@@ -193,6 +202,7 @@ help:
193202
@echo " test-unit - Run unit tests only"
194203
@echo " test-integration - Run integration tests"
195204
@echo " test-mcp - Run MCP protocol tests (JSON-RPC stdio)"
205+
@echo " test-opencode - Run OpenCode integration tests"
196206
@echo " test-quick - Run quick tests for development"
197207
@echo " test-all - Run comprehensive test suite"
198208
@echo ""

cmd/ai_config.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ type aiConfig struct {
1818
}
1919

2020
// Ordered list for consistent display
21-
var aiConfigOrder = []string{"claude", "cursor", "copilot", "gemini", "codex"}
21+
var aiConfigOrder = []string{"claude", "cursor", "copilot", "gemini", "codex", "opencode"}
2222

2323
var aiConfigs = map[string]aiConfig{
2424
"claude": {
@@ -51,6 +51,12 @@ var aiConfigs = map[string]aiConfig{
5151
commandsDir: ".codex/commands",
5252
fileExt: ".md",
5353
},
54+
"opencode": {
55+
name: "opencode",
56+
displayName: "OpenCode",
57+
commandsDir: ".opencode/skills",
58+
fileExt: ".md",
59+
},
5460
}
5561

5662
// promptAISelection shows the AI selection UI.

cmd/bootstrap.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -651,6 +651,12 @@ func installMCPServers(basePath string, selectedAIs []string) {
651651
installLocalMCP(basePath, ".cursor", "mcp.json", binPath)
652652
case "copilot":
653653
installCopilot(binPath, basePath)
654+
case "opencode":
655+
// OpenCode: creates opencode.json at project root
656+
// During development, use taskwing-local-dev-mcp for testing changes
657+
if err := installOpenCode(binPath, basePath); err != nil {
658+
fmt.Fprintf(os.Stderr, "⚠️ OpenCode MCP installation failed: %v\n", err)
659+
}
654660
}
655661
}
656662
}

cmd/bootstrap_test.go

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
/*
2+
Copyright © 2025 Joseph Goksu josephgoksu@gmail.com
3+
*/
4+
package cmd
5+
6+
import (
7+
"encoding/json"
8+
"os"
9+
"path/filepath"
10+
"testing"
11+
)
12+
13+
// TestInstallMCPServers_OpenCode tests that installMCPServers correctly installs OpenCode MCP config.
14+
func TestInstallMCPServers_OpenCode(t *testing.T) {
15+
tmpDir := t.TempDir()
16+
17+
// Mock binPath - in tests we can use any path
18+
binPath := "/usr/local/bin/taskwing"
19+
20+
// Call installMCPServers with opencode
21+
installMCPServers(tmpDir, []string{"opencode"})
22+
23+
// Verify opencode.json was created
24+
configPath := filepath.Join(tmpDir, "opencode.json")
25+
content, err := os.ReadFile(configPath)
26+
if err != nil {
27+
t.Fatalf("Failed to read opencode.json: %v", err)
28+
}
29+
30+
// Parse and verify structure
31+
var config OpenCodeConfig
32+
if err := json.Unmarshal(content, &config); err != nil {
33+
t.Fatalf("Invalid JSON in opencode.json: %v", err)
34+
}
35+
36+
// Verify schema
37+
if config.Schema != "https://opencode.ai/config.json" {
38+
t.Errorf("Schema = %q, want %q", config.Schema, "https://opencode.ai/config.json")
39+
}
40+
41+
// Verify MCP section exists
42+
if config.MCP == nil {
43+
t.Fatal("MCP section is nil")
44+
}
45+
46+
// Server name should include project directory name
47+
expectedServerName := "taskwing-mcp-" + filepath.Base(tmpDir)
48+
serverCfg, ok := config.MCP[expectedServerName]
49+
if !ok {
50+
// Check if any taskwing-mcp server exists
51+
found := false
52+
for name := range config.MCP {
53+
if len(name) >= 12 && name[:12] == "taskwing-mcp" {
54+
found = true
55+
serverCfg = config.MCP[name]
56+
break
57+
}
58+
}
59+
if !found {
60+
t.Fatalf("No taskwing-mcp server entry found in MCP section. Got: %v", config.MCP)
61+
}
62+
}
63+
64+
// Verify type is "local"
65+
if serverCfg.Type != "local" {
66+
t.Errorf("Type = %q, want %q", serverCfg.Type, "local")
67+
}
68+
69+
// Verify command is array format
70+
if len(serverCfg.Command) != 2 {
71+
t.Fatalf("Command length = %d, want 2", len(serverCfg.Command))
72+
}
73+
// Command[0] will use the actual executable path, not our mock binPath
74+
// Just verify the second element is "mcp"
75+
if serverCfg.Command[1] != "mcp" {
76+
t.Errorf("Command[1] = %q, want %q", serverCfg.Command[1], "mcp")
77+
}
78+
79+
_ = binPath // suppress unused variable warning
80+
}
81+
82+
// TestInstallMCPServers_AllIncludesOpenCode tests that "all" AIs doesn't break when including opencode.
83+
func TestInstallMCPServers_AllIncludesOpenCode(t *testing.T) {
84+
tmpDir := t.TempDir()
85+
86+
// Install multiple AIs including opencode
87+
installMCPServers(tmpDir, []string{"claude", "opencode"})
88+
89+
// Verify opencode.json was created
90+
configPath := filepath.Join(tmpDir, "opencode.json")
91+
if _, err := os.Stat(configPath); os.IsNotExist(err) {
92+
t.Error("opencode.json was not created when installing multiple AIs including opencode")
93+
}
94+
}
95+
96+
// TestAIConfigOrder_IncludesOpenCode verifies opencode is in the AI selection list.
97+
func TestAIConfigOrder_IncludesOpenCode(t *testing.T) {
98+
found := false
99+
for _, ai := range aiConfigOrder {
100+
if ai == "opencode" {
101+
found = true
102+
break
103+
}
104+
}
105+
if !found {
106+
t.Error("opencode is not in aiConfigOrder")
107+
}
108+
}
109+
110+
// TestAIConfigs_OpenCodeEntry verifies opencode config entry exists with correct values.
111+
func TestAIConfigs_OpenCodeEntry(t *testing.T) {
112+
cfg, ok := aiConfigs["opencode"]
113+
if !ok {
114+
t.Fatal("opencode entry not found in aiConfigs")
115+
}
116+
117+
if cfg.name != "opencode" {
118+
t.Errorf("name = %q, want %q", cfg.name, "opencode")
119+
}
120+
if cfg.displayName != "OpenCode" {
121+
t.Errorf("displayName = %q, want %q", cfg.displayName, "OpenCode")
122+
}
123+
if cfg.commandsDir != ".opencode/skills" {
124+
t.Errorf("commandsDir = %q, want %q", cfg.commandsDir, ".opencode/skills")
125+
}
126+
if cfg.fileExt != ".md" {
127+
t.Errorf("fileExt = %q, want %q", cfg.fileExt, ".md")
128+
}
129+
}

0 commit comments

Comments
 (0)