From a694201374d1717cfccd75d066130351a6e9929d Mon Sep 17 00:00:00 2001 From: Yves Junqueira Date: Sat, 28 Mar 2026 23:50:39 -0300 Subject: [PATCH 1/2] feat: auto-refresh tool cache via background hash check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Checks /api/tools-hash once per hour in a detached subprocess. If the hash differs from the cached value, a full refresh runs in the background. The current command is never blocked — updates take effect on the next invocation. --- cmd/cache.go | 6 ++-- cmd/dynamic.go | 51 +++++++++++++++++++++++++++++++ cmd/refresh.go | 69 +++++++++++++++++++++++++++++++++--------- internal/client/mcp.go | 26 ++++++++++++++++ 4 files changed, 135 insertions(+), 17 deletions(-) diff --git a/cmd/cache.go b/cmd/cache.go index e62a9ca..a2c6f9e 100644 --- a/cmd/cache.go +++ b/cmd/cache.go @@ -11,8 +11,10 @@ import ( // ToolsCache stores tool definitions fetched from MCP servers. type ToolsCache struct { - Servers map[string]ServerToolsCache `json:"servers"` - UpdatedAt time.Time `json:"updated_at"` + Servers map[string]ServerToolsCache `json:"servers"` + UpdatedAt time.Time `json:"updated_at"` + Hash string `json:"hash,omitempty"` + LastCheckedAt time.Time `json:"last_checked_at,omitempty"` } // ServerToolsCache holds the tools for a single MCP server. diff --git a/cmd/dynamic.go b/cmd/dynamic.go index 604b432..14a99e1 100644 --- a/cmd/dynamic.go +++ b/cmd/dynamic.go @@ -4,13 +4,18 @@ import ( "encoding/json" "fmt" "os" + "os/exec" "strconv" "strings" + "syscall" + "time" "github.com/pipeboard-co/pipeboard-cli/internal/client" "github.com/spf13/cobra" ) +const autoRefreshInterval = 1 * time.Hour + // registerDynamicCommands loads the tools cache and registers platform commands. func registerDynamicCommands() { cache, err := loadToolsCache() @@ -36,6 +41,52 @@ func registerDynamicCommands() { rootCmd.AddCommand(platformCmd) } + + // Check for tool updates in the background + maybeAutoRefresh(cache) +} + +// maybeAutoRefresh checks whether the tools cache is stale and, if so, +// spawns a detached background process to check the hash and refresh if +// needed. The current command always uses the existing cache; updates +// take effect on the next invocation. +func maybeAutoRefresh(cache *ToolsCache) { + if time.Since(cache.LastCheckedAt) < autoRefreshInterval { + return + } + + // Spawn a detached "pipeboard refresh --if-changed" subprocess. + // It survives parent exit and writes the cache for the next run. + self, err := os.Executable() + if err != nil { + return + } + cmd := exec.Command(self, "refresh", "--if-changed") + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + cmd.Stdout = nil + cmd.Stderr = nil + _ = cmd.Start() +} + +// runAutoRefresh is the implementation behind "refresh --if-changed". +// It checks the hash first and only does a full refresh if tools changed. +func runAutoRefresh(baseURL, token string) error { + cache, _ := loadToolsCache() + + hashResult, err := client.FetchToolsHash(baseURL) + if err != nil { + return err + } + + if cache != nil && hashResult.Hash == cache.Hash { + // Tools unchanged — just bump the check timestamp + cache.LastCheckedAt = time.Now() + return saveToolsCache(cache) + } + + // Hash changed or no cache — do a full refresh + _, err = fetchAndCacheTools(baseURL, token, false) + return err } // toolNameToCommandName converts an MCP tool name to a CLI command name. diff --git a/cmd/refresh.go b/cmd/refresh.go index 6cb8fbc..5020a9e 100644 --- a/cmd/refresh.go +++ b/cmd/refresh.go @@ -9,53 +9,92 @@ import ( "github.com/spf13/cobra" ) +var ifChanged bool + var refreshCmd = &cobra.Command{ Use: "refresh", Short: "Fetch and cache tool definitions from Pipeboard MCP servers", Long: `Fetches available tools from all Pipeboard MCP servers and caches them locally. This enables platform commands like 'pipeboard google-ads get-campaigns'. -Run this after first install or when new tools become available.`, +Normally you don't need to run this — the CLI automatically checks for +updates once per hour and refreshes in the background. Use this command +to force an immediate refresh.`, RunE: runRefresh, } +func init() { + refreshCmd.Flags().BoolVar(&ifChanged, "if-changed", false, "Only refresh if tools hash has changed (used by auto-refresh)") + refreshCmd.Flags().MarkHidden("if-changed") +} + func runRefresh(cmd *cobra.Command, args []string) error { token, err := getToken() if err != nil { return err } - c := client.New(apiURL, token) - cache := &ToolsCache{ - Servers: make(map[string]ServerToolsCache), - UpdatedAt: time.Now(), + if ifChanged { + return runAutoRefresh(apiURL, token) + } + + cache, err := fetchAndCacheTools(apiURL, token, true) + if err != nil { + return err } totalTools := 0 + for _, sc := range cache.Servers { + totalTools += len(sc.Tools) + } + path, _ := toolsCachePath() + fmt.Fprintf(os.Stderr, "\nCached %d tools to %s\n", totalTools, path) + return nil +} + +// fetchAndCacheTools fetches tools from all servers and saves the cache. +// If verbose is true, progress is printed to stderr. +func fetchAndCacheTools(baseURL, token string, verbose bool) (*ToolsCache, error) { + c := client.New(baseURL, token) + cache := &ToolsCache{ + Servers: make(map[string]ServerToolsCache), + UpdatedAt: time.Now(), + LastCheckedAt: time.Now(), + } + for _, server := range knownServers { - fmt.Fprintf(os.Stderr, "Fetching tools from %s... ", server.Path) + if verbose { + fmt.Fprintf(os.Stderr, "Fetching tools from %s... ", server.Path) + } if err := c.Initialize(server.Path); err != nil { - fmt.Fprintf(os.Stderr, "error: %v\n", err) + if verbose { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + } continue } result, err := c.ListTools(server.Path) if err != nil { - fmt.Fprintf(os.Stderr, "error: %v\n", err) + if verbose { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + } continue } cache.Servers[server.Path] = ServerToolsCache{Tools: result.Tools} - totalTools += len(result.Tools) - fmt.Fprintf(os.Stderr, "%d tools\n", len(result.Tools)) + if verbose { + fmt.Fprintf(os.Stderr, "%d tools\n", len(result.Tools)) + } } - if err := saveToolsCache(cache); err != nil { - return fmt.Errorf("saving cache: %w", err) + // Fetch and store the current hash + if hashResult, err := client.FetchToolsHash(baseURL); err == nil { + cache.Hash = hashResult.Hash } - path, _ := toolsCachePath() - fmt.Fprintf(os.Stderr, "\nCached %d tools to %s\n", totalTools, path) - return nil + if err := saveToolsCache(cache); err != nil { + return nil, fmt.Errorf("saving cache: %w", err) + } + return cache, nil } diff --git a/internal/client/mcp.go b/internal/client/mcp.go index 3939250..dd292d8 100644 --- a/internal/client/mcp.go +++ b/internal/client/mcp.go @@ -165,6 +165,32 @@ func (c *Client) call(serverPath, method string, params interface{}) (json.RawMe return rpcResp.Result, nil } +// ToolsHashResult is the response from GET /tools-hash. +type ToolsHashResult struct { + Hash string `json:"hash"` +} + +// FetchToolsHash retrieves the combined tools hash from the server. +// baseURL is the MCP base URL (e.g. https://mcp.pipeboard.co). +// This endpoint requires no authentication. +func FetchToolsHash(baseURL string) (*ToolsHashResult, error) { + resp, err := http.Get(baseURL + "/api/tools-hash") + if err != nil { + return nil, fmt.Errorf("fetching tools hash: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("tools-hash HTTP %d", resp.StatusCode) + } + + var result ToolsHashResult + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("parsing tools-hash: %w", err) + } + return &result, nil +} + func (c *Client) notify(serverPath, method string, params interface{}) error { // Notifications have no ID and expect no response. type notification struct { From f53587ef357bac686408c36c62b06ff24da80715 Mon Sep 17 00:00:00 2001 From: Yves Junqueira Date: Sat, 28 Mar 2026 23:59:02 -0300 Subject: [PATCH 2/2] fix: prevent recursive subprocess spawning in auto-refresh The background auto-refresh child process would re-enter maybeAutoRefresh during init and spawn another child before updating the cache. Guard with PIPEBOARD_AUTO_REFRESH env var. --- cmd/dynamic.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/cmd/dynamic.go b/cmd/dynamic.go index 14a99e1..183f506 100644 --- a/cmd/dynamic.go +++ b/cmd/dynamic.go @@ -51,6 +51,12 @@ func registerDynamicCommands() { // needed. The current command always uses the existing cache; updates // take effect on the next invocation. func maybeAutoRefresh(cache *ToolsCache) { + // Guard against recursive spawning: the child sets this env var so + // it won't spawn another child during its own init(). + if os.Getenv("PIPEBOARD_AUTO_REFRESH") == "1" { + return + } + if time.Since(cache.LastCheckedAt) < autoRefreshInterval { return } @@ -62,6 +68,7 @@ func maybeAutoRefresh(cache *ToolsCache) { return } cmd := exec.Command(self, "refresh", "--if-changed") + cmd.Env = append(os.Environ(), "PIPEBOARD_AUTO_REFRESH=1") cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} cmd.Stdout = nil cmd.Stderr = nil