diff --git a/.github/workflows/build.dev.yml b/.github/workflows/build.dev.yml index 045ff343..dfaefabb 100644 --- a/.github/workflows/build.dev.yml +++ b/.github/workflows/build.dev.yml @@ -128,7 +128,7 @@ jobs: - name: Run container tests env: - DEVCELL_IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_LC }}:v0.0.0-${{ matrix.arch }}-${{ matrix.variant }} + DEVCELL_TEST_IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_LC }}:v0.0.0-${{ matrix.arch }}-${{ matrix.variant }} DEVCELL_BASE_IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_LC }}:v0.0.0-${{ matrix.arch }}-base DEVCELL_TEST_BASE_IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_LC }}:v0.0.0-${{ matrix.arch }}-base DEVCELL_USER_IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_LC }}:v0.0.0-${{ matrix.arch }}-${{ matrix.variant }} diff --git a/cmd/apparg.go b/cmd/apparg.go new file mode 100644 index 00000000..ca8652d3 --- /dev/null +++ b/cmd/apparg.go @@ -0,0 +1,87 @@ +package main + +import ( + "bytes" + "os/exec" + "sort" + "strings" + + "github.com/DimmKirr/devcell/internal/ux" + "github.com/spf13/cobra" +) + +// resolveAppArg resolves a positional app argument to a full AppName. +// Accepts either a full name ("devcell-271") or a numeric suffix ("271"). +// A bare suffix is expanded by scanning running cell-* containers. +func resolveAppArg(arg string) string { + // If it looks like a full app name (contains a dash), use as-is. + if strings.Contains(arg, "-") { + return arg + } + // Treat as suffix — scan running containers for a match. + names := runningAppNames() + for _, name := range names { + if strings.HasSuffix(name, "-"+arg) { + return name + } + } + // No match found — return as-is and let the caller's docker inspect fail + // with a clear "container not found" error. + return arg +} + +// runningAppNames returns AppNames of running cell containers by parsing +// container names of the form "cell--run". +func runningAppNames() []string { + out, err := exec.Command("docker", "ps", + "--filter", "name=cell-", + "--format", "{{.Names}}").Output() + if err != nil { + return nil + } + return parseContainerNames(string(out)) +} + +// parseContainerNames extracts AppNames from docker ps output lines. +// Each line should be a container name like "cell-devcell-271-run". +func parseContainerNames(output string) []string { + var names []string + for _, line := range strings.Split(strings.TrimSpace(output), "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "cell-") && strings.HasSuffix(line, "-run") { + appName := line[len("cell-") : len(line)-len("-run")] + names = append(names, appName) + } + } + return names +} + +// selectCell shows an interactive picker when multiple cells are running. +// Returns the selected AppName. +func selectCell(apps map[string]string) (string, error) { + var names []string + for name := range apps { + names = append(names, name) + } + sort.Strings(names) + return ux.GetSelection("Multiple cells found — select one", names) +} + +// completeRunningApps provides shell completion for running cell container names. +func completeRunningApps(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { + out, err := exec.Command("docker", "ps", + "--filter", "name=cell-", + "--format", "{{.Names}}").Output() + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + var completions []string + for _, line := range strings.Split(string(bytes.TrimSpace(out)), "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "cell-") && strings.HasSuffix(line, "-run") { + appName := line[len("cell-") : len(line)-len("-run")] + completions = append(completions, appName) + } + } + return completions, cobra.ShellCompDirectiveNoFileComp +} diff --git a/cmd/apparg_test.go b/cmd/apparg_test.go new file mode 100644 index 00000000..00d9b14b --- /dev/null +++ b/cmd/apparg_test.go @@ -0,0 +1,89 @@ +package main_test + +import ( + "os/exec" + "strings" + "testing" +) + +// TestRDP_PositionalArgInHelp verifies "cell rdp" shows positional arg syntax. +func TestRDP_PositionalArgInHelp(t *testing.T) { + out, err := exec.Command(binaryPath, "rdp", "--help").CombinedOutput() + if err != nil { + t.Fatalf("rdp --help failed: %v\noutput: %s", err, out) + } + s := string(out) + if !strings.Contains(s, "[app-name or suffix]") { + t.Errorf("expected positional arg in usage, got:\n%s", s) + } + if strings.Contains(s, "--app") { + t.Errorf("--app flag should be removed, got:\n%s", s) + } +} + +// TestVNC_PositionalArgInHelp verifies "cell vnc" shows positional arg syntax. +func TestVNC_PositionalArgInHelp(t *testing.T) { + out, err := exec.Command(binaryPath, "vnc", "--help").CombinedOutput() + if err != nil { + t.Fatalf("vnc --help failed: %v\noutput: %s", err, out) + } + s := string(out) + if !strings.Contains(s, "[app-name or suffix]") { + t.Errorf("expected positional arg in usage, got:\n%s", s) + } + if strings.Contains(s, "--app") { + t.Errorf("--app flag should be removed, got:\n%s", s) + } +} + +// TestRDP_RejectsExtraArgs verifies that more than 1 positional arg is rejected. +func TestRDP_RejectsExtraArgs(t *testing.T) { + cmd := exec.Command(binaryPath, "rdp", "app1", "app2") + out, err := cmd.CombinedOutput() + if err == nil { + t.Errorf("expected error for extra args, got: %s", out) + } +} + +// TestVNC_RejectsExtraArgs verifies that more than 1 positional arg is rejected. +func TestVNC_RejectsExtraArgs(t *testing.T) { + cmd := exec.Command(binaryPath, "vnc", "app1", "app2") + out, err := cmd.CombinedOutput() + if err == nil { + t.Errorf("expected error for extra args, got: %s", out) + } +} + +// TestResolveAppArg_FullName verifies full name is passed to docker inspect. +func TestResolveAppArg_FullName(t *testing.T) { + cmd := exec.Command(binaryPath, "rdp", "devcell-271") + out, err := cmd.CombinedOutput() + if err == nil { + t.Fatalf("expected error for non-existent container, got: %s", out) + } + t.Logf("output: %s", out) +} + +// TestResolveAppArg_SuffixFormat verifies bare number triggers suffix lookup. +func TestResolveAppArg_SuffixFormat(t *testing.T) { + cmd := exec.Command(binaryPath, "rdp", "271") + out, err := cmd.CombinedOutput() + if err == nil { + t.Fatalf("expected error for non-existent container, got: %s", out) + } + t.Logf("output: %s", out) +} + +func TestRDP_HelpShowsExamples(t *testing.T) { + out, err := exec.Command(binaryPath, "rdp", "--help").CombinedOutput() + if err != nil { + t.Fatalf("rdp --help failed: %v", err) + } + s := string(out) + if !strings.Contains(s, "cell rdp devcell-271") { + t.Errorf("expected positional example in help, got:\n%s", s) + } + if !strings.Contains(s, "cell rdp 271") { + t.Errorf("expected suffix example in help, got:\n%s", s) + } +} diff --git a/cmd/apparg_unit_test.go b/cmd/apparg_unit_test.go new file mode 100644 index 00000000..15aab7af --- /dev/null +++ b/cmd/apparg_unit_test.go @@ -0,0 +1,82 @@ +package main + +import ( + "sort" + "testing" +) + +func TestParseContainerNames(t *testing.T) { + input := "cell-devcell-271-run\ncell-devcell-246-run\n" + names := parseContainerNames(input) + if len(names) != 2 { + t.Fatalf("expected 2 names, got %d: %v", len(names), names) + } + if names[0] != "devcell-271" || names[1] != "devcell-246" { + t.Errorf("unexpected names: %v", names) + } +} + +func TestParseContainerNames_IgnoresNonCell(t *testing.T) { + input := "cell-devcell-271-run\nsome-other-container\nryuk-abc123\n" + names := parseContainerNames(input) + if len(names) != 1 || names[0] != "devcell-271" { + t.Errorf("expected [devcell-271], got: %v", names) + } +} + +func TestParseContainerNames_Empty(t *testing.T) { + names := parseContainerNames("") + if len(names) != 0 { + t.Errorf("expected empty, got: %v", names) + } +} + +func TestParseContainerNames_ArbitraryAppName(t *testing.T) { + // App names aren't always "devcell-*" — they're based on the directory name. + input := "cell-myproject-3-run\ncell-foo-bar-0-run\n" + names := parseContainerNames(input) + if len(names) != 2 { + t.Fatalf("expected 2 names, got %d: %v", len(names), names) + } + if names[0] != "myproject-3" { + t.Errorf("names[0] = %q, want myproject-3", names[0]) + } + if names[1] != "foo-bar-0" { + t.Errorf("names[1] = %q, want foo-bar-0", names[1]) + } +} + +func TestResolveAppArg_FullNamePassthrough(t *testing.T) { + got := resolveAppArg("devcell-271") + if got != "devcell-271" { + t.Errorf("expected devcell-271, got %q", got) + } +} + +func TestResolveAppArg_SuffixNoMatch(t *testing.T) { + // No running containers will match in test env — falls through to raw value. + got := resolveAppArg("999") + if got != "999" { + t.Errorf("expected 999, got %q", got) + } +} + +func TestSelectCellOptions_Sorted(t *testing.T) { + apps := map[string]string{ + "devcell-271": "27189", + "devcell-246": "24689", + "myproject-42": "4289", + } + // Verify the picker would present sorted names. + var names []string + for name := range apps { + names = append(names, name) + } + sort.Strings(names) + expected := []string{"devcell-246", "devcell-271", "myproject-42"} + for i, name := range names { + if name != expected[i] { + t.Errorf("names[%d] = %q, want %q", i, name, expected[i]) + } + } +} diff --git a/cmd/claude.go b/cmd/claude.go index b15b9b7f..14855578 100644 --- a/cmd/claude.go +++ b/cmd/claude.go @@ -1,6 +1,13 @@ package main -import "github.com/spf13/cobra" +import ( + "fmt" + "os" + + "github.com/DimmKirr/devcell/internal/cfg" + "github.com/DimmKirr/devcell/internal/config" + "github.com/spf13/cobra" +) var claudeCmd = &cobra.Command{ Use: "claude [args...]", @@ -10,12 +17,47 @@ var claudeCmd = &cobra.Command{ The current working directory is mounted as /workspace. All additional args are forwarded to the claude binary unchanged. +Use --ollama to route Claude Code through a local ollama instance +(Anthropic Messages API compatibility). This sets ANTHROPIC_BASE_URL +to point at ollama on the host. Can also be enabled permanently via +use_ollama = true in the [claude] section of devcell.toml. + Examples: cell claude - cell claude --resume`, + cell claude --resume + cell claude --ollama`, DisableFlagParsing: true, RunE: func(cmd *cobra.Command, args []string) error { - return runAgent("claude", []string{"--dangerously-skip-permissions"}, args, nil) + return runAgent("claude", []string{"--dangerously-skip-permissions"}, args, claudeEnv()) }, } + +// claudeEnv returns extra env vars for the claude container. +// When --ollama flag or [claude] use_ollama=true is set, it injects +// env vars that redirect Claude Code's API calls to a local ollama instance. +func claudeEnv() map[string]string { + dbg := scanFlag("--debug") + useOllama := scanFlag("--ollama") + + if !useOllama { + c, err := config.LoadFromOS() + if err == nil { + cellCfg := cfg.LoadFromOS(c.ConfigDir, c.BaseDir) + useOllama = cellCfg.Claude.UseOllama + } + } + + if !useOllama { + return nil + } + + if dbg { + fmt.Fprintf(os.Stderr, " claude: ollama mode enabled, redirecting API to host ollama\n") + } + return map[string]string{ + "ANTHROPIC_BASE_URL": "http://host.docker.internal:11434", + "ANTHROPIC_AUTH_TOKEN": "ollama", + "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1", + } +} diff --git a/cmd/claude_test.go b/cmd/claude_test.go new file mode 100644 index 00000000..5eabd850 --- /dev/null +++ b/cmd/claude_test.go @@ -0,0 +1,136 @@ +package main_test + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// TestClaude_OllamaFlag_InjectsEnv verifies that "cell claude --ollama --dry-run" +// injects ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN, and +// CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC into docker argv. +func TestClaude_OllamaFlag_InjectsEnv(t *testing.T) { + home := scaffoldedHome(t) + + cmd := exec.Command(binaryPath, "claude", "--ollama", "--dry-run") + cmd.Env = append(os.Environ(), "CELL_ID=1", "HOME="+home) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("claude --ollama --dry-run failed: %v\noutput: %s", err, out) + } + + argv := string(out) + if !strings.Contains(argv, "ANTHROPIC_BASE_URL=http://host.docker.internal:11434") { + t.Errorf("expected ANTHROPIC_BASE_URL in argv:\n%s", argv) + } + if !strings.Contains(argv, "ANTHROPIC_AUTH_TOKEN=ollama") { + t.Errorf("expected ANTHROPIC_AUTH_TOKEN=ollama in argv:\n%s", argv) + } + if !strings.Contains(argv, "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1") { + t.Errorf("expected CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 in argv:\n%s", argv) + } +} + +// TestClaude_OllamaFlag_Stripped verifies --ollama is NOT forwarded to claude binary. +func TestClaude_OllamaFlag_Stripped(t *testing.T) { + home := scaffoldedHome(t) + + cmd := exec.Command(binaryPath, "claude", "--ollama", "--dry-run") + cmd.Env = append(os.Environ(), "CELL_ID=1", "HOME="+home) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("claude --ollama --dry-run failed: %v\noutput: %s", err, out) + } + + // After the image tag, --ollama should not appear + argv := strings.TrimSpace(string(out)) + // Split and find args after image tag + parts := strings.Fields(argv) + for _, p := range parts { + if p == "--ollama" { + t.Errorf("--ollama should be stripped from argv, but found it:\n%s", argv) + } + } +} + +// TestClaude_NoOllama_NoEnv verifies that without --ollama flag or config, +// no ANTHROPIC_BASE_URL is injected. +func TestClaude_NoOllama_NoEnv(t *testing.T) { + home := scaffoldedHome(t) + + cmd := exec.Command(binaryPath, "claude", "--dry-run") + cmd.Env = append(os.Environ(), "CELL_ID=1", "HOME="+home) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("claude --dry-run failed: %v\noutput: %s", err, out) + } + + argv := string(out) + if strings.Contains(argv, "ANTHROPIC_BASE_URL") { + t.Errorf("ANTHROPIC_BASE_URL should not be set without --ollama:\n%s", argv) + } + if strings.Contains(argv, "ANTHROPIC_AUTH_TOKEN") { + t.Errorf("ANTHROPIC_AUTH_TOKEN should not be set without --ollama:\n%s", argv) + } +} + +// TestClaude_ConfigUseOllama_InjectsEnv verifies that [claude] use_ollama=true +// in devcell.toml injects the ollama env vars. +func TestClaude_ConfigUseOllama_InjectsEnv(t *testing.T) { + home := scaffoldedHome(t) + + cfgDir := filepath.Join(home, ".config", "devcell") + tomlContent := `[cell] +[claude] +use_ollama = true +` + if err := os.WriteFile(filepath.Join(cfgDir, "devcell.toml"), []byte(tomlContent), 0644); err != nil { + t.Fatal(err) + } + + cmd := exec.Command(binaryPath, "claude", "--dry-run") + cmd.Env = append(os.Environ(), "CELL_ID=1", "HOME="+home) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("claude --dry-run failed: %v\noutput: %s", err, out) + } + + argv := string(out) + if !strings.Contains(argv, "ANTHROPIC_BASE_URL=http://host.docker.internal:11434") { + t.Errorf("expected ANTHROPIC_BASE_URL from config:\n%s", argv) + } + if !strings.Contains(argv, "ANTHROPIC_AUTH_TOKEN=ollama") { + t.Errorf("expected ANTHROPIC_AUTH_TOKEN=ollama from config:\n%s", argv) + } +} + +// TestClaude_OllamaWithUserArgs verifies that --ollama + user args work together. +func TestClaude_OllamaWithUserArgs(t *testing.T) { + home := scaffoldedHome(t) + + cmd := exec.Command(binaryPath, "claude", "--ollama", "--dry-run", "--resume") + cmd.Env = append(os.Environ(), "CELL_ID=1", "HOME="+home) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("claude --ollama --dry-run --resume failed: %v\noutput: %s", err, out) + } + + argv := strings.TrimSpace(string(out)) + // --resume should be forwarded + if !strings.Contains(argv, "--resume") { + t.Errorf("expected --resume in argv:\n%s", argv) + } + // --ollama should NOT be forwarded + fields := strings.Fields(argv) + for _, f := range fields { + if f == "--ollama" { + t.Errorf("--ollama leaked into argv:\n%s", argv) + } + } + // ollama env should be present + if !strings.Contains(argv, "ANTHROPIC_BASE_URL") { + t.Errorf("expected ANTHROPIC_BASE_URL in argv:\n%s", argv) + } +} diff --git a/cmd/init.go b/cmd/init.go index 7fdf2836..b5c2a360 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -77,7 +77,7 @@ func detectOllamaModels() string { return "" } ranked := ollama.RankModels(models, 10, nil, nil) - snippet := ollama.FormatTOMLSnippet(ranked) + snippet := ollama.FormatActiveTOMLSnippet(ranked) if snippet != "" { fmt.Printf(" Detected ollama with %d models\n", len(ranked)) } diff --git a/cmd/opencode.go b/cmd/opencode.go index c32474a3..ef6c7be5 100644 --- a/cmd/opencode.go +++ b/cmd/opencode.go @@ -1,11 +1,16 @@ package main import ( + "context" "encoding/json" + "fmt" + "os" + "path/filepath" "sort" "github.com/DimmKirr/devcell/internal/cfg" "github.com/DimmKirr/devcell/internal/config" + "github.com/DimmKirr/devcell/internal/ollama" "github.com/spf13/cobra" ) @@ -24,8 +29,9 @@ var opencodeCmd = &cobra.Command{ The current working directory is mounted as /workspace. All additional args are forwarded to the opencode binary unchanged. -When [models] is configured in devcell.toml, provider and permission -settings are injected via OPENCODE_CONFIG_CONTENT (highest precedence). +On first run, if no opencode config exists at $CELL_HOME/.config/opencode/, +locally available ollama models are auto-detected and written there. +When [models] is configured in devcell.toml, those models are used instead. Examples: @@ -68,24 +74,185 @@ func init() { opencodeCmd.AddCommand(opencodeResumeCmd) } +// opencodeConfigPath returns the path to opencode's config file in the cell +// home directory. This is the same .opencode.json that the entrypoint merges +// MCP servers into, so models and MCP coexist in one file. +func opencodeConfigPath(cellHome string) string { + return filepath.Join(cellHome, ".opencode.json") +} + // opencodeEnv returns extra env vars for the opencode container. -// Always injects OPENCODE_CONFIG_CONTENT with at least permission:"allow". -// When [models] is configured in devcell.toml, includes provider/model config. +// +// On every run it detects available models (from devcell.toml or ollama) and +// merges them into $CELL_HOME/.opencode.json, preserving any existing keys +// (MCP servers, user customizations). The merged result is also injected +// via OPENCODE_CONFIG_CONTENT. func opencodeEnv() map[string]string { + dbg := scanFlag("--debug") + c, err := config.LoadFromOS() + if err != nil { + if dbg { + fmt.Fprintf(os.Stderr, " opencode: config load failed, using minimal config\n") + } + return map[string]string{ + "OPENCODE_CONFIG_CONTENT": string(buildOpencodeJSON(cfg.ModelsSection{})), + } + } + + // Resolve models: devcell.toml [models] > auto-detect ollama > empty. + cellCfg := cfg.LoadFromOS(c.ConfigDir, c.BaseDir) + models := cellCfg.Models + if len(models.Providers) > 0 { + if dbg { + fmt.Fprintf(os.Stderr, " opencode: using models from devcell.toml [models]\n") + } + } else { + if dbg { + fmt.Fprintf(os.Stderr, " opencode: no [models] in devcell.toml, probing ollama...\n") + } + models = autoDetectOllamaModels() + } + + if dbg { + if models.Default != "" { + fmt.Fprintf(os.Stderr, " opencode: default model: %s\n", models.Default) + } else { + fmt.Fprintf(os.Stderr, " opencode: no models detected\n") + } + } + + configPath := opencodeConfigPath(c.CellHome) + + // Build the model/provider portion of the config. + modelData := buildOpencodeJSON(models) + + // Merge into existing .opencode.json (preserves MCP servers, user keys). + merged, err := mergeOpencodeConfig(configPath, modelData, dbg) + if err != nil { + fmt.Fprintf(os.Stderr, "warning: could not merge opencode config: %v\n", err) + merged = modelData + } + + // Write merged result back to disk. + if err := writeOpencodeConfig(configPath, merged); err != nil { + fmt.Fprintf(os.Stderr, "warning: could not write opencode config: %v\n", err) + } else if dbg { + fmt.Fprintf(os.Stderr, " opencode: config written to %s\n", configPath) + } + return map[string]string{ - "OPENCODE_CONFIG_CONTENT": string(buildOpencodeJSON(loadModelsSection())), + "OPENCODE_CONFIG_CONTENT": string(merged), } } -// loadModelsSection loads the [models] section from devcell.toml. -// Returns zero value if config can't be loaded (non-fatal). -func loadModelsSection() cfg.ModelsSection { - c, err := config.LoadFromOS() +// mergeOpencodeConfig reads an existing .opencode.json and merges model/provider +// fields from newData into it. Existing keys (mcp, user customizations) are +// preserved. Model fields ($schema, permission, model, provider) are overwritten. +func mergeOpencodeConfig(path string, newData []byte, dbg bool) ([]byte, error) { + existing, err := os.ReadFile(path) if err != nil { + if dbg { + fmt.Fprintf(os.Stderr, " opencode: no existing config, creating new\n") + } + return newData, nil + } + + // Parse existing config as generic map to preserve unknown keys. + var base map[string]json.RawMessage + if err := json.Unmarshal(existing, &base); err != nil { + if dbg { + fmt.Fprintf(os.Stderr, " opencode: existing config invalid JSON, replacing\n") + } + return newData, nil + } + + // Parse new model data. + var overlay map[string]json.RawMessage + if err := json.Unmarshal(newData, &overlay); err != nil { + return nil, fmt.Errorf("parse model data: %w", err) + } + + // Merge: overlay keys overwrite base keys. + for k, v := range overlay { + base[k] = v + } + + if dbg { + fmt.Fprintf(os.Stderr, " opencode: merged models into existing config (%d keys)\n", len(base)) + } + + return json.Marshal(base) +} + +// autoDetectOllamaModels probes the local ollama instance for available models, +// ranks them by SWE-bench fallback ratings, and returns a ModelsSection with +// all detected models and the #1 ranked model as default. +func autoDetectOllamaModels() cfg.ModelsSection { + dbg := scanFlag("--debug") + ctx := context.Background() + if !ollama.Detect(ctx, ollama.DefaultBaseURL) { + if dbg { + fmt.Fprintf(os.Stderr, " opencode: ollama not detected at %s\n", ollama.DefaultBaseURL) + } return cfg.ModelsSection{} } - cellCfg := cfg.LoadFromOS(c.ConfigDir, c.BaseDir) - return cellCfg.Models + models, err := ollama.FetchModels(ctx, ollama.DefaultBaseURL) + if err != nil || len(models) == 0 { + if dbg { + fmt.Fprintf(os.Stderr, " opencode: no models found from ollama\n") + } + return cfg.ModelsSection{} + } + if dbg { + fmt.Fprintf(os.Stderr, " opencode: found %d models from ollama\n", len(models)) + } + ranked := ollama.RankModels(models, 0, nil, nil) + if len(ranked) == 0 { + return cfg.ModelsSection{} + } + + if dbg { + for _, r := range ranked { + fmt.Fprintf(os.Stderr, " opencode: #%d %s (score: %.1f, source: %s)\n", r.Rank, r.Name, r.SWEScore, r.ScoreSource) + } + fmt.Fprintf(os.Stderr, " opencode: best → %s\n", ranked[0].Name) + } + + var names []string + for _, r := range ranked { + names = append(names, r.Name) + } + + // Only provide the model list — don't auto-select a default. + // The user picks in opencode's UI, or sets [models] default in devcell.toml. + return cfg.ModelsSection{ + Providers: map[string]cfg.LLMProvider{ + "ollama": {Models: names}, + }, + } +} + +// writeOpencodeConfig writes JSON data to the opencode config path, +// creating parent directories as needed. +func writeOpencodeConfig(path string, data []byte) error { + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return err + } + // Pretty-print for user readability. + var pretty json.RawMessage = data + formatted, err := json.MarshalIndent(pretty, "", " ") + if err != nil { + formatted = data + } + return os.WriteFile(path, append(formatted, '\n'), 0644) +} + +// fileExists returns true if path exists and is a regular file. +var fileExists = defaultFileExists + +func defaultFileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() } // opencodeJSON is the structure marshalled to opencode.json. diff --git a/cmd/opencode_test.go b/cmd/opencode_test.go index ade2bb33..18ad2246 100644 --- a/cmd/opencode_test.go +++ b/cmd/opencode_test.go @@ -193,6 +193,117 @@ func TestOpencode_ConfigContentNoModels(t *testing.T) { } } +// TestOpencode_ExistingConfigMergesModels verifies that when .opencode.json +// already exists, models are merged in while preserving existing keys (e.g. mcp). +func TestOpencode_ExistingConfigMergesModels(t *testing.T) { + home := scaffoldedHome(t) + + cellHome := filepath.Join(home, ".devcell", "main") + if err := os.MkdirAll(cellHome, 0755); err != nil { + t.Fatal(err) + } + // Pre-existing .opencode.json with MCP servers and a custom key. + if err := os.WriteFile( + filepath.Join(cellHome, ".opencode.json"), + []byte(`{"mcp":{"my-server":{"command":"node","args":["server.js"]}},"customKey":"preserved"}`), + 0644, + ); err != nil { + t.Fatal(err) + } + + // Write devcell.toml with [models] so models get injected. + cfgDir := filepath.Join(home, ".config", "devcell") + tomlContent := `[cell] +[models] +default = "ollama/qwen3:8b" +[models.providers.ollama] +models = ["qwen3:8b"] +` + if err := os.WriteFile(filepath.Join(cfgDir, "devcell.toml"), []byte(tomlContent), 0644); err != nil { + t.Fatal(err) + } + + cmd := exec.Command(binaryPath, "opencode", "--dry-run") + cmd.Env = append(os.Environ(), "CELL_ID=1", "HOME="+home) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("opencode --dry-run failed: %v\noutput: %s", err, out) + } + + // OPENCODE_CONFIG_CONTENT should be injected (merge always injects). + argv := string(out) + if !strings.Contains(argv, "OPENCODE_CONFIG_CONTENT=") { + t.Fatalf("OPENCODE_CONFIG_CONTENT not found in argv:\n%s", argv) + } + + // Read the merged file from disk. + data, err := os.ReadFile(filepath.Join(cellHome, ".opencode.json")) + if err != nil { + t.Fatalf("expected .opencode.json: %v", err) + } + var parsed map[string]interface{} + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + + // Models should be injected. + if parsed["model"] != "ollama/qwen3:8b" { + t.Errorf("expected model ollama/qwen3:8b, got: %v", parsed["model"]) + } + // MCP servers should be preserved. + if _, ok := parsed["mcp"]; !ok { + t.Errorf("mcp key should be preserved after merge, got: %v", parsed) + } + // Custom keys should be preserved. + if parsed["customKey"] != "preserved" { + t.Errorf("customKey should be preserved after merge, got: %v", parsed["customKey"]) + } +} + +// TestOpencode_WritesConfigToDisk verifies that when no opencode config +// exists, one is created at $CellHome/.config/opencode/opencode.json. +func TestOpencode_WritesConfigToDisk(t *testing.T) { + home := scaffoldedHome(t) + + // Write devcell.toml with [models] section + cfgDir := filepath.Join(home, ".config", "devcell") + tomlContent := `[cell] +[models] +default = "ollama/deepseek-r1:32b" +[models.providers.ollama] +models = ["deepseek-r1:32b"] +` + if err := os.WriteFile(filepath.Join(cfgDir, "devcell.toml"), []byte(tomlContent), 0644); err != nil { + t.Fatal(err) + } + + cmd := exec.Command(binaryPath, "opencode", "--dry-run") + cmd.Env = append(os.Environ(), "CELL_ID=1", "HOME="+home) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("opencode --dry-run failed: %v\noutput: %s", err, out) + } + + // Check that .opencode.json was written to disk + cellHome := filepath.Join(home, ".devcell", "main") + configPath := filepath.Join(cellHome, ".opencode.json") + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("expected opencode.json to be written at %s: %v", configPath, err) + } + + var parsed map[string]interface{} + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatalf("written opencode.json is not valid JSON: %v", err) + } + if parsed["permission"] != "allow" { + t.Errorf("expected permission:allow in written config, got: %v", parsed["permission"]) + } + if parsed["model"] != "ollama/deepseek-r1:32b" { + t.Errorf("expected model ollama/deepseek-r1:32b, got: %v", parsed["model"]) + } +} + // extractEnvFromArgv finds -e KEY=VALUE in a shell-joined argv string and // returns VALUE. shellJoin wraps the whole "-e" arg in single quotes when it // contains special chars, producing: 'KEY=VALUE' diff --git a/cmd/rdp.go b/cmd/rdp.go index 6654a8ef..a07be0aa 100644 --- a/cmd/rdp.go +++ b/cmd/rdp.go @@ -15,20 +15,28 @@ import ( ) var rdpCmd = &cobra.Command{ - Use: "rdp", + Use: "rdp [app-name or suffix]", Short: "Open RDP connection to the running devcell container", - RunE: runRDP, + Long: `Open an RDP connection to a running devcell container. + +When multiple containers are running, specify which one by app name or +just the numeric suffix: + + cell rdp devcell-271 + cell rdp 271`, + Args: cobra.MaximumNArgs(1), + RunE: runRDP, + ValidArgsFunction: completeRunningApps, } func init() { rdpCmd.Flags().Bool("list", false, "list all running cell containers and their RDP ports") - rdpCmd.Flags().String("app", "", "open RDP to a named container (by AppName)") rdpCmd.Flags().Bool("verbose", false, "show debug info for RDP port lookup") rdpCmd.Flags().Bool("fullscreen", false, "open RDP session in fullscreen mode") rdpCmd.Flags().String("viewer", "", "RDP viewer: freerdp (default), macrdp, royaltsx") } -func runRDP(cmd *cobra.Command, _ []string) error { +func runRDP(cmd *cobra.Command, args []string) error { applyOutputFlags() verbose, _ := cmd.Flags().GetBool("verbose") if verbose { @@ -36,15 +44,14 @@ func runRDP(cmd *cobra.Command, _ []string) error { ux.LogPlainText = true } list, _ := cmd.Flags().GetBool("list") - appName, _ := cmd.Flags().GetString("app") rdpFullscreen, _ = cmd.Flags().GetBool("fullscreen") rdpViewer, _ = cmd.Flags().GetString("viewer") if list { return rdpList() } - if appName != "" { - return rdpApp(appName) + if len(args) > 0 { + return rdpApp(resolveAppArg(args[0])) } return rdpDefault() } @@ -177,11 +184,11 @@ func rdpDefault() error { return openRDP(c, port) } } - var opts []string - for appName := range m { - opts = append(opts, " cell rdp --app "+appName) + selected, err := selectCell(m) + if err != nil { + return err } - return fmt.Errorf("multiple containers for this directory — pick one:\n%s", strings.Join(opts, "\n")) + return openRDP(c, m[selected]) } } @@ -189,7 +196,7 @@ func rdpDefault() error { rdpDebug("no label match; falling back to bind-mount inspect") allOut, err := exec.Command("docker", "ps", "-q", "--filter", "name=cell-").Output() if err != nil || len(bytes.TrimSpace(allOut)) == 0 { - return fmt.Errorf("no running container found for %q — run 'cell rdp --list' to see all", c.BaseDir) + return fmt.Errorf("no running cell found for %q — run 'cell rdp --list' to see all", c.BaseDir) } ids := strings.Fields(string(bytes.TrimSpace(allOut))) rdpDebug("inspecting %d containers: %v", len(ids), ids) @@ -204,15 +211,19 @@ func rdpDefault() error { rdpDebug("bind-mount matches: %+v", matches) switch len(matches) { case 0: - return fmt.Errorf("no running container found for %q — run 'cell rdp --list' to see all", c.BaseDir) + return fmt.Errorf("no running cell found for %q — run 'cell rdp --list' to see all", c.BaseDir) case 1: return openRDP(c, matches[0].Port) default: - var opts []string + bindM := make(map[string]string, len(matches)) for _, m := range matches { - opts = append(opts, " cell rdp --app "+m.AppName) + bindM[m.AppName] = m.Port + } + selected, err := selectCell(bindM) + if err != nil { + return err } - return fmt.Errorf("multiple containers for this directory — pick one:\n%s", strings.Join(opts, "\n")) + return openRDP(c, bindM[selected]) } } diff --git a/cmd/root.go b/cmd/root.go index 09733f71..d0255dab 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -94,6 +94,7 @@ var cellBoolFlags = map[string]bool{ "--plain-text": true, "--debug": true, "--macos": true, + "--ollama": true, } // cellStringFlags are string flags consumed by devcell: strip the flag token @@ -196,6 +197,15 @@ func runAgent(binary string, defaultFlags, userArgs []string, extraEnv map[strin } } + if ux.Verbose { + fmt.Printf(" APP_NAME: %s | VNC: localhost:%s | RDP: localhost:%s | HOME: %s\n", + c.AppName, c.VNCPort, c.RDPPort, c.CellHome) + } + + if !ux.Verbose { + ux.Println(fmt.Sprintf("Opening Cell %s ...", c.AppName)) + } + // Ensure network if err := runner.EnsureNetwork(context.Background()); err != nil { fmt.Fprintf(os.Stderr, "warning: network setup failed: %v\n", err) @@ -211,11 +221,6 @@ func runAgent(binary string, defaultFlags, userArgs []string, extraEnv map[strin fmt.Fprintf(os.Stderr, "warning: backup failed: %v\n", err) } - if ux.Verbose { - fmt.Printf(" APP_NAME: %s | VNC: localhost:%s | RDP: localhost:%s | HOME: %s\n", - c.AppName, c.VNCPort, c.RDPPort, c.CellHome) - } - // Pin the container to the exact image ID just built so a concurrent // cell build on another terminal can't swap the tag under us mid-launch. imageID, err := runner.LocalImageID(context.Background()) @@ -241,12 +246,31 @@ func runAgent(binary string, defaultFlags, userArgs []string, extraEnv map[strin return nil } - // Replace process with docker (or op if prefix present) - execBin, err := exec.LookPath(argv[0]) - if err != nil { - return fmt.Errorf("binary not found %q: %w", argv[0], err) + cmd := exec.Command(argv[0], argv[1:]...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Start(); err != nil { + return fmt.Errorf("start %q: %w", argv[0], err) + } + + // Forward signals to the child process. + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + go func() { + for sig := range sigCh { + _ = cmd.Process.Signal(sig) + } + }() + + if err := cmd.Wait(); err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + os.Exit(exitErr.ExitCode()) + } + return err } - return syscall.Exec(execBin, argv, os.Environ()) + return nil } // osArgs is the argument source for flag scanning. Overridable in tests. diff --git a/cmd/strip_test.go b/cmd/strip_test.go index 1f303991..76fc67f9 100644 --- a/cmd/strip_test.go +++ b/cmd/strip_test.go @@ -70,6 +70,14 @@ func TestStripCellFlags_MultipleMixed(t *testing.T) { } } +func TestStripCellFlags_OllamaStripped(t *testing.T) { + got := stripCellFlags([]string{"--ollama", "--resume"}) + want := []string{"--resume"} + if !reflect.DeepEqual(got, want) { + t.Errorf("want %v, got %v", want, got) + } +} + func TestStripCellFlags_BaseImageStripped(t *testing.T) { got := stripCellFlags([]string{"--base-image", "myregistry/img:v1", "--resume", "abc"}) want := []string{"--resume", "abc"} diff --git a/cmd/vnc.go b/cmd/vnc.go index f3d3dce7..297607da 100644 --- a/cmd/vnc.go +++ b/cmd/vnc.go @@ -16,19 +16,27 @@ import ( ) var vncCmd = &cobra.Command{ - Use: "vnc", + Use: "vnc [app-name or suffix]", Short: "Open VNC connection to the running devcell container", - RunE: runVNC, + Long: `Open a VNC connection to a running devcell container. + +When multiple containers are running, specify which one by app name or +just the numeric suffix: + + cell vnc devcell-271 + cell vnc 271`, + Args: cobra.MaximumNArgs(1), + RunE: runVNC, + ValidArgsFunction: completeRunningApps, } func init() { vncCmd.Flags().Bool("list", false, "list all running cell containers and their VNC ports") - vncCmd.Flags().String("app", "", "open VNC to a named container (by AppName)") vncCmd.Flags().Bool("verbose", false, "show debug info for VNC port lookup") vncCmd.Flags().String("viewer", "", "VNC viewer: royaltsx, tigervnc, screensharing (macOS)") } -func runVNC(cmd *cobra.Command, _ []string) error { +func runVNC(cmd *cobra.Command, args []string) error { applyOutputFlags() verbose, _ := cmd.Flags().GetBool("verbose") if verbose { @@ -36,14 +44,13 @@ func runVNC(cmd *cobra.Command, _ []string) error { ux.LogPlainText = true } list, _ := cmd.Flags().GetBool("list") - appName, _ := cmd.Flags().GetString("app") vncViewer, _ = cmd.Flags().GetString("viewer") if list { return vncList() } - if appName != "" { - return vncApp(appName) + if len(args) > 0 { + return vncApp(resolveAppArg(args[0])) } return vncDefault() } @@ -155,12 +162,11 @@ func vncDefault() error { return openVNC(port) } } - // Multiple sessions — list options. - var opts []string - for appName := range m { - opts = append(opts, " cell vnc --app "+appName) + selected, err := selectCell(m) + if err != nil { + return err } - return fmt.Errorf("multiple containers for this directory — pick one:\n%s", strings.Join(opts, "\n")) + return openVNC(m[selected]) } } @@ -168,7 +174,7 @@ func vncDefault() error { vncDebug("no label match; falling back to bind-mount inspect") allOut, err := exec.Command("docker", "ps", "-q", "--filter", "name=cell-").Output() if err != nil || len(bytes.TrimSpace(allOut)) == 0 { - return fmt.Errorf("no running container found for %q — run 'cell vnc --list' to see all", c.BaseDir) + return fmt.Errorf("no running cell found for %q — run 'cell vnc --list' to see all", c.BaseDir) } ids := strings.Fields(string(bytes.TrimSpace(allOut))) vncDebug("inspecting %d containers: %v", len(ids), ids) @@ -183,15 +189,19 @@ func vncDefault() error { vncDebug("bind-mount matches: %+v", matches) switch len(matches) { case 0: - return fmt.Errorf("no running container found for %q — run 'cell vnc --list' to see all", c.BaseDir) + return fmt.Errorf("no running cell found for %q — run 'cell vnc --list' to see all", c.BaseDir) case 1: return openVNC(matches[0].Port) default: - var opts []string + bindM := make(map[string]string, len(matches)) for _, m := range matches { - opts = append(opts, " cell vnc --app "+m.AppName) + bindM[m.AppName] = m.Port + } + selected, err := selectCell(bindM) + if err != nil { + return err } - return fmt.Errorf("multiple containers for this directory — pick one:\n%s", strings.Join(opts, "\n")) + return openVNC(bindM[selected]) } } diff --git a/go.mod b/go.mod index d689e395..fbcc3460 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/BurntSushi/toml v1.4.0 github.com/creack/pty v1.1.24 github.com/docker/docker v28.5.1+incompatible + github.com/ollama/ollama v0.17.6 github.com/pterm/pterm v0.12.80 github.com/spf13/cobra v1.8.1 github.com/testcontainers/testcontainers-go v0.40.0 @@ -28,7 +29,6 @@ require ( github.com/containerd/log v0.1.0 // indirect github.com/containerd/platforms v0.2.1 // indirect github.com/cpuguy83/dockercfg v0.3.2 // indirect - github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/go-connections v0.6.0 // indirect @@ -55,14 +55,12 @@ require ( github.com/moby/sys/userns v0.1.0 // indirect github.com/moby/term v0.5.0 // indirect github.com/morikuni/aec v1.0.0 // indirect - github.com/ollama/ollama v0.17.6 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/shirou/gopsutil/v4 v4.25.6 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/spf13/pflag v1.0.5 // indirect diff --git a/go.sum b/go.sum index 07faae13..b7d85543 100644 --- a/go.sum +++ b/go.sum @@ -46,7 +46,6 @@ github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpS github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= -github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= @@ -83,21 +82,16 @@ github.com/gookit/color v1.5.4 h1:FZmqs7XOyGgCAxmWyPslpiok1k05wmY3SJTytgvYFs0= github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= -github.com/hupe1980/go-huggingface v0.0.15 h1:tTWmUGGunC/BYz4hrwS8SSVtMYVYjceG2uhL8HxeXvw= -github.com/hupe1980/go-huggingface v0.0.15/go.mod h1:IRvsik3+b9BJyw9hCfw1arI6gDObcVto1UA8f3kt8mM= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= -github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw= github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.0.10/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= -github.com/klauspost/cpuid/v2 v2.2.3 h1:sxCkb+qR91z4vsqw4vGGZlDgPz3G7gjaLyK3V8y70BU= -github.com/klauspost/cpuid/v2 v2.2.3/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= +github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -156,14 +150,10 @@ github.com/pterm/pterm v0.12.40/go.mod h1:ffwPLwlbXxP+rxT0GsgDTzS3y3rmpAO1NMjUkG github.com/pterm/pterm v0.12.80 h1:mM55B+GnKUnLMUSqhdINe4s6tOuVQIetQ3my8JGyAIg= github.com/pterm/pterm v0.12.80/go.mod h1:c6DeF9bSnOSeFPZlfs4ZRAFcf5SCoTwvwQ5xaKGQlHo= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= -github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= -github.com/russross/blackfriday v1.6.0 h1:KqfZb0pUVN2lYqZUYRddxF4OR8ZMURnJIG5Y3VRLtww= -github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ= github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= @@ -219,18 +209,16 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= golang.org/x/exp v0.0.0-20250218142911-aa4b98e5adaa h1:t2QcU6V556bFjYgu4L6C+6VrCPyJZ+eyRsABUPs1mz4= +golang.org/x/exp v0.0.0-20250218142911-aa4b98e5adaa/go.mod h1:BHOTPb3L19zxehTsLoJXVaTktb06DFgmdW6Wb9s8jqk= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM= -golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= +golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/images/entrypoint.sh b/images/entrypoint.sh index 754b866f..aeedec93 100755 --- a/images/entrypoint.sh +++ b/images/entrypoint.sh @@ -12,12 +12,20 @@ export HOME="/home/$HOST_USER" # ── Verbose logging — only active when DEVCELL_DEBUG=true ───────────────────── # Default is silent so containers launched without the flag stay quiet. +# Debug mode includes elapsed time since entrypoint start for profiling. +_ENTRYPOINT_T0=$(($(date +%s%N) / 1000000)) if [ "${DEVCELL_DEBUG:-false}" = "true" ]; then - log() { echo "$@"; } + log() { + local _ms=$(( $(date +%s%N) / 1000000 - _ENTRYPOINT_T0 )) + printf '[%d.%03ds] %s\n' $((_ms/1000)) $((_ms%1000)) "$*" + } else log() { :; } fi + +log "Entrypoint start (user=$HOST_USER app=${APP_NAME:-})" + # ── Create session user if needed ───────────────────────────────────────────── if ! id "$HOST_USER" &>/dev/null; then useradd -m -s /bin/zsh "$HOST_USER" 2>/dev/null @@ -118,23 +126,37 @@ setup_asdf_home() { ASDF_DATA_DIR="$user_asdf" HOME="$HOME" "$asdf_bin" reshim 2>/dev/null || true # Install any versions listed in ~/.tool-versions that aren't baked. - # Runs on first start (or after user edits the file); subsequent starts are - # instant because installed versions persist in the cell home. + # Skips if the file hasn't changed since the last successful install + # (checksum stored in ~/.asdf). First start or edits trigger a full check. if [ -f "$HOME/.tool-versions" ]; then - log "Installing global tool versions from ~/.tool-versions..." - (cd "$HOME" && ASDF_DATA_DIR="$user_asdf" HOME="$HOME" USER="$HOST_USER" \ - "$asdf_bin" install 2>&1) | while IFS= read -r line; do log "$line"; done || true - chown -R "$HOST_USER" "$user_asdf" + local tv_sha + tv_sha=$(sha256sum "$HOME/.tool-versions" 2>/dev/null | cut -d' ' -f1) + if [ -f "$user_asdf/.tv-global.sha" ] && [ "$(cat "$user_asdf/.tv-global.sha" 2>/dev/null)" = "$tv_sha" ]; then + log "Global .tool-versions unchanged, skipping install" + else + log "Installing global tool versions from ~/.tool-versions..." + (cd "$HOME" && ASDF_DATA_DIR="$user_asdf" HOME="$HOME" USER="$HOST_USER" \ + "$asdf_bin" install 2>&1) | while IFS= read -r line; do log "$line"; done || true + chown -R "$HOST_USER" "$user_asdf" + echo "$tv_sha" > "$user_asdf/.tv-global.sha" + fi fi # If the workspace has a .tool-versions, install any missing versions now so # they land in ~/.asdf (CellHome) and persist — no re-download on next start. local workspace="/${APP_NAME:-}" if [ -n "$APP_NAME" ] && [ -f "$workspace/.tool-versions" ]; then - log "Installing workspace tool versions from $workspace/.tool-versions..." - (cd "$workspace" && ASDF_DATA_DIR="$user_asdf" HOME="$HOME" USER="$HOST_USER" \ - "$asdf_bin" install 2>&1) | while IFS= read -r line; do log "$line"; done || true - chown -R "$HOST_USER" "$user_asdf" + local ws_sha + ws_sha=$(sha256sum "$workspace/.tool-versions" 2>/dev/null | cut -d' ' -f1) + if [ -f "$user_asdf/.tv-workspace.sha" ] && [ "$(cat "$user_asdf/.tv-workspace.sha" 2>/dev/null)" = "$ws_sha" ]; then + log "Workspace .tool-versions unchanged, skipping install" + else + log "Installing workspace tool versions from $workspace/.tool-versions..." + (cd "$workspace" && ASDF_DATA_DIR="$user_asdf" HOME="$HOME" USER="$HOST_USER" \ + "$asdf_bin" install 2>&1) | while IFS= read -r line; do log "$line"; done || true + chown -R "$HOST_USER" "$user_asdf" + echo "$ws_sha" > "$user_asdf/.tv-workspace.sha" + fi fi } setup_asdf_home @@ -567,4 +589,6 @@ export PLAYWRIGHT_MCP_USER_DATA_DIR="${HOME}/.playwright-${APP_NAME:-cell}" export ASDF_DATA_DIR="${HOME}/.asdf" export PATH="${HOME}/.asdf/shims:${PATH}" +log "Entrypoint ready — exec $*" + exec gosu "$USER" "$@" diff --git a/internal/cfg/cfg.go b/internal/cfg/cfg.go index 0b984ad3..31d0bbdd 100644 --- a/internal/cfg/cfg.go +++ b/internal/cfg/cfg.go @@ -36,10 +36,17 @@ type ModelsSection struct { Providers map[string]LLMProvider `toml:"providers"` } +// ClaudeSection holds [claude] config. +type ClaudeSection struct { + UseOllama bool `toml:"use_ollama"` +} + // CellConfig is the merged configuration from all TOML layers. type CellConfig struct { Cell CellSection + Claude ClaudeSection Env map[string]string + Asdf map[string]string // [asdf] — keys map to ASDF_ env vars Volumes []VolumeMount Packages PackagesSection Models ModelsSection `toml:"models"` @@ -68,6 +75,7 @@ func Merge(global, project CellConfig) CellConfig { out := CellConfig{ Cell: global.Cell, Env: make(map[string]string), + Asdf: make(map[string]string), } // Copy global env @@ -79,6 +87,14 @@ func Merge(global, project CellConfig) CellConfig { out.Env[k] = v } + // Asdf: same accumulate semantics as Env + for k, v := range global.Asdf { + out.Asdf[k] = v + } + for k, v := range project.Asdf { + out.Asdf[k] = v + } + // Scalars: project wins when non-zero if project.Cell.ImageTag != "" { out.Cell.ImageTag = project.Cell.ImageTag @@ -87,6 +103,12 @@ func Merge(global, project CellConfig) CellConfig { out.Cell.GUI = true } + // Claude: project wins when true + out.Claude = global.Claude + if project.Claude.UseOllama { + out.Claude.UseOllama = true + } + // Slices accumulate: global first, then project out.Volumes = append(global.Volumes, project.Volumes...) diff --git a/internal/cfg/cfg_test.go b/internal/cfg/cfg_test.go index 108a6ef9..6677fa43 100644 --- a/internal/cfg/cfg_test.go +++ b/internal/cfg/cfg_test.go @@ -148,6 +148,42 @@ EXTRA = "yes" } } +// --- Asdf section --- + +func TestLoadFile_AsdfSection(t *testing.T) { + dir := t.TempDir() + writeTOML(t, dir, "devcell.toml", ` +[asdf] +golang_mod_version_enabled = "true" +legacy_version_file = "yes" +`) + c, err := cfg.LoadFile(filepath.Join(dir, "devcell.toml")) + if err != nil { + t.Fatal(err) + } + if c.Asdf["golang_mod_version_enabled"] != "true" { + t.Errorf("golang_mod_version_enabled: want true, got %q", c.Asdf["golang_mod_version_enabled"]) + } + if c.Asdf["legacy_version_file"] != "yes" { + t.Errorf("legacy_version_file: want yes, got %q", c.Asdf["legacy_version_file"]) + } +} + +func TestMerge_AsdfAccumulates(t *testing.T) { + global := cfg.CellConfig{Asdf: map[string]string{"A": "1", "B": "global"}} + project := cfg.CellConfig{Asdf: map[string]string{"B": "project", "C": "3"}} + merged := cfg.Merge(global, project) + if merged.Asdf["A"] != "1" { + t.Errorf("A should be 1, got %q", merged.Asdf["A"]) + } + if merged.Asdf["B"] != "project" { + t.Errorf("B: project should win, got %q", merged.Asdf["B"]) + } + if merged.Asdf["C"] != "3" { + t.Errorf("C should be 3, got %q", merged.Asdf["C"]) + } +} + // --- GUI field --- func TestLoadFile_GUITrue(t *testing.T) { @@ -295,3 +331,50 @@ func TestMerge_ModelsProjectWins(t *testing.T) { t.Errorf("ollama models should be project's, got %v", merged.Models.Providers["ollama"].Models) } } + +// --- Claude section --- + +func TestLoadFile_ClaudeUseOllama(t *testing.T) { + dir := t.TempDir() + writeTOML(t, dir, "devcell.toml", ` +[claude] +use_ollama = true +`) + c, err := cfg.LoadFile(filepath.Join(dir, "devcell.toml")) + if err != nil { + t.Fatal(err) + } + if !c.Claude.UseOllama { + t.Error("expected UseOllama=true after parsing use_ollama=true") + } +} + +func TestLoadFile_ClaudeDefaultsFalse(t *testing.T) { + dir := t.TempDir() + writeTOML(t, dir, "devcell.toml", `[cell]`) + c, err := cfg.LoadFile(filepath.Join(dir, "devcell.toml")) + if err != nil { + t.Fatal(err) + } + if c.Claude.UseOllama { + t.Error("expected UseOllama=false when not set") + } +} + +func TestMerge_ClaudeProjectEnablesOverGlobal(t *testing.T) { + global := cfg.CellConfig{Claude: cfg.ClaudeSection{UseOllama: false}} + project := cfg.CellConfig{Claude: cfg.ClaudeSection{UseOllama: true}} + merged := cfg.Merge(global, project) + if !merged.Claude.UseOllama { + t.Error("expected project use_ollama=true to win over global false") + } +} + +func TestMerge_ClaudeGlobalKeptWhenProjectUnset(t *testing.T) { + global := cfg.CellConfig{Claude: cfg.ClaudeSection{UseOllama: true}} + project := cfg.CellConfig{} + merged := cfg.Merge(global, project) + if !merged.Claude.UseOllama { + t.Error("expected global use_ollama=true to be preserved when project unset") + } +} diff --git a/internal/ollama/ollama.go b/internal/ollama/ollama.go index 0e9936c7..49c9fc2b 100644 --- a/internal/ollama/ollama.go +++ b/internal/ollama/ollama.go @@ -134,11 +134,17 @@ func resolveScore(m Model, sweScores map[string]float64, hfInfoMap map[string]HF } } - // Strategy 3: hardcoded fallback. + // Strategy 3: hardcoded fallback (exact name match). if s := sweBenchRatings[m.Name]; s > 0 { return s, "est" } + // Strategy 4: loose fallback — match by family:size prefix. + // e.g. "qwen3-coder:30b-128k" matches "qwen3-coder:30b" + if s := matchFallbackScore(m.Name); s > 0 { + return s, "est" + } + return 0, "" } @@ -162,3 +168,24 @@ func FormatTOMLSnippet(ranked []RankedModel) string { return b.String() } + +// FormatActiveTOMLSnippet generates an active (uncommented) TOML snippet +// for devcell.toml from ranked models. The #1 ranked model becomes the default. +func FormatActiveTOMLSnippet(ranked []RankedModel) string { + if len(ranked) == 0 { + return "" + } + + var names []string + for _, r := range ranked { + names = append(names, fmt.Sprintf("%q", r.Name)) + } + + var b strings.Builder + b.WriteString("[models]\n") + b.WriteString(fmt.Sprintf("default = \"ollama/%s\"\n", ranked[0].Name)) + b.WriteString("\n[models.providers.ollama]\n") + b.WriteString(fmt.Sprintf("models = [%s]\n", strings.Join(names, ", "))) + + return b.String() +} diff --git a/internal/ollama/ollama_test.go b/internal/ollama/ollama_test.go index 5bbef45c..abf3547f 100644 --- a/internal/ollama/ollama_test.go +++ b/internal/ollama/ollama_test.go @@ -301,6 +301,46 @@ func TestFormatTOMLSnippet_ProducesCommentedConfig(t *testing.T) { } } +func TestFormatActiveTOMLSnippet_ProducesUncommentedConfig(t *testing.T) { + ranked := []ollama.RankedModel{ + {Model: ollama.Model{Name: "deepseek-r1:70b"}, SWEScore: 43.8, Rank: 1}, + {Model: ollama.Model{Name: "qwen3:32b"}, SWEScore: 38.2, Rank: 2}, + } + + snippet := ollama.FormatActiveTOMLSnippet(ranked) + + if len(snippet) == 0 { + t.Fatal("expected non-empty snippet") + } + // Should start with active TOML (no comment prefix) + if snippet[0] == '#' { + t.Error("expected snippet to NOT start with comment") + } + // Should have the default model set to #1 ranked + if !contains(snippet, `default = "ollama/deepseek-r1:70b"`) { + t.Error("expected default model to be #1 ranked") + } + // Should list both models + if !contains(snippet, "deepseek-r1:70b") || !contains(snippet, "qwen3:32b") { + t.Error("expected both models in snippet") + } + // Should have [models] header + if !contains(snippet, "[models]") { + t.Error("expected [models] section header") + } + // Should have [models.providers.ollama] header + if !contains(snippet, "[models.providers.ollama]") { + t.Error("expected [models.providers.ollama] section header") + } +} + +func TestFormatActiveTOMLSnippet_Empty(t *testing.T) { + snippet := ollama.FormatActiveTOMLSnippet(nil) + if snippet != "" { + t.Errorf("expected empty string for nil ranked, got: %q", snippet) + } +} + func contains(s, sub string) bool { return len(s) > 0 && len(sub) > 0 && indexOf(s, sub) >= 0 } diff --git a/internal/ollama/ratings.go b/internal/ollama/ratings.go index c16f43b4..ff7abd9c 100644 --- a/internal/ollama/ratings.go +++ b/internal/ollama/ratings.go @@ -18,77 +18,88 @@ package ollama // - CodeLlama paper: https://arxiv.org/abs/2308.12950 // - DeepSeek-Coder-V2: https://arxiv.org/abs/2406.11931 var sweBenchRatings = map[string]float64{ + // Qwen 3 Coder Next (MoE 80B/A3B, Feb 2026) + // SWE-bench Verified: 70.6% (SWE-Agent), Aider: 66.2% + // Near Sonnet 4.5 level despite only 3B active params + "qwen3-coder-next:latest": 58.0, + + // Qwen 3 Coder (MoE 30B/A3B, agentic coding model) + // SWE-bench Verified: 67-69.6% (OpenHands), Aider: ~60% + "qwen3-coder:30b": 55.0, + + // GLM-4.7 Flash (THUDM, 30B) + // SWE-bench Verified: 74.2% (full-precision 358B) + // Flash variant is distilled, Q4 discount applied + "glm-4.7-flash:latest": 35.0, + // DeepSeek-R1 distilled (NOT on SWE-bench Verified) - // Full R1 671B: LiveCodeBench 65.9%, Aider Polyglot 57-71%, Codeforces 2029 - // Distilled: LiveCodeBench 70B=65.2%, 32B=62.1%, 14B=59.1%, 7B=49.1%, 1.5B=33.8% - // Codeforces: 70B=1633, 32B=1691, 14B=1481, 7B=1189, 1.5B=954 + // Full R1 671B: LiveCodeBench 65.9%, Aider Polyglot 57-71% + // Distilled: LiveCodeBench 70B=65.2%, 32B=62.1%, 14B=59.1% // Q4 quantization discount applied (~15-20%) - "deepseek-r1:70b": 32.0, // LiveCodeBench 65.2%, Codeforces 1633 - "deepseek-r1:32b": 30.0, // LiveCodeBench 62.1%, Codeforces 1691 (close to 70B!) - "deepseek-r1:14b": 22.0, // LiveCodeBench 59.1%, Codeforces 1481 - "deepseek-r1:8b": 12.0, // LiveCodeBench ~49% (7B=49.1, 8B=Llama-8B similar) - "deepseek-r1:7b": 11.0, // LiveCodeBench 49.1%, Codeforces 1189 - "deepseek-r1:1.5b": 3.0, // LiveCodeBench 33.8%, Codeforces 954 + "deepseek-r1:70b": 32.0, + "deepseek-r1:32b": 30.0, + "deepseek-r1:14b": 22.0, + "deepseek-r1:8b": 12.0, + "deepseek-r1:7b": 11.0, + "deepseek-r1:1.5b": 3.0, + + // Devstral (Mistral code models) + // SWE-bench mini-agent: small=56.4%, full=53.8% + "devstral:latest": 24.0, + "devstral-small:latest": 45.0, // devstral-small-2, SWE-bench 56.4% + + // Qwen 3.5 (March 2026, multimodal, 262K context) + // Beats qwen3-30B on reasoning despite 9B size + "qwen3.5:9b": 20.0, + "qwen3.5:32b": 30.0, // Qwen 3 (base chat model, not code-specialized) // Aider Polyglot: 235B=59.6%, 32B=40.0% - // Qwen3-Coder-480B/A35B scores 55.4% SWE-bench (mini-agent), 69.6% (OpenHands) - "qwen3:235b": 40.0, // Aider 59.6%, MoE A22B active params - "qwen3:32b": 26.0, // Aider 40.0% - "qwen3:30b": 25.0, // MoE variant (A3B), similar to 32B - "qwen3:8b": 14.0, // extrapolated from 32B scaling + "qwen3:235b": 40.0, + "qwen3:32b": 26.0, + "qwen3:30b": 25.0, + "qwen3:8b": 14.0, "qwen3:4b": 7.0, "qwen3:1.7b": 4.0, "qwen3:0.6b": 1.0, // Qwen 2.5 Coder (code-specific fine-tune) - // SWE-bench: 47% best (Skywork+TTS), 38% (Skywork), 9% (mini-agent) - // Aider Edit: 32B=72.9%, 14B=61.7%, 7B=57.9% - // Aider Polyglot: 32B=16.4% (polyglot is much harder than edit benchmark) - "qwen2.5-coder:32b": 22.0, // strong at code editing; weaker at multi-step - "qwen2.5-coder:14b": 15.0, // Aider Edit 61.7% - "qwen2.5-coder:7b": 10.0, // Aider Edit 57.9% + // SWE-bench: 47% best (Skywork+TTS), Aider Edit: 32B=72.9% + "qwen2.5-coder:32b": 22.0, + "qwen2.5-coder:14b": 15.0, + "qwen2.5-coder:7b": 10.0, "qwen2.5-coder:3b": 5.0, "qwen2.5-coder:1.5b": 3.0, - // CodeLlama (2023-era models, significantly weaker than modern alternatives) - // HumanEval: 70B=67%, 34B=54%, 13B~47%, 7B~33% - // Not on Aider Polyglot or SWE-bench; scaled relative to modern models - "codellama:70b": 10.0, // HumanEval 67% but weak at multi-step tasks - "codellama:34b": 7.0, // HumanEval 54% - "codellama:13b": 4.0, // HumanEval ~47% - "codellama:7b": 2.0, // HumanEval ~33% - - // DeepSeek Coder V2 (2024, strong for its era) - // V2-16B: HumanEval 81.1% - // V1-33B: HumanEval ~70% - "deepseek-coder-v2:16b": 18.0, // HumanEval 81.1%, MoE architecture + // CodeLlama (2023-era, significantly weaker than modern alternatives) + "codellama:70b": 10.0, + "codellama:34b": 7.0, + "codellama:13b": 4.0, + "codellama:7b": 2.0, + + // DeepSeek Coder V2 (2024) + "deepseek-coder-v2:16b": 18.0, "deepseek-coder:33b": 12.0, "deepseek-coder:6.7b": 7.0, - // Devstral (Mistral code model) - // SWE-bench mini-agent: small=56.4%, full=53.8% - "devstral:latest": 24.0, - - // Llama 3.1 (general purpose, not code-specialized) - // Aider Edit: 70B=58.6%, 8B=37.6% + // Llama 3.1 (general purpose) "llama3.1:70b": 16.0, "llama3.1:8b": 5.0, - // Phi-4 (Microsoft, compact reasoning model) + // Phi-4 (Microsoft, compact reasoning) "phi4:14b": 14.0, - // Gemma 3 (Google, Aider Polyglot: 27B=4.9%) + // Gemma 3 (Google) "gemma3:27b": 4.0, "gemma3:12b": 2.0, - // Codestral (Mistral code model, Aider Polyglot: 11.1%) + // Codestral (Mistral code model) "codestral:latest": 8.0, // Mistral general "mistral:7b": 3.0, - // Llama 4 (Meta, Aider Polyglot: Maverick=15.6%) + // Llama 4 (Meta) "llama4:maverick": 12.0, "llama4:scout": 6.0, } diff --git a/internal/ollama/swebench.go b/internal/ollama/swebench.go index 609c6322..313971cd 100644 --- a/internal/ollama/swebench.go +++ b/internal/ollama/swebench.go @@ -189,3 +189,31 @@ func modelFamily(name string) string { } return name } + +// matchFallbackScore finds a fallback rating for a model by checking if any +// rated model name is a prefix of the given name, or if they share the same +// family. Handles variants like "qwen3-coder:30b-128k" matching +// "qwen3-coder:30b". +func matchFallbackScore(name string) float64 { + // Prefix match: "qwen3-coder:30b-128k" starts with "qwen3-coder:30b" + var bestScore float64 + var bestLen int + for rated, score := range sweBenchRatings { + if strings.HasPrefix(name, rated) && len(rated) > bestLen { + bestScore = score + bestLen = len(rated) + } + } + if bestScore > 0 { + return bestScore + } + + // Family match: pick the best score for the same family. + family := modelFamily(name) + for rated, score := range sweBenchRatings { + if modelFamily(rated) == family && score > bestScore { + bestScore = score + } + } + return bestScore +} diff --git a/internal/runner/runner.go b/internal/runner/runner.go index ceb2209a..c1946e1b 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -132,6 +132,11 @@ func BuildArgv(spec RunSpec, fs FS, lookPath func(string) (string, error)) []str argv = append(argv, "-e", k+"="+v) } + // cfg [asdf] entries → ASDF_=value + for k, v := range spec.CellCfg.Asdf { + argv = append(argv, "-e", "ASDF_"+strings.ToUpper(k)+"="+v) + } + // Command-specific extra env vars (e.g. OPENCODE_CONFIG_CONTENT) for k, v := range spec.ExtraEnv { argv = append(argv, "-e", k+"="+v) diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go index 801b8b26..ff94fd07 100644 --- a/internal/runner/runner_test.go +++ b/internal/runner/runner_test.go @@ -241,6 +241,17 @@ func TestArgv_ReadonlyVolume(t *testing.T) { } } +// --- cfg asdf --- + +func TestArgv_AsdfEnvVars(t *testing.T) { + argv := buildArgv(t, func(s *runner.RunSpec) { + s.CellCfg.Asdf = map[string]string{"golang_mod_version_enabled": "true"} + }) + if !hasArg(argv, "ASDF_GOLANG_MOD_VERSION_ENABLED=true") { + t.Errorf("expected ASDF_GOLANG_MOD_VERSION_ENABLED=true in argv: %v", argv) + } +} + // --- Network and port --- func TestArgv_VNCPort(t *testing.T) { diff --git a/internal/scaffold/templates/devcell.toml.tmpl b/internal/scaffold/templates/devcell.toml.tmpl index 6e87b57d..6d8f6fe6 100644 --- a/internal/scaffold/templates/devcell.toml.tmpl +++ b/internal/scaffold/templates/devcell.toml.tmpl @@ -7,11 +7,20 @@ # Enable GUI (Xvfb + VNC + browser). Injects DEVCELL_GUI_ENABLED=true. gui = true +[claude] +# Route Claude Code through a local ollama instance (Anthropic API compat). +# Equivalent to: cell claude --ollama +# use_ollama = true + [env] # Extra environment variables forwarded into the container. -# ANTHROPIC_BASE_URL = "http://host.docker.internal:8080" # MY_TOKEN = "abc123" +[asdf] +# ASDF runtime settings forwarded as ASDF_ env vars. +# See: https://asdf-vm.com/manage/configuration.html +golang_mod_version_enabled = "true" + # [[volumes]] # Extra volume mounts appended to docker run. # mount = "~/work/secrets:/run/secrets:ro" diff --git a/internal/ux/ux.go b/internal/ux/ux.go index a1eb6f9f..16750d57 100644 --- a/internal/ux/ux.go +++ b/internal/ux/ux.go @@ -29,6 +29,7 @@ func NewProgressSpinner(message string) *ProgressSpinner { s.Sequence = []string{" ⠋ ", " ⠙ ", " ⠹ ", " ⠸ ", " ⠼ ", " ⠴ ", " ⠦ ", " ⠧ ", " ⠇ ", " ⠏ "} s.Style = pterm.NewStyle(pterm.FgLightBlue) s.Delay = 80 * time.Millisecond + s.ShowTimer = true ps.spinner, _ = s.Start(message) } else { pterm.Info.Println(message) @@ -60,8 +61,8 @@ func (ps *ProgressSpinner) Success(message string) *ProgressSpinner { func (ps *ProgressSpinner) Stop() { if ps.spinner != nil && ps.spinner.IsActive { ps.spinner.Stop() - // Clear the spinner line - fmt.Print("\r\033[K") + // pterm Stop() may print a final frame; erase it and move cursor up. + fmt.Print("\r\033[K\033[A\r\033[K") } } @@ -84,6 +85,15 @@ func GetConfirmation(message string) (bool, error) { Show() } +// GetSelection shows an interactive selection prompt and returns the chosen option. +func GetSelection(message string, options []string) (string, error) { + prefixed := fmt.Sprintf(" %s %s", pterm.LightBlue("?"), message) + return pterm.DefaultInteractiveSelect. + WithDefaultText(prefixed). + WithOptions(options). + Show() +} + // Println prints a styled line (or plain info when LogPlainText is set). func Println(message string) { if !LogPlainText { diff --git a/nixhome/modules/desktop/default.nix b/nixhome/modules/desktop/default.nix index 68c4f7e7..c8c78589 100644 --- a/nixhome/modules/desktop/default.nix +++ b/nixhome/modules/desktop/default.nix @@ -19,7 +19,13 @@ # libglu1-mesa / -dev → pkgs.libGLU # libtiff5-dev → pkgs.libtiff # libwxgtk3.2-1, libwxgtk-webview3.2-1 → pkgs.wxGTK32 (pname="wxwidgets" 3.2.x) -{pkgs, ...}: { +{pkgs, lib, ...}: +let + # Import theme — palette (c), fonts (f), and generated fluxbox cfg. + theme = import ./theme.nix { inherit lib; }; + inherit (theme) c f cfg init xresources wallpaper pixmaps; +in +{ # Contribute playwright MCP server to the system-level managed-mcp.json. # \${VAR} in string values → literal ${VAR} in JSON → Claude Code expands at runtime. devcell.managedMcp.servers.playwright = { @@ -40,6 +46,7 @@ xorg.xrandr # display configuration (from x11-apps) xorg.xset # X server settings utility xorg.xsetroot # solid color / background setter + xorg.xrdb # X resource database — loads .Xresources (xterm colors, fonts) # Background image setter — sets wallpaper before/after fluxbox starts feh @@ -50,6 +57,9 @@ # Screenshot capture — used by tests to verify desktop renders imagemagick # provides `import` CLI for X11 screen capture + # X11 automation — simulate keyboard/mouse input, query windows + xdotool # (use: xdotool key ctrl+c, xdotool search --name "Firefox") + # Terminal emulator — launched from fluxbox menu xterm @@ -74,6 +84,8 @@ # Fonts — required for Chromium and other GUI apps noto-fonts dejavu_fonts + jetbrains-mono # neobrutalist UI font — used by fluxbox theme and xterm + inter # geometric sans — fallback UI font # Playwright MCP wrapper — sets per-app user-data-dir and forwards secrets # from $USER_WORKING_DIR/.env to playwright-mcp via --secrets. @@ -116,10 +128,7 @@ xsession.windowManager.fluxbox = { enable = true; - # Point menuFile at /opt/devcell so fluxbox reads it regardless of session $HOME. - init = '' - session.menuFile: /opt/devcell/.fluxbox/menu - ''; + inherit init; # Full keybindings. home-manager replaces the entire default keys file, # so we must include useful defaults here. Scroll-to-cycle-workspaces @@ -164,8 +173,8 @@ # so the compat link /nix/var/nix/profiles/per-user/$USER/profile is used correctly # regardless of which username the container runs as. menu = '' - [begin] (DevcCell) - [submenu] (Applications) + [begin] ( [*.] devcell ) + [submenu] ( Applications ) [exec] (Chromium) {sh -c 'chromium &'} [end] [exec] (Terminal) {${pkgs.xterm}/bin/xterm} @@ -175,12 +184,21 @@ ''; }; - home.file.".fluxbox/wallpaper.png".source = ./wallpaper.png; - - # ── Entrypoint fragment: GUI service startup ────────────────────────────── - # Sourced by entrypoint.sh from /etc/devcell/entrypoint.d/ at container start. - # Staged there by the activation script in entrypoint.nix. - home.file.".config/devcell/entrypoint.d/50-gui.sh" = { + # ── Theme file deployment ───────────────────────────────────────────────── + # All visual assets: wallpaper, Xresources, fluxbox style + overlay, button pixmaps. + home.file = { + ".fluxbox/wallpaper.png".source = wallpaper; + ".Xresources".text = xresources; + ".fluxbox/styles/devcell-ocean/theme.cfg".text = cfg; + ".fluxbox/overlay".text = cfg; + ".fluxbox/apps".text = '' + [app] (name=.*) + [Tab] {no} + [end] + ''; + # ── Entrypoint fragment: GUI service startup ──────────────────────────── + # Sourced by entrypoint.sh from /etc/devcell/entrypoint.d/ at container start. + ".config/devcell/entrypoint.d/50-gui.sh" = { executable = true; text = '' #!/bin/bash @@ -197,9 +215,18 @@ log "Starting Xvfb on display :''${DISPLAY_NUM}..." gosu "$USER" Xvfb :''${DISPLAY_NUM} -screen 0 ''${RESOLUTION} 2>/dev/null & - sleep 1 - export DISPLAY=:''${DISPLAY_NUM} + # Wait for X server to accept connections (socket file appears before server is ready) + for i in $(seq 1 40); do + xset -display :''${DISPLAY_NUM} q >/dev/null 2>&1 && break + sleep 0.05 + done + + # Load X resources (xterm dark theme, cursor color, fonts) + # Run as root — X resource database is per-display, not per-user. + if [ -f "$DEVCELL_HOME/.Xresources" ]; then + xrdb -display :''${DISPLAY_NUM} -merge "$DEVCELL_HOME/.Xresources" 2>/dev/null || true + fi if [ -f "$DEVCELL_HOME/.fluxbox/wallpaper.png" ]; then gosu "$USER" feh --bg-fill "$DEVCELL_HOME/.fluxbox/wallpaper.png" 2>/dev/null || true @@ -210,7 +237,7 @@ FLUXBOX_RC=/tmp/fluxbox-init cp "$DEVCELL_HOME/.fluxbox/init" "$FLUXBOX_RC" chmod u+w "$FLUXBOX_RC" - WORKSPACE_NAME="''${APP_NAME:-cell}" + WORKSPACE_NAME=" ''${APP_NAME:-cell} " if grep -q "session.screen0.workspaceNames" "$FLUXBOX_RC"; then sed -i "s/^session.screen0.workspaceNames:.*/session.screen0.workspaceNames: ''${WORKSPACE_NAME}/" "$FLUXBOX_RC" else @@ -218,7 +245,11 @@ fi log "Starting fluxbox (workspace: ''${WORKSPACE_NAME})..." gosu "$USER" fluxbox -rc "$FLUXBOX_RC" &>/dev/null & - sleep 1 + # Poll for fluxbox process instead of fixed sleep 1 + for i in $(seq 1 20); do + pgrep -u "$USER" fluxbox >/dev/null 2>&1 && break + sleep 0.05 + done if [ -f "$DEVCELL_HOME/.fluxbox/wallpaper.png" ]; then gosu "$USER" feh --bg-fill "$DEVCELL_HOME/.fluxbox/wallpaper.png" 2>/dev/null || true @@ -234,9 +265,6 @@ # ── xrdp (RDP gateway to existing VNC session) ──────────────────────── XRDP_BIN=$(command -v xrdp 2>/dev/null) if [ -n "$XRDP_BIN" ]; then - # Set user password so sesman PAM auth works for RDP login - echo "$USER:rdp" | chpasswd - XRDP_CFG="/tmp/xrdp" mkdir -p "$XRDP_CFG" XRDP_PREFIX=$(dirname "$(dirname "$(readlink -f "$XRDP_BIN")")") @@ -289,34 +317,7 @@ echo 'password=vnc' } >> "$XRDP_CFG/xrdp.ini" - # sesman.ini — needed by xrdp for PAM auth; logs to file only - { - echo '[Globals]' - echo 'EnableUserWindowManager=false' - echo 'DefaultWindowManager=startwm.sh' - echo - echo '[Security]' - echo 'AllowRootLogin=true' - echo 'MaxLoginRetry=3' - echo 'TerminalServerUsers=tsusers' - echo 'TerminalServerAdmins=tsadmins' - echo - echo '[Sessions]' - echo 'X11DisplayOffset=10' - echo 'MaxSessions=1' - echo 'KillDisconnected=false' - echo 'DisconnectedTimeLimit=0' - echo 'IdleTimeLimit=0' - echo - echo '[Logging]' - echo 'LogFile=/var/log/xrdp-sesman.log' - echo "LogLevel=$XRDP_LOG_LEVEL" - echo 'EnableSyslog=false' - } > "$XRDP_CFG/sesman.ini" - log "Starting xrdp on port 3389 (RDP → VNC :''${DISPLAY_NUM})..." - xrdp-sesman --nodaemon --config "$XRDP_CFG/sesman.ini" >>/var/log/xrdp-sesman.log 2>&1 & - sleep 1 xrdp --nodaemon --config "$XRDP_CFG/xrdp.ini" >>/var/log/xrdp.log 2>&1 & log "xrdp ready - connect to localhost:''${EXT_RDP_PORT:-3389}" @@ -324,5 +325,6 @@ log "xrdp not found — skipping RDP server" fi ''; - }; + }; + } // pixmaps; } diff --git a/nixhome/modules/desktop/pixmaps/close.xpm b/nixhome/modules/desktop/pixmaps/close.xpm new file mode 100644 index 00000000..ea0a5050 --- /dev/null +++ b/nixhome/modules/desktop/pixmaps/close.xpm @@ -0,0 +1,37 @@ +/* XPM */ +static char *close_xpm[] = { +"30 30 3 1", +" c None", +". c #E85D26", +"x c #9B3E19", +" ", +" ", +" .......... ", +" .............. ", +" ................ ", +" .................... ", +" .................... ", +" ...................... ", +" .....x............x..... ", +" ......x..........x...... ", +" ........x........x........ ", +" .........x......x......... ", +" ..........x....x.......... ", +" ...........x..x........... ", +" ............xx............ ", +" ............xx............ ", +" ...........x..x........... ", +" ..........x....x.......... ", +" .........x......x......... ", +" ........x........x........ ", +" ......x..........x...... ", +" .....x............x..... ", +" ...................... ", +" .................... ", +" .................... ", +" ................ ", +" .............. ", +" .......... ", +" ", +" " +}; diff --git a/nixhome/modules/desktop/pixmaps/close_unfocus.xpm b/nixhome/modules/desktop/pixmaps/close_unfocus.xpm new file mode 100644 index 00000000..d392101c --- /dev/null +++ b/nixhome/modules/desktop/pixmaps/close_unfocus.xpm @@ -0,0 +1,36 @@ +/* XPM */ +static char *close_unfocus_xpm[] = { +"30 30 2 1", +" c None", +". c #445566", +" ", +" ", +" .......... ", +" .............. ", +" ................ ", +" .................... ", +" .................... ", +" ...................... ", +" ........................ ", +" ........................ ", +" .......................... ", +" .......................... ", +" .......................... ", +" .......................... ", +" .......................... ", +" .......................... ", +" .......................... ", +" .......................... ", +" .......................... ", +" .......................... ", +" ........................ ", +" ........................ ", +" ...................... ", +" .................... ", +" .................... ", +" ................ ", +" .............. ", +" .......... ", +" ", +" " +}; diff --git a/nixhome/modules/desktop/pixmaps/max.xpm b/nixhome/modules/desktop/pixmaps/max.xpm new file mode 100644 index 00000000..27e441ea --- /dev/null +++ b/nixhome/modules/desktop/pixmaps/max.xpm @@ -0,0 +1,37 @@ +/* XPM */ +static char *max_xpm[] = { +"30 30 3 1", +" c None", +". c #1abc9c", +"x c #128068", +" ", +" ", +" .......... ", +" .............. ", +" ................ ", +" .................... ", +" .................... ", +" ...................... ", +" ........................ ", +" ......xxx............... ", +" .......xx................. ", +" .......x.................. ", +" .......................... ", +" .......................... ", +" .......................... ", +" .......................... ", +" .......................... ", +" .................x........ ", +" ................xx........ ", +" ...............xxx........ ", +" ........................ ", +" ........................ ", +" ...................... ", +" .................... ", +" .................... ", +" ................ ", +" .............. ", +" .......... ", +" ", +" " +}; diff --git a/nixhome/modules/desktop/pixmaps/max_unfocus.xpm b/nixhome/modules/desktop/pixmaps/max_unfocus.xpm new file mode 100644 index 00000000..c1734bd4 --- /dev/null +++ b/nixhome/modules/desktop/pixmaps/max_unfocus.xpm @@ -0,0 +1,36 @@ +/* XPM */ +static char *max_unfocus_xpm[] = { +"30 30 2 1", +" c None", +". c #445566", +" ", +" ", +" .......... ", +" .............. ", +" ................ ", +" .................... ", +" .................... ", +" ...................... ", +" ........................ ", +" ........................ ", +" .......................... ", +" .......................... ", +" .......................... ", +" .......................... ", +" .......................... ", +" .......................... ", +" .......................... ", +" .......................... ", +" .......................... ", +" .......................... ", +" ........................ ", +" ........................ ", +" ...................... ", +" .................... ", +" .................... ", +" ................ ", +" .............. ", +" .......... ", +" ", +" " +}; diff --git a/nixhome/modules/desktop/pixmaps/min.xpm b/nixhome/modules/desktop/pixmaps/min.xpm new file mode 100644 index 00000000..c2894aac --- /dev/null +++ b/nixhome/modules/desktop/pixmaps/min.xpm @@ -0,0 +1,37 @@ +/* XPM */ +static char *min_xpm[] = { +"30 30 3 1", +" c None", +". c #b8e336", +"x c #7A9724", +" ", +" ", +" .......... ", +" .............. ", +" ................ ", +" .................... ", +" .................... ", +" ...................... ", +" ........................ ", +" ........................ ", +" .......................... ", +" .......................... ", +" .......................... ", +" .......................... ", +" ........xxxxxxxxxx........ ", +" ........xxxxxxxxxx........ ", +" .......................... ", +" .......................... ", +" .......................... ", +" .......................... ", +" ........................ ", +" ........................ ", +" ...................... ", +" .................... ", +" .................... ", +" ................ ", +" .............. ", +" .......... ", +" ", +" " +}; diff --git a/nixhome/modules/desktop/pixmaps/min_unfocus.xpm b/nixhome/modules/desktop/pixmaps/min_unfocus.xpm new file mode 100644 index 00000000..1263bbb9 --- /dev/null +++ b/nixhome/modules/desktop/pixmaps/min_unfocus.xpm @@ -0,0 +1,36 @@ +/* XPM */ +static char *min_unfocus_xpm[] = { +"30 30 2 1", +" c None", +". c #445566", +" ", +" ", +" .......... ", +" .............. ", +" ................ ", +" .................... ", +" .................... ", +" ...................... ", +" ........................ ", +" ........................ ", +" .......................... ", +" .......................... ", +" .......................... ", +" .......................... ", +" .......................... ", +" .......................... ", +" .......................... ", +" .......................... ", +" .......................... ", +" .......................... ", +" ........................ ", +" ........................ ", +" ...................... ", +" .................... ", +" .................... ", +" ................ ", +" .............. ", +" .......... ", +" ", +" " +}; diff --git a/nixhome/modules/desktop/theme.nix b/nixhome/modules/desktop/theme.nix new file mode 100644 index 00000000..1ecf6322 --- /dev/null +++ b/nixhome/modules/desktop/theme.nix @@ -0,0 +1,226 @@ +# desktop/theme.nix — Neobrutalist Fluxbox theme +# Single source of truth for palette, fonts, and fluxbox theme generation. +# Imported by default.nix; palette (c) and fonts (f) are reusable across +# fluxbox theme, Xresources, and any future GUI config. +{ lib }: +let + # ── Design tokens ──────────────────────────────────────────────────────── + c = { + border = "#000000"; # window borders, toolbar bg, title bar bg + surface = "#0a0a18"; # unfocused windows, menu body, xterm bg + accent = "#1abc9c"; # window handle, menu title, cursor + highlight = "#b8e336"; # workspace badge, grips, menu hilite + inactive = "#667788"; # inactive text, disabled items + raised = "#1a1a2e"; # focused iconbar, tab bg — slightly lighter than surface + text = "#f0f0f0"; # active window title text + textBright = "#ffffff"; # clock, menu body text + }; + + f = rec { + family = "JetBrains Mono"; + sm = "${family}-9"; + smB = "${family}-9:bold"; + md = "${family}-10"; + mdB = "${family}-10:bold"; + lg = "${family}-11"; + xlB = "${family}-13:bold"; + }; + + # ── Fluxbox theme generator ───────────────────────────────────────────── + # Converts list of { section; props; } → fluxbox .cfg text. + # Props are attrsets; keys sort alphabetically within each section. + mkFluxboxCfg = sections: + lib.concatMapStringsSep "\n\n" (s: + "! ── ${s.section}\n" + + lib.concatMapStringsSep "\n" + (k: "${k}: ${toString s.props.${k}}") + (builtins.attrNames s.props) + ) sections; + + # ── Theme definition ───────────────────────────────────────────────────── + cfg = mkFluxboxCfg [ + { section = "Toolbar"; + props = { + "toolbar" = "flat"; + "toolbar.borderColor" = c.border; + "toolbar.borderWidth" = 0; + "toolbar.clock" = "flat"; + "toolbar.clock.color" = c.border; + "toolbar.clock.font" = f.smB; + "toolbar.clock.textColor" = c.textBright; + "toolbar.color" = c.border; + "toolbar.height" = 28; + "toolbar.iconbar.empty" = "flat"; + "toolbar.iconbar.empty.color" = c.border; + "toolbar.iconbar.focused" = "flat"; + "toolbar.iconbar.focused.borderWidth" = 0; + "toolbar.iconbar.focused.color" = c.raised; + "toolbar.iconbar.focused.font" = f.smB; + "toolbar.iconbar.focused.textColor" = c.textBright; + "toolbar.iconbar.unfocused" = "flat"; + "toolbar.iconbar.unfocused.borderWidth" = 0; + "toolbar.iconbar.unfocused.color" = c.border; + "toolbar.iconbar.unfocused.font" = f.smB; + "toolbar.iconbar.unfocused.textColor" = c.inactive; + "toolbar.workspace" = "flat"; + "toolbar.workspace.color" = c.highlight; + "toolbar.workspace.font" = f.smB; + "toolbar.workspace.justify" = "center"; + "toolbar.workspace.textColor" = c.border; + }; + } + { section = "Focused window"; + props = { + "window.button.focus" = "parentrelative"; + "window.button.focus.picColor" = c.accent; + "window.button.pressed" = "flat"; + "window.button.pressed.color" = c.border; + "window.button.pressed.picColor" = c.textBright; + "window.close.pixmap" = "pixmaps/close.xpm"; + "window.close.pressed.pixmap" = "pixmaps/close.xpm"; + "window.close.unfocus.pixmap" = "pixmaps/close_unfocus.xpm"; + "window.grip.focus" = "flat"; + "window.grip.focus.color" = c.highlight; + "window.handle.focus" = "flat"; + "window.handle.focus.color" = c.accent; + "window.handleWidth" = 6; + "window.iconify.pixmap" = "pixmaps/min.xpm"; + "window.iconify.pressed.pixmap" = "pixmaps/min.xpm"; + "window.iconify.unfocus.pixmap" = "pixmaps/min_unfocus.xpm"; + "window.label.focus" = "parentrelative"; + "window.label.focus.font" = f.mdB; + "window.label.focus.justify" = "left"; + "window.label.focus.textColor" = c.text; + "window.maximize.pixmap" = "pixmaps/max.xpm"; + "window.maximize.pressed.pixmap" = "pixmaps/max.xpm"; + "window.maximize.unfocus.pixmap" = "pixmaps/max_unfocus.xpm"; + "window.title.focus" = "flat"; + "window.title.focus.color" = c.border; + "window.title.height" = 30; + }; + } + { section = "Unfocused window"; + props = { + "window.button.unfocus" = "parentrelative"; + "window.button.unfocus.picColor" = c.inactive; + "window.grip.unfocus" = "flat"; + "window.grip.unfocus.color" = c.raised; + "window.handle.unfocus" = "flat"; + "window.handle.unfocus.color" = c.raised; + "window.label.unfocus" = "parentrelative"; + "window.label.unfocus.font" = f.md; + "window.label.unfocus.textColor" = c.inactive; + "window.title.unfocus" = "flat"; + "window.title.unfocus.color" = c.surface; + }; + } + { section = "Window border — THICK BLACK (neobrutalist signature)"; + props = { + "window.borderColor" = c.border; + "window.borderWidth" = 3; + "window.frame.focusColor" = c.border; + "window.frame.unfocusColor" = c.surface; + }; + } + { section = "Menu — OPAQUE, BIG, BOLD"; + props = { + "menu.borderColor" = c.border; + "menu.borderWidth" = 3; + "menu.bullet" = "triangle"; + "menu.bullet.position" = "right"; + "menu.frame" = "flat"; + "menu.frame.color" = c.surface; + "menu.frame.disableColor" = c.inactive; + "menu.frame.font" = f.lg; + "menu.frame.textColor" = c.textBright; + "menu.hilite" = "flat"; + "menu.hilite.color" = c.highlight; + "menu.hilite.textColor" = c.border; + "menu.itemHeight" = 28; + "menu.title" = "flat"; + "menu.title.color" = c.highlight; + "menu.title.font" = f.xlB; + "menu.title.justify" = "left"; + "menu.title.textColor" = c.border; + "menu.titleHeight" = 32; + }; + } + { section = "Tabs — hidden (tab labels match titlebar bg to prevent visual duplication)"; + props = { + "window.tab.borderColor" = c.border; + "window.tab.borderWidth" = 0; + "window.tab.label.focus" = "flat"; + "window.tab.label.focus.color" = c.border; + "window.tab.label.focus.textColor" = c.border; + "window.tab.label.unfocus" = "flat"; + "window.tab.label.unfocus.color" = c.border; + "window.tab.label.unfocus.textColor" = c.border; + }; + } + ]; + + # ── Fluxbox init (session settings) ─────────────────────────────────────── + # entrypoint 50-gui.sh patches workspaceNames at runtime from $APP_NAME. + init = '' + session.appsFile: /opt/devcell/.fluxbox/apps + session.menuFile: /opt/devcell/.fluxbox/menu + session.styleFile: /opt/devcell/.fluxbox/styles/devcell-ocean/theme.cfg + session.styleOverlay: /opt/devcell/.fluxbox/overlay + session.screen0.strftimeFormat: %a %d %b %H:%M + session.screen0.toolbar.placement: BottomCenter + session.screen0.toolbar.widthPercent: 100 + session.screen0.toolbar.visible: true + session.screen0.toolbar.tools: prevworkspace, workspacename, nextworkspace, iconbar, systemtray, clock + session.screen0.tabs.intitlebar: true + session.screen0.tabs.usePixmap: false + session.screen0.tab.placement: TopLeft + session.screen0.tab.width: 64 + session.screen0.iconbar.usePixmap: false + session.screen0.iconbar.iconTextPadding: 8 + session.screen0.titlebar.left: Stick + session.screen0.titlebar.right: Minimize Maximize Close + ''; + + # ── Xterm / X resources — dark theme from the same palette ────────────── + xresources = '' + XTerm*background: ${c.surface} + XTerm*foreground: #e0f0ff + XTerm*cursorColor: ${c.accent} + XTerm*faceName: ${f.family} + XTerm*faceSize: 11 + XTerm*internalBorder: 4 + XTerm*scrollBar: False + XTerm*saveLines: 4096 + XTerm*color0: ${c.surface} + XTerm*color1: #ff5555 + XTerm*color2: ${c.highlight} + XTerm*color3: #f1fa8c + XTerm*color4: #2e86c1 + XTerm*color5: #bd93f9 + XTerm*color6: ${c.accent} + XTerm*color7: #bfbfbf + XTerm*color8: #555577 + XTerm*color9: #ff6e6e + XTerm*color10: #c8f346 + XTerm*color11: #ffffa5 + XTerm*color12: #5dade2 + XTerm*color13: #d6bcfa + XTerm*color14: #48d1b5 + XTerm*color15: ${c.textBright} + ''; + + # ── Window button pixmaps (macOS-inspired neobrutalist circles) ─────────── + # close=orange, maximize=teal, minimize=yellow-green; unfocused=grey + pixmaps = { + ".fluxbox/styles/devcell-ocean/pixmaps/close.xpm".source = ./pixmaps/close.xpm; + ".fluxbox/styles/devcell-ocean/pixmaps/close_unfocus.xpm".source = ./pixmaps/close_unfocus.xpm; + ".fluxbox/styles/devcell-ocean/pixmaps/max.xpm".source = ./pixmaps/max.xpm; + ".fluxbox/styles/devcell-ocean/pixmaps/max_unfocus.xpm".source = ./pixmaps/max_unfocus.xpm; + ".fluxbox/styles/devcell-ocean/pixmaps/min.xpm".source = ./pixmaps/min.xpm; + ".fluxbox/styles/devcell-ocean/pixmaps/min_unfocus.xpm".source = ./pixmaps/min_unfocus.xpm; + }; + + # Default wallpaper — ocean waves with devcell logo. + wallpaper = ./wallpaper.png; + +in { inherit c f cfg init xresources pixmaps wallpaper; } diff --git a/test/entrypoint_test.go b/test/entrypoint_test.go index bae4cc8d..67b8bffc 100644 --- a/test/entrypoint_test.go +++ b/test/entrypoint_test.go @@ -6,6 +6,7 @@ import ( "os" osexec "os/exec" "path/filepath" + "regexp" "strings" "testing" "time" @@ -15,43 +16,11 @@ import ( "github.com/testcontainers/testcontainers-go/wait" ) -// gitShortSHA returns the abbreviated commit hash of HEAD. -func gitShortSHA(t *testing.T) string { - t.Helper() - out, err := osexec.Command("git", "rev-parse", "--short", "HEAD").Output() - if err != nil { - t.Fatalf("git rev-parse: %v", err) - } - return strings.TrimSpace(string(out)) -} - -// buildTestBaseImage builds the base image via docker buildx bake with a -// unique test-specific tag. Returns the tag. Removes the image on cleanup. -func buildTestBaseImage(t *testing.T) string { - t.Helper() - tag := fmt.Sprintf("devcell-test-base:%s-%s", gitShortSHA(t), time.Now().Format("20060102T150405")) - t.Logf("Building base image: %s", tag) - - cmd := osexec.Command("docker", "buildx", "bake", - "--file", "docker-bake.hcl", - "--load", - "--set", fmt.Sprintf("local-base.tags=%s", tag), - "local") - cmd.Dir = filepath.Join("..") - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { - t.Fatalf("build base image: %v", err) - } - t.Cleanup(func() { osexec.Command("docker", "rmi", tag).Run() }) - return tag -} - // buildTestUserImage builds a user image from a scaffolded config directory. // Returns the tag. Removes the image on cleanup. func buildTestUserImage(t *testing.T, configDir string) string { t.Helper() - tag := fmt.Sprintf("devcell-test-user:%s-%s", gitShortSHA(t), time.Now().Format("20060102T150405")) + tag := fmt.Sprintf("devcell-test-user:%s-%s", shortSHA(), time.Now().Format("20060102T150405")) t.Logf("Building user image: %s (from %s)", tag, configDir) cmd := osexec.Command("docker", "build", "-t", tag, configDir) @@ -83,16 +52,11 @@ func TestEntrypointFragments(t *testing.T) { } // 1. Resolve base image. - baseImage := os.Getenv("DEVCELL_TEST_BASE_IMAGE") - if baseImage == "" { - baseImage = buildTestBaseImage(t) - } else { - t.Logf("Using pre-built base image: %s", baseImage) - } + baseImg := baseImage() // 2. Scaffold config dir with this base image. configDir := t.TempDir() - t.Setenv("DEVCELL_BASE_IMAGE", baseImage) + t.Setenv("DEVCELL_BASE_IMAGE", baseImg) if err := scaffold.Scaffold(configDir, ""); err != nil { t.Fatalf("scaffold: %v", err) } @@ -102,10 +66,10 @@ func TestEntrypointFragments(t *testing.T) { if err != nil { t.Fatalf("read Dockerfile: %v", err) } - if !strings.HasPrefix(string(dockerfile), "FROM "+baseImage) { + if !strings.HasPrefix(string(dockerfile), "FROM "+baseImg) { t.Fatalf("Dockerfile FROM doesn't match base image: got %.80s", string(dockerfile)) } - t.Logf("Scaffold OK: Dockerfile FROM %s", baseImage) + t.Logf("Scaffold OK: Dockerfile FROM %s", baseImg) // 3. Build user image. userImage := buildTestUserImage(t, configDir) @@ -172,3 +136,93 @@ func TestEntrypointFragments(t *testing.T) { t.Logf("PASS: xrdp listening on :3389\n%s", out) }) } + +// TestEntrypointDebugTimestamps verifies that DEVCELL_DEBUG=true produces +// timestamped log lines in the format [X.XXXs]. +func TestEntrypointDebugTimestamps(t *testing.T) { + if testing.Short() { + t.Skip("skipping in short mode") + } + img := baseImage() + + out, err := osexec.Command("docker", "run", "--rm", + "--user", "0", + "-e", "HOST_USER=testuser", + "-e", "APP_NAME=tstest", + "-e", "DEVCELL_DEBUG=true", + img, + "echo", "ready", + ).CombinedOutput() + if err != nil { + t.Fatalf("docker run: %v\noutput: %s", err, out) + } + + output := string(out) + t.Logf("Debug output:\n%s", output) + + // Every log line should have a timestamp like [0.123s] or [1.456s] + tsPattern := regexp.MustCompile(`\[\d+\.\d{3}s\]`) + if !tsPattern.MatchString(output) { + t.Fatalf("FAIL: no timestamped log lines found (expected [X.XXXs] format)") + } + + // Verify multiple log lines have timestamps (not just one) + matches := tsPattern.FindAllString(output, -1) + t.Logf("PASS: found %d timestamped log lines", len(matches)) + if len(matches) < 2 { + t.Errorf("expected at least 2 timestamped lines, got %d", len(matches)) + } + + // Verify no log lines WITHOUT timestamps (lines that look like log output but lack [X.XXXs]) + for _, line := range strings.Split(output, "\n") { + line = strings.TrimSpace(line) + if line == "" || line == "ready" { + continue + } + // Lines starting with ✓ or containing known log markers should have timestamps + if (strings.Contains(line, "✓") || strings.Contains(line, "Installing") || + strings.Contains(line, "Starting") || strings.Contains(line, "Merging")) && + !tsPattern.MatchString(line) { + t.Errorf("FAIL: log line missing timestamp: %s", line) + } + } +} + +// TestEntrypointSilentWithoutDebug verifies that without DEVCELL_DEBUG, the +// entrypoint produces no log output (spinner is now in the Go CLI, not the entrypoint). +func TestEntrypointSilentWithoutDebug(t *testing.T) { + if testing.Short() { + t.Skip("skipping in short mode") + } + img := baseImage() + + out, err := osexec.Command("docker", "run", "--rm", + "--user", "0", + "-e", "HOST_USER=testuser", + "-e", "APP_NAME=myapp42", + img, + "echo", "ready", + ).CombinedOutput() + if err != nil { + t.Fatalf("docker run: %v\noutput: %s", err, out) + } + + output := string(out) + t.Logf("Non-debug output:\n%s", output) + + // No debug timestamps should appear + tsPattern := regexp.MustCompile(`\[\d+\.\d{3}s\]`) + if tsPattern.MatchString(output) { + t.Errorf("FAIL: debug timestamps found in non-debug mode") + } else { + t.Logf("PASS: no debug timestamps in non-debug mode") + } + + // No verbose log lines should appear + for _, marker := range []string{"Installing global tool", "Starting Xvfb", "Starting fluxbox", "Merging Claude"} { + if strings.Contains(output, marker) { + t.Errorf("FAIL: debug log line leaked in non-debug mode: %s", marker) + } + } + t.Logf("PASS: no debug log lines leaked") +} diff --git a/test/environment_test.go b/test/environment_test.go index bae5cfb0..5cd56df8 100644 --- a/test/environment_test.go +++ b/test/environment_test.go @@ -8,7 +8,7 @@ package container_test // Run against current image to capture baseline: // go test -v -run TestEnv ./... // Run against refactored image: -// DEVCELL_IMAGE=ghcr.io/dimmkirr/devcell:opt-devcell go test -v -run TestEnv ./... +// DEVCELL_TEST_IMAGE=ghcr.io/dimmkirr/devcell:opt-devcell go test -v -run TestEnv ./... import ( "fmt" diff --git a/test/helpers_test.go b/test/helpers_test.go new file mode 100644 index 00000000..2fff70dd --- /dev/null +++ b/test/helpers_test.go @@ -0,0 +1,136 @@ +package container_test + +// helpers — shared test infrastructure: image building, container lifecycle, exec. + +import ( + "bytes" + "context" + "fmt" + "io" + "log" + "os" + osexec "os/exec" + "strings" + "sync" + "testing" + "time" + + "github.com/docker/docker/pkg/stdcopy" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" +) + +var ( + ultimateOnce sync.Once + ultimateTag string + ultimateErr error + + baseOnce sync.Once + baseTag string + baseErr error +) + +// TestMain cleans up locally-built test images after all tests complete. +func TestMain(m *testing.M) { + code := m.Run() + if ultimateTag != "" { + osexec.Command("docker", "rmi", ultimateTag).Run() + } + if baseTag != "" { + osexec.Command("docker", "rmi", baseTag).Run() + } + os.Exit(code) +} + +// shortSHA returns the abbreviated commit hash of HEAD. +func shortSHA() string { + out, err := osexec.Command("git", "rev-parse", "--short", "HEAD").Output() + if err != nil { + panic(fmt.Sprintf("git rev-parse: %v", err)) + } + return strings.TrimSpace(string(out)) +} + +// buildLocalImage builds a bake target with a unique tag and returns the tag. +func buildLocalImage(target, tagPrefix string) (string, error) { + tag := fmt.Sprintf("%s:%s-%s", tagPrefix, shortSHA(), time.Now().Format("20060102T150405")) + log.Printf("Building %s image: %s", target, tag) + cmd := osexec.Command("docker", "buildx", "bake", + "--file", "docker-bake.hcl", + "--load", + "--set", fmt.Sprintf("%s.tags=%s", target, tag), + target) + cmd.Dir = ".." + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("build %s: %w", target, err) + } + return tag, nil +} + +// image returns the ultimate image tag for tests. +// Uses DEVCELL_TEST_IMAGE if set (CI); otherwise builds local-ultimate once with a unique tag. +func image() string { + if img := os.Getenv("DEVCELL_TEST_IMAGE"); img != "" { + return img + } + ultimateOnce.Do(func() { + ultimateTag, ultimateErr = buildLocalImage("local-ultimate", "devcell-test") + }) + if ultimateErr != nil { + panic(fmt.Sprintf("image: %v", ultimateErr)) + } + return ultimateTag +} + +// baseImage returns the base image tag for entrypoint tests. +// Uses DEVCELL_TEST_BASE_IMAGE if set (CI); otherwise builds local-base once with a unique tag. +func baseImage() string { + if img := os.Getenv("DEVCELL_TEST_BASE_IMAGE"); img != "" { + return img + } + baseOnce.Do(func() { + baseTag, baseErr = buildLocalImage("local-base", "devcell-test-base") + }) + if baseErr != nil { + panic(fmt.Sprintf("baseImage: %v", baseErr)) + } + return baseTag +} + +func startContainer(t *testing.T, env map[string]string) testcontainers.Container { + t.Helper() + ctx := context.Background() + + req := testcontainers.ContainerRequest{ + Image: image(), + Env: env, + User: "0", // entrypoint.sh starts as root, drops via gosu + Cmd: []string{"tail", "-f", "/dev/null"}, + WaitingFor: wait.ForExec([]string{"pgrep", "tail"}). + WithStartupTimeout(30 * 1e9), + } + + c, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) + if err != nil { + t.Fatalf("start container: %v", err) + } + t.Cleanup(func() { _ = c.Terminate(ctx) }) + return c +} + +func exec(t *testing.T, c testcontainers.Container, cmd []string) (string, int) { + t.Helper() + ctx := context.Background() + code, reader, err := c.Exec(ctx, cmd) + if err != nil { + t.Fatalf("exec %v: %v", cmd, err) + } + var stdout bytes.Buffer + stdcopy.StdCopy(&stdout, io.Discard, reader) + return strings.TrimSpace(stdout.String()), code +} diff --git a/test/kicad_mcp_test.go b/test/kicad_mcp_test.go index 76b2ca0b..dca40665 100644 --- a/test/kicad_mcp_test.go +++ b/test/kicad_mcp_test.go @@ -3,7 +3,7 @@ package container_test // kicad_mcp_test.go — tests for the kicad-mcp MCP server in the electronics profile. // Run against the electronics image: // -// DEVCELL_IMAGE=ghcr.io/dimmkirr/devcell:latest-electronics go test -v -run TestKicadMcp ./... +// DEVCELL_TEST_IMAGE=ghcr.io/dimmkirr/devcell:latest-electronics go test -v -run TestKicadMcp ./... import ( "encoding/json" diff --git a/test/secrets_test.go b/test/secrets_test.go deleted file mode 100644 index 5e8269c2..00000000 --- a/test/secrets_test.go +++ /dev/null @@ -1,59 +0,0 @@ -package container_test - -// helpers_test.go — shared test infrastructure: image selection, container lifecycle, exec. - -import ( - "bytes" - "context" - "io" - "os" - "strings" - "testing" - - "github.com/docker/docker/pkg/stdcopy" - "github.com/testcontainers/testcontainers-go" - "github.com/testcontainers/testcontainers-go/wait" -) - -func image() string { - if img := os.Getenv("DEVCELL_IMAGE"); img != "" { - return img - } - return "ghcr.io/dimmkirr/devcell:ultimate-local" -} - -func startContainer(t *testing.T, env map[string]string) testcontainers.Container { - t.Helper() - ctx := context.Background() - - req := testcontainers.ContainerRequest{ - Image: image(), - Env: env, - User: "0", // entrypoint.sh starts as root, drops via gosu - Cmd: []string{"tail", "-f", "/dev/null"}, - WaitingFor: wait.ForExec([]string{"pgrep", "tail"}). - WithStartupTimeout(30 * 1e9), - } - - c, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, - }) - if err != nil { - t.Fatalf("start container: %v", err) - } - t.Cleanup(func() { _ = c.Terminate(ctx) }) - return c -} - -func exec(t *testing.T, c testcontainers.Container, cmd []string) (string, int) { - t.Helper() - ctx := context.Background() - code, reader, err := c.Exec(ctx, cmd) - if err != nil { - t.Fatalf("exec %v: %v", cmd, err) - } - var stdout bytes.Buffer - stdcopy.StdCopy(&stdout, io.Discard, reader) - return strings.TrimSpace(stdout.String()), code -} diff --git a/test/vnc_test.go b/test/vnc_test.go index 3f131a4f..41b3e976 100644 --- a/test/vnc_test.go +++ b/test/vnc_test.go @@ -10,7 +10,7 @@ package container_test // PORT_PREFIX is a small digit (TMUX_PANE %0–%9, no SESSION_PORT_PREFIX). // // Run against the GUI-capable image (base-gui or ultimate): -// DEVCELL_IMAGE=ghcr.io/dimmkirr/devcell:latest-ultimate go test -v -run TestVnc ./... +// DEVCELL_TEST_IMAGE=ghcr.io/dimmkirr/devcell:latest-ultimate go test -v -run TestVnc ./... import ( "context" diff --git a/test/workflow_test.go b/test/workflow_test.go index 99449cb9..c2084e9c 100644 --- a/test/workflow_test.go +++ b/test/workflow_test.go @@ -14,7 +14,7 @@ import ( ) // TestBaseImage validates base image capabilities via direct docker run. -// CI runs this with DEVCELL_IMAGE pointing to the base image. +// CI runs this with DEVCELL_TEST_IMAGE pointing to the base image. func TestBaseImage(t *testing.T) { if testing.Short() { t.Skip("skipping in short mode") @@ -83,7 +83,7 @@ func TestBaseImage(t *testing.T) { } // TestCellShell validates the cell shell command end-to-end via PTY. -// CI runs this with DEVCELL_IMAGE pointing to the ultimate image. +// CI runs this with DEVCELL_TEST_IMAGE pointing to the ultimate image. func TestCellShell(t *testing.T) { if testing.Short() { t.Skip("skipping in short mode") @@ -119,9 +119,10 @@ func TestCellShell(t *testing.T) { } projectDir := t.TempDir() - userImage := image() // pre-built image from DEVCELL_IMAGE + userImage := image() // pre-built image from DEVCELL_TEST_IMAGE - // cellShellHome creates a manually-managed HOME directory. + // cellShellHome creates a manually-managed HOME directory with the + // subdirectories that BuildArgv bind-mounts into the container. // cell shell bind-mounts CellHome into the container, and the container // creates files owned by its internal user. t.TempDir() cleanup can't // remove those files (permission denied), so we clean up via docker. @@ -131,6 +132,12 @@ func TestCellShell(t *testing.T) { if err != nil { t.Fatalf("mkdtemp: %v", err) } + // Create directories that BuildArgv mounts from $HOME. + for _, sub := range []string{".claude/commands", ".claude/agents", ".claude/skills"} { + if err := os.MkdirAll(filepath.Join(home, sub), 0o755); err != nil { + t.Fatalf("mkdir %s: %v", sub, err) + } + } t.Cleanup(func() { osexec.Command("docker", "run", "--rm", "-v", home+":"+home, @@ -172,6 +179,36 @@ func TestCellShell(t *testing.T) { t.Errorf("expected cell shell output to contain 'nix', got: %s", out) } }) + + t.Run("spinner_visible", func(t *testing.T) { + home := cellShellHome(t) + // No --debug flag, so the Go spinner should render in PTY output. + cmd := osexec.Command(cellBin, "shell", "--", "echo", "done") + cmd.Dir = projectDir + cmd.Env = append(os.Environ(), + "XDG_CONFIG_HOME="+configDir, + "HOME="+home, + "DEVCELL_USER_IMAGE="+userImage, + ) + + out := runPTY(t, cmd) + t.Logf("PTY output (raw): %q", out) + + // Check for the "Opening Cell" status line. + if !strings.Contains(out, "Opening Cell") { + t.Fatalf("'Opening Cell' text not found in PTY output") + } + t.Logf("PASS: 'Opening Cell' rendered in PTY output") + + // If Docker successfully started the container, verify command output. + // Docker may refuse the mount when TMPDIR is not in shared paths + // (e.g. Docker Desktop file sharing). The spinner still renders. + if strings.Contains(out, "mounts denied") { + t.Logf("SKIP: Docker mount denied (TMPDIR not in Docker shared paths) — spinner verified") + } else if !strings.Contains(out, "done") { + t.Errorf("expected command output 'done' in PTY output") + } + }) } // runPTY starts cmd in a PTY, collects output, and returns it.