@@ -6,6 +6,7 @@ package doctor
66import (
77 "os"
88 "path/filepath"
9+ "sort"
910 "strings"
1011
1112 "github.com/steveyegge/gastown/internal/shell"
@@ -103,9 +104,222 @@ func (c *GlobalStateCheck) Run(ctx *CheckContext) *CheckResult {
103104}
104105
105106func hasShellIntegration (rcPath string ) bool {
106- data , err := os .ReadFile (rcPath )
107+ // Look for official marker (from gt shell install) or manual sourcing of the hook script.
108+ markers := []string {"Gas Town Integration" , "shell-hook.sh" }
109+ return checkSourceChain (rcPath , markers , make (map [string ]bool ), 0 )
110+ }
111+
112+ // checkSourceChain reads filePath, checks for any marker string, and
113+ // recursively follows source/. directives found in the file. This handles
114+ // users with modular shell configs (e.g. .zshrc sources profile-specific
115+ // files that source the Gas Town hook script).
116+ func checkSourceChain (filePath string , markers []string , visited map [string ]bool , depth int ) bool {
117+ if depth > 5 {
118+ return false
119+ }
120+
121+ absPath , err := filepath .Abs (filePath )
107122 if err != nil {
108123 return false
109124 }
110- return strings .Contains (string (data ), "Gas Town Integration" )
125+ if visited [absPath ] {
126+ return false
127+ }
128+ visited [absPath ] = true
129+
130+ data , err := os .ReadFile (absPath )
131+ if err != nil {
132+ return false
133+ }
134+ content := string (data )
135+
136+ for _ , marker := range markers {
137+ if strings .Contains (content , marker ) {
138+ return true
139+ }
140+ }
141+
142+ homeDir , _ := os .UserHomeDir ()
143+ vars := extractShellVars (content , homeDir )
144+
145+ for _ , line := range strings .Split (content , "\n " ) {
146+ for _ , sourced := range resolveSourcePaths (line , homeDir , vars ) {
147+ if checkSourceChain (sourced , markers , visited , depth + 1 ) {
148+ return true
149+ }
150+ }
151+ }
152+
153+ return false
154+ }
155+
156+ // extractShellVars extracts simple variable assignments (VAR="val" or
157+ // export VAR="val") from shell content for resolving paths in source
158+ // directives. Command substitutions and complex expressions are ignored.
159+ func extractShellVars (content , homeDir string ) map [string ]string {
160+ vars := map [string ]string {"HOME" : homeDir }
161+
162+ for _ , line := range strings .Split (content , "\n " ) {
163+ line = strings .TrimSpace (line )
164+ if strings .HasPrefix (line , "#" ) {
165+ continue
166+ }
167+ line = strings .TrimPrefix (line , "export " )
168+
169+ eqIdx := strings .Index (line , "=" )
170+ if eqIdx == - 1 {
171+ continue
172+ }
173+
174+ name := strings .TrimSpace (line [:eqIdx ])
175+ if ! isShellVarName (name ) {
176+ continue
177+ }
178+
179+ value := strings .TrimSpace (line [eqIdx + 1 :])
180+ value = unquoteShell (value )
181+
182+ // Skip command substitutions and complex expressions
183+ if strings .Contains (value , "$(" ) || strings .Contains (value , "`" ) {
184+ continue
185+ }
186+
187+ value = expandShellVars (value , vars , homeDir )
188+ vars [name ] = value
189+ }
190+
191+ return vars
192+ }
193+
194+ func isShellVarName (s string ) bool {
195+ if s == "" {
196+ return false
197+ }
198+ for i , c := range s {
199+ if c == '_' || (c >= 'A' && c <= 'Z' ) || (c >= 'a' && c <= 'z' ) {
200+ continue
201+ }
202+ if i > 0 && c >= '0' && c <= '9' {
203+ continue
204+ }
205+ return false
206+ }
207+ return true
208+ }
209+
210+ func unquoteShell (s string ) string {
211+ if len (s ) >= 2 {
212+ if (s [0 ] == '"' && s [len (s )- 1 ] == '"' ) || (s [0 ] == '\'' && s [len (s )- 1 ] == '\'' ) {
213+ return s [1 : len (s )- 1 ]
214+ }
215+ }
216+ return s
217+ }
218+
219+ // expandShellVars expands ~, ${VAR}, and $VAR references. Longer variable
220+ // names are replaced first to avoid partial prefix matches.
221+ func expandShellVars (s string , vars map [string ]string , homeDir string ) string {
222+ if strings .HasPrefix (s , "~/" ) {
223+ s = homeDir + s [1 :]
224+ }
225+
226+ for name , value := range vars {
227+ s = strings .ReplaceAll (s , "${" + name + "}" , value )
228+ }
229+
230+ names := make ([]string , 0 , len (vars ))
231+ for name := range vars {
232+ names = append (names , name )
233+ }
234+ sort .Slice (names , func (i , j int ) bool {
235+ return len (names [i ]) > len (names [j ])
236+ })
237+ for _ , name := range names {
238+ s = strings .ReplaceAll (s , "$" + name , vars [name ])
239+ }
240+
241+ return s
242+ }
243+
244+ // resolveSourcePaths extracts file paths from source/. directives,
245+ // expanding variables and falling back to glob patterns for unresolved
246+ // variables.
247+ func resolveSourcePaths (line , homeDir string , vars map [string ]string ) []string {
248+ line = strings .TrimSpace (line )
249+ if strings .HasPrefix (line , "#" ) {
250+ return nil
251+ }
252+
253+ // Strip conditional prefixes: [[ ... ]] && source ..., [[ ! ... ]] || source ...
254+ for _ , sep := range []string {"&& source " , "|| source " , "&& . " , "|| . " } {
255+ if idx := strings .Index (line , sep ); idx != - 1 {
256+ line = strings .TrimSpace (line [idx + 3 :])
257+ break
258+ }
259+ }
260+
261+ var raw string
262+ switch {
263+ case strings .HasPrefix (line , "source " ):
264+ raw = strings .TrimSpace (line [7 :])
265+ case strings .HasPrefix (line , ". " ):
266+ raw = strings .TrimSpace (line [2 :])
267+ default :
268+ return nil
269+ }
270+
271+ raw = unquoteShell (raw )
272+
273+ // Strip trailing inline comment
274+ if idx := strings .Index (raw , " #" ); idx != - 1 {
275+ raw = strings .TrimSpace (raw [:idx ])
276+ }
277+
278+ resolved := expandShellVars (raw , vars , homeDir )
279+
280+ if ! strings .Contains (resolved , "$" ) {
281+ return []string {resolved }
282+ }
283+
284+ // Unresolved variables remain — try glob by replacing $VAR with *
285+ globbed := replaceUnresolvedVars (resolved )
286+ if strings .ContainsAny (globbed , "?[" ) {
287+ return nil
288+ }
289+ matches , err := filepath .Glob (globbed )
290+ if err != nil || len (matches ) == 0 {
291+ return nil
292+ }
293+ return matches
294+ }
295+
296+ // replaceUnresolvedVars replaces remaining $VAR and ${VAR} patterns with *
297+ // so the path can be used as a glob pattern.
298+ func replaceUnresolvedVars (s string ) string {
299+ var b strings.Builder
300+ i := 0
301+ for i < len (s ) {
302+ if s [i ] == '$' {
303+ if i + 1 < len (s ) && s [i + 1 ] == '{' {
304+ end := strings .Index (s [i :], "}" )
305+ if end != - 1 {
306+ b .WriteByte ('*' )
307+ i += end + 1
308+ continue
309+ }
310+ }
311+ j := i + 1
312+ for j < len (s ) && (s [j ] == '_' || (s [j ] >= 'A' && s [j ] <= 'Z' ) || (s [j ] >= 'a' && s [j ] <= 'z' ) || (j > i + 1 && s [j ] >= '0' && s [j ] <= '9' )) {
313+ j ++
314+ }
315+ if j > i + 1 {
316+ b .WriteByte ('*' )
317+ i = j
318+ continue
319+ }
320+ }
321+ b .WriteByte (s [i ])
322+ i ++
323+ }
324+ return b .String ()
111325}
0 commit comments