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)
|
- 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()"]
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user