feat: use OS-preferred config directory (#26)
This commit is contained in:
@@ -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()"]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user