Add the statusline. Fix the installation.

This commit is contained in:
2025-12-16 00:15:25 +00:00
parent be4a7c19d0
commit bc4e9e66ae
14 changed files with 582 additions and 60 deletions
+8 -1
View File
@@ -12,6 +12,13 @@ before:
- bash -c "cd ui && npm ci --silent && npm run build"
- bash -c "rm -rf internal/worker/static && mkdir -p internal/worker/static && cp -r ui/dist/* internal/worker/static/"
# Generate versioned plugin configuration files (GoReleaser Pro feature)
template_files:
- src: plugin/.claude-plugin/plugin.json.tpl
dst: .claude-plugin/plugin.json
- src: plugin/.claude-plugin/marketplace.json.tpl
dst: .claude-plugin/marketplace.json
builds:
# Worker service
- id: worker
@@ -211,7 +218,7 @@ archives:
{{- .Os }}_
{{- .Arch }}
files:
- src: plugin/.claude-plugin/*
- src: dist/.claude-plugin/*
dst: .claude-plugin
strip_parent: true
- src: plugin/hooks/hooks.json
+11 -17
View File
@@ -45,6 +45,7 @@ hooks:
go build $(BUILD_TAGS) $(LDFLAGS) -o $(BUILD_DIR)/hooks/post-tool-use ./cmd/hooks/post-tool-use
go build $(BUILD_TAGS) $(LDFLAGS) -o $(BUILD_DIR)/hooks/subagent-stop ./cmd/hooks/subagent-stop
go build $(BUILD_TAGS) $(LDFLAGS) -o $(BUILD_DIR)/hooks/stop ./cmd/hooks/stop
go build $(BUILD_TAGS) $(LDFLAGS) -o $(BUILD_DIR)/hooks/statusline ./cmd/hooks/statusline
# Build MCP server
mcp:
@@ -65,6 +66,7 @@ build-linux:
GOOS=linux GOARCH=amd64 go build $(LDFLAGS) -o $(BUILD_DIR)/linux-amd64/hooks/post-tool-use ./cmd/hooks/post-tool-use
GOOS=linux GOARCH=amd64 go build $(LDFLAGS) -o $(BUILD_DIR)/linux-amd64/hooks/subagent-stop ./cmd/hooks/subagent-stop
GOOS=linux GOARCH=amd64 go build $(LDFLAGS) -o $(BUILD_DIR)/linux-amd64/hooks/stop ./cmd/hooks/stop
GOOS=linux GOARCH=amd64 go build $(LDFLAGS) -o $(BUILD_DIR)/linux-amd64/hooks/statusline ./cmd/hooks/statusline
build-darwin:
@echo "Building for macOS..."
@@ -83,6 +85,8 @@ build-darwin:
GOOS=darwin GOARCH=arm64 go build $(LDFLAGS) -o $(BUILD_DIR)/darwin-arm64/hooks/subagent-stop ./cmd/hooks/subagent-stop
GOOS=darwin GOARCH=amd64 go build $(LDFLAGS) -o $(BUILD_DIR)/darwin-amd64/hooks/stop ./cmd/hooks/stop
GOOS=darwin GOARCH=arm64 go build $(LDFLAGS) -o $(BUILD_DIR)/darwin-arm64/hooks/stop ./cmd/hooks/stop
GOOS=darwin GOARCH=amd64 go build $(LDFLAGS) -o $(BUILD_DIR)/darwin-amd64/hooks/statusline ./cmd/hooks/statusline
GOOS=darwin GOARCH=arm64 go build $(LDFLAGS) -o $(BUILD_DIR)/darwin-arm64/hooks/statusline ./cmd/hooks/statusline
build-windows:
@echo "Building for Windows..."
@@ -94,6 +98,7 @@ build-windows:
GOOS=windows GOARCH=amd64 go build $(LDFLAGS) -o $(BUILD_DIR)/windows-amd64/hooks/post-tool-use.exe ./cmd/hooks/post-tool-use
GOOS=windows GOARCH=amd64 go build $(LDFLAGS) -o $(BUILD_DIR)/windows-amd64/hooks/subagent-stop.exe ./cmd/hooks/subagent-stop
GOOS=windows GOARCH=amd64 go build $(LDFLAGS) -o $(BUILD_DIR)/windows-amd64/hooks/stop.exe ./cmd/hooks/stop
GOOS=windows GOARCH=amd64 go build $(LDFLAGS) -o $(BUILD_DIR)/windows-amd64/hooks/statusline.exe ./cmd/hooks/statusline
# Stop any running worker
stop-worker:
@@ -107,8 +112,8 @@ stop-worker:
start-worker:
@echo "Starting worker..."
@# Prefer cache directory (where Claude Code looks), fall back to marketplaces
@if [ -f "$(HOME)/.claude/plugins/cache/claude-mnemonic/claude-mnemonic/1.0.0/worker" ]; then \
nohup $(HOME)/.claude/plugins/cache/claude-mnemonic/claude-mnemonic/1.0.0/worker > /tmp/claude-mnemonic-worker.log 2>&1 & \
@if [ -f "$(HOME)/.claude/plugins/cache/claude-mnemonic/claude-mnemonic/$(VERSION)/worker" ]; then \
nohup $(HOME)/.claude/plugins/cache/claude-mnemonic/claude-mnemonic/$(VERSION)/worker > /tmp/claude-mnemonic-worker.log 2>&1 & \
else \
nohup $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/worker > /tmp/claude-mnemonic-worker.log 2>&1 & \
fi
@@ -132,22 +137,11 @@ install: build stop-worker
cp $(BUILD_DIR)/mcp-server $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/
cp $(BUILD_DIR)/hooks/* $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/hooks/
cp $(PLUGIN_DIR)/hooks/hooks.json $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/hooks/
cp $(PLUGIN_DIR)/.claude-plugin/plugin.json $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/.claude-plugin/
cp $(PLUGIN_DIR)/.claude-plugin/marketplace.json $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/.claude-plugin/
@# Also install to cache directory (where Claude Code looks for plugins)
@if [ -d "$(HOME)/.claude/plugins/cache/claude-mnemonic" ]; then \
echo "Updating plugin in cache directory..."; \
CACHE_DIR=$$(find $(HOME)/.claude/plugins/cache/claude-mnemonic -type d -name "hooks" -exec dirname {} \; 2>/dev/null | head -1); \
if [ -n "$$CACHE_DIR" ]; then \
cp $(BUILD_DIR)/worker "$$CACHE_DIR/"; \
cp $(BUILD_DIR)/mcp-server "$$CACHE_DIR/"; \
cp $(BUILD_DIR)/hooks/* "$$CACHE_DIR/hooks/"; \
cp $(PLUGIN_DIR)/hooks/hooks.json "$$CACHE_DIR/hooks/"; \
echo "Cache directory updated: $$CACHE_DIR"; \
fi; \
fi
@# Update plugin.json and marketplace.json with current version to prevent stale version directories
@sed 's/"version": "[^"]*"/"version": "$(VERSION)"/g' $(PLUGIN_DIR)/.claude-plugin/plugin.json > $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/.claude-plugin/plugin.json
@sed 's/"version": "[^"]*"/"version": "$(VERSION)"/g' $(PLUGIN_DIR)/.claude-plugin/marketplace.json > $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/.claude-plugin/marketplace.json
@echo "Registering plugin with Claude Code..."
@./scripts/register-plugin.sh
@./scripts/register-plugin.sh "$(VERSION)"
@$(MAKE) start-worker
@echo "Installation complete!"
+1
View File
@@ -100,6 +100,7 @@ cosign verify-blob \
| **Project Isolation** | Each project has its own knowledge base |
| **Global Patterns** | Best practices are shared across all projects |
| **Semantic Search** | Find relevant context with natural language |
| **Live Statusline** | Real-time metrics in Claude Code: `[mnemonic] ● served:42 | project:28 memories` |
| **Web Dashboard** | Browse and manage memories at `localhost:37777` |
### How knowledge flows
+307
View File
@@ -0,0 +1,307 @@
// Package main provides the statusline hook for Claude Code.
// This binary outputs a status line showing claude-mnemonic metrics.
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"time"
"github.com/lukaszraczylo/claude-mnemonic/pkg/hooks"
)
// StatusInput is the JSON input from Claude Code's statusline feature.
type StatusInput struct {
HookEventName string `json:"hook_event_name"`
SessionID string `json:"session_id"`
CWD string `json:"cwd"`
Model struct {
ID string `json:"id"`
DisplayName string `json:"display_name"`
} `json:"model"`
Workspace struct {
CurrentDir string `json:"current_dir"`
ProjectDir string `json:"project_dir"`
} `json:"workspace"`
Version string `json:"version"`
Cost struct {
TotalCostUSD float64 `json:"total_cost_usd"`
TotalDurationMS int64 `json:"total_duration_ms"`
TotalAPIDurationMS int64 `json:"total_api_duration_ms"`
TotalLinesAdded int `json:"total_lines_added"`
TotalLinesRemoved int `json:"total_lines_removed"`
} `json:"cost"`
ContextWindow struct {
TotalInputTokens int `json:"total_input_tokens"`
TotalOutputTokens int `json:"total_output_tokens"`
ContextWindowSize int `json:"context_window_size"`
} `json:"context_window"`
}
// WorkerStats is the response from the worker's /api/stats endpoint.
type WorkerStats struct {
Uptime string `json:"uptime"`
ActiveSessions int `json:"activeSessions"`
QueueDepth int `json:"queueDepth"`
IsProcessing bool `json:"isProcessing"`
ConnectedClients int `json:"connectedClients"`
SessionsToday int `json:"sessionsToday"`
Ready bool `json:"ready"`
Project string `json:"project,omitempty"`
ProjectObservations int `json:"projectObservations,omitempty"`
Retrieval struct {
TotalRequests int64 `json:"TotalRequests"`
ObservationsServed int64 `json:"ObservationsServed"`
SearchRequests int64 `json:"SearchRequests"`
ContextInjections int64 `json:"ContextInjections"`
} `json:"retrieval"`
}
// ANSI color codes
const (
colorReset = "\033[0m"
colorGreen = "\033[32m"
colorYellow = "\033[33m"
colorCyan = "\033[36m"
colorGray = "\033[90m"
colorRed = "\033[31m"
)
func main() {
// Read input from stdin
inputData, err := io.ReadAll(os.Stdin)
if err != nil {
// On error, output minimal status
fmt.Println(formatOffline())
return
}
var input StatusInput
if err := json.Unmarshal(inputData, &input); err != nil {
fmt.Println(formatOffline())
return
}
// Determine project directory
projectDir := input.Workspace.ProjectDir
if projectDir == "" {
projectDir = input.Workspace.CurrentDir
}
if projectDir == "" {
projectDir = input.CWD
}
// Generate project ID
project := ""
if projectDir != "" {
project = hooks.ProjectIDWithName(projectDir)
}
// Get worker stats
stats := getWorkerStats(project)
// Format and output statusline
fmt.Println(formatStatusLine(stats, input))
}
// getWorkerStats fetches stats from the worker service.
func getWorkerStats(project string) *WorkerStats {
port := hooks.GetWorkerPort()
// Build URL with optional project parameter
endpoint := fmt.Sprintf("http://127.0.0.1:%d/api/stats", port)
if project != "" {
endpoint += "?project=" + url.QueryEscape(project)
}
// Create HTTP client with short timeout (statusline must be fast)
client := &http.Client{Timeout: 100 * time.Millisecond}
resp, err := client.Get(endpoint)
if err != nil {
return nil
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil
}
var stats WorkerStats
if err := json.NewDecoder(resp.Body).Decode(&stats); err != nil {
return nil
}
return &stats
}
// formatStatusLine formats the status line output.
func formatStatusLine(stats *WorkerStats, input StatusInput) string {
// Check if colors are enabled (default: yes, unless TERM is dumb or NO_COLOR is set)
useColors := os.Getenv("NO_COLOR") == "" && os.Getenv("TERM") != "dumb"
if os.Getenv("CLAUDE_MNEMONIC_STATUSLINE_COLORS") == "false" {
useColors = false
} else if os.Getenv("CLAUDE_MNEMONIC_STATUSLINE_COLORS") == "true" {
useColors = true
}
// Check format preference
format := os.Getenv("CLAUDE_MNEMONIC_STATUSLINE_FORMAT")
if format == "" {
format = "default"
}
if stats == nil {
return formatOfflineColored(useColors)
}
if !stats.Ready {
return formatStartingColored(useColors)
}
switch format {
case "compact":
return formatCompact(stats, useColors)
case "minimal":
return formatMinimal(stats, useColors)
default:
return formatDefault(stats, useColors)
}
}
// formatDefault returns the default status line format.
func formatDefault(stats *WorkerStats, useColors bool) string {
// [mnemonic] ● served:42 | injected:5 | searches:3 | project:28 memories
var prefix, indicator, reset string
if useColors {
prefix = colorCyan + "[mnemonic]" + colorReset
indicator = colorGreen + "●" + colorReset
reset = colorReset
} else {
prefix = "[mnemonic]"
indicator = "●"
}
// Build status parts with clear labels
parts := []string{}
// Total memories served to Claude this session
parts = append(parts, fmt.Sprintf("served:%d", stats.Retrieval.ObservationsServed))
// Context injections (memories auto-loaded at session start)
if stats.Retrieval.ContextInjections > 0 {
parts = append(parts, fmt.Sprintf("injected:%d", stats.Retrieval.ContextInjections))
}
// Semantic searches performed
if stats.Retrieval.SearchRequests > 0 {
parts = append(parts, fmt.Sprintf("searches:%d", stats.Retrieval.SearchRequests))
}
// Project-specific memory count
if stats.ProjectObservations > 0 {
if useColors {
parts = append(parts, fmt.Sprintf("%sproject:%d memories%s", colorYellow, stats.ProjectObservations, reset))
} else {
parts = append(parts, fmt.Sprintf("project:%d memories", stats.ProjectObservations))
}
}
// Processing indicator
if stats.IsProcessing || stats.QueueDepth > 0 {
if useColors {
parts = append(parts, colorYellow+"processing..."+colorReset)
} else {
parts = append(parts, "processing...")
}
}
result := prefix + " " + indicator
for i, part := range parts {
if i == 0 {
result += " " + part
} else {
result += " | " + part
}
}
return result
}
// formatCompact returns a compact status line.
func formatCompact(stats *WorkerStats, useColors bool) string {
// [m] ● 42/5/3 (28)
var prefix, indicator string
if useColors {
prefix = colorCyan + "[m]" + colorReset
indicator = colorGreen + "●" + colorReset
} else {
prefix = "[m]"
indicator = "●"
}
result := fmt.Sprintf("%s %s %d/%d/%d",
prefix, indicator,
stats.Retrieval.ObservationsServed,
stats.Retrieval.ContextInjections,
stats.Retrieval.SearchRequests,
)
if stats.ProjectObservations > 0 {
result += fmt.Sprintf(" (%d)", stats.ProjectObservations)
}
if stats.IsProcessing || stats.QueueDepth > 0 {
if useColors {
result += " " + colorYellow + "⚙" + colorReset
} else {
result += " ⚙"
}
}
return result
}
// formatMinimal returns a minimal status line.
func formatMinimal(stats *WorkerStats, useColors bool) string {
// ● 42 obs
var indicator string
if useColors {
indicator = colorGreen + "●" + colorReset
} else {
indicator = "●"
}
result := fmt.Sprintf("%s %d", indicator, stats.Retrieval.ObservationsServed)
if stats.ProjectObservations > 0 {
result += fmt.Sprintf("/%d", stats.ProjectObservations)
}
return result
}
// formatOffline returns the offline status.
func formatOffline() string {
return formatOfflineColored(true)
}
// formatOfflineColored returns the offline status with optional colors.
func formatOfflineColored(useColors bool) string {
if useColors {
return colorCyan + "[mnemonic]" + colorReset + " " + colorGray + "○" + colorReset
}
return "[mnemonic] ○"
}
// formatStartingColored returns the starting status with optional colors.
func formatStartingColored(useColors bool) string {
if useColors {
return colorCyan + "[mnemonic]" + colorReset + " " + colorYellow + "◐" + colorReset + " starting"
}
return "[mnemonic] ◐ starting"
}
+1 -1
View File
@@ -431,7 +431,7 @@ const features = [
{ icon: 'fas fa-folder-tree', title: 'Project-aware context', description: 'Your React knowledge stays in React projects. Your Go patterns stay in Go projects. No context pollution.' },
{ icon: 'fas fa-globe', title: 'Shared best practices', description: 'Security patterns, performance tips, and universal learnings automatically available across all your projects.' },
{ icon: 'fas fa-search', title: 'Finds what matters', description: 'Semantic search finds relevant memories even when you don\'t remember the exact words. "That auth thing" just works.' },
{ icon: 'fas fa-credit-card', title: 'No API keys needed', description: 'Uses Claude Code CLI - works with your existing Claude Pro/Max subscription. No separate API costs.' },
{ icon: 'fas fa-chart-line', title: 'Live statusline', description: 'Real-time metrics right in Claude Code: memories served, searches performed, and project memory count at a glance.' },
{ icon: 'fas fa-lock', title: '100% private', description: 'Your code context never leaves your machine. No telemetry. No cloud sync. Your memories are yours.' },
]
+9 -5
View File
@@ -74,12 +74,16 @@ func (s *ObservationStore) StoreObservation(ctx context.Context, sdkSessionID, p
id, _ := result.LastInsertId()
// Cleanup old observations beyond the limit for this project
// Cleanup old observations beyond the limit for this project (async to not block handler)
if project != "" {
deletedIDs, _ := s.CleanupOldObservations(ctx, project)
if len(deletedIDs) > 0 && s.cleanupFunc != nil {
s.cleanupFunc(ctx, deletedIDs)
}
go func(proj string) {
cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
deletedIDs, _ := s.CleanupOldObservations(cleanupCtx, proj)
if len(deletedIDs) > 0 && s.cleanupFunc != nil {
s.cleanupFunc(cleanupCtx, deletedIDs)
}
}(project)
}
return id, nowEpoch, nil
+9 -5
View File
@@ -51,11 +51,15 @@ func (s *PromptStore) SaveUserPromptWithMatches(ctx context.Context, claudeSessi
id, _ := result.LastInsertId()
// Cleanup old prompts beyond the global limit
deletedIDs, _ := s.CleanupOldPrompts(ctx)
if len(deletedIDs) > 0 && s.cleanupFunc != nil {
s.cleanupFunc(ctx, deletedIDs)
}
// Cleanup old prompts beyond the global limit (async to not block handler)
go func() {
cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
deletedIDs, _ := s.CleanupOldPrompts(cleanupCtx)
if len(deletedIDs) > 0 && s.cleanupFunc != nil {
s.cleanupFunc(cleanupCtx, deletedIDs)
}
}()
return id, nil
}
+14 -2
View File
@@ -514,7 +514,7 @@ func (s *Service) handleGetStats(w http.ResponseWriter, r *http.Request) {
retrievalStats := s.GetRetrievalStats()
sessionsToday, _ := s.sessionStore.GetSessionsToday(r.Context())
writeJSON(w, map[string]interface{}{
response := map[string]interface{}{
"uptime": time.Since(s.startTime).String(),
"activeSessions": s.sessionManager.GetActiveSessionCount(),
"queueDepth": s.sessionManager.GetTotalQueueDepth(),
@@ -522,7 +522,19 @@ func (s *Service) handleGetStats(w http.ResponseWriter, r *http.Request) {
"connectedClients": s.sseBroadcaster.ClientCount(),
"sessionsToday": sessionsToday,
"retrieval": retrievalStats,
})
"ready": s.ready.Load(),
}
// Include project-specific observation count if project is specified
if project := r.URL.Query().Get("project"); project != "" {
count, err := s.observationStore.GetObservationCount(r.Context(), project)
if err == nil {
response["projectObservations"] = count
response["project"] = project
}
}
writeJSON(w, response)
}
// handleGetRetrievalStats returns detailed retrieval statistics.
+60 -15
View File
@@ -6,10 +6,17 @@ import (
"fmt"
"net/http"
"sync"
"time"
"github.com/rs/zerolog/log"
)
const (
// WriteTimeout is the timeout for writing to SSE clients.
// Prevents blocking on stale connections.
WriteTimeout = 2 * time.Second
)
// Client represents a connected SSE client.
type Client struct {
ID string
@@ -101,6 +108,7 @@ func (b *Broadcaster) removeClientByID(id string) {
}
// Broadcast sends a message to all connected clients.
// Uses non-blocking writes with timeout to prevent stale connections from blocking.
func (b *Broadcaster) Broadcast(data interface{}) {
jsonData, err := json.Marshal(data)
if err != nil {
@@ -117,30 +125,67 @@ func (b *Broadcaster) Broadcast(data interface{}) {
}
b.mu.RUnlock()
// Track dead clients for removal
var deadClients []*Client
if len(clients) == 0 {
return
}
// Use a channel to collect dead clients from concurrent writes
deadClientsCh := make(chan string, len(clients))
var wg sync.WaitGroup
for _, client := range clients {
select {
case <-client.Done:
continue
default:
_, err := client.Writer.Write([]byte(message))
if err != nil {
log.Debug().
Str("clientId", client.ID).
Err(err).
Msg("Failed to write to SSE client, marking for removal")
deadClients = append(deadClients, client)
continue
}
client.Flusher.Flush()
wg.Add(1)
go func(c *Client) {
defer wg.Done()
b.writeToClient(c, message, deadClientsCh)
}(client)
}
}
// Remove dead clients outside the iteration
for _, client := range deadClients {
b.removeClientByID(client.ID)
// Wait for all writes to complete (with their individual timeouts)
wg.Wait()
close(deadClientsCh)
// Remove dead clients
for clientID := range deadClientsCh {
b.removeClientByID(clientID)
}
}
// writeToClient writes a message to a single client with timeout.
func (b *Broadcaster) writeToClient(client *Client, message string, deadCh chan<- string) {
// Use a timeout channel to prevent blocking on stale connections
done := make(chan struct{})
go func() {
defer close(done)
_, err := client.Writer.Write([]byte(message))
if err != nil {
log.Debug().
Str("clientId", client.ID).
Err(err).
Msg("Failed to write to SSE client, marking for removal")
deadCh <- client.ID
return
}
client.Flusher.Flush()
}()
select {
case <-done:
// Write completed successfully
case <-time.After(WriteTimeout):
log.Warn().
Str("clientId", client.ID).
Dur("timeout", WriteTimeout).
Msg("SSE write timed out, marking client for removal")
deadCh <- client.ID
case <-client.Done:
// Client disconnected during write
}
}
@@ -0,0 +1,22 @@
{
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
"name": "claude-mnemonic",
"version": "{{ .Version }}",
"description": "Persistent memory system for Claude Code - stores observations, session summaries, and user prompts with semantic search",
"owner": {
"name": "lukaszraczylo",
"email": "lukaszraczylo@users.noreply.github.com"
},
"plugins": [
{
"name": "claude-mnemonic",
"description": "Persistent memory system for Claude Code - Go implementation with SQLite and ChromaDB",
"version": "{{ .Version }}",
"author": {
"name": "lukaszraczylo"
},
"source": "./",
"category": "productivity"
}
]
}
+9
View File
@@ -0,0 +1,9 @@
{
"name": "claude-mnemonic",
"version": "{{ .Version }}",
"description": "Persistent memory system for Claude Code - Go implementation with SQLite and ChromaDB",
"author": {
"name": "lukaszraczylo",
"email": "lukaszraczylo@users.noreply.github.com"
}
}
+44 -2
View File
@@ -27,9 +27,28 @@ function Write-Error { param($Message) Write-Host "[ERROR] $Message" -Foreground
function Get-LatestVersion {
try {
$release = Invoke-RestMethod -Uri "https://api.github.com/repos/$GitHubRepo/releases/latest"
$headers = @{}
if ($env:GITHUB_TOKEN) {
$headers["Authorization"] = "token $env:GITHUB_TOKEN"
}
$release = Invoke-RestMethod -Uri "https://api.github.com/repos/$GitHubRepo/releases/latest" -Headers $headers
return $release.tag_name
} catch {
$errorMsg = $_.Exception.Message
if ($errorMsg -match "rate limit" -or $_.Exception.Response.StatusCode -eq 403) {
Write-Host ""
Write-Host "[ERROR] GitHub API rate limit exceeded." -ForegroundColor Red
Write-Host ""
Write-Host "You have a few options:" -ForegroundColor Yellow
Write-Host " 1. Wait ~1 hour for the rate limit to reset"
Write-Host " 2. Specify a version manually:"
Write-Host " `$env:MNEMONIC_VERSION = 'v0.6.1'; irm https://raw.githubusercontent.com/$GitHubRepo/main/scripts/install.ps1 | iex" -ForegroundColor Cyan
Write-Host " 3. Use a GitHub token (set `$env:GITHUB_TOKEN)"
Write-Host " 4. Clone and build from source:"
Write-Host " git clone https://github.com/$GitHubRepo.git" -ForegroundColor Cyan
Write-Host " cd claude-mnemonic; make build; make install" -ForegroundColor Cyan
exit 1
}
Write-Error "Failed to fetch latest version from GitHub: $_"
}
}
@@ -88,6 +107,14 @@ function Register-Plugin {
# Ensure directories exist
New-Item -ItemType Directory -Path "$env:USERPROFILE\.claude\plugins" -Force | Out-Null
# Clean up old cache versions to prevent stale binaries
$CacheBase = Split-Path $CachePath -Parent
if (Test-Path $CacheBase) {
Write-Info "Cleaning up old cache versions..."
Get-ChildItem -Path $CacheBase -Directory | Where-Object { $_.Name -ne $VersionClean } | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
}
New-Item -ItemType Directory -Path $CachePath -Force | Out-Null
# Create JSON files if they don't exist
@@ -132,8 +159,19 @@ function Register-Plugin {
$Settings | Add-Member -NotePropertyName "enabledPlugins" -NotePropertyValue @{} -Force
}
$Settings.enabledPlugins | Add-Member -NotePropertyName $PluginKey -NotePropertyValue $true -Force
# Configure statusline
$StatuslineCmd = "$InstallDir\hooks\statusline.exe"
$StatuslineEntry = @{
type = "command"
command = $StatuslineCmd
padding = 0
}
$Settings | Add-Member -NotePropertyName "statusLine" -NotePropertyValue $StatuslineEntry -Force
$Settings | ConvertTo-Json -Depth 10 | Out-File -Encoding UTF8 $SettingsFile
Write-Success "Plugin enabled in settings.json"
Write-Success "Statusline configured in settings.json"
# Update known_marketplaces.json
$Marketplaces = Get-Content $MarketplacesFile -Raw | ConvertFrom-Json
@@ -194,8 +232,12 @@ function Uninstall-ClaudeMnemonic {
$Settings = Get-Content $SettingsFile -Raw | ConvertFrom-Json
if ($Settings.enabledPlugins) {
$Settings.enabledPlugins.PSObject.Properties.Remove($PluginKey)
$Settings | ConvertTo-Json -Depth 10 | Out-File -Encoding UTF8 $SettingsFile
}
# Remove statusline if it's ours
if ($Settings.statusLine -and $Settings.statusLine.command -match "claude-mnemonic") {
$Settings.PSObject.Properties.Remove("statusLine")
}
$Settings | ConvertTo-Json -Depth 10 | Out-File -Encoding UTF8 $SettingsFile
}
if (Test-Path $MarketplacesFile) {
$Marketplaces = Get-Content $MarketplacesFile -Raw | ConvertFrom-Json
+62 -7
View File
@@ -81,11 +81,44 @@ detect_platform() {
# Get the latest release version from GitHub
get_latest_version() {
local version
version=$(curl -sS "https://api.github.com/repos/${GITHUB_REPO}/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
local response version curl_opts
# Use GitHub token if available (higher rate limit)
curl_opts=(-sS)
if [[ -n "${GITHUB_TOKEN:-}" ]]; then
curl_opts+=(-H "Authorization: token ${GITHUB_TOKEN}")
fi
# Fetch with error handling
response=$(curl "${curl_opts[@]}" "https://api.github.com/repos/${GITHUB_REPO}/releases/latest" 2>&1)
# Check for rate limiting
if echo "$response" | grep -q "API rate limit exceeded"; then
echo ""
error "GitHub API rate limit exceeded.
You have a few options:
1. Wait ~1 hour for the rate limit to reset
2. Specify a version manually:
curl -sSL https://raw.githubusercontent.com/${GITHUB_REPO}/main/scripts/install.sh | bash -s -- v0.6.1
3. Use a GitHub token (set GITHUB_TOKEN environment variable)
4. Clone and build from source:
git clone https://github.com/${GITHUB_REPO}.git
cd claude-mnemonic && make build && make install"
fi
# Check for other API errors
if echo "$response" | grep -q '"message":'; then
local msg
msg=$(echo "$response" | grep '"message":' | sed -E 's/.*"message": *"([^"]+)".*/\1/')
error "GitHub API error: $msg"
fi
# Extract version
version=$(echo "$response" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
if [[ -z "$version" ]]; then
error "Failed to fetch latest version from GitHub"
error "Failed to fetch latest version from GitHub. Response: $response"
fi
echo "$version"
@@ -151,6 +184,15 @@ register_plugin() {
# Ensure directories exist
mkdir -p "$HOME/.claude/plugins"
# Clean up old cache versions to prevent stale binaries
local cache_base
cache_base=$(dirname "$CACHE_DIR")
if [[ -d "$cache_base" ]]; then
info "Cleaning up old cache versions..."
find "$cache_base" -mindepth 1 -maxdepth 1 -type d ! -name "${version#v}" -exec rm -rf {} \; 2>/dev/null || true
fi
mkdir -p "${CACHE_DIR}/${version}"
# Create JSON files if they don't exist
@@ -193,12 +235,24 @@ EOF
success "Plugin registered in installed_plugins.json"
# Enable in settings.json
jq --arg key "$PLUGIN_KEY" \
'.enabledPlugins //= {} | .enabledPlugins[$key] = true' "$SETTINGS_FILE" > "${SETTINGS_FILE}.tmp" \
# Enable in settings.json and configure statusline
local statusline_cmd="$INSTALL_DIR/hooks/statusline"
local statusline_entry
statusline_entry=$(cat <<EOF
{
"type": "command",
"command": "$statusline_cmd",
"padding": 0
}
EOF
)
jq --arg key "$PLUGIN_KEY" --argjson statusline "$statusline_entry" \
'.enabledPlugins //= {} | .enabledPlugins[$key] = true | .statusLine = $statusline' "$SETTINGS_FILE" > "${SETTINGS_FILE}.tmp" \
&& mv "${SETTINGS_FILE}.tmp" "$SETTINGS_FILE"
success "Plugin enabled in settings.json"
success "Statusline configured in settings.json"
# Register marketplace
local marketplace_entry
@@ -394,7 +448,8 @@ if [[ "${1:-}" == "--uninstall" ]]; then
jq 'del(.plugins["'"$PLUGIN_KEY"'"])' "$PLUGINS_FILE" > "${PLUGINS_FILE}.tmp" && mv "${PLUGINS_FILE}.tmp" "$PLUGINS_FILE"
fi
if [[ -f "$SETTINGS_FILE" ]]; then
jq 'del(.enabledPlugins["'"$PLUGIN_KEY"'"])' "$SETTINGS_FILE" > "${SETTINGS_FILE}.tmp" && mv "${SETTINGS_FILE}.tmp" "$SETTINGS_FILE"
# Remove plugin from enabled plugins and remove statusline if it's ours
jq 'del(.enabledPlugins["'"$PLUGIN_KEY"'"]) | if .statusLine.command | test("claude-mnemonic") then del(.statusLine) else . end' "$SETTINGS_FILE" > "${SETTINGS_FILE}.tmp" && mv "${SETTINGS_FILE}.tmp" "$SETTINGS_FILE"
fi
if [[ -f "$MARKETPLACES_FILE" ]]; then
jq 'del(.["claude-mnemonic"])' "$MARKETPLACES_FILE" > "${MARKETPLACES_FILE}.tmp" && mv "${MARKETPLACES_FILE}.tmp" "$MARKETPLACES_FILE"
+25 -5
View File
@@ -9,13 +9,22 @@ MARKETPLACES_FILE="$HOME/.claude/plugins/known_marketplaces.json"
PLUGIN_KEY="claude-mnemonic@claude-mnemonic"
MARKETPLACE_NAME="claude-mnemonic"
MARKETPLACE_PATH="$HOME/.claude/plugins/marketplaces/claude-mnemonic"
CACHE_PATH="$HOME/.claude/plugins/cache/claude-mnemonic/claude-mnemonic/1.0.0"
VERSION="1.0.0"
# Get version from git tags (same as Makefile), or use argument if provided
VERSION="${1:-$(git describe --tags --always --dirty 2>/dev/null || echo "dev")}"
CACHE_BASE="$HOME/.claude/plugins/cache/claude-mnemonic/claude-mnemonic"
CACHE_PATH="$CACHE_BASE/$VERSION"
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")
# Ensure plugins directory exists
mkdir -p "$HOME/.claude/plugins"
# Clean up old cache versions to prevent stale binaries
if [ -d "$CACHE_BASE" ]; then
echo "Cleaning up old cache versions..."
find "$CACHE_BASE" -mindepth 1 -maxdepth 1 -type d ! -name "$VERSION" -exec rm -rf {} \; 2>/dev/null || true
fi
# Create installed_plugins.json if it doesn't exist
if [ ! -f "$PLUGINS_FILE" ]; then
echo '{"version": 2, "plugins": {}}' > "$PLUGINS_FILE"
@@ -60,13 +69,24 @@ EOF
echo "Plugin registered in installed_plugins.json"
# Enable the plugin in settings.json
# Enable the plugin in settings.json and configure statusline
# First ensure enabledPlugins object exists, then add our plugin
jq --arg key "$PLUGIN_KEY" \
'.enabledPlugins //= {} | .enabledPlugins[$key] = true' "$SETTINGS_FILE" > "${SETTINGS_FILE}.tmp" \
STATUSLINE_CMD="$MARKETPLACE_PATH/hooks/statusline"
STATUSLINE_ENTRY=$(cat <<EOF
{
"type": "command",
"command": "$STATUSLINE_CMD",
"padding": 0
}
EOF
)
jq --arg key "$PLUGIN_KEY" --argjson statusline "$STATUSLINE_ENTRY" \
'.enabledPlugins //= {} | .enabledPlugins[$key] = true | .statusLine = $statusline' "$SETTINGS_FILE" > "${SETTINGS_FILE}.tmp" \
&& mv "${SETTINGS_FILE}.tmp" "$SETTINGS_FILE"
echo "Plugin enabled in settings.json"
echo "Statusline configured in settings.json"
# Register the marketplace in known_marketplaces.json
MARKETPLACE_ENTRY=$(cat <<EOF