diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 605370a..e263d95 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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()"] diff --git a/README.md b/README.md index 5f8e753..5878518 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cmd/fence/main.go b/cmd/fence/main.go index 7ce6969..9fd4ddd 100644 --- a/cmd/fence/main.go +++ b/cmd/fence/main.go @@ -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 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 } diff --git a/docs/configuration.md b/docs/configuration.md index 548c1df..f58146b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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 diff --git a/docs/library.md b/docs/library.md index 50948b9..0d6c2da 100644 --- a/docs/library.md +++ b/docs/library.md @@ -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` diff --git a/docs/quickstart.md b/docs/quickstart.md index 5dc0f8b..4576e46 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -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 { diff --git a/internal/config/config.go b/internal/config/config.go index f80bdce..a4d95c9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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. diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 39fcdf2..d95b852 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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) } }