Apartments.com — Printing Press CLI Prerequisites: Install the CLI This skill drives the binary. You must verify the CLI is installed before invoking any command from this skill. If it is missing, install it first: 1. Install via the Printing Press installer: 2. Verify: 3. Ensure (or ) is on . If the install fails (no Node, offline, etc.), fall back to a direct Go install (requires Go 1.26.3 or newer): If reports "command not found" after install, the install step did not put the binary on . Do not proceed with skill commands until verification succeeds. When to Use This CLI Use this CLI when…

/ commas, handle ranges like \"1500-2200\" by\n\t// taking the upper bound (max-rent semantics).\n\tv = strings.ReplaceAll(v, \",\", \"\")\n\tv = strings.TrimPrefix(v, \"$\")\n\tif idx := strings.IndexByte(v, '-'); idx >= 0 {\n\t\tv = strings.TrimSpace(v[idx+1:])\n\t}\n\tif f, err := strconv.ParseFloat(v, 64); err == nil {\n\t\treturn int(f)\n\t}\n\treturn 0\n}\n\n// resolveURL turns a possibly-relative href into an absolute URL using\n// baseURL as the reference.\nfunc resolveURL(href, baseURL string) string {\n\tif href == \"\" {\n\t\treturn \"\"\n\t}\n\tif strings.HasPrefix(href, \"http://\") || strings.HasPrefix(href, \"https://\") {\n\t\treturn href\n\t}\n\tbase, err := url.Parse(baseURL)\n\tif err != nil {\n\t\treturn href\n\t}\n\tref, err := url.Parse(href)\n\tif err != nil {\n\t\treturn href\n\t}\n\treturn base.ResolveReference(ref).String()\n}\n\n// ParsePlacards walks a search-results HTML page and extracts placard\n// summaries. Apartments.com renders each card as an `\u003carticle>` element\n// whose class contains \"placard\"; URL + property ID live on data-url /\n// data-listingid, and beds / price are inner text in `.bedTextBox` and\n// `.priceTextBox`. We accept any element whose class contains \"placard\"\n// (not just \u003carticle>) so the parser stays resilient to markup\n// reshuffles. Caps at 60 placards per page. The legacy data-beds /\n// data-baths / data-maxrent attribute fast-path is preserved for older\n// HTML and for unit tests.\nfunc ParsePlacards(htmlBytes []byte, baseURL string) ([]Placard, error) {\n\tdoc, err := html.Parse(bytes.NewReader(htmlBytes))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar out []Placard\n\tseen := map[string]bool{}\n\n\tvar walk func(*html.Node)\n\twalk = func(n *html.Node) {\n\t\tif len(out) >= 60 || n == nil {\n\t\t\treturn\n\t\t}\n\t\t// Match elements whose class atoms include exactly \"placard\"\n\t\t// (apartments.com 2024+ markup) or anchors whose class atoms\n\t\t// include \"placardtitle\" (legacy/unit-test markup). Substring\n\t\t// match is too loose: \"placards\" (the ul wrapper),\n\t\t// \"placardContainer\", \"placardCarouselImgCount\", etc. all\n\t\t// contain the substring \"placard\" but are not card roots.\n\t\tisPlacardCard := n.Type == html.ElementNode && hasClassAtom(n, \"placard\")\n\t\tif isPlacardCard {\n\t\t\thref := attr(n, \"data-url\")\n\t\t\tif href == \"\" {\n\t\t\t\thref = attr(n, \"href\")\n\t\t\t}\n\t\t\tif href == \"\" {\n\t\t\t\tif a := firstAnchorWithHref(n); a != nil {\n\t\t\t\t\thref = attr(a, \"href\")\n\t\t\t\t}\n\t\t\t}\n\t\t\tabs := resolveURL(href, baseURL)\n\t\t\tif abs != \"\" && !seen[abs] {\n\t\t\t\tseen[abs] = true\n\t\t\t\tp := Placard{\n\t\t\t\t\tURL: abs,\n\t\t\t\t\tPropertyID: lastPathSegment(abs),\n\t\t\t\t}\n\t\t\t\tif pid := attr(n, \"data-listingid\"); pid != \"\" {\n\t\t\t\t\tp.PropertyID = pid\n\t\t\t\t}\n\t\t\t\tp.Title = cliutil.CleanText(attr(n, \"title\"))\n\t\t\t\tif p.Title == \"\" {\n\t\t\t\t\tp.Title = innerTextByClassSubstr(n, \"js-placardtitle\")\n\t\t\t\t}\n\t\t\t\tif p.Title == \"\" {\n\t\t\t\t\tp.Title = cliutil.CleanText(attr(n, \"data-streetaddress\"))\n\t\t\t\t}\n\t\t\t\t// Legacy attribute fast-path (also exercised by tests).\n\t\t\t\tdataHost := n\n\t\t\t\tif attr(n, \"data-beds\") == \"\" && attr(n, \"data-maxrent\") == \"\" {\n\t\t\t\t\tif anc := closestWithDataAttr(n, \"data-beds\"); anc != nil {\n\t\t\t\t\t\tdataHost = anc\n\t\t\t\t\t} else if anc := closestWithDataAttr(n, \"data-maxrent\"); anc != nil {\n\t\t\t\t\t\tdataHost = anc\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tp.Beds = parseBedsValue(attr(dataHost, \"data-beds\"))\n\t\t\t\tp.Baths = parseFloatAttr(attr(dataHost, \"data-baths\"))\n\t\t\t\tp.MaxRent = parseIntAttr(attr(dataHost, \"data-maxrent\"))\n\t\t\t\t// Modern fallback: read beds and maxRent from inner\n\t\t\t\t// .bedTextBox / .priceTextBox text. Apartments.com may\n\t\t\t\t// emit ranges (\"1-2 Beds\", \"$1,199+\"), so we handle\n\t\t\t\t// both upper-bound (max) extraction.\n\t\t\t\tif p.Beds == 0 {\n\t\t\t\t\tif t := innerTextByClassSubstr(n, \"bedtextbox\"); t != \"\" {\n\t\t\t\t\t\tp.Beds = parseBedsFromText(t)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif p.MaxRent == 0 {\n\t\t\t\t\tif t := innerTextByClassSubstr(n, \"pricetextbox\"); t != \"\" {\n\t\t\t\t\t\tp.MaxRent = parsePriceFromText(t)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tout = append(out, p)\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tfor c := n.FirstChild; c != nil; c = c.NextSibling {\n\t\t\twalk(c)\n\t\t}\n\t}\n\twalk(doc)\n\treturn out, nil\n}\n\n// firstAnchorWithHref walks n's subtree and returns the first \u003ca>\n// carrying a non-empty href.\nfunc firstAnchorWithHref(n *html.Node) *html.Node {\n\tvar found *html.Node\n\tvar walk func(*html.Node)\n\twalk = func(n *html.Node) {\n\t\tif found != nil || n == nil {\n\t\t\treturn\n\t\t}\n\t\tif n.Type == html.ElementNode && strings.EqualFold(n.Data, \"a\") && attr(n, \"href\") != \"\" {\n\t\t\tfound = n\n\t\t\treturn\n\t\t}\n\t\tfor c := n.FirstChild; c != nil; c = c.NextSibling {\n\t\t\twalk(c)\n\t\t}\n\t}\n\twalk(n)\n\treturn found\n}\n\n// innerTextByClassSubstr returns the trimmed text of the first\n// descendant whose class contains needle (case-insensitive).\nfunc innerTextByClassSubstr(n *html.Node, needle string) string {\n\tvar found *html.Node\n\tvar walk func(*html.Node)\n\twalk = func(n *html.Node) {\n\t\tif found != nil || n == nil {\n\t\t\treturn\n\t\t}\n\t\tif n.Type == html.ElementNode && hasAttrSubstring(n, \"class\", needle) {\n\t\t\tfound = n\n\t\t\treturn\n\t\t}\n\t\tfor c := n.FirstChild; c != nil; c = c.NextSibling {\n\t\t\twalk(c)\n\t\t}\n\t}\n\twalk(n)\n\tif found == nil {\n\t\treturn \"\"\n\t}\n\treturn cliutil.CleanText(strings.TrimSpace(nodeText(found)))\n}\n\n// parseBedsFromText pulls a bed count out of text like \"2 Beds\",\n// \"Studio\", \"1-2 Beds\". Returns the upper bound for ranges; returns 0\n// for studios.\nfunc parseBedsFromText(t string) int {\n\tlow := strings.ToLower(t)\n\tif strings.Contains(low, \"studio\") {\n\t\treturn 0\n\t}\n\t// Match the last integer before \"bed\".\n\tbed := strings.Index(low, \"bed\")\n\tif bed \u003c 0 {\n\t\treturn 0\n\t}\n\tprefix := low[:bed]\n\t// Find rightmost integer in prefix.\n\tdigits := \"\"\n\tfor i := len(prefix) - 1; i >= 0; i-- {\n\t\tc := prefix[i]\n\t\tif c >= '0' && c \u003c= '9' {\n\t\t\tdigits = string(c) + digits\n\t\t\tcontinue\n\t\t}\n\t\tif digits != \"\" {\n\t\t\tbreak\n\t\t}\n\t}\n\tif digits == \"\" {\n\t\treturn 0\n\t}\n\tn, err := strconv.Atoi(digits)\n\tif err != nil {\n\t\treturn 0\n\t}\n\treturn n\n}\n\n// parsePriceFromText pulls a max rent from text like \"$1,199+\",\n// \"$1,199 - $2,400\". Returns the upper bound when a range is present;\n// returns the leading number otherwise.\nfunc parsePriceFromText(t string) int {\n\tclean := strings.ReplaceAll(t, \",\", \"\")\n\tclean = strings.ReplaceAll(clean, \"$\", \"\")\n\tclean = strings.ReplaceAll(clean, \"+\", \"\")\n\tclean = strings.TrimSpace(clean)\n\tif clean == \"\" {\n\t\treturn 0\n\t}\n\t// Range form \"1199 - 2400\" → take last number.\n\tif idx := strings.Index(clean, \"-\"); idx >= 0 {\n\t\tclean = strings.TrimSpace(clean[idx+1:])\n\t}\n\t// Strip trailing /mo or similar.\n\tfor i := 0; i \u003c len(clean); i++ {\n\t\tc := clean[i]\n\t\tif (c \u003c '0' || c > '9') && c != '.' {\n\t\t\tclean = clean[:i]\n\t\t\tbreak\n\t\t}\n\t}\n\tif clean == \"\" {\n\t\treturn 0\n\t}\n\tf, err := strconv.ParseFloat(clean, 64)\n\tif err != nil {\n\t\treturn 0\n\t}\n\treturn int(f)\n}\n\n// ParseListing walks a listing detail page and extracts schema.org\n// microdata + data-* attributes. Best-effort: missing fields stay zero\n// rather than erroring.\nfunc ParseListing(htmlBytes []byte, listingURL string) (Listing, error) {\n\tdoc, err := html.Parse(bytes.NewReader(htmlBytes))\n\tif err != nil {\n\t\treturn Listing{}, err\n\t}\n\tli := Listing{\n\t\tURL: listingURL,\n\t\tPropertyID: lastPathSegment(listingURL),\n\t}\n\n\t// schema.org meta itemprop fields.\n\tvar (\n\t\tstreet, city, state, postal string\n\t\ttitle string\n\t)\n\n\t// Walk once, collect everything we care about.\n\tvar (\n\t\tamenities []string\n\t\tphotos []string\n\t\tdataBeds string\n\t\tdataBaths string\n\t\tdataMax string\n\t\tdataSqft string\n\t\tdataAvail string\n\t\tphoneVal string\n\t\tfloorPlans []FloorPlan\n\t)\n\n\t// To avoid duplicate amenities we dedupe via a set.\n\tamSet := map[string]bool{}\n\n\tvar inAmenityBlock int\n\tvar walk func(*html.Node)\n\twalk = func(n *html.Node) {\n\t\tif n == nil {\n\t\t\treturn\n\t\t}\n\t\tif n.Type == html.ElementNode {\n\t\t\tswitch strings.ToLower(n.Data) {\n\t\t\tcase \"meta\":\n\t\t\t\tswitch strings.ToLower(attr(n, \"itemprop\")) {\n\t\t\t\tcase \"streetaddress\":\n\t\t\t\t\tstreet = cliutil.CleanText(attr(n, \"content\"))\n\t\t\t\tcase \"addresslocality\":\n\t\t\t\t\tcity = cliutil.CleanText(attr(n, \"content\"))\n\t\t\t\tcase \"addressregion\":\n\t\t\t\t\tstate = cliutil.CleanText(attr(n, \"content\"))\n\t\t\t\tcase \"postalcode\":\n\t\t\t\t\tpostal = cliutil.CleanText(attr(n, \"content\"))\n\t\t\t\tcase \"telephone\":\n\t\t\t\t\tif phoneVal == \"\" {\n\t\t\t\t\t\tphoneVal = cliutil.CleanText(attr(n, \"content\"))\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\tcase \"title\":\n\t\t\t\tif title == \"\" {\n\t\t\t\t\ttitle = cliutil.CleanText(nodeText(n))\n\t\t\t\t}\n\t\t\tcase \"img\":\n\t\t\t\tsrc := attr(n, \"src\")\n\t\t\t\tif src == \"\" {\n\t\t\t\t\tsrc = attr(n, \"data-src\")\n\t\t\t\t}\n\t\t\t\tif src != \"\" && (strings.Contains(src, \"apartments.com\") || strings.HasPrefix(src, \"/\")) {\n\t\t\t\t\tabs := resolveURL(src, listingURL)\n\t\t\t\t\tif abs != \"\" {\n\t\t\t\t\t\tphotos = append(photos, abs)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\t// Pick up first non-empty data-* attributes.\n\t\t\tif dataBeds == \"\" {\n\t\t\t\tif v := attr(n, \"data-beds\"); v != \"\" {\n\t\t\t\t\tdataBeds = v\n\t\t\t\t}\n\t\t\t}\n\t\t\tif dataBaths == \"\" {\n\t\t\t\tif v := attr(n, \"data-baths\"); v != \"\" {\n\t\t\t\t\tdataBaths = v\n\t\t\t\t}\n\t\t\t}\n\t\t\tif dataMax == \"\" {\n\t\t\t\tif v := attr(n, \"data-maxrent\"); v != \"\" {\n\t\t\t\t\tdataMax = v\n\t\t\t\t}\n\t\t\t}\n\t\t\tif dataSqft == \"\" {\n\t\t\t\tif v := attr(n, \"data-sqft-min\"); v != \"\" {\n\t\t\t\t\tdataSqft = v\n\t\t\t\t}\n\t\t\t}\n\t\t\tif dataAvail == \"\" {\n\t\t\t\tif v := attr(n, \"data-availability\"); v != \"\" {\n\t\t\t\t\tdataAvail = v\n\t\t\t\t}\n\t\t\t}\n\t\t\t// Floor plan rows: any element carrying data-rent-min OR\n\t\t\t// data-rent-max + a data-beds-min (the apartments.com\n\t\t\t// \"all\" floor-plan tab structure).\n\t\t\tif attr(n, \"data-rent-min\") != \"\" || attr(n, \"data-rent-max\") != \"\" ||\n\t\t\t\tattr(n, \"data-beds-min\") != \"\" {\n\t\t\t\tfp := FloorPlan{\n\t\t\t\t\tName: cliutil.CleanText(attr(n, \"data-name\")),\n\t\t\t\t\tBeds: parseBedsValue(attr(n, \"data-beds-min\")),\n\t\t\t\t\tBaths: parseFloatAttr(attr(n, \"data-baths-min\")),\n\t\t\t\t\tSqft: parseIntAttr(attr(n, \"data-sqft-min\")),\n\t\t\t\t\tRentMin: parseIntAttr(attr(n, \"data-rent-min\")),\n\t\t\t\t\tRentMax: parseIntAttr(attr(n, \"data-rent-max\")),\n\t\t\t\t}\n\t\t\t\tif fp.Name == \"\" {\n\t\t\t\t\tfp.Name = cliutil.CleanText(attr(n, \"data-fp-name\"))\n\t\t\t\t}\n\t\t\t\t// Only keep if at least one numeric signal landed.\n\t\t\t\tif fp.Beds > 0 || fp.Sqft > 0 || fp.RentMin > 0 || fp.RentMax > 0 {\n\t\t\t\t\tfloorPlans = append(floorPlans, fp)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Amenity tracking: enter when we see a class containing\n\t\t\t// \"amenitiesList\" / \"specsList\" / \"amenityGroup\".\n\t\t\tclassV := strings.ToLower(attr(n, \"class\"))\n\t\t\tisAmenityHost := strings.Contains(classV, \"amenitieslist\") ||\n\t\t\t\tstrings.Contains(classV, \"specslist\") ||\n\t\t\t\tstrings.Contains(classV, \"amenitygroup\")\n\t\t\tif isAmenityHost {\n\t\t\t\tinAmenityBlock++\n\t\t\t}\n\t\t\tif inAmenityBlock > 0 && strings.EqualFold(n.Data, \"li\") {\n\t\t\t\ttxt := cliutil.CleanText(strings.TrimSpace(nodeText(n)))\n\t\t\t\tif txt != \"\" && !amSet[txt] {\n\t\t\t\t\tamSet[txt] = true\n\t\t\t\t\tamenities = append(amenities, txt)\n\t\t\t\t}\n\t\t\t}\n\t\t\tfor c := n.FirstChild; c != nil; c = c.NextSibling {\n\t\t\t\twalk(c)\n\t\t\t}\n\t\t\tif isAmenityHost {\n\t\t\t\tinAmenityBlock--\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t\tfor c := n.FirstChild; c != nil; c = c.NextSibling {\n\t\t\twalk(c)\n\t\t}\n\t}\n\twalk(doc)\n\n\tli.Title = title\n\tli.Address = Address{\n\t\tStreetAddress: street,\n\t\tCity: city,\n\t\tState: state,\n\t\tPostalCode: postal,\n\t}\n\tli.Beds = parseBedsValue(dataBeds)\n\tli.Baths = parseFloatAttr(dataBaths)\n\tli.MaxRent = parseIntAttr(dataMax)\n\tli.Sqft = parseIntAttr(dataSqft)\n\tli.AvailableAt = dataAvail\n\tli.Phone = phoneVal\n\tli.Amenities = amenities\n\tli.Photos = dedupeStrings(photos)\n\tif floorPlans == nil {\n\t\tfloorPlans = []FloorPlan{}\n\t}\n\tli.FloorPlans = floorPlans\n\n\t// Best-effort pet hints from amenities text. Apartments.com's pet\n\t// block isn't a stable schema.org target, so we infer Allows*Cats /\n\t// Allows*Dogs from amenity text and leave fees zero.\n\tfor _, am := range amenities {\n\t\tl := strings.ToLower(am)\n\t\tif strings.Contains(l, \"cat\") {\n\t\t\tli.PetPolicy.AllowsCats = true\n\t\t}\n\t\tif strings.Contains(l, \"dog\") {\n\t\t\tli.PetPolicy.AllowsDogs = true\n\t\t}\n\t}\n\n\treturn li, nil\n}\n\nfunc dedupeStrings(in []string) []string {\n\tif len(in) == 0 {\n\t\treturn nil\n\t}\n\tseen := map[string]bool{}\n\tout := make([]string, 0, len(in))\n\tfor _, s := range in {\n\t\tif s == \"\" || seen[s] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[s] = true\n\t\tout = append(out, s)\n\t}\n\treturn out\n}\n\n// firstDescendantWithDataAttr is used by callers that walk into the\n// floor-plan block. Currently unused at package level but exposed via\n// internal helpers above; keeping it package-private avoids surprising\n// downstream callers.\nvar _ = firstDescendantWithDataAttr\n","content_type":"text/plain; charset=utf-8","language":"go","size":19032,"content_sha256":"4380d50553c0d8a418e9656e8f3b12e655c9ecf9c19f5267df90218ebd436bfc"},{"filename":"internal/apt/store_ext.go","content":"package apt\n\nimport (\n\t\"database/sql\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"strings\"\n\t\"time\"\n)\n\n// extSchema is the additive set of tables and indexes that hold\n// apartments.com's per-listing time series, saved-search bookkeeping,\n// and the local shortlist. EnsureExtSchema runs them every time so\n// commands can rely on the schema being present without depending on\n// the generator's migration sequence.\nconst extSchema = `\nCREATE TABLE IF NOT EXISTS listing_snapshots (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n listing_url TEXT NOT NULL,\n property_id TEXT,\n saved_search TEXT,\n observed_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n max_rent INTEGER,\n beds INTEGER,\n baths REAL,\n available_at TEXT,\n fetch_status INTEGER DEFAULT 200,\n raw_data JSON\n);\nCREATE INDEX IF NOT EXISTS idx_snapshots_url ON listing_snapshots(listing_url);\nCREATE INDEX IF NOT EXISTS idx_snapshots_search ON listing_snapshots(saved_search, observed_at);\n\nCREATE TABLE IF NOT EXISTS saved_searches (\n slug TEXT PRIMARY KEY,\n options_json TEXT NOT NULL,\n last_synced_at DATETIME,\n listing_count INTEGER DEFAULT 0\n);\n\nCREATE TABLE IF NOT EXISTS shortlist (\n listing_url TEXT NOT NULL,\n tag TEXT NOT NULL DEFAULT '',\n note TEXT NOT NULL DEFAULT '',\n added_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n PRIMARY KEY (listing_url, tag)\n);\n`\n\n// EnsureExtSchema runs the apt-extension CREATE TABLE / CREATE INDEX\n// statements. Idempotent: every command that touches these tables can\n// safely call it.\nfunc EnsureExtSchema(db *sql.DB) error {\n\tfor _, stmt := range strings.Split(extSchema, \";\") {\n\t\ts := strings.TrimSpace(stmt)\n\t\tif s == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif _, err := db.Exec(s); err != nil {\n\t\t\treturn fmt.Errorf(\"apt schema: %s: %w\", firstLine(s), err)\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc firstLine(s string) string {\n\tif i := strings.IndexByte(s, '\\n'); i >= 0 {\n\t\treturn s[:i]\n\t}\n\treturn s\n}\n\n// SnapshotInsert is the row inserted into listing_snapshots.\ntype SnapshotInsert struct {\n\tListingURL string\n\tPropertyID string\n\tSavedSearch string\n\tMaxRent int\n\tBeds int\n\tBaths float64\n\tAvailableAt string\n\tFetchStatus int\n\tRaw json.RawMessage\n}\n\n// InsertSnapshot writes one snapshot row and returns its rowid.\nfunc InsertSnapshot(db *sql.DB, in SnapshotInsert) (int64, error) {\n\tif in.FetchStatus == 0 {\n\t\tin.FetchStatus = 200\n\t}\n\tres, err := db.Exec(\n\t\t`INSERT INTO listing_snapshots\n\t\t (listing_url, property_id, saved_search, observed_at, max_rent,\n\t\t beds, baths, available_at, fetch_status, raw_data)\n\t\t VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,\n\t\tin.ListingURL, in.PropertyID, in.SavedSearch, time.Now().UTC(),\n\t\tin.MaxRent, in.Beds, in.Baths, in.AvailableAt, in.FetchStatus,\n\t\tstring(in.Raw),\n\t)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\treturn res.LastInsertId()\n}\n\n// UpsertSavedSearch stores or refreshes a saved-search row keyed on\n// slug. listing_count is the count from the most recent sync.\nfunc UpsertSavedSearch(db *sql.DB, slug string, opts SearchOptions, count int) error {\n\toptsJSON, err := json.Marshal(opts)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, err = db.Exec(\n\t\t`INSERT INTO saved_searches (slug, options_json, last_synced_at, listing_count)\n\t\t VALUES (?, ?, ?, ?)\n\t\t ON CONFLICT(slug) DO UPDATE SET options_json = excluded.options_json,\n\t\t last_synced_at = excluded.last_synced_at,\n\t\t listing_count = excluded.listing_count`,\n\t\tslug, string(optsJSON), time.Now().UTC(), count,\n\t)\n\treturn err\n}\n\n// SyncTimestamp is one observed_at marker for a saved-search.\ntype SyncTimestamp struct {\n\tObservedAt time.Time\n}\n\n// LatestSyncTimestamps returns up to N most recent distinct sync\n// snapshots (rounded to the second) for a saved-search slug, newest\n// first. Each apartments.com sync produces a burst of rows with\n// timestamps within milliseconds of each other; the GROUP BY clamps\n// them into one logical sync.\nfunc LatestSyncTimestamps(db *sql.DB, slug string, limit int) ([]time.Time, error) {\n\tif limit \u003c= 0 {\n\t\tlimit = 2\n\t}\n\trows, err := db.Query(\n\t\t`SELECT MAX(observed_at) AS ts\n\t\t FROM listing_snapshots\n\t\t WHERE saved_search = ?\n\t\t GROUP BY strftime('%Y-%m-%d %H:%M:%S', observed_at)\n\t\t ORDER BY ts DESC\n\t\t LIMIT ?`,\n\t\tslug, limit,\n\t)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\tvar out []time.Time\n\tfor rows.Next() {\n\t\tvar s string\n\t\tif err := rows.Scan(&s); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tt := parseStoredTime(s)\n\t\tout = append(out, t)\n\t}\n\treturn out, rows.Err()\n}\n\n// SnapshotRow is one materialized listing-snapshot row.\ntype SnapshotRow struct {\n\tListingURL string `json:\"listing_url\"`\n\tPropertyID string `json:\"property_id,omitempty\"`\n\tSavedSearch string `json:\"saved_search,omitempty\"`\n\tObservedAt time.Time `json:\"observed_at\"`\n\tMaxRent int `json:\"max_rent,omitempty\"`\n\tBeds int `json:\"beds,omitempty\"`\n\tBaths float64 `json:\"baths,omitempty\"`\n\tAvailableAt string `json:\"available_at,omitempty\"`\n\tFetchStatus int `json:\"fetch_status,omitempty\"`\n}\n\n// SnapshotsForSearchAt returns the rows for one saved-search whose\n// observed_at falls within ±2 seconds of `at` — the granularity at\n// which LatestSyncTimestamps groups bursts.\nfunc SnapshotsForSearchAt(db *sql.DB, slug string, at time.Time) ([]SnapshotRow, error) {\n\trows, err := db.Query(\n\t\t`SELECT listing_url, property_id, saved_search, observed_at, max_rent, beds, baths, available_at, fetch_status\n\t\t FROM listing_snapshots\n\t\t WHERE saved_search = ?\n\t\t AND ABS(strftime('%s', observed_at) - strftime('%s', ?)) \u003c= 2`,\n\t\tslug, at.UTC().Format(time.RFC3339),\n\t)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\tvar out []SnapshotRow\n\tfor rows.Next() {\n\t\tvar (\n\t\t\tr SnapshotRow\n\t\t\tts string\n\t\t\tpropertyID sql.NullString\n\t\t\tsavedSearch sql.NullString\n\t\t\tavailableAt sql.NullString\n\t\t\tmaxRent sql.NullInt64\n\t\t\tbeds sql.NullInt64\n\t\t\tbaths sql.NullFloat64\n\t\t\tfetchStatus sql.NullInt64\n\t\t)\n\t\tif err := rows.Scan(&r.ListingURL, &propertyID, &savedSearch, &ts, &maxRent, &beds, &baths, &availableAt, &fetchStatus); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tr.PropertyID = propertyID.String\n\t\tr.SavedSearch = savedSearch.String\n\t\tr.ObservedAt = parseStoredTime(ts)\n\t\tr.MaxRent = int(maxRent.Int64)\n\t\tr.Beds = int(beds.Int64)\n\t\tr.Baths = baths.Float64\n\t\tr.AvailableAt = availableAt.String\n\t\tr.FetchStatus = int(fetchStatus.Int64)\n\t\tout = append(out, r)\n\t}\n\treturn out, rows.Err()\n}\n\n// SnapshotsForURL returns every snapshot row for one listing URL,\n// oldest first (history view).\nfunc SnapshotsForURL(db *sql.DB, listingURL string) ([]SnapshotRow, error) {\n\trows, err := db.Query(\n\t\t`SELECT listing_url, property_id, saved_search, observed_at, max_rent, beds, baths, available_at, fetch_status\n\t\t FROM listing_snapshots\n\t\t WHERE listing_url = ?\n\t\t ORDER BY observed_at ASC`,\n\t\tlistingURL,\n\t)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\tvar out []SnapshotRow\n\tfor rows.Next() {\n\t\tvar (\n\t\t\tr SnapshotRow\n\t\t\tts string\n\t\t\tpropertyID sql.NullString\n\t\t\tsavedSearch sql.NullString\n\t\t\tavailableAt sql.NullString\n\t\t\tmaxRent sql.NullInt64\n\t\t\tbeds sql.NullInt64\n\t\t\tbaths sql.NullFloat64\n\t\t\tfetchStatus sql.NullInt64\n\t\t)\n\t\tif err := rows.Scan(&r.ListingURL, &propertyID, &savedSearch, &ts, &maxRent, &beds, &baths, &availableAt, &fetchStatus); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tr.PropertyID = propertyID.String\n\t\tr.SavedSearch = savedSearch.String\n\t\tr.ObservedAt = parseStoredTime(ts)\n\t\tr.MaxRent = int(maxRent.Int64)\n\t\tr.Beds = int(beds.Int64)\n\t\tr.Baths = baths.Float64\n\t\tr.AvailableAt = availableAt.String\n\t\tr.FetchStatus = int(fetchStatus.Int64)\n\t\tout = append(out, r)\n\t}\n\treturn out, rows.Err()\n}\n\n// parseStoredTime is a local copy of cliutil.ParseStoredTime to avoid\n// a hard dependency on cliutil from the apt package's pure-data path.\nfunc parseStoredTime(s string) time.Time {\n\ts = strings.TrimSpace(s)\n\tif s == \"\" {\n\t\treturn time.Time{}\n\t}\n\tfor _, layout := range []string{\n\t\ttime.RFC3339Nano,\n\t\ttime.RFC3339,\n\t\t\"2006-01-02 15:04:05.999999999 -0700 MST\",\n\t\t\"2006-01-02 15:04:05.999999 -0700 MST\",\n\t\t\"2006-01-02 15:04:05.999 -0700 MST\",\n\t\t\"2006-01-02 15:04:05 -0700 MST\",\n\t\t\"2006-01-02 15:04:05.999999999 -0700\",\n\t\t\"2006-01-02 15:04:05 -0700\",\n\t\t\"2006-01-02 15:04:05\",\n\t} {\n\t\tif t, err := time.Parse(layout, s); err == nil {\n\t\t\treturn t\n\t\t}\n\t}\n\treturn time.Time{}\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":8358,"content_sha256":"221524ac0eac2a9a44a5be6c262aef1a4ebb0b33c6502573028eef02ae4c26bd"},{"filename":"internal/apt/url_test.go","content":"package apt\n\nimport \"testing\"\n\nfunc TestBuildSearchURL(t *testing.T) {\n\tcases := []struct {\n\t\tname string\n\t\topts SearchOptions\n\t\twant string\n\t}{\n\t\t{\n\t\t\tname: \"city+state only\",\n\t\t\topts: SearchOptions{City: \"austin\", State: \"tx\"},\n\t\t\twant: \"/austin-tx/\",\n\t\t},\n\t\t{\n\t\t\tname: \"exact beds\",\n\t\t\topts: SearchOptions{City: \"austin\", State: \"tx\", Beds: 2},\n\t\t\twant: \"/austin-tx/2-bedrooms/\",\n\t\t},\n\t\t{\n\t\t\tname: \"min beds + price max\",\n\t\t\topts: SearchOptions{City: \"austin\", State: \"tx\", BedsMin: 2, PriceMax: 2500},\n\t\t\twant: \"/austin-tx/min-2-bedrooms-under-2500/\",\n\t\t},\n\t\t{\n\t\t\tname: \"exact beds + price range + pets dog\",\n\t\t\topts: SearchOptions{City: \"new-york\", State: \"ny\", Beds: 2, PriceMin: 2000, PriceMax: 3500, Pets: \"dog\"},\n\t\t\twant: \"/new-york-ny/2-bedrooms-2000-to-3500-pet-friendly-dog/\",\n\t\t},\n\t\t{\n\t\t\tname: \"zip + min beds + pets both\",\n\t\t\topts: SearchOptions{Zip: \"78704\", BedsMin: 1, Pets: \"both\"},\n\t\t\twant: \"/78704/min-1-bedrooms-pet-friendly-cat-or-dog/\",\n\t\t},\n\t\t{\n\t\t\tname: \"house prefix + exact beds\",\n\t\t\topts: SearchOptions{City: \"austin\", State: \"tx\", Type: \"house\", Beds: 3},\n\t\t\twant: \"/houses/austin-tx/3-bedrooms/\",\n\t\t},\n\t\t{\n\t\t\tname: \"page 3\",\n\t\t\topts: SearchOptions{City: \"austin\", State: \"tx\", Page: 3},\n\t\t\twant: \"/austin-tx/3/\",\n\t\t},\n\t\t{\n\t\t\tname: \"studio\",\n\t\t\topts: SearchOptions{City: \"austin\", State: \"tx\", Studio: true},\n\t\t\twant: \"/austin-tx/studio/\",\n\t\t},\n\t\t{\n\t\t\tname: \"page 1 omitted\",\n\t\t\topts: SearchOptions{City: \"austin\", State: \"tx\", Page: 1},\n\t\t\twant: \"/austin-tx/\",\n\t\t},\n\t\t{\n\t\t\tname: \"condo + price range\",\n\t\t\topts: SearchOptions{City: \"austin\", State: \"tx\", Type: \"condo\", PriceMin: 1500, PriceMax: 2500},\n\t\t\twant: \"/condos/austin-tx/1500-to-2500/\",\n\t\t},\n\t\t{\n\t\t\tname: \"any pets\",\n\t\t\topts: SearchOptions{City: \"austin\", State: \"tx\", Pets: \"any\"},\n\t\t\twant: \"/austin-tx/pet-friendly/\",\n\t\t},\n\t\t{\n\t\t\tname: \"min baths\",\n\t\t\topts: SearchOptions{City: \"austin\", State: \"tx\", BathsMin: 2},\n\t\t\twant: \"/austin-tx/min-2-bathrooms/\",\n\t\t},\n\t}\n\n\tfor _, tc := range cases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tgot := BuildSearchURL(tc.opts)\n\t\t\tif got != tc.want {\n\t\t\t\tt.Errorf(\"BuildSearchURL(%+v)\\n got=%q\\n want=%q\", tc.opts, got, tc.want)\n\t\t\t}\n\t\t})\n\t}\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":2171,"content_sha256":"8632b303c59a4061b02e5e856b64f1c5085942006fce177dfb822dfbfc196ea8"},{"filename":"internal/apt/url.go","content":"// Package apt holds apartments.com-specific helpers: slug-URL composition,\n// HTML parsing for placard summaries and listing detail pages, and the\n// extension migrations + query helpers for the local SQLite store.\npackage apt\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n)\n\n// SearchOptions describes one apartments.com path-slug search. The zero\n// value (City/State only) is valid; every other field is optional.\ntype SearchOptions struct {\n\tCity string // lowercased, hyphens for spaces\n\tState string // 2-letter lowercase\n\tZip string // overrides City+State if set\n\tBeds int // 0 = studio (when Studio true), else exact\n\tBedsMin int // mutually exclusive with Beds\n\tStudio bool\n\tBaths int\n\tBathsMin int\n\tPriceMin int\n\tPriceMax int\n\tPets string // \"\", \"any\", \"cat\", \"dog\", \"both\"\n\tType string // \"\", \"apartment\", \"house\", \"condo\", \"townhome\"\n\tPage int\n}\n\n// BuildSearchURL renders a SearchOptions to an apartments.com path-slug\n// relative URL like \"/austin-tx/min-2-bedrooms-under-2500-pet-friendly/\".\n// The result always begins and ends with \"/\".\nfunc BuildSearchURL(opts SearchOptions) string {\n\tvar segments []string\n\n\t// Property-type prefix.\n\tswitch strings.ToLower(opts.Type) {\n\tcase \"house\":\n\t\tsegments = append(segments, \"houses\")\n\tcase \"condo\":\n\t\tsegments = append(segments, \"condos\")\n\tcase \"townhome\":\n\t\tsegments = append(segments, \"townhomes\")\n\t}\n\n\t// Location.\n\tif opts.Zip != \"\" {\n\t\tsegments = append(segments, opts.Zip)\n\t} else if opts.City != \"\" || opts.State != \"\" {\n\t\tloc := opts.City\n\t\tif opts.State != \"\" {\n\t\t\tif loc == \"\" {\n\t\t\t\tloc = opts.State\n\t\t\t} else {\n\t\t\t\tloc = loc + \"-\" + opts.State\n\t\t\t}\n\t\t}\n\t\tsegments = append(segments, loc)\n\t}\n\n\t// Filters: beds → baths → price → pets.\n\tvar filters []string\n\tif f := bedsFilter(opts); f != \"\" {\n\t\tfilters = append(filters, f)\n\t}\n\tif f := bathsFilter(opts); f != \"\" {\n\t\tfilters = append(filters, f)\n\t}\n\tif f := priceFilter(opts); f != \"\" {\n\t\tfilters = append(filters, f)\n\t}\n\tif f := petsFilter(opts); f != \"\" {\n\t\tfilters = append(filters, f)\n\t}\n\tif len(filters) > 0 {\n\t\tsegments = append(segments, strings.Join(filters, \"-\"))\n\t}\n\n\t// Pagination (1-indexed; omit on page 1).\n\tif opts.Page > 1 {\n\t\tsegments = append(segments, fmt.Sprintf(\"%d\", opts.Page))\n\t}\n\n\t// Compose the path. Leading + trailing slash is the apartments.com\n\t// canonical form.\n\tif len(segments) == 0 {\n\t\treturn \"/\"\n\t}\n\treturn \"/\" + strings.Join(segments, \"/\") + \"/\"\n}\n\nfunc bedsFilter(o SearchOptions) string {\n\tif o.Studio && o.Beds == 0 && o.BedsMin == 0 {\n\t\treturn \"studio\"\n\t}\n\tif o.Beds > 0 {\n\t\treturn fmt.Sprintf(\"%d-bedrooms\", o.Beds)\n\t}\n\tif o.BedsMin > 0 {\n\t\treturn fmt.Sprintf(\"min-%d-bedrooms\", o.BedsMin)\n\t}\n\treturn \"\"\n}\n\nfunc bathsFilter(o SearchOptions) string {\n\tif o.Baths > 0 {\n\t\treturn fmt.Sprintf(\"%d-bathrooms\", o.Baths)\n\t}\n\tif o.BathsMin > 0 {\n\t\treturn fmt.Sprintf(\"min-%d-bathrooms\", o.BathsMin)\n\t}\n\treturn \"\"\n}\n\nfunc priceFilter(o SearchOptions) string {\n\tif o.PriceMin > 0 && o.PriceMax > 0 {\n\t\treturn fmt.Sprintf(\"%d-to-%d\", o.PriceMin, o.PriceMax)\n\t}\n\tif o.PriceMax > 0 {\n\t\treturn fmt.Sprintf(\"under-%d\", o.PriceMax)\n\t}\n\tif o.PriceMin > 0 {\n\t\treturn fmt.Sprintf(\"over-%d\", o.PriceMin)\n\t}\n\treturn \"\"\n}\n\nfunc petsFilter(o SearchOptions) string {\n\tswitch strings.ToLower(o.Pets) {\n\tcase \"any\":\n\t\treturn \"pet-friendly\"\n\tcase \"cat\":\n\t\treturn \"pet-friendly-cat\"\n\tcase \"dog\":\n\t\treturn \"pet-friendly-dog\"\n\tcase \"both\":\n\t\treturn \"pet-friendly-cat-or-dog\"\n\t}\n\treturn \"\"\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":3455,"content_sha256":"948098eb01fa41bdf781113860d7727fcafca9b184ce6f11a11a3ff09e879912"},{"filename":"internal/cache/cache.go","content":"// Copyright 2026 rderwin and contributors. Licensed under Apache-2.0. See LICENSE.\n// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.\n\n// Package cache provides a file-based response cache with optional embedded database backend.\n// The default implementation stores JSON responses as flat files in ~/.cache/\u003ccli>/ with a TTL.\n// For higher-throughput or concurrent-write scenarios, replace the file backend with an\n// embedded database such as bolt (go.etcd.io/bbolt), badger (github.com/dgraph-io/badger),\n// or sqlite (modernc.org/sqlite).\npackage cache\n\nimport (\n\t\"crypto/sha256\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"time\"\n)\n\n// Store is a key-value cache backed by the filesystem.\ntype Store struct {\n\tDir string\n\tTTL time.Duration\n}\n\n// New creates a file-based cache store.\nfunc New(dir string, ttl time.Duration) *Store {\n\treturn &Store{Dir: dir, TTL: ttl}\n}\n\n// Get retrieves a cached value. Returns nil if not found or expired.\nfunc (s *Store) Get(key string) (json.RawMessage, bool) {\n\tpath := s.path(key)\n\tinfo, err := os.Stat(path)\n\tif err != nil || time.Since(info.ModTime()) > s.TTL {\n\t\treturn nil, false\n\t}\n\tdata, err := os.ReadFile(path)\n\tif err != nil {\n\t\treturn nil, false\n\t}\n\treturn json.RawMessage(data), true\n}\n\n// Set stores a value in the cache.\nfunc (s *Store) Set(key string, value json.RawMessage) {\n\t_ = os.MkdirAll(s.Dir, 0o755)\n\t_ = os.WriteFile(s.path(key), []byte(value), 0o644)\n}\n\n// Clear removes all cached entries.\nfunc (s *Store) Clear() error {\n\treturn os.RemoveAll(s.Dir)\n}\n\nfunc (s *Store) path(key string) string {\n\th := sha256.Sum256([]byte(key))\n\treturn filepath.Join(s.Dir, hex.EncodeToString(h[:8])+\".json\")\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":1724,"content_sha256":"a1d7b267d392fb8c6419462a64ddc1673fb61d44bbff762ce35e22aaad528c52"},{"filename":"internal/cli/agent_context.go","content":"// Copyright 2026 rderwin and contributors. Licensed under Apache-2.0. See LICENSE.\n// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.\n\npackage cli\n\nimport (\n\t\"encoding/json\"\n\t\"os\"\n\t\"sort\"\n\n\t\"github.com/spf13/cobra\"\n\t\"github.com/spf13/pflag\"\n)\n\n// agentContextSchemaVersion is bumped on any breaking change to the JSON\n// shape emitted by `agent-context`. Agents should check this before\n// parsing. Shape at v2 adds optional browser-sniff discovery context.\nconst agentContextSchemaVersion = \"2\"\n\n// agentContext is the structured description of this CLI consumed by AI\n// agents. Inspired by Cloudflare's /cdn-cgi/explorer/api runtime endpoint\n// (2026-04-13 Wrangler post): agents can introspect the live CLI without\n// parsing --help or reading source.\ntype agentContext struct {\n\tSchemaVersion string `json:\"schema_version\"`\n\tCLI agentContextCLI `json:\"cli\"`\n\tAuth agentContextAuth `json:\"auth\"`\n\tDiscovery *agentContextDiscovery `json:\"discovery,omitempty\"`\n\tCommands []agentContextCommand `json:\"commands\"`\n\tAvailableProfiles []string `json:\"available_profiles\"`\n\tFeedbackEndpointConfigured bool `json:\"feedback_endpoint_configured\"`\n}\n\ntype agentContextCLI struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tVersion string `json:\"version\"`\n}\n\ntype agentContextAuth struct {\n\tMode string `json:\"mode\"`\n\tEnvVars []string `json:\"env_vars\"`\n}\n\ntype agentContextDiscovery struct {\n\tSource string `json:\"source\"`\n\tTargetURL string `json:\"target_url,omitempty\"`\n\tEntryCount int `json:\"entry_count,omitempty\"`\n\tAPIEntryCount int `json:\"api_entry_count,omitempty\"`\n\tReachability string `json:\"reachability,omitempty\"`\n\tProtocols []string `json:\"protocols,omitempty\"`\n\tAuthCandidates []string `json:\"auth_candidates,omitempty\"`\n\tProtections []string `json:\"protections,omitempty\"`\n\tGenerationHints []string `json:\"generation_hints,omitempty\"`\n\tWarnings []string `json:\"warnings,omitempty\"`\n\tCandidateCommands []string `json:\"candidate_commands,omitempty\"`\n}\n\ntype agentContextCommand struct {\n\tName string `json:\"name\"`\n\tUse string `json:\"use,omitempty\"`\n\tShort string `json:\"short,omitempty\"`\n\tAnnotations map[string]string `json:\"annotations,omitempty\"`\n\tFlags []agentContextFlag `json:\"flags,omitempty\"`\n\tSubcommands []agentContextCommand `json:\"subcommands,omitempty\"`\n}\n\ntype agentContextFlag struct {\n\tName string `json:\"name\"`\n\tType string `json:\"type\"`\n\tUsage string `json:\"usage,omitempty\"`\n\tDefault string `json:\"default,omitempty\"`\n}\n\nfunc newAgentContextCmd(rootCmd *cobra.Command) *cobra.Command {\n\tvar pretty bool\n\tcmd := &cobra.Command{\n\t\tUse: \"agent-context\",\n\t\tShort: \"Emit structured JSON describing this CLI for agents\",\n\t\tAnnotations: map[string]string{\"mcp:read-only\": \"true\"},\n\t\tLong: `Outputs a machine-readable description of commands, flags, and auth so\nagents can introspect this CLI at runtime without parsing --help or\nreading source. Schema is versioned via schema_version.`,\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tctx := buildAgentContext(rootCmd)\n\t\t\tenc := json.NewEncoder(os.Stdout)\n\t\t\tif pretty {\n\t\t\t\tenc.SetIndent(\"\", \" \")\n\t\t\t}\n\t\t\treturn enc.Encode(ctx)\n\t\t},\n\t}\n\tcmd.Flags().BoolVar(&pretty, \"pretty\", false, \"indent JSON output for human reading\")\n\treturn cmd\n}\n\nfunc buildAgentContext(rootCmd *cobra.Command) agentContext {\n\tenvVars := []string{}\n\tauthMode := \"none\"\n\tif authMode == \"\" {\n\t\tauthMode = \"none\"\n\t}\n\tprofiles := ListProfileNames()\n\tif profiles == nil {\n\t\tprofiles = []string{}\n\t}\n\treturn agentContext{\n\t\tSchemaVersion: agentContextSchemaVersion,\n\t\tCLI: agentContextCLI{\n\t\t\tName: \"apartments-pp-cli\",\n\t\t\tDescription: \"Search Apartments.com listings, sync results to a local SQLite store, and run workflows the website never built —...\",\n\t\t\tVersion: rootCmd.Version,\n\t\t},\n\t\tAuth: agentContextAuth{\n\t\t\tMode: authMode,\n\t\t\tEnvVars: envVars,\n\t\t},\n\t\tDiscovery: buildAgentDiscoveryContext(),\n\t\tCommands: collectAgentCommands(rootCmd),\n\t\tAvailableProfiles: profiles,\n\t\tFeedbackEndpointConfigured: FeedbackEndpointConfigured(),\n\t}\n}\n\nfunc buildAgentDiscoveryContext() *agentContextDiscovery {\n\treturn &agentContextDiscovery{\n\t\tSource: \"traffic-analysis\",\n\t\tTargetURL: \"https://www.apartments.com\",\n\t\tEntryCount: 0,\n\t\tAPIEntryCount: 0,\n\t\tReachability: \"browser_http (85% confidence)\",\n\t\tProtocols: []string{\n\t\t\t\"html-ssr (95% confidence)\",\n\t\t},\n\t\tAuthCandidates: []string{},\n\t\tProtections: []string{\n\t\t\t\"akamai-bot-manager (85% confidence)\",\n\t\t},\n\t\tGenerationHints: []string{\n\t\t\t\"use Surf with Chrome TLS fingerprint at runtime (UsesBrowserHTTPTransport)\",\n\t\t\t\"all responses are HTML/SSR — extract via html_extract mode: page\",\n\t\t\t\"no clearance cookie capture; no resident browser sidecar\",\n\t\t\t\"schema.org microdata (meta itemprop=streetAddress|addressLocality|addressRegion|postalCode) plus data-beds / data-baths / data-maxrent attributes are the primary extraction targets\",\n\t\t},\n\t\tWarnings: []string{\n\t\t\t\"protection-active: Apartments.com (CoStar) employs Akamai-style bot detection. stdlib HTTP returns 403; Surf with Chrome TLS fingerprint clears it. Watch for protection escalation that might require Chrome-clearance cookie import or full-browser fallback in future versions.\",\n\t\t},\n\t\tCandidateCommands: []string{\n\t\t\t\"search — Path-slug search is the primary entry point at apartments.com\",\n\t\t\t\"get — Listing detail page extracts schema.org microdata\",\n\t\t},\n\t}\n}\n\n// collectAgentCommands walks the cobra tree from the given command and\n// returns its direct children (skipping hidden commands and the\n// agent-context command itself to avoid self-reference). Each child is\n// recursed into if it has subcommands. Flags are captured via VisitAll.\n// Output is sorted by command name for stable diffs across regenerations.\nfunc collectAgentCommands(c *cobra.Command) []agentContextCommand {\n\tchildren := c.Commands()\n\tsort.Slice(children, func(i, j int) bool { return children[i].Name() \u003c children[j].Name() })\n\n\tout := make([]agentContextCommand, 0, len(children))\n\tfor _, sub := range children {\n\t\tif sub.Hidden || sub.Name() == \"agent-context\" {\n\t\t\tcontinue\n\t\t}\n\t\tentry := agentContextCommand{\n\t\t\tName: sub.Name(),\n\t\t\tUse: sub.Use,\n\t\t\tShort: sub.Short,\n\t\t}\n\t\t// Surface Cobra annotations (e.g., pp:endpoint, mcp:read-only) so\n\t\t// agents and the live-dogfood classifier can detect destructive-at-auth\n\t\t// endpoints without parsing source. Empty maps are stripped via\n\t\t// omitempty in the struct tag.\n\t\tif len(sub.Annotations) > 0 {\n\t\t\tentry.Annotations = make(map[string]string, len(sub.Annotations))\n\t\t\tfor k, v := range sub.Annotations {\n\t\t\t\tentry.Annotations[k] = v\n\t\t\t}\n\t\t}\n\t\tsub.Flags().VisitAll(func(f *pflag.Flag) {\n\t\t\tentry.Flags = append(entry.Flags, agentContextFlag{\n\t\t\t\tName: f.Name,\n\t\t\t\tType: f.Value.Type(),\n\t\t\t\tUsage: f.Usage,\n\t\t\t\tDefault: f.DefValue,\n\t\t\t})\n\t\t})\n\t\tsort.Slice(entry.Flags, func(i, j int) bool {\n\t\t\treturn entry.Flags[i].Name \u003c entry.Flags[j].Name\n\t\t})\n\t\tif len(sub.Commands()) > 0 {\n\t\t\tentry.Subcommands = collectAgentCommands(sub)\n\t\t}\n\t\tout = append(out, entry)\n\t}\n\treturn out\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":7504,"content_sha256":"080a722e8fc9baeecdf3259419c92b6ec4bc0e5f9209bcfe05652d7f3b5d7274"},{"filename":"internal/cli/api_discovery.go","content":"// Copyright 2026 rderwin and contributors. Licensed under Apache-2.0. See LICENSE.\n// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.\n\npackage cli\n\nimport (\n\t\"fmt\"\n\t\"sort\"\n\t\"strings\"\n\n\t\"github.com/spf13/cobra\"\n)\n\nfunc newAPICmd(flags *rootFlags) *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse: \"api [interface]\",\n\t\tShort: \"Browse all API endpoints by interface name\",\n\t\tAnnotations: map[string]string{\"mcp:read-only\": \"true\"},\n\t\tLong: `Browse and call any API endpoint using the raw interface names.\n\nThe friendly top-level commands cover the most common operations.\nThis command provides access to ALL endpoints for power users and\nagents that need full API coverage.\n\nRun 'api' with no arguments to list all interfaces.\nRun 'api \u003cinterface>' to see that interface's methods.`,\n\t\tExample: ` # List all available interfaces\n apartments-pp-cli api\n\n # Show methods for a specific interface\n apartments-pp-cli api \u003cinterface-name>`,\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\troot := cmd.Root()\n\n\t\t\tif len(args) > 0 {\n\t\t\t\ttarget := strings.ToLower(args[0])\n\t\t\t\tfor _, child := range root.Commands() {\n\t\t\t\t\tif child.Hidden && strings.ToLower(child.Name()) == target {\n\t\t\t\t\t\tmethods := child.Commands()\n\t\t\t\t\t\tif len(methods) == 0 {\n\t\t\t\t\t\t\treturn child.Help()\n\t\t\t\t\t\t}\n\t\t\t\t\t\tfmt.Fprintf(cmd.OutOrStdout(), \"%s — %s\\n\\nMethods:\\n\", child.Name(), child.Short)\n\t\t\t\t\t\tfor _, method := range methods {\n\t\t\t\t\t\t\tfmt.Fprintf(cmd.OutOrStdout(), \" %-50s %s\\n\", child.Name()+\" \"+method.Name(), method.Short)\n\t\t\t\t\t\t}\n\t\t\t\t\t\tfmt.Fprintf(cmd.OutOrStdout(), \"\\nUse '%s-pp-cli %s \u003cmethod> --help' for details.\\n\", \"apartments\", child.Name())\n\t\t\t\t\t\treturn nil\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\treturn fmt.Errorf(\"interface %q not found. Run '%s-pp-cli api' to list all interfaces\", args[0], \"apartments\")\n\t\t\t}\n\n\t\t\tvar interfaces []string\n\t\t\tfor _, child := range root.Commands() {\n\t\t\t\tif child.Hidden {\n\t\t\t\t\tinterfaces = append(interfaces, fmt.Sprintf(\" %-45s %s\", child.Name(), child.Short))\n\t\t\t\t}\n\t\t\t}\n\t\t\tsort.Strings(interfaces)\n\n\t\t\tif len(interfaces) == 0 {\n\t\t\t\tfmt.Fprintln(cmd.OutOrStdout(), \"No hidden API interfaces found.\")\n\t\t\t\treturn nil\n\t\t\t}\n\n\t\t\tfmt.Fprintf(cmd.OutOrStdout(), \"Available API interfaces (%d):\\n\\n\", len(interfaces))\n\t\t\tfor _, line := range interfaces {\n\t\t\t\tfmt.Fprintln(cmd.OutOrStdout(), line)\n\t\t\t}\n\t\t\tfmt.Fprintf(cmd.OutOrStdout(), \"\\nUse '%s-pp-cli api \u003cinterface>' to see methods.\\n\", \"apartments\")\n\t\t\treturn nil\n\t\t},\n\t}\n\n\treturn cmd\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":2511,"content_sha256":"253f98b2d791b1417aa51bfbb34fd79a1c31db42fe92af0b8ce2f3cfbaa61431"},{"filename":"internal/cli/apt_compare.go","content":"// Copyright 2026 rderwin and contributors. Licensed under Apache-2.0. See LICENSE.\n\npackage cli\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/mvanhorn/printing-press-library/library/other/apartments/internal/apt\"\n\t\"github.com/mvanhorn/printing-press-library/library/other/apartments/internal/client\"\n\t\"github.com/mvanhorn/printing-press-library/library/other/apartments/internal/store\"\n\n\t\"github.com/spf13/cobra\"\n)\n\n// compareOutput is the wide-table layout: rows are field names, columns\n// are listings.\ntype compareOutput struct {\n\tFields []string `json:\"fields\"`\n\tListings []apt.Listing `json:\"listings\"`\n}\n\nfunc newCompareCmd(flags *rootFlags) *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse: \"compare \u003curl-or-id> \u003curl-or-id>...\",\n\t\tShort: \"Pivot 2-8 listings into a wide table — one column per listing.\",\n\t\tAnnotations: map[string]string{\"mcp:read-only\": \"true\"},\n\t\tExample: strings.Trim(`\n apartments-pp-cli compare the-domain-austin-tx the-grove-austin-tx --json\n apartments-pp-cli compare https://www.apartments.com/foo/abc123/ the-grove-austin-tx\n`, \"\\n\"),\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tif len(args) \u003c 2 {\n\t\t\t\treturn cmd.Help()\n\t\t\t}\n\t\t\tif len(args) > 8 {\n\t\t\t\treturn usageErr(fmt.Errorf(\"at most 8 listings can be compared at once\"))\n\t\t\t}\n\t\t\tif dryRunOK(flags) {\n\t\t\t\treturn nil\n\t\t\t}\n\t\t\tdb, err := openAptStore(cmd.Context())\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tdefer db.Close()\n\n\t\t\tc, err := flags.newClient()\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tvar listings []apt.Listing\n\t\t\tfor _, arg := range args {\n\t\t\t\tli, fetchErr := loadOrFetchListing(db, c, arg)\n\t\t\t\tif fetchErr != nil {\n\t\t\t\t\treturn fetchErr\n\t\t\t\t}\n\t\t\t\tlistings = append(listings, li)\n\t\t\t}\n\n\t\t\tout := compareOutput{\n\t\t\t\tFields: []string{\n\t\t\t\t\t\"property_id\", \"title\", \"address.city\", \"address.state\",\n\t\t\t\t\t\"beds\", \"baths\", \"max_rent\", \"sqft\", \"price_per_sqft\",\n\t\t\t\t\t\"amenities_count\", \"phone\",\n\t\t\t\t},\n\t\t\t\tListings: listings,\n\t\t\t}\n\t\t\treturn printJSONFiltered(cmd.OutOrStdout(), out, flags)\n\t\t},\n\t}\n\treturn cmd\n}\n\n// loadOrFetchListing reads a listing from the local cache by property\n// ID; on miss, fetches via the client and caches the result.\nfunc loadOrFetchListing(db *store.Store, c *client.Client, arg string) (apt.Listing, error) {\n\tpropertyID := arg\n\tlistingURL := arg\n\trelPath := \"/\" + strings.Trim(arg, \"/\") + \"/\"\n\tif strings.HasPrefix(arg, \"http\") {\n\t\tpropertyID = apt.ListingURLToPropertyID(arg)\n\t\tidx := strings.Index(arg, \"://\")\n\t\tif idx >= 0 {\n\t\t\trest := arg[idx+3:]\n\t\t\tslash := strings.Index(rest, \"/\")\n\t\t\tif slash >= 0 {\n\t\t\t\trelPath = rest[slash:]\n\t\t\t}\n\t\t}\n\t} else {\n\t\tlistingURL = \"https://www.apartments.com\" + relPath\n\t}\n\n\tif propertyID != \"\" {\n\t\tvar data string\n\t\terr := db.DB().QueryRow(`SELECT data FROM listing WHERE id = ?`, propertyID).Scan(&data)\n\t\tif err == nil && data != \"\" {\n\t\t\tvar li apt.Listing\n\t\t\tif json.Unmarshal([]byte(data), &li) == nil && li.URL != \"\" {\n\t\t\t\treturn li, nil\n\t\t\t}\n\t\t}\n\t}\n\n\tbody, gerr := c.Get(relPath, nil)\n\tif gerr != nil {\n\t\treturn apt.Listing{}, classifyAPIError(gerr)\n\t}\n\tli, perr := apt.ParseListing([]byte(body), listingURL)\n\tif perr != nil {\n\t\treturn apt.Listing{}, apiErr(perr)\n\t}\n\tif li.PropertyID != \"\" {\n\t\tif raw, mErr := json.Marshal(li); mErr == nil {\n\t\t\t_ = db.UpsertListing(raw)\n\t\t}\n\t}\n\treturn li, nil\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":3317,"content_sha256":"f43405cd8389009a4e2aa029845f5e9fd4710a5e4051b071fb9741d8ae606bf7"},{"filename":"internal/cli/apt_digest.go","content":"// Copyright 2026 rderwin and contributors. Licensed under Apache-2.0. See LICENSE.\n\npackage cli\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"sort\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/mvanhorn/printing-press-library/library/other/apartments/internal/apt\"\n\n\t\"github.com/spf13/cobra\"\n)\n\ntype digestOutput struct {\n\tSavedSearch string `json:\"saved_search\"`\n\tSince string `json:\"since\"`\n\tGeneratedAt string `json:\"generated_at\"`\n\tNewListings []watchEntry `json:\"new_listings\"`\n\tRemovedListings []watchEntry `json:\"removed_listings\"`\n\tPriceDrops []dropEntry `json:\"price_drops\"`\n\tTopBySqft []rankEntry `json:\"top_by_sqft\"`\n\tStaleListings []staleEntry `json:\"stale_listings\"`\n\tPhantomListings []phantomEntry `json:\"phantom_listings\"`\n}\n\nfunc newDigestCmd(flags *rootFlags) *cobra.Command {\n\tvar savedSearch string\n\tvar sinceStr string\n\tvar format string\n\n\tcmd := &cobra.Command{\n\t\tUse: \"digest\",\n\t\tShort: \"Single-shot composer: new + removed + drops + top-5 + stale + phantoms for one saved-search.\",\n\t\tAnnotations: map[string]string{\"mcp:read-only\": \"true\"},\n\t\tExample: strings.Trim(`\n apartments-pp-cli digest --saved-search austin-2br --since 7d --json\n apartments-pp-cli digest --saved-search austin-2br --format md\n`, \"\\n\"),\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tif dryRunOK(flags) {\n\t\t\t\treturn nil\n\t\t\t}\n\t\t\tif savedSearch == \"\" {\n\t\t\t\treturn usageErr(fmt.Errorf(\"--saved-search is required\"))\n\t\t\t}\n\t\t\tswitch format {\n\t\t\tcase \"\", \"json\", \"md\":\n\t\t\tdefault:\n\t\t\t\treturn usageErr(fmt.Errorf(\"invalid --format %q: must be json|md\", format))\n\t\t\t}\n\t\t\twindow, err := parseDurationLoose(sinceStr)\n\t\t\tif err != nil {\n\t\t\t\treturn usageErr(err)\n\t\t\t}\n\t\t\tif window \u003c= 0 {\n\t\t\t\twindow = 7 * 24 * time.Hour\n\t\t\t}\n\n\t\t\tdb, derr := openAptStore(cmd.Context())\n\t\t\tif derr != nil {\n\t\t\t\treturn derr\n\t\t\t}\n\t\t\tdefer db.Close()\n\n\t\t\tout := digestOutput{\n\t\t\t\tSavedSearch: savedSearch,\n\t\t\t\tSince: sinceStr,\n\t\t\t\tGeneratedAt: time.Now().UTC().Format(time.RFC3339),\n\t\t\t\tNewListings: []watchEntry{},\n\t\t\t\tRemovedListings: []watchEntry{},\n\t\t\t\tPriceDrops: []dropEntry{},\n\t\t\t\tTopBySqft: []rankEntry{},\n\t\t\t\tStaleListings: []staleEntry{},\n\t\t\t\tPhantomListings: []phantomEntry{},\n\t\t\t}\n\n\t\t\t// new + removed via watch logic.\n\t\t\ttsList, err := apt.LatestSyncTimestamps(db.DB(), savedSearch, 2)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tif len(tsList) == 2 {\n\t\t\t\tlatestRows, err := apt.SnapshotsForSearchAt(db.DB(), savedSearch, tsList[0])\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t\tprevRows, err := apt.SnapshotsForSearchAt(db.DB(), savedSearch, tsList[1])\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t\tlatest := indexByURL(latestRows)\n\t\t\t\tprev := indexByURL(prevRows)\n\t\t\t\tfor url, r := range latest {\n\t\t\t\t\tif _, ok := prev[url]; !ok {\n\t\t\t\t\t\tout.NewListings = append(out.NewListings, watchEntry{URL: url, MaxRent: r.MaxRent, Beds: r.Beds})\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tfor url, r := range prev {\n\t\t\t\t\tif _, ok := latest[url]; !ok {\n\t\t\t\t\t\tout.RemovedListings = append(out.RemovedListings, watchEntry{URL: url, MaxRent: r.MaxRent, Beds: r.Beds})\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// price drops within saved-search url set + window.\n\t\t\tcutoff := time.Now().Add(-window).UTC().Format(time.RFC3339)\n\t\t\trows, err := db.DB().Query(\n\t\t\t\t`SELECT listing_url,\n\t\t\t\t MAX(observed_at) AS latest_obs,\n\t\t\t\t MIN(observed_at) AS earliest_obs\n\t\t\t\t FROM listing_snapshots\n\t\t\t\t WHERE saved_search = ? AND observed_at >= ? AND max_rent > 0\n\t\t\t\t GROUP BY listing_url\n\t\t\t\t HAVING COUNT(*) >= 2`,\n\t\t\t\tsavedSearch, cutoff,\n\t\t\t)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\ttype pair struct{ url, latestTS, earliestTS string }\n\t\t\tvar pairs []pair\n\t\t\tfor rows.Next() {\n\t\t\t\tvar p pair\n\t\t\t\tif err := rows.Scan(&p.url, &p.latestTS, &p.earliestTS); err != nil {\n\t\t\t\t\trows.Close()\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t\tpairs = append(pairs, p)\n\t\t\t}\n\t\t\trows.Close()\n\t\t\tfor _, p := range pairs {\n\t\t\t\tlatestRent, _ := singleSnapshot(db.DB().QueryRow(\n\t\t\t\t\t`SELECT max_rent FROM listing_snapshots\n\t\t\t\t\t WHERE listing_url = ? AND observed_at = ?\n\t\t\t\t\t LIMIT 1`,\n\t\t\t\t\tp.url, p.latestTS,\n\t\t\t\t))\n\t\t\t\tearliestRent, _ := singleSnapshot(db.DB().QueryRow(\n\t\t\t\t\t`SELECT max_rent FROM listing_snapshots\n\t\t\t\t\t WHERE listing_url = ? AND observed_at = ?\n\t\t\t\t\t LIMIT 1`,\n\t\t\t\t\tp.url, p.earliestTS,\n\t\t\t\t))\n\t\t\t\tif earliestRent \u003c= 0 || latestRent \u003c= 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tdropPct := float64(earliestRent-latestRent) / float64(earliestRent) * 100.0\n\t\t\t\tif dropPct \u003c 5 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tout.PriceDrops = append(out.PriceDrops, dropEntry{\n\t\t\t\t\tURL: p.url,\n\t\t\t\t\tEarliestRent: earliestRent,\n\t\t\t\t\tLatestRent: latestRent,\n\t\t\t\t\tDropPct: dropPct,\n\t\t\t\t\tObservedAt: p.latestTS,\n\t\t\t\t})\n\t\t\t}\n\t\t\tsort.SliceStable(out.PriceDrops, func(i, j int) bool {\n\t\t\t\treturn out.PriceDrops[i].DropPct > out.PriceDrops[j].DropPct\n\t\t\t})\n\n\t\t\t// top by sqft from listings whose URL appears in the\n\t\t\t// saved-search snapshots within window.\n\t\t\turlSet := map[string]bool{}\n\t\t\turlsRows, err := db.DB().Query(\n\t\t\t\t`SELECT DISTINCT listing_url FROM listing_snapshots\n\t\t\t\t WHERE saved_search = ? AND observed_at >= ?`,\n\t\t\t\tsavedSearch, cutoff,\n\t\t\t)\n\t\t\tif err == nil {\n\t\t\t\tfor urlsRows.Next() {\n\t\t\t\t\tvar u string\n\t\t\t\t\tif scanErr := urlsRows.Scan(&u); scanErr == nil {\n\t\t\t\t\t\turlSet[u] = true\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\turlsRows.Close()\n\t\t\t}\n\n\t\t\tcached, err := loadCachedListings(db.DB())\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tvar ranked []rankEntry\n\t\t\tfor _, r := range cached {\n\t\t\t\tli := r.Data\n\t\t\t\tif !urlSet[li.URL] {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\te := rankEntry{URL: li.URL, PropertyID: li.PropertyID, Title: li.Title,\n\t\t\t\t\tBeds: li.Beds, MaxRent: li.MaxRent, Sqft: li.Sqft}\n\t\t\t\tif li.MaxRent > 0 && li.Sqft > 0 {\n\t\t\t\t\te.PricePerSqft = float64(li.MaxRent) / float64(li.Sqft)\n\t\t\t\t}\n\t\t\t\tif li.MaxRent > 0 && li.Beds > 0 {\n\t\t\t\t\te.PricePerBed = float64(li.MaxRent) / float64(li.Beds)\n\t\t\t\t}\n\t\t\t\tif e.PricePerSqft > 0 {\n\t\t\t\t\tranked = append(ranked, e)\n\t\t\t\t}\n\t\t\t}\n\t\t\tsort.SliceStable(ranked, func(i, j int) bool {\n\t\t\t\treturn ranked[i].PricePerSqft \u003c ranked[j].PricePerSqft\n\t\t\t})\n\t\t\tif len(ranked) > 5 {\n\t\t\t\tranked = ranked[:5]\n\t\t\t}\n\t\t\tfor i := range ranked {\n\t\t\t\tranked[i].Rank = i + 1\n\t\t\t}\n\t\t\tout.TopBySqft = ranked\n\n\t\t\t// stale within saved-search snapshots: pick urls observed\n\t\t\t// in this saved-search whose latest observed price hasn't\n\t\t\t// changed for ≥ window.\n\t\t\tstaleURLs := map[string]struct {\n\t\t\t\trent int\n\t\t\t\tavail string\n\t\t\t\tchanged time.Time\n\t\t\t\tlatest time.Time\n\t\t\t}{}\n\t\t\tstaleRows, err := db.DB().Query(\n\t\t\t\t`SELECT listing_url, max_rent, available_at, observed_at\n\t\t\t\t FROM listing_snapshots\n\t\t\t\t WHERE saved_search = ?\n\t\t\t\t ORDER BY listing_url, observed_at DESC`,\n\t\t\t\tsavedSearch,\n\t\t\t)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\ttype ssample struct {\n\t\t\t\trent int\n\t\t\t\tavail string\n\t\t\t\tobservedAt time.Time\n\t\t\t}\n\t\t\tgroups := map[string][]ssample{}\n\t\t\tordered := []string{}\n\t\t\tfor staleRows.Next() {\n\t\t\t\tvar (\n\t\t\t\t\turl string\n\t\t\t\t\trent int\n\t\t\t\t\tavail string\n\t\t\t\t\tts string\n\t\t\t\t)\n\t\t\t\tif err := staleRows.Scan(&url, &rent, &avail, &ts); err != nil {\n\t\t\t\t\tstaleRows.Close()\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t\tif _, ok := groups[url]; !ok {\n\t\t\t\t\tordered = append(ordered, url)\n\t\t\t\t}\n\t\t\t\tgroups[url] = append(groups[url], ssample{rent, avail, parseSnapshotTime(ts)})\n\t\t\t}\n\t\t\tstaleRows.Close()\n\t\t\tnow := time.Now().UTC()\n\t\t\tfor _, url := range ordered {\n\t\t\t\tss := groups[url]\n\t\t\t\tif len(ss) == 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tlastChanged := ss[0].observedAt\n\t\t\t\tfor _, s := range ss[1:] {\n\t\t\t\t\tif s.rent == ss[0].rent && s.avail == ss[0].avail {\n\t\t\t\t\t\tlastChanged = s.observedAt\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t\tif now.Sub(lastChanged) >= window {\n\t\t\t\t\tstaleURLs[url] = struct {\n\t\t\t\t\t\trent int\n\t\t\t\t\t\tavail string\n\t\t\t\t\t\tchanged time.Time\n\t\t\t\t\t\tlatest time.Time\n\t\t\t\t\t}{ss[0].rent, ss[0].avail, lastChanged, ss[0].observedAt}\n\t\t\t\t}\n\t\t\t}\n\t\t\tfor url, s := range staleURLs {\n\t\t\t\tout.StaleListings = append(out.StaleListings, staleEntry{\n\t\t\t\t\tURL: url,\n\t\t\t\t\tMaxRent: s.rent,\n\t\t\t\t\tUnchangedDays: int(now.Sub(s.changed).Hours() / 24),\n\t\t\t\t\tLastChangedAt: s.changed.Format(time.RFC3339),\n\t\t\t\t\tLastObservedAt: s.latest.Format(time.RFC3339),\n\t\t\t\t})\n\t\t\t}\n\t\t\tsort.SliceStable(out.StaleListings, func(i, j int) bool {\n\t\t\t\treturn out.StaleListings[i].UnchangedDays > out.StaleListings[j].UnchangedDays\n\t\t\t})\n\n\t\t\t// phantoms: urls in this saved-search with fetch_error\n\t\t\t// signals or that didn't appear in the latest sync.\n\t\t\tphantoms := map[string][]string{}\n\t\t\tperr := db.DB().QueryRow(\n\t\t\t\t`SELECT 1 FROM listing_snapshots WHERE saved_search = ? LIMIT 1`,\n\t\t\t\tsavedSearch,\n\t\t\t).Scan(new(int))\n\t\t\t_ = perr\n\t\t\tfetchErrRows, err := db.DB().Query(\n\t\t\t\t`SELECT DISTINCT listing_url FROM listing_snapshots\n\t\t\t\t WHERE saved_search = ? AND fetch_status >= 400`,\n\t\t\t\tsavedSearch,\n\t\t\t)\n\t\t\tif err == nil {\n\t\t\t\tfor fetchErrRows.Next() {\n\t\t\t\t\tvar u string\n\t\t\t\t\tif scanErr := fetchErrRows.Scan(&u); scanErr == nil {\n\t\t\t\t\t\tphantoms[u] = appendUnique(phantoms[u], \"fetch_error\")\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tfetchErrRows.Close()\n\t\t\t}\n\t\t\tif len(tsList) > 0 {\n\t\t\t\tlatestSync := tsList[0]\n\t\t\t\tdroppedRows, err := db.DB().Query(\n\t\t\t\t\t`SELECT DISTINCT listing_url FROM listing_snapshots\n\t\t\t\t\t WHERE saved_search = ?`,\n\t\t\t\t\tsavedSearch,\n\t\t\t\t)\n\t\t\t\tif err == nil {\n\t\t\t\t\tfor droppedRows.Next() {\n\t\t\t\t\t\tvar u string\n\t\t\t\t\t\tif scanErr := droppedRows.Scan(&u); scanErr == nil {\n\t\t\t\t\t\t\tvar maxObs string\n\t\t\t\t\t\t\tif perr := db.DB().QueryRow(\n\t\t\t\t\t\t\t\t`SELECT MAX(observed_at) FROM listing_snapshots\n\t\t\t\t\t\t\t\t WHERE listing_url = ? AND saved_search = ?`,\n\t\t\t\t\t\t\t\tu, savedSearch,\n\t\t\t\t\t\t\t).Scan(&maxObs); perr == nil {\n\t\t\t\t\t\t\t\tif parseSnapshotTime(maxObs).Before(latestSync.Add(-1 * time.Second)) {\n\t\t\t\t\t\t\t\t\tphantoms[u] = appendUnique(phantoms[u], \"dropped_from_search\")\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tdroppedRows.Close()\n\t\t\t\t}\n\t\t\t}\n\t\t\tfor url, st := range staleURLs {\n\t\t\t\tphantoms[url] = appendUnique(phantoms[url], \"stale\")\n\t\t\t\t_ = st\n\t\t\t}\n\t\t\tfor url, reasons := range phantoms {\n\t\t\t\tif len(reasons) == 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tout.PhantomListings = append(out.PhantomListings, phantomEntry{\n\t\t\t\t\tURL: url,\n\t\t\t\t\tReasons: reasons,\n\t\t\t\t})\n\t\t\t}\n\t\t\tsort.SliceStable(out.PhantomListings, func(i, j int) bool {\n\t\t\t\treturn len(out.PhantomListings[i].Reasons) > len(out.PhantomListings[j].Reasons)\n\t\t\t})\n\n\t\t\tif format == \"md\" {\n\t\t\t\treturn writeDigestMarkdown(cmd, out)\n\t\t\t}\n\t\t\treturn printJSONFiltered(cmd.OutOrStdout(), out, flags)\n\t\t},\n\t}\n\tcmd.Flags().StringVar(&savedSearch, \"saved-search\", \"\", \"Saved-search slug (required).\")\n\tcmd.Flags().StringVar(&sinceStr, \"since\", \"7d\", \"Window: how far back to look.\")\n\tcmd.Flags().StringVar(&format, \"format\", \"json\", \"Output format: json|md.\")\n\treturn cmd\n}\n\nfunc writeDigestMarkdown(cmd *cobra.Command, d digestOutput) error {\n\tw := cmd.OutOrStdout()\n\tfmt.Fprintf(w, \"# Digest: %s\\n\\n\", d.SavedSearch)\n\tfmt.Fprintf(w, \"Generated %s, since %s.\\n\\n\", d.GeneratedAt, d.Since)\n\tmdBlock(w, \"New listings\", len(d.NewListings))\n\tfor _, e := range d.NewListings {\n\t\tfmt.Fprintf(w, \"- [%s](%s) — $%d/mo\\n\", e.URL, e.URL, e.MaxRent)\n\t}\n\tmdBlock(w, \"Removed listings\", len(d.RemovedListings))\n\tfor _, e := range d.RemovedListings {\n\t\tfmt.Fprintf(w, \"- [%s](%s) — $%d/mo\\n\", e.URL, e.URL, e.MaxRent)\n\t}\n\tmdBlock(w, \"Price drops\", len(d.PriceDrops))\n\tfor _, e := range d.PriceDrops {\n\t\tfmt.Fprintf(w, \"- [%s](%s) — %.1f%% drop ($%d → $%d)\\n\", e.URL, e.URL, e.DropPct, e.EarliestRent, e.LatestRent)\n\t}\n\tmdBlock(w, \"Top by $/sqft\", len(d.TopBySqft))\n\tfor _, e := range d.TopBySqft {\n\t\tfmt.Fprintf(w, \"- [%s](%s) — $%.2f/sqft (%d sqft, $%d/mo)\\n\", e.URL, e.URL, e.PricePerSqft, e.Sqft, e.MaxRent)\n\t}\n\tmdBlock(w, \"Stale\", len(d.StaleListings))\n\tfor _, e := range d.StaleListings {\n\t\tfmt.Fprintf(w, \"- [%s](%s) — unchanged %d days\\n\", e.URL, e.URL, e.UnchangedDays)\n\t}\n\tmdBlock(w, \"Phantoms\", len(d.PhantomListings))\n\tfor _, e := range d.PhantomListings {\n\t\tfmt.Fprintf(w, \"- [%s](%s) — %s\\n\", e.URL, e.URL, strings.Join(e.Reasons, \", \"))\n\t}\n\treturn nil\n}\n\nfunc mdBlock(w io.Writer, title string, n int) {\n\tfmt.Fprintf(w, \"## %s (%d)\\n\\n\", title, n)\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":12077,"content_sha256":"6ee836051933b42c5b21c1061509219c4f96af2b64e19325d140dd531e1c15c5"},{"filename":"internal/cli/apt_drops.go","content":"// Copyright 2026 rderwin and contributors. Licensed under Apache-2.0. See LICENSE.\n\npackage cli\n\nimport (\n\t\"sort\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/spf13/cobra\"\n)\n\n// dropEntry describes one listing with a price drop within the window.\ntype dropEntry struct {\n\tURL string `json:\"url\"`\n\tEarliestRent int `json:\"earliest_rent\"`\n\tLatestRent int `json:\"latest_rent\"`\n\tDropPct float64 `json:\"drop_pct\"`\n\tObservedAt string `json:\"observed_at,omitempty\"`\n}\n\nfunc newDropsCmd(flags *rootFlags) *cobra.Command {\n\tvar sinceStr string\n\tvar minPct float64\n\tvar limit int\n\n\tcmd := &cobra.Command{\n\t\tUse: \"drops\",\n\t\tShort: \"List synced listings whose max rent dropped by ≥N% within a time window.\",\n\t\tAnnotations: map[string]string{\"mcp:read-only\": \"true\"},\n\t\tExample: strings.Trim(`\n apartments-pp-cli drops --since 14d --min-pct 5 --json\n apartments-pp-cli drops --since 30d --min-pct 10 --limit 50\n apartments-pp-cli drops --since 7d\n`, \"\\n\"),\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tif dryRunOK(flags) {\n\t\t\t\treturn nil\n\t\t\t}\n\t\t\twindow, err := parseDurationLoose(sinceStr)\n\t\t\tif err != nil {\n\t\t\t\treturn usageErr(err)\n\t\t\t}\n\t\t\tif window \u003c= 0 {\n\t\t\t\twindow = 14 * 24 * time.Hour\n\t\t\t}\n\t\t\tdb, derr := openAptStore(cmd.Context())\n\t\t\tif derr != nil {\n\t\t\t\treturn derr\n\t\t\t}\n\t\t\tdefer db.Close()\n\n\t\t\tcutoff := time.Now().Add(-window).UTC()\n\t\t\trows, err := db.DB().Query(\n\t\t\t\t`SELECT listing_url,\n\t\t\t\t MAX(observed_at) AS latest_obs,\n\t\t\t\t MIN(observed_at) AS earliest_obs\n\t\t\t\t FROM listing_snapshots\n\t\t\t\t WHERE observed_at >= ? AND max_rent IS NOT NULL AND max_rent > 0\n\t\t\t\t GROUP BY listing_url\n\t\t\t\t HAVING COUNT(*) >= 2`,\n\t\t\t\tcutoff.Format(time.RFC3339),\n\t\t\t)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tdefer rows.Close()\n\n\t\t\ttype pair struct {\n\t\t\t\turl string\n\t\t\t\tlatestTS string\n\t\t\t\tearliestTS string\n\t\t\t}\n\t\t\tvar pairs []pair\n\t\t\tfor rows.Next() {\n\t\t\t\tvar p pair\n\t\t\t\tif err := rows.Scan(&p.url, &p.latestTS, &p.earliestTS); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t\tpairs = append(pairs, p)\n\t\t\t}\n\t\t\tif err := rows.Err(); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tvar out []dropEntry\n\t\t\tfor _, p := range pairs {\n\t\t\t\tlatestRow, err := singleSnapshot(db.DB().QueryRow(\n\t\t\t\t\t`SELECT max_rent FROM listing_snapshots\n\t\t\t\t\t WHERE listing_url = ? AND observed_at = ? AND max_rent > 0\n\t\t\t\t\t LIMIT 1`,\n\t\t\t\t\tp.url, p.latestTS,\n\t\t\t\t))\n\t\t\t\tif err != nil {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tearliestRow, err := singleSnapshot(db.DB().QueryRow(\n\t\t\t\t\t`SELECT max_rent FROM listing_snapshots\n\t\t\t\t\t WHERE listing_url = ? AND observed_at = ? AND max_rent > 0\n\t\t\t\t\t LIMIT 1`,\n\t\t\t\t\tp.url, p.earliestTS,\n\t\t\t\t))\n\t\t\t\tif err != nil {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tif earliestRow \u003c= 0 || latestRow \u003c= 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tdropPct := float64(earliestRow-latestRow) / float64(earliestRow) * 100.0\n\t\t\t\tif dropPct \u003c minPct {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tout = append(out, dropEntry{\n\t\t\t\t\tURL: p.url,\n\t\t\t\t\tEarliestRent: earliestRow,\n\t\t\t\t\tLatestRent: latestRow,\n\t\t\t\t\tDropPct: dropPct,\n\t\t\t\t\tObservedAt: p.latestTS,\n\t\t\t\t})\n\t\t\t}\n\t\t\tsort.SliceStable(out, func(i, j int) bool {\n\t\t\t\treturn out[i].DropPct > out[j].DropPct\n\t\t\t})\n\t\t\tif limit > 0 && len(out) > limit {\n\t\t\t\tout = out[:limit]\n\t\t\t}\n\t\t\tif out == nil {\n\t\t\t\tout = []dropEntry{}\n\t\t\t}\n\t\t\treturn printJSONFiltered(cmd.OutOrStdout(), out, flags)\n\t\t},\n\t}\n\tcmd.Flags().StringVar(&sinceStr, \"since\", \"14d\", \"Window: how far back to look (e.g. 7d, 24h).\")\n\tcmd.Flags().Float64Var(&minPct, \"min-pct\", 5, \"Minimum drop percentage.\")\n\tcmd.Flags().IntVar(&limit, \"limit\", 25, \"Max rows to return.\")\n\treturn cmd\n}\n\n// singleSnapshot scans a single max_rent value from a *sql.Row.\nfunc singleSnapshot(row interface {\n\tScan(dest ...any) error\n}) (int, error) {\n\tvar v int\n\tif err := row.Scan(&v); err != nil {\n\t\treturn 0, err\n\t}\n\treturn v, nil\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":3813,"content_sha256":"ae20ff6ac67bc47af334e89b104891be59b1f8e3e5c46be0d9370cd66e46f10a"},{"filename":"internal/cli/apt_floorplans.go","content":"// Copyright 2026 rderwin and contributors. Licensed under Apache-2.0. See LICENSE.\n\npackage cli\n\nimport (\n\t\"fmt\"\n\t\"sort\"\n\t\"strings\"\n\n\t\"github.com/spf13/cobra\"\n)\n\ntype floorPlanRow struct {\n\tListingURL string `json:\"listing_url\"`\n\tPlanName string `json:\"plan_name,omitempty\"`\n\tBeds int `json:\"beds,omitempty\"`\n\tBaths float64 `json:\"baths,omitempty\"`\n\tSqft int `json:\"sqft,omitempty\"`\n\tRentMin int `json:\"rent_min,omitempty\"`\n\tRentMax int `json:\"rent_max,omitempty\"`\n\tPricePerSqft float64 `json:\"price_per_sqft,omitempty\"`\n}\n\nfunc newFloorplansCmd(flags *rootFlags) *cobra.Command {\n\tvar rank string\n\tvar beds int\n\tvar limit int\n\n\tcmd := &cobra.Command{\n\t\tUse: \"floorplans\",\n\t\tShort: \"Rank per-floor-plan rent/sqft across synced listings.\",\n\t\tAnnotations: map[string]string{\"mcp:read-only\": \"true\"},\n\t\tExample: strings.Trim(`\n apartments-pp-cli floorplans --rank price-per-sqft --beds 2 --json\n apartments-pp-cli floorplans --limit 50\n`, \"\\n\"),\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tswitch rank {\n\t\t\tcase \"\", \"price-per-sqft\":\n\t\t\tdefault:\n\t\t\t\treturn usageErr(fmt.Errorf(\"invalid --rank %q: must be price-per-sqft\", rank))\n\t\t\t}\n\t\t\tif dryRunOK(flags) {\n\t\t\t\treturn nil\n\t\t\t}\n\t\t\tdb, err := openAptStore(cmd.Context())\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tdefer db.Close()\n\n\t\t\trows, err := loadCachedListings(db.DB())\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tvar out []floorPlanRow\n\t\t\tfor _, r := range rows {\n\t\t\t\tli := r.Data\n\t\t\t\tfor _, fp := range li.FloorPlans {\n\t\t\t\t\tif cmd.Flags().Changed(\"beds\") && fp.Beds != beds {\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\t\t\t\t\trow := floorPlanRow{\n\t\t\t\t\t\tListingURL: li.URL,\n\t\t\t\t\t\tPlanName: fp.Name,\n\t\t\t\t\t\tBeds: fp.Beds,\n\t\t\t\t\t\tBaths: fp.Baths,\n\t\t\t\t\t\tSqft: fp.Sqft,\n\t\t\t\t\t\tRentMin: fp.RentMin,\n\t\t\t\t\t\tRentMax: fp.RentMax,\n\t\t\t\t\t}\n\t\t\t\t\tif fp.RentMin > 0 && fp.Sqft > 0 {\n\t\t\t\t\t\trow.PricePerSqft = float64(fp.RentMin) / float64(fp.Sqft)\n\t\t\t\t\t}\n\t\t\t\t\tout = append(out, row)\n\t\t\t\t}\n\t\t\t}\n\t\t\tsort.SliceStable(out, func(i, j int) bool {\n\t\t\t\tai := out[i].PricePerSqft\n\t\t\t\tbj := out[j].PricePerSqft\n\t\t\t\tif ai == 0 {\n\t\t\t\t\tai = 1e18\n\t\t\t\t}\n\t\t\t\tif bj == 0 {\n\t\t\t\t\tbj = 1e18\n\t\t\t\t}\n\t\t\t\treturn ai \u003c bj\n\t\t\t})\n\t\t\tif limit > 0 && len(out) > limit {\n\t\t\t\tout = out[:limit]\n\t\t\t}\n\t\t\tif out == nil {\n\t\t\t\tout = []floorPlanRow{}\n\t\t\t}\n\t\t\treturn printJSONFiltered(cmd.OutOrStdout(), out, flags)\n\t\t},\n\t}\n\tcmd.Flags().StringVar(&rank, \"rank\", \"price-per-sqft\", \"Ranker (currently: price-per-sqft).\")\n\tcmd.Flags().IntVar(&beds, \"beds\", 0, \"Filter to plans with exact bed count.\")\n\tcmd.Flags().IntVar(&limit, \"limit\", 25, \"Max rows to return.\")\n\treturn cmd\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":2659,"content_sha256":"09d456a81b3df6e32cc4612b3ab023fc0dd1dc625b8d7b824ead227696333bbb"},{"filename":"internal/cli/apt_helpers.go","content":"// Copyright 2026 rderwin and contributors. Licensed under Apache-2.0. See LICENSE.\n\npackage cli\n\nimport (\n\t\"context\"\n\t\"database/sql\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"sort\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/mvanhorn/printing-press-library/library/other/apartments/internal/apt\"\n\t\"github.com/mvanhorn/printing-press-library/library/other/apartments/internal/store\"\n)\n\n// cityStateFromListingURL extracts the trailing (city, state) tokens\n// from an apartments.com listing URL. Listing URLs are shaped like:\n//\n//\thttps://www.apartments.com/{property-name}-{city}-{state}/{id}/\n//\n// where state is a two-letter US abbreviation. Returns (\"\",\"\") when\n// the trailing two-letter slug doesn't look like a state. Multi-word\n// cities (san-francisco, new-york) require a dictionary to fully\n// disambiguate; this helper returns the LAST hyphenated token before\n// the state, which is correct for most single-word cities.\nfunc cityStateFromListingURL(u string) (city, state string) {\n\tif u == \"\" {\n\t\treturn \"\", \"\"\n\t}\n\tidx := strings.Index(u, \"://\")\n\tif idx >= 0 {\n\t\tu = u[idx+3:]\n\t}\n\tif hostEnd := strings.Index(u, \"/\"); hostEnd >= 0 {\n\t\tu = u[hostEnd+1:]\n\t}\n\tu = strings.Trim(u, \"/\")\n\tparts := strings.Split(u, \"/\")\n\tif len(parts) == 0 {\n\t\treturn \"\", \"\"\n\t}\n\ttokens := strings.Split(parts[0], \"-\")\n\tif len(tokens) \u003c 2 {\n\t\treturn \"\", \"\"\n\t}\n\tcandState := strings.ToLower(tokens[len(tokens)-1])\n\tif len(candState) != 2 {\n\t\treturn \"\", \"\"\n\t}\n\tcandCity := strings.ToLower(tokens[len(tokens)-2])\n\treturn candCity, candState\n}\n\n// lookupListingSnapshot returns the most recent placard observation for\n// a listing URL from the local snapshots table. Used as a fallback when\n// the live listing-detail fetch returns 403 (apartments.com listing\n// pages have stricter bot protection than search pages).\nfunc lookupListingSnapshot(ctx context.Context, listingURL string) (*apt.Listing, error) {\n\ts, err := openAptStore(ctx)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer s.Close()\n\trows, err := apt.SnapshotsForURL(s.DB(), listingURL)\n\tif err != nil || len(rows) == 0 {\n\t\treturn nil, err\n\t}\n\tr := rows[len(rows)-1] // SnapshotsForURL returns oldest-first; take the latest\n\tli := &apt.Listing{\n\t\tURL: listingURL,\n\t\tPropertyID: apt.ListingURLToPropertyID(listingURL),\n\t\tBeds: r.Beds,\n\t\tBaths: r.Baths,\n\t\tMaxRent: r.MaxRent,\n\t}\n\treturn li, nil\n}\n\n// openAptStore opens the local SQLite store and ensures the apt\n// extension schema is present. Callers must Close() the returned\n// store.\nfunc openAptStore(ctx context.Context) (*store.Store, error) {\n\ts, err := store.OpenWithContext(ctx, defaultDBPath(\"apartments-pp-cli\"))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"opening local store: %w\", err)\n\t}\n\tif err := apt.EnsureExtSchema(s.DB()); err != nil {\n\t\ts.Close()\n\t\treturn nil, fmt.Errorf(\"apt schema: %w\", err)\n\t}\n\treturn s, nil\n}\n\n// listingsRow is one cached listing as we read it back from the\n// generic listing(id, data JSON) table.\ntype listingsRow struct {\n\tID string\n\tData apt.Listing\n}\n\n// loadCachedListings returns every listing currently cached in the\n// local store, decoded into apt.Listing. Filter is best-effort and\n// applied in Go after the SQL fetch.\nfunc loadCachedListings(db *sql.DB) ([]listingsRow, error) {\n\trows, err := db.Query(`SELECT id, data FROM listing`)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\tvar out []listingsRow\n\tfor rows.Next() {\n\t\tvar (\n\t\t\tid string\n\t\t\tdata string\n\t\t)\n\t\tif err := rows.Scan(&id, &data); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tvar li apt.Listing\n\t\tif err := json.Unmarshal([]byte(data), &li); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tout = append(out, listingsRow{ID: id, Data: li})\n\t}\n\treturn out, rows.Err()\n}\n\n// latestObservationPerURL returns the most recent SnapshotRow for each\n// listing_url present in listing_snapshots. Used by drops/stale/etc.\nfunc latestObservationPerURL(db *sql.DB) ([]apt.SnapshotRow, error) {\n\trows, err := db.Query(\n\t\t`SELECT s.listing_url, s.property_id, s.saved_search, s.observed_at,\n\t\t s.max_rent, s.beds, s.baths, s.available_at, s.fetch_status\n\t\t FROM listing_snapshots s\n\t\t INNER JOIN (\n\t\t SELECT listing_url, MAX(observed_at) AS max_obs\n\t\t FROM listing_snapshots\n\t\t GROUP BY listing_url\n\t\t ) m\n\t\t ON s.listing_url = m.listing_url AND s.observed_at = m.max_obs`,\n\t)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\tvar out []apt.SnapshotRow\n\tfor rows.Next() {\n\t\tvar (\n\t\t\tr apt.SnapshotRow\n\t\t\tts string\n\t\t\tpropertyID sql.NullString\n\t\t\tsavedSearch sql.NullString\n\t\t\tavailableAt sql.NullString\n\t\t\tmaxRent sql.NullInt64\n\t\t\tbeds sql.NullInt64\n\t\t\tbaths sql.NullFloat64\n\t\t\tfetchStatus sql.NullInt64\n\t\t)\n\t\tif err := rows.Scan(&r.ListingURL, &propertyID, &savedSearch, &ts, &maxRent, &beds, &baths, &availableAt, &fetchStatus); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tr.PropertyID = propertyID.String\n\t\tr.SavedSearch = savedSearch.String\n\t\tr.ObservedAt = parseSnapshotTime(ts)\n\t\tr.MaxRent = int(maxRent.Int64)\n\t\tr.Beds = int(beds.Int64)\n\t\tr.Baths = baths.Float64\n\t\tr.AvailableAt = availableAt.String\n\t\tr.FetchStatus = int(fetchStatus.Int64)\n\t\tout = append(out, r)\n\t}\n\treturn out, rows.Err()\n}\n\n// parseSnapshotTime mirrors apt.parseStoredTime; we keep a copy here so\n// the apt package stays consumable as a pure-data layer.\nfunc parseSnapshotTime(s string) time.Time {\n\ts = strings.TrimSpace(s)\n\tif s == \"\" {\n\t\treturn time.Time{}\n\t}\n\tfor _, layout := range []string{\n\t\ttime.RFC3339Nano,\n\t\ttime.RFC3339,\n\t\t\"2006-01-02 15:04:05.999999999 -0700 MST\",\n\t\t\"2006-01-02 15:04:05.999999 -0700 MST\",\n\t\t\"2006-01-02 15:04:05.999 -0700 MST\",\n\t\t\"2006-01-02 15:04:05 -0700 MST\",\n\t\t\"2006-01-02 15:04:05.999999999 -0700\",\n\t\t\"2006-01-02 15:04:05 -0700\",\n\t\t\"2006-01-02 15:04:05\",\n\t} {\n\t\tif t, err := time.Parse(layout, s); err == nil {\n\t\t\treturn t\n\t\t}\n\t}\n\treturn time.Time{}\n}\n\n// parseDurationLoose understands time.ParseDuration plus a \"Nd\" suffix\n// (days, integer N) so flags like --since 7d work without a custom flag\n// type. Returns 0 + nil when the input is empty.\nfunc parseDurationLoose(s string) (time.Duration, error) {\n\ts = strings.TrimSpace(s)\n\tif s == \"\" {\n\t\treturn 0, nil\n\t}\n\tif strings.HasSuffix(s, \"d\") {\n\t\tvar days int\n\t\tif _, err := fmt.Sscanf(s, \"%dd\", &days); err == nil && days >= 0 {\n\t\t\treturn time.Duration(days) * 24 * time.Hour, nil\n\t\t}\n\t}\n\treturn time.ParseDuration(s)\n}\n\n// median returns the middle value of a sorted-in-place copy of xs.\n// Returns 0 for empty input.\nfunc median(xs []float64) float64 {\n\tif len(xs) == 0 {\n\t\treturn 0\n\t}\n\tcp := make([]float64, len(xs))\n\tcopy(cp, xs)\n\tsort.Float64s(cp)\n\tmid := len(cp) / 2\n\tif len(cp)%2 == 0 {\n\t\treturn (cp[mid-1] + cp[mid]) / 2\n\t}\n\treturn cp[mid]\n}\n\n// percentile returns the p-th percentile (0..100) using linear\n// interpolation on a sorted copy of xs.\nfunc percentile(xs []float64, p float64) float64 {\n\tif len(xs) == 0 {\n\t\treturn 0\n\t}\n\tcp := make([]float64, len(xs))\n\tcopy(cp, xs)\n\tsort.Float64s(cp)\n\tif p \u003c= 0 {\n\t\treturn cp[0]\n\t}\n\tif p >= 100 {\n\t\treturn cp[len(cp)-1]\n\t}\n\tidx := (p / 100.0) * float64(len(cp)-1)\n\tlo := int(idx)\n\tfrac := idx - float64(lo)\n\tif lo+1 >= len(cp) {\n\t\treturn cp[lo]\n\t}\n\treturn cp[lo] + frac*(cp[lo+1]-cp[lo])\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":7155,"content_sha256":"5fa7abe8f32897d17b9278e1ede92843a8bbac80c0fe27e36930a01f8e632ed9"},{"filename":"internal/cli/apt_history.go","content":"// Copyright 2026 rderwin and contributors. Licensed under Apache-2.0. See LICENSE.\n\npackage cli\n\nimport (\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/mvanhorn/printing-press-library/library/other/apartments/internal/apt\"\n\n\t\"github.com/spf13/cobra\"\n)\n\ntype historyEntry struct {\n\tObservedAt string `json:\"observed_at\"`\n\tMaxRent int `json:\"max_rent,omitempty\"`\n\tBeds int `json:\"beds,omitempty\"`\n\tBaths float64 `json:\"baths,omitempty\"`\n\tAvailableAt string `json:\"available_at,omitempty\"`\n\tFetchStatus int `json:\"fetch_status,omitempty\"`\n}\n\nfunc newHistoryCmd(flags *rootFlags) *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse: \"history \u003curl-or-id>\",\n\t\tShort: \"Time-series of every observation of one listing.\",\n\t\tAnnotations: map[string]string{\"mcp:read-only\": \"true\"},\n\t\tExample: strings.Trim(`\n apartments-pp-cli history https://www.apartments.com/the-domain-austin-tx/abc123/ --json\n apartments-pp-cli history the-domain-austin-tx\n`, \"\\n\"),\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tif len(args) == 0 {\n\t\t\t\treturn cmd.Help()\n\t\t\t}\n\t\t\tif dryRunOK(flags) {\n\t\t\t\treturn nil\n\t\t\t}\n\t\t\targ := args[0]\n\t\t\tlistingURL := arg\n\t\t\tif !strings.HasPrefix(arg, \"http\") {\n\t\t\t\tlistingURL = \"https://www.apartments.com/\" + strings.Trim(arg, \"/\") + \"/\"\n\t\t\t}\n\t\t\tdb, err := openAptStore(cmd.Context())\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tdefer db.Close()\n\n\t\t\trows, err := apt.SnapshotsForURL(db.DB(), listingURL)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tout := make([]historyEntry, 0, len(rows))\n\t\t\tfor _, r := range rows {\n\t\t\t\tout = append(out, historyEntry{\n\t\t\t\t\tObservedAt: r.ObservedAt.Format(time.RFC3339),\n\t\t\t\t\tMaxRent: r.MaxRent,\n\t\t\t\t\tBeds: r.Beds,\n\t\t\t\t\tBaths: r.Baths,\n\t\t\t\t\tAvailableAt: r.AvailableAt,\n\t\t\t\t\tFetchStatus: r.FetchStatus,\n\t\t\t\t})\n\t\t\t}\n\t\t\treturn printJSONFiltered(cmd.OutOrStdout(), out, flags)\n\t\t},\n\t}\n\treturn cmd\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":1890,"content_sha256":"4c1f1d91c78cbf2c9b978f85dea0d615188ac77a9c01bfb4de9b5e8349a1be27"},{"filename":"internal/cli/apt_market.go","content":"// Copyright 2026 rderwin and contributors. Licensed under Apache-2.0. See LICENSE.\n\npackage cli\n\nimport (\n\t\"strings\"\n\n\t\"github.com/spf13/cobra\"\n)\n\ntype marketStats struct {\n\tCityState string `json:\"city_state\"`\n\tCount int `json:\"count\"`\n\tMedianRent float64 `json:\"median_rent\"`\n\tP10Rent float64 `json:\"p10_rent\"`\n\tP90Rent float64 `json:\"p90_rent\"`\n\tMedianRentPerSqft float64 `json:\"median_rent_per_sqft\"`\n\tPetFriendlyShare float64 `json:\"pet_friendly_share\"`\n\tBeds *int `json:\"beds,omitempty\"`\n}\n\nfunc newMarketCmd(flags *rootFlags) *cobra.Command {\n\tvar beds int\n\n\tcmd := &cobra.Command{\n\t\tUse: \"market \u003ccity-state>\",\n\t\tShort: \"Median, p10/p90 rent, rent/sqft, pet-friendly share for one city/state.\",\n\t\tAnnotations: map[string]string{\"mcp:read-only\": \"true\"},\n\t\tExample: strings.Trim(`\n apartments-pp-cli market austin-tx --json\n apartments-pp-cli market new-york-ny --beds 2\n`, \"\\n\"),\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tif len(args) == 0 {\n\t\t\t\treturn cmd.Help()\n\t\t\t}\n\t\t\tif dryRunOK(flags) {\n\t\t\t\treturn nil\n\t\t\t}\n\t\t\tslug := strings.ToLower(strings.TrimSpace(args[0]))\n\t\t\tcity, state := splitCityState(slug)\n\n\t\t\tdb, err := openAptStore(cmd.Context())\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tdefer db.Close()\n\n\t\t\trows, err := loadCachedListings(db.DB())\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tstats := marketStats{CityState: slug}\n\t\t\tif cmd.Flags().Changed(\"beds\") {\n\t\t\t\tb := beds\n\t\t\t\tstats.Beds = &b\n\t\t\t}\n\n\t\t\tvar rents, ratios []float64\n\t\t\tpetFriendly := 0\n\t\t\tmatched := 0\n\t\t\tfor _, r := range rows {\n\t\t\t\tli := r.Data\n\t\t\t\t// Address fields are populated only by successful listing\n\t\t\t\t// detail fetches; placards (sync-search) populate URL but\n\t\t\t\t// not address. Fall back to parsing city-state from the\n\t\t\t\t// URL slug — listing URLs always end with -{city}-{state}.\n\t\t\t\tliCity := strings.ToLower(strings.ReplaceAll(li.Address.City, \" \", \"-\"))\n\t\t\t\tliState := strings.ToLower(li.Address.State)\n\t\t\t\tif liCity == \"\" || liState == \"\" {\n\t\t\t\t\turlCity, urlState := cityStateFromListingURL(li.URL)\n\t\t\t\t\tif liCity == \"\" {\n\t\t\t\t\t\tliCity = urlCity\n\t\t\t\t\t}\n\t\t\t\t\tif liState == \"\" {\n\t\t\t\t\t\tliState = urlState\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif city != \"\" && !strings.HasPrefix(liCity, city) {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tif state != \"\" && !strings.EqualFold(liState, state) {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tif cmd.Flags().Changed(\"beds\") && li.Beds != beds {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tmatched++\n\t\t\t\tif li.MaxRent > 0 {\n\t\t\t\t\trents = append(rents, float64(li.MaxRent))\n\t\t\t\t\tif li.Sqft > 0 {\n\t\t\t\t\t\tratios = append(ratios, float64(li.MaxRent)/float64(li.Sqft))\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif li.PetPolicy.AllowsCats || li.PetPolicy.AllowsDogs {\n\t\t\t\t\tpetFriendly++\n\t\t\t\t}\n\t\t\t}\n\t\t\tstats.Count = matched\n\t\t\tstats.MedianRent = median(rents)\n\t\t\tstats.P10Rent = percentile(rents, 10)\n\t\t\tstats.P90Rent = percentile(rents, 90)\n\t\t\tstats.MedianRentPerSqft = median(ratios)\n\t\t\tif matched > 0 {\n\t\t\t\tstats.PetFriendlyShare = float64(petFriendly) / float64(matched)\n\t\t\t}\n\t\t\treturn printJSONFiltered(cmd.OutOrStdout(), stats, flags)\n\t\t},\n\t}\n\tcmd.Flags().IntVar(&beds, \"beds\", 0, \"Filter by exact bed count.\")\n\treturn cmd\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":3167,"content_sha256":"7bf00b967474a2c6d016a8cc71f7b952c42b4c0e953e10d1b52bc95aa63b9f87"},{"filename":"internal/cli/apt_musthave.go","content":"// Copyright 2026 rderwin and contributors. Licensed under Apache-2.0. See LICENSE.\n\npackage cli\n\nimport (\n\t\"strings\"\n\n\t\"github.com/mvanhorn/printing-press-library/library/other/apartments/internal/apt\"\n\n\t\"github.com/spf13/cobra\"\n)\n\nfunc newMustHaveCmd(flags *rootFlags) *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse: \"must-have \u003cterm> [term...]\",\n\t\tShort: \"Filter synced listings to those whose amenities array contains ALL listed terms.\",\n\t\tAnnotations: map[string]string{\"mcp:read-only\": \"true\"},\n\t\tExample: strings.Trim(`\n apartments-pp-cli must-have \"in unit washer\" --json\n apartments-pp-cli must-have pool gym garage\n`, \"\\n\"),\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tif len(args) == 0 {\n\t\t\t\treturn cmd.Help()\n\t\t\t}\n\t\t\tif dryRunOK(flags) {\n\t\t\t\treturn nil\n\t\t\t}\n\t\t\tterms := make([]string, 0, len(args))\n\t\t\tfor _, a := range args {\n\t\t\t\ta = strings.ToLower(strings.TrimSpace(a))\n\t\t\t\tif a != \"\" {\n\t\t\t\t\tterms = append(terms, a)\n\t\t\t\t}\n\t\t\t}\n\t\t\tdb, err := openAptStore(cmd.Context())\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tdefer db.Close()\n\n\t\t\trows, err := loadCachedListings(db.DB())\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tvar matched []apt.Listing\n\t\t\tvar withAmenities int\n\t\t\tfor _, r := range rows {\n\t\t\t\tli := r.Data\n\t\t\t\tif len(li.Amenities) > 0 {\n\t\t\t\t\twithAmenities++\n\t\t\t\t}\n\t\t\t\tlowered := make([]string, len(li.Amenities))\n\t\t\t\tfor i, a := range li.Amenities {\n\t\t\t\t\tlowered[i] = strings.ToLower(a)\n\t\t\t\t}\n\t\t\t\tok := true\n\t\t\t\tfor _, t := range terms {\n\t\t\t\t\thit := false\n\t\t\t\t\tfor _, a := range lowered {\n\t\t\t\t\t\tif strings.Contains(a, t) {\n\t\t\t\t\t\t\thit = true\n\t\t\t\t\t\t\tbreak\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tif !hit {\n\t\t\t\t\t\tok = false\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif ok {\n\t\t\t\t\tmatched = append(matched, li)\n\t\t\t\t}\n\t\t\t}\n\t\t\tif matched == nil {\n\t\t\t\tmatched = []apt.Listing{}\n\t\t\t}\n\t\t\t// Empty-result envelope: when no listings match, surface why.\n\t\t\t// This prevents an agent from pivoting on '[]' alone.\n\t\t\tif len(matched) == 0 {\n\t\t\t\tout := map[string]any{\n\t\t\t\t\t\"terms\": terms,\n\t\t\t\t\t\"matches\": matched,\n\t\t\t\t\t\"cached_listings\": len(rows),\n\t\t\t\t\t\"cached_with_amenities\": withAmenities,\n\t\t\t\t}\n\t\t\t\tswitch {\n\t\t\t\tcase len(rows) == 0:\n\t\t\t\t\tout[\"hint\"] = \"no listings cached locally — run 'apartments-pp-cli sync-search \u003cslug> --city \u003ccity> --state \u003cst>' first\"\n\t\t\t\tcase withAmenities == 0:\n\t\t\t\t\tout[\"hint\"] = \"cached listings have no amenities populated — apartments.com listing detail pages 403-block; amenities are populated only when 'listing get' succeeds for individual URLs\"\n\t\t\t\tdefault:\n\t\t\t\t\tout[\"hint\"] = \"no cached listing has all of these terms in its amenities array; try fewer terms or different wording\"\n\t\t\t\t}\n\t\t\t\treturn printJSONFiltered(cmd.OutOrStdout(), out, flags)\n\t\t\t}\n\t\t\treturn printJSONFiltered(cmd.OutOrStdout(), matched, flags)\n\t\t},\n\t}\n\treturn cmd\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":2819,"content_sha256":"a668eba2571895b4944035994fe09d540e9984c813255a5b3982d4267027d9bc"},{"filename":"internal/cli/apt_nearby.go","content":"// Copyright 2026 rderwin and contributors. Licensed under Apache-2.0. See LICENSE.\n\npackage cli\n\nimport (\n\t\"fmt\"\n\t\"sort\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/mvanhorn/printing-press-library/library/other/apartments/internal/apt\"\n\n\t\"github.com/spf13/cobra\"\n)\n\n// nearbyEntry is one ranked placard from a nearby fan-out.\ntype nearbyEntry struct {\n\tURL string `json:\"url\"`\n\tPropertyID string `json:\"property_id,omitempty\"`\n\tTitle string `json:\"title,omitempty\"`\n\tBeds int `json:\"beds,omitempty\"`\n\tBaths float64 `json:\"baths,omitempty\"`\n\tMaxRent int `json:\"max_rent,omitempty\"`\n\tSearchSlug string `json:\"search_slug,omitempty\"`\n\tPricePerBed float64 `json:\"price_per_bed,omitempty\"`\n}\n\nfunc newNearbyCmd(flags *rootFlags) *cobra.Command {\n\tvar rank string\n\trf := &rentalsFlags{}\n\n\tcmd := &cobra.Command{\n\t\tUse: \"nearby \u003ccity-state> [city-state...]\",\n\t\tShort: \"Fan out a search across multiple city-state slugs and return one ranked, deduped list.\",\n\t\tAnnotations: map[string]string{\"mcp:read-only\": \"true\"},\n\t\tExample: strings.Trim(`\n apartments-pp-cli nearby austin-tx round-rock-tx --beds 2 --price-max 2500 --json\n apartments-pp-cli nearby brooklyn-ny queens-ny --pets dog --rank rent\n # Note: --rank sqft only ranks listings with both maxrent and sqft populated;\n # most placard summaries don't carry sqft, so those float to the bottom.\n apartments-pp-cli nearby austin-tx round-rock-tx pflugerville-tx --rank sqft\n`, \"\\n\"),\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tif len(args) \u003c 2 {\n\t\t\t\treturn cmd.Help()\n\t\t\t}\n\t\t\tswitch rank {\n\t\t\tcase \"\", \"rent\", \"bed\", \"sqft\":\n\t\t\tdefault:\n\t\t\t\treturn usageErr(fmt.Errorf(\"invalid --rank %q: must be rent|bed|sqft\", rank))\n\t\t\t}\n\t\t\tif dryRunOK(flags) {\n\t\t\t\treturn nil\n\t\t\t}\n\t\t\tc, err := flags.newClient()\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tseen := map[string]bool{}\n\t\t\tvar collected []nearbyEntry\n\t\t\tfor _, slug := range args {\n\t\t\t\tslug = strings.ToLower(strings.TrimSpace(slug))\n\t\t\t\tif slug == \"\" {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tcity, state := splitCityState(slug)\n\t\t\t\tlocalOpts := rf.toOptions()\n\t\t\t\tlocalOpts.City = city\n\t\t\t\tlocalOpts.State = state\n\t\t\t\tpath := apt.BuildSearchURL(localOpts)\n\t\t\t\tdata, gerr := c.Get(path, nil)\n\t\t\t\tif gerr != nil {\n\t\t\t\t\tfmt.Fprintf(cmd.ErrOrStderr(), \"warning: fetch %s failed: %v\\n\", slug, gerr)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tplacards, perr := apt.ParsePlacards([]byte(data), c.BaseURL)\n\t\t\t\tif perr != nil {\n\t\t\t\t\tfmt.Fprintf(cmd.ErrOrStderr(), \"warning: parse %s failed: %v\\n\", slug, perr)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tfor _, p := range placards {\n\t\t\t\t\tif seen[p.URL] {\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\t\t\t\t\tseen[p.URL] = true\n\t\t\t\t\tne := nearbyEntry{\n\t\t\t\t\t\tURL: p.URL,\n\t\t\t\t\t\tPropertyID: p.PropertyID,\n\t\t\t\t\t\tTitle: p.Title,\n\t\t\t\t\t\tBeds: p.Beds,\n\t\t\t\t\t\tBaths: p.Baths,\n\t\t\t\t\t\tMaxRent: p.MaxRent,\n\t\t\t\t\t\tSearchSlug: slug,\n\t\t\t\t\t}\n\t\t\t\t\tif p.Beds > 0 && p.MaxRent > 0 {\n\t\t\t\t\t\tne.PricePerBed = float64(p.MaxRent) / float64(p.Beds)\n\t\t\t\t\t}\n\t\t\t\t\tcollected = append(collected, ne)\n\t\t\t\t}\n\t\t\t\ttime.Sleep(800 * time.Millisecond)\n\t\t\t}\n\n\t\t\trankBy := rank\n\t\t\tif rankBy == \"\" {\n\t\t\t\trankBy = \"rent\"\n\t\t\t}\n\t\t\tsort.SliceStable(collected, func(i, j int) bool {\n\t\t\t\ta, b := collected[i], collected[j]\n\t\t\t\tswitch rankBy {\n\t\t\t\tcase \"bed\":\n\t\t\t\t\tai := bigIfZero(a.PricePerBed)\n\t\t\t\t\tbj := bigIfZero(b.PricePerBed)\n\t\t\t\t\treturn ai \u003c bj\n\t\t\t\tcase \"sqft\":\n\t\t\t\t\t// Placards don't carry sqft, so this rank tier\n\t\t\t\t\t// effectively groups MaxRent ascending; documented.\n\t\t\t\t\treturn tieBreakRent(a, b)\n\t\t\t\tdefault: // rent\n\t\t\t\t\treturn tieBreakRent(a, b)\n\t\t\t\t}\n\t\t\t})\n\t\t\tif collected == nil {\n\t\t\t\tcollected = []nearbyEntry{}\n\t\t\t}\n\t\t\treturn printJSONFiltered(cmd.OutOrStdout(), collected, flags)\n\t\t},\n\t}\n\tcmd.Flags().StringVar(&rank, \"rank\", \"rent\", \"Ranker: rent|bed|sqft.\")\n\t// Flags inlined (not via addRentalsFlags) so the public-library\n\t// verify-skill static analyzer can see them on this command.\n\tcmd.Flags().StringVar(&rf.city, \"city\", \"\", \"City slug (lowercased, hyphens for spaces). Example: austin, new-york.\")\n\tcmd.Flags().StringVar(&rf.state, \"state\", \"\", \"Two-letter state abbreviation (lowercase).\")\n\tcmd.Flags().StringVar(&rf.zip, \"zip\", \"\", \"ZIP code; overrides --city/--state when set.\")\n\tcmd.Flags().IntVar(&rf.beds, \"beds\", 0, \"Exact bedroom count. Mutually exclusive with --beds-min.\")\n\tcmd.Flags().IntVar(&rf.bedsMin, \"beds-min\", 0, \"Minimum bedrooms. Mutually exclusive with --beds.\")\n\tcmd.Flags().BoolVar(&rf.studio, \"studio\", false, \"Match studios.\")\n\tcmd.Flags().IntVar(&rf.baths, \"baths\", 0, \"Exact bathroom count.\")\n\tcmd.Flags().IntVar(&rf.bathsMin, \"baths-min\", 0, \"Minimum bathrooms.\")\n\tcmd.Flags().IntVar(&rf.priceMin, \"price-min\", 0, \"Minimum monthly rent in USD.\")\n\tcmd.Flags().IntVar(&rf.priceMax, \"price-max\", 0, \"Maximum monthly rent in USD.\")\n\tcmd.Flags().StringVar(&rf.pets, \"pets\", \"\", \"Pet filter: any|cat|dog|both|none.\")\n\tcmd.Flags().StringVar(&rf.typ, \"type\", \"\", \"Property type: apartment|house|condo|townhome.\")\n\tcmd.Flags().IntVar(&rf.page, \"page\", 0, \"Page number (1-indexed; default 1).\")\n\tcmd.Flags().IntVar(&rf.limit, \"limit\", 60, \"Max placards to return.\")\n\tcmd.Flags().BoolVar(&rf.all, \"all\", false, \"Auto-paginate up to 5 pages.\")\n\treturn cmd\n}\n\nfunc bigIfZero(v float64) float64 {\n\tif v == 0 {\n\t\treturn 1e18\n\t}\n\treturn v\n}\n\nfunc tieBreakRent(a, b nearbyEntry) bool {\n\tai := a.MaxRent\n\tbj := b.MaxRent\n\tif ai == 0 {\n\t\tai = 1 \u003c\u003c 30\n\t}\n\tif bj == 0 {\n\t\tbj = 1 \u003c\u003c 30\n\t}\n\treturn ai \u003c bj\n}\n\nfunc splitCityState(slug string) (string, string) {\n\tidx := strings.LastIndex(slug, \"-\")\n\tif idx \u003c= 0 || idx >= len(slug)-1 {\n\t\treturn slug, \"\"\n\t}\n\treturn slug[:idx], slug[idx+1:]\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":5630,"content_sha256":"d79e994901986d1c0c1983366c2c1c33d4e4498d77416e6e639be59ace9ebe8e"},{"filename":"internal/cli/apt_phantoms.go","content":"// Copyright 2026 rderwin and contributors. Licensed under Apache-2.0. See LICENSE.\n\npackage cli\n\nimport (\n\t\"sort\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/spf13/cobra\"\n)\n\ntype phantomEntry struct {\n\tURL string `json:\"url\"`\n\tReasons []string `json:\"reasons\"`\n\tLastObservedAt string `json:\"last_observed_at,omitempty\"`\n\tLastMaxRent int `json:\"last_max_rent,omitempty\"`\n}\n\nfunc newPhantomsCmd(flags *rootFlags) *cobra.Command {\n\tvar days int\n\n\tcmd := &cobra.Command{\n\t\tUse: \"phantoms\",\n\t\tShort: \"Surface listings flagged by 404, dropped-from-search, or stale.\",\n\t\tAnnotations: map[string]string{\"mcp:read-only\": \"true\"},\n\t\tExample: strings.Trim(`\n apartments-pp-cli phantoms --days 45 --json\n apartments-pp-cli phantoms --days 30\n`, \"\\n\"),\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tif dryRunOK(flags) {\n\t\t\t\treturn nil\n\t\t\t}\n\t\t\tdb, err := openAptStore(cmd.Context())\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tdefer db.Close()\n\n\t\t\tlatest, err := latestObservationPerURL(db.DB())\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tnow := time.Now().UTC()\n\t\t\tthreshold := time.Duration(days) * 24 * time.Hour\n\t\t\treasonsByURL := map[string][]string{}\n\n\t\t\t// Signal A: any snapshot with fetch_status >= 400.\n\t\t\trows, err := db.DB().Query(\n\t\t\t\t`SELECT DISTINCT listing_url\n\t\t\t\t FROM listing_snapshots\n\t\t\t\t WHERE fetch_status >= 400`,\n\t\t\t)\n\t\t\tif err == nil {\n\t\t\t\tfor rows.Next() {\n\t\t\t\t\tvar url string\n\t\t\t\t\tif scanErr := rows.Scan(&url); scanErr == nil {\n\t\t\t\t\t\treasonsByURL[url] = append(reasonsByURL[url], \"fetch_error\")\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\trows.Close()\n\t\t\t}\n\n\t\t\t// Signal B: dropped from saved-search. For each url, find\n\t\t\t// the latest sync of any saved-search it ever appeared in,\n\t\t\t// and compare to its own latest observation.\n\t\t\tsavedRows, err := db.DB().Query(\n\t\t\t\t`SELECT s.listing_url, s.saved_search,\n\t\t\t\t (SELECT MAX(observed_at) FROM listing_snapshots ls\n\t\t\t\t WHERE ls.saved_search = s.saved_search) AS search_latest,\n\t\t\t\t s.observed_at AS url_latest_in_search\n\t\t\t\t FROM (\n\t\t\t\t SELECT listing_url, saved_search, MAX(observed_at) AS observed_at\n\t\t\t\t FROM listing_snapshots\n\t\t\t\t WHERE saved_search IS NOT NULL AND saved_search \u003c> ''\n\t\t\t\t GROUP BY listing_url, saved_search\n\t\t\t\t ) s`,\n\t\t\t)\n\t\t\tif err == nil {\n\t\t\t\tfor savedRows.Next() {\n\t\t\t\t\tvar (\n\t\t\t\t\t\turl string\n\t\t\t\t\t\tsearch string\n\t\t\t\t\t\tsearchLatest string\n\t\t\t\t\t\turlLatest string\n\t\t\t\t\t)\n\t\t\t\t\tif scanErr := savedRows.Scan(&url, &search, &searchLatest, &urlLatest); scanErr == nil {\n\t\t\t\t\t\tif parseSnapshotTime(urlLatest).Before(parseSnapshotTime(searchLatest)) {\n\t\t\t\t\t\t\treasonsByURL[url] = appendUnique(reasonsByURL[url], \"dropped_from_search\")\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tsavedRows.Close()\n\t\t\t}\n\n\t\t\t// Signal C: stale ≥ days.\n\t\t\tlatestByURL := map[string]int{}\n\t\t\tlatestObsByURL := map[string]time.Time{}\n\t\t\tfor _, r := range latest {\n\t\t\t\tlatestByURL[r.ListingURL] = r.MaxRent\n\t\t\t\tlatestObsByURL[r.ListingURL] = r.ObservedAt\n\t\t\t\tif now.Sub(r.ObservedAt) >= threshold {\n\t\t\t\t\treasonsByURL[r.ListingURL] = appendUnique(reasonsByURL[r.ListingURL], \"stale\")\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tvar out []phantomEntry\n\t\t\tfor url, rs := range reasonsByURL {\n\t\t\t\tif len(rs) == 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tobs := latestObsByURL[url]\n\t\t\t\tout = append(out, phantomEntry{\n\t\t\t\t\tURL: url,\n\t\t\t\t\tReasons: rs,\n\t\t\t\t\tLastObservedAt: obs.Format(time.RFC3339),\n\t\t\t\t\tLastMaxRent: latestByURL[url],\n\t\t\t\t})\n\t\t\t}\n\t\t\tsort.SliceStable(out, func(i, j int) bool {\n\t\t\t\tif len(out[i].Reasons) != len(out[j].Reasons) {\n\t\t\t\t\treturn len(out[i].Reasons) > len(out[j].Reasons)\n\t\t\t\t}\n\t\t\t\treturn out[i].URL \u003c out[j].URL\n\t\t\t})\n\t\t\tif out == nil {\n\t\t\t\tout = []phantomEntry{}\n\t\t\t}\n\t\t\treturn printJSONFiltered(cmd.OutOrStdout(), out, flags)\n\t\t},\n\t}\n\tcmd.Flags().IntVar(&days, \"days\", 45, \"Stale threshold in days.\")\n\treturn cmd\n}\n\nfunc appendUnique(s []string, v string) []string {\n\tfor _, x := range s {\n\t\tif x == v {\n\t\t\treturn s\n\t\t}\n\t}\n\treturn append(s, v)\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":3961,"content_sha256":"e0c50c369104f2d7683c7c912bebdd449a33f176354f704d0715156f8f3c1e4c"},{"filename":"internal/cli/apt_rank.go","content":"// Copyright 2026 rderwin and contributors. Licensed under Apache-2.0. See LICENSE.\n\npackage cli\n\nimport (\n\t\"fmt\"\n\t\"sort\"\n\t\"strings\"\n\n\t\"github.com/spf13/cobra\"\n)\n\n// rankEntry is one row in the --by-aware ranking output.\ntype rankEntry struct {\n\tRank int `json:\"rank\"`\n\tURL string `json:\"url\"`\n\tPropertyID string `json:\"property_id,omitempty\"`\n\tTitle string `json:\"title,omitempty\"`\n\tBeds int `json:\"beds,omitempty\"`\n\tMaxRent int `json:\"max_rent,omitempty\"`\n\tSqft int `json:\"sqft,omitempty\"`\n\tPricePerSqft float64 `json:\"price_per_sqft,omitempty\"`\n\tPricePerBed float64 `json:\"price_per_bed,omitempty\"`\n}\n\nfunc newRankCmd(flags *rootFlags) *cobra.Command {\n\tvar by string\n\trf := &rentalsFlags{}\n\n\tcmd := &cobra.Command{\n\t\tUse: \"rank\",\n\t\tShort: \"Rank synced listings by ratio metrics — price per square foot or price per bedroom.\",\n\t\tAnnotations: map[string]string{\"mcp:read-only\": \"true\"},\n\t\tExample: strings.Trim(`\n apartments-pp-cli rank --by sqft --limit 10 --json\n apartments-pp-cli rank --by bed --beds 2\n apartments-pp-cli rank --by rent --price-max 2500\n`, \"\\n\"),\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tswitch by {\n\t\t\tcase \"\", \"sqft\", \"bed\", \"rent\":\n\t\t\tdefault:\n\t\t\t\treturn usageErr(fmt.Errorf(\"invalid --by %q: must be sqft|bed|rent\", by))\n\t\t\t}\n\t\t\tif dryRunOK(flags) {\n\t\t\t\treturn nil\n\t\t\t}\n\t\t\tdb, err := openAptStore(cmd.Context())\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tdefer db.Close()\n\n\t\t\trows, err := loadCachedListings(db.DB())\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tvar entries []rankEntry\n\t\t\tfor _, r := range rows {\n\t\t\t\tli := r.Data\n\t\t\t\tif rf.beds > 0 && li.Beds != rf.beds {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tif rf.bedsMin > 0 && li.Beds \u003c rf.bedsMin {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tif rf.priceMin > 0 && li.MaxRent \u003c rf.priceMin {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tif rf.priceMax > 0 && li.MaxRent > rf.priceMax {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tif rf.city != \"\" && !strings.EqualFold(strings.ReplaceAll(strings.ToLower(li.Address.City), \" \", \"-\"), rf.city) {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tif rf.state != \"\" && !strings.EqualFold(li.Address.State, rf.state) {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\te := rankEntry{\n\t\t\t\t\tURL: li.URL,\n\t\t\t\t\tPropertyID: li.PropertyID,\n\t\t\t\t\tTitle: li.Title,\n\t\t\t\t\tBeds: li.Beds,\n\t\t\t\t\tMaxRent: li.MaxRent,\n\t\t\t\t\tSqft: li.Sqft,\n\t\t\t\t}\n\t\t\t\tif li.MaxRent > 0 && li.Sqft > 0 {\n\t\t\t\t\te.PricePerSqft = float64(li.MaxRent) / float64(li.Sqft)\n\t\t\t\t}\n\t\t\t\tif li.MaxRent > 0 && li.Beds > 0 {\n\t\t\t\t\te.PricePerBed = float64(li.MaxRent) / float64(li.Beds)\n\t\t\t\t}\n\t\t\t\tentries = append(entries, e)\n\t\t\t}\n\n\t\t\tbyKey := by\n\t\t\tif byKey == \"\" {\n\t\t\t\tbyKey = \"sqft\"\n\t\t\t}\n\t\t\tsort.SliceStable(entries, func(i, j int) bool {\n\t\t\t\ta, b := entries[i], entries[j]\n\t\t\t\tswitch byKey {\n\t\t\t\tcase \"sqft\":\n\t\t\t\t\tai := a.PricePerSqft\n\t\t\t\t\tbj := b.PricePerSqft\n\t\t\t\t\tif ai == 0 {\n\t\t\t\t\t\tai = 1e18\n\t\t\t\t\t}\n\t\t\t\t\tif bj == 0 {\n\t\t\t\t\t\tbj = 1e18\n\t\t\t\t\t}\n\t\t\t\t\tif ai != bj {\n\t\t\t\t\t\treturn ai \u003c bj\n\t\t\t\t\t}\n\t\t\t\tcase \"bed\":\n\t\t\t\t\tai := a.PricePerBed\n\t\t\t\t\tbj := b.PricePerBed\n\t\t\t\t\tif ai == 0 {\n\t\t\t\t\t\tai = 1e18\n\t\t\t\t\t}\n\t\t\t\t\tif bj == 0 {\n\t\t\t\t\t\tbj = 1e18\n\t\t\t\t\t}\n\t\t\t\t\tif ai != bj {\n\t\t\t\t\t\treturn ai \u003c bj\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tar := a.MaxRent\n\t\t\t\tbr := b.MaxRent\n\t\t\t\tif ar == 0 {\n\t\t\t\t\tar = 1 \u003c\u003c 30\n\t\t\t\t}\n\t\t\t\tif br == 0 {\n\t\t\t\t\tbr = 1 \u003c\u003c 30\n\t\t\t\t}\n\t\t\t\treturn ar \u003c br\n\t\t\t})\n\n\t\t\tif rf.limit > 0 && len(entries) > rf.limit {\n\t\t\t\tentries = entries[:rf.limit]\n\t\t\t}\n\t\t\tfor i := range entries {\n\t\t\t\tentries[i].Rank = i + 1\n\t\t\t}\n\t\t\tif entries == nil {\n\t\t\t\tentries = []rankEntry{}\n\t\t\t}\n\t\t\treturn printJSONFiltered(cmd.OutOrStdout(), entries, flags)\n\t\t},\n\t}\n\tcmd.Flags().StringVar(&by, \"by\", \"sqft\", \"Ranker: sqft|bed|rent.\")\n\t// Flags inlined (not via addRentalsFlags) so the public-library\n\t// verify-skill static analyzer can see them on this command.\n\tcmd.Flags().StringVar(&rf.city, \"city\", \"\", \"City slug (lowercased, hyphens for spaces). Example: austin, new-york.\")\n\tcmd.Flags().StringVar(&rf.state, \"state\", \"\", \"Two-letter state abbreviation (lowercase).\")\n\tcmd.Flags().StringVar(&rf.zip, \"zip\", \"\", \"ZIP code; overrides --city/--state when set.\")\n\tcmd.Flags().IntVar(&rf.beds, \"beds\", 0, \"Exact bedroom count. Mutually exclusive with --beds-min.\")\n\tcmd.Flags().IntVar(&rf.bedsMin, \"beds-min\", 0, \"Minimum bedrooms. Mutually exclusive with --beds.\")\n\tcmd.Flags().BoolVar(&rf.studio, \"studio\", false, \"Match studios.\")\n\tcmd.Flags().IntVar(&rf.baths, \"baths\", 0, \"Exact bathroom count.\")\n\tcmd.Flags().IntVar(&rf.bathsMin, \"baths-min\", 0, \"Minimum bathrooms.\")\n\tcmd.Flags().IntVar(&rf.priceMin, \"price-min\", 0, \"Minimum monthly rent in USD.\")\n\tcmd.Flags().IntVar(&rf.priceMax, \"price-max\", 0, \"Maximum monthly rent in USD.\")\n\tcmd.Flags().StringVar(&rf.pets, \"pets\", \"\", \"Pet filter: any|cat|dog|both|none.\")\n\tcmd.Flags().StringVar(&rf.typ, \"type\", \"\", \"Property type: apartment|house|condo|townhome.\")\n\tcmd.Flags().IntVar(&rf.page, \"page\", 0, \"Page number (1-indexed; default 1).\")\n\tcmd.Flags().IntVar(&rf.limit, \"limit\", 25, \"Max rows to return.\")\n\tcmd.Flags().BoolVar(&rf.all, \"all\", false, \"Auto-paginate up to 5 pages.\")\n\trf.limit = 25\n\t// Hide --all/--page from rank — they're not meaningful here, but\n\t// kept on the struct for code reuse.\n\t_ = cmd.Flags().MarkHidden(\"all\")\n\t_ = cmd.Flags().MarkHidden(\"page\")\n\treturn cmd\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":5276,"content_sha256":"6993ef8dfb2b09279adafdab6e98d2443625710878dc4725c383ae204e6f9f69"},{"filename":"internal/cli/apt_shortlist.go","content":"// Copyright 2026 rderwin and contributors. Licensed under Apache-2.0. See LICENSE.\n\npackage cli\n\nimport (\n\t\"database/sql\"\n\t\"encoding/json\"\n\t\"strings\"\n\n\t\"github.com/mvanhorn/printing-press-library/library/other/apartments/internal/apt\"\n\n\t\"github.com/spf13/cobra\"\n)\n\ntype shortlistRow struct {\n\tURL string `json:\"url\"`\n\tTag string `json:\"tag,omitempty\"`\n\tNote string `json:\"note,omitempty\"`\n\tAddedAt string `json:\"added_at,omitempty\"`\n\tMaxRent int `json:\"max_rent,omitempty\"`\n\tBeds int `json:\"beds,omitempty\"`\n\tBaths float64 `json:\"baths,omitempty\"`\n\tTitle string `json:\"title,omitempty\"`\n\tPropertyID string `json:\"property_id,omitempty\"`\n\tSqft int `json:\"sqft,omitempty\"`\n}\n\nfunc newShortlistCmd(flags *rootFlags) *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse: \"shortlist\",\n\t\tShort: \"Tag-based local shortlist; add/show/remove listings with notes and tags.\",\n\t\tAnnotations: map[string]string{\"mcp:read-only\": \"true\"},\n\t\tExample: strings.Trim(`\n apartments-pp-cli shortlist add https://www.apartments.com/the-domain-austin-tx/abc123/ --tag favorite --note \"rooftop pool\"\n apartments-pp-cli shortlist show --tag favorite --json\n apartments-pp-cli shortlist remove https://www.apartments.com/the-domain-austin-tx/abc123/\n`, \"\\n\"),\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\treturn cmd.Help()\n\t\t},\n\t}\n\tcmd.AddCommand(newShortlistAddCmd(flags))\n\tcmd.AddCommand(newShortlistShowCmd(flags))\n\tcmd.AddCommand(newShortlistRemoveCmd(flags))\n\treturn cmd\n}\n\nfunc newShortlistAddCmd(flags *rootFlags) *cobra.Command {\n\tvar tag string\n\tvar note string\n\tcmd := &cobra.Command{\n\t\tUse: \"add \u003curl>\",\n\t\tShort: \"Add a listing URL to the local shortlist.\",\n\t\tAnnotations: map[string]string{\"mcp:read-only\": \"false\"},\n\t\tExample: strings.Trim(`\n apartments-pp-cli shortlist add https://www.apartments.com/foo/abc123/ --tag favorite\n apartments-pp-cli shortlist add the-domain-austin-tx --tag visit --note \"pet friendly\"\n`, \"\\n\"),\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tif len(args) == 0 {\n\t\t\t\treturn cmd.Help()\n\t\t\t}\n\t\t\tif dryRunOK(flags) {\n\t\t\t\treturn nil\n\t\t\t}\n\t\t\turl := normalizeListingURL(args[0])\n\t\t\tdb, err := openAptStore(cmd.Context())\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tdefer db.Close()\n\t\t\t_, err = db.DB().Exec(\n\t\t\t\t`INSERT INTO shortlist (listing_url, tag, note)\n\t\t\t\t VALUES (?, ?, ?)\n\t\t\t\t ON CONFLICT(listing_url, tag) DO UPDATE SET note = excluded.note`,\n\t\t\t\turl, tag, note,\n\t\t\t)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tout := map[string]any{\"url\": url, \"tag\": tag, \"note\": note}\n\t\t\treturn printJSONFiltered(cmd.OutOrStdout(), out, flags)\n\t\t},\n\t}\n\tcmd.Flags().StringVar(&tag, \"tag\", \"\", \"Optional tag (e.g. favorite, visit).\")\n\tcmd.Flags().StringVar(¬e, \"note\", \"\", \"Optional free-text note.\")\n\treturn cmd\n}\n\nfunc newShortlistShowCmd(flags *rootFlags) *cobra.Command {\n\tvar tag string\n\tcmd := &cobra.Command{\n\t\tUse: \"show\",\n\t\tShort: \"Show all shortlisted listings, joined to the latest cached listing data.\",\n\t\tAnnotations: map[string]string{\"mcp:read-only\": \"true\"},\n\t\tExample: strings.Trim(`\n apartments-pp-cli shortlist show --json\n apartments-pp-cli shortlist show --tag favorite\n`, \"\\n\"),\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tif dryRunOK(flags) {\n\t\t\t\treturn nil\n\t\t\t}\n\t\t\tdb, err := openAptStore(cmd.Context())\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tdefer db.Close()\n\t\t\tquery := `SELECT listing_url, tag, note, added_at FROM shortlist`\n\t\t\tvar qargs []any\n\t\t\tif cmd.Flags().Changed(\"tag\") {\n\t\t\t\tquery += ` WHERE tag = ?`\n\t\t\t\tqargs = append(qargs, tag)\n\t\t\t}\n\t\t\tquery += ` ORDER BY added_at DESC`\n\t\t\trows, err := db.DB().Query(query, qargs...)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tdefer rows.Close()\n\t\t\tvar out []shortlistRow\n\t\t\tfor rows.Next() {\n\t\t\t\tvar (\n\t\t\t\t\tr shortlistRow\n\t\t\t\t\tt sql.NullString\n\t\t\t\t\tn sql.NullString\n\t\t\t\t\taddedAt sql.NullString\n\t\t\t\t)\n\t\t\t\tif err := rows.Scan(&r.URL, &t, &n, &addedAt); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t\tr.Tag = t.String\n\t\t\t\tr.Note = n.String\n\t\t\t\tr.AddedAt = addedAt.String\n\t\t\t\t// Join to the cached listing.\n\t\t\t\tpropertyID := apt.ListingURLToPropertyID(r.URL)\n\t\t\t\tif propertyID != \"\" {\n\t\t\t\t\tvar data string\n\t\t\t\t\tqerr := db.DB().QueryRow(`SELECT data FROM listing WHERE id = ?`, propertyID).Scan(&data)\n\t\t\t\t\tif qerr == nil && data != \"\" {\n\t\t\t\t\t\tvar li apt.Listing\n\t\t\t\t\t\tif json.Unmarshal([]byte(data), &li) == nil {\n\t\t\t\t\t\t\tr.MaxRent = li.MaxRent\n\t\t\t\t\t\t\tr.Beds = li.Beds\n\t\t\t\t\t\t\tr.Baths = li.Baths\n\t\t\t\t\t\t\tr.Title = li.Title\n\t\t\t\t\t\t\tr.PropertyID = li.PropertyID\n\t\t\t\t\t\t\tr.Sqft = li.Sqft\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tout = append(out, r)\n\t\t\t}\n\t\t\tif out == nil {\n\t\t\t\tout = []shortlistRow{}\n\t\t\t}\n\t\t\treturn printJSONFiltered(cmd.OutOrStdout(), out, flags)\n\t\t},\n\t}\n\tcmd.Flags().StringVar(&tag, \"tag\", \"\", \"Filter by tag.\")\n\treturn cmd\n}\n\nfunc newShortlistRemoveCmd(flags *rootFlags) *cobra.Command {\n\tvar tag string\n\tcmd := &cobra.Command{\n\t\tUse: \"remove \u003curl>\",\n\t\tShort: \"Remove a listing URL from the local shortlist.\",\n\t\tAnnotations: map[string]string{\"mcp:read-only\": \"false\"},\n\t\tExample: strings.Trim(`\n apartments-pp-cli shortlist remove https://www.apartments.com/foo/abc123/\n apartments-pp-cli shortlist remove the-domain-austin-tx --tag visit\n`, \"\\n\"),\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tif len(args) == 0 {\n\t\t\t\treturn cmd.Help()\n\t\t\t}\n\t\t\tif dryRunOK(flags) {\n\t\t\t\treturn nil\n\t\t\t}\n\t\t\turl := normalizeListingURL(args[0])\n\t\t\tdb, err := openAptStore(cmd.Context())\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tdefer db.Close()\n\t\t\tquery := `DELETE FROM shortlist WHERE listing_url = ?`\n\t\t\tqargs := []any{url}\n\t\t\tif cmd.Flags().Changed(\"tag\") {\n\t\t\t\tquery += ` AND tag = ?`\n\t\t\t\tqargs = append(qargs, tag)\n\t\t\t}\n\t\t\tres, err := db.DB().Exec(query, qargs...)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tn, _ := res.RowsAffected()\n\t\t\tout := map[string]any{\"url\": url, \"removed\": n}\n\t\t\treturn printJSONFiltered(cmd.OutOrStdout(), out, flags)\n\t\t},\n\t}\n\tcmd.Flags().StringVar(&tag, \"tag\", \"\", \"Only remove the row with this tag.\")\n\treturn cmd\n}\n\nfunc normalizeListingURL(arg string) string {\n\tif strings.HasPrefix(arg, \"http://\") || strings.HasPrefix(arg, \"https://\") {\n\t\treturn arg\n\t}\n\treturn \"https://www.apartments.com/\" + strings.Trim(arg, \"/\") + \"/\"\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":6309,"content_sha256":"7e0b0c43aac6af1895230a17f109b6ee20a619e568b432fceb7714755b89fb93"},{"filename":"internal/cli/apt_stale.go","content":"// Copyright 2026 rderwin and contributors. Licensed under Apache-2.0. See LICENSE.\n\npackage cli\n\nimport (\n\t\"sort\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/spf13/cobra\"\n)\n\ntype staleEntry struct {\n\tURL string `json:\"url\"`\n\tMaxRent int `json:\"max_rent,omitempty\"`\n\tUnchangedDays int `json:\"unchanged_days\"`\n\tLastChangedAt string `json:\"last_changed_at,omitempty\"`\n\tLastObservedAt string `json:\"last_observed_at,omitempty\"`\n}\n\nfunc newStaleCmd(flags *rootFlags) *cobra.Command {\n\tvar days int\n\tvar limit int\n\n\tcmd := &cobra.Command{\n\t\tUse: \"stale\",\n\t\tShort: \"Flag synced listings whose price and availability haven't changed in N days.\",\n\t\tAnnotations: map[string]string{\"mcp:read-only\": \"true\"},\n\t\tExample: strings.Trim(`\n apartments-pp-cli stale --days 30 --json\n apartments-pp-cli stale --days 60 --limit 50\n`, \"\\n\"),\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tif dryRunOK(flags) {\n\t\t\t\treturn nil\n\t\t\t}\n\t\t\tdb, err := openAptStore(cmd.Context())\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tdefer db.Close()\n\n\t\t\trows, err := db.DB().Query(\n\t\t\t\t`SELECT listing_url, max_rent, available_at, observed_at\n\t\t\t\t FROM listing_snapshots\n\t\t\t\t ORDER BY listing_url, observed_at DESC`,\n\t\t\t)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tdefer rows.Close()\n\n\t\t\ttype sample struct {\n\t\t\t\trent int\n\t\t\t\tavail string\n\t\t\t\tobservedAt time.Time\n\t\t\t}\n\t\t\tgroups := map[string][]sample{}\n\t\t\turlsOrdered := []string{}\n\t\t\tfor rows.Next() {\n\t\t\t\tvar (\n\t\t\t\t\turl string\n\t\t\t\t\trent int\n\t\t\t\t\tavail string\n\t\t\t\t\tts string\n\t\t\t\t)\n\t\t\t\tif err := rows.Scan(&url, &rent, &avail, &ts); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t\tif _, ok := groups[url]; !ok {\n\t\t\t\t\turlsOrdered = append(urlsOrdered, url)\n\t\t\t\t}\n\t\t\t\tgroups[url] = append(groups[url], sample{rent, avail, parseSnapshotTime(ts)})\n\t\t\t}\n\t\t\tif err := rows.Err(); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tnow := time.Now().UTC()\n\t\t\tthreshold := time.Duration(days) * 24 * time.Hour\n\t\t\tvar out []staleEntry\n\t\t\tfor _, url := range urlsOrdered {\n\t\t\t\tss := groups[url]\n\t\t\t\tif len(ss) == 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tlatest := ss[0]\n\t\t\t\tlastChanged := latest.observedAt\n\t\t\t\tfor _, s := range ss[1:] {\n\t\t\t\t\tif s.rent == latest.rent && s.avail == latest.avail {\n\t\t\t\t\t\tlastChanged = s.observedAt\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t\tunchanged := now.Sub(lastChanged)\n\t\t\t\tif unchanged \u003c threshold {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tout = append(out, staleEntry{\n\t\t\t\t\tURL: url,\n\t\t\t\t\tMaxRent: latest.rent,\n\t\t\t\t\tUnchangedDays: int(unchanged.Hours() / 24),\n\t\t\t\t\tLastChangedAt: lastChanged.Format(time.RFC3339),\n\t\t\t\t\tLastObservedAt: latest.observedAt.Format(time.RFC3339),\n\t\t\t\t})\n\t\t\t}\n\t\t\tsort.SliceStable(out, func(i, j int) bool {\n\t\t\t\treturn out[i].UnchangedDays > out[j].UnchangedDays\n\t\t\t})\n\t\t\tif limit > 0 && len(out) > limit {\n\t\t\t\tout = out[:limit]\n\t\t\t}\n\t\t\tif out == nil {\n\t\t\t\tout = []staleEntry{}\n\t\t\t}\n\t\t\treturn printJSONFiltered(cmd.OutOrStdout(), out, flags)\n\t\t},\n\t}\n\tcmd.Flags().IntVar(&days, \"days\", 30, \"Threshold in days.\")\n\tcmd.Flags().IntVar(&limit, \"limit\", 25, \"Max rows to return.\")\n\treturn cmd\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":3083,"content_sha256":"69cb074faf473a2770f201ea4d5acc0c0209df93f12f92d813ed2d94d085f705"},{"filename":"internal/cli/apt_sync.go","content":"// Copyright 2026 rderwin and contributors. Licensed under Apache-2.0. See LICENSE.\n\npackage cli\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/mvanhorn/printing-press-library/library/other/apartments/internal/apt\"\n\n\t\"github.com/spf13/cobra\"\n)\n\nfunc newAptSyncCmd(flags *rootFlags) *cobra.Command {\n\trf := &rentalsFlags{}\n\tcmd := &cobra.Command{\n\t\tUse: \"sync-search \u003csaved-search>\",\n\t\tShort: \"Run a saved-search and append the results to listing_snapshots.\",\n\t\tAnnotations: map[string]string{\"mcp:read-only\": \"true\"},\n\t\tExample: strings.Trim(`\n apartments-pp-cli sync-search austin-2br --city austin --state tx --beds 2 --price-max 2500\n apartments-pp-cli sync-search downtown-pet-friendly --city austin --state tx --pets dog --json\n apartments-pp-cli sync-search east-side --zip 78704 --beds-min 1 --all\n`, \"\\n\"),\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tif len(args) == 0 {\n\t\t\t\treturn cmd.Help()\n\t\t\t}\n\t\t\tslug := args[0]\n\t\t\tif dryRunOK(flags) {\n\t\t\t\topts := rf.toOptions()\n\t\t\t\tpath := apt.BuildSearchURL(opts)\n\t\t\t\tfmt.Fprintln(cmd.OutOrStdout(), \"would sync:\", slug, \"->\", path)\n\t\t\t\treturn nil\n\t\t\t}\n\t\t\tif err := rf.validate(); err != nil {\n\t\t\t\treturn usageErr(err)\n\t\t\t}\n\t\t\topts := rf.toOptions()\n\t\t\tpath := apt.BuildSearchURL(opts)\n\n\t\t\tc, err := flags.newClient()\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tdata, gerr := c.Get(path, nil)\n\t\t\tfetchStatus := 200\n\t\t\tif gerr != nil {\n\t\t\t\tfetchStatus = 0\n\t\t\t\treturn classifyAPIError(gerr)\n\t\t\t}\n\t\t\tplacards, perr := apt.ParsePlacards([]byte(data), c.BaseURL)\n\t\t\tif perr != nil {\n\t\t\t\treturn apiErr(perr)\n\t\t\t}\n\n\t\t\tdb, derr := openAptStore(cmd.Context())\n\t\t\tif derr != nil {\n\t\t\t\treturn derr\n\t\t\t}\n\t\t\tdefer db.Close()\n\n\t\t\tnow := time.Now().UTC()\n\t\t\tfor _, p := range placards {\n\t\t\t\traw, _ := json.Marshal(p)\n\t\t\t\t_, ierr := apt.InsertSnapshot(db.DB(), apt.SnapshotInsert{\n\t\t\t\t\tListingURL: p.URL,\n\t\t\t\t\tPropertyID: p.PropertyID,\n\t\t\t\t\tSavedSearch: slug,\n\t\t\t\t\tMaxRent: p.MaxRent,\n\t\t\t\t\tBeds: p.Beds,\n\t\t\t\t\tBaths: p.Baths,\n\t\t\t\t\tFetchStatus: fetchStatus,\n\t\t\t\t\tRaw: raw,\n\t\t\t\t})\n\t\t\t\tif ierr != nil {\n\t\t\t\t\treturn ierr\n\t\t\t\t}\n\t\t\t\t// Also upsert into the canonical `listing` table so\n\t\t\t\t// transcendence commands (rank, value, market, etc.) see\n\t\t\t\t// this data. Detail-only fields (sqft, amenities,\n\t\t\t\t// pet_policy fees) stay zero — populated only on listing\n\t\t\t\t// command success, which apartments.com 403s most of the\n\t\t\t\t// time. Placard data still drives rent and bed-count\n\t\t\t\t// rankings.\n\t\t\t\tli := apt.Listing{\n\t\t\t\t\tURL: p.URL,\n\t\t\t\t\tPropertyID: p.PropertyID,\n\t\t\t\t\tTitle: p.Title,\n\t\t\t\t\tBeds: p.Beds,\n\t\t\t\t\tBaths: p.Baths,\n\t\t\t\t\tMaxRent: p.MaxRent,\n\t\t\t\t}\n\t\t\t\tif liRaw, mErr := json.Marshal(li); mErr == nil {\n\t\t\t\t\t_ = db.UpsertListing(liRaw)\n\t\t\t\t}\n\t\t\t}\n\t\t\tif uerr := apt.UpsertSavedSearch(db.DB(), slug, opts, len(placards)); uerr != nil {\n\t\t\t\treturn uerr\n\t\t\t}\n\t\t\tout := map[string]any{\n\t\t\t\t\"saved_search\": slug,\n\t\t\t\t\"count\": len(placards),\n\t\t\t\t\"synced_at\": now.Format(time.RFC3339),\n\t\t\t\t\"path\": path,\n\t\t\t}\n\t\t\treturn printJSONFiltered(cmd.OutOrStdout(), out, flags)\n\t\t},\n\t}\n\taddRentalsFlags(cmd, rf)\n\treturn cmd\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":3173,"content_sha256":"e34302a6e9b3c59866267056a8ca422ad5ff6f5011c1dff51284dd524da2e4f4"},{"filename":"internal/cli/apt_value.go","content":"// Copyright 2026 rderwin and contributors. Licensed under Apache-2.0. See LICENSE.\n\npackage cli\n\nimport (\n\t\"fmt\"\n\t\"sort\"\n\t\"strings\"\n\n\t\"github.com/spf13/cobra\"\n)\n\n// valueEntry is one ranked listing in --budget-aware total-cost order.\ntype valueEntry struct {\n\tRank int `json:\"rank\"`\n\tURL string `json:\"url\"`\n\tPropertyID string `json:\"property_id,omitempty\"`\n\tTitle string `json:\"title,omitempty\"`\n\tBeds int `json:\"beds,omitempty\"`\n\tMaxRent int `json:\"max_rent,omitempty\"`\n\tTotalCost int `json:\"total_cost\"`\n\tPetRent int `json:\"pet_rent,omitempty\"`\n\tPetDeposit int `json:\"pet_deposit,omitempty\"`\n\tPetFee int `json:\"pet_fee,omitempty\"`\n}\n\nfunc newValueCmd(flags *rootFlags) *cobra.Command {\n\tvar budget int\n\tvar pet string\n\tvar months int\n\n\tcmd := &cobra.Command{\n\t\tUse: \"value\",\n\t\tShort: \"Rank synced listings by 12-month total cost (rent + pet rent + pet deposit + pet fee).\",\n\t\tAnnotations: map[string]string{\"mcp:read-only\": \"true\"},\n\t\tExample: strings.Trim(`\n apartments-pp-cli value --budget 2500 --pet dog --months 12 --json\n apartments-pp-cli value --budget 1800 --months 18\n apartments-pp-cli value --pet none\n`, \"\\n\"),\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tswitch pet {\n\t\t\tcase \"\", \"none\", \"cat\", \"dog\", \"both\":\n\t\t\tdefault:\n\t\t\t\treturn usageErr(fmt.Errorf(\"invalid --pet %q: must be none|cat|dog|both\", pet))\n\t\t\t}\n\t\t\tif months \u003c= 0 {\n\t\t\t\tmonths = 12\n\t\t\t}\n\t\t\tif dryRunOK(flags) {\n\t\t\t\treturn nil\n\t\t\t}\n\t\t\tdb, err := openAptStore(cmd.Context())\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tdefer db.Close()\n\n\t\t\trows, err := loadCachedListings(db.DB())\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\thasPet := pet == \"cat\" || pet == \"dog\" || pet == \"both\"\n\n\t\t\tvar out []valueEntry\n\t\t\tfor _, r := range rows {\n\t\t\t\tli := r.Data\n\t\t\t\tif li.MaxRent \u003c= 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\ttotal := li.MaxRent * months\n\t\t\t\tif hasPet {\n\t\t\t\t\ttotal += li.PetPolicy.PetRent * months\n\t\t\t\t\ttotal += li.PetPolicy.PetDeposit\n\t\t\t\t\ttotal += li.PetPolicy.PetFee\n\t\t\t\t}\n\t\t\t\tif budget > 0 && total > budget*months {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tout = append(out, valueEntry{\n\t\t\t\t\tURL: li.URL,\n\t\t\t\t\tPropertyID: li.PropertyID,\n\t\t\t\t\tTitle: li.Title,\n\t\t\t\t\tBeds: li.Beds,\n\t\t\t\t\tMaxRent: li.MaxRent,\n\t\t\t\t\tTotalCost: total,\n\t\t\t\t\tPetRent: li.PetPolicy.PetRent,\n\t\t\t\t\tPetDeposit: li.PetPolicy.PetDeposit,\n\t\t\t\t\tPetFee: li.PetPolicy.PetFee,\n\t\t\t\t})\n\t\t\t}\n\t\t\tsort.SliceStable(out, func(i, j int) bool {\n\t\t\t\treturn out[i].TotalCost \u003c out[j].TotalCost\n\t\t\t})\n\t\t\tfor i := range out {\n\t\t\t\tout[i].Rank = i + 1\n\t\t\t}\n\t\t\tif out == nil {\n\t\t\t\tout = []valueEntry{}\n\t\t\t}\n\t\t\treturn printJSONFiltered(cmd.OutOrStdout(), out, flags)\n\t\t},\n\t}\n\tcmd.Flags().IntVar(&budget, \"budget\", 0, \"Hard monthly budget in USD; total cost / months must not exceed this.\")\n\tcmd.Flags().StringVar(&pet, \"pet\", \"none\", \"Pet status: none|cat|dog|both.\")\n\tcmd.Flags().IntVar(&months, \"months\", 12, \"Lease length in months.\")\n\treturn cmd\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":2970,"content_sha256":"5442163e5e58ac340e8c57810d693d11a6d884fafacce168e829566842d1aec1"},{"filename":"internal/cli/apt_watch.go","content":"// Copyright 2026 rderwin and contributors. Licensed under Apache-2.0. See LICENSE.\n\npackage cli\n\nimport (\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/mvanhorn/printing-press-library/library/other/apartments/internal/apt\"\n\n\t\"github.com/spf13/cobra\"\n)\n\n// watchEntry is what we emit for each diff bucket.\ntype watchEntry struct {\n\tURL string `json:\"url\"`\n\tMaxRent int `json:\"max_rent,omitempty\"`\n\tPrevRent int `json:\"prev_max_rent,omitempty\"`\n\tBeds int `json:\"beds,omitempty\"`\n}\n\nfunc newWatchCmd(flags *rootFlags) *cobra.Command {\n\tvar sinceStr string\n\n\tcmd := &cobra.Command{\n\t\tUse: \"watch \u003csaved-search>\",\n\t\tShort: \"Diff the latest two syncs of a saved-search: NEW, REMOVED, PRICE_CHANGED.\",\n\t\tAnnotations: map[string]string{\"mcp:read-only\": \"true\"},\n\t\tExample: strings.Trim(`\n apartments-pp-cli watch austin-2br\n apartments-pp-cli watch austin-2br --since 7d --json\n apartments-pp-cli watch downtown-pet-friendly --since 24h\n`, \"\\n\"),\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tif len(args) == 0 {\n\t\t\t\treturn cmd.Help()\n\t\t\t}\n\t\t\tif dryRunOK(flags) {\n\t\t\t\treturn nil\n\t\t\t}\n\t\t\tslug := args[0]\n\t\t\tdb, err := openAptStore(cmd.Context())\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tdefer db.Close()\n\n\t\t\tout := map[string]any{\n\t\t\t\t\"saved_search\": slug,\n\t\t\t\t\"new_listings\": []watchEntry{},\n\t\t\t\t\"removed_listings\": []watchEntry{},\n\t\t\t\t\"price_changed\": []watchEntry{},\n\t\t\t}\n\n\t\t\ttsList, err := apt.LatestSyncTimestamps(db.DB(), slug, 2)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tsince, perr := parseDurationLoose(sinceStr)\n\t\t\tif perr != nil {\n\t\t\t\treturn usageErr(perr)\n\t\t\t}\n\t\t\t// Optional --since gate: drop the older timestamp if it\n\t\t\t// falls outside the window.\n\t\t\tif since > 0 && len(tsList) >= 2 {\n\t\t\t\tcutoff := time.Now().Add(-since)\n\t\t\t\tif tsList[1].Before(cutoff) {\n\t\t\t\t\ttsList = tsList[:1]\n\t\t\t\t}\n\t\t\t}\n\t\t\tif len(tsList) \u003c 2 {\n\t\t\t\treturn printJSONFiltered(cmd.OutOrStdout(), out, flags)\n\t\t\t}\n\t\t\tlatestRows, err := apt.SnapshotsForSearchAt(db.DB(), slug, tsList[0])\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tprevRows, err := apt.SnapshotsForSearchAt(db.DB(), slug, tsList[1])\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tlatest := indexByURL(latestRows)\n\t\t\tprev := indexByURL(prevRows)\n\n\t\t\tvar newOnes, removed, priced []watchEntry\n\t\t\tfor url, r := range latest {\n\t\t\t\tif _, ok := prev[url]; !ok {\n\t\t\t\t\tnewOnes = append(newOnes, watchEntry{URL: url, MaxRent: r.MaxRent, Beds: r.Beds})\n\t\t\t\t}\n\t\t\t}\n\t\t\tfor url, r := range prev {\n\t\t\t\tif _, ok := latest[url]; !ok {\n\t\t\t\t\tremoved = append(removed, watchEntry{URL: url, MaxRent: r.MaxRent, Beds: r.Beds})\n\t\t\t\t}\n\t\t\t}\n\t\t\tfor url, r := range latest {\n\t\t\t\tp, ok := prev[url]\n\t\t\t\tif !ok {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tif r.MaxRent != p.MaxRent && r.MaxRent > 0 && p.MaxRent > 0 {\n\t\t\t\t\tpriced = append(priced, watchEntry{\n\t\t\t\t\t\tURL: url,\n\t\t\t\t\t\tMaxRent: r.MaxRent,\n\t\t\t\t\t\tPrevRent: p.MaxRent,\n\t\t\t\t\t\tBeds: r.Beds,\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\t\t\tif newOnes == nil {\n\t\t\t\tnewOnes = []watchEntry{}\n\t\t\t}\n\t\t\tif removed == nil {\n\t\t\t\tremoved = []watchEntry{}\n\t\t\t}\n\t\t\tif priced == nil {\n\t\t\t\tpriced = []watchEntry{}\n\t\t\t}\n\t\t\tout[\"new_listings\"] = newOnes\n\t\t\tout[\"removed_listings\"] = removed\n\t\t\tout[\"price_changed\"] = priced\n\t\t\tout[\"latest_synced_at\"] = tsList[0].Format(time.RFC3339)\n\t\t\tout[\"prev_synced_at\"] = tsList[1].Format(time.RFC3339)\n\t\t\treturn printJSONFiltered(cmd.OutOrStdout(), out, flags)\n\t\t},\n\t}\n\tcmd.Flags().StringVar(&sinceStr, \"since\", \"\", \"Only diff if the previous sync is newer than this (e.g. 24h, 7d).\")\n\treturn cmd\n}\n\nfunc indexByURL(rows []apt.SnapshotRow) map[string]apt.SnapshotRow {\n\tm := map[string]apt.SnapshotRow{}\n\tfor _, r := range rows {\n\t\tm[r.ListingURL] = r\n\t}\n\treturn m\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":3670,"content_sha256":"b869db1f0f714b09b77bc3cd154653112da77bd286875eb5baafef5252e84031"},{"filename":"internal/cli/auto_refresh.go","content":"// Copyright 2026 rderwin and contributors. Licensed under Apache-2.0. See LICENSE.\n// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.\n\npackage cli\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"os\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/mvanhorn/printing-press-library/library/other/apartments/internal/cliutil\"\n\t\"github.com/mvanhorn/printing-press-library/library/other/apartments/internal/store\"\n)\n\n// readCommandResources maps command paths (`cmd.CommandPath()`) to the\n// resource types those commands read. The auto-refresh hook consults this\n// map to decide whether to refresh the local cache before serving.\n// Populated from generated syncable resource commands and any custom\n// command-path coverage declared in spec.Cache.Commands.\nvar readCommandResources = map[string][]string{}\n\n// cachePolicy returns the cache freshness policy assembled from spec\n// configuration. Defaults: 6h global stale-after, env opt-out named after\n// the CLI. Per-resource overrides from spec.Cache.Resources take priority.\nfunc cachePolicy() cliutil.Policy {\n\tstaleAfter := 6 * time.Hour\n\tperResource := map[string]time.Duration{}\n\t// Default env opt-out name is the CLI name normalized with the same\n\t// ASCII-safe convention used in generated docs and config env vars.\n\tenvOptOut := \"APARTMENTS_NO_AUTO_REFRESH\"\n\treturn cliutil.Policy{\n\t\tStaleAfter: staleAfter,\n\t\tPerResource: perResource,\n\t\tEnvOptOut: envOptOut,\n\t\tShareEnabled: false,\n\t}\n}\n\n// refreshTimeout returns the wall-clock budget for a single auto-refresh\n// call. Beyond this, the command serves stale data with a stderr warning\n// instead of blocking the agent or user indefinitely.\nfunc refreshTimeout() time.Duration {\n\treturn 30 * time.Second\n}\n\n// autoRefreshIfStale decides whether to refresh and runs the refresh in\n// one call. Refresh failures become stderr warnings and the command proceeds\n// with the stale cache. The returned metadata is attached to generated JSON\n// provenance envelopes under meta.freshness.\nfunc autoRefreshIfStale(ctx context.Context, flags *rootFlags, resources []string) (meta cliutil.FreshnessMeta) {\n\tstarted := time.Now()\n\tmeta = cliutil.FreshnessMeta{\n\t\tDecision: \"skipped\",\n\t\tResources: append([]string(nil), resources...),\n\t\tSource: flags.dataSource,\n\t}\n\tdefer func() {\n\t\tmeta.ElapsedMS = time.Since(started).Milliseconds()\n\t}()\n\tif flags.dataSource != \"auto\" {\n\t\tmeta.Reason = \"data_source_\" + flags.dataSource\n\t\treturn meta\n\t}\n\tif len(resources) == 0 {\n\t\tmeta.Reason = \"no_resources\"\n\t\treturn meta\n\t}\n\tpolicy := cachePolicy()\n\tif policy.EnvOptOut != \"\" && os.Getenv(policy.EnvOptOut) == \"1\" {\n\t\tmeta.Decision = \"skipped\"\n\t\tmeta.Reason = \"env_opt_out\"\n\t\treturn meta\n\t}\n\tdbPath := defaultDBPath(\"apartments-pp-cli\")\n\tdb, err := store.OpenWithContext(ctx, dbPath)\n\tif err != nil {\n\t\tfmt.Fprintf(os.Stderr, \"warning: auto-refresh skipped (open: %v)\\n\", err)\n\t\tmeta.Decision = \"error\"\n\t\tmeta.Reason = \"open_store\"\n\t\tmeta.Error = err.Error()\n\t\treturn meta\n\t}\n\tdefer db.Close()\n\n\tdecision, err := cliutil.EnsureFresh(ctx, db.DB(), resources, policy)\n\tmeta.Decision = decision.String()\n\tif err != nil {\n\t\tfmt.Fprintf(os.Stderr, \"warning: auto-refresh decision failed: %v\\n\", err)\n\t\tmeta.Decision = \"error\"\n\t\tmeta.Reason = \"decision_failed\"\n\t\tmeta.Error = err.Error()\n\t\treturn meta\n\t}\n\tif decision == cliutil.DecisionFresh || decision == cliutil.DecisionNoStore {\n\t\tmeta.Reason = decision.String()\n\t\treturn meta\n\t}\n\n\trefreshCtx, cancel := context.WithTimeout(ctx, refreshTimeout())\n\tdefer cancel()\n\tmeta.Ran = true\n\tif err := runAutoRefresh(refreshCtx, flags, db, resources); err != nil {\n\t\tfmt.Fprintf(os.Stderr, \"warning: using stale apartments-pp-cli cache (refresh failed: %v)\\n\", err)\n\t\tmeta.Reason = \"refresh_failed\"\n\t\tmeta.Error = err.Error()\n\t\treturn meta\n\t}\n\tmeta.Reason = \"refreshed\"\n\treturn meta\n}\n\n// ensureFreshForResources lets hand-authored commands participate in the same\n// freshness hook as generated resource commands. Custom commands should call\n// this before reading from the store, then use wrapWithProvenance for JSON\n// output if they want meta.freshness.\nfunc ensureFreshForResources(ctx context.Context, flags *rootFlags, resources ...string) cliutil.FreshnessMeta {\n\tmeta := autoRefreshIfStale(ctx, flags, resources)\n\tflags.freshnessMeta = meta\n\treturn meta\n}\n\n// ensureFreshForCommand looks up a registered command path in\n// readCommandResources and applies the same freshness hook used by root\n// pre-run. commandPath must match cobra.CommandPath(), including the binary\n// name. It returns skipped metadata for unregistered commands.\nfunc ensureFreshForCommand(ctx context.Context, flags *rootFlags, commandPath string) cliutil.FreshnessMeta {\n\tresources, ok := readCommandResources[commandPath]\n\tif !ok {\n\t\tmeta := cliutil.FreshnessMeta{\n\t\t\tDecision: \"skipped\",\n\t\t\tReason: \"unregistered_command\",\n\t\t\tSource: flags.dataSource,\n\t\t}\n\t\tflags.freshnessMeta = meta\n\t\treturn meta\n\t}\n\treturn ensureFreshForResources(ctx, flags, resources...)\n}\n\n// runAutoRefresh invokes the API-backed refresh path for the stale resources.\n// Phase 3 will split this into share-vs-API based on cliutil.Decision; today\n// we always go to the API with a tight page budget (maxPages=1), matching\n// sync --latest-only semantics.\nfunc runAutoRefresh(ctx context.Context, flags *rootFlags, db *store.Store, resources []string) error {\n\tc, err := flags.newClient()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"build client: %w\", err)\n\t}\n\tc.NoCache = true\n\tvar failures []string\n\tfor _, resource := range resources {\n\t\tselect {\n\t\tcase \u003c-ctx.Done():\n\t\t\treturn ctx.Err()\n\t\tdefault:\n\t\t}\n\t\tresult := syncResource(c, db, resource, \"\", false, 1)\n\t\tif result.Err != nil {\n\t\t\tfailures = append(failures, fmt.Sprintf(\"%s: %v\", resource, result.Err))\n\t\t}\n\t}\n\tif len(failures) > 0 {\n\t\treturn fmt.Errorf(\"%s\", strings.Join(failures, \"; \"))\n\t}\n\treturn nil\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":5871,"content_sha256":"d5bd6eafc5aaeeb0e20f8421456bb41c2e022a96e4d822835a68760d0e1ad86e"},{"filename":"internal/cli/channel_workflow.go","content":"// Copyright 2026 rderwin and contributors. Licensed under Apache-2.0. See LICENSE.\n// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.\n\npackage cli\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/mvanhorn/printing-press-library/library/other/apartments/internal/store\"\n\t\"github.com/spf13/cobra\"\n)\n\nfunc newWorkflowCmd(flags *rootFlags) *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse: \"workflow\",\n\t\tShort: \"Compound workflows that combine multiple API operations\",\n\t}\n\n\tcmd.AddCommand(newWorkflowArchiveCmd(flags))\n\tcmd.AddCommand(newWorkflowStatusCmd(flags))\n\n\treturn cmd\n}\n\nfunc newWorkflowArchiveCmd(flags *rootFlags) *cobra.Command {\n\tvar dbPath string\n\tvar full bool\n\n\tcmd := &cobra.Command{\n\t\tUse: \"archive\",\n\t\tShort: \"Sync all resources to local store for offline access and search\",\n\t\tLong: `Archive fetches all syncable resources from the API and stores them in a\nlocal SQLite database. Supports incremental sync (only new data since last run)\nand full resync. After archiving, use 'search' for instant full-text search.`,\n\t\tExample: ` # Archive all resources\n apartments-pp-cli workflow archive\n\n # Full re-archive (ignore previous sync state)\n apartments-pp-cli workflow archive --full`,\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tc, err := flags.newClient()\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tc.NoCache = true\n\n\t\t\tif dbPath == \"\" {\n\t\t\t\tdbPath = defaultDBPath(\"apartments-pp-cli\")\n\t\t\t}\n\t\t\ts, err := store.OpenWithContext(cmd.Context(), dbPath)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"opening store: %w\", err)\n\t\t\t}\n\t\t\tdefer s.Close()\n\n\t\t\tresources := []string{}\n\t\t\ttotalSynced := 0\n\n\t\t\tfor _, resource := range resources {\n\t\t\t\tcursor := \"\"\n\t\t\t\tif !full {\n\t\t\t\t\texisting, _, _, err := s.GetSyncState(resource)\n\t\t\t\t\tif err == nil && existing != \"\" {\n\t\t\t\t\t\tcursor = existing\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tfmt.Fprintf(cmd.ErrOrStderr(), \"Syncing %s...\\n\", resource)\n\n\t\t\t\tparams := map[string]string{\"limit\": \"100\"}\n\t\t\t\tif cursor != \"\" {\n\t\t\t\t\tparams[\"after\"] = cursor\n\t\t\t\t}\n\n\t\t\t\tcount := 0\n\t\t\t\tfor {\n\t\t\t\t\tdata, fetchErr := c.Get(\"/\"+resource, params)\n\t\t\t\t\tif fetchErr != nil {\n\t\t\t\t\t\tfmt.Fprintf(cmd.ErrOrStderr(), \" warning: %s: %v\\n\", resource, fetchErr)\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t\tvar items []json.RawMessage\n\t\t\t\t\tif err := json.Unmarshal(data, &items); err != nil {\n\t\t\t\t\t\t// Might be a single object, not array\n\t\t\t\t\t\tif err := s.Upsert(resource, resource+\"-singleton\", data); err != nil {\n\t\t\t\t\t\t\tfmt.Fprintf(cmd.ErrOrStderr(), \" warning: store %s: %v\\n\", resource, err)\n\t\t\t\t\t\t}\n\t\t\t\t\t\tcount++\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t\tif len(items) == 0 {\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t\tfor _, item := range items {\n\t\t\t\t\t\tvar obj struct {\n\t\t\t\t\t\t\tID string `json:\"id\"`\n\t\t\t\t\t\t}\n\t\t\t\t\t\tjson.Unmarshal(item, &obj)\n\t\t\t\t\t\tid := obj.ID\n\t\t\t\t\t\tif id == \"\" {\n\t\t\t\t\t\t\tid = fmt.Sprintf(\"%s-%d\", resource, count)\n\t\t\t\t\t\t}\n\t\t\t\t\t\tif err := s.Upsert(resource, id, item); err != nil {\n\t\t\t\t\t\t\tfmt.Fprintf(cmd.ErrOrStderr(), \" warning: store %s/%s: %v\\n\", resource, id, err)\n\t\t\t\t\t\t}\n\t\t\t\t\t\tcursor = id\n\t\t\t\t\t\tcount++\n\t\t\t\t\t}\n\t\t\t\t\tif len(items) \u003c 100 {\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t\tparams[\"after\"] = cursor\n\t\t\t\t}\n\n\t\t\t\tif count > 0 {\n\t\t\t\t\ts.SaveSyncState(resource, cursor, count)\n\t\t\t\t}\n\t\t\t\ttotalSynced += count\n\t\t\t\tfmt.Fprintf(cmd.ErrOrStderr(), \" %s: %d items\\n\", resource, count)\n\t\t\t}\n\n\t\t\tif flags.asJSON {\n\t\t\t\tenc := json.NewEncoder(cmd.OutOrStdout())\n\t\t\t\tenc.SetIndent(\"\", \" \")\n\t\t\t\treturn enc.Encode(map[string]any{\n\t\t\t\t\t\"resources_synced\": len(resources),\n\t\t\t\t\t\"total_items\": totalSynced,\n\t\t\t\t\t\"store_path\": dbPath,\n\t\t\t\t\t\"timestamp\": time.Now().UTC().Format(time.RFC3339),\n\t\t\t\t})\n\t\t\t}\n\n\t\t\tfmt.Fprintf(cmd.OutOrStdout(), \"Archived %d items across %d resources to %s\\n\", totalSynced, len(resources), dbPath)\n\t\t\treturn nil\n\t\t},\n\t}\n\n\tcmd.Flags().StringVar(&dbPath, \"db\", \"\", \"Database path (default: ~/.local/share/apartments-pp-cli/data.db)\")\n\tcmd.Flags().BoolVar(&full, \"full\", false, \"Full re-archive (ignore previous sync state)\")\n\n\treturn cmd\n}\n\nfunc newWorkflowStatusCmd(flags *rootFlags) *cobra.Command {\n\tvar dbPath string\n\n\tcmd := &cobra.Command{\n\t\tUse: \"status\",\n\t\tShort: \"Show local archive status and sync state for all resources\",\n\t\tAnnotations: map[string]string{\"mcp:read-only\": \"true\"},\n\t\tExample: ` # Show archive status\n apartments-pp-cli workflow status\n\n # Show status as JSON\n apartments-pp-cli workflow status --json`,\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tif dbPath == \"\" {\n\t\t\t\tdbPath = defaultDBPath(\"apartments-pp-cli\")\n\t\t\t}\n\t\t\ts, err := store.OpenWithContext(cmd.Context(), dbPath)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"opening store: %w\", err)\n\t\t\t}\n\t\t\tdefer s.Close()\n\n\t\t\tstatus, err := s.Status()\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tif flags.asJSON {\n\t\t\t\tenc := json.NewEncoder(cmd.OutOrStdout())\n\t\t\t\tenc.SetIndent(\"\", \" \")\n\t\t\t\treturn enc.Encode(status)\n\t\t\t}\n\n\t\t\tif len(status) == 0 {\n\t\t\t\tfmt.Fprintln(cmd.OutOrStdout(), \"No archived data. Run 'workflow archive' to sync.\")\n\t\t\t\treturn nil\n\t\t\t}\n\n\t\t\tfmt.Fprintln(cmd.OutOrStdout(), \"Archive Status:\")\n\t\t\ttotal := 0\n\t\t\tfor resource, count := range status {\n\t\t\t\tfmt.Fprintf(cmd.OutOrStdout(), \" %-30s %d items\\n\", resource, count)\n\t\t\t\ttotal += count\n\t\t\t}\n\t\t\tfmt.Fprintf(cmd.OutOrStdout(), \"\\n Total: %d items\\n\", total)\n\t\t\tfmt.Fprintf(cmd.OutOrStdout(), \" Store: %s\\n\", dbPath)\n\t\t\treturn nil\n\t\t},\n\t}\n\n\tcmd.Flags().StringVar(&dbPath, \"db\", \"\", \"Database path\")\n\n\treturn cmd\n}\n\n// defaultDBPath is defined in helpers.go\n","content_type":"text/plain; charset=utf-8","language":"go","size":5491,"content_sha256":"1ba48c916bde68526c0eb21425dcdfc848b0f8ddb82f793d4d9d9dc7ee0f57e5"},{"filename":"internal/cli/data_source.go","content":"// Copyright 2026 rderwin and contributors. Licensed under Apache-2.0. See LICENSE.\n// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.\n\npackage cli\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net\"\n\t\"net/url\"\n\t\"os\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/mvanhorn/printing-press-library/library/other/apartments/internal/client\"\n\t\"github.com/mvanhorn/printing-press-library/library/other/apartments/internal/store\"\n)\n\n// isNetworkError returns true for errors caused by network connectivity issues\n// (DNS, connection refused, timeout). HTTP 4xx/5xx errors are NOT network errors.\nfunc isNetworkError(err error) bool {\n\tif err == nil {\n\t\treturn false\n\t}\n\tvar urlErr *url.Error\n\tif As(err, &urlErr) {\n\t\t// url.Error wraps the underlying network error\n\t\terr = urlErr.Err\n\t}\n\tvar netErr *net.OpError\n\tif As(err, &netErr) {\n\t\treturn true\n\t}\n\tvar dnsErr *net.DNSError\n\tif As(err, &dnsErr) {\n\t\treturn true\n\t}\n\t// Check for common network error strings\n\tmsg := err.Error()\n\treturn strings.Contains(msg, \"connection refused\") ||\n\t\tstrings.Contains(msg, \"no such host\") ||\n\t\tstrings.Contains(msg, \"network is unreachable\") ||\n\t\tstrings.Contains(msg, \"i/o timeout\") ||\n\t\tstrings.Contains(msg, \"TLS handshake timeout\")\n}\n\n// openStoreForRead opens the local SQLite store for reading.\n// Returns nil, nil if the database file does not exist (no sync has been run).\nfunc openStoreForRead(ctx context.Context, cliName string) (*store.Store, error) {\n\tdbPath := defaultDBPath(cliName)\n\tif _, err := os.Stat(dbPath); os.IsNotExist(err) {\n\t\treturn nil, nil\n\t}\n\treturn store.OpenWithContext(ctx, dbPath)\n}\n\n// localProvenance builds a DataProvenance for local data reads.\nfunc localProvenance(db *store.Store, resourceType, reason string) DataProvenance {\n\tprov := DataProvenance{\n\t\tSource: \"local\",\n\t\tReason: reason,\n\t\tResourceType: resourceType,\n\t}\n\t_, lastSynced, _, err := db.GetSyncState(resourceType)\n\tif err == nil && !lastSynced.IsZero() {\n\t\tprov.SyncedAt = &lastSynced\n\t}\n\treturn prov\n}\n\nfunc attachFreshness(prov DataProvenance, flags *rootFlags) DataProvenance {\n\tif flags != nil {\n\t\tprov.Freshness = flags.freshnessMeta\n\t}\n\treturn prov\n}\n\n// resolveRead dispatches a GET request to either the live API or local store\n// based on the --data-source flag. Returns the response data and provenance metadata.\n//\n// Parameters:\n// - c: the HTTP client for live API calls\n// - flags: root flags containing dataSource setting\n// - resourceType: the store resource type name (e.g., \"links\", \"domains\")\n// - isList: true for list endpoints, false for get-by-ID endpoints\n// - path: the API path (e.g., \"/links\" or \"/links/abc123\")\n// - params: query parameters for the API call\n// - headers: per-endpoint required headers (e.g. cal-api-version, Stripe-Version)\n// baked in by the command template at codegen time. Pass nil when the endpoint\n// declares no per-endpoint header overrides. Without this parameter, store-backed\n// reads on per-endpoint-versioned APIs silently get the wrong response shape\n// (cal-com retro #334 F1).\nfunc resolveRead(ctx context.Context, c *client.Client, flags *rootFlags, resourceType string, isList bool, path string, params map[string]string, headers map[string]string) (json.RawMessage, DataProvenance, error) {\n\tswitch flags.dataSource {\n\tcase \"local\":\n\t\tdata, prov, err := resolveLocal(ctx, resourceType, isList, path, params, \"user_requested\")\n\t\treturn data, attachFreshness(prov, flags), err\n\n\tcase \"live\":\n\t\tdata, err := c.GetWithHeaders(path, params, headers)\n\t\tif err != nil {\n\t\t\treturn nil, DataProvenance{}, err\n\t\t}\n\t\treturn data, attachFreshness(DataProvenance{Source: \"live\"}, flags), nil\n\n\tdefault: // \"auto\"\n\t\tdata, err := c.GetWithHeaders(path, params, headers)\n\t\tif err == nil {\n\t\t\twriteThroughCache(ctx, resourceType, data)\n\t\t\treturn data, attachFreshness(DataProvenance{Source: \"live\"}, flags), nil\n\t\t}\n\t\tif !isNetworkError(err) {\n\t\t\t// HTTP 4xx/5xx errors propagate — not a fallback case\n\t\t\treturn nil, DataProvenance{}, err\n\t\t}\n\t\t// Network error — try local fallback\n\t\tfallbackData, fallbackProv, fallbackErr := resolveLocal(ctx, resourceType, isList, path, params, \"api_unreachable\")\n\t\tif fallbackErr != nil {\n\t\t\treturn nil, DataProvenance{}, fmt.Errorf(\"API unreachable and no local data. Run 'apartments-pp-cli sync' to enable offline access.\\n\\nOriginal error: %w\", err)\n\t\t}\n\t\treturn fallbackData, attachFreshness(fallbackProv, flags), nil\n\t}\n}\n\n// resolvePaginatedRead dispatches a paginated GET request to either the live API\n// or local store. When local, skips pagination and returns all synced data. The\n// headers argument carries per-endpoint required headers; pass nil when the\n// endpoint declares no overrides.\nfunc resolvePaginatedRead(ctx context.Context, c *client.Client, flags *rootFlags, resourceType string, path string, params map[string]string, headers map[string]string, fetchAll bool, cursorParam, nextCursorPath, hasMoreField string) (json.RawMessage, DataProvenance, error) {\n\tswitch flags.dataSource {\n\tcase \"local\":\n\t\tdata, prov, err := resolveLocal(ctx, resourceType, true, path, params, \"user_requested\")\n\t\treturn data, attachFreshness(prov, flags), err\n\n\tcase \"live\":\n\t\tdata, err := paginatedGet(c, path, params, headers, fetchAll, cursorParam, nextCursorPath, hasMoreField)\n\t\tif err != nil {\n\t\t\treturn nil, DataProvenance{}, err\n\t\t}\n\t\treturn data, attachFreshness(DataProvenance{Source: \"live\"}, flags), nil\n\n\tdefault: // \"auto\"\n\t\tdata, err := paginatedGet(c, path, params, headers, fetchAll, cursorParam, nextCursorPath, hasMoreField)\n\t\tif err == nil {\n\t\t\twriteThroughCache(ctx, resourceType, data)\n\t\t\treturn data, attachFreshness(DataProvenance{Source: \"live\"}, flags), nil\n\t\t}\n\t\tif !isNetworkError(err) {\n\t\t\treturn nil, DataProvenance{}, err\n\t\t}\n\t\tfallbackData, fallbackProv, fallbackErr := resolveLocal(ctx, resourceType, true, path, params, \"api_unreachable\")\n\t\tif fallbackErr != nil {\n\t\t\treturn nil, DataProvenance{}, fmt.Errorf(\"API unreachable and no local data. Run 'apartments-pp-cli sync' to enable offline access.\\n\\nOriginal error: %w\", err)\n\t\t}\n\t\treturn fallbackData, attachFreshness(fallbackProv, flags), nil\n\t}\n}\n\n// writeThroughCache upserts live API results into the local SQLite store so\n// FTS search covers everything the user has looked up — not just explicit syncs.\n// Best-effort: failures are silently ignored (the live result already succeeded).\nfunc writeThroughCache(ctx context.Context, resourceType string, data json.RawMessage) {\n\tdb, err := store.OpenWithContext(ctx, defaultDBPath(\"apartments-pp-cli\"))\n\tif err != nil {\n\t\treturn\n\t}\n\tdefer db.Close()\n\n\t// Collect items to upsert from various response shapes\n\tvar items []json.RawMessage\n\n\t// Try direct array first\n\tif json.Unmarshal(data, &items) != nil || len(items) == 0 {\n\t\titems = nil\n\t\t// Try object — check for common envelope patterns (results, data, items)\n\t\tvar envelope map[string]json.RawMessage\n\t\tif json.Unmarshal(data, &envelope) == nil {\n\t\t\tfor _, key := range []string{\"results\", \"data\", \"items\"} {\n\t\t\t\tif raw, ok := envelope[key]; ok {\n\t\t\t\t\tvar arr []json.RawMessage\n\t\t\t\t\tif json.Unmarshal(raw, &arr) == nil && len(arr) > 0 {\n\t\t\t\t\t\titems = arr\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\t// Single object with an id field (e.g., detail response)\n\t\t\tif items == nil {\n\t\t\t\tif idRaw, ok := envelope[\"id\"]; ok {\n\t\t\t\t\tid := strings.Trim(string(idRaw), \"\\\"\")\n\t\t\t\t\t_ = db.Upsert(resourceType, id, data)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t// Upsert each item individually\n\tfor _, item := range items {\n\t\tvar obj map[string]json.RawMessage\n\t\tif json.Unmarshal(item, &obj) != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif idRaw, ok := obj[\"id\"]; ok {\n\t\t\tid := strings.Trim(string(idRaw), \"\\\"\")\n\t\t\t_ = db.Upsert(resourceType, id, item)\n\t\t}\n\t}\n}\n\n// resolveLocal reads data from the local SQLite store.\n// Note: local reads return ALL synced data for the resource type. Endpoint-specific\n// filters (query params, path scoping like /teams/{id}/users) are NOT applied locally.\n// The provenance metadata includes \"unscoped\":true when params were present but not applied.\nfunc resolveLocal(ctx context.Context, resourceType string, isList bool, path string, params map[string]string, reason string) (json.RawMessage, DataProvenance, error) {\n\tdb, err := openStoreForRead(ctx, \"apartments-pp-cli\")\n\tif err != nil {\n\t\treturn nil, DataProvenance{}, fmt.Errorf(\"opening local database: %w\\nRun 'apartments-pp-cli sync' first.\", err)\n\t}\n\tif db == nil {\n\t\treturn nil, DataProvenance{}, fmt.Errorf(\"no local data. Run 'apartments-pp-cli sync' first\")\n\t}\n\tdefer db.Close()\n\n\tprov := localProvenance(db, resourceType, reason)\n\n\t// Warn if endpoint had filters that local reads can't reproduce\n\tif len(params) > 0 {\n\t\tfmt.Fprintf(os.Stderr, \"warning: local data is unfiltered — endpoint filters are not applied to cached data\\n\")\n\t}\n\n\tif isList {\n\t\traw, err := db.List(resourceType, 0) // 0 = no limit, return all synced data\n\t\tif err != nil {\n\t\t\treturn nil, DataProvenance{}, fmt.Errorf(\"querying local store: %w\", err)\n\t\t}\n\t\t// Filter out empty/invalid records (empty arrays, null, whitespace-only)\n\t\t// that can end up in the store from pagination boundary artifacts.\n\t\tvar items []json.RawMessage\n\t\tfor _, r := range raw {\n\t\t\ttrimmed := strings.TrimSpace(string(r))\n\t\t\tif trimmed == \"\" || trimmed == \"null\" || trimmed == \"[]\" || trimmed == \"{}\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\titems = append(items, r)\n\t\t}\n\t\tif len(items) == 0 {\n\t\t\treturn nil, DataProvenance{}, fmt.Errorf(\"no local data for %q. Run 'apartments-pp-cli sync' first\", resourceType)\n\t\t}\n\t\t// Marshal []json.RawMessage into a single JSON array\n\t\tdata, err := json.Marshal(items)\n\t\tif err != nil {\n\t\t\treturn nil, DataProvenance{}, fmt.Errorf(\"marshaling local data: %w\", err)\n\t\t}\n\t\treturn data, prov, nil\n\t}\n\n\t// Get by ID — extract the last path segment as the ID\n\tparts := strings.Split(strings.TrimRight(path, \"/\"), \"/\")\n\tid := parts[len(parts)-1]\n\n\titem, err := db.Get(resourceType, id)\n\tif err != nil {\n\t\treturn nil, DataProvenance{}, fmt.Errorf(\"querying local store: %w\", err)\n\t}\n\tif item == nil {\n\t\treturn nil, DataProvenance{}, fmt.Errorf(\"resource %q with ID %q not found in local store. Run 'apartments-pp-cli sync' first\", resourceType, id)\n\t}\n\treturn item, prov, nil\n}\n\n// Ensure time import is used (compilation guard).\nvar _ = time.Now\n","content_type":"text/plain; charset=utf-8","language":"go","size":10320,"content_sha256":"73a0127af150a7a08d19049c50b7932776e11a4410fafa0331b93ed99c037b17"},{"filename":"internal/cli/deliver.go","content":"// Copyright 2026 rderwin and contributors. Licensed under Apache-2.0. See LICENSE.\n// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.\n\npackage cli\n\nimport (\n\t\"bytes\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"time\"\n)\n\n// DeliverSink describes where command output should be routed when\n// --deliver is set. Parsed from the sink specifier \"scheme:target\".\ntype DeliverSink struct {\n\tScheme string\n\tTarget string\n}\n\n// ParseDeliverSink parses a --deliver value. Supported schemes:\n//\n//\tstdout -> default, no redirection\n//\tfile:\u003cpath> -> write output atomically to \u003cpath>\n//\twebhook:\u003curl> -> POST output body to \u003curl>\n//\n// Returns an error for unknown schemes with a message naming the\n// supported set, so agents see a structured refusal rather than a\n// silent misroute.\nfunc ParseDeliverSink(spec string) (DeliverSink, error) {\n\tif spec == \"\" || spec == \"stdout\" {\n\t\treturn DeliverSink{Scheme: \"stdout\"}, nil\n\t}\n\tidx := strings.Index(spec, \":\")\n\tif idx == -1 {\n\t\treturn DeliverSink{}, fmt.Errorf(\"unknown --deliver sink %q: expected scheme:target (supported: stdout, file:\u003cpath>, webhook:\u003curl>)\", spec)\n\t}\n\tscheme := spec[:idx]\n\ttarget := spec[idx+1:]\n\tswitch scheme {\n\tcase \"file\":\n\t\tif target == \"\" {\n\t\t\treturn DeliverSink{}, fmt.Errorf(\"--deliver file:\u003cpath> requires a path\")\n\t\t}\n\tcase \"webhook\":\n\t\tif !strings.HasPrefix(target, \"http://\") && !strings.HasPrefix(target, \"https://\") {\n\t\t\treturn DeliverSink{}, fmt.Errorf(\"--deliver webhook:\u003curl> requires an http:// or https:// URL, got %q\", target)\n\t\t}\n\tdefault:\n\t\treturn DeliverSink{}, fmt.Errorf(\"unknown --deliver scheme %q (supported: stdout, file, webhook)\", scheme)\n\t}\n\treturn DeliverSink{Scheme: scheme, Target: target}, nil\n}\n\n// Deliver routes a captured output buffer to the configured sink. stdout\n// is a no-op because the buffer has already been streamed to stdout via\n// the MultiWriter set up in root.go.\nfunc Deliver(sink DeliverSink, body []byte, compact bool) error {\n\tswitch sink.Scheme {\n\tcase \"\", \"stdout\":\n\t\treturn nil\n\tcase \"file\":\n\t\treturn deliverFile(sink.Target, body)\n\tcase \"webhook\":\n\t\treturn deliverWebhook(sink.Target, body, compact)\n\tdefault:\n\t\treturn fmt.Errorf(\"unsupported deliver sink %q\", sink.Scheme)\n\t}\n}\n\nfunc deliverFile(path string, body []byte) error {\n\t// Atomic write: tmp + rename. Protects agents from seeing a partial\n\t// file if the process is interrupted mid-write.\n\tdir := filepath.Dir(path)\n\tif dir != \"\" && dir != \".\" {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\treturn fmt.Errorf(\"creating deliver dir: %w\", err)\n\t\t}\n\t}\n\ttmp := path + \".tmp\"\n\tif err := os.WriteFile(tmp, body, 0o600); err != nil {\n\t\treturn fmt.Errorf(\"writing deliver tmp: %w\", err)\n\t}\n\tif err := os.Rename(tmp, path); err != nil {\n\t\treturn fmt.Errorf(\"replacing deliver file: %w\", err)\n\t}\n\treturn nil\n}\n\nfunc deliverWebhook(url string, body []byte, compact bool) error {\n\tcontentType := \"application/json\"\n\tif compact {\n\t\tcontentType = \"application/x-ndjson\"\n\t}\n\treq, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))\n\tif err != nil {\n\t\treturn fmt.Errorf(\"building webhook request: %w\", err)\n\t}\n\treq.Header.Set(\"Content-Type\", contentType)\n\treq.Header.Set(\"User-Agent\", \"apartments-pp-cli/deliver\")\n\n\tclient := &http.Client{Timeout: 30 * time.Second}\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"posting to webhook: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\tif resp.StatusCode >= 400 {\n\t\treturn fmt.Errorf(\"webhook returned %s\", resp.Status)\n\t}\n\treturn nil\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":3559,"content_sha256":"9b505c97f24af6dd9cb9bffe325d37c22acc0d0aca6ce1a3234b064d4dd21c3a"},{"filename":"internal/cli/doctor.go","content":"// Copyright 2026 rderwin and contributors. Licensed under Apache-2.0. See LICENSE.\n// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.\n\npackage cli\n\nimport (\n\t\"context\"\n\t\"database/sql\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/mvanhorn/printing-press-library/library/other/apartments/internal/client\"\n\t\"github.com/mvanhorn/printing-press-library/library/other/apartments/internal/config\"\n\t\"github.com/mvanhorn/printing-press-library/library/other/apartments/internal/store\"\n\t\"github.com/spf13/cobra\"\n)\n\n// looksLikeDoctorInterstitial reports whether the response body matches a known\n// bot-detection challenge page (Cloudflare, Akamai, Vercel, AWS WAF, DataDome,\n// PerimeterX). Only fires on the doctor probe — used to distinguish \"transport\n// reached the wall\" from \"transport failed entirely.\" Returns the vendor name\n// when matched, or empty string when no match.\n//\n// Markers are anchored to \u003ctitle> or vendor-specific strings to avoid\n// false-positives on benign content. For example, a recipe titled \"Just A\n// Moment of Pause Cookies\" must NOT match the Cloudflare challenge marker;\n// only \"\u003ctitle>just a moment\" (the actual interstitial title) does.\nfunc looksLikeDoctorInterstitial(body []byte) string {\n\tif len(body) == 0 {\n\t\treturn \"\"\n\t}\n\tlimit := len(body)\n\tif limit > 8192 {\n\t\tlimit = 8192\n\t}\n\tprefix := strings.ToLower(string(body[:limit]))\n\tif !strings.Contains(prefix, \"\u003ctitle\") {\n\t\t// Every bot interstitial we recognize sets a \u003ctitle>; bodies without\n\t\t// one are body-only API responses, not challenge pages.\n\t\treturn \"\"\n\t}\n\tswitch {\n\tcase strings.Contains(prefix, \"\u003ctitle>just a moment\") || // CF JS challenge\n\t\tstrings.Contains(prefix, \"challenges.cloudflare.com\") || // CF Turnstile\n\t\t(strings.Contains(prefix, \"attention required\") && strings.Contains(prefix, \"cloudflare\")):\n\t\treturn \"Cloudflare\"\n\tcase strings.Contains(prefix, \"akamai\") && (strings.Contains(prefix, \"request unsuccessful\") || strings.Contains(prefix, \"access denied\")):\n\t\treturn \"Akamai\"\n\tcase strings.Contains(prefix, \"x-vercel-mitigated\") || strings.Contains(prefix, \"x-vercel-challenge-token\") ||\n\t\t(strings.Contains(prefix, \"vercel\") && strings.Contains(prefix, \"challenge\")):\n\t\treturn \"Vercel\"\n\tcase strings.Contains(prefix, \"request blocked\") && strings.Contains(prefix, \"aws waf\"):\n\t\treturn \"AWS WAF\"\n\tcase strings.Contains(prefix, \"datadome\") && (strings.Contains(prefix, \"blocked\") || strings.Contains(prefix, \"captcha\") || strings.Contains(prefix, \"challenge\")):\n\t\treturn \"DataDome\"\n\tcase strings.Contains(prefix, \"perimeterx\") || strings.Contains(prefix, \"px-captcha\"):\n\t\treturn \"PerimeterX\"\n\t}\n\treturn \"\"\n}\n\nfunc newDoctorCmd(flags *rootFlags) *cobra.Command {\n\tvar failOn string\n\tcmd := &cobra.Command{\n\t\tUse: \"doctor\",\n\t\tShort: \"Check CLI health\",\n\t\tExample: ` apartments-pp-cli doctor\n apartments-pp-cli doctor --json\n apartments-pp-cli doctor --fail-on warn`,\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\treport := map[string]any{}\n\n\t\t\t// Check config\n\t\t\tcfg, err := config.Load(flags.configPath)\n\t\t\tif err != nil {\n\t\t\t\treport[\"config\"] = fmt.Sprintf(\"error: %s\", err)\n\t\t\t} else {\n\t\t\t\treport[\"config\"] = \"ok\"\n\t\t\t\treport[\"config_path\"] = cfg.Path\n\t\t\t\treport[\"base_url\"] = cfg.BaseURL\n\t\t\t}\n\n\t\t\t// Check auth\n\t\t\treport[\"auth\"] = \"not required\"\n\n\t\t\t// Check auth environment variables\n\n\t\t\t// Check API connectivity and validate credentials.\n\t\t\t//\n\t\t\t// The doctor uses the same client every other command uses --\n\t\t\t// flags.newClient() returns a *client.Client wrapping whatever\n\t\t\t// transport the spec declared (Surf for browser-chrome, stdlib\n\t\t\t// for standard). A separate stdlib http.Client would silently\n\t\t\t// bypass that choice and report false negatives against\n\t\t\t// Cloudflare-fronted, Akamai-fronted, or otherwise bot-detected\n\t\t\t// sites. By going through flags.newClient(), the doctor's\n\t\t\t// reachability verdict matches what real commands experience.\n\t\t\tif cfg != nil && cfg.BaseURL != \"\" {\n\t\t\t\tc, clientErr := flags.newClient()\n\t\t\t\tif clientErr != nil {\n\t\t\t\t\treport[\"api\"] = fmt.Sprintf(\"client init error: %s\", clientErr)\n\t\t\t\t} else {\n\t\t\t\t\t// Step 1: Basic reachability via the configured transport.\n\t\t\t\t\treachBody, reachErr := c.Get(\"/\", nil)\n\t\t\t\t\tvar reachAPIErr *client.APIError\n\t\t\t\t\tswitch {\n\t\t\t\t\tcase reachErr == nil:\n\t\t\t\t\t\t// 2xx response — clearly reachable. Still inspect the\n\t\t\t\t\t\t// body for a known interstitial; some bot walls return\n\t\t\t\t\t\t// 200 with a JS challenge page.\n\t\t\t\t\t\tif vendor := looksLikeDoctorInterstitial(reachBody); vendor != \"\" {\n\t\t\t\t\t\t\treport[\"api\"] = fmt.Sprintf(\"blocked by %s interstitial — the configured transport reached the wall. Try a different network, wait for the IP-level rate limit to clear, or check that the browser-chrome transport is bound correctly.\", vendor)\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\treport[\"api\"] = \"reachable\"\n\t\t\t\t\t\t}\n\t\t\t\t\tcase errors.As(reachErr, &reachAPIErr):\n\t\t\t\t\t\t// Non-2xx from the server. The network reached, the\n\t\t\t\t\t\t// server responded — that's \"reachable\" for our\n\t\t\t\t\t\t// purposes. Inspect the response body for a known\n\t\t\t\t\t\t// interstitial first; otherwise note the status.\n\t\t\t\t\t\tstatus := reachAPIErr.StatusCode\n\t\t\t\t\t\tif vendor := looksLikeDoctorInterstitial([]byte(reachAPIErr.Body)); vendor != \"\" {\n\t\t\t\t\t\t\treport[\"api\"] = fmt.Sprintf(\"blocked by %s interstitial (HTTP %d) — the configured transport reached the wall.\", vendor, status)\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\treport[\"api\"] = fmt.Sprintf(\"reachable (HTTP %d at /)\", status)\n\t\t\t\t\t\t}\n\t\t\t\t\tdefault:\n\t\t\t\t\t\t// Network-level failure: DNS, connection refused, TLS,\n\t\t\t\t\t\t// transport init, etc. The transport itself didn't\n\t\t\t\t\t\t// connect.\n\t\t\t\t\t\treport[\"api\"] = fmt.Sprintf(\"unreachable: %s\", reachErr)\n\t\t\t\t\t}\n\n\t\t\t\t\t// Step 2: Validate credentials with an authenticated probe.\n\t\t\t\t\tauthHeader := cfg.AuthHeader()\n\t\t\t\t\tif authHeader == \"\" {\n\t\t\t\t\t\t// No auth configured — skip credential validation\n\t\t\t\t\t} else if reachErr != nil && !errors.As(reachErr, &reachAPIErr) {\n\t\t\t\t\t\treport[\"credentials\"] = \"skipped (API unreachable)\"\n\t\t\t\t\t} else {\n\t\t\t\t\t\tverifyPath := \"/\"\n\t\t\t\t\t\tauthParams := map[string]string{}\n\t\t\t\t\t\tauthHeaders := map[string]string{}\n\t\t\t\t\t\tauthHeaders[\"Authorization\"] = authHeader\n\t\t\t\t\t\t_, authErr := c.GetWithHeaders(verifyPath, authParams, authHeaders)\n\t\t\t\t\t\tvar authAPIErr *client.APIError\n\t\t\t\t\t\tswitch {\n\t\t\t\t\t\tcase authErr == nil:\n\t\t\t\t\t\t\treport[\"credentials\"] = \"valid\"\n\t\t\t\t\t\tcase errors.As(authErr, &authAPIErr):\n\t\t\t\t\t\t\tswitch {\n\t\t\t\t\t\t\tcase authAPIErr.StatusCode == 401 || authAPIErr.StatusCode == 403:\n\t\t\t\t\t\t\t\t// The probe hit the bare base URL because no auth.verify_path\n\t\t\t\t\t\t\t\t// is configured in the spec. Many APIs return 401/403 from a\n\t\t\t\t\t\t\t\t// bare versioned root regardless of token validity (the path\n\t\t\t\t\t\t\t\t// isn't routed but the gateway still demands credentials).\n\t\t\t\t\t\t\t\t// Don't claim invalid without certainty — set verify_path to\n\t\t\t\t\t\t\t\t// a known-good authenticated GET (e.g. /me, /v1/account, /user)\n\t\t\t\t\t\t\t\t// for a definitive verdict.\n\t\t\t\t\t\t\t\treport[\"credentials\"] = fmt.Sprintf(\"inconclusive (HTTP %d from base URL — set auth.verify_path in spec for a definitive probe)\", authAPIErr.StatusCode)\n\t\t\t\t\t\t\tdefault:\n\t\t\t\t\t\t\t\t// Non-auth HTTP error (404, 500, etc.) — don't blame credentials\n\t\t\t\t\t\t\t\treport[\"credentials\"] = fmt.Sprintf(\"ok (HTTP %d from %s, but auth was accepted)\", authAPIErr.StatusCode, verifyPath)\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\tdefault:\n\t\t\t\t\t\t\treport[\"credentials\"] = fmt.Sprintf(\"error: %s\", authErr)\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else if cfg != nil && cfg.BaseURL == \"\" {\n\t\t\t\treport[\"api\"] = \"not configured (set base_url in config file)\"\n\t\t\t}\n\t\t\t// Cache health: only reported when this CLI has a local store.\n\t\t\t// Surfaces rows + last_synced_at per resource, schema version,\n\t\t\t// and a fresh/stale/unknown verdict so agents can introspect\n\t\t\t// whether to trust the cached data before issuing queries.\n\t\t\treport[\"cache\"] = collectCacheReport(cmd.Context(), \"\")\n\n\t\t\treport[\"version\"] = version\n\n\t\t\tif flags.asJSON {\n\t\t\t\tif err := printJSONFiltered(cmd.OutOrStdout(), report, flags); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t\treturn doctorExitForFailOn(failOn, report)\n\t\t\t}\n\n\t\t\t// Human-readable output with color\n\t\t\tw := cmd.OutOrStdout()\n\t\t\tcheckKeys := []struct{ key, label string }{\n\t\t\t\t{\"config\", \"Config\"},\n\t\t\t\t{\"auth\", \"Auth\"},\n\t\t\t\t{\"api\", \"API\"},\n\t\t\t\t{\"credentials\", \"Credentials\"},\n\t\t\t}\n\t\t\tfor _, ck := range checkKeys {\n\t\t\t\tv, ok := report[ck.key]\n\t\t\t\tif !ok {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\ts := fmt.Sprintf(\"%v\", v)\n\t\t\t\tindicator := green(\"OK\")\n\t\t\t\tswitch {\n\t\t\t\tcase strings.HasPrefix(s, \"optional\"):\n\t\t\t\t\t// Optional-auth CLI with no key set — informational, not a failure.\n\t\t\t\t\tindicator = yellow(\"INFO\")\n\t\t\t\tcase strings.HasPrefix(s, \"inconclusive\"):\n\t\t\t\t\t// The credential probe could not produce a definitive verdict\n\t\t\t\t\t// (typically because the bare base URL returns 401/403 even for\n\t\t\t\t\t// valid tokens). Surface as WARN, not FAIL — the user's actual\n\t\t\t\t\t// commands will reveal a real auth failure if one exists.\n\t\t\t\t\tindicator = yellow(\"WARN\")\n\t\t\t\tcase strings.Contains(s, \"error\") || strings.Contains(s, \"not configured\") || strings.Contains(s, \"unreachable\") || strings.Contains(s, \"invalid\") || strings.Contains(s, \"missing\"):\n\t\t\t\t\tindicator = red(\"FAIL\")\n\t\t\t\tcase s == \"not required\":\n\t\t\t\t\t// Public APIs: no auth needed is a healthy state, not a warning.\n\t\t\t\t\tindicator = green(\"OK\")\n\t\t\t\tcase strings.Contains(s, \"not \") || strings.Contains(s, \"skipped\") || strings.Contains(s, \"inferred\"):\n\t\t\t\t\tindicator = yellow(\"WARN\")\n\t\t\t\t}\n\t\t\t\tfmt.Fprintf(w, \" %s %s: %s\\n\", indicator, ck.label, s)\n\t\t\t}\n\t\t\t// Print info keys without status indicator\n\t\t\tfor _, key := range []string{\"config_path\", \"base_url\", \"auth_source\", \"version\"} {\n\t\t\t\tif v, ok := report[key]; ok {\n\t\t\t\t\tfmt.Fprintf(w, \" %s: %v\\n\", key, v)\n\t\t\t\t}\n\t\t\t}\n\t\t\t// Print auth setup hints (indented under Auth line)\n\t\t\t// Cache section: render after the primary health block so it\n\t\t\t// sits next to version info, mirroring the JSON report layout.\n\t\t\tif cacheAny, ok := report[\"cache\"]; ok {\n\t\t\t\tif cacheRep, ok := cacheAny.(map[string]any); ok {\n\t\t\t\t\trenderCacheReport(w, cacheRep)\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn doctorExitForFailOn(failOn, report)\n\t\t},\n\t}\n\tcmd.Flags().StringVar(&failOn, \"fail-on\", \"\", \"Exit non-zero when a health level is reached: stale, error. Default is never.\")\n\treturn cmd\n}\n\n// doctorExitForFailOn returns a non-nil error when the report's worst\n// status meets or exceeds the --fail-on threshold. \"error\" always trips\n// when any section reports an error; \"stale\" also trips when the cache\n// section is stale. The default empty string means never fail on status.\nfunc doctorExitForFailOn(failOn string, report map[string]any) error {\n\tif failOn == \"\" {\n\t\treturn nil\n\t}\n\tworstError := false\n\tworstStale := false\n\tfor _, v := range report {\n\t\ts, ok := v.(string)\n\t\tif ok {\n\t\t\tif strings.Contains(s, \"error\") || strings.Contains(s, \"unreachable\") || strings.Contains(s, \"invalid\") || strings.Contains(s, \"missing\") {\n\t\t\t\tworstError = true\n\t\t\t}\n\t\t}\n\t\tif m, ok := v.(map[string]any); ok {\n\t\t\tif st, _ := m[\"status\"].(string); st == \"error\" {\n\t\t\t\tworstError = true\n\t\t\t} else if st == \"stale\" {\n\t\t\t\tworstStale = true\n\t\t\t}\n\t\t}\n\t}\n\tswitch failOn {\n\tcase \"error\":\n\t\tif worstError {\n\t\t\treturn fmt.Errorf(\"doctor: --fail-on=error triggered\")\n\t\t}\n\tcase \"stale\":\n\t\tif worstError || worstStale {\n\t\t\treturn fmt.Errorf(\"doctor: --fail-on=stale triggered\")\n\t\t}\n\tdefault:\n\t\treturn fmt.Errorf(\"doctor: unknown --fail-on value %q (valid: stale, error)\", failOn)\n\t}\n\treturn nil\n}\n\n// collectCacheReport opens the local store, reads per-resource sync state,\n// and returns a map summarising cache health. Never panics on missing DB\n// or open failure; returns a map with status=unknown or status=error so the\n// caller can render and agents can interpret.\n//\n// staleAfterSpec is the CLI's configured threshold (e.g. \"6h\"); empty means\n// use the runtime default. The default is deliberately conservative (6h)\n// because the alternative is no freshness story at all.\nfunc collectCacheReport(ctx context.Context, staleAfterSpec string) map[string]any {\n\treport := map[string]any{}\n\tdbPath := defaultDBPath(\"apartments-pp-cli\")\n\treport[\"db_path\"] = dbPath\n\n\tfi, err := os.Stat(dbPath)\n\tif err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treport[\"status\"] = \"unknown\"\n\t\t\treport[\"hint\"] = \"Database not created yet; run 'apartments-pp-cli sync' to hydrate.\"\n\t\t\treturn report\n\t\t}\n\t\treport[\"status\"] = \"error\"\n\t\treport[\"error\"] = err.Error()\n\t\treturn report\n\t}\n\treport[\"db_bytes\"] = fi.Size()\n\n\ts, err := store.OpenWithContext(ctx, dbPath)\n\tif err != nil {\n\t\treport[\"status\"] = \"error\"\n\t\treport[\"error\"] = err.Error()\n\t\treturn report\n\t}\n\tdefer s.Close()\n\n\tif v, verr := s.SchemaVersion(); verr == nil {\n\t\treport[\"schema_version\"] = v\n\t}\n\n\tstaleAfter := 6 * time.Hour\n\tif staleAfterSpec != \"\" {\n\t\tif d, derr := time.ParseDuration(staleAfterSpec); derr == nil {\n\t\t\tstaleAfter = d\n\t\t}\n\t}\n\n\trows, qerr := s.DB().Query(`SELECT resource_type, COALESCE(total_count, 0), last_synced_at FROM sync_state ORDER BY resource_type`)\n\tif qerr != nil {\n\t\t// sync_state may not exist on a fresh DB that has migrated but not\n\t\t// yet had any sync runs — treat as unknown rather than error.\n\t\treport[\"status\"] = \"unknown\"\n\t\treport[\"hint\"] = \"No sync state recorded; run 'apartments-pp-cli sync' to populate.\"\n\t\treturn report\n\t}\n\tdefer rows.Close()\n\n\tvar resources []map[string]any\n\tfresh := true\n\thaveAny := false\n\toldest := time.Duration(0)\n\tfor rows.Next() {\n\t\tvar rtype string\n\t\tvar count int64\n\t\tvar lastSynced sql.NullTime\n\t\tif err := rows.Scan(&rtype, &count, &lastSynced); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tr := map[string]any{\"type\": rtype, \"rows\": count}\n\t\tif lastSynced.Valid {\n\t\t\thaveAny = true\n\t\t\tr[\"last_synced_at\"] = lastSynced.Time.UTC().Format(time.RFC3339)\n\t\t\tage := time.Since(lastSynced.Time)\n\t\t\tr[\"staleness\"] = age.Round(time.Minute).String()\n\t\t\tif age > staleAfter {\n\t\t\t\tfresh = false\n\t\t\t}\n\t\t\tif age > oldest {\n\t\t\t\toldest = age\n\t\t\t}\n\t\t} else {\n\t\t\tr[\"staleness\"] = \"never\"\n\t\t\tfresh = false\n\t\t}\n\t\tresources = append(resources, r)\n\t}\n\treport[\"resources\"] = resources\n\treport[\"stale_after\"] = staleAfter.String()\n\n\tswitch {\n\tcase !haveAny && len(resources) == 0:\n\t\treport[\"status\"] = \"unknown\"\n\t\treport[\"hint\"] = \"sync_state is empty; run 'apartments-pp-cli sync' to hydrate.\"\n\tcase fresh:\n\t\treport[\"status\"] = \"fresh\"\n\tdefault:\n\t\treport[\"status\"] = \"stale\"\n\t\treport[\"oldest_age\"] = oldest.Round(time.Minute).String()\n\t\treport[\"hint\"] = \"Some resources are older than stale_after; run 'apartments-pp-cli sync' to refresh.\"\n\t}\n\treturn report\n}\n\nfunc renderCacheReport(w io.Writer, rep map[string]any) {\n\tstatus, _ := rep[\"status\"].(string)\n\tindicator := green(\"OK\")\n\tswitch status {\n\tcase \"stale\":\n\t\tindicator = yellow(\"WARN\")\n\tcase \"error\":\n\t\tindicator = red(\"FAIL\")\n\tcase \"unknown\":\n\t\tindicator = yellow(\"INFO\")\n\t}\n\tfmt.Fprintf(w, \" %s Cache: %s\\n\", indicator, status)\n\tif v, ok := rep[\"db_path\"]; ok {\n\t\tfmt.Fprintf(w, \" db_path: %v\\n\", v)\n\t}\n\tif v, ok := rep[\"schema_version\"]; ok {\n\t\tfmt.Fprintf(w, \" schema_version: %v\\n\", v)\n\t}\n\tif v, ok := rep[\"db_bytes\"]; ok {\n\t\tfmt.Fprintf(w, \" db_bytes: %v\\n\", v)\n\t}\n\tif v, ok := rep[\"stale_after\"]; ok {\n\t\tfmt.Fprintf(w, \" stale_after: %v\\n\", v)\n\t}\n\tif v, ok := rep[\"oldest_age\"]; ok {\n\t\tfmt.Fprintf(w, \" oldest_age: %v\\n\", v)\n\t}\n\tif resourcesAny, ok := rep[\"resources\"]; ok {\n\t\tif resources, ok := resourcesAny.([]map[string]any); ok && len(resources) > 0 {\n\t\t\tfmt.Fprintf(w, \" resources:\\n\")\n\t\t\tfor _, r := range resources {\n\t\t\t\trtype, _ := r[\"type\"].(string)\n\t\t\t\trows := r[\"rows\"]\n\t\t\t\tstaleness, _ := r[\"staleness\"].(string)\n\t\t\t\tfmt.Fprintf(w, \" - %s: %v rows, %s\\n\", rtype, rows, staleness)\n\t\t\t}\n\t\t}\n\t}\n\tif hint, ok := rep[\"hint\"]; ok {\n\t\tfmt.Fprintf(w, \" hint: %v\\n\", hint)\n\t}\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":15801,"content_sha256":"af3b1e78f75622ba545dffd4cd20749252f5d8881ad874434bedbb2c0a3913e3"},{"filename":"internal/cli/export.go","content":"// Copyright 2026 rderwin and contributors. Licensed under Apache-2.0. See LICENSE.\n// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.\n\npackage cli\n\nimport (\n\t\"bufio\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"os\"\n\n\t\"github.com/spf13/cobra\"\n)\n\nfunc newExportCmd(flags *rootFlags) *cobra.Command {\n\tvar format string\n\tvar outputFile string\n\tvar limit int\n\tvar noCache bool\n\n\tcmd := &cobra.Command{\n\t\tUse: \"export \u003cresource> [id]\",\n\t\tShort: \"Export data to JSONL or JSON for backup, migration, or analysis\",\n\t\tLong: `Export paginated API data to a local file. Supports JSONL (one JSON object\nper line, streaming-friendly) and JSON (array). JSONL is recommended for\nlarge datasets as it has no memory pressure.`,\n\t\tExample: ` # Export all items as JSONL (streaming, recommended for large datasets)\n apartments-pp-cli export \u003cresource> --format jsonl --output data.jsonl\n\n # Export with limit\n apartments-pp-cli export \u003cresource> --format jsonl --limit 1000\n\n # Pipe to another tool\n apartments-pp-cli export \u003cresource> --format jsonl | jq '.id'`,\n\t\tArgs: cobra.MinimumNArgs(1),\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tc, err := flags.newClient()\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tif noCache {\n\t\t\t\tc.NoCache = true\n\t\t\t}\n\n\t\t\tresource := args[0]\n\t\t\tpath := \"/\" + resource\n\t\t\tif len(args) > 1 {\n\t\t\t\tpath += \"/\" + args[1]\n\t\t\t}\n\n\t\t\tvar writer *bufio.Writer\n\t\t\tif outputFile != \"\" {\n\t\t\t\tf, err := os.Create(outputFile)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"creating output file: %w\", err)\n\t\t\t\t}\n\t\t\t\tdefer f.Close()\n\t\t\t\twriter = bufio.NewWriter(f)\n\t\t\t\tdefer writer.Flush()\n\t\t\t} else {\n\t\t\t\twriter = bufio.NewWriter(os.Stdout)\n\t\t\t\tdefer writer.Flush()\n\t\t\t}\n\n\t\t\tdata, err := c.Get(path, nil)\n\t\t\tif err != nil {\n\t\t\t\treturn classifyAPIError(err)\n\t\t\t}\n\n\t\t\tswitch format {\n\t\t\tcase \"jsonl\":\n\t\t\t\tvar items []json.RawMessage\n\t\t\t\tif err := json.Unmarshal(data, &items); err != nil {\n\t\t\t\t\tfmt.Fprintln(writer, string(data))\n\t\t\t\t\treturn nil\n\t\t\t\t}\n\t\t\t\tcount := 0\n\t\t\t\tfor _, item := range items {\n\t\t\t\t\tif limit > 0 && count >= limit {\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t\tfmt.Fprintln(writer, string(item))\n\t\t\t\t\tcount++\n\t\t\t\t}\n\t\t\t\tif outputFile != \"\" {\n\t\t\t\t\tfmt.Fprintf(os.Stderr, \"Exported %d records to %s\\n\", count, outputFile)\n\t\t\t\t}\n\t\t\tdefault:\n\t\t\t\tenc := json.NewEncoder(writer)\n\t\t\t\tenc.SetIndent(\"\", \" \")\n\t\t\t\tvar parsed any\n\t\t\t\tif err := json.Unmarshal(data, &parsed); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t\treturn enc.Encode(parsed)\n\t\t\t}\n\t\t\treturn nil\n\t\t},\n\t}\n\n\tcmd.Flags().StringVar(&format, \"format\", \"jsonl\", \"Output format: jsonl or json\")\n\tcmd.Flags().StringVarP(&outputFile, \"output\", \"o\", \"\", \"Output file path (default: stdout)\")\n\tcmd.Flags().IntVar(&limit, \"limit\", 0, \"Maximum records to export (0 = unlimited)\")\n\tcmd.Flags().BoolVar(&noCache, \"no-cache\", false, \"Bypass response cache for fresh data\")\n\n\treturn cmd\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":2867,"content_sha256":"fb38738423d3dba87660ab7c37d95ba722d061335711db016d544e7fc2dd8521"},{"filename":"internal/cli/feedback.go","content":"// Copyright 2026 rderwin and contributors. Licensed under Apache-2.0. See LICENSE.\n// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.\n\npackage cli\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/spf13/cobra\"\n)\n\n// FeedbackEntry is one line in the local feedback ledger. Every run of\n// the feedback command appends one entry; upstream POST is a separate,\n// optional step.\ntype FeedbackEntry struct {\n\tText string `json:\"text\"`\n\tCLI string `json:\"cli\"`\n\tVersion string `json:\"version\"`\n\tAgentID string `json:\"agent_id,omitempty\"`\n\tTimestamp time.Time `json:\"timestamp\"`\n}\n\nconst feedbackMaxTextLen = 4096\n\nfunc feedbackFilePath() (string, error) {\n\thome, err := os.UserHomeDir()\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"resolving home dir: %w\", err)\n\t}\n\tdir := filepath.Join(home, \".apartments-pp-cli\")\n\tif err := os.MkdirAll(dir, 0o700); err != nil {\n\t\treturn \"\", fmt.Errorf(\"creating state dir: %w\", err)\n\t}\n\treturn filepath.Join(dir, \"feedback.jsonl\"), nil\n}\n\n// FeedbackEndpointConfigured reports whether an upstream feedback URL\n// is available. Surfaced via agent-context so introspecting agents know\n// whether their feedback will ship upstream.\nfunc FeedbackEndpointConfigured() bool {\n\treturn os.Getenv(\"APARTMENTS_FEEDBACK_ENDPOINT\") != \"\"\n}\n\nfunc feedbackEndpoint() string {\n\treturn os.Getenv(\"APARTMENTS_FEEDBACK_ENDPOINT\")\n}\n\nfunc feedbackAutoSend() bool {\n\tv := strings.ToLower(strings.TrimSpace(os.Getenv(\"APARTMENTS_FEEDBACK_AUTO_SEND\")))\n\treturn v == \"1\" || v == \"true\" || v == \"yes\"\n}\n\nfunc appendFeedback(entry FeedbackEntry) error {\n\tp, err := feedbackFilePath()\n\tif err != nil {\n\t\treturn err\n\t}\n\tf, err := os.OpenFile(p, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"opening feedback ledger: %w\", err)\n\t}\n\tdefer f.Close()\n\treturn json.NewEncoder(f).Encode(entry)\n}\n\nfunc postFeedback(url string, entry FeedbackEntry) error {\n\tbody, err := json.Marshal(entry)\n\tif err != nil {\n\t\treturn err\n\t}\n\treq, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))\n\tif err != nil {\n\t\treturn fmt.Errorf(\"building feedback request: %w\", err)\n\t}\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\treq.Header.Set(\"User-Agent\", \"apartments-pp-cli/feedback\")\n\tclient := &http.Client{Timeout: 15 * time.Second}\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"posting feedback: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\tif resp.StatusCode >= 400 {\n\t\treturn fmt.Errorf(\"feedback endpoint returned %s\", resp.Status)\n\t}\n\treturn nil\n}\n\nfunc newFeedbackCmd(flags *rootFlags) *cobra.Command {\n\tvar useStdin bool\n\tvar send bool\n\tcmd := &cobra.Command{\n\t\tUse: \"feedback [text]\",\n\t\tShort: \"Record feedback about this CLI (local by default; upstream opt-in)\",\n\t\tLong: `Feedback is captured locally first at ~/.apartments-pp-cli/feedback.jsonl.\nWhen ` + \"`APARTMENTS_FEEDBACK_ENDPOINT`\" + ` is set and either --send is\npassed or ` + \"`APARTMENTS_FEEDBACK_AUTO_SEND=true`\" + `, the entry is\nPOSTed as JSON after the local write.\n\nWrite what surprised you or tripped you up, not a bug report. The\nloop is: agent notices friction -> one invocation -> captured -> the\nmaintainer sees it.`,\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tvar text string\n\t\t\tif useStdin {\n\t\t\t\tdata, err := io.ReadAll(cmd.InOrStdin())\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"reading stdin: %w\", err)\n\t\t\t\t}\n\t\t\t\ttext = strings.TrimSpace(string(data))\n\t\t\t} else if len(args) > 0 {\n\t\t\t\ttext = strings.Join(args, \" \")\n\t\t\t}\n\t\t\ttext = strings.TrimSpace(text)\n\t\t\tif text == \"\" {\n\t\t\t\treturn fmt.Errorf(\"feedback text is empty (pass arguments or --stdin)\")\n\t\t\t}\n\t\t\ttruncated := false\n\t\t\tif len(text) > feedbackMaxTextLen {\n\t\t\t\ttext = text[:feedbackMaxTextLen]\n\t\t\t\ttruncated = true\n\t\t\t}\n\n\t\t\tentry := FeedbackEntry{\n\t\t\t\tText: text,\n\t\t\t\tCLI: \"apartments-pp-cli\",\n\t\t\t\tVersion: version,\n\t\t\t\tAgentID: os.Getenv(\"AGENT_ID\"),\n\t\t\t\tTimestamp: time.Now().UTC(),\n\t\t\t}\n\t\t\tif err := appendFeedback(entry); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tupstreamResult := map[string]any{\"sent\": false}\n\t\t\tif endpoint := feedbackEndpoint(); endpoint != \"\" && (send || feedbackAutoSend()) {\n\t\t\t\tif err := postFeedback(endpoint, entry); err != nil {\n\t\t\t\t\tfmt.Fprintf(cmd.ErrOrStderr(), \"warning: feedback upstream POST failed: %v\\n\", err)\n\t\t\t\t\tupstreamResult[\"sent\"] = false\n\t\t\t\t\tupstreamResult[\"error\"] = err.Error()\n\t\t\t\t} else {\n\t\t\t\t\tupstreamResult[\"sent\"] = true\n\t\t\t\t\tupstreamResult[\"endpoint\"] = endpoint\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif flags.asJSON {\n\t\t\t\treturn printJSONFiltered(cmd.OutOrStdout(), map[string]any{\n\t\t\t\t\t\"recorded\": true,\n\t\t\t\t\t\"truncated\": truncated,\n\t\t\t\t\t\"upstream\": upstreamResult,\n\t\t\t\t\t\"entry\": entry,\n\t\t\t\t}, flags)\n\t\t\t}\n\t\t\tfmt.Fprintf(cmd.OutOrStdout(), \"feedback recorded locally (%d chars%s)\\n\", len(text), func() string {\n\t\t\t\tif truncated {\n\t\t\t\t\treturn \", truncated\"\n\t\t\t\t}\n\t\t\t\treturn \"\"\n\t\t\t}())\n\t\t\tif sent, _ := upstreamResult[\"sent\"].(bool); sent {\n\t\t\t\tfmt.Fprintf(cmd.OutOrStdout(), \"upstream POST: %v\\n\", upstreamResult[\"endpoint\"])\n\t\t\t}\n\t\t\treturn nil\n\t\t},\n\t}\n\tcmd.Flags().BoolVar(&useStdin, \"stdin\", false, \"Read feedback body from stdin rather than arguments\")\n\tcmd.Flags().BoolVar(&send, \"send\", false, \"POST to the configured feedback endpoint in addition to local write\")\n\n\tcmd.AddCommand(newFeedbackListCmd(flags))\n\treturn cmd\n}\n\nfunc newFeedbackListCmd(flags *rootFlags) *cobra.Command {\n\tvar limit int\n\tcmd := &cobra.Command{\n\t\tUse: \"list\",\n\t\tShort: \"List recent feedback entries\",\n\t\tExample: ` apartments-pp-cli feedback list\n apartments-pp-cli feedback list --limit 5\n apartments-pp-cli feedback list --json`,\n\t\tRunE: func(cmd *cobra.Command, _ []string) error {\n\t\t\tp, err := feedbackFilePath()\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tdata, err := os.ReadFile(p)\n\t\t\tif err != nil {\n\t\t\t\tif os.IsNotExist(err) {\n\t\t\t\t\tif flags.asJSON {\n\t\t\t\t\t\treturn printJSONFiltered(cmd.OutOrStdout(), []FeedbackEntry{}, flags)\n\t\t\t\t\t}\n\t\t\t\t\treturn nil\n\t\t\t\t}\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tvar entries []FeedbackEntry\n\t\t\tfor _, line := range strings.Split(string(data), \"\\n\") {\n\t\t\t\tline = strings.TrimSpace(line)\n\t\t\t\tif line == \"\" {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tvar e FeedbackEntry\n\t\t\t\tif err := json.Unmarshal([]byte(line), &e); err != nil {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tentries = append(entries, e)\n\t\t\t}\n\t\t\tif limit > 0 && limit \u003c len(entries) {\n\t\t\t\tentries = entries[len(entries)-limit:]\n\t\t\t}\n\t\t\treturn printJSONFiltered(cmd.OutOrStdout(), entries, flags)\n\t\t},\n\t}\n\tcmd.Flags().IntVar(&limit, \"limit\", 20, \"Maximum number of recent entries to return\")\n\treturn cmd\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":6642,"content_sha256":"57825edfe28a8722f2f0a68db4ed2ee63ec0a005663eb2b22948e4765be46adb"},{"filename":"internal/cli/helpers.go","content":"// Copyright 2026 rderwin and contributors. Licensed under Apache-2.0. See LICENSE.\n// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.\n\npackage cli\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"github.com/spf13/cobra\"\n\t\"github.com/spf13/pflag\"\n\t\"io\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"sort\"\n\t\"strings\"\n\t\"text/tabwriter\"\n\t\"time\"\n\t\"unicode\"\n)\n\nvar As = errors.As\n\n// noColor is set by the --no-color flag\nvar noColor bool\n\n// humanFriendly is set by the --human-friendly flag; colors are off by default (agent-safe)\nvar humanFriendly bool\n\nfunc colorEnabled() bool {\n\tif noColor {\n\t\treturn false\n\t}\n\tif !humanFriendly {\n\t\treturn false\n\t}\n\tif os.Getenv(\"NO_COLOR\") != \"\" {\n\t\treturn false\n\t}\n\tif os.Getenv(\"TERM\") == \"dumb\" {\n\t\treturn false\n\t}\n\treturn isTerminal(os.Stdout)\n}\n\nfunc isTerminal(w io.Writer) bool {\n\tif f, ok := w.(*os.File); ok {\n\t\tfi, err := f.Stat()\n\t\tif err != nil {\n\t\t\treturn true\n\t\t}\n\t\treturn (fi.Mode() & os.ModeCharDevice) != 0\n\t}\n\treturn false\n}\n\nfunc bold(s string) string {\n\tif !colorEnabled() {\n\t\treturn s\n\t}\n\treturn \"\\033[1m\" + s + \"\\033[0m\"\n}\n\nfunc green(s string) string {\n\tif !colorEnabled() {\n\t\treturn s\n\t}\n\treturn \"\\033[32m\" + s + \"\\033[0m\"\n}\n\nfunc red(s string) string {\n\tif !colorEnabled() {\n\t\treturn s\n\t}\n\treturn \"\\033[31m\" + s + \"\\033[0m\"\n}\n\nfunc yellow(s string) string {\n\tif !colorEnabled() {\n\t\treturn s\n\t}\n\treturn \"\\033[33m\" + s + \"\\033[0m\"\n}\n\ntype cliError struct {\n\tcode int\n\terr error\n}\n\nfunc (e *cliError) Error() string { return e.err.Error() }\nfunc (e *cliError) Unwrap() error { return e.err }\n\nfunc usageErr(err error) error { return &cliError{code: 2, err: err} }\nfunc notFoundErr(err error) error { return &cliError{code: 3, err: err} }\nfunc authErr(err error) error { return &cliError{code: 4, err: err} }\nfunc apiErr(err error) error { return &cliError{code: 5, err: err} }\nfunc configErr(err error) error { return &cliError{code: 10, err: err} }\nfunc rateLimitErr(err error) error { return &cliError{code: 7, err: err} }\n\n// dryRunOK reports whether the command should short-circuit without doing any\n// real work because --dry-run was set. The verify pipeline probes hand-written\n// commands with --dry-run; commands that put validation in cobra's `Args:` or\n// `MarkFlagRequired` cannot reach a dry-run guard inside RunE because cobra\n// runs those checks before RunE. The verify-friendly pattern for hand-written\n// commands is:\n//\n//\tRunE: func(cmd *cobra.Command, args []string) error {\n//\t if len(args) == 0 {\n//\t return cmd.Help()\n//\t }\n//\t if dryRunOK(flags) {\n//\t return nil\n//\t }\n//\t // ... real work ...\n//\t}\n//\n// See SKILL.md \"Phase 3: Build The GOAT\" for the full pattern.\nfunc dryRunOK(flags *rootFlags) bool {\n\treturn flags != nil && flags.dryRun\n}\n\n// accessWarning describes an API access-denial that sync converts into a\n// non-fatal warning. It carries enough structured data for the sync_warning\n// JSON event without parsing free-form error strings downstream.\ntype accessWarning struct {\n\tStatus int // HTTP status when applicable; 0 for GraphQL field-level denials.\n\tReason string // \"forbidden\" | \"insufficient_access\" | \"unauthenticated\"\n\tMessage string // human-readable detail (the API's body or GraphQL error message)\n}\n\n// isSyncAccessWarning is a stub: this CLI has no auth, so the API cannot\n// reject sync requests on access-policy grounds. Every error stays a hard\n// failure. Defining the function unconditionally keeps sync.go agnostic to\n// auth presence.\nfunc isSyncAccessWarning(err error) (*accessWarning, bool) { return nil, false }\n\n// classifyAPIError maps API errors to structured exit codes with actionable hints.\nfunc classifyAPIError(err error) error {\n\tmsg := err.Error()\n\tswitch {\n\tcase strings.Contains(msg, \"HTTP 409\"):\n\t\t// 409 Conflict = resource already exists. For agents retrying creates, this is success.\n\t\tfmt.Fprintln(os.Stderr, \"already exists (no-op)\")\n\t\treturn nil\n\tcase strings.Contains(msg, \"HTTP 401\"):\n\t\treturn authErr(fmt.Errorf(\"%w\\nhint: check your API credentials.\"+\n\t\t\t\"\\n Run 'apartments-pp-cli doctor' to check auth status.\", err))\n\tcase strings.Contains(msg, \"HTTP 403\"):\n\t\t// apartments.com uses Akamai bot protection. 403 here means the\n\t\t// request was bot-flagged, not that auth is missing (this CLI has\n\t\t// no auth). Surf clears most search pages but listing detail\n\t\t// pages and rate-limited search calls can still fail.\n\t\treturn apiErr(fmt.Errorf(\"%w\\nhint: apartments.com bot protection (Akamai) rejected this request. This is not an auth problem — this CLI has no authentication.\"+\n\t\t\t\"\\n Listing detail pages (/\u003cproperty-slug>/) are stricter than search pages.\"+\n\t\t\t\"\\n For listing details, the `listing` command falls back to local-store snapshots from a prior `rentals`/`sync-search`.\"+\n\t\t\t\"\\n For rentals/search 403s, wait 30-60s and retry; Akamai's threshold resets quickly.\"+\n\t\t\t\"\\n Run 'apartments-pp-cli doctor' to recheck reachability.\", err))\n\tcase strings.Contains(msg, \"HTTP 404\"):\n\t\treturn notFoundErr(fmt.Errorf(\"%w\\nhint: resource not found. Run the 'list' command to see available items\", err))\n\tcase strings.Contains(msg, \"HTTP 429\"):\n\t\treturn rateLimitErr(err)\n\tdefault:\n\t\treturn apiErr(err)\n\t}\n}\n\nfunc truncate(s string, max int) string {\n\tif len(s) \u003c= max {\n\t\treturn s\n\t}\n\tif max \u003c= 3 {\n\t\treturn s[:max]\n\t}\n\treturn s[:max-3] + \"...\"\n}\n\nfunc newTabWriter(w io.Writer) *tabwriter.Writer {\n\treturn tabwriter.NewWriter(w, 2, 4, 2, ' ', 0)\n}\n\n// paginatedGet fetches pages and concatenates array results. The headers\n// argument carries per-endpoint required headers (e.g. cal-api-version) that\n// must be sent on every page request, including the first; pass nil when the\n// endpoint has no per-endpoint header overrides.\nfunc paginatedGet(c interface {\n\tGetWithHeaders(path string, params map[string]string, headers map[string]string) (json.RawMessage, error)\n}, path string, params map[string]string, headers map[string]string, fetchAll bool, cursorParam, nextCursorPath, hasMoreField string) (json.RawMessage, error) {\n\t// Clean zero-value params\n\tclean := map[string]string{}\n\tfor k, v := range params {\n\t\tif v != \"\" && v != \"0\" && v != \"false\" {\n\t\t\tclean[k] = v\n\t\t}\n\t}\n\n\tif !fetchAll {\n\t\treturn c.GetWithHeaders(path, clean, headers)\n\t}\n\n\t// Fetch all pages\n\tvar allItems []json.RawMessage\n\tpage := 0\n\tfor {\n\t\tpage++\n\t\tif humanFriendly {\n\t\t\tfmt.Fprintf(os.Stderr, \"fetching page %d...\\n\", page)\n\t\t} else {\n\t\t\tfmt.Fprintf(os.Stderr, `{\"event\":\"page_fetch\",\"page\":%d}`+\"\\n\", page)\n\t\t}\n\n\t\tdata, err := c.GetWithHeaders(path, clean, headers)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\t// Try to extract items array\n\t\tvar items []json.RawMessage\n\t\tif json.Unmarshal(data, &items) == nil {\n\t\t\tallItems = append(allItems, items...)\n\t\t} else {\n\t\t\t// Response is an object - look for array inside\n\t\t\tvar obj map[string]json.RawMessage\n\t\t\tif json.Unmarshal(data, &obj) == nil {\n\t\t\t\t// Try common data fields\n\t\t\t\tfor _, field := range []string{\"data\", \"items\", \"results\", \"messages\", \"members\", \"values\"} {\n\t\t\t\t\tif arr, ok := obj[field]; ok {\n\t\t\t\t\t\tvar nested []json.RawMessage\n\t\t\t\t\t\tif json.Unmarshal(arr, &nested) == nil {\n\t\t\t\t\t\t\tallItems = append(allItems, nested...)\n\t\t\t\t\t\t\tbreak\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\t// Check for next cursor\n\t\t\t\tif nextCursorPath != \"\" {\n\t\t\t\t\tif tokenRaw, ok := obj[nextCursorPath]; ok {\n\t\t\t\t\t\tvar token string\n\t\t\t\t\t\tif json.Unmarshal(tokenRaw, &token) == nil && token != \"\" {\n\t\t\t\t\t\t\tclean[cursorParam] = token\n\t\t\t\t\t\t\tcontinue\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\t// Check has_more\n\t\t\t\tif hasMoreField != \"\" {\n\t\t\t\t\tif moreRaw, ok := obj[hasMoreField]; ok {\n\t\t\t\t\t\tvar more bool\n\t\t\t\t\t\tif json.Unmarshal(moreRaw, &more) == nil && more {\n\t\t\t\t\t\t\tcontinue\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\t// No more pages\n\t\t\tbreak\n\t\t}\n\n\t\t// For direct arrays, can't paginate without cursor\n\t\tbreak\n\t}\n\n\tif humanFriendly {\n\t\tfmt.Fprintf(os.Stderr, \"fetched %d items across %d pages\\n\", len(allItems), page)\n\t} else {\n\t\tfmt.Fprintf(os.Stderr, `{\"event\":\"complete\",\"total\":%d,\"pages\":%d}`+\"\\n\", len(allItems), page)\n\t}\n\tresult, _ := json.Marshal(allItems)\n\treturn json.RawMessage(result), nil\n}\n\n// printJSONFiltered marshals a Go-typed value through the same output\n// pipeline endpoint-mirror commands use. Hand-written novel commands that\n// build a typed slice/struct call this so --select, --compact, --csv, and\n// --quiet all behave the same way as on generator-emitted commands.\nfunc printJSONFiltered(w io.Writer, v any, flags *rootFlags) error {\n\traw, err := json.Marshal(v)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn printOutputWithFlags(w, json.RawMessage(raw), flags)\n}\n\n// filterFields keeps only the specified fields (comma-separated) from JSON objects/arrays.\n// Supports dotted paths like \"events.shortName\" to descend into nested structures.\n// Arrays are traversed element-wise: \"events.shortName\" keeps shortName on each event.\nfunc filterFields(data json.RawMessage, fields string) json.RawMessage {\n\tvar paths [][]string\n\tfor _, f := range strings.Split(fields, \",\") {\n\t\tf = strings.TrimSpace(f)\n\t\tif f == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tparts := strings.Split(f, \".\")\n\t\tfor i := range parts {\n\t\t\tparts[i] = strings.ToLower(parts[i])\n\t\t}\n\t\tpaths = append(paths, parts)\n\t}\n\tif len(paths) == 0 {\n\t\treturn data\n\t}\n\treturn filterFieldsRec(data, paths)\n}\n\n// filterFieldsRec applies path filters to a JSON value. Each path is a list of\n// lowercase segments; arrays descend element-wise.\nfunc filterFieldsRec(data json.RawMessage, paths [][]string) json.RawMessage {\n\tvar arr []json.RawMessage\n\tif err := json.Unmarshal(data, &arr); err == nil {\n\t\tout := make([]json.RawMessage, len(arr))\n\t\tfor i, el := range arr {\n\t\t\tout[i] = filterFieldsRec(el, paths)\n\t\t}\n\t\tresult, _ := json.Marshal(out)\n\t\treturn result\n\t}\n\n\tvar obj map[string]json.RawMessage\n\tif err := json.Unmarshal(data, &obj); err == nil {\n\t\tkeepWhole := map[string]bool{}\n\t\tsubPaths := map[string][][]string{}\n\t\tfor _, p := range paths {\n\t\t\tif len(p) == 0 {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\thead := p[0]\n\t\t\tif len(p) == 1 {\n\t\t\t\tkeepWhole[head] = true\n\t\t\t} else {\n\t\t\t\tsubPaths[head] = append(subPaths[head], p[1:])\n\t\t\t}\n\t\t}\n\t\tfiltered := map[string]json.RawMessage{}\n\t\tfor k, v := range obj {\n\t\t\tmatched := matchSelectSegment(k, keepWhole, subPaths)\n\t\t\tif matched == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif keepWhole[matched] {\n\t\t\t\tfiltered[k] = v\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif subs := subPaths[matched]; subs != nil {\n\t\t\t\tfiltered[k] = filterFieldsRec(v, subs)\n\t\t\t}\n\t\t}\n\t\tresult, _ := json.Marshal(filtered)\n\t\treturn result\n\t}\n\n\treturn data\n}\n\n// matchSelectSegment returns the matching lowercase segment, or \"\" if no match.\n// Supports direct case-insensitive match and camelCase→kebab-case conversion.\nfunc matchSelectSegment(fieldName string, keepWhole map[string]bool, subPaths map[string][][]string) string {\n\tlower := strings.ToLower(fieldName)\n\tif keepWhole[lower] || subPaths[lower] != nil {\n\t\treturn lower\n\t}\n\tkebab := camelToKebab(fieldName)\n\tif kebab != lower && (keepWhole[kebab] || subPaths[kebab] != nil) {\n\t\treturn kebab\n\t}\n\treturn \"\"\n}\n\n// camelToKebab converts \"orderDate\" or \"orderdate\" to \"order-date\" by splitting on\n// uppercase boundaries. For already-lowercase input, splits on known word boundaries.\nfunc camelToKebab(s string) string {\n\tvar b strings.Builder\n\trunes := []rune(s)\n\tfor i, r := range runes {\n\t\tif i > 0 && unicode.IsUpper(r) && unicode.IsLower(runes[i-1]) {\n\t\t\tb.WriteByte('-')\n\t\t}\n\t\tb.WriteRune(unicode.ToLower(r))\n\t}\n\treturn b.String()\n}\n\n// printOutputWithFlags routes output through the right format based on flags.\nfunc printOutputWithFlags(w io.Writer, data json.RawMessage, flags *rootFlags) error {\n\t// --select wins over --compact when both are set: an explicit field list\n\t// is the user's authoritative request, so the high-gravity allow-list\n\t// must not strip those fields out before --select can pick them. When\n\t// only --compact is set (e.g., --agent without --select), the allow-list\n\t// still runs.\n\tif flags.selectFields != \"\" {\n\t\tdata = filterFields(data, flags.selectFields)\n\t} else if flags.compact {\n\t\tdata = compactFields(data)\n\t}\n\t// --quiet: suppress all output, exit code communicates result\n\tif flags.quiet {\n\t\treturn nil\n\t}\n\t// --csv: render as CSV\n\tif flags.csv {\n\t\treturn printCSV(w, data)\n\t}\n\t// --plain: render as tab-separated values for pipe-friendly consumption\n\tif flags.plain {\n\t\treturn printPlain(w, data)\n\t}\n\treturn printOutput(w, data, flags.asJSON)\n}\n\n// printPlain renders JSON arrays as tab-separated values (TSV) with a header\n// row. For non-array payloads it falls back to plain JSON. TSV is friendlier\n// than CSV for shell pipelines (cut, awk) because tab characters never appear\n// inside typical scalar values.\nfunc printPlain(w io.Writer, data json.RawMessage) error {\n\tvar items []map[string]any\n\tif err := json.Unmarshal(data, &items); err != nil || len(items) == 0 {\n\t\tfmt.Fprintln(w, string(data))\n\t\treturn nil\n\t}\n\tkeySet := map[string]bool{}\n\tfor _, item := range items {\n\t\tfor k := range item {\n\t\t\tkeySet[k] = true\n\t\t}\n\t}\n\tvar keys []string\n\tfor k := range keySet {\n\t\tkeys = append(keys, k)\n\t}\n\tsort.Strings(keys)\n\tfmt.Fprintln(w, strings.Join(keys, \"\\t\"))\n\tfor _, item := range items {\n\t\tvar vals []string\n\t\tfor _, k := range keys {\n\t\t\tv := item[k]\n\t\t\tif v == nil {\n\t\t\t\tvals = append(vals, \"\")\n\t\t\t} else {\n\t\t\t\ts := fmt.Sprintf(\"%v\", v)\n\t\t\t\t// Tabs and newlines break TSV; replace with spaces.\n\t\t\t\ts = strings.ReplaceAll(s, \"\\t\", \" \")\n\t\t\t\ts = strings.ReplaceAll(s, \"\\n\", \" \")\n\t\t\t\tvals = append(vals, s)\n\t\t\t}\n\t\t}\n\t\tfmt.Fprintln(w, strings.Join(vals, \"\\t\"))\n\t}\n\treturn nil\n}\n\n// compactFields keeps only the most important fields for agent consumption.\n// For arrays: allowlist of high-gravity fields (no descriptions).\n// For single objects: blocklist that strips known-verbose fields (descriptions, comments, etc.).\nfunc compactFields(data json.RawMessage) json.RawMessage {\n\t// Try array first\n\tvar items []map[string]any\n\tif err := json.Unmarshal(data, &items); err == nil {\n\t\treturn compactListFields(items)\n\t}\n\n\t// Single object — use blocklist\n\tvar obj map[string]any\n\tif err := json.Unmarshal(data, &obj); err == nil {\n\t\treturn compactObjectFields(obj)\n\t}\n\n\treturn data\n}\n\n// compactListFields keeps only high-gravity fields for array responses.\nfunc compactListFields(items []map[string]any) json.RawMessage {\n\tkeepFields := map[string]bool{\n\t\t\"id\": true, \"name\": true, \"title\": true, \"identifier\": true,\n\t\t\"status\": true, \"state\": true, \"type\": true, \"priority\": true,\n\t\t\"url\": true, \"email\": true, \"key\": true,\n\t\t\"created_at\": true, \"updated_at\": true, \"createdAt\": true, \"updatedAt\": true,\n\t}\n\n\tfiltered := make([]map[string]any, 0, len(items))\n\tfor _, item := range items {\n\t\tcompact := map[string]any{}\n\t\tfor k, v := range item {\n\t\t\tif keepFields[k] {\n\t\t\t\tcompact[k] = v\n\t\t\t}\n\t\t}\n\t\tfiltered = append(filtered, compact)\n\t}\n\tresult, _ := json.Marshal(filtered)\n\treturn result\n}\n\n// compactObjectFields strips known-verbose fields from single-object responses.\n// Uses a blocklist so it works across all API domains (project management, payments, CRM, etc.).\nfunc compactObjectFields(obj map[string]any) json.RawMessage {\n\tstripFields := map[string]bool{\n\t\t\"description\": true, \"body\": true, \"content\": true,\n\t\t\"comments\": true, \"attachments\": true, \"html\": true, \"markdown\": true,\n\t}\n\n\tcompact := map[string]any{}\n\tfor k, v := range obj {\n\t\tif !stripFields[k] {\n\t\t\tcompact[k] = v\n\t\t}\n\t}\n\tresult, _ := json.Marshal(compact)\n\treturn result\n}\n\n// printCSV renders JSON arrays as CSV with header row.\nfunc printCSV(w io.Writer, data json.RawMessage) error {\n\tvar items []map[string]any\n\tif err := json.Unmarshal(data, &items); err != nil || len(items) == 0 {\n\t\t// Single object or empty - just print as JSON\n\t\tfmt.Fprintln(w, string(data))\n\t\treturn nil\n\t}\n\t// Collect all keys for header\n\tkeySet := map[string]bool{}\n\tfor _, item := range items {\n\t\tfor k := range item {\n\t\t\tkeySet[k] = true\n\t\t}\n\t}\n\tvar keys []string\n\tfor k := range keySet {\n\t\tkeys = append(keys, k)\n\t}\n\tsort.Strings(keys)\n\t// Header\n\tfmt.Fprintln(w, strings.Join(keys, \",\"))\n\t// Rows\n\tfor _, item := range items {\n\t\tvar vals []string\n\t\tfor _, k := range keys {\n\t\t\tv := item[k]\n\t\t\tif v == nil {\n\t\t\t\tvals = append(vals, \"\")\n\t\t\t} else {\n\t\t\t\ts := fmt.Sprintf(\"%v\", v)\n\t\t\t\tif strings.ContainsAny(s, \",\\\"\\n\") {\n\t\t\t\t\ts = `\"` + strings.ReplaceAll(s, `\"`, `\"\"`) + `\"`\n\t\t\t\t}\n\t\t\t\tvals = append(vals, s)\n\t\t\t}\n\t\t}\n\t\tfmt.Fprintln(w, strings.Join(vals, \",\"))\n\t}\n\treturn nil\n}\n\n// printOutput auto-detects arrays and renders as tables, or prints raw JSON for objects.\nfunc printOutput(w io.Writer, data json.RawMessage, asJSON bool) error {\n\tif !asJSON && !isTerminal(w) {\n\t\tasJSON = true\n\t}\n\n\tif asJSON {\n\t\tenc := json.NewEncoder(w)\n\t\tenc.SetIndent(\"\", \" \")\n\t\treturn enc.Encode(data)\n\t}\n\n\t// Try to detect if response is an array\n\tvar items []map[string]any\n\tif err := json.Unmarshal(data, &items); err == nil && len(items) > 0 {\n\t\tif err := printAutoTable(w, items); err != nil {\n\t\t\treturn err\n\t\t}\n\t\t// Agent-friendly: show count and suggest narrowing when results are large\n\t\tif len(items) >= 25 {\n\t\t\tfmt.Fprintf(os.Stderr, \"\\nShowing %d results. To narrow: add --limit, --json --select, or filter flags.\\n\", len(items))\n\t\t}\n\t\treturn nil\n\t}\n\n\t// Single object - pretty print\n\tvar obj map[string]any\n\tif err := json.Unmarshal(data, &obj); err == nil {\n\t\tenc := json.NewEncoder(w)\n\t\tenc.SetIndent(\"\", \" \")\n\t\treturn enc.Encode(obj)\n\t}\n\n\t// Fallback: print raw\n\tfmt.Fprintln(w, string(data))\n\treturn nil\n}\n\n// levenshteinDistance computes the edit distance between two strings using a two-row DP approach.\nfunc levenshteinDistance(a, b string) int {\n\tif len(a) == 0 {\n\t\treturn len(b)\n\t}\n\tif len(b) == 0 {\n\t\treturn len(a)\n\t}\n\tif len(a) \u003c len(b) {\n\t\ta, b = b, a\n\t}\n\tprev := make([]int, len(b)+1)\n\tcurr := make([]int, len(b)+1)\n\tfor j := range prev {\n\t\tprev[j] = j\n\t}\n\tfor i := 1; i \u003c= len(a); i++ {\n\t\tcurr[0] = i\n\t\tfor j := 1; j \u003c= len(b); j++ {\n\t\t\tcost := 1\n\t\t\tif a[i-1] == b[j-1] {\n\t\t\t\tcost = 0\n\t\t\t}\n\t\t\tins := curr[j-1] + 1\n\t\t\tdel := prev[j] + 1\n\t\t\tsub := prev[j-1] + cost\n\t\t\tmin := ins\n\t\t\tif del \u003c min {\n\t\t\t\tmin = del\n\t\t\t}\n\t\t\tif sub \u003c min {\n\t\t\t\tmin = sub\n\t\t\t}\n\t\t\tcurr[j] = min\n\t\t}\n\t\tprev, curr = curr, prev\n\t}\n\treturn prev[len(b)]\n}\n\n// suggestFlag returns the closest known flag name to the unknown string, or \"\" if none is close enough.\nfunc suggestFlag(unknown string, cmd *cobra.Command) string {\n\tunknown = strings.TrimLeft(unknown, \"-\")\n\tbest := \"\"\n\tbestDist := 4 // only consider distance \u003c= 3\n\tcheck := func(name string) {\n\t\td := levenshteinDistance(unknown, name)\n\t\tif d \u003c bestDist && d*5 \u003c= len(unknown)*2 {\n\t\t\tbestDist = d\n\t\t\tbest = name\n\t\t}\n\t}\n\tcmd.Flags().VisitAll(func(f *pflag.Flag) {\n\t\tcheck(f.Name)\n\t})\n\tcmd.InheritedFlags().VisitAll(func(f *pflag.Flag) {\n\t\tcheck(f.Name)\n\t})\n\treturn best\n}\n\nfunc printAutoTable(w io.Writer, items []map[string]any) error {\n\tif len(items) == 0 {\n\t\treturn nil\n\t}\n\n\t// Count scalar vs complex fields to decide format\n\tscalarCount := 0\n\tfor _, v := range items[0] {\n\t\tswitch v.(type) {\n\t\tcase string, float64, bool, nil:\n\t\t\tscalarCount++\n\t\t}\n\t}\n\n\t// Use sectional/card layout for complex items (many fields or nested data)\n\tif len(items[0]) > 8 || scalarCount \u003c len(items[0])-2 {\n\t\treturn printAutoCards(w, items)\n\t}\n\n\theaders := prioritizeHeaders(items[0])\n\n\t// Limit to 6 columns max for readability\n\tif len(headers) > 6 {\n\t\theaders = headers[:6]\n\t}\n\n\t// Build rows\n\trows := make([][]string, 0, len(items))\n\tfor _, item := range items {\n\t\trow := make([]string, len(headers))\n\t\tfor i, h := range headers {\n\t\t\trow[i] = formatCellValue(item[h])\n\t\t}\n\t\trows = append(rows, row)\n\t}\n\n\t// Print with tab alignment using tabwriter\n\ttw := newTabWriter(w)\n\tupperHeaders := make([]string, len(headers))\n\tfor i, h := range headers {\n\t\tupperHeaders[i] = bold(strings.ToUpper(h))\n\t}\n\n\tfmt.Fprintln(tw, strings.Join(upperHeaders, \"\\t\"))\n\tfor _, row := range rows {\n\t\tfmt.Fprintln(tw, strings.Join(row, \"\\t\"))\n\t}\n\treturn tw.Flush()\n}\n\n// prioritizeHeaders orders scalar fields by importance for table display.\nfunc prioritizeHeaders(item map[string]any) []string {\n\treturn prioritizeFields(item, false)\n}\n\n// prioritizeAllHeaders orders all fields (including arrays) by importance for card display.\nfunc prioritizeAllHeaders(item map[string]any) []string {\n\treturn prioritizeFields(item, true)\n}\n\n// prioritizeFields orders fields by importance: identity → temporal → status → other.\n// When includeComplex is true, arrays and objects are included (for card layout).\n//\n// Uses exact-or-suffix matching to avoid false positives: \"name\" matches \"Name\" and\n// \"UserName\" but not \"BuildingName\" (because \"Building\" is not a known prefix that\n// indicates identity). The field is split on camelCase/snake_case boundaries and the\n// LAST segment is matched against patterns.\nfunc prioritizeFields(item map[string]any, includeComplex bool) []string {\n\t// Priority tiers — matched against the last segment of the field name.\n\t// \"OrderDate\" → last segment \"date\" → tier 1 (temporal).\n\t// \"BuildingName\" → last segment \"name\" → tier 0... but we want to avoid this.\n\t// Solution: exact match on the full lowered name OR suffix segment match,\n\t// with a penalty for compound names that have a non-identity prefix.\n\ttype pattern struct {\n\t\tword string\n\t\ttier int\n\t}\n\t// Exact matches (full field name, case-insensitive) — highest confidence\n\texactMatches := map[string]int{\n\t\t\"id\": 0, \"name\": 0, \"title\": 0, \"slug\": 0, \"key\": 0,\n\t\t\"date\": 1, \"created\": 1, \"updated\": 1, \"createdat\": 1, \"updatedat\": 1,\n\t\t\"status\": 2, \"state\": 2, \"statuscode\": 2,\n\t\t\"summary\": 3, \"description\": 3, \"price\": 3, \"amount\": 3, \"total\": 3,\n\t\t\"cost\": 3, \"points\": 3, \"score\": 3,\n\t\t\"type\": 4, \"kind\": 4, \"category\": 4, \"email\": 4, \"phone\": 4, \"url\": 4,\n\t}\n\t// Suffix patterns — match when the field ends with this word (after splitting)\n\tsuffixMatches := map[string]int{\n\t\t\"id\": 0, \"name\": 0, \"title\": 0,\n\t\t\"date\": 1, \"time\": 1,\n\t\t\"status\": 2, \"state\": 2, \"code\": 2,\n\t\t\"price\": 3, \"amount\": 3, \"total\": 3, \"cost\": 3,\n\t\t\"summary\": 3, \"description\": 3, \"points\": 3, \"score\": 3,\n\t\t\"type\": 4, \"kind\": 4, \"category\": 4, \"method\": 4,\n\t}\n\n\tnumTiers := 5\n\n\ttype scored struct {\n\t\tname string\n\t\ttier int\n\t\tindex int\n\t}\n\n\tvar all []scored\n\tidx := 0\n\tfor k, v := range item {\n\t\tif !includeComplex {\n\t\t\tswitch v.(type) {\n\t\t\tcase []any, map[string]any:\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t\t// Skip values that won't render usefully in cards\n\t\tif includeComplex {\n\t\t\tformatted := formatCellValue(v)\n\t\t\tif formatted == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\n\t\ttier := numTiers // default: unclassified\n\t\tlower := strings.ToLower(k)\n\n\t\t// 1. Exact match on full field name\n\t\tif t, ok := exactMatches[lower]; ok {\n\t\t\ttier = t\n\t\t} else {\n\t\t\t// 2. Split camelCase into segments and match the last one\n\t\t\tsegments := splitCamelCase(lower)\n\t\t\tif len(segments) > 0 {\n\t\t\t\tlastSeg := segments[len(segments)-1]\n\t\t\t\tif t, ok := suffixMatches[lastSeg]; ok {\n\t\t\t\t\t// Compound names with identity suffixes (BuildingName, TipTime)\n\t\t\t\t\t// get demoted one tier because the prefix dilutes the signal\n\t\t\t\t\tif len(segments) > 1 {\n\t\t\t\t\t\ttier = t + 1\n\t\t\t\t\t} else {\n\t\t\t\t\t\ttier = t\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// Demote booleans to last\n\t\tif _, ok := v.(bool); ok && tier >= numTiers {\n\t\t\ttier = numTiers + 1\n\t\t}\n\t\tall = append(all, scored{name: k, tier: tier, index: idx})\n\t\tidx++\n\t}\n\n\tsort.Slice(all, func(i, j int) bool {\n\t\tif all[i].tier != all[j].tier {\n\t\t\treturn all[i].tier \u003c all[j].tier\n\t\t}\n\t\treturn all[i].index \u003c all[j].index\n\t})\n\n\theaders := make([]string, len(all))\n\tfor i, s := range all {\n\t\theaders[i] = s.name\n\t}\n\treturn headers\n}\n\n// splitCamelCase splits \"OrderDate\" → [\"order\", \"date\"], \"statusCode\" → [\"status\", \"code\"],\n// \"page_size\" → [\"page\", \"size\"].\nfunc splitCamelCase(s string) []string {\n\tvar segments []string\n\tvar current strings.Builder\n\trunes := []rune(s)\n\tfor i, r := range runes {\n\t\tif r == '_' || r == '-' {\n\t\t\tif current.Len() > 0 {\n\t\t\t\tsegments = append(segments, current.String())\n\t\t\t\tcurrent.Reset()\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\t\tif i > 0 && unicode.IsUpper(r) && unicode.IsLower(runes[i-1]) {\n\t\t\tif current.Len() > 0 {\n\t\t\t\tsegments = append(segments, current.String())\n\t\t\t\tcurrent.Reset()\n\t\t\t}\n\t\t}\n\t\tcurrent.WriteRune(unicode.ToLower(r))\n\t}\n\tif current.Len() > 0 {\n\t\tsegments = append(segments, current.String())\n\t}\n\treturn segments\n}\n\n// printAutoCards renders items as labeled cards — one block per item.\n// Used for complex responses with many fields or nested data.\nfunc printAutoCards(w io.Writer, items []map[string]any) error {\n\theaders := prioritizeAllHeaders(items[0])\n\n\t// Find the longest header for alignment (from fields we'll actually show)\n\tmaxLen := 0\n\tfor _, h := range headers {\n\t\tif len(h) > maxLen {\n\t\t\tmaxLen = len(h)\n\t\t}\n\t}\n\n\tfor i, item := range items {\n\t\tif i > 0 {\n\t\t\tfmt.Fprintln(w)\n\t\t}\n\n\t\t// Card header: use first priority field as the card title\n\t\ttitleVal := formatCellValue(item[headers[0]])\n\t\tif len(headers) > 1 {\n\t\t\tsecondVal := formatCellValue(item[headers[1]])\n\t\t\tif secondVal != \"\" {\n\t\t\t\tfmt.Fprintf(w, \"%s %s — %s\\n\", bold(strings.ToUpper(headers[0])), titleVal, secondVal)\n\t\t\t} else {\n\t\t\t\tfmt.Fprintf(w, \"%s %s\\n\", bold(strings.ToUpper(headers[0])), titleVal)\n\t\t\t}\n\t\t} else {\n\t\t\tfmt.Fprintf(w, \"%s %s\\n\", bold(strings.ToUpper(headers[0])), titleVal)\n\t\t}\n\n\t\t// Remaining fields indented — skip empty, zero, and false values\n\t\tfor _, h := range headers[2:] {\n\t\t\tv := formatCellValue(item[h])\n\t\t\tif v == \"\" || v == \"false\" || v == \"0\" || v == \"[]\" || v == \"null\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\t// Multi-line values (nested arrays) start with \\n\n\t\t\tif strings.HasPrefix(v, \"\\n\") {\n\t\t\t\tfmt.Fprintf(w, \" %s:%s\\n\", h, v)\n\t\t\t} else {\n\t\t\t\tfmt.Fprintf(w, \" %-*s %s\\n\", maxLen, h+\":\", v)\n\t\t\t}\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc formatCellValue(v any) string {\n\tswitch val := v.(type) {\n\tcase string:\n\t\t// Format ISO dates as just the date portion\n\t\tif len(val) >= 19 && val[4] == '-' && val[7] == '-' && val[10] == 'T' {\n\t\t\treturn val[:10]\n\t\t}\n\t\treturn truncate(val, 60)\n\tcase float64:\n\t\tif val == float64(int64(val)) {\n\t\t\treturn fmt.Sprintf(\"%d\", int64(val))\n\t\t}\n\t\treturn fmt.Sprintf(\"%.2f\", val)\n\tcase bool:\n\t\treturn fmt.Sprintf(\"%t\", val)\n\tcase nil:\n\t\treturn \"\"\n\tcase []any:\n\t\tif len(val) == 0 {\n\t\t\treturn \"\"\n\t\t}\n\t\t// If array contains objects, format each as a summary line\n\t\tif obj, isObj := val[0].(map[string]any); isObj {\n\t\t\t_ = obj\n\t\t\treturn formatObjectArray(val)\n\t\t}\n\t\t// Flatten simple arrays into comma-separated string\n\t\tparts := make([]string, 0, len(val))\n\t\tfor _, item := range val {\n\t\t\tif s, ok := item.(string); ok {\n\t\t\t\tparts = append(parts, s)\n\t\t\t} else {\n\t\t\t\tb, _ := json.Marshal(item)\n\t\t\t\tparts = append(parts, string(b))\n\t\t\t}\n\t\t}\n\t\treturn truncate(strings.Join(parts, \", \"), 60)\n\tcase map[string]any:\n\t\treturn formatSingleObject(val)\n\tdefault:\n\t\tb, _ := json.Marshal(val)\n\t\treturn truncate(string(b), 60)\n\t}\n}\n\n// formatObjectArray renders an array of objects as multi-line summary.\n// Each object is summarized by its most descriptive fields: name/title, qty, size, price.\nfunc formatObjectArray(items []any) string {\n\tvar lines []string\n\tfor _, raw := range items {\n\t\tobj, ok := raw.(map[string]any)\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\t\tlines = append(lines, formatObjectSummary(obj))\n\t}\n\tif len(lines) == 0 {\n\t\treturn \"\"\n\t}\n\t// Multi-line: newline-prefixed so the card renderer can indent\n\treturn \"\\n\" + strings.Join(lines, \"\\n\")\n}\n\n// formatObjectSummary extracts the most useful fields from an object into a one-line summary.\n// Looks for: qty/count → name/title → size → price, in that order.\nfunc formatObjectSummary(obj map[string]any) string {\n\tvar parts []string\n\n\t// Quantity\n\tqty := findField(obj, \"qty\", \"count\", \"quantity\")\n\tif qty != \"\" && qty != \"1\" && qty != \"0\" {\n\t\tparts = append(parts, qty+\"x\")\n\t} else if qty == \"1\" {\n\t\tparts = append(parts, \"1x\")\n\t}\n\n\t// Name — check nested objects too (e.g., Side1.Name)\n\tname := findField(obj, \"name\", \"title\", \"label\", \"description\")\n\tif name == \"\" {\n\t\t// Check nested objects for name\n\t\tfor _, key := range []string{\"Side1\", \"side1\", \"Item\", \"item\", \"Product\", \"product\"} {\n\t\t\tif nested, ok := obj[key].(map[string]any); ok {\n\t\t\t\tname = findField(nested, \"name\", \"title\", \"label\")\n\t\t\t\tif name != \"\" {\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\tif name != \"\" {\n\t\tparts = append(parts, name)\n\t}\n\n\t// Size\n\tsize := findField(obj, \"sizename\", \"size_name\")\n\tif size == \"\" {\n\t\tsize = findField(obj, \"catname\", \"cat_name\", \"category\")\n\t}\n\tif size != \"\" {\n\t\tparts = append(parts, \"—\")\n\t\tparts = append(parts, size)\n\t}\n\n\t// Price\n\tprice := findField(obj, \"extprice\", \"price\", \"amount\", \"total\")\n\tif price != \"\" && price != \"0\" {\n\t\tparts = append(parts, fmt.Sprintf(\"($%s)\", price))\n\t}\n\n\tif len(parts) == 0 {\n\t\t// Fallback: JSON summary\n\t\tb, _ := json.Marshal(obj)\n\t\treturn truncate(string(b), 80)\n\t}\n\treturn \" \" + strings.Join(parts, \" \")\n}\n\n// formatSingleObject renders a single object by its most descriptive fields.\nfunc formatSingleObject(obj map[string]any) string {\n\tname := findField(obj, \"name\", \"title\", \"label\", \"description\")\n\tif name != \"\" {\n\t\treturn name\n\t}\n\tid := findField(obj, \"id\", \"key\", \"code\")\n\tif id != \"\" {\n\t\treturn id\n\t}\n\treturn \"\"\n}\n\n// findField searches an object for a field name (case-insensitive) and returns its formatted value.\nfunc findField(obj map[string]any, names ...string) string {\n\tfor _, name := range names {\n\t\tfor k, v := range obj {\n\t\t\tif strings.EqualFold(k, name) {\n\t\t\t\treturn formatCellValue(v)\n\t\t\t}\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// DataProvenance describes where data came from and when it was last synced.\ntype DataProvenance struct {\n\tSource string `json:\"source\"` // \"live\" or \"local\"\n\tSyncedAt *time.Time `json:\"synced_at,omitempty\"` // when local data was last synced\n\tReason string `json:\"reason,omitempty\"` // why local was used: \"user_requested\", \"api_unreachable\", \"no_search_endpoint\"\n\tResourceType string `json:\"resource_type,omitempty\"` // which resource type was queried\n\tFreshness any `json:\"freshness,omitempty\"` // optional machine-owned freshness metadata for covered command paths\n}\n\n// defaultDBPath returns the canonical path for the local SQLite database.\nfunc defaultDBPath(name string) string {\n\thome, _ := os.UserHomeDir()\n\treturn filepath.Join(home, \".local\", \"share\", name, \"data.db\")\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":30249,"content_sha256":"228de68bfebbd9d0837f74cc68d48cd96ef3767bc52741700ada9b5d5387017b"},{"filename":"internal/cli/html_extract.go","content":"// Copyright 2026 rderwin and contributors. Licensed under Apache-2.0. See LICENSE.\n// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.\n\npackage cli\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\tstdhtml \"html\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strconv\"\n\t\"strings\"\n\n\txhtml \"golang.org/x/net/html\"\n)\n\ntype htmlExtractionOptions struct {\n\tMode string\n\tBaseURL string\n\tLinkPrefixes []string\n\tLimit int\n\tScriptSelector string // for mode: embedded-json — selector \"tag\" or \"tag#id\"\n\tJSONPath string // for mode: embedded-json — dot-notation walk\n}\n\ntype htmlExtractedPage struct {\n\tTitle string `json:\"title,omitempty\"`\n\tDescription string `json:\"description,omitempty\"`\n\tImageURL string `json:\"image_url,omitempty\"`\n\tCanonicalURL string `json:\"canonical_url,omitempty\"`\n\tLinks []htmlLink `json:\"links,omitempty\"`\n}\n\ntype htmlLink struct {\n\tRank int `json:\"rank,omitempty\"`\n\tName string `json:\"name,omitempty\"`\n\tText string `json:\"text,omitempty\"`\n\tImage string `json:\"image,omitempty\"`\n\tURL string `json:\"url,omitempty\"`\n\tSlug string `json:\"slug,omitempty\"`\n}\n\n// htmlRawTextTags lists the four HTML5 elements whose content is parsed as\n// raw text (not as nested elements) per the HTML spec. Walking these as if\n// they were normal element subtrees leaks raw markup -- e.g., a `\u003cnoscript>`\n// fallback containing `\u003cimg src=\"...\">` produces literal `\u003cimg>` text in the\n// anchor's nodeText. This map is used to skip those subtrees during link-mode\n// text extraction and image discovery.\nvar htmlRawTextTags = map[string]struct{}{\n\t\"noscript\": {},\n\t\"script\": {},\n\t\"style\": {},\n\t\"template\": {},\n}\n\nfunc extractHTMLResponse(raw []byte, opts htmlExtractionOptions) (json.RawMessage, error) {\n\t// Dispatch on Mode at the top so each branch owns its own parsing\n\t// path. embedded-json doesn't need DOM walking; running the page\n\t// parse + walkHTML + looksLikeHTMLChallenge unconditionally would\n\t// (a) waste work on every embedded-json call and (b) risk false\n\t// \"challenge page\" rejection on Next.js / Nuxt pages with generic\n\t// titles. Page and links share the page-mode parse.\n\tmode := strings.ToLower(strings.TrimSpace(opts.Mode))\n\tswitch mode {\n\tcase \"links\", \"page\", \"\":\n\t\treturn extractHTMLPageOrLinks(raw, opts)\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"unsupported html_extract mode: %q\", opts.Mode)\n\t}\n}\n\n// extractHTMLPageOrLinks handles the page (default) and links modes.\n// Both require parsing the HTML and walking the DOM; the only difference\n// is what they return at the end.\nfunc extractHTMLPageOrLinks(raw []byte, opts htmlExtractionOptions) (json.RawMessage, error) {\n\tdoc, err := xhtml.Parse(strings.NewReader(string(raw)))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"parsing HTML response: %w\", err)\n\t}\n\n\tpage := htmlExtractedPage{}\n\twalkHTML(doc, func(n *xhtml.Node) {\n\t\tif n.Type != xhtml.ElementNode {\n\t\t\treturn\n\t\t}\n\t\tswitch strings.ToLower(n.Data) {\n\t\tcase \"title\":\n\t\t\tif page.Title == \"\" {\n\t\t\t\tpage.Title = cleanHTMLText(nodeText(n))\n\t\t\t}\n\t\tcase \"meta\":\n\t\t\tapplyMeta(&page, n)\n\t\tcase \"link\":\n\t\t\tif strings.EqualFold(attrValue(n, \"rel\"), \"canonical\") && page.CanonicalURL == \"\" {\n\t\t\t\tpage.CanonicalURL = normalizeHTMLURL(attrValue(n, \"href\"), opts.BaseURL)\n\t\t\t}\n\t\tcase \"a\":\n\t\t\tif link, ok := extractHTMLLink(n, opts); ok {\n\t\t\t\tpage.Links = append(page.Links, link)\n\t\t\t}\n\t\t}\n\t})\n\n\tif looksLikeHTMLChallenge(page.Title, string(raw)) {\n\t\treturn nil, fmt.Errorf(\"HTML response looks like a browser challenge page\")\n\t}\n\n\tlimit := opts.Limit\n\tif limit \u003c= 0 {\n\t\tlimit = 50\n\t}\n\tif len(page.Links) > limit {\n\t\tpage.Links = page.Links[:limit]\n\t}\n\n\tswitch strings.ToLower(strings.TrimSpace(opts.Mode)) {\n\tcase \"links\":\n\t\tdata, err := json.Marshal(page.Links)\n\t\treturn json.RawMessage(data), err\n\tdefault:\n\t\tdata, err := json.Marshal(page)\n\t\treturn json.RawMessage(data), err\n\t}\n}\n\nfunc walkHTML(n *xhtml.Node, visit func(*xhtml.Node)) {\n\tif n == nil {\n\t\treturn\n\t}\n\tvisit(n)\n\tfor child := n.FirstChild; child != nil; child = child.NextSibling {\n\t\twalkHTML(child, visit)\n\t}\n}\n\nfunc applyMeta(page *htmlExtractedPage, n *xhtml.Node) {\n\tname := strings.ToLower(attrValue(n, \"name\"))\n\tproperty := strings.ToLower(attrValue(n, \"property\"))\n\tcontent := cleanHTMLText(attrValue(n, \"content\"))\n\tif content == \"\" {\n\t\treturn\n\t}\n\tswitch {\n\tcase page.Title == \"\" && (property == \"og:title\" || name == \"twitter:title\"):\n\t\tpage.Title = content\n\tcase page.Description == \"\" && (name == \"description\" || property == \"og:description\" || name == \"twitter:description\"):\n\t\tpage.Description = content\n\tcase page.ImageURL == \"\" && (property == \"og:image\" || name == \"twitter:image\"):\n\t\tpage.ImageURL = content\n\t}\n}\n\nfunc extractHTMLLink(n *xhtml.Node, opts htmlExtractionOptions) (htmlLink, bool) {\n\thref := strings.TrimSpace(attrValue(n, \"href\"))\n\tif href == \"\" || strings.HasPrefix(href, \"#\") || strings.HasPrefix(strings.ToLower(href), \"javascript:\") {\n\t\treturn htmlLink{}, false\n\t}\n\tnormalized := normalizeHTMLURL(href, opts.BaseURL)\n\tif normalized == \"\" {\n\t\treturn htmlLink{}, false\n\t}\n\tparsed, err := url.Parse(normalized)\n\tif err != nil {\n\t\treturn htmlLink{}, false\n\t}\n\tif !htmlLinkMatchesPrefixes(parsed.Path, opts.LinkPrefixes) {\n\t\treturn htmlLink{}, false\n\t}\n\n\t// Use the suppression-aware walker for anchor text. Sites like Allrecipes,\n\t// Food Network, and other Dotdash/Meredith properties wrap above-the-fold\n\t// images in \u003cnoscript> for non-JS users; the HTML parser preserves\n\t// noscript content as raw TextNode children, and a naive walk leaks\n\t// \"\u003cimg src=...>\" markup into the result. Suppressing noscript/script/\n\t// style/template subtrees in nodeTextSuppressing returns clean text.\n\ttext := cleanHTMLText(nodeTextSuppressing(n))\n\trank, name := splitRankedHTMLLinkText(text)\n\tif name == \"\" {\n\t\tname = text\n\t}\n\timage := normalizeHTMLURL(firstImageSrc(n, opts.BaseURL), opts.BaseURL)\n\treturn htmlLink{\n\t\tRank: rank,\n\t\tName: name,\n\t\tText: text,\n\t\tImage: image,\n\t\tURL: normalized,\n\t\tSlug: htmlLinkSlug(parsed.Path, opts.LinkPrefixes),\n\t}, true\n}\n\nfunc normalizeHTMLURL(raw string, base string) string {\n\tref, err := url.Parse(strings.TrimSpace(raw))\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\tif ref.IsAbs() {\n\t\treturn ref.String()\n\t}\n\tbaseURL, err := url.Parse(base)\n\tif err != nil || baseURL.Scheme == \"\" || baseURL.Host == \"\" {\n\t\treturn ref.String()\n\t}\n\treturn baseURL.ResolveReference(ref).String()\n}\n\nfunc htmlExtractionRequestURL(base string, path string, params map[string]string) string {\n\tbaseURL, err := url.Parse(base)\n\tif err != nil || baseURL.Scheme == \"\" || baseURL.Host == \"\" {\n\t\treturn base\n\t}\n\tref, err := url.Parse(path)\n\tif err != nil {\n\t\treturn base\n\t}\n\tfull := baseURL.ResolveReference(ref)\n\tif len(params) > 0 {\n\t\tq := full.Query()\n\t\tfor key, value := range params {\n\t\t\tif value != \"\" {\n\t\t\t\tq.Set(key, value)\n\t\t\t}\n\t\t}\n\t\tfull.RawQuery = q.Encode()\n\t}\n\treturn full.String()\n}\n\nfunc htmlLinkMatchesPrefixes(path string, prefixes []string) bool {\n\tif len(prefixes) == 0 {\n\t\treturn true\n\t}\n\tfor _, prefix := range prefixes {\n\t\tprefix = strings.TrimSpace(prefix)\n\t\tif prefix == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !strings.HasPrefix(prefix, \"/\") {\n\t\t\tprefix = \"/\" + prefix\n\t\t}\n\t\tif htmlPathMatchesPrefix(path, prefix) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc htmlPathMatchesPrefix(path string, prefix string) bool {\n\tif prefix == \"/@\" {\n\t\treturn strings.HasPrefix(path, prefix)\n\t}\n\ttrimmed := strings.TrimRight(prefix, \"/\")\n\treturn path == trimmed || strings.HasPrefix(path, trimmed+\"/\")\n}\n\nfunc htmlLinkSlug(path string, prefixes []string) string {\n\tfor _, prefix := range prefixes {\n\t\tprefix = strings.TrimSpace(prefix)\n\t\tif prefix == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !strings.HasPrefix(prefix, \"/\") {\n\t\t\tprefix = \"/\" + prefix\n\t\t}\n\t\trest := strings.Trim(strings.TrimPrefix(path, strings.TrimRight(prefix, \"/\")), \"/\")\n\t\tif rest == path || rest == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tparts := strings.Split(rest, \"/\")\n\t\treturn parts[0]\n\t}\n\tparts := strings.Split(strings.Trim(path, \"/\"), \"/\")\n\tif len(parts) == 0 {\n\t\treturn \"\"\n\t}\n\treturn parts[len(parts)-1]\n}\n\nfunc attrValue(n *xhtml.Node, name string) string {\n\tfor _, attr := range n.Attr {\n\t\tif strings.EqualFold(attr.Key, name) {\n\t\t\treturn attr.Val\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc nodeText(n *xhtml.Node) string {\n\tvar parts []string\n\twalkHTML(n, func(child *xhtml.Node) {\n\t\tif child.Type == xhtml.TextNode {\n\t\t\tparts = append(parts, child.Data)\n\t\t}\n\t})\n\treturn strings.Join(parts, \" \")\n}\n\n// nodeTextSuppressing walks n's descendants and concatenates TextNode content,\n// skipping subtrees rooted at noscript/script/style/template elements. The\n// HTML5 spec parses content of those four elements as raw text, so a normal\n// walk leaks their literal markup (e.g., the `\u003cimg src=...>` inside a\n// `\u003cnoscript>` fallback) into the output. Used by extractHTMLLink instead of\n// nodeText to keep result fields clean.\nfunc nodeTextSuppressing(n *xhtml.Node) string {\n\tvar parts []string\n\tvar walk func(*xhtml.Node)\n\twalk = func(node *xhtml.Node) {\n\t\tif node == nil {\n\t\t\treturn\n\t\t}\n\t\tif node.Type == xhtml.ElementNode {\n\t\t\tif _, raw := htmlRawTextTags[strings.ToLower(node.Data)]; raw {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tif node.Type == xhtml.TextNode {\n\t\t\tparts = append(parts, node.Data)\n\t\t}\n\t\tfor child := node.FirstChild; child != nil; child = child.NextSibling {\n\t\t\twalk(child)\n\t\t}\n\t}\n\twalk(n)\n\treturn strings.Join(parts, \" \")\n}\n\n// firstImageSrc returns the URL of the first non-suppressed `\u003cimg>` inside\n// the given node's subtree, normalized against base.\n//\n// Source priority (highest first):\n//\n// 1. `data-src` — modern lazy-loading attribute; carries the real URL when\n// `src` is a placeholder (1x1 pixel, base64 transparent gif). Most\n// content-heavy sites that lazy-load (Pinterest, NYT Cooking, Food52)\n// follow this pattern, so `data-src` beats `src` when both are present.\n// 2. `data-srcset` / `srcset` — first comma-separated URL. Responsive image\n// hint lists; we take the first declared candidate for simplicity.\n// 3. `src` — final fallback for plain `\u003cimg>` tags without lazy-loading.\n//\n// Suppressed subtrees (noscript/script/style/template) are skipped, so an\n// `\u003cimg>` rendered for JS-disabled users does not get picked when an\n// above-the-fold rendered image exists.\n//\n// Limitation: returns the FIRST matching image in DOM order, with no quality\n// heuristic. Sites that put a small icon (badge, share button, profile pic)\n// before the hero image will have the icon URL surfaced. Acceptable for\n// recipe-card and product-card layouts where hero images come first;\n// document the limitation if a CLI's spec target uses an icon-first layout.\n//\n// Returns empty string when no image is found.\nfunc firstImageSrc(n *xhtml.Node, base string) string {\n\t_ = base // base is used by the caller's normalizeHTMLURL wrapper, not here\n\tif n == nil {\n\t\treturn \"\"\n\t}\n\tvar found string\n\tvar walk func(*xhtml.Node)\n\twalk = func(node *xhtml.Node) {\n\t\tif found != \"\" || node == nil {\n\t\t\treturn\n\t\t}\n\t\tif node.Type == xhtml.ElementNode {\n\t\t\tif _, raw := htmlRawTextTags[strings.ToLower(node.Data)]; raw {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif strings.EqualFold(node.Data, \"img\") {\n\t\t\t\t// Priority 1: data-src (lazy-load real URL)\n\t\t\t\tif src := strings.TrimSpace(attrValue(node, \"data-src\")); src != \"\" {\n\t\t\t\t\tfound = src\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\t// Priority 2: data-srcset / srcset (first candidate)\n\t\t\t\tif srcset := strings.TrimSpace(attrValue(node, \"data-srcset\")); srcset != \"\" {\n\t\t\t\t\tif src := firstSrcsetURL(srcset); src != \"\" {\n\t\t\t\t\t\tfound = src\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif srcset := strings.TrimSpace(attrValue(node, \"srcset\")); srcset != \"\" {\n\t\t\t\t\tif src := firstSrcsetURL(srcset); src != \"\" {\n\t\t\t\t\t\tfound = src\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\t// Priority 3: plain src (may be a placeholder, but the only\n\t\t\t\t// signal available for non-lazy-loaded images)\n\t\t\t\tif src := strings.TrimSpace(attrValue(node, \"src\")); src != \"\" {\n\t\t\t\t\tfound = src\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tfor child := node.FirstChild; child != nil; child = child.NextSibling {\n\t\t\tif found != \"\" {\n\t\t\t\treturn\n\t\t\t}\n\t\t\twalk(child)\n\t\t}\n\t}\n\twalk(n)\n\treturn found\n}\n\n// firstSrcsetURL extracts the first URL from a srcset / data-srcset attribute.\n// Srcset format is comma-separated `URL DESCRIPTOR` pairs, e.g.:\n//\n//\t\"https://x.com/a.jpg 1x, https://x.com/[email protected] 2x\"\n//\n// We take the first URL (first whitespace-separated token of the first\n// comma-separated entry). Returns empty string if the value is malformed.\nfunc firstSrcsetURL(srcset string) string {\n\tif srcset == \"\" {\n\t\treturn \"\"\n\t}\n\tfirst := srcset\n\tif i := strings.IndexByte(first, ','); i >= 0 {\n\t\tfirst = first[:i]\n\t}\n\tfirst = strings.TrimSpace(first)\n\tif i := strings.IndexAny(first, \" \\t\"); i >= 0 {\n\t\tfirst = first[:i]\n\t}\n\treturn strings.TrimSpace(first)\n}\n\nvar htmlWhitespace = regexp.MustCompile(`\\s+`)\nvar htmlRankedText = regexp.MustCompile(`^(\\d+)[.)]\\s+(.+) pp-apartments — Skillopedia )\n\nfunc cleanHTMLText(value string) string {\n\tvalue = stdhtml.UnescapeString(value)\n\treturn strings.TrimSpace(htmlWhitespace.ReplaceAllString(value, \" \"))\n}\n\nfunc splitRankedHTMLLinkText(value string) (int, string) {\n\tvalue = cleanHTMLText(value)\n\tmatches := htmlRankedText.FindStringSubmatch(value)\n\tif len(matches) != 3 {\n\t\treturn 0, value\n\t}\n\trank, err := strconv.Atoi(matches[1])\n\tif err != nil {\n\t\treturn 0, value\n\t}\n\treturn rank, matches[2]\n}\n\nfunc looksLikeHTMLChallenge(title string, raw string) bool {\n\tcombined := strings.ToLower(title + \"\\n\" + raw)\n\tmarkers := []string{\n\t\t\"\u003ctitle>just a moment\",\n\t\t\"cf-browser-verification\",\n\t\t\"cf-challenge\",\n\t\t\"cf-mitigated\",\n\t\t\"_cf_chl_\",\n\t\t\"challenge-platform\",\n\t\t\"verify you are human\",\n\t}\n\tfor _, marker := range markers {\n\t\tif strings.Contains(combined, marker) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":13723,"content_sha256":"1d3ff426532376298675cb18db352a83f1c8e307ff160cdb7d7b0e7dc58c841b"},{"filename":"internal/cli/import.go","content":"// Copyright 2026 rderwin and contributors. Licensed under Apache-2.0. See LICENSE.\n// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.\n\npackage cli\n\nimport (\n\t\"bufio\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"strings\"\n\n\t\"github.com/spf13/cobra\"\n)\n\nfunc newImportCmd(flags *rootFlags) *cobra.Command {\n\tvar inputFile string\n\tvar dryRun bool\n\tvar batchSize int\n\n\tcmd := &cobra.Command{\n\t\tUse: \"import \u003cresource>\",\n\t\tShort: \"Import data from JSONL file via API create/upsert calls\",\n\t\tLong: `Import data from a JSONL file by issuing POST requests for each record.\nEach line must be a valid JSON object. Failed records are logged to stderr\nbut do not stop the import.`,\n\t\tExample: ` # Import from a JSONL file\n apartments-pp-cli import \u003cresource> --input data.jsonl\n\n # Dry-run to preview without sending\n apartments-pp-cli import \u003cresource> --input data.jsonl --dry-run\n\n # Import from stdin\n cat data.jsonl | apartments-pp-cli import \u003cresource> --input -`,\n\t\tArgs: cobra.ExactArgs(1),\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tc, err := flags.newClient()\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tc.DryRun = dryRun\n\n\t\t\tresource := args[0]\n\t\t\tpath := \"/\" + resource\n\n\t\t\tvar reader io.Reader\n\t\t\tif inputFile == \"-\" || inputFile == \"\" {\n\t\t\t\treader = os.Stdin\n\t\t\t} else {\n\t\t\t\tf, err := os.Open(inputFile)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"opening input file: %w\", err)\n\t\t\t\t}\n\t\t\t\tdefer f.Close()\n\t\t\t\treader = f\n\t\t\t}\n\n\t\t\tscanner := bufio.NewScanner(reader)\n\t\t\tscanner.Buffer(make([]byte, 1024*1024), 1024*1024) // 1MB line buffer\n\n\t\t\tvar success, failed, skipped int\n\t\t\tfor scanner.Scan() {\n\t\t\t\tline := strings.TrimSpace(scanner.Text())\n\t\t\t\tif line == \"\" || line[0] == '#' {\n\t\t\t\t\tskipped++\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\tvar body map[string]any\n\t\t\t\tif err := json.Unmarshal([]byte(line), &body); err != nil {\n\t\t\t\t\tfmt.Fprintf(os.Stderr, \"warning: skipping invalid JSON line: %v\\n\", err)\n\t\t\t\t\tfailed++\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\t_, _, err := c.Post(path, body)\n\t\t\t\tif err != nil {\n\t\t\t\t\tfmt.Fprintf(os.Stderr, \"warning: failed to import record: %v\\n\", err)\n\t\t\t\t\tfailed++\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tsuccess++\n\t\t\t}\n\n\t\t\tif err := scanner.Err(); err != nil {\n\t\t\t\treturn fmt.Errorf(\"reading input: %w\", err)\n\t\t\t}\n\n\t\t\t// JSON envelope: {succeeded, failed, skipped}.\n\t\t\tif flags.asJSON {\n\t\t\t\treturn printJSONFiltered(cmd.OutOrStdout(), map[string]any{\n\t\t\t\t\t\"succeeded\": success,\n\t\t\t\t\t\"failed\": failed,\n\t\t\t\t\t\"skipped\": skipped,\n\t\t\t\t}, flags)\n\t\t\t}\n\t\t\tfmt.Fprintf(os.Stderr, \"Import complete: %d succeeded, %d failed, %d skipped\\n\", success, failed, skipped)\n\t\t\treturn nil\n\t\t},\n\t}\n\n\tcmd.Flags().StringVarP(&inputFile, \"input\", \"i\", \"\", \"Input JSONL file path (use - for stdin)\")\n\t_ = cmd.MarkFlagRequired(\"input\")\n\tcmd.Flags().BoolVar(&dryRun, \"dry-run\", false, \"Preview import without sending requests\")\n\tcmd.Flags().IntVar(&batchSize, \"batch-size\", 1, \"Records per batch (future: batch API support)\")\n\n\treturn cmd\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":2970,"content_sha256":"d568f6ec9066bcf3db6a00ad3098c4da3cff3299e0debf87cc2a4ca5c6191809"},{"filename":"internal/cli/profile.go","content":"// Copyright 2026 rderwin and contributors. Licensed under Apache-2.0. See LICENSE.\n// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.\n\npackage cli\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"sort\"\n\t\"strings\"\n\n\t\"github.com/spf13/cobra\"\n\t\"github.com/spf13/pflag\"\n)\n\n// Profile is a named set of flag values saved for reuse across invocations.\n// HeyGen's \"Beacon\" pattern: one named context that a scheduled agent reuses\n// day after day with the same voice/format but different input each run.\ntype Profile struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description,omitempty\"`\n\tValues map[string]string `json:\"values\"`\n}\n\ntype profileStore struct {\n\tProfiles map[string]Profile `json:\"profiles\"`\n}\n\nfunc profileStorePath() (string, error) {\n\thome, err := os.UserHomeDir()\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"resolving home dir: %w\", err)\n\t}\n\tdir := filepath.Join(home, \".apartments-pp-cli\")\n\tif err := os.MkdirAll(dir, 0o700); err != nil {\n\t\treturn \"\", fmt.Errorf(\"creating state dir: %w\", err)\n\t}\n\treturn filepath.Join(dir, \"profiles.json\"), nil\n}\n\nfunc loadProfileStore() (*profileStore, error) {\n\tp, err := profileStorePath()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdata, err := os.ReadFile(p)\n\tif err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn &profileStore{Profiles: map[string]Profile{}}, nil\n\t\t}\n\t\treturn nil, fmt.Errorf(\"reading profiles: %w\", err)\n\t}\n\tvar s profileStore\n\tif err := json.Unmarshal(data, &s); err != nil {\n\t\treturn nil, fmt.Errorf(\"parsing profiles: %w\", err)\n\t}\n\tif s.Profiles == nil {\n\t\ts.Profiles = map[string]Profile{}\n\t}\n\treturn &s, nil\n}\n\nfunc saveProfileStore(s *profileStore) error {\n\tp, err := profileStorePath()\n\tif err != nil {\n\t\treturn err\n\t}\n\tdata, err := json.MarshalIndent(s, \"\", \" \")\n\tif err != nil {\n\t\treturn fmt.Errorf(\"marshaling profiles: %w\", err)\n\t}\n\ttmp := p + \".tmp\"\n\tif err := os.WriteFile(tmp, data, 0o600); err != nil {\n\t\treturn fmt.Errorf(\"writing profiles: %w\", err)\n\t}\n\treturn os.Rename(tmp, p)\n}\n\n// GetProfile returns a profile by name, or (nil, nil) if not found.\nfunc GetProfile(name string) (*Profile, error) {\n\ts, err := loadProfileStore()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif p, ok := s.Profiles[name]; ok {\n\t\treturn &p, nil\n\t}\n\treturn nil, nil\n}\n\n// ApplyProfileToFlags overlays profile values onto flags that the user has\n// not set explicitly on the command line. Used from root.go's\n// PersistentPreRunE so profile values feed the whole command tree.\nfunc ApplyProfileToFlags(cmd *cobra.Command, profile *Profile) error {\n\tif profile == nil || len(profile.Values) == 0 {\n\t\treturn nil\n\t}\n\t// Reserved flags that never come from a profile - they control profile\n\t// resolution itself or are dangerous to overlay.\n\treserved := map[string]bool{\n\t\t\"profile\": true, \"config\": true, \"help\": true,\n\t}\n\tfor name, value := range profile.Values {\n\t\tif reserved[name] {\n\t\t\tcontinue\n\t\t}\n\t\tflag := cmd.Flags().Lookup(name)\n\t\tif flag == nil {\n\t\t\tflag = cmd.InheritedFlags().Lookup(name)\n\t\t}\n\t\tif flag == nil {\n\t\t\tcontinue\n\t\t}\n\t\tif flag.Changed {\n\t\t\tcontinue\n\t\t}\n\t\tif err := flag.Value.Set(value); err != nil {\n\t\t\treturn fmt.Errorf(\"applying profile value %s=%q: %w\", name, value, err)\n\t\t}\n\t}\n\treturn nil\n}\n\n// ListProfileNames returns profile names sorted alphabetically. Used by the\n// agent-context subcommand to expose available_profiles at runtime.\nfunc ListProfileNames() []string {\n\ts, err := loadProfileStore()\n\tif err != nil {\n\t\treturn nil\n\t}\n\tnames := make([]string, 0, len(s.Profiles))\n\tfor name := range s.Profiles {\n\t\tnames = append(names, name)\n\t}\n\tsort.Strings(names)\n\treturn names\n}\n\nfunc newProfileCmd(flags *rootFlags) *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse: \"profile\",\n\t\tShort: \"Named sets of flags saved for reuse\",\n\t\tLong: `Profiles capture a set of flag values under a name so a scheduled\nagent can invoke the same command with the same configuration each run.\n\n profile save \u003cname> captures the current invocation's set flags\n profile use \u003cname> prints the values (for inspection)\n profile list lists all saved profiles\n profile show \u003cname> shows the values of one profile\n profile delete \u003cname> removes a profile\n\nUse --profile \u003cname> on any command to apply that profile's values.\nExplicit flags override profile values.`,\n\t}\n\tcmd.AddCommand(newProfileSaveCmd(flags))\n\tcmd.AddCommand(newProfileUseCmd(flags))\n\tcmd.AddCommand(newProfileListCmd(flags))\n\tcmd.AddCommand(newProfileShowCmd(flags))\n\tcmd.AddCommand(newProfileDeleteCmd(flags))\n\treturn cmd\n}\n\nfunc newProfileSaveCmd(flags *rootFlags) *cobra.Command {\n\tvar description string\n\tcmd := &cobra.Command{\n\t\tUse: \"save \u003cname> [--\u003cflag> \u003cvalue> ...]\",\n\t\tShort: \"Save the current invocation's non-default flags as a named profile\",\n\t\tLong: `Captures every flag explicitly set on the invocation and stores\nthem under \u003cname>. To update an existing profile, run save again; the\nentry is replaced.\n\nTo avoid creating empty profiles, at least one non-default flag must be\npresent (other than --profile and --config).`,\n\t\tExample: ` apartments-pp-cli profile save my-defaults --json --compact\n apartments-pp-cli profile save tonight-defaults --region US`,\n\t\tArgs: cobra.ExactArgs(1),\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tname := args[0]\n\t\t\tif strings.ContainsAny(name, `/\\: `) {\n\t\t\t\treturn fmt.Errorf(\"profile name %q contains reserved characters\", name)\n\t\t\t}\n\t\t\tvalues := map[string]string{}\n\t\t\t// Walk inherited + local flags, capture only those the user set.\n\t\t\tskip := map[string]bool{\"profile\": true, \"config\": true, \"help\": true, \"description\": true}\n\t\t\tvisit := func(fl *pflag.Flag) {\n\t\t\t\tif fl.Changed && !skip[fl.Name] {\n\t\t\t\t\tvalues[fl.Name] = fl.Value.String()\n\t\t\t\t}\n\t\t\t}\n\t\t\tcmd.InheritedFlags().VisitAll(visit)\n\t\t\tcmd.Flags().VisitAll(visit)\n\t\t\tif len(values) == 0 {\n\t\t\t\treturn fmt.Errorf(\"no non-default flags set - pass at least one flag to save into %q\", name)\n\t\t\t}\n\t\t\ts, err := loadProfileStore()\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\ts.Profiles[name] = Profile{Name: name, Description: description, Values: values}\n\t\t\tif err := saveProfileStore(s); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tif flags.asJSON {\n\t\t\t\treturn printJSONFiltered(cmd.OutOrStdout(), s.Profiles[name], flags)\n\t\t\t}\n\t\t\tfmt.Fprintf(cmd.OutOrStdout(), \"saved profile %q with %d values\\n\", name, len(values))\n\t\t\treturn nil\n\t\t},\n\t}\n\tcmd.Flags().StringVar(&description, \"description\", \"\", \"Short description shown in 'profile list'\")\n\treturn cmd\n}\n\nfunc newProfileUseCmd(flags *rootFlags) *cobra.Command {\n\treturn &cobra.Command{\n\t\tUse: \"use \u003cname>\",\n\t\tShort: \"Print the flag values a profile will apply (does not execute anything)\",\n\t\tExample: ` apartments-pp-cli profile use my-defaults\n apartments-pp-cli profile use tonight-defaults --json`,\n\t\tArgs: cobra.ExactArgs(1),\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tp, err := GetProfile(args[0])\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tif p == nil {\n\t\t\t\treturn fmt.Errorf(\"profile %q not found\", args[0])\n\t\t\t}\n\t\t\tif flags.asJSON {\n\t\t\t\treturn printJSONFiltered(cmd.OutOrStdout(), p, flags)\n\t\t\t}\n\t\t\tfmt.Fprintf(cmd.OutOrStdout(), \"profile %q:\\n\", p.Name)\n\t\t\tif p.Description != \"\" {\n\t\t\t\tfmt.Fprintf(cmd.OutOrStdout(), \" description: %s\\n\", p.Description)\n\t\t\t}\n\t\t\tkeys := make([]string, 0, len(p.Values))\n\t\t\tfor k := range p.Values {\n\t\t\t\tkeys = append(keys, k)\n\t\t\t}\n\t\t\tsort.Strings(keys)\n\t\t\tfor _, k := range keys {\n\t\t\t\tfmt.Fprintf(cmd.OutOrStdout(), \" --%s %s\\n\", k, p.Values[k])\n\t\t\t}\n\t\t\treturn nil\n\t\t},\n\t}\n}\n\nfunc newProfileListCmd(flags *rootFlags) *cobra.Command {\n\treturn &cobra.Command{\n\t\tUse: \"list\",\n\t\tShort: \"List saved profiles\",\n\t\tExample: ` apartments-pp-cli profile list\n apartments-pp-cli profile list --json`,\n\t\tRunE: func(cmd *cobra.Command, _ []string) error {\n\t\t\ts, err := loadProfileStore()\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tnames := make([]string, 0, len(s.Profiles))\n\t\t\tfor n := range s.Profiles {\n\t\t\t\tnames = append(names, n)\n\t\t\t}\n\t\t\tsort.Strings(names)\n\t\t\tif flags.asJSON {\n\t\t\t\tout := make([]map[string]any, 0, len(names))\n\t\t\t\tfor _, n := range names {\n\t\t\t\t\tp := s.Profiles[n]\n\t\t\t\t\tout = append(out, map[string]any{\n\t\t\t\t\t\t\"name\": p.Name,\n\t\t\t\t\t\t\"description\": p.Description,\n\t\t\t\t\t\t\"field_count\": len(p.Values),\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t\treturn printJSONFiltered(cmd.OutOrStdout(), out, flags)\n\t\t\t}\n\t\t\theaders := []string{\"NAME\", \"FIELDS\", \"DESCRIPTION\"}\n\t\t\trows := make([][]string, 0, len(names))\n\t\t\tfor _, n := range names {\n\t\t\t\tp := s.Profiles[n]\n\t\t\t\trows = append(rows, []string{p.Name, fmt.Sprintf(\"%d\", len(p.Values)), p.Description})\n\t\t\t}\n\t\t\treturn flags.printTable(cmd, headers, rows)\n\t\t},\n\t}\n}\n\nfunc newProfileShowCmd(flags *rootFlags) *cobra.Command {\n\treturn &cobra.Command{\n\t\tUse: \"show \u003cname>\",\n\t\tShort: \"Show a profile's values as JSON\",\n\t\tExample: ` apartments-pp-cli profile show my-defaults\n apartments-pp-cli profile show tonight-defaults --json`,\n\t\tArgs: cobra.ExactArgs(1),\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tp, err := GetProfile(args[0])\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tif p == nil {\n\t\t\t\treturn fmt.Errorf(\"profile %q not found\", args[0])\n\t\t\t}\n\t\t\treturn printJSONFiltered(cmd.OutOrStdout(), p, flags)\n\t\t},\n\t}\n}\n\nfunc newProfileDeleteCmd(flags *rootFlags) *cobra.Command {\n\treturn &cobra.Command{\n\t\tUse: \"delete \u003cname>\",\n\t\tShort: \"Remove a profile\",\n\t\tExample: ` apartments-pp-cli profile delete my-defaults --yes\n apartments-pp-cli profile delete old-profile --yes --json`,\n\t\tArgs: cobra.ExactArgs(1),\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tname := args[0]\n\t\t\ts, err := loadProfileStore()\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tif _, ok := s.Profiles[name]; !ok {\n\t\t\t\treturn fmt.Errorf(\"profile %q not found\", name)\n\t\t\t}\n\t\t\tif !flags.yes {\n\t\t\t\tfmt.Fprintf(cmd.ErrOrStderr(), \"refusing to delete %q without --yes\\n\", name)\n\t\t\t\treturn fmt.Errorf(\"confirmation required: pass --yes\")\n\t\t\t}\n\t\t\tdelete(s.Profiles, name)\n\t\t\tif err := saveProfileStore(s); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\t// JSON envelope: {deleted: name}.\n\t\t\tif flags.asJSON {\n\t\t\t\treturn printJSONFiltered(cmd.OutOrStdout(), map[string]any{\n\t\t\t\t\t\"deleted\": name,\n\t\t\t\t}, flags)\n\t\t\t}\n\t\t\tfmt.Fprintf(cmd.OutOrStdout(), \"deleted profile %q\\n\", name)\n\t\t\treturn nil\n\t\t},\n\t}\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":10334,"content_sha256":"c687d5f52e157bb60d2c0bd4bb0ecd6f867fd7a6c96c3ac066c1413773b6f7d8"},{"filename":"internal/cli/promoted_listing.go","content":"// Copyright 2026 rderwin and contributors. Licensed under Apache-2.0. See LICENSE.\n\npackage cli\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/url\"\n\t\"strings\"\n\n\t\"github.com/mvanhorn/printing-press-library/library/other/apartments/internal/apt\"\n\t\"github.com/mvanhorn/printing-press-library/library/other/apartments/internal/store\"\n\n\t\"github.com/spf13/cobra\"\n)\n\nfunc newListingCmd(flags *rootFlags) *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse: \"listing [property-id-or-url]\",\n\t\tShort: \"Fetch one apartments.com listing detail page and parse schema.org microdata.\",\n\t\tAnnotations: map[string]string{\"mcp:read-only\": \"true\"},\n\t\tExample: strings.Trim(`\n apartments-pp-cli listing the-domain-austin-tx\n apartments-pp-cli listing https://www.apartments.com/the-domain-austin-tx/abc123/\n apartments-pp-cli listing the-domain-austin-tx --json\n`, \"\\n\"),\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tif len(args) == 0 {\n\t\t\t\treturn cmd.Help()\n\t\t\t}\n\t\t\tif dryRunOK(flags) {\n\t\t\t\tfmt.Fprintln(cmd.OutOrStdout(), \"would GET listing:\", args[0])\n\t\t\t\treturn nil\n\t\t\t}\n\t\t\targ := args[0]\n\t\t\tabsURL := arg\n\t\t\tpath := \"/\" + strings.Trim(arg, \"/\") + \"/\"\n\t\t\tif strings.HasPrefix(arg, \"http://\") || strings.HasPrefix(arg, \"https://\") {\n\t\t\t\tu, err := url.Parse(arg)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn usageErr(fmt.Errorf(\"invalid listing URL: %w\", err))\n\t\t\t\t}\n\t\t\t\tpath = u.Path\n\t\t\t\tif !strings.HasSuffix(path, \"/\") {\n\t\t\t\t\tpath += \"/\"\n\t\t\t\t}\n\t\t\t\tabsURL = arg\n\t\t\t} else {\n\t\t\t\tabsURL = \"https://www.apartments.com\" + path\n\t\t\t}\n\n\t\t\t// Listing detail pages have stricter Akamai protection than search\n\t\t\t// pages. Surf clears /city-state/ search URLs but not most\n\t\t\t// /property-slug/ detail URLs. Try the live fetch first; fall back\n\t\t\t// to the latest snapshot in the local store on 403 so the placard\n\t\t\t// data captured by `rentals` and `sync-search` remains useful.\n\t\t\tc, err := flags.newClient()\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tdata, gerr := c.Get(path, nil)\n\t\t\tif gerr == nil {\n\t\t\t\tli, perr := apt.ParseListing([]byte(data), absURL)\n\t\t\t\tif perr != nil {\n\t\t\t\t\treturn apiErr(perr)\n\t\t\t\t}\n\t\t\t\tif li.PropertyID != \"\" {\n\t\t\t\t\tif db, derr := store.OpenWithContext(cmd.Context(), defaultDBPath(\"apartments-pp-cli\")); derr == nil {\n\t\t\t\t\t\tif raw, mErr := json.Marshal(li); mErr == nil {\n\t\t\t\t\t\t\t_ = db.UpsertListing(raw)\n\t\t\t\t\t\t}\n\t\t\t\t\t\tdb.Close()\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\treturn printJSONFiltered(cmd.OutOrStdout(), li, flags)\n\t\t\t}\n\n\t\t\t// Live fetch failed. Try the local store: rentals/sync-search\n\t\t\t// already cached placards under listing_snapshots keyed on the\n\t\t\t// canonical URL. Pull the most recent snapshot for this URL.\n\t\t\tfallback, fbErr := lookupListingSnapshot(cmd.Context(), absURL)\n\t\t\tif fbErr == nil && fallback != nil {\n\t\t\t\tfmt.Fprintln(cmd.ErrOrStderr(), \"note: live fetch returned 403 (apartments.com listing pages have stricter protection than search). Falling back to most-recent snapshot from `rentals`/`sync-search`.\")\n\t\t\t\treturn printJSONFiltered(cmd.OutOrStdout(), fallback, flags)\n\t\t\t}\n\t\t\treturn classifyAPIError(gerr)\n\t\t},\n\t}\n\treturn cmd\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":3055,"content_sha256":"e96d5ae010a2336d77e15eb28ac23d4b2039aee4ded32adb3bc67db50174e35f"},{"filename":"internal/cli/promoted_rentals.go","content":"// Copyright 2026 rderwin and contributors. Licensed under Apache-2.0. See LICENSE.\n\npackage cli\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/mvanhorn/printing-press-library/library/other/apartments/internal/apt\"\n\n\t\"github.com/spf13/cobra\"\n)\n\n// rentalsFlags holds every flag the rentals command (and its sync-search\n// sibling) consume — kept here so other commands can reuse the binding\n// helper.\ntype rentalsFlags struct {\n\tcity string\n\tstate string\n\tzip string\n\tbeds int\n\tbedsMin int\n\tstudio bool\n\tbaths int\n\tbathsMin int\n\tpriceMin int\n\tpriceMax int\n\tpets string\n\ttyp string\n\tpage int\n\tlimit int\n\tall bool\n}\n\nfunc (rf *rentalsFlags) toOptions() apt.SearchOptions {\n\treturn apt.SearchOptions{\n\t\tCity: rf.city,\n\t\tState: rf.state,\n\t\tZip: rf.zip,\n\t\tBeds: rf.beds,\n\t\tBedsMin: rf.bedsMin,\n\t\tStudio: rf.studio,\n\t\tBaths: rf.baths,\n\t\tBathsMin: rf.bathsMin,\n\t\tPriceMin: rf.priceMin,\n\t\tPriceMax: rf.priceMax,\n\t\tPets: rf.pets,\n\t\tType: rf.typ,\n\t\tPage: rf.page,\n\t}\n}\n\nfunc (rf *rentalsFlags) validate() error {\n\tif rf.pets != \"\" {\n\t\tswitch strings.ToLower(rf.pets) {\n\t\tcase \"any\", \"cat\", \"dog\", \"both\", \"none\":\n\t\tdefault:\n\t\t\treturn fmt.Errorf(\"invalid --pets %q: must be one of any|cat|dog|both|none\", rf.pets)\n\t\t}\n\t}\n\tif rf.typ != \"\" {\n\t\tswitch strings.ToLower(rf.typ) {\n\t\tcase \"apartment\", \"house\", \"condo\", \"townhome\":\n\t\tdefault:\n\t\t\treturn fmt.Errorf(\"invalid --type %q: must be one of apartment|house|condo|townhome\", rf.typ)\n\t\t}\n\t}\n\tif rf.beds > 0 && rf.bedsMin > 0 {\n\t\treturn fmt.Errorf(\"--beds and --beds-min are mutually exclusive\")\n\t}\n\tif rf.zip == \"\" && rf.city == \"\" && rf.state == \"\" {\n\t\treturn fmt.Errorf(\"provide --city + --state or --zip\")\n\t}\n\tif rf.limit \u003c= 0 {\n\t\trf.limit = 60\n\t}\n\treturn nil\n}\n\n// addRentalsFlags binds rentalsFlags to a cobra command. All commands\n// that build a SearchOptions reuse this helper so the flag surface is\n// identical across rentals, sync-search, and nearby.\nfunc addRentalsFlags(cmd *cobra.Command, rf *rentalsFlags) {\n\tcmd.Flags().StringVar(&rf.city, \"city\", \"\", \"City slug (lowercased, hyphens for spaces). Example: austin, new-york.\")\n\tcmd.Flags().StringVar(&rf.state, \"state\", \"\", \"Two-letter state abbreviation (lowercase).\")\n\tcmd.Flags().StringVar(&rf.zip, \"zip\", \"\", \"ZIP code; overrides --city/--state when set.\")\n\tcmd.Flags().IntVar(&rf.beds, \"beds\", 0, \"Exact bedroom count. Mutually exclusive with --beds-min.\")\n\tcmd.Flags().IntVar(&rf.bedsMin, \"beds-min\", 0, \"Minimum bedrooms. Mutually exclusive with --beds.\")\n\tcmd.Flags().BoolVar(&rf.studio, \"studio\", false, \"Match studios.\")\n\tcmd.Flags().IntVar(&rf.baths, \"baths\", 0, \"Exact bathroom count.\")\n\tcmd.Flags().IntVar(&rf.bathsMin, \"baths-min\", 0, \"Minimum bathrooms.\")\n\tcmd.Flags().IntVar(&rf.priceMin, \"price-min\", 0, \"Minimum monthly rent in USD.\")\n\tcmd.Flags().IntVar(&rf.priceMax, \"price-max\", 0, \"Maximum monthly rent in USD.\")\n\tcmd.Flags().StringVar(&rf.pets, \"pets\", \"\", \"Pet filter: any|cat|dog|both|none.\")\n\tcmd.Flags().StringVar(&rf.typ, \"type\", \"\", \"Property type: apartment|house|condo|townhome.\")\n\tcmd.Flags().IntVar(&rf.page, \"page\", 0, \"Page number (1-indexed; default 1).\")\n\tcmd.Flags().IntVar(&rf.limit, \"limit\", 60, \"Max placards to return.\")\n\tcmd.Flags().BoolVar(&rf.all, \"all\", false, \"Auto-paginate up to 5 pages.\")\n}\n\nfunc newRentalsCmd(flags *rootFlags) *cobra.Command {\n\trf := &rentalsFlags{}\n\n\tcmd := &cobra.Command{\n\t\tUse: \"rentals\",\n\t\tShort: \"Search apartments.com by city/state, beds, price, pets, etc.\",\n\t\tAnnotations: map[string]string{\"mcp:read-only\": \"true\"},\n\t\tExample: strings.Trim(`\n apartments-pp-cli rentals --city austin --state tx\n apartments-pp-cli rentals --city austin --state tx --beds 2 --price-max 2500 --pets dog --json\n apartments-pp-cli rentals --zip 78704 --beds-min 1 --price-min 1500 --price-max 2500\n apartments-pp-cli rentals --city austin --state tx --type house --beds 3 --all\n apartments-pp-cli rentals --city austin --state tx --beds 2 --pets dog --dry-run\n`, \"\\n\"),\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tif dryRunOK(flags) {\n\t\t\t\topts := rf.toOptions()\n\t\t\t\tfmt.Fprintln(cmd.OutOrStdout(), \"would GET:\", apt.BuildSearchURL(opts))\n\t\t\t\treturn nil\n\t\t\t}\n\t\t\tif err := rf.validate(); err != nil {\n\t\t\t\treturn usageErr(err)\n\t\t\t}\n\t\t\topts := rf.toOptions()\n\n\t\t\tc, err := flags.newClient()\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tvar collected []apt.Placard\n\t\t\tpage := opts.Page\n\t\t\tif page \u003c 1 {\n\t\t\t\tpage = 1\n\t\t\t}\n\t\t\tmaxPages := 1\n\t\t\tif rf.all {\n\t\t\t\tmaxPages = 5\n\t\t\t}\n\n\t\t\tfor i := 0; i \u003c maxPages; i++ {\n\t\t\t\tcurrent := opts\n\t\t\t\tif i == 0 && page == 1 {\n\t\t\t\t\tcurrent.Page = 0 // canonical: omit /1/\n\t\t\t\t} else {\n\t\t\t\t\tcurrent.Page = page\n\t\t\t\t}\n\t\t\t\tpath := apt.BuildSearchURL(current)\n\t\t\t\tdata, gerr := c.Get(path, nil)\n\t\t\t\tif gerr != nil {\n\t\t\t\t\tif i == 0 {\n\t\t\t\t\t\treturn classifyAPIError(gerr)\n\t\t\t\t\t}\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t\tplacards, perr := apt.ParsePlacards([]byte(data), c.BaseURL)\n\t\t\t\tif perr != nil {\n\t\t\t\t\treturn apiErr(perr)\n\t\t\t\t}\n\t\t\t\tfor _, p := range placards {\n\t\t\t\t\tp.SearchSlug = path\n\t\t\t\t\tcollected = append(collected, p)\n\t\t\t\t\tif len(collected) >= rf.limit {\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif len(collected) >= rf.limit || len(placards) == 0 {\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t\tif !rf.all {\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t\tpage++\n\t\t\t\ttime.Sleep(800 * time.Millisecond)\n\t\t\t}\n\t\t\tif len(collected) > rf.limit {\n\t\t\t\tcollected = collected[:rf.limit]\n\t\t\t}\n\t\t\tif collected == nil {\n\t\t\t\tcollected = []apt.Placard{}\n\t\t\t}\n\t\t\treturn printJSONFiltered(cmd.OutOrStdout(), collected, flags)\n\t\t},\n\t}\n\n\taddRentalsFlags(cmd, rf)\n\treturn cmd\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":5582,"content_sha256":"c3df8f5b64b574ac7604104b648aca0a97f440e398a42bc21c91ba5d3cb17378"},{"filename":"internal/cli/root.go","content":"// Copyright 2026 rderwin and contributors. Licensed under Apache-2.0. See LICENSE.\n// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.\n\npackage cli\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"strings\"\n\t\"text/tabwriter\"\n\t\"time\"\n\n\t\"github.com/mvanhorn/printing-press-library/library/other/apartments/internal/client\"\n\t\"github.com/mvanhorn/printing-press-library/library/other/apartments/internal/config\"\n\t\"github.com/spf13/cobra\"\n)\n\nvar version = \"1.0.0\"\n\ntype rootFlags struct {\n\tasJSON bool\n\tcompact bool\n\tcsv bool\n\tplain bool\n\tquiet bool\n\tdryRun bool\n\tnoCache bool\n\tnoInput bool\n\tyes bool\n\tagent bool\n\tselectFields string\n\tconfigPath string\n\tprofileName string\n\tdeliverSpec string\n\ttimeout time.Duration\n\trateLimit float64\n\tdataSource string\n\tfreshnessMeta any\n\n\t// deliverBuf captures command output when --deliver is set to a\n\t// non-stdout sink. Flushed to the sink after Execute returns.\n\tdeliverBuf *bytes.Buffer\n\tdeliverSink DeliverSink\n}\n\n// RootCmd returns the Cobra command tree without executing it. The MCP server\n// uses this to mirror every user-facing command as an agent tool.\nfunc RootCmd() *cobra.Command {\n\tvar flags rootFlags\n\treturn newRootCmd(&flags)\n}\n\n// Execute runs the CLI in non-interactive mode: never prompts, all values via flags or stdin.\nfunc Execute() error {\n\tvar flags rootFlags\n\trootCmd := newRootCmd(&flags)\n\n\terr := rootCmd.Execute()\n\tif err != nil && strings.Contains(err.Error(), \"unknown flag\") {\n\t\tmsg := err.Error()\n\t\t// Extract the flag name from the error message (e.g., \"unknown flag: --foob\")\n\t\tif idx := strings.Index(msg, \"unknown flag: \"); idx >= 0 {\n\t\t\tflagStr := strings.TrimSpace(msg[idx+len(\"unknown flag: \"):])\n\t\t\tif suggestion := suggestFlag(flagStr, rootCmd); suggestion != \"\" {\n\t\t\t\treturn fmt.Errorf(\"%w\\nhint: did you mean --%s?\", err, suggestion)\n\t\t\t}\n\t\t}\n\t}\n\tif err == nil && flags.deliverBuf != nil {\n\t\tif derr := Deliver(flags.deliverSink, flags.deliverBuf.Bytes(), flags.compact); derr != nil {\n\t\t\tfmt.Fprintf(os.Stderr, \"warning: deliver to %s:%s failed: %v\\n\", flags.deliverSink.Scheme, flags.deliverSink.Target, derr)\n\t\t\treturn derr\n\t\t}\n\t}\n\treturn err\n}\n\nfunc newRootCmd(flags *rootFlags) *cobra.Command {\n\trootCmd := &cobra.Command{\n\t\tUse: \"apartments-pp-cli\",\n\t\tShort: `Search Apartments.com rentals from the terminal with offline sync, ranking, diff, and shortlist workflows.`,\n\t\tLong: `Search Apartments.com rentals from the terminal with offline sync, ranking, diff, and shortlist workflows.\n\nHighlights (not in the official API docs):\n • watch Re-run a stored search and surface what's NEW, REMOVED, or PRICE-CHANGED since the last sync.\n • nearby Fan out a search across multiple cities, zips, or neighborhoods and return one ranked, deduped list.\n • value Rank synced listings by 12-month total cost (rent + pet rent + pet deposit + pet fee), filtered to your hard budget.\n • rank Rank synced listings by ratio metrics — price per square foot or price per bedroom.\n • compare Pivot 2–8 listings into a wide table — one column per listing — with computed $/sqft and amenity overlap.\n • drops List listings whose max-rent dropped by ≥N% within a time window.\n • stale Flag listings whose price and availability haven't changed in N days — often phantom or stuck.\n • phantoms Surface listings flagged by a three-signal join: 404 on re-fetch, dropped from saved-search results, or stale ≥45 days.\n • market Median, p10, p90 of rent and rent/sqft, pet-friendly share, by city/state and bed count.\n • history Time-series of every observation of one listing — rent, availability, status.\n • digest Single-shot composer: new + removed + price-drops + top-5 by $/sqft + stale + phantom flags for one saved search over N days.\n • floorplans Rank per-floor-plan rent/sqft across synced listings — same building can yield 4 plans at different ratios.\n • must-have Filter synced listings to those whose amenities array contains ALL listed terms via FTS5.\n • shortlist Tag-based local shortlist table; add/show/remove listings with notes and tags.\n\nAgent mode: add --agent to any command for JSON output + non-interactive mode.\nHealth check: run 'apartments-pp-cli doctor' to verify auth and connectivity.\nSee README.md or the bundled SKILL.md for recipes.`,\n\t\tSilenceUsage: true,\n\t\tVersion: version,\n\t}\n\trootCmd.SetVersionTemplate(\"apartments-pp-cli {{ .Version }}\\n\")\n\n\trootCmd.PersistentFlags().BoolVar(&flags.asJSON, \"json\", false, \"Output as JSON\")\n\trootCmd.PersistentFlags().BoolVar(&flags.compact, \"compact\", false, \"Return only key fields (id, name, status, timestamps) for minimal token usage\")\n\trootCmd.PersistentFlags().BoolVar(&flags.csv, \"csv\", false, \"Output as CSV (table and array responses)\")\n\trootCmd.PersistentFlags().BoolVar(&flags.plain, \"plain\", false, \"Output as plain tab-separated text\")\n\trootCmd.PersistentFlags().BoolVar(&flags.quiet, \"quiet\", false, \"Bare output, one value per line\")\n\trootCmd.PersistentFlags().StringVar(&flags.configPath, \"config\", \"\", \"Config file path\")\n\trootCmd.PersistentFlags().DurationVar(&flags.timeout, \"timeout\", 30*time.Second, \"Request timeout\")\n\trootCmd.PersistentFlags().BoolVar(&flags.dryRun, \"dry-run\", false, \"Show request without sending\")\n\trootCmd.PersistentFlags().BoolVar(&flags.noCache, \"no-cache\", false, \"Bypass response cache\")\n\trootCmd.PersistentFlags().BoolVar(&flags.noInput, \"no-input\", false, \"Disable all interactive prompts (for CI/agents)\")\n\trootCmd.PersistentFlags().StringVar(&flags.selectFields, \"select\", \"\", \"Comma-separated fields to include in output (e.g. --select id,name,status)\")\n\trootCmd.PersistentFlags().BoolVar(&flags.yes, \"yes\", false, \"Skip confirmation prompts (for agents and scripts)\")\n\trootCmd.PersistentFlags().BoolVar(&noColor, \"no-color\", false, \"Disable colored output\")\n\trootCmd.PersistentFlags().BoolVar(&humanFriendly, \"human-friendly\", false, \"Enable colored output and rich formatting\")\n\trootCmd.PersistentFlags().BoolVar(&flags.agent, \"agent\", false, \"Set all agent-friendly defaults (--json --compact --no-input --no-color --yes)\")\n\trootCmd.PersistentFlags().StringVar(&flags.dataSource, \"data-source\", \"auto\", \"Data source for read commands: auto (live with local fallback), live (API only), local (synced data only)\")\n\trootCmd.PersistentFlags().StringVar(&flags.profileName, \"profile\", \"\", \"Apply values from a saved profile (see 'apartments-pp-cli profile list')\")\n\trootCmd.PersistentFlags().StringVar(&flags.deliverSpec, \"deliver\", \"\", \"Route output to a sink: stdout (default), file:\u003cpath>, webhook:\u003curl>\")\n\trootCmd.PersistentFlags().Float64Var(&flags.rateLimit, \"rate-limit\", 2, \"Max requests per second (0 to disable, default 2 for sniffed APIs)\")\n\n\trootCmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error {\n\t\tif flags.deliverSpec != \"\" {\n\t\t\tsink, err := ParseDeliverSink(flags.deliverSpec)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tflags.deliverSink = sink\n\t\t\tif sink.Scheme != \"stdout\" && sink.Scheme != \"\" {\n\t\t\t\tflags.deliverBuf = &bytes.Buffer{}\n\t\t\t\tcmd.SetOut(io.MultiWriter(os.Stdout, flags.deliverBuf))\n\t\t\t}\n\t\t}\n\t\tif flags.profileName != \"\" {\n\t\t\tprofile, err := GetProfile(flags.profileName)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tif profile == nil {\n\t\t\t\tavailable := ListProfileNames()\n\t\t\t\tif len(available) == 0 {\n\t\t\t\t\treturn fmt.Errorf(\"profile %q not found (no profiles saved yet; run '%s profile save \u003cname> --\u003cflag> \u003cvalue>')\", flags.profileName, cmd.Root().Name())\n\t\t\t\t}\n\t\t\t\treturn fmt.Errorf(\"profile %q not found; available: %s\", flags.profileName, strings.Join(available, \", \"))\n\t\t\t}\n\t\t\tif err := ApplyProfileToFlags(cmd, profile); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tif flags.agent {\n\t\t\tif !cmd.Flags().Changed(\"json\") {\n\t\t\t\tflags.asJSON = true\n\t\t\t}\n\t\t\tif !cmd.Flags().Changed(\"compact\") {\n\t\t\t\tflags.compact = true\n\t\t\t}\n\t\t\tif !cmd.Flags().Changed(\"no-input\") {\n\t\t\t\tflags.noInput = true\n\t\t\t}\n\t\t\tif !cmd.Flags().Changed(\"yes\") {\n\t\t\t\tflags.yes = true\n\t\t\t}\n\t\t\tif !cmd.Flags().Changed(\"no-color\") {\n\t\t\t\tnoColor = true\n\t\t\t}\n\t\t}\n\t\tswitch flags.dataSource {\n\t\tcase \"auto\", \"live\", \"local\":\n\t\t\t// valid\n\t\tdefault:\n\t\t\treturn fmt.Errorf(\"invalid --data-source value %q: must be auto, live, or local\", flags.dataSource)\n\t\t}\n\t\t// Auto-refresh stale local caches before serving read commands.\n\t\t// Looks up the current command path in readCommandResources and\n\t\t// consults cliutil.EnsureFresh against sync_state. When stale,\n\t\t// runs a bounded API refresh. Failures become stderr warnings;\n\t\t// the command proceeds with the stale cache either way.\n\t\tif resources, isRead := readCommandResources[cmd.CommandPath()]; isRead {\n\t\t\tflags.freshnessMeta = autoRefreshIfStale(cmd.Context(), flags, resources)\n\t\t}\n\t\treturn nil\n\t}\n\trootCmd.AddCommand(newDoctorCmd(flags))\n\trootCmd.AddCommand(newAgentContextCmd(rootCmd))\n\trootCmd.AddCommand(newProfileCmd(flags))\n\trootCmd.AddCommand(newFeedbackCmd(flags))\n\trootCmd.AddCommand(newWhichCmd(flags))\n\trootCmd.AddCommand(newExportCmd(flags))\n\trootCmd.AddCommand(newImportCmd(flags))\n\trootCmd.AddCommand(newSyncCmd(flags))\n\trootCmd.AddCommand(newWorkflowCmd(flags))\n\trootCmd.AddCommand(newAPICmd(flags))\n\trootCmd.AddCommand(newVersionCliCmd())\n\n\trootCmd.AddCommand(newListingCmd(flags))\n\trootCmd.AddCommand(newRentalsCmd(flags))\n\trootCmd.AddCommand(newAptSyncCmd(flags))\n\trootCmd.AddCommand(newWatchCmd(flags))\n\trootCmd.AddCommand(newNearbyCmd(flags))\n\trootCmd.AddCommand(newValueCmd(flags))\n\trootCmd.AddCommand(newRankCmd(flags))\n\trootCmd.AddCommand(newCompareCmd(flags))\n\trootCmd.AddCommand(newDropsCmd(flags))\n\trootCmd.AddCommand(newStaleCmd(flags))\n\trootCmd.AddCommand(newPhantomsCmd(flags))\n\trootCmd.AddCommand(newMarketCmd(flags))\n\trootCmd.AddCommand(newHistoryCmd(flags))\n\trootCmd.AddCommand(newDigestCmd(flags))\n\trootCmd.AddCommand(newFloorplansCmd(flags))\n\trootCmd.AddCommand(newMustHaveCmd(flags))\n\trootCmd.AddCommand(newShortlistCmd(flags))\n\n\treturn rootCmd\n}\n\nfunc ExitCode(err error) int {\n\tvar codeErr *cliError\n\tif As(err, &codeErr) {\n\t\treturn codeErr.code\n\t}\n\treturn 1\n}\n\nfunc (f *rootFlags) newClient() (*client.Client, error) {\n\tcfg, err := config.Load(f.configPath)\n\tif err != nil {\n\t\treturn nil, configErr(err)\n\t}\n\tc := client.New(cfg, f.timeout, f.rateLimit)\n\tc.DryRun = f.dryRun\n\tc.NoCache = f.noCache\n\treturn c, nil\n}\n\nfunc (f *rootFlags) printJSON(w *cobra.Command, v any) error {\n\tenc := json.NewEncoder(w.OutOrStdout())\n\tenc.SetIndent(\"\", \" \")\n\treturn enc.Encode(v)\n}\n\nfunc (f *rootFlags) printTable(w *cobra.Command, headers []string, rows [][]string) error {\n\tif f.asJSON {\n\t\treturn fmt.Errorf(\"use printJSON for JSON output\")\n\t}\n\ttw := tabwriter.NewWriter(w.OutOrStdout(), 2, 4, 2, ' ', 0)\n\theader := \"\"\n\tfor i, h := range headers {\n\t\tif i > 0 {\n\t\t\theader += \"\\t\"\n\t\t}\n\t\theader += h\n\t}\n\tfmt.Fprintln(tw, header)\n\tfor _, row := range rows {\n\t\tline := \"\"\n\t\tfor i, cell := range row {\n\t\t\tif i > 0 {\n\t\t\t\tline += \"\\t\"\n\t\t\t}\n\t\t\tline += cell\n\t\t}\n\t\tfmt.Fprintln(tw, line)\n\t}\n\treturn tw.Flush()\n}\n\nfunc newVersionCliCmd() *cobra.Command {\n\treturn &cobra.Command{\n\t\tUse: \"version\",\n\t\tShort: \"Print version\",\n\t\tRun: func(cmd *cobra.Command, args []string) {\n\t\t\tfmt.Printf(\"apartments-pp-cli %s\\n\", version)\n\t\t},\n\t}\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":11334,"content_sha256":"c6a1c1191db2929f229e017b935baea5af37a958e35970038b6c08566cd08451"},{"filename":"internal/cli/sync.go","content":"// Copyright 2026 rderwin and contributors. Licensed under Apache-2.0. See LICENSE.\n// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.\n\npackage cli\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"github.com/mvanhorn/printing-press-library/library/other/apartments/internal/store\"\n\t\"github.com/spf13/cobra\"\n\t\"net/url\"\n\t\"os\"\n\t\"regexp\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n)\n\n// syncResult holds the outcome of syncing a single resource.\ntype syncResult struct {\n\tResource string\n\tCount int\n\tErr error\n\tWarn error\n\tDuration time.Duration\n}\n\nfunc newSyncCmd(flags *rootFlags) *cobra.Command {\n\tvar resources []string\n\tvar full bool\n\tvar since string\n\tvar concurrency int\n\tvar dbPath string\n\tvar maxPages int\n\tvar latestOnly bool\n\tvar strict bool\n\n\tcmd := &cobra.Command{\n\t\tUse: \"sync\",\n\t\tShort: \"Sync API data to local SQLite for offline search and analysis\",\n\t\tLong: `Sync data from the API into a local SQLite database. Supports resumable\nincremental sync (only fetches new data since last sync) and full resync.\nOnce synced, use the 'search' command for instant full-text search.\n\nExit codes & warnings:\n Resources the API denies access to (HTTP 403, or HTTP 400 with an\n access-policy body) are reported as warnings rather than failing the\n run. In --json mode each is emitted as a {\"event\":\"sync_warning\",...}\n line carrying status, reason, and message fields, and a final\n {\"event\":\"sync_summary\",...} aggregates the run.\n\n Exit 0 when at least one resource synced and no resource flagged in\n the spec as critical (x-critical: true) failed; non-critical failures\n emit {\"event\":\"sync_warning\",\"reason\":\"exit_policy_default_changed\",\n ...} so callers can detect that a partial failure was tolerated. Pass\n --strict to exit non-zero on any per-resource failure. Exit is always\n non-zero when every selected resource failed, regardless of --strict.`,\n\t\tExample: ` # Sync all resources\n apartments-pp-cli sync\n\n # Sync specific resources only\n apartments-pp-cli sync --resources channels,messages\n\n # Full resync (ignore previous checkpoint)\n apartments-pp-cli sync --full\n\n # Incremental sync: only records from the last 7 days\n apartments-pp-cli sync --since 7d\n\n # Parallel sync with 8 workers\n apartments-pp-cli sync --concurrency 8\n\n # Latest-only: refresh head of each resource, no historical backfill\n apartments-pp-cli sync --latest-only`,\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tc, err := flags.newClient()\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tc.NoCache = true\n\n\t\t\tif dbPath == \"\" {\n\t\t\t\tdbPath = defaultDBPath(\"apartments-pp-cli\")\n\t\t\t}\n\n\t\t\tdb, err := store.OpenWithContext(cmd.Context(), dbPath)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"opening local database: %w\", err)\n\t\t\t}\n\t\t\tdefer db.Close()\n\n\t\t\t// If no specific resources, sync top-level resources\n\t\t\tif len(resources) == 0 {\n\t\t\t\tresources = defaultSyncResources()\n\t\t\t}\n\n\t\t\t// --full: clear all sync cursors before starting\n\t\t\tif full {\n\t\t\t\tfor _, resource := range resources {\n\t\t\t\t\t_ = db.SaveSyncState(resource, \"\", 0)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// --latest-only narrows to the first page of each resource\n\t\t\t// ignoring the historical resume cursor. We cap maxPages at 1\n\t\t\t// here rather than re-interpreting it downstream so the rest\n\t\t\t// of the sync loop stays oblivious. Mutually-useful with\n\t\t\t// --since: if the user set --since, that threshold still wins\n\t\t\t// and we don't short-circuit historical context they asked for.\n\t\t\tif latestOnly {\n\t\t\t\tif since == \"\" {\n\t\t\t\t\tmaxPages = 1\n\t\t\t\t\t// Clear the cursor so we start from the head each time;\n\t\t\t\t\t// the goal of --latest-only is \"refresh the top\" not\n\t\t\t\t\t// \"resume from wherever I left off\".\n\t\t\t\t\tfor _, resource := range resources {\n\t\t\t\t\t\texisting, _, _, _ := db.GetSyncState(resource)\n\t\t\t\t\t\tif existing != \"\" {\n\t\t\t\t\t\t\t_ = db.SaveSyncState(resource, \"\", 0)\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t} else if humanFriendly {\n\t\t\t\t\tfmt.Fprintln(os.Stderr, \"warning: --latest-only ignored because --since is set; --since takes precedence\")\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Resolve --since into an RFC3339 timestamp\n\t\t\tsinceTS := \"\"\n\t\t\tif since != \"\" {\n\t\t\t\tts, err := parseSinceDuration(since)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"invalid --since value %q: %w\", since, err)\n\t\t\t\t}\n\t\t\t\tsinceTS = ts.Format(time.RFC3339)\n\t\t\t}\n\n\t\t\t// Worker pool: produce resources, N workers consume\n\t\t\tif concurrency \u003c 1 {\n\t\t\t\tconcurrency = 4\n\t\t\t}\n\n\t\t\tstarted := time.Now()\n\t\t\twork := make(chan string, len(resources))\n\t\t\tresults := make(chan syncResult, len(resources))\n\n\t\t\tvar wg sync.WaitGroup\n\t\t\tfor i := 0; i \u003c concurrency; i++ {\n\t\t\t\twg.Add(1)\n\t\t\t\tgo func() {\n\t\t\t\t\tdefer wg.Done()\n\t\t\t\t\tfor resource := range work {\n\t\t\t\t\t\tres := syncResource(c, db, resource, sinceTS, full, maxPages)\n\t\t\t\t\t\tresults \u003c- res\n\t\t\t\t\t}\n\t\t\t\t}()\n\t\t\t}\n\n\t\t\t// Enqueue all resources\n\t\t\tfor _, resource := range resources {\n\t\t\t\twork \u003c- resource\n\t\t\t}\n\t\t\tclose(work)\n\n\t\t\t// Collect results in a separate goroutine\n\t\t\tgo func() {\n\t\t\t\twg.Wait()\n\t\t\t\tclose(results)\n\t\t\t}()\n\n\t\t\tvar totalSynced int\n\t\t\tvar errCount int\n\t\t\tvar criticalErrCount int\n\t\t\tvar warnCount int\n\t\t\tvar successCount int\n\t\t\tfor res := range results {\n\t\t\t\tif res.Err != nil {\n\t\t\t\t\tif humanFriendly {\n\t\t\t\t\t\tfmt.Fprintf(os.Stderr, \" %s: error: %v\\n\", res.Resource, res.Err)\n\t\t\t\t\t}\n\t\t\t\t\terrCount++\n\t\t\t\t\tif criticalResources[res.Resource] {\n\t\t\t\t\t\tcriticalErrCount++\n\t\t\t\t\t}\n\t\t\t\t} else if res.Warn != nil {\n\t\t\t\t\tif humanFriendly {\n\t\t\t\t\t\tfmt.Fprintf(os.Stderr, \" %s: warning: %v\\n\", res.Resource, res.Warn)\n\t\t\t\t\t}\n\t\t\t\t\twarnCount++\n\t\t\t\t} else {\n\t\t\t\t\tif humanFriendly {\n\t\t\t\t\t\tfmt.Fprintf(os.Stderr, \" %s: %d synced (done)\\n\", res.Resource, res.Count)\n\t\t\t\t\t}\n\t\t\t\t\ttotalSynced += res.Count\n\t\t\t\t\tsuccessCount++\n\t\t\t\t}\n\t\t\t}\n\n\t\t\telapsed := time.Since(started)\n\t\t\ttotalResources := successCount + warnCount + errCount\n\t\t\tif humanFriendly {\n\t\t\t\tif warnCount > 0 {\n\t\t\t\t\tfmt.Fprintf(os.Stderr, \"Sync complete: %d records across %d resources (%d warned, %.1fs)\\n\",\n\t\t\t\t\t\ttotalSynced, totalResources, warnCount, elapsed.Seconds())\n\t\t\t\t} else {\n\t\t\t\t\tfmt.Fprintf(os.Stderr, \"Sync complete: %d records across %d resources (%.1fs)\\n\",\n\t\t\t\t\t\ttotalSynced, totalResources, elapsed.Seconds())\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tfmt.Fprintf(os.Stdout, `{\"event\":\"sync_summary\",\"total_records\":%d,\"resources\":%d,\"success\":%d,\"warned\":%d,\"errored\":%d,\"duration_ms\":%d}`+\"\\n\",\n\t\t\t\t\ttotalSynced, totalResources, successCount, warnCount, errCount, elapsed.Milliseconds())\n\t\t\t}\n\n\t\t\t// Exit-code policy:\n\t\t\t// 1. --strict + any error -> non-zero (legacy contract)\n\t\t\t// 2. any critical failure -> non-zero regardless of --strict\n\t\t\t// 3. nothing synced -> non-zero (preserves \"all-warned\" / \"all-errored\" exit)\n\t\t\t// 4. otherwise -> exit 0 (any data synced + no critical failed)\n\t\t\t// When branch 4 suppresses what branch 1 would have rejected, emit a\n\t\t\t// one-shot sync_warning with reason \"exit_policy_default_changed\" so\n\t\t\t// CI scripts that depend on $? != 0 can discover the contract change\n\t\t\t// without reading the CHANGELOG.\n\t\t\tif strict && errCount > 0 {\n\t\t\t\treturn fmt.Errorf(\"%d resource(s) failed to sync\", errCount)\n\t\t\t}\n\t\t\tif criticalErrCount > 0 {\n\t\t\t\treturn fmt.Errorf(\"%d critical resource(s) failed to sync\", criticalErrCount)\n\t\t\t}\n\t\t\tif successCount == 0 {\n\t\t\t\tif warnCount > 0 && errCount == 0 {\n\t\t\t\t\treturn fmt.Errorf(\"%d resource(s) skipped due to insufficient access\", warnCount)\n\t\t\t\t}\n\t\t\t\tif errCount > 0 {\n\t\t\t\t\treturn fmt.Errorf(\"%d resource(s) failed to sync\", errCount)\n\t\t\t\t}\n\t\t\t}\n\t\t\tif errCount > 0 && !strict && criticalErrCount == 0 && successCount > 0 {\n\t\t\t\tif !humanFriendly {\n\t\t\t\t\tmsg := fmt.Sprintf(\"%d resource(s) failed but exit code is 0 because the new default treats non-critical failures as warnings. Pass --strict to restore the old behavior, or annotate critical resources with x-critical: true. See CHANGELOG.\", errCount)\n\t\t\t\t\tfmt.Fprintf(os.Stdout, `{\"event\":\"sync_warning\",\"reason\":\"exit_policy_default_changed\",\"errored\":%d,\"message\":\"%s\"}`+\"\\n\",\n\t\t\t\t\t\terrCount, strings.ReplaceAll(msg, `\"`, `\\\"`))\n\t\t\t\t} else {\n\t\t\t\t\tfmt.Fprintf(os.Stderr, \"warning: %d resource(s) failed but exit code is 0 because the new default treats non-critical failures as warnings. Pass --strict to restore the old behavior, or annotate critical resources with x-critical: true.\\n\", errCount)\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn nil\n\t\t},\n\t}\n\n\tcmd.Flags().StringSliceVar(&resources, \"resources\", nil, \"Comma-separated resource types to sync\")\n\tcmd.Flags().BoolVar(&full, \"full\", false, \"Full resync (ignore previous checkpoint)\")\n\tcmd.Flags().StringVar(&since, \"since\", \"\", \"Incremental sync duration (e.g. 7d, 24h, 1w, 30m)\")\n\tcmd.Flags().IntVar(&concurrency, \"concurrency\", 4, \"Number of parallel sync workers\")\n\tcmd.Flags().StringVar(&dbPath, \"db\", \"\", \"Database path (default: ~/.local/share/apartments-pp-cli/data.db)\")\n\tcmd.Flags().IntVar(&maxPages, \"max-pages\", 100, \"Maximum pages to fetch per resource (0 = unlimited; cap-hit emits a sync_warning event)\")\n\tcmd.Flags().BoolVar(&latestOnly, \"latest-only\", false, \"Refresh head of each resource only; clears resume cursor and caps pages at 1. Mutually exclusive with --since (--since wins).\")\n\tcmd.Flags().BoolVar(&strict, \"strict\", false, \"Exit non-zero on any per-resource failure (default: only critical failures or all-resource failure exit non-zero).\")\n\n\treturn cmd\n}\n\n// syncResource handles the full paginated sync of a single resource.\n// It resumes from the last cursor unless sinceTS or full mode overrides it.\nfunc syncResource(c interface {\n\tGet(string, map[string]string) (json.RawMessage, error)\n\tRateLimit() float64\n}, db *store.Store, resource, sinceTS string, full bool, maxPages int) syncResult {\n\tstarted := time.Now()\n\n\tif !humanFriendly {\n\t\tfmt.Fprintf(os.Stdout, `{\"event\":\"sync_start\",\"resource\":\"%s\"}`+\"\\n\", resource)\n\t}\n\n\tpath, err := syncResourcePath(resource)\n\tif err != nil {\n\t\treturn syncResult{Resource: resource, Err: err, Duration: time.Since(started)}\n\t}\n\tvar totalCount int\n\n\t// Resume cursor from sync_state (unless --full cleared it)\n\texistingCursor, lastSynced, _, _ := db.GetSyncState(resource)\n\n\t// Determine the since param value:\n\t// 1. Explicit --since flag takes priority\n\t// 2. Otherwise use last_synced_at from sync_state for incremental sync\n\tsinceParam := determineSinceParam()\n\teffectiveSince := sinceTS\n\tif effectiveSince == \"\" && !lastSynced.IsZero() && !full {\n\t\teffectiveSince = lastSynced.Format(time.RFC3339)\n\t}\n\n\tcursor := existingCursor\n\tpageSize := determinePaginationDefaults()\n\n\tvar progressCount int64\n\tpagesFetched := 0\n\tlastNextCursor := \"\"\n\t// extractFailureTotal accumulates per-item primary-key extraction\n\t// misses across pages within this resource sync. Resource-level\n\t// concurrency is 1 (one goroutine per resource via the work channel)\n\t// so this counter cannot race. We emit one primary_key_unresolved\n\t// sync_anomaly per resource per run when there's at least one miss\n\t// (rate-limited via the anomalyEmitted flag) and a roll-up\n\t// all_items_failed_id_extraction event when 100% of a single page\n\t// failed extraction.\n\tvar extractFailureTotal int\n\tvar consumedTotal int\n\tanomalyEmitted := false\n\n\tfor {\n\t\tparams := map[string]string{}\n\n\t\t// Set page size\n\t\tparams[pageSize.limitParam] = strconv.Itoa(pageSize.limit)\n\n\t\t// Set cursor for resume\n\t\tif cursor != \"\" {\n\t\t\tparams[pageSize.cursorParam] = cursor\n\t\t}\n\n\t\t// Set since filter\n\t\tif effectiveSince != \"\" {\n\t\t\tparams[sinceParam] = effectiveSince\n\t\t}\n\n\t\tdata, err := c.Get(path, params)\n\t\tif err != nil {\n\t\t\tif w, ok := isSyncAccessWarning(err); ok {\n\t\t\t\tif !humanFriendly {\n\t\t\t\t\tfmt.Fprintf(os.Stdout, `{\"event\":\"sync_warning\",\"resource\":\"%s\",\"status\":%d,\"reason\":\"%s\",\"message\":\"%s\"}`+\"\\n\",\n\t\t\t\t\t\tresource, w.Status, w.Reason, strings.ReplaceAll(w.Message, `\"`, `\\\"`))\n\t\t\t\t}\n\t\t\t\treturn syncResult{Resource: resource, Count: totalCount, Warn: fmt.Errorf(\"skipped %s: %s\", resource, w.Reason), Duration: time.Since(started)}\n\t\t\t}\n\t\t\tif !humanFriendly {\n\t\t\t\tfmt.Fprintf(os.Stdout, `{\"event\":\"sync_error\",\"resource\":\"%s\",\"error\":\"%s\"}`+\"\\n\", resource, strings.ReplaceAll(err.Error(), `\"`, `\\\"`))\n\t\t\t}\n\t\t\treturn syncResult{Resource: resource, Count: totalCount, Err: fmt.Errorf(\"fetching %s: %w\", resource, err), Duration: time.Since(started)}\n\t\t}\n\n\t\t// Try to extract items from the response.\n\t\t// Strategy: try array first, then common wrapper keys.\n\t\titems, nextCursor, hasMore := extractPageItems(data, pageSize.cursorParam)\n\n\t\tif len(items) == 0 {\n\t\t\t// Single object response - try to store as-is\n\t\t\tif err := upsertSingleObject(db, resource, data); err != nil {\n\t\t\t\tif !humanFriendly {\n\t\t\t\t\tfmt.Fprintf(os.Stdout, `{\"event\":\"sync_error\",\"resource\":\"%s\",\"error\":\"%s\"}`+\"\\n\", resource, strings.ReplaceAll(err.Error(), `\"`, `\\\"`))\n\t\t\t\t}\n\t\t\t\treturn syncResult{Resource: resource, Err: err, Duration: time.Since(started)}\n\t\t\t}\n\t\t\ttotalCount++\n\t\t\tbreak\n\t\t}\n\n\t\t// Batch upsert all items from this page. UpsertBatch returns\n\t\t// (stored, extractFailures, err): stored counts rows actually\n\t\t// landed; extractFailures counts items that survived JSON\n\t\t// unmarshal but had no extractable primary key (templated\n\t\t// IDField AND generic fallback both missed). Tracking these\n\t\t// separately lets us emit precise sync_anomaly events: a\n\t\t// roll-up \"all_items_failed_id_extraction\" when an entire\n\t\t// page yields zero stored, a per-resource\n\t\t// \"primary_key_unresolved\" the first time any single item\n\t\t// fails, and the F4b \"stored_count_zero_after_extraction\"\n\t\t// probe when extraction succeeded but rows still didn't land.\n\t\tstored, extractFailures, err := upsertResourceBatch(db, resource, items)\n\t\tif err != nil {\n\t\t\tif !humanFriendly {\n\t\t\t\tfmt.Fprintf(os.Stdout, `{\"event\":\"sync_error\",\"resource\":\"%s\",\"error\":\"%s\"}`+\"\\n\", resource, strings.ReplaceAll(err.Error(), `\"`, `\\\"`))\n\t\t\t}\n\t\t\treturn syncResult{Resource: resource, Count: totalCount, Err: fmt.Errorf(\"upserting batch for %s: %w\", resource, err), Duration: time.Since(started)}\n\t\t}\n\n\t\tconsumedTotal += len(items)\n\t\textractFailureTotal += extractFailures\n\n\t\t// When a non-empty page yielded zero stored rows, the API\n\t\t// returned items in a shape we couldn't extract IDs from —\n\t\t// likely scalar IDs (Firebase /topstories.json, GitHub user-\n\t\t// repo lists) where the spec author should declare a hydration\n\t\t// pattern, or an unrecognized primary-key field name.\n\t\tif len(items) > 0 && stored == 0 {\n\t\t\tif humanFriendly {\n\t\t\t\tfmt.Fprintf(os.Stderr, \"warning: %s returned %d items but stored 0 — the local store will be empty for this resource. Likely cause: scalar item shape rather than objects with extractable IDs.\\n\", resource, len(items))\n\t\t\t} else {\n\t\t\t\tfmt.Fprintf(os.Stdout, `{\"event\":\"sync_anomaly\",\"resource\":\"%s\",\"consumed\":%d,\"stored\":0,\"reason\":\"all_items_failed_id_extraction\"}`+\"\\n\", resource, len(items))\n\t\t\t}\n\t\t\tanomalyEmitted = true\n\t\t} else if extractFailures > 0 && !anomalyEmitted {\n\t\t\t// Per-item primary-key resolution failure but at least one\n\t\t\t// item landed — emit one structured warning per resource per\n\t\t\t// sync run so users see the first occurrence of silent drops\n\t\t\t// instead of waiting for total failure.\n\t\t\tif humanFriendly {\n\t\t\t\tfmt.Fprintf(os.Stderr, \"\\nwarning: %s had %d item(s) on this page with no extractable primary key — those rows were dropped silently. Annotate the spec with x-resource-id to fix.\\n\", resource, extractFailures)\n\t\t\t} else {\n\t\t\t\tfmt.Fprintf(os.Stdout, `{\"event\":\"sync_anomaly\",\"resource\":\"%s\",\"consumed\":%d,\"stored\":%d,\"count\":%d,\"reason\":\"primary_key_unresolved\"}`+\"\\n\", resource, len(items), stored, extractFailures)\n\t\t\t}\n\t\t\tanomalyEmitted = true\n\t\t}\n\n\t\ttotalCount += stored\n\t\tatomic.AddInt64(&progressCount, int64(stored))\n\n\t\t// Progress reporting (include rate limit info when active)\n\t\tcurrentRate := c.RateLimit()\n\t\tif humanFriendly {\n\t\t\tif currentRate > 0 {\n\t\t\t\tfmt.Fprintf(os.Stderr, \"\\r %s: %d synced [%.1f req/s]\", resource, atomic.LoadInt64(&progressCount), currentRate)\n\t\t\t} else {\n\t\t\t\tfmt.Fprintf(os.Stderr, \"\\r %s: %d synced\", resource, atomic.LoadInt64(&progressCount))\n\t\t\t}\n\t\t} else {\n\t\t\tif currentRate > 0 {\n\t\t\t\tfmt.Fprintf(os.Stdout, `{\"event\":\"sync_progress\",\"resource\":\"%s\",\"fetched\":%d,\"rate_rps\":%.1f}`+\"\\n\", resource, atomic.LoadInt64(&progressCount), currentRate)\n\t\t\t} else {\n\t\t\t\tfmt.Fprintf(os.Stdout, `{\"event\":\"sync_progress\",\"resource\":\"%s\",\"fetched\":%d}`+\"\\n\", resource, atomic.LoadInt64(&progressCount))\n\t\t\t}\n\t\t}\n\n\t\t// Save cursor after each page for resumability\n\t\tif err := db.SaveSyncState(resource, nextCursor, totalCount); err != nil {\n\t\t\t// Non-fatal: log and continue\n\t\t\tfmt.Fprintf(os.Stderr, \"\\nwarning: failed to save sync state for %s: %v\\n\", resource, err)\n\t\t}\n\n\t\tpagesFetched++\n\n\t\t// Enforce page ceiling to prevent runaway syncs on large-catalog APIs\n\t\tif maxPages > 0 && pagesFetched >= maxPages {\n\t\t\tif humanFriendly {\n\t\t\t\tfmt.Fprintf(os.Stderr, \"\\n %s: reached --max-pages limit (%d pages, %d items)\\n\", resource, maxPages, totalCount)\n\t\t\t} else {\n\t\t\t\tfmt.Fprintf(os.Stdout, `{\"event\":\"sync_warning\",\"resource\":\"%s\",\"reason\":\"max_pages_cap_hit\",\"message\":\"reached --max-pages cap of %d; data may be truncated. Re-run with --max-pages 0 (unlimited) or higher to verify.\"}`+\"\\n\", resource, maxPages)\n\t\t\t}\n\t\t\tbreak\n\t\t}\n\n\t\t// Sticky-cursor detector: if the API echoes the same next cursor across\n\t\t// consecutive pages without advancing, abort to prevent burning the\n\t\t// --max-pages budget on a non-terminating loop. Checked AFTER the cap\n\t\t// guard so cap-hit takes precedence; checked BEFORE the natural-end\n\t\t// check below because the natural-end check would not catch a sticky\n\t\t// non-empty cursor on its own.\n\t\tif nextCursor != \"\" && nextCursor == lastNextCursor {\n\t\t\tif humanFriendly {\n\t\t\t\tfmt.Fprintf(os.Stderr, \"\\n %s: API returned the same next cursor across two pages; aborting to prevent budget waste.\\n\", resource)\n\t\t\t} else {\n\t\t\t\tfmt.Fprintf(os.Stdout, `{\"event\":\"sync_warning\",\"resource\":\"%s\",\"reason\":\"stuck_pagination\",\"message\":\"API returned the same next cursor across two pages for resource %s; aborting to prevent budget waste.\"}`+\"\\n\", resource, resource)\n\t\t\t}\n\t\t\tbreak\n\t\t}\n\t\tlastNextCursor = nextCursor\n\n\t\t// Determine if there are more pages\n\t\tif !hasMore || len(items) \u003c pageSize.limit || nextCursor == \"\" {\n\t\t\tbreak\n\t\t}\n\n\t\tcursor = nextCursor\n\t}\n\n\t// Final sync state: clear cursor (sync is complete), update count\n\t_ = db.SaveSyncState(resource, \"\", totalCount)\n\n\t// F4b symptom probe: if items were consumed and successfully\n\t// extracted (extractFailures \u003c consumed) but nothing landed in\n\t// the store, something downstream of extraction silently dropped\n\t// rows — FTS5 trigger error, transaction rollback, character\n\t// encoding. Emit a sync_anomaly so the symptom is visible the\n\t// next time it recurs; the underlying root cause is held out for\n\t// controlled repro.\n\tif consumedTotal > 0 && totalCount == 0 && extractFailureTotal \u003c consumedTotal {\n\t\tif humanFriendly {\n\t\t\tfmt.Fprintf(os.Stderr, \"\\nwarning: %s consumed %d items, extracted %d primary keys, but stored 0 rows — extraction succeeded yet nothing landed. Investigate FTS triggers / transaction rollback / encoding.\\n\", resource, consumedTotal, consumedTotal-extractFailureTotal)\n\t\t} else {\n\t\t\tfmt.Fprintf(os.Stdout, `{\"event\":\"sync_anomaly\",\"resource\":\"%s\",\"consumed\":%d,\"stored\":0,\"extract_failures\":%d,\"reason\":\"stored_count_zero_after_extraction\"}`+\"\\n\", resource, consumedTotal, extractFailureTotal)\n\t\t}\n\t}\n\n\tif !humanFriendly {\n\t\tfmt.Fprintf(os.Stdout, `{\"event\":\"sync_complete\",\"resource\":\"%s\",\"total\":%d,\"duration_ms\":%d}`+\"\\n\", resource, totalCount, time.Since(started).Milliseconds())\n\t}\n\n\treturn syncResult{Resource: resource, Count: totalCount, Duration: time.Since(started)}\n}\n\n// paginationDefaults holds the resolved pagination parameter names and page size.\ntype paginationDefaults struct {\n\tcursorParam string\n\tlimitParam string\n\tlimit int\n}\n\n// determinePaginationDefaults returns the pagination parameter names to use.\n// Values are detected from the API spec by the profiler at generation time.\nfunc determinePaginationDefaults() paginationDefaults {\n\treturn paginationDefaults{\n\t\tcursorParam: \"after\",\n\t\tlimitParam: \"limit\",\n\t\tlimit: 100,\n\t}\n}\n\n// determineSinceParam returns the query parameter name for incremental sync filtering.\nfunc determineSinceParam() string {\n\treturn \"since\"\n}\n\n// extractPageItems attempts to extract an array of items and pagination cursor from a response.\n// It tries multiple strategies:\n// 1. Direct JSON array\n// 2. Common wrapper keys: \"data\", \"results\", \"items\", \"records\", \"nodes\", \"entries\"\n// It also extracts the next cursor from common response fields.\nfunc extractPageItems(data json.RawMessage, cursorParam string) ([]json.RawMessage, string, bool) {\n\t// Strategy 1: direct array\n\tvar items []json.RawMessage\n\tif err := json.Unmarshal(data, &items); err == nil {\n\t\treturn items, \"\", false\n\t}\n\n\t// Strategy 2: object with known wrapper keys\n\tvar envelope map[string]json.RawMessage\n\tif err := json.Unmarshal(data, &envelope); err != nil {\n\t\treturn nil, \"\", false\n\t}\n\n\t// Try common item keys first (fast path)\n\titemKeys := []string{\"data\", \"results\", \"items\", \"records\", \"nodes\", \"entries\"}\n\tfor _, key := range itemKeys {\n\t\tif raw, ok := envelope[key]; ok {\n\t\t\tif err := json.Unmarshal(raw, &items); err == nil && len(items) > 0 {\n\t\t\t\tnextCursor, hasMore := extractPaginationFromEnvelope(envelope, cursorParam)\n\t\t\t\treturn items, nextCursor, hasMore\n\t\t\t}\n\t\t}\n\t}\n\n\t// Fallback: try every key in the envelope. If exactly one maps to a JSON\n\t// array with items, use it. This handles APIs that wrap responses with the\n\t// resource name (e.g., {\"markets\": [...], \"cursor\": \"...\"}).\n\tvar arrayKey string\n\tvar arrayItems []json.RawMessage\n\tarrayCount := 0\n\tfor key, raw := range envelope {\n\t\tvar candidate []json.RawMessage\n\t\tif err := json.Unmarshal(raw, &candidate); err == nil && len(candidate) > 0 {\n\t\t\tarrayKey = key\n\t\t\tarrayItems = candidate\n\t\t\tarrayCount++\n\t\t}\n\t}\n\tif arrayCount == 1 {\n\t\tnextCursor, hasMore := extractPaginationFromEnvelope(envelope, cursorParam)\n\t\t_ = arrayKey // used for detection, items extracted above\n\t\treturn arrayItems, nextCursor, hasMore\n\t}\n\n\treturn nil, \"\", false\n}\n\n// extractPaginationFromEnvelope extracts cursor and has_more from a response envelope.\nfunc extractPaginationFromEnvelope(envelope map[string]json.RawMessage, cursorParam string) (string, bool) {\n\tvar hasMore bool\n\n\tnextCursor := nextCursorFromLinks(envelope, cursorParam)\n\n\t// Try common cursor field names\n\tcursorKeys := []string{\n\t\t\"next_cursor\", \"nextCursor\", \"cursor\", \"next_page_token\",\n\t\t\"nextPageToken\", \"page_token\", \"after\", \"end_cursor\", \"endCursor\",\n\t}\n\tif nextCursor == \"\" {\n\t\tnextCursor = findCursorInMap(envelope, cursorKeys)\n\t}\n\n\t// If no top-level cursor was found, look one level deeper into well-known\n\t// pagination wrapper objects. Slack returns {\"messages\":[...],\n\t// \"response_metadata\":{\"next_cursor\":\"...\"}}; MongoDB Atlas uses\n\t// \"pagination\"; many APIs use \"meta\" or \"paging\". Purely additive — only\n\t// runs when the top-level scan returned empty — and uses the same\n\t// cursorKeys set so wrapper contents go through the same name match.\n\tif nextCursor == \"\" {\n\t\tpaginationWrapperKeys := []string{\"response_metadata\", \"pagination\", \"meta\", \"paging\"}\n\t\tfor _, wrapperKey := range paginationWrapperKeys {\n\t\t\trawWrapper, ok := envelope[wrapperKey]\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tvar inner map[string]json.RawMessage\n\t\t\tif json.Unmarshal(rawWrapper, &inner) != nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif c := findCursorInMap(inner, cursorKeys); c != \"\" {\n\t\t\t\tnextCursor = c\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\n\t// Try common has_more field names\n\thasMoreKeys := []string{\"has_more\", \"hasMore\", \"has_next\", \"hasNext\", \"next_page\"}\n\tfor _, key := range hasMoreKeys {\n\t\tif raw, ok := envelope[key]; ok {\n\t\t\tif err := json.Unmarshal(raw, &hasMore); err == nil {\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\n\t// If we found a cursor, assume there are more pages even without explicit has_more\n\tif nextCursor != \"\" && !hasMore {\n\t\thasMore = true\n\t}\n\n\treturn nextCursor, hasMore\n}\n\n// nextCursorFromLinks extracts JSON:API-style pagination cursors from\n// {\"links\":{\"next\":\"https://example.com/items?page[cursor]=...\"}}.\nfunc nextCursorFromLinks(envelope map[string]json.RawMessage, cursorParam string) string {\n\trawLinks, ok := envelope[\"links\"]\n\tif !ok {\n\t\treturn \"\"\n\t}\n\tvar links map[string]json.RawMessage\n\tif json.Unmarshal(rawLinks, &links) != nil {\n\t\treturn \"\"\n\t}\n\trawNext, ok := links[\"next\"]\n\tif !ok {\n\t\treturn \"\"\n\t}\n\tvar nextURL string\n\tif json.Unmarshal(rawNext, &nextURL) != nil || nextURL == \"\" {\n\t\treturn \"\"\n\t}\n\n\tcursorKeys := []string{cursorParam}\n\tif cursorParam != \"page[cursor]\" {\n\t\tcursorKeys = append(cursorKeys, \"page[cursor]\")\n\t}\n\tif cursorParam != \"cursor\" {\n\t\tcursorKeys = append(cursorKeys, \"cursor\")\n\t}\n\tif cursorParam != \"after\" {\n\t\tcursorKeys = append(cursorKeys, \"after\")\n\t}\n\n\tparsed, err := url.Parse(nextURL)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\tvalues := parsed.Query()\n\tfor _, key := range cursorKeys {\n\t\tif key == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif cursor := values.Get(key); cursor != \"\" {\n\t\t\treturn cursor\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// findCursorInMap returns the first non-empty string-typed value in m\n// whose key matches one of cursorKeys. Used by extractPaginationFromEnvelope\n// to scan both the top-level envelope and well-known wrapper objects with\n// the same name-match rules — extracted so the two scans can't drift.\nfunc findCursorInMap(m map[string]json.RawMessage, cursorKeys []string) string {\n\tfor _, key := range cursorKeys {\n\t\traw, ok := m[key]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\t\tvar s string\n\t\tif err := json.Unmarshal(raw, &s); err == nil && s != \"\" {\n\t\t\treturn s\n\t\t}\n\t}\n\treturn \"\"\n}\n\ntype discriminatorDispatch struct {\n\tField string\n\tValues map[string]string\n}\n\nvar discriminatorDispatchers = map[string]discriminatorDispatch{}\n\nfunc upsertResourceBatch(db *store.Store, resource string, items []json.RawMessage) (int, int, error) {\n\tif _, ok := discriminatorDispatchers[resource]; !ok {\n\t\treturn db.UpsertBatch(resource, items)\n\t}\n\n\tgrouped := map[string][]json.RawMessage{}\n\torder := []string{}\n\tfor _, item := range items {\n\t\ttarget := resource\n\t\tvar obj map[string]any\n\t\tif err := json.Unmarshal(item, &obj); err == nil {\n\t\t\ttarget = resolveDiscriminatedResource(resource, obj)\n\t\t}\n\t\tif _, ok := grouped[target]; !ok {\n\t\t\torder = append(order, target)\n\t\t}\n\t\tgrouped[target] = append(grouped[target], item)\n\t}\n\n\tvar stored, extractFailures int\n\tfor _, target := range order {\n\t\ttargetStored, targetExtractFailures, err := db.UpsertBatch(target, grouped[target])\n\t\tif err != nil {\n\t\t\treturn stored, extractFailures + targetExtractFailures, err\n\t\t}\n\t\tstored += targetStored\n\t\textractFailures += targetExtractFailures\n\t}\n\treturn stored, extractFailures, nil\n}\n\nfunc resolveDiscriminatedResource(resource string, obj map[string]any) string {\n\tdispatcher, ok := discriminatorDispatchers[resource]\n\tif !ok || dispatcher.Field == \"\" {\n\t\treturn resource\n\t}\n\tvalue := store.LookupFieldValue(obj, dispatcher.Field)\n\tif value == nil {\n\t\treturn resource\n\t}\n\tif target, ok := dispatcher.Values[fmt.Sprintf(\"%v\", value)]; ok && target != \"\" {\n\t\treturn target\n\t}\n\treturn resource\n}\n\n// upsertSingleObject stores a non-array API response as a single record.\nfunc upsertSingleObject(db *store.Store, resource string, data json.RawMessage) error {\n\tvar obj map[string]any\n\tif err := json.Unmarshal(data, &obj); err != nil {\n\t\t// Not a JSON object either - store raw under resource name\n\t\treturn db.Upsert(resource, resource, data)\n\t}\n\n\tresource = resolveDiscriminatedResource(resource, obj)\n\n\tid := extractID(resource, obj)\n\tif id == \"\" {\n\t\tid = resource\n\t}\n\n\tswitch resource {\n\tcase \"listing\":\n\t\treturn db.UpsertListing(data)\n\tdefault:\n\t\treturn db.Upsert(resource, id, data)\n\t}\n}\n\n// parseSinceDuration converts human-friendly duration strings into a time.Time.\n// Supported formats: \"7d\" (days), \"24h\" (hours), \"30m\" (minutes), \"1w\" (weeks).\nfunc parseSinceDuration(s string) (time.Time, error) {\n\tre := regexp.MustCompile(`^(\\d+)([dhwm]) pp-apartments — Skillopedia )\n\tmatches := re.FindStringSubmatch(strings.TrimSpace(s))\n\tif matches == nil {\n\t\treturn time.Time{}, fmt.Errorf(\"expected format like 7d, 24h, 1w, or 30m\")\n\t}\n\n\tn, err := strconv.Atoi(matches[1])\n\tif err != nil {\n\t\treturn time.Time{}, err\n\t}\n\n\tnow := time.Now()\n\tswitch matches[2] {\n\tcase \"d\":\n\t\treturn now.Add(-time.Duration(n) * 24 * time.Hour), nil\n\tcase \"h\":\n\t\treturn now.Add(-time.Duration(n) * time.Hour), nil\n\tcase \"w\":\n\t\treturn now.Add(-time.Duration(n) * 7 * 24 * time.Hour), nil\n\tcase \"m\":\n\t\treturn now.Add(-time.Duration(n) * time.Minute), nil\n\tdefault:\n\t\treturn time.Time{}, fmt.Errorf(\"unknown unit %q\", matches[2])\n\t}\n}\n\nfunc defaultSyncResources() []string {\n\treturn []string{}\n}\n\n// syncResourcePath maps resource names to their actual API endpoint paths.\n// For REST APIs this is typically \"/\u003cresource>\". For non-REST APIs (e.g., Steam)\n// this preserves the actual endpoint path like \"/ISteamApps/GetAppList/v2\".\nfunc syncResourcePath(resource string) (string, error) {\n\tpaths := map[string]string{}\n\tif p, ok := paths[resource]; ok {\n\t\treturn p, nil\n\t}\n\treturn \"\", fmt.Errorf(\"unknown sync resource %q\", resource)\n}\n\n// resourceIDFieldOverrides projects per-resource IDField (set by the profiler\n// from x-resource-id or the response-schema fallback chain) into a runtime\n// lookup map. extractID consults this first so the templated path wins over\n// the generic fallback list; the generic list applies only when the override\n// is empty or the override field is absent on a given item.\n//\n// Includes both flat resources and dependent (parent-child) resources so\n// annotations on a child path-item are honored at runtime, not just on\n// flat paths.\nvar resourceIDFieldOverrides = map[string]string{}\n\n// genericIDFieldFallbacks is the runtime safety net for resources that did\n// NOT receive a templated IDField. API-specific names belong in spec\n// annotations (x-resource-id), not this list.\nvar genericIDFieldFallbacks = []string{\"id\", \"ID\", \"name\", \"uuid\", \"slug\", \"key\", \"code\", \"uid\"}\n\n// criticalResources is the template-time projection of per-resource Critical\n// (set by the profiler from the spec's path-item x-critical extension). It\n// is consulted at error-aggregation time so a non-critical failure can be\n// downgraded to a sync_warning + exit 0 unless --strict was passed.\n//\n// Includes both flat resources and dependent (parent-child) resources so a\n// failed child sync flagged x-critical: true exits non-zero just like a\n// flat-resource critical failure.\nvar criticalResources = map[string]bool{}\n\n// extractID resolves an item's primary-key field. It consults the\n// per-resource templated override first; on miss, it falls through to the\n// generic fallback list. resource may be empty for callers that don't have\n// a resource context (only the generic list applies in that case).\n//\n// Field lookups go through store.LookupFieldValue so snake_case overrides\n// match camelCase JSON renderings. UpsertBatch resolves fields the same\n// way — divergence between the two paths produces silent drops on\n// heterogeneous payloads.\nfunc extractID(resource string, obj map[string]any) string {\n\tif override, ok := resourceIDFieldOverrides[resource]; ok && override != \"\" {\n\t\tif v := store.LookupFieldValue(obj, override); v != nil {\n\t\t\ts := fmt.Sprintf(\"%v\", v)\n\t\t\tif s != \"\" && s != \"\u003cnil>\" {\n\t\t\t\treturn s\n\t\t\t}\n\t\t}\n\t}\n\tfor _, key := range genericIDFieldFallbacks {\n\t\tif v := store.LookupFieldValue(obj, key); v != nil {\n\t\t\ts := fmt.Sprintf(\"%v\", v)\n\t\t\tif s != \"\" && s != \"\u003cnil>\" {\n\t\t\t\treturn s\n\t\t\t}\n\t\t}\n\t}\n\treturn \"\"\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":31544,"content_sha256":"9879d7084a46be5ff3f7eb66dd4606a5c76e98560ba21fdd5c2eecded4afdad6"},{"filename":"internal/cli/which_test.go","content":"// Copyright 2026 rderwin and contributors. Licensed under Apache-2.0. See LICENSE.\n// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.\n\npackage cli\n\nimport (\n\t\"strings\"\n\t\"testing\"\n)\n\n// Fixture index used across which-ranking tests. Covers a typical mix\n// of single-word commands, multi-word commands, and grouped entries so\n// the ranker is exercised against shapes a generated CLI actually\n// produces.\nvar whichTestIndex = []whichEntry{\n\t{Command: \"search\", Description: \"Full-text search across synced resources\", Group: \"Local state\"},\n\t{Command: \"stale\", Description: \"Find tickets that have not moved in a while\", Group: \"Local state\"},\n\t{Command: \"bottleneck\", Description: \"Identify pipeline bottlenecks\", Group: \"Local state\"},\n\t{Command: \"send\", Description: \"Send a message\", Group: \"Write operations\"},\n\t{Command: \"sync\", Description: \"Sync resources to local SQLite\", Group: \"Local state\"},\n}\n\n// Happy path: a query that matches a command by keyword returns that\n// command first. This is the load-bearing promise of `which`.\nfunc TestRankWhich_ExactTokenMatchWins(t *testing.T) {\n\tgot := rankWhich(whichTestIndex, \"search\", 3)\n\tif len(got) == 0 {\n\t\tt.Fatalf(\"expected at least one match, got zero\")\n\t}\n\tif got[0].Entry.Command != \"search\" {\n\t\tt.Errorf(\"top match: want search, got %s\", got[0].Entry.Command)\n\t}\n}\n\n// Happy path: a query matching the description wins when the command\n// itself does not contain the query tokens.\nfunc TestRankWhich_DescriptionMatch(t *testing.T) {\n\tgot := rankWhich(whichTestIndex, \"bottlenecks\", 3)\n\tif len(got) == 0 || got[0].Entry.Command != \"bottleneck\" {\n\t\tt.Errorf(\"expected bottleneck command as top match for bottlenecks query, got %+v\", got)\n\t}\n}\n\n// Happy path: a multi-word query resolves to the best single match by\n// summing per-token scores.\nfunc TestRankWhich_MultiTokenQuery(t *testing.T) {\n\tgot := rankWhich(whichTestIndex, \"send a message\", 3)\n\tif len(got) == 0 || got[0].Entry.Command != \"send\" {\n\t\tt.Errorf(\"expected send as top match for 'send a message', got %+v\", got)\n\t}\n}\n\n// Edge case: empty query should surface the full index (listing mode)\n// rather than treating as no-match. Agents use this for broad discovery.\nfunc TestRankWhich_EmptyQueryListsIndex(t *testing.T) {\n\tgot := rankWhich(whichTestIndex, \"\", 3)\n\tif len(got) != len(whichTestIndex) {\n\t\tt.Errorf(\"empty query should return all %d entries, got %d\", len(whichTestIndex), len(got))\n\t}\n\tfor i, m := range got {\n\t\tif m.Score != 0 {\n\t\t\tt.Errorf(\"empty query entry %d: score should be 0, got %d\", i, m.Score)\n\t\t}\n\t}\n}\n\n// Edge case: the limit flag caps the result set so agents can ask for\n// a single top answer when they want a deterministic branch.\nfunc TestRankWhich_LimitCapsResults(t *testing.T) {\n\tgot := rankWhich(whichTestIndex, \"local\", 1)\n\tif len(got) > 1 {\n\t\tt.Errorf(\"limit=1 should return at most 1 match, got %d\", len(got))\n\t}\n}\n\n// No-match path: a query that hits nothing in the index returns an\n// empty slice so the caller can exit with the no-match code (2) rather\n// than printing a misleading best-effort result.\nfunc TestRankWhich_NoMatchReturnsEmpty(t *testing.T) {\n\tgot := rankWhich(whichTestIndex, \"nonexistentxyz\", 3)\n\tif len(got) != 0 {\n\t\tt.Errorf(\"nonsense query should return zero matches, got %d (%+v)\", len(got), got)\n\t}\n}\n\n// Sanity: whichIndex compiles and is well-formed. Generated CLIs with\n// zero NovelFeatures ship an empty index, and that is still a valid\n// state (which returns the \"no curated index\" error at runtime).\nfunc TestWhichIndex_ExistsAndIsWellFormed(t *testing.T) {\n\tfor i, e := range whichIndex {\n\t\tif e.Command == \"\" {\n\t\t\tt.Errorf(\"whichIndex[%d] has empty Command - template rendered bad data\", i)\n\t\t}\n\t\tif strings.TrimSpace(e.Description) == \"\" {\n\t\t\tt.Errorf(\"whichIndex[%d] (%s) has empty Description - template rendered bad data\", i, e.Command)\n\t\t}\n\t}\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":3898,"content_sha256":"7ba01a78e8ea23f770537090cf28ac0b77551c3de45f646bfca9bcc21e35e78e"},{"filename":"internal/cli/which.go","content":"// Copyright 2026 rderwin and contributors. Licensed under Apache-2.0. See LICENSE.\n// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.\n\npackage cli\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"sort\"\n\t\"strings\"\n\n\t\"github.com/spf13/cobra\"\n)\n\n// whichEntry is one row of the curated capability index. The index is\n// seeded at generation time from the same NovelFeature list that drives\n// the SKILL.md feature section, so the command a `which` query returns\n// is guaranteed to exist and to match what the skill advertises.\ntype whichEntry struct {\n\tCommand string `json:\"command\"`\n\tDescription string `json:\"description\"`\n\tGroup string `json:\"group,omitempty\"`\n\tWhyItMatters string `json:\"why_it_matters,omitempty\"`\n}\n\n// whichIndex is the curated list of capabilities this CLI advertises as\n// its hero features. Endpoint-level commands are discoverable via\n// `--help`; `which` exists to resolve a natural-language capability\n// query to one of the commands the skill says matter most.\nvar whichIndex = []whichEntry{\n\t{Command: \"watch\", Description: \"Re-run a stored search and surface what's NEW, REMOVED, or PRICE-CHANGED since the last sync.\", Group: \"Time-series intelligence\", WhyItMatters: \"Pick this when an agent is tracking a relocation over time and needs a reproducible 'what changed since last week' digest, not a fresh search.\"},\n\t{Command: \"nearby\", Description: \"Fan out a search across multiple cities, zips, or neighborhoods and return one ranked, deduped list.\", Group: \"Cross-market joins\", WhyItMatters: \"Pick this when an agent needs a single ranked feed across multiple search slugs without writing a fan-out loop.\"},\n\t{Command: \"value\", Description: \"Rank synced listings by 12-month total cost (rent + pet rent + pet deposit + pet fee), filtered to your hard budget.\", Group: \"Local-store math\", WhyItMatters: \"Pick this when budget is binding and pet fees might push a listing over the line.\"},\n\t{Command: \"rank\", Description: \"Rank synced listings by ratio metrics — price per square foot or price per bedroom.\", Group: \"Local-store math\", WhyItMatters: \"Pick this when value-per-dollar is the goal, not 'best match' or 'lowest price'.\"},\n\t{Command: \"compare\", Description: \"Pivot 2–8 listings into a wide table — one column per listing — with computed $/sqft and amenity overlap.\", Group: \"Shortlist workflows\", WhyItMatters: \"Pick this when narrowing a shortlist; the wide table makes amenity-overlap deltas obvious.\"},\n\t{Command: \"drops\", Description: \"List listings whose max-rent dropped by ≥N% within a time window.\", Group: \"Time-series intelligence\", WhyItMatters: \"Pick this when timing the market or watching for distressed listings.\"},\n\t{Command: \"stale\", Description: \"Flag listings whose price and availability haven't changed in N days — often phantom or stuck.\", Group: \"Time-series intelligence\", WhyItMatters: \"Pick this when a listing seems too good to be true; stale ones often are.\"},\n\t{Command: \"phantoms\", Description: \"Surface listings flagged by a three-signal join: 404 on re-fetch, dropped from saved-search results, or stale ≥45 days.\", Group: \"Time-series intelligence\", WhyItMatters: \"Pick this when prepping a shortlist for tour scheduling — phantoms waste tour slots.\"},\n\t{Command: \"market\", Description: \"Median, p10, p90 of rent and rent/sqft, pet-friendly share, by city/state and bed count.\", Group: \"Aggregations\", WhyItMatters: \"Pick this when an agent needs to anchor 'is this a fair price' against the local distribution.\"},\n\t{Command: \"history\", Description: \"Time-series of every observation of one listing — rent, availability, status.\", Group: \"Time-series intelligence\", WhyItMatters: \"Pick this when reasoning about a single listing's price trajectory.\"},\n\t{Command: \"digest\", Description: \"Single-shot composer: new + removed + price-drops + top-5 by $/sqft + stale + phantom flags for one saved search over N days.\", Group: \"Shortlist workflows\", WhyItMatters: \"Pick this when an agent needs a Monday-morning summary in one call.\"},\n\t{Command: \"floorplans\", Description: \"Rank per-floor-plan rent/sqft across synced listings — same building can yield 4 plans at different ratios.\", Group: \"Local-store math\", WhyItMatters: \"Pick this when a building has multiple floor plans and you want the cheap one specifically.\"},\n\t{Command: \"must-have\", Description: \"Filter synced listings to those whose amenities array contains ALL listed terms via FTS5.\", Group: \"Local-store math\", WhyItMatters: \"Pick this when the must-haves are free-text, not in apartments.com's amenity dropdown.\"},\n\t{Command: \"shortlist\", Description: \"Tag-based local shortlist table; add/show/remove listings with notes and tags.\", Group: \"Shortlist workflows\", WhyItMatters: \"Pick this when an agent or user is curating a shortlist; downstream commands like `compare` read from it.\"},\n}\n\n// whichMatch pairs an index entry with its ranking score for a query.\n// Higher score means stronger match. The ranker is naive (exact token\n// then substring then group tag) because 20-40 entries do not need\n// semantic retrieval - a ranker upgrade is a future change that would\n// not break this contract.\ntype whichMatch struct {\n\tEntry whichEntry `json:\"entry\"`\n\tScore int `json:\"score\"`\n}\n\n// rankWhich returns up to `limit` best matches for `query` against the\n// index, sorted by descending score. Score breakdown:\n//\n//\t+3 exact token match on the command's leaf or full path\n//\t+2 substring match on the command (any part)\n//\t+2 substring match on the description\n//\t+1 group tag contains the query as a word\n//\n// Ties break on declaration order in the index. An empty query returns\n// every entry at score 0 in declaration order - this is the \"list all\"\n// behavior the skill documents for broad agent discovery.\nfunc rankWhich(index []whichEntry, query string, limit int) []whichMatch {\n\tif limit \u003c= 0 {\n\t\tlimit = 3\n\t}\n\tq := strings.ToLower(strings.TrimSpace(query))\n\tif q == \"\" {\n\t\tout := make([]whichMatch, 0, len(index))\n\t\tfor _, e := range index {\n\t\t\tout = append(out, whichMatch{Entry: e, Score: 0})\n\t\t}\n\t\treturn out\n\t}\n\tqTokens := strings.Fields(q)\n\n\tscored := make([]whichMatch, 0, len(index))\n\tfor i, e := range index {\n\t\tscore := whichScoreEntry(e, q, qTokens)\n\t\tscored = append(scored, whichMatch{Entry: e, Score: score})\n\t\t_ = i\n\t}\n\n\tsort.SliceStable(scored, func(i, j int) bool {\n\t\treturn scored[i].Score > scored[j].Score\n\t})\n\t// Drop zero-score matches when the query was non-empty; agents\n\t// branching on exit code rely on \"no match\" meaning no confidence.\n\tfiltered := scored[:0]\n\tfor _, m := range scored {\n\t\tif m.Score > 0 {\n\t\t\tfiltered = append(filtered, m)\n\t\t}\n\t}\n\tif len(filtered) > limit {\n\t\tfiltered = filtered[:limit]\n\t}\n\treturn filtered\n}\n\nfunc whichScoreEntry(e whichEntry, query string, qTokens []string) int {\n\tscore := 0\n\tcmd := strings.ToLower(e.Command)\n\tcmdTokens := strings.Fields(cmd)\n\tdesc := strings.ToLower(e.Description)\n\tgroup := strings.ToLower(e.Group)\n\n\t// Exact token match on the command path (any token).\n\tfor _, qt := range qTokens {\n\t\tfor _, ct := range cmdTokens {\n\t\t\tif qt == ct {\n\t\t\t\tscore += 3\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\t// Substring match on the full command (covers hyphenated leaves).\n\tif strings.Contains(cmd, query) {\n\t\tscore += 2\n\t}\n\t// Substring match on the description.\n\tif strings.Contains(desc, query) {\n\t\tscore += 2\n\t}\n\t// Group tag match.\n\tif group != \"\" {\n\t\tfor _, qt := range qTokens {\n\t\t\tif strings.Contains(group, qt) {\n\t\t\t\tscore += 1\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\treturn score\n}\n\nfunc newWhichCmd(flags *rootFlags) *cobra.Command {\n\tvar limit int\n\tcmd := &cobra.Command{\n\t\tUse: \"which [query]\",\n\t\tShort: \"Find the command that implements a capability\",\n\t\tAnnotations: map[string]string{\n\t\t\t\"pp:typed-exit-codes\": \"0,2\",\n\t\t},\n\t\tLong: `which resolves a natural-language capability query (for example, \"search messages\" or \"stale tickets\") to the best matching command from this CLI's curated feature index.\n\nExit codes:\n 0 at least one match found\n 2 no confident match - the query did not score against any indexed capability; fall back to '--help' or 'search' if this CLI has one`,\n\t\tExample: ` apartments-pp-cli which \"stale tickets\"\n apartments-pp-cli which \"bottleneck\"\n apartments-pp-cli which --limit 1 \"send message\"\n apartments-pp-cli which # list the full capability index`,\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tif len(whichIndex) == 0 {\n\t\t\t\treturn usageErr(fmt.Errorf(\"this CLI has no curated capability index; run '--help' to see every command\"))\n\t\t\t}\n\t\t\tquery := strings.Join(args, \" \")\n\t\t\tmatches := rankWhich(whichIndex, query, limit)\n\n\t\t\t// Empty query returns the whole index at score 0 (listing mode).\n\t\t\tif strings.TrimSpace(query) == \"\" {\n\t\t\t\treturn renderWhich(cmd, flags, rankWhichAll(whichIndex))\n\t\t\t}\n\n\t\t\tif len(matches) == 0 {\n\t\t\t\treturn usageErr(fmt.Errorf(\"no match for %q; try '%s --help' for the full command list\", query, cmd.Root().Name()))\n\t\t\t}\n\t\t\treturn renderWhich(cmd, flags, matches)\n\t\t},\n\t}\n\tcmd.Flags().IntVar(&limit, \"limit\", 3, \"Maximum number of matches to return\")\n\treturn cmd\n}\n\n// rankWhichAll is a narrow helper used by the \"empty query lists the\n// index\" path. It returns every entry in declaration order at score 0\n// so the render path treats them uniformly.\nfunc rankWhichAll(index []whichEntry) []whichMatch {\n\tout := make([]whichMatch, 0, len(index))\n\tfor _, e := range index {\n\t\tout = append(out, whichMatch{Entry: e, Score: 0})\n\t}\n\treturn out\n}\n\nfunc renderWhich(cmd *cobra.Command, flags *rootFlags, matches []whichMatch) error {\n\tw := cmd.OutOrStdout()\n\t// Output shape follows the same rule as every other generated\n\t// command: JSON when the caller asked for it OR when stdout is not\n\t// a terminal; table when a human is looking.\n\tasJSON := flags.asJSON\n\tif !asJSON && !isTerminal(w) {\n\t\tasJSON = true\n\t}\n\tif asJSON {\n\t\tenc := json.NewEncoder(w)\n\t\tenc.SetIndent(\"\", \" \")\n\t\treturn enc.Encode(matches)\n\t}\n\tfmt.Fprintf(w, \"%-24s %-8s %s\\n\", \"COMMAND\", \"SCORE\", \"DESCRIPTION\")\n\tfor _, m := range matches {\n\t\tfmt.Fprintf(w, \"%-24s %-8d %s\\n\", m.Entry.Command, m.Score, m.Entry.Description)\n\t}\n\treturn nil\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":10212,"content_sha256":"fcc474dd1251c06288503b2df6009581b96b7f3fc4b641b430c2cfbe2b15994d"},{"filename":"internal/client/client.go","content":"// Copyright 2026 rderwin and contributors. Licensed under Apache-2.0. See LICENSE.\n// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.\n\npackage client\n\nimport (\n\t\"bytes\"\n\t\"crypto/sha256\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"github.com/enetx/surf\"\n\t\"io\"\n\t\"math\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"sort\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/mvanhorn/printing-press-library/library/other/apartments/internal/cliutil\"\n\t\"github.com/mvanhorn/printing-press-library/library/other/apartments/internal/config\"\n)\n\ntype Client struct {\n\tBaseURL string\n\tConfig *config.Config\n\tHTTPClient *http.Client\n\tDryRun bool\n\tNoCache bool\n\tcacheDir string\n\tlimiter *cliutil.AdaptiveLimiter\n}\n\n// APIError carries HTTP status information for structured exit codes.\ntype APIError struct {\n\tMethod string\n\tPath string\n\tStatusCode int\n\tBody string\n}\n\nfunc (e *APIError) Error() string {\n\treturn fmt.Sprintf(\"%s %s returned HTTP %d: %s\", e.Method, e.Path, e.StatusCode, e.Body)\n}\n\nfunc newHTTPClient(timeout time.Duration, jar http.CookieJar) *http.Client {\n\tbuilder := surf.NewClient().\n\t\tBuilder().\n\t\tImpersonate().\n\t\tChrome().\n\t\tTimeout(timeout)\n\tif jar == nil {\n\t\tbuilder = builder.Session()\n\t}\n\tsurfClient := builder.Build().Unwrap()\n\thttpClient := surfClient.Std()\n\thttpClient.Timeout = timeout\n\tif jar != nil {\n\t\thttpClient.Jar = jar\n\t}\n\treturn httpClient\n}\n\nfunc New(cfg *config.Config, timeout time.Duration, rateLimit float64) *Client {\n\thomeDir, _ := os.UserHomeDir()\n\tcacheDir := filepath.Join(homeDir, \".cache\", \"apartments-pp-cli\")\n\thttpClient := newHTTPClient(timeout, nil)\n\treturn &Client{\n\t\tBaseURL: strings.TrimRight(cfg.BaseURL, \"/\"),\n\t\tConfig: cfg,\n\t\tHTTPClient: httpClient,\n\t\tcacheDir: cacheDir,\n\t\tlimiter: cliutil.NewAdaptiveLimiter(rateLimit),\n\t}\n}\n\n// RateLimit returns the current effective rate limit in req/s. Returns 0 if disabled.\nfunc (c *Client) RateLimit() float64 {\n\treturn c.limiter.Rate()\n}\n\nfunc (c *Client) Get(path string, params map[string]string) (json.RawMessage, error) {\n\treturn c.GetWithHeaders(path, params, nil)\n}\n\nfunc (c *Client) GetWithHeaders(path string, params map[string]string, headers map[string]string) (json.RawMessage, error) {\n\t// Check cache for GET requests\n\tif !c.NoCache && !c.DryRun && c.cacheDir != \"\" {\n\t\tif cached, ok := c.readCache(path, params); ok {\n\t\t\treturn cached, nil\n\t\t}\n\t}\n\tresult, _, err := c.do(\"GET\", path, params, nil, headers)\n\tif err == nil && !c.NoCache && !c.DryRun && c.cacheDir != \"\" {\n\t\tc.writeCache(path, params, result)\n\t}\n\treturn result, err\n}\n\nfunc (c *Client) ProbeGet(path string) (int, error) {\n\t_, status, err := c.do(\"GET\", path, nil, nil, nil)\n\treturn status, err\n}\n\nfunc (c *Client) cacheKey(path string, params map[string]string) string {\n\tkey := path\n\tfor k, v := range params {\n\t\tkey += k + \"=\" + v\n\t}\n\th := sha256.Sum256([]byte(key))\n\treturn hex.EncodeToString(h[:8])\n}\n\nfunc (c *Client) readCache(path string, params map[string]string) (json.RawMessage, bool) {\n\tcacheFile := filepath.Join(c.cacheDir, c.cacheKey(path, params)+\".json\")\n\tinfo, err := os.Stat(cacheFile)\n\tif err != nil || time.Since(info.ModTime()) > 5*time.Minute {\n\t\treturn nil, false\n\t}\n\tdata, err := os.ReadFile(cacheFile)\n\tif err != nil {\n\t\treturn nil, false\n\t}\n\treturn json.RawMessage(data), true\n}\n\nfunc (c *Client) writeCache(path string, params map[string]string, data json.RawMessage) {\n\tos.MkdirAll(c.cacheDir, 0o755)\n\tcacheFile := filepath.Join(c.cacheDir, c.cacheKey(path, params)+\".json\")\n\tos.WriteFile(cacheFile, []byte(data), 0o644)\n}\n\n// invalidateCache wholesale-removes the cache directory so the next read\n// after a mutation cannot return a stale snapshot. Selective per-resource\n// invalidation rejected: cache keys are opaque sha256 hashes.\nfunc (c *Client) invalidateCache() {\n\tif c.cacheDir == \"\" {\n\t\treturn\n\t}\n\t_ = os.RemoveAll(c.cacheDir)\n}\n\nfunc (c *Client) Post(path string, body any) (json.RawMessage, int, error) {\n\treturn c.do(\"POST\", path, nil, body, nil)\n}\n\nfunc (c *Client) PostWithHeaders(path string, body any, headers map[string]string) (json.RawMessage, int, error) {\n\treturn c.do(\"POST\", path, nil, body, headers)\n}\n\nfunc (c *Client) Delete(path string) (json.RawMessage, int, error) {\n\treturn c.do(\"DELETE\", path, nil, nil, nil)\n}\n\nfunc (c *Client) DeleteWithHeaders(path string, headers map[string]string) (json.RawMessage, int, error) {\n\treturn c.do(\"DELETE\", path, nil, nil, headers)\n}\n\nfunc (c *Client) Put(path string, body any) (json.RawMessage, int, error) {\n\treturn c.do(\"PUT\", path, nil, body, nil)\n}\n\nfunc (c *Client) PutWithHeaders(path string, body any, headers map[string]string) (json.RawMessage, int, error) {\n\treturn c.do(\"PUT\", path, nil, body, headers)\n}\n\nfunc (c *Client) Patch(path string, body any) (json.RawMessage, int, error) {\n\treturn c.do(\"PATCH\", path, nil, body, nil)\n}\n\nfunc (c *Client) PatchWithHeaders(path string, body any, headers map[string]string) (json.RawMessage, int, error) {\n\treturn c.do(\"PATCH\", path, nil, body, headers)\n}\n\n// do executes an HTTP request. headerOverrides, when non-nil, override global\n// RequiredHeaders for this specific request (used for per-endpoint API versioning).\nfunc (c *Client) do(method, path string, params map[string]string, body any, headerOverrides map[string]string) (json.RawMessage, int, error) {\n\ttargetURL := c.BaseURL + path\n\n\tvar bodyBytes []byte\n\tif body != nil {\n\t\tb, err := json.Marshal(body)\n\t\tif err != nil {\n\t\t\treturn nil, 0, fmt.Errorf(\"marshaling body: %w\", err)\n\t\t}\n\t\tbodyBytes = b\n\t}\n\n\t// Resolve auth material before the dry-run branch so --dry-run can preview\n\t// exactly what would be sent. Uses only cached credentials; a token that\n\t// requires a network refresh will be re-fetched on the live request path,\n\t// not during dry-run.\n\tauthHeader, err := c.authHeader()\n\tif err != nil {\n\t\treturn nil, 0, err\n\t}\n\n\t// Build the request for dry-run display or actual execution\n\tif c.DryRun {\n\t\treturn c.dryRun(method, targetURL, path, params, bodyBytes, headerOverrides, authHeader)\n\t}\n\n\tconst maxRetries = 3\n\tvar lastErr error\n\n\tfor attempt := 0; attempt \u003c= maxRetries; attempt++ {\n\t\t// Proactive rate limiting — wait before sending\n\t\tc.limiter.Wait()\n\t\tvar bodyReader io.Reader\n\t\tif bodyBytes != nil {\n\t\t\tbodyReader = strings.NewReader(string(bodyBytes))\n\t\t}\n\n\t\treq, err := http.NewRequest(method, targetURL, bodyReader)\n\t\tif err != nil {\n\t\t\treturn nil, 0, fmt.Errorf(\"creating request: %w\", err)\n\t\t}\n\t\tif bodyBytes != nil {\n\t\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\t}\n\n\t\tif params != nil {\n\t\t\tq := req.URL.Query()\n\t\t\tfor k, v := range params {\n\t\t\t\tif v != \"\" {\n\t\t\t\t\tq.Set(k, v)\n\t\t\t\t}\n\t\t\t}\n\t\t\treq.URL.RawQuery = q.Encode()\n\t\t}\n\n\t\tif authHeader != \"\" {\n\t\t\treq.Header.Set(\"Authorization\", authHeader)\n\t\t}\n\t\t// Per-endpoint header overrides (e.g., different API version per resource)\n\t\tfor k, v := range headerOverrides {\n\t\t\treq.Header.Set(k, v)\n\t\t}\n\n\t\tresp, err := c.HTTPClient.Do(req)\n\t\tif err != nil {\n\t\t\tlastErr = fmt.Errorf(\"%s %s: %w\", method, path, err)\n\t\t\tcontinue\n\t\t}\n\n\t\trespBody, err := io.ReadAll(resp.Body)\n\t\tresp.Body.Close()\n\t\tif err != nil {\n\t\t\treturn nil, 0, fmt.Errorf(\"reading response: %w\", err)\n\t\t}\n\t\trespBody = sanitizeJSONResponse(respBody)\n\n\t\t// Success\n\t\tif resp.StatusCode \u003c 400 {\n\t\t\tc.limiter.OnSuccess()\n\t\t\tif method != http.MethodGet && !c.DryRun {\n\t\t\t\tc.invalidateCache()\n\t\t\t}\n\t\t\treturn json.RawMessage(respBody), resp.StatusCode, nil\n\t\t}\n\n\t\tapiErr := &APIError{\n\t\t\tMethod: method,\n\t\t\tPath: path,\n\t\t\tStatusCode: resp.StatusCode,\n\t\t\tBody: truncateBody(respBody),\n\t\t}\n\n\t\t// Rate limited - adjust adaptive limiter and retry\n\t\tif resp.StatusCode == 429 && attempt \u003c maxRetries {\n\t\t\tc.limiter.OnRateLimit()\n\t\t\twait := cliutil.RetryAfter(resp)\n\t\t\tfmt.Fprintf(os.Stderr, \"rate limited, waiting %s (attempt %d/%d, rate adjusted to %.1f req/s)\\n\", wait, attempt+1, maxRetries, c.limiter.Rate())\n\t\t\ttime.Sleep(wait)\n\t\t\tlastErr = apiErr\n\t\t\tcontinue\n\t\t}\n\n\t\t// Server error - retry with backoff\n\t\tif resp.StatusCode >= 500 && attempt \u003c maxRetries {\n\t\t\twait := time.Duration(math.Pow(2, float64(attempt))) * time.Second\n\t\t\tfmt.Fprintf(os.Stderr, \"server error %d, retrying in %s (attempt %d/%d)\\n\", resp.StatusCode, wait, attempt+1, maxRetries)\n\t\t\ttime.Sleep(wait)\n\t\t\tlastErr = apiErr\n\t\t\tcontinue\n\t\t}\n\n\t\t// Client error or retries exhausted - return the error\n\t\treturn nil, resp.StatusCode, apiErr\n\t}\n\n\treturn nil, 0, lastErr\n}\n\n// dryRun prints the outgoing request exactly as the live path would send it,\n// using the auth material already resolved in `do()`. Never triggers a network\n// call — the caller is responsible for passing cached auth material only.\nfunc (c *Client) dryRun(method, targetURL, path string, params map[string]string, body []byte, headerOverrides map[string]string, authHeader string) (json.RawMessage, int, error) {\n\tfmt.Fprintf(os.Stderr, \"%s %s\\n\", method, targetURL)\n\tqueryPrinted := false\n\tif params != nil {\n\t\tkeys := make([]string, 0, len(params))\n\t\tfor k := range params {\n\t\t\tif params[k] != \"\" {\n\t\t\t\tkeys = append(keys, k)\n\t\t\t}\n\t\t}\n\t\tsort.Strings(keys)\n\t\tfor _, k := range keys {\n\t\t\tsep := \"?\"\n\t\t\tif queryPrinted {\n\t\t\t\tsep = \"&\"\n\t\t\t}\n\t\t\tfmt.Fprintf(os.Stderr, \" %s%s=%s\\n\", sep, k, params[k])\n\t\t\tqueryPrinted = true\n\t\t}\n\t}\n\t_ = queryPrinted\n\tif body != nil {\n\t\tvar pretty json.RawMessage\n\t\tif json.Unmarshal(body, &pretty) == nil {\n\t\t\tenc := json.NewEncoder(os.Stderr)\n\t\t\tenc.SetIndent(\" \", \" \")\n\t\t\tfmt.Fprintf(os.Stderr, \" Body:\\n\")\n\t\t\tenc.Encode(pretty)\n\t\t}\n\t}\n\tif authHeader != \"\" {\n\t\tfmt.Fprintf(os.Stderr, \" %s: %s\\n\", \"Authorization\", maskToken(authHeader))\n\t}\n\tfmt.Fprintf(os.Stderr, \"\\n(dry run - no request sent)\\n\")\n\treturn json.RawMessage(`{\"dry_run\": true}`), 0, nil\n}\n\nfunc (c *Client) ConfiguredTimeout() time.Duration {\n\tif c.HTTPClient != nil && c.HTTPClient.Timeout > 0 {\n\t\treturn c.HTTPClient.Timeout\n\t}\n\treturn 30 * time.Second\n}\n\nfunc (c *Client) authHeader() (string, error) {\n\tif c.Config == nil {\n\t\treturn \"\", nil\n\t}\n\tif c.Config.AccessToken != \"\" && !c.Config.TokenExpiry.IsZero() && time.Now().After(c.Config.TokenExpiry) && c.Config.RefreshToken != \"\" {\n\t\tif err := c.refreshAccessToken(); err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t}\n\treturn c.Config.AuthHeader(), nil\n}\n\nfunc (c *Client) refreshAccessToken() error {\n\tif c.Config == nil {\n\t\treturn nil\n\t}\n\tif c.Config.RefreshToken == \"\" {\n\t\treturn nil\n\t}\n\n\ttokenURL := \"\"\n\tif tokenURL == \"\" {\n\t\treturn nil\n\t}\n\n\tparams := url.Values{\n\t\t\"grant_type\": {\"refresh_token\"},\n\t\t\"refresh_token\": {c.Config.RefreshToken},\n\t\t\"client_id\": {c.Config.ClientID},\n\t}\n\tif c.Config.ClientSecret != \"\" {\n\t\tparams.Set(\"client_secret\", c.Config.ClientSecret)\n\t}\n\n\tresp, err := c.HTTPClient.PostForm(tokenURL, params)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"refreshing access token: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode >= 400 {\n\t\tbody, _ := io.ReadAll(resp.Body)\n\t\treturn fmt.Errorf(\"refreshing access token: HTTP %d: %s\", resp.StatusCode, truncateBody(body))\n\t}\n\n\tvar tokenResp struct {\n\t\tAccessToken string `json:\"access_token\"`\n\t\tRefreshToken string `json:\"refresh_token\"`\n\t\tExpiresIn int `json:\"expires_in\"`\n\t}\n\tif err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {\n\t\treturn fmt.Errorf(\"parsing refresh response: %w\", err)\n\t}\n\tif tokenResp.AccessToken == \"\" {\n\t\treturn fmt.Errorf(\"refreshing access token: no access token in response\")\n\t}\n\n\trefreshToken := c.Config.RefreshToken\n\tif tokenResp.RefreshToken != \"\" {\n\t\trefreshToken = tokenResp.RefreshToken\n\t}\n\n\texpiry := time.Time{}\n\tif tokenResp.ExpiresIn > 0 {\n\t\texpiry = time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)\n\t}\n\n\tif err := c.Config.SaveTokens(c.Config.ClientID, c.Config.ClientSecret, tokenResp.AccessToken, refreshToken, expiry); err != nil {\n\t\treturn fmt.Errorf(\"saving refreshed token: %w\", err)\n\t}\n\n\treturn nil\n}\n\n// sanitizeJSONResponse strips known JSONP/XSSI prefixes and UTF-8 BOM from\n// response bodies so that downstream JSON parsing succeeds. For clean JSON\n// responses these checks are no-ops.\nfunc sanitizeJSONResponse(body []byte) []byte {\n\t// UTF-8 BOM\n\tbody = bytes.TrimPrefix(body, []byte(\"\\xEF\\xBB\\xBF\"))\n\n\t// JSONP/XSSI prefixes, ordered longest-first where prefixes overlap\n\tprefixes := [][]byte{\n\t\t[]byte(\")]}'\\n\"),\n\t\t[]byte(\")]}'\"),\n\t\t[]byte(\"{}&&\"),\n\t\t[]byte(\"for(;;);\"),\n\t\t[]byte(\"while(1);\"),\n\t}\n\tfor _, p := range prefixes {\n\t\tif bytes.HasPrefix(body, p) {\n\t\t\tbody = bytes.TrimPrefix(body, p)\n\t\t\tbody = bytes.TrimLeft(body, \" \\t\\r\\n\")\n\t\t\tbreak\n\t\t}\n\t}\n\treturn body\n}\n\n// maskToken redacts all but the last 4 characters of a token for safe display.\nfunc maskToken(token string) string {\n\tif token == \"\" {\n\t\treturn \"\"\n\t}\n\tif len(token) \u003c= 4 {\n\t\treturn \"****\"\n\t}\n\treturn \"****\" + token[len(token)-4:]\n}\n\nfunc truncateBody(b []byte) string {\n\ts := string(b)\n\tif len(s) > 200 {\n\t\treturn s[:200] + \"...\"\n\t}\n\treturn s\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":12890,"content_sha256":"8a5f201cb19dbeca58158b16e0666d8300bc75becc14e4ffea6aa97250bcf39e"},{"filename":"internal/cliutil/cliutil_test.go","content":"// Copyright 2026 rderwin and contributors. Licensed under Apache-2.0. See LICENSE.\n// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.\n\npackage cliutil\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"testing\"\n\t\"time\"\n)\n\n// ---- CleanText ----\n\nfunc TestCleanText(t *testing.T) {\n\tcases := []struct {\n\t\tname string\n\t\tin string\n\t\twant string\n\t}{\n\t\t{\"decodes numeric entity\", \"The Food Lab's Cookie\", \"The Food Lab's Cookie\"},\n\t\t{\"decodes named entity\", \"AT&T\", \"AT&T\"},\n\t\t{\"trims whitespace\", \" Chicken Tikka \", \"Chicken Tikka\"},\n\t\t{\"empty input\", \"\", \"\"},\n\t\t{\"plain passthrough\", \"Already clean.\", \"Already clean.\"},\n\t\t// Single-pass unescape contract: nested & decodes once to &\n\t\t// but the inner & stays encoded. If a caller needs repeated\n\t\t// unescaping they have a deeper upstream problem.\n\t\t{\"single pass on nested entity\", \"&\", \"&\"},\n\t}\n\tfor _, tc := range cases {\n\t\ttc := tc\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tif got := CleanText(tc.in); got != tc.want {\n\t\t\t\tt.Errorf(\"CleanText(%q) = %q, want %q\", tc.in, got, tc.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestParseStoredTime(t *testing.T) {\n\tcases := []struct {\n\t\tname string\n\t\tin string\n\t\twant time.Time\n\t}{\n\t\t{\n\t\t\tname: \"rfc3339 nano\",\n\t\t\tin: \"2026-04-21T09:02:49.123456789-07:00\",\n\t\t\twant: time.Date(2026, 4, 21, 9, 2, 49, 123456789, time.FixedZone(\"\", -7*60*60)),\n\t\t},\n\t\t{\n\t\t\tname: \"modernc go string\",\n\t\t\tin: \"2026-04-21 09:02:49.123456789 -0700 PDT\",\n\t\t\twant: time.Date(2026, 4, 21, 9, 2, 49, 123456789, time.FixedZone(\"PDT\", -7*60*60)),\n\t\t},\n\t\t{\n\t\t\tname: \"blank\",\n\t\t\tin: \"\",\n\t\t\twant: time.Time{},\n\t\t},\n\t\t{\n\t\t\tname: \"invalid\",\n\t\t\tin: \"not a time\",\n\t\t\twant: time.Time{},\n\t\t},\n\t}\n\tfor _, tc := range cases {\n\t\ttc := tc\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tgot := ParseStoredTime(tc.in)\n\t\t\tif !got.Equal(tc.want) {\n\t\t\t\tt.Fatalf(\"ParseStoredTime(%q) = %s, want %s\", tc.in, got, tc.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// ---- FanoutRun ----\n\nfunc TestFanoutRunAllSucceed(t *testing.T) {\n\tsources := []string{\"a\", \"b\", \"c\"}\n\tresults, errs := FanoutRun(context.Background(), sources,\n\t\tfunc(s string) string { return s },\n\t\tfunc(_ context.Context, s string) (string, error) {\n\t\t\treturn s + \"!\", nil\n\t\t},\n\t)\n\tif len(errs) != 0 {\n\t\tt.Fatalf(\"unexpected errors: %+v\", errs)\n\t}\n\tif len(results) != 3 {\n\t\tt.Fatalf(\"want 3 results, got %d\", len(results))\n\t}\n\t// Source-order contract: results must match input order.\n\tfor i, r := range results {\n\t\tif r.Source != sources[i] {\n\t\t\tt.Errorf(\"results[%d].Source = %q, want %q\", i, r.Source, sources[i])\n\t\t}\n\t\tif r.Value != sources[i]+\"!\" {\n\t\t\tt.Errorf(\"results[%d].Value = %q, want %q\", i, r.Value, sources[i]+\"!\")\n\t\t}\n\t}\n}\n\nfunc TestFanoutRunMixed(t *testing.T) {\n\tsources := []string{\"good\", \"bad\", \"good2\"}\n\tresults, errs := FanoutRun(context.Background(), sources,\n\t\tfunc(s string) string { return s },\n\t\tfunc(_ context.Context, s string) (string, error) {\n\t\t\tif s == \"bad\" {\n\t\t\t\treturn \"\", errors.New(\"intentional failure\")\n\t\t\t}\n\t\t\treturn \"ok-\" + s, nil\n\t\t},\n\t)\n\tif len(results) != 2 || len(errs) != 1 {\n\t\tt.Fatalf(\"want 2 results + 1 error, got %d results + %d errors\", len(results), len(errs))\n\t}\n\tif errs[0].Source != \"bad\" {\n\t\tt.Errorf(\"error source = %q, want bad\", errs[0].Source)\n\t}\n\t// Results must stay in source order even with failure in the middle.\n\tif results[0].Source != \"good\" || results[1].Source != \"good2\" {\n\t\tt.Errorf(\"results out of source order: %q %q\", results[0].Source, results[1].Source)\n\t}\n}\n\nfunc TestFanoutRunCancelledBeforeStart(t *testing.T) {\n\tctx, cancel := context.WithCancel(context.Background())\n\tcancel() // cancelled up front\n\n\tsources := []string{\"a\", \"b\", \"c\"}\n\tresults, errs := FanoutRun(ctx, sources,\n\t\tfunc(s string) string { return s },\n\t\tfunc(_ context.Context, s string) (string, error) {\n\t\t\treturn s, nil\n\t\t},\n\t)\n\tif len(results) != 0 {\n\t\tt.Fatalf(\"want no results on pre-cancel, got %d\", len(results))\n\t}\n\t// Every source must report ctx.Err() — no silent drops.\n\tif len(errs) != len(sources) {\n\t\tt.Fatalf(\"want %d cancel errors, got %d\", len(sources), len(errs))\n\t}\n\tfor i, e := range errs {\n\t\tif e.Source != sources[i] {\n\t\t\tt.Errorf(\"errs[%d].Source = %q, want %q\", i, e.Source, sources[i])\n\t\t}\n\t\tif !errors.Is(e.Err, context.Canceled) {\n\t\t\tt.Errorf(\"errs[%d].Err = %v, want context.Canceled\", i, e.Err)\n\t\t}\n\t}\n}\n\nfunc TestFanoutRunBoundedConcurrency(t *testing.T) {\n\t// Atomic counter wraps fn to verify max concurrent executions never\n\t// exceeds WithConcurrency(n). Directly tests the bounded-channel\n\t// contract without relying on runtime.NumGoroutine (too noisy).\n\t//\n\t// A sync.WaitGroup inside fn blocks each worker until concurrency-many\n\t// workers have entered fn, guaranteeing the overlap needed to observe\n\t// the max-inflight bound. A busy-wait would be flaky on fast or loaded\n\t// hardware; the WaitGroup-based barrier makes the overlap deterministic.\n\tvar inflight int64\n\tvar maxInflight int64\n\tvar barrier sync.WaitGroup\n\tbarrier.Add(4) // require all 4 workers at the barrier before proceeding\n\n\tsources := make([]int, 100)\n\tfor i := range sources {\n\t\tsources[i] = i\n\t}\n\t// Only the first 4 calls participate in the barrier so the test doesn't\n\t// deadlock — remaining 96 just run normally and contribute to max-inflight\n\t// observations.\n\tvar gated int64\n\n\t_, errs := FanoutRun(context.Background(), sources,\n\t\tfunc(i int) string { return fmt.Sprintf(\"src-%d\", i) },\n\t\tfunc(_ context.Context, _ int) (struct{}, error) {\n\t\t\tcur := atomic.AddInt64(&inflight, 1)\n\t\t\tfor {\n\t\t\t\tm := atomic.LoadInt64(&maxInflight)\n\t\t\t\tif cur \u003c= m || atomic.CompareAndSwapInt64(&maxInflight, m, cur) {\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\tif atomic.AddInt64(&gated, 1) \u003c= 4 {\n\t\t\t\tbarrier.Done()\n\t\t\t\tbarrier.Wait()\n\t\t\t}\n\t\t\tatomic.AddInt64(&inflight, -1)\n\t\t\treturn struct{}{}, nil\n\t\t},\n\t\tWithConcurrency(4),\n\t)\n\tif len(errs) != 0 {\n\t\tt.Fatalf(\"unexpected errors: %+v\", errs)\n\t}\n\tif maxInflight > 4 {\n\t\tt.Errorf(\"max in-flight = %d, want \u003c= 4\", maxInflight)\n\t}\n\tif maxInflight \u003c 4 {\n\t\t// Barrier forces all 4 workers into fn simultaneously. If this\n\t\t// assertion ever fails the bounded-channel contract is broken.\n\t\tt.Errorf(\"max in-flight = %d, want = 4 (barrier guarantees all 4 workers reach fn together)\", maxInflight)\n\t}\n}\n\nfunc TestFanoutRunEmptySources(t *testing.T) {\n\tresults, errs := FanoutRun(context.Background(), []string{},\n\t\tfunc(s string) string { return s },\n\t\tfunc(_ context.Context, s string) (string, error) { return s, nil },\n\t)\n\tif len(results) != 0 {\n\t\tt.Errorf(\"empty sources should produce 0 results, got %d\", len(results))\n\t}\n\tif len(errs) != 0 {\n\t\tt.Errorf(\"empty sources should produce 0 errors, got %d\", len(errs))\n\t}\n}\n\nfunc TestFanoutRunAllFail(t *testing.T) {\n\tsources := []string{\"a\", \"b\", \"c\"}\n\tresults, errs := FanoutRun(context.Background(), sources,\n\t\tfunc(s string) string { return s },\n\t\tfunc(_ context.Context, _ string) (string, error) {\n\t\t\treturn \"\", errors.New(\"boom\")\n\t\t},\n\t)\n\tif len(results) != 0 {\n\t\tt.Errorf(\"want 0 results when all fail, got %d\", len(results))\n\t}\n\tif len(errs) != 3 {\n\t\tt.Fatalf(\"want 3 errors, got %d\", len(errs))\n\t}\n\t// Source-order preserved even on all-fail.\n\tfor i, e := range errs {\n\t\tif e.Source != sources[i] {\n\t\t\tt.Errorf(\"errs[%d].Source = %q, want %q\", i, e.Source, sources[i])\n\t\t}\n\t}\n}\n\nfunc TestFanoutRunWithConcurrencyClampsZero(t *testing.T) {\n\t// WithConcurrency(0) and WithConcurrency(-1) must clamp to 1, not deadlock.\n\tfor _, n := range []int{0, -1, -100} {\n\t\tsources := []string{\"a\", \"b\"}\n\t\tresults, errs := FanoutRun(context.Background(), sources,\n\t\t\tfunc(s string) string { return s },\n\t\t\tfunc(_ context.Context, s string) (string, error) { return s, nil },\n\t\t\tWithConcurrency(n),\n\t\t)\n\t\tif len(results) != 2 {\n\t\t\tt.Errorf(\"WithConcurrency(%d): want 2 results, got %d\", n, len(results))\n\t\t}\n\t\tif len(errs) != 0 {\n\t\t\tt.Errorf(\"WithConcurrency(%d): unexpected errors: %+v\", n, errs)\n\t\t}\n\t}\n}\n\nfunc TestFanoutRunRecoversPanic(t *testing.T) {\n\t// An fn that panics must not crash the process. The panicking source\n\t// gets a FanoutError; other sources complete normally.\n\tsources := []string{\"good1\", \"panic\", \"good2\"}\n\tresults, errs := FanoutRun(context.Background(), sources,\n\t\tfunc(s string) string { return s },\n\t\tfunc(_ context.Context, s string) (string, error) {\n\t\t\tif s == \"panic\" {\n\t\t\t\tpanic(\"oops\")\n\t\t\t}\n\t\t\treturn \"ok-\" + s, nil\n\t\t},\n\t)\n\tif len(results) != 2 {\n\t\tt.Fatalf(\"want 2 results (good1, good2), got %d\", len(results))\n\t}\n\tif len(errs) != 1 {\n\t\tt.Fatalf(\"want 1 error (panic), got %d\", len(errs))\n\t}\n\tif errs[0].Source != \"panic\" {\n\t\tt.Errorf(\"panic error source = %q, want panic\", errs[0].Source)\n\t}\n\tif errs[0].Err == nil || !containsString(errs[0].Err.Error(), \"oops\") {\n\t\tt.Errorf(\"want panic error mentioning 'oops', got %v\", errs[0].Err)\n\t}\n}\n\nfunc TestFanoutRunCancelMidFlight(t *testing.T) {\n\t// Regression test: cancel while workers are mid-fn. Producer may still\n\t// be feeding the bounded channel; some workers are executing fn; others\n\t// may have already pulled the next job. The contract: every source ends\n\t// up with either a result or an error, never neither and never both.\n\t// Run with -race to catch any slot-write race that a naïve implementation\n\t// would introduce.\n\tctx, cancel := context.WithCancel(context.Background())\n\tsources := make([]int, 30)\n\tfor i := range sources {\n\t\tsources[i] = i\n\t}\n\t// Fire cancel after a few fn calls start.\n\tvar started int64\n\tresults, errs := FanoutRun(ctx, sources,\n\t\tfunc(i int) string { return fmt.Sprintf(\"src-%d\", i) },\n\t\tfunc(c context.Context, _ int) (struct{}, error) {\n\t\t\tif atomic.AddInt64(&started, 1) == 3 {\n\t\t\t\tcancel()\n\t\t\t}\n\t\t\t// Brief wait so cancel can propagate and the bounded channel's\n\t\t\t// drain/feed interaction actually exercises mid-flight state.\n\t\t\tfor j := 0; j \u003c 10000; j++ {\n\t\t\t\t_ = j\n\t\t\t}\n\t\t\treturn struct{}{}, c.Err()\n\t\t},\n\t)\n\t// Every source must be accounted for exactly once.\n\tif total := len(results) + len(errs); total != len(sources) {\n\t\tt.Errorf(\"results+errs = %d, want %d (every source accounted once)\", total, len(sources))\n\t}\n\t// No double-accounting: a source must not appear in both.\n\tseen := map[string]int{}\n\tfor _, r := range results {\n\t\tseen[r.Source]++\n\t}\n\tfor _, e := range errs {\n\t\tseen[e.Source]++\n\t}\n\tfor src, n := range seen {\n\t\tif n != 1 {\n\t\t\tt.Errorf(\"source %q accounted %d times, want exactly 1\", src, n)\n\t\t}\n\t}\n}\n\n// containsString is a tiny strings.Contains alias so the test file only\n// imports the stdlib packages it otherwise uses.\nfunc containsString(haystack, needle string) bool {\n\tfor i := 0; i+len(needle) \u003c= len(haystack); i++ {\n\t\tif haystack[i:i+len(needle)] == needle {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc TestFanoutRunSerialWithConcurrency1(t *testing.T) {\n\t// Serial execution: completion order must equal source order because\n\t// there's only one worker. Double-checks the source-order contract.\n\tsources := []string{\"a\", \"b\", \"c\"}\n\tvar completionOrder []string\n\tvar mu sync.Mutex\n\n\tresults, _ := FanoutRun(context.Background(), sources,\n\t\tfunc(s string) string { return s },\n\t\tfunc(_ context.Context, s string) (string, error) {\n\t\t\tmu.Lock()\n\t\t\tcompletionOrder = append(completionOrder, s)\n\t\t\tmu.Unlock()\n\t\t\treturn s, nil\n\t\t},\n\t\tWithConcurrency(1),\n\t)\n\tfor i, r := range results {\n\t\tif r.Source != sources[i] {\n\t\t\tt.Errorf(\"results[%d].Source = %q, want %q\", i, r.Source, sources[i])\n\t\t}\n\t}\n\tfor i, s := range completionOrder {\n\t\tif s != sources[i] {\n\t\t\tt.Errorf(\"serial completion order[%d] = %q, want %q\", i, s, sources[i])\n\t\t}\n\t}\n}\n\n// ---- FanoutReportErrors ----\n\nfunc TestFanoutReportErrorsOrder(t *testing.T) {\n\terrs := []FanoutError{\n\t\t{Source: \"alpha\", Err: errors.New(\"boom\")},\n\t\t{Source: \"beta\", Err: errors.New(\"crash\\nsecond line\")},\n\t}\n\tvar buf bytes.Buffer\n\tFanoutReportErrors(&buf, errs)\n\twant := \"warn: alpha: boom\\nwarn: beta: crash\\n\"\n\tif got := buf.String(); got != want {\n\t\tt.Errorf(\"output mismatch.\\n got: %q\\nwant: %q\", got, want)\n\t}\n}\n\nfunc TestFanoutReportErrorsEmpty(t *testing.T) {\n\tvar buf bytes.Buffer\n\tFanoutReportErrors(&buf, nil)\n\tif buf.Len() != 0 {\n\t\tt.Errorf(\"expected no output for empty errs, got %q\", buf.String())\n\t}\n}\n\nfunc TestFanoutReportErrorsTruncates(t *testing.T) {\n\t// A long single-line error gets truncated to 120 chars + ellipsis so\n\t// stderr stays scan-able when 14 sources all fail with verbose messages.\n\tlongMsg := \"\"\n\tfor i := 0; i \u003c 200; i++ {\n\t\tlongMsg += \"x\"\n\t}\n\terrs := []FanoutError{\n\t\t{Source: \"verbose\", Err: errors.New(longMsg)},\n\t}\n\tvar buf bytes.Buffer\n\tFanoutReportErrors(&buf, errs)\n\tout := buf.String()\n\t// \"warn: verbose: \" is 15 chars; body must be 120 chars + \"…\" + \"\\n\"\n\tif !containsString(out, \"…\") {\n\t\tt.Errorf(\"expected truncation ellipsis in output, got %q\", out)\n\t}\n\tif len(out) > 160 {\n\t\tt.Errorf(\"truncated line should be ~140 chars, got %d (%q)\", len(out), out)\n\t}\n}\n\n// ---- ProbeReachable ----\n\n// TestProbeReachable_200 asserts that a plain 2xx GET is classified\n// reachable with the right code.\nfunc TestProbeReachable_200(t *testing.T) {\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.WriteHeader(http.StatusOK)\n\t\t_, _ = w.Write([]byte(\"hello\"))\n\t}))\n\tdefer srv.Close()\n\n\tstatus, code, err := ProbeReachable(context.Background(), srv.Client(), srv.URL)\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected err: %v\", err)\n\t}\n\tif status != ReachabilityReachable {\n\t\tt.Errorf(\"status: want %q, got %q\", ReachabilityReachable, status)\n\t}\n\tif code != 200 {\n\t\tt.Errorf(\"code: want 200, got %d\", code)\n\t}\n}\n\n// TestProbeReachable_206_Reachable asserts hosts that honor Range\n// (returning 206 Partial Content) are classified reachable.\nfunc TestProbeReachable_206_Reachable(t *testing.T) {\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.WriteHeader(http.StatusPartialContent)\n\t}))\n\tdefer srv.Close()\n\n\tstatus, code, err := ProbeReachable(context.Background(), srv.Client(), srv.URL)\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected err: %v\", err)\n\t}\n\tif status != ReachabilityReachable {\n\t\tt.Errorf(\"status: want reachable, got %q\", status)\n\t}\n\tif code != 206 {\n\t\tt.Errorf(\"code: want 206, got %d\", code)\n\t}\n}\n\n// TestProbeReachable_416_Reachable asserts hosts that don't support\n// Range (returning 416 Range Not Satisfiable) are still reachable —\n// the headers came back, the host is up. This is the F4 motivating\n// case: HEAD-then-GET probes incorrectly report unreachable here.\nfunc TestProbeReachable_416_Reachable(t *testing.T) {\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.WriteHeader(http.StatusRequestedRangeNotSatisfiable)\n\t}))\n\tdefer srv.Close()\n\n\tstatus, code, err := ProbeReachable(context.Background(), srv.Client(), srv.URL)\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected err: %v\", err)\n\t}\n\tif status != ReachabilityReachable {\n\t\tt.Errorf(\"status: want reachable (416 means headers came back), got %q\", status)\n\t}\n\tif code != 416 {\n\t\tt.Errorf(\"code: want 416, got %d\", code)\n\t}\n}\n\n// TestProbeReachable_403_Blocked asserts CDN bot screens (4xx other\n// than 416) are classified blocked, not unreachable. The host is up\n// and refusing this request — a doctor command should render WARN\n// rather than FAIL.\nfunc TestProbeReachable_403_Blocked(t *testing.T) {\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.WriteHeader(http.StatusForbidden)\n\t}))\n\tdefer srv.Close()\n\n\tstatus, code, err := ProbeReachable(context.Background(), srv.Client(), srv.URL)\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected err: %v\", err)\n\t}\n\tif status != ReachabilityBlocked {\n\t\tt.Errorf(\"status: want blocked, got %q\", status)\n\t}\n\tif code != 403 {\n\t\tt.Errorf(\"code: want 403, got %d\", code)\n\t}\n}\n\n// TestProbeReachable_NetworkError_Unreachable asserts network-layer\n// failures (DNS, connection refused, timeout) report unreachable with\n// a non-nil err.\nfunc TestProbeReachable_NetworkError_Unreachable(t *testing.T) {\n\t// Use a port that nothing is listening on.\n\tstatus, code, err := ProbeReachable(context.Background(), http.DefaultClient, \"http://127.0.0.1:1\")\n\tif err == nil {\n\t\tt.Fatal(\"expected non-nil err for unreachable host\")\n\t}\n\tif status != ReachabilityUnreachable {\n\t\tt.Errorf(\"status: want unreachable, got %q\", status)\n\t}\n\tif code != 0 {\n\t\tt.Errorf(\"code: want 0 (no response), got %d\", code)\n\t}\n}\n\n// TestProbeReachable_NilClient_UsesDefault confirms the nil-client\n// guard so doctor commands don't have to plumb an explicit *http.Client\n// when default behavior is fine.\nfunc TestProbeReachable_NilClient_UsesDefault(t *testing.T) {\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.WriteHeader(http.StatusOK)\n\t}))\n\tdefer srv.Close()\n\n\tstatus, _, err := ProbeReachable(context.Background(), nil, srv.URL)\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected err: %v\", err)\n\t}\n\tif status != ReachabilityReachable {\n\t\tt.Errorf(\"status: want reachable, got %q\", status)\n\t}\n}\n\n// TestProbeReachable_NilClient_HasTimeout asserts the nil-client\n// fallback uses a bounded-timeout client rather than http.DefaultClient\n// (which has no timeout). Without this, a probe against a slow host\n// could hang indefinitely. The test starts a server that hangs forever\n// and relies on the default 10s timeout to bail out — capped to 12s\n// total so a regression that drops the timeout would surface as a\n// test failure rather than a hung test.\nfunc TestProbeReachable_NilClient_HasTimeout(t *testing.T) {\n\tif testing.Short() {\n\t\tt.Skip(\"skip slow timeout test in -short mode\")\n\t}\n\thang := make(chan struct{})\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\u003c-hang // never returns until t.Cleanup closes it\n\t}))\n\tt.Cleanup(func() {\n\t\tclose(hang)\n\t\tsrv.Close()\n\t})\n\n\tdone := make(chan struct{})\n\tvar status string\n\tvar probeErr error\n\tgo func() {\n\t\tstatus, _, probeErr = ProbeReachable(context.Background(), nil, srv.URL)\n\t\tclose(done)\n\t}()\n\tselect {\n\tcase \u003c-done:\n\t\t// Probe returned within the bounded timeout — expected.\n\t\tif probeErr == nil {\n\t\t\tt.Fatalf(\"expected timeout err, got nil\")\n\t\t}\n\t\tif status != ReachabilityUnreachable {\n\t\t\tt.Errorf(\"status: want unreachable on timeout, got %q\", status)\n\t\t}\n\tcase \u003c-time.After(12 * time.Second):\n\t\tt.Fatalf(\"ProbeReachable hung past defaultProbeTimeout — nil-client fallback may be missing its bounded-timeout guard\")\n\t}\n}\n\n// TestProbeReachable_SendsRangeHeader confirms the probe sends\n// `Range: bytes=0-1023` so hosts that support Range bound the\n// response body before we even read it.\nfunc TestProbeReachable_SendsRangeHeader(t *testing.T) {\n\tvar receivedRange string\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\treceivedRange = r.Header.Get(\"Range\")\n\t\tw.WriteHeader(http.StatusOK)\n\t}))\n\tdefer srv.Close()\n\n\t_, _, err := ProbeReachable(context.Background(), srv.Client(), srv.URL)\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected err: %v\", err)\n\t}\n\tif receivedRange != \"bytes=0-1023\" {\n\t\tt.Errorf(\"Range header: want %q, got %q\", \"bytes=0-1023\", receivedRange)\n\t}\n}\n\n// ---- AdaptiveLimiter / RateLimitError / RetryAfter / Backoff ----\n\nfunc TestAdaptiveLimiter_NewNilOnNonPositive(t *testing.T) {\n\tif NewAdaptiveLimiter(0) != nil {\n\t\tt.Fatal(\"NewAdaptiveLimiter(0) should return nil\")\n\t}\n\tif NewAdaptiveLimiter(-1) != nil {\n\t\tt.Fatal(\"NewAdaptiveLimiter(-1) should return nil\")\n\t}\n}\n\nfunc TestAdaptiveLimiter_NilSafeMethods(t *testing.T) {\n\tvar l *AdaptiveLimiter\n\tl.Wait()\n\tl.OnSuccess()\n\tl.OnRateLimit()\n\tif got := l.Rate(); got != 0 {\n\t\tt.Errorf(\"nil limiter Rate() = %v, want 0\", got)\n\t}\n}\n\nfunc TestAdaptiveLimiter_RampsUpAfterSuccesses(t *testing.T) {\n\tl := NewAdaptiveLimiter(2.0)\n\tstartRate := l.Rate()\n\tfor i := 0; i \u003c l.rampAfter; i++ {\n\t\tl.OnSuccess()\n\t}\n\tif got := l.Rate(); got \u003c= startRate {\n\t\tt.Errorf(\"Rate() after rampAfter successes = %v, want > %v\", got, startRate)\n\t}\n}\n\nfunc TestAdaptiveLimiter_HalvesOnRateLimit(t *testing.T) {\n\tl := NewAdaptiveLimiter(8.0)\n\tstartRate := l.Rate()\n\tl.OnRateLimit()\n\tgot := l.Rate()\n\tif got != startRate/2 {\n\t\tt.Errorf(\"Rate() after OnRateLimit = %v, want %v\", got, startRate/2)\n\t}\n}\n\nfunc TestAdaptiveLimiter_FloorsAtHalfRPS(t *testing.T) {\n\tl := NewAdaptiveLimiter(2.0)\n\tfor i := 0; i \u003c 10; i++ {\n\t\tl.OnRateLimit()\n\t}\n\tif got := l.Rate(); got \u003c 0.5 {\n\t\tt.Errorf(\"Rate() after many OnRateLimit = %v, want >= 0.5\", got)\n\t}\n}\n\nfunc TestAdaptiveLimiter_WaitEnforcesPacing(t *testing.T) {\n\tl := NewAdaptiveLimiter(10.0)\n\tl.Wait()\n\tstart := time.Now()\n\tl.Wait()\n\telapsed := time.Since(start)\n\tif elapsed \u003c 80*time.Millisecond {\n\t\tt.Errorf(\"second Wait() took %v, want >= 80ms\", elapsed)\n\t}\n}\n\nfunc TestRateLimitError_ErrorMessage(t *testing.T) {\n\tcases := []struct {\n\t\tname string\n\t\terr *RateLimitError\n\t\twant string\n\t}{\n\t\t{\n\t\t\tname: \"with retry-after and body\",\n\t\t\terr: &RateLimitError{URL: \"https://api.example.com/x\", RetryAfter: 5 * time.Second, Body: \"slow down\"},\n\t\t\twant: \"rate limited: HTTP 429 for https://api.example.com/x; retry after 5s: slow down\",\n\t\t},\n\t\t{\n\t\t\tname: \"with retry-after no body\",\n\t\t\terr: &RateLimitError{URL: \"https://api.example.com/x\", RetryAfter: 2 * time.Second},\n\t\t\twant: \"rate limited: HTTP 429 for https://api.example.com/x; retry after 2s\",\n\t\t},\n\t\t{\n\t\t\tname: \"no retry-after with body\",\n\t\t\terr: &RateLimitError{URL: \"https://api.example.com/x\", Body: \"later\"},\n\t\t\twant: \"rate limited: HTTP 429 for https://api.example.com/x: later\",\n\t\t},\n\t\t{\n\t\t\tname: \"no retry-after no body\",\n\t\t\terr: &RateLimitError{URL: \"https://api.example.com/x\"},\n\t\t\twant: \"rate limited: HTTP 429 for https://api.example.com/x\",\n\t\t},\n\t}\n\tfor _, tc := range cases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tif got := tc.err.Error(); got != tc.want {\n\t\t\t\tt.Errorf(\"Error() = %q, want %q\", got, tc.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestRateLimitError_ErrorsAs(t *testing.T) {\n\tvar err error = &RateLimitError{URL: \"https://x\", RetryAfter: time.Second}\n\tvar target *RateLimitError\n\tif !errors.As(err, &target) {\n\t\tt.Fatal(\"errors.As should match *RateLimitError\")\n\t}\n\tif target.URL != \"https://x\" {\n\t\tt.Errorf(\"target.URL = %q, want %q\", target.URL, \"https://x\")\n\t}\n}\n\nfunc TestRetryAfter_Seconds(t *testing.T) {\n\tresp := &http.Response{Header: http.Header{}}\n\tresp.Header.Set(\"Retry-After\", \"10\")\n\tif got := RetryAfter(resp); got != 10*time.Second {\n\t\tt.Errorf(\"RetryAfter(10) = %v, want 10s\", got)\n\t}\n}\n\nfunc TestRetryAfter_HTTPDate(t *testing.T) {\n\tfuture := time.Now().Add(7 * time.Second).UTC().Format(http.TimeFormat)\n\tresp := &http.Response{Header: http.Header{}}\n\tresp.Header.Set(\"Retry-After\", future)\n\tgot := RetryAfter(resp)\n\tif got \u003c 5*time.Second || got > 8*time.Second {\n\t\tt.Errorf(\"RetryAfter(http-date 7s ahead) = %v, want ~7s\", got)\n\t}\n}\n\nfunc TestRetryAfter_EpochSeconds(t *testing.T) {\n\tfuture := time.Now().Add(7 * time.Second)\n\tresp := &http.Response{Header: http.Header{}}\n\tresp.Header.Set(\"Retry-After\", fmt.Sprint(future.Unix()))\n\tgot := RetryAfter(resp)\n\tif got \u003c 5*time.Second || got > 8*time.Second {\n\t\tt.Errorf(\"RetryAfter(epoch seconds 7s ahead) = %v, want ~7s\", got)\n\t}\n}\n\nfunc TestRetryAfter_EpochMilliseconds(t *testing.T) {\n\tfuture := time.Now().Add(7 * time.Second)\n\tresp := &http.Response{Header: http.Header{}}\n\tresp.Header.Set(\"Retry-After\", fmt.Sprint(future.UnixMilli()))\n\tgot := RetryAfter(resp)\n\tif got \u003c 5*time.Second || got > 8*time.Second {\n\t\tt.Errorf(\"RetryAfter(epoch milliseconds 7s ahead) = %v, want ~7s\", got)\n\t}\n}\n\nfunc TestRetryAfter_Cap(t *testing.T) {\n\tresp := &http.Response{Header: http.Header{}}\n\tresp.Header.Set(\"Retry-After\", \"600\")\n\tif got := RetryAfter(resp); got != MaxRetryWait {\n\t\tt.Errorf(\"RetryAfter(600) = %v, want capped at %v\", got, MaxRetryWait)\n\t}\n}\n\nfunc TestRetryAfter_Missing(t *testing.T) {\n\tresp := &http.Response{Header: http.Header{}}\n\tif got := RetryAfter(resp); got != 5*time.Second {\n\t\tt.Errorf(\"RetryAfter(missing) = %v, want 5s default\", got)\n\t}\n}\n\nfunc TestRetryAfter_MalformedFallsBackToDefault(t *testing.T) {\n\tresp := &http.Response{Header: http.Header{}}\n\tresp.Header.Set(\"Retry-After\", \"not-a-number\")\n\tif got := RetryAfter(resp); got != 5*time.Second {\n\t\tt.Errorf(\"RetryAfter(garbage) = %v, want 5s default\", got)\n\t}\n}\n\nfunc TestRetryAfter_NilResp(t *testing.T) {\n\tif got := RetryAfter(nil); got != 5*time.Second {\n\t\tt.Errorf(\"RetryAfter(nil) = %v, want 5s default\", got)\n\t}\n}\n\nfunc TestBackoff_DoublesPerAttempt(t *testing.T) {\n\tcases := []struct {\n\t\tattempt int\n\t\twant time.Duration\n\t}{\n\t\t{0, 1 * time.Second},\n\t\t{1, 2 * time.Second},\n\t\t{2, 4 * time.Second},\n\t\t{3, 8 * time.Second},\n\t\t{4, 16 * time.Second},\n\t}\n\tfor _, tc := range cases {\n\t\tif got := Backoff(tc.attempt); got != tc.want {\n\t\t\tt.Errorf(\"Backoff(%d) = %v, want %v\", tc.attempt, got, tc.want)\n\t\t}\n\t}\n}\n\nfunc TestBackoff_CapsAtMax(t *testing.T) {\n\tif got := Backoff(20); got != MaxBackoff {\n\t\tt.Errorf(\"Backoff(20) = %v, want capped at %v\", got, MaxBackoff)\n\t}\n}\n\nfunc TestBackoff_NegativeAttemptClampsToZero(t *testing.T) {\n\tif got := Backoff(-3); got != 1*time.Second {\n\t\tt.Errorf(\"Backoff(-3) = %v, want 1s (clamped to 0)\", got)\n\t}\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":25083,"content_sha256":"07d92ff27c31d88f9ce0e86a3ce3227d5b33a02ba98ef3297390ab631ba73893"},{"filename":"internal/cliutil/fanout.go","content":"// Copyright 2026 rderwin and contributors. Licensed under Apache-2.0. See LICENSE.\n// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.\n\n// Package cliutil contains shared helpers emitted into every generated CLI\n// by the Printing Press. Helpers live in their own package (not in package\n// cli) to avoid symbol collisions with agent-authored commands in package\n// cli. Callers import as `cliutil` and invoke `cliutil.FanoutRun(...)`,\n// `cliutil.CleanText(...)`, etc.\npackage cliutil\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"strings\"\n\t\"sync\"\n)\n\n// FanoutError represents one source's failure from a FanoutRun call.\n// Source identifies which input produced the error; Err is the error returned\n// by the caller's fn.\ntype FanoutError struct {\n\tSource string\n\tErr error\n}\n\n// FanoutResult pairs a successful fn return value with its source name so\n// callers can iterate results without a separate source lookup.\ntype FanoutResult[T any] struct {\n\tSource string\n\tValue T\n}\n\n// FanoutOption configures a FanoutRun call. Use the With* constructors.\ntype FanoutOption func(*fanoutOptions)\n\ntype fanoutOptions struct {\n\tconcurrency int\n}\n\n// defaultFanoutConcurrency is the worker count when the caller passes no\n// WithConcurrency option. 4 matches the existing sync.go worker-pool idiom\n// and is safe for scraping CLIs where per-host 429 pressure is real.\nconst defaultFanoutConcurrency = 4\n\n// WithConcurrency overrides the default worker count for a single FanoutRun\n// call. Use higher values for fan-outs without external rate limits; keep\n// the default (4) for scraping CLIs. Values below 1 are clamped to 1.\nfunc WithConcurrency(n int) FanoutOption {\n\treturn func(o *fanoutOptions) {\n\t\tif n \u003c 1 {\n\t\t\tn = 1\n\t\t}\n\t\to.concurrency = n\n\t}\n}\n\n// FanoutRun invokes fn concurrently for each source and collects successful\n// results plus per-source errors. It never returns a top-level error and\n// recovers panics from fn as per-source FanoutErrors — partial failures\n// surface via the returned errors slice, which should be piped to\n// FanoutReportErrors so no source is silently dropped.\n//\n// Contract:\n// - Workers respect ctx: on ctx.Done() they stop pulling new jobs, and\n// in-flight fn calls receive the cancelled ctx.\n// - Unpulled sources produce a FanoutError{Err: ctx.Err()} so reporting\n// stays complete — cancel never silently drops a source.\n// - Errors are collected by source index and returned in source order,\n// not completion order, so FanoutReportErrors output is deterministic\n// across runs.\n// - The jobs channel is bounded at 2*concurrency so large source lists\n// don't buffer one goroutine per source.\n//\n// Per-source rate limiting is the caller's responsibility. Wrap fn with a\n// limiter (e.g., golang.org/x/time/rate) if you're fanning out to sites\n// that enforce per-host throttles; naïve scrape fan-out triggers 429s.\nfunc FanoutRun[S, T any](\n\tctx context.Context,\n\tsources []S,\n\tname func(S) string,\n\tfn func(context.Context, S) (T, error),\n\topts ...FanoutOption,\n) ([]FanoutResult[T], []FanoutError) {\n\tcfg := fanoutOptions{concurrency: defaultFanoutConcurrency}\n\tfor _, o := range opts {\n\t\to(&cfg)\n\t}\n\tif cfg.concurrency \u003c 1 {\n\t\tcfg.concurrency = 1\n\t}\n\n\t// Parallel slices indexed by source position so output stays in source\n\t// order regardless of completion order. Using pointers lets us detect\n\t// \"no result and no error\" (shouldn't happen but is a defensive signal).\n\ttype slot struct {\n\t\tresult *FanoutResult[T]\n\t\terr *FanoutError\n\t}\n\tslots := make([]slot, len(sources))\n\n\ttype job struct{ idx int }\n\tjobs := make(chan job, cfg.concurrency*2)\n\n\tvar wg sync.WaitGroup\n\tfor w := 0; w \u003c cfg.concurrency; w++ {\n\t\twg.Add(1)\n\t\tgo func() {\n\t\t\tdefer wg.Done()\n\t\t\tfor j := range jobs {\n\t\t\t\tidx := j.idx\n\t\t\t\tfunc() {\n\t\t\t\t\t// Recover panics from fn so one bad source doesn't kill\n\t\t\t\t\t// the whole process. The panic becomes a per-source\n\t\t\t\t\t// FanoutError alongside regular errors.\n\t\t\t\t\tdefer func() {\n\t\t\t\t\t\tif r := recover(); r != nil {\n\t\t\t\t\t\t\tslots[idx].err = &FanoutError{\n\t\t\t\t\t\t\t\tSource: name(sources[idx]),\n\t\t\t\t\t\t\t\tErr: fmt.Errorf(\"panic in fanout fn: %v\", r),\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}()\n\t\t\t\t\t// Respect cancellation: if ctx is already done, record\n\t\t\t\t\t// the cancel error rather than running fn with a\n\t\t\t\t\t// useless context.\n\t\t\t\t\tif err := ctx.Err(); err != nil {\n\t\t\t\t\t\tslots[idx].err = &FanoutError{Source: name(sources[idx]), Err: err}\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t\tval, err := fn(ctx, sources[idx])\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\tslots[idx].err = &FanoutError{Source: name(sources[idx]), Err: err}\n\t\t\t\t\t} else {\n\t\t\t\t\t\tv := val\n\t\t\t\t\t\tslots[idx].result = &FanoutResult[T]{Source: name(sources[idx]), Value: v}\n\t\t\t\t\t}\n\t\t\t\t}()\n\t\t\t}\n\t\t}()\n\t}\n\n\t// Feed jobs, but stop feeding if ctx cancels so unpulled sources get a\n\t// ctx.Err() FanoutError rather than being silently dropped.\n\tfunc() {\n\t\tdefer close(jobs)\n\t\tfor i := range sources {\n\t\t\tselect {\n\t\t\tcase \u003c-ctx.Done():\n\t\t\t\t// Mark this and all remaining sources as cancelled, then stop.\n\t\t\t\tfor j := i; j \u003c len(sources); j++ {\n\t\t\t\t\tslots[j].err = &FanoutError{Source: name(sources[j]), Err: ctx.Err()}\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\tcase jobs \u003c- job{idx: i}:\n\t\t\t}\n\t\t}\n\t}()\n\n\twg.Wait()\n\n\tresults := make([]FanoutResult[T], 0, len(sources))\n\terrs := make([]FanoutError, 0, len(slots))\n\tfor _, s := range slots {\n\t\tif s.result != nil {\n\t\t\tresults = append(results, *s.result)\n\t\t}\n\t\tif s.err != nil {\n\t\t\terrs = append(errs, *s.err)\n\t\t}\n\t}\n\treturn results, errs\n}\n\n// FanoutReportErrors writes one warning line per FanoutError to w in source\n// order. Format: \"warn: \u003csource>: \u003cshort-error>\\n\" where \u003cshort-error> is\n// the error's first line truncated to 120 chars. No-op when errs is empty.\n//\n// Call this after FanoutRun so partial failures never get silently dropped\n// — the warning surface is the whole reason for the helper.\nfunc FanoutReportErrors(w io.Writer, errs []FanoutError) {\n\tfor _, e := range errs {\n\t\tfmt.Fprintf(w, \"warn: %s: %s\\n\", e.Source, shortFanoutErr(e.Err))\n\t}\n}\n\n// shortFanoutErr condenses an error to a single-line reason string for\n// stderr display alongside many sources.\nfunc shortFanoutErr(err error) string {\n\tif err == nil {\n\t\treturn \"\"\n\t}\n\ts := err.Error()\n\tif i := strings.Index(s, \"\\n\"); i >= 0 {\n\t\ts = s[:i]\n\t}\n\tconst max = 120\n\tif len(s) > max {\n\t\ts = s[:max] + \"…\"\n\t}\n\treturn s\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":6382,"content_sha256":"0f4ba40de8347e23bd92312d646482fcfbbcf9913088cce911b9718d5170dd8e"},{"filename":"internal/cliutil/freshness_test.go","content":"// Copyright 2026 rderwin and contributors. Licensed under Apache-2.0. See LICENSE.\n// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.\n\npackage cliutil\n\nimport (\n\t\"context\"\n\t\"database/sql\"\n\t\"encoding/json\"\n\t\"path/filepath\"\n\t\"testing\"\n\t\"time\"\n\n\t_ \"modernc.org/sqlite\"\n)\n\n// newTestDB creates a tiny sqlite DB matching the sync_state shape that\n// generated stores emit. Keeping this independent of the real store\n// package lets cliutil stay self-contained.\nfunc newTestDB(t *testing.T) *sql.DB {\n\tt.Helper()\n\tdbPath := filepath.Join(t.TempDir(), \"cliutil.db\")\n\tdb, err := sql.Open(\"sqlite\", dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"open: %v\", err)\n\t}\n\tt.Cleanup(func() { db.Close() })\n\tif _, err := db.Exec(`CREATE TABLE sync_state (\n\t\tresource_type TEXT PRIMARY KEY,\n\t\tlast_cursor TEXT,\n\t\tlast_synced_at DATETIME,\n\t\ttotal_count INTEGER DEFAULT 0\n\t)`); err != nil {\n\t\tt.Fatalf(\"create sync_state: %v\", err)\n\t}\n\treturn db\n}\n\nfunc stamp(t *testing.T, db *sql.DB, rtype string, last time.Time) {\n\tt.Helper()\n\tif _, err := db.Exec(\n\t\t`INSERT INTO sync_state(resource_type, last_synced_at, total_count) VALUES (?, ?, 0)\n\t\t ON CONFLICT(resource_type) DO UPDATE SET last_synced_at = excluded.last_synced_at`,\n\t\trtype, last,\n\t); err != nil {\n\t\tt.Fatalf(\"stamp sync_state: %v\", err)\n\t}\n}\n\nfunc TestEnsureFresh_AllFresh(t *testing.T) {\n\tdb := newTestDB(t)\n\tstamp(t, db, \"issues\", time.Now().Add(-1*time.Minute))\n\n\tdecision, err := EnsureFresh(context.Background(), db, []string{\"issues\"}, Policy{StaleAfter: 6 * time.Hour})\n\tif err != nil {\n\t\tt.Fatalf(\"err: %v\", err)\n\t}\n\tif decision != DecisionFresh {\n\t\tt.Fatalf(\"got %v, want fresh\", decision)\n\t}\n}\n\nfunc TestEnsureFresh_StaleAPI(t *testing.T) {\n\tdb := newTestDB(t)\n\tstamp(t, db, \"issues\", time.Now().Add(-10*time.Hour))\n\n\tdecision, err := EnsureFresh(context.Background(), db, []string{\"issues\"}, Policy{StaleAfter: 6 * time.Hour})\n\tif err != nil {\n\t\tt.Fatalf(\"err: %v\", err)\n\t}\n\tif decision != DecisionStaleAPI {\n\t\tt.Fatalf(\"got %v, want stale-api\", decision)\n\t}\n}\n\nfunc TestEnsureFresh_StaleShare(t *testing.T) {\n\tdb := newTestDB(t)\n\tstamp(t, db, \"issues\", time.Now().Add(-10*time.Hour))\n\n\tdecision, err := EnsureFresh(context.Background(), db, []string{\"issues\"}, Policy{StaleAfter: 6 * time.Hour, ShareEnabled: true})\n\tif err != nil {\n\t\tt.Fatalf(\"err: %v\", err)\n\t}\n\tif decision != DecisionStaleShare {\n\t\tt.Fatalf(\"got %v, want stale-share\", decision)\n\t}\n}\n\nfunc TestEnsureFresh_WorstCaseWins(t *testing.T) {\n\tdb := newTestDB(t)\n\tstamp(t, db, \"issues\", time.Now().Add(-1*time.Minute)) // fresh\n\tstamp(t, db, \"teams\", time.Now().Add(-10*time.Hour)) // stale\n\n\tdecision, _ := EnsureFresh(context.Background(), db, []string{\"issues\", \"teams\"}, Policy{StaleAfter: 6 * time.Hour})\n\tif decision != DecisionStaleAPI {\n\t\tt.Fatalf(\"got %v, want stale-api (worst case wins)\", decision)\n\t}\n}\n\nfunc TestEnsureFresh_MissingResourceIsStale(t *testing.T) {\n\tdb := newTestDB(t)\n\tstamp(t, db, \"issues\", time.Now().Add(-1*time.Minute))\n\n\tdecision, _ := EnsureFresh(context.Background(), db, []string{\"issues\", \"never_synced\"}, Policy{StaleAfter: 6 * time.Hour})\n\tif decision != DecisionStaleAPI {\n\t\tt.Fatalf(\"got %v, want stale-api (missing row)\", decision)\n\t}\n}\n\nfunc TestEnsureFresh_NullLastSyncedIsStale(t *testing.T) {\n\tdb := newTestDB(t)\n\tif _, err := db.Exec(`INSERT INTO sync_state(resource_type, last_synced_at) VALUES (?, NULL)`, \"issues\"); err != nil {\n\t\tt.Fatalf(\"stamp null: %v\", err)\n\t}\n\n\tdecision, err := EnsureFresh(context.Background(), db, []string{\"issues\"}, Policy{StaleAfter: 6 * time.Hour})\n\tif err != nil {\n\t\tt.Fatalf(\"err: %v\", err)\n\t}\n\tif decision != DecisionStaleAPI {\n\t\tt.Fatalf(\"got %v, want stale-api (NULL last_synced_at)\", decision)\n\t}\n}\n\nfunc TestEnsureFresh_PerResourceOverrideShorterWins(t *testing.T) {\n\tdb := newTestDB(t)\n\tstamp(t, db, \"quotes\", time.Now().Add(-30*time.Minute))\n\n\t// global 6h threshold would pass, but quotes:5m override flips to stale.\n\tdecision, _ := EnsureFresh(context.Background(), db, []string{\"quotes\"}, Policy{\n\t\tStaleAfter: 6 * time.Hour,\n\t\tPerResource: map[string]time.Duration{\"quotes\": 5 * time.Minute},\n\t})\n\tif decision != DecisionStaleAPI {\n\t\tt.Fatalf(\"got %v, want stale-api (shorter per-resource threshold wins)\", decision)\n\t}\n}\n\nfunc TestEnsureFresh_ZeroThresholdNeverStale(t *testing.T) {\n\tdb := newTestDB(t)\n\tstamp(t, db, \"teams\", time.Now().Add(-1000*time.Hour))\n\n\t// Zero per-resource threshold means \"never stale\" — an escape hatch for\n\t// slow-changing resources that should not auto-refresh.\n\tdecision, _ := EnsureFresh(context.Background(), db, []string{\"teams\"}, Policy{\n\t\tStaleAfter: 6 * time.Hour,\n\t\tPerResource: map[string]time.Duration{\"teams\": 0},\n\t})\n\tif decision != DecisionFresh {\n\t\tt.Fatalf(\"got %v, want fresh (zero threshold)\", decision)\n\t}\n}\n\nfunc TestEnsureFresh_EnvOptOutShortCircuits(t *testing.T) {\n\tdb := newTestDB(t)\n\tstamp(t, db, \"issues\", time.Now().Add(-1000*time.Hour)) // very stale\n\n\tt.Setenv(\"MY_CLI_NO_AUTO_REFRESH\", \"1\")\n\n\tdecision, err := EnsureFresh(context.Background(), db, []string{\"issues\"}, Policy{\n\t\tStaleAfter: 6 * time.Hour,\n\t\tEnvOptOut: \"MY_CLI_NO_AUTO_REFRESH\",\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"err: %v\", err)\n\t}\n\tif decision != DecisionFresh {\n\t\tt.Fatalf(\"got %v, want fresh (env opt-out)\", decision)\n\t}\n}\n\nfunc TestEnsureFresh_NilDBReturnsNoStore(t *testing.T) {\n\tdecision, err := EnsureFresh(context.Background(), nil, []string{\"issues\"}, Policy{StaleAfter: 6 * time.Hour})\n\tif err != nil {\n\t\tt.Fatalf(\"err: %v\", err)\n\t}\n\tif decision != DecisionNoStore {\n\t\tt.Fatalf(\"got %v, want no-store\", decision)\n\t}\n}\n\nfunc TestEnsureFresh_NoResourcesReturnsFresh(t *testing.T) {\n\tdb := newTestDB(t)\n\tdecision, err := EnsureFresh(context.Background(), db, nil, Policy{StaleAfter: 6 * time.Hour})\n\tif err != nil {\n\t\tt.Fatalf(\"err: %v\", err)\n\t}\n\tif decision != DecisionFresh {\n\t\tt.Fatalf(\"got %v, want fresh (empty resources)\", decision)\n\t}\n}\n\nfunc TestEnsureFresh_MissingSyncStateTableIsNoStore(t *testing.T) {\n\tdbPath := filepath.Join(t.TempDir(), \"empty.db\")\n\tdb, err := sql.Open(\"sqlite\", dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"open: %v\", err)\n\t}\n\tt.Cleanup(func() { db.Close() })\n\n\tdecision, err := EnsureFresh(context.Background(), db, []string{\"issues\"}, Policy{StaleAfter: 6 * time.Hour})\n\tif err != nil {\n\t\tt.Fatalf(\"err: %v\", err)\n\t}\n\tif decision != DecisionNoStore {\n\t\tt.Fatalf(\"got %v, want no-store (missing sync_state table)\", decision)\n\t}\n}\n\nfunc TestDecision_StringStableTags(t *testing.T) {\n\tcases := map[Decision]string{\n\t\tDecisionFresh: \"fresh\",\n\t\tDecisionStaleAPI: \"stale-api\",\n\t\tDecisionStaleShare: \"stale-share\",\n\t\tDecisionNoStore: \"no-store\",\n\t}\n\tfor d, want := range cases {\n\t\tif d.String() != want {\n\t\t\tt.Errorf(\"%d.String() = %q, want %q\", d, d.String(), want)\n\t\t}\n\t}\n}\n\nfunc TestFreshnessMeta_JSONShape(t *testing.T) {\n\tmeta := FreshnessMeta{\n\t\tDecision: \"stale-api\",\n\t\tRan: true,\n\t\tReason: \"refreshed\",\n\t\tResources: []string{\"issues\"},\n\t\tElapsedMS: 12,\n\t\tSource: \"auto\",\n\t}\n\tdata, err := json.Marshal(meta)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\tvar got map[string]any\n\tif err := json.Unmarshal(data, &got); err != nil {\n\t\tt.Fatalf(\"unmarshal: %v\", err)\n\t}\n\tfor _, key := range []string{\"decision\", \"ran\", \"reason\", \"resources\", \"elapsed_ms\", \"source\"} {\n\t\tif _, ok := got[key]; !ok {\n\t\t\tt.Fatalf(\"missing json key %q in %s\", key, string(data))\n\t\t}\n\t}\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":7401,"content_sha256":"e9bcbea474a281a2f96038ad7dc2c496ad0106954a81296b6f674b8b5e0ed2fd"},{"filename":"internal/cliutil/freshness.go","content":"// Copyright 2026 rderwin and contributors. Licensed under Apache-2.0. See LICENSE.\n// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.\n\npackage cliutil\n\nimport (\n\t\"context\"\n\t\"database/sql\"\n\t\"fmt\"\n\t\"os\"\n\t\"strings\"\n\t\"time\"\n)\n\n// Decision is the outcome of a freshness check. It tells the caller what\n// to do next without doing the refresh itself — EnsureFresh is pure\n// decision logic so callers can compose it with their own refresh path\n// (API sync, git-backed share import, or both).\ntype Decision int\n\nconst (\n\t// DecisionFresh means every requested resource is within the staleness\n\t// budget and the caller can serve from cache directly.\n\tDecisionFresh Decision = iota\n\t// DecisionStaleAPI means at least one requested resource is stale and\n\t// the caller should refresh from the upstream API.\n\tDecisionStaleAPI\n\t// DecisionStaleShare means at least one requested resource is stale\n\t// AND the caller configured a share remote, so the caller should\n\t// prefer `git pull + import` over an API hit.\n\tDecisionStaleShare\n\t// DecisionNoStore means the caller asked about freshness but the store\n\t// has no sync_state table yet (first run). Treated the same as stale\n\t// by most callers, but distinct for logging.\n\tDecisionNoStore\n)\n\n// String returns a stable tag suitable for logging and JSON.\nfunc (d Decision) String() string {\n\tswitch d {\n\tcase DecisionFresh:\n\t\treturn \"fresh\"\n\tcase DecisionStaleAPI:\n\t\treturn \"stale-api\"\n\tcase DecisionStaleShare:\n\t\treturn \"stale-share\"\n\tcase DecisionNoStore:\n\t\treturn \"no-store\"\n\tdefault:\n\t\treturn \"unknown\"\n\t}\n}\n\n// FreshnessMeta is the stable agent-facing metadata shape for commands that\n// participate in machine-owned freshness. Generated JSON provenance envelopes\n// attach this under meta.freshness.\ntype FreshnessMeta struct {\n\tDecision string `json:\"decision\"` // fresh, stale-api, stale-share, no-store, skipped, or error\n\tRan bool `json:\"ran\"` // true when the hook attempted a refresh\n\tReason string `json:\"reason,omitempty\"` // fresh, refreshed, refresh_failed, data_source_live, etc.\n\tResources []string `json:\"resources,omitempty\"` // resource types covered by the command path\n\tElapsedMS int64 `json:\"elapsed_ms,omitempty\"` // wall-clock spent in the freshness hook\n\tError string `json:\"error,omitempty\"` // refresh/decision error, redacted only by the caller if needed\n\tSource string `json:\"source,omitempty\"` // current data-source mode: auto, local, or live\n}\n\n// Policy configures a freshness check. StaleAfter is the default threshold\n// applied to any resource not listed in PerResource. PerResource is an\n// override map (resource_type -> per-type threshold). EnvOptOut, when set\n// and that env var equals \"1\", short-circuits to DecisionFresh without\n// touching the database — useful for CI batch jobs and for agents that\n// want to serve whatever is on disk without a refresh round trip.\n// ShareEnabled controls which stale decision is returned: when true and\n// the data is stale, the decision is DecisionStaleShare so the caller\n// prefers a git pull over an API refresh.\ntype Policy struct {\n\tStaleAfter time.Duration\n\tPerResource map[string]time.Duration\n\tEnvOptOut string\n\tShareEnabled bool\n}\n\n// EnsureFresh inspects sync_state for the requested resources and returns\n// a Decision. It does not refresh — callers are expected to branch on the\n// decision and invoke the appropriate refresh path themselves. The ctx\n// bounds the DB query only; it does not bound the caller's subsequent\n// refresh work.\n//\n// A resource with no sync_state row or a NULL last_synced_at is treated\n// as stale. The worst-case decision across resources wins — one stale\n// resource makes the whole read stale.\nfunc EnsureFresh(ctx context.Context, db *sql.DB, resources []string, policy Policy) (Decision, error) {\n\tif policy.EnvOptOut != \"\" && os.Getenv(policy.EnvOptOut) == \"1\" {\n\t\treturn DecisionFresh, nil\n\t}\n\tif db == nil {\n\t\treturn DecisionNoStore, nil\n\t}\n\tif len(resources) == 0 {\n\t\treturn DecisionFresh, nil\n\t}\n\n\t// Build the IN clause with positional parameters — resource names come\n\t// from the generated command map, not user input, but sanitising with\n\t// placeholders is still the right instinct.\n\tplaceholders := make([]string, len(resources))\n\targs := make([]any, len(resources))\n\tfor i, r := range resources {\n\t\tplaceholders[i] = \"?\"\n\t\targs[i] = r\n\t}\n\tquery := fmt.Sprintf(\n\t\t`SELECT resource_type, last_synced_at FROM sync_state WHERE resource_type IN (%s)`,\n\t\tstrings.Join(placeholders, \",\"),\n\t)\n\n\trows, err := db.QueryContext(ctx, query, args...)\n\tif err != nil {\n\t\t// sync_state may not exist on a brand-new DB that was just migrated\n\t\t// but never synced. Treat as no-store so the caller knows to\n\t\t// hydrate, not as an outright error.\n\t\tif strings.Contains(err.Error(), \"no such table\") {\n\t\t\treturn DecisionNoStore, nil\n\t\t}\n\t\treturn DecisionFresh, fmt.Errorf(\"query sync_state: %w\", err)\n\t}\n\tdefer rows.Close()\n\n\tseen := make(map[string]bool, len(resources))\n\tanyStale := false\n\tfor rows.Next() {\n\t\tvar rtype string\n\t\tvar lastSynced sql.NullTime\n\t\tif err := rows.Scan(&rtype, &lastSynced); err != nil {\n\t\t\treturn DecisionFresh, fmt.Errorf(\"scan sync_state row: %w\", err)\n\t\t}\n\t\tseen[rtype] = true\n\t\tif !lastSynced.Valid {\n\t\t\tanyStale = true\n\t\t\tcontinue\n\t\t}\n\t\tthreshold := policy.StaleAfter\n\t\tif policy.PerResource != nil {\n\t\t\tif override, ok := policy.PerResource[rtype]; ok {\n\t\t\t\tthreshold = override\n\t\t\t}\n\t\t}\n\t\tif threshold \u003c= 0 {\n\t\t\t// A zero-valued threshold means \"never stale\" — used by callers\n\t\t\t// that want to opt a specific resource out of auto-refresh\n\t\t\t// without disabling the whole policy.\n\t\t\tcontinue\n\t\t}\n\t\tif time.Since(lastSynced.Time) > threshold {\n\t\t\tanyStale = true\n\t\t}\n\t}\n\tif err := rows.Err(); err != nil {\n\t\treturn DecisionFresh, fmt.Errorf(\"iterate sync_state rows: %w\", err)\n\t}\n\n\t// A requested resource with no row is stale by definition.\n\tfor _, r := range resources {\n\t\tif !seen[r] {\n\t\t\tanyStale = true\n\t\t\tbreak\n\t\t}\n\t}\n\n\tif !anyStale {\n\t\treturn DecisionFresh, nil\n\t}\n\tif policy.ShareEnabled {\n\t\treturn DecisionStaleShare, nil\n\t}\n\treturn DecisionStaleAPI, nil\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":6208,"content_sha256":"89bd03fe3fb890ded16ba1b9aa06df09b429d43fcb0d2b407d1c444f595dc189"},{"filename":"internal/cliutil/probe.go","content":"// Copyright 2026 rderwin and contributors. Licensed under Apache-2.0. See LICENSE.\n// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.\n\npackage cliutil\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"time\"\n)\n\n// defaultProbeTimeout caps the request when the caller passes a nil\n// client and didn't set a context deadline. Without this cap, a probe\n// against a non-responsive host could hang indefinitely (the global\n// http.DefaultClient has no Timeout). Callers who pass their own\n// *http.Client are expected to set Timeout there; this value only\n// applies to the nil-client fallback.\nconst defaultProbeTimeout = 10 * time.Second\n\n// ReachabilityStatus is one of the strings returned by ProbeReachable.\n// Callers (typically a doctor command listing per-source health) match\n// on these constants when deciding whether to render OK/WARN/FAIL.\nconst (\n\t// ReachabilityReachable means the host responded with a 2xx, a 206\n\t// (partial — Range honored), or a 416 (Range not honored, but the\n\t// host did respond with headers). All three are evidence that the\n\t// host is alive and responding to GET.\n\tReachabilityReachable = \"reachable\"\n\t// ReachabilityBlocked means the host responded with a 4xx (other\n\t// than 416) or 5xx. The host is up but is refusing this request —\n\t// usually a CDN bot screen, a paywall, or a server error.\n\tReachabilityBlocked = \"blocked\"\n\t// ReachabilityUnreachable means the request errored at the network\n\t// layer — DNS failure, connection refused, TLS shutdown, timeout.\n\tReachabilityUnreachable = \"unreachable\"\n)\n\n// ProbeReachable does a lightweight reachability probe against url\n// using client and returns a (status, code, err) triple. The probe\n// uses GET with a `Range: bytes=0-1023` header so it never pulls more\n// than ~1 KB of body, regardless of how the host responds; the body\n// is read and discarded so the connection can be released.\n//\n// Why not HEAD: many recipe-site CDNs (BBC, RecipeTin Eats, AllRecipes,\n// Serious Eats, The Kitchn) terminate HEAD requests with a TLS\n// shutdown / EOF even though they serve GET cleanly. A HEAD-based\n// probe lies — reporting \"unreachable EOF\" for hosts that work fine\n// for the real fetch path. recipe-goat hit this in retro #301\n// finding F4: doctor reported six sites unreachable that the goat\n// ranker was successfully scraping.\n//\n// Use this from any doctor or health-check command that does\n// per-source reachability fan-out, with the same client that the real\n// fetch path uses (typically a Surf-Chrome client built via\n// surf.NewClient().Builder().Impersonate().Chrome().Build()). Probe\n// drift between the doctor probe and the fetch path is the bug class\n// this helper exists to prevent.\n//\n// Returned values:\n// - status is one of ReachabilityReachable, ReachabilityBlocked, or\n// ReachabilityUnreachable.\n// - code is the HTTP status code, or 0 when the request errored at\n// the network layer.\n// - err is non-nil only for network-layer failures. A 4xx or 5xx\n// response is reported via status/code with err == nil.\nfunc ProbeReachable(ctx context.Context, client *http.Client, url string) (status string, code int, err error) {\n\tif client == nil {\n\t\t// Build a copy of DefaultClient with a bounded timeout — the\n\t\t// global DefaultClient has none, so a nil-client probe against\n\t\t// a slow host would hang. Callers passing their own client are\n\t\t// expected to set Timeout themselves.\n\t\tclient = &http.Client{Timeout: defaultProbeTimeout}\n\t}\n\treq, reqErr := http.NewRequestWithContext(ctx, \"GET\", url, nil)\n\tif reqErr != nil {\n\t\treturn ReachabilityUnreachable, 0, fmt.Errorf(\"building request: %w\", reqErr)\n\t}\n\t// Range: bytes=0-1023 keeps body bounded for hosts that honor it.\n\t// Hosts that don't support Range respond with 200 + full body or\n\t// 416 — both are caught below as \"reachable\", and the limited\n\t// io.Copy below ensures we never pull more than 1 KiB anyway.\n\treq.Header.Set(\"Range\", \"bytes=0-1023\")\n\tresp, doErr := client.Do(req)\n\tif doErr != nil {\n\t\treturn ReachabilityUnreachable, 0, doErr\n\t}\n\tdefer resp.Body.Close()\n\t// Drain up to 2 KiB so the connection can be reused. We read past\n\t// the 1024-byte Range hint to cover hosts that ignored it.\n\t_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 2048))\n\tswitch {\n\tcase resp.StatusCode >= 200 && resp.StatusCode \u003c 300:\n\t\treturn ReachabilityReachable, resp.StatusCode, nil\n\tcase resp.StatusCode == http.StatusRequestedRangeNotSatisfiable:\n\t\t// 416 means the host doesn't support Range. We still got\n\t\t// headers back, so the host is up and responding to GET —\n\t\t// classify as reachable.\n\t\treturn ReachabilityReachable, resp.StatusCode, nil\n\tdefault:\n\t\treturn ReachabilityBlocked, resp.StatusCode, nil\n\t}\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":4771,"content_sha256":"d8e8c93ee5476c90c657eee88f1db2c28c9a7056e633c123ca1ad54254b817cb"},{"filename":"internal/cliutil/ratelimit.go","content":"// Copyright 2026 rderwin and contributors. Licensed under Apache-2.0. See LICENSE.\n// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.\n\npackage cliutil\n\nimport (\n\t\"fmt\"\n\t\"math\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n)\n\n// AdaptiveLimiter paces outbound requests with adaptive ceiling discovery.\n// Starts at a floor rate, ramps up after consecutive successes, halves on 429\n// and records a ceiling. Per-session only — not persisted. Methods are safe\n// to call on a nil receiver.\ntype AdaptiveLimiter struct {\n\tmu sync.Mutex\n\trate float64\n\tfloor float64\n\tceiling float64\n\tsuccesses int\n\trampAfter int\n\tlastRequest time.Time // zero-value: first Wait() returns immediately\n}\n\n// NewAdaptiveLimiter returns a limiter starting at ratePerSec, or nil when\n// rate-limiting should be disabled. Methods on the nil limiter no-op.\nfunc NewAdaptiveLimiter(ratePerSec float64) *AdaptiveLimiter {\n\tif ratePerSec \u003c= 0 {\n\t\treturn nil\n\t}\n\treturn &AdaptiveLimiter{\n\t\trate: ratePerSec,\n\t\tfloor: ratePerSec,\n\t\trampAfter: 10,\n\t}\n}\n\nfunc (l *AdaptiveLimiter) Wait() {\n\tif l == nil {\n\t\treturn\n\t}\n\tl.mu.Lock()\n\tdelay := time.Duration(float64(time.Second) / l.rate)\n\telapsed := time.Since(l.lastRequest)\n\tl.mu.Unlock()\n\tif elapsed \u003c delay {\n\t\ttime.Sleep(delay - elapsed)\n\t}\n\tl.mu.Lock()\n\tl.lastRequest = time.Now()\n\tl.mu.Unlock()\n}\n\nfunc (l *AdaptiveLimiter) OnSuccess() {\n\tif l == nil {\n\t\treturn\n\t}\n\tl.mu.Lock()\n\tdefer l.mu.Unlock()\n\tl.successes++\n\tif l.successes >= l.rampAfter {\n\t\tnewRate := l.rate * 1.25\n\t\tif l.ceiling > 0 && newRate > l.ceiling*0.9 {\n\t\t\tnewRate = l.ceiling * 0.9\n\t\t}\n\t\tl.rate = newRate\n\t\tl.successes = 0\n\t}\n}\n\nfunc (l *AdaptiveLimiter) OnRateLimit() {\n\tif l == nil {\n\t\treturn\n\t}\n\tl.mu.Lock()\n\tdefer l.mu.Unlock()\n\tl.ceiling = l.rate\n\tl.rate = l.rate / 2\n\tif l.rate \u003c 0.5 {\n\t\tl.rate = 0.5\n\t}\n\tl.successes = 0\n}\n\nfunc (l *AdaptiveLimiter) Rate() float64 {\n\tif l == nil {\n\t\treturn 0\n\t}\n\tl.mu.Lock()\n\tdefer l.mu.Unlock()\n\treturn l.rate\n}\n\n// RateLimitError signals an upstream returned 429 after retries were\n// exhausted. Callers must surface this as a hard error rather than empty\n// results — empty-on-throttle is indistinguishable from \"no data exists\"\n// and silently corrupts downstream queries.\ntype RateLimitError struct {\n\tURL string\n\tRetryAfter time.Duration\n\tBody string\n}\n\nfunc (e *RateLimitError) Error() string {\n\tmsg := fmt.Sprintf(\"rate limited: HTTP 429 for %s\", e.URL)\n\tif e.RetryAfter > 0 {\n\t\tmsg += fmt.Sprintf(\"; retry after %s\", e.RetryAfter)\n\t}\n\tif body := strings.TrimSpace(e.Body); body != \"\" {\n\t\tmsg += \": \" + body\n\t}\n\treturn msg\n}\n\n// MaxRetryWait caps the wait derived from a Retry-After header so a buggy\n// or hostile upstream cannot pin a CLI for hours.\nconst MaxRetryWait = 60 * time.Second\n\n// RetryAfter parses an HTTP Retry-After header (RFC 7231: delta-seconds or\n// HTTP-date), capped at MaxRetryWait. Returns 5s when missing or unparseable.\nfunc RetryAfter(resp *http.Response) time.Duration {\n\tif resp == nil {\n\t\treturn 5 * time.Second\n\t}\n\theader := strings.TrimSpace(resp.Header.Get(\"Retry-After\"))\n\tif header == \"\" {\n\t\treturn 5 * time.Second\n\t}\n\tif seconds, err := strconv.Atoi(header); err == nil {\n\t\td := time.Duration(seconds) * time.Second\n\t\tif d > MaxRetryWait {\n\t\t\treturn MaxRetryWait\n\t\t}\n\t\tif d \u003c= 0 {\n\t\t\treturn 5 * time.Second\n\t\t}\n\t\treturn d\n\t}\n\tif t, err := http.ParseTime(header); err == nil {\n\t\twait := time.Until(t)\n\t\tif wait > MaxRetryWait {\n\t\t\treturn MaxRetryWait\n\t\t}\n\t\tif wait > 0 {\n\t\t\treturn wait\n\t\t}\n\t}\n\treturn 5 * time.Second\n}\n\n// MaxBackoff caps Backoff so tests stay bounded. Callers needing jitter\n// add their own; the bare exponential keeps the contract deterministic.\nconst MaxBackoff = 30 * time.Second\n\n// Backoff returns 2^attempt seconds capped at MaxBackoff.\nfunc Backoff(attempt int) time.Duration {\n\tif attempt \u003c 0 {\n\t\tattempt = 0\n\t}\n\twait := time.Duration(math.Pow(2, float64(attempt))) * time.Second\n\tif wait > MaxBackoff {\n\t\treturn MaxBackoff\n\t}\n\treturn wait\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":4048,"content_sha256":"1fc116072cb9cbdb23200ca151022969a08eb3487f69782476f44e8dade47feb"},{"filename":"internal/cliutil/text.go","content":"// Copyright 2026 rderwin and contributors. Licensed under Apache-2.0. See LICENSE.\n// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.\n\npackage cliutil\n\nimport (\n\t\"html\"\n\t\"strings\"\n\t\"time\"\n)\n\n// CleanText normalizes scraped text by trimming whitespace and decoding\n// HTML entities. Always use this when extracting strings from HTML or\n// schema.org JSON-LD. Skipping this step is how recipe-goat's\n// \"The Food Lab's\" bug shipped: schema.org strings passed through\n// unescaped because the JSON-LD parser didn't normalize.\n//\n// Single unescape pass: \"&\" -> \"&\" (matches html.UnescapeString\n// stdlib behavior). If you need multiple passes you almost always have a\n// deeper escaping problem upstream — fix there, not here.\nfunc CleanText(s string) string {\n\treturn html.UnescapeString(strings.TrimSpace(s))\n}\n\n// ParseStoredTime parses timestamps read back from SQLite-backed generated\n// stores. modernc.org/sqlite can serialize time.Time using Go's native\n// time.String format, while hand-written sync code often stores RFC3339.\n// Use this helper instead of a single time.Parse(time.RFC3339, value) call\n// when scanning timestamp columns from the store.\nfunc ParseStoredTime(s string) time.Time {\n\ts = strings.TrimSpace(s)\n\tif s == \"\" {\n\t\treturn time.Time{}\n\t}\n\tfor _, layout := range []string{\n\t\ttime.RFC3339Nano,\n\t\ttime.RFC3339,\n\t\t\"2006-01-02 15:04:05.999999999 -0700 MST\",\n\t\t\"2006-01-02 15:04:05.999999 -0700 MST\",\n\t\t\"2006-01-02 15:04:05.999 -0700 MST\",\n\t\t\"2006-01-02 15:04:05 -0700 MST\",\n\t\t\"2006-01-02 15:04:05.999999999 -0700\",\n\t\t\"2006-01-02 15:04:05 -0700\",\n\t} {\n\t\tif t, err := time.Parse(layout, s); err == nil {\n\t\t\treturn t\n\t\t}\n\t}\n\treturn time.Time{}\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":1733,"content_sha256":"a3ffebc8685a0e9f0b9b755ee3eccbccb9d38bea87b31eebf8673ec4f27e2b09"},{"filename":"internal/cliutil/verifyenv.go","content":"// Copyright 2026 rderwin and contributors. Licensed under Apache-2.0. See LICENSE.\n// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.\n\npackage cliutil\n\nimport \"os\"\n\n// VerifyEnvVar is the env var the printing-press verifier sets in every\n// mock-mode subprocess. Generated commands that perform visible side\n// effects (open browser tabs, send notifications, dial out to OS\n// handlers) MUST short-circuit when this env var is \"1\" to avoid\n// spamming the user's environment during verify runs.\nconst VerifyEnvVar = \"PRINTING_PRESS_VERIFY\"\n\n// IsVerifyEnv reports whether the current process is running under the\n// printing-press verifier in mock mode. Generated commands with side\n// effects pair this check with print-by-default + explicit opt-in\n// (--launch, --send, --play) so a verify pass on a fresh CLI does not\n// pop browser tabs or fire off real notifications.\n//\n// Defense-in-depth: even if the verifier's heuristic side-effect\n// classifier misses a command, this env-var short-circuit catches it.\n//\n//\tif cliutil.IsVerifyEnv() {\n//\t fmt.Fprintln(cmd.OutOrStdout(), \"would launch:\", url)\n//\t return nil\n//\t}\nfunc IsVerifyEnv() bool {\n\treturn os.Getenv(VerifyEnvVar) == \"1\"\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":1245,"content_sha256":"35f171581bea7140973ffe7c2cdc24aa9c542353429438999f773b4a66be1b8b"},{"filename":"internal/config/config.go","content":"// Copyright 2026 rderwin and contributors. Licensed under Apache-2.0. See LICENSE.\n// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.\n\npackage config\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/pelletier/go-toml/v2\"\n)\n\ntype Config struct {\n\tBaseURL string `toml:\"base_url\"`\n\tAuthHeaderVal string `toml:\"auth_header\"`\n\tAuthSource string `toml:\"-\"`\n\tAccessToken string `toml:\"access_token\"`\n\tRefreshToken string `toml:\"refresh_token\"`\n\tTokenExpiry time.Time `toml:\"token_expiry\"`\n\tClientID string `toml:\"client_id\"`\n\tClientSecret string `toml:\"client_secret\"`\n\tPath string `toml:\"-\"`\n}\n\nfunc Load(configPath string) (*Config, error) {\n\tcfg := &Config{\n\t\tBaseURL: \"https://www.apartments.com\",\n\t}\n\n\t// Resolve config path\n\tpath := configPath\n\tif path == \"\" {\n\t\tpath = os.Getenv(\"APARTMENTS_CONFIG\")\n\t}\n\tif path == \"\" {\n\t\thome, _ := os.UserHomeDir()\n\t\tpath = filepath.Join(home, \".config\", \"apartments-pp-cli\", \"config.toml\")\n\t}\n\tcfg.Path = path\n\n\t// Try to load config file\n\tdata, err := os.ReadFile(path)\n\tif err == nil {\n\t\tif err := toml.Unmarshal(data, cfg); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"parsing config %s: %w\", path, err)\n\t\t}\n\t}\n\n\t// Env var overrides\n\n\t// Base URL override (used by printing-press verify to point at mock/test servers)\n\tif v := os.Getenv(\"APARTMENTS_BASE_URL\"); v != \"\" {\n\t\tcfg.BaseURL = v\n\t}\n\treturn cfg, nil\n}\n\nfunc (c *Config) AuthHeader() string {\n\tif c.AuthHeaderVal != \"\" {\n\t\treturn c.AuthHeaderVal\n\t}\n\treturn \"\"\n}\n\nfunc applyAuthFormat(format string, replacements map[string]string) string {\n\tif format == \"\" {\n\t\treturn \"\"\n\t}\n\tfor key, value := range replacements {\n\t\tformat = strings.ReplaceAll(format, \"{\"+key+\"}\", value)\n\t}\n\tif strings.Contains(format, \"{\") {\n\t\treturn \"\"\n\t}\n\treturn format\n}\n\nfunc (c *Config) SaveTokens(clientID, clientSecret, accessToken, refreshToken string, expiry time.Time) error {\n\tc.ClientID = clientID\n\tc.ClientSecret = clientSecret\n\tc.AccessToken = accessToken\n\tc.RefreshToken = refreshToken\n\tc.TokenExpiry = expiry\n\treturn c.save()\n}\n\nfunc (c *Config) ClearTokens() error {\n\tc.AccessToken = \"\"\n\tc.RefreshToken = \"\"\n\tc.TokenExpiry = time.Time{}\n\treturn c.save()\n}\n\nfunc (c *Config) save() error {\n\tdir := filepath.Dir(c.Path)\n\tif err := os.MkdirAll(dir, 0o700); err != nil {\n\t\treturn fmt.Errorf(\"creating config dir: %w\", err)\n\t}\n\tdata, err := toml.Marshal(c)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"marshaling config: %w\", err)\n\t}\n\treturn os.WriteFile(c.Path, data, 0o600)\n}\n\n// Ensure strings import is used\nvar _ = strings.ReplaceAll\n","content_type":"text/plain; charset=utf-8","language":"go","size":2632,"content_sha256":"12c1ca4c5093b2485dedd3b8621513286f07a03a5f008050293a0f484387728d"},{"filename":"internal/mcp/cobratree/classify.go","content":"// Copyright 2026 rderwin and contributors. Licensed under Apache-2.0. See LICENSE.\n// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.\n\npackage cobratree\n\nimport (\n\t\"strings\"\n\n\t\"github.com/spf13/cobra\"\n)\n\nconst (\n\tEndpointAnnotation = \"pp:endpoint\"\n\tHiddenAnnotation = \"mcp:hidden\"\n\t// ReadOnlyAnnotation, when set on a Cobra command to \"true\"/\"1\"/\"yes\",\n\t// causes the runtime walker to register the resulting MCP tool with\n\t// readOnlyHint=true. Use for novel CLI commands that don't mutate\n\t// external state — read-only API queries, local cache reads, etc.\n\t// Without it, hosts like Claude Desktop default to \"could write or\n\t// delete\" and demand permission per call.\n\tReadOnlyAnnotation = \"mcp:read-only\"\n)\n\ntype commandKind int\n\nconst (\n\tcommandNovel commandKind = iota\n\tcommandEndpoint\n\tcommandFramework\n\tcommandHidden\n)\n\n// frameworkCommands are top-level CLI commands the walker should skip when\n// mirroring the Cobra tree. Two cases qualify:\n//\n// 1. A typed MCP tool already covers the same capability (the typed tool's\n// schema is strictly better than a shell-out). Examples: `sql`, `search`,\n// `context`/`about`/`agent-context`, `api` (endpoint mirror tools cover it).\n// 2. The command is non-functional via MCP (interactive setup, shell-only\n// ergonomics, trivial introspection, local-only feedback). Examples:\n// `auth`, `completion`, `doctor`, `version`, `feedback`, `profile`,\n// `which`, `help`.\n//\n// Commands that DO have agent value — `sync` (populates the store that `sql`\n// and `search` query), `stale`/`orphans`/`reconcile`/`load` (store\n// diagnostics), `export`/`import` (data movement), `workflow`\n// (compound operations), `analytics` (aggregations) — must NOT be in this\n// list. Excluding `sync` while exposing `sql` is a broken contract because\n// the typed `sql` tool returns empty results until something populates the\n// store. See AGENTS.md \"Agent-Native Surface\" for the principle.\n//\n// Adding a new generator-emitted command means deciding which of the two\n// cases above applies. When in doubt, leave it out — the walker registers\n// any user-facing command as a shell-out tool, and the cost of a slightly\n// underused tool is much smaller than the cost of a broken contract like\n// `sql` without `sync`.\nvar frameworkCommands = map[string]bool{\n\t\"about\": true,\n\t\"agent-context\": true,\n\t\"api\": true,\n\t\"auth\": true,\n\t\"completion\": true,\n\t\"doctor\": true,\n\t\"feedback\": true,\n\t\"help\": true,\n\t\"profile\": true,\n\t\"search\": true,\n\t\"sql\": true,\n\t\"version\": true,\n\t\"which\": true,\n}\n\nfunc classify(cmd *cobra.Command) commandKind {\n\tif cmd == nil || cmd.Hidden || isMCPHidden(cmd) {\n\t\treturn commandHidden\n\t}\n\tif endpointID(cmd) != \"\" {\n\t\treturn commandEndpoint\n\t}\n\tif frameworkCommands[cmd.Name()] {\n\t\treturn commandFramework\n\t}\n\treturn commandNovel\n}\n\nfunc endpointID(cmd *cobra.Command) string {\n\tif cmd == nil || cmd.Annotations == nil {\n\t\treturn \"\"\n\t}\n\treturn strings.TrimSpace(cmd.Annotations[EndpointAnnotation])\n}\n\nfunc isMCPHidden(cmd *cobra.Command) bool {\n\treturn annotationIsTrue(cmd, HiddenAnnotation)\n}\n\nfunc isMCPReadOnly(cmd *cobra.Command) bool {\n\treturn annotationIsTrue(cmd, ReadOnlyAnnotation)\n}\n\nfunc annotationIsTrue(cmd *cobra.Command, key string) bool {\n\tif cmd == nil || cmd.Annotations == nil {\n\t\treturn false\n\t}\n\tv := strings.ToLower(strings.TrimSpace(cmd.Annotations[key]))\n\treturn v == \"true\" || v == \"1\" || v == \"yes\"\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":3553,"content_sha256":"32ced54f71a96c6e2eaceae7371062cde4e44d22268ae76295a450656eded8a1"},{"filename":"internal/mcp/cobratree/cli_path.go","content":"// Copyright 2026 rderwin and contributors. Licensed under Apache-2.0. See LICENSE.\n// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.\n\npackage cobratree\n\nimport (\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n)\n\n// SiblingCLIPath resolves the companion CLI via sibling-of-executable,\n// APARTMENTS_CLI_PATH env var, then PATH.\nfunc SiblingCLIPath() (string, error) {\n\tconst cliName = \"apartments-pp-cli\"\n\tif exe, err := os.Executable(); err == nil {\n\t\tcandidate := filepath.Join(filepath.Dir(exe), cliName)\n\t\tif _, err := os.Stat(candidate); err == nil {\n\t\t\treturn candidate, nil\n\t\t}\n\t}\n\tif v := os.Getenv(\"APARTMENTS_CLI_PATH\"); v != \"\" {\n\t\treturn v, nil\n\t}\n\treturn exec.LookPath(cliName)\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":728,"content_sha256":"7d543967cc8a38726a3b13ecc716919ede2d50235b52040e68dd6014ad2e4728"},{"filename":"internal/mcp/cobratree/names.go","content":"// Copyright 2026 rderwin and contributors. Licensed under Apache-2.0. See LICENSE.\n// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.\n\npackage cobratree\n\nimport (\n\t\"strings\"\n\t\"unicode\"\n)\n\nfunc toolNameForPath(parts []string) string {\n\tvar out []rune\n\tfor _, part := range parts {\n\t\tfor _, r := range part {\n\t\t\tswitch {\n\t\t\tcase unicode.IsLetter(r) || unicode.IsDigit(r):\n\t\t\t\tout = append(out, unicode.ToLower(r))\n\t\t\tdefault:\n\t\t\t\tout = append(out, '_')\n\t\t\t}\n\t\t}\n\t\tout = append(out, '_')\n\t}\n\treturn strings.Trim(strings.Join(strings.FieldsFunc(string(out), func(r rune) bool { return r == '_' }), \"_\"), \"_\")\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":655,"content_sha256":"0947a57abd85f774c5cfdda92cec67c65cebd1361bec4cc6c8795003c51347d9"},{"filename":"internal/mcp/cobratree/shellout.go","content":"// Copyright 2026 rderwin and contributors. Licensed under Apache-2.0. See LICENSE.\n// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.\n\npackage cobratree\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"os/exec\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\n\tmcplib \"github.com/mark3labs/mcp-go/mcp\"\n\t\"github.com/mark3labs/mcp-go/server\"\n)\n\nfunc shellOutToCLI(cliPath func() (string, error), commandPath []string) server.ToolHandlerFunc {\n\tlookupPath, lookupErr := cliPath()\n\tprefixArgs := append([]string{}, commandPath...)\n\treturn func(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {\n\t\tif lookupErr != nil {\n\t\t\treturn mcplib.NewToolResultError(fmt.Sprintf(\"companion CLI binary not found: %v\\nTried sibling lookup, APARTMENTS_CLI_PATH env var, and PATH.\", lookupErr)), nil\n\t\t}\n\t\targs := req.GetArguments()\n\t\tfinalArgs := append([]string{}, prefixArgs...)\n\t\tfinalArgs = append(finalArgs, cliArgsFromMCP(args)...)\n\t\tif raw, _ := args[\"args\"].(string); strings.TrimSpace(raw) != \"\" {\n\t\t\tfinalArgs = append(finalArgs, splitShellArgs(raw)...)\n\t\t}\n\t\tcmd := exec.CommandContext(ctx, lookupPath, finalArgs...)\n\t\tout, err := cmd.CombinedOutput()\n\t\tif err != nil {\n\t\t\treturn mcplib.NewToolResultError(string(out)), nil\n\t\t}\n\t\treturn mcplib.NewToolResultText(string(out)), nil\n\t}\n}\n\nfunc cliArgsFromMCP(args map[string]any) []string {\n\tkeys := make([]string, 0, len(args))\n\tfor k := range args {\n\t\tif k != \"args\" {\n\t\t\tkeys = append(keys, k)\n\t\t}\n\t}\n\tsort.Strings(keys)\n\n\tvar out []string\n\tfor _, k := range keys {\n\t\tv := args[k]\n\t\tswitch tv := v.(type) {\n\t\tcase bool:\n\t\t\tif tv {\n\t\t\t\tout = append(out, \"--\"+k)\n\t\t\t}\n\t\tcase float64:\n\t\t\tout = append(out, \"--\"+k, strconv.FormatFloat(tv, 'f', -1, 64))\n\t\tcase string:\n\t\t\tif tv != \"\" {\n\t\t\t\tout = append(out, \"--\"+k, tv)\n\t\t\t}\n\t\tcase []any:\n\t\t\tif len(tv) > 0 {\n\t\t\t\tparts := make([]string, 0, len(tv))\n\t\t\t\tfor _, item := range tv {\n\t\t\t\t\tparts = append(parts, fmt.Sprintf(\"%v\", item))\n\t\t\t\t}\n\t\t\t\tout = append(out, \"--\"+k, strings.Join(parts, \",\"))\n\t\t\t}\n\t\tdefault:\n\t\t\tif v != nil {\n\t\t\t\tout = append(out, \"--\"+k, fmt.Sprintf(\"%v\", v))\n\t\t\t}\n\t\t}\n\t}\n\treturn out\n}\n\n// splitShellArgs whitespace-splits with double-quoted-token preservation.\nfunc splitShellArgs(s string) []string {\n\tvar tokens []string\n\tvar cur []rune\n\tinQuote := false\n\tfor _, r := range s {\n\t\tswitch {\n\t\tcase r == '\"':\n\t\t\tinQuote = !inQuote\n\t\tcase (r == ' ' || r == '\\t') && !inQuote:\n\t\t\tif len(cur) > 0 {\n\t\t\t\ttokens = append(tokens, string(cur))\n\t\t\t\tcur = cur[:0]\n\t\t\t}\n\t\tdefault:\n\t\t\tcur = append(cur, r)\n\t\t}\n\t}\n\tif len(cur) > 0 {\n\t\ttokens = append(tokens, string(cur))\n\t}\n\treturn tokens\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":2628,"content_sha256":"08e3b18fa4d512fbe9342c5bb4b0b091b6818a7ba9c43b362a525f7a76989013"},{"filename":"internal/mcp/cobratree/typemap.go","content":"// Copyright 2026 rderwin and contributors. Licensed under Apache-2.0. See LICENSE.\n// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.\n\npackage cobratree\n\nimport (\n\t\"regexp\"\n\t\"strings\"\n\n\tmcplib \"github.com/mark3labs/mcp-go/mcp\"\n\t\"github.com/spf13/cobra\"\n\t\"github.com/spf13/pflag\"\n)\n\nvar positionalPattern = regexp.MustCompile(`(?:^|\\s)(?:\u003c[^>]+>|\\[[^\\]]+\\])`)\n\nfunc toolOptionsForFlags(cmd *cobra.Command) []mcplib.ToolOption {\n\tvar opts []mcplib.ToolOption\n\tseen := map[string]bool{}\n\taddFlag := func(flag *pflag.Flag) {\n\t\tif flag == nil || flag.Hidden || flag.Deprecated != \"\" {\n\t\t\treturn\n\t\t}\n\t\tif seen[flag.Name] {\n\t\t\treturn\n\t\t}\n\t\tseen[flag.Name] = true\n\t\topts = append(opts, toolOptionForFlag(flag))\n\t}\n\tcmd.InheritedFlags().VisitAll(addFlag)\n\tcmd.NonInheritedFlags().VisitAll(addFlag)\n\treturn opts\n}\n\nfunc toolOptionForFlag(flag *pflag.Flag) mcplib.ToolOption {\n\tpropOpts := []mcplib.PropertyOption{mcplib.Description(flagDescription(flag))}\n\tif isRequired(flag) {\n\t\tpropOpts = append(propOpts, mcplib.Required())\n\t}\n\tswitch flag.Value.Type() {\n\tcase \"bool\":\n\t\treturn mcplib.WithBoolean(flag.Name, propOpts...)\n\tcase \"int\", \"int8\", \"int16\", \"int32\", \"int64\",\n\t\t\"uint\", \"uint8\", \"uint16\", \"uint32\", \"uint64\",\n\t\t\"float32\", \"float64\", \"count\":\n\t\treturn mcplib.WithNumber(flag.Name, propOpts...)\n\tcase \"string\", \"stringSlice\", \"stringArray\", \"duration\":\n\t\treturn mcplib.WithString(flag.Name, propOpts...)\n\tdefault:\n\t\tpropOpts[0] = mcplib.Description(flagDescription(flag) + \" (unknown Cobra flag type \" + flag.Value.Type() + \"; pass as a string)\")\n\t\treturn mcplib.WithString(flag.Name, propOpts...)\n\t}\n}\n\nfunc flagDescription(flag *pflag.Flag) string {\n\tusage := strings.TrimSpace(flag.Usage)\n\tif usage == \"\" {\n\t\tusage = \"Value for --\" + flag.Name\n\t}\n\tif flag.DefValue != \"\" && flag.DefValue != \"[]\" {\n\t\tusage += \" (default: \" + flag.DefValue + \")\"\n\t}\n\treturn usage\n}\n\nfunc isRequired(flag *pflag.Flag) bool {\n\tif flag == nil || flag.Annotations == nil {\n\t\treturn false\n\t}\n\t_, ok := flag.Annotations[cobra.BashCompOneRequiredFlag]\n\treturn ok\n}\n\nfunc commandTakesArgs(cmd *cobra.Command) bool {\n\treturn positionalPattern.MatchString(cmd.Use)\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":2191,"content_sha256":"17e522e8d1c11bb17176e0a81788b6eb4a5f1926c6412265d39d84b0e0e3d071"},{"filename":"internal/mcp/cobratree/walker.go","content":"// Copyright 2026 rderwin and contributors. Licensed under Apache-2.0. See LICENSE.\n// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.\n\npackage cobratree\n\nimport (\n\tmcplib \"github.com/mark3labs/mcp-go/mcp\"\n\t\"github.com/mark3labs/mcp-go/server\"\n\t\"github.com/spf13/cobra\"\n)\n\n// RegisterAll walks root's user-facing Cobra commands and registers shell-out\n// MCP tools for commands that are not already covered by typed endpoint tools.\nfunc RegisterAll(s *server.MCPServer, root *cobra.Command, cliPath func() (string, error)) {\n\tif root == nil {\n\t\treturn\n\t}\n\twalk(root, nil, func(cmd *cobra.Command, path []string) {\n\t\tswitch classify(cmd) {\n\t\tcase commandHidden:\n\t\t\treturn\n\t\tcase commandEndpoint, commandFramework:\n\t\t\treturn\n\t\t}\n\t\tif !cmd.Runnable() {\n\t\t\treturn\n\t\t}\n\n\t\ttoolName := toolNameForPath(path)\n\t\tif toolName == \"\" {\n\t\t\treturn\n\t\t}\n\t\toptions := []mcplib.ToolOption{mcplib.WithDescription(descriptionFor(cmd))}\n\t\toptions = append(options, toolOptionsForFlags(cmd)...)\n\t\tif commandTakesArgs(cmd) {\n\t\t\toptions = append(options, mcplib.WithString(\"args\", mcplib.Description(\"Additional positional arguments or raw CLI flags to append to the command.\")))\n\t\t}\n\t\tif isMCPReadOnly(cmd) {\n\t\t\toptions = append(options, mcplib.WithReadOnlyHintAnnotation(true), mcplib.WithDestructiveHintAnnotation(false))\n\t\t}\n\t\ts.AddTool(mcplib.NewTool(toolName, options...), shellOutToCLI(cliPath, path))\n\t})\n}\n\nfunc walk(cmd *cobra.Command, path []string, visit func(*cobra.Command, []string)) {\n\tfor _, child := range cmd.Commands() {\n\t\tif child.Hidden || isMCPHidden(child) {\n\t\t\tcontinue\n\t\t}\n\t\tchildPath := append(append([]string{}, path...), child.Name())\n\t\tvisit(child, childPath)\n\t\tif kind := classify(child); kind != commandHidden && kind != commandFramework {\n\t\t\twalk(child, childPath, visit)\n\t\t}\n\t}\n}\n\nfunc descriptionFor(cmd *cobra.Command) string {\n\tif cmd.Long != \"\" {\n\t\treturn cmd.Long\n\t}\n\tif cmd.Short != \"\" {\n\t\treturn cmd.Short\n\t}\n\treturn \"Run `\" + cmd.CommandPath() + \"` through the companion CLI binary.\"\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":2050,"content_sha256":"1166318c22c4a350e513cccf016e71cb37b87fef5bafc64e22125ead2713a5e0"},{"filename":"internal/mcp/tools.go","content":"// Copyright 2026 rderwin and contributors. Licensed under Apache-2.0. See LICENSE.\n// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.\n\npackage mcp\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"time\"\n\n\tmcplib \"github.com/mark3labs/mcp-go/mcp\"\n\t\"github.com/mark3labs/mcp-go/server\"\n\t\"github.com/mvanhorn/printing-press-library/library/other/apartments/internal/cli\"\n\t\"github.com/mvanhorn/printing-press-library/library/other/apartments/internal/client\"\n\t\"github.com/mvanhorn/printing-press-library/library/other/apartments/internal/config\"\n\t\"github.com/mvanhorn/printing-press-library/library/other/apartments/internal/mcp/cobratree\"\n\t\"github.com/mvanhorn/printing-press-library/library/other/apartments/internal/store\"\n)\n\n// RegisterTools registers all API operations as MCP tools.\nfunc RegisterTools(s *server.MCPServer) {\n\ts.AddTool(\n\t\tmcplib.NewTool(\"listing_get\",\n\t\t\tmcplib.WithDescription(\"Fetch one Apartments.com listing detail page and parse schema.org microdata. Required: property_id (default: example-property).\"),\n\t\t\tmcplib.WithString(\"property_id\", mcplib.Required(), mcplib.Description(\"Apartments.com listing URL slug (e.g. 'the-domain-austin-tx-1234').\")),\n\t\t\tmcplib.WithReadOnlyHintAnnotation(true),\n\t\t\tmcplib.WithDestructiveHintAnnotation(false),\n\t\t\tmcplib.WithOpenWorldHintAnnotation(true),\n\t\t),\n\t\tmakeAPIHandler(\"GET\", \"/{property_id}/\", []string{\"property_id\"}),\n\t)\n\ts.AddTool(\n\t\tmcplib.NewTool(\"rentals_find\",\n\t\t\tmcplib.WithDescription(\"Run a path-slug search at apartments.com and return listing placards parsed from the HTML response. Required: city (default: austin), state (default: tx).\"),\n\t\t\tmcplib.WithString(\"city\", mcplib.Required(), mcplib.Description(\"City slug (lowercase, hyphenated). Example: austin, new-york, san-francisco.\")),\n\t\t\tmcplib.WithString(\"state\", mcplib.Required(), mcplib.Description(\"Two-letter state abbreviation (lowercase). Example: tx, ny, ca.\")),\n\t\t\tmcplib.WithReadOnlyHintAnnotation(true),\n\t\t\tmcplib.WithDestructiveHintAnnotation(false),\n\t\t\tmcplib.WithOpenWorldHintAnnotation(true),\n\t\t),\n\t\tmakeAPIHandler(\"GET\", \"/{city}-{state}/\", []string{\"city\", \"state\"}),\n\t)\n\t// SQL tool — ad-hoc analysis on synced data without API calls\n\ts.AddTool(\n\t\tmcplib.NewTool(\"sql\",\n\t\t\tmcplib.WithDescription(\"Run read-only SQL against local database. Use for ad-hoc analysis, aggregations, and joins across synced resources. Requires sync first.\"),\n\t\t\tmcplib.WithString(\"query\", mcplib.Required(), mcplib.Description(\"SQL query (SELECT only). Tables match resource names.\")),\n\t\t\tmcplib.WithReadOnlyHintAnnotation(true),\n\t\t\tmcplib.WithDestructiveHintAnnotation(false),\n\t\t),\n\t\thandleSQL,\n\t)\n\n\t// Context tool — front-loaded domain knowledge for agents.\n\t// Call this first to understand the API taxonomy, query patterns, and capabilities.\n\ts.AddTool(\n\t\tmcplib.NewTool(\"context\",\n\t\t\tmcplib.WithDescription(\"Get API domain context: resource taxonomy, auth requirements, query tips, and unique capabilities. Call this first.\"),\n\t\t\tmcplib.WithReadOnlyHintAnnotation(true),\n\t\t\tmcplib.WithDestructiveHintAnnotation(false),\n\t\t),\n\t\thandleContext,\n\t)\n\n\t// Runtime Cobra-tree mirror — exposes every user-facing command that is\n\t// not already covered by a typed endpoint or framework MCP tool.\n\tcobratree.RegisterAll(s, cli.RootCmd(), cobratree.SiblingCLIPath)\n}\n\n// makeAPIHandler creates a generic MCP tool handler for an API endpoint.\nfunc makeAPIHandler(method, pathTemplate string, positionalParams []string) server.ToolHandlerFunc {\n\treturn func(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {\n\t\tc, err := newMCPClient()\n\t\tif err != nil {\n\t\t\treturn mcplib.NewToolResultError(err.Error()), nil\n\t\t}\n\n\t\t// mcp-go v0.47+ made CallToolParams.Arguments an `any` to support\n\t\t// non-map payloads; GetArguments() returns the map[string]any shape\n\t\t// we rely on here (or an empty map when the payload is something else).\n\t\targs := req.GetArguments()\n\n\t\t// positionalParams mixes real URL path params with CLI positional\n\t\t// args that map to query params (e.g. `search \u003cquery>` -> ?query=);\n\t\t// the placeholder check below disambiguates them at runtime.\n\t\tpath := pathTemplate\n\t\tpathParams := make(map[string]bool, len(positionalParams))\n\t\tfor _, p := range positionalParams {\n\t\t\tplaceholder := \"{\" + p + \"}\"\n\t\t\tif !strings.Contains(pathTemplate, placeholder) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tpathParams[p] = true\n\t\t\tif v, ok := args[p]; ok {\n\t\t\t\tpath = strings.Replace(path, placeholder, fmt.Sprintf(\"%v\", v), 1)\n\t\t\t}\n\t\t}\n\n\t\tparams := make(map[string]string)\n\t\tfor k, v := range args {\n\t\t\tif pathParams[k] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tparams[k] = fmt.Sprintf(\"%v\", v)\n\t\t}\n\n\t\tvar data json.RawMessage\n\t\tswitch method {\n\t\tcase \"GET\":\n\t\t\tdata, err = c.Get(path, params)\n\t\tcase \"POST\":\n\t\t\tbody, _ := json.Marshal(args)\n\t\t\tdata, _, err = c.Post(path, body)\n\t\tcase \"PUT\":\n\t\t\tbody, _ := json.Marshal(args)\n\t\t\tdata, _, err = c.Put(path, body)\n\t\tcase \"PATCH\":\n\t\t\tbody, _ := json.Marshal(args)\n\t\t\tdata, _, err = c.Patch(path, body)\n\t\tcase \"DELETE\":\n\t\t\tdata, _, err = c.Delete(path)\n\t\tdefault:\n\t\t\treturn mcplib.NewToolResultError(\"unsupported method: \" + method), nil\n\t\t}\n\n\t\tif err != nil {\n\t\t\tmsg := err.Error()\n\t\t\tswitch {\n\t\t\tcase strings.Contains(msg, \"HTTP 409\"):\n\t\t\t\treturn mcplib.NewToolResultText(\"already exists (no-op)\"), nil\n\t\t\tcase strings.Contains(msg, \"HTTP 401\"):\n\t\t\t\treturn mcplib.NewToolResultError(\"authentication failed: \" + msg +\n\t\t\t\t\t\"\\nhint: check your API credentials.\" +\n\t\t\t\t\t\"\\n Run 'apartments-pp-cli doctor' to check auth status.\"), nil\n\t\t\tcase strings.Contains(msg, \"HTTP 403\"):\n\t\t\t\treturn mcplib.NewToolResultError(\"permission denied: \" + msg +\n\t\t\t\t\t\"\\nhint: your credentials are valid but lack access to this resource.\" +\n\t\t\t\t\t\"\\n Run 'apartments-pp-cli doctor' to check auth status.\"), nil\n\t\t\tcase strings.Contains(msg, \"HTTP 404\"):\n\t\t\t\tif method == \"DELETE\" {\n\t\t\t\t\treturn mcplib.NewToolResultText(\"already deleted (no-op)\"), nil\n\t\t\t\t}\n\t\t\t\treturn mcplib.NewToolResultError(\"not found: \" + msg), nil\n\t\t\tcase strings.Contains(msg, \"HTTP 429\"):\n\t\t\t\treturn mcplib.NewToolResultError(\"rate limited: \" + msg), nil\n\t\t\tdefault:\n\t\t\t\treturn mcplib.NewToolResultError(msg), nil\n\t\t\t}\n\t\t}\n\n\t\t// For GET responses, wrap bare arrays with count metadata\n\t\tif method == \"GET\" {\n\t\t\ttrimmed := strings.TrimSpace(string(data))\n\t\t\tif len(trimmed) > 0 && trimmed[0] == '[' {\n\t\t\t\tvar items []json.RawMessage\n\t\t\t\tif json.Unmarshal(data, &items) == nil {\n\t\t\t\t\twrapped := map[string]any{\n\t\t\t\t\t\t\"count\": len(items),\n\t\t\t\t\t\t\"items\": items,\n\t\t\t\t\t}\n\t\t\t\t\tout, _ := json.Marshal(wrapped)\n\t\t\t\t\treturn mcplib.NewToolResultText(string(out)), nil\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\treturn mcplib.NewToolResultText(string(data)), nil\n\t}\n}\n\nfunc newMCPClient() (*client.Client, error) {\n\thome, _ := os.UserHomeDir()\n\tcfgPath := filepath.Join(home, \".config\", \"apartments-pp-cli\", \"config.toml\")\n\tcfg, err := config.Load(cfgPath)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"loading config: %w\", err)\n\t}\n\tc := client.New(cfg, 30*time.Second, 2)\n\t// Agents calling through MCP need fresh data every call. The on-disk\n\t// response cache survives across MCP server invocations, so a\n\t// DELETE/PATCH followed by a GET would otherwise return the\n\t// pre-mutation snapshot for up to the cache TTL. The interactive CLI\n\t// constructs its own client and is unaffected.\n\tc.NoCache = true\n\treturn c, nil\n}\n\nfunc dbPath() string {\n\thome, _ := os.UserHomeDir()\n\treturn filepath.Join(home, \".local\", \"share\", \"apartments-pp-cli\", \"data.db\")\n}\n\n// Note: MCP tools use their own dbPath() because they are in a separate package (main, not cli).\n// The CLI's defaultDBPath() in the cli package uses the same canonical path.\n\nfunc handleSQL(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {\n\targs := req.GetArguments()\n\tquery, ok := args[\"query\"].(string)\n\tif !ok || query == \"\" {\n\t\treturn mcplib.NewToolResultError(\"query is required\"), nil\n\t}\n\n\t// Block write operations\n\tupper := strings.ToUpper(strings.TrimSpace(query))\n\tfor _, prefix := range []string{\"INSERT\", \"UPDATE\", \"DELETE\", \"DROP\", \"ALTER\", \"CREATE\"} {\n\t\tif strings.HasPrefix(upper, prefix) {\n\t\t\treturn mcplib.NewToolResultError(\"only SELECT queries are allowed\"), nil\n\t\t}\n\t}\n\n\tdb, err := store.OpenWithContext(ctx, dbPath())\n\tif err != nil {\n\t\treturn mcplib.NewToolResultError(fmt.Sprintf(\"opening database: %v\", err)), nil\n\t}\n\tdefer db.Close()\n\n\trows, err := db.Query(query)\n\tif err != nil {\n\t\treturn mcplib.NewToolResultError(fmt.Sprintf(\"query failed: %v\", err)), nil\n\t}\n\tdefer rows.Close()\n\n\tcols, _ := rows.Columns()\n\tvar results []map[string]any\n\tfor rows.Next() {\n\t\tvalues := make([]any, len(cols))\n\t\tptrs := make([]any, len(cols))\n\t\tfor i := range values {\n\t\t\tptrs[i] = &values[i]\n\t\t}\n\t\trows.Scan(ptrs...)\n\t\trow := make(map[string]any)\n\t\tfor i, col := range cols {\n\t\t\trow[col] = values[i]\n\t\t}\n\t\tresults = append(results, row)\n\t}\n\n\tdata, _ := json.MarshalIndent(results, \"\", \" \")\n\treturn mcplib.NewToolResultText(string(data)), nil\n}\n\nfunc handleContext(_ context.Context, _ mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {\n\tctx := map[string]any{\n\t\t\"api\": \"apartments\",\n\t\t\"description\": \"Search Apartments.com listings, sync results to a local SQLite store, and run workflows the website never built —...\",\n\t\t\"archetype\": \"generic\",\n\t\t\"tool_count\": 2,\n\t\t// tool_surface tells agents which surface a capability lives on.\n\t\t\"tool_surface\": \"MCP exposes typed endpoint tools plus a runtime mirror of user-facing CLI commands. Endpoint tools keep typed schemas; command-mirror tools shell out to the companion apartments-pp-cli binary.\",\n\t\t\"resources\": []map[string]any{\n\t\t\t{\n\t\t\t\t\"name\": \"listing\",\n\t\t\t\t\"description\": \"Fetch a single Apartments.com listing detail page by URL or property ID, parsing rent, beds/baths, address,...\",\n\t\t\t\t\"endpoints\": []string{\"get\"},\n\t\t\t\t\"searchable\": true,\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"name\": \"rentals\",\n\t\t\t\t\"description\": \"Search Apartments.com rental listings by city, beds, baths, price, and pet policy. Returns parsed listing placards.\",\n\t\t\t\t\"endpoints\": []string{\"find\"},\n\t\t\t\t\"searchable\": true,\n\t\t\t},\n\t\t},\n\t\t\"query_tips\": []string{\n\t\t\t\"Pagination uses cursor-based paging. Pass after parameter for subsequent pages.\",\n\t\t\t\"Control page size with the limit parameter (default 100).\",\n\t\t\t\"Use the sql tool for ad-hoc analysis on synced data. Run sync first to populate the local database.\",\n\t\t\t\"Use the search tool for full-text search across all synced resources. Faster than iterating list endpoints.\",\n\t\t\t\"Prefer sql/search over repeated API calls when the data is already synced.\",\n\t\t},\n\t\t// Command-mirror capabilities are exposed through MCP by shelling out\n\t\t// to the companion CLI binary.\n\t\t\"command_mirror_capabilities\": []map[string]string{\n\t\t\t{\"name\": \"Watch saved searches with diff\", \"command\": \"watch\", \"description\": \"Re-run a stored search and surface what's NEW, REMOVED, or PRICE-CHANGED since the last sync.\", \"rationale\": \"apartments.com email alerts only cover new listings — there is no removal alert, no price-drop alert, and no...\", \"via\": \"mcp-command-mirror\"},\n\t\t\t{\"name\": \"Multi-slug union ranking\", \"command\": \"nearby\", \"description\": \"Fan out a search across multiple cities, zips, or neighborhoods and return one ranked, deduped list.\", \"rationale\": \"apartments.com URL is single-slug per query — three target neighborhoods means three browser tabs. Fan-out plus...\", \"via\": \"mcp-command-mirror\"},\n\t\t\t{\"name\": \"Total-cost-of-occupancy ranking\", \"command\": \"value\", \"description\": \"Rank synced listings by 12-month total cost (rent + pet rent + pet deposit + pet fee), filtered to your hard budget.\", \"rationale\": \"apartments.com sort options ignore pet fees entirely. The cost arithmetic happens in local SQL over the synced...\", \"via\": \"mcp-command-mirror\"},\n\t\t\t{\"name\": \"$/sqft and $/bed ranking\", \"command\": \"rank\", \"description\": \"Rank synced listings by ratio metrics — price per square foot or price per bedroom.\", \"rationale\": \"apartments.com sort omits ratio metrics. Dividing maxrent by sqft or beds in local SQL is one line.\", \"via\": \"mcp-command-mirror\"},\n\t\t\t{\"name\": \"Side-by-side compare\", \"command\": \"compare\", \"description\": \"Pivot 2–8 listings into a wide table — one column per listing — with computed $/sqft and amenity overlap.\", \"rationale\": \"apartments.com has no compare view. Local-store pivot lets agents emit a structured wide-table response.\", \"via\": \"mcp-command-mirror\"},\n\t\t\t{\"name\": \"Price-drop alerts\", \"command\": \"drops\", \"description\": \"List listings whose max-rent dropped by ≥N% within a time window.\", \"rationale\": \"apartments.com offers no price-drop view — only new-listing emails. Time-window aggregation needs the snapshot...\", \"via\": \"mcp-command-mirror\"},\n\t\t\t{\"name\": \"Stale-listing flag\", \"command\": \"stale\", \"description\": \"Flag listings whose price and availability haven't changed in N days — often phantom or stuck.\", \"rationale\": \"Stuck-listing detection needs the snapshot table — apartments.com has no UI for it.\", \"via\": \"mcp-command-mirror\"},\n\t\t\t{\"name\": \"Phantom-listing detector\", \"command\": \"phantoms\", \"description\": \"Surface listings flagged by a three-signal join: 404 on re-fetch, dropped from saved-search results, or stale ≥45...\", \"rationale\": \"No single page exposes 'this listing is probably already leased.' Union of three local-store predicates does.\", \"via\": \"mcp-command-mirror\"},\n\t\t\t{\"name\": \"Neighborhood market summary\", \"command\": \"market\", \"description\": \"Median, p10, p90 of rent and rent/sqft, pet-friendly share, by city/state and bed count.\", \"rationale\": \"apartments.com shows zero distributional stats. Aggregation SQL over the local store does.\", \"via\": \"mcp-command-mirror\"},\n\t\t\t{\"name\": \"Listing history\", \"command\": \"history\", \"description\": \"Time-series of every observation of one listing — rent, availability, status.\", \"rationale\": \"Per-listing audit trail comes from the snapshot table only this CLI keeps.\", \"via\": \"mcp-command-mirror\"},\n\t\t\t{\"name\": \"Weekly digest\", \"command\": \"digest\", \"description\": \"Single-shot composer: new + removed + price-drops + top-5 by $/sqft + stale + phantom flags for one saved search...\", \"rationale\": \"Composes the time-series and ranking commands into the actual weekly ritual that exists in user workflows.\", \"via\": \"mcp-command-mirror\"},\n\t\t\t{\"name\": \"Floor-plan-level value report\", \"command\": \"floorplans\", \"description\": \"Rank per-floor-plan rent/sqft across synced listings — same building can yield 4 plans at different ratios.\", \"rationale\": \"Building-level rent hides plan-level variance. Joining to the floor_plans child table exposes it.\", \"via\": \"mcp-command-mirror\"},\n\t\t\t{\"name\": \"Amenity must-have intersect\", \"command\": \"must-have\", \"description\": \"Filter synced listings to those whose amenities array contains ALL listed terms via FTS5.\", \"rationale\": \"apartments.com amenity filter is a fixed curated list — 'natural light' or 'in-unit washer' aren't in it. FTS5...\", \"via\": \"mcp-command-mirror\"},\n\t\t\t{\"name\": \"Local shortlist\", \"command\": \"shortlist\", \"description\": \"Tag-based local shortlist table; add/show/remove listings with notes and tags.\", \"rationale\": \"Replaces the user's spreadsheet workflow with a queryable table that joins to listings for compare and digest.\", \"via\": \"mcp-command-mirror\"},\n\t\t},\n\t\t\"playbook\": []map[string]string{\n\t\t\t{\"topic\": \"Watch saved searches with diff\", \"insight\": \"apartments.com email alerts only cover new listings — there is no removal alert, no price-drop alert, and no per-listing history. Diffing requires the snapshot history table only this CLI keeps.\"},\n\t\t\t{\"topic\": \"Multi-slug union ranking\", \"insight\": \"apartments.com URL is single-slug per query — three target neighborhoods means three browser tabs. Fan-out plus listing-URL dedupe lives only in the local store.\"},\n\t\t\t{\"topic\": \"Total-cost-of-occupancy ranking\", \"insight\": \"apartments.com sort options ignore pet fees entirely. The cost arithmetic happens in local SQL over the synced pet_policy struct.\"},\n\t\t\t{\"topic\": \"$/sqft and $/bed ranking\", \"insight\": \"apartments.com sort omits ratio metrics. Dividing maxrent by sqft or beds in local SQL is one line.\"},\n\t\t\t{\"topic\": \"Side-by-side compare\", \"insight\": \"apartments.com has no compare view. Local-store pivot lets agents emit a structured wide-table response.\"},\n\t\t\t{\"topic\": \"Price-drop alerts\", \"insight\": \"apartments.com offers no price-drop view — only new-listing emails. Time-window aggregation needs the snapshot history table.\"},\n\t\t\t{\"topic\": \"Stale-listing flag\", \"insight\": \"Stuck-listing detection needs the snapshot table — apartments.com has no UI for it.\"},\n\t\t\t{\"topic\": \"Phantom-listing detector\", \"insight\": \"No single page exposes 'this listing is probably already leased.' Union of three local-store predicates does.\"},\n\t\t\t{\"topic\": \"Neighborhood market summary\", \"insight\": \"apartments.com shows zero distributional stats. Aggregation SQL over the local store does.\"},\n\t\t\t{\"topic\": \"Listing history\", \"insight\": \"Per-listing audit trail comes from the snapshot table only this CLI keeps.\"},\n\t\t\t{\"topic\": \"Weekly digest\", \"insight\": \"Composes the time-series and ranking commands into the actual weekly ritual that exists in user workflows.\"},\n\t\t\t{\"topic\": \"Floor-plan-level value report\", \"insight\": \"Building-level rent hides plan-level variance. Joining to the floor_plans child table exposes it.\"},\n\t\t\t{\"topic\": \"Amenity must-have intersect\", \"insight\": \"apartments.com amenity filter is a fixed curated list — 'natural light' or 'in-unit washer' aren't in it. FTS5 AND-join is.\"},\n\t\t\t{\"topic\": \"Local shortlist\", \"insight\": \"Replaces the user's spreadsheet workflow with a queryable table that joins to listings for compare and digest.\"},\n\t\t},\n\t}\n\tdata, _ := json.MarshalIndent(ctx, \"\", \" \")\n\treturn mcplib.NewToolResultText(string(data)), nil\n}\n\n// RegisterNovelFeatureTools is kept as a compatibility no-op for older MCP\n// mains. New generated mains call RegisterTools only; RegisterTools now\n// includes the runtime Cobra-tree mirror.\nfunc RegisterNovelFeatureTools(s *server.MCPServer) {\n\t_ = s\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":18153,"content_sha256":"6214c49e2f8ce107c05f8f0d335dae6899a3e24ad4fd37adec6bb8df45b3e318"},{"filename":"internal/store/schema_version_test.go","content":"// Copyright 2026 rderwin and contributors. Licensed under Apache-2.0. See LICENSE.\n// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.\n\npackage store\n\nimport (\n\t\"context\"\n\t\"database/sql\"\n\t\"errors\"\n\t\"path/filepath\"\n\t\"sync\"\n\t\"testing\"\n\t\"time\"\n\n\t_ \"modernc.org/sqlite\"\n)\n\n// TestSchemaVersion_StampedOnFreshDB verifies that opening a brand-new\n// database stamps the current schema version. This is the contract that\n// makes StoreSchemaVersion upgrades safe: every freshly-created DB\n// records the version it was built under.\nfunc TestSchemaVersion_StampedOnFreshDB(t *testing.T) {\n\tdbPath := filepath.Join(t.TempDir(), \"data.db\")\n\ts, err := Open(dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"open fresh db: %v\", err)\n\t}\n\tdefer s.Close()\n\n\tv, err := s.SchemaVersion()\n\tif err != nil {\n\t\tt.Fatalf(\"read schema version: %v\", err)\n\t}\n\tif v != StoreSchemaVersion {\n\t\tt.Fatalf(\"fresh db version = %d, want %d\", v, StoreSchemaVersion)\n\t}\n}\n\n// TestSchemaVersion_StampExistingZeroDB verifies the stamp-and-continue\n// rule for existing deployed databases. A DB that predates the gate has\n// user_version = 0; opening it with this binary should stamp the version\n// to 1 without touching any data.\nfunc TestSchemaVersion_StampExistingZeroDB(t *testing.T) {\n\tdbPath := filepath.Join(t.TempDir(), \"data.db\")\n\n\t// Pre-create the DB with user_version = 0 and no tables, simulating\n\t// a database created by a pre-gate version of the binary before any\n\t// migrations ran.\n\traw, err := sql.Open(\"sqlite\", dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"open raw: %v\", err)\n\t}\n\tif _, err := raw.Exec(`PRAGMA user_version = 0`); err != nil {\n\t\tt.Fatalf(\"stamp zero: %v\", err)\n\t}\n\traw.Close()\n\n\ts, err := Open(dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"open pre-gate db: %v\", err)\n\t}\n\tdefer s.Close()\n\n\tv, err := s.SchemaVersion()\n\tif err != nil {\n\t\tt.Fatalf(\"read schema version: %v\", err)\n\t}\n\tif v != StoreSchemaVersion {\n\t\tt.Fatalf(\"post-stamp version = %d, want %d\", v, StoreSchemaVersion)\n\t}\n}\n\n// TestSchemaVersion_RefusesNewerDB verifies fail-fast when the on-disk\n// schema is newer than the binary supports. Without this gate, a user\n// who upgrades their library but not their binary would hit silent\n// \"no such column\" errors instead of a clear version mismatch.\nfunc TestSchemaVersion_RefusesNewerDB(t *testing.T) {\n\tdbPath := filepath.Join(t.TempDir(), \"data.db\")\n\n\traw, err := sql.Open(\"sqlite\", dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"open raw: %v\", err)\n\t}\n\tif _, err := raw.Exec(`PRAGMA user_version = 999`); err != nil {\n\t\tt.Fatalf(\"stamp future version: %v\", err)\n\t}\n\traw.Close()\n\n\t_, err = Open(dbPath)\n\tif err == nil {\n\t\tt.Fatalf(\"expected open to fail on newer schema, got nil\")\n\t}\n}\n\n// TestMigrate_ConcurrentFreshDB exercises the BEGIN IMMEDIATE migration\n// transaction. Without it, N goroutines opening the same fresh DB in\n// parallel race per CREATE TABLE statement and trip SQLITE_BUSY despite\n// the busy_timeout. With it, they serialize on the RESERVED lock\n// acquired at BEGIN time and every Open succeeds.\nfunc TestMigrate_ConcurrentFreshDB(t *testing.T) {\n\tif testing.Short() {\n\t\tt.Skip(\"concurrent migration test can take up to migrationLockTimeout under contention\")\n\t}\n\tt.Parallel()\n\n\tdbPath := filepath.Join(t.TempDir(), \"data.db\")\n\n\tconst n = 8\n\terrs := make(chan error, n)\n\tvar wg sync.WaitGroup\n\twg.Add(n)\n\tfor i := 0; i \u003c n; i++ {\n\t\tgo func() {\n\t\t\tdefer wg.Done()\n\t\t\ts, err := Open(dbPath)\n\t\t\tif err != nil {\n\t\t\t\terrs \u003c- err\n\t\t\t\treturn\n\t\t\t}\n\t\t\ts.Close()\n\t\t}()\n\t}\n\twg.Wait()\n\tclose(errs)\n\n\tfor err := range errs {\n\t\tt.Fatalf(\"concurrent Open failed: %v\", err)\n\t}\n}\n\n// holdWriteLock takes an exclusive write lock on dbPath that a peer's\n// BEGIN IMMEDIATE cannot acquire until the returned cleanup runs. Used\n// to construct contention scenarios in the migration tests.\nfunc holdWriteLock(t *testing.T, dbPath string) (cleanup func()) {\n\tt.Helper()\n\tholder, err := sql.Open(\"sqlite\", dbPath+\"?_journal_mode=WAL&_busy_timeout=5000\")\n\tif err != nil {\n\t\tt.Fatalf(\"open holder: %v\", err)\n\t}\n\thtx, err := holder.Begin()\n\tif err != nil {\n\t\t_ = holder.Close()\n\t\tt.Fatalf(\"begin holder tx: %v\", err)\n\t}\n\tif _, err := htx.Exec(`CREATE TABLE IF NOT EXISTS holder_lock (id INTEGER)`); err != nil {\n\t\t_ = htx.Rollback()\n\t\t_ = holder.Close()\n\t\tt.Fatalf(\"seed holder write: %v\", err)\n\t}\n\treturn func() {\n\t\t_ = htx.Rollback()\n\t\t_ = holder.Close()\n\t}\n}\n\n// TestOpenWithContext_RespectsCancellation verifies that a caller that\n// cancels its context during a stalled migration sees the cancellation\n// surface as the returned error within a short window, instead of\n// having to wait out the full migrationLockTimeout. SIGINT in a Cobra\n// command's context must interrupt store.Open, not just block on it.\nfunc TestOpenWithContext_RespectsCancellation(t *testing.T) {\n\tt.Parallel()\n\n\tdbPath := filepath.Join(t.TempDir(), \"data.db\")\n\tdefer holdWriteLock(t, dbPath)()\n\n\t// Pre-cancel the context. The migration's BEGIN IMMEDIATE will BUSY\n\t// against the holder; the very first iteration of retryOnBusy then\n\t// hits the ctx.Done() arm of its select and propagates ctx.Canceled.\n\t// A blocked-then-cancel pattern using time.Sleep would prove the\n\t// same property but cost the sleep interval on every CI run.\n\tctx, cancel := context.WithCancel(context.Background())\n\tcancel()\n\n\tstart := time.Now()\n\t_, err := OpenWithContext(ctx, dbPath)\n\telapsed := time.Since(start)\n\n\tif err == nil {\n\t\tt.Fatalf(\"expected OpenWithContext to fail under contention with cancelled ctx\")\n\t}\n\tif !errors.Is(err, context.Canceled) {\n\t\tt.Fatalf(\"expected context.Canceled in error chain, got: %v\", err)\n\t}\n\t// Without ctx threading this would block until migrationLockTimeout\n\t// (default 30s). 5s is generous headroom over the actual return\n\t// time (microseconds for a pre-cancelled ctx) without flaking CI.\n\tif elapsed > 5*time.Second {\n\t\tt.Fatalf(\"OpenWithContext returned after %s; pre-cancelled ctx should short-circuit immediately\", elapsed)\n\t}\n}\n\n// TestMigrate_RejectsNewerDBImmediately verifies that an old binary\n// opening a newer-schema DB rejects fast even when a peer migrator is\n// still holding the write lock. The schema-version check runs on the\n// pinned connection BEFORE BEGIN IMMEDIATE so the rejection path\n// doesn't have to wait out the migration lock.\nfunc TestMigrate_RejectsNewerDBImmediately(t *testing.T) {\n\tt.Parallel()\n\n\tdbPath := filepath.Join(t.TempDir(), \"data.db\")\n\n\t// Pre-stamp the DB at a version this binary doesn't support.\n\traw, err := sql.Open(\"sqlite\", dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"open raw: %v\", err)\n\t}\n\tif _, err := raw.Exec(`PRAGMA user_version = 999`); err != nil {\n\t\tt.Fatalf(\"stamp future version: %v\", err)\n\t}\n\traw.Close()\n\n\tdefer holdWriteLock(t, dbPath)()\n\n\tstart := time.Now()\n\t_, err = Open(dbPath)\n\telapsed := time.Since(start)\n\n\tif err == nil {\n\t\tt.Fatalf(\"expected Open to refuse a newer-schema DB\")\n\t}\n\t// The fast-path goal: rejection must arrive well under\n\t// migrationLockTimeout. 5s leaves headroom over the WAL init race\n\t// (a few ms in practice) without being so tight CI flakes.\n\tif elapsed > 5*time.Second {\n\t\tt.Fatalf(\"Open rejected after %s; fast-path should reject in well under migrationLockTimeout (30s)\", elapsed)\n\t}\n}\n\n// TestSchemaVersion_ReopenIsIdempotent verifies that opening an already\n// correctly-stamped DB is a no-op — the second open reads the version\n// and the migrations are all idempotent (CREATE TABLE IF NOT EXISTS).\nfunc TestSchemaVersion_ReopenIsIdempotent(t *testing.T) {\n\tdbPath := filepath.Join(t.TempDir(), \"data.db\")\n\n\ts1, err := Open(dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"first open: %v\", err)\n\t}\n\ts1.Close()\n\n\ts2, err := Open(dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"second open: %v\", err)\n\t}\n\tdefer s2.Close()\n\n\tv, err := s2.SchemaVersion()\n\tif err != nil {\n\t\tt.Fatalf(\"read schema version: %v\", err)\n\t}\n\tif v != StoreSchemaVersion {\n\t\tt.Fatalf(\"reopened version = %d, want %d\", v, StoreSchemaVersion)\n\t}\n}\n\n// TestMigrate_AddsColumnsOnUpgrade_Listing verifies that opening a\n// database created by an older binary succeeds and adds newly generated\n// columns before CREATE INDEX runs against the pre-existing table. Regression\n// coverage for parent_id upgrades and indexed generated columns.\nfunc TestMigrate_AddsColumnsOnUpgrade_Listing(t *testing.T) {\n\tdbPath := filepath.Join(t.TempDir(), \"data.db\")\n\n\t// Pre-create the DB with the older table shape: id, data, synced_at and\n\t// none of the newer generated columns. user_version stays 0 (pre-gate).\n\traw, err := sql.Open(\"sqlite\", dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"open raw: %v\", err)\n\t}\n\tif _, err := raw.Exec(`CREATE TABLE listing (\n\t\tid TEXT PRIMARY KEY,\n\t\tdata JSON NOT NULL,\n\t\tsynced_at DATETIME DEFAULT CURRENT_TIMESTAMP\n\t)`); err != nil {\n\t\traw.Close()\n\t\tt.Fatalf(\"create old table: %v\", err)\n\t}\n\traw.Close()\n\n\t// Opening with the new binary must run CREATE INDEX statements without\n\t// erroring on missing generated columns.\n\ts, err := Open(dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"open upgraded db: %v\", err)\n\t}\n\tdefer s.Close()\n\n\t// The migration must have added every generated column.\n\trows, err := s.DB().Query(`PRAGMA table_info(listing)`)\n\tif err != nil {\n\t\tt.Fatalf(\"table_info: %v\", err)\n\t}\n\tdefer rows.Close()\n\n\thasColumn := make(map[string]bool)\n\tfor rows.Next() {\n\t\tvar cid int\n\t\tvar name, typ string\n\t\tvar notnull, pk int\n\t\tvar dflt sql.NullString\n\t\tif err := rows.Scan(&cid, &name, &typ, ¬null, &dflt, &pk); err != nil {\n\t\t\tt.Fatalf(\"scan: %v\", err)\n\t\t}\n\t\thasColumn[name] = true\n\t}\n\tif err := rows.Err(); err != nil {\n\t\tt.Fatalf(\"rows: %v\", err)\n\t}\n\n\tfor _, want := range []string{\n\t\t\"property_id\",\n\t} {\n\t\tif !hasColumn[want] {\n\t\t\tt.Fatalf(\"%s column missing from listing after migrate\", want)\n\t\t}\n\t}\n}\n\n// TestMigrate_AddsColumnsOnUpgrade_SyncState verifies that opening a\n// database created by an older binary succeeds and adds newly generated\n// columns before CREATE INDEX runs against the pre-existing table. Regression\n// coverage for parent_id upgrades and indexed generated columns.\nfunc TestMigrate_AddsColumnsOnUpgrade_SyncState(t *testing.T) {\n\tdbPath := filepath.Join(t.TempDir(), \"data.db\")\n\n\t// Pre-create the DB with the older table shape: id, data, synced_at and\n\t// none of the newer generated columns. user_version stays 0 (pre-gate).\n\traw, err := sql.Open(\"sqlite\", dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"open raw: %v\", err)\n\t}\n\tif _, err := raw.Exec(`CREATE TABLE sync_state (\n\t\tid TEXT PRIMARY KEY,\n\t\tdata JSON NOT NULL,\n\t\tsynced_at DATETIME DEFAULT CURRENT_TIMESTAMP\n\t)`); err != nil {\n\t\traw.Close()\n\t\tt.Fatalf(\"create old table: %v\", err)\n\t}\n\traw.Close()\n\n\t// Opening with the new binary must run CREATE INDEX statements without\n\t// erroring on missing generated columns.\n\ts, err := Open(dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"open upgraded db: %v\", err)\n\t}\n\tdefer s.Close()\n\n\t// The migration must have added every generated column.\n\trows, err := s.DB().Query(`PRAGMA table_info(sync_state)`)\n\tif err != nil {\n\t\tt.Fatalf(\"table_info: %v\", err)\n\t}\n\tdefer rows.Close()\n\n\thasColumn := make(map[string]bool)\n\tfor rows.Next() {\n\t\tvar cid int\n\t\tvar name, typ string\n\t\tvar notnull, pk int\n\t\tvar dflt sql.NullString\n\t\tif err := rows.Scan(&cid, &name, &typ, ¬null, &dflt, &pk); err != nil {\n\t\t\tt.Fatalf(\"scan: %v\", err)\n\t\t}\n\t\thasColumn[name] = true\n\t}\n\tif err := rows.Err(); err != nil {\n\t\tt.Fatalf(\"rows: %v\", err)\n\t}\n\n\tfor _, want := range []string{\n\t\t\"last_cursor\",\n\t\t\"last_synced_at\",\n\t\t\"total_count\",\n\t} {\n\t\tif !hasColumn[want] {\n\t\t\tt.Fatalf(\"%s column missing from sync_state after migrate\", want)\n\t\t}\n\t}\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":11520,"content_sha256":"45c44332a459ab4942bc4f58e71bd14f1aa3b890677beecaab3c10a34fc0e682"},{"filename":"internal/store/store.go","content":"// Copyright 2026 rderwin and contributors. Licensed under Apache-2.0. See LICENSE.\n// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.\n\n// Package store provides local SQLite persistence for apartments-pp-cli.\n// Uses modernc.org/sqlite (pure Go, no CGO) for zero-dependency cross-compilation.\n// FTS5 full-text search indexes are created for searchable content.\npackage store\n\nimport (\n\t\"context\"\n\t\"database/sql\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t_ \"modernc.org/sqlite\"\n)\n\nvar uuidPattern = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12} pp-apartments — Skillopedia )\n\n// IsUUID returns true if the input looks like a UUID.\nfunc IsUUID(s string) bool {\n\treturn uuidPattern.MatchString(s)\n}\n\n// StoreSchemaVersion is the on-disk schema version this binary understands.\n// It is stamped into SQLite's PRAGMA user_version on fresh databases and\n// checked on every open. Bump this whenever a migration changes table\n// shape — adding columns, dropping indexes, changing FTS5 tokenizers —\n// so an older binary refuses to open a newer database rather than silently\n// producing wrong results against a schema it cannot read.\nconst StoreSchemaVersion = 1\n\ntype Store struct {\n\tdb *sql.DB\n\t// writeMu serializes all DB writes. Read paths bypass the lock and run\n\t// concurrently against WAL. Resource-level concurrency in sync.go.tmpl\n\t// is 1 (one goroutine per resource via len(resources)-sized work channel)\n\t// — read-then-write sequences (e.g., GetSyncCursor → SaveSyncState) are\n\t// race-free by construction within a resource.\n\twriteMu sync.Mutex\n\tpath string\n}\n\n// Open opens or creates the SQLite store at dbPath using the background\n// context. Prefer OpenWithContext from a Cobra command so SIGINT during\n// a slow migration interrupts the open instead of stranding the caller.\nfunc Open(dbPath string) (*Store, error) {\n\treturn OpenWithContext(context.Background(), dbPath)\n}\n\n// OpenWithContext opens or creates the SQLite store at dbPath. The\n// context is honored by the migration path: cancellation interrupts the\n// retry-on-SQLITE_BUSY loop and propagates ctx.Err() back to the caller\n// instead of waiting out the full migrationLockTimeout.\nfunc OpenWithContext(ctx context.Context, dbPath string) (*Store, error) {\n\tif err := os.MkdirAll(filepath.Dir(dbPath), 0o755); err != nil {\n\t\treturn nil, fmt.Errorf(\"creating db directory: %w\", err)\n\t}\n\n\tdb, err := sql.Open(\"sqlite\", dbPath+\"?_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)&_pragma=busy_timeout(5000)&_pragma=foreign_keys(ON)&_pragma=temp_store(MEMORY)&_pragma=mmap_size(268435456)\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"opening database: %w\", err)\n\t}\n\n\t// WAL mode + 2 connections allows one read cursor open while a second\n\t// query executes (e.g., analytics commands calling helpers during row\n\t// iteration). Writes are still serialized by SQLite's WAL lock.\n\tdb.SetMaxOpenConns(2)\n\n\ts := &Store{db: db, path: dbPath}\n\tif err := s.migrate(ctx); err != nil {\n\t\tdb.Close()\n\t\treturn nil, fmt.Errorf(\"running migrations: %w\", err)\n\t}\n\n\treturn s, nil\n}\n\nfunc (s *Store) Close() error {\n\treturn s.db.Close()\n}\n\n// Path returns the on-disk path of the backing SQLite file.\nfunc (s *Store) Path() string {\n\treturn s.path\n}\n\n// DB exposes the underlying *sql.DB for callers that need to run ad-hoc\n// queries (e.g., doctor's cache inspection, share snapshot import).\n// Callers must not call Close on the returned handle.\nfunc (s *Store) DB() *sql.DB {\n\treturn s.db\n}\n\n// SchemaVersion reads PRAGMA user_version, which is stamped by migrate().\n// A zero value means the database predates the schema-version gate — not\n// a bug, but the caller may want to warn.\nfunc (s *Store) SchemaVersion() (int, error) {\n\tvar v int\n\tif err := s.db.QueryRow(`PRAGMA user_version`).Scan(&v); err != nil {\n\t\treturn 0, fmt.Errorf(\"read user_version: %w\", err)\n\t}\n\treturn v, nil\n}\n\n// ensureColumn adds a column to an existing table if it isn't already\n// present. It is the upgrade-path safety valve for schema additions:\n// CREATE TABLE IF NOT EXISTS is a no-op when the table already exists, so\n// columns added by newer binaries (e.g. parent_id from the dependent-\n// resources work) never land on databases created by older binaries —\n// which then trip \"no such column\" when a follow-on CREATE INDEX runs.\n//\n// Skips silently if the table doesn't yet exist (fresh install — the\n// CREATE TABLE migration will create it with the column already declared)\n// or if the column already exists. Runs on the pinned migration\n// connection so it sees the writes performed by the in-flight BEGIN\n// IMMEDIATE transaction; using s.db here would route through the pool\n// and BUSY against the holding writer under concurrent migrators.\nfunc (s *Store) ensureColumn(ctx context.Context, conn *sql.Conn, table, column, decl string) error {\n\tvar name string\n\terr := conn.QueryRowContext(ctx,\n\t\t`SELECT name FROM sqlite_master WHERE type='table' AND name=?`, table,\n\t).Scan(&name)\n\tif err == sql.ErrNoRows {\n\t\treturn nil\n\t}\n\tif err != nil {\n\t\treturn fmt.Errorf(\"checking table %s: %w\", table, err)\n\t}\n\n\trows, err := conn.QueryContext(ctx, fmt.Sprintf(`PRAGMA table_info(\"%s\")`, table))\n\tif err != nil {\n\t\treturn fmt.Errorf(\"table_info %s: %w\", table, err)\n\t}\n\tdefer rows.Close()\n\tfor rows.Next() {\n\t\tvar cid int\n\t\tvar n, typ string\n\t\tvar notnull, pk int\n\t\tvar dflt sql.NullString\n\t\tif err := rows.Scan(&cid, &n, &typ, ¬null, &dflt, &pk); err != nil {\n\t\t\treturn fmt.Errorf(\"scan table_info %s: %w\", table, err)\n\t\t}\n\t\tif n == column {\n\t\t\treturn nil\n\t\t}\n\t}\n\tif err := rows.Err(); err != nil {\n\t\treturn fmt.Errorf(\"iterating table_info %s: %w\", table, err)\n\t}\n\n\tif _, err := conn.ExecContext(ctx, fmt.Sprintf(`ALTER TABLE \"%s\" ADD COLUMN \"%s\" %s`, table, column, decl)); err != nil {\n\t\t// A concurrent Open() may have added the column between our\n\t\t// PRAGMA check and this ALTER. SQLite returns SQLITE_ERROR with\n\t\t// \"duplicate column name\", which busy_timeout does not retry.\n\t\t// The DB is now in the desired state regardless of who won.\n\t\tif strings.Contains(err.Error(), \"duplicate column name\") {\n\t\t\treturn nil\n\t\t}\n\t\treturn fmt.Errorf(\"add column %s.%s: %w\", table, column, err)\n\t}\n\treturn nil\n}\n\n// backfillColumns adds columns that newer binaries declare but that\n// pre-existing databases (created before those columns were added) lack.\n// Must run before the migrations slice so that subsequent CREATE INDEX\n// statements referencing the column can succeed against the upgraded\n// table. Idempotent: safe to call on fresh DBs (table-not-found short-\n// circuit) and on already-current DBs (column-exists short-circuit).\n//\n// Table names are emitted bare (no safeName) — ensureColumn double-quotes\n// them at SQL emit time and uses parameter binding for the sqlite_master\n// lookup, so the values flow as Go string literals first and SQL\n// identifiers second. Wrapping with safeName here would embed literal\n// double-quote characters into the Go string and break compilation for\n// any spec whose dependent-resource snake_cased name is a SQL reserved\n// word.\nfunc (s *Store) backfillColumns(ctx context.Context, conn *sql.Conn) error {\n\tfor _, c := range []struct{ table, column, decl string }{\n\t\t{table: \"listing\", column: \"property_id\", decl: \"TEXT\"},\n\t\t{table: \"sync_state\", column: \"last_cursor\", decl: \"TEXT\"},\n\t\t{table: \"sync_state\", column: \"last_synced_at\", decl: \"DATETIME\"},\n\t\t{table: \"sync_state\", column: \"total_count\", decl: \"INTEGER DEFAULT 0\"},\n\t} {\n\t\tif err := s.ensureColumn(ctx, conn, c.table, c.column, c.decl); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (s *Store) migrate(ctx context.Context) error {\n\tconn, err := s.db.Conn(ctx)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"acquiring migration connection: %w\", err)\n\t}\n\tdefer conn.Close()\n\n\t// Read user_version before the migration lock so an old binary\n\t// opening a newer-schema DB rejects immediately. WAL readers don't\n\t// normally block on writers, but the fresh-DB WAL-init race can BUSY\n\t// a SELECT — share the lock's deadline so total budget stays bounded.\n\tdeadline := time.Now().Add(migrationLockTimeout)\n\tvar current int\n\tif err := retryOnBusy(ctx, deadline, \"reading schema version\", func() error {\n\t\treturn conn.QueryRowContext(ctx, `PRAGMA user_version`).Scan(¤t)\n\t}); err != nil {\n\t\treturn err\n\t}\n\tif current > StoreSchemaVersion {\n\t\treturn fmt.Errorf(\"database schema version %d is newer than supported version %d; upgrade the CLI binary or open an older database\", current, StoreSchemaVersion)\n\t}\n\n\tmigrations := []string{\n\t\t`CREATE TABLE IF NOT EXISTS resources (\n\t\t\tid TEXT PRIMARY KEY,\n\t\t\tresource_type TEXT NOT NULL,\n\t\t\tdata JSON NOT NULL,\n\t\t\tsynced_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n\t\t\tupdated_at DATETIME DEFAULT CURRENT_TIMESTAMP\n\t\t)`,\n\t\t`CREATE INDEX IF NOT EXISTS idx_resources_type ON resources(resource_type)`,\n\t\t`CREATE INDEX IF NOT EXISTS idx_resources_synced ON resources(synced_at)`,\n\t\t`CREATE TABLE IF NOT EXISTS sync_state (\n\t\t\tresource_type TEXT PRIMARY KEY,\n\t\t\tlast_cursor TEXT,\n\t\t\tlast_synced_at DATETIME,\n\t\t\ttotal_count INTEGER DEFAULT 0\n\t\t)`,\n\t\t`CREATE VIRTUAL TABLE IF NOT EXISTS resources_fts USING fts5(\n\t\t\tid, resource_type, content, tokenize='porter unicode61'\n\t\t)`,\n\t\t`CREATE TABLE IF NOT EXISTS listing (\n\t\t\tid TEXT PRIMARY KEY,\n\t\t\tdata JSON NOT NULL,\n\t\t\tsynced_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n\t\t\tproperty_id TEXT\n\t\t)`,\n\t\t`CREATE INDEX IF NOT EXISTS idx_listing_property_id ON listing(property_id)`,\n\t}\n\n\t// Run every migration — including the column backfill and the\n\t// schema-version stamp — inside a single BEGIN IMMEDIATE transaction\n\t// pinned to one connection. IMMEDIATE acquires SQLite's RESERVED lock\n\t// at BEGIN time so concurrent migrators serialize on it instead of\n\t// racing per-statement and tripping SQLITE_BUSY despite busy_timeout.\n\t// modernc.org/sqlite's busy_timeout does not always cover write-write\n\t// contention at BEGIN/COMMIT time, so we retry both explicitly on\n\t// SQLITE_BUSY for up to migrationLockTimeout.\n\treturn withMigrationLock(ctx, conn, deadline, func() error {\n\t\t// Re-read user_version inside the lock. This is load-bearing,\n\t\t// not paranoid: between the pre-lock read above and our\n\t\t// successful BEGIN IMMEDIATE, a newer-binary peer may have\n\t\t// committed a higher version stamp. Without this re-read, an\n\t\t// older binary (smaller StoreSchemaVersion) would proceed to\n\t\t// stamp its own lower version at the end of the closure,\n\t\t// silently downgrading user_version on a schema that's already\n\t\t// at the newer level. Future maintainers: leave this read in.\n\t\tvar current int\n\t\tif err := conn.QueryRowContext(ctx, `PRAGMA user_version`).Scan(¤t); err != nil {\n\t\t\treturn fmt.Errorf(\"reading schema version: %w\", err)\n\t\t}\n\t\tif current > StoreSchemaVersion {\n\t\t\treturn fmt.Errorf(\"database schema version %d is newer than supported version %d; upgrade the CLI binary or open an older database\", current, StoreSchemaVersion)\n\t\t}\n\n\t\tif err := s.backfillColumns(ctx, conn); err != nil {\n\t\t\treturn fmt.Errorf(\"backfilling columns: %w\", err)\n\t\t}\n\t\tfor _, m := range migrations {\n\t\t\tif _, err := conn.ExecContext(ctx, m); err != nil {\n\t\t\t\treturn fmt.Errorf(\"migration failed: %w\", err)\n\t\t\t}\n\t\t}\n\t\t// Stamp the schema version. On a fresh DB this writes 1; on an\n\t\t// already-stamped DB this is a no-op write of the same value.\n\t\t// An older DB with user_version = 0 and pre-existing tables\n\t\t// gets stamped here without any data rewrites because the\n\t\t// migrations above are idempotent via CREATE TABLE IF NOT EXISTS.\n\t\tif _, err := conn.ExecContext(ctx, fmt.Sprintf(`PRAGMA user_version = %d`, StoreSchemaVersion)); err != nil {\n\t\t\treturn fmt.Errorf(\"stamp user_version: %w\", err)\n\t\t}\n\t\treturn nil\n\t})\n}\n\nconst (\n\tmigrationLockTimeout = 30 * time.Second\n\tmigrationLockBackoffMin = 5 * time.Millisecond\n\tmigrationLockBackoffMax = 100 * time.Millisecond\n)\n\n// withMigrationLock runs fn inside a BEGIN IMMEDIATE / COMMIT pair on\n// conn, retrying both BEGIN and COMMIT on SQLITE_BUSY against the\n// caller-provided deadline. Sharing the deadline with the pre-lock\n// version read keeps total Open() latency bounded by a single budget.\n// The real upper bound is deadline + one trailing backoff interval\n// (≤100ms) + the driver's busy_timeout for the in-flight Exec, since\n// the deadline is checked after each failed attempt rather than as a\n// hard wall-clock cutoff. fn must use conn (not s.db) so its writes\n// participate in the held transaction.\nfunc withMigrationLock(ctx context.Context, conn *sql.Conn, deadline time.Time, fn func() error) error {\n\tif err := execWithBusyRetry(ctx, conn, \"BEGIN IMMEDIATE\", \"begin migration transaction\", deadline); err != nil {\n\t\treturn err\n\t}\n\tcommitted := false\n\tdefer func() {\n\t\tif committed {\n\t\t\treturn\n\t\t}\n\t\t// ROLLBACK uses context.Background() so caller-context cancellation\n\t\t// can't strand the connection in an open transaction. A failed\n\t\t// rollback is rare on local SQLite (broken file handle, fatal\n\t\t// driver error) but worth surfacing — silent swallow leaves a\n\t\t// pinned connection returned to the pool with state that will\n\t\t// confuse later queries.\n\t\tif _, rerr := conn.ExecContext(context.Background(), \"ROLLBACK\"); rerr != nil {\n\t\t\tfmt.Fprintf(os.Stderr, \"warning: store migration rollback failed: %v\\n\", rerr)\n\t\t}\n\t}()\n\n\tif err := fn(); err != nil {\n\t\treturn err\n\t}\n\n\tif err := execWithBusyRetry(ctx, conn, \"COMMIT\", \"commit migration transaction\", deadline); err != nil {\n\t\treturn err\n\t}\n\tcommitted = true\n\treturn nil\n}\n\n// execWithBusyRetry runs stmt on conn and retries on SQLITE_BUSY until\n// deadline. It covers BEGIN IMMEDIATE and COMMIT contention;\n// modernc.org/sqlite's busy_timeout does not reliably cover either when\n// multiple connections race for the WAL write lock.\nfunc execWithBusyRetry(ctx context.Context, conn *sql.Conn, stmt, label string, deadline time.Time) error {\n\treturn retryOnBusy(ctx, deadline, label, func() error {\n\t\t_, err := conn.ExecContext(ctx, stmt)\n\t\treturn err\n\t})\n}\n\n// retryOnBusy runs op and retries it on SQLITE_BUSY/LOCKED until\n// deadline. The same retry shape covers Exec, Query, and any other\n// SQLite call that can race the WAL writer lock — including the\n// pre-lock user_version read, where the WAL initialization race on a\n// fresh DB can BUSY a SELECT that should otherwise succeed under WAL\n// reader/writer concurrency.\nfunc retryOnBusy(ctx context.Context, deadline time.Time, label string, op func() error) error {\n\tbackoff := migrationLockBackoffMin\n\tfor {\n\t\terr := op()\n\t\tif err == nil {\n\t\t\treturn nil\n\t\t}\n\t\tif !isSQLiteBusy(err) {\n\t\t\treturn fmt.Errorf(\"%s: %w\", label, err)\n\t\t}\n\t\tif time.Now().After(deadline) {\n\t\t\t// The label carries the operation context (e.g. \"begin\n\t\t\t// migration transaction\", \"reading schema version\") — we\n\t\t\t// don't hardcode \"waiting for write lock\" because pre-lock\n\t\t\t// reads also flow through this helper.\n\t\t\treturn fmt.Errorf(\"%s: timed out after %s under SQLite contention: %w\", label, migrationLockTimeout, err)\n\t\t}\n\t\tselect {\n\t\tcase \u003c-ctx.Done():\n\t\t\treturn fmt.Errorf(\"%s: %w\", label, ctx.Err())\n\t\tcase \u003c-time.After(backoff):\n\t\t}\n\t\tbackoff = min(backoff*2, migrationLockBackoffMax)\n\t}\n}\n\n// isSQLiteBusy reports whether err is a retryable SQLite lock condition.\n// Covers both the file-level WAL writer race (SQLITE_BUSY / \"database is\n// locked\") and the table-level shared-cache contention (SQLITE_LOCKED /\n// \"database table is locked\"). The match is on the error string because\n// modernc.org/sqlite does not export an error type the generated code\n// can switch on without dragging the driver package into every store\n// consumer.\nfunc isSQLiteBusy(err error) bool {\n\tif err == nil {\n\t\treturn false\n\t}\n\tmsg := err.Error()\n\treturn strings.Contains(msg, \"SQLITE_BUSY\") ||\n\t\tstrings.Contains(msg, \"SQLITE_LOCKED\") ||\n\t\tstrings.Contains(msg, \"database is locked\") ||\n\t\tstrings.Contains(msg, \"database table is locked\")\n}\n\nfunc (s *Store) upsertGenericResourceTx(tx *sql.Tx, resourceType, id string, data json.RawMessage) error {\n\t_, err := tx.Exec(\n\t\t`INSERT INTO resources (id, resource_type, data, synced_at, updated_at)\n\t\t VALUES (?, ?, ?, ?, ?)\n\t\t ON CONFLICT(id) DO UPDATE SET data = excluded.data, synced_at = excluded.synced_at, updated_at = excluded.updated_at`,\n\t\tid, resourceType, string(data), time.Now(), time.Now(),\n\t)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tftsRowid := ftsRowID(id)\n\t// Use explicit rowid for FTS5 compatibility with modernc.org/sqlite.\n\t// Standard DELETE WHERE column=? may not work on FTS5 virtual tables.\n\tif _, err = tx.Exec(`DELETE FROM resources_fts WHERE rowid = ?`, ftsRowid); err != nil {\n\t\tfmt.Fprintf(os.Stderr, \"warning: FTS index cleanup failed: %v\\n\", err)\n\t}\n\n\tif _, err = tx.Exec(\n\t\t`INSERT INTO resources_fts (rowid, id, resource_type, content)\n\t\t VALUES (?, ?, ?, ?)`,\n\t\tftsRowid, id, resourceType, string(data),\n\t); err != nil {\n\t\t// FTS insert failure is non-fatal\n\t\tfmt.Fprintf(os.Stderr, \"warning: FTS index update failed: %v\\n\", err)\n\t}\n\n\treturn nil\n}\n\nfunc (s *Store) Upsert(resourceType, id string, data json.RawMessage) error {\n\ts.writeMu.Lock()\n\tdefer s.writeMu.Unlock()\n\ttx, err := s.db.Begin()\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer tx.Rollback()\n\n\tif err := s.upsertGenericResourceTx(tx, resourceType, id, data); err != nil {\n\t\treturn err\n\t}\n\n\treturn tx.Commit()\n}\n\nfunc (s *Store) Get(resourceType, id string) (json.RawMessage, error) {\n\tvar data string\n\terr := s.db.QueryRow(\n\t\t`SELECT data FROM resources WHERE resource_type = ? AND id = ?`,\n\t\tresourceType, id,\n\t).Scan(&data)\n\tif err == sql.ErrNoRows {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn json.RawMessage(data), nil\n}\n\nfunc (s *Store) List(resourceType string, limit int) ([]json.RawMessage, error) {\n\tif limit \u003c= 0 {\n\t\tlimit = 200\n\t}\n\trows, err := s.db.Query(\n\t\t`SELECT data FROM resources WHERE resource_type = ? ORDER BY updated_at DESC LIMIT ?`,\n\t\tresourceType, limit,\n\t)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\n\tvar results []json.RawMessage\n\tfor rows.Next() {\n\t\tvar data string\n\t\tif err := rows.Scan(&data); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tresults = append(results, json.RawMessage(data))\n\t}\n\treturn results, rows.Err()\n}\n\nfunc (s *Store) Search(query string, limit int) ([]json.RawMessage, error) {\n\tif limit \u003c= 0 {\n\t\tlimit = 50\n\t}\n\trows, err := s.db.Query(\n\t\t`SELECT r.data FROM resources r\n\t\t JOIN resources_fts f ON r.id = f.id\n\t\t WHERE resources_fts MATCH ?\n\t\t ORDER BY rank\n\t\t LIMIT ?`,\n\t\tquery, limit,\n\t)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\n\tvar results []json.RawMessage\n\tfor rows.Next() {\n\t\tvar data string\n\t\tif err := rows.Scan(&data); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tresults = append(results, json.RawMessage(data))\n\t}\n\treturn results, rows.Err()\n}\n\nfunc extractObjectID(obj map[string]any) string {\n\tfor _, key := range []string{\"id\", \"ID\", \"uuid\", \"slug\", \"name\"} {\n\t\tif v, ok := obj[key]; ok {\n\t\t\treturn fmt.Sprintf(\"%v\", v)\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// ftsRowID derives a deterministic rowid from a string ID for use with FTS5.\n// modernc.org/sqlite's FTS5 implementation may not support DELETE WHERE column=?\n// on virtual tables, so we use explicit rowids and DELETE WHERE rowid=? instead.\nfunc ftsRowID(id string) int64 {\n\tvar h uint64\n\tfor _, c := range id {\n\t\th = h*31 + uint64(c)\n\t}\n\treturn int64(h & 0x7FFFFFFFFFFFFFFF) // ensure positive\n}\n\n// LookupFieldValue resolves a field value from a JSON object map, trying\n// the snake_case key first and the camelCase rendering second. Exported so\n// the sync command's extractID and the upsert path resolve fields the same\n// way — a divergence here produces silent drops on heterogeneous payloads.\nfunc LookupFieldValue(obj map[string]any, snakeKey string) any {\n\tif v, ok := obj[snakeKey]; ok {\n\t\treturn sqliteFieldValue(v)\n\t}\n\tparts := strings.Split(snakeKey, \"_\")\n\tfor i := 1; i \u003c len(parts); i++ {\n\t\tif parts[i] == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tparts[i] = strings.ToUpper(parts[i][:1]) + parts[i][1:]\n\t}\n\tif v, ok := obj[strings.Join(parts, \"\")]; ok {\n\t\treturn sqliteFieldValue(v)\n\t}\n\treturn nil\n}\n\nfunc sqliteFieldValue(v any) any {\n\tswitch v.(type) {\n\tcase nil, string, bool, int, int64, float64, []byte:\n\t\treturn v\n\tdefault:\n\t\tdata, err := json.Marshal(v)\n\t\tif err != nil {\n\t\t\treturn fmt.Sprint(v)\n\t\t}\n\t\treturn string(data)\n\t}\n}\n\n// lookupFieldValue is kept as an unexported alias for in-package callers so\n// the existing UpsertBatch code reads naturally without prefixing every call\n// with the package name.\nfunc lookupFieldValue(obj map[string]any, snakeKey string) any {\n\treturn LookupFieldValue(obj, snakeKey)\n}\n\n// upsertListingTx writes the typed-table portion of a listing upsert\n// inside an existing transaction. The caller is responsible for the generic\n// resources insert (via upsertGenericResourceTx) and for committing the tx.\n// Splitting this out lets UpsertBatch dispatch typed inserts per item without\n// opening a per-item transaction.\nfunc (s *Store) upsertListingTx(tx *sql.Tx, id string, obj map[string]any, data json.RawMessage) error {\n\tif _, err := tx.Exec(\n\t\t`INSERT INTO listing (id, data, synced_at, property_id)\n\t\t VALUES (?, ?, ?, ?)\n\t\t ON CONFLICT(id) DO UPDATE SET data = excluded.data, synced_at = excluded.synced_at, property_id = excluded.property_id`,\n\t\tid,\n\t\tstring(data),\n\t\ttime.Now(),\n\t\tlookupFieldValue(obj, \"property_id\"),\n\t); err != nil {\n\t\treturn fmt.Errorf(\"insert into listing: %w\", err)\n\t}\n\n\treturn nil\n}\n\n// UpsertListing inserts or updates a listing record with domain-specific columns.\nfunc (s *Store) UpsertListing(data json.RawMessage) error {\n\tvar obj map[string]any\n\tif err := json.Unmarshal(data, &obj); err != nil {\n\t\treturn fmt.Errorf(\"unmarshaling listing: %w\", err)\n\t}\n\n\tid := extractObjectID(obj)\n\tif id == \"\" {\n\t\treturn fmt.Errorf(\"missing id for listing\")\n\t}\n\n\ts.writeMu.Lock()\n\tdefer s.writeMu.Unlock()\n\ttx, err := s.db.Begin()\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer tx.Rollback()\n\n\tif err := s.upsertGenericResourceTx(tx, \"listing\", id, data); err != nil {\n\t\treturn err\n\t}\n\tif err := s.upsertListingTx(tx, id, obj, data); err != nil {\n\t\treturn err\n\t}\n\n\treturn tx.Commit()\n}\n\n// resourceIDFieldOverrides projects per-resource IDField (set by the profiler\n// from x-resource-id or response-schema fallback) into a runtime lookup map.\n// UpsertBatch consults this first so the templated path wins over the\n// generic fallback list. Empty when no resource declared an override; the\n// runtime fallback list still applies.\n//\n// Includes both flat resources and dependent (parent-child) resources so a\n// child path-item annotated with x-resource-id resolves the same as a flat\n// path-item.\nvar resourceIDFieldOverrides = map[string]string{}\n\n// genericIDFieldFallbacks is the runtime safety net for resources that did\n// NOT receive a templated IDField. API-specific names belong in spec\n// annotations (x-resource-id), not this list.\nvar genericIDFieldFallbacks = []string{\"id\", \"ID\", \"name\", \"uuid\", \"slug\", \"key\", \"code\", \"uid\"}\n\n// UpsertBatch inserts or replaces multiple records in a single transaction\n// and returns (stored, extractFailures, err). stored counts rows actually\n// landed; extractFailures counts items that survived JSON unmarshal but had\n// no extractable primary key (templated IDField AND generic fallback both\n// missed). callers (sync.go.tmpl) compare these against len(items) to emit\n// the per-item primary_key_unresolved warning and the F4b\n// stored_count_zero_after_extraction probe.\n//\n// For resource types that have a domain-specific typed table, the per-item\n// generic insert is followed by a dispatch to the matching upsert\u003cPascal>Tx\n// inside the same transaction. Without that dispatch, paginated syncs would\n// only populate the generic resources table — typed tables (and indexed\n// columns like parent_id added by dependent-resource sync) would stay empty.\nfunc (s *Store) UpsertBatch(resourceType string, items []json.RawMessage) (int, int, error) {\n\ts.writeMu.Lock()\n\tdefer s.writeMu.Unlock()\n\ttx, err := s.db.Begin()\n\tif err != nil {\n\t\treturn 0, 0, fmt.Errorf(\"starting batch transaction: %w\", err)\n\t}\n\tdefer tx.Rollback()\n\n\tvar stored, skippedCount, extractFailures int\n\tfor _, item := range items {\n\t\tvar obj map[string]any\n\t\tif err := json.Unmarshal(item, &obj); err != nil {\n\t\t\tskippedCount++\n\t\t\tcontinue\n\t\t}\n\t\t// Templated IDField wins; generic fallback list runs second when\n\t\t// the override is empty OR the override field is absent on this\n\t\t// particular item (response shape mismatches happen even when the\n\t\t// spec declares x-resource-id).\n\t\tvar id string\n\t\tif override, ok := resourceIDFieldOverrides[resourceType]; ok && override != \"\" {\n\t\t\tif v := lookupFieldValue(obj, override); v != nil {\n\t\t\t\ts := fmt.Sprintf(\"%v\", v)\n\t\t\t\tif s != \"\" && s != \"\u003cnil>\" {\n\t\t\t\t\tid = s\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif id == \"\" {\n\t\t\tfor _, key := range genericIDFieldFallbacks {\n\t\t\t\tif v := lookupFieldValue(obj, key); v != nil {\n\t\t\t\t\ts := fmt.Sprintf(\"%v\", v)\n\t\t\t\t\tif s != \"\" && s != \"\u003cnil>\" {\n\t\t\t\t\t\tid = s\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif id == \"\" {\n\t\t\tskippedCount++\n\t\t\textractFailures++\n\t\t\tcontinue\n\t\t}\n\n\t\tif err := s.upsertGenericResourceTx(tx, resourceType, id, item); err != nil {\n\t\t\treturn 0, extractFailures, fmt.Errorf(\"upserting %s/%s: %w\", resourceType, id, err)\n\t\t}\n\n\t\tswitch resourceType {\n\t\tcase \"listing\":\n\t\t\tif err := s.upsertListingTx(tx, id, obj, item); err != nil {\n\t\t\t\treturn 0, extractFailures, fmt.Errorf(\"typed upsert for %s/%s: %w\", resourceType, id, err)\n\t\t\t}\n\t\t}\n\t\tstored++\n\t}\n\n\t// Warn when most items in a batch lack an extractable ID — this likely\n\t// means the API uses a primary key field we don't recognize yet.\n\tif skippedCount > 0 && len(items) > 0 && skippedCount*2 > len(items) {\n\t\tfmt.Fprintf(os.Stderr, \"warning: %d/%d %s items skipped (no extractable ID field found)\\n\", skippedCount, len(items), resourceType)\n\t}\n\n\tif err := tx.Commit(); err != nil {\n\t\treturn 0, extractFailures, err\n\t}\n\treturn stored, extractFailures, nil\n}\n\nfunc (s *Store) SaveSyncState(resourceType, cursor string, count int) error {\n\ts.writeMu.Lock()\n\tdefer s.writeMu.Unlock()\n\t_, err := s.db.Exec(\n\t\t`INSERT INTO sync_state (resource_type, last_cursor, last_synced_at, total_count)\n\t\t VALUES (?, ?, ?, ?)\n\t\t ON CONFLICT(resource_type) DO UPDATE SET last_cursor = excluded.last_cursor,\n\t\t last_synced_at = excluded.last_synced_at, total_count = excluded.total_count`,\n\t\tresourceType, cursor, time.Now(), count,\n\t)\n\treturn err\n}\n\nfunc (s *Store) GetSyncState(resourceType string) (cursor string, lastSynced time.Time, count int, err error) {\n\terr = s.db.QueryRow(\n\t\t`SELECT last_cursor, last_synced_at, total_count FROM sync_state WHERE resource_type = ?`,\n\t\tresourceType,\n\t).Scan(&cursor, &lastSynced, &count)\n\tif err == sql.ErrNoRows {\n\t\treturn \"\", time.Time{}, 0, nil\n\t}\n\treturn\n}\n\n// SaveSyncCursor stores the pagination cursor for a resource type.\nfunc (s *Store) SaveSyncCursor(resourceType, cursor string) error {\n\ts.writeMu.Lock()\n\tdefer s.writeMu.Unlock()\n\t_, err := s.db.Exec(\n\t\t`INSERT INTO sync_state (resource_type, last_cursor, last_synced_at, total_count)\n\t\t VALUES (?, ?, CURRENT_TIMESTAMP, 0)\n\t\t ON CONFLICT(resource_type) DO UPDATE SET last_cursor = ?, last_synced_at = CURRENT_TIMESTAMP`,\n\t\tresourceType, cursor, cursor,\n\t)\n\treturn err\n}\n\n// GetSyncCursor returns the last pagination cursor for a resource type.\nfunc (s *Store) GetSyncCursor(resourceType string) string {\n\tvar cursor sql.NullString\n\ts.db.QueryRow(\"SELECT last_cursor FROM sync_state WHERE resource_type = ?\", resourceType).Scan(&cursor)\n\tif cursor.Valid {\n\t\treturn cursor.String\n\t}\n\treturn \"\"\n}\n\n// ListIDs returns all IDs from a resource's domain table, or from the generic\n// resources table if no domain table exists. Used by dependent sync to iterate parents.\nfunc (s *Store) ListIDs(resourceType string) ([]string, error) {\n\t// Try domain table first (tables are named after the resource type)\n\tquery := fmt.Sprintf(\"SELECT id FROM %s\", resourceType)\n\trows, err := s.db.Query(query)\n\tif err != nil {\n\t\t// Fall back to generic resources table\n\t\trows, err = s.db.Query(\"SELECT id FROM resources WHERE resource_type = ?\", resourceType)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\tdefer rows.Close()\n\n\tvar ids []string\n\tfor rows.Next() {\n\t\tvar id string\n\t\tif err := rows.Scan(&id); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tids = append(ids, id)\n\t}\n\treturn ids, rows.Err()\n}\n\n// GetLastSyncedAt returns the last sync timestamp for a resource type.\nfunc (s *Store) GetLastSyncedAt(resourceType string) string {\n\tvar ts sql.NullString\n\ts.db.QueryRow(\"SELECT last_synced_at FROM sync_state WHERE resource_type = ?\", resourceType).Scan(&ts)\n\tif ts.Valid {\n\t\treturn ts.String\n\t}\n\treturn \"\"\n}\n\n// ClearSyncCursors resets all sync state for a full resync.\nfunc (s *Store) ClearSyncCursors() error {\n\ts.writeMu.Lock()\n\tdefer s.writeMu.Unlock()\n\t_, err := s.db.Exec(\"DELETE FROM sync_state\")\n\treturn err\n}\n\n// Query executes a raw SQL query and returns the rows.\n// Used by workflow commands that need custom queries against the local store.\nfunc (s *Store) Query(query string, args ...any) (*sql.Rows, error) {\n\treturn s.db.Query(query, args...)\n}\n\nfunc (s *Store) Count(resourceType string) (int, error) {\n\tvar count int\n\terr := s.db.QueryRow(\n\t\t`SELECT COUNT(*) FROM resources WHERE resource_type = ?`,\n\t\tresourceType,\n\t).Scan(&count)\n\treturn count, err\n}\n\nfunc (s *Store) Status() (map[string]int, error) {\n\trows, err := s.db.Query(\n\t\t`SELECT resource_type, COUNT(*) FROM resources GROUP BY resource_type ORDER BY resource_type`,\n\t)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\n\tstatus := make(map[string]int)\n\tfor rows.Next() {\n\t\tvar rt string\n\t\tvar count int\n\t\tif err := rows.Scan(&rt, &count); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tstatus[rt] = count\n\t}\n\treturn status, rows.Err()\n}\n\n// ResolveByName resolves a human-readable name to a UUID from synced data.\n// If the input is already a UUID, it is returned as-is.\n// matchFields are JSON field names to search against (e.g., \"name\", \"key\", \"email\").\nfunc (s *Store) ResolveByName(resourceType string, input string, matchFields ...string) (string, error) {\n\tif IsUUID(input) {\n\t\treturn input, nil\n\t}\n\n\tvar matches []string\n\tfor _, field := range matchFields {\n\t\tquery := fmt.Sprintf(\n\t\t\t`SELECT id FROM resources WHERE resource_type = ? AND LOWER(json_extract(data, '$.%s')) = LOWER(?)`,\n\t\t\tfield,\n\t\t)\n\t\trows, err := s.db.Query(query, resourceType, input)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tfor rows.Next() {\n\t\t\tvar id string\n\t\t\tif rows.Scan(&id) == nil {\n\t\t\t\t// Deduplicate\n\t\t\t\tfound := false\n\t\t\t\tfor _, m := range matches {\n\t\t\t\t\tif m == id {\n\t\t\t\t\t\tfound = true\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif !found {\n\t\t\t\t\tmatches = append(matches, id)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\trows.Close()\n\t}\n\n\tswitch len(matches) {\n\tcase 0:\n\t\treturn \"\", fmt.Errorf(\"%s %q not found in local store. Run 'sync' first, or use the UUID directly\", resourceType, input)\n\tcase 1:\n\t\treturn matches[0], nil\n\tdefault:\n\t\thint := matches[0]\n\t\tif len(matches) > 5 {\n\t\t\thint = strings.Join(matches[:5], \", \") + \"...\"\n\t\t} else {\n\t\t\thint = strings.Join(matches, \", \")\n\t\t}\n\t\treturn \"\", fmt.Errorf(\"ambiguous: %q matches %d %s entries (%s). Use the exact UUID instead\", input, len(matches), resourceType, hint)\n\t}\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":31264,"content_sha256":"5bee9d75b11afcfebf776d7b81d5b3ba58162c9daac7320af002b62739e76054"},{"filename":"internal/store/upsert_batch_test.go","content":"// Copyright 2026 rderwin and contributors. Licensed under Apache-2.0. See LICENSE.\n// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.\n\npackage store\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"sync\"\n\t\"testing\"\n\n\t_ \"modernc.org/sqlite\"\n)\n\n// TestStoreWrite_NoSQLITE_BUSY_HighConcurrency exercises the writeMu serialization\n// guarantee: 16 fetcher-style goroutines hammer the store with a mix of\n// UpsertBatch, SaveSyncState, and SaveSyncCursor calls. Before the mutex\n// fix, this test reproduces SQLITE_BUSY at default sync concurrency on\n// pure-Go SQLite (modernc.org/sqlite + WAL) because multiple writers\n// race for the WAL lock and busy_timeout retries are not exhaustive.\n//\n// Run under `go test -race` to catch any data races on Store fields.\nfunc TestStoreWrite_NoSQLITE_BUSY_HighConcurrency(t *testing.T) {\n\tdbPath := filepath.Join(t.TempDir(), \"data.db\")\n\ts, err := Open(dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"open: %v\", err)\n\t}\n\tdefer s.Close()\n\n\tconst goroutines = 16\n\tconst itemsPerBatch = 5\n\n\tvar wg sync.WaitGroup\n\terrCh := make(chan error, goroutines*3)\n\n\tfor g := 0; g \u003c goroutines; g++ {\n\t\twg.Add(1)\n\t\tgo func(gid int) {\n\t\t\tdefer wg.Done()\n\t\t\trt := fmt.Sprintf(\"rt_%d\", gid)\n\t\t\titems := make([]json.RawMessage, 0, itemsPerBatch)\n\t\t\tfor i := 0; i \u003c itemsPerBatch; i++ {\n\t\t\t\titems = append(items, json.RawMessage(fmt.Sprintf(`{\"id\": \"g%d-i%d\"}`, gid, i)))\n\t\t\t}\n\t\t\tif _, _, err := s.UpsertBatch(rt, items); err != nil {\n\t\t\t\terrCh \u003c- fmt.Errorf(\"UpsertBatch goroutine %d: %w\", gid, err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif err := s.SaveSyncState(rt, fmt.Sprintf(\"cursor-%d\", gid), itemsPerBatch); err != nil {\n\t\t\t\terrCh \u003c- fmt.Errorf(\"SaveSyncState goroutine %d: %w\", gid, err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif err := s.SaveSyncCursor(rt, fmt.Sprintf(\"cursor2-%d\", gid)); err != nil {\n\t\t\t\terrCh \u003c- fmt.Errorf(\"SaveSyncCursor goroutine %d: %w\", gid, err)\n\t\t\t\treturn\n\t\t\t}\n\t\t}(g)\n\t}\n\twg.Wait()\n\tclose(errCh)\n\n\tfor err := range errCh {\n\t\tif err == nil {\n\t\t\tcontinue\n\t\t}\n\t\t// SQLITE_BUSY surfaces as \"database is locked\" or \"SQLITE_BUSY\"\n\t\t// in the error message — assert neither occurs.\n\t\tmsg := err.Error()\n\t\tif strings.Contains(msg, \"SQLITE_BUSY\") || strings.Contains(strings.ToLower(msg), \"database is locked\") {\n\t\t\tt.Fatalf(\"got SQLITE_BUSY-class error under concurrent writers: %v\", err)\n\t\t}\n\t\tt.Fatalf(\"unexpected error under concurrent writers: %v\", err)\n\t}\n\n\t// Verify all rows persisted: goroutines * itemsPerBatch in the generic\n\t// resources table.\n\tdb := s.DB()\n\tvar total int\n\tif err := db.QueryRow(`SELECT COUNT(*) FROM resources`).Scan(&total); err != nil {\n\t\tt.Fatalf(\"count resources: %v\", err)\n\t}\n\tif total != goroutines*itemsPerBatch {\n\t\tt.Fatalf(\"resources total = %d, want %d\", total, goroutines*itemsPerBatch)\n\t}\n}\n\n// TestStoreWrite_PanicReleasesLock confirms that a panic inside a locked\n// section unwinds via defer s.writeMu.Unlock() so subsequent writers can\n// proceed. A leaked lock would deadlock the second call indefinitely.\nfunc TestStoreWrite_PanicReleasesLock(t *testing.T) {\n\tdbPath := filepath.Join(t.TempDir(), \"data.db\")\n\ts, err := Open(dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"open: %v\", err)\n\t}\n\tdefer s.Close()\n\n\t// Trigger panic by passing a nil *Store method receiver indirectly:\n\t// we call UpsertBatch with malformed JSON that survives Unmarshal\n\t// (it's wrapped in skipped-count handling) — there's no easy panic\n\t// path inside a locked section that doesn't also corrupt state, so\n\t// we instead simulate the post-panic state by manually locking and\n\t// unlocking, then assert subsequent calls succeed.\n\tfunc() {\n\t\tdefer func() {\n\t\t\trecover()\n\t\t}()\n\t\ts.writeMu.Lock()\n\t\tdefer s.writeMu.Unlock()\n\t\tpanic(\"simulated writer panic\")\n\t}()\n\n\t// Subsequent writer must not block.\n\tdone := make(chan struct{})\n\tgo func() {\n\t\tif _, _, err := s.UpsertBatch(\"post_panic\", []json.RawMessage{json.RawMessage(`{\"id\": \"x\"}`)}); err != nil {\n\t\t\tt.Errorf(\"post-panic UpsertBatch: %v\", err)\n\t\t}\n\t\tclose(done)\n\t}()\n\t\u003c-done\n}\n\n// TestUpsertBatch_TemplatedIDFieldOverrideWins exercises the\n// per-resource ID-field override. When the spec author annotates a\n// path-item with x-resource-id, the profiler emits SyncableResource.IDField,\n// the generator templates this into resourceIDFieldOverrides, and\n// UpsertBatch consults that map first. This test seeds the override map\n// at runtime (since the generated table here may or may not declare any\n// override) to assert the lookup path itself works.\nfunc TestUpsertBatch_TemplatedIDFieldOverrideWins(t *testing.T) {\n\tdbPath := filepath.Join(t.TempDir(), \"data.db\")\n\ts, err := Open(dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"open: %v\", err)\n\t}\n\tdefer s.Close()\n\n\t// Inject a runtime override for a synthetic resource. Item carries\n\t// no generic-fallback field (no id/name/uuid/...) — only a custom\n\t// \"ticker\" field. Without the override, all 3 items would be\n\t// dropped as PK-unresolved; with it, all 3 land.\n\tprev, hadPrev := resourceIDFieldOverrides[\"overrideTest\"]\n\tresourceIDFieldOverrides[\"overrideTest\"] = \"ticker\"\n\tdefer func() {\n\t\tif hadPrev {\n\t\t\tresourceIDFieldOverrides[\"overrideTest\"] = prev\n\t\t} else {\n\t\t\tdelete(resourceIDFieldOverrides, \"overrideTest\")\n\t\t}\n\t}()\n\n\titems := []json.RawMessage{\n\t\tjson.RawMessage(`{\"ticker\": \"AAPL\", \"price\": 100}`),\n\t\tjson.RawMessage(`{\"ticker\": \"GOOG\", \"price\": 200}`),\n\t\tjson.RawMessage(`{\"ticker\": \"MSFT\", \"price\": 300}`),\n\t}\n\tstored, extractFailures, err := s.UpsertBatch(\"overrideTest\", items)\n\tif err != nil {\n\t\tt.Fatalf(\"UpsertBatch: %v\", err)\n\t}\n\tif stored != 3 {\n\t\tt.Fatalf(\"stored = %d, want 3 (templated override should resolve all PKs)\", stored)\n\t}\n\tif extractFailures != 0 {\n\t\tt.Fatalf(\"extractFailures = %d, want 0\", extractFailures)\n\t}\n}\n\n// TestUpsertBatch_GenericFallbackList covers each name in the reduced\n// fallback list. The kalshi-accreted names (ticker/event_ticker/series_ticker)\n// were dropped because the user owns kalshi and will regenerate\n// it with x-resource-id annotations; this test pins what the generic list\n// is now responsible for so a future trim doesn't silently break unannotated\n// specs.\nfunc TestUpsertBatch_GenericFallbackList(t *testing.T) {\n\tdbPath := filepath.Join(t.TempDir(), \"data.db\")\n\ts, err := Open(dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"open: %v\", err)\n\t}\n\tdefer s.Close()\n\n\tfor _, key := range []string{\"id\", \"ID\", \"name\", \"uuid\", \"slug\", \"key\", \"code\", \"uid\"} {\n\t\tt.Run(key, func(t *testing.T) {\n\t\t\trt := \"fallback_\" + key\n\t\t\titems := []json.RawMessage{\n\t\t\t\tjson.RawMessage(fmt.Sprintf(`{%q: %q}`, key, \"value-1\")),\n\t\t\t\tjson.RawMessage(fmt.Sprintf(`{%q: %q}`, key, \"value-2\")),\n\t\t\t}\n\t\t\tstored, extractFailures, err := s.UpsertBatch(rt, items)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"UpsertBatch(%q): %v\", key, err)\n\t\t\t}\n\t\t\tif stored != 2 {\n\t\t\t\tt.Fatalf(\"stored = %d, want 2 (fallback %q must resolve)\", stored, key)\n\t\t\t}\n\t\t\tif extractFailures != 0 {\n\t\t\t\tt.Fatalf(\"extractFailures = %d, want 0\", extractFailures)\n\t\t\t}\n\t\t})\n\t}\n\n\t// Negative: API-specific names dropped must NOT resolve.\n\t// Spec authors annotate these via x-resource-id instead.\n\tfor _, key := range []string{\"ticker\", \"event_ticker\", \"series_ticker\"} {\n\t\tt.Run(\"dropped_\"+key, func(t *testing.T) {\n\t\t\trt := \"dropped_\" + key\n\t\t\titems := []json.RawMessage{\n\t\t\t\tjson.RawMessage(fmt.Sprintf(`{%q: %q}`, key, \"v1\")),\n\t\t\t}\n\t\t\tstored, extractFailures, err := s.UpsertBatch(rt, items)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"UpsertBatch(%q): %v\", key, err)\n\t\t\t}\n\t\t\tif stored != 0 {\n\t\t\t\tt.Fatalf(\"stored = %d, want 0 (%q must NOT be in the generic fallback list)\", stored, key)\n\t\t\t}\n\t\t\tif extractFailures != 1 {\n\t\t\t\tt.Fatalf(\"extractFailures = %d, want 1 (%q drop must surface as extract failure)\", extractFailures, key)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestUpsertBatch_ExtractFailuresReturnedForPerItemMisses pins the third\n// return value: items that survive JSON unmarshal but have no extractable\n// PK (templated override AND generic fallback both miss) bump\n// extractFailures. The sync.go.tmpl call site uses this to emit the\n// per-resource primary_key_unresolved sync_anomaly the first time silent\n// drops occur.\nfunc TestUpsertBatch_ExtractFailuresReturnedForPerItemMisses(t *testing.T) {\n\tdbPath := filepath.Join(t.TempDir(), \"data.db\")\n\ts, err := Open(dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"open: %v\", err)\n\t}\n\tdefer s.Close()\n\n\titems := []json.RawMessage{\n\t\tjson.RawMessage(`{\"id\": \"ok-1\"}`),\n\t\tjson.RawMessage(`{\"some_random_field\": \"no-pk-here\"}`),\n\t\tjson.RawMessage(`{\"id\": \"ok-2\"}`),\n\t\tjson.RawMessage(`{\"another_field\": 42}`),\n\t}\n\tstored, extractFailures, err := s.UpsertBatch(\"mixed_extraction\", items)\n\tif err != nil {\n\t\tt.Fatalf(\"UpsertBatch: %v\", err)\n\t}\n\tif stored != 2 {\n\t\tt.Fatalf(\"stored = %d, want 2 (only items with id should land)\", stored)\n\t}\n\tif extractFailures != 2 {\n\t\tt.Fatalf(\"extractFailures = %d, want 2 (two items have no extractable PK)\", extractFailures)\n\t}\n}\n\n// TestUpsertBatch_PopulatesListingTable verifies that UpsertBatch\n// dispatches paginated items into both the generic resources table AND the\n// typed listing table. Regression for issue #268: before the fix, paginated\n// syncs only filled the generic resources table, so domain commands that\n// query the typed table saw zero rows.\nfunc TestUpsertBatch_PopulatesListingTable(t *testing.T) {\n\tdbPath := filepath.Join(t.TempDir(), \"data.db\")\n\ts, err := Open(dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"open: %v\", err)\n\t}\n\tdefer s.Close()\n\n\titems := []json.RawMessage{\n\t\tjson.RawMessage(`{\"id\": \"test-001\"}`),\n\t\tjson.RawMessage(`{\"id\": \"test-002\"}`),\n\t\tjson.RawMessage(`{\"id\": \"test-003\"}`),\n\t}\n\tif _, _, err := s.UpsertBatch(\"listing\", items); err != nil {\n\t\tt.Fatalf(\"UpsertBatch: %v\", err)\n\t}\n\n\tdb := s.DB()\n\n\tvar generic int\n\tif err := db.QueryRow(`SELECT COUNT(*) FROM resources WHERE resource_type = ?`, \"listing\").Scan(&generic); err != nil {\n\t\tt.Fatalf(\"count resources: %v\", err)\n\t}\n\tif generic != len(items) {\n\t\tt.Fatalf(\"resources count = %d, want %d\", generic, len(items))\n\t}\n\n\tvar typed int\n\ttypedQuery := fmt.Sprintf(`SELECT COUNT(*) FROM \"%s\"`, \"listing\")\n\tif err := db.QueryRow(typedQuery).Scan(&typed); err != nil {\n\t\tt.Fatalf(\"count listing: %v\", err)\n\t}\n\tif typed != len(items) {\n\t\tt.Fatalf(\"listing count = %d, want %d (typed table not populated by UpsertBatch)\", typed, len(items))\n\t}\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":10264,"content_sha256":"214306f36d491113037d3def6de521be9b0b7c9c17eae49571bcd4a3440b0530"},{"filename":"internal/types/types.go","content":"// Copyright 2026 rderwin and contributors. Licensed under Apache-2.0. See LICENSE.\n// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.\n\npackage types\n","content_type":"text/plain; charset=utf-8","language":"go","size":197,"content_sha256":"344d00e36902aa09a509331dee82063af27d2b09490dd3c2247a62b7113f6c0e"},{"filename":"Makefile","content":".PHONY: build test lint install clean\n\nbuild:\n\tgo build -o bin/apartments-pp-cli ./cmd/apartments-pp-cli\n\ntest:\n\tgo test ./...\n\nlint:\n\tgolangci-lint run\n\ninstall:\n\tgo install ./cmd/apartments-pp-cli\n\nclean:\n\trm -rf bin/\n\nbuild-mcp:\n\tgo build -o bin/apartments-pp-mcp ./cmd/apartments-pp-mcp\n\ninstall-mcp:\n\tgo install ./cmd/apartments-pp-mcp\n\nbuild-all: build build-mcp\n","content_type":"text/plain; charset=utf-8","language":"makefile","size":369,"content_sha256":"f66057f37c0126c245da6e7c189831bbaa28acb462cd48441b84b147cbc33762"},{"filename":"manifest.json","content":"{\n \"manifest_version\": \"0.3\",\n \"name\": \"apartments-pp-mcp\",\n \"display_name\": \"Apartments\",\n \"version\": \"3.8.0\",\n \"description\": \"Search Apartments.com rentals from the terminal with offline sync, ranking, diff, and shortlist workflows.\",\n \"author\": {\n \"name\": \"CLI Printing Press\"\n },\n \"license\": \"Apache-2.0\",\n \"server\": {\n \"type\": \"binary\",\n \"entry_point\": \"bin/apartments-pp-mcp\",\n \"mcp_config\": {\n \"command\": \"${__dirname}/bin/apartments-pp-mcp\",\n \"args\": []\n }\n },\n \"compatibility\": {\n \"claude_desktop\": \">=1.0.0\",\n \"platforms\": [\n \"darwin\",\n \"linux\",\n \"win32\"\n ]\n }\n}\n","content_type":"application/json; charset=utf-8","language":"json","size":635,"content_sha256":"2deb780a0b7e5741bbc6c9c5f24b239caa61e3aa8875b77603f00e0013ddc023"},{"filename":"NOTICE","content":"apartments-pp-cli\nCopyright 2026 rderwin and contributors\n\nCreated by rderwin (@rderwin).\n\nThis CLI was generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press)\nby Matt Van Horn and Trevin Chow. The Non-Obvious Insight, domain archetype detection, workflow commands,\nand behavioral insight commands were produced by the printing press's creative vision engine.\n\nCLI Printing Press is licensed separately under the MIT License.\n","content_type":"text/plain; charset=utf-8","language":null,"size":451,"content_sha256":"7ae1d583aaf30b36f63bc53a8882ae965fdd3593af62193f30491b826e11ef42"},{"filename":"README.md","content":"# Apartments.com CLI\n\n**The apartment-hunt CLI that actually works in 2026 — Surf-cleared bot protection plus a local SQLite store the website itself doesn't have.**\n\nSearch every Apartments.com listing path-slug from the terminal, sync results to a local SQLite store, and run the workflows the website never built: diff a saved search week-over-week with `watch`, rank by $/sqft net of pet fees with `value`, compare a shortlist side-by-side with `compare`, and surface price drops or phantom listings with `drops`, `stale`, and `phantoms`. Every command is `--json`/`--select`-shaped so an agent can pipe the output without burning context.\n\nLearn more at [Apartments.com](https://www.apartments.com).\n\nCreated by [@rderwin](https://github.com/rderwin) (rderwin).\n\n## Install\n\nThe recommended path installs both the `apartments-pp-cli` binary and the `pp-apartments` agent skill (Claude Code, Codex, Cursor, Gemini CLI, GitHub Copilot, and other agents supported by the upstream [`skills`](https://github.com/vercel-labs/skills) CLI) in one shot:\n\n```bash\nnpx -y @mvanhorn/printing-press-library install apartments\n```\n\nFor CLI only (no skill):\n\n```bash\nnpx -y @mvanhorn/printing-press-library install apartments --cli-only\n```\n\nFor skill only — installs the skill into the same agents as the default command above, but skips the CLI binary (use this to update or reinstall just the skill):\n\n```bash\nnpx -y @mvanhorn/printing-press-library install apartments --skill-only\n```\n\nTo constrain the skill install to one or more specific agents (repeatable — agent names match the [`skills`](https://github.com/vercel-labs/skills) CLI):\n\n```bash\nnpx -y @mvanhorn/printing-press-library install apartments --agent claude-code\nnpx -y @mvanhorn/printing-press-library install apartments --agent claude-code --agent codex\n```\n\n### Without Node (Go fallback)\n\nIf `npx` isn't available (no Node, offline), install the CLI directly via Go (requires Go 1.26.3 or newer):\n\n```bash\ngo install github.com/mvanhorn/printing-press-library/library/other/apartments/cmd/apartments-pp-cli@latest\n```\n\nThis installs the CLI only — no skill.\n\n### Pre-built binary\n\nDownload a pre-built binary for your platform from the [latest release](https://github.com/mvanhorn/printing-press-library/releases/tag/apartments-current). On macOS, clear the Gatekeeper quarantine: `xattr -d com.apple.quarantine \u003cbinary>`. On Unix, mark it executable: `chmod +x \u003cbinary>`.\n\n\u003c!-- pp-hermes-install-anchor -->\n## Install for Hermes\n\nFrom the Hermes CLI:\n\n```bash\nhermes skills install mvanhorn/printing-press-library/cli-skills/pp-apartments --force\n```\n\nInside a Hermes chat session:\n\n```bash\n/skills install mvanhorn/printing-press-library/cli-skills/pp-apartments --force\n```\n\n## Install for OpenClaw\n\nTell your OpenClaw agent (copy this):\n\n```\nInstall the pp-apartments skill from https://github.com/mvanhorn/printing-press-library/tree/main/cli-skills/pp-apartments. The skill defines how its required CLI can be installed.\n```\n\n## Use with Claude Desktop\n\nThis CLI ships an [MCPB](https://github.com/modelcontextprotocol/mcpb) bundle — Claude Desktop's standard format for one-click MCP extension installs (no JSON config required).\n\nTo install:\n\n1. Download the `.mcpb` for your platform from the [latest release](https://github.com/mvanhorn/printing-press-library/releases/tag/apartments-current).\n2. Double-click the `.mcpb` file. Claude Desktop opens and walks you through the install.\n\nRequires Claude Desktop 1.0.0 or later. Pre-built bundles ship for macOS Apple Silicon (`darwin-arm64`) and Windows (`amd64`, `arm64`); for other platforms, use the manual config below.\n\n\u003cdetails>\n\u003csummary>Manual JSON config (advanced)\u003c/summary>\n\nIf you can't use the MCPB bundle (older Claude Desktop, unsupported platform), install the MCP binary and configure it manually.\n\n```bash\ngo install github.com/mvanhorn/printing-press-library/library/other/apartments/cmd/apartments-pp-mcp@latest\n```\n\nAdd to your Claude Desktop config (`~/Library/Application Support/Claude/claude_desktop_config.json`):\n\n```json\n{\n \"mcpServers\": {\n \"apartments\": {\n \"command\": \"apartments-pp-mcp\"\n }\n }\n}\n```\n\n\u003c/details>\n\n## Authentication\n\nNo authentication required. Apartments.com's anonymous search and listing pages are the entire API surface this CLI uses; saved-search login (cookie session) is intentionally out of scope. Surf with Chrome TLS fingerprint clears the Akamai-style protection at runtime — no clearance cookie capture, no resident browser.\n\n## Quick Start\n\n```bash\n# First search — verifies Surf transport clears protection and JSON output is well-formed.\napartments-pp-cli rentals --city austin --state TX --beds 2 --price-max 2500 --pets dog --json\n\n# Persist that search to the local store under the slug 'austin-2br' so transcendence commands can read it.\napartments-pp-cli sync-search austin-2br --city austin --state TX --beds 2 --price-max 2500 --pets dog\n\n# Rank the synced listings by $/sqft — the ratio metric apartments.com's sort omits.\napartments-pp-cli rank --by sqft --beds 2 --price-max 2500 --json --limit 10\n\n# After a few days, run this — `watch` diffs against the previous sync and emits NEW / REMOVED / PRICE-CHANGED sets.\napartments-pp-cli watch austin-2br --since 7d --json\n\n# The Monday-morning digest: new + removed + price drops + top-by-$/sqft + stale + phantoms in one structured output.\napartments-pp-cli digest --saved-search austin-2br --since 7d --format md\n\n```\n\n## Unique Features\n\nThese capabilities aren't available in any other tool for this API.\n\n### Time-series intelligence\n- **`watch`** — Re-run a stored search and surface what's NEW, REMOVED, or PRICE-CHANGED since the last sync.\n\n _Pick this when an agent is tracking a relocation over time and needs a reproducible 'what changed since last week' digest, not a fresh search._\n\n ```bash\n apartments-pp-cli watch austin-2br --json --since 7d\n ```\n- **`drops`** — List listings whose max-rent dropped by ≥N% within a time window.\n\n _Pick this when timing the market or watching for distressed listings._\n\n ```bash\n apartments-pp-cli drops --since 14d --min-pct 5 --json\n ```\n- **`stale`** — Flag listings whose price and availability haven't changed in N days — often phantom or stuck.\n\n _Pick this when a listing seems too good to be true; stale ones often are._\n\n ```bash\n apartments-pp-cli stale --days 30 --json --select url,maxrent,unchanged_days\n ```\n- **`phantoms`** — Surface listings flagged by a three-signal join: 404 on re-fetch, dropped from saved-search results, or stale ≥45 days.\n\n _Pick this when prepping a shortlist for tour scheduling — phantoms waste tour slots._\n\n ```bash\n apartments-pp-cli phantoms --json\n ```\n- **`history`** — Time-series of every observation of one listing — rent, availability, status.\n\n _Pick this when reasoning about a single listing's price trajectory._\n\n ```bash\n apartments-pp-cli history https://www.apartments.com/example-property-1234 --json\n ```\n\n### Cross-market joins\n- **`nearby`** — Fan out a search across multiple cities, zips, or neighborhoods and return one ranked, deduped list.\n\n _Pick this when an agent needs a single ranked feed across multiple search slugs without writing a fan-out loop._\n\n ```bash\n apartments-pp-cli nearby austin-tx round-rock-tx pflugerville-tx --beds 2 --price-max 2500 --rank sqft --agent\n ```\n\n### Local-store math\n- **`value`** — Rank synced listings by 12-month total cost (rent + pet rent + pet deposit + pet fee), filtered to your hard budget.\n\n _Pick this when budget is binding and pet fees might push a listing over the line._\n\n ```bash\n apartments-pp-cli value --budget 2800 --pet dog --months 12 --json --select rank,url,total_cost\n ```\n- **`rank`** — Rank synced listings by ratio metrics — price per square foot or price per bedroom.\n\n _Pick this when value-per-dollar is the goal, not 'best match' or 'lowest price'._\n\n ```bash\n apartments-pp-cli rank --by sqft --beds 2 --price-max 2500 --json --limit 10\n ```\n- **`floorplans`** — Rank per-floor-plan rent/sqft across synced listings — same building can yield 4 plans at different ratios.\n\n _Pick this when a building has multiple floor plans and you want the cheap one specifically._\n\n ```bash\n apartments-pp-cli floorplans --rank price-per-sqft --beds 2 --json --limit 10\n ```\n- **`must-have`** — Filter synced listings to those whose amenities array contains ALL listed terms via FTS5.\n\n _Pick this when the must-haves are free-text, not in apartments.com's amenity dropdown._\n\n ```bash\n apartments-pp-cli must-have \"in-unit washer\" \"covered parking\" \"dishwasher\" --json\n ```\n\n### Shortlist workflows\n- **`compare`** — Pivot 2–8 listings into a wide table — one column per listing — with computed $/sqft and amenity overlap.\n\n _Pick this when narrowing a shortlist; the wide table makes amenity-overlap deltas obvious._\n\n ```bash\n apartments-pp-cli compare austin-arboretum-1 austin-arboretum-2 austin-arboretum-3 --json\n ```\n- **`digest`** — Single-shot composer: new + removed + price-drops + top-5 by $/sqft + stale + phantom flags for one saved search over N days.\n\n _Pick this when an agent needs a Monday-morning summary in one call._\n\n ```bash\n apartments-pp-cli digest --saved-search austin-2br --since 7d --format md\n ```\n- **`shortlist`** — Tag-based local shortlist table; add/show/remove listings with notes and tags.\n\n _Pick this when an agent or user is curating a shortlist; downstream commands like `compare` read from it._\n\n ```bash\n apartments-pp-cli shortlist add https://www.apartments.com/example-1234 --tag austin --note \"liked the kitchen\"\n ```\n\n### Aggregations\n- **`market`** — Median, p10, p90 of rent and rent/sqft, pet-friendly share, by city/state and bed count.\n\n _Pick this when an agent needs to anchor 'is this a fair price' against the local distribution._\n\n ```bash\n apartments-pp-cli market austin-tx --beds 2 --json\n ```\n\n## Usage\n\nRun `apartments-pp-cli --help` for the full command reference and flag list.\n\n## Commands\n\n### Search & Fetch (live)\n\n| Command | What it does |\n|---|---|\n| `rentals` | Path-slug search by city/state/zip, beds, price, pets, type. Returns parsed placards. |\n| `listing \u003curl-or-id>` | Fetch one detail page; falls back to the most recent placard snapshot when the live fetch is rate-gated. |\n| `nearby \u003cslug...>` | Fan out across multiple city-state slugs; returns one ranked, deduped list. |\n\n### Persist (local SQLite store)\n\n| Command | What it does |\n|---|---|\n| `sync-search \u003cslug>` | Run a saved search and append placards to `listing_snapshots` under the slug. |\n| `sync` | Generic sync helper for the synced-data layer. |\n| `import` / `export` | Round-trip the local store via JSONL/JSON for backup or migration. |\n\n### Time-series & change detection\n\n| Command | What it does |\n|---|---|\n| `watch \u003cslug>` | Diff the latest two syncs of a saved search: NEW / REMOVED / PRICE_CHANGED. |\n| `drops` | Listings whose max-rent dropped by ≥N% within a `--since` window. |\n| `stale` | Listings whose price and availability have not changed in N days. |\n| `phantoms` | Listings flagged by 404 on re-fetch, dropped from saved-search results, or stale ≥`--days`. |\n| `history \u003curl-or-id>` | Time-series of every observation of one listing — rent, availability, status. |\n| `digest` | One-shot composer: new + removed + price drops + top-by-$/sqft + stale + phantoms for one saved search. |\n\n### Ranking & analysis (on synced data)\n\n| Command | What it does |\n|---|---|\n| `rank` | Rank by ratio metrics: `--by sqft\\|bed\\|rent`. |\n| `value` | Rank by 12-month total cost (rent + pet fees), filtered by `--budget`. |\n| `floorplans` | Rank per-floor-plan rent/sqft — same building can yield 4 plans at different ratios. |\n| `market \u003ccity-state>` | Median, p10, p90 of rent and rent/sqft, pet-friendly share, by bed count. |\n| `must-have \u003cterm...>` | Filter to listings whose amenities array contains ALL listed terms. |\n| `compare \u003cid...>` | Pivot 2–8 listings into a wide table — one column per listing — with computed $/sqft and amenity overlap. |\n\n### Shortlist & profiles\n\n| Command | What it does |\n|---|---|\n| `shortlist add\\|show\\|remove` | Tag-based local shortlist with notes; downstream commands like `compare` read from it. |\n| `profile` | Save / list / apply named flag sets for reuse. |\n\n### Utilities\n\n| Command | What it does |\n|---|---|\n| `doctor` | Verify config, transport, and connectivity. |\n| `agent-context` | Emit structured JSON describing this CLI for agents. |\n| `which` | Find the command that implements a capability. |\n| `api` | Browse all API endpoints by interface name. |\n| `workflow` | Compound workflows that combine multiple operations. |\n| `feedback` | Record feedback about this CLI (local by default; upstream opt-in). |\n| `version` | Print version. |\n\n## Cookbook\n\nRecipes use verified flag names and the local store. Run `apartments-pp-cli sync-search \u003cslug> --city \u003ccity> --state \u003cst>` once before any \"synced data\" recipe.\n\n```bash\n# 1. Cheapest 2BRs in Austin under $2,500 with dog policy, ranked by $/sqft.\napartments-pp-cli rentals --city austin --state tx --beds 2 --price-max 2500 --pets dog --json \\\n | jq '.[] | select(.sqft > 0) | {url, rent: .max_rent, sqft, ppsqft: (.max_rent / .sqft)}'\n\n# 2. Persist a saved-search so transcendence commands can read it.\napartments-pp-cli sync-search austin-2br --city austin --state tx --beds 2 --price-max 2500 --pets dog\n\n# 3. Weekly diff on the saved-search — what's new, removed, or repriced since the last sync.\napartments-pp-cli watch austin-2br --since 7d --json\n\n# 4. Monday-morning digest as Markdown for an email or PR description.\napartments-pp-cli digest --saved-search austin-2br --since 7d --format md\n\n# 5. Rank synced listings by 12-month total cost net of pet fees, capped at $2,800/mo.\napartments-pp-cli value --budget 2800 --pet dog --months 12 --json --select rank,url,total_cost\n\n# 6. Ranked $/sqft across multiple metro slugs — one feed.\napartments-pp-cli nearby austin-tx round-rock-tx pflugerville-tx --beds 2 --price-max 2500 --rank sqft --agent\n\n# 7. Surface listings whose price has dropped 10%+ in the last 14 days.\napartments-pp-cli drops --since 14d --min-pct 10 --json --limit 50\n\n# 8. Flag stuck or phantom listings before booking tours.\napartments-pp-cli phantoms --days 45 --json\napartments-pp-cli stale --days 30 --json --limit 25\n\n# 9. Rank floor plans within the same building (same building, different price ratios).\napartments-pp-cli floorplans --rank price-per-sqft --beds 2 --json --limit 10\n\n# 10. Anchor \"is this a fair price\" against the local distribution.\napartments-pp-cli market austin-tx --beds 2 --json\n\n# 11. Filter to listings that have ALL of these amenities (FTS5 intersect).\napartments-pp-cli must-have \"in-unit washer\" \"covered parking\" \"dishwasher\" --json\n\n# 12. Side-by-side compare a shortlist of 2–8 listings.\napartments-pp-cli compare the-domain-austin-tx the-grove-austin-tx austin-arboretum --json\n\n# 13. Build a tagged shortlist as you research.\napartments-pp-cli shortlist add https://www.apartments.com/the-domain-austin-tx/abc123/ --tag favorite --note \"rooftop pool\"\napartments-pp-cli shortlist show --tag favorite --json\n\n# 14. Time-series of one listing's rent and availability.\napartments-pp-cli history https://www.apartments.com/the-domain-austin-tx/abc123/ --json\n\n# 15. Save common output flags as a reusable profile, then apply them to any command.\napartments-pp-cli profile save agent-defaults --json --compact --no-color\napartments-pp-cli rentals --profile agent-defaults --city austin --state tx --beds 2\n```\n\n## Output Formats\n\n```bash\n# Human-readable table (default in terminal, JSON when piped)\napartments-pp-cli listing example-property\n\n# JSON for scripting and agents\napartments-pp-cli listing example-property --json\n\n# Filter to specific fields\napartments-pp-cli listing example-property --json --select id,name,status\n\n# Dry run — show the request without sending\napartments-pp-cli listing example-property --dry-run\n\n# Agent mode — JSON + compact + no prompts in one flag\napartments-pp-cli listing example-property --agent\n```\n\n## Agent Usage\n\nThis CLI is designed for AI agent consumption:\n\n- **Non-interactive** - never prompts, every input is a flag\n- **Pipeable** - `--json` output to stdout, errors to stderr\n- **Filterable** - `--select id,name` returns only fields you need\n- **Previewable** - `--dry-run` shows the request without sending\n- **Read-only by default** - this CLI does not create, update, delete, publish, send, or mutate remote resources\n- **Offline-friendly** - sync/search commands can use the local SQLite store when available\n- **Agent-safe by default** - no colors or formatting unless `--human-friendly` is set\n\nExit codes: `0` success, `2` usage error, `3` not found, `5` API error, `7` rate limited, `10` config error.\n\n## Freshness\n\nThis CLI owns bounded freshness for registered store-backed read command paths. In `--data-source auto` mode, covered commands check the local SQLite store before serving results; stale or missing resources trigger a bounded refresh, and refresh failures fall back to the existing local data with a warning. `--data-source local` never refreshes, and `--data-source live` reads the API without mutating the local store.\n\nSet `APARTMENTS_NO_AUTO_REFRESH=1` to disable the pre-read freshness hook while preserving the selected data source.\n\nJSON outputs that use the generated provenance envelope include freshness metadata at `meta.freshness`. This metadata describes the freshness decision for the covered command path; it does not claim full historical backfill or API-specific enrichment.\n\n## Health Check\n\n```bash\napartments-pp-cli doctor\n```\n\nVerifies configuration and connectivity to the API.\n\n## Configuration\n\nConfig file: `~/.config/apartments-pp-cli/config.toml`\n\n## Troubleshooting\n**Not found errors (exit code 3)**\n- Check the resource ID is correct\n- Run the `list` command to see available items\n\n### API-specific\n\n- **search returns 0 listings or HTTP 403** — Run `apartments-pp-cli doctor` to verify Surf transport is active. If 403 persists, the Chrome TLS fingerprint may need refresh — file an issue with the doctor output.\n- **watch reports no changes when you know listings changed** — Confirm a previous `sync` exists: `apartments-pp-cli sql \"SELECT MAX(observed_at) FROM listing_snapshots WHERE saved_search = 'austin-2br'\"`. If empty, run sync first.\n- **value or rank command returns empty** — These read from the local store. Run `sync` first. Check with `apartments-pp-cli sql \"SELECT COUNT(*) FROM listings\"`.\n- **amenity must-have intersect returns no rows** — FTS5 needs amenities populated; some listings have empty amenity arrays. Try `apartments-pp-cli sql \"SELECT url, length(amenities) FROM listings ORDER BY length(amenities) DESC LIMIT 5\"` to confirm.\n- **rate limited or repeated 429s during sync** — Sync uses adaptive pacing automatically. If you hit a wall, pause for 30 seconds and re-run; cliutil.AdaptiveLimiter will back off.\n\n## Known Gaps\n\n- **`listing \u003curl>` live fetch is rate-gated.** Apartments.com applies stricter Akamai bot protection on individual listing detail pages (`/\u003cproperty-slug>/`) than on search-results pages (`/\u003ccity-state>/`). Surf with Chrome TLS fingerprint clears the search pages reliably (`probe-reachability` reports `mode: browser_http`), but most listing detail pages return 403 even via Surf (`mode: browser_clearance_http` — would require clearance-cookie import or full browser). The `listing` command **falls back to the most-recent snapshot from `rentals` / `sync-search`** when the live fetch returns 403, so placard data (URL, beds, baths, max rent, search-slug provenance) remains available. Detail-only fields (`amenities`, `floor_plans`, `pet_policy.fees`, `available_at`, `phone`) require either a future clearance-cookie path or a manual HAR; they are populated only for listings whose live fetch succeeds.\n- **Saved-search login (cookie session) is intentionally out of scope for v1.** All shipped commands work anonymously.\n\n## HTTP Transport\n\nThis CLI uses Chrome-compatible HTTP transport for browser-facing endpoints. It does not require a resident browser process for normal API calls.\n\n## Discovery Signals\n\nThis CLI was generated with browser-captured traffic analysis.\n- Target observed: https://www.apartments.com\n- Capture coverage: 0 API entries from 0 total network entries\n- Reachability: browser_http (85% confidence)\n- Protocols: html-ssr (95% confidence)\n- Protection signals: akamai-bot-manager (85% confidence)\n- Generation hints: use Surf with Chrome TLS fingerprint at runtime (UsesBrowserHTTPTransport), all responses are HTML/SSR — extract via html_extract mode: page, no clearance cookie capture; no resident browser sidecar, schema.org microdata (meta itemprop=streetAddress|addressLocality|addressRegion|postalCode) plus data-beds / data-baths / data-maxrent attributes are the primary extraction targets\n- Candidate command ideas: search — Path-slug search is the primary entry point at apartments.com; get — Listing detail page extracts schema.org microdata\n\nWarnings from discovery:\n- protection-active: Apartments.com (CoStar) employs Akamai-style bot detection. stdlib HTTP returns 403; Surf with Chrome TLS fingerprint clears it. Watch for protection escalation that might require Chrome-clearance cookie import or full-browser fallback in future versions.\n\n---\n\n## Sources & Inspiration\n\nThis CLI was built by studying these projects and resources:\n\n- [**johnludwigm/PyApartments**](https://github.com/johnludwigm/PyApartments) — Python (12 stars)\n- [**adinutzyc21/apartments-scraper**](https://github.com/adinutzyc21/apartments-scraper) — Python\n- [**shilongdai/Apartment_Scraper**](https://github.com/shilongdai/Apartment_Scraper) — Python\n- [**cccdenhart/apartments-scraper**](https://github.com/cccdenhart/apartments-scraper) — Python\n- [**davidhuang620/Apartments.com-web-Scrapping**](https://github.com/davidhuang620/Apartments.com-web-Scrapping) — Python\n\nGenerated by [CLI Printing Press](https://github.com/mvanhorn/cli-printing-press)\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":22271,"content_sha256":"0ab33c02a6c6e34f93ea10e6c316d82356b8471042f908e2306ffbd2810a9569"},{"filename":"spec.yaml","content":"name: apartments\ndescription: Search Apartments.com listings, sync results to a local SQLite store, and run workflows the website never built — diff saved searches, rank by $/sqft, compare shortlists, and surface price drops or phantom listings.\ncli_description: \"Search Apartments.com rentals from the terminal with offline sync, ranking, diff, and shortlist workflows.\"\nversion: \"0.1.0\"\nkind: synthetic\nspec_source: sniffed\nhttp_transport: browser-chrome\nwebsite_url: \"https://www.apartments.com\"\ncategory: other\nbase_url: \"https://www.apartments.com\"\n\nauth:\n type: none\n\nconfig:\n format: toml\n path: \"~/.config/apartments-pp-cli/config.toml\"\n\ncache:\n enabled: true\n\nresources:\n rentals:\n description: \"Search Apartments.com rental listings by city, beds, baths, price, and pet policy. Returns parsed listing placards.\"\n endpoints:\n find:\n method: GET\n path: \"/{city}-{state}/\"\n description: \"Run a path-slug search at apartments.com and return listing placards parsed from the HTML response.\"\n params:\n - name: city\n type: string\n required: true\n positional: false\n path_param: true\n description: \"City slug (lowercase, hyphenated). Example: austin, new-york, san-francisco.\"\n default: \"austin\"\n - name: state\n type: string\n required: true\n positional: false\n path_param: true\n description: \"Two-letter state abbreviation (lowercase). Example: tx, ny, ca.\"\n enum: [al, ak, az, ar, ca, co, ct, de, fl, ga, hi, id, il, in, ia, ks, ky, la, me, md, ma, mi, mn, ms, mo, mt, ne, nv, nh, nj, nm, ny, nc, nd, oh, ok, or, pa, ri, sc, sd, tn, tx, ut, vt, va, wa, wv, wi, wy, dc]\n default: \"tx\"\n response:\n type: text\n item: string\n response_format: html\n html_extract:\n mode: page\n listing:\n description: \"Fetch a single Apartments.com listing detail page by URL or property ID, parsing rent, beds/baths, address, amenities, and pet policy.\"\n endpoints:\n get:\n method: GET\n path: \"/{property_id}/\"\n description: \"Fetch one Apartments.com listing detail page and parse schema.org microdata.\"\n params:\n - name: property_id\n type: string\n required: true\n positional: true\n path_param: true\n description: \"Apartments.com listing URL slug (e.g. 'the-domain-austin-tx-1234').\"\n default: \"example-property\"\n response:\n type: text\n item: string\n response_format: html\n html_extract:\n mode: page\n\nextra_commands:\n - name: search\n description: \"Search Apartments.com listings by city, state, beds, baths, price, pets, and property type.\"\n - name: get\n args: \"\u003curl-or-id>\"\n description: \"Fetch a single listing detail page (rent, beds/baths, amenities, pet policy).\"\n - name: sync\n args: \"\u003csaved-search>\"\n description: \"Run a saved search against apartments.com and snapshot placards into the local store.\"\n - name: watch\n args: \"\u003csaved-search>\"\n description: \"Diff the latest sync of a saved search against the previous; emit NEW / REMOVED / PRICE-CHANGED listings.\"\n - name: nearby\n args: \"\u003cslug...>\"\n description: \"Fan out a search across multiple city/zip/neighborhood slugs and return one ranked, deduped list.\"\n - name: value\n description: \"Rank synced listings by 12-month total cost of occupancy (rent + pet rent + pet deposit + pet fee).\"\n - name: rank\n description: \"Rank synced listings by ratio metrics — $/sqft or $/bed.\"\n - name: compare\n args: \"\u003curl-or-id...>\"\n description: \"Pivot 2–8 listings into a wide table — one column per listing — with computed $/sqft and amenity overlap.\"\n - name: drops\n description: \"List listings whose max-rent dropped by ≥N% within a time window.\"\n - name: stale\n description: \"Flag listings whose price and availability have not changed in N days — phantom or stuck signal.\"\n - name: phantoms\n description: \"Surface listings flagged by a three-signal union: 404 on re-fetch, dropped from saved-search results, or stale ≥45 days.\"\n - name: market\n args: \"\u003ccity-state>\"\n description: \"Aggregate synced listings: median, p10, p90 of rent and rent/sqft, plus pet-friendly share.\"\n - name: history\n args: \"\u003curl-or-id>\"\n description: \"Time-series of every observation of one listing — rent, availability, status.\"\n - name: digest\n description: \"Weekly digest composer: new + removed + price-drops + top-by-sqft + stale + phantoms in one structured output.\"\n - name: floorplans\n description: \"Rank per-floor-plan rent/sqft across synced listings.\"\n - name: must-have\n args: \"\u003cterm...>\"\n description: \"Filter synced listings to those whose amenities array contains ALL listed terms via FTS5.\"\n - name: shortlist\n description: \"Local shortlist table — add / show / remove listings with notes and tags.\"\n","content_type":"application/yaml; charset=utf-8","language":"yaml","size":5052,"content_sha256":"b9a554bf8d6f2b53d86037a9df8e29e3b04af01b0bd83a4f51599b6c5c2e12cf"},{"filename":"tools-manifest.json","content":"{\n \"api_name\": \"apartments\",\n \"base_url\": \"https://www.apartments.com\",\n \"description\": \"Search Apartments.com listings, sync results to a local SQLite store, and run workflows the website never built — diff saved searches, rank by $/sqft, compare shortlists, and surface price drops or phantom listings.\",\n \"mcp_ready\": \"full\",\n \"http_transport\": \"browser-chrome\",\n \"auth\": {\n \"type\": \"none\"\n },\n \"required_headers\": [],\n \"tools\": [\n {\n \"name\": \"listing_get\",\n \"description\": \"Fetch one Apartments.com listing detail page and parse schema.org microdata. Required: property_id (default: example-property).\",\n \"method\": \"GET\",\n \"path\": \"/{property_id}/\",\n \"params\": [\n {\n \"name\": \"property_id\",\n \"type\": \"string\",\n \"location\": \"path\",\n \"description\": \"Apartments.com listing URL slug (e.g. 'the-domain-austin-tx-1234').\",\n \"required\": true\n }\n ]\n },\n {\n \"name\": \"rentals_find\",\n \"description\": \"Run a path-slug search at apartments.com and return listing placards parsed from the HTML response. Required: city (default: austin), state (default: tx).\",\n \"method\": \"GET\",\n \"path\": \"/{city}-{state}/\",\n \"params\": [\n {\n \"name\": \"city\",\n \"type\": \"string\",\n \"location\": \"path\",\n \"description\": \"City slug (lowercase, hyphenated). Example: austin, new-york, san-francisco.\",\n \"required\": true\n },\n {\n \"name\": \"state\",\n \"type\": \"string\",\n \"location\": \"path\",\n \"description\": \"Two-letter state abbreviation (lowercase). Example: tx, ny, ca.\",\n \"required\": true\n }\n ]\n }\n ]\n}\n","content_type":"application/json; charset=utf-8","language":"json","size":1729,"content_sha256":"eb18e5b17daf359e29dec4776863cba7df5862f4914454aee137585c275e8c2f"},{"filename":"workflow-verify-report.json","content":"{\n \"dir\": \"/Users/toriniku/printing-press/library/apartments\",\n \"workflows\": null,\n \"verdict\": \"workflow-pass\",\n \"issues\": [\n \"no workflow manifest found, skipping\"\n ]\n}","content_type":"application/json; charset=utf-8","language":"json","size":177,"content_sha256":"5ceb02b3249dc208b07edffaa5260c5f15f52fa0ccac83321c87265c4720950c"}],"content_json":{"type":"doc","content":[{"type":"heading","attrs":{"level":1},"content":[{"text":"Apartments.com — Printing Press CLI","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Prerequisites: Install the CLI","type":"text"}]},{"type":"paragraph","content":[{"text":"This skill drives the ","type":"text"},{"text":"apartments-pp-cli","type":"text","marks":[{"type":"code_inline"}]},{"text":" binary. ","type":"text"},{"text":"You must verify the CLI is installed before invoking any command from this skill.","type":"text","marks":[{"type":"strong"}]},{"text":" If it is missing, install it first:","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Install via the Printing Press installer:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"npx -y @mvanhorn/printing-press-library install apartments --cli-only","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Verify: ","type":"text"},{"text":"apartments-pp-cli --version","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Ensure ","type":"text"},{"text":"$GOPATH/bin","type":"text","marks":[{"type":"code_inline"}]},{"text":" (or ","type":"text"},{"text":"$HOME/go/bin","type":"text","marks":[{"type":"code_inline"}]},{"text":") is on ","type":"text"},{"text":"$PATH","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"If the ","type":"text"},{"text":"npx","type":"text","marks":[{"type":"code_inline"}]},{"text":" install fails (no Node, offline, etc.), fall back to a direct Go install (requires Go 1.26.3 or newer):","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"go install github.com/mvanhorn/printing-press-library/library/other/apartments/cmd/apartments-pp-cli@latest","type":"text"}]},{"type":"paragraph","content":[{"text":"If ","type":"text"},{"text":"--version","type":"text","marks":[{"type":"code_inline"}]},{"text":" reports \"command not found\" after install, the install step did not put the binary on ","type":"text"},{"text":"$PATH","type":"text","marks":[{"type":"code_inline"}]},{"text":". Do not proceed with skill commands until verification succeeds.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"When to Use This CLI","type":"text"}]},{"type":"paragraph","content":[{"text":"Use this CLI when an agent or human needs structured Apartments.com data and needs the workflows the website itself doesn't expose: cross-search diffs over time, $/sqft and total-cost-of-occupancy rankings, multi-slug union queries, side-by-side comparison, and digest-style summaries. Reach for it for relocation tracking, value-per-dollar screens across shortlists, leasing-agent weekly digests, and any rental-search scenario that needs JSON + offline composition. Skip it for one-off browsing — the apartments.com website is fine for that.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"When Not to Use This CLI","type":"text"}]},{"type":"paragraph","content":[{"text":"Do not activate this CLI for requests that require creating, updating, deleting, publishing, commenting, upvoting, inviting, ordering, sending messages, booking, purchasing, or changing remote state. This printed CLI exposes read-only commands for inspection, export, sync, and analysis.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Unique Capabilities","type":"text"}]},{"type":"paragraph","content":[{"text":"These capabilities aren't available in any other tool for this API.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Time-series intelligence","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"watch","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" — Re-run a stored search and surface what's NEW, REMOVED, or PRICE-CHANGED since the last sync.","type":"text"}]},{"type":"paragraph","content":[{"text":"Pick this when an agent is tracking a relocation over time and needs a reproducible 'what changed since last week' digest, not a fresh search.","type":"text","marks":[{"type":"em"}]}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"apartments-pp-cli watch austin-2br --json --since 7d","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"drops","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" — List listings whose max-rent dropped by ≥N% within a time window.","type":"text"}]},{"type":"paragraph","content":[{"text":"Pick this when timing the market or watching for distressed listings.","type":"text","marks":[{"type":"em"}]}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"apartments-pp-cli drops --since 14d --min-pct 5 --json","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"stale","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" — Flag listings whose price and availability haven't changed in N days — often phantom or stuck.","type":"text"}]},{"type":"paragraph","content":[{"text":"Pick this when a listing seems too good to be true; stale ones often are.","type":"text","marks":[{"type":"em"}]}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"apartments-pp-cli stale --days 30 --json --select url,maxrent,unchanged_days","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"phantoms","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" — Surface listings flagged by a three-signal join: 404 on re-fetch, dropped from saved-search results, or stale ≥45 days.","type":"text"}]},{"type":"paragraph","content":[{"text":"Pick this when prepping a shortlist for tour scheduling — phantoms waste tour slots.","type":"text","marks":[{"type":"em"}]}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"apartments-pp-cli phantoms --json","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"history","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" — Time-series of every observation of one listing — rent, availability, status.","type":"text"}]},{"type":"paragraph","content":[{"text":"Pick this when reasoning about a single listing's price trajectory.","type":"text","marks":[{"type":"em"}]}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"apartments-pp-cli history https://www.apartments.com/example-property-1234 --json","type":"text"}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Cross-market joins","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"nearby","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" — Fan out a search across multiple cities, zips, or neighborhoods and return one ranked, deduped list.","type":"text"}]},{"type":"paragraph","content":[{"text":"Pick this when an agent needs a single ranked feed across multiple search slugs without writing a fan-out loop.","type":"text","marks":[{"type":"em"}]}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"apartments-pp-cli nearby austin-tx round-rock-tx pflugerville-tx --beds 2 --price-max 2500 --rank sqft --agent","type":"text"}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Local-store math","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"value","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" — Rank synced listings by 12-month total cost (rent + pet rent + pet deposit + pet fee), filtered to your hard budget.","type":"text"}]},{"type":"paragraph","content":[{"text":"Pick this when budget is binding and pet fees might push a listing over the line.","type":"text","marks":[{"type":"em"}]}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"apartments-pp-cli value --budget 2800 --pet dog --months 12 --json --select rank,url,total_cost","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"rank","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" — Rank synced listings by ratio metrics — price per square foot or price per bedroom.","type":"text"}]},{"type":"paragraph","content":[{"text":"Pick this when value-per-dollar is the goal, not 'best match' or 'lowest price'.","type":"text","marks":[{"type":"em"}]}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"apartments-pp-cli rank --by sqft --beds 2 --price-max 2500 --json --limit 10","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"floorplans","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" — Rank per-floor-plan rent/sqft across synced listings — same building can yield 4 plans at different ratios.","type":"text"}]},{"type":"paragraph","content":[{"text":"Pick this when a building has multiple floor plans and you want the cheap one specifically.","type":"text","marks":[{"type":"em"}]}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"apartments-pp-cli floorplans --rank price-per-sqft --beds 2 --json --limit 10","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"must-have","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" — Filter synced listings to those whose amenities array contains ALL listed terms via FTS5.","type":"text"}]},{"type":"paragraph","content":[{"text":"Pick this when the must-haves are free-text, not in apartments.com's amenity dropdown.","type":"text","marks":[{"type":"em"}]}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"apartments-pp-cli must-have \"in-unit washer\" \"covered parking\" \"dishwasher\" --json","type":"text"}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Shortlist workflows","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"compare","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" — Pivot 2–8 listings into a wide table — one column per listing — with computed $/sqft and amenity overlap.","type":"text"}]},{"type":"paragraph","content":[{"text":"Pick this when narrowing a shortlist; the wide table makes amenity-overlap deltas obvious.","type":"text","marks":[{"type":"em"}]}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"apartments-pp-cli compare austin-arboretum-1 austin-arboretum-2 austin-arboretum-3 --json","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"digest","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" — Single-shot composer: new + removed + price-drops + top-5 by $/sqft + stale + phantom flags for one saved search over N days.","type":"text"}]},{"type":"paragraph","content":[{"text":"Pick this when an agent needs a Monday-morning summary in one call.","type":"text","marks":[{"type":"em"}]}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"apartments-pp-cli digest --saved-search austin-2br --since 7d --format md","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"shortlist","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" — Tag-based local shortlist table; add/show/remove listings with notes and tags.","type":"text"}]},{"type":"paragraph","content":[{"text":"Pick this when an agent or user is curating a shortlist; downstream commands like ","type":"text","marks":[{"type":"em"}]},{"text":"compare","type":"text","marks":[{"type":"code_inline"},{"type":"em"}]},{"text":" read from it.","type":"text","marks":[{"type":"em"}]}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"apartments-pp-cli shortlist add https://www.apartments.com/example-1234 --tag austin --note \"liked the kitchen\"","type":"text"}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Aggregations","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"market","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" — Median, p10, p90 of rent and rent/sqft, pet-friendly share, by city/state and bed count.","type":"text"}]},{"type":"paragraph","content":[{"text":"Pick this when an agent needs to anchor 'is this a fair price' against the local distribution.","type":"text","marks":[{"type":"em"}]}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"apartments-pp-cli market austin-tx --beds 2 --json","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"HTTP Transport","type":"text"}]},{"type":"paragraph","content":[{"text":"This CLI uses Chrome-compatible HTTP transport for browser-facing endpoints. It does not require a resident browser process for normal API calls.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Discovery Signals","type":"text"}]},{"type":"paragraph","content":[{"text":"This CLI was generated with browser-observed traffic context.","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Capture coverage: 0 API entries from 0 total network entries","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Protocols: html-ssr (95% confidence)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Generation hints: use Surf with Chrome TLS fingerprint at runtime (UsesBrowserHTTPTransport), all responses are HTML/SSR — extract via html_extract mode: page, no clearance cookie capture; no resident browser sidecar, schema.org microdata (meta itemprop=streetAddress|addressLocality|addressRegion|postalCode) plus data-beds / data-baths / data-maxrent attributes are the primary extraction targets","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Candidate command ideas: rentals — Path-slug search is the primary entry point at apartments.com; listing — Listing detail page extracts schema.org microdata","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Caveats: protection-active: Apartments.com (CoStar) employs Akamai-style bot detection. stdlib HTTP returns 403; Surf with Chrome TLS fingerprint clears it. Watch for protection escalation that might require Chrome-clearance cookie import or full-browser fallback in future versions.","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Command Reference","type":"text"}]},{"type":"paragraph","content":[{"text":"listing","type":"text","marks":[{"type":"strong"}]},{"text":" — Fetch a single Apartments.com listing detail page by URL or property ID, parsing rent, beds/baths, address, amenities, and pet policy.","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"apartments-pp-cli listing \u003cproperty_id>","type":"text","marks":[{"type":"code_inline"}]},{"text":" — Fetch one Apartments.com listing detail page and parse schema.org microdata.","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"rentals","type":"text","marks":[{"type":"strong"}]},{"text":" — Search Apartments.com rental listings by city, beds, baths, price, and pet policy. Returns parsed listing placards.","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"apartments-pp-cli rentals","type":"text","marks":[{"type":"code_inline"}]},{"text":" — Run a path-slug search at apartments.com and return listing placards parsed from the HTML response.","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Hand-written commands","type":"text","marks":[{"type":"strong"}]}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"apartments-pp-cli sync-search \u003csaved-search>","type":"text","marks":[{"type":"code_inline"}]},{"text":" — Run a saved search against apartments.com and snapshot placards into the local store.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"apartments-pp-cli watch \u003csaved-search>","type":"text","marks":[{"type":"code_inline"}]},{"text":" — Diff the latest sync of a saved search against the previous; emit NEW / REMOVED / PRICE-CHANGED listings.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"apartments-pp-cli nearby \u003cslug...>","type":"text","marks":[{"type":"code_inline"}]},{"text":" — Fan out a search across multiple city/zip/neighborhood slugs and return one ranked, deduped list.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"apartments-pp-cli value","type":"text","marks":[{"type":"code_inline"}]},{"text":" — Rank synced listings by 12-month total cost of occupancy (rent + pet rent + pet deposit + pet fee).","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"apartments-pp-cli rank","type":"text","marks":[{"type":"code_inline"}]},{"text":" — Rank synced listings by ratio metrics — $/sqft or $/bed.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"apartments-pp-cli compare \u003curl-or-id...>","type":"text","marks":[{"type":"code_inline"}]},{"text":" — Pivot 2–8 listings into a wide table — one column per listing — with computed $/sqft and amenity overlap.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"apartments-pp-cli drops","type":"text","marks":[{"type":"code_inline"}]},{"text":" — List listings whose max-rent dropped by ≥N% within a time window.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"apartments-pp-cli stale","type":"text","marks":[{"type":"code_inline"}]},{"text":" — Flag listings whose price and availability have not changed in N days — phantom or stuck signal.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"apartments-pp-cli phantoms","type":"text","marks":[{"type":"code_inline"}]},{"text":" — Surface listings flagged by a three-signal union: 404 on re-fetch, dropped from saved-search results, or stale ≥45...","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"apartments-pp-cli market \u003ccity-state>","type":"text","marks":[{"type":"code_inline"}]},{"text":" — Aggregate synced listings: median, p10, p90 of rent and rent/sqft, plus pet-friendly share.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"apartments-pp-cli history \u003curl-or-id>","type":"text","marks":[{"type":"code_inline"}]},{"text":" — Time-series of every observation of one listing — rent, availability, status.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"apartments-pp-cli digest","type":"text","marks":[{"type":"code_inline"}]},{"text":" — Weekly digest composer: new + removed + price-drops + top-by-sqft + stale + phantoms in one structured output.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"apartments-pp-cli floorplans","type":"text","marks":[{"type":"code_inline"}]},{"text":" — Rank per-floor-plan rent/sqft across synced listings.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"apartments-pp-cli must-have \u003cterm...>","type":"text","marks":[{"type":"code_inline"}]},{"text":" — Filter synced listings to those whose amenities array contains ALL listed terms via FTS5.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"apartments-pp-cli shortlist","type":"text","marks":[{"type":"code_inline"}]},{"text":" — Local shortlist table — add / show / remove listings with notes and tags.","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Freshness Contract","type":"text"}]},{"type":"paragraph","content":[{"text":"This printed CLI owns bounded freshness only for registered store-backed read command paths. In ","type":"text"},{"text":"--data-source auto","type":"text","marks":[{"type":"code_inline"}]},{"text":" mode, those paths check ","type":"text"},{"text":"sync_state","type":"text","marks":[{"type":"code_inline"}]},{"text":" and may run a bounded refresh before reading local data. ","type":"text"},{"text":"--data-source local","type":"text","marks":[{"type":"code_inline"}]},{"text":" never refreshes. ","type":"text"},{"text":"--data-source live","type":"text","marks":[{"type":"code_inline"}]},{"text":" reads the API and does not mutate the local store. Set ","type":"text"},{"text":"APARTMENTS_NO_AUTO_REFRESH=1","type":"text","marks":[{"type":"code_inline"}]},{"text":" to skip the freshness hook without changing source selection.","type":"text"}]},{"type":"paragraph","content":[{"text":"When JSON output uses the generated provenance envelope, freshness metadata appears at ","type":"text"},{"text":"meta.freshness","type":"text","marks":[{"type":"code_inline"}]},{"text":". Treat it as current-cache freshness for the covered command path, not a guarantee of complete historical backfill or API-specific enrichment.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Finding the right command","type":"text"}]},{"type":"paragraph","content":[{"text":"When you know what you want to do but not which command does it, ask the CLI directly:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"apartments-pp-cli which \"\u003ccapability in your own words>\"","type":"text"}]},{"type":"paragraph","content":[{"text":"which","type":"text","marks":[{"type":"code_inline"}]},{"text":" resolves a natural-language capability query to the best matching command from this CLI's curated feature index. Exit code ","type":"text"},{"text":"0","type":"text","marks":[{"type":"code_inline"}]},{"text":" means at least one match; exit code ","type":"text"},{"text":"2","type":"text","marks":[{"type":"code_inline"}]},{"text":" means no confident match — fall back to ","type":"text"},{"text":"--help","type":"text","marks":[{"type":"code_inline"}]},{"text":" or use a narrower query.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Recipes","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Three-neighborhood relocation hunt","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"apartments-pp-cli nearby austin-tx round-rock-tx pflugerville-tx --beds 2 --price-max 2500 --pets dog --rank sqft --json --select url,addressLocality,maxrent,sqft,price_per_sqft","type":"text"}]},{"type":"paragraph","content":[{"text":"Single ranked feed across three target neighborhoods, deduped by listing URL, with ","type":"text"},{"text":"--select","type":"text","marks":[{"type":"code_inline"}]},{"text":" keeping only the columns the agent needs.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Weekly leasing-agent digest","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"apartments-pp-cli digest --saved-search client-rachel --since 7d --format md","type":"text"}]},{"type":"paragraph","content":[{"text":"Composes new / removed / price-drops / top-5-by-sqft / stale / phantom flags for a single client search into a markdown report ready to paste into email.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Total-cost screen with hard budget","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"apartments-pp-cli value --budget 2800 --pet dog --months 12 --json --select rank,url,maxrent,pet_rent,total_cost,price_per_sqft","type":"text"}]},{"type":"paragraph","content":[{"text":"Ranks the synced listings by 12-month total cost (rent + pet rent + pet deposit + pet fee), filtered to under-budget rows only.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Phantom-detection sweep before tour scheduling","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"apartments-pp-cli phantoms --json --select url,reason,unchanged_days","type":"text"}]},{"type":"paragraph","content":[{"text":"Three-signal union — 404, dropped from saved search, stale ≥45 days. Filter your shortlist before booking tours so you don't drive across town to a leased unit.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Offline FTS amenity intersect","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"apartments-pp-cli must-have \"in-unit washer\" \"covered parking\" \"dishwasher\" --json --select url,addressLocality,maxrent,amenities","type":"text"}]},{"type":"paragraph","content":[{"text":"Path filter for amenities apartments.com's filter dropdown doesn't expose. FTS5 AND-join over the synced amenities array.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Auth Setup","type":"text"}]},{"type":"paragraph","content":[{"text":"No authentication required.","type":"text"}]},{"type":"paragraph","content":[{"text":"Run ","type":"text"},{"text":"apartments-pp-cli doctor","type":"text","marks":[{"type":"code_inline"}]},{"text":" to verify setup.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Agent Mode","type":"text"}]},{"type":"paragraph","content":[{"text":"Add ","type":"text"},{"text":"--agent","type":"text","marks":[{"type":"code_inline"}]},{"text":" to any command. Expands to: ","type":"text"},{"text":"--json --compact --no-input --no-color --yes","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Pipeable","type":"text","marks":[{"type":"strong"}]},{"text":" — JSON on stdout, errors on stderr","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Filterable","type":"text","marks":[{"type":"strong"}]},{"text":" — ","type":"text"},{"text":"--select","type":"text","marks":[{"type":"code_inline"}]},{"text":" keeps a subset of fields. Dotted paths descend into nested structures; arrays traverse element-wise. Critical for keeping context small on verbose APIs:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"apartments-pp-cli listing example-property --agent --select id,name,status","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Previewable","type":"text","marks":[{"type":"strong"}]},{"text":" — ","type":"text"},{"text":"--dry-run","type":"text","marks":[{"type":"code_inline"}]},{"text":" shows the request without sending","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Offline-friendly","type":"text","marks":[{"type":"strong"}]},{"text":" — sync/search commands can use the local SQLite store when available","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Non-interactive","type":"text","marks":[{"type":"strong"}]},{"text":" — never prompts, every input is a flag","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Read-only","type":"text","marks":[{"type":"strong"}]},{"text":" — do not use this CLI for create, update, delete, publish, comment, upvote, invite, order, send, or other mutating requests","type":"text"}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Response envelope","type":"text"}]},{"type":"paragraph","content":[{"text":"Commands that read from the local store or the API wrap output in a provenance envelope:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"json"},"content":[{"text":"{\n \"meta\": {\"source\": \"live\" | \"local\", \"synced_at\": \"...\", \"reason\": \"...\"},\n \"results\": \u003cdata>\n}","type":"text"}]},{"type":"paragraph","content":[{"text":"Parse ","type":"text"},{"text":".results","type":"text","marks":[{"type":"code_inline"}]},{"text":" for data and ","type":"text"},{"text":".meta.source","type":"text","marks":[{"type":"code_inline"}]},{"text":" to know whether it's live or local. A human-readable ","type":"text"},{"text":"N results (live)","type":"text","marks":[{"type":"code_inline"}]},{"text":" summary is printed to stderr only when stdout is a terminal — piped/agent consumers get pure JSON on stdout.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Agent Feedback","type":"text"}]},{"type":"paragraph","content":[{"text":"When you (or the agent) notice something off about this CLI, record it:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":""},"content":[{"text":"apartments-pp-cli feedback \"the --since flag is inclusive but docs say exclusive\"\napartments-pp-cli feedback --stdin \u003c notes.txt\napartments-pp-cli feedback list --json --limit 10","type":"text"}]},{"type":"paragraph","content":[{"text":"Entries are stored locally at ","type":"text"},{"text":"~/.apartments-pp-cli/feedback.jsonl","type":"text","marks":[{"type":"code_inline"}]},{"text":". They are never POSTed unless ","type":"text"},{"text":"APARTMENTS_FEEDBACK_ENDPOINT","type":"text","marks":[{"type":"code_inline"}]},{"text":" is set AND either ","type":"text"},{"text":"--send","type":"text","marks":[{"type":"code_inline"}]},{"text":" is passed or ","type":"text"},{"text":"APARTMENTS_FEEDBACK_AUTO_SEND=true","type":"text","marks":[{"type":"code_inline"}]},{"text":". Default behavior is local-only.","type":"text"}]},{"type":"paragraph","content":[{"text":"Write what ","type":"text"},{"text":"surprised","type":"text","marks":[{"type":"em"}]},{"text":" you, not a bug report. Short, specific, one line: that is the part that compounds.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Output Delivery","type":"text"}]},{"type":"paragraph","content":[{"text":"Every command accepts ","type":"text"},{"text":"--deliver \u003csink>","type":"text","marks":[{"type":"code_inline"}]},{"text":". The output goes to the named sink in addition to (or instead of) stdout, so agents can route command results without hand-piping. Three sinks are supported:","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Sink","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Effect","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"stdout","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Default; write to stdout only","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"file:\u003cpath>","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Atomically write output to ","type":"text"},{"text":"\u003cpath>","type":"text","marks":[{"type":"code_inline"}]},{"text":" (tmp + rename)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"webhook:\u003curl>","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"POST the output body to the URL (","type":"text"},{"text":"application/json","type":"text","marks":[{"type":"code_inline"}]},{"text":" or ","type":"text"},{"text":"application/x-ndjson","type":"text","marks":[{"type":"code_inline"}]},{"text":" when ","type":"text"},{"text":"--compact","type":"text","marks":[{"type":"code_inline"}]},{"text":")","type":"text"}]}]}]}]},{"type":"paragraph","content":[{"text":"Unknown schemes are refused with a structured error naming the supported set. Webhook failures return non-zero and log the URL + HTTP status on stderr.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Named Profiles","type":"text"}]},{"type":"paragraph","content":[{"text":"A profile is a saved set of flag values, reused across invocations. Use it when a scheduled agent calls the same command every run with the same configuration - HeyGen's \"Beacon\" pattern.","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":""},"content":[{"text":"apartments-pp-cli profile save briefing --json\napartments-pp-cli --profile briefing listing example-property\napartments-pp-cli profile list --json\napartments-pp-cli profile show briefing\napartments-pp-cli profile delete briefing --yes","type":"text"}]},{"type":"paragraph","content":[{"text":"Explicit flags always win over profile values; profile values win over defaults. ","type":"text"},{"text":"agent-context","type":"text","marks":[{"type":"code_inline"}]},{"text":" lists all available profiles under ","type":"text"},{"text":"available_profiles","type":"text","marks":[{"type":"code_inline"}]},{"text":" so introspecting agents discover them at runtime.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Exit Codes","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Code","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Meaning","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"0","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Success","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"2","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Usage error (wrong arguments)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"3","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Resource not found","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"5","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"API error (upstream issue)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"7","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Rate limited (wait and retry)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"10","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Config error","type":"text"}]}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Argument Parsing","type":"text"}]},{"type":"paragraph","content":[{"text":"Parse ","type":"text"},{"text":"$ARGUMENTS","type":"text","marks":[{"type":"code_inline"}]},{"text":":","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Empty, ","type":"text","marks":[{"type":"strong"}]},{"text":"help","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":", or ","type":"text","marks":[{"type":"strong"}]},{"text":"--help","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" → show ","type":"text"},{"text":"apartments-pp-cli --help","type":"text","marks":[{"type":"code_inline"}]},{"text":" output","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Starts with ","type":"text","marks":[{"type":"strong"}]},{"text":"install","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" → ends with ","type":"text"},{"text":"mcp","type":"text","marks":[{"type":"code_inline"}]},{"text":" → MCP installation; otherwise → see Prerequisites above","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Anything else","type":"text","marks":[{"type":"strong"}]},{"text":" → Direct Use (execute as CLI command with ","type":"text"},{"text":"--agent","type":"text","marks":[{"type":"code_inline"}]},{"text":")","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"MCP Server Installation","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Install the MCP server:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"go install github.com/mvanhorn/printing-press-library/library/other/apartments/cmd/apartments-pp-mcp@latest","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Register with Claude Code:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"claude mcp add apartments-pp-mcp -- apartments-pp-mcp","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Verify: ","type":"text"},{"text":"claude mcp list","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Direct Use","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Check if installed: ","type":"text"},{"text":"which apartments-pp-cli","type":"text","marks":[{"type":"code_inline"}]},{"text":" If not found, offer to install (see Prerequisites at the top of this skill).","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Match the user query to the best command from the Unique Capabilities and Command Reference above.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Execute with the ","type":"text"},{"text":"--agent","type":"text","marks":[{"type":"code_inline"}]},{"text":" flag:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"apartments-pp-cli \u003ccommand> [subcommand] [args] --agent","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"If ambiguous, drill into subcommand help: ","type":"text"},{"text":"apartments-pp-cli \u003ccommand> --help","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]}]},{"type":"hr","attrs":{"markup":"---"}}]},"metadata":{"date":"2026-06-05","name":"pp-apartments","author":"@skillopedia","source":{"stars":1369,"repo_name":"printing-press-library","origin_url":"https://github.com/mvanhorn/printing-press-library/blob/HEAD/library/other/apartments/SKILL.md","repo_owner":"mvanhorn","body_sha256":"a9c9fa47f17e594f9147681c60f22be4f1c518b965a475ff692319c6a45e70f6","cluster_key":"0df07d8e02455252ce99f1f612fd14d36f0b543f9c905913c59fa2be38231499","clean_bundle":{"format":"clean-skill-bundle-v1","source":"mvanhorn/printing-press-library/library/other/apartments/SKILL.md","attachments":[{"id":"27c64eb7-bcf7-5d14-a82a-679dacfc7936","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/27c64eb7-bcf7-5d14-a82a-679dacfc7936/attachment.yml","path":".golangci.yml","size":147,"sha256":"41e91d2f2ed2b361555240c1ce682a57a3f3a650d7349ec5f8d10ac43c936b31","contentType":"application/yaml; charset=utf-8"},{"id":"753132cf-84ac-5277-aa2a-bf53ad2a207c","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/753132cf-84ac-5277-aa2a-bf53ad2a207c/attachment.yaml","path":".goreleaser.yaml","size":1468,"sha256":"a1175f23580c9c18afe720e209120e7e37d05d7052f71703249ae1445032fd95","contentType":"application/yaml; charset=utf-8"},{"id":"2e1ad9ac-8611-5487-b7bd-34626c79b462","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/2e1ad9ac-8611-5487-b7bd-34626c79b462/attachment.md","path":".manuscripts/20260503-193955/discovery/browser-sniff-report.md","size":3774,"sha256":"1aeef599b91d75461295648e8c07c0bfb446058b186bd47a4f54283346617d47","contentType":"text/markdown; charset=utf-8"},{"id":"2ed3d01a-41ff-583d-8dfe-5d470cf5a105","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/2ed3d01a-41ff-583d-8dfe-5d470cf5a105/attachment.json","path":".manuscripts/20260503-193955/discovery/traffic-analysis.json","size":2775,"sha256":"309d69d10b5731603e539cfb31d7d7fa916e5e0c61bc70bdeb47b620a4a7ba90","contentType":"application/json; charset=utf-8"},{"id":"5dfd49d0-465c-5454-8dd5-76f1e2eef741","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/5dfd49d0-465c-5454-8dd5-76f1e2eef741/attachment.md","path":".manuscripts/20260503-193955/proofs/2026-05-03-193955-fix-apartments-pp-cli-acceptance.md","size":6973,"sha256":"df57ba6ce849645b428e226285a610aa78b16f8197a7680f680d50607262f740","contentType":"text/markdown; charset=utf-8"},{"id":"44ab649a-580a-5b5d-a988-2d083b87ec30","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/44ab649a-580a-5b5d-a988-2d083b87ec30/attachment.md","path":".manuscripts/20260503-193955/proofs/2026-05-03-193955-fix-apartments-pp-cli-polish.md","size":1521,"sha256":"53880430442d653f0bccf707b573d70e62739330698bf448cdc784e34f3983e2","contentType":"text/markdown; charset=utf-8"},{"id":"c6bd376d-aa88-5eae-bf75-09678ce6cf1f","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/c6bd376d-aa88-5eae-bf75-09678ce6cf1f/attachment.md","path":".manuscripts/20260503-193955/proofs/2026-05-03-193955-fix-apartments-pp-cli-shipcheck.md","size":5881,"sha256":"ca95fe27cfb2ccaa9a5ce46948b73e05353f1d798eeec104d5ca2637c8b0ab5a","contentType":"text/markdown; charset=utf-8"},{"id":"117e4ccf-4c09-5b37-8725-da0fc22f5e1e","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/117e4ccf-4c09-5b37-8725-da0fc22f5e1e/attachment.md","path":".manuscripts/20260503-193955/proofs/2026-05-05-014500-retro-apartments-pp-cli.md","size":11510,"sha256":"857bcc49c3d12b3b2fbfe167cf226dd548dc19471a86b0fcb05c4ccba3ae9cf8","contentType":"text/markdown; charset=utf-8"},{"id":"407ad54c-1d43-510f-a33b-67791072a5d5","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/407ad54c-1d43-510f-a33b-67791072a5d5/attachment.json","path":".manuscripts/20260503-193955/proofs/phase5-acceptance.json","size":537,"sha256":"ba4e4fed86e871451c2ddb8bbc0eae1eba6c2c097aa9cdae9cf96146655cf724","contentType":"application/json; charset=utf-8"},{"id":"22881c81-15e1-5ffe-9fce-466e26c396d7","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/22881c81-15e1-5ffe-9fce-466e26c396d7/attachment.json","path":".manuscripts/20260503-193955/research.json","size":24795,"sha256":"ab85487574f4b70076d97f4f62766958a6bf3f35855f3688b98a88716de6def1","contentType":"application/json; charset=utf-8"},{"id":"bb4e0b02-237d-579b-b8d3-930cbf845af3","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/bb4e0b02-237d-579b-b8d3-930cbf845af3/attachment.md","path":".manuscripts/20260503-193955/research/2026-05-03-193955-feat-apartments-pp-cli-absorb-manifest.md","size":8458,"sha256":"aa3e201f6210c1ba2de87633a07c8f76b388b975d0118aeef1a863d965d2392e","contentType":"text/markdown; charset=utf-8"},{"id":"bd3e1f5b-d548-546e-bc60-7fc3415bbe7d","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/bd3e1f5b-d548-546e-bc60-7fc3415bbe7d/attachment.md","path":".manuscripts/20260503-193955/research/2026-05-03-193955-feat-apartments-pp-cli-brief.md","size":5645,"sha256":"53215b20c7f152e18b09901b5c9437373ff9fa16fe28d67280c1e44c7f733005","contentType":"text/markdown; charset=utf-8"},{"id":"701ffdde-2fe8-501e-84f7-cb2ab4a72509","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/701ffdde-2fe8-501e-84f7-cb2ab4a72509/attachment.md","path":".manuscripts/20260503-193955/research/2026-05-03-193955-novel-features-brainstorm.md","size":7593,"sha256":"ea90b869f8e8c6ace8b9c80bfe08cd45ab5fd288b9b8eed0641695da562c8250","contentType":"text/markdown; charset=utf-8"},{"id":"7e4c87ab-4264-584f-a55b-f5e387a4c77e","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/7e4c87ab-4264-584f-a55b-f5e387a4c77e/attachment.yaml","path":".manuscripts/20260503-193955/research/apartments-spec.yaml","size":5052,"sha256":"b9a554bf8d6f2b53d86037a9df8e29e3b04af01b0bd83a4f51599b6c5c2e12cf","contentType":"application/yaml; charset=utf-8"},{"id":"d37945f9-8d34-5ec9-8aea-de6d7a42b0b4","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/d37945f9-8d34-5ec9-8aea-de6d7a42b0b4/attachment.json","path":".printing-press-patches.json","size":151,"sha256":"73e44a0e0192a7f85d640d9fec6ccc0d3e7282336da18c84c813f0634a0657df","contentType":"application/json; charset=utf-8"},{"id":"d8cf8f3a-f6ba-50f0-8818-cfa3d9a5fd46","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/d8cf8f3a-f6ba-50f0-8818-cfa3d9a5fd46/attachment.json","path":".printing-press-tools-polish.json","size":245,"sha256":"5a00c2674a2b12ce4b65bb638fc58fd777ea0a5bf3f8ed532525a92d5c63e1ac","contentType":"application/json; charset=utf-8"},{"id":"5b529267-d308-5612-bd79-0ef2549531dd","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/5b529267-d308-5612-bd79-0ef2549531dd/attachment.json","path":".printing-press.json","size":3566,"sha256":"a960a3297f582edd018e34b25f0086e4d7f109ef27cd161bb725267414d6a806","contentType":"application/json; charset=utf-8"},{"id":"17d9a41d-b278-5c79-bc1c-3ebfb3918e9a","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/17d9a41d-b278-5c79-bc1c-3ebfb3918e9a/attachment.md","path":"AGENTS.md","size":3055,"sha256":"f368d492267e8bf398a13ef6d9cfab2e7736df1503941974e9183190c10d6c5c","contentType":"text/markdown; charset=utf-8"},{"id":"e9e20f6b-b03d-54f8-a817-251a85eebb55","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/e9e20f6b-b03d-54f8-a817-251a85eebb55/attachment","path":"Makefile","size":369,"sha256":"f66057f37c0126c245da6e7c189831bbaa28acb462cd48441b84b147cbc33762","contentType":"text/plain; charset=utf-8"},{"id":"58c0f7c3-35f6-5dce-8650-d2b3cb456833","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/58c0f7c3-35f6-5dce-8650-d2b3cb456833/attachment","path":"NOTICE","size":451,"sha256":"7ae1d583aaf30b36f63bc53a8882ae965fdd3593af62193f30491b826e11ef42","contentType":"text/plain; charset=utf-8"},{"id":"6caabffa-4bab-58be-afe1-c20e55d1459c","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/6caabffa-4bab-58be-afe1-c20e55d1459c/attachment.md","path":"README.md","size":22271,"sha256":"0ab33c02a6c6e34f93ea10e6c316d82356b8471042f908e2306ffbd2810a9569","contentType":"text/markdown; charset=utf-8"},{"id":"ab92cd7b-743b-5d70-9825-d3109f63800d","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/ab92cd7b-743b-5d70-9825-d3109f63800d/attachment.go","path":"cmd/apartments-pp-cli/main.go","size":433,"sha256":"6c59af126c021ef009ee54b400a308bbae600ed2792518c3db6a7e7dc258371e","contentType":"text/plain; charset=utf-8"},{"id":"f537f95f-0737-5049-bb90-3ed7c933f412","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/f537f95f-0737-5049-bb90-3ed7c933f412/attachment.go","path":"cmd/apartments-pp-mcp/main.go","size":612,"sha256":"12b4158e5ab741b63272ec16afe4edfb4b93f9e11bd117c38e77932832be2368","contentType":"text/plain; charset=utf-8"},{"id":"71693b30-b411-5da6-8a7a-2f86288fbbe5","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/71693b30-b411-5da6-8a7a-2f86288fbbe5/attachment.json","path":"dogfood-results.json","size":1945,"sha256":"f8c68b1ac91f7977f8264a5e1286da7350a65e9aafe0afaa33f196dfd056b98f","contentType":"application/json; charset=utf-8"},{"id":"b9b7e674-579e-557e-ae97-bc9ba541d521","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/b9b7e674-579e-557e-ae97-bc9ba541d521/attachment.mod","path":"go.mod","size":1688,"sha256":"1bb0b9128350fe48071c3bb57c55cfe54e9ebb6f2d020f8223f9fac91d400edf","contentType":"application/xml-dtd"},{"id":"172e9e52-a328-5a45-8ee2-f1bdfcfdd4b5","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/172e9e52-a328-5a45-8ee2-f1bdfcfdd4b5/attachment.sum","path":"go.sum","size":10096,"sha256":"53b59fd0499fc8f6f6198598a5da581ec0c9e23ed1f24faa5232f03e0a8f7ba6","contentType":"text/plain; charset=utf-8"},{"id":"81b8aa1f-dbfd-59a9-b221-2da84f0ca01d","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/81b8aa1f-dbfd-59a9-b221-2da84f0ca01d/attachment.go","path":"internal/apt/extract.go","size":19032,"sha256":"4380d50553c0d8a418e9656e8f3b12e655c9ecf9c19f5267df90218ebd436bfc","contentType":"text/plain; charset=utf-8"},{"id":"c9917d89-f2cc-51b0-84c4-a869dd50f60b","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/c9917d89-f2cc-51b0-84c4-a869dd50f60b/attachment.go","path":"internal/apt/extract_test.go","size":4447,"sha256":"30e8cc3505fe0d6a3f105d88f85eb24747473daaa496dad821da35d3e8fbf5b0","contentType":"text/plain; charset=utf-8"},{"id":"5374ae27-19f9-53c7-9ee2-af11ed9b12ba","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/5374ae27-19f9-53c7-9ee2-af11ed9b12ba/attachment.go","path":"internal/apt/store_ext.go","size":8358,"sha256":"221524ac0eac2a9a44a5be6c262aef1a4ebb0b33c6502573028eef02ae4c26bd","contentType":"text/plain; charset=utf-8"},{"id":"da915b99-c2ee-57eb-a331-ac41c3460519","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/da915b99-c2ee-57eb-a331-ac41c3460519/attachment.go","path":"internal/apt/url.go","size":3455,"sha256":"948098eb01fa41bdf781113860d7727fcafca9b184ce6f11a11a3ff09e879912","contentType":"text/plain; charset=utf-8"},{"id":"7c5d2a77-a1f2-5fbb-9b47-5cbaa2520b27","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/7c5d2a77-a1f2-5fbb-9b47-5cbaa2520b27/attachment.go","path":"internal/apt/url_test.go","size":2171,"sha256":"8632b303c59a4061b02e5e856b64f1c5085942006fce177dfb822dfbfc196ea8","contentType":"text/plain; charset=utf-8"},{"id":"808edf68-ecb1-5522-a555-caaa02c634ff","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/808edf68-ecb1-5522-a555-caaa02c634ff/attachment.go","path":"internal/cache/cache.go","size":1724,"sha256":"a1d7b267d392fb8c6419462a64ddc1673fb61d44bbff762ce35e22aaad528c52","contentType":"text/plain; charset=utf-8"},{"id":"53941958-5354-5947-bf03-e571cd06fee6","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/53941958-5354-5947-bf03-e571cd06fee6/attachment.go","path":"internal/cli/agent_context.go","size":7504,"sha256":"080a722e8fc9baeecdf3259419c92b6ec4bc0e5f9209bcfe05652d7f3b5d7274","contentType":"text/plain; charset=utf-8"},{"id":"db03b64c-5305-54bc-8f02-3c84d55c2b7e","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/db03b64c-5305-54bc-8f02-3c84d55c2b7e/attachment.go","path":"internal/cli/api_discovery.go","size":2511,"sha256":"253f98b2d791b1417aa51bfbb34fd79a1c31db42fe92af0b8ce2f3cfbaa61431","contentType":"text/plain; charset=utf-8"},{"id":"b7941894-0d03-55c4-9e1d-1d3b87b07a85","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/b7941894-0d03-55c4-9e1d-1d3b87b07a85/attachment.go","path":"internal/cli/apt_compare.go","size":3317,"sha256":"f43405cd8389009a4e2aa029845f5e9fd4710a5e4051b071fb9741d8ae606bf7","contentType":"text/plain; charset=utf-8"},{"id":"e5daa9b7-565e-5b6d-bbf6-2b9ac4b96023","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/e5daa9b7-565e-5b6d-bbf6-2b9ac4b96023/attachment.go","path":"internal/cli/apt_digest.go","size":12077,"sha256":"6ee836051933b42c5b21c1061509219c4f96af2b64e19325d140dd531e1c15c5","contentType":"text/plain; charset=utf-8"},{"id":"393aa2fe-b1bf-5753-945b-c2963020547c","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/393aa2fe-b1bf-5753-945b-c2963020547c/attachment.go","path":"internal/cli/apt_drops.go","size":3813,"sha256":"ae20ff6ac67bc47af334e89b104891be59b1f8e3e5c46be0d9370cd66e46f10a","contentType":"text/plain; charset=utf-8"},{"id":"3351ea6e-0f6d-57c8-af69-52a39c7399d2","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/3351ea6e-0f6d-57c8-af69-52a39c7399d2/attachment.go","path":"internal/cli/apt_floorplans.go","size":2659,"sha256":"09d456a81b3df6e32cc4612b3ab023fc0dd1dc625b8d7b824ead227696333bbb","contentType":"text/plain; charset=utf-8"},{"id":"09d82ca4-8b29-5abd-a1a1-e97ddc59f940","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/09d82ca4-8b29-5abd-a1a1-e97ddc59f940/attachment.go","path":"internal/cli/apt_helpers.go","size":7155,"sha256":"5fa7abe8f32897d17b9278e1ede92843a8bbac80c0fe27e36930a01f8e632ed9","contentType":"text/plain; charset=utf-8"},{"id":"a6f54183-76c3-5cc2-9e62-ee800b1bb937","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/a6f54183-76c3-5cc2-9e62-ee800b1bb937/attachment.go","path":"internal/cli/apt_history.go","size":1890,"sha256":"4c1f1d91c78cbf2c9b978f85dea0d615188ac77a9c01bfb4de9b5e8349a1be27","contentType":"text/plain; charset=utf-8"},{"id":"2fbc9034-fca0-535e-853c-02c7a571a529","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/2fbc9034-fca0-535e-853c-02c7a571a529/attachment.go","path":"internal/cli/apt_market.go","size":3167,"sha256":"7bf00b967474a2c6d016a8cc71f7b952c42b4c0e953e10d1b52bc95aa63b9f87","contentType":"text/plain; charset=utf-8"},{"id":"0842460d-a9e1-5d76-90da-ed2c9d770bc9","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/0842460d-a9e1-5d76-90da-ed2c9d770bc9/attachment.go","path":"internal/cli/apt_musthave.go","size":2819,"sha256":"a668eba2571895b4944035994fe09d540e9984c813255a5b3982d4267027d9bc","contentType":"text/plain; charset=utf-8"},{"id":"b464459d-70f5-5835-befc-127657ce4fbd","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/b464459d-70f5-5835-befc-127657ce4fbd/attachment.go","path":"internal/cli/apt_nearby.go","size":5630,"sha256":"d79e994901986d1c0c1983366c2c1c33d4e4498d77416e6e639be59ace9ebe8e","contentType":"text/plain; charset=utf-8"},{"id":"7aa5601c-d151-55c3-93d1-be1438d85cf8","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/7aa5601c-d151-55c3-93d1-be1438d85cf8/attachment.go","path":"internal/cli/apt_phantoms.go","size":3961,"sha256":"e0c50c369104f2d7683c7c912bebdd449a33f176354f704d0715156f8f3c1e4c","contentType":"text/plain; charset=utf-8"},{"id":"4bf38992-2d28-5ccb-b968-d2b16150b246","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/4bf38992-2d28-5ccb-b968-d2b16150b246/attachment.go","path":"internal/cli/apt_rank.go","size":5276,"sha256":"6993ef8dfb2b09279adafdab6e98d2443625710878dc4725c383ae204e6f9f69","contentType":"text/plain; charset=utf-8"},{"id":"6474ec82-881e-5401-bdee-4a8da5c2f098","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/6474ec82-881e-5401-bdee-4a8da5c2f098/attachment.go","path":"internal/cli/apt_shortlist.go","size":6309,"sha256":"7e0b0c43aac6af1895230a17f109b6ee20a619e568b432fceb7714755b89fb93","contentType":"text/plain; charset=utf-8"},{"id":"192c5de7-52ef-585c-bcc8-64feceddd224","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/192c5de7-52ef-585c-bcc8-64feceddd224/attachment.go","path":"internal/cli/apt_stale.go","size":3083,"sha256":"69cb074faf473a2770f201ea4d5acc0c0209df93f12f92d813ed2d94d085f705","contentType":"text/plain; charset=utf-8"},{"id":"63d4fd98-e555-50e1-b880-50549e8af78b","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/63d4fd98-e555-50e1-b880-50549e8af78b/attachment.go","path":"internal/cli/apt_sync.go","size":3173,"sha256":"e34302a6e9b3c59866267056a8ca422ad5ff6f5011c1dff51284dd524da2e4f4","contentType":"text/plain; charset=utf-8"},{"id":"3df51f2d-6208-5a7c-b56d-dec7aede1b77","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/3df51f2d-6208-5a7c-b56d-dec7aede1b77/attachment.go","path":"internal/cli/apt_value.go","size":2970,"sha256":"5442163e5e58ac340e8c57810d693d11a6d884fafacce168e829566842d1aec1","contentType":"text/plain; charset=utf-8"},{"id":"e481304d-d1f4-564c-b0ec-f6d9b895ccc5","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/e481304d-d1f4-564c-b0ec-f6d9b895ccc5/attachment.go","path":"internal/cli/apt_watch.go","size":3670,"sha256":"b869db1f0f714b09b77bc3cd154653112da77bd286875eb5baafef5252e84031","contentType":"text/plain; charset=utf-8"},{"id":"cb04ee0f-666f-5d38-804c-332189ac87ac","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/cb04ee0f-666f-5d38-804c-332189ac87ac/attachment.go","path":"internal/cli/auto_refresh.go","size":5871,"sha256":"d5bd6eafc5aaeeb0e20f8421456bb41c2e022a96e4d822835a68760d0e1ad86e","contentType":"text/plain; charset=utf-8"},{"id":"19d7e7ef-d2ff-5a2d-9754-c56faff0dd19","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/19d7e7ef-d2ff-5a2d-9754-c56faff0dd19/attachment.go","path":"internal/cli/channel_workflow.go","size":5491,"sha256":"1ba48c916bde68526c0eb21425dcdfc848b0f8ddb82f793d4d9d9dc7ee0f57e5","contentType":"text/plain; charset=utf-8"},{"id":"aac0feea-2249-57c3-9403-0bd4997381c3","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/aac0feea-2249-57c3-9403-0bd4997381c3/attachment.go","path":"internal/cli/data_source.go","size":10320,"sha256":"73a0127af150a7a08d19049c50b7932776e11a4410fafa0331b93ed99c037b17","contentType":"text/plain; charset=utf-8"},{"id":"b4bf373d-7d85-5d41-b995-f70c462c47ed","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/b4bf373d-7d85-5d41-b995-f70c462c47ed/attachment.go","path":"internal/cli/deliver.go","size":3559,"sha256":"9b505c97f24af6dd9cb9bffe325d37c22acc0d0aca6ce1a3234b064d4dd21c3a","contentType":"text/plain; charset=utf-8"},{"id":"5d7b5c7e-0d70-579e-a143-8b3674f6df90","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/5d7b5c7e-0d70-579e-a143-8b3674f6df90/attachment.go","path":"internal/cli/doctor.go","size":15801,"sha256":"af3b1e78f75622ba545dffd4cd20749252f5d8881ad874434bedbb2c0a3913e3","contentType":"text/plain; charset=utf-8"},{"id":"4e5897fb-255d-5944-9bc7-7a5aeac1486c","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/4e5897fb-255d-5944-9bc7-7a5aeac1486c/attachment.go","path":"internal/cli/export.go","size":2867,"sha256":"fb38738423d3dba87660ab7c37d95ba722d061335711db016d544e7fc2dd8521","contentType":"text/plain; charset=utf-8"},{"id":"e32220b9-ebbe-5099-ac3f-54325a6eebcd","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/e32220b9-ebbe-5099-ac3f-54325a6eebcd/attachment.go","path":"internal/cli/feedback.go","size":6642,"sha256":"57825edfe28a8722f2f0a68db4ed2ee63ec0a005663eb2b22948e4765be46adb","contentType":"text/plain; charset=utf-8"},{"id":"7978cfa3-8baf-5188-9a65-a21e0e780cb2","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/7978cfa3-8baf-5188-9a65-a21e0e780cb2/attachment.go","path":"internal/cli/helpers.go","size":30249,"sha256":"228de68bfebbd9d0837f74cc68d48cd96ef3767bc52741700ada9b5d5387017b","contentType":"text/plain; charset=utf-8"},{"id":"60516648-acd1-53c0-a9f2-08fd71db1932","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/60516648-acd1-53c0-a9f2-08fd71db1932/attachment.go","path":"internal/cli/html_extract.go","size":13723,"sha256":"1d3ff426532376298675cb18db352a83f1c8e307ff160cdb7d7b0e7dc58c841b","contentType":"text/plain; charset=utf-8"},{"id":"70b12776-495a-5be5-b584-0841fc093aa6","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/70b12776-495a-5be5-b584-0841fc093aa6/attachment.go","path":"internal/cli/import.go","size":2970,"sha256":"d568f6ec9066bcf3db6a00ad3098c4da3cff3299e0debf87cc2a4ca5c6191809","contentType":"text/plain; charset=utf-8"},{"id":"aa8e8546-03ff-5538-a36a-ce5d26edf93a","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/aa8e8546-03ff-5538-a36a-ce5d26edf93a/attachment.go","path":"internal/cli/profile.go","size":10334,"sha256":"c687d5f52e157bb60d2c0bd4bb0ecd6f867fd7a6c96c3ac066c1413773b6f7d8","contentType":"text/plain; charset=utf-8"},{"id":"990db9c9-0fc1-58c0-a73d-b5aa986f6ac5","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/990db9c9-0fc1-58c0-a73d-b5aa986f6ac5/attachment.go","path":"internal/cli/promoted_listing.go","size":3055,"sha256":"e96d5ae010a2336d77e15eb28ac23d4b2039aee4ded32adb3bc67db50174e35f","contentType":"text/plain; charset=utf-8"},{"id":"78e0f552-6d86-5715-bb48-de3ac19218d1","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/78e0f552-6d86-5715-bb48-de3ac19218d1/attachment.go","path":"internal/cli/promoted_rentals.go","size":5582,"sha256":"c3df8f5b64b574ac7604104b648aca0a97f440e398a42bc21c91ba5d3cb17378","contentType":"text/plain; charset=utf-8"},{"id":"b4c18d25-cd7e-5e5c-bc93-554ce71ad67f","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/b4c18d25-cd7e-5e5c-bc93-554ce71ad67f/attachment.go","path":"internal/cli/root.go","size":11334,"sha256":"c6a1c1191db2929f229e017b935baea5af37a958e35970038b6c08566cd08451","contentType":"text/plain; charset=utf-8"},{"id":"f2ebd87a-3eaa-5168-a9cc-c01ce2b6adf1","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/f2ebd87a-3eaa-5168-a9cc-c01ce2b6adf1/attachment.go","path":"internal/cli/sync.go","size":31544,"sha256":"9879d7084a46be5ff3f7eb66dd4606a5c76e98560ba21fdd5c2eecded4afdad6","contentType":"text/plain; charset=utf-8"},{"id":"6c21a67e-f996-506f-a3c8-d848bca6344b","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/6c21a67e-f996-506f-a3c8-d848bca6344b/attachment.go","path":"internal/cli/which.go","size":10212,"sha256":"fcc474dd1251c06288503b2df6009581b96b7f3fc4b641b430c2cfbe2b15994d","contentType":"text/plain; charset=utf-8"},{"id":"3186c8b5-68b0-5a32-87cc-d14037a1acce","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/3186c8b5-68b0-5a32-87cc-d14037a1acce/attachment.go","path":"internal/cli/which_test.go","size":3898,"sha256":"7ba01a78e8ea23f770537090cf28ac0b77551c3de45f646bfca9bcc21e35e78e","contentType":"text/plain; charset=utf-8"},{"id":"faefaa33-7481-56f6-a7bd-445544426184","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/faefaa33-7481-56f6-a7bd-445544426184/attachment.go","path":"internal/client/client.go","size":12890,"sha256":"8a5f201cb19dbeca58158b16e0666d8300bc75becc14e4ffea6aa97250bcf39e","contentType":"text/plain; charset=utf-8"},{"id":"36079c8a-b6bc-5b46-b11a-2cf0fc8ed0f2","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/36079c8a-b6bc-5b46-b11a-2cf0fc8ed0f2/attachment.go","path":"internal/cliutil/cliutil_test.go","size":25083,"sha256":"07d92ff27c31d88f9ce0e86a3ce3227d5b33a02ba98ef3297390ab631ba73893","contentType":"text/plain; charset=utf-8"},{"id":"a0db6329-4218-5b1d-84c2-8bd559a49ed4","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/a0db6329-4218-5b1d-84c2-8bd559a49ed4/attachment.go","path":"internal/cliutil/fanout.go","size":6382,"sha256":"0f4ba40de8347e23bd92312d646482fcfbbcf9913088cce911b9718d5170dd8e","contentType":"text/plain; charset=utf-8"},{"id":"39fb1561-d47f-5618-bd12-4b946030db40","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/39fb1561-d47f-5618-bd12-4b946030db40/attachment.go","path":"internal/cliutil/freshness.go","size":6208,"sha256":"89bd03fe3fb890ded16ba1b9aa06df09b429d43fcb0d2b407d1c444f595dc189","contentType":"text/plain; charset=utf-8"},{"id":"d3ec56c8-7ca8-574a-ba17-e7104726dfd4","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/d3ec56c8-7ca8-574a-ba17-e7104726dfd4/attachment.go","path":"internal/cliutil/freshness_test.go","size":7401,"sha256":"e9bcbea474a281a2f96038ad7dc2c496ad0106954a81296b6f674b8b5e0ed2fd","contentType":"text/plain; charset=utf-8"},{"id":"488187f5-8aa8-55c4-86b0-b51b2a4ac22e","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/488187f5-8aa8-55c4-86b0-b51b2a4ac22e/attachment.go","path":"internal/cliutil/probe.go","size":4771,"sha256":"d8e8c93ee5476c90c657eee88f1db2c28c9a7056e633c123ca1ad54254b817cb","contentType":"text/plain; charset=utf-8"},{"id":"48a1a930-99e5-5642-8495-fbdb2b350bd1","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/48a1a930-99e5-5642-8495-fbdb2b350bd1/attachment.go","path":"internal/cliutil/ratelimit.go","size":4048,"sha256":"1fc116072cb9cbdb23200ca151022969a08eb3487f69782476f44e8dade47feb","contentType":"text/plain; charset=utf-8"},{"id":"5d8880b7-b4eb-5c4f-83ac-b55eb9d71dc5","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/5d8880b7-b4eb-5c4f-83ac-b55eb9d71dc5/attachment.go","path":"internal/cliutil/text.go","size":1733,"sha256":"a3ffebc8685a0e9f0b9b755ee3eccbccb9d38bea87b31eebf8673ec4f27e2b09","contentType":"text/plain; charset=utf-8"},{"id":"5296b008-61b6-53f8-acd9-74528fbc8f0a","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/5296b008-61b6-53f8-acd9-74528fbc8f0a/attachment.go","path":"internal/cliutil/verifyenv.go","size":1245,"sha256":"35f171581bea7140973ffe7c2cdc24aa9c542353429438999f773b4a66be1b8b","contentType":"text/plain; charset=utf-8"},{"id":"64ef6d8f-6117-5ca6-9ed3-f77214f8aad8","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/64ef6d8f-6117-5ca6-9ed3-f77214f8aad8/attachment.go","path":"internal/config/config.go","size":2632,"sha256":"12c1ca4c5093b2485dedd3b8621513286f07a03a5f008050293a0f484387728d","contentType":"text/plain; charset=utf-8"},{"id":"054abb61-8f74-5335-97bb-14d82ce0628f","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/054abb61-8f74-5335-97bb-14d82ce0628f/attachment.go","path":"internal/mcp/cobratree/classify.go","size":3553,"sha256":"32ced54f71a96c6e2eaceae7371062cde4e44d22268ae76295a450656eded8a1","contentType":"text/plain; charset=utf-8"},{"id":"19b6e169-6ae1-55a6-90c0-d584064dc403","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/19b6e169-6ae1-55a6-90c0-d584064dc403/attachment.go","path":"internal/mcp/cobratree/cli_path.go","size":728,"sha256":"7d543967cc8a38726a3b13ecc716919ede2d50235b52040e68dd6014ad2e4728","contentType":"text/plain; charset=utf-8"},{"id":"1e28d7d1-7944-560c-88bc-0c891cf89895","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/1e28d7d1-7944-560c-88bc-0c891cf89895/attachment.go","path":"internal/mcp/cobratree/names.go","size":655,"sha256":"0947a57abd85f774c5cfdda92cec67c65cebd1361bec4cc6c8795003c51347d9","contentType":"text/plain; charset=utf-8"},{"id":"7a4d5e8c-da38-5d65-9ff5-2118927bf942","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/7a4d5e8c-da38-5d65-9ff5-2118927bf942/attachment.go","path":"internal/mcp/cobratree/shellout.go","size":2628,"sha256":"08e3b18fa4d512fbe9342c5bb4b0b091b6818a7ba9c43b362a525f7a76989013","contentType":"text/plain; charset=utf-8"},{"id":"924e2b0f-788a-5761-9ce0-db6ffac5c46c","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/924e2b0f-788a-5761-9ce0-db6ffac5c46c/attachment.go","path":"internal/mcp/cobratree/typemap.go","size":2191,"sha256":"17e522e8d1c11bb17176e0a81788b6eb4a5f1926c6412265d39d84b0e0e3d071","contentType":"text/plain; charset=utf-8"},{"id":"a335a20e-2e94-5c62-9273-5eaeb982cf01","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/a335a20e-2e94-5c62-9273-5eaeb982cf01/attachment.go","path":"internal/mcp/cobratree/walker.go","size":2050,"sha256":"1166318c22c4a350e513cccf016e71cb37b87fef5bafc64e22125ead2713a5e0","contentType":"text/plain; charset=utf-8"},{"id":"d136d061-6cec-5740-91f2-66e8d07b7f7f","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/d136d061-6cec-5740-91f2-66e8d07b7f7f/attachment.go","path":"internal/mcp/tools.go","size":18153,"sha256":"6214c49e2f8ce107c05f8f0d335dae6899a3e24ad4fd37adec6bb8df45b3e318","contentType":"text/plain; charset=utf-8"},{"id":"7ba6ab54-16f2-5f3f-9fc7-4adcf5075965","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/7ba6ab54-16f2-5f3f-9fc7-4adcf5075965/attachment.go","path":"internal/store/schema_version_test.go","size":11520,"sha256":"45c44332a459ab4942bc4f58e71bd14f1aa3b890677beecaab3c10a34fc0e682","contentType":"text/plain; charset=utf-8"},{"id":"6fd14dc6-885b-5896-987f-7acec553eaf7","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/6fd14dc6-885b-5896-987f-7acec553eaf7/attachment.go","path":"internal/store/store.go","size":31264,"sha256":"5bee9d75b11afcfebf776d7b81d5b3ba58162c9daac7320af002b62739e76054","contentType":"text/plain; charset=utf-8"},{"id":"89499c66-5d83-540d-93fa-4701fadaa49e","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/89499c66-5d83-540d-93fa-4701fadaa49e/attachment.go","path":"internal/store/upsert_batch_test.go","size":10264,"sha256":"214306f36d491113037d3def6de521be9b0b7c9c17eae49571bcd4a3440b0530","contentType":"text/plain; charset=utf-8"},{"id":"6190b4dd-9d1e-576f-b8de-6caa52632b1b","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/6190b4dd-9d1e-576f-b8de-6caa52632b1b/attachment.go","path":"internal/types/types.go","size":197,"sha256":"344d00e36902aa09a509331dee82063af27d2b09490dd3c2247a62b7113f6c0e","contentType":"text/plain; charset=utf-8"},{"id":"329d6d91-1db0-5643-be26-a3775bb93480","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/329d6d91-1db0-5643-be26-a3775bb93480/attachment.json","path":"manifest.json","size":635,"sha256":"2deb780a0b7e5741bbc6c9c5f24b239caa61e3aa8875b77603f00e0013ddc023","contentType":"application/json; charset=utf-8"},{"id":"c2bba3f2-78d0-51f6-9314-a40391740a29","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/c2bba3f2-78d0-51f6-9314-a40391740a29/attachment.yaml","path":"spec.yaml","size":5052,"sha256":"b9a554bf8d6f2b53d86037a9df8e29e3b04af01b0bd83a4f51599b6c5c2e12cf","contentType":"application/yaml; charset=utf-8"},{"id":"249feec7-c174-5bb2-b400-5e6f74170580","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/249feec7-c174-5bb2-b400-5e6f74170580/attachment.json","path":"tools-manifest.json","size":1729,"sha256":"eb18e5b17daf359e29dec4776863cba7df5862f4914454aee137585c275e8c2f","contentType":"application/json; charset=utf-8"},{"id":"cb369714-2ba3-5413-bb51-0906e9bd2228","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/cb369714-2ba3-5413-bb51-0906e9bd2228/attachment.json","path":"workflow-verify-report.json","size":177,"sha256":"5ceb02b3249dc208b07edffaa5260c5f15f52fa0ccac83321c87265c4720950c","contentType":"application/json; charset=utf-8"}],"bundle_sha256":"b853006b928a1d20faea0b5a5803858bbaf5694dfecef80b34b093b88211527a","attachment_count":92,"text_attachments":92,"attachment_storage":"skillopedia-attachments-v1","binary_attachments":0,"excluded_attachments":[]},"cluster_size":1,"skill_md_path":"library/other/apartments/SKILL.md","import_metadata":{"date":"2026-06-05","author":"@skillopedia","version":"v1","category":"testing-qa","category_label":"Testing"},"exact_dupes_collapsed_into_this":0},"license":"Apache-2.0","version":"v1","category":"testing-qa","metadata":"{\"openclaw\":{\"requires\":{\"bins\":[\"apartments-pp-cli\"]},\"install\":[{\"id\":\"go\",\"kind\":\"shell\",\"command\":\"go install github.com/mvanhorn/printing-press-library/library/other/apartments/cmd/apartments-pp-cli@latest\",\"bins\":[\"apartments-pp-cli\"],\"label\":\"Install via go install\"}]}}","import_tag":"clean-skills-v1","description":"The apartment-hunt CLI that actually works in 2026 — Surf-cleared bot protection plus a local SQLite store the website itself doesn't have. Trigger phrases: `find apartments in \u003ccity>`, `watch apartment listings for \u003carea>`, `rank rentals by price per square foot`, `compare these apartments`, `use apartments-pp-cli`, `run apartments`.","allowed-tools":"Read Bash","argument-hint":"\u003ccommand> [args] | install cli|mcp"}},"renderedAt":1782981498341}

Apartments.com — Printing Press CLI Prerequisites: Install the CLI This skill drives the binary. You must verify the CLI is installed before invoking any command from this skill. If it is missing, install it first: 1. Install via the Printing Press installer: 2. Verify: 3. Ensure (or ) is on . If the install fails (no Node, offline, etc.), fall back to a direct Go install (requires Go 1.26.3 or newer): If reports "command not found" after install, the install step did not put the binary on . Do not proceed with skill commands until verification succeeds. When to Use This CLI Use this CLI when…