diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..915a5c0 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,26 @@ +# Sandworm Development Guide + +## Commands +- Build: `just build` - creates binary at bin/sandworm +- Run: `just run [args]` - run from source with arguments +- Test: `just test` - runs all tests with race detection and coverage +- Test single: `go test -v ./internal/package -run TestName` +- Lint: `just lint` - runs golangci-lint +- Format: `just fmt` - formats with gofmt and goimports +- Install: `just install` - builds and installs to $GOPATH/bin using goreleaser + +## Code Style +- Follow standard Go conventions (gofmt compliant) +- Imports: stdlib first, then third-party, alphabetically sorted +- Error handling: wrap errors with context using `fmt.Errorf("context: %w", err)` +- Naming: PascalCase for exported, camelCase for unexported +- Documentation: all functions and packages have doc comments +- Tests: table-driven tests with t.Run subtests +- Structure: keep packages small and focused on single responsibility +- Permissions: use explicit octal literals (`0o644`, `0o755`) +- Organization: helper functions grouped with "MARK:" comments + +## Project Structure +- `cmd/` - application entrypoints +- `internal/` - private implementation packages +- `bin/` - build artifacts (not committed) diff --git a/cmd/sandworm/main.go b/cmd/sandworm/main.go index b827081..ea7acb7 100644 --- a/cmd/sandworm/main.go +++ b/cmd/sandworm/main.go @@ -148,11 +148,12 @@ func runPush(opts *cmdOptions) error { return err } + fmt.Println("Syncing with Claude project...") if err := client.Push(opts.outputFile, "project.txt"); err != nil { return fmt.Errorf("unable to push: %w", err) } - fmt.Printf("Updated project file (%s)\n", util.FormatSize(size)) + fmt.Printf("Project file synced (%s)\n", util.FormatSize(size)) return nil } diff --git a/internal/claude/claude.go b/internal/claude/claude.go index 84a49aa..8a1e928 100644 --- a/internal/claude/claude.go +++ b/internal/claude/claude.go @@ -4,7 +4,10 @@ package claude import ( "bytes" "compress/gzip" + "context" + "crypto/sha256" "crypto/tls" + "encoding/hex" "encoding/json" "fmt" "io" @@ -26,6 +29,7 @@ const ( organizationID = "claude.organization_id" projectID = "claude.project_id" documentID = "claude.document_id" + contentHashKey = "claude.content_hash" // Stores hash of last uploaded content ) var sessionKeyRegex = regexp.MustCompile(`^sessionKey=([^;]+)`) @@ -125,12 +129,31 @@ func (c *Client) Setup(force bool) (bool, error) { } // Push uploads a file to the selected Claude project. If a file with the same -// name exists, it's replaced. +// name exists, it's replaced, but only if the content has changed. func (c *Client) Push(filePath, fileName string) error { if err := c.validateConfig(); err != nil { return err } + // Read new file content + content, err := os.ReadFile(filePath) + if err != nil { + return fmt.Errorf("failed to read file: %w", err) + } + + // Calculate content hash + contentHash := calculateContentHash(content) + + // Check if content is unchanged from last push + if c.config.Has(contentHashKey) && c.config.Get(contentHashKey) == contentHash { + // If we already have a document ID and the content is unchanged, + // no need to re-upload + if c.config.Has(documentID) { + fmt.Println("Content unchanged, skipping upload.") + return nil + } + } + // If no document ID is set, try to find existing document if !c.config.Has(documentID) { docs, err := c.listDocuments() @@ -160,18 +183,17 @@ func (c *Client) Push(filePath, fileName string) error { } } - // Read and upload new file - content, err := os.ReadFile(filePath) - if err != nil { - return fmt.Errorf("failed to read file: %w", err) - } - + // Upload new document doc, err := c.uploadDocument(fileName, string(content)) if err != nil { return err } - return c.config.Set(documentID, doc.ID) + // Store document ID and content hash + if err := c.config.Set(documentID, doc.ID); err != nil { + return err + } + return c.config.Set(contentHashKey, contentHash) } // PurgeProjectFiles removes all files from the current project. @@ -198,15 +220,25 @@ func (c *Client) PurgeProjectFiles(progressFn func(fileName string, current, tot } } + // Clear stored document ID and content hash if err := c.config.Delete(documentID); err != nil { return len(docs), err } + if err := c.config.Delete(contentHashKey); err != nil { + return len(docs), err + } return len(docs), nil } // MARK: Internal helper functions +// calculateContentHash computes a SHA-256 hash of the content +func calculateContentHash(content []byte) string { + hash := sha256.Sum256(content) + return hex.EncodeToString(hash[:]) +} + // validateConfig ensures all required configuration values are present func (c *Client) validateConfig() error { required := []string{sessionKey, organizationID, projectID} @@ -222,7 +254,7 @@ func (c *Client) validateConfig() error { return nil } -// makeRequest performs an HTTP request to the Claude API +// makeRequest performs an HTTP request to the Claude API with timeout and retry func (c *Client) makeRequest(method, path string, body interface{}) ([]byte, error) { var bodyReader io.Reader if body != nil { @@ -233,6 +265,7 @@ func (c *Client) makeRequest(method, path string, body interface{}) ([]byte, err bodyReader = bytes.NewReader(data) } + // Set up the request req, err := http.NewRequest(method, baseURL+"/api"+path, bodyReader) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) @@ -257,52 +290,85 @@ func (c *Client) makeRequest(method, path string, body interface{}) ([]byte, err req.Header.Set(k, v) } - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("request failed: %w", err) - } - defer resp.Body.Close() - - // Read response body w/ manual decoding (necessary since we're using a custom - // Accept-Encoding header above). + // Retry logic for transient errors + maxRetries := 3 var respBody []byte - switch resp.Header.Get("Content-Encoding") { - case "gzip": - gz, err := gzip.NewReader(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to create gzip reader: %w", err) + var lastErr error + + for attempt := 0; attempt < maxRetries; attempt++ { + if attempt > 0 { + // Exponential backoff with jitter + backoff := time.Duration(1<= 300 { - return nil, fmt.Errorf("API request failed: %d - %s", resp.StatusCode, string(respBody)) - } + // Check for error status codes that shouldn't be retried + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + // Only retry on 5xx errors (server errors) or 429 (rate limit) + if resp.StatusCode >= 500 || resp.StatusCode == 429 { + lastErr = fmt.Errorf("API request failed (attempt %d/%d): %d - %s", + attempt+1, maxRetries, resp.StatusCode, string(respBody)) + continue + } + return nil, fmt.Errorf("API request failed: %d - %s", resp.StatusCode, string(respBody)) + } - // Update session key if it changed - if cookie := resp.Header.Get("Set-Cookie"); cookie != "" { - if matches := sessionKeyRegex.FindStringSubmatch(cookie); matches != nil { - newKey := matches[1] - if newKey != c.config.Get(sessionKey) { - if err := c.config.Set(sessionKey, newKey); err != nil { - return nil, err + // Update session key if it changed + if cookie := resp.Header.Get("Set-Cookie"); cookie != "" { + if matches := sessionKeyRegex.FindStringSubmatch(cookie); matches != nil { + newKey := matches[1] + if newKey != c.config.Get(sessionKey) { + if err := c.config.Set(sessionKey, newKey); err != nil { + return nil, err + } } } } + + // Success! Return the response + return respBody, nil } - return respBody, nil + // If we got here, all retries failed + return nil, fmt.Errorf("request failed after %d attempts: %w", maxRetries, lastErr) } // MARK: Anthropic API requests