Skip to content
This repository was archived by the owner on Mar 21, 2026. It is now read-only.

fix: respect CLAUDE_CONFIG_DIR env var for Claude config directory#326

Merged
zhubert merged 1 commit intomainfrom
issue-324
Mar 11, 2026
Merged

fix: respect CLAUDE_CONFIG_DIR env var for Claude config directory#326
zhubert merged 1 commit intomainfrom
issue-324

Conversation

@zhubert
Copy link
Copy Markdown
Owner

@zhubert zhubert commented Mar 11, 2026

Summary

Adds support for the CLAUDE_CONFIG_DIR environment variable so that Plural correctly locates Claude Code's configuration directory when it's customized, instead of always assuming ~/.claude.

Changes

  • Add internal/claudeconfig/config_dir.go with GetClaudeConfigDir() helper that checks CLAUDE_CONFIG_DIR env var before falling back to ~/.claude
  • Update container volume mount in process_manager.go to use the resolved config dir
  • Update credentialsFileExists() to look for .credentials.json in the resolved config dir
  • Update validatePlanPath() in MCP server to validate against the resolved config dir
  • Update getClaudeDir() in plugins package to use the shared helper
  • Add tests for all affected code paths

Test plan

  • go test ./internal/claudeconfig/... — unit tests for GetClaudeConfigDir() with and without env var
  • go test ./internal/claude/... — tests for container args and credentials file detection with custom config dir
  • go test ./internal/mcp/... — tests for plan path validation with custom config dir
  • go test ./... — full test suite passes

Fixes #324

…ctory

Introduces GetClaudeConfigDir() in internal/claudeconfig as a central
helper that returns CLAUDE_CONFIG_DIR if set, falling back to ~/.claude.
Updates all four call sites (plugins, process_manager container mount,
credentialsFileExists, and mcp validatePlanPath) to use it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings March 11, 2026 17:13
@zhubert
Copy link
Copy Markdown
Owner Author

zhubert commented Mar 11, 2026

Session Transcript
User:
GitHub Issue #324: Respect CLAUDE_CONFIG_DIR

https://github.com/zhubert/plural/issues/324

<user-content type="issue_body">
Claude Code supports the environment variable CLAUDE_CONFIG_DIR which specifies where to look for "configuration and data files".

Plural needs to use that appropriate directory if it has been set.
</user-content>

---
Approved implementation plan:
<user-content type="approved_plan">
## Implementation Plan: Respect `CLAUDE_CONFIG_DIR`

### Summary

Claude Code supports the `CLAUDE_CONFIG_DIR` environment variable to override the default `~/.claude` directory for configuration and data files. Currently, Plural hardcodes `~/.claude` in four separate places. This plan introduces a central helper function and updates all affected callsites.

---

### Affected Files

| File | Location | Current behavior |
|------|----------|-----------------|
| `internal/claudeconfig/mcp.go` | `discoverMCPToolPatterns()` | Reads `~/.claude.json` (project MCP config) — **not** inside `~/.claude/`, so likely unaffected |
| `internal/plugins/plugins.go` | `getClaudeDir()` | Returns `filepath.Join(home, ".claude")` |
| `internal/claude/process_manager.go` | `credentialsFileExists()` | Checks `~/.claude/.credentials.json` |
| `internal/claude/process_manager.go` | `buildContainerRunArgs()` | Mounts `homeDir + "/.claude"` as `/home/claude/.claude-host:ro` |
| `internal/mcp/server.go` | `validatePlanPath()` | Validates plan path against `~/.claude/plans/` |

> **Note on `~/.claude.json`**: This file lives at `$HOME/.claude.json` (adjacent to the directory, not inside it). Based on Claude Code's behavior, `CLAUDE_CONFIG_DIR` only affects the `~/.claude/` **directory**, not this file. No change needed in `internal/claudeconfig/mcp.go`.

---

### Step-by-Step Implementation Plan

#### Step 1: Add `GetClaudeConfigDir()` to `internal/claudeconfig/`

Create a new file `internal/claudeconfig/config_dir.go` (or add to an existing file) with:

```go
// GetClaudeConfigDir returns the Claude Code configuration directory.
// If the CLAUDE_CONFIG_DIR environment variable is set, it is used as-is.
// Otherwise, falls back to ~/.claude.
func GetClaudeConfigDir() (string, error) {
    if dir := os.Getenv("CLAUDE_CONFIG_DIR"); dir != "" {
        return dir, nil
    }
    home, err := os.UserHomeDir()
    if err != nil {
        return "", err
    }
    return filepath.Join(home, ".claude"), nil
}

This function will have a corresponding test file covering both the env-var path and the fallback path.

Step 2: Update internal/plugins/plugins.go

Replace the private getClaudeDir() function body to call claudeconfig.GetClaudeConfigDir(), or import and use it directly in ListMarketplaces(), ListPlugins().

func getClaudeDir() string {
    dir, err := claudeconfig.GetClaudeConfigDir()
    if err != nil {
        return ""
    }
    return dir
}

Step 3: Update internal/claude/process_manager.go

credentialsFileExists() (line ~1175):

func credentialsFileExists() bool {
    dir, err := claudeconfig.GetClaudeConfigDir()
    if err != nil {
        return false
    }
    _, err = os.Stat(filepath.Join(dir, ".credentials.json"))
    return err == nil
}

buildContainerRunArgs() (line ~1016):

claudeDir, err := claudeconfig.GetClaudeConfigDir()
if err != nil {
    return containerRunResult{}, fmt.Errorf("failed to determine claude config dir: %w", err)
}
// ...
"-v", claudeDir + ":/home/claude/.claude-host:ro",

Step 4: Update internal/mcp/server.go

validatePlanPath() (line ~902):

func validatePlanPath(planPath string) error {
    claudeDir, err := claudeconfig.GetClaudeConfigDir()
    if err != nil {
        return fmt.Errorf("cannot determine claude config directory: %w", err)
    }
    allowedDir := filepath.Join(claudeDir, "plans")
    // ... rest unchanged
}

Step 5: Write tests

  • internal/claudeconfig/config_dir_test.go: table-driven tests for GetClaudeConfigDir() covering:
    • CLAUDE_CONFIG_DIR set → returns env var value
    • CLAUDE_CONFIG_DIR unset → returns ~/.claude
    • Both cases with t.Setenv() for isolation
  • Update internal/claude/process_manager_test.go to test credentialsFileExists() with CLAUDE_CONFIG_DIR set
  • Update internal/mcp/server_test.go plan-path tests to verify CLAUDE_CONFIG_DIR overrides the allowed directory

Potential Risks / Edge Cases

  1. CLAUDE_CONFIG_DIR with trailing slashfilepath.Join normalizes this, so no issue.
  2. Relative path in CLAUDE_CONFIG_DIR — Could be surprising. We can document that it's used as-is (matching Claude Code's behavior).
  3. Import cycleinternal/claudeconfig is already imported by internal/plugins. Need to verify internal/claude and internal/mcp don't already import claudeconfig in a way that creates a cycle. Quick check shows internal/claude does not currently import claudeconfig, so a new import is needed.
  4. Container entrypoint — The entrypoint script in build.go (line 143–152) copies from /home/claude/.claude-host which is the mounted ~/.claude dir. Since Step 3 already mounts the correct directory, no additional change is needed in the Dockerfile template.

No changes needed

  • internal/claudeconfig/mcp.go — reads ~/.claude.json (not inside ~/.claude/), unaffected by CLAUDE_CONFIG_DIR
  • internal/container/build.go — Dockerfile template copies from the mount point /home/claude/.claude-host, which is fixed inside the container; the host side is already fixed in Step 3

Questions / Clarifications

None — the scope is well-defined. Ready to implement once approved.

Assistant:
[ToolSearch: select:Read,Write,Edit,Grep,Glob,Bash...]
[TodoWrite]
[Read: claudeconfig]
[Read: plugins.go]
[Glob: /workspace/internal/claudeconfig/.go]
[Read: process_manager.go]
[Grep: credentialsFileExists]
[Grep: validatePlanPath]
[Read: process_manager.go]
[Read: server.go]
Now let me read the existing claudeconfig files and process_manager.go imports to understand the current structure:
[Read: mcp.go]
[Read: process_manager.go]
[Read: server.go]
Now I have everything I need. Let me implement the changes:
[Write: config_dir.go]
[TodoWrite]
[Edit: plugins.go]
[Edit: plugins.go]
Now update process_manager.go:
[Edit: process_manager.go]
[Edit: process_manager.go]
Now update buildContainerRunArgs to use GetClaudeConfigDir():
[Edit: process_manager.go]
[TodoWrite]
Now update mcp/server.go:
[Edit: server.go]
[Edit: server.go]
[TodoWrite]
Now let me write the tests. First, let me check the existing test files for patterns:
[Read: mcp_test.go]
[Glob: /workspace/internal/mcp/
_test.go]
[Read: server_test.go]
[Grep: validatePlanPath]
[Read: server_test.go]
[Read: server_test.go]
Now I have a clear picture. Let me write the tests. First, the config_dir test:
[Write: config_dir_test.go]
Now let me check the existing process_manager tests to see how they're structured:
[Glob: /workspace/internal/claude/*_test.go]
[Grep: credentialsFileExists|build...]
[Read: process_manager_test.go]
[Read: process_manager_test.go]
I need to add tests for CLAUDE_CONFIG_DIR respecting in credentialsFileExists and buildContainerRunArgs. Let me look at the end of the process_manager_test.go to find the right place to add tests:
[Read: process_manager_test.go]
[Bash: wc -l /workspace/internal/claude/proc...]
[Read: process_manager_test.go]
[Read: server_test.go]
Now let me check what imports are at the top of process_manager_test.go:
[Read: process_manager_test.go]
Now I'll add the CLAUDE_CONFIG_DIR tests to the process_manager_test.go and server_test.go files:
[Edit: process_manager_test.go]
Now add the CLAUDE_CONFIG_DIR test to server_test.go:
[Edit: server_test.go]
Now I need to ensure CLAUDE_CONFIG_DIR is cleared before TestValidatePlanPath so it uses the default. Let me check if there's a mechanism for that already:
[Read: server_test.go]
The TestValidatePlanPath test uses os.UserHomeDir() so it will compute the correct ~/.claude/plans path. Since t.Setenv in the _ClaudeConfigDir test restores env after the test completes, the existing test will be unaffected. Good.

Now let me run the tests to verify:
[Bash: go test ./internal/claudeconfig/... 2>&1]
[Bash: go test ./internal/plugins/... ./inte...]
All tests pass. Let me run the full test suite:
[Bash: go test ./... 2>&1]
The failures are pre-existing and unrelated to my changes:

  • clipboard / ui / cmd: missing X11/Xlib.h system library in this container
  • internal/manager: pre-existing test failures unrelated to my changes

All the packages I changed pass their tests: claudeconfig, plugins, claude, mcp.
[TodoWrite]
Now let me commit the changes:
[Bash: git status && git diff --stat]
[Bash: git config --global --add safe.direct...]
[Bash: git add internal/claudeconfig/config_...]
Done. Here's a summary of what was implemented:

New file: internal/claudeconfig/config_dir.go

  • GetClaudeConfigDir() returns $CLAUDE_CONFIG_DIR if set, otherwise ~/.claude

Updated files:

  • internal/plugins/plugins.gogetClaudeDir() now delegates to GetClaudeConfigDir()
  • internal/claude/process_manager.gocredentialsFileExists() and buildContainerRunArgs() now use the correct config dir
  • internal/mcp/server.govalidatePlanPath() now validates against the correct plans dir

New tests:

  • internal/claudeconfig/config_dir_test.go — table-driven tests for both env-var and fallback paths
  • New tests in process_manager_test.go for credentialsFileExists and buildContainerRunArgs respecting CLAUDE_CONFIG_DIR
  • New test in server_test.go for validatePlanPath respecting CLAUDE_CONFIG_DIR
</details>

@zhubert zhubert merged commit c958b31 into main Mar 11, 2026
3 checks passed
@zhubert zhubert deleted the issue-324 branch March 11, 2026 17:15
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds support for CLAUDE_CONFIG_DIR so the codebase consistently resolves Claude Code’s configuration directory (instead of assuming ~/.claude) across container execution, credentials lookup, MCP plan-path validation, and plugins.

Changes:

  • Introduces internal/claudeconfig.GetClaudeConfigDir() to resolve the Claude config directory using CLAUDE_CONFIG_DIR with a fallback to ~/.claude.
  • Updates Claude container volume mounting and host credentials discovery to use the resolved config directory.
  • Updates MCP plan path validation to validate against the resolved Claude config directory and adds targeted tests.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
internal/claudeconfig/config_dir.go Adds shared helper to resolve Claude config directory from CLAUDE_CONFIG_DIR or ~/.claude.
internal/claudeconfig/config_dir_test.go Unit tests for GetClaudeConfigDir() behavior with/without env var.
internal/claude/process_manager.go Uses resolved Claude config dir for docker volume mount and .credentials.json discovery.
internal/claude/process_manager_test.go Adds tests ensuring credentials detection and container mount respect CLAUDE_CONFIG_DIR.
internal/mcp/server.go Updates plan-path validation to constrain to <claudeDir>/plans.
internal/mcp/server_test.go Adds test coverage for plan-path validation when CLAUDE_CONFIG_DIR is set.
internal/plugins/plugins.go Switches plugins package to use shared Claude config dir helper.
Comments suppressed due to low confidence (1)

internal/mcp/server.go:923

  • validatePlanPath() only checks string prefixes on a cleaned absolute path and does not account for symlinks. A symlink placed inside the allowed plans directory that points outside it would pass validation but cause readPlanFromPath() to read an arbitrary file. Consider validating using resolved (EvalSymlinks) paths and/or rejecting symlinks when opening the file so the allowed-dir constraint can't be bypassed.
	// Clean and resolve the path to eliminate ../ traversal
	absPath, err := filepath.Abs(planPath)
	if err != nil {
		return fmt.Errorf("cannot resolve path: %w", err)
	}
	cleanPath := filepath.Clean(absPath)

	// Ensure the resolved path is within the allowed directory.
	// We append os.PathSeparator to prevent prefix matches like
	// ~/.claude/plans-evil/ matching ~/.claude/plans
	if !strings.HasPrefix(cleanPath, allowedDir+string(os.PathSeparator)) && cleanPath != allowedDir {
		return fmt.Errorf("path must be within %s", allowedDir)
	}

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.

Comment on lines +11 to +19
func GetClaudeConfigDir() (string, error) {
if dir := os.Getenv("CLAUDE_CONFIG_DIR"); dir != "" {
return dir, nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".claude"), nil
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GetClaudeConfigDir() returns CLAUDE_CONFIG_DIR as-is. If the env var contains leading/trailing whitespace or a relative path, callers can misbehave (e.g., validatePlanPath compares against an absolute path, and docker -v bind mounts require an absolute host path). Consider trimming whitespace and normalizing to an absolute, cleaned path before returning (or clearly documenting that only absolute paths are supported).

Copilot uses AI. Check for mistakes.
Comment on lines +1007 to +1010
name: "path in default ~/.claude/plans is rejected when CLAUDE_CONFIG_DIR is set",
path: filepath.Join(os.Getenv("HOME"), ".claude", "plans", "plan.md"),
wantErr: true,
},
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test case uses os.Getenv("HOME") to construct the default ~/.claude path. On platforms/environments where HOME isn't set (or doesn't reflect os.UserHomeDir()), the test may still pass but won't actually validate the intended behavior. Prefer using os.UserHomeDir() and filepath.Join(home, ".claude", ...) for the default-path case.

Copilot uses AI. Check for mistakes.
Comment on lines 35 to 42
// getClaudeDir returns the Claude config directory path
func getClaudeDir() string {
home, err := os.UserHomeDir()
dir, err := claudeconfig.GetClaudeConfigDir()
if err != nil {
return ""
}
return filepath.Join(home, ".claude")
return dir
}
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getClaudeDir() now hides the error from GetClaudeConfigDir() and forces callers to handle failure via an empty string, which leads to misleading downstream errors like "could not find home directory" even though the home dir may be fine. Consider returning (string, error) here and propagating a more accurate error message (claude config dir), especially now that the source may be CLAUDE_CONFIG_DIR.

Copilot uses AI. Check for mistakes.
Comment on lines 1001 to 1018
@@ -1013,7 +1014,7 @@ func buildContainerRunArgs(config ProcessConfig, claudeArgs []string) (container
"run", "-i", "--rm",
"--name", containerName,
"-v", config.WorkingDir + ":/workspace",
"-v", homeDir + "/.claude:/home/claude/.claude-host:ro",
"-v", claudeDir + ":/home/claude/.claude-host:ro",
"-w", "/workspace",
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

buildContainerRunArgs() now mounts the host Claude config dir directly. If CLAUDE_CONFIG_DIR is set to a relative path (or includes whitespace), docker bind mounts may fail at runtime (bind mounts generally require absolute host paths) and the failure won't be caught here. Consider normalizing/validating the resolved directory (e.g., ensure absolute) before constructing the -v argument and returning a clear error if it's invalid.

Copilot uses AI. Check for mistakes.
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Respect CLAUDE_CONFIG_DIR

2 participants