Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,19 @@ type pageAPI interface {
### Integration Tests
After significant code changes, run through the manual integration test suite in [integration-tests.md](integration-tests.md). These tests verify real-world behavior against a live Confluence instance and catch edge cases that unit tests miss.

## Environment Variables

Variables are checked in precedence order (first match wins):

| Setting | Precedence |
|---------|------------|
| URL | `CFL_URL` → `ATLASSIAN_URL` → config |
| Email | `CFL_EMAIL` → `ATLASSIAN_EMAIL` → config |
| API Token | `CFL_API_TOKEN` → `ATLASSIAN_API_TOKEN` → config |
| Default Space | `CFL_DEFAULT_SPACE` → config |

Use `ATLASSIAN_*` for shared credentials across cfl and jtk. Use `CFL_*` to override per-tool.

## Undocumented Constants

| Constant | Value | Location |
Expand Down
32 changes: 24 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -651,14 +651,30 @@ output_format: table

### Environment Variables

Environment variables override config file values:

| Variable | Description |
|----------|-------------|
| `CFL_URL` | Confluence instance URL |
| `CFL_EMAIL` | Your Atlassian email |
| `CFL_API_TOKEN` | Your API token |
| `CFL_DEFAULT_SPACE` | Default space key |
Environment variables override config file values. Variables are checked in order of precedence (first match wins):

| Setting | Precedence (highest to lowest) |
|---------|-------------------------------|
| URL | `CFL_URL` → `ATLASSIAN_URL` → config file |
| Email | `CFL_EMAIL` → `ATLASSIAN_EMAIL` → config file |
| API Token | `CFL_API_TOKEN` → `ATLASSIAN_API_TOKEN` → config file |
| Default Space | `CFL_DEFAULT_SPACE` → config file |

**Shared credentials:** If you use both `cfl` and `jtk` (Jira CLI), set `ATLASSIAN_*` variables once:

```bash
export ATLASSIAN_URL=https://mycompany.atlassian.net
export ATLASSIAN_EMAIL=user@example.com
export ATLASSIAN_API_TOKEN=your-api-token
```

**Per-tool override:** Use `CFL_*` to override for Confluence specifically:

```bash
export ATLASSIAN_EMAIL=user@example.com
export ATLASSIAN_API_TOKEN=your-api-token
export CFL_URL=https://confluence.internal.corp.com # Different URL for Confluence
```

---

Expand Down
15 changes: 12 additions & 3 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,21 +50,30 @@ func (c *Config) NormalizeURL() {

// LoadFromEnv loads configuration from environment variables.
// Environment variables override existing values only if set and non-empty.
// Precedence: CFL_* → ATLASSIAN_* → existing config value
func (c *Config) LoadFromEnv() {
if url := os.Getenv("CFL_URL"); url != "" {
if url := getEnvWithFallback("CFL_URL", "ATLASSIAN_URL"); url != "" {
c.URL = url
}
if email := os.Getenv("CFL_EMAIL"); email != "" {
if email := getEnvWithFallback("CFL_EMAIL", "ATLASSIAN_EMAIL"); email != "" {
c.Email = email
}
if token := os.Getenv("CFL_API_TOKEN"); token != "" {
if token := getEnvWithFallback("CFL_API_TOKEN", "ATLASSIAN_API_TOKEN"); token != "" {
c.APIToken = token
}
if space := os.Getenv("CFL_DEFAULT_SPACE"); space != "" {
c.DefaultSpace = space
}
}

// getEnvWithFallback returns the value of the primary env var, or the fallback if primary is empty.
func getEnvWithFallback(primary, fallback string) string {
if v := os.Getenv(primary); v != "" {
return v
}
return os.Getenv(fallback)
}

// DefaultConfigPath returns the default configuration file path.
func DefaultConfigPath() string {
// Try XDG config directory first
Expand Down
91 changes: 91 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -208,3 +208,94 @@ func TestLoad_FileNotFound(t *testing.T) {
_, err := Load("/nonexistent/path/config.yml")
require.Error(t, err)
}

func TestConfig_LoadFromEnv_AtlassianFallback(t *testing.T) {
// Clear all relevant env vars
clearEnvVars := func() {
os.Unsetenv("CFL_URL")
os.Unsetenv("CFL_EMAIL")
os.Unsetenv("CFL_API_TOKEN")
os.Unsetenv("ATLASSIAN_URL")
os.Unsetenv("ATLASSIAN_EMAIL")
os.Unsetenv("ATLASSIAN_API_TOKEN")
}

t.Run("ATLASSIAN_* used when CFL_* not set", func(t *testing.T) {
clearEnvVars()
defer clearEnvVars()

os.Setenv("ATLASSIAN_URL", "https://shared.atlassian.net")
os.Setenv("ATLASSIAN_EMAIL", "shared@example.com")
os.Setenv("ATLASSIAN_API_TOKEN", "shared-token")

cfg := &Config{}
cfg.LoadFromEnv()

assert.Equal(t, "https://shared.atlassian.net", cfg.URL)
assert.Equal(t, "shared@example.com", cfg.Email)
assert.Equal(t, "shared-token", cfg.APIToken)
})

t.Run("CFL_* takes precedence over ATLASSIAN_*", func(t *testing.T) {
clearEnvVars()
defer clearEnvVars()

os.Setenv("CFL_URL", "https://cfl.atlassian.net")
os.Setenv("CFL_EMAIL", "cfl@example.com")
os.Setenv("CFL_API_TOKEN", "cfl-token")
os.Setenv("ATLASSIAN_URL", "https://shared.atlassian.net")
os.Setenv("ATLASSIAN_EMAIL", "shared@example.com")
os.Setenv("ATLASSIAN_API_TOKEN", "shared-token")

cfg := &Config{}
cfg.LoadFromEnv()

assert.Equal(t, "https://cfl.atlassian.net", cfg.URL)
assert.Equal(t, "cfl@example.com", cfg.Email)
assert.Equal(t, "cfl-token", cfg.APIToken)
})

t.Run("mixed CFL_* and ATLASSIAN_*", func(t *testing.T) {
clearEnvVars()
defer clearEnvVars()

// Only URL is CFL-specific, rest use shared
os.Setenv("CFL_URL", "https://cfl.atlassian.net")
os.Setenv("ATLASSIAN_EMAIL", "shared@example.com")
os.Setenv("ATLASSIAN_API_TOKEN", "shared-token")

cfg := &Config{}
cfg.LoadFromEnv()

assert.Equal(t, "https://cfl.atlassian.net", cfg.URL)
assert.Equal(t, "shared@example.com", cfg.Email)
assert.Equal(t, "shared-token", cfg.APIToken)
})
}

func TestGetEnvWithFallback(t *testing.T) {
os.Unsetenv("TEST_PRIMARY")
os.Unsetenv("TEST_FALLBACK")
defer func() {
os.Unsetenv("TEST_PRIMARY")
os.Unsetenv("TEST_FALLBACK")
}()

t.Run("returns primary when set", func(t *testing.T) {
os.Setenv("TEST_PRIMARY", "primary-value")
os.Setenv("TEST_FALLBACK", "fallback-value")
assert.Equal(t, "primary-value", getEnvWithFallback("TEST_PRIMARY", "TEST_FALLBACK"))
})

t.Run("returns fallback when primary empty", func(t *testing.T) {
os.Unsetenv("TEST_PRIMARY")
os.Setenv("TEST_FALLBACK", "fallback-value")
assert.Equal(t, "fallback-value", getEnvWithFallback("TEST_PRIMARY", "TEST_FALLBACK"))
})

t.Run("returns empty when both empty", func(t *testing.T) {
os.Unsetenv("TEST_PRIMARY")
os.Unsetenv("TEST_FALLBACK")
assert.Equal(t, "", getEnvWithFallback("TEST_PRIMARY", "TEST_FALLBACK"))
})
}
Loading