Skill: nyluke/subwayskill (version 1.0.0)

--- name: subwayskill description: > Fetch NYC subway departure times. Use when the user asks about subway trains, commute times, when the next train is, or NYC transit schedules. Covers all subway lines (1-7, A-G, J, Z, L, N, Q, R, W, S, SIR). version: 1.0.0 author: nyluke tags: - transit - nyc - subway - mta - commute homepage: https://github.com/nyluke/subwayskill license: MIT allowed-tools: Bash(subwayskill:*) metadata: openclaw: emoji: "\U0001F687" category: transit requires: bins: - subwayskill install: - go: github.com/nyluke/subwayskill@latest --- # NYC Subway Departure Times Run the `subwayskill` CLI to answer questions about NYC subway times. ## Commands ```bash # All default stations (Clinton-Washington C, Bergen 2/3, 7 Av Q, Atlantic Av 4/5) subwayskill # Specific line and station (fuzzy match — lowercase, partial names work) subwayskill C clinton-washington subwayskill 2 bergen # Filter direction: N (northbound/Manhattan) or S (southbound/Brooklyn) subwayskill 2 bergen -d N # Future time — automatically falls back to static schedule subwayskill 2 bergen -t 19:00 # JSON output for structured data subwayskill --json subwayskill C clinton-washington --json # Control time window (minutes) subwayskill 2 bergen -w 60 ``` ## Interpreting output - **realtime**: live data from MTA GTFS-RT feeds, shows relative times ("3 min") and absolute times - **scheduled**: static GTFS schedule fallback, shows absolute times only — used when the requested time is beyond realtime coverage - Directions show destination labels when available (e.g., "Manhattan-bound", "Coney Island-bound") ## Tips - Station names are fuzzy-matched: "clinton" finds "Clinton-Washington Avs", "atlantic" finds "Atlantic Av-Barclays Ctr" - If the user doesn't specify a station, run with no args to show all defaults - If the user asks about a future time (e.g., "when's the 2 train at Bergen at 7pm?"), use `-t HH:MM` - Use `--json` when you need to do further processing or comparisons - An unknown station returns suggestions — relay those to the user

path: README.md
content: # subwayskill

NYC subway departure times CLI. Fetches realtime train arrivals from MTA GTFS-RT feeds, with automatic fallback to static schedule data for future times.

Designed to output LLM-friendly text — useful when you already know your route and just want to know when the next train is.

## Install

```
go install github.com/nyluke/subwayskill@latest
```

Or clone and build:

```
git clone https://github.com/nyluke/subwayskill.git
cd subwayskill
go build
```

## Usage

```
subwayskill [LINE] [STATION] [flags]
```

### No arguments — show default stations (realtime)

```
$ subwayskill

=== NYC Subway Departures (realtime) ===
Query time: 2026-02-16 14:35 EST

--- Clinton-Washington Avs (C) ---
  C northbound (Manhattan-bound): 2 min (14:37), 14 min (14:49), 26 min (15:01)
  C southbound (Euclid Av-bound):  5 min (14:40), 18 min (14:53)

--- Bergen St (2, 3) ---
  2 northbound: 3 min (14:38), 11 min (14:46)
  3 northbound: 7 min (14:42), 19 min (14:54)
  ...
```

Default stations: Clinton-Washington Avs (C), Bergen St (2,3), 7 Av (Q), Atlantic Av-Barclays Ctr (4,5).

### Specific line + station

```
$ subwayskill C clinton-washington
$ subwayskill 2 bergen
```

Station names are fuzzy-matched — lowercase, punctuation-insensitive, partial matches work.

### Direction filter

```
$ subwayskill 2 bergen -d N    # northbound only
$ subwayskill C clinton-washington -d S    # southbound only
```

### Future time — automatic schedule fallback

```
$ subwayskill 2 bergen -t 19:00

=== NYC Subway Departures (scheduled) ===
Target time: 2026-02-16 19:00 EST

--- Bergen St (2) ---
  2 northbound: 18:52, 19:04, 19:16, 19:28
  2 southbound: 18:55, 19:07, 19:19, 19:31
  Source: Supplemented GTFS
```

When the target time is beyond realtime feed coverage, the tool automatically downloads and caches static GTFS schedules (supplemented + regular) to `~/.cache/subwayskill/`.

### JSON output

```
$ subwayskill --json
$ subwayskill C clinton-washington --json
```

### Flags

| Flag | Description |
|------|-------------|
| `-d, --direction N\|S` | Filter northbound or southbound |
| `-t, --time HH:MM` | Target departure time (default: now) |
| `-w, --window INT` | Minutes of departures to show (default: 30 for schedule, all for realtime) |
| `--json` | JSON output |

## Data sources

- **Realtime**: MTA GTFS-RT feeds (no API key required)
- **Schedule**: [MTA static GTFS](https://api.mta.info/) — regular + supplemented (7-day lookahead with planned service changes)

## License

MIT


path: main.go
content: package main

import "subwayskill/cmd"

func main() {
	cmd.Execute()
}


path: internal/display/display.go
content: package display

import (
	"encoding/json"
	"fmt"
	"strings"
	"time"

	"subwayskill/internal/feed"
)

// StationResult groups departures for a single station.
type StationResult struct {
	StationName string
	StopID      string
	Routes      []string
	NorthLabel  string
	SouthLabel  string
	Departures  []feed.Departure
	Source      string // "realtime" or "scheduled"
	SourceNote  string // extra info about schedule source
}

// FormatText renders station results as LLM-friendly text.
func FormatText(results []StationResult, queryTime time.Time, targetTime *time.Time) string {
	var b strings.Builder

	source := "realtime"
	for _, r := range results {
		if r.Source == "scheduled" {
			source = "scheduled"
			break
		}
	}

	loc := queryTime.Location()
	b.WriteString(fmt.Sprintf("=== NYC Subway Departures (%s) ===\n", source))
	if targetTime != nil {
		b.WriteString(fmt.Sprintf("Target time: %s\n", targetTime.In(loc).Format("2006-01-02 15:04 MST")))
	} else {
		b.WriteString(fmt.Sprintf("Query time: %s\n", queryTime.In(loc).Format("2006-01-02 15:04 MST")))
	}

	for _, result := range results {
		routeStr := strings.Join(result.Routes, ", ")
		b.WriteString(fmt.Sprintf("\n--- %s (%s) ---\n", result.StationName, routeStr))

		if len(result.Departures) == 0 {
			b.WriteString("  No upcoming departures found\n")
			continue
		}

		// Group by route and direction
		type groupKey struct {
			Route     string
			Direction string
		}
		groups := make(map[groupKey][]feed.Departure)
		var order []groupKey

		for _, d := range result.Departures {
			key := groupKey{Route: d.Route, Direction: d.Direction}
			if _, exists := groups[key]; !exists {
				order = append(order, key)
			}
			groups[key] = append(groups[key], d)
		}

		for _, key := range order {
			deps := groups[key]
			dirLabel := "northbound"
			if key.Direction == "S" {
				dirLabel = "southbound"
			}

			// Add destination label if available
			destLabel := ""
			if key.Direction == "N" && result.NorthLabel != "" {
				destLabel = fmt.Sprintf(" (%s)", result.NorthLabel)
			} else if key.Direction == "S" && result.SouthLabel != "" {
				destLabel = fmt.Sprintf(" (%s)", result.SouthLabel)
			}

			var timeStrs []string
			for _, d := range deps {
				mins := int(time.Until(d.Time).Minutes())
				if targetTime != nil {
					// For scheduled, just show absolute times
					timeStrs = append(timeStrs, d.Time.In(loc).Format("15:04"))
				} else if mins <= 0 {
					timeStrs = append(timeStrs, fmt.Sprintf("now (%s)", d.Time.In(loc).Format("15:04")))
				} else {
					timeStrs = append(timeStrs, fmt.Sprintf("%d min (%s)", mins, d.Time.In(loc).Format("15:04")))
				}
			}

			b.WriteString(fmt.Sprintf("  %s %s%s: %s\n",
				key.Route, dirLabel, destLabel, strings.Join(timeStrs, ", ")))
		}

		if result.SourceNote != "" {
			b.WriteString(fmt.Sprintf("  Source: %s\n", result.SourceNote))
		}
	}

	return b.String()
}

// JSONOutput is the structured JSON output format.
type JSONOutput struct {
	Source     string          `json:"source"`
	QueryTime string          `json:"query_time"`
	Stations  []JSONStation   `json:"stations"`
}

type JSONStation struct {
	Name       string          `json:"name"`
	StopID     string          `json:"stop_id"`
	Routes     []string        `json:"routes"`
	Source     string          `json:"source"`
	SourceNote string          `json:"source_note,omitempty"`
	Departures []JSONDeparture `json:"departures"`
}

type JSONDeparture struct {
	Route     string `json:"route"`
	Direction string `json:"direction"`
	DirLabel  string `json:"direction_label"`
	Time      string `json:"time"`
	MinAway   int    `json:"minutes_away"`
}

// FormatJSON renders station results as JSON.
func FormatJSON(results []StationResult, queryTime time.Time) (string, error) {
	loc := queryTime.Location()

	source := "realtime"
	for _, r := range results {
		if r.Source == "scheduled" {
			source = "scheduled"
			break
		}
	}

	output := JSONOutput{
		Source:    source,
		QueryTime: queryTime.In(loc).Format(time.RFC3339),
	}

	for _, result := range results {
		js := JSONStation{
			Name:       result.StationName,
			StopID:     result.StopID,
			Routes:     result.Routes,
			Source:     result.Source,
			SourceNote: result.SourceNote,
		}

		for _, d := range result.Departures {
			dirLabel := "northbound"
			if d.Direction == "S" {
				dirLabel = "southbound"
			}

			mins := int(time.Until(d.Time).Minutes())
			if mins < 0 {
				mins = 0
			}

			js.Departures = append(js.Departures, JSONDeparture{
				Route:     d.Route,
				Direction: d.Direction,
				DirLabel:  dirLabel,
				Time:      d.Time.In(loc).Format(time.RFC3339),
				MinAway:   mins,
			})
		}

		output.Stations = append(output.Stations, js)
	}

	data, err := json.MarshalIndent(output, "", "  ")
	if err != nil {
		return "", err
	}
	return string(data), nil
}


path: internal/station/station.go
content: package station

import (
	"encoding/csv"
	"fmt"
	"io"
	"strings"

	_ "embed"
)

//go:embed data/stations.csv
var stationsCSV string

// Station represents a subway station with its metadata.
type Station struct {
	StopID          string
	Name            string
	Routes          []string
	NorthLabel      string
	SouthLabel      string
	Line            string
	Borough         string
	DaytimeRoutes   string
}

// DefaultStation is a pre-configured station the tool shows when run with no args.
type DefaultStation struct {
	Station
	FeedRoutes []string // which routes to show from this station
}

// Defaults returns the 4 default stations.
func Defaults() []DefaultStation {
	return []DefaultStation{
		{
			Station: Station{
				StopID:     "A44",
				Name:       "Clinton-Washington Avs",
				Routes:     []string{"C"},
				NorthLabel: "Manhattan-bound",
				SouthLabel: "Euclid Av-bound",
			},
			FeedRoutes: []string{"C"},
		},
		{
			Station: Station{
				StopID:     "236",
				Name:       "Bergen St",
				Routes:     []string{"2", "3"},
				NorthLabel: "Manhattan-bound",
				SouthLabel: "Flatbush Av-bound",
			},
			FeedRoutes: []string{"2", "3"},
		},
		{
			Station: Station{
				StopID:     "D25",
				Name:       "7 Av",
				Routes:     []string{"Q"},
				NorthLabel: "Manhattan-bound",
				SouthLabel: "Coney Island-bound",
			},
			FeedRoutes: []string{"Q"},
		},
		{
			Station: Station{
				StopID:     "235",
				Name:       "Atlantic Av-Barclays Ctr",
				Routes:     []string{"4", "5"},
				NorthLabel: "Manhattan-bound",
				SouthLabel: "Flatbush Av-bound",
			},
			FeedRoutes: []string{"4", "5"},
		},
	}
}

// allStations is the parsed CSV data, lazily loaded.
var allStations []Station

func loadStations() []Station {
	if allStations != nil {
		return allStations
	}

	r := csv.NewReader(strings.NewReader(stationsCSV))
	header, err := r.Read()
	if err != nil {
		return nil
	}

	idx := make(map[string]int)
	for i, h := range header {
		idx[h] = i
	}

	for {
		record, err := r.Read()
		if err == io.EOF {
			break
		}
		if err != nil {
			continue
		}

		routes := strings.Fields(record[idx["Daytime Routes"]])
		s := Station{
			StopID:        record[idx["GTFS Stop ID"]],
			Name:          record[idx["Stop Name"]],
			Routes:        routes,
			NorthLabel:    record[idx["North Direction Label"]],
			SouthLabel:    record[idx["South Direction Label"]],
			Line:          record[idx["Line"]],
			Borough:       record[idx["Borough"]],
			DaytimeRoutes: record[idx["Daytime Routes"]],
		}
		allStations = append(allStations, s)
	}
	return allStations
}

// normalize strips punctuation, lowercases, and collapses spaces for fuzzy matching.
func normalize(s string) string {
	s = strings.ToLower(s)
	var b strings.Builder
	for _, c := range s {
		if c == '-' || c == '/' || c == '\'' {
			b.WriteRune(' ')
		} else if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == ' ' {
			b.WriteRune(c)
		}
	}
	// collapse multiple spaces
	return strings.Join(strings.Fields(b.String()), " ")
}

// Lookup finds a station by name (fuzzy) that serves the given route.
// Returns the station and which routes at that station to display.
func Lookup(route, nameQuery string) (*Station, error) {
	route = strings.ToUpper(strings.TrimSpace(route))
	query := normalize(nameQuery)

	if query == "" && route == "" {
		return nil, fmt.Errorf("no station or line specified")
	}

	stations := loadStations()

	// Also check defaults for known aliases
	for _, d := range Defaults() {
		if normalize(d.Name) == query || strings.Contains(normalize(d.Name), query) {
			if route == "" || containsRoute(d.Routes, route) {
				s := d.Station
				if route != "" {
					s.Routes = []string{route}
				}
				return &s, nil
			}
		}
	}

	var exactMatches, partialMatches []Station

	for _, s := range stations {
		if route != "" && !containsRoute(s.Routes, route) {
			continue
		}

		normalized := normalize(s.Name)
		if normalized == query {
			match := s
			if route != "" {
				match.Routes = []string{route}
			}
			exactMatches = append(exactMatches, match)
		} else if strings.Contains(normalized, query) {
			match := s
			if route != "" {
				match.Routes = []string{route}
			}
			partialMatches = append(partialMatches, match)
		}
	}

	if len(exactMatches) > 0 {
		return &exactMatches[0], nil
	}
	if len(partialMatches) > 0 {
		return &partialMatches[0], nil
	}

	// Generate suggestions
	suggestions := suggestStations(route, query, stations)
	if len(suggestions) > 0 {
		if route != "" {
			return nil, fmt.Errorf("station %q not found for line %s. Did you mean:\n%s",
				nameQuery, route, formatSuggestions(suggestions))
		}
		return nil, fmt.Errorf("station %q not found. Did you mean:\n%s",
			nameQuery, formatSuggestions(suggestions))
	}

	if route != "" {
		return nil, fmt.Errorf("station %q not found for line %s", nameQuery, route)
	}
	return nil, fmt.Errorf("station %q not found", nameQuery)
}

func containsRoute(routes []string, route string) bool {
	for _, r := range routes {
		if r == route {
			return true
		}
	}
	return false
}

// suggestStations finds stations with similar names or on the same route.
func suggestStations(route, query string, stations []Station) []string {
	var suggestions []string
	seen := make(map[string]bool)

	// First: stations on the same route with any name overlap
	if route != "" {
		for _, s := range stations {
			if !containsRoute(s.Routes, route) {
				continue
			}
			norm := normalize(s.Name)
			queryWords := strings.Fields(query)
			nameWords := strings.Fields(norm)
			for _, qw := range queryWords {
				for _, nw := range nameWords {
					if len(qw) >= 2 && (strings.Contains(nw, qw) || strings.Contains(qw, nw)) {
						if !seen[s.Name] {
							suggestions = append(suggestions, fmt.Sprintf("  %s (%s)", s.Name, s.DaytimeRoutes))
							seen[s.Name] = true
						}
					}
				}
			}
			if len(suggestions) >= 5 {
				break
			}
		}
	}

	// Then: any station with name overlap (no route filter)
	if len(suggestions) < 5 {
		for _, s := range stations {
			norm := normalize(s.Name)
			queryWords := strings.Fields(query)
			for _, qw := range queryWords {
				if len(qw) >= 3 && strings.Contains(norm, qw) {
					if !seen[s.Name] {
						suggestions = append(suggestions, fmt.Sprintf("  %s (%s)", s.Name, s.DaytimeRoutes))
						seen[s.Name] = true
					}
				}
			}
			if len(suggestions) >= 5 {
				break
			}
		}
	}

	// If still nothing, just show some stations on this route
	if len(suggestions) == 0 && route != "" {
		count := 0
		for _, s := range stations {
			if containsRoute(s.Routes, route) && !seen[s.Name] {
				suggestions = append(suggestions, fmt.Sprintf("  %s (%s)", s.Name, s.DaytimeRoutes))
				seen[s.Name] = true
				count++
				if count >= 5 {
					break
				}
			}
		}
	}

	return suggestions
}

func formatSuggestions(suggestions []string) string {
	return strings.Join(suggestions, "\n")
}

// AllRoutes returns all known route IDs from the CSV.
func AllRoutes() []string {
	stations := loadStations()
	seen := make(map[string]bool)
	var routes []string
	for _, s := range stations {
		for _, r := range s.Routes {
			if !seen[r] {
				routes = append(routes, r)
				seen[r] = true
			}
		}
	}
	return routes
}

// IsValidRoute checks if a route ID is known.
func IsValidRoute(route string) bool {
	route = strings.ToUpper(strings.TrimSpace(route))
	for _, r := range AllRoutes() {
		if r == route {
			return true
		}
	}
	return false
}


path: internal/feed/feed.go
content: package feed

import (
	"fmt"
	"io"
	"net/http"
	"sort"
	"strings"
	"time"

	"github.com/MobilityData/gtfs-realtime-bindings/golang/gtfs"
	"google.golang.org/protobuf/proto"
)

// feedURLs maps route IDs to their GTFS-RT feed URLs.
var feedURLs = map[string]string{
	"1": "https://api-endpoint.mta.info/Dataservice/mtagtfsfeeds/nyct%2Fgtfs",
	"2": "https://api-endpoint.mta.info/Dataservice/mtagtfsfeeds/nyct%2Fgtfs",
	"3": "https://api-endpoint.mta.info/Dataservice/mtagtfsfeeds/nyct%2Fgtfs",
	"4": "https://api-endpoint.mta.info/Dataservice/mtagtfsfeeds/nyct%2Fgtfs",
	"5": "https://api-endpoint.mta.info/Dataservice/mtagtfsfeeds/nyct%2Fgtfs",
	"6": "https://api-endpoint.mta.info/Dataservice/mtagtfsfeeds/nyct%2Fgtfs",
	"7": "https://api-endpoint.mta.info/Dataservice/mtagtfsfeeds/nyct%2Fgtfs",
	"S": "https://api-endpoint.mta.info/Dataservice/mtagtfsfeeds/nyct%2Fgtfs",
	"A": "https://api-endpoint.mta.info/Dataservice/mtagtfsfeeds/nyct%2Fgtfs-ace",
	"C": "https://api-endpoint.mta.info/Dataservice/mtagtfsfeeds/nyct%2Fgtfs-ace",
	"E": "https://api-endpoint.mta.info/Dataservice/mtagtfsfeeds/nyct%2Fgtfs-ace",
	"B": "https://api-endpoint.mta.info/Dataservice/mtagtfsfeeds/nyct%2Fgtfs-bdfm",
	"D": "https://api-endpoint.mta.info/Dataservice/mtagtfsfeeds/nyct%2Fgtfs-bdfm",
	"F": "https://api-endpoint.mta.info/Dataservice/mtagtfsfeeds/nyct%2Fgtfs-bdfm",
	"M": "https://api-endpoint.mta.info/Dataservice/mtagtfsfeeds/nyct%2Fgtfs-bdfm",
	"N": "https://api-endpoint.mta.info/Dataservice/mtagtfsfeeds/nyct%2Fgtfs-nqrw",
	"Q": "https://api-endpoint.mta.info/Dataservice/mtagtfsfeeds/nyct%2Fgtfs-nqrw",
	"R": "https://api-endpoint.mta.info/Dataservice/mtagtfsfeeds/nyct%2Fgtfs-nqrw",
	"W": "https://api-endpoint.mta.info/Dataservice/mtagtfsfeeds/nyct%2Fgtfs-nqrw",
	"G":   "https://api-endpoint.mta.info/Dataservice/mtagtfsfeeds/nyct%2Fgtfs-g",
	"J":   "https://api-endpoint.mta.info/Dataservice/mtagtfsfeeds/nyct%2Fgtfs-jz",
	"Z":   "https://api-endpoint.mta.info/Dataservice/mtagtfsfeeds/nyct%2Fgtfs-jz",
	"L":   "https://api-endpoint.mta.info/Dataservice/mtagtfsfeeds/nyct%2Fgtfs-l",
	"SIR": "https://api-endpoint.mta.info/Dataservice/mtagtfsfeeds/nyct%2Fgtfs-si",
}

// Departure represents a single train departure.
type Departure struct {
	Route     string
	Direction string // "N" or "S"
	Time      time.Time
	StopID    string
}

// FeedURL returns the GTFS-RT feed URL for a given route.
func FeedURL(route string) (string, error) {
	url, ok := feedURLs[strings.ToUpper(route)]
	if !ok {
		return "", fmt.Errorf("unknown route: %s", route)
	}
	return url, nil
}

// FeedURLsForRoutes returns deduplicated feed URLs for a set of routes.
func FeedURLsForRoutes(routes []string) map[string][]string {
	// url -> routes served by that feed
	result := make(map[string][]string)
	for _, r := range routes {
		url, err := FeedURL(r)
		if err != nil {
			continue
		}
		result[url] = append(result[url], r)
	}
	return result
}

// FetchDepartures fetches realtime departures for a stop from a feed URL,
// filtered by the given routes and optional direction.
func FetchDepartures(feedURL, stopID string, routes []string, direction string) ([]Departure, error) {
	body, err := fetchFeed(feedURL)
	if err != nil {
		return nil, err
	}
	return parseDepartures(body, stopID, routes, direction)
}

func fetchFeed(url string) ([]byte, error) {
	client := &http.Client{Timeout: 10 * time.Second}
	resp, err := client.Get(url)
	if err != nil {
		return nil, fmt.Errorf("fetching feed: %w", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("feed returned status %d", resp.StatusCode)
	}

	return io.ReadAll(resp.Body)
}

func parseDepartures(data []byte, stopID string, routes []string, direction string) ([]Departure, error) {
	feed := &gtfs.FeedMessage{}
	if err := proto.Unmarshal(data, feed); err != nil {
		return nil, fmt.Errorf("parsing feed: %w", err)
	}

	routeSet := make(map[string]bool)
	for _, r := range routes {
		routeSet[strings.ToUpper(r)] = true
	}

	now := time.Now()
	var departures []Departure

	for _, entity := range feed.Entity {
		tu := entity.GetTripUpdate()
		if tu == nil {
			continue
		}

		routeID := tu.GetTrip().GetRouteId()
		if len(routeSet) > 0 && !routeSet[strings.ToUpper(routeID)] {
			continue
		}

		for _, stu := range tu.GetStopTimeUpdate() {
			sid := stu.GetStopId()
			if !matchesStop(sid, stopID, direction) {
				continue
			}

			var depTime time.Time
			if dep := stu.GetDeparture(); dep != nil && dep.GetTime() > 0 {
				depTime = time.Unix(dep.GetTime(), 0)
			} else if arr := stu.GetArrival(); arr != nil && arr.GetTime() > 0 {
				depTime = time.Unix(arr.GetTime(), 0)
			} else {
				continue
			}

			// Skip trains that have already left
			if depTime.Before(now.Add(-1 * time.Minute)) {
				continue
			}

			dir := "N"
			if strings.HasSuffix(sid, "S") {
				dir = "S"
			}

			departures = append(departures, Departure{
				Route:     routeID,
				Direction: dir,
				Time:      depTime,
				StopID:    sid,
			})
		}
	}

	sort.Slice(departures, func(i, j int) bool {
		return departures[i].Time.Before(departures[j].Time)
	})

	return departures, nil
}

// matchesStop checks if a stop time's stop ID matches our target.
// stopID is the base (e.g. "A44"), direction is "" or "N"/"S".
// Feed stop IDs have direction suffix: "A44N", "A44S".
func matchesStop(feedStopID, targetBase, direction string) bool {
	if !strings.HasPrefix(feedStopID, targetBase) {
		return false
	}
	suffix := strings.TrimPrefix(feedStopID, targetBase)
	if suffix != "N" && suffix != "S" {
		return false
	}
	if direction != "" && suffix != strings.ToUpper(direction) {
		return false
	}
	return true
}


path: cmd/root.go
content: package cmd

import (
	"fmt"
	"os"
	"strings"
	"sync"
	"time"

	"subwayskill/internal/display"
	"subwayskill/internal/feed"
	"subwayskill/internal/schedule"
	"subwayskill/internal/station"

	"github.com/spf13/cobra"
)

var (
	direction string
	timeFlag  string
	window    int
	jsonOut   bool
)

type stationQuery struct {
	name       string
	stopID     string
	routes     []string
	northLabel string
	southLabel string
}

var rootCmd = &cobra.Command{
	Use:   "subwayskill [LINE] [STATION]",
	Short: "NYC subway departure times",
	Long:  "Fetches NYC subway departure times for specific stations. Defaults to Clinton-Washington (C), Bergen (2,3), 7 Av (Q), Atlantic Av (4,5).",
	Args:  cobra.MaximumNArgs(2),
	RunE:  run,
}

func init() {
	rootCmd.Flags().StringVarP(&direction, "direction", "d", "", "filter direction: N (northbound) or S (southbound)")
	rootCmd.Flags().StringVarP(&timeFlag, "time", "t", "", "target departure time (HH:MM), default: now")
	rootCmd.Flags().IntVarP(&window, "window", "w", 0, "minutes of departures to show (default: 30 for schedule, all for realtime)")
	rootCmd.Flags().BoolVar(&jsonOut, "json", false, "output as JSON")
}

func Execute() {
	if err := rootCmd.Execute(); err != nil {
		os.Exit(1)
	}
}

func run(cmd *cobra.Command, args []string) error {
	now := time.Now()
	loc := now.Location()

	// Validate direction
	if direction != "" {
		direction = strings.ToUpper(direction)
		if direction != "N" && direction != "S" {
			return fmt.Errorf("direction must be N or S, got %q", direction)
		}
	}

	// Parse target time
	var targetTime *time.Time
	useSchedule := false

	if timeFlag != "" {
		t, err := time.ParseInLocation("15:04", timeFlag, loc)
		if err != nil {
			return fmt.Errorf("invalid time %q (expected HH:MM): %w", timeFlag, err)
		}
		// Set to today's date
		target := time.Date(now.Year(), now.Month(), now.Day(), t.Hour(), t.Minute(), 0, 0, loc)
		// If the time is in the past, assume tomorrow
		if target.Before(now) {
			target = target.Add(24 * time.Hour)
		}
		targetTime = &target

		// If target is more than 5 minutes in the future, we may need schedule
		if target.Sub(now) > 5*time.Minute {
			useSchedule = true
		}
	}

	// Determine which stations to query
	var queries []stationQuery

	if len(args) >= 1 {
		// Specific line/station
		route := strings.ToUpper(args[0])

		// Validate route
		if !station.IsValidRoute(route) {
			return fmt.Errorf("unknown line %q. Valid lines: 1 2 3 4 5 6 7 A C E B D F M G J Z L N Q R W S SIR", route)
		}

		if len(args) >= 2 {
			nameQuery := args[1]
			s, err := station.Lookup(route, nameQuery)
			if err != nil {
				return err
			}
			queries = append(queries, stationQuery{
				name:       s.Name,
				stopID:     s.StopID,
				routes:     s.Routes,
				northLabel: s.NorthLabel,
				southLabel: s.SouthLabel,
			})
		} else {
			// Just a line, no station — show defaults that serve this line
			defaults := station.Defaults()
			found := false
			for _, d := range defaults {
				for _, r := range d.FeedRoutes {
					if r == route {
						queries = append(queries, stationQuery{
							name:       d.Name,
							stopID:     d.StopID,
							routes:     []string{route},
							northLabel: d.NorthLabel,
							southLabel: d.SouthLabel,
						})
						found = true
					}
				}
			}
			if !found {
				return fmt.Errorf("line %s is valid but not in default stations. Specify a station: subwayskill %s <station-name>", route, route)
			}
		}
	} else {
		// No args — all defaults
		for _, d := range station.Defaults() {
			queries = append(queries, stationQuery{
				name:       d.Name,
				stopID:     d.StopID,
				routes:     d.FeedRoutes,
				northLabel: d.NorthLabel,
				southLabel: d.SouthLabel,
			})
		}
	}

	// Fetch departures
	var results []display.StationResult

	if useSchedule {
		// Try realtime first, fall back to schedule
		results = fetchWithScheduleFallback(queries, targetTime, now)
	} else {
		// Realtime only
		results = fetchRealtime(queries, now)
	}

	// Output
	if jsonOut {
		out, err := display.FormatJSON(results, now)
		if err != nil {
			return fmt.Errorf("formatting JSON: %w", err)
		}
		fmt.Println(out)
	} else {
		fmt.Print(display.FormatText(results, now, targetTime))
	}

	return nil
}

func fetchRealtime(queries []stationQuery, now time.Time) []display.StationResult {
	type result struct {
		idx int
		sr  display.StationResult
	}

	results := make([]display.StationResult, len(queries))
	ch := make(chan result, len(queries))
	var wg sync.WaitGroup

	for i, q := range queries {
		wg.Add(1)
		go func(idx int, q stationQuery) {
			defer wg.Done()

			var allDeps []feed.Departure
			feedMap := feed.FeedURLsForRoutes(q.routes)

			for url, routes := range feedMap {
				deps, err := feed.FetchDepartures(url, q.stopID, routes, direction)
				if err != nil {
					fmt.Fprintf(os.Stderr, "Warning: feed error for %s: %v\n", q.name, err)
					continue
				}
				allDeps = append(allDeps, deps...)
			}

			ch <- result{
				idx: idx,
				sr: display.StationResult{
					StationName: q.name,
					StopID:      q.stopID,
					Routes:      q.routes,
					NorthLabel:  q.northLabel,
					SouthLabel:  q.southLabel,
					Departures:  allDeps,
					Source:      "realtime",
				},
			}
		}(i, q)
	}

	go func() {
		wg.Wait()
		close(ch)
	}()

	for r := range ch {
		results[r.idx] = r.sr
	}

	return results
}

func fetchWithScheduleFallback(queries []stationQuery, targetTime *time.Time, now time.Time) []display.StationResult {
	results := make([]display.StationResult, len(queries))

	for i, q := range queries {
		// Try realtime first
		var allDeps []feed.Departure
		feedMap := feed.FeedURLsForRoutes(q.routes)

		for url, routes := range feedMap {
			deps, err := feed.FetchDepartures(url, q.stopID, routes, direction)
			if err != nil {
				continue
			}
			allDeps = append(allDeps, deps...)
		}

		// Filter realtime to time window around target
		w := window
		if w == 0 {
			w = 30
		}
		halfWindow := time.Duration(w/2) * time.Minute
		var filtered []feed.Departure
		for _, d := range allDeps {
			if d.Time.After(targetTime.Add(-halfWindow)) && d.Time.Before(targetTime.Add(halfWindow)) {
				filtered = append(filtered, d)
			}
		}

		if len(filtered) > 0 {
			results[i] = display.StationResult{
				StationName: q.name,
				StopID:      q.stopID,
				Routes:      q.routes,
				NorthLabel:  q.northLabel,
				SouthLabel:  q.southLabel,
				Departures:  filtered,
				Source:      "realtime",
			}
			continue
		}

		// Fall back to schedule
		deps, sourceNote, err := schedule.GetScheduledDepartures(q.stopID, q.routes, direction, *targetTime, w)
		if err != nil {
			fmt.Fprintf(os.Stderr, "Warning: schedule error for %s: %v\n", q.name, err)
		}

		results[i] = display.StationResult{
			StationName: q.name,
			StopID:      q.stopID,
			Routes:      q.routes,
			NorthLabel:  q.northLabel,
			SouthLabel:  q.southLabel,
			Departures:  deps,
			Source:      "scheduled",
			SourceNote:  sourceNote,
		}
	}

	return results
}


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: clean (LOW)