diff --git a/cmd/cli/commands/launch.go b/cmd/cli/commands/launch.go index 5324e5366..c48fb0b61 100644 --- a/cmd/cli/commands/launch.go +++ b/cmd/cli/commands/launch.go @@ -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 { @@ -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) } @@ -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) { diff --git a/cmd/cli/commands/launch_test.go b/cmd/cli/commands/launch_test.go index f765195ac..80140977c 100644 --- a/cmd/cli/commands/launch_test.go +++ b/cmd/cli/commands/launch_test.go @@ -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) { @@ -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") +} diff --git a/cmd/cli/docs/reference/docker_model_launch.yaml b/cmd/cli/docs/reference/docker_model_launch.yaml index 31ec71e84..ead0529a8 100644 --- a/cmd/cli/docs/reference/docker_model_launch.yaml +++ b/cmd/cli/docs/reference/docker_model_launch.yaml @@ -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" diff --git a/cmd/cli/docs/reference/model_launch.md b/cmd/cli/docs/reference/model_launch.md index fd3c48ae3..ca8e9773d 100644 --- a/cmd/cli/docs/reference/model_launch.md +++ b/cmd/cli/docs/reference/model_launch.md @@ -3,12 +3,22 @@ Launch an app configured to use Docker Model Runner. +Without arguments, lists all supported apps. + Supported apps: anythingllm, claude, codex, openclaw, opencode, openwebui +Examples: + docker model launch + docker model launch opencode + docker model launch claude -- --help + docker model launch openwebui --port 3000 + docker model launch claude --config + ### Options | Name | Type | Default | Description | |:------------|:---------|:--------|:------------------------------------------------| +| `--config` | `bool` | | Print configuration without launching | | `--detach` | `bool` | | Run containerized app in background | | `--dry-run` | `bool` | | Print what would be executed without running it | | `--image` | `string` | | Override container image for containerized apps |