feat: use OS-preferred config directory (#26)

This commit is contained in:
JY Tan
2026-02-01 16:17:33 -08:00
committed by GitHub
parent 7679fecf06
commit c8621e8f6c
8 changed files with 105 additions and 42 deletions

View File

@@ -71,7 +71,7 @@ type Config struct {
}
```
- Loads from `~/.fence.json` or custom path
- Loads from XDG config dir (`~/.config/fence/fence.json` or `~/Library/Application Support/fence/fence.json`) or custom path
- Falls back to restrictive defaults (block all network, default command deny list)
- Validates paths and normalizes them
@@ -245,7 +245,7 @@ Flow:
```mermaid
flowchart TD
A["1. CLI parses arguments"] --> B["2. Load config from ~/.fence.json"]
A["1. CLI parses arguments"] --> B["2. Load config from XDG config dir"]
B --> C["3. Create Manager"]
C --> D["4. Manager.Initialize()"]

View File

@@ -80,7 +80,7 @@ fence --help
### Configuration
Fence reads from `~/.fence.json` by default:
Fence reads from `~/.config/fence/fence.json` by default (or `~/Library/Application Support/fence/fence.json` on macOS).
```json
{
@@ -96,7 +96,7 @@ Use `fence --settings ./custom.json` to specify a different config.
### Import from Claude Code
```bash
fence import --claude -o ~/.fence.json
fence import --claude --save
```
## Features

View File

@@ -2,6 +2,7 @@
package main
import (
"bufio"
"encoding/json"
"fmt"
"os"
@@ -55,8 +56,8 @@ func main() {
with network and filesystem restrictions.
By default, all network access is blocked. Configure allowed domains in
~/.fence.json or pass a settings file with --settings, or use a built-in
template with --template.
~/.config/fence/fence.json (or ~/Library/Application Support/fence/fence.json on macOS)
or pass a settings file with --settings, or use a built-in template with --template.
Examples:
fence curl https://example.com # Will be blocked (no domains allowed)
@@ -68,7 +69,7 @@ Examples:
fence -p 3000 -c "npm run dev" # Expose port 3000 for inbound connections
fence --list-templates # Show available built-in templates
Configuration file format (~/.fence.json):
Configuration file format:
{
"network": {
"allowedDomains": ["github.com", "*.npmjs.org"],
@@ -91,7 +92,7 @@ Configuration file format (~/.fence.json):
rootCmd.Flags().BoolVarP(&debug, "debug", "d", false, "Enable debug logging")
rootCmd.Flags().BoolVarP(&monitor, "monitor", "m", false, "Monitor and log sandbox violations (macOS: log stream, all: proxy denials)")
rootCmd.Flags().StringVarP(&settingsPath, "settings", "s", "", "Path to settings file (default: ~/.fence.json)")
rootCmd.Flags().StringVarP(&settingsPath, "settings", "s", "", "Path to settings file (default: OS config directory)")
rootCmd.Flags().StringVarP(&templateName, "template", "t", "", "Use built-in template (e.g., ai-coding-agents, npm-install)")
rootCmd.Flags().BoolVar(&listTemplates, "list-templates", false, "List available templates")
rootCmd.Flags().StringVarP(&cmdString, "c", "c", "", "Run command string directly (like sh -c)")
@@ -303,6 +304,8 @@ func newImportCmd() *cobra.Command {
claudeMode bool
inputFile string
outputFile string
saveFlag bool
forceFlag bool
extendTmpl string
noExtend bool
)
@@ -320,23 +323,25 @@ for network access (npm, GitHub, LLM providers) and filesystem protections.
Use --no-extend for a minimal config, or --extend to choose a different template.
Examples:
# Import from default Claude Code settings (~/.claude/settings.json)
# Preview import (prints JSON to stdout)
fence import --claude
# Import from a specific Claude Code settings file
fence import --claude -f ~/.claude/settings.json
# Save to the default config path
# Linux: ~/.config/fence/fence.json
# macOS: ~/Library/Application Support/fence/fence.json
fence import --claude --save
# Import and write to a specific output file
fence import --claude -o .fence.json
# Save to a specific output file
fence import --claude -o ./fence.json
# Import from a specific Claude Code settings file
fence import --claude -f ~/.claude/settings.json --save
# Import without extending any template (minimal config)
fence import --claude --no-extend
fence import --claude --no-extend --save
# Import and extend a different template
fence import --claude --extend local-dev-server
# Import from project-level Claude settings
fence import --claude -f .claude/settings.local.json -o .fence.json`,
fence import --claude --extend local-dev-server --save`,
RunE: func(cmd *cobra.Command, args []string) error {
if !claudeMode {
return fmt.Errorf("no import source specified. Use --claude to import from Claude Code")
@@ -357,13 +362,41 @@ Examples:
for _, warning := range result.Warnings {
fmt.Fprintf(os.Stderr, "Warning: %s\n", warning)
}
if len(result.Warnings) > 0 {
fmt.Fprintln(os.Stderr)
}
if outputFile != "" {
if err := importer.WriteConfig(result.Config, outputFile); err != nil {
// Determine output destination
var destPath string
if saveFlag {
destPath = config.DefaultConfigPath()
} else if outputFile != "" {
destPath = outputFile
}
if destPath != "" {
if !forceFlag {
if _, err := os.Stat(destPath); err == nil {
fmt.Printf("File %q already exists. Overwrite? [y/N] ", destPath)
reader := bufio.NewReader(os.Stdin)
response, _ := reader.ReadString('\n')
response = strings.TrimSpace(strings.ToLower(response))
if response != "y" && response != "yes" {
fmt.Println("Aborted.")
return nil
}
}
}
if err := os.MkdirAll(filepath.Dir(destPath), 0o750); err != nil {
return fmt.Errorf("failed to create config directory: %w", err)
}
if err := importer.WriteConfig(result.Config, destPath); err != nil {
return err
}
fmt.Printf("Imported %d rules from %s\n", result.RulesImported, result.SourcePath)
fmt.Printf("Written to %s\n", outputFile)
fmt.Printf("Written to %q\n", destPath)
} else {
// Print clean JSON to stdout, helpful info to stderr (don't interfere with piping)
data, err := importer.MarshalConfigJSON(result.Config)
@@ -375,7 +408,7 @@ Examples:
fmt.Fprintf(os.Stderr, "\n# Extends %q - inherited rules not shown\n", result.Config.Extends)
}
fmt.Fprintf(os.Stderr, "# Imported %d rules from %s\n", result.RulesImported, result.SourcePath)
fmt.Fprintf(os.Stderr, "# Use -o <file> to write to a file (includes comments)\n")
fmt.Fprintf(os.Stderr, "# Use --save to write to the default config path\n")
}
return nil
@@ -384,10 +417,13 @@ Examples:
cmd.Flags().BoolVar(&claudeMode, "claude", false, "Import from Claude Code settings")
cmd.Flags().StringVarP(&inputFile, "file", "f", "", "Path to settings file (default: ~/.claude/settings.json for --claude)")
cmd.Flags().StringVarP(&outputFile, "output", "o", "", "Output file path (default: stdout)")
cmd.Flags().StringVarP(&outputFile, "output", "o", "", "Output file path")
cmd.Flags().BoolVar(&saveFlag, "save", false, "Save to the default config path")
cmd.Flags().BoolVarP(&forceFlag, "force", "y", false, "Overwrite existing file without prompting")
cmd.Flags().StringVar(&extendTmpl, "extend", "", "Template to extend (default: code)")
cmd.Flags().BoolVar(&noExtend, "no-extend", false, "Don't extend any template (minimal config)")
cmd.MarkFlagsMutuallyExclusive("extend", "no-extend")
cmd.MarkFlagsMutuallyExclusive("save", "output")
return cmd
}

View File

@@ -1,6 +1,6 @@
# Configuration
Fence reads settings from `~/.fence.json` by default (or pass `--settings ./fence.json`). Config files support JSONC.
Fence reads settings from `~/.config/fence/fence.json` by default (or `~/Library/Application Support/fence/fence.json` on macOS). Legacy `~/.fence.json` is also supported. Pass `--settings ./fence.json` to use a custom path. Config files support JSONC.
Example config:
@@ -263,23 +263,23 @@ SSH host patterns support wildcards anywhere:
If you've been using Claude Code and have already built up permission rules, you can import them into fence:
```bash
# Import from default Claude Code settings (~/.claude/settings.json)
# Preview import (prints JSON to stdout)
fence import --claude
# Import from a specific file
fence import --claude -f ~/.claude/settings.json
# Save to the default config path
fence import --claude --save
# Import and write to a specific output file
fence import --claude -o .fence.json
# Import from a specific file
fence import --claude -f ~/.claude/settings.json --save
# Save to a specific output file
fence import --claude -o ./fence.json
# Import without extending any template (minimal config)
fence import --claude --no-extend
fence import --claude --no-extend --save
# Import and extend a different template
fence import --claude --extend local-dev-server
# Import from project-level Claude settings
fence import --claude -f .claude/settings.local.json -o .fence.json
fence import --claude --extend local-dev-server --save
```
### Default Template

View File

@@ -83,7 +83,7 @@ cfg.Network.AllowedDomains = []string{"example.com"}
Loads configuration from a JSON file. Supports JSONC (comments allowed).
```go
cfg, err := fence.LoadConfig("~/.fence.json")
cfg, err := fence.LoadConfig(fence.DefaultConfigPath())
if err != nil {
log.Fatal(err)
}
@@ -94,7 +94,7 @@ if cfg == nil {
#### `DefaultConfigPath() string`
Returns the default config file path (`~/.fence.json`).
Returns the default config file path (`~/.config/fence/fence.json` on Linux, `~/Library/Application Support/fence/fence.json` on macOS, with fallback to legacy `~/.fence.json`).
#### `NewManager(cfg *Config, debug, monitor bool) *Manager`

View File

@@ -66,7 +66,7 @@ curl: (56) CONNECT tunnel failed, response 403
## Allow Specific Domains
Create a config file at `~/.fence.json`:
Create a config file at `~/.config/fence/fence.json` (or `~/Library/Application Support/fence/fence.json` on macOS):
```json
{

View File

@@ -133,12 +133,38 @@ func Default() *Config {
}
// DefaultConfigPath returns the default config file path.
// Uses the OS-preferred config directory (XDG on Linux, ~/Library/Application Support on macOS).
// Falls back to ~/.fence.json if the new location doesn't exist but the legacy one does.
func DefaultConfigPath() string {
// Try OS-preferred config directory first
configDir, err := os.UserConfigDir()
if err == nil {
newPath := filepath.Join(configDir, "fence", "fence.json")
if _, err := os.Stat(newPath); err == nil {
return newPath
}
// Check if parent directory exists (user has set up the new location)
// If so, prefer this even if config doesn't exist yet
if _, err := os.Stat(filepath.Dir(newPath)); err == nil {
return newPath
}
}
// Fall back to legacy path if it exists
home, err := os.UserHomeDir()
if err != nil {
return ".fence.json"
return "fence.json"
}
return filepath.Join(home, ".fence.json")
legacyPath := filepath.Join(home, ".fence.json")
if _, err := os.Stat(legacyPath); err == nil {
return legacyPath
}
// Neither exists, prefer new XDG-compliant path
if configDir != "" {
return filepath.Join(configDir, "fence", "fence.json")
}
return filepath.Join(home, ".config", "fence", "fence.json")
}
// Load loads configuration from a file path.

View File

@@ -295,9 +295,10 @@ func TestDefaultConfigPath(t *testing.T) {
if path == "" {
t.Error("DefaultConfigPath() returned empty string")
}
// Should end with .fence.json
if filepath.Base(path) != ".fence.json" {
t.Errorf("DefaultConfigPath() = %q, expected to end with .fence.json", path)
// Should end with fence.json (either new XDG path or legacy .fence.json)
base := filepath.Base(path)
if base != "fence.json" && base != ".fence.json" {
t.Errorf("DefaultConfigPath() = %q, expected to end with fence.json or .fence.json", path)
}
}