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
125 changes: 117 additions & 8 deletions cmd/cli/commands/launch.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,24 +78,63 @@ var supportedApps = func() []string {
return apps
}()

// appDescriptions provides human-readable descriptions for supported apps.
var appDescriptions = map[string]string{
"anythingllm": "RAG platform with Docker Model Runner provider",
"claude": "Claude Code AI assistant",
"codex": "Codex CLI",
"openclaw": "Open Claw AI assistant",
"opencode": "Open Code AI code editor",
"openwebui": "Open WebUI for models",
}

func newLaunchCmd() *cobra.Command {
var (
port int
image string
detach bool
dryRun bool
port int
image string
detach bool
dryRun bool
configOnly bool
)
c := &cobra.Command{
Use: "launch APP [-- APP_ARGS...]",
Use: "launch [APP] [-- APP_ARGS...]",
Short: "Launch an app configured to use Docker Model Runner",
Long: fmt.Sprintf(`Launch an app configured to use Docker Model Runner.

Supported apps: %s`, strings.Join(supportedApps, ", ")),
Args: requireMinArgs(1, "launch", "APP [-- APP_ARGS...]"),
Without arguments, lists all supported apps.

Supported apps: %s

Examples:
docker model launch
docker model launch opencode
docker model launch claude -- --help
docker model launch openwebui --port 3000
docker model launch claude --config`, strings.Join(supportedApps, ", ")),
ValidArgs: supportedApps,
RunE: func(cmd *cobra.Command, args []string) error {
// No args - list supported apps
if len(args) == 0 {
return listSupportedApps(cmd)
}

app := strings.ToLower(args[0])
appArgs := args[1:]

// Extract passthrough args using -- separator
var appArgs []string
dashIdx := cmd.ArgsLenAtDash()
if dashIdx == -1 {
// No "--" separator
if len(args) > 1 {
return fmt.Errorf("unexpected arguments: %s\nUse '--' to pass extra arguments to the app", strings.Join(args[1:], " "))
}
} else {
// "--" was used: require exactly 1 arg (the app name) before it
if dashIdx != 1 {
return fmt.Errorf("unexpected arguments before '--': %s\nUsage: docker model launch [APP] [-- APP_ARGS...]", strings.Join(args[1:dashIdx], " "))
}
appArgs = args[dashIdx:]
}

runner, err := getStandaloneRunner(cmd.Context())
if err != nil {
Expand All @@ -107,6 +146,11 @@ Supported apps: %s`, strings.Join(supportedApps, ", ")),
return err
}

// --config: print configuration without launching
if configOnly {
return printAppConfig(cmd, app, ep, image, port)
}

if ca, ok := containerApps[app]; ok {
return launchContainerApp(cmd, ca, ep.container, image, port, detach, appArgs, dryRun)
}
Expand All @@ -120,9 +164,74 @@ Supported apps: %s`, strings.Join(supportedApps, ", ")),
c.Flags().StringVar(&image, "image", "", "Override container image for containerized apps")
c.Flags().BoolVar(&detach, "detach", false, "Run containerized app in background")
c.Flags().BoolVar(&dryRun, "dry-run", false, "Print what would be executed without running it")
c.Flags().BoolVar(&configOnly, "config", false, "Print configuration without launching")
return c
}

// listSupportedApps prints all supported apps with their descriptions and install status.
func listSupportedApps(cmd *cobra.Command) error {
cmd.Println("Supported apps:")
cmd.Println()
for _, name := range supportedApps {
desc := appDescriptions[name]
if desc == "" {
desc = name
}
status := ""
if _, ok := hostApps[name]; ok {
if _, err := exec.LookPath(name); err != nil {
status = " (not installed)"
}
}
cmd.Printf(" %-15s %s%s\n", name, desc, status)
}
cmd.Println()
cmd.Println("Usage: docker model launch [APP] [-- APP_ARGS...]")
return nil
}

// printAppConfig prints the configuration that would be used for the given app.
func printAppConfig(cmd *cobra.Command, app string, ep engineEndpoints, imageOverride string, portOverride int) error {
if ca, ok := containerApps[app]; ok {
img := imageOverride
if img == "" {
img = ca.defaultImage
}
hostPort := portOverride
if hostPort == 0 {
hostPort = ca.defaultHostPort
}
cmd.Printf("Configuration for %s (container app):\n", app)
cmd.Printf(" Image: %s\n", img)
cmd.Printf(" Container port: %d\n", ca.containerPort)
cmd.Printf(" Host port: %d\n", hostPort)
if ca.envFn != nil {
cmd.Printf(" Environment:\n")
for _, e := range ca.envFn(ep.container) {
cmd.Printf(" %s\n", e)
}
}
return nil
}
if cli, ok := hostApps[app]; ok {
cmd.Printf("Configuration for %s (host app):\n", app)
if cli.envFn != nil {
cmd.Printf(" Environment:\n")
for _, e := range cli.envFn(ep.host) {
cmd.Printf(" %s\n", e)
}
}
if cli.configInstructions != nil {
cmd.Printf(" Manual configuration:\n")
for _, line := range cli.configInstructions(ep.host) {
cmd.Printf(" %s\n", line)
}
}
return nil
}
return fmt.Errorf("unsupported app %q (supported: %s)", app, strings.Join(supportedApps, ", "))
}

// resolveBaseEndpoints resolves the base URLs (without path) for both
// container and host client locations.
func resolveBaseEndpoints(runner *standaloneRunner) (engineEndpoints, error) {
Expand Down
194 changes: 191 additions & 3 deletions cmd/cli/commands/launch_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -345,13 +345,20 @@ func TestNewLaunchCmdValidArgs(t *testing.T) {
require.Equal(t, supportedApps, cmd.ValidArgs)
}

func TestNewLaunchCmdRequiresAtLeastOneArg(t *testing.T) {
func TestNewLaunchCmdNoArgsListsApps(t *testing.T) {
buf := new(bytes.Buffer)
cmd := newLaunchCmd()
cmd.SetOut(buf)
cmd.SetArgs([]string{})
err := cmd.Execute()

require.Error(t, err)
require.Contains(t, err.Error(), "requires at least 1 arg")
require.NoError(t, err)
output := buf.String()
require.Contains(t, output, "Supported apps:")
for _, app := range supportedApps {
require.Contains(t, output, app)
}
require.Contains(t, output, "Usage: docker model launch [APP]")
}

func TestNewLaunchCmdDispatchContainerApp(t *testing.T) {
Expand Down Expand Up @@ -415,3 +422,184 @@ func TestNewLaunchCmdDispatchUnsupportedApp(t *testing.T) {
require.Error(t, err)
require.Contains(t, err.Error(), "unsupported app")
}

func TestNewLaunchCmdConfigFlag(t *testing.T) {
ctx, err := desktop.NewContextForTest(
"http://localhost"+inference.ExperimentalEndpointsPrefix,
nil,
types.ModelRunnerEngineKindDesktop,
)
require.NoError(t, err)
modelRunner = ctx

buf := new(bytes.Buffer)
cmd := newLaunchCmd()
cmd.SetOut(buf)
cmd.SetArgs([]string{"openwebui", "--config"})

err = cmd.Execute()
require.NoError(t, err)

output := buf.String()
require.Contains(t, output, "Configuration for openwebui")
require.Contains(t, output, "container app")
require.Contains(t, output, "ghcr.io/open-webui/open-webui:latest")
}

func TestNewLaunchCmdConfigFlagHostApp(t *testing.T) {
ctx, err := desktop.NewContextForTest(
"http://localhost"+inference.ExperimentalEndpointsPrefix,
nil,
types.ModelRunnerEngineKindDesktop,
)
require.NoError(t, err)
modelRunner = ctx

buf := new(bytes.Buffer)
cmd := newLaunchCmd()
cmd.SetOut(buf)
cmd.SetArgs([]string{"claude", "--config"})

err = cmd.Execute()
require.NoError(t, err)

output := buf.String()
require.Contains(t, output, "Configuration for claude")
require.Contains(t, output, "host app")
require.Contains(t, output, "ANTHROPIC_BASE_URL")
require.Contains(t, output, "ANTHROPIC_API_KEY")
}

func TestNewLaunchCmdRejectsExtraArgsWithoutDash(t *testing.T) {
ctx, err := desktop.NewContextForTest(
"http://localhost"+inference.ExperimentalEndpointsPrefix,
nil,
types.ModelRunnerEngineKindDesktop,
)
require.NoError(t, err)
modelRunner = ctx

buf := new(bytes.Buffer)
cmd := newLaunchCmd()
cmd.SetOut(buf)
cmd.SetArgs([]string{"opencode", "extra-arg"})

err = cmd.Execute()
require.Error(t, err)
require.Contains(t, err.Error(), "unexpected arguments")
require.Contains(t, err.Error(), "Use '--'")
}

func TestNewLaunchCmdRejectsExtraArgsBeforeDash(t *testing.T) {
ctx, err := desktop.NewContextForTest(
"http://localhost"+inference.ExperimentalEndpointsPrefix,
nil,
types.ModelRunnerEngineKindDesktop,
)
require.NoError(t, err)
modelRunner = ctx

buf := new(bytes.Buffer)
cmd := newLaunchCmd()
cmd.SetOut(buf)
cmd.SetArgs([]string{"claude", "extra", "--", "--help"})

err = cmd.Execute()
require.Error(t, err)
require.Contains(t, err.Error(), "unexpected arguments before '--'")
}

func TestNewLaunchCmdPassthroughArgs(t *testing.T) {
ctx, err := desktop.NewContextForTest(
"http://localhost"+inference.ExperimentalEndpointsPrefix,
nil,
types.ModelRunnerEngineKindDesktop,
)
require.NoError(t, err)
modelRunner = ctx

buf := new(bytes.Buffer)
cmd := newLaunchCmd()
cmd.SetOut(buf)
cmd.SetArgs([]string{"openwebui", "--dry-run", "--", "--extra-flag"})

err = cmd.Execute()
require.NoError(t, err)

output := buf.String()
require.Contains(t, output, "Would run: docker")
require.Contains(t, output, "--extra-flag")
}

func TestAppDescriptionsExistForAllApps(t *testing.T) {
for _, app := range supportedApps {
require.NotEmpty(t, appDescriptions[app], "missing description for app %q", app)
}
}

func TestListSupportedApps(t *testing.T) {
buf := new(bytes.Buffer)
cmd := newTestCmd(buf)

err := listSupportedApps(cmd)
require.NoError(t, err)

output := buf.String()
require.Contains(t, output, "Supported apps:")
require.Contains(t, output, "claude")
require.Contains(t, output, "opencode")
require.Contains(t, output, "openwebui")
}

func TestPrintAppConfigContainerApp(t *testing.T) {
buf := new(bytes.Buffer)
cmd := newTestCmd(buf)

ep := engineEndpoints{container: testBaseURL, host: testBaseURL}
err := printAppConfig(cmd, "openwebui", ep, "", 0)
require.NoError(t, err)

output := buf.String()
require.Contains(t, output, "Configuration for openwebui")
require.Contains(t, output, "container app")
require.Contains(t, output, "ghcr.io/open-webui/open-webui:latest")
require.Contains(t, output, "OPENAI_API_BASE")
}

func TestPrintAppConfigContainerAppOverrides(t *testing.T) {
buf := new(bytes.Buffer)
cmd := newTestCmd(buf)

ep := engineEndpoints{container: testBaseURL, host: testBaseURL}
err := printAppConfig(cmd, "openwebui", ep, "custom/image:v2", 9999)
require.NoError(t, err)

output := buf.String()
require.Contains(t, output, "custom/image:v2")
require.NotContains(t, output, "ghcr.io/open-webui/open-webui:latest")
require.Contains(t, output, "9999")
}

func TestPrintAppConfigHostApp(t *testing.T) {
buf := new(bytes.Buffer)
cmd := newTestCmd(buf)

ep := engineEndpoints{container: testBaseURL, host: testBaseURL}
err := printAppConfig(cmd, "claude", ep, "", 0)
require.NoError(t, err)

output := buf.String()
require.Contains(t, output, "Configuration for claude")
require.Contains(t, output, "host app")
require.Contains(t, output, "ANTHROPIC_BASE_URL")
}

func TestPrintAppConfigUnsupported(t *testing.T) {
buf := new(bytes.Buffer)
cmd := newTestCmd(buf)

ep := engineEndpoints{container: testBaseURL, host: testBaseURL}
err := printAppConfig(cmd, "bogus", ep, "", 0)
require.Error(t, err)
require.Contains(t, err.Error(), "unsupported app")
}
21 changes: 20 additions & 1 deletion cmd/cli/docs/reference/docker_model_launch.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,30 @@ short: Launch an app configured to use Docker Model Runner
long: |-
Launch an app configured to use Docker Model Runner.

Without arguments, lists all supported apps.

Supported apps: anythingllm, claude, codex, openclaw, opencode, openwebui
usage: docker model launch APP [-- APP_ARGS...]

Examples:
docker model launch
docker model launch opencode
docker model launch claude -- --help
docker model launch openwebui --port 3000
docker model launch claude --config
usage: docker model launch [APP] [-- APP_ARGS...]
pname: docker model
plink: docker_model.yaml
options:
- option: config
value_type: bool
default_value: "false"
description: Print configuration without launching
deprecated: false
hidden: false
experimental: false
experimentalcli: false
kubernetes: false
swarm: false
- option: detach
value_type: bool
default_value: "false"
Expand Down
Loading
Loading