diff --git a/AGENTS.md b/AGENTS.md index 7d617ae..392aa3e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,7 +45,7 @@ pantry store \ Categories: `decision`, `bug`, `pattern`, `context`, `learning`. -Set `--source` to your agent identifier: `claude-code`, `codex`, `cursor`, or `opencode`. +Set `--source` to your agent identifier: `claude-code`, `codex`, `cursor`, `windsurf`, `antigravity`, or `opencode`. `--project` defaults to the current directory name — only set it explicitly if storing a note for a different project. @@ -72,7 +72,7 @@ You MUST store a note when any of these happen: Run once to auto-install hooks for your agent: ```bash -pantry setup cursor # or: claude-code, codex, opencode +pantry setup cursor # or: claude-code, windsurf, antigravity, codex, opencode ``` To remove: `pantry uninstall cursor` diff --git a/README.md b/README.md index 297f341..76cfb09 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Local note storage for coding agents. Your agent keeps notes on decisions, bugs, ## Features -- **Works with multiple agents** — Claude Code, Cursor, Codex, OpenCode, RooCode. One command sets up MCP config for your agent. +- **Works with multiple agents** — Claude Code, Cursor, Windsurf, Antigravity, Codex, OpenCode, RooCode. One command sets up MCP config for your agent. - **MCP native** — Runs as an MCP server exposing `pantry_store`, `pantry_search`, and `pantry_context` as tools. - **Local-first** — Everything stays on your machine. Notes are stored as Markdown in `~/.pantry/shelves/`, readable in Obsidian or any editor. - **Zero idle cost** — No background processes, no daemon, no RAM overhead. The MCP server only runs when the agent starts it. @@ -44,7 +44,7 @@ pantry init ### Connect your agent ```bash -pantry setup claude-code # or: cursor, codex, opencode, roocode +pantry setup claude-code # or: cursor, windsurf, antigravity, codex, opencode, roocode ``` This writes the MCP server entry into your agent's config file. Restart the agent and pantry will be available as a tool. @@ -53,7 +53,7 @@ Run `pantry doctor` to verify everything is working. ### Tell your agent to use Pantry -MCP registration makes the tools available, but your agent also needs instructions to actually use them. The `setup` command installs a skill file automatically for agents that support it (Claude Code, Cursor, Codex). For other agents — or if you prefer to use a project-level rules file — add the following to your `AGENTS.md`, `.rules`, `CLAUDE.md`, or equivalent: +MCP registration makes the tools available, but your agent also needs instructions to actually use them. The `setup` command installs a skill file automatically for agents that support it (Claude Code, Cursor, Windsurf, Antigravity, Codex). For other agents — or if you prefer to use a project-level rules file — add the following to your `AGENTS.md`, `.rules`, `CLAUDE.md`, or equivalent: ```markdown ## Pantry — persistent notes @@ -80,15 +80,18 @@ Do not skip either step. Notes are how context survives across sessions. Keyword search (FTS5) works with no extra setup. To also enable semantic vector search, configure an embedding provider in `~/.pantry/config.yaml`: **Ollama (local, free):** + ```yaml embedding: provider: ollama model: nomic-embed-text base_url: http://localhost:11434 ``` + Install [Ollama](https://ollama.com), then: `ollama pull nomic-embed-text` **OpenAI:** + ```yaml embedding: provider: openai @@ -97,6 +100,7 @@ embedding: ``` **OpenRouter:** + ```yaml embedding: provider: openrouter @@ -104,7 +108,17 @@ embedding: api_key: sk-or-... ``` +**Google (Gemini API):** + +```yaml +embedding: + provider: google + model: gemini-embedding-001 + api_key: AIzaSy... +``` + After changing providers, rebuild the vector index: + ```bash pantry reindex ``` @@ -116,9 +130,9 @@ All config file values can be overridden with environment variables. They take p | Variable | Description | Example | |----------|-------------|---------| | `PANTRY_HOME` | Override pantry home directory | `/data/pantry` | -| `PANTRY_EMBEDDING_PROVIDER` | Embedding provider | `ollama`, `openai`, `openrouter` | -| `PANTRY_EMBEDDING_MODEL` | Embedding model name | `text-embedding-3-small` | -| `PANTRY_EMBEDDING_API_KEY` | API key for the embedding provider | `sk-...` | +| `PANTRY_EMBEDDING_PROVIDER` | Embedding provider | `ollama`, `openai`, `openrouter`, `google` | +| `PANTRY_EMBEDDING_MODEL` | Embedding model name | `text-embedding-3-small`, `gemini-embedding-001` | +| `PANTRY_EMBEDDING_API_KEY` | API key for the embedding provider | `sk-...`, `AIzaSy...` | | `PANTRY_EMBEDDING_BASE_URL` | Base URL for the embedding API | `http://localhost:11434` | | `PANTRY_CONTEXT_SEMANTIC` | Semantic search mode | `auto`, `always`, `never` | diff --git a/internal/config/config.go b/internal/config/config.go index 2379ee4..855515c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -113,9 +113,9 @@ func LoadConfig(path string) (*Config, error) { // Validate returns an error if the configuration contains invalid values. // Call this after LoadConfig to surface misconfiguration at startup. func (c *Config) Validate() error { - validProviders := map[string]bool{"ollama": true, "openai": true, "openrouter": true} + validProviders := map[string]bool{"ollama": true, "openai": true, "openrouter": true, "google": true} if !validProviders[c.Embedding.Provider] { - return fmt.Errorf("invalid embedding.provider %q: must be one of ollama, openai, openrouter", c.Embedding.Provider) + return fmt.Errorf("invalid embedding.provider %q: must be one of ollama, openai, openrouter, google", c.Embedding.Provider) } if c.Embedding.Model == "" { @@ -127,7 +127,7 @@ func (c *Config) Validate() error { return fmt.Errorf("invalid context.semantic %q: must be one of auto, always, never", c.Context.Semantic) } - if c.Embedding.Provider == "openai" || c.Embedding.Provider == "openrouter" { + if c.Embedding.Provider == "openai" || c.Embedding.Provider == "openrouter" || c.Embedding.Provider == "google" { if c.Embedding.APIKey == nil || *c.Embedding.APIKey == "" { return fmt.Errorf("embedding.api_key is required for provider %q", c.Embedding.Provider) } @@ -162,10 +162,10 @@ func GetDefaultConfigTemplate() string { # Embedding provider for semantic search. # Without this, keyword search (FTS5) still works. embedding: - provider: ollama # ollama | openai | openrouter + provider: ollama # ollama | openai | openrouter | google model: nomic-embed-text base_url: http://localhost:11434 - # api_key: sk-... # required for openai/openrouter + # api_key: sk-... # required for openai/openrouter/google # How items are retrieved at session start. # "auto" uses vectors when available, falls back to keywords. diff --git a/internal/embeddings/factory.go b/internal/embeddings/factory.go index 35c864c..6dc16c7 100644 --- a/internal/embeddings/factory.go +++ b/internal/embeddings/factory.go @@ -43,6 +43,18 @@ func NewProvider(cfg config.EmbeddingConfig) (Provider, error) { return NewOpenAIProvider(cfg.Model, *cfg.APIKey, baseURL), nil + case "google": + if cfg.APIKey == nil || *cfg.APIKey == "" { + return nil, errors.New("API key required for Google provider") + } + + baseURL := "" + if cfg.BaseURL != nil { + baseURL = *cfg.BaseURL + } + + return NewGoogleProvider(cfg.Model, *cfg.APIKey, baseURL), nil + default: return nil, fmt.Errorf("unknown embedding provider: %s", cfg.Provider) } diff --git a/internal/embeddings/google.go b/internal/embeddings/google.go new file mode 100644 index 0000000..3b8f881 --- /dev/null +++ b/internal/embeddings/google.go @@ -0,0 +1,106 @@ +package embeddings + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" +) + +// GoogleProvider implements embedding generation using Google's Gemini API. +type GoogleProvider struct { + model string + apiKey string + baseURL string + client *http.Client +} + +// NewGoogleProvider creates a new Google Gemini embedding provider. +func NewGoogleProvider(model string, apiKey string, baseURL string) *GoogleProvider { + if baseURL == "" { + baseURL = "https://generativelanguage.googleapis.com/v1beta" + } + + return &GoogleProvider{ + model: model, + apiKey: apiKey, + baseURL: strings.TrimSuffix(baseURL, "/"), + client: &http.Client{}, + } +} + +type googleEmbedRequest struct { + Model string `json:"model"` + Content googleEmbedContent `json:"content"` +} + +type googleEmbedContent struct { + Parts []googleEmbedPart `json:"parts"` +} + +type googleEmbedPart struct { + Text string `json:"text"` +} + +type googleEmbedResponse struct { + Embedding struct { + Values []float32 `json:"values"` + } `json:"embedding"` +} + +// Embed generates an embedding vector using Google Gemini API. +func (p *GoogleProvider) Embed(ctx context.Context, text string) ([]float32, error) { + // The Gemini API requires the model name to be prefixed with "models/" + modelPath := p.model + if !strings.HasPrefix(modelPath, "models/") { + modelPath = "models/" + modelPath + } + + url := fmt.Sprintf("%s/%s:embedContent", p.baseURL, modelPath) + + reqData := googleEmbedRequest{ + Model: modelPath, + Content: googleEmbedContent{ + Parts: []googleEmbedPart{{Text: text}}, + }, + } + + jsonData, err := json.Marshal(reqData) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(jsonData)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("x-goog-api-key", p.apiKey) + + resp, err := p.client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to call Google API: %w", err) + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("google API returned status %d: %s", resp.StatusCode, string(body)) + } + + var response googleEmbedResponse + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + if len(response.Embedding.Values) == 0 { + return nil, fmt.Errorf("no embedding values returned by google api") + } + + return response.Embedding.Values, nil +} diff --git a/pantry b/pantry new file mode 100755 index 0000000..8507f98 Binary files /dev/null and b/pantry differ diff --git a/pkg/cli/setup.go b/pkg/cli/setup.go index 2e39935..d683373 100644 --- a/pkg/cli/setup.go +++ b/pkg/cli/setup.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "github.com/spf13/cobra" ) @@ -22,7 +23,7 @@ func runAgentCmd(agent string, handlers map[string]agentFunc, configDir string, fn, ok := handlers[agent] if !ok { fmt.Fprintf(os.Stderr, "Error: unknown agent: %s\n", agent) - fmt.Fprintf(os.Stderr, "Supported agents: claude, cursor, codex, opencode, roocode\n") + fmt.Fprintf(os.Stderr, "Supported agents: claude, cursor, windsurf, antigravity, codex, opencode, roocode\n") os.Exit(1) } @@ -45,6 +46,8 @@ var setupCmd = &cobra.Command{ "claude": setupClaudeCode, "claude-code": setupClaudeCode, "cursor": setupCursor, + "windsurf": setupWindsurf, + "antigravity": setupAntigravity, "codex": setupCodex, "opencode": func(_ string, project bool) (map[string]string, error) { return setupOpenCode(project) }, "roo": setupRooCode, @@ -63,6 +66,8 @@ var uninstallCmd = &cobra.Command{ "claude": uninstallClaudeCode, "claude-code": uninstallClaudeCode, "cursor": uninstallCursor, + "windsurf": uninstallWindsurf, + "antigravity": uninstallAntigravity, "codex": uninstallCodex, "opencode": func(_ string, project bool) (map[string]string, error) { return uninstallOpenCode(project) }, "roo": uninstallRooCode, @@ -243,6 +248,141 @@ func setupCursor(configDir string, project bool) (map[string]string, error) { return map[string]string{"message": msg}, nil } +func setupWindsurf(configDir string, project bool) (map[string]string, error) { + var targets []string + if configDir != "" { + targets = append(targets, configDir) + } else { + var baseDir string + if project { + baseDir, _ = os.Getwd() + } else { + baseDir, _ = os.UserHomeDir() + } + + appTarget := filepath.Join(baseDir, ".codeium", "windsurf") + if info, err := os.Stat(appTarget); err == nil && info.IsDir() { + targets = append(targets, appTarget) + } + + pluginTarget := filepath.Join(baseDir, ".codeium") + if info, err := os.Stat(pluginTarget); err == nil && info.IsDir() { + targets = append(targets, pluginTarget) + } + } + + if len(targets) == 0 { + return map[string]string{"message": "Windsurf/Cascade installation directories not found"}, nil + } + + var installed []string + for _, target := range targets { + configPath := filepath.Join(target, "mcp_config.json") + + // Read existing config or create new + var config map[string]any + if data, err := os.ReadFile(configPath); err == nil { + if err := json.Unmarshal(data, &config); err != nil { + return nil, fmt.Errorf("failed to parse existing config for %s: %w", target, err) + } + } else { + config = make(map[string]any) + } + + // Add MCP server config + mcpServers, ok := config["mcpServers"].(map[string]any) + if !ok { + mcpServers = make(map[string]any) + config["mcpServers"] = mcpServers + } + + mcpServers["pantry"] = map[string]any{ + "command": "pantry", + "args": []string{"mcp"}, + } + + // Write config + if err := os.MkdirAll(target, 0755); err != nil { + return nil, fmt.Errorf("failed to create config directory %s: %w", target, err) + } + + data, err := json.MarshalIndent(config, "", " ") + if err != nil { + return nil, fmt.Errorf("failed to marshal config for %s: %w", target, err) + } + + if err := os.WriteFile(configPath, data, 0644); err != nil { + return nil, fmt.Errorf("failed to write config for %s: %w", target, err) + } + + msg := "Installed Pantry MCP server in " + configPath + if installSkill(target) { + msg += " and skill" + } + installed = append(installed, msg) + } + + return map[string]string{"message": strings.Join(installed, "\n")}, nil +} + +func setupAntigravity(configDir string, project bool) (map[string]string, error) { + var target string + if configDir != "" { + target = configDir + } else if project { + cwd, _ := os.Getwd() + target = filepath.Join(cwd, ".gemini", "antigravity") + } else { + home, _ := os.UserHomeDir() + target = filepath.Join(home, ".gemini", "antigravity") + } + + configPath := filepath.Join(target, "mcp_config.json") + + // Read existing config or create new + var config map[string]any + if data, err := os.ReadFile(configPath); err == nil { + if err := json.Unmarshal(data, &config); err != nil { + return nil, fmt.Errorf("failed to parse existing config: %w", err) + } + } else { + config = make(map[string]any) + } + + // Add MCP server config + mcpServers, ok := config["mcpServers"].(map[string]any) + if !ok { + mcpServers = make(map[string]any) + config["mcpServers"] = mcpServers + } + + mcpServers["pantry"] = map[string]any{ + "command": "pantry", + "args": []string{"mcp"}, + } + + // Write config + if err := os.MkdirAll(target, 0755); err != nil { + return nil, fmt.Errorf("failed to create config directory: %w", err) + } + + data, err := json.MarshalIndent(config, "", " ") + if err != nil { + return nil, fmt.Errorf("failed to marshal config: %w", err) + } + + if err := os.WriteFile(configPath, data, 0644); err != nil { + return nil, fmt.Errorf("failed to write config: %w", err) + } + + msg := "Installed Pantry MCP server in " + configPath + if installSkill(target) { + msg += " and skill" + } + + return map[string]string{"message": msg}, nil +} + func setupCodex(configDir string, project bool) (map[string]string, error) { target := resolveConfigDir(".codex", configDir, project) configPath := filepath.Join(target, "config.toml") @@ -434,6 +574,89 @@ func uninstallCursor(configDir string, project bool) (map[string]string, error) return map[string]string{"message": msg}, nil } +func uninstallWindsurf(configDir string, project bool) (map[string]string, error) { + var targets []string + if configDir != "" { + targets = append(targets, configDir) + } else { + var baseDir string + if project { + baseDir, _ = os.Getwd() + } else { + baseDir, _ = os.UserHomeDir() + } + + appTarget := filepath.Join(baseDir, ".codeium", "windsurf") + if info, err := os.Stat(appTarget); err == nil && info.IsDir() { + targets = append(targets, appTarget) + } + + pluginTarget := filepath.Join(baseDir, ".codeium") + if info, err := os.Stat(pluginTarget); err == nil && info.IsDir() { + targets = append(targets, pluginTarget) + } + } + + if len(targets) == 0 { + return map[string]string{"message": "Windsurf/Cascade installation directory not found"}, nil + } + + var removed []string + for _, target := range targets { + configPath := filepath.Join(target, "mcp_config.json") + + if _, err := os.Stat(configPath); os.IsNotExist(err) { + continue + } + + if err := removePantryFromMCPJSON(configPath); err != nil { + return nil, err + } + + msg := "Removed Pantry from " + configPath + if uninstallSkill(target) { + msg += " and skill" + } + removed = append(removed, msg) + } + + if len(removed) == 0 { + return map[string]string{"message": "Pantry not found in Windsurf configs"}, nil + } + + return map[string]string{"message": strings.Join(removed, "\n")}, nil +} + +func uninstallAntigravity(configDir string, project bool) (map[string]string, error) { + var target string + if configDir != "" { + target = configDir + } else if project { + cwd, _ := os.Getwd() + target = filepath.Join(cwd, ".gemini", "antigravity") + } else { + home, _ := os.UserHomeDir() + target = filepath.Join(home, ".gemini", "antigravity") + } + + configPath := filepath.Join(target, "mcp_config.json") + + if _, err := os.Stat(configPath); os.IsNotExist(err) { + return map[string]string{"message": "Pantry not found in Antigravity config"}, nil + } + + if err := removePantryFromMCPJSON(configPath); err != nil { + return nil, err + } + + msg := "Removed Pantry from " + configPath + if uninstallSkill(target) { + msg += " and skill" + } + + return map[string]string{"message": msg}, nil +} + func uninstallCodex(configDir string, project bool) (map[string]string, error) { target := resolveConfigDir(".codex", configDir, project) diff --git a/testing/test-pantry.sh b/testing/test-pantry.sh index c703f5a..d8e6960 100755 --- a/testing/test-pantry.sh +++ b/testing/test-pantry.sh @@ -186,6 +186,10 @@ SETUP_DIR="$TEST_HOME/fake-agent-config" mkdir -p "$SETUP_DIR" run_contains "setup cursor with test config-dir" "Installed\|Pantry" $PANTRY_BIN setup cursor --config-dir "$SETUP_DIR" run "uninstall cursor with test config-dir" $PANTRY_BIN uninstall cursor --config-dir "$SETUP_DIR" +run_contains "setup windsurf with test config-dir" "Installed\|Pantry" $PANTRY_BIN setup windsurf --config-dir "$SETUP_DIR" +run "uninstall windsurf with test config-dir" $PANTRY_BIN uninstall windsurf --config-dir "$SETUP_DIR" +run_contains "setup antigravity with test config-dir" "Installed\|Pantry" $PANTRY_BIN setup antigravity --config-dir "$SETUP_DIR" +run "uninstall antigravity with test config-dir" $PANTRY_BIN uninstall antigravity --config-dir "$SETUP_DIR" run_contains "setup roocode with test config-dir" "Installed\|Pantry" $PANTRY_BIN setup roocode --config-dir "$SETUP_DIR" run_contains "setup roocode creates mcp.json" "mcp.json" ls "$SETUP_DIR" run "uninstall roocode with test config-dir" $PANTRY_BIN uninstall roocode --config-dir "$SETUP_DIR"