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
17 changes: 17 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,20 @@ jobs:

- name: Test
run: go test ./...

# OpenCode integration tests - runs on main merges and PRs
integration-opencode:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-go@v5
with:
go-version: '1.24'
cache: true

- name: Build binary for integration tests
run: make build

- name: Run OpenCode integration tests
run: go test -v ./tests/integration/... -run "TestOpenCode"
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **OpenCode Support**: Full integration with OpenCode AI assistant
- Bootstrap creates `opencode.json` at project root with MCP server configuration
- Skills directory `.opencode/skills/` with TaskWing slash commands (tw-next, tw-done, tw-brief, etc.)
- Plugin hooks `.opencode/plugins/taskwing-hooks.js` for autonomous task execution using Bun's ctx.$ API
- Doctor health checks validate OpenCode configuration (MCP, skills, plugins)
- Integration tests and CI job for OpenCode-specific validation
- Documentation in TUTORIAL.md with opencode.json example, skill structure, and plugin format
- **Workspace-Aware Knowledge Scoping**: Full monorepo support for knowledge management
- New `tw workspaces` command to list detected workspaces in a monorepo
- `--workspace` and `--all` flags for `tw list` and `tw context` commands
Expand Down
2 changes: 0 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -332,8 +332,6 @@ taskwing hook session-end # Cleanup session (SessionEnd hook)
taskwing hook status # View current session state
```

**Note**: `session-init` auto-injects the project knowledge brief (same as `/tw-brief`) at session start.

**Circuit breakers** prevent runaway execution:
- `--max-tasks=5` - Stop after N tasks for human review
- `--max-minutes=30` - Stop after N minutes
Expand Down
10 changes: 10 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,15 @@ test-mcp: build
test-mcp-functional: test-mcp
@echo "✅ MCP functional tests complete (using test-mcp)"

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


# Generate test coverage
.PHONY: coverage
Expand Down Expand Up @@ -193,6 +202,7 @@ help:
@echo " test-unit - Run unit tests only"
@echo " test-integration - Run integration tests"
@echo " test-mcp - Run MCP protocol tests (JSON-RPC stdio)"
@echo " test-opencode - Run OpenCode integration tests"
@echo " test-quick - Run quick tests for development"
@echo " test-all - Run comprehensive test suite"
@echo ""
Expand Down
8 changes: 7 additions & 1 deletion cmd/ai_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ type aiConfig struct {
}

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

var aiConfigs = map[string]aiConfig{
"claude": {
Expand Down Expand Up @@ -51,6 +51,12 @@ var aiConfigs = map[string]aiConfig{
commandsDir: ".codex/commands",
fileExt: ".md",
},
"opencode": {
name: "opencode",
displayName: "OpenCode",
commandsDir: ".opencode/skills",
fileExt: ".md",
},
}

// promptAISelection shows the AI selection UI.
Expand Down
6 changes: 6 additions & 0 deletions cmd/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -651,6 +651,12 @@ func installMCPServers(basePath string, selectedAIs []string) {
installLocalMCP(basePath, ".cursor", "mcp.json", binPath)
case "copilot":
installCopilot(binPath, basePath)
case "opencode":
// OpenCode: creates opencode.json at project root
// During development, use taskwing-local-dev-mcp for testing changes
if err := installOpenCode(binPath, basePath); err != nil {
fmt.Fprintf(os.Stderr, "⚠️ OpenCode MCP installation failed: %v\n", err)
}
}
}
}
Expand Down
129 changes: 129 additions & 0 deletions cmd/bootstrap_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
/*
Copyright © 2025 Joseph Goksu josephgoksu@gmail.com
*/
package cmd

import (
"encoding/json"
"os"
"path/filepath"
"testing"
)

// TestInstallMCPServers_OpenCode tests that installMCPServers correctly installs OpenCode MCP config.
func TestInstallMCPServers_OpenCode(t *testing.T) {
tmpDir := t.TempDir()

// Mock binPath - in tests we can use any path
binPath := "/usr/local/bin/taskwing"

// Call installMCPServers with opencode
installMCPServers(tmpDir, []string{"opencode"})

// Verify opencode.json was created
configPath := filepath.Join(tmpDir, "opencode.json")
content, err := os.ReadFile(configPath)
if err != nil {
t.Fatalf("Failed to read opencode.json: %v", err)
}

// Parse and verify structure
var config OpenCodeConfig
if err := json.Unmarshal(content, &config); err != nil {
t.Fatalf("Invalid JSON in opencode.json: %v", err)
}

// Verify schema
if config.Schema != "https://opencode.ai/config.json" {
t.Errorf("Schema = %q, want %q", config.Schema, "https://opencode.ai/config.json")
}

// Verify MCP section exists
if config.MCP == nil {
t.Fatal("MCP section is nil")
}

// Server name should include project directory name
expectedServerName := "taskwing-mcp-" + filepath.Base(tmpDir)
serverCfg, ok := config.MCP[expectedServerName]
if !ok {
// Check if any taskwing-mcp server exists
found := false
for name := range config.MCP {
if len(name) >= 12 && name[:12] == "taskwing-mcp" {
found = true
serverCfg = config.MCP[name]
break
}
}
if !found {
t.Fatalf("No taskwing-mcp server entry found in MCP section. Got: %v", config.MCP)
}
}

// Verify type is "local"
if serverCfg.Type != "local" {
t.Errorf("Type = %q, want %q", serverCfg.Type, "local")
}

// Verify command is array format
if len(serverCfg.Command) != 2 {
t.Fatalf("Command length = %d, want 2", len(serverCfg.Command))
}
// Command[0] will use the actual executable path, not our mock binPath
// Just verify the second element is "mcp"
if serverCfg.Command[1] != "mcp" {
t.Errorf("Command[1] = %q, want %q", serverCfg.Command[1], "mcp")
}

_ = binPath // suppress unused variable warning
}

// TestInstallMCPServers_AllIncludesOpenCode tests that "all" AIs doesn't break when including opencode.
func TestInstallMCPServers_AllIncludesOpenCode(t *testing.T) {
tmpDir := t.TempDir()

// Install multiple AIs including opencode
installMCPServers(tmpDir, []string{"claude", "opencode"})

// Verify opencode.json was created
configPath := filepath.Join(tmpDir, "opencode.json")
if _, err := os.Stat(configPath); os.IsNotExist(err) {
t.Error("opencode.json was not created when installing multiple AIs including opencode")
}
}

// TestAIConfigOrder_IncludesOpenCode verifies opencode is in the AI selection list.
func TestAIConfigOrder_IncludesOpenCode(t *testing.T) {
found := false
for _, ai := range aiConfigOrder {
if ai == "opencode" {
found = true
break
}
}
if !found {
t.Error("opencode is not in aiConfigOrder")
}
}

// TestAIConfigs_OpenCodeEntry verifies opencode config entry exists with correct values.
func TestAIConfigs_OpenCodeEntry(t *testing.T) {
cfg, ok := aiConfigs["opencode"]
if !ok {
t.Fatal("opencode entry not found in aiConfigs")
}

if cfg.name != "opencode" {
t.Errorf("name = %q, want %q", cfg.name, "opencode")
}
if cfg.displayName != "OpenCode" {
t.Errorf("displayName = %q, want %q", cfg.displayName, "OpenCode")
}
if cfg.commandsDir != ".opencode/skills" {
t.Errorf("commandsDir = %q, want %q", cfg.commandsDir, ".opencode/skills")
}
if cfg.fileExt != ".md" {
t.Errorf("fileExt = %q, want %q", cfg.fileExt, ".md")
}
}
Loading