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