go/tasks/plans/2026-01-29-core-devops-impl.md
Snider 4eb1e02f5e
Some checks are pending
Security Scan / Go Vulnerability Check (push) Waiting to run
Security Scan / Secret Detection (push) Waiting to run
Security Scan / Dependency & Config Scan (push) Waiting to run
feat/ml-integration (#2)
Co-authored-by: Charon (snider-linux) <charon@lethean.io>
Co-authored-by: Snider <snider@host.uk.com>
Co-authored-by: Virgil <virgil@lethean.io>
Co-authored-by: Claude <developers@lethean.io>
Reviewed-on: core/cli#2
Co-authored-by: Snider <snider@lethean.io>
Co-committed-by: Snider <snider@lethean.io>
2026-02-16 06:19:09 +00:00

48 KiB

Core DevOps CLI Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Implement core dev commands for portable development environment using core-devops LinuxKit images.

Architecture: pkg/devops package handles image management, config, and orchestration. Reuses pkg/container.LinuxKitManager for VM lifecycle. Image sources (GitHub, Registry, CDN) implement common interface. CLI in cmd/core/cmd/dev.go.

Tech Stack: Go, pkg/container, golang.org/x/crypto/ssh, os/exec for gh CLI, YAML config


Task 1: Create DevOps Package Structure

Files:

  • Create: pkg/devops/devops.go
  • Create: pkg/devops/go.mod

Step 1: Create go.mod

module forge.lthn.ai/core/cli/pkg/devops

go 1.25

require (
	forge.lthn.ai/core/cli/pkg/container v0.0.0
	golang.org/x/crypto v0.32.0
	gopkg.in/yaml.v3 v3.0.1
)

replace forge.lthn.ai/core/cli/pkg/container => ../container

Step 2: Create devops.go with core types

// Package devops provides a portable development environment using LinuxKit images.
package devops

import (
	"context"
	"fmt"
	"os"
	"path/filepath"
	"runtime"

	"forge.lthn.ai/core/cli/pkg/container"
)

// DevOps manages the portable development environment.
type DevOps struct {
	config    *Config
	images    *ImageManager
	container *container.LinuxKitManager
}

// New creates a new DevOps instance.
func New() (*DevOps, error) {
	cfg, err := LoadConfig()
	if err != nil {
		return nil, fmt.Errorf("devops.New: failed to load config: %w", err)
	}

	images, err := NewImageManager(cfg)
	if err != nil {
		return nil, fmt.Errorf("devops.New: failed to create image manager: %w", err)
	}

	mgr, err := container.NewLinuxKitManager()
	if err != nil {
		return nil, fmt.Errorf("devops.New: failed to create container manager: %w", err)
	}

	return &DevOps{
		config:    cfg,
		images:    images,
		container: mgr,
	}, nil
}

// ImageName returns the platform-specific image name.
func ImageName() string {
	return fmt.Sprintf("core-devops-%s-%s.qcow2", runtime.GOOS, runtime.GOARCH)
}

// ImagesDir returns the path to the images directory.
func ImagesDir() (string, error) {
	if dir := os.Getenv("CORE_IMAGES_DIR"); dir != "" {
		return dir, nil
	}
	home, err := os.UserHomeDir()
	if err != nil {
		return "", err
	}
	return filepath.Join(home, ".core", "images"), nil
}

// ImagePath returns the full path to the platform-specific image.
func ImagePath() (string, error) {
	dir, err := ImagesDir()
	if err != nil {
		return "", err
	}
	return filepath.Join(dir, ImageName()), nil
}

// IsInstalled checks if the dev image is installed.
func (d *DevOps) IsInstalled() bool {
	path, err := ImagePath()
	if err != nil {
		return false
	}
	_, err = os.Stat(path)
	return err == nil
}

Step 3: Add to go.work

Run: cd /Users/snider/Code/Core && echo " ./pkg/devops" >> go.work && go work sync

Step 4: Verify it compiles

Run: cd /Users/snider/Code/Core && go build ./pkg/devops/... Expected: Error (missing Config, ImageManager) - that's OK for now

Step 5: Commit

git add pkg/devops/
git add go.work go.work.sum
git commit -m "feat(devops): add package structure

Initial pkg/devops setup with DevOps type and path helpers.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"

Task 2: Implement Config Loading

Files:

  • Create: pkg/devops/config.go
  • Create: pkg/devops/config_test.go

Step 1: Write the failing test

package devops

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

func TestLoadConfig_Good_Default(t *testing.T) {
	// Use temp home dir
	tmpDir := t.TempDir()
	t.Setenv("HOME", tmpDir)

	cfg, err := LoadConfig()
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if cfg.Images.Source != "auto" {
		t.Errorf("expected source 'auto', got %q", cfg.Images.Source)
	}
}

func TestLoadConfig_Good_FromFile(t *testing.T) {
	tmpDir := t.TempDir()
	t.Setenv("HOME", tmpDir)

	configDir := filepath.Join(tmpDir, ".core")
	os.MkdirAll(configDir, 0755)

	configContent := `version: 1
images:
  source: github
  github:
    repo: myorg/images
`
	os.WriteFile(filepath.Join(configDir, "config.yaml"), []byte(configContent), 0644)

	cfg, err := LoadConfig()
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if cfg.Images.Source != "github" {
		t.Errorf("expected source 'github', got %q", cfg.Images.Source)
	}
	if cfg.Images.GitHub.Repo != "myorg/images" {
		t.Errorf("expected repo 'myorg/images', got %q", cfg.Images.GitHub.Repo)
	}
}

Step 2: Run test to verify it fails

Run: cd /Users/snider/Code/Core && go test ./pkg/devops/... -run TestLoadConfig -v Expected: FAIL (LoadConfig not defined)

Step 3: Write implementation

package devops

import (
	"os"
	"path/filepath"

	"gopkg.in/yaml.v3"
)

// Config holds global devops configuration from ~/.core/config.yaml.
type Config struct {
	Version int          `yaml:"version"`
	Images  ImagesConfig `yaml:"images"`
}

// ImagesConfig holds image source configuration.
type ImagesConfig struct {
	Source   string         `yaml:"source"` // auto, github, registry, cdn
	GitHub   GitHubConfig   `yaml:"github,omitempty"`
	Registry RegistryConfig `yaml:"registry,omitempty"`
	CDN      CDNConfig      `yaml:"cdn,omitempty"`
}

// GitHubConfig holds GitHub Releases configuration.
type GitHubConfig struct {
	Repo string `yaml:"repo"` // owner/repo format
}

// RegistryConfig holds container registry configuration.
type RegistryConfig struct {
	Image string `yaml:"image"` // e.g., ghcr.io/host-uk/core-devops
}

// CDNConfig holds CDN/S3 configuration.
type CDNConfig struct {
	URL string `yaml:"url"` // base URL for downloads
}

// DefaultConfig returns sensible defaults.
func DefaultConfig() *Config {
	return &Config{
		Version: 1,
		Images: ImagesConfig{
			Source: "auto",
			GitHub: GitHubConfig{
				Repo: "host-uk/core-images",
			},
			Registry: RegistryConfig{
				Image: "ghcr.io/host-uk/core-devops",
			},
		},
	}
}

// ConfigPath returns the path to the config file.
func ConfigPath() (string, error) {
	home, err := os.UserHomeDir()
	if err != nil {
		return "", err
	}
	return filepath.Join(home, ".core", "config.yaml"), nil
}

// LoadConfig loads configuration from ~/.core/config.yaml.
// Returns default config if file doesn't exist.
func LoadConfig() (*Config, error) {
	configPath, err := ConfigPath()
	if err != nil {
		return DefaultConfig(), nil
	}

	data, err := os.ReadFile(configPath)
	if err != nil {
		if os.IsNotExist(err) {
			return DefaultConfig(), nil
		}
		return nil, err
	}

	cfg := DefaultConfig()
	if err := yaml.Unmarshal(data, cfg); err != nil {
		return nil, err
	}

	return cfg, nil
}

Step 4: Run tests

Run: cd /Users/snider/Code/Core && go test ./pkg/devops/... -run TestLoadConfig -v Expected: PASS

Step 5: Commit

git add pkg/devops/config.go pkg/devops/config_test.go
git commit -m "feat(devops): add config loading

Loads ~/.core/config.yaml with image source preferences.
Defaults to auto-detection with host-uk/core-images.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"

Task 3: Implement ImageSource Interface

Files:

  • Create: pkg/devops/sources/source.go

Step 1: Create source interface

// Package sources provides image download sources for core-devops.
package sources

import (
	"context"
)

// ImageSource defines the interface for downloading dev images.
type ImageSource interface {
	// Name returns the source identifier.
	Name() string
	// Available checks if this source can be used.
	Available() bool
	// LatestVersion returns the latest available version.
	LatestVersion(ctx context.Context) (string, error)
	// Download downloads the image to the destination path.
	// Reports progress via the callback if provided.
	Download(ctx context.Context, dest string, progress func(downloaded, total int64)) error
}

// SourceConfig holds configuration for a source.
type SourceConfig struct {
	// GitHub configuration
	GitHubRepo string
	// Registry configuration
	RegistryImage string
	// CDN configuration
	CDNURL string
	// Image name (e.g., core-devops-darwin-arm64.qcow2)
	ImageName string
}

Step 2: Verify it compiles

Run: cd /Users/snider/Code/Core && go build ./pkg/devops/... Expected: No errors

Step 3: Commit

git add pkg/devops/sources/source.go
git commit -m "feat(devops): add ImageSource interface

Defines common interface for GitHub, Registry, and CDN sources.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"

Task 4: Implement GitHub Source

Files:

  • Create: pkg/devops/sources/github.go
  • Create: pkg/devops/sources/github_test.go

Step 1: Write the failing test

package sources

import (
	"testing"
)

func TestGitHubSource_Good_Available(t *testing.T) {
	src := NewGitHubSource(SourceConfig{
		GitHubRepo: "host-uk/core-images",
		ImageName:  "core-devops-darwin-arm64.qcow2",
	})

	if src.Name() != "github" {
		t.Errorf("expected name 'github', got %q", src.Name())
	}

	// Available depends on gh CLI being installed
	_ = src.Available()
}

Step 2: Run test to verify it fails

Run: cd /Users/snider/Code/Core && go test ./pkg/devops/sources/... -run TestGitHubSource -v Expected: FAIL

Step 3: Write implementation

package sources

import (
	"context"
	"encoding/json"
	"fmt"
	"os"
	"os/exec"
	"strings"
)

// GitHubSource downloads images from GitHub Releases.
type GitHubSource struct {
	config SourceConfig
}

// NewGitHubSource creates a new GitHub source.
func NewGitHubSource(cfg SourceConfig) *GitHubSource {
	return &GitHubSource{config: cfg}
}

// Name returns "github".
func (s *GitHubSource) Name() string {
	return "github"
}

// Available checks if gh CLI is installed and authenticated.
func (s *GitHubSource) Available() bool {
	_, err := exec.LookPath("gh")
	if err != nil {
		return false
	}
	// Check if authenticated
	cmd := exec.Command("gh", "auth", "status")
	return cmd.Run() == nil
}

// LatestVersion returns the latest release tag.
func (s *GitHubSource) LatestVersion(ctx context.Context) (string, error) {
	cmd := exec.CommandContext(ctx, "gh", "release", "view",
		"-R", s.config.GitHubRepo,
		"--json", "tagName",
		"-q", ".tagName",
	)
	out, err := cmd.Output()
	if err != nil {
		return "", fmt.Errorf("github.LatestVersion: %w", err)
	}
	return strings.TrimSpace(string(out)), nil
}

// Download downloads the image from the latest release.
func (s *GitHubSource) Download(ctx context.Context, dest string, progress func(downloaded, total int64)) error {
	// Get release assets to find our image
	cmd := exec.CommandContext(ctx, "gh", "release", "download",
		"-R", s.config.GitHubRepo,
		"-p", s.config.ImageName,
		"-D", dest,
		"--clobber",
	)
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr

	if err := cmd.Run(); err != nil {
		return fmt.Errorf("github.Download: %w", err)
	}
	return nil
}

// releaseAsset represents a GitHub release asset.
type releaseAsset struct {
	Name string `json:"name"`
	Size int64  `json:"size"`
	URL  string `json:"url"`
}

Step 4: Run tests

Run: cd /Users/snider/Code/Core && go test ./pkg/devops/sources/... -run TestGitHubSource -v Expected: PASS

Step 5: Commit

git add pkg/devops/sources/github.go pkg/devops/sources/github_test.go
git commit -m "feat(devops): add GitHub Releases source

Downloads core-devops images from GitHub Releases using gh CLI.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"

Task 5: Implement CDN Source

Files:

  • Create: pkg/devops/sources/cdn.go
  • Create: pkg/devops/sources/cdn_test.go

Step 1: Write the failing test

package sources

import (
	"testing"
)

func TestCDNSource_Good_Available(t *testing.T) {
	src := NewCDNSource(SourceConfig{
		CDNURL:    "https://images.example.com",
		ImageName: "core-devops-darwin-arm64.qcow2",
	})

	if src.Name() != "cdn" {
		t.Errorf("expected name 'cdn', got %q", src.Name())
	}

	// CDN is available if URL is configured
	if !src.Available() {
		t.Error("expected Available() to be true when URL is set")
	}
}

func TestCDNSource_Bad_NoURL(t *testing.T) {
	src := NewCDNSource(SourceConfig{
		ImageName: "core-devops-darwin-arm64.qcow2",
	})

	if src.Available() {
		t.Error("expected Available() to be false when URL is empty")
	}
}

Step 2: Run test to verify it fails

Run: cd /Users/snider/Code/Core && go test ./pkg/devops/sources/... -run TestCDNSource -v Expected: FAIL

Step 3: Write implementation

package sources

import (
	"context"
	"fmt"
	"io"
	"net/http"
	"os"
	"path/filepath"
)

// CDNSource downloads images from a CDN or S3 bucket.
type CDNSource struct {
	config SourceConfig
}

// NewCDNSource creates a new CDN source.
func NewCDNSource(cfg SourceConfig) *CDNSource {
	return &CDNSource{config: cfg}
}

// Name returns "cdn".
func (s *CDNSource) Name() string {
	return "cdn"
}

// Available checks if CDN URL is configured.
func (s *CDNSource) Available() bool {
	return s.config.CDNURL != ""
}

// LatestVersion fetches version from manifest or returns "latest".
func (s *CDNSource) LatestVersion(ctx context.Context) (string, error) {
	// Try to fetch manifest.json for version info
	url := fmt.Sprintf("%s/manifest.json", s.config.CDNURL)
	req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
	if err != nil {
		return "latest", nil
	}

	resp, err := http.DefaultClient.Do(req)
	if err != nil || resp.StatusCode != 200 {
		return "latest", nil
	}
	defer resp.Body.Close()

	// For now, just return latest - could parse manifest for version
	return "latest", nil
}

// Download downloads the image from CDN.
func (s *CDNSource) Download(ctx context.Context, dest string, progress func(downloaded, total int64)) error {
	url := fmt.Sprintf("%s/%s", s.config.CDNURL, s.config.ImageName)

	req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
	if err != nil {
		return fmt.Errorf("cdn.Download: %w", err)
	}

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return fmt.Errorf("cdn.Download: %w", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != 200 {
		return fmt.Errorf("cdn.Download: HTTP %d", resp.StatusCode)
	}

	// Ensure dest directory exists
	if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil {
		return fmt.Errorf("cdn.Download: %w", err)
	}

	// Create destination file
	destPath := filepath.Join(dest, s.config.ImageName)
	f, err := os.Create(destPath)
	if err != nil {
		return fmt.Errorf("cdn.Download: %w", err)
	}
	defer f.Close()

	// Copy with progress
	total := resp.ContentLength
	var downloaded int64

	buf := make([]byte, 32*1024)
	for {
		n, err := resp.Body.Read(buf)
		if n > 0 {
			if _, werr := f.Write(buf[:n]); werr != nil {
				return fmt.Errorf("cdn.Download: %w", werr)
			}
			downloaded += int64(n)
			if progress != nil {
				progress(downloaded, total)
			}
		}
		if err == io.EOF {
			break
		}
		if err != nil {
			return fmt.Errorf("cdn.Download: %w", err)
		}
	}

	return nil
}

Step 4: Run tests

Run: cd /Users/snider/Code/Core && go test ./pkg/devops/sources/... -run TestCDNSource -v Expected: PASS

Step 5: Commit

git add pkg/devops/sources/cdn.go pkg/devops/sources/cdn_test.go
git commit -m "feat(devops): add CDN/S3 source

Downloads core-devops images from custom CDN with progress reporting.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"

Task 6: Implement ImageManager

Files:

  • Create: pkg/devops/images.go
  • Create: pkg/devops/images_test.go

Step 1: Write the failing test

package devops

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

func TestImageManager_Good_IsInstalled(t *testing.T) {
	tmpDir := t.TempDir()
	t.Setenv("CORE_IMAGES_DIR", tmpDir)

	cfg := DefaultConfig()
	mgr, err := NewImageManager(cfg)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}

	// Not installed yet
	if mgr.IsInstalled() {
		t.Error("expected IsInstalled() to be false")
	}

	// Create fake image
	imagePath := filepath.Join(tmpDir, ImageName())
	os.WriteFile(imagePath, []byte("fake"), 0644)

	// Now installed
	if !mgr.IsInstalled() {
		t.Error("expected IsInstalled() to be true")
	}
}

Step 2: Run test to verify it fails

Run: cd /Users/snider/Code/Core && go test ./pkg/devops/... -run TestImageManager -v Expected: FAIL

Step 3: Write implementation

package devops

import (
	"context"
	"encoding/json"
	"fmt"
	"os"
	"path/filepath"
	"time"

	"forge.lthn.ai/core/cli/pkg/devops/sources"
)

// ImageManager handles image downloads and updates.
type ImageManager struct {
	config   *Config
	manifest *Manifest
	sources  []sources.ImageSource
}

// Manifest tracks installed images.
type Manifest struct {
	Images map[string]ImageInfo `json:"images"`
	path   string
}

// ImageInfo holds metadata about an installed image.
type ImageInfo struct {
	Version    string    `json:"version"`
	SHA256     string    `json:"sha256,omitempty"`
	Downloaded time.Time `json:"downloaded"`
	Source     string    `json:"source"`
}

// NewImageManager creates a new image manager.
func NewImageManager(cfg *Config) (*ImageManager, error) {
	imagesDir, err := ImagesDir()
	if err != nil {
		return nil, err
	}

	// Ensure images directory exists
	if err := os.MkdirAll(imagesDir, 0755); err != nil {
		return nil, err
	}

	// Load or create manifest
	manifestPath := filepath.Join(imagesDir, "manifest.json")
	manifest, err := loadManifest(manifestPath)
	if err != nil {
		return nil, err
	}

	// Build source list based on config
	imageName := ImageName()
	sourceCfg := sources.SourceConfig{
		GitHubRepo:    cfg.Images.GitHub.Repo,
		RegistryImage: cfg.Images.Registry.Image,
		CDNURL:        cfg.Images.CDN.URL,
		ImageName:     imageName,
	}

	var srcs []sources.ImageSource
	switch cfg.Images.Source {
	case "github":
		srcs = []sources.ImageSource{sources.NewGitHubSource(sourceCfg)}
	case "cdn":
		srcs = []sources.ImageSource{sources.NewCDNSource(sourceCfg)}
	default: // "auto"
		srcs = []sources.ImageSource{
			sources.NewGitHubSource(sourceCfg),
			sources.NewCDNSource(sourceCfg),
		}
	}

	return &ImageManager{
		config:   cfg,
		manifest: manifest,
		sources:  srcs,
	}, nil
}

// IsInstalled checks if the dev image is installed.
func (m *ImageManager) IsInstalled() bool {
	path, err := ImagePath()
	if err != nil {
		return false
	}
	_, err = os.Stat(path)
	return err == nil
}

// Install downloads and installs the dev image.
func (m *ImageManager) Install(ctx context.Context, progress func(downloaded, total int64)) error {
	imagesDir, err := ImagesDir()
	if err != nil {
		return err
	}

	// Find first available source
	var src sources.ImageSource
	for _, s := range m.sources {
		if s.Available() {
			src = s
			break
		}
	}
	if src == nil {
		return fmt.Errorf("no image source available")
	}

	// Get version
	version, err := src.LatestVersion(ctx)
	if err != nil {
		return fmt.Errorf("failed to get latest version: %w", err)
	}

	fmt.Printf("Downloading %s from %s...\n", ImageName(), src.Name())

	// Download
	if err := src.Download(ctx, imagesDir, progress); err != nil {
		return err
	}

	// Update manifest
	m.manifest.Images[ImageName()] = ImageInfo{
		Version:    version,
		Downloaded: time.Now(),
		Source:     src.Name(),
	}

	return m.manifest.Save()
}

// CheckUpdate checks if an update is available.
func (m *ImageManager) CheckUpdate(ctx context.Context) (current, latest string, hasUpdate bool, err error) {
	info, ok := m.manifest.Images[ImageName()]
	if !ok {
		return "", "", false, fmt.Errorf("image not installed")
	}
	current = info.Version

	// Find first available source
	var src sources.ImageSource
	for _, s := range m.sources {
		if s.Available() {
			src = s
			break
		}
	}
	if src == nil {
		return current, "", false, fmt.Errorf("no image source available")
	}

	latest, err = src.LatestVersion(ctx)
	if err != nil {
		return current, "", false, err
	}

	hasUpdate = current != latest
	return current, latest, hasUpdate, nil
}

func loadManifest(path string) (*Manifest, error) {
	m := &Manifest{
		Images: make(map[string]ImageInfo),
		path:   path,
	}

	data, err := os.ReadFile(path)
	if err != nil {
		if os.IsNotExist(err) {
			return m, nil
		}
		return nil, err
	}

	if err := json.Unmarshal(data, m); err != nil {
		return nil, err
	}
	m.path = path

	return m, nil
}

// Save writes the manifest to disk.
func (m *Manifest) Save() error {
	data, err := json.MarshalIndent(m, "", "  ")
	if err != nil {
		return err
	}
	return os.WriteFile(m.path, data, 0644)
}

Step 4: Run tests

Run: cd /Users/snider/Code/Core && go test ./pkg/devops/... -run TestImageManager -v Expected: PASS

Step 5: Commit

git add pkg/devops/images.go pkg/devops/images_test.go
git commit -m "feat(devops): add ImageManager

Manages image downloads, manifest tracking, and update checking.
Tries sources in priority order (GitHub, CDN).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"

Task 7: Implement Boot/Stop/Status

Files:

  • Modify: pkg/devops/devops.go
  • Create: pkg/devops/devops_test.go

Step 1: Add boot/stop/status methods to devops.go

// Add to devops.go

// BootOptions configures how to boot the dev environment.
type BootOptions struct {
	Memory int    // MB, default 4096
	CPUs   int    // default 2
	Name   string // container name
	Fresh  bool   // destroy existing and start fresh
}

// DefaultBootOptions returns sensible defaults.
func DefaultBootOptions() BootOptions {
	return BootOptions{
		Memory: 4096,
		CPUs:   2,
		Name:   "core-dev",
	}
}

// Boot starts the dev environment.
func (d *DevOps) Boot(ctx context.Context, opts BootOptions) error {
	if !d.images.IsInstalled() {
		return fmt.Errorf("dev image not installed (run 'core dev install' first)")
	}

	// Check if already running
	if !opts.Fresh {
		running, err := d.IsRunning(ctx)
		if err == nil && running {
			return fmt.Errorf("dev environment already running (use 'core dev stop' first or --fresh)")
		}
	}

	// Stop existing if fresh
	if opts.Fresh {
		_ = d.Stop(ctx)
	}

	imagePath, err := ImagePath()
	if err != nil {
		return err
	}

	runOpts := container.RunOptions{
		Name:    opts.Name,
		Detach:  true,
		Memory:  opts.Memory,
		CPUs:    opts.CPUs,
		SSHPort: 2222,
	}

	_, err = d.container.Run(ctx, imagePath, runOpts)
	return err
}

// Stop stops the dev environment.
func (d *DevOps) Stop(ctx context.Context) error {
	containers, err := d.container.List(ctx)
	if err != nil {
		return err
	}

	for _, c := range containers {
		if c.Name == "core-dev" && c.Status == container.StatusRunning {
			return d.container.Stop(ctx, c.ID)
		}
	}

	return nil
}

// IsRunning checks if the dev environment is running.
func (d *DevOps) IsRunning(ctx context.Context) (bool, error) {
	containers, err := d.container.List(ctx)
	if err != nil {
		return false, err
	}

	for _, c := range containers {
		if c.Name == "core-dev" && c.Status == container.StatusRunning {
			return true, nil
		}
	}

	return false, nil
}

// Status returns information about the dev environment.
type DevStatus struct {
	Installed    bool
	Running      bool
	ImageVersion string
	ContainerID  string
	Memory       int
	CPUs         int
	SSHPort      int
	Uptime       time.Duration
}

// Status returns the current dev environment status.
func (d *DevOps) Status(ctx context.Context) (*DevStatus, error) {
	status := &DevStatus{
		Installed: d.images.IsInstalled(),
	}

	if info, ok := d.images.manifest.Images[ImageName()]; ok {
		status.ImageVersion = info.Version
	}

	containers, err := d.container.List(ctx)
	if err != nil {
		return status, nil
	}

	for _, c := range containers {
		if c.Name == "core-dev" && c.Status == container.StatusRunning {
			status.Running = true
			status.ContainerID = c.ID
			status.Memory = c.Memory
			status.CPUs = c.CPUs
			status.SSHPort = 2222
			status.Uptime = time.Since(c.StartedAt)
			break
		}
	}

	return status, nil
}

Step 2: Add missing import to devops.go

import (
	"time"
	// ... other imports
)

Step 3: Verify it compiles

Run: cd /Users/snider/Code/Core && go build ./pkg/devops/... Expected: No errors

Step 4: Commit

git add pkg/devops/devops.go
git commit -m "feat(devops): add Boot/Stop/Status methods

Manages dev VM lifecycle using LinuxKitManager.
Supports fresh boot, status checking, graceful stop.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"

Task 8: Implement Shell Command

Files:

  • Create: pkg/devops/shell.go

Step 1: Create shell.go

package devops

import (
	"context"
	"fmt"
	"os"
	"os/exec"
)

// ShellOptions configures the shell connection.
type ShellOptions struct {
	Console bool     // Use serial console instead of SSH
	Command []string // Command to run (empty = interactive shell)
}

// Shell connects to the dev environment.
func (d *DevOps) Shell(ctx context.Context, opts ShellOptions) error {
	running, err := d.IsRunning(ctx)
	if err != nil {
		return err
	}
	if !running {
		return fmt.Errorf("dev environment not running (run 'core dev boot' first)")
	}

	if opts.Console {
		return d.serialConsole(ctx)
	}

	return d.sshShell(ctx, opts.Command)
}

// sshShell connects via SSH.
func (d *DevOps) sshShell(ctx context.Context, command []string) error {
	args := []string{
		"-o", "StrictHostKeyChecking=no",
		"-o", "UserKnownHostsFile=/dev/null",
		"-o", "LogLevel=ERROR",
		"-A", // Agent forwarding
		"-p", "2222",
		"root@localhost",
	}

	if len(command) > 0 {
		args = append(args, command...)
	}

	cmd := exec.CommandContext(ctx, "ssh", args...)
	cmd.Stdin = os.Stdin
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr

	return cmd.Run()
}

// serialConsole attaches to the QEMU serial console.
func (d *DevOps) serialConsole(ctx context.Context) error {
	// Find the container to get its console socket
	containers, err := d.container.List(ctx)
	if err != nil {
		return err
	}

	for _, c := range containers {
		if c.Name == "core-dev" {
			// Use socat to connect to the console socket
			socketPath := fmt.Sprintf("/tmp/core-%s-console.sock", c.ID)
			cmd := exec.CommandContext(ctx, "socat", "-,raw,echo=0", "unix-connect:"+socketPath)
			cmd.Stdin = os.Stdin
			cmd.Stdout = os.Stdout
			cmd.Stderr = os.Stderr
			return cmd.Run()
		}
	}

	return fmt.Errorf("console not available")
}

Step 2: Verify it compiles

Run: cd /Users/snider/Code/Core && go build ./pkg/devops/... Expected: No errors

Step 3: Commit

git add pkg/devops/shell.go
git commit -m "feat(devops): add Shell for SSH and console access

Connects to dev VM via SSH (default) or serial console (--console).
Supports SSH agent forwarding for credential access.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"

Task 9: Implement Test Detection

Files:

  • Create: pkg/devops/test.go
  • Create: pkg/devops/test_test.go

Step 1: Write the failing test

package devops

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

func TestDetectTestCommand_Good_ComposerJSON(t *testing.T) {
	tmpDir := t.TempDir()
	os.WriteFile(filepath.Join(tmpDir, "composer.json"), []byte(`{"scripts":{"test":"pest"}}`), 0644)

	cmd := DetectTestCommand(tmpDir)
	if cmd != "composer test" {
		t.Errorf("expected 'composer test', got %q", cmd)
	}
}

func TestDetectTestCommand_Good_PackageJSON(t *testing.T) {
	tmpDir := t.TempDir()
	os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(`{"scripts":{"test":"vitest"}}`), 0644)

	cmd := DetectTestCommand(tmpDir)
	if cmd != "npm test" {
		t.Errorf("expected 'npm test', got %q", cmd)
	}
}

func TestDetectTestCommand_Good_GoMod(t *testing.T) {
	tmpDir := t.TempDir()
	os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module example"), 0644)

	cmd := DetectTestCommand(tmpDir)
	if cmd != "go test ./..." {
		t.Errorf("expected 'go test ./...', got %q", cmd)
	}
}

func TestDetectTestCommand_Good_CoreTestYaml(t *testing.T) {
	tmpDir := t.TempDir()
	coreDir := filepath.Join(tmpDir, ".core")
	os.MkdirAll(coreDir, 0755)
	os.WriteFile(filepath.Join(coreDir, "test.yaml"), []byte("command: custom-test"), 0644)

	cmd := DetectTestCommand(tmpDir)
	if cmd != "custom-test" {
		t.Errorf("expected 'custom-test', got %q", cmd)
	}
}

Step 2: Run test to verify it fails

Run: cd /Users/snider/Code/Core && go test ./pkg/devops/... -run TestDetectTestCommand -v Expected: FAIL

Step 3: Write implementation

package devops

import (
	"context"
	"encoding/json"
	"fmt"
	"os"
	"path/filepath"

	"gopkg.in/yaml.v3"
)

// TestConfig holds test configuration from .core/test.yaml.
type TestConfig struct {
	Version  int           `yaml:"version"`
	Command  string        `yaml:"command,omitempty"`
	Commands []TestCommand `yaml:"commands,omitempty"`
	Env      map[string]string `yaml:"env,omitempty"`
}

// TestCommand is a named test command.
type TestCommand struct {
	Name string `yaml:"name"`
	Run  string `yaml:"run"`
}

// TestOptions configures test execution.
type TestOptions struct {
	Name    string   // Run specific named command from .core/test.yaml
	Command []string // Override command (from -- args)
}

// Test runs tests in the dev environment.
func (d *DevOps) Test(ctx context.Context, projectDir string, opts TestOptions) error {
	running, err := d.IsRunning(ctx)
	if err != nil {
		return err
	}
	if !running {
		return fmt.Errorf("dev environment not running (run 'core dev boot' first)")
	}

	var cmd string

	// Priority: explicit command > named command > auto-detect
	if len(opts.Command) > 0 {
		cmd = joinCommand(opts.Command)
	} else if opts.Name != "" {
		cfg, err := LoadTestConfig(projectDir)
		if err != nil {
			return err
		}
		for _, c := range cfg.Commands {
			if c.Name == opts.Name {
				cmd = c.Run
				break
			}
		}
		if cmd == "" {
			return fmt.Errorf("test command %q not found in .core/test.yaml", opts.Name)
		}
	} else {
		cmd = DetectTestCommand(projectDir)
		if cmd == "" {
			return fmt.Errorf("could not detect test command (create .core/test.yaml)")
		}
	}

	// Run via SSH
	return d.sshShell(ctx, []string{"cd", "/app", "&&", cmd})
}

// DetectTestCommand auto-detects the test command for a project.
func DetectTestCommand(projectDir string) string {
	// 1. Check .core/test.yaml
	cfg, err := LoadTestConfig(projectDir)
	if err == nil && cfg.Command != "" {
		return cfg.Command
	}

	// 2. Check composer.json
	if hasFile(projectDir, "composer.json") {
		return "composer test"
	}

	// 3. Check package.json
	if hasFile(projectDir, "package.json") {
		return "npm test"
	}

	// 4. Check go.mod
	if hasFile(projectDir, "go.mod") {
		return "go test ./..."
	}

	// 5. Check pytest
	if hasFile(projectDir, "pytest.ini") || hasFile(projectDir, "pyproject.toml") {
		return "pytest"
	}

	// 6. Check Taskfile
	if hasFile(projectDir, "Taskfile.yaml") || hasFile(projectDir, "Taskfile.yml") {
		return "task test"
	}

	return ""
}

// LoadTestConfig loads .core/test.yaml.
func LoadTestConfig(projectDir string) (*TestConfig, error) {
	path := filepath.Join(projectDir, ".core", "test.yaml")
	data, err := os.ReadFile(path)
	if err != nil {
		return nil, err
	}

	var cfg TestConfig
	if err := yaml.Unmarshal(data, &cfg); err != nil {
		return nil, err
	}

	return &cfg, nil
}

func hasFile(dir, name string) bool {
	_, err := os.Stat(filepath.Join(dir, name))
	return err == nil
}

func joinCommand(parts []string) string {
	result := ""
	for i, p := range parts {
		if i > 0 {
			result += " "
		}
		result += p
	}
	return result
}

Step 4: Run tests

Run: cd /Users/snider/Code/Core && go test ./pkg/devops/... -run TestDetectTestCommand -v Expected: PASS

Step 5: Commit

git add pkg/devops/test.go pkg/devops/test_test.go
git commit -m "feat(devops): add test detection and execution

Auto-detects test framework from project files.
Supports .core/test.yaml for custom configuration.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"

Task 10: Implement Serve with Mount

Files:

  • Create: pkg/devops/serve.go

Step 1: Create serve.go

package devops

import (
	"context"
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
)

// ServeOptions configures the dev server.
type ServeOptions struct {
	Port int    // Port to serve on (default 8000)
	Path string // Subdirectory to serve (default: current dir)
}

// Serve mounts the project and starts a dev server.
func (d *DevOps) Serve(ctx context.Context, projectDir string, opts ServeOptions) error {
	running, err := d.IsRunning(ctx)
	if err != nil {
		return err
	}
	if !running {
		return fmt.Errorf("dev environment not running (run 'core dev boot' first)")
	}

	if opts.Port == 0 {
		opts.Port = 8000
	}

	servePath := projectDir
	if opts.Path != "" {
		servePath = filepath.Join(projectDir, opts.Path)
	}

	// Mount project directory via SSHFS
	if err := d.mountProject(ctx, servePath); err != nil {
		return fmt.Errorf("failed to mount project: %w", err)
	}

	// Detect and run serve command
	serveCmd := DetectServeCommand(servePath)
	fmt.Printf("Starting server: %s\n", serveCmd)
	fmt.Printf("Listening on http://localhost:%d\n", opts.Port)

	// Run serve command via SSH
	return d.sshShell(ctx, []string{"cd", "/app", "&&", serveCmd})
}

// mountProject mounts a directory into the VM via SSHFS.
func (d *DevOps) mountProject(ctx context.Context, path string) error {
	absPath, err := filepath.Abs(path)
	if err != nil {
		return err
	}

	// Use reverse SSHFS mount
	// The VM connects back to host to mount the directory
	cmd := exec.CommandContext(ctx, "ssh",
		"-o", "StrictHostKeyChecking=no",
		"-o", "UserKnownHostsFile=/dev/null",
		"-R", "10000:localhost:22", // Reverse tunnel for SSHFS
		"-p", "2222",
		"root@localhost",
		"mkdir -p /app && sshfs -p 10000 "+os.Getenv("USER")+"@localhost:"+absPath+" /app -o allow_other",
	)
	return cmd.Run()
}

// DetectServeCommand auto-detects the serve command for a project.
func DetectServeCommand(projectDir string) string {
	// Laravel/Octane
	if hasFile(projectDir, "artisan") {
		return "php artisan octane:start --host=0.0.0.0 --port=8000"
	}

	// Node.js with dev script
	if hasFile(projectDir, "package.json") {
		if hasPackageScript(projectDir, "dev") {
			return "npm run dev -- --host 0.0.0.0"
		}
		if hasPackageScript(projectDir, "start") {
			return "npm start"
		}
	}

	// PHP with composer
	if hasFile(projectDir, "composer.json") {
		return "frankenphp php-server -l :8000"
	}

	// Go
	if hasFile(projectDir, "go.mod") {
		if hasFile(projectDir, "main.go") {
			return "go run ."
		}
	}

	// Python
	if hasFile(projectDir, "manage.py") {
		return "python manage.py runserver 0.0.0.0:8000"
	}

	// Fallback: simple HTTP server
	return "python3 -m http.server 8000"
}

func hasPackageScript(projectDir, script string) bool {
	data, err := os.ReadFile(filepath.Join(projectDir, "package.json"))
	if err != nil {
		return false
	}

	var pkg struct {
		Scripts map[string]string `json:"scripts"`
	}
	if err := json.Unmarshal(data, &pkg); err != nil {
		return false
	}

	_, ok := pkg.Scripts[script]
	return ok
}

Step 2: Verify it compiles

Run: cd /Users/snider/Code/Core && go build ./pkg/devops/... Expected: No errors

Step 3: Commit

git add pkg/devops/serve.go
git commit -m "feat(devops): add Serve with project mounting

Mounts project via SSHFS and runs auto-detected dev server.
Supports Laravel, Node.js, PHP, Go, Python projects.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"

Task 11: Implement Claude Sandbox

Files:

  • Create: pkg/devops/claude.go

Step 1: Create claude.go

package devops

import (
	"context"
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
)

// ClaudeOptions configures the Claude sandbox session.
type ClaudeOptions struct {
	NoAuth bool     // Don't forward any auth
	Auth   []string // Selective auth: "gh", "anthropic", "ssh", "git"
	Model  string   // Model to use: opus, sonnet
}

// Claude starts a sandboxed Claude session in the dev environment.
func (d *DevOps) Claude(ctx context.Context, projectDir string, opts ClaudeOptions) error {
	// Auto-boot if not running
	running, err := d.IsRunning(ctx)
	if err != nil {
		return err
	}
	if !running {
		fmt.Println("Dev environment not running, booting...")
		if err := d.Boot(ctx, DefaultBootOptions()); err != nil {
			return fmt.Errorf("failed to boot: %w", err)
		}
	}

	// Mount project
	if err := d.mountProject(ctx, projectDir); err != nil {
		return fmt.Errorf("failed to mount project: %w", err)
	}

	// Prepare environment variables to forward
	envVars := []string{}

	if !opts.NoAuth {
		authTypes := opts.Auth
		if len(authTypes) == 0 {
			authTypes = []string{"gh", "anthropic", "ssh", "git"}
		}

		for _, auth := range authTypes {
			switch auth {
			case "anthropic":
				if key := os.Getenv("ANTHROPIC_API_KEY"); key != "" {
					envVars = append(envVars, "ANTHROPIC_API_KEY="+key)
				}
			case "git":
				// Forward git config
				name, _ := exec.Command("git", "config", "user.name").Output()
				email, _ := exec.Command("git", "config", "user.email").Output()
				if len(name) > 0 {
					envVars = append(envVars, "GIT_AUTHOR_NAME="+strings.TrimSpace(string(name)))
					envVars = append(envVars, "GIT_COMMITTER_NAME="+strings.TrimSpace(string(name)))
				}
				if len(email) > 0 {
					envVars = append(envVars, "GIT_AUTHOR_EMAIL="+strings.TrimSpace(string(email)))
					envVars = append(envVars, "GIT_COMMITTER_EMAIL="+strings.TrimSpace(string(email)))
				}
			}
		}
	}

	// Build SSH command with agent forwarding
	args := []string{
		"-o", "StrictHostKeyChecking=no",
		"-o", "UserKnownHostsFile=/dev/null",
		"-o", "LogLevel=ERROR",
		"-A", // SSH agent forwarding
		"-p", "2222",
	}

	// Add environment variables
	for _, env := range envVars {
		args = append(args, "-o", "SendEnv="+strings.Split(env, "=")[0])
	}

	args = append(args, "root@localhost")

	// Build command to run inside
	claudeCmd := "cd /app && claude"
	if opts.Model != "" {
		claudeCmd += " --model " + opts.Model
	}
	args = append(args, claudeCmd)

	// Set environment for SSH
	cmd := exec.CommandContext(ctx, "ssh", args...)
	cmd.Stdin = os.Stdin
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr
	cmd.Env = append(os.Environ(), envVars...)

	fmt.Println("Starting Claude in sandboxed environment...")
	fmt.Println("Project mounted at /app")
	fmt.Println("Auth forwarded: SSH agent" + formatAuthList(opts))
	fmt.Println()

	return cmd.Run()
}

func formatAuthList(opts ClaudeOptions) string {
	if opts.NoAuth {
		return " (none)"
	}
	if len(opts.Auth) == 0 {
		return ", gh, anthropic, git"
	}
	return ", " + strings.Join(opts.Auth, ", ")
}

// CopyGHAuth copies GitHub CLI auth to the VM.
func (d *DevOps) CopyGHAuth(ctx context.Context) error {
	home, err := os.UserHomeDir()
	if err != nil {
		return err
	}

	ghConfigDir := filepath.Join(home, ".config", "gh")
	if _, err := os.Stat(ghConfigDir); os.IsNotExist(err) {
		return nil // No gh config to copy
	}

	// Use scp to copy gh config
	cmd := exec.CommandContext(ctx, "scp",
		"-o", "StrictHostKeyChecking=no",
		"-o", "UserKnownHostsFile=/dev/null",
		"-P", "2222",
		"-r", ghConfigDir,
		"root@localhost:/root/.config/",
	)
	return cmd.Run()
}

Step 2: Verify it compiles

Run: cd /Users/snider/Code/Core && go build ./pkg/devops/... Expected: No errors

Step 3: Commit

git add pkg/devops/claude.go
git commit -m "feat(devops): add Claude sandbox session

Starts Claude in immutable dev environment with auth forwarding.
Auto-boots VM, mounts project, forwards credentials.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"

Task 12: Add CLI Commands

Files:

  • Create: cmd/core/cmd/dev.go
  • Modify: cmd/core/cmd/root.go

Step 1: Create dev.go

package cmd

import (
	"context"
	"fmt"
	"os"
	"strings"

	"github.com/charmbracelet/lipgloss"
	"forge.lthn.ai/core/cli/pkg/devops"
	"github.com/leaanthony/clir"
)

var (
	devHeaderStyle = lipgloss.NewStyle().
		Bold(true).
		Foreground(lipgloss.Color("#3b82f6"))

	devSuccessStyle = lipgloss.NewStyle().
		Bold(true).
		Foreground(lipgloss.Color("#22c55e"))

	devErrorStyle = lipgloss.NewStyle().
		Bold(true).
		Foreground(lipgloss.Color("#ef4444"))

	devDimStyle = lipgloss.NewStyle().
		Foreground(lipgloss.Color("#6b7280"))
)

// AddDevCommand adds the dev command group.
func AddDevCommand(app *clir.Cli) {
	devCmd := app.NewSubCommand("dev", "Portable development environment")
	devCmd.LongDescription("Manage the core-devops portable development environment.\n" +
		"A sandboxed, immutable Linux VM with 100+ development tools.")

	addDevInstallCommand(devCmd)
	addDevBootCommand(devCmd)
	addDevStopCommand(devCmd)
	addDevStatusCommand(devCmd)
	addDevShellCommand(devCmd)
	addDevServeCommand(devCmd)
	addDevTestCommand(devCmd)
	addDevClaudeCommand(devCmd)
	addDevUpdateCommand(devCmd)
}

func addDevInstallCommand(parent *clir.Cli) {
	var source string
	cmd := parent.NewSubCommand("install", "Download the dev environment image")
	cmd.StringFlag("source", "Image source: auto, github, registry, cdn", &source)

	cmd.Action(func() error {
		ctx := context.Background()
		d, err := devops.New()
		if err != nil {
			return err
		}

		if d.IsInstalled() {
			fmt.Printf("%s Dev image already installed\n", devSuccessStyle.Render("OK:"))
			fmt.Println("Use 'core dev update' to check for updates")
			return nil
		}

		fmt.Printf("%s Downloading dev image...\n", devHeaderStyle.Render("Install:"))

		progress := func(downloaded, total int64) {
			if total > 0 {
				pct := float64(downloaded) / float64(total) * 100
				fmt.Printf("\r  %.1f%% (%d / %d MB)", pct, downloaded/1024/1024, total/1024/1024)
			}
		}

		if err := d.Install(ctx, progress); err != nil {
			return err
		}

		fmt.Println()
		fmt.Printf("%s Dev image installed\n", devSuccessStyle.Render("Success:"))
		return nil
	})
}

func addDevBootCommand(parent *clir.Cli) {
	var memory, cpus int
	var fresh bool

	cmd := parent.NewSubCommand("boot", "Start the dev environment")
	cmd.IntFlag("memory", "Memory in MB (default: 4096)", &memory)
	cmd.IntFlag("cpus", "Number of CPUs (default: 2)", &cpus)
	cmd.BoolFlag("fresh", "Destroy existing and start fresh", &fresh)

	cmd.Action(func() error {
		ctx := context.Background()
		d, err := devops.New()
		if err != nil {
			return err
		}

		opts := devops.DefaultBootOptions()
		if memory > 0 {
			opts.Memory = memory
		}
		if cpus > 0 {
			opts.CPUs = cpus
		}
		opts.Fresh = fresh

		fmt.Printf("%s Starting dev environment...\n", devHeaderStyle.Render("Boot:"))

		if err := d.Boot(ctx, opts); err != nil {
			return err
		}

		fmt.Printf("%s Dev environment running\n", devSuccessStyle.Render("Success:"))
		fmt.Printf("  Memory: %d MB\n", opts.Memory)
		fmt.Printf("  CPUs:   %d\n", opts.CPUs)
		fmt.Printf("  SSH:    ssh -p 2222 root@localhost\n")
		return nil
	})
}

func addDevStopCommand(parent *clir.Cli) {
	cmd := parent.NewSubCommand("stop", "Stop the dev environment")
	cmd.Action(func() error {
		ctx := context.Background()
		d, err := devops.New()
		if err != nil {
			return err
		}

		fmt.Printf("%s Stopping dev environment...\n", devHeaderStyle.Render("Stop:"))

		if err := d.Stop(ctx); err != nil {
			return err
		}

		fmt.Printf("%s Dev environment stopped\n", devSuccessStyle.Render("Success:"))
		return nil
	})
}

func addDevStatusCommand(parent *clir.Cli) {
	cmd := parent.NewSubCommand("status", "Show dev environment status")
	cmd.Action(func() error {
		ctx := context.Background()
		d, err := devops.New()
		if err != nil {
			return err
		}

		status, err := d.Status(ctx)
		if err != nil {
			return err
		}

		fmt.Printf("%s Dev Environment\n\n", devHeaderStyle.Render("Status:"))

		if status.Installed {
			fmt.Printf("  Image:   %s\n", devSuccessStyle.Render("installed"))
			fmt.Printf("  Version: %s\n", status.ImageVersion)
		} else {
			fmt.Printf("  Image:   %s\n", devDimStyle.Render("not installed"))
		}

		if status.Running {
			fmt.Printf("  Status:  %s\n", devSuccessStyle.Render("running"))
			fmt.Printf("  ID:      %s\n", status.ContainerID[:8])
			fmt.Printf("  Memory:  %d MB\n", status.Memory)
			fmt.Printf("  CPUs:    %d\n", status.CPUs)
			fmt.Printf("  SSH:     port %d\n", status.SSHPort)
			fmt.Printf("  Uptime:  %s\n", status.Uptime.Round(1000000000))
		} else {
			fmt.Printf("  Status:  %s\n", devDimStyle.Render("stopped"))
		}

		return nil
	})
}

func addDevShellCommand(parent *clir.Cli) {
	var console bool
	cmd := parent.NewSubCommand("shell", "Open a shell in the dev environment")
	cmd.BoolFlag("console", "Use serial console instead of SSH", &console)

	cmd.Action(func() error {
		ctx := context.Background()
		d, err := devops.New()
		if err != nil {
			return err
		}

		return d.Shell(ctx, devops.ShellOptions{Console: console})
	})
}

func addDevServeCommand(parent *clir.Cli) {
	var port int
	var path string

	cmd := parent.NewSubCommand("serve", "Mount project and start dev server")
	cmd.IntFlag("port", "Port to serve on (default: 8000)", &port)
	cmd.StringFlag("path", "Subdirectory to serve", &path)

	cmd.Action(func() error {
		ctx := context.Background()
		d, err := devops.New()
		if err != nil {
			return err
		}

		projectDir, _ := os.Getwd()
		return d.Serve(ctx, projectDir, devops.ServeOptions{Port: port, Path: path})
	})
}

func addDevTestCommand(parent *clir.Cli) {
	var name string

	cmd := parent.NewSubCommand("test", "Run tests in dev environment")
	cmd.StringFlag("name", "Run specific named test from .core/test.yaml", &name)

	cmd.Action(func() error {
		ctx := context.Background()
		d, err := devops.New()
		if err != nil {
			return err
		}

		projectDir, _ := os.Getwd()
		args := cmd.OtherArgs()

		return d.Test(ctx, projectDir, devops.TestOptions{
			Name:    name,
			Command: args,
		})
	})
}

func addDevClaudeCommand(parent *clir.Cli) {
	var noAuth bool
	var auth string
	var model string

	cmd := parent.NewSubCommand("claude", "Start Claude in sandboxed dev environment")
	cmd.BoolFlag("no-auth", "Don't forward any credentials", &noAuth)
	cmd.StringFlag("auth", "Selective auth forwarding: gh,anthropic,ssh,git", &auth)
	cmd.StringFlag("model", "Model to use: opus, sonnet", &model)

	cmd.Action(func() error {
		ctx := context.Background()
		d, err := devops.New()
		if err != nil {
			return err
		}

		projectDir, _ := os.Getwd()

		var authList []string
		if auth != "" {
			authList = strings.Split(auth, ",")
		}

		return d.Claude(ctx, projectDir, devops.ClaudeOptions{
			NoAuth: noAuth,
			Auth:   authList,
			Model:  model,
		})
	})
}

func addDevUpdateCommand(parent *clir.Cli) {
	var force bool
	cmd := parent.NewSubCommand("update", "Check for and download image updates")
	cmd.BoolFlag("force", "Force download even if up to date", &force)

	cmd.Action(func() error {
		ctx := context.Background()
		d, err := devops.New()
		if err != nil {
			return err
		}

		if !d.IsInstalled() {
			return fmt.Errorf("dev image not installed (run 'core dev install' first)")
		}

		fmt.Printf("%s Checking for updates...\n", devHeaderStyle.Render("Update:"))

		current, latest, hasUpdate, err := d.CheckUpdate(ctx)
		if err != nil {
			return err
		}

		if !hasUpdate && !force {
			fmt.Printf("%s Already up to date (%s)\n", devSuccessStyle.Render("OK:"), current)
			return nil
		}

		fmt.Printf("  Current: %s\n", current)
		fmt.Printf("  Latest:  %s\n", latest)

		progress := func(downloaded, total int64) {
			if total > 0 {
				pct := float64(downloaded) / float64(total) * 100
				fmt.Printf("\r  Downloading: %.1f%%", pct)
			}
		}

		if err := d.Install(ctx, progress); err != nil {
			return err
		}

		fmt.Println()
		fmt.Printf("%s Updated to %s\n", devSuccessStyle.Render("Success:"), latest)
		return nil
	})
}

Step 2: Add to root.go

Add after other command registrations:

AddDevCommand(app)

Step 3: Verify it compiles

Run: cd /Users/snider/Code/Core && go build ./cmd/core/... Expected: No errors

Step 4: Commit

git add cmd/core/cmd/dev.go cmd/core/cmd/root.go
git commit -m "feat(cli): add dev command group

Commands:
- core dev install/boot/stop/status
- core dev shell/serve/test
- core dev claude (sandboxed AI session)
- core dev update

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"

Task 13: Final Integration Test

Step 1: Build CLI

Run: cd /Users/snider/Code/Core && go build -o bin/core ./cmd/core Expected: No errors

Step 2: Test help output

Run: ./bin/core dev --help Expected: Shows all dev subcommands

Step 3: Run package tests

Run: cd /Users/snider/Code/Core && go test ./pkg/devops/... -v Expected: All tests pass

Step 4: Update TODO.md

Mark S4.6 tasks as complete in tasks/TODO.md

Step 5: Final commit

git add -A
git commit -m "chore(devops): finalize S4.6 core-devops CLI

All dev commands implemented:
- install/boot/stop/status
- shell/serve/test
- claude (sandboxed AI session)
- update

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"

Summary

13 tasks covering:

  1. Package structure
  2. Config loading
  3. ImageSource interface
  4. GitHub source
  5. CDN source
  6. ImageManager
  7. Boot/Stop/Status
  8. Shell command
  9. Test detection
  10. Serve with mount
  11. Claude sandbox
  12. CLI commands
  13. Integration test