Initial commit

This commit is contained in:
2025-12-14 21:59:59 +00:00
commit 9c2a1a795a
126 changed files with 21728 additions and 0 deletions
+51
View File
@@ -0,0 +1,51 @@
// Package hooks provides hook utilities for claude-mnemonic.
package hooks
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
)
// HookResponse is the response sent back to Claude Code.
type HookResponse struct {
Continue bool `json:"continue"`
}
// ProjectIDWithName returns both the hash ID and the directory name for display.
// Format: "dirname_abc123" (name + truncated hash for human-readability)
func ProjectIDWithName(cwd string) string {
absPath, err := filepath.Abs(cwd)
if err != nil {
absPath = cwd
}
dirName := filepath.Base(absPath)
hash := sha256.Sum256([]byte(absPath))
shortHash := hex.EncodeToString(hash[:3]) // 6 chars
return fmt.Sprintf("%s_%s", dirName, shortHash)
}
// Exit codes for Claude Code hooks
const (
ExitSuccess = 0
ExitFailure = 1
ExitUserMessageOnly = 3 // Display stderr as user message
)
// WriteResponse writes a hook response to stdout.
func WriteResponse(hookName string, success bool) {
response := HookResponse{Continue: success}
data, _ := json.Marshal(response)
fmt.Println(string(data))
}
// WriteError writes an error message to stderr and exits.
func WriteError(hookName string, err error) {
fmt.Fprintf(os.Stderr, "[%s] Error: %v\n", hookName, err)
WriteResponse(hookName, false)
}
+277
View File
@@ -0,0 +1,277 @@
// Package hooks provides hook utilities for claude-mnemonic.
package hooks
import (
"bytes"
"encoding/json"
"fmt"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
)
// Version is set at build time via ldflags
var Version = "dev"
const (
// DefaultWorkerPort is the default worker port.
DefaultWorkerPort = 37777
// HealthCheckTimeout is the timeout for health checks (reduced from 5s for faster startup).
HealthCheckTimeout = 1 * time.Second
// StartupTimeout is the timeout for worker startup.
StartupTimeout = 30 * time.Second
)
// GetWorkerPort returns the worker port from environment or default.
func GetWorkerPort() int {
if port := os.Getenv("CLAUDE_MNEMONIC_WORKER_PORT"); port != "" {
if p, err := strconv.Atoi(port); err == nil && p > 0 {
return p
}
}
return DefaultWorkerPort
}
// IsWorkerRunning checks if the worker is running and healthy.
func IsWorkerRunning(port int) bool {
client := &http.Client{Timeout: HealthCheckTimeout}
resp, err := client.Get(fmt.Sprintf("http://127.0.0.1:%d/api/health", port))
if err != nil {
return false
}
defer resp.Body.Close()
return resp.StatusCode == http.StatusOK
}
// EnsureWorkerRunning ensures the worker is running, starting it if necessary.
// If a worker is already running and healthy with matching version, it reuses it.
// If version mismatch or unhealthy, it kills the old worker and starts fresh.
func EnsureWorkerRunning() (int, error) {
port := GetWorkerPort()
// Check if already running and healthy
if IsWorkerRunning(port) {
// Check version - if mismatch, restart
if runningVersion := GetWorkerVersion(port); runningVersion != "" {
if runningVersion != Version {
fmt.Fprintf(os.Stderr, "[claude-mnemonic] Worker version mismatch (running: %s, expected: %s), restarting...\n", runningVersion, Version)
if err := KillProcessOnPort(port); err != nil {
fmt.Fprintf(os.Stderr, "[claude-mnemonic] Warning: failed to kill old worker: %v\n", err)
}
time.Sleep(500 * time.Millisecond)
} else {
// Version matches, reuse existing worker
return port, nil
}
} else {
// Couldn't get version, assume it's fine
return port, nil
}
}
// Check if port is in use but worker is unhealthy
if IsPortInUse(port) {
// Something is using the port but not responding to health checks
// Try to kill it
if err := KillProcessOnPort(port); err != nil {
// Log but continue - maybe it will die on its own
fmt.Fprintf(os.Stderr, "[claude-mnemonic] Warning: failed to kill unhealthy process on port %d: %v\n", port, err)
}
// Wait a moment for port to be released
time.Sleep(500 * time.Millisecond)
}
// Find worker binary
workerPath := findWorkerBinary()
if workerPath == "" {
return 0, fmt.Errorf("worker binary not found")
}
// Start worker
cmd := exec.Command(workerPath) // #nosec G204 -- workerPath is from internal findWorkerBinary
cmd.Stdout = os.Stderr
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
return 0, fmt.Errorf("failed to start worker: %w", err)
}
// Wait for worker to be ready with exponential backoff
deadline := time.Now().Add(StartupTimeout)
backoff := 50 * time.Millisecond
maxBackoff := 500 * time.Millisecond
for time.Now().Before(deadline) {
if IsWorkerRunning(port) {
return port, nil
}
time.Sleep(backoff)
// Exponential backoff with cap
backoff = backoff * 2
if backoff > maxBackoff {
backoff = maxBackoff
}
}
return 0, fmt.Errorf("worker failed to start within timeout")
}
// GetWorkerVersion gets the version of the running worker.
func GetWorkerVersion(port int) string {
client := &http.Client{Timeout: HealthCheckTimeout}
resp, err := client.Get(fmt.Sprintf("http://127.0.0.1:%d/api/version", port))
if err != nil {
return ""
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return ""
}
var result map[string]string
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return ""
}
return result["version"]
}
// IsPortInUse checks if the port is in use (regardless of health).
func IsPortInUse(port int) bool {
conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), 500*time.Millisecond)
if err != nil {
return false
}
_ = conn.Close()
return true
}
// KillProcessOnPort finds and kills the process using the given port.
func KillProcessOnPort(port int) error {
// Use lsof to find the process (works on macOS and Linux)
cmd := exec.Command("lsof", "-t", "-i", fmt.Sprintf(":%d", port)) // #nosec G204 -- port is from internal config
output, err := cmd.Output()
if err != nil {
// lsof returns exit code 1 when no process is found - that's fine
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
return nil // No process found
}
return fmt.Errorf("failed to find process on port: %w", err)
}
pidStr := strings.TrimSpace(string(output))
if pidStr == "" {
return nil // No process found
}
// Handle multiple PIDs (one per line)
pids := strings.Split(pidStr, "\n")
for _, pid := range pids {
pid = strings.TrimSpace(pid)
if pid == "" {
continue
}
// Kill the process
killCmd := exec.Command("kill", "-9", pid) // #nosec G204 -- pid is from lsof output
if err := killCmd.Run(); err != nil {
return fmt.Errorf("failed to kill process %s: %w", pid, err)
}
}
return nil
}
// findWorkerBinary finds the worker binary path.
func findWorkerBinary() string {
// Check CLAUDE_PLUGIN_ROOT first (set by Claude Code when running hooks)
if pluginRoot := os.Getenv("CLAUDE_PLUGIN_ROOT"); pluginRoot != "" {
workerPath := filepath.Join(pluginRoot, "worker")
if _, err := os.Stat(workerPath); err == nil {
return workerPath
}
}
// Check common locations
home := os.Getenv("HOME")
locations := []string{
"./worker",
"./bin/worker",
filepath.Join(home, ".claude/plugins/cache/claude-mnemonic/claude-mnemonic/1.0.0/worker"),
filepath.Join(home, ".claude/plugins/marketplaces/claude-mnemonic/worker"),
}
for _, loc := range locations {
if _, err := os.Stat(loc); err == nil {
return loc
}
}
// Try PATH
if path, err := exec.LookPath("claude-mnemonic-worker"); err == nil {
return path
}
return ""
}
// POST sends a POST request to the worker.
func POST(port int, path string, body interface{}) (map[string]interface{}, error) {
client := &http.Client{Timeout: 10 * time.Second}
jsonBody, err := json.Marshal(body)
if err != nil {
return nil, err
}
resp, err := client.Post(
fmt.Sprintf("http://127.0.0.1:%d%s", port, path),
"application/json",
bytes.NewReader(jsonBody),
)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("request failed: %s", resp.Status)
}
var result map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
// Not all endpoints return JSON
return nil, nil
}
return result, nil
}
// GET sends a GET request to the worker.
func GET(port int, path string) (map[string]interface{}, error) {
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(fmt.Sprintf("http://127.0.0.1:%d%s", port, path))
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("request failed: %s", resp.Status)
}
var result map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return result, nil
}
+192
View File
@@ -0,0 +1,192 @@
// Package hooks provides hook utilities for claude-mnemonic.
package hooks
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetWorkerPort(t *testing.T) {
// Test default port
port := GetWorkerPort()
assert.Equal(t, DefaultWorkerPort, port)
// Test with environment variable
t.Setenv("CLAUDE_MNEMONIC_WORKER_PORT", "12345")
port = GetWorkerPort()
assert.Equal(t, 12345, port)
// Test with invalid environment variable (should return default)
t.Setenv("CLAUDE_MNEMONIC_WORKER_PORT", "invalid")
port = GetWorkerPort()
assert.Equal(t, DefaultWorkerPort, port)
}
func TestIsWorkerRunning(t *testing.T) {
// Create a test server that responds to health checks
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/health" {
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"status": "ready"})
} else {
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
// Extract port from test server URL
// Note: In real tests we'd use the actual port, but test server uses random port
// So we test with a non-existent port
assert.False(t, IsWorkerRunning(99999)) // Non-existent port
}
func TestIsPortInUse(t *testing.T) {
// Create a test server to occupy a port
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
// Non-existent port should not be in use
assert.False(t, IsPortInUse(99999))
}
func TestGetWorkerVersion(t *testing.T) {
tests := []struct {
name string
serverResponse func(w http.ResponseWriter, r *http.Request)
expectedResult string
}{
{
name: "returns version from server",
serverResponse: func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/version" {
json.NewEncoder(w).Encode(map[string]string{"version": "1.2.3"})
}
},
expectedResult: "1.2.3",
},
{
name: "returns empty on 404",
serverResponse: func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
},
expectedResult: "",
},
{
name: "returns empty on invalid JSON",
serverResponse: func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("not json"))
},
expectedResult: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(tt.serverResponse))
defer server.Close()
// We can't easily test with the actual function since it uses a hardcoded localhost
// But we can verify the logic works with the test server
})
}
}
func TestProjectIDWithName(t *testing.T) {
tests := []struct {
cwd string
expected string
}{
{
cwd: "/Users/test/projects/my-project",
expected: "my-project_", // Will have hash suffix
},
{
cwd: "/tmp",
expected: "tmp_",
},
{
cwd: "/",
expected: "", // Empty dirname
},
}
for _, tt := range tests {
t.Run(tt.cwd, func(t *testing.T) {
result := ProjectIDWithName(tt.cwd)
if tt.expected != "" {
assert.Contains(t, result, tt.expected[:len(tt.expected)-1]) // Check prefix before underscore
assert.Contains(t, result, "_") // Should have underscore separator
}
})
}
}
func TestVersionMatching(t *testing.T) {
// Test that version matching logic works correctly
tests := []struct {
name string
runningVersion string
hookVersion string
shouldRestart bool
}{
{
name: "matching versions",
runningVersion: "1.0.0",
hookVersion: "1.0.0",
shouldRestart: false,
},
{
name: "mismatched versions",
runningVersion: "1.0.0",
hookVersion: "2.0.0",
shouldRestart: true,
},
{
name: "dirty vs clean",
runningVersion: "1.0.0",
hookVersion: "1.0.0-dirty",
shouldRestart: true,
},
{
name: "empty running version",
runningVersion: "",
hookVersion: "1.0.0",
shouldRestart: false, // Can't determine, don't restart
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Simulate the version check logic
shouldRestart := false
if tt.runningVersion != "" && tt.runningVersion != tt.hookVersion {
shouldRestart = true
}
assert.Equal(t, tt.shouldRestart, shouldRestart)
})
}
}
func TestKillProcessOnPort_NoProcess(t *testing.T) {
// Test killing a process on a port that has no process
// Should not error, just return nil
err := KillProcessOnPort(99999) // Port unlikely to be in use
// lsof will return empty/error, which is fine
require.NoError(t, err)
}
func TestFindWorkerBinary(t *testing.T) {
// Test that findWorkerBinary returns empty string when binary not found
// This is hard to test without mocking the filesystem
// But we can verify it doesn't panic
result := findWorkerBinary()
// Result depends on whether worker is installed, so we just check it doesn't panic
t.Logf("findWorkerBinary returned: %s", result)
}