diff --git a/apps/agent/internal/devcontainer/README.md b/apps/agent/internal/devcontainer/README.md new file mode 100644 index 0000000..131157f --- /dev/null +++ b/apps/agent/internal/devcontainer/README.md @@ -0,0 +1,152 @@ +# DevContainer Package + +This package provides image selection logic for the Dev8 agent, enabling automatic selection of appropriate Microsoft DevContainer base images with Dev8 custom features. + +## Architecture + +The package implements a strategy for migrating from custom-built Docker images to official Microsoft DevContainer images: + +``` +Previous Architecture: +┌─────────────────────────────┐ +│ dev8-ai-tools:latest │ ← Custom 4-layer build +│ (5-10 min build time) │ +└─────────────────────────────┘ + +New Architecture: +┌─────────────────────────────┐ +│ mcr.microsoft.com/ │ ← Pre-built by Microsoft +│ devcontainers/python:1 │ (0 build time) +├─────────────────────────────┤ +│ + Dev8 Features (installed │ +│ at container start): │ +│ - supervisor │ +│ - claude-cli │ +│ - ai-tools │ +└─────────────────────────────┘ +``` + +## Components + +### ImageSelector + +Analyzes repository metadata and selects the appropriate DevContainer base image: + +```go +selector := devcontainer.NewImageSelector() + +metadata := devcontainer.RepoMetadata{ + Files: []string{"package.json", "tsconfig.json"}, + PackageFiles: map[string]string{ + "package.json": "{}", + }, +} + +spec, err := selector.SelectImage(ctx, metadata) +// spec.BaseImage = "mcr.microsoft.com/devcontainers/typescript-node:1-20-bullseye" +// spec.Features = [supervisor, claude-cli, ai-tools] +``` + +### Supported Languages + +The selector automatically detects and selects images for: + +- **TypeScript/JavaScript**: `typescript-node` image +- **Python**: `python` image with Python 3.11 +- **Go**: `go` image with Go 1.22 +- **Rust**: `rust` image +- **C/C++**: `cpp` image +- **Java**: `java` image with Java 17 +- **PHP**: `php` image with PHP 8.2 +- **Universal**: Multi-language image for polyglot projects +- **Base**: Minimal Debian image for other languages + +## Language Detection + +Language detection follows this priority: + +1. **Explicit language data** (from GitHub API or similar) +2. **Package manager files**: + - `package.json` + `tsconfig.json` → TypeScript + - `package.json` → JavaScript + - `requirements.txt` or `pyproject.toml` → Python + - `go.mod` → Go + - `Cargo.toml` → Rust +3. **Fallback**: Universal image + +## Dev8 Features + +All workspaces include three core features: + +### 1. Supervisor + +- Monitors workspace activity +- Performs automated backups +- Reports health to agent +- Configurable via environment variables + +### 2. Claude CLI + +- Command-line interface for Anthropic's Claude +- Supports all Claude 3 models +- Shell completion included + +### 3. AI Tools Bundle + +- GitHub CLI with Copilot extension +- Azure CLI for infrastructure +- GPT and Gemini CLI wrappers +- tmux with custom configuration +- Productivity shell aliases + +## Integration with Agent + +The agent uses this package when creating new workspaces: + +```go +// In environment service +selector := devcontainer.NewImageSelector() + +// Analyze repository +metadata := analyzeRepository(repoURL) + +// Select image +spec, err := selector.SelectImage(ctx, metadata) + +// Deploy with selected image +deploymentSpec := services.ContainerDeploymentSpec{ + Image: spec.GetImageString(), + // ... other config +} +``` + +## Benefits + +1. **Zero Build Time**: Microsoft images are pre-built and cached +2. **Regular Updates**: Microsoft maintains and updates base images +3. **Standardization**: Consistent base across all workspaces +4. **Flexibility**: Features can be added/removed without rebuilds +5. **Compatibility**: Works with standard DevContainer tools + +## Future Enhancements + +- [ ] Support for custom user-defined features +- [ ] Automatic detection of framework-specific needs (Django, React, etc.) +- [ ] Integration with existing `.devcontainer/devcontainer.json` +- [ ] Feature version pinning per workspace +- [ ] Dynamic feature installation based on workspace activity + +## Testing + +Run tests with: + +```bash +cd apps/agent +go test ./internal/devcontainer/... +``` + +## Related Documentation + +- [DevContainer Features](../../../packages/devcontainer-features/) +- [Microsoft DevContainer Images](https://github.com/devcontainers/images) +- [DevContainer Specification](https://containers.dev/) diff --git a/apps/agent/internal/devcontainer/selector.go b/apps/agent/internal/devcontainer/selector.go new file mode 100644 index 0000000..82dc5a3 --- /dev/null +++ b/apps/agent/internal/devcontainer/selector.go @@ -0,0 +1,258 @@ +package devcontainer + +import ( + "context" + "encoding/json" + "fmt" + "strings" +) + +// ImageSelector selects the appropriate DevContainer base image based on repository analysis +type ImageSelector struct { + // FeatureRegistry contains the GitHub Container Registry path for Dev8 features + FeatureRegistry string +} + +// NewImageSelector creates a new image selector +func NewImageSelector() *ImageSelector { + return &ImageSelector{ + FeatureRegistry: "ghcr.io/dev8-community/devcontainer-features", + } +} + +// ImageSpec contains the selected image and features +type ImageSpec struct { + // BaseImage is the Microsoft DevContainer image to use + BaseImage string + // Features are the Dev8 features to add + Features []Feature + // Language is the detected primary language + Language string +} + +// Feature represents a DevContainer feature +type Feature struct { + // ID is the feature identifier (e.g., "supervisor", "claude-cli") + ID string + // Version is the feature version (e.g., "1", "latest") + Version string + // Options contains feature-specific configuration + Options map[string]interface{} +} + +// SelectImage analyzes repository metadata and selects the appropriate DevContainer image +func (s *ImageSelector) SelectImage(ctx context.Context, repoMetadata RepoMetadata) (*ImageSpec, error) { + // Detect primary language from repository + language := detectLanguage(repoMetadata) + + // Select base image based on language + baseImage := s.selectBaseImage(language) + + // Build feature list (always include Dev8 core features) + features := s.buildFeatureList(language) + + return &ImageSpec{ + BaseImage: baseImage, + Features: features, + Language: language, + }, nil +} + +// RepoMetadata contains repository analysis data +type RepoMetadata struct { + // Files is a list of files in the repository root + Files []string + // PackageFiles contains package manager files (package.json, requirements.txt, etc.) + PackageFiles map[string]string + // Languages contains detected languages with their byte counts + Languages map[string]int64 + // HasDevContainer indicates if a .devcontainer configuration already exists + HasDevContainer bool + // DevContainerConfig contains existing devcontainer.json if present + DevContainerConfig map[string]interface{} +} + +// detectLanguage determines the primary language from repository metadata +func detectLanguage(metadata RepoMetadata) string { + // If explicit language data is provided, use it + if len(metadata.Languages) > 0 { + var maxBytes int64 + var primaryLang string + for lang, bytes := range metadata.Languages { + if bytes > maxBytes { + maxBytes = bytes + primaryLang = lang + } + } + return normalizeLanguage(primaryLang) + } + + // Fallback: detect from package files + if _, hasPackageJSON := metadata.PackageFiles["package.json"]; hasPackageJSON { + // Check if it's TypeScript or JavaScript + for _, file := range metadata.Files { + if strings.HasSuffix(file, ".ts") || strings.HasSuffix(file, "tsconfig.json") { + return "typescript" + } + } + return "javascript" + } + + if _, hasPyProject := metadata.PackageFiles["pyproject.toml"]; hasPyProject { + return "python" + } + if _, hasRequirements := metadata.PackageFiles["requirements.txt"]; hasRequirements { + return "python" + } + if _, hasGoMod := metadata.PackageFiles["go.mod"]; hasGoMod { + return "go" + } + if _, hasCargoToml := metadata.PackageFiles["Cargo.toml"]; hasCargoToml { + return "rust" + } + + // Default to universal image for unknown languages + return "universal" +} + +// normalizeLanguage normalizes language names to match Microsoft's image naming +func normalizeLanguage(lang string) string { + lang = strings.ToLower(lang) + switch lang { + case "typescript", "javascript", "js", "ts": + return "typescript" + case "python", "py": + return "python" + case "go", "golang": + return "go" + case "rust", "rs": + return "rust" + case "c", "cpp", "c++": + return "cpp" + case "java": + return "java" + case "php": + return "php" + default: + return "universal" + } +} + +// selectBaseImage returns the Microsoft DevContainer image for the given language +func (s *ImageSelector) selectBaseImage(language string) string { + // Microsoft DevContainer images are published to mcr.microsoft.com + baseRegistry := "mcr.microsoft.com/devcontainers" + + switch language { + case "typescript", "javascript": + return fmt.Sprintf("%s/typescript-node:1-20-bullseye", baseRegistry) + case "python": + return fmt.Sprintf("%s/python:1-3.11-bullseye", baseRegistry) + case "go": + return fmt.Sprintf("%s/go:1-1.22-bullseye", baseRegistry) + case "rust": + return fmt.Sprintf("%s/rust:1-bullseye", baseRegistry) + case "cpp": + return fmt.Sprintf("%s/cpp:1-bullseye", baseRegistry) + case "java": + return fmt.Sprintf("%s/java:1-17-bullseye", baseRegistry) + case "php": + return fmt.Sprintf("%s/php:1-8.2-bullseye", baseRegistry) + case "universal": + return fmt.Sprintf("%s/universal:2-linux", baseRegistry) + default: + // Fallback to base image + return fmt.Sprintf("%s/base:1-bullseye", baseRegistry) + } +} + +// buildFeatureList returns the Dev8 features to install for the given language +func (s *ImageSelector) buildFeatureList(language string) []Feature { + features := []Feature{ + // Always install supervisor + { + ID: fmt.Sprintf("%s/supervisor", s.FeatureRegistry), + Version: "1", + Options: map[string]interface{}{ + "version": "latest", + }, + }, + // Always install Claude CLI + { + ID: fmt.Sprintf("%s/claude-cli", s.FeatureRegistry), + Version: "1", + Options: map[string]interface{}{ + "version": "1.0.0", + "installShellCompletion": true, + }, + }, + // Always install AI tools bundle + { + ID: fmt.Sprintf("%s/ai-tools", s.FeatureRegistry), + Version: "1", + Options: map[string]interface{}{ + "installGithubCLI": true, + "installCopilot": true, + "installAzureCLI": true, + "installYq": true, + "installTmux": true, + "setupShellAliases": true, + }, + }, + } + + return features +} + +// ToDevContainerJSON generates a devcontainer.json configuration +func (spec *ImageSpec) ToDevContainerJSON() (string, error) { + config := map[string]interface{}{ + "name": "Dev8 Workspace", + "image": spec.BaseImage, + } + + // Add features + if len(spec.Features) > 0 { + features := make(map[string]interface{}) + for _, feature := range spec.Features { + key := fmt.Sprintf("%s:%s", feature.ID, feature.Version) + if len(feature.Options) > 0 { + features[key] = feature.Options + } else { + features[key] = map[string]interface{}{} + } + } + config["features"] = features + } + + // Standard Dev8 configuration + config["customizations"] = map[string]interface{}{ + "vscode": map[string]interface{}{ + "extensions": []string{ + "ms-vscode.vscode-typescript-next", + "dbaeumer.vscode-eslint", + "esbenp.prettier-vscode", + }, + }, + } + + // Mount workspace directory + config["workspaceFolder"] = "/workspaces" + config["workspaceMount"] = "source=/home/dev8/workspace,target=/workspaces,type=bind" + + // Post-create commands + config["postCreateCommand"] = "echo 'Dev8 workspace ready!'" + + data, err := json.MarshalIndent(config, "", " ") + if err != nil { + return "", fmt.Errorf("failed to marshal devcontainer config: %w", err) + } + + return string(data), nil +} + +// GetImageString returns the complete image string including features +// For now, we return just the base image and let the deployment handle features separately +func (spec *ImageSpec) GetImageString() string { + return spec.BaseImage +} diff --git a/apps/agent/internal/devcontainer/selector_test.go b/apps/agent/internal/devcontainer/selector_test.go new file mode 100644 index 0000000..78b4943 --- /dev/null +++ b/apps/agent/internal/devcontainer/selector_test.go @@ -0,0 +1,213 @@ +package devcontainer + +import ( + "context" + "strings" + "testing" +) + +func TestDetectLanguage(t *testing.T) { + tests := []struct { + name string + metadata RepoMetadata + want string + }{ + { + name: "TypeScript from package.json and tsconfig", + metadata: RepoMetadata{ + Files: []string{"tsconfig.json", "package.json", "src/index.ts"}, + PackageFiles: map[string]string{"package.json": "{}"}, + }, + want: "typescript", + }, + { + name: "JavaScript from package.json only", + metadata: RepoMetadata{ + Files: []string{"package.json", "src/index.js"}, + PackageFiles: map[string]string{"package.json": "{}"}, + }, + want: "javascript", + }, + { + name: "Python from requirements.txt", + metadata: RepoMetadata{ + Files: []string{"requirements.txt", "main.py"}, + PackageFiles: map[string]string{"requirements.txt": "flask==2.0.0"}, + }, + want: "python", + }, + { + name: "Go from go.mod", + metadata: RepoMetadata{ + Files: []string{"go.mod", "main.go"}, + PackageFiles: map[string]string{"go.mod": "module example.com/app"}, + }, + want: "go", + }, + { + name: "Rust from Cargo.toml", + metadata: RepoMetadata{ + Files: []string{"Cargo.toml", "src/main.rs"}, + PackageFiles: map[string]string{"Cargo.toml": "[package]"}, + }, + want: "rust", + }, + { + name: "Universal for unknown", + metadata: RepoMetadata{ + Files: []string{"README.md"}, + }, + want: "universal", + }, + { + name: "Language detection from bytes", + metadata: RepoMetadata{ + Languages: map[string]int64{ + "Python": 1000, + "JavaScript": 500, + }, + }, + want: "python", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := detectLanguage(tt.metadata) + if got != tt.want { + t.Errorf("detectLanguage() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSelectBaseImage(t *testing.T) { + selector := NewImageSelector() + + tests := []struct { + language string + want string + }{ + {"typescript", "mcr.microsoft.com/devcontainers/typescript-node:1-20-bullseye"}, + {"python", "mcr.microsoft.com/devcontainers/python:1-3.11-bullseye"}, + {"go", "mcr.microsoft.com/devcontainers/go:1-1.22-bullseye"}, + {"rust", "mcr.microsoft.com/devcontainers/rust:1-bullseye"}, + {"universal", "mcr.microsoft.com/devcontainers/universal:2-linux"}, + } + + for _, tt := range tests { + t.Run(tt.language, func(t *testing.T) { + got := selector.selectBaseImage(tt.language) + if got != tt.want { + t.Errorf("selectBaseImage(%s) = %v, want %v", tt.language, got, tt.want) + } + }) + } +} + +func TestSelectImage(t *testing.T) { + selector := NewImageSelector() + + tests := []struct { + name string + metadata RepoMetadata + wantLang string + }{ + { + name: "TypeScript project", + metadata: RepoMetadata{ + Files: []string{"tsconfig.json", "package.json"}, + PackageFiles: map[string]string{"package.json": "{}"}, + }, + wantLang: "typescript", + }, + { + name: "Python project", + metadata: RepoMetadata{ + Files: []string{"requirements.txt"}, + PackageFiles: map[string]string{"requirements.txt": "flask"}, + }, + wantLang: "python", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + spec, err := selector.SelectImage(context.Background(), tt.metadata) + if err != nil { + t.Fatalf("SelectImage() error = %v", err) + } + + if spec.Language != tt.wantLang { + t.Errorf("Language = %v, want %v", spec.Language, tt.wantLang) + } + + if spec.BaseImage == "" { + t.Error("BaseImage should not be empty") + } + + // Should always have 3 core features + if len(spec.Features) != 3 { + t.Errorf("Features length = %d, want 3", len(spec.Features)) + } + }) + } +} + +func TestBuildFeatureList(t *testing.T) { + selector := NewImageSelector() + features := selector.buildFeatureList("python") + + // Should always have supervisor, claude-cli, and ai-tools + if len(features) != 3 { + t.Errorf("Expected 3 features, got %d", len(features)) + } + + // Check that all expected features are present + featureIDs := make(map[string]bool) + for _, f := range features { + // Extract feature name from full path + parts := strings.Split(f.ID, "/") + featureName := parts[len(parts)-1] + featureIDs[featureName] = true + } + + requiredFeatures := []string{"supervisor", "claude-cli", "ai-tools"} + for _, required := range requiredFeatures { + if !featureIDs[required] { + t.Errorf("Missing required feature: %s", required) + } + } +} + +func TestToDevContainerJSON(t *testing.T) { + spec := &ImageSpec{ + BaseImage: "mcr.microsoft.com/devcontainers/python:1-3.11-bullseye", + Language: "python", + Features: []Feature{ + { + ID: "ghcr.io/dev8-community/devcontainer-features/supervisor", + Version: "1", + Options: map[string]interface{}{"version": "latest"}, + }, + }, + } + + json, err := spec.ToDevContainerJSON() + if err != nil { + t.Fatalf("ToDevContainerJSON() error = %v", err) + } + + if json == "" { + t.Error("ToDevContainerJSON() should not return empty string") + } + + // Check that it contains expected fields + if !strings.Contains(json, spec.BaseImage) { + t.Error("JSON should contain base image") + } + + if !strings.Contains(json, "features") { + t.Error("JSON should contain features") + } +}