Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/build.dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
87 changes: 87 additions & 0 deletions cmd/apparg.go
Original file line number Diff line number Diff line change
@@ -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-<appname>-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
}
89 changes: 89 additions & 0 deletions cmd/apparg_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
82 changes: 82 additions & 0 deletions cmd/apparg_unit_test.go
Original file line number Diff line number Diff line change
@@ -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])
}
}
}
48 changes: 45 additions & 3 deletions cmd/claude.go
Original file line number Diff line number Diff line change
@@ -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...]",
Expand All @@ -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",
}
}
Loading
Loading