diff --git a/internal/cli/mcp.go b/internal/cli/mcp.go index 71e85e3..90445ce 100644 --- a/internal/cli/mcp.go +++ b/internal/cli/mcp.go @@ -304,6 +304,11 @@ func toolDefinitions() []toolDef { Description: "Report the configured ltm host and a short fingerprint of the stored token. Use to verify the server the MCP will hit before pushing.", InputSchema: map[string]any{"type": "object", "properties": map[string]any{}}, }, + { + Name: "platform", + Description: "Return the URL of the managed ltm platform's web dashboard so the agent can surface it to the user. Errors when the user is configured against a self-hosted server. No server round-trip.", + InputSchema: map[string]any{"type": "object", "properties": map[string]any{}}, + }, } } @@ -343,6 +348,8 @@ func handleToolCall(req *rpcRequest) rpcResponse { text = string(embeddedExamplePacket) case "whoami": text, err = toolWhoami() + case "platform": + text, err = toolPlatform() default: return rpcResponse{Error: &rpcError{Code: -32602, Message: "unknown tool: " + params.Name}} } diff --git a/internal/cli/platform.go b/internal/cli/platform.go new file mode 100644 index 0000000..defd459 --- /dev/null +++ b/internal/cli/platform.go @@ -0,0 +1,56 @@ +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/dennisdevulder/ltm/internal/config" +) + +// browserOpener is the system-browser launcher used by `ltm platform`. +// It's a var so tests can replace it without spawning a real browser. +var browserOpener = openBrowser + +// platformDashboardURL returns the managed dashboard URL or an error +// explaining why the current configuration can't reach one. Shared by the +// `ltm platform` CLI command and the `platform` MCP tool so the two stay +// in lock-step. +func platformDashboardURL() (string, error) { + cfg, err := config.Load() + if err != nil { + return "", err + } + cfg.Resolve() + switch { + case cfg.Host == "": + return "", fmt.Errorf("no host configured. run 'ltm auth' to sign in to the managed platform") + case cfg.Host != defaultHubURL: + return "", fmt.Errorf("'ltm platform' opens the managed dashboard at %s, but you are configured against %s (self-hosted). open your own dashboard directly", defaultHubURL, cfg.Host) + } + return defaultHubURL, nil +} + +func newPlatformCmd() *cobra.Command { + return &cobra.Command{ + Use: "platform", + Aliases: []string{"dashboard"}, + Short: "Open the managed ltm platform dashboard in your browser.", + Long: "Open the managed ltm platform dashboard at " + defaultHubURL + " in your default browser.\n\n" + + "Only works when you are signed in to the managed platform. Self-hosted users manage their own dashboard.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + target, err := platformDashboardURL() + if err != nil { + return err + } + fmt.Println(target) + _ = browserOpener(target) + return nil + }, + } +} + +func toolPlatform() (string, error) { + return platformDashboardURL() +} diff --git a/internal/cli/platform_test.go b/internal/cli/platform_test.go new file mode 100644 index 0000000..bae07c4 --- /dev/null +++ b/internal/cli/platform_test.go @@ -0,0 +1,130 @@ +package cli + +import ( + "strings" + "testing" +) + +// setManagedEnv isolates config to a tempdir and points LTM_HOST at the +// managed platform. Returns no values — env is restored by t's cleanup. +func setManagedEnv(t *testing.T) { + t.Helper() + t.Setenv("LTM_CONFIG_DIR", t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", "") + t.Setenv("LTM_HOST", defaultHubURL) +} + +// recordBrowser swaps browserOpener for a recorder. Returns a pointer that +// the caller can read after running the command. Restoration is registered +// via t.Cleanup. +func recordBrowser(t *testing.T) *string { + t.Helper() + var got string + orig := browserOpener + browserOpener = func(u string) error { got = u; return nil } + t.Cleanup(func() { browserOpener = orig }) + return &got +} + +func TestPlatform_ManagedHost_PrintsAndOpensURL(t *testing.T) { + setManagedEnv(t) + opened := recordBrowser(t) + + out, _, err := run(t, nil, "platform") + if err != nil { + t.Fatalf("platform: %v", err) + } + if !strings.Contains(out, defaultHubURL) { + t.Errorf("stdout should contain %q, got: %q", defaultHubURL, out) + } + if *opened != defaultHubURL { + t.Errorf("browserOpener called with %q, want %q", *opened, defaultHubURL) + } +} + +func TestPlatform_SelfHosted_Errors(t *testing.T) { + _, srv := setupCLI(t) + opened := recordBrowser(t) + + _, _, err := run(t, nil, "platform") + if err == nil { + t.Fatal("expected error for self-hosted host, got nil") + } + msg := err.Error() + if !strings.Contains(msg, "self-hosted") { + t.Errorf("error should mention self-hosted, got: %q", msg) + } + if !strings.Contains(msg, srv.URL) { + t.Errorf("error should surface the current host %q, got: %q", srv.URL, msg) + } + if *opened != "" { + t.Errorf("browserOpener must not be called when self-hosted, got %q", *opened) + } +} + +func TestPlatform_NoHost_Errors(t *testing.T) { + t.Setenv("LTM_CONFIG_DIR", t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", "") + t.Setenv("LTM_HOST", "") + opened := recordBrowser(t) + + _, _, err := run(t, nil, "platform") + if err == nil { + t.Fatal("expected error when no host configured, got nil") + } + msg := err.Error() + if !strings.Contains(msg, "no host configured") { + t.Errorf("error should mention no host configured, got: %q", msg) + } + if !strings.Contains(msg, "ltm auth") { + t.Errorf("error should suggest 'ltm auth', got: %q", msg) + } + if *opened != "" { + t.Errorf("browserOpener must not be called with no host, got %q", *opened) + } +} + +func TestPlatform_DashboardAlias(t *testing.T) { + setManagedEnv(t) + opened := recordBrowser(t) + + out, _, err := run(t, nil, "dashboard") + if err != nil { + t.Fatalf("dashboard alias: %v", err) + } + if !strings.Contains(out, defaultHubURL) { + t.Errorf("stdout should contain %q, got: %q", defaultHubURL, out) + } + if *opened != defaultHubURL { + t.Errorf("alias should drive same RunE; browserOpener got %q, want %q", *opened, defaultHubURL) + } +} + +func TestMCP_Platform_ManagedHost_ReturnsURL(t *testing.T) { + setManagedEnv(t) + + resp := callTool(t, "platform", map[string]any{}) + text, isError := toolText(t, resp) + if isError { + t.Fatalf("expected success, got error result: %q", text) + } + if text != defaultHubURL { + t.Errorf("tool returned %q, want %q", text, defaultHubURL) + } +} + +func TestMCP_Platform_SelfHosted_IsError(t *testing.T) { + _, srv := setupCLI(t) + + resp := callTool(t, "platform", map[string]any{}) + text, isError := toolText(t, resp) + if !isError { + t.Fatalf("expected isError=true for self-hosted, got success: %q", text) + } + if !strings.Contains(text, "self-hosted") { + t.Errorf("error text should mention self-hosted, got: %q", text) + } + if !strings.Contains(text, srv.URL) { + t.Errorf("error text should surface current host %q, got: %q", srv.URL, text) + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go index f27130d..d6d7a0b 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -31,6 +31,7 @@ func NewRootCmd() *cobra.Command { root.AddCommand( newConfigCmd(), newAuthCmd(), + newPlatformCmd(), newPushCmd(), newSaveCmd(), newPullCmd(),