返回 DeepSeek-Reasonix
paths.go
根目录 / internal / config / paths.go
1 package config
2
3 import (
4 "fmt"
5 "hash/fnv"
6 "os"
7 "path/filepath"
8 "runtime"
9 "strings"
10 "unicode/utf8"
11
12 "reasonix/internal/command"
13 )
14
15 var (
16 runtimeGOOS = runtime.GOOS
17 osUserHomeDir = os.UserHomeDir
18 osUserConfigDir = func() string {
19 dir, err := os.UserConfigDir()
20 if err != nil {
21 return ""
22 }
23 return dir
24 }
25 osUserCacheDir = func() string {
26 dir, err := os.UserCacheDir()
27 if err != nil {
28 return ""
29 }
30 return dir
31 }
32 )
33
34 func userConfigPath() string {
35 dir := userConfigDir()
36 if dir == "" {
37 return ""
38 }
39 return filepath.Join(dir, "config.toml")
40 }
41
42 func userConfigDir() string {
43 return reasonixHomeDir()
44 }
45
46 func reasonixHomeDir() string {
47 if dir := cleanEnvDir("REASONIX_HOME"); dir != "" {
48 return dir
49 }
50 if runtimeGOOS == "windows" {
51 if dir := osUserConfigDir(); dir != "" {
52 return filepath.Join(dir, "reasonix")
53 }
54 if home, err := osUserHomeDir(); err == nil && home != "" {
55 return filepath.Join(home, "AppData", "Roaming", "reasonix")
56 }
57 return ""
58 }
59 if home, err := osUserHomeDir(); err == nil && home != "" {
60 return filepath.Join(home, ".reasonix")
61 }
62 if dir := osUserConfigDir(); dir != "" {
63 return filepath.Join(dir, "reasonix")
64 }
65 return ""
66 }
67
68 func userConfigLoadPath() string {
69 primary := userConfigPath()
70 if primary == "" {
71 return legacyUserConfigPath()
72 }
73 if _, err := os.Stat(primary); err == nil {
74 return primary
75 }
76 if legacy := legacyUserConfigPath(); legacy != "" {
77 if _, err := os.Stat(legacy); err == nil {
78 return legacy
79 }
80 }
81 for _, legacy := range legacyXDGConfigPaths() {
82 if legacy == "" || samePath(legacy, primary) {
83 continue
84 }
85 if _, err := os.Stat(legacy); err == nil {
86 return legacy
87 }
88 }
89 return primary
90 }
91
92 func legacyUserConfigPath() string {
93 dir := legacyOSSupportDir()
94 if dir == "" {
95 return ""
96 }
97 path := filepath.Join(dir, "config.toml")
98 if primary := userConfigPath(); primary != "" && samePath(path, primary) {
99 return ""
100 }
101 return path
102 }
103
104 func userConfigCandidatePaths() []string {
105 var paths []string
106 if p := userConfigPath(); p != "" {
107 paths = append(paths, p)
108 }
109 if p := legacyUserConfigPath(); p != "" {
110 paths = append(paths, p)
111 }
112 paths = append(paths, legacyXDGConfigPaths()...)
113 return paths
114 }
115
116 func legacyXDGConfigPaths() []string {
117 if IsolatedHomeDir() != "" {
118 return nil
119 }
120 if runtimeGOOS == "windows" {
121 return nil
122 }
123 seen := map[string]bool{}
124 var paths []string
125 add := func(path string) {
126 if path == "" {
127 return
128 }
129 path = filepath.Clean(path)
130 if seen[path] {
131 return
132 }
133 seen[path] = true
134 paths = append(paths, path)
135 }
136 if dir := cleanEnvDir("XDG_CONFIG_HOME"); dir != "" {
137 add(filepath.Join(dir, "reasonix", "config.toml"))
138 }
139 if home, err := osUserHomeDir(); err == nil && home != "" {
140 add(filepath.Join(home, ".config", "reasonix", "config.toml"))
141 }
142 return paths
143 }
144
145 func userSupportDir() string {
146 if dir := cleanEnvDir("REASONIX_STATE_HOME"); dir != "" {
147 return dir
148 }
149 return reasonixHomeDir()
150 }
151
152 func legacyOSSupportDir() string {
153 if IsolatedHomeDir() != "" {
154 return ""
155 }
156 dir := osUserConfigDir()
157 if dir == "" {
158 return ""
159 }
160 path := filepath.Join(dir, "reasonix")
161 if current := reasonixHomeDir(); current != "" && samePath(path, current) {
162 return ""
163 }
164 return path
165 }
166
167 func userCacheDir() string {
168 if dir := cleanEnvDir("REASONIX_CACHE_HOME"); dir != "" {
169 return dir
170 }
171 if dir := cleanEnvDir("REASONIX_HOME"); dir != "" {
172 return filepath.Join(dir, "cache")
173 }
174 dir := osUserCacheDir()
175 if dir == "" {
176 return ""
177 }
178 return filepath.Join(dir, "reasonix")
179 }
180
181 func cleanEnvDir(name string) string {
182 dir := strings.TrimSpace(os.Getenv(name))
183 if dir == "" {
184 return ""
185 }
186 dir = ExpandVars(dir)
187 if dir == "~" {
188 if home, err := osUserHomeDir(); err == nil && home != "" {
189 dir = home
190 }
191 } else if strings.HasPrefix(dir, "~/") || strings.HasPrefix(dir, `~\`) {
192 if home, err := osUserHomeDir(); err == nil && home != "" {
193 dir = filepath.Join(home, dir[2:])
194 }
195 }
196 if !filepath.IsAbs(dir) {
197 if abs, err := filepath.Abs(dir); err == nil {
198 dir = abs
199 }
200 }
201 return filepath.Clean(dir)
202 }
203
204 func samePath(a, b string) bool {
205 if a == "" || b == "" {
206 return false
207 }
208 aa, aerr := filepath.Abs(a)
209 bb, berr := filepath.Abs(b)
210 if aerr == nil {
211 a = aa
212 }
213 if berr == nil {
214 b = bb
215 }
216 return filepath.Clean(a) == filepath.Clean(b)
217 }
218
219 // IsolatedHomeDir returns the REASONIX_HOME directory when it has been
220 // explicitly set via the environment variable. A non-empty return signals a
221 // self-contained runtime that must not fall back to legacy OS-default data
222 // paths or import data from the system-wide production install.
223 func IsolatedHomeDir() string {
224 return cleanEnvDir("REASONIX_HOME")
225 }
226
227 // userConfigDisplayPath is userConfigPath collapsed to a ~-relative form for
228 // comments rendered into the user's own config.toml, so Windows users see the
229 // real location instead of a hardcoded ~/.reasonix path.
230 func userConfigDisplayPath() string {
231 p := userConfigPath()
232 if p == "" {
233 return "<os-config-dir>/reasonix/config.toml"
234 }
235 if home, err := osUserHomeDir(); err == nil && home != "" {
236 if rel, err := filepath.Rel(home, p); err == nil && !strings.HasPrefix(rel, "..") {
237 return "~/" + filepath.ToSlash(rel)
238 }
239 }
240 return p
241 }
242
243 // UserConfigPath is the user-global config.toml. It lives under Reasonix home:
244 // REASONIX_HOME/config.toml, then ~/.reasonix/config.toml on Unix-like systems,
245 // or %AppData%/reasonix/config.toml on Windows. If %AppData% is unavailable on
246 // Windows, it falls back to %USERPROFILE%/AppData/Roaming/reasonix/config.toml.
247 // "" when the user config dir can't be resolved.
248 func UserConfigPath() string { return userConfigPath() }
249
250 // LegacyUserConfigPath is the old OS app-support config.toml path when it
251 // differs from UserConfigPath. It is read as a compatibility fallback when the
252 // primary user config does not exist.
253 func LegacyUserConfigPath() string { return legacyUserConfigPath() }
254
255 // LegacyUserConfigPaths returns every known legacy user config path that differs
256 // from the current v1.8.1 Reasonix-home config path.
257 func LegacyUserConfigPaths() []string {
258 primary := userConfigPath()
259 var out []string
260 add := func(path string) {
261 if path == "" || samePath(path, primary) {
262 return
263 }
264 for _, existing := range out {
265 if samePath(existing, path) {
266 return
267 }
268 }
269 out = append(out, path)
270 }
271 add(legacyUserConfigPath())
272 for _, path := range legacyXDGConfigPaths() {
273 add(path)
274 }
275 return out
276 }
277
278 // ReasonixManagedConfigPaths returns the Reasonix-owned user configuration
279 // FILES that model-driven tools may repair on the user's request, each gated
280 // by a fresh per-write human approval: the current config.toml, compatibility
281 // TOML locations, and the legacy v0.x ~/.reasonix/config.json. Individual
282 // files, never directories — the Reasonix home also holds credentials (.env),
283 // global hooks (settings.json), skills, and session stores, and none of those
284 // may ride along on a config repair.
285 func ReasonixManagedConfigPaths() []string {
286 var out []string
287 out = appendUniquePath(out, UserConfigPath())
288 for _, path := range LegacyUserConfigPaths() {
289 out = appendUniquePath(out, path)
290 }
291 out = appendUniquePath(out, legacyConfigPath())
292 return out
293 }
294
295 func appendUniquePath(paths []string, path string) []string {
296 path = strings.TrimSpace(path)
297 if path == "" {
298 return paths
299 }
300 clean := filepath.Clean(path)
301 for _, existing := range paths {
302 if samePath(existing, clean) {
303 return paths
304 }
305 }
306 return append(paths, clean)
307 }
308
309 // ReasonixHomeDir is the current Reasonix home directory. It honors
310 // REASONIX_HOME, then uses ~/.reasonix on macOS/Linux or %APPDATA%/reasonix on
311 // Windows, with a %USERPROFILE%/AppData/Roaming fallback when %APPDATA% is
312 // unavailable.
313 func ReasonixHomeDir() string { return reasonixHomeDir() }
314
315 // RemoteStateDir is local state for the remote-SSH module (the managed
316 // known_hosts file, cached host metadata): <Reasonix home>/remote. Routed
317 // through the home resolver so REASONIX_HOME isolation holds.
318 func RemoteStateDir() string {
319 home := reasonixHomeDir()
320 if strings.TrimSpace(home) == "" {
321 return ""
322 }
323 return filepath.Join(home, "remote")
324 }
325
326 // RemoteKnownHostsPath is the Reasonix-managed known_hosts file (OpenSSH
327 // format) that records TOFU-accepted host keys. The user's own
328 // ~/.ssh/known_hosts is only ever read, never written.
329 func RemoteKnownHostsPath() string {
330 dir := RemoteStateDir()
331 if dir == "" {
332 return ""
333 }
334 return filepath.Join(dir, "known_hosts")
335 }
336
337 // MissingReasoningWarnStateDir is the shared directory for the rate-limited
338 // missing tool-call thinking recovery gate (#7059): <Reasonix home>/state. The
339 // legacy name preserves callers and the existing state-file contract. Routed
340 // through the home resolver so REASONIX_HOME isolation holds.
341 func MissingReasoningWarnStateDir() string {
342 home := reasonixHomeDir()
343 if strings.TrimSpace(home) == "" {
344 return ""
345 }
346 return filepath.Join(home, "state")
347 }
348
349 // WorkspaceLeaseDir stores cross-process Delivery writer locks outside user
350 // workspaces. It intentionally follows the cache root rather than project or
351 // session state: taking a lease must never dirty the repository it protects.
352 func WorkspaceLeaseDir() string {
353 // Deliberately ignore REASONIX_HOME/REASONIX_CACHE_HOME here. Two app
354 // instances with different state profiles can still open the same user
355 // workspace, so their safety lock must converge on one OS-user cache root.
356 dir := osUserCacheDir()
357 if strings.TrimSpace(dir) == "" {
358 return ""
359 }
360 return filepath.Join(dir, "reasonix", "workspace-leases")
361 }
362
363 // RepairMutationLockDir stores target-path repair locks in the OS-user cache.
364 // It deliberately ignores Reasonix home/cache overrides: isolated instances
365 // can still repair the same project reasonix.toml, so their locks must converge.
366 func RepairMutationLockDir() string {
367 dir := osUserCacheDir()
368 if strings.TrimSpace(dir) == "" {
369 return ""
370 }
371 return filepath.Join(dir, "reasonix", "repair-mutation-locks")
372 }
373
374 // DeliveryWorktreeDir is durable storage for user-visible isolated Delivery
375 // workspaces. Explicit state/home overrides remain authoritative. Windows uses
376 // LocalAppData by default so large Git worktrees do not roam with the user's
377 // profile; other platforms keep using Reasonix state storage.
378 func DeliveryWorktreeDir() string {
379 if dir := cleanEnvDir("REASONIX_STATE_HOME"); dir != "" {
380 return filepath.Join(dir, "worktrees")
381 }
382 if dir := cleanEnvDir("REASONIX_HOME"); dir != "" {
383 return filepath.Join(dir, "worktrees")
384 }
385 if runtimeGOOS == "windows" {
386 if dir := osUserCacheDir(); dir != "" {
387 return filepath.Join(dir, "reasonix", "worktrees")
388 }
389 if home, err := osUserHomeDir(); err == nil && home != "" {
390 return filepath.Join(home, "AppData", "Local", "reasonix", "worktrees")
391 }
392 return ""
393 }
394 dir := userSupportDir()
395 if dir == "" {
396 return ""
397 }
398 return filepath.Join(dir, "worktrees")
399 }
400
401 // UserCredentialsPath is the reasonix-owned global .env file under Reasonix
402 // home. It is the single source for provider credentials saved by Reasonix, so
403 // stale shell, Windows, project, or home env vars cannot silently override keys
404 // the user saved through setup or settings. "" when Reasonix home can't be
405 // resolved.
406 func UserCredentialsPath() string {
407 dir := reasonixHomeDir()
408 if dir == "" {
409 return ""
410 }
411 return filepath.Join(dir, ".env")
412 }
413
414 // ArchiveDir is where compacted conversation history is archived for
415 // traceability (one timestamped .jsonl per compaction). Empty if the user state
416 // directory cannot be resolved, in which case archiving is skipped.
417 func ArchiveDir() string {
418 dir := userSupportDir()
419 if dir == "" {
420 return ""
421 }
422 return filepath.Join(dir, "archive")
423 }
424
425 // SessionDir is where chat sessions are persisted (one .jsonl per session).
426 // Used by `reasonix --continue` / `--resume` to find the recent ones. Empty
427 // if the user state dir can't be resolved — sessions then aren't saved.
428 func SessionDir() string {
429 dir := userSupportDir()
430 if dir == "" {
431 return ""
432 }
433 return filepath.Join(dir, "sessions")
434 }
435
436 // StatsDir is where usage statistics are persisted (one .jsonl per day, e.g.
437 // stats/2026-08-02.jsonl). It lives under the user state root — not the install
438 // directory, which is typically read-only and replaced on upgrade — so usage
439 // records survive app updates. Empty if the user state dir can't be resolved,
440 // in which case usage accounting is skipped.
441 func StatsDir() string {
442 dir := userSupportDir()
443 if dir == "" {
444 return ""
445 }
446 return filepath.Join(dir, "stats")
447 }
448
449 // ProjectSessionDir is the per-workspace session directory the desktop sidebar
450 // lists: <state root>/projects/<slug>/sessions. Empty when either the state root
451 // or workspaceRoot doesn't resolve.
452 func ProjectSessionDir(workspaceRoot string) string {
453 base := MemoryUserDir()
454 root := strings.TrimSpace(workspaceRoot)
455 if base == "" || root == "" {
456 return ""
457 }
458 if abs, err := filepath.Abs(root); err == nil {
459 root = abs
460 }
461 return filepath.Join(base, "projects", WorkspaceSlug(root), "sessions")
462 }
463
464 // WorkspaceSlug flattens an absolute workspace path into the directory name
465 // used under <config root>/projects. Windows spells the same folder with
466 // varying case (drive-letter case, Explorer renames), so the slug folds case
467 // there — matching agent.CanonicalSessionPath's key form — or equivalent
468 // spellings of one workspace would produce distinct slug strings. Existing
469 // mixed-case slug directories need no migration: NTFS resolves names
470 // case-insensitively, so the folded slug opens the same directory.
471 func WorkspaceSlug(absPath string) string {
472 if runtimeGOOS == "windows" {
473 absPath = strings.ToLower(absPath)
474 }
475 slug := strings.NewReplacer(string(os.PathSeparator), "-", "/", "-", "\\", "-", ":", "-").Replace(absPath)
476 return boundFilenameComponent(slug, 255)
477 }
478
479 // boundFilenameComponent caps a derived filename component at the common
480 // per-component filesystem limit (255 bytes on ext4/APFS/NTFS). maxLen is the
481 // byte budget for this component (path segments pass 255; names that gain an
482 // extension pass 255 minus the extension length). Inputs at or under the
483 // budget pass through byte-identical — every component that ever existed on
484 // disk is under the budget, or it could not have been created — so existing
485 // directories and files keep resolving. Only inputs that would previously
486 // have failed with ENAMETOOLONG are truncated, with an FNV-1a hash of the
487 // full input appended so distinct deep paths cannot collapse to one name.
488 func boundFilenameComponent(s string, maxLen int) string {
489 if maxLen <= 0 || len(s) <= maxLen {
490 return s
491 }
492 h := fnv.New64a()
493 _, _ = h.Write([]byte(s))
494 budget := maxLen - 17 // room for "-" + 16 hex digits
495 prefix := s[:budget]
496 // Back off to a rune boundary so a multi-byte character is never split.
497 for len(prefix) > 0 && !utf8.ValidString(prefix) {
498 prefix = prefix[:len(prefix)-1]
499 }
500 return fmt.Sprintf("%s-%016x", prefix, h.Sum64())
501 }
502
503 // BoundFilenameComponent is the exported form for sibling packages deriving
504 // filename components from unbounded input. maxLen is the byte budget for the
505 // component (pass 255 for a bare path segment; subtract the extension length
506 // when one will be appended).
507 func BoundFilenameComponent(s string, maxLen int) string {
508 return boundFilenameComponent(s, maxLen)
509 }
510
511 // CacheDir is the per-user cache root for derived/regenerable artefacts: MCP
512 // handshake snapshots, plugin startup-latency telemetry. Empty when the OS dir is
513 // unavailable — callers must tolerate that (caching is best-effort).
514 func CacheDir() string {
515 dir := userCacheDir()
516 if dir == "" {
517 return ""
518 }
519 return dir
520 }
521
522 // MemoryUserDir returns the reasonix user state root (…/reasonix), under which
523 // the user-global REASONIX.md and the per-project auto-memory store live. Empty
524 // when the user state dir can't be resolved, which disables user-scoped memory.
525 func MemoryUserDir() string {
526 return userSupportDir()
527 }
528
529 // ConventionDirs are the parent directories scanned for agent assets (skills,
530 // commands), in canonical-first order. .reasonix is ours; .agents / .agent /
531 // .claude let users drop in assets authored for other agent tools without moving
532 // files. Shared so skills (internal/skill) and commands (CommandDirs) discover
533 // the same set. Note: hooks are NOT scanned across these — a .claude/settings.json
534 // uses a different hook schema that can't be parsed as ours, so hooks stay in
535 // .reasonix/settings.json (see internal/hook).
536 var ConventionDirs = []string{".reasonix", ".agents", ".agent", ".claude"}
537
538 // conventionSubdirsAsc joins sub under each ConventionDir of base, in ascending
539 // priority (reverse of ConventionDirs) so the canonical .reasonix ends up the
540 // highest-priority entry — command.Load lets a later directory win on a clash.
541 func conventionSubdirsAsc(base, sub string) []string {
542 out := make([]string, 0, len(ConventionDirs))
543 for i := len(ConventionDirs) - 1; i >= 0; i-- {
544 out = append(out, filepath.Join(base, ConventionDirs[i], sub))
545 }
546 return out
547 }
548
549 // CommandDirs returns the directories scanned for custom slash commands, lowest
550 // priority first, so a later (more specific) directory overrides an earlier one
551 // on a name clash. Order: home-dir convention dirs (~/.claude/commands …
552 // ~/.reasonix/commands), the Reasonix home commands dir, the legacy OS
553 // app-support dir if different, then the project's
554 // convention dirs (.claude/commands … .reasonix/commands). Scanning the .claude /
555 // .agents / .agent dirs lets commands authored for other agent tools (same .md +
556 // frontmatter format) work here unchanged.
557 func CommandDirs() []string {
558 return CommandDirsForRoot(".")
559 }
560
561 // CommandDirsForRoot is like CommandDirs but resolves the project convention
562 // dirs under root instead of the current working directory. Global dirs are
563 // unchanged — they are always user-scoped.
564 func CommandDirsForRoot(root string) []string {
565 roots := CommandRootsForRoot(root)
566 dirs := make([]string, 0, len(roots))
567 for _, spec := range roots {
568 dirs = append(dirs, spec.Path)
569 }
570 return dirs
571 }
572
573 // CommandRootsForRoot is the ownership-aware form of CommandDirsForRoot.
574 // Plugin roots retain their package name so the loader can expose stable,
575 // package-qualified command names and hidden short-name compatibility aliases.
576 func CommandRootsForRoot(root string) []command.Root {
577 root = resolveRoot(root)
578 var roots []command.Root
579 add := func(spec command.Root) {
580 if spec.Path == "" {
581 return
582 }
583 for _, existing := range roots {
584 if samePath(existing.Path, spec.Path) && existing.Plugin == spec.Plugin {
585 return
586 }
587 }
588 roots = append(roots, spec)
589 }
590 // Enabled plugin packages contribute command dirs before user/project dirs,
591 // so explicit commands still win exact canonical-name clashes.
592 for _, spec := range pluginPackageCommandRoots() {
593 add(spec)
594 }
595 if dir := legacyOSSupportDir(); dir != "" {
596 add(command.Root{Path: filepath.Join(dir, "commands")})
597 }
598 for _, legacy := range legacyXDGConfigPaths() {
599 add(command.Root{Path: filepath.Join(filepath.Dir(legacy), "commands")})
600 }
601 if home, err := osUserHomeDir(); err == nil {
602 for _, dir := range conventionSubdirsAsc(home, "commands") {
603 add(command.Root{Path: dir})
604 }
605 }
606 if dir := userConfigDir(); dir != "" {
607 add(command.Root{Path: filepath.Join(dir, "commands")})
608 }
609 if dir := userSupportDir(); dir != "" && !samePath(dir, userConfigDir()) {
610 add(command.Root{Path: filepath.Join(dir, "commands")})
611 }
612 for _, dir := range conventionSubdirsAsc(root, "commands") {
613 add(command.Root{Path: dir})
614 }
615 return roots
616 }
617
618 // SourcePath returns the highest-priority config file that exists, or "" if none.
619 func SourcePath() string {
620 return SourcePathForRoot(".")
621 }
622
623 // SourcePathForRoot returns the highest-priority config file that exists under
624 // root, or "" if none. Equivalent to SourcePath() when root is ".".
625 func SourcePathForRoot(root string) string {
626 root = resolveRoot(root)
627 projectTOML := "reasonix.toml"
628 if root != "." {
629 projectTOML = filepath.Join(root, "reasonix.toml")
630 }
631 if _, err := os.Stat(projectTOML); err == nil {
632 return projectTOML
633 }
634 if uc := userConfigLoadPath(); uc != "" {
635 if _, err := os.Stat(uc); err == nil {
636 return uc
637 }
638 }
639 return ""
640 }
641
641 lines GO