Vectrum
Sign In
96 lines · 3.7 KB · Go
// Package ccsettings reads the configuration Claude Code keeps for a user and
// for a project, so that someone who already set Claude Code up does not have
// to set Codey up a second time: the MCP servers it knows about, the
// permission rules in its settings files, and the CLAUDE.md files it loads
// into every conversation.
//
// Reading is the easy half. The half that needs care is what is honoured, and
// it turns on who wrote each file. ~/.claude.json and ~/.claude/settings.json
// are the user's own, written by the user or by `claude` on their behalf, and
// they carry the same weight as Codey's own config. A project's .mcp.json and
// .claude/settings.json are committed to the repository, which means they were
// written by whoever wrote the repository: starting a server listed there is
// running a command a stranger chose, and an allow rule there is a stranger
// deciding what runs without asking. Those two are honoured only once the user
// has said, for this checkout and these exact definitions, that they trust
// them — the same bargain Claude Code's own trust dialog strikes. Anything
// that can only take power away — a deny rule, an ask rule — is honoured from
// every file, because nobody is harmed by an agent that asks more often.
//
// Nothing here writes to Claude Code's files. The one file this package
// writes is Codey's own record of those trust decisions.
package ccsettings

import (
	"encoding/json"
	"errors"
	"fmt"
	"io/fs"
	"os"
	"path/filepath"
	"strings"
)

// Scope is who a setting belongs to, which is what decides whether it is
// honoured without asking.
type Scope string

const (
	// ScopeUser is the user's own, for every project: ~/.claude/settings.json,
	// and the top-level servers in ~/.claude.json.
	ScopeUser Scope = "user"
	// ScopeProject is committed to the repository: .mcp.json and
	// .claude/settings.json at the working root. Written by whoever wrote the
	// repository, so what it grants waits for trust.
	ScopeProject Scope = "project"
	// ScopeLocal is the user's own, for this project only:
	// .claude/settings.local.json, which Claude Code keeps out of git, and the
	// per-project servers in ~/.claude.json, which `claude mcp add` writes.
	ScopeLocal Scope = "local"
)

// File names, relative to the home directory or the working root.
const (
	ClaudeJSON        = ".claude.json"
	UserSettings      = ".claude/settings.json"
	ProjectSettings   = ".claude/settings.json"
	LocalSettings     = ".claude/settings.local.json"
	ProjectMCP        = ".mcp.json"
	UserInstructions  = ".claude/CLAUDE.md"
	instructionsName  = "CLAUDE.md"
	localInstructions = "CLAUDE.local.md"
)

// readJSON decodes a file into v. A missing file is (false, nil): every file
// here is optional, and most machines have none of them.
func readJSON(path string, v any) (bool, error) {
	raw, err := os.ReadFile(path)
	if errors.Is(err, fs.ErrNotExist) {
		return false, nil
	}
	if err != nil {
		return false, err
	}
	if err := json.Unmarshal(raw, v); err != nil {
		return false, fmt.Errorf("%s is not valid JSON: %w", path, err)
	}
	return true, nil
}

// shown is how a path is written in a message: under ~ when it is in the home
// directory, relative when it is in the working root, absolute otherwise. A
// prompt that says ".mcp.json" is one a person reads; one that says
// /home/someone/src/thing/.mcp.json is one they skim.
func shown(home, root, path string) string {
	if root != "" {
		if rel, err := filepath.Rel(root, path); err == nil && !strings.HasPrefix(rel, "..") {
			return filepath.ToSlash(rel)
		}
	}
	if home != "" {
		if rel, err := filepath.Rel(home, path); err == nil && !strings.HasPrefix(rel, "..") {
			return "~/" + filepath.ToSlash(rel)
		}
	}
	return path
}