Compare commits

..

55 Commits

Author SHA1 Message Date
github-actions[bot]
265f2b807e Update version to v1.4.240 and commit 2025-07-07 21:25:55 +00:00
Kayvan Sylvan
dc63e0d1cc Merge pull request #1593 from ksylvan/0707-claude-oauth-improvement
Refactor: Generalize OAuth flow for improved token handling.
2025-07-07 14:24:22 -07:00
Kayvan Sylvan
75842d8610 chore: refactor token path to use authTokenIdentifier 2025-07-07 13:59:13 -07:00
Kayvan Sylvan
bcd4c6caea test: add comprehensive OAuth testing suite for Anthropic plugin
## CHANGES

- Add OAuth test file with 434 lines coverage
- Create mock token server for safe testing
- Implement PKCE generation and validation tests
- Add token expiration logic verification tests
- Create OAuth transport round-trip testing
- Add benchmark tests for performance validation
- Implement helper functions for test token creation
- Add comprehensive error path testing scenarios
2025-07-07 13:50:57 -07:00
Kayvan Sylvan
a6a63698e1 fix: update RefreshToken to use tokenIdentifier parameter 2025-07-07 13:31:08 -07:00
Kayvan Sylvan
0528556b5c refactor: replace hardcoded "claude" with configurable authTokenIdentifier parameter
## CHANGES

- Replace hardcoded "claude" string with `authTokenIdentifier` constant
- Update `RunOAuthFlow` to accept token identifier parameter
- Modify `RefreshToken` to use configurable token identifier
- Update `exchangeToken` to accept token identifier parameter
- Enhance `getValidToken` to use parameterized token identifier
- Add token refresh attempt before full OAuth flow
- Improve OAuth flow with existing token validation
2025-07-07 13:19:00 -07:00
github-actions[bot]
47cf24e19d Update version to v1.4.239 and commit 2025-07-07 18:58:38 +00:00
Kayvan Sylvan
3f07afbef4 Merge pull request #1592 from ksylvan/0707-possible-go-routine-race-condition-fix
Fix Streaming Error Handling in Chatter
2025-07-07 11:57:11 -07:00
Kayvan Sylvan
38d714dccd chore: improve error comparison in TestChatter_Send_StreamingErrorPropagation 2025-07-07 11:20:01 -07:00
Kayvan Sylvan
d0b5c95d61 chore: remove redundant channel closure in Send method
### CHANGES

- Remove redundant `close(responseChan)` in `Send` method
- Update `SendStream` to close `responseChan` properly
- Modify test to reflect channel closure logic
2025-07-07 11:02:04 -07:00
Kayvan Sylvan
f8f80ca206 chore: rename doneChan to done and add streaming aggregation test
## CHANGES

- Rename `doneChan` variable to `done` for consistency
- Add `streamChunks` field to mock vendor struct
- Implement chunk sending logic in mock SendStream method
- Add comprehensive streaming success aggregation test case
- Verify message aggregation from multiple stream chunks
- Test assistant response role and content validation
- Ensure proper session handling in streaming scenarios
2025-07-07 10:49:29 -07:00
Kayvan Sylvan
0af458872f feat: add test for Chatter's Send method error propagation
### CHANGES

- Implement mockVendor for testing ai.Vendor interface
- Add TestChatter_Send_StreamingErrorPropagation test case
- Verify error propagation in Chatter's Send method
- Ensure session returns even on streaming error
- Create temporary database for testing Chatter functionality
2025-07-07 10:36:40 -07:00
Kayvan Sylvan
24e46a6f37 chore: rename channels for clarity in Send method
### CHANGES

- Rename `done` to `doneChan` for clarity
- Adjust channel closure for `doneChan`
- Update channel listening logic to use `doneChan`
2025-07-07 10:28:54 -07:00
Kayvan Sylvan
d6a31e68b0 refactor: rename channel variable to responseChan for better clarity in streaming logic
## CHANGES

- Rename `channel` variable to `responseChan` for clarity
- Update channel references in goroutine defer statements
- Pass renamed channel to `SendStream` method call
- Maintain consistent naming throughout streaming flow
2025-07-07 10:23:42 -07:00
Kayvan Sylvan
b1013ca61b chore: close channel after sending stream in Send
### CHANGES

- Add `channel` closure after sending stream
- Ensure resource cleanup in `Send` method
2025-07-07 10:09:24 -07:00
Kayvan Sylvan
6b4ce946a5 chore: refactor error handling and response aggregation in Send
### CHANGES

- Simplify response aggregation loop in `Send`
- Remove redundant select case for closed channel
- Streamline error checking from `errChan`
- Ensure goroutine completion before returning
2025-07-07 09:39:58 -07:00
Kayvan Sylvan
2d2830e9c8 chore: enhance Chatter.Send method with proper goroutine synchronization
### CHANGES
- Add `done` channel to track goroutine completion.
- Replace `errChan` closure with `done` channel closure.
- Ensure main loop waits for goroutine on channel close.
- Synchronize error handling with `done` channel wait.
2025-07-07 09:09:04 -07:00
Kayvan Sylvan
115327fdab refactor: use select to handle stream and error channels concurrently
### CHANGES

- Replace for-range loop with a non-blocking select statement.
- Process message and error channels concurrently for better handling.
- Improve the robustness of streaming error detection.
- Exit loop cleanly when the message channel closes.
2025-07-07 08:37:31 -07:00
Kayvan Sylvan
e672f9b73f chore: simplify error handling in streaming chat response by removing unnecessary select statement 2025-07-07 08:15:24 -07:00
Kayvan Sylvan
ef4364a1aa fix: improve error handling in streaming chat functionality
## CHANGES

- Add dedicated error channel for stream operations
- Separate error handling from message streaming logic
- Check for streaming errors after channel closure
- Close error channel properly in goroutine cleanup
- Remove error messages from message stream channel
- Add proper error propagation for stream failures
2025-07-07 03:31:58 -07:00
github-actions[bot]
cb3f8ed43d Update version to v1.4.238 and commit 2025-07-07 10:24:00 +00:00
Kayvan Sylvan
4c1803cb6d Merge pull request #1591 from ksylvan/0707-anthropic-can-now-use-only-oauth
Improved Anthropic Plugin Configuration Logic
2025-07-07 03:22:27 -07:00
Kayvan Sylvan
d1c614d44e refactor: extract vendor token identifier constant and remove redundant configure call
## CHANGES

- Extract vendor token identifier into named constant
- Remove redundant Configure() call from IsConfigured method
- Use constant for token validation consistency
- Improve code maintainability with centralized identifier
2025-07-07 03:16:45 -07:00
Kayvan Sylvan
dbaa0b9754 feat: add vendor configuration validation and OAuth auto-authentication
## CHANGES

- Add IsConfigured check to vendor configuration loop
- Implement IsConfigured method for Anthropic client validation
- Remove conditional API key requirement based on OAuth
- Add automatic OAuth flow when no valid token
- Validate both API key and OAuth token configurations
- Simplify API key setup question logic
- Add token expiration checking with 5-minute buffer
2025-07-07 02:49:27 -07:00
github-actions[bot]
4cfe2375ab Update version to v1.4.237 and commit 2025-07-07 03:05:51 +00:00
Kayvan Sylvan
2b371b69c7 Merge pull request #1590 from ksylvan/0706-webui-topp-fix
Do not pass non-default TopP values
2025-07-06 20:04:16 -07:00
Kayvan Sylvan
6222a613e4 fix: add conditional check for TopP parameter in OpenAI client
## CHANGES

- Add zero-value check before setting TopP parameter
- Prevent sending TopP when value is zero
- Apply fix to both chat completions method
- Apply fix to response parameters method
- Ensure consistent parameter handling across OpenAI calls
2025-07-06 19:53:21 -07:00
github-actions[bot]
0882c43532 Update version to v1.4.236 and commit 2025-07-06 19:39:24 +00:00
Kayvan Sylvan
f0e1a1b77f Merge pull request #1587 from ksylvan/0705-enhance-bug-report-template
Enhance bug report template
2025-07-06 12:37:56 -07:00
Kayvan Sylvan
a774f991ab chore: enhance bug report template with detailed system info and installation method fields
## CHANGES

- Add detailed instructions for bug reproduction steps
- Include operating system dropdown with specific architectures
- Add OS version textarea with command examples
- Create installation method dropdown with all options
- Replace version checkbox with proper version output field
- Improve formatting and organization of form sections
- Add helpful links to installation documentation
2025-07-06 11:01:53 -07:00
github-actions[bot]
a40bacaf34 Update version to v1.4.235 and commit 2025-07-06 10:36:33 +00:00
Kayvan Sylvan
969b85380c Merge pull request #1586 from ksylvan/0705-another-fix-for-cistom-directory
Fix to persist the CUSTOM_PATTERNS_DIRECTORY variable
2025-07-06 03:35:05 -07:00
Kayvan Sylvan
e8fe4434db fix: make custom patterns persist correctly 2025-07-06 03:29:10 -07:00
github-actions[bot]
7c7ceca264 Update version to v1.4.234 and commit 2025-07-06 09:00:46 +00:00
Kayvan Sylvan
c19d7ccd9d Merge pull request #1581 from ksylvan/0705-custom-directory-creation-bug
Fix Custom Patterns Directory Creation Logic
2025-07-06 01:59:19 -07:00
Kayvan Sylvan
bd0c5f730e chore: improve directory creation logic in configure method
### CHANGES

- Add `fmt` package for logging errors
- Check directory existence before creating
- Log error without clearing directory value
2025-07-06 01:55:18 -07:00
github-actions[bot]
5900dac58f Update version to v1.4.233 and commit 2025-07-06 08:36:45 +00:00
Kayvan Sylvan
237219c3cc Merge pull request #1580 from ksylvan/0705-fix-custom-pattern-loading
Alphabetical Pattern Sorting and Configuration Refactor
2025-07-06 01:35:19 -07:00
Kayvan Sylvan
26fd700098 refactor: move custom patterns directory initialization to Configure method
- Move custom patterns directory logic to Configure method
- Initialize CustomPatternsDir after loading .env file
- Add alphabetical sorting to pattern names retrieval
- Override ListNames method for PatternsEntity class
- Improve pattern listing with proper error handling
- Ensure custom patterns loaded after environment configuration
2025-07-06 01:30:12 -07:00
Kayvan Sylvan
6bd926dd0f Merge pull request #1578 from ksylvan/0705-custom-pattern-readme
Document Custom Patterns Directory Support
2025-07-06 00:38:01 -07:00
Kayvan Sylvan
16ac519415 docs: add comprehensive custom patterns setup and usage guide
## CHANGES

- Add custom patterns directory setup instructions
- Document priority system for custom vs built-in patterns
- Include step-by-step custom pattern creation workflow
- Explain update-safe custom pattern storage
- Add table of contents entries for new sections
- Document seamless integration with existing fabric commands
- Clarify privacy and precedence behavior for custom patterns
2025-07-06 00:32:54 -07:00
github-actions[bot]
a32cc5fa01 Update version to v1.4.232 and commit 2025-07-06 07:22:25 +00:00
Daniel Miessler 🛡️
26b5bb2e9e Merge pull request #1577 from ksylvan/0705-custom-patterns-dir
Add Custom Patterns Directory Support
2025-07-06 00:20:58 -07:00
Kayvan Sylvan
b751d323b1 feat: add custom patterns directory support with environment variable configuration
## CHANGES

- Add custom patterns directory support via environment variable
- Implement custom patterns plugin with registry integration
- Override main patterns with custom directory patterns
- Expand home directory paths in custom patterns config
- Add comprehensive test coverage for custom patterns functionality
- Integrate custom patterns into plugin setup workflow
- Support pattern precedence with custom over main patterns
2025-07-05 23:51:43 -07:00
github-actions[bot]
d081fd269c Update version to v1.4.231 and commit 2025-07-05 22:25:30 +00:00
Kayvan Sylvan
369a0a850d Merge pull request #1565 from ksylvan/0701-claude-oauth-support
OAuth Authentication Support for Anthropic
2025-07-05 15:23:56 -07:00
Kayvan Sylvan
8dc5343ee6 fix: remove duplicate API key setup question in Anthropic client 2025-07-05 15:05:21 -07:00
Kayvan Sylvan
eda552dac5 refactor: extract OAuth functionality from anthropic client to separate module
## CHANGES

- Remove OAuth transport implementation from main client
- Extract OAuth flow functions to separate module
- Remove unused imports and constants from client
- Replace inline OAuth transport with NewOAuthTransport call
- Update runOAuthFlow to exported RunOAuthFlow function
- Clean up token management and refresh logic
- Simplify client configuration by removing OAuth internals
2025-07-05 14:59:38 -07:00
Kayvan Sylvan
f13a56685b feat: add OAuth login support for Anthropic API configuration 2025-07-05 14:46:43 -07:00
Kayvan Sylvan
2f9afe0247 feat: remove OAuth flow functions for simplified token handling 2025-07-05 11:45:25 -07:00
Kayvan Sylvan
1ec525ad97 chore: simplify base URL configuration in configure method
### CHANGES

- Remove redundant base URL trimming logic
- Append base URL directly without modification
- Eliminate conditional check for API version suffix
2025-07-05 11:39:35 -07:00
Kayvan Sylvan
b7dc6748e0 feat: enhance OAuth authentication flow with automatic re-authentication and timeout handling
## CHANGES

- Add automatic OAuth flow initiation when no token exists
- Implement fallback re-authentication when token refresh fails
- Add timeout contexts for OAuth and refresh operations
- Create context-aware OAuth flow and token exchange functions
- Enhance error handling with graceful authentication recovery
- Add user input timeout protection for authorization codes
- Preserve refresh tokens during token exchange operations
2025-07-05 09:59:27 -07:00
Kayvan Sylvan
f1b612d828 refactor: remove OAuth endpoint logic and standardize on v2 API endpoint
## CHANGES

- Remove OAuth-specific v1 endpoint handling logic
- Standardize all API calls to use v2 endpoint
- Simplify baseURL configuration by removing conditional branching
- Update endpoint logic to always append v2 suffix
2025-07-05 09:37:57 -07:00
Kayvan Sylvan
eac5a104f2 feat: implement OAuth token refresh and persistent storage for Claude authentication
## CHANGES

- Add automatic OAuth token refresh when expired
- Implement persistent token storage using common OAuth storage
- Remove deprecated AuthToken setting from client configuration
- Add token validation with 5-minute expiration buffer
- Create refreshToken function for seamless token renewal
- Update OAuth flow to save complete token information
- Enhance error handling for OAuth authentication failures
- Simplify client configuration by removing manual token management
2025-07-05 09:17:50 -07:00
Kayvan Sylvan
4bff88fae3 feat: add OAuth authentication support for Anthropic Claude
- Move golang.org/x/oauth2 from indirect to direct dependency
- Add OAuth login option for Anthropic client
- Implement PKCE OAuth flow with browser integration
- Add custom HTTP transport for OAuth Bearer tokens
- Support both API key and OAuth authentication methods
- Add Claude Code system message for OAuth sessions
- Update REST API to handle OAuth tokens
- Improve environment variable name sanitization with regex
2025-07-05 08:32:16 -07:00
21 changed files with 1912 additions and 70 deletions

View File

@@ -7,29 +7,74 @@ body:
attributes:
value: |
Thanks for taking the time to fill out this bug report!
Please provide as much detail as possible to help us understand and reproduce the issue.
- type: textarea
id: what-happened
attributes:
label: What happened?
description: Also tell us, what did you expect to happen?
placeholder: Tell us what you see!
value: "I was doing THIS, when THAT happened. I was expecting THAT_OTHER_THING to happen instead."
value: "Please provide all the steps to reproduce the bug. I was doing THIS, when THAT happened. I was expecting THAT_OTHER_THING to happen instead."
validations:
required: true
- type: checkboxes
- type: dropdown
id: os
attributes:
label: Operating System
options:
- macOS - Silicon (arm64)
- macOS - Intel (amd64)
- Linux - amd64
- Linux - arm64
- Windows
validations:
required: true
- type: textarea
id: os-version
attributes:
label: OS Version
description: Please provide details about your OS version by running one of the following commands.
placeholder: |
macOS: `sw_vers`
Linux: `uname -a` or `cat /etc/os-release`
Windows: `ver`
render: shell
- type: dropdown
id: installation
attributes:
label: How did you install Fabric?
description: "Please select the method you used to install Fabric. You can find this information in the [Installation section of the README](https://github.com/ksylvan/fabric/blob/main/README.md#installation)."
options:
- Release Binary - Windows
- Release Binary - macOS (arm64)
- Release Binary - macOS (amd64)
- Release Binary - Linux (amd64)
- Release Binary - Linux (arm64)
- Package Manager - Homebrew (macOS)
- Package Manager - AUR (Arch Linux)
- From Source
- Other
validations:
required: true
- type: textarea
id: version
attributes:
label: Version check
description: Please make sure you were using the latest version of this project available in the `main` branch.
options:
- label: Yes I was.
required: true
label: Version
description: Please copy and paste the output of `fabric --version` (or `fabric-ai --version` if you installed it via brew) here.
render: text
- type: textarea
id: logs
attributes:
label: Relevant log output
description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks.
render: shell
- type: textarea
id: screens
attributes:

View File

@@ -93,6 +93,9 @@ Keep in mind that many of these were recorded when Fabric was Python-based, so r
- [Just use the Patterns](#just-use-the-patterns)
- [Prompt Strategies](#prompt-strategies)
- [Custom Patterns](#custom-patterns)
- [Setting Up Custom Patterns](#setting-up-custom-patterns)
- [Using Custom Patterns](#using-custom-patterns)
- [How It Works](#how-it-works)
- [Helper Apps](#helper-apps)
- [`to_pdf`](#to_pdf)
- [`to_pdf` Installation](#to_pdf-installation)
@@ -652,11 +655,48 @@ Use `fabric -S` and select the option to install the strategies in your `~/.conf
You may want to use Fabric to create your own custom Patterns—but not share them with others. No problem!
Just make a directory in `~/.config/custompatterns/` (or wherever) and put your `.md` files in there.
Fabric now supports a dedicated custom patterns directory that keeps your personal patterns separate from the built-in ones. This means your custom patterns won't be overwritten when you update Fabric's built-in patterns.
When you're ready to use them, copy them into `~/.config/fabric/patterns/`
### Setting Up Custom Patterns
You can then use them like any other Patterns, but they won't be public unless you explicitly submit them as Pull Requests to the Fabric project. So don't worry—they're private to you.
1. Run the Fabric setup:
```bash
fabric --setup
```
2. Select the "Custom Patterns" option from the Tools menu and enter your desired directory path (e.g., `~/my-custom-patterns`)
3. Fabric will automatically create the directory if it does not exist.
### Using Custom Patterns
1. Create your custom pattern directory structure:
```bash
mkdir -p ~/my-custom-patterns/my-analyzer
```
2. Create your pattern file
```bash
echo "You are an expert analyzer of ..." > ~/my-custom-patterns/my-analyzer/system.md
```
3. **Use your custom pattern:**
```bash
fabric --pattern my-analyzer "analyze this text"
```
### How It Works
- **Priority System**: Custom patterns take precedence over built-in patterns with the same name
- **Seamless Integration**: Custom patterns appear in `fabric --listpatterns` alongside built-in ones
- **Update Safe**: Your custom patterns are never affected by `fabric --updatepatterns`
- **Private by Default**: Custom patterns remain private unless you explicitly share them
Your custom patterns are completely private and won't be affected by Fabric updates!
## Helper Apps

124
common/oauth_storage.go Normal file
View File

@@ -0,0 +1,124 @@
package common
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
)
// OAuthToken represents stored OAuth token information
type OAuthToken struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresAt int64 `json:"expires_at"`
TokenType string `json:"token_type"`
Scope string `json:"scope"`
}
// IsExpired checks if the token is expired or will expire within the buffer time
func (t *OAuthToken) IsExpired(bufferMinutes int) bool {
if t.ExpiresAt == 0 {
return true
}
bufferTime := time.Duration(bufferMinutes) * time.Minute
return time.Now().Add(bufferTime).Unix() >= t.ExpiresAt
}
// OAuthStorage handles persistent storage of OAuth tokens
type OAuthStorage struct {
configDir string
}
// NewOAuthStorage creates a new OAuth storage instance
func NewOAuthStorage() (*OAuthStorage, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("failed to get user home directory: %w", err)
}
configDir := filepath.Join(homeDir, ".config", "fabric")
// Ensure config directory exists
if err := os.MkdirAll(configDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create config directory: %w", err)
}
return &OAuthStorage{configDir: configDir}, nil
}
// GetTokenPath returns the file path for a provider's OAuth token
func (s *OAuthStorage) GetTokenPath(provider string) string {
return filepath.Join(s.configDir, fmt.Sprintf(".%s_oauth", provider))
}
// SaveToken saves an OAuth token to disk with proper permissions
func (s *OAuthStorage) SaveToken(provider string, token *OAuthToken) error {
tokenPath := s.GetTokenPath(provider)
// Marshal token to JSON
data, err := json.MarshalIndent(token, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal token: %w", err)
}
// Write to temporary file first for atomic operation
tempPath := tokenPath + ".tmp"
if err := os.WriteFile(tempPath, data, 0600); err != nil {
return fmt.Errorf("failed to write token file: %w", err)
}
// Atomic rename
if err := os.Rename(tempPath, tokenPath); err != nil {
os.Remove(tempPath) // Clean up temp file
return fmt.Errorf("failed to save token file: %w", err)
}
return nil
}
// LoadToken loads an OAuth token from disk
func (s *OAuthStorage) LoadToken(provider string) (*OAuthToken, error) {
tokenPath := s.GetTokenPath(provider)
// Check if file exists
if _, err := os.Stat(tokenPath); os.IsNotExist(err) {
return nil, nil // No token stored
}
// Read token file
data, err := os.ReadFile(tokenPath)
if err != nil {
return nil, fmt.Errorf("failed to read token file: %w", err)
}
// Unmarshal token
var token OAuthToken
if err := json.Unmarshal(data, &token); err != nil {
return nil, fmt.Errorf("failed to parse token file: %w", err)
}
return &token, nil
}
// DeleteToken removes a stored OAuth token
func (s *OAuthStorage) DeleteToken(provider string) error {
tokenPath := s.GetTokenPath(provider)
if err := os.Remove(tokenPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to delete token file: %w", err)
}
return nil
}
// HasValidToken checks if a valid (non-expired) token exists for a provider
func (s *OAuthStorage) HasValidToken(provider string, bufferMinutes int) bool {
token, err := s.LoadToken(provider)
if err != nil || token == nil {
return false
}
return !token.IsExpired(bufferMinutes)
}

View File

@@ -0,0 +1,232 @@
package common
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestOAuthToken_IsExpired(t *testing.T) {
tests := []struct {
name string
expiresAt int64
bufferMinutes int
expected bool
}{
{
name: "token not expired",
expiresAt: time.Now().Unix() + 3600, // 1 hour from now
bufferMinutes: 5,
expected: false,
},
{
name: "token expired",
expiresAt: time.Now().Unix() - 3600, // 1 hour ago
bufferMinutes: 5,
expected: true,
},
{
name: "token expires within buffer",
expiresAt: time.Now().Unix() + 120, // 2 minutes from now
bufferMinutes: 5,
expected: true,
},
{
name: "zero expiry time",
expiresAt: 0,
bufferMinutes: 5,
expected: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
token := &OAuthToken{ExpiresAt: tt.expiresAt}
if got := token.IsExpired(tt.bufferMinutes); got != tt.expected {
t.Errorf("IsExpired() = %v, want %v", got, tt.expected)
}
})
}
}
func TestOAuthStorage_SaveAndLoadToken(t *testing.T) {
// Create temporary directory for testing
tempDir, err := os.MkdirTemp("", "fabric_oauth_test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
// Create storage with custom config dir
storage := &OAuthStorage{configDir: tempDir}
// Test token
token := &OAuthToken{
AccessToken: "test_access_token",
RefreshToken: "test_refresh_token",
ExpiresAt: time.Now().Unix() + 3600,
TokenType: "Bearer",
Scope: "test_scope",
}
// Test saving token
err = storage.SaveToken("test_provider", token)
if err != nil {
t.Fatalf("Failed to save token: %v", err)
}
// Verify file exists and has correct permissions
tokenPath := storage.GetTokenPath("test_provider")
info, err := os.Stat(tokenPath)
if err != nil {
t.Fatalf("Token file not created: %v", err)
}
if info.Mode().Perm() != 0600 {
t.Errorf("Token file has wrong permissions: %v, want 0600", info.Mode().Perm())
}
// Test loading token
loadedToken, err := storage.LoadToken("test_provider")
if err != nil {
t.Fatalf("Failed to load token: %v", err)
}
if loadedToken == nil {
t.Fatal("Loaded token is nil")
}
// Verify token data
if loadedToken.AccessToken != token.AccessToken {
t.Errorf("AccessToken mismatch: got %v, want %v", loadedToken.AccessToken, token.AccessToken)
}
if loadedToken.RefreshToken != token.RefreshToken {
t.Errorf("RefreshToken mismatch: got %v, want %v", loadedToken.RefreshToken, token.RefreshToken)
}
if loadedToken.ExpiresAt != token.ExpiresAt {
t.Errorf("ExpiresAt mismatch: got %v, want %v", loadedToken.ExpiresAt, token.ExpiresAt)
}
}
func TestOAuthStorage_LoadNonExistentToken(t *testing.T) {
// Create temporary directory for testing
tempDir, err := os.MkdirTemp("", "fabric_oauth_test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
storage := &OAuthStorage{configDir: tempDir}
// Try to load non-existent token
token, err := storage.LoadToken("nonexistent")
if err != nil {
t.Fatalf("Unexpected error loading non-existent token: %v", err)
}
if token != nil {
t.Error("Expected nil token for non-existent provider")
}
}
func TestOAuthStorage_DeleteToken(t *testing.T) {
// Create temporary directory for testing
tempDir, err := os.MkdirTemp("", "fabric_oauth_test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
storage := &OAuthStorage{configDir: tempDir}
// Create and save a token
token := &OAuthToken{
AccessToken: "test_token",
RefreshToken: "test_refresh",
ExpiresAt: time.Now().Unix() + 3600,
}
err = storage.SaveToken("test_provider", token)
if err != nil {
t.Fatalf("Failed to save token: %v", err)
}
// Verify token exists
tokenPath := storage.GetTokenPath("test_provider")
if _, err := os.Stat(tokenPath); os.IsNotExist(err) {
t.Fatal("Token file should exist before deletion")
}
// Delete token
err = storage.DeleteToken("test_provider")
if err != nil {
t.Fatalf("Failed to delete token: %v", err)
}
// Verify token is deleted
if _, err := os.Stat(tokenPath); !os.IsNotExist(err) {
t.Error("Token file should not exist after deletion")
}
// Test deleting non-existent token (should not error)
err = storage.DeleteToken("nonexistent")
if err != nil {
t.Errorf("Deleting non-existent token should not error: %v", err)
}
}
func TestOAuthStorage_HasValidToken(t *testing.T) {
// Create temporary directory for testing
tempDir, err := os.MkdirTemp("", "fabric_oauth_test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
storage := &OAuthStorage{configDir: tempDir}
// Test with no token
if storage.HasValidToken("test_provider", 5) {
t.Error("Should return false when no token exists")
}
// Save valid token
validToken := &OAuthToken{
AccessToken: "valid_token",
RefreshToken: "refresh_token",
ExpiresAt: time.Now().Unix() + 3600, // 1 hour from now
}
err = storage.SaveToken("test_provider", validToken)
if err != nil {
t.Fatalf("Failed to save valid token: %v", err)
}
// Test with valid token
if !storage.HasValidToken("test_provider", 5) {
t.Error("Should return true for valid token")
}
// Save expired token
expiredToken := &OAuthToken{
AccessToken: "expired_token",
RefreshToken: "refresh_token",
ExpiresAt: time.Now().Unix() - 3600, // 1 hour ago
}
err = storage.SaveToken("expired_provider", expiredToken)
if err != nil {
t.Fatalf("Failed to save expired token: %v", err)
}
// Test with expired token
if storage.HasValidToken("expired_provider", 5) {
t.Error("Should return false for expired token")
}
}
func TestOAuthStorage_GetTokenPath(t *testing.T) {
storage := &OAuthStorage{configDir: "/test/config"}
expected := filepath.Join("/test/config", ".test_provider_oauth")
actual := storage.GetTokenPath("test_provider")
if actual != expected {
t.Errorf("GetTokenPath() = %v, want %v", actual, expected)
}
}

View File

@@ -66,17 +66,35 @@ func (o *Chatter) Send(request *common.ChatRequest, opts *common.ChatOptions) (s
message := ""
if o.Stream {
channel := make(chan string)
responseChan := make(chan string)
errChan := make(chan error, 1)
done := make(chan struct{})
go func() {
if streamErr := o.vendor.SendStream(session.GetVendorMessages(), opts, channel); streamErr != nil {
channel <- streamErr.Error()
defer close(done)
if streamErr := o.vendor.SendStream(session.GetVendorMessages(), opts, responseChan); streamErr != nil {
errChan <- streamErr
}
}()
for response := range channel {
for response := range responseChan {
message += response
fmt.Print(response)
}
// Wait for goroutine to finish
<-done
// Check for errors in errChan
select {
case streamErr := <-errChan:
if streamErr != nil {
err = streamErr
return
}
default:
// No errors, continue
}
} else {
if message, err = o.vendor.Send(context.Background(), session.GetVendorMessages(), opts); err != nil {
return

181
core/chatter_test.go Normal file
View File

@@ -0,0 +1,181 @@
package core
import (
"bytes"
"context"
"errors"
"testing"
"github.com/danielmiessler/fabric/chat"
"github.com/danielmiessler/fabric/common"
"github.com/danielmiessler/fabric/plugins/db/fsdb"
)
// mockVendor implements the ai.Vendor interface for testing
type mockVendor struct {
sendStreamError error
streamChunks []string
}
func (m *mockVendor) GetName() string {
return "mock"
}
func (m *mockVendor) GetSetupDescription() string {
return "mock vendor"
}
func (m *mockVendor) IsConfigured() bool {
return true
}
func (m *mockVendor) Configure() error {
return nil
}
func (m *mockVendor) Setup() error {
return nil
}
func (m *mockVendor) SetupFillEnvFileContent(*bytes.Buffer) {
}
func (m *mockVendor) ListModels() ([]string, error) {
return []string{"test-model"}, nil
}
func (m *mockVendor) SendStream(messages []*chat.ChatCompletionMessage, opts *common.ChatOptions, responseChan chan string) error {
// Send chunks if provided (for successful streaming test)
if m.streamChunks != nil {
for _, chunk := range m.streamChunks {
responseChan <- chunk
}
}
// Close the channel like real vendors do
close(responseChan)
return m.sendStreamError
}
func (m *mockVendor) Send(ctx context.Context, messages []*chat.ChatCompletionMessage, opts *common.ChatOptions) (string, error) {
return "test response", nil
}
func (m *mockVendor) NeedsRawMode(modelName string) bool {
return false
}
func TestChatter_Send_StreamingErrorPropagation(t *testing.T) {
// Create a temporary database for testing
tempDir := t.TempDir()
db := fsdb.NewDb(tempDir)
// Create a mock vendor that will return an error from SendStream
expectedError := errors.New("streaming error")
mockVendor := &mockVendor{
sendStreamError: expectedError,
}
// Create chatter with streaming enabled
chatter := &Chatter{
db: db,
Stream: true, // Enable streaming to trigger SendStream path
vendor: mockVendor,
model: "test-model",
}
// Create a test request
request := &common.ChatRequest{
Message: &chat.ChatCompletionMessage{
Role: chat.ChatMessageRoleUser,
Content: "test message",
},
}
// Create test options
opts := &common.ChatOptions{
Model: "test-model",
}
// Call Send and expect it to return the streaming error
session, err := chatter.Send(request, opts)
// Verify that the error from SendStream is propagated
if err == nil {
t.Fatal("Expected error to be returned, but got nil")
}
if !errors.Is(err, expectedError) {
t.Errorf("Expected error %q, but got %q", expectedError, err)
}
// Session should still be returned (it was built successfully before the streaming error)
if session == nil {
t.Error("Expected session to be returned even when streaming error occurs")
}
}
func TestChatter_Send_StreamingSuccessfulAggregation(t *testing.T) {
// Create a temporary database for testing
tempDir := t.TempDir()
db := fsdb.NewDb(tempDir)
// Create test chunks that should be aggregated
testChunks := []string{"Hello", " ", "world", "!", " This", " is", " a", " test."}
expectedMessage := "Hello world! This is a test."
// Create a mock vendor that will send chunks successfully
mockVendor := &mockVendor{
sendStreamError: nil, // No error for successful streaming
streamChunks: testChunks,
}
// Create chatter with streaming enabled
chatter := &Chatter{
db: db,
Stream: true, // Enable streaming to trigger SendStream path
vendor: mockVendor,
model: "test-model",
}
// Create a test request
request := &common.ChatRequest{
Message: &chat.ChatCompletionMessage{
Role: chat.ChatMessageRoleUser,
Content: "test message",
},
}
// Create test options
opts := &common.ChatOptions{
Model: "test-model",
}
// Call Send and expect successful aggregation
session, err := chatter.Send(request, opts)
// Verify no error occurred
if err != nil {
t.Fatalf("Expected no error, but got: %v", err)
}
// Verify session was returned
if session == nil {
t.Fatal("Expected session to be returned")
}
// Verify the message was aggregated correctly
messages := session.GetVendorMessages()
if len(messages) != 2 { // user message + assistant response
t.Fatalf("Expected 2 messages, got %d", len(messages))
}
// Check the assistant's response (last message)
assistantMessage := messages[len(messages)-1]
if assistantMessage.Role != chat.ChatMessageRoleAssistant {
t.Errorf("Expected assistant role, got %s", assistantMessage.Role)
}
if assistantMessage.Content != expectedMessage {
t.Errorf("Expected aggregated message %q, got %q", expectedMessage, assistantMessage.Content)
}
}

View File

@@ -31,6 +31,7 @@ import (
"github.com/danielmiessler/fabric/plugins/db/fsdb"
"github.com/danielmiessler/fabric/plugins/template"
"github.com/danielmiessler/fabric/plugins/tools"
"github.com/danielmiessler/fabric/plugins/tools/custom_patterns"
"github.com/danielmiessler/fabric/plugins/tools/jina"
"github.com/danielmiessler/fabric/plugins/tools/lang"
"github.com/danielmiessler/fabric/plugins/tools/youtube"
@@ -69,6 +70,7 @@ func NewPluginRegistry(db *fsdb.Db) (ret *PluginRegistry, err error) {
VendorManager: ai.NewVendorsManager(),
VendorsAll: ai.NewVendorsManager(),
PatternsLoader: tools.NewPatternsLoader(db.Patterns),
CustomPatterns: custom_patterns.NewCustomPatterns(),
YouTube: youtube.NewYouTube(),
Language: lang.NewLanguage(),
Jina: jina.NewClient(),
@@ -138,6 +140,7 @@ type PluginRegistry struct {
VendorsAll *ai.VendorsManager
Defaults *tools.Defaults
PatternsLoader *tools.PatternsLoader
CustomPatterns *custom_patterns.CustomPatterns
YouTube *youtube.YouTube
Language *lang.Language
Jina *jina.Client
@@ -151,6 +154,7 @@ func (o *PluginRegistry) SaveEnvFile() (err error) {
o.Defaults.Settings.FillEnvFileContent(&envFileContent)
o.PatternsLoader.SetupFillEnvFileContent(&envFileContent)
o.CustomPatterns.SetupFillEnvFileContent(&envFileContent)
o.Strategies.SetupFillEnvFileContent(&envFileContent)
for _, vendor := range o.VendorManager.Vendors {
@@ -183,7 +187,7 @@ func (o *PluginRegistry) Setup() (err error) {
return vendor
})...)
groupsPlugins.AddGroupItems("Tools", o.Defaults, o.Jina, o.Language, o.PatternsLoader, o.Strategies, o.YouTube)
groupsPlugins.AddGroupItems("Tools", o.CustomPatterns, o.Defaults, o.Jina, o.Language, o.PatternsLoader, o.Strategies, o.YouTube)
for {
groupsPlugins.Print(false)
@@ -239,7 +243,7 @@ func (o *PluginRegistry) SetupVendor(vendorName string) (err error) {
func (o *PluginRegistry) ConfigureVendors() {
o.VendorManager.Clear()
for _, vendor := range o.VendorsAll.Vendors {
if vendorErr := vendor.Configure(); vendorErr == nil {
if vendorErr := vendor.Configure(); vendorErr == nil && vendor.IsConfigured() {
o.VendorManager.AddVendors(vendor)
}
}

2
go.mod
View File

@@ -25,6 +25,7 @@ require (
github.com/samber/lo v1.50.0
github.com/sgaunet/perplexity-go/v2 v2.8.0
github.com/stretchr/testify v1.10.0
golang.org/x/oauth2 v0.30.0
golang.org/x/text v0.26.0
google.golang.org/api v0.236.0
gopkg.in/yaml.v3 v3.0.1
@@ -108,7 +109,6 @@ require (
golang.org/x/arch v0.18.0 // indirect
golang.org/x/crypto v0.39.0 // indirect
golang.org/x/net v0.41.0 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/sync v0.15.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/time v0.12.0 // indirect

View File

@@ -1 +1 @@
"1.4.230"
"1.4.240"

View File

@@ -3,6 +3,7 @@ package anthropic
import (
"context"
"fmt"
"net/http"
"strings"
"github.com/anthropics/anthropic-sdk-go"
@@ -18,6 +19,8 @@ const webSearchToolName = "web_search"
const webSearchToolType = "web_search_20250305"
const sourcesHeader = "## Sources"
const authTokenIdentifier = "claude"
func NewClient() (ret *Client) {
vendorName := "Anthropic"
ret = &Client{}
@@ -30,7 +33,8 @@ func NewClient() (ret *Client) {
ret.ApiBaseURL = ret.AddSetupQuestion("API Base URL", false)
ret.ApiBaseURL.Value = defaultBaseUrl
ret.ApiKey = ret.PluginBase.AddSetupQuestion("API key", true)
ret.UseOAuth = ret.AddSetupQuestionBool("Use OAuth login", false)
ret.ApiKey = ret.PluginBase.AddSetupQuestion("API key", false)
ret.maxTokens = 4096
ret.defaultRequiredUserMessage = "Hi"
@@ -46,10 +50,43 @@ func NewClient() (ret *Client) {
return
}
// IsConfigured returns true if either the API key or OAuth is configured
func (an *Client) IsConfigured() bool {
// Check if API key is configured
if an.ApiKey.Value != "" {
return true
}
// Check if OAuth is enabled and has a valid token
if plugins.ParseBoolElseFalse(an.UseOAuth.Value) {
storage, err := common.NewOAuthStorage()
if err != nil {
return false
}
// If no valid token exists, automatically run OAuth flow
if !storage.HasValidToken(authTokenIdentifier, 5) {
fmt.Println("OAuth enabled but no valid token found. Starting authentication...")
_, err := RunOAuthFlow(authTokenIdentifier)
if err != nil {
fmt.Printf("OAuth authentication failed: %v\n", err)
return false
}
// After successful OAuth flow, check again
return storage.HasValidToken(authTokenIdentifier, 5)
}
return true
}
return false
}
type Client struct {
*plugins.PluginBase
ApiBaseURL *plugins.SetupQuestion
ApiKey *plugins.SetupQuestion
UseOAuth *plugins.SetupQuestion
maxTokens int
defaultRequiredUserMessage string
@@ -58,24 +95,50 @@ type Client struct {
client anthropic.Client
}
func (an *Client) configure() (err error) {
if an.ApiBaseURL.Value != "" {
baseURL := an.ApiBaseURL.Value
func (an *Client) Setup() (err error) {
if err = an.PluginBase.Ask(an.Name); err != nil {
return
}
// As of 2.0beta1, using v2 API endpoint.
// https://github.com/anthropics/anthropic-sdk-go/blob/main/CHANGELOG.md#020-beta1-2025-03-25
if strings.Contains(baseURL, "-") && !strings.HasSuffix(baseURL, "/v2") {
baseURL = strings.TrimSuffix(baseURL, "/")
baseURL = baseURL + "/v2"
if plugins.ParseBoolElseFalse(an.UseOAuth.Value) {
// Check if we have a valid stored token
storage, err := common.NewOAuthStorage()
if err != nil {
return err
}
an.client = anthropic.NewClient(
option.WithAPIKey(an.ApiKey.Value),
option.WithBaseURL(baseURL),
)
} else {
an.client = anthropic.NewClient(option.WithAPIKey(an.ApiKey.Value))
if !storage.HasValidToken(authTokenIdentifier, 5) {
// No valid token, run OAuth flow
if _, err = RunOAuthFlow(authTokenIdentifier); err != nil {
return err
}
}
}
err = an.configure()
return
}
func (an *Client) configure() (err error) {
opts := []option.RequestOption{}
if an.ApiBaseURL.Value != "" {
opts = append(opts, option.WithBaseURL(an.ApiBaseURL.Value))
}
if plugins.ParseBoolElseFalse(an.UseOAuth.Value) {
// For OAuth, use Bearer token with custom headers
// Create custom HTTP client that adds OAuth Bearer token and beta header
baseTransport := &http.Transport{}
httpClient := &http.Client{
Transport: NewOAuthTransport(an, baseTransport),
}
opts = append(opts, option.WithHTTPClient(httpClient))
} else {
opts = append(opts, option.WithAPIKey(an.ApiKey.Value))
}
an.client = anthropic.NewClient(opts...)
return
}
@@ -124,6 +187,17 @@ func (an *Client) buildMessageParams(msgs []anthropic.MessageParam, opts *common
Messages: msgs,
}
// Add Claude Code spoofing system message for OAuth authentication
if plugins.ParseBoolElseFalse(an.UseOAuth.Value) {
params.System = []anthropic.TextBlockParam{
{
Type: "text",
Text: "You are Claude Code, Anthropic's official CLI for Claude.",
},
}
}
if opts.Search {
// Build the web-search tool definition:
webTool := anthropic.WebSearchTool20250305Param{
@@ -207,6 +281,9 @@ func (an *Client) toMessages(msgs []*chat.ChatCompletionMessage) (ret []anthropi
var anthropicMessages []anthropic.MessageParam
var systemContent string
// Note: Claude Code spoofing is now handled in buildMessageParams
isFirstUserMessage := true
lastRoleWasUser := false

View File

@@ -0,0 +1,321 @@
package anthropic
import (
"bytes"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"os/exec"
"strings"
"time"
"github.com/danielmiessler/fabric/common"
"golang.org/x/oauth2"
)
// OAuth configuration constants
const (
oauthClientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
oauthAuthURL = "https://claude.ai/oauth/authorize"
oauthTokenURL = "https://console.anthropic.com/v1/oauth/token"
oauthRedirectURL = "https://console.anthropic.com/oauth/code/callback"
)
// OAuthTransport is a custom HTTP transport that adds OAuth Bearer token and beta header
type OAuthTransport struct {
client *Client
base http.RoundTripper
}
// RoundTrip implements the http.RoundTripper interface
func (t *OAuthTransport) RoundTrip(req *http.Request) (*http.Response, error) {
// Clone the request to avoid modifying the original
newReq := req.Clone(req.Context())
// Get current token (may refresh if needed)
token, err := t.getValidToken(authTokenIdentifier)
if err != nil {
return nil, fmt.Errorf("failed to get valid OAuth token: %w", err)
}
// Add OAuth Bearer token
newReq.Header.Set("Authorization", "Bearer "+token)
// Add the anthropic-beta header for OAuth
newReq.Header.Set("anthropic-beta", "oauth-2025-04-20")
// Set User-Agent to match AI SDK exactly
newReq.Header.Set("User-Agent", "ai-sdk/anthropic")
// Remove x-api-key header if present (OAuth doesn't use it)
newReq.Header.Del("x-api-key")
return t.base.RoundTrip(newReq)
}
// getValidToken returns a valid access token, refreshing if necessary
func (t *OAuthTransport) getValidToken(tokenIdentifier string) (string, error) {
storage, err := common.NewOAuthStorage()
if err != nil {
return "", fmt.Errorf("failed to create OAuth storage: %w", err)
}
// Load stored token
token, err := storage.LoadToken(tokenIdentifier)
if err != nil {
return "", fmt.Errorf("failed to load stored token: %w", err)
}
// If no token exists, run OAuth flow
if token == nil {
fmt.Println("No OAuth token found, initiating authentication...")
newAccessToken, err := RunOAuthFlow(tokenIdentifier)
if err != nil {
return "", fmt.Errorf("failed to authenticate: %w", err)
}
return newAccessToken, nil
}
// Check if token needs refresh (5 minute buffer)
if token.IsExpired(5) {
fmt.Println("OAuth token expired, refreshing...")
newAccessToken, err := RefreshToken(tokenIdentifier)
if err != nil {
// If refresh fails, try re-authentication
fmt.Println("Token refresh failed, re-authenticating...")
newAccessToken, err = RunOAuthFlow(tokenIdentifier)
if err != nil {
return "", fmt.Errorf("failed to refresh or re-authenticate: %w", err)
}
}
return newAccessToken, nil
}
return token.AccessToken, nil
}
// NewOAuthTransport creates a new OAuth transport for the given client
func NewOAuthTransport(client *Client, base http.RoundTripper) *OAuthTransport {
return &OAuthTransport{
client: client,
base: base,
}
}
// generatePKCE generates PKCE code verifier and challenge
func generatePKCE() (verifier, challenge string, err error) {
b := make([]byte, 32)
if _, err = rand.Read(b); err != nil {
return
}
verifier = base64.RawURLEncoding.EncodeToString(b)
sum := sha256.Sum256([]byte(verifier))
challenge = base64.RawURLEncoding.EncodeToString(sum[:])
return
}
// openBrowser attempts to open the given URL in the default browser
func openBrowser(url string) {
commands := [][]string{{"xdg-open", url}, {"open", url}, {"cmd", "/c", "start", url}}
for _, cmd := range commands {
if exec.Command(cmd[0], cmd[1:]...).Start() == nil {
return
}
}
}
// RunOAuthFlow executes the complete OAuth authorization flow
func RunOAuthFlow(tokenIdentifier string) (token string, err error) {
// First check if we have an existing token that can be refreshed
storage, err := common.NewOAuthStorage()
if err == nil {
existingToken, err := storage.LoadToken(tokenIdentifier)
if err == nil && existingToken != nil {
// If token exists but is expired, try refreshing first
if existingToken.IsExpired(5) {
fmt.Println("Found expired OAuth token, attempting refresh...")
refreshedToken, refreshErr := RefreshToken(tokenIdentifier)
if refreshErr == nil {
fmt.Println("Token refresh successful")
return refreshedToken, nil
}
fmt.Printf("Token refresh failed (%v), proceeding with full OAuth flow...\n", refreshErr)
} else {
// Token exists and is still valid
return existingToken.AccessToken, nil
}
}
}
verifier, challenge, err := generatePKCE()
if err != nil {
return
}
cfg := oauth2.Config{
ClientID: oauthClientID,
Endpoint: oauth2.Endpoint{AuthURL: oauthAuthURL, TokenURL: oauthTokenURL},
RedirectURL: oauthRedirectURL,
Scopes: []string{"org:create_api_key", "user:profile", "user:inference"},
}
authURL := cfg.AuthCodeURL(verifier,
oauth2.SetAuthURLParam("code_challenge", challenge),
oauth2.SetAuthURLParam("code_challenge_method", "S256"),
oauth2.SetAuthURLParam("code", "true"),
oauth2.SetAuthURLParam("state", verifier),
)
fmt.Println("Open the following URL in your browser. Fabric would like to authorize:")
fmt.Println(authURL)
openBrowser(authURL)
fmt.Print("Paste the authorization code here: ")
var code string
fmt.Scanln(&code)
parts := strings.SplitN(code, "#", 2)
state := verifier
if len(parts) == 2 {
state = parts[1]
}
// Manual token exchange to match opencode implementation
tokenReq := map[string]string{
"code": parts[0],
"state": state,
"grant_type": "authorization_code",
"client_id": oauthClientID,
"redirect_uri": oauthRedirectURL,
"code_verifier": verifier,
}
token, err = exchangeToken(tokenIdentifier, tokenReq)
return
}
// exchangeToken exchanges authorization code for access token
func exchangeToken(tokenIdentifier string, params map[string]string) (token string, err error) {
reqBody, err := json.Marshal(params)
if err != nil {
return
}
resp, err := http.Post(oauthTokenURL, "application/json", bytes.NewBuffer(reqBody))
if err != nil {
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
err = fmt.Errorf("token exchange failed: %s - %s", resp.Status, string(body))
return
}
var result struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"`
TokenType string `json:"token_type"`
Scope string `json:"scope"`
}
if err = json.NewDecoder(resp.Body).Decode(&result); err != nil {
return
}
// Save the complete token information
storage, err := common.NewOAuthStorage()
if err != nil {
return result.AccessToken, fmt.Errorf("failed to create OAuth storage: %w", err)
}
oauthToken := &common.OAuthToken{
AccessToken: result.AccessToken,
RefreshToken: result.RefreshToken,
ExpiresAt: time.Now().Unix() + int64(result.ExpiresIn),
TokenType: result.TokenType,
Scope: result.Scope,
}
if err = storage.SaveToken(tokenIdentifier, oauthToken); err != nil {
return result.AccessToken, fmt.Errorf("failed to save OAuth token: %w", err)
}
token = result.AccessToken
return
}
// RefreshToken refreshes an expired OAuth token using the refresh token
func RefreshToken(tokenIdentifier string) (string, error) {
storage, err := common.NewOAuthStorage()
if err != nil {
return "", fmt.Errorf("failed to create OAuth storage: %w", err)
}
// Load existing token
token, err := storage.LoadToken(tokenIdentifier)
if err != nil {
return "", fmt.Errorf("failed to load stored token: %w", err)
}
if token == nil || token.RefreshToken == "" {
return "", fmt.Errorf("no refresh token available")
}
// Prepare refresh request
refreshReq := map[string]string{
"grant_type": "refresh_token",
"refresh_token": token.RefreshToken,
"client_id": oauthClientID,
}
reqBody, err := json.Marshal(refreshReq)
if err != nil {
return "", fmt.Errorf("failed to marshal refresh request: %w", err)
}
// Make refresh request
resp, err := http.Post(oauthTokenURL, "application/json", bytes.NewBuffer(reqBody))
if err != nil {
return "", fmt.Errorf("refresh request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("token refresh failed: %s - %s", resp.Status, string(body))
}
var result struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"`
TokenType string `json:"token_type"`
Scope string `json:"scope"`
}
if err = json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", fmt.Errorf("failed to parse refresh response: %w", err)
}
// Update stored token
newToken := &common.OAuthToken{
AccessToken: result.AccessToken,
RefreshToken: result.RefreshToken,
ExpiresAt: time.Now().Unix() + int64(result.ExpiresIn),
TokenType: result.TokenType,
Scope: result.Scope,
}
// Use existing refresh token if new one not provided
if newToken.RefreshToken == "" {
newToken.RefreshToken = token.RefreshToken
}
if err = storage.SaveToken(tokenIdentifier, newToken); err != nil {
return "", fmt.Errorf("failed to save refreshed token: %w", err)
}
return result.AccessToken, nil
}

View File

@@ -0,0 +1,434 @@
package anthropic
// OAuth Testing Strategy:
//
// This test suite covers OAuth functionality while avoiding real external calls.
// Key principles:
// 1. Never trigger real OAuth flows that would open browsers or call external APIs
// 2. Use temporary directories and mock tokens for isolated testing
// 3. Skip integration tests that would require real OAuth servers
// 4. Test error paths and edge cases safely
//
// Tests are categorized as:
// - Unit tests: Test individual functions with mocked data (SAFE)
// - Integration tests: Would require real OAuth servers (SKIPPED)
// - Error path tests: Test failure scenarios safely (SAFE)
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/danielmiessler/fabric/common"
)
// createTestToken creates a test OAuth token
func createTestToken(accessToken, refreshToken string, expiresIn int64) *common.OAuthToken {
return &common.OAuthToken{
AccessToken: accessToken,
RefreshToken: refreshToken,
ExpiresAt: time.Now().Unix() + expiresIn,
TokenType: "Bearer",
Scope: "org:create_api_key user:profile user:inference",
}
}
// createExpiredToken creates an expired test token
func createExpiredToken(accessToken, refreshToken string) *common.OAuthToken {
return &common.OAuthToken{
AccessToken: accessToken,
RefreshToken: refreshToken,
ExpiresAt: time.Now().Unix() - 3600, // Expired 1 hour ago
TokenType: "Bearer",
Scope: "org:create_api_key user:profile user:inference",
}
}
// mockTokenServer creates a mock OAuth token server for testing
func mockTokenServer(_ *testing.T, responses map[string]interface{}) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/oauth/token" {
http.NotFound(w, r)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read body", http.StatusBadRequest)
return
}
var req map[string]string
if err := json.Unmarshal(body, &req); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
grantType := req["grant_type"]
response, exists := responses[grantType]
if !exists {
http.Error(w, "Unsupported grant type", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
if errorResp, ok := response.(map[string]interface{}); ok && errorResp["error"] != nil {
w.WriteHeader(http.StatusBadRequest)
}
json.NewEncoder(w).Encode(response)
}))
}
func TestGeneratePKCE(t *testing.T) {
verifier, challenge, err := generatePKCE()
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if verifier == "" {
t.Error("Expected non-empty verifier")
}
if challenge == "" {
t.Error("Expected non-empty challenge")
}
if len(verifier) < 43 { // Base64 encoded 32 bytes should be at least 43 chars
t.Errorf("Verifier too short: %d chars", len(verifier))
}
if len(challenge) < 43 { // SHA256 hash should be at least 43 chars when base64 encoded
t.Errorf("Challenge too short: %d chars", len(challenge))
}
}
func TestExchangeToken_Success(t *testing.T) {
// Create mock server
server := mockTokenServer(t, map[string]interface{}{
"authorization_code": map[string]interface{}{
"access_token": "test_access_token",
"refresh_token": "test_refresh_token",
"expires_in": 3600,
"token_type": "Bearer",
"scope": "org:create_api_key user:profile user:inference",
},
})
defer server.Close()
// Create a temporary directory for token storage
tempDir := t.TempDir()
// Mock the storage creation to use our temp directory
originalHome := os.Getenv("HOME")
defer os.Setenv("HOME", originalHome)
// Set up a fake home directory
fakeHome := filepath.Join(tempDir, "home")
os.MkdirAll(filepath.Join(fakeHome, ".config", "fabric"), 0755)
os.Setenv("HOME", fakeHome)
// This test would need the actual exchangeToken function to be modified to accept a custom URL
// For now, we'll test the logic without the actual HTTP call
t.Skip("Skipping integration test - would need URL injection for proper testing")
}
func TestRefreshToken_Success(t *testing.T) {
// Create temporary directory and set up fake home
tempDir := t.TempDir()
fakeHome := filepath.Join(tempDir, "home")
configDir := filepath.Join(fakeHome, ".config", "fabric")
os.MkdirAll(configDir, 0755)
originalHome := os.Getenv("HOME")
defer os.Setenv("HOME", originalHome)
os.Setenv("HOME", fakeHome)
// Create an expired token
expiredToken := createExpiredToken("old_access_token", "valid_refresh_token")
// Save the expired token
tokenPath := filepath.Join(configDir, ".test_oauth")
data, _ := json.MarshalIndent(expiredToken, "", " ")
os.WriteFile(tokenPath, data, 0600)
// Create mock server for refresh
server := mockTokenServer(t, map[string]interface{}{
"refresh_token": map[string]interface{}{
"access_token": "new_access_token",
"refresh_token": "new_refresh_token",
"expires_in": 3600,
"token_type": "Bearer",
"scope": "org:create_api_key user:profile user:inference",
},
})
defer server.Close()
// This test would need the RefreshToken function to accept a custom URL
t.Skip("Skipping integration test - would need URL injection for proper testing")
}
func TestRefreshToken_NoRefreshToken(t *testing.T) {
// Create temporary directory and set up fake home
tempDir := t.TempDir()
fakeHome := filepath.Join(tempDir, "home")
configDir := filepath.Join(fakeHome, ".config", "fabric")
os.MkdirAll(configDir, 0755)
originalHome := os.Getenv("HOME")
defer os.Setenv("HOME", originalHome)
os.Setenv("HOME", fakeHome)
// Create a token without refresh token
tokenWithoutRefresh := &common.OAuthToken{
AccessToken: "access_token",
RefreshToken: "", // No refresh token
ExpiresAt: time.Now().Unix() - 3600,
TokenType: "Bearer",
Scope: "org:create_api_key user:profile user:inference",
}
// Save the token
tokenPath := filepath.Join(configDir, ".test_oauth")
data, _ := json.MarshalIndent(tokenWithoutRefresh, "", " ")
os.WriteFile(tokenPath, data, 0600)
// Test RefreshToken
_, err := RefreshToken("test")
if err == nil {
t.Error("Expected error when no refresh token available")
}
if !strings.Contains(err.Error(), "no refresh token available") {
t.Errorf("Expected 'no refresh token available' error, got: %v", err)
}
}
func TestRefreshToken_NoStoredToken(t *testing.T) {
// Create temporary directory and set up fake home
tempDir := t.TempDir()
fakeHome := filepath.Join(tempDir, "home")
configDir := filepath.Join(fakeHome, ".config", "fabric")
os.MkdirAll(configDir, 0755)
originalHome := os.Getenv("HOME")
defer os.Setenv("HOME", originalHome)
os.Setenv("HOME", fakeHome)
// Don't create any token file
// Test RefreshToken
_, err := RefreshToken("nonexistent")
if err == nil {
t.Error("Expected error when no token stored")
}
}
func TestOAuthTransport_RoundTrip(t *testing.T) {
// Create a mock client
client := &Client{}
// Create the transport
transport := NewOAuthTransport(client, http.DefaultTransport)
// Create a test request
req := httptest.NewRequest("GET", "https://api.anthropic.com/v1/messages", nil)
req.Header.Set("x-api-key", "should-be-removed")
// Create temporary directory and set up fake home with valid token
tempDir := t.TempDir()
fakeHome := filepath.Join(tempDir, "home")
configDir := filepath.Join(fakeHome, ".config", "fabric")
os.MkdirAll(configDir, 0755)
originalHome := os.Getenv("HOME")
defer os.Setenv("HOME", originalHome)
os.Setenv("HOME", fakeHome)
// Create a valid token
validToken := createTestToken("valid_access_token", "refresh_token", 3600)
tokenPath := filepath.Join(configDir, fmt.Sprintf(".%s_oauth", authTokenIdentifier))
data, _ := json.MarshalIndent(validToken, "", " ")
os.WriteFile(tokenPath, data, 0600)
// Create a mock server to handle the request
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Check that OAuth headers are set correctly
auth := r.Header.Get("Authorization")
if auth != "Bearer valid_access_token" {
t.Errorf("Expected 'Bearer valid_access_token', got '%s'", auth)
}
beta := r.Header.Get("anthropic-beta")
if beta != "oauth-2025-04-20" {
t.Errorf("Expected 'oauth-2025-04-20', got '%s'", beta)
}
userAgent := r.Header.Get("User-Agent")
if userAgent != "ai-sdk/anthropic" {
t.Errorf("Expected 'ai-sdk/anthropic', got '%s'", userAgent)
}
// Check that x-api-key header is removed
if r.Header.Get("x-api-key") != "" {
t.Error("Expected x-api-key header to be removed")
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("success"))
}))
defer server.Close()
// Update the request URL to point to our mock server
req.URL.Host = strings.TrimPrefix(server.URL, "http://")
req.URL.Scheme = "http"
// Execute the request
resp, err := transport.RoundTrip(req)
if err != nil {
t.Fatalf("RoundTrip failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("Expected status 200, got %d", resp.StatusCode)
}
}
func TestRunOAuthFlow_ExistingValidToken(t *testing.T) {
// Create temporary directory and set up fake home
tempDir := t.TempDir()
fakeHome := filepath.Join(tempDir, "home")
configDir := filepath.Join(fakeHome, ".config", "fabric")
os.MkdirAll(configDir, 0755)
originalHome := os.Getenv("HOME")
defer os.Setenv("HOME", originalHome)
os.Setenv("HOME", fakeHome)
// Create a valid token
validToken := createTestToken("existing_valid_token", "refresh_token", 3600)
tokenPath := filepath.Join(configDir, ".test_oauth")
data, _ := json.MarshalIndent(validToken, "", " ")
os.WriteFile(tokenPath, data, 0600)
// Test RunOAuthFlow - should return existing token without starting OAuth flow
token, err := RunOAuthFlow("test")
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if token != "existing_valid_token" {
t.Errorf("Expected 'existing_valid_token', got '%s'", token)
}
}
// Test helper functions
func TestCreateTestToken(t *testing.T) {
token := createTestToken("access", "refresh", 3600)
if token.AccessToken != "access" {
t.Errorf("Expected access token 'access', got '%s'", token.AccessToken)
}
if token.RefreshToken != "refresh" {
t.Errorf("Expected refresh token 'refresh', got '%s'", token.RefreshToken)
}
if token.IsExpired(5) {
t.Error("Expected token to not be expired")
}
}
func TestCreateExpiredToken(t *testing.T) {
token := createExpiredToken("access", "refresh")
if !token.IsExpired(5) {
t.Error("Expected token to be expired")
}
}
// TestTokenExpirationLogic tests the token expiration detection without OAuth flows
func TestTokenExpirationLogic(t *testing.T) {
// Test valid token
validToken := createTestToken("access", "refresh", 3600)
if validToken.IsExpired(5) {
t.Error("Valid token should not be expired")
}
// Test expired token
expiredToken := createExpiredToken("access", "refresh")
if !expiredToken.IsExpired(5) {
t.Error("Expired token should be expired")
}
// Test token expiring soon (within buffer)
soonExpiredToken := createTestToken("access", "refresh", 240) // 4 minutes
if !soonExpiredToken.IsExpired(5) { // 5 minute buffer
t.Error("Token expiring within buffer should be considered expired")
}
}
// TestGetValidTokenWithValidToken tests the getValidToken method with a valid token
func TestGetValidTokenWithValidToken(t *testing.T) {
// Create temporary directory and set up fake home
tempDir := t.TempDir()
fakeHome := filepath.Join(tempDir, "home")
configDir := filepath.Join(fakeHome, ".config", "fabric")
os.MkdirAll(configDir, 0755)
originalHome := os.Getenv("HOME")
defer os.Setenv("HOME", originalHome)
os.Setenv("HOME", fakeHome)
// Create a valid token
validToken := createTestToken("valid_access_token", "refresh_token", 3600)
tokenPath := filepath.Join(configDir, ".test_oauth")
data, _ := json.MarshalIndent(validToken, "", " ")
os.WriteFile(tokenPath, data, 0600)
// Create transport
client := &Client{}
transport := NewOAuthTransport(client, http.DefaultTransport)
// Test getValidToken - this should return the valid token without any OAuth flow
token, err := transport.getValidToken("test")
if err != nil {
t.Fatalf("Expected no error with valid token, got: %v", err)
}
if token != "valid_access_token" {
t.Errorf("Expected 'valid_access_token', got '%s'", token)
}
}
// Benchmark tests
func BenchmarkGeneratePKCE(b *testing.B) {
for i := 0; i < b.N; i++ {
_, _, err := generatePKCE()
if err != nil {
b.Fatal(err)
}
}
}
func BenchmarkTokenIsExpired(b *testing.B) {
token := createTestToken("access", "refresh", 3600)
b.ResetTimer()
for i := 0; i < b.N; i++ {
token.IsExpired(5)
}
}

View File

@@ -69,7 +69,9 @@ func (o *Client) buildChatCompletionParams(
if !opts.Raw {
ret.Temperature = openai.Float(opts.Temperature)
ret.TopP = openai.Float(opts.TopP)
if opts.TopP != 0 {
ret.TopP = openai.Float(opts.TopP)
}
if opts.MaxTokens != 0 {
ret.MaxTokens = openai.Int(int64(opts.MaxTokens))
}

View File

@@ -221,7 +221,9 @@ func (o *Client) buildResponseParams(
if !opts.Raw {
ret.Temperature = openai.Float(opts.Temperature)
ret.TopP = openai.Float(opts.TopP)
if opts.TopP != 0 {
ret.TopP = openai.Float(opts.TopP)
}
if opts.MaxTokens != 0 {
ret.MaxOutputTokens = openai.Int(int64(opts.MaxTokens))
}

View File

@@ -4,6 +4,7 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/joho/godotenv"
@@ -19,6 +20,7 @@ func NewDb(dir string) (db *Db) {
StorageEntity: &StorageEntity{Label: "Patterns", Dir: db.FilePath("patterns"), ItemIsDir: true},
SystemPatternFile: "system.md",
UniquePatternsFilePath: db.FilePath("unique_patterns.txt"),
CustomPatternsDir: "", // Will be set after loading .env file
}
db.Sessions = &SessionsEntity{
@@ -49,6 +51,18 @@ func (o *Db) Configure() (err error) {
return
}
// Set custom patterns directory after loading .env file
customPatternsDir := os.Getenv("CUSTOM_PATTERNS_DIRECTORY")
if customPatternsDir != "" {
// Expand home directory if needed
if strings.HasPrefix(customPatternsDir, "~/") {
if homeDir, err := os.UserHomeDir(); err == nil {
customPatternsDir = filepath.Join(homeDir, customPatternsDir[2:])
}
}
o.Patterns.CustomPatternsDir = customPatternsDir
}
if err = o.Patterns.Configure(); err != nil {
return
}

View File

@@ -4,6 +4,7 @@ import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"github.com/danielmiessler/fabric/common"
@@ -16,6 +17,7 @@ type PatternsEntity struct {
*StorageEntity
SystemPatternFile string
UniquePatternsFilePath string
CustomPatternsDir string
}
// Pattern represents a single pattern with its metadata
@@ -43,7 +45,7 @@ func (o *PatternsEntity) GetApplyVariables(
}
// Use the resolved absolute path to get the pattern
pattern, err = o.getFromFile(absPath)
pattern, _ = o.getFromFile(absPath)
} else {
// Otherwise, get the pattern from the database
pattern, err = o.getFromDB(source)
@@ -89,6 +91,19 @@ func (o *PatternsEntity) applyVariables(
// retrieves a pattern from the database by name
func (o *PatternsEntity) getFromDB(name string) (ret *Pattern, err error) {
// First check custom patterns directory if it exists
if o.CustomPatternsDir != "" {
customPatternPath := filepath.Join(o.CustomPatternsDir, name, o.SystemPatternFile)
if pattern, customErr := os.ReadFile(customPatternPath); customErr == nil {
ret = &Pattern{
Name: name,
Pattern: string(pattern),
}
return ret, nil
}
}
// Fallback to main patterns directory
patternPath := filepath.Join(o.Dir, name, o.SystemPatternFile)
var pattern []byte
@@ -145,6 +160,71 @@ func (o *PatternsEntity) getFromFile(pathStr string) (pattern *Pattern, err erro
return
}
// GetNames overrides StorageEntity.GetNames to include custom patterns directory
func (o *PatternsEntity) GetNames() (ret []string, err error) {
// Get names from main patterns directory
mainNames, err := o.StorageEntity.GetNames()
if err != nil {
return nil, err
}
// Create a map to track unique pattern names (custom patterns override main ones)
nameMap := make(map[string]bool)
for _, name := range mainNames {
nameMap[name] = true
}
// Get names from custom patterns directory if it exists
if o.CustomPatternsDir != "" {
// Create a temporary StorageEntity for the custom directory
customStorage := &StorageEntity{
Dir: o.CustomPatternsDir,
ItemIsDir: o.StorageEntity.ItemIsDir,
FileExtension: o.StorageEntity.FileExtension,
}
customNames, customErr := customStorage.GetNames()
if customErr == nil {
// Add custom patterns, they will override main patterns with same name
for _, name := range customNames {
nameMap[name] = true
}
}
// Ignore errors from custom directory (it might not exist)
}
// Convert map keys back to slice
ret = make([]string, 0, len(nameMap))
for name := range nameMap {
ret = append(ret, name)
}
// Sort the patterns alphabetically
sort.Strings(ret)
return ret, nil
}
// ListNames overrides StorageEntity.ListNames to use PatternsEntity.GetNames
func (o *PatternsEntity) ListNames(shellCompleteList bool) (err error) {
var names []string
if names, err = o.GetNames(); err != nil {
return
}
if len(names) == 0 {
if !shellCompleteList {
fmt.Printf("\nNo %v\n", o.StorageEntity.Label)
}
return
}
for _, item := range names {
fmt.Printf("%s\n", item)
}
return
}
// Get required for Storage interface
func (o *PatternsEntity) Get(name string) (*Pattern, error) {
// Use GetPattern with no variables

View File

@@ -162,3 +162,123 @@ func TestPatternsEntity_Save(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, content, data)
}
func TestPatternsEntity_CustomPatterns(t *testing.T) {
// Create main patterns directory
mainDir, err := os.MkdirTemp("", "test-main-patterns-*")
require.NoError(t, err)
defer os.RemoveAll(mainDir)
// Create custom patterns directory
customDir, err := os.MkdirTemp("", "test-custom-patterns-*")
require.NoError(t, err)
defer os.RemoveAll(customDir)
entity := &PatternsEntity{
StorageEntity: &StorageEntity{
Dir: mainDir,
Label: "patterns",
ItemIsDir: true,
},
SystemPatternFile: "system.md",
CustomPatternsDir: customDir,
}
// Create a pattern in main directory
createTestPattern(t, &PatternsEntity{
StorageEntity: &StorageEntity{
Dir: mainDir,
Label: "patterns",
ItemIsDir: true,
},
SystemPatternFile: "system.md",
}, "main-pattern", "Main pattern content")
// Create a pattern in custom directory
createTestPattern(t, &PatternsEntity{
StorageEntity: &StorageEntity{
Dir: customDir,
Label: "patterns",
ItemIsDir: true,
},
SystemPatternFile: "system.md",
}, "custom-pattern", "Custom pattern content")
// Create a pattern with same name in both directories (custom should override)
createTestPattern(t, &PatternsEntity{
StorageEntity: &StorageEntity{
Dir: mainDir,
Label: "patterns",
ItemIsDir: true,
},
SystemPatternFile: "system.md",
}, "shared-pattern", "Main shared pattern")
createTestPattern(t, &PatternsEntity{
StorageEntity: &StorageEntity{
Dir: customDir,
Label: "patterns",
ItemIsDir: true,
},
SystemPatternFile: "system.md",
}, "shared-pattern", "Custom shared pattern")
// Test GetNames includes both directories
names, err := entity.GetNames()
require.NoError(t, err)
assert.Contains(t, names, "main-pattern")
assert.Contains(t, names, "custom-pattern")
assert.Contains(t, names, "shared-pattern")
// Test that custom pattern overrides main pattern
pattern, err := entity.getFromDB("shared-pattern")
require.NoError(t, err)
assert.Equal(t, "Custom shared pattern", pattern.Pattern)
// Test that main pattern is accessible when not overridden
pattern, err = entity.getFromDB("main-pattern")
require.NoError(t, err)
assert.Equal(t, "Main pattern content", pattern.Pattern)
// Test that custom pattern is accessible
pattern, err = entity.getFromDB("custom-pattern")
require.NoError(t, err)
assert.Equal(t, "Custom pattern content", pattern.Pattern)
}
func TestPatternsEntity_CustomPatternsEmpty(t *testing.T) {
// Test behavior when custom patterns directory is empty or doesn't exist
mainDir, err := os.MkdirTemp("", "test-main-patterns-*")
require.NoError(t, err)
defer os.RemoveAll(mainDir)
entity := &PatternsEntity{
StorageEntity: &StorageEntity{
Dir: mainDir,
Label: "patterns",
ItemIsDir: true,
},
SystemPatternFile: "system.md",
CustomPatternsDir: "/nonexistent/directory",
}
// Create a pattern in main directory
createTestPattern(t, &PatternsEntity{
StorageEntity: &StorageEntity{
Dir: mainDir,
Label: "patterns",
ItemIsDir: true,
},
SystemPatternFile: "system.md",
}, "main-pattern", "Main pattern content")
// Test GetNames works even with nonexistent custom directory
names, err := entity.GetNames()
require.NoError(t, err)
assert.Contains(t, names, "main-pattern")
// Test that main pattern is accessible
pattern, err := entity.getFromDB("main-pattern")
require.NoError(t, err)
assert.Equal(t, "Main pattern content", pattern.Pattern)
}

View File

@@ -0,0 +1,66 @@
package custom_patterns
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/danielmiessler/fabric/plugins"
)
func NewCustomPatterns() (ret *CustomPatterns) {
label := "Custom Patterns"
ret = &CustomPatterns{}
ret.PluginBase = &plugins.PluginBase{
Name: label,
SetupDescription: "Custom Patterns - Set directory for your custom patterns (optional)",
EnvNamePrefix: plugins.BuildEnvVariablePrefix(label),
ConfigureCustom: ret.configure,
}
ret.CustomPatternsDir = ret.AddSetupQuestionCustom("Directory", false,
"Enter the path to your custom patterns directory (leave empty to skip)")
return
}
type CustomPatterns struct {
*plugins.PluginBase
CustomPatternsDir *plugins.SetupQuestion
}
func (o *CustomPatterns) configure() error {
if o.CustomPatternsDir.Value != "" {
// Expand home directory if needed
if strings.HasPrefix(o.CustomPatternsDir.Value, "~/") {
if homeDir, err := os.UserHomeDir(); err == nil {
o.CustomPatternsDir.Value = filepath.Join(homeDir, o.CustomPatternsDir.Value[2:])
}
}
// Convert to absolute path
if absPath, err := filepath.Abs(o.CustomPatternsDir.Value); err == nil {
o.CustomPatternsDir.Value = absPath
}
// Check if directory exists, create only if it doesn't
if _, err := os.Stat(o.CustomPatternsDir.Value); os.IsNotExist(err) {
if err := os.MkdirAll(o.CustomPatternsDir.Value, 0755); err != nil {
// Log the error but don't clear the value - let it persist in env file
fmt.Printf("Warning: Could not create custom patterns directory %s: %v\n", o.CustomPatternsDir.Value, err)
}
}
}
return nil
}
// IsConfigured returns true if a custom patterns directory has been set
func (o *CustomPatterns) IsConfigured() bool {
// First configure to load values from environment variables
o.Configure()
// Check if the plugin has been configured with a directory
return o.CustomPatternsDir.Value != ""
}

View File

@@ -0,0 +1,79 @@
package custom_patterns
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewCustomPatterns(t *testing.T) {
plugin := NewCustomPatterns()
assert.NotNil(t, plugin)
assert.Equal(t, "Custom Patterns", plugin.GetName())
assert.Equal(t, "Custom Patterns - Set directory for your custom patterns (optional)", plugin.GetSetupDescription())
assert.False(t, plugin.IsConfigured()) // Should not be configured initially
}
func TestCustomPatterns_Configure(t *testing.T) {
plugin := NewCustomPatterns()
// Test with empty directory (should work)
plugin.CustomPatternsDir.Value = ""
err := plugin.configure()
assert.NoError(t, err)
// Test with home directory expansion
plugin.CustomPatternsDir.Value = "~/test-patterns"
err = plugin.configure()
assert.NoError(t, err)
homeDir, _ := os.UserHomeDir()
expectedPath := filepath.Join(homeDir, "test-patterns")
absExpected, _ := filepath.Abs(expectedPath)
assert.Equal(t, absExpected, plugin.CustomPatternsDir.Value)
// Clean up
os.RemoveAll(plugin.CustomPatternsDir.Value)
}
func TestCustomPatterns_ConfigureWithTempDir(t *testing.T) {
plugin := NewCustomPatterns()
// Test with a temporary directory
tmpDir, err := os.MkdirTemp("", "test-custom-patterns-*")
require.NoError(t, err)
defer os.RemoveAll(tmpDir)
plugin.CustomPatternsDir.Value = tmpDir
err = plugin.configure()
assert.NoError(t, err)
absPath, _ := filepath.Abs(tmpDir)
assert.Equal(t, absPath, plugin.CustomPatternsDir.Value)
// Verify directory exists
info, err := os.Stat(plugin.CustomPatternsDir.Value)
assert.NoError(t, err)
assert.True(t, info.IsDir())
// Should be configured now
assert.True(t, plugin.IsConfigured())
}
func TestCustomPatterns_IsConfigured(t *testing.T) {
plugin := NewCustomPatterns()
// Initially not configured
assert.False(t, plugin.IsConfigured())
// Set a directory
plugin.CustomPatternsDir.Value = "/some/path"
assert.True(t, plugin.IsConfigured())
// Clear the directory
plugin.CustomPatternsDir.Value = ""
assert.False(t, plugin.IsConfigured())
}

View File

@@ -57,17 +57,18 @@ func (h *ConfigHandler) GetConfig(c *gin.Context) {
}
config := map[string]string{
"openai": os.Getenv("OPENAI_API_KEY"),
"anthropic": os.Getenv("ANTHROPIC_API_KEY"),
"groq": os.Getenv("GROQ_API_KEY"),
"mistral": os.Getenv("MISTRAL_API_KEY"),
"gemini": os.Getenv("GEMINI_API_KEY"),
"ollama": os.Getenv("OLLAMA_URL"),
"openrouter": os.Getenv("OPENROUTER_API_KEY"),
"silicon": os.Getenv("SILICON_API_KEY"),
"deepseek": os.Getenv("DEEPSEEK_API_KEY"),
"grokai": os.Getenv("GROKAI_API_KEY"),
"lmstudio": os.Getenv("LM_STUDIO_API_BASE_URL"),
"openai": os.Getenv("OPENAI_API_KEY"),
"anthropic": os.Getenv("ANTHROPIC_API_KEY"),
"anthropic_use_oauth_login": os.Getenv("ANTHROPIC_USE_OAUTH_LOGIN"),
"groq": os.Getenv("GROQ_API_KEY"),
"mistral": os.Getenv("MISTRAL_API_KEY"),
"gemini": os.Getenv("GEMINI_API_KEY"),
"ollama": os.Getenv("OLLAMA_URL"),
"openrouter": os.Getenv("OPENROUTER_API_KEY"),
"silicon": os.Getenv("SILICON_API_KEY"),
"deepseek": os.Getenv("DEEPSEEK_API_KEY"),
"grokai": os.Getenv("GROKAI_API_KEY"),
"lmstudio": os.Getenv("LM_STUDIO_API_BASE_URL"),
}
c.JSON(http.StatusOK, config)
@@ -80,17 +81,18 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) {
}
var config struct {
OpenAIApiKey string `json:"openai_api_key"`
AnthropicApiKey string `json:"anthropic_api_key"`
GroqApiKey string `json:"groq_api_key"`
MistralApiKey string `json:"mistral_api_key"`
GeminiApiKey string `json:"gemini_api_key"`
OllamaURL string `json:"ollama_url"`
OpenRouterApiKey string `json:"openrouter_api_key"`
SiliconApiKey string `json:"silicon_api_key"`
DeepSeekApiKey string `json:"deepseek_api_key"`
GrokaiApiKey string `json:"grokai_api_key"`
LMStudioURL string `json:"lm_studio_base_url"`
OpenAIApiKey string `json:"openai_api_key"`
AnthropicApiKey string `json:"anthropic_api_key"`
AnthropicUseAuthToken string `json:"anthropic_use_auth_token"`
GroqApiKey string `json:"groq_api_key"`
MistralApiKey string `json:"mistral_api_key"`
GeminiApiKey string `json:"gemini_api_key"`
OllamaURL string `json:"ollama_url"`
OpenRouterApiKey string `json:"openrouter_api_key"`
SiliconApiKey string `json:"silicon_api_key"`
DeepSeekApiKey string `json:"deepseek_api_key"`
GrokaiApiKey string `json:"grokai_api_key"`
LMStudioURL string `json:"lm_studio_base_url"`
}
if err := c.ShouldBindJSON(&config); err != nil {
@@ -99,17 +101,18 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) {
}
envVars := map[string]string{
"OPENAI_API_KEY": config.OpenAIApiKey,
"ANTHROPIC_API_KEY": config.AnthropicApiKey,
"GROQ_API_KEY": config.GroqApiKey,
"MISTRAL_API_KEY": config.MistralApiKey,
"GEMINI_API_KEY": config.GeminiApiKey,
"OLLAMA_URL": config.OllamaURL,
"OPENROUTER_API_KEY": config.OpenRouterApiKey,
"SILICON_API_KEY": config.SiliconApiKey,
"DEEPSEEK_API_KEY": config.DeepSeekApiKey,
"GROKAI_API_KEY": config.GrokaiApiKey,
"LM_STUDIO_API_BASE_URL": config.LMStudioURL,
"OPENAI_API_KEY": config.OpenAIApiKey,
"ANTHROPIC_API_KEY": config.AnthropicApiKey,
"ANTHROPIC_USE_OAUTH_LOGIN": config.AnthropicUseAuthToken,
"GROQ_API_KEY": config.GroqApiKey,
"MISTRAL_API_KEY": config.MistralApiKey,
"GEMINI_API_KEY": config.GeminiApiKey,
"OLLAMA_URL": config.OllamaURL,
"OPENROUTER_API_KEY": config.OpenRouterApiKey,
"SILICON_API_KEY": config.SiliconApiKey,
"DEEPSEEK_API_KEY": config.DeepSeekApiKey,
"GROKAI_API_KEY": config.GrokaiApiKey,
"LM_STUDIO_API_BASE_URL": config.LMStudioURL,
}
var envContent strings.Builder

View File

@@ -1,3 +1,3 @@
package main
var version = "v1.4.230"
var version = "v1.4.240"