Files
2026-07-13 13:00:08 +08:00

549 lines
17 KiB
Go

package config
import (
"fmt"
"hash/fnv"
"os"
"path/filepath"
"runtime"
"strings"
"unicode/utf8"
"reasonix/internal/command"
)
var (
runtimeGOOS = runtime.GOOS
osUserHomeDir = os.UserHomeDir
osUserConfigDir = func() string {
dir, err := os.UserConfigDir()
if err != nil {
return ""
}
return dir
}
)
func userConfigPath() string {
dir := userConfigDir()
if dir == "" {
return ""
}
return filepath.Join(dir, "config.toml")
}
func userConfigDir() string {
return reasonixHomeDir()
}
func reasonixHomeDir() string {
if dir := cleanEnvDir("REASONIX_HOME"); dir != "" {
return dir
}
if runtimeGOOS == "windows" {
if dir := osUserConfigDir(); dir != "" {
return filepath.Join(dir, "reasonix")
}
if home, err := osUserHomeDir(); err == nil && home != "" {
return filepath.Join(home, "AppData", "Roaming", "reasonix")
}
return ""
}
if home, err := osUserHomeDir(); err == nil && home != "" {
return filepath.Join(home, ".reasonix")
}
if dir := osUserConfigDir(); dir != "" {
return filepath.Join(dir, "reasonix")
}
return ""
}
func userConfigLoadPath() string {
primary := userConfigPath()
if primary == "" {
return legacyUserConfigPath()
}
if _, err := os.Stat(primary); err == nil {
return primary
}
if legacy := legacyUserConfigPath(); legacy != "" {
if _, err := os.Stat(legacy); err == nil {
return legacy
}
}
for _, legacy := range legacyXDGConfigPaths() {
if legacy == "" || samePath(legacy, primary) {
continue
}
if _, err := os.Stat(legacy); err == nil {
return legacy
}
}
return primary
}
func legacyUserConfigPath() string {
dir := legacyOSSupportDir()
if dir == "" {
return ""
}
path := filepath.Join(dir, "config.toml")
if primary := userConfigPath(); primary != "" && samePath(path, primary) {
return ""
}
return path
}
func userConfigCandidatePaths() []string {
var paths []string
if p := userConfigPath(); p != "" {
paths = append(paths, p)
}
if p := legacyUserConfigPath(); p != "" {
paths = append(paths, p)
}
paths = append(paths, legacyXDGConfigPaths()...)
return paths
}
func legacyXDGConfigPaths() []string {
if IsolatedHomeDir() != "" {
return nil
}
if runtimeGOOS == "windows" {
return nil
}
seen := map[string]bool{}
var paths []string
add := func(path string) {
if path == "" {
return
}
path = filepath.Clean(path)
if seen[path] {
return
}
seen[path] = true
paths = append(paths, path)
}
if dir := cleanEnvDir("XDG_CONFIG_HOME"); dir != "" {
add(filepath.Join(dir, "reasonix", "config.toml"))
}
if home, err := osUserHomeDir(); err == nil && home != "" {
add(filepath.Join(home, ".config", "reasonix", "config.toml"))
}
return paths
}
func userSupportDir() string {
if dir := cleanEnvDir("REASONIX_STATE_HOME"); dir != "" {
return dir
}
return reasonixHomeDir()
}
func legacyOSSupportDir() string {
if IsolatedHomeDir() != "" {
return ""
}
dir := osUserConfigDir()
if dir == "" {
return ""
}
path := filepath.Join(dir, "reasonix")
if current := reasonixHomeDir(); current != "" && samePath(path, current) {
return ""
}
return path
}
func userCacheDir() string {
if dir := cleanEnvDir("REASONIX_CACHE_HOME"); dir != "" {
return dir
}
if dir := cleanEnvDir("REASONIX_HOME"); dir != "" {
return filepath.Join(dir, "cache")
}
dir, err := os.UserCacheDir()
if err != nil {
return ""
}
return filepath.Join(dir, "reasonix")
}
func cleanEnvDir(name string) string {
dir := strings.TrimSpace(os.Getenv(name))
if dir == "" {
return ""
}
dir = ExpandVars(dir)
if dir == "~" {
if home, err := osUserHomeDir(); err == nil && home != "" {
dir = home
}
} else if strings.HasPrefix(dir, "~/") || strings.HasPrefix(dir, `~\`) {
if home, err := osUserHomeDir(); err == nil && home != "" {
dir = filepath.Join(home, dir[2:])
}
}
if !filepath.IsAbs(dir) {
if abs, err := filepath.Abs(dir); err == nil {
dir = abs
}
}
return filepath.Clean(dir)
}
func samePath(a, b string) bool {
if a == "" || b == "" {
return false
}
aa, aerr := filepath.Abs(a)
bb, berr := filepath.Abs(b)
if aerr == nil {
a = aa
}
if berr == nil {
b = bb
}
return filepath.Clean(a) == filepath.Clean(b)
}
// IsolatedHomeDir returns the REASONIX_HOME directory when it has been
// explicitly set via the environment variable. A non-empty return signals a
// self-contained runtime that must not fall back to legacy OS-default data
// paths or import data from the system-wide production install.
func IsolatedHomeDir() string {
return cleanEnvDir("REASONIX_HOME")
}
// userConfigDisplayPath is userConfigPath collapsed to a ~-relative form for
// comments rendered into the user's own config.toml, so Windows users see the
// real location instead of a hardcoded ~/.reasonix path.
func userConfigDisplayPath() string {
p := userConfigPath()
if p == "" {
return "<os-config-dir>/reasonix/config.toml"
}
if home, err := osUserHomeDir(); err == nil && home != "" {
if rel, err := filepath.Rel(home, p); err == nil && !strings.HasPrefix(rel, "..") {
return "~/" + filepath.ToSlash(rel)
}
}
return p
}
// UserConfigPath is the user-global config.toml. It lives under Reasonix home:
// REASONIX_HOME/config.toml, then ~/.reasonix/config.toml on Unix-like systems,
// or %AppData%/reasonix/config.toml on Windows. If %AppData% is unavailable on
// Windows, it falls back to %USERPROFILE%/AppData/Roaming/reasonix/config.toml.
// "" when the user config dir can't be resolved.
func UserConfigPath() string { return userConfigPath() }
// LegacyUserConfigPath is the old OS app-support config.toml path when it
// differs from UserConfigPath. It is read as a compatibility fallback when the
// primary user config does not exist.
func LegacyUserConfigPath() string { return legacyUserConfigPath() }
// LegacyUserConfigPaths returns every known legacy user config path that differs
// from the current v1.8.1 Reasonix-home config path.
func LegacyUserConfigPaths() []string {
primary := userConfigPath()
var out []string
add := func(path string) {
if path == "" || samePath(path, primary) {
return
}
for _, existing := range out {
if samePath(existing, path) {
return
}
}
out = append(out, path)
}
add(legacyUserConfigPath())
for _, path := range legacyXDGConfigPaths() {
add(path)
}
return out
}
// ReasonixManagedConfigPaths returns the Reasonix-owned user configuration
// FILES that model-driven tools may repair on the user's request, each gated
// by a fresh per-write human approval: the current config.toml, compatibility
// TOML locations, and the legacy v0.x ~/.reasonix/config.json. Individual
// files, never directories — the Reasonix home also holds credentials (.env),
// global hooks (settings.json), skills, and session stores, and none of those
// may ride along on a config repair.
func ReasonixManagedConfigPaths() []string {
var out []string
out = appendUniquePath(out, UserConfigPath())
for _, path := range LegacyUserConfigPaths() {
out = appendUniquePath(out, path)
}
out = appendUniquePath(out, legacyConfigPath())
return out
}
func appendUniquePath(paths []string, path string) []string {
path = strings.TrimSpace(path)
if path == "" {
return paths
}
clean := filepath.Clean(path)
for _, existing := range paths {
if samePath(existing, clean) {
return paths
}
}
return append(paths, clean)
}
// ReasonixHomeDir is the current Reasonix home directory. It honors
// REASONIX_HOME, then uses ~/.reasonix on macOS/Linux or %APPDATA%/reasonix on
// Windows, with a %USERPROFILE%/AppData/Roaming fallback when %APPDATA% is
// unavailable.
func ReasonixHomeDir() string { return reasonixHomeDir() }
// UserCredentialsPath is the reasonix-owned global .env file under Reasonix
// home. It is the single source for provider credentials saved by Reasonix, so
// stale shell, Windows, project, or home env vars cannot silently override keys
// the user saved through setup or settings. "" when Reasonix home can't be
// resolved.
func UserCredentialsPath() string {
dir := reasonixHomeDir()
if dir == "" {
return ""
}
return filepath.Join(dir, ".env")
}
// ArchiveDir is where compacted conversation history is archived for
// traceability (one timestamped .jsonl per compaction). Empty if the user state
// directory cannot be resolved, in which case archiving is skipped.
func ArchiveDir() string {
dir := userSupportDir()
if dir == "" {
return ""
}
return filepath.Join(dir, "archive")
}
// SessionDir is where chat sessions are persisted (one .jsonl per session).
// Used by `reasonix --continue` / `--resume` to find the recent ones. Empty
// if the user state dir can't be resolved — sessions then aren't saved.
func SessionDir() string {
dir := userSupportDir()
if dir == "" {
return ""
}
return filepath.Join(dir, "sessions")
}
// ProjectSessionDir is the per-workspace session directory the desktop sidebar
// lists: <state root>/projects/<slug>/sessions. Empty when either the state root
// or workspaceRoot doesn't resolve.
func ProjectSessionDir(workspaceRoot string) string {
base := MemoryUserDir()
root := strings.TrimSpace(workspaceRoot)
if base == "" || root == "" {
return ""
}
if abs, err := filepath.Abs(root); err == nil {
root = abs
}
return filepath.Join(base, "projects", WorkspaceSlug(root), "sessions")
}
// MemoryCompilerDir is the project-scoped state directory for the Memory v5
// execution compiler. Empty means persistent compiler state is unavailable.
func MemoryCompilerDir(workspaceRoot string) string {
base := MemoryUserDir()
root := strings.TrimSpace(workspaceRoot)
if base == "" || root == "" {
return ""
}
if abs, err := filepath.Abs(root); err == nil {
root = abs
}
return filepath.Join(base, "projects", WorkspaceSlug(root), "memory", "compiler")
}
// WorkspaceSlug flattens an absolute workspace path into the directory name
// used under <config root>/projects. Windows spells the same folder with
// varying case (drive-letter case, Explorer renames), so the slug folds case
// there — matching agent.CanonicalSessionPath's key form — or equivalent
// spellings of one workspace would produce distinct slug strings. Existing
// mixed-case slug directories need no migration: NTFS resolves names
// case-insensitively, so the folded slug opens the same directory.
func WorkspaceSlug(absPath string) string {
if runtimeGOOS == "windows" {
absPath = strings.ToLower(absPath)
}
slug := strings.NewReplacer(string(os.PathSeparator), "-", "/", "-", "\\", "-", ":", "-").Replace(absPath)
return boundFilenameComponent(slug, 255)
}
// boundFilenameComponent caps a derived filename component at the common
// per-component filesystem limit (255 bytes on ext4/APFS/NTFS). maxLen is the
// byte budget for this component (path segments pass 255; names that gain an
// extension pass 255 minus the extension length). Inputs at or under the
// budget pass through byte-identical — every component that ever existed on
// disk is under the budget, or it could not have been created — so existing
// directories and files keep resolving. Only inputs that would previously
// have failed with ENAMETOOLONG are truncated, with an FNV-1a hash of the
// full input appended so distinct deep paths cannot collapse to one name.
func boundFilenameComponent(s string, maxLen int) string {
if maxLen <= 0 || len(s) <= maxLen {
return s
}
h := fnv.New64a()
_, _ = h.Write([]byte(s))
budget := maxLen - 17 // room for "-" + 16 hex digits
prefix := s[:budget]
// Back off to a rune boundary so a multi-byte character is never split.
for len(prefix) > 0 && !utf8.ValidString(prefix) {
prefix = prefix[:len(prefix)-1]
}
return fmt.Sprintf("%s-%016x", prefix, h.Sum64())
}
// BoundFilenameComponent is the exported form for sibling packages deriving
// filename components from unbounded input. maxLen is the byte budget for the
// component (pass 255 for a bare path segment; subtract the extension length
// when one will be appended).
func BoundFilenameComponent(s string, maxLen int) string {
return boundFilenameComponent(s, maxLen)
}
// CacheDir is the per-user cache root for derived/regenerable artefacts: MCP
// handshake snapshots, plugin startup-latency telemetry. Empty when the OS dir is
// unavailable — callers must tolerate that (caching is best-effort).
func CacheDir() string {
dir := userCacheDir()
if dir == "" {
return ""
}
return dir
}
// MemoryUserDir returns the reasonix user state root (…/reasonix), under which
// the user-global REASONIX.md and the per-project auto-memory store live. Empty
// when the user state dir can't be resolved, which disables user-scoped memory.
func MemoryUserDir() string {
return userSupportDir()
}
// ConventionDirs are the parent directories scanned for agent assets (skills,
// commands), in canonical-first order. .reasonix is ours; .agents / .agent /
// .claude let users drop in assets authored for other agent tools without moving
// files. Shared so skills (internal/skill) and commands (CommandDirs) discover
// the same set. Note: hooks are NOT scanned across these — a .claude/settings.json
// uses a different hook schema that can't be parsed as ours, so hooks stay in
// .reasonix/settings.json (see internal/hook).
var ConventionDirs = []string{".reasonix", ".agents", ".agent", ".claude"}
// conventionSubdirsAsc joins sub under each ConventionDir of base, in ascending
// priority (reverse of ConventionDirs) so the canonical .reasonix ends up the
// highest-priority entry — command.Load lets a later directory win on a clash.
func conventionSubdirsAsc(base, sub string) []string {
out := make([]string, 0, len(ConventionDirs))
for i := len(ConventionDirs) - 1; i >= 0; i-- {
out = append(out, filepath.Join(base, ConventionDirs[i], sub))
}
return out
}
// CommandDirs returns the directories scanned for custom slash commands, lowest
// priority first, so a later (more specific) directory overrides an earlier one
// on a name clash. Order: home-dir convention dirs (~/.claude/commands …
// ~/.reasonix/commands), the Reasonix home commands dir, the legacy OS
// app-support dir if different, then the project's
// convention dirs (.claude/commands … .reasonix/commands). Scanning the .claude /
// .agents / .agent dirs lets commands authored for other agent tools (same .md +
// frontmatter format) work here unchanged.
func CommandDirs() []string {
return CommandDirsForRoot(".")
}
// CommandDirsForRoot is like CommandDirs but resolves the project convention
// dirs under root instead of the current working directory. Global dirs are
// unchanged — they are always user-scoped.
func CommandDirsForRoot(root string) []string {
roots := CommandRootsForRoot(root)
dirs := make([]string, 0, len(roots))
for _, spec := range roots {
dirs = append(dirs, spec.Path)
}
return dirs
}
// CommandRootsForRoot is the ownership-aware form of CommandDirsForRoot.
// Plugin roots retain their package name so the loader can expose stable,
// package-qualified command names and hidden short-name compatibility aliases.
func CommandRootsForRoot(root string) []command.Root {
root = resolveRoot(root)
var roots []command.Root
add := func(spec command.Root) {
if spec.Path == "" {
return
}
for _, existing := range roots {
if samePath(existing.Path, spec.Path) && existing.Plugin == spec.Plugin {
return
}
}
roots = append(roots, spec)
}
// Enabled plugin packages contribute command dirs before user/project dirs,
// so explicit commands still win exact canonical-name clashes.
for _, spec := range pluginPackageCommandRoots() {
add(spec)
}
if dir := legacyOSSupportDir(); dir != "" {
add(command.Root{Path: filepath.Join(dir, "commands")})
}
for _, legacy := range legacyXDGConfigPaths() {
add(command.Root{Path: filepath.Join(filepath.Dir(legacy), "commands")})
}
if home, err := osUserHomeDir(); err == nil {
for _, dir := range conventionSubdirsAsc(home, "commands") {
add(command.Root{Path: dir})
}
}
if dir := userConfigDir(); dir != "" {
add(command.Root{Path: filepath.Join(dir, "commands")})
}
if dir := userSupportDir(); dir != "" && !samePath(dir, userConfigDir()) {
add(command.Root{Path: filepath.Join(dir, "commands")})
}
for _, dir := range conventionSubdirsAsc(root, "commands") {
add(command.Root{Path: dir})
}
return roots
}
// SourcePath returns the highest-priority config file that exists, or "" if none.
func SourcePath() string {
return SourcePathForRoot(".")
}
// SourcePathForRoot returns the highest-priority config file that exists under
// root, or "" if none. Equivalent to SourcePath() when root is ".".
func SourcePathForRoot(root string) string {
root = resolveRoot(root)
projectTOML := "reasonix.toml"
if root != "." {
projectTOML = filepath.Join(root, "reasonix.toml")
}
if _, err := os.Stat(projectTOML); err == nil {
return projectTOML
}
if uc := userConfigLoadPath(); uc != "" {
if _, err := os.Stat(uc); err == nil {
return uc
}
}
return ""
}