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) - Falls back to restrictive defaults (block all network, default command deny list)
- Validates paths and normalizes them - Validates paths and normalizes them
@@ -245,7 +245,7 @@ Flow:
```mermaid ```mermaid
flowchart TD 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"] B --> C["3. Create Manager"]
C --> D["4. Manager.Initialize()"] C --> D["4. Manager.Initialize()"]

View File

@@ -80,7 +80,7 @@ fence --help
### Configuration ### 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 ```json
{ {
@@ -96,7 +96,7 @@ Use `fence --settings ./custom.json` to specify a different config.
### Import from Claude Code ### Import from Claude Code
```bash ```bash
fence import --claude -o ~/.fence.json fence import --claude --save
``` ```
## Features ## Features

View File

@@ -2,6 +2,7 @@
package main package main
import ( import (
"bufio"
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
@@ -55,8 +56,8 @@ func main() {
with network and filesystem restrictions. with network and filesystem restrictions.
By default, all network access is blocked. Configure allowed domains in 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 ~/.config/fence/fence.json (or ~/Library/Application Support/fence/fence.json on macOS)
template with --template. or pass a settings file with --settings, or use a built-in template with --template.
Examples: Examples:
fence curl https://example.com # Will be blocked (no domains allowed) 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 -p 3000 -c "npm run dev" # Expose port 3000 for inbound connections
fence --list-templates # Show available built-in templates fence --list-templates # Show available built-in templates
Configuration file format (~/.fence.json): Configuration file format:
{ {
"network": { "network": {
"allowedDomains": ["github.com", "*.npmjs.org"], "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(&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().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().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().BoolVar(&listTemplates, "list-templates", false, "List available templates")
rootCmd.Flags().StringVarP(&cmdString, "c", "c", "", "Run command string directly (like sh -c)") rootCmd.Flags().StringVarP(&cmdString, "c", "c", "", "Run command string directly (like sh -c)")
@@ -303,6 +304,8 @@ func newImportCmd() *cobra.Command {
claudeMode bool claudeMode bool
inputFile string inputFile string
outputFile string outputFile string
saveFlag bool
forceFlag bool
extendTmpl string extendTmpl string
noExtend bool 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. Use --no-extend for a minimal config, or --extend to choose a different template.
Examples: Examples:
# Import from default Claude Code settings (~/.claude/settings.json) # Preview import (prints JSON to stdout)
fence import --claude fence import --claude
# Import from a specific Claude Code settings file # Save to the default config path
fence import --claude -f ~/.claude/settings.json # Linux: ~/.config/fence/fence.json
# macOS: ~/Library/Application Support/fence/fence.json
fence import --claude --save
# Import and write to a specific output file # Save to a specific output file
fence import --claude -o .fence.json 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) # Import without extending any template (minimal config)
fence import --claude --no-extend fence import --claude --no-extend --save
# Import and extend a different template # Import and extend a different template
fence import --claude --extend local-dev-server fence import --claude --extend local-dev-server --save`,
# Import from project-level Claude settings
fence import --claude -f .claude/settings.local.json -o .fence.json`,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
if !claudeMode { if !claudeMode {
return fmt.Errorf("no import source specified. Use --claude to import from Claude Code") return fmt.Errorf("no import source specified. Use --claude to import from Claude Code")
@@ -357,13 +362,41 @@ Examples:
for _, warning := range result.Warnings { for _, warning := range result.Warnings {
fmt.Fprintf(os.Stderr, "Warning: %s\n", warning) fmt.Fprintf(os.Stderr, "Warning: %s\n", warning)
} }
if len(result.Warnings) > 0 {
fmt.Fprintln(os.Stderr)
}
if outputFile != "" { // Determine output destination
if err := importer.WriteConfig(result.Config, outputFile); err != nil { 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 return err
} }
fmt.Printf("Imported %d rules from %s\n", result.RulesImported, result.SourcePath) 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 { } else {
// Print clean JSON to stdout, helpful info to stderr (don't interfere with piping) // Print clean JSON to stdout, helpful info to stderr (don't interfere with piping)
data, err := importer.MarshalConfigJSON(result.Config) 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, "\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, "# 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 return nil
@@ -384,10 +417,13 @@ Examples:
cmd.Flags().BoolVar(&claudeMode, "claude", false, "Import from Claude Code settings") 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(&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().StringVar(&extendTmpl, "extend", "", "Template to extend (default: code)")
cmd.Flags().BoolVar(&noExtend, "no-extend", false, "Don't extend any template (minimal config)") cmd.Flags().BoolVar(&noExtend, "no-extend", false, "Don't extend any template (minimal config)")
cmd.MarkFlagsMutuallyExclusive("extend", "no-extend") cmd.MarkFlagsMutuallyExclusive("extend", "no-extend")
cmd.MarkFlagsMutuallyExclusive("save", "output")
return cmd return cmd
} }

View File

@@ -1,6 +1,6 @@
# Configuration # 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: 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: If you've been using Claude Code and have already built up permission rules, you can import them into fence:
```bash ```bash
# Import from default Claude Code settings (~/.claude/settings.json) # Preview import (prints JSON to stdout)
fence import --claude fence import --claude
# Import from a specific file # Save to the default config path
fence import --claude -f ~/.claude/settings.json fence import --claude --save
# Import and write to a specific output file # Import from a specific file
fence import --claude -o .fence.json 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) # Import without extending any template (minimal config)
fence import --claude --no-extend fence import --claude --no-extend --save
# Import and extend a different template # Import and extend a different template
fence import --claude --extend local-dev-server fence import --claude --extend local-dev-server --save
# Import from project-level Claude settings
fence import --claude -f .claude/settings.local.json -o .fence.json
``` ```
### Default Template ### Default Template

View File

@@ -83,7 +83,7 @@ cfg.Network.AllowedDomains = []string{"example.com"}
Loads configuration from a JSON file. Supports JSONC (comments allowed). Loads configuration from a JSON file. Supports JSONC (comments allowed).
```go ```go
cfg, err := fence.LoadConfig("~/.fence.json") cfg, err := fence.LoadConfig(fence.DefaultConfigPath())
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
@@ -94,7 +94,7 @@ if cfg == nil {
#### `DefaultConfigPath() string` #### `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` #### `NewManager(cfg *Config, debug, monitor bool) *Manager`

View File

@@ -66,7 +66,7 @@ curl: (56) CONNECT tunnel failed, response 403
## Allow Specific Domains ## 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 ```json
{ {

View File

@@ -133,12 +133,38 @@ func Default() *Config {
} }
// DefaultConfigPath returns the default config file path. // 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 { 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() home, err := os.UserHomeDir()
if err != nil { 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. // Load loads configuration from a file path.

View File

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