Skip to content

Commit c8621e8

Browse files
authored
feat: use OS-preferred config directory (#26)
1 parent 7679fec commit c8621e8

File tree

8 files changed

+104
-41
lines changed

8 files changed

+104
-41
lines changed

ARCHITECTURE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ type Config struct {
7171
}
7272
```
7373

74-
- Loads from `~/.fence.json` or custom path
74+
- Loads from XDG config dir (`~/.config/fence/fence.json` or `~/Library/Application Support/fence/fence.json`) or custom path
7575
- Falls back to restrictive defaults (block all network, default command deny list)
7676
- Validates paths and normalizes them
7777

@@ -245,7 +245,7 @@ Flow:
245245

246246
```mermaid
247247
flowchart TD
248-
A["1. CLI parses arguments"] --> B["2. Load config from ~/.fence.json"]
248+
A["1. CLI parses arguments"] --> B["2. Load config from XDG config dir"]
249249
B --> C["3. Create Manager"]
250250
C --> D["4. Manager.Initialize()"]
251251

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ fence --help
8080

8181
### Configuration
8282

83-
Fence reads from `~/.fence.json` by default:
83+
Fence reads from `~/.config/fence/fence.json` by default (or `~/Library/Application Support/fence/fence.json` on macOS).
8484

8585
```json
8686
{
@@ -96,7 +96,7 @@ Use `fence --settings ./custom.json` to specify a different config.
9696
### Import from Claude Code
9797

9898
```bash
99-
fence import --claude -o ~/.fence.json
99+
fence import --claude --save
100100
```
101101

102102
## Features

cmd/fence/main.go

Lines changed: 55 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
package main
33

44
import (
5+
"bufio"
56
"encoding/json"
67
"fmt"
78
"os"
@@ -55,8 +56,8 @@ func main() {
5556
with network and filesystem restrictions.
5657
5758
By default, all network access is blocked. Configure allowed domains in
58-
~/.fence.json or pass a settings file with --settings, or use a built-in
59-
template with --template.
59+
~/.config/fence/fence.json (or ~/Library/Application Support/fence/fence.json on macOS)
60+
or pass a settings file with --settings, or use a built-in template with --template.
6061
6162
Examples:
6263
fence curl https://example.com # Will be blocked (no domains allowed)
@@ -68,7 +69,7 @@ Examples:
6869
fence -p 3000 -c "npm run dev" # Expose port 3000 for inbound connections
6970
fence --list-templates # Show available built-in templates
7071
71-
Configuration file format (~/.fence.json):
72+
Configuration file format:
7273
{
7374
"network": {
7475
"allowedDomains": ["github.com", "*.npmjs.org"],
@@ -91,7 +92,7 @@ Configuration file format (~/.fence.json):
9192

9293
rootCmd.Flags().BoolVarP(&debug, "debug", "d", false, "Enable debug logging")
9394
rootCmd.Flags().BoolVarP(&monitor, "monitor", "m", false, "Monitor and log sandbox violations (macOS: log stream, all: proxy denials)")
94-
rootCmd.Flags().StringVarP(&settingsPath, "settings", "s", "", "Path to settings file (default: ~/.fence.json)")
95+
rootCmd.Flags().StringVarP(&settingsPath, "settings", "s", "", "Path to settings file (default: OS config directory)")
9596
rootCmd.Flags().StringVarP(&templateName, "template", "t", "", "Use built-in template (e.g., ai-coding-agents, npm-install)")
9697
rootCmd.Flags().BoolVar(&listTemplates, "list-templates", false, "List available templates")
9798
rootCmd.Flags().StringVarP(&cmdString, "c", "c", "", "Run command string directly (like sh -c)")
@@ -303,6 +304,8 @@ func newImportCmd() *cobra.Command {
303304
claudeMode bool
304305
inputFile string
305306
outputFile string
307+
saveFlag bool
308+
forceFlag bool
306309
extendTmpl string
307310
noExtend bool
308311
)
@@ -320,23 +323,25 @@ for network access (npm, GitHub, LLM providers) and filesystem protections.
320323
Use --no-extend for a minimal config, or --extend to choose a different template.
321324
322325
Examples:
323-
# Import from default Claude Code settings (~/.claude/settings.json)
326+
# Preview import (prints JSON to stdout)
324327
fence import --claude
325328
326-
# Import from a specific Claude Code settings file
327-
fence import --claude -f ~/.claude/settings.json
329+
# Save to the default config path
330+
# Linux: ~/.config/fence/fence.json
331+
# macOS: ~/Library/Application Support/fence/fence.json
332+
fence import --claude --save
333+
334+
# Save to a specific output file
335+
fence import --claude -o ./fence.json
328336
329-
# Import and write to a specific output file
330-
fence import --claude -o .fence.json
337+
# Import from a specific Claude Code settings file
338+
fence import --claude -f ~/.claude/settings.json --save
331339
332340
# Import without extending any template (minimal config)
333-
fence import --claude --no-extend
341+
fence import --claude --no-extend --save
334342
335343
# Import and extend a different template
336-
fence import --claude --extend local-dev-server
337-
338-
# Import from project-level Claude settings
339-
fence import --claude -f .claude/settings.local.json -o .fence.json`,
344+
fence import --claude --extend local-dev-server --save`,
340345
RunE: func(cmd *cobra.Command, args []string) error {
341346
if !claudeMode {
342347
return fmt.Errorf("no import source specified. Use --claude to import from Claude Code")
@@ -357,13 +362,41 @@ Examples:
357362
for _, warning := range result.Warnings {
358363
fmt.Fprintf(os.Stderr, "Warning: %s\n", warning)
359364
}
365+
if len(result.Warnings) > 0 {
366+
fmt.Fprintln(os.Stderr)
367+
}
368+
369+
// Determine output destination
370+
var destPath string
371+
if saveFlag {
372+
destPath = config.DefaultConfigPath()
373+
} else if outputFile != "" {
374+
destPath = outputFile
375+
}
376+
377+
if destPath != "" {
378+
if !forceFlag {
379+
if _, err := os.Stat(destPath); err == nil {
380+
fmt.Printf("File %q already exists. Overwrite? [y/N] ", destPath)
381+
reader := bufio.NewReader(os.Stdin)
382+
response, _ := reader.ReadString('\n')
383+
response = strings.TrimSpace(strings.ToLower(response))
384+
if response != "y" && response != "yes" {
385+
fmt.Println("Aborted.")
386+
return nil
387+
}
388+
}
389+
}
390+
391+
if err := os.MkdirAll(filepath.Dir(destPath), 0o750); err != nil {
392+
return fmt.Errorf("failed to create config directory: %w", err)
393+
}
360394

361-
if outputFile != "" {
362-
if err := importer.WriteConfig(result.Config, outputFile); err != nil {
395+
if err := importer.WriteConfig(result.Config, destPath); err != nil {
363396
return err
364397
}
365398
fmt.Printf("Imported %d rules from %s\n", result.RulesImported, result.SourcePath)
366-
fmt.Printf("Written to %s\n", outputFile)
399+
fmt.Printf("Written to %q\n", destPath)
367400
} else {
368401
// Print clean JSON to stdout, helpful info to stderr (don't interfere with piping)
369402
data, err := importer.MarshalConfigJSON(result.Config)
@@ -375,7 +408,7 @@ Examples:
375408
fmt.Fprintf(os.Stderr, "\n# Extends %q - inherited rules not shown\n", result.Config.Extends)
376409
}
377410
fmt.Fprintf(os.Stderr, "# Imported %d rules from %s\n", result.RulesImported, result.SourcePath)
378-
fmt.Fprintf(os.Stderr, "# Use -o <file> to write to a file (includes comments)\n")
411+
fmt.Fprintf(os.Stderr, "# Use --save to write to the default config path\n")
379412
}
380413

381414
return nil
@@ -384,10 +417,13 @@ Examples:
384417

385418
cmd.Flags().BoolVar(&claudeMode, "claude", false, "Import from Claude Code settings")
386419
cmd.Flags().StringVarP(&inputFile, "file", "f", "", "Path to settings file (default: ~/.claude/settings.json for --claude)")
387-
cmd.Flags().StringVarP(&outputFile, "output", "o", "", "Output file path (default: stdout)")
420+
cmd.Flags().StringVarP(&outputFile, "output", "o", "", "Output file path")
421+
cmd.Flags().BoolVar(&saveFlag, "save", false, "Save to the default config path")
422+
cmd.Flags().BoolVarP(&forceFlag, "force", "y", false, "Overwrite existing file without prompting")
388423
cmd.Flags().StringVar(&extendTmpl, "extend", "", "Template to extend (default: code)")
389424
cmd.Flags().BoolVar(&noExtend, "no-extend", false, "Don't extend any template (minimal config)")
390425
cmd.MarkFlagsMutuallyExclusive("extend", "no-extend")
426+
cmd.MarkFlagsMutuallyExclusive("save", "output")
391427

392428
return cmd
393429
}

docs/configuration.md

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Configuration
22

3-
Fence reads settings from `~/.fence.json` by default (or pass `--settings ./fence.json`). Config files support JSONC.
3+
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.
44

55
Example config:
66

@@ -263,23 +263,23 @@ SSH host patterns support wildcards anywhere:
263263
If you've been using Claude Code and have already built up permission rules, you can import them into fence:
264264

265265
```bash
266-
# Import from default Claude Code settings (~/.claude/settings.json)
266+
# Preview import (prints JSON to stdout)
267267
fence import --claude
268268

269+
# Save to the default config path
270+
fence import --claude --save
271+
269272
# Import from a specific file
270-
fence import --claude -f ~/.claude/settings.json
273+
fence import --claude -f ~/.claude/settings.json --save
271274

272-
# Import and write to a specific output file
273-
fence import --claude -o .fence.json
275+
# Save to a specific output file
276+
fence import --claude -o ./fence.json
274277

275278
# Import without extending any template (minimal config)
276-
fence import --claude --no-extend
279+
fence import --claude --no-extend --save
277280

278281
# Import and extend a different template
279-
fence import --claude --extend local-dev-server
280-
281-
# Import from project-level Claude settings
282-
fence import --claude -f .claude/settings.local.json -o .fence.json
282+
fence import --claude --extend local-dev-server --save
283283
```
284284

285285
### Default Template

docs/library.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ cfg.Network.AllowedDomains = []string{"example.com"}
8383
Loads configuration from a JSON file. Supports JSONC (comments allowed).
8484

8585
```go
86-
cfg, err := fence.LoadConfig("~/.fence.json")
86+
cfg, err := fence.LoadConfig(fence.DefaultConfigPath())
8787
if err != nil {
8888
log.Fatal(err)
8989
}
@@ -94,7 +94,7 @@ if cfg == nil {
9494

9595
#### `DefaultConfigPath() string`
9696

97-
Returns the default config file path (`~/.fence.json`).
97+
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`).
9898

9999
#### `NewManager(cfg *Config, debug, monitor bool) *Manager`
100100

docs/quickstart.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ curl: (56) CONNECT tunnel failed, response 403
6666

6767
## Allow Specific Domains
6868

69-
Create a config file at `~/.fence.json`:
69+
Create a config file at `~/.config/fence/fence.json` (or `~/Library/Application Support/fence/fence.json` on macOS):
7070

7171
```json
7272
{

internal/config/config.go

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -133,12 +133,38 @@ func Default() *Config {
133133
}
134134

135135
// DefaultConfigPath returns the default config file path.
136+
// Uses the OS-preferred config directory (XDG on Linux, ~/Library/Application Support on macOS).
137+
// Falls back to ~/.fence.json if the new location doesn't exist but the legacy one does.
136138
func DefaultConfigPath() string {
139+
// Try OS-preferred config directory first
140+
configDir, err := os.UserConfigDir()
141+
if err == nil {
142+
newPath := filepath.Join(configDir, "fence", "fence.json")
143+
if _, err := os.Stat(newPath); err == nil {
144+
return newPath
145+
}
146+
// Check if parent directory exists (user has set up the new location)
147+
// If so, prefer this even if config doesn't exist yet
148+
if _, err := os.Stat(filepath.Dir(newPath)); err == nil {
149+
return newPath
150+
}
151+
}
152+
153+
// Fall back to legacy path if it exists
137154
home, err := os.UserHomeDir()
138155
if err != nil {
139-
return ".fence.json"
156+
return "fence.json"
157+
}
158+
legacyPath := filepath.Join(home, ".fence.json")
159+
if _, err := os.Stat(legacyPath); err == nil {
160+
return legacyPath
161+
}
162+
163+
// Neither exists, prefer new XDG-compliant path
164+
if configDir != "" {
165+
return filepath.Join(configDir, "fence", "fence.json")
140166
}
141-
return filepath.Join(home, ".fence.json")
167+
return filepath.Join(home, ".config", "fence", "fence.json")
142168
}
143169

144170
// Load loads configuration from a file path.

internal/config/config_test.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -295,9 +295,10 @@ func TestDefaultConfigPath(t *testing.T) {
295295
if path == "" {
296296
t.Error("DefaultConfigPath() returned empty string")
297297
}
298-
// Should end with .fence.json
299-
if filepath.Base(path) != ".fence.json" {
300-
t.Errorf("DefaultConfigPath() = %q, expected to end with .fence.json", path)
298+
// Should end with fence.json (either new XDG path or legacy .fence.json)
299+
base := filepath.Base(path)
300+
if base != "fence.json" && base != ".fence.json" {
301+
t.Errorf("DefaultConfigPath() = %q, expected to end with fence.json or .fence.json", path)
301302
}
302303
}
303304

0 commit comments

Comments
 (0)