package ccsettings
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
)
// Instruction is one CLAUDE.md-style file, read.
type Instruction struct {
Path string
// Shown names the file in the prompt, and Role says whose it is.
Shown string
Role string
Body string
}
// Roles, as the prompt describes them. Saying whose a file is matters to the
// model as much as to a person: the user's own preferences and a repository's
// conventions are different kinds of instruction.
const (
roleUser = "the user's own instructions, for every project"
roleProject = "this project's instructions, checked in"
roleLocal = "the user's private instructions for this project"
importMaxHop = 5
// maxInstructionBytes bounds one file. These go into every request, and
// a generated file or a stray log someone named CLAUDE.md should not be
// able to fill the window.
maxInstructionBytes = 64 << 10
)
// LoadInstructions reads the files Claude Code loads into a conversation
// started in root, in the order it loads them, least specific first:
//
// - ~/.claude/CLAUDE.md, the user's own;
// - then for each directory from the top of the project down to root —
// the top being the nearest directory with a .git in it, or the home
// directory if there is none on the way — CLAUDE.md, .claude/CLAUDE.md
// and CLAUDE.local.md.
//
// Each file's @path imports follow it, up to five hops deep. An import is
// relative to the file it appears in, and may not leave the tree its file
// belongs to: a project's files may import from the project, the user's from
// the home directory. A repository's CLAUDE.md that could import
// @~/.aws/credentials would be a repository reading your credentials into
// the conversation, which is then sent to the model.
//
// Claude Code also loads a subdirectory's CLAUDE.md when it first reads a
// file there, and .claude/rules/; neither is done here.
func LoadInstructions(home, root string) ([]Instruction, []error) {
l := &instructionLoader{home: home, root: root, seen: map[string]bool{}}
if home != "" {
l.load(filepath.Join(home, UserInstructions), roleUser, home, "", 0)
}
if root == "" {
return l.out, l.errs
}
dirs := projectDirs(home, root)
top := dirs[0]
for _, dir := range dirs {
for _, name := range []string{instructionsName, filepath.Join(".claude", instructionsName), localInstructions} {
path := filepath.Join(dir, name)
role, bound := roleProject, top
if name == localInstructions {
role = roleLocal
// A CLAUDE.local.md is the user's, and may reach into their
// home directory — Claude Code's own docs suggest importing a
// personal file from there — unless git is tracking it, which
// makes it the repository's after all.
if _, err := os.Lstat(path); err != nil {
continue
}
if home != "" && !gitTracks(dir, localInstructions) {
bound = home
}
}
l.load(path, role, bound, "", 0)
}
}
return l.out, l.errs
}
// projectDirs is root and the directories above it that belong to the same
// project, outermost first. The walk stops at a directory holding .git, or at
// the home directory. A root that is neither in a repository nor under home
// is a project of one directory: walking up to / would read whatever CLAUDE.md
// happened to be lying in /tmp.
func projectDirs(home, root string) []string {
root = filepath.Clean(root)
var dirs []string
for d := root; ; {
dirs = append(dirs, d)
if _, err := os.Lstat(filepath.Join(d, ".git")); err == nil {
break
}
if home != "" && d == filepath.Clean(home) {
break
}
parent := filepath.Dir(d)
if parent == d {
return []string{root}
}
d = parent
}
for i, j := 0, len(dirs)-1; i < j; i, j = i+1, j-1 {
dirs[i], dirs[j] = dirs[j], dirs[i]
}
return dirs
}
type instructionLoader struct {
home string
root string
seen map[string]bool
out []Instruction
errs []error
}
// load reads one file and then its imports. bound is the directory the file
// and everything it imports must stay inside; importer is the file that
// imported this one, empty for a file found by name.
func (l *instructionLoader) load(path, role, bound, importer string, hop int) {
path = filepath.Clean(path)
real, err := filepath.EvalSymlinks(path)
if err != nil {
return // not there, which is the usual case
}
if l.seen[real] {
return
}
// The user's own file is wherever they keep it — often a symlink into a
// dotfiles repository. Everything else, including what it imports, stays
// inside its bound, and that is checked on the resolved path: a symlink
// committed to a repository can point anywhere.
ownFile := importer == "" && role == roleUser
if !ownFile && !within(bound, real) {
if importer != "" {
where := "the project"
if bound == l.home {
where = "the home directory"
}
l.errs = append(l.errs, fmt.Errorf("CLAUDE.md: %s imports %s, which is outside %s — skipped", l.shown(importer), l.shown(path), where))
}
return
}
info, err := os.Stat(real)
if err != nil || !info.Mode().IsRegular() {
return
}
raw, err := os.ReadFile(real)
if err != nil {
l.errs = append(l.errs, fmt.Errorf("CLAUDE.md: %w", err))
return
}
l.seen[real] = true
body := string(raw)
if len(body) > maxInstructionBytes {
l.errs = append(l.errs, fmt.Errorf("CLAUDE.md: %s is over %d KB — only the start of it is used", l.shown(path), maxInstructionBytes>>10))
body = body[:maxInstructionBytes]
}
in := Instruction{Path: path, Shown: l.shown(path), Role: role, Body: strings.TrimSpace(body)}
if importer != "" {
in.Role = "imported by " + l.shown(importer)
}
if in.Body != "" {
l.out = append(l.out, in)
}
if hop >= importMaxHop {
return
}
for _, ref := range imports(body) {
target := ref
switch {
case strings.HasPrefix(ref, "~/"):
if l.home == "" {
continue
}
target = filepath.Join(l.home, ref[2:])
case !filepath.IsAbs(ref):
target = filepath.Join(filepath.Dir(path), ref)
}
l.load(target, role, bound, path, hop+1)
}
}
func (l *instructionLoader) shown(path string) string {
return shown(l.home, l.root, path)
}
func within(dir, path string) bool {
if real, err := filepath.EvalSymlinks(dir); err == nil {
dir = real
}
rel, err := filepath.Rel(dir, path)
return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && !filepath.IsAbs(rel)
}
var (
importRef = regexp.MustCompile("(?:^|[\\s(])@([^\\s`]+)")
inlineCode = regexp.MustCompile("`[^`]*`")
)
// imports finds the @path references in a file, skipping code: an @ in a
// code block or span is an example, or a decorator, not an import.
func imports(body string) []string {
var out []string
fenced := false
for _, line := range strings.Split(body, "\n") {
if strings.HasPrefix(strings.TrimSpace(line), "```") {
fenced = !fenced
continue
}
if fenced {
continue
}
line = inlineCode.ReplaceAllString(line, "")
for _, m := range importRef.FindAllStringSubmatch(line, -1) {
ref := strings.TrimRight(m[1], ".,;:!?)]'\"")
if ref != "" {
out = append(out, ref)
}
}
}
return out
}
// FormatInstructions is the system prompt section the files make. It is built
// once, at startup, and so is constant for the session: the cached prefix
// still matches request to request.
func FormatInstructions(files []Instruction) string {
if len(files) == 0 {
return ""
}
var b strings.Builder
b.WriteString("\n\n# Instructions from CLAUDE.md\n\n" +
"These are the instruction files Claude Code would load for this repository,\n" +
"read at startup and given here in full, so there is no need to read them\n" +
"again. Follow them: on anything about this codebase they outrank the\n" +
"defaults above. Later files are more specific to where you are working, and\n" +
"win where two disagree.")
for _, f := range files {
fmt.Fprintf(&b, "\n\n## %s (%s)\n\n%s", f.Shown, f.Role, f.Body)
}
return b.String()
}