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
7 changes: 7 additions & 0 deletions internal/cli/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}},
},
}
}

Expand Down Expand Up @@ -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}}
}
Expand Down
56 changes: 56 additions & 0 deletions internal/cli/platform.go
Original file line number Diff line number Diff line change
@@ -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()
}
130 changes: 130 additions & 0 deletions internal/cli/platform_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
1 change: 1 addition & 0 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ func NewRootCmd() *cobra.Command {
root.AddCommand(
newConfigCmd(),
newAuthCmd(),
newPlatformCmd(),
newPushCmd(),
newSaveCmd(),
newPullCmd(),
Expand Down
Loading