feat(core): add workspace-local cache for GitHub API responses

- Add pkg/cache for file-based caching with TTL
- Cache lives in workspace .core/cache/ (not home dir)
- Search command now caches repo lists for 1 hour
- Shows "Cache: host-uk (5s ago)" on cache hit
- Use --refresh to bypass cache
- Add .core/ to core-devops .gitignore

Structure:
  .core/cache/github/<org>/repos.json

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-01-28 15:19:03 +00:00
parent 75c52e96e9
commit 936e968ad0
4 changed files with 233 additions and 35 deletions

View file

@ -5,9 +5,13 @@ import (
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
"path/filepath"
"sort" "sort"
"strings" "strings"
"time"
"github.com/Snider/Core/pkg/cache"
"github.com/Snider/Core/pkg/repos"
"github.com/leaanthony/clir" "github.com/leaanthony/clir"
) )
@ -17,19 +21,21 @@ func AddSearchCommand(parent *clir.Cli) {
var pattern string var pattern string
var repoType string var repoType string
var limit int var limit int
var refresh bool
searchCmd := parent.NewSubCommand("search", "Search GitHub for repos by pattern") searchCmd := parent.NewSubCommand("search", "Search GitHub for repos by pattern")
searchCmd.LongDescription("Searches GitHub for repositories matching a pattern.\n" + searchCmd.LongDescription("Searches GitHub for repositories matching a pattern.\n" +
"Uses gh CLI for authenticated search.\n\n" + "Uses gh CLI for authenticated search. Results are cached for 1 hour.\n\n" +
"Examples:\n" + "Examples:\n" +
" core search --org host-uk --pattern 'core-*'\n" + " core search --org host-uk --pattern 'core-*'\n" +
" core search --org mycompany --pattern '*-mod-*'\n" + " core search --org mycompany --pattern '*-mod-*'\n" +
" core search --org letheanvpn --pattern '*'") " core search --org letheanvpn --refresh")
searchCmd.StringFlag("org", "GitHub organization to search (required)", &org) searchCmd.StringFlag("org", "GitHub organization to search (required)", &org)
searchCmd.StringFlag("pattern", "Repo name pattern (* for wildcard)", &pattern) searchCmd.StringFlag("pattern", "Repo name pattern (* for wildcard)", &pattern)
searchCmd.StringFlag("type", "Filter by type in name (mod, services, plug, website)", &repoType) searchCmd.StringFlag("type", "Filter by type in name (mod, services, plug, website)", &repoType)
searchCmd.IntFlag("limit", "Max results (default 50)", &limit) searchCmd.IntFlag("limit", "Max results (default 50)", &limit)
searchCmd.BoolFlag("refresh", "Bypass cache and fetch fresh data", &refresh)
searchCmd.Action(func() error { searchCmd.Action(func() error {
if org == "" { if org == "" {
@ -41,7 +47,7 @@ func AddSearchCommand(parent *clir.Cli) {
if limit == 0 { if limit == 0 {
limit = 50 limit = 50
} }
return runSearch(org, pattern, repoType, limit) return runSearch(org, pattern, repoType, limit, refresh)
}) })
} }
@ -54,44 +60,71 @@ type ghRepo struct {
Language string `json:"language"` Language string `json:"language"`
} }
func runSearch(org, pattern, repoType string, limit int) error { func runSearch(org, pattern, repoType string, limit int, refresh bool) error {
if !ghAuthenticated() { // Initialize cache in workspace .core/ directory
return fmt.Errorf("gh CLI not authenticated. Run: gh auth login") var cacheDir string
if regPath, err := repos.FindRegistry(); err == nil {
cacheDir = filepath.Join(filepath.Dir(regPath), ".core", "cache")
} }
// Check for bad GH_TOKEN which can override keyring auth c, err := cache.New(cacheDir, 0)
if os.Getenv("GH_TOKEN") != "" {
fmt.Printf("%s GH_TOKEN env var is set - this may cause auth issues\n", dimStyle.Render("Note:"))
fmt.Printf("%s Unset it with: unset GH_TOKEN\n\n", dimStyle.Render(""))
}
fmt.Printf("%s %s", dimStyle.Render("Searching:"), org)
if pattern != "" && pattern != "*" {
fmt.Printf(" (filter: %s)", pattern)
}
if repoType != "" {
fmt.Printf(" (type: %s)", repoType)
}
fmt.Println()
fmt.Println()
// Always use gh repo list (more reliable than gh search repos)
cmd := exec.Command("gh", "repo", "list", org,
"--json", "name,description,visibility,updatedAt,primaryLanguage",
"--limit", fmt.Sprintf("%d", limit))
output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
errStr := strings.TrimSpace(string(output)) // Cache init failed, continue without cache
if strings.Contains(errStr, "401") || strings.Contains(errStr, "Bad credentials") { c = nil
return fmt.Errorf("authentication failed - try: unset GH_TOKEN && gh auth login")
}
return fmt.Errorf("search failed: %s", errStr)
} }
cacheKey := cache.GitHubReposKey(org)
var repos []ghRepo var repos []ghRepo
if err := json.Unmarshal(output, &repos); err != nil { var fromCache bool
return fmt.Errorf("failed to parse results: %w", err)
// Try cache first (unless refresh requested)
if c != nil && !refresh {
if found, err := c.Get(cacheKey, &repos); found && err == nil {
fromCache = true
age := c.Age(cacheKey)
fmt.Printf("%s %s %s\n", dimStyle.Render("Cache:"), org, dimStyle.Render(fmt.Sprintf("(%s ago)", age.Round(time.Second))))
}
}
// Fetch from GitHub if not cached
if !fromCache {
if !ghAuthenticated() {
return fmt.Errorf("gh CLI not authenticated. Run: gh auth login")
}
// Check for bad GH_TOKEN which can override keyring auth
if os.Getenv("GH_TOKEN") != "" {
fmt.Printf("%s GH_TOKEN env var is set - this may cause auth issues\n", dimStyle.Render("Note:"))
fmt.Printf("%s Unset it with: unset GH_TOKEN\n\n", dimStyle.Render(""))
}
fmt.Printf("%s %s... ", dimStyle.Render("Fetching:"), org)
// Always use gh repo list (more reliable than gh search repos)
cmd := exec.Command("gh", "repo", "list", org,
"--json", "name,description,visibility,updatedAt,primaryLanguage",
"--limit", fmt.Sprintf("%d", limit))
output, err := cmd.CombinedOutput()
if err != nil {
fmt.Println()
errStr := strings.TrimSpace(string(output))
if strings.Contains(errStr, "401") || strings.Contains(errStr, "Bad credentials") {
return fmt.Errorf("authentication failed - try: unset GH_TOKEN && gh auth login")
}
return fmt.Errorf("search failed: %s", errStr)
}
if err := json.Unmarshal(output, &repos); err != nil {
return fmt.Errorf("failed to parse results: %w", err)
}
// Cache the results
if c != nil {
_ = c.Set(cacheKey, repos)
}
fmt.Printf("%s\n", successStyle.Render("✓"))
} }
// Filter by glob pattern and type // Filter by glob pattern and type

View file

@ -7,6 +7,7 @@ use (
./cmd/core-mcp ./cmd/core-mcp
./cmd/examples/core-static-di ./cmd/examples/core-static-di
./cmd/lthn-desktop ./cmd/lthn-desktop
./pkg/cache
./pkg/config ./pkg/config
./pkg/core ./pkg/core
./pkg/display ./pkg/display

161
pkg/cache/cache.go vendored Normal file
View file

@ -0,0 +1,161 @@
// Package cache provides a file-based cache for GitHub API responses.
package cache
import (
"encoding/json"
"os"
"path/filepath"
"time"
)
// DefaultTTL is the default cache expiry time.
const DefaultTTL = 1 * time.Hour
// Cache represents a file-based cache.
type Cache struct {
baseDir string
ttl time.Duration
}
// Entry represents a cached item with metadata.
type Entry struct {
Data json.RawMessage `json:"data"`
CachedAt time.Time `json:"cached_at"`
ExpiresAt time.Time `json:"expires_at"`
}
// New creates a new cache instance.
// If baseDir is empty, uses .core/cache in current directory
func New(baseDir string, ttl time.Duration) (*Cache, error) {
if baseDir == "" {
// Use .core/cache in current working directory
cwd, err := os.Getwd()
if err != nil {
return nil, err
}
baseDir = filepath.Join(cwd, ".core", "cache")
}
if ttl == 0 {
ttl = DefaultTTL
}
// Ensure cache directory exists
if err := os.MkdirAll(baseDir, 0755); err != nil {
return nil, err
}
return &Cache{
baseDir: baseDir,
ttl: ttl,
}, nil
}
// Path returns the full path for a cache key.
func (c *Cache) Path(key string) string {
return filepath.Join(c.baseDir, key+".json")
}
// Get retrieves a cached item if it exists and hasn't expired.
func (c *Cache) Get(key string, dest interface{}) (bool, error) {
path := c.Path(key)
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
var entry Entry
if err := json.Unmarshal(data, &entry); err != nil {
// Invalid cache file, treat as miss
return false, nil
}
// Check expiry
if time.Now().After(entry.ExpiresAt) {
return false, nil
}
// Unmarshal the actual data
if err := json.Unmarshal(entry.Data, dest); err != nil {
return false, err
}
return true, nil
}
// Set stores an item in the cache.
func (c *Cache) Set(key string, data interface{}) error {
path := c.Path(key)
// Ensure parent directory exists
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
}
// Marshal the data
dataBytes, err := json.Marshal(data)
if err != nil {
return err
}
entry := Entry{
Data: dataBytes,
CachedAt: time.Now(),
ExpiresAt: time.Now().Add(c.ttl),
}
entryBytes, err := json.MarshalIndent(entry, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, entryBytes, 0644)
}
// Delete removes an item from the cache.
func (c *Cache) Delete(key string) error {
path := c.Path(key)
err := os.Remove(path)
if os.IsNotExist(err) {
return nil
}
return err
}
// Clear removes all cached items.
func (c *Cache) Clear() error {
return os.RemoveAll(c.baseDir)
}
// Age returns how old a cached item is, or -1 if not cached.
func (c *Cache) Age(key string) time.Duration {
path := c.Path(key)
data, err := os.ReadFile(path)
if err != nil {
return -1
}
var entry Entry
if err := json.Unmarshal(data, &entry); err != nil {
return -1
}
return time.Since(entry.CachedAt)
}
// GitHub-specific cache keys
// GitHubReposKey returns the cache key for an org's repo list.
func GitHubReposKey(org string) string {
return filepath.Join("github", org, "repos")
}
// GitHubRepoKey returns the cache key for a specific repo's metadata.
func GitHubRepoKey(org, repo string) string {
return filepath.Join("github", org, repo, "meta")
}

3
pkg/cache/go.mod vendored Normal file
View file

@ -0,0 +1,3 @@
module github.com/Snider/Core/pkg/cache
go 1.24