diff --git a/cmd/cli/commands/launch.go b/cmd/cli/commands/launch.go index 1570c31db..39eff4f4b 100644 --- a/cmd/cli/commands/launch.go +++ b/cmd/cli/commands/launch.go @@ -37,6 +37,7 @@ type containerApp struct { containerPort int envFn func(baseURL string) []string extraDockerArgs []string // additional docker run args (e.g., volume mounts) + interactive bool // attach TTY for interactive/TUI apps } // containerApps are launched via "docker run --rm". @@ -48,7 +49,18 @@ var containerApps = map[string]containerApp{ envFn: anythingllmEnv, extraDockerArgs: []string{"-v", "anythingllm_storage:/app/server/storage"}, }, - "openwebui": {defaultImage: "ghcr.io/open-webui/open-webui:latest", defaultHostPort: 3000, containerPort: 8080, envFn: openwebuiEnv}, + "openwebui": { + defaultImage: "ghcr.io/open-webui/open-webui:latest", + defaultHostPort: 3000, + containerPort: 8080, + envFn: openwebuiEnv, + }, + "llmfit": { + defaultImage: "ghcr.io/alexsjones/llmfit", + envFn: llmfitEnv, + interactive: true, + extraDockerArgs: []string{"--entrypoint", "llmfit"}, + }, } // hostApp describes a native CLI app launched on the host. @@ -86,6 +98,7 @@ var appDescriptions = map[string]string{ "openclaw": "Open Claw AI assistant", "opencode": "Open Code AI code editor", "openwebui": "Open WebUI for models", + "llmfit": "Recommend models that run on your system", } func newLaunchCmd() *cobra.Command { @@ -109,9 +122,11 @@ Supported apps: %s Examples: docker model launch docker model launch opencode + docker model launch llmfit docker model launch claude -- --help docker model launch openwebui --port 3000 - docker model launch claude --config`, strings.Join(supportedApps, ", ")), + docker model launch claude --config + docker model launch llmfit -- recommend -n 5`, strings.Join(supportedApps, ", ")), ValidArgs: supportedApps, RunE: func(cmd *cobra.Command, args []string) error { // No args - list supported apps @@ -153,7 +168,7 @@ Examples: } if ca, ok := containerApps[app]; ok { - return launchContainerApp(cmd, ca, ep.container, image, port, detach, appArgs, dryRun) + return launchContainerApp(cmd, app, ca, ep.container, image, port, detach, appArgs, dryRun) } if cli, ok := hostApps[app]; ok { return launchHostApp(cmd, app, ep.host, cli, model, runner, appArgs, dryRun) @@ -205,8 +220,11 @@ func printAppConfig(cmd *cobra.Command, app string, ep engineEndpoints, imageOve } 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) + cmd.Printf(" Interactive: %v\n", ca.interactive) + if ca.containerPort > 0 { + 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) { @@ -281,7 +299,7 @@ func resolveBaseEndpoints(runner *standaloneRunner) (engineEndpoints, error) { } // launchContainerApp launches a container-based app via "docker run". -func launchContainerApp(cmd *cobra.Command, ca containerApp, baseURL string, imageOverride string, portOverride int, detach bool, appArgs []string, dryRun bool) error { +func launchContainerApp(cmd *cobra.Command, appName string, ca containerApp, baseURL string, imageOverride string, portOverride int, detach bool, appArgs []string, dryRun bool) error { img := imageOverride if img == "" { img = ca.defaultImage @@ -295,9 +313,20 @@ func launchContainerApp(cmd *cobra.Command, ca containerApp, baseURL string, ima if detach { dockerArgs = append(dockerArgs, "-d") } - dockerArgs = append(dockerArgs, - "-p", fmt.Sprintf("%d:%d", hostPort, ca.containerPort), - ) + if ca.interactive { + if detach { + cmd.Printf("Warning: %s runs in interactive mode, app may not work as expected in detached mode\n", appName) + } else { + dockerArgs = append(dockerArgs, "-it") + } + } + + if ca.containerPort > 0 { + dockerArgs = append(dockerArgs, + "-p", fmt.Sprintf("%d:%d", hostPort, ca.containerPort), + ) + } + dockerArgs = append(dockerArgs, ca.extraDockerArgs...) if ca.envFn == nil { return fmt.Errorf("container app requires envFn to be set") @@ -419,6 +448,12 @@ func anthropicEnv(baseURL string) []string { } } +func llmfitEnv(baseURL string) []string { + return []string{ + "DOCKER_MODEL_RUNNER_HOST=" + baseURL, + } +} + // withEnv returns the current process environment extended with extra vars. func withEnv(extra ...string) []string { return append(os.Environ(), extra...) diff --git a/cmd/cli/commands/launch_test.go b/cmd/cli/commands/launch_test.go index 069c2426e..0a5316f39 100644 --- a/cmd/cli/commands/launch_test.go +++ b/cmd/cli/commands/launch_test.go @@ -159,7 +159,7 @@ func TestLaunchContainerAppDryRun(t *testing.T) { buf := new(bytes.Buffer) cmd := newTestCmd(buf) - err := launchContainerApp(cmd, ca, testBaseURL, "", 0, false, nil, true) + err := launchContainerApp(cmd, "openapi", ca, testBaseURL, "", 0, false, nil, true) require.NoError(t, err) output := buf.String() @@ -177,7 +177,7 @@ func TestLaunchContainerAppOverrides(t *testing.T) { buf := new(bytes.Buffer) cmd := newTestCmd(buf) - err := launchContainerApp(cmd, ca, testBaseURL, overrideImage, overridePort, false, nil, true) + err := launchContainerApp(cmd, "openapi", ca, testBaseURL, overrideImage, overridePort, false, nil, true) require.NoError(t, err) output := buf.String() @@ -191,7 +191,7 @@ func TestLaunchContainerAppDetach(t *testing.T) { buf := new(bytes.Buffer) cmd := newTestCmd(buf) - err := launchContainerApp(cmd, ca, testBaseURL, "", 0, true, nil, true) + err := launchContainerApp(cmd, "openpai", ca, testBaseURL, "", 0, true, nil, true) require.NoError(t, err) output := buf.String() @@ -206,7 +206,7 @@ func TestLaunchContainerAppUsesEnvFn(t *testing.T) { buf := new(bytes.Buffer) cmd := newTestCmd(buf) - err := launchContainerApp(cmd, ca, testBaseURL, "", 0, false, nil, true) + err := launchContainerApp(cmd, "openapi", ca, testBaseURL, "", 0, false, nil, true) require.NoError(t, err) output := buf.String() @@ -219,7 +219,7 @@ func TestLaunchContainerAppNilEnvFn(t *testing.T) { buf := new(bytes.Buffer) cmd := newTestCmd(buf) - err := launchContainerApp(cmd, ca, testBaseURL, "", 0, false, nil, true) + err := launchContainerApp(cmd, "", ca, testBaseURL, "", 0, false, nil, true) require.Error(t, err) require.Contains(t, err.Error(), "container app requires envFn to be set") } @@ -549,6 +549,7 @@ func TestListSupportedApps(t *testing.T) { require.Contains(t, output, "claude") require.Contains(t, output, "opencode") require.Contains(t, output, "openwebui") + require.Contains(t, output, "llmfit") } func TestOpenWebuiEnvIncludesWebuiAuth(t *testing.T) { @@ -570,6 +571,9 @@ func TestPrintAppConfigContainerApp(t *testing.T) { 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, "Interactive: false") + require.Contains(t, output, "Container port") + require.Contains(t, output, "Host port") require.Contains(t, output, "OPENAI_API_BASE") require.Contains(t, output, "WEBUI_AUTH=false") } @@ -588,6 +592,23 @@ func TestPrintAppConfigContainerAppOverrides(t *testing.T) { require.Contains(t, output, "9999") } +func TestPrintAppConfigContainerAppNoPorts(t *testing.T) { + buf := new(bytes.Buffer) + cmd := newTestCmd(buf) + + ep := engineEndpoints{container: testBaseURL, host: testBaseURL} + err := printAppConfig(cmd, "llmfit", ep, "", 0) + require.NoError(t, err) + output := buf.String() + require.Contains(t, output, "Configuration for llmfit") + require.Contains(t, output, "container app") + require.Contains(t, output, "ghcr.io/alexsjones/llmfit") + require.Contains(t, output, "Interactive: true") + require.Contains(t, output, "DOCKER_MODEL_RUNNER_HOST="+testBaseURL) + require.NotContains(t, output, "Container port") + require.NotContains(t, output, "Host port") +} + func TestPrintAppConfigHostApp(t *testing.T) { buf := new(bytes.Buffer) cmd := newTestCmd(buf) @@ -611,3 +632,65 @@ func TestPrintAppConfigUnsupported(t *testing.T) { require.Error(t, err) require.Contains(t, err.Error(), "unsupported app") } + +func TestLaunchContainerAppNoPort(t *testing.T) { + ca := containerApp{ + defaultImage: "ghcr.io/alexsjones/llmfit", + envFn: llmfitEnv, + interactive: true, + } + buf := new(bytes.Buffer) + cmd := newTestCmd(buf) + + err := launchContainerApp(cmd, "llmfit", ca, testBaseURL, "", 0, false, nil, true) + require.NoError(t, err) + + output := buf.String() + require.Contains(t, output, "Would run: docker") + require.Contains(t, output, "run --rm -it") + require.NotContains(t, output, "-p") + require.Contains(t, output, "DOCKER_MODEL_RUNNER_HOST="+testBaseURL) + require.Contains(t, output, "ghcr.io/alexsjones/llmfit") +} + +func TestLaunchContainerAppNoPortWithArgs(t *testing.T) { + ca := containerApp{ + defaultImage: "ghcr.io/alexsjones/llmfit", + envFn: llmfitEnv, + interactive: true, + } + buf := new(bytes.Buffer) + cmd := newTestCmd(buf) + + err := launchContainerApp(cmd, "llmfit", ca, testBaseURL, "", 0, false, []string{"recommend", "-n", "3"}, true) + require.NoError(t, err) + + output := buf.String() + require.Contains(t, output, "Would run: docker") + require.Contains(t, output, "run --rm -it") + require.NotContains(t, output, "-p") + require.Contains(t, output, "recommend -n 3") + require.Contains(t, output, "DOCKER_MODEL_RUNNER_HOST="+testBaseURL) + require.Contains(t, output, "ghcr.io/alexsjones/llmfit") +} + +func TestLaunchContainerAppInteractiveSkippedOnDetach(t *testing.T) { + ca := containerApp{ + defaultImage: "ghcr.io/alexsjones/llmfit", + envFn: llmfitEnv, + interactive: true, + } + stdout := new(bytes.Buffer) + stderr := new(bytes.Buffer) + cmd := &cobra.Command{} + cmd.SetOut(stdout) + cmd.SetErr(stderr) + + err := launchContainerApp(cmd, "llmfit", ca, testBaseURL, "", 0, true, nil, true) + require.NoError(t, err) + + output := stdout.String() + require.Contains(t, output, "run --rm -d") + require.NotContains(t, output, "-it") + require.Contains(t, output, "Warning: llmfit runs in interactive mode, app may not work as expected in detached mode") +} diff --git a/cmd/cli/docs/reference/docker_model_launch.yaml b/cmd/cli/docs/reference/docker_model_launch.yaml index 58f074ee0..f407a3c0f 100644 --- a/cmd/cli/docs/reference/docker_model_launch.yaml +++ b/cmd/cli/docs/reference/docker_model_launch.yaml @@ -5,14 +5,16 @@ long: |- Without arguments, lists all supported apps. - Supported apps: anythingllm, claude, codex, openclaw, opencode, openwebui + Supported apps: anythingllm, claude, codex, llmfit, openclaw, opencode, openwebui Examples: docker model launch docker model launch opencode + docker model launch llmfit docker model launch claude -- --help docker model launch openwebui --port 3000 docker model launch claude --config + docker model launch llmfit -- recommend -n 5 usage: docker model launch [APP] [-- APP_ARGS...] pname: docker model plink: docker_model.yaml diff --git a/cmd/cli/docs/reference/model_launch.md b/cmd/cli/docs/reference/model_launch.md index 161e2a3a3..426cdeb67 100644 --- a/cmd/cli/docs/reference/model_launch.md +++ b/cmd/cli/docs/reference/model_launch.md @@ -5,14 +5,16 @@ Launch an app configured to use Docker Model Runner. Without arguments, lists all supported apps. -Supported apps: anythingllm, claude, codex, openclaw, opencode, openwebui +Supported apps: anythingllm, claude, codex, llmfit, openclaw, opencode, openwebui Examples: docker model launch docker model launch opencode + docker model launch llmfit docker model launch claude -- --help docker model launch openwebui --port 3000 docker model launch claude --config + docker model launch llmfit -- recommend -n 5 ### Options