Long-term knowledge about this project. Append new entries; do not delete history. Format:
## YYYY-MM-DD β Topic
- Project: BrewUI β Homebrew's official macOS GUI
- Stage: Early / scaffolding. AI config layer created. Prototype context migrated from separate repo.
- Primary agents in use: Claude, Cursor. Designed to be agent-agnostic.
- AI config files created:
AGENTS.md,CLAUDE.md,.cursorrules,CONVENTIONS.md,ARCHITECTURE.md,.ai/directory. - Key rule: All agents must read
AGENTS.mdat the start of each session and update.ai/progress.mdat the end. - Next step: Migrate project-specific context from prototype repo. Update placeholder sections in
CONVENTIONS.md,ARCHITECTURE.md, and this file.
- Language: Swift 6.0, strict concurrency mode enabled
- UI: SwiftUI (pure β AppKit only when SwiftUI cannot meet the requirement)
- State:
@Observable(Swift 5.9+);async/awaitthroughout - Package manager: Swift Package Manager
- macOS targets: Tahoe 26, Sequoia 15, Sonoma 14 β minimum Tahoe 26
- Data sources:
brewCLI subprocess + Homebrew JSON API (formulae.brew.sh) - Architecture pattern decided: Views β ViewModels β Repositories/Interactors β Services β external (brew CLI / JSON API)
- Repository naming: protocol =
*Repository, real impl =Brew*Repository, mock =Mock*Repository - Interactor naming: protocol =
*Interacting, impl =*Interactor, mock =Mock*Interactor - Formatter:
swift-format(official Swift project tool) - Test framework: Swift Testing (preferred) or XCTest
- No
try!or force-unwrap anywhere β not in production, not in tests. Use#require/XCTUnwrapin tests. - Accessibility identifiers: single source of truth in
Utilities/AccessibilityIdentifiers.swift, shared between app and UI test targets
- Sandboxing: App is unsandboxed. No entitlements needed for subprocess execution.
- Homebrew detection: Check
/opt/homebrew/bin/brew(Apple Silicon) then/usr/local/bin/brew(Intel). Degrade gracefully if neither found. - JSON API schema drift: Prefer tolerant handling for unknown fields; required-field policy is endpoint-specific (see 2026-05-18 strict catalogue decoding entry).
- Pre-commit enforcement: Repository-managed pre-commit hook runs staged Swift files through Mint (
mint run swiftformat, thenmint run swiftlintwith--fix, then strictmint run swiftlint). See 2026-04-12 β Mint for SwiftFormat and SwiftLint. After format/lint it onlygit adds fully staged files whose worktree blob changed; partially staged files are blocked only when format/lint actually changed the file on disk (not merely because index β worktree). - Bootstrap integration:
./scripts/bootstrapinstalls git hooks automatically viascripts/install-git-hooksto minimize manual setup for contributors.
- Version pins: SwiftFormat and SwiftLint versions live in root
Mintfile(nicklockwood/SwiftFormat,realm/SwiftLint). - Install path: Homebrew (
Brewfile) installs Mint only;./scripts/bootstraprunsmint bootstrapso pinned tools are built once and cached under Mint. - Enforcement: Pre-commit and
swift_qualityCI invoke tools viamint run swiftformat/mint run swiftlintfrom the repo root (Mintfile discovery), not global Homebrew formulae for those binaries.
- Pinned Swift version for tooling: Added root
.swift-versionwith6.2(aligned to latest installed stable Swift 6.2.4) for deterministic SwiftFormat behavior. - Project formatter config: Added root
.swiftformatwith explicit Swift version and baseline whitespace/line-ending settings. - Project linter config: Added root
.swiftlint.ymlwith scoped includes/excludes and practical early-stage defaults forline_lengthandidentifier_name.
- PR checks policy: Required PR checks are lightweight and path-scoped for fast feedback.
- Workflow split: CI is separated into focused workflows (
swift_quality,pr_build_test,actionlint,ui_smoke) instead of a monolithic pipeline. - Optional heavy check: UI smoke testing is explicitly opt-in via manual
workflow_dispatch(ui_smoke.yml) and is not required by default.
- Xcode project/scheme/targets were renamed from
BrewUItoBrewfor clarity. - Test targets now map as:
BrewTests= unit testsBrewUITests= UI tests
- Repository root folder remains
BrewUI(part of a larger parent project layout). - App bundle identifier and installer package identifier changed to
sh.brew.app(wassh.brew.BrewUI). Test bundle identifiers track renamed targets (sh.brew.BrewTestsandsh.brew.BrewUITests).
- GitHub workflow linting should avoid
uses: docker://...because org allowlist policy can reject it. - Preferred pattern for this repo is Homebrew-influenced and allowlist-friendly:
- use
Homebrew/actions/setup-homebrew@1f8e202ffddf94def7f42f6fa3a482e821489f9c # 2026.07.10.1 - use
Homebrew/actions/cache-homebrew-prefix@1f8e202ffddf94def7f42f6fa3a482e821489f9c # 2026.07.10.1to installactionlint/shellcheck - run
actionlintvia arun:step
- use
- Actionlint remains path-scoped to workflow changes for low CI overhead.
- No
docs/adr/: Architecture Decision Records are not used in this repo. - Where βwhyβ lives: Durable decisions, constraints, and rationale go in
.ai/memory.md(dated entries, append-only history). ARCHITECTURE.md: Describes structure and how pieces fit; add extra explanation only when something is unusual or easy to misread.
ARCHITECTURE.mdis the single source of truth for system shape, layers, data flow, file/folder layout, tech stack baseline, and whereAccessibilityIdentifiers.swiftlives.CONVENTIONS.mdowns naming rules, implementation patterns, and contributor-facing how-to; it should reference architecture instead of repeating topology or the stack table.
- Do not add standing checklist items to
.github/PULL_REQUEST_TEMPLATE.mdfor doc deduplication (or similar); the template should stay short and not grow indefinitely. - Doc duplication: rely on
Document scope/ ownership matrix inCONVENTIONS.md, cross-links inARCHITECTURE.md, and review judgment β not extra PR checkboxes.
ARCHITECTURE.mdandCONVENTIONS.mdare intentionally minimal for the early scaffolding phase; grow them as real code and patterns appear.- Product / platform constraints live under Constraints & decisions in
ARCHITECTURE.mdonly (not duplicated inCONVENTIONS.md). CONVENTIONS.mdholds BrewUI-specific naming deltas, tooling pointers, and short implementation notes; generic Swift/SwiftUI guidance defers to Apple docs + SwiftLint/SwiftFormat.- Doc deduplication: use the opening blockquotes and cross-links between the two files; the old standalone βownership matrixβ in
CONVENTIONS.mdwas removed in favour of that lighter approach.
- Added
.cursor/rules/doc-relative-links.mdcto require relative Markdown links for intra-repo doc references (avoid absolute GitHub blob URLs in docs).
- Where it lives:
Brew/Theme/(BrewColors,BrewSpacing/BrewLayout/BrewRadius,BrewFonts) is the single source for UI semantics;CONVENTIONS.mdhas a Design system section; agents use.cursor/rules/design-system.mdc(globsBrew/**/*.swift) alongsideswift-implementation.mdc. - Rule: Extend Theme when adding new semantics β avoid raw hex or magic numbers in feature views (already stated in
BrewColors.swiftcomments).
InstalledViewModelDummyDatalives in its own file with static sample arrays.InstalledViewModel.inittakes optional row arrays and applies?? InstalledViewModelDummyData.*in the initializer body β do not use= InstalledViewModelDummyData.formulaeas default parameter values, or Swift 6 reports main-actor / default-argument isolation issues.
- Flow:
InstalledViewModelβInstalledPackagesRepositoryβBrewCommandRunning+BrewExecutableLocator. Parsing is pureInstalledPackagesParseronbrew list --versions --formula|caskstdout (ARCHITECTURE.mdβ tolerant CLI handling). - Tests: Mock
BrewCommandRunningwith a[[String]: CommandOutput]map; never run realbrewin unit tests (CONVENTIONS.mdTesting).BrewExecutableLocator(overrideURL:)exists for tests only.
- Drift: Xcode had
ENABLE_APP_SANDBOX = YESwhileARCHITECTURE.mdspecifies an unsandboxed app (2026-03-03 β Additional Decisions). Sandboxing prevented seeing/executing/opt/homebrew/bin/brewand writing session logs under the repo.cursor/path. - Fix:
ENABLE_APP_SANDBOX = NOfor the Brew app target (Debug and Release). Revisit sandbox + entitlements only if distribution constraints require it.
- Pattern:
InstalledViewModelTestsandBrewInstalledPackagesRepositoryTestsuse the realBrewInstalledPackagesRepositorywith boundary fakes only:MockBrewCommandRunner+BrewExecutableLocator(overrideURL:)orMissingBrewExecutableLocator(BrewExecutableLocating). Shared helpers:BrewTests/TestSupport/InstalledPackagesRepositoryTestSupport.swift. Documented inCONVENTIONS.mdTesting.
- When
SidebarItemgains a second case (e.g. Discover), introduce aMainWindowViewModel(orAppShellViewModel) to own selection and any tab rules; keepContentViewβsswitchonly for constructing child views. Presentation for sidebar rows can move there for unit tests.
- Layout: The main window uses the three-column
NavigationSplitViewinitializer: sidebar (ShellSidebarView), content (InstalledShellViewβ list + chrome only), detail (InstalledPackageDetailView/InstalledPackageDetailPlaceholder). The previousHSplitViewinside the detail region is removed; column widths useBrewLayouttokens includinginstalledListColumn*andinstalledDetailColumn*.minWindowWidthis the sum of sidebar, list, and detail minimum column widths. Installed data loading runs fromContentViewvia.task(id: selectedSidebarItem)when the Installed tab is selected.
- Added
MainWindowView+MainWindowViewModelso shell layout/navigation selection/load policy are separated from feature views. InstalledColumnsnow owns Installed feature column composition (contentColumn,detailColumn) and related width modifiers.BrewAppnow presentsMainWindowViewdirectly;ContentViewremains a thin compatibility wrapper for previews/incremental migration.
- For async/failable view-model data that drives UI rendering, prefer a single enum state (for example
.loading,.loaded(Data),.error(String)) over separateisLoading/data/errorproperties. - This pattern is now used by
InstalledDetailsViewModelviaInstalledDetailsLoadState, and documented inCONVENTIONS.mdunder Implementation notes.
BrewInstalledPackagesRepositorynow hydrates installed list data from a singlebrew info --installed --json=v2call instead ofbrew list --versionstext output.- Existing Installed list UI contract remains stable because repository output is still
InstalledPackagesSnapshot, mapped/sorted beforeInstalledViewModelrow mapping. - Tests now validate mixed formula/cask JSON payloads, optional/missing fields, command failure behavior, and invalid JSON decode failures for installed list loading.
BrewCommandServicenow starts concurrent stdout/stderr readers immediately after process launch and only then waits for process termination, avoiding wait-before-read deadlocks on large command output.- Added
BrewCommandServiceTestsincluding a large output regression case (250k chars on each stream) to protect command execution paths used for installed list and details loading.
NoopBrewCommandCenter: keep the existingpreview()andforTesting()helpers; avoid renaming preview/test helpers during visibility-only sweeps.BrewCommandExecutionContext: keepnoopForTestingAndPreviews()for existing noop subprocess wiring.- Main window: keep sidebar selection as local
@StateinMainWindowView;InstalledColumnsRootremains the dependency-composition boundary perCONVENTIONS.md. - Encapsulation:
upgradeOperationPhaseisprivateon list/detail VMs where onlyisUpgrading/showsUpgradeBusyare user-facing.
- Protocol
BrewCommandCenter:actorprotocol βsubmit(id:command:),phase(for:),phaseByID()(full snapshot map),isActive(id:)(allasyncfrom callers). BrewMutatingCommand:Sendablecommand pattern βvar operationKind,func run(in: BrewCommandExecutionContext) async throws(no caller closures; kind is not duplicated atsubmit).BrewCommandExecutionContext:commandRunner(BrewCommandRunning) +brewExecutableURL()vialocator(BrewExecutableLocating).SerialBrewCommandCenter:actor;SerialBrewWorkQueueinner actor ensures one mutating command at a time acrossawait; duplicateBrewOperationIDcoalesces via sharedTaskhandle.OperationFailure:Sendableenum(.brewCommand,.brewLaunchFailed,.brewExecutableNotFound,.generic) forBrewOperationPhase.failed(reason:);init(catching:)mapsBrewCommandError,BrewLookupError, and other errors to cases;userFacingMessageis a derived line for UI.- Transport types (
BrewOperationModels.swift):BrewOperationKind,BrewOperationID,BrewOperationPhase. - Domain package discriminator:
HomebrewPackageKind;InstalledPackageKindtypealias;BrewOperationID.init(kind:name:)inBrewOperationID+Homebrew.swift. - Composition:
BrewAppholdsSerialBrewCommandCenter(executionContext: .live())and applies.environment(\.brewCommandCenter, center)toMainWindowView.MainWindowViewkeeps sidebar selection in local@Stateand embedsInstalledColumnsRoot(). The root view owns dependency composition for the Installed surface by reading@Environment(\.brewCommandCenter)and constructingInstalledColumns(repository:brewCommandCenter:). Previews useNoopBrewCommandCenter.preview()and.environment(\.brewCommandCenter, β¦); unit tests constructSerialBrewCommandCenter,NoopBrewCommandCenter.forTesting(), orRecordingSerialBrewCommandCenteras needed.
- Upgrade path:
InstalledDetailsViewModelcallsawait brewCommandCenter.submit(id:command:)withBrewOperationID(row: selectedRow)andPackageUpgradeCommand(row: selectedRow)(samekind:nameasInstalledPackageRow/id;PackageUpgradeCommandimplementsBrewMutatingCommandwithBrewCommandExecutionContext; mirrorsbrew upgrade/brew upgrade --caskargv split).BrewOperationID.init(row:)delegates toinit(kind:name:). InstalledViewModeltakesbrewCommandCenter: any BrewCommandCenterininit(repository:brewCommandCenter:);BrewApppasses the sameSerialBrewCommandCenterinstance as for.environment(\.brewCommandCenter, β¦). RemovedPackageUpgradeRunning/BrewPackageUpgradeService.
InstalledDetailsViewModel.upgradeSelectedPackage()now starts and owns an unstructured task (upgradeTask) so upgrade execution viabrewCommandCenter.submitis not canceled by a view-scoped caller task when navigating away from detail UI.InstalledPackageDetailViewinvokesupgradeSelectedPackage()directly (no view-levelTask { ... }wrapper), keeping task-lifetime policy in the view model.
BrewCommandCenter: addedphaseChanges(for: BrewOperationID) async -> AsyncStream<BrewOperationPhase>β multicast per id inSerialBrewCommandCenterwithcontinuation.onTerminationcleanup;NoopBrewCommandCenteryieldsBrewOperationPhase.idleonce;RecordingSerialBrewCommandCenterforwards toinner.- Use
AsyncStream<Element>(bufferingPolicy: .unbounded) { β¦ }to pick the continuation-based initializer (plainAsyncStream { β¦ }can resolve tounfoldingunder default actor isolation). - Installed list:
InstalledListRowViewModel(observeRowUpdates) +InstalledListRowRootwith.task(id: row.id); removed parentupgradeBusyRowIDspolling loop fromInstalledViewModel/InstalledPackagesView.
InstalledPackagesRepository: addedloadInstalledPackage(kind:named:) async throws -> InstalledPackageInfoplus shared errorInstalledPackagesRepositoryError.packageNotFound(kind:name:).- Default protocol behavior: repository extension falls back to
loadInstalledPackages()+ section/name lookup so existing test doubles remain source-compatible until they adopt specialized implementations. BrewInstalledPackagesRepository: narrow read now executesbrew info --json=v2 --formula|--cask <name>and maps only the requested section (formulaeorcasks) by exact name/token.- Tests: added dedicated lookup coverage in
BrewInstalledPackagesRepositorySinglePackageTestsand new command-fixture helperInstalledPackagesTestSupport.packageInfoJSONResponse(...).
- Row ownership:
InstalledListRowViewModelnow owns mutableInstalledPackageRowsnapshot state (beyond phase) so UI labels can update from narrow refreshes without full-list reloads. - Refresh trigger: Row VM watches
phaseChanges(for:)and performs a narrow repository refresh when a row operation transitionsrunning -> idle, then emitsonRowUpdatedfor parent catalog merge. - View wiring:
InstalledPackagesViewwiresInstalledListRowRootwith explicit closures (refreshedInstalledRow,mergeInstalledRow) so row refresh coordination remains in view/view-model boundaries rather than nested VM factories. - Detail upgrade path:
InstalledViewModelonUpgradeSuccessnow refreshes and merges only the selected row instead of calling full-listrefreshInstalledPackagesPreservingUI().
- Reverted the row-level refresh/patch architecture to reduce complexity:
InstalledListRowViewModelnow observesphaseChanges(for:)for progress only, and no longer owns row-refresh callbacks or repository fetch logic. - Upgrade completion now uses
InstalledViewModel.refreshInstalledPackagesPreservingUI()as the single refresh path (full snapshot, no.loadingtransition), preserving smooth list UX without skeleton flicker. - Kept DI/wiring improvements: view-layer wiring still creates/injects child VMs (
InstalledColumnsfor details VM,InstalledPackagesViewfor row roots / command-center environment injection); list VM does not create child VMs. - Removed narrow single-package repository API (
loadInstalledPackage(kind:named:)) and dedicated lookup tests introduced solely for the abandoned row-refresh approach.
- Project build setting
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActoris enabled for the app target, so non-UI infra code can require explicit actor-neutral annotations. SwiftLintruleunneeded_synthesized_initializeris disabled in.swiftlint.ymlto allow intentional explicit emptyinit/deinitdeclarations when carrying concurrency-isolation intent.- Preferred style: use member-level
nonisolatedfirst (for initializers/factories/helpers/protocol requirements), and avoid type-levelnonisolatedunless a full type-level actor-neutral contract is clearly needed.
- Collapsed Installed feature data models into a single domain model:
BrewPackagenow backs list rows, detail payloads, and repository contracts. - Repositories now map
brew info --json=v2through sharedBrewInfoJSON+Mappinghelpers and returnBrewPackagevalues (InstalledPackagesRepositoryreturns[BrewPackage],PackageDetailsRepositoryreturnsBrewPackage). - Installed list/detail presentation formatting moved to feature view models (
InstalledListRowViewModel,InstalledDetailsViewModel) plusInstalledBrewVersionFormatting; models are now presentation-agnostic. - Removed legacy Installed models (
InstalledPackageInfo,InstalledPackageRow,InstalledPackageDetails) and dead parser path (InstalledPackagesParser+ parser tests).
InstalledViewModelowns the catalog and observesBrewCommandCenter.allPhaseChanges()to refresh after mutating operations complete (running β idle).- Detail and row view models no longer fetch from a repository;
PackageDetailsRepositoryand related infrastructure are removed. - Detail and list rows stay in sync by propagating injected
BrewPackagefrom the parent viaonChange(of: package)βupdate(package:)on the child view models. - Detail-upgrade phase observer caveat: the detail VM still polls phase around submit rather than subscribing to a stream for concurrent operations on the same id; acceptable for now.
- Feature
*Rootviews are the dependency composition boundary for that surface: they read app-level dependencies (for example@Environment), construct/inject content-view dependencies, and own view-model lifecycle boundaries. - Content views should receive dependencies from their root and focus on rendering and behavior; avoid direct app-level dependency acquisition in content views when a root wrapper exists.
- Treat optional content view models introduced solely to compensate for misplaced dependency acquisition as an anti-pattern.
- Keep the Installed list mounted under a stable parent container (
HSplitView) across selection changes; switching between list-only and split layouts can remount the list and reset scroll position. - Prefer native
List(selection:)for Installed row selection state, with view-model-backed selection binding (setSelection(_:)) and row tags by stable package id.
- Installed detail mutations now support both upgrade and uninstall through the shared
BrewCommandCenterpipeline; uninstall usesPackageUninstallCommandwith newBrewOperationKindcases (uninstallFormula,uninstallCask). InstalledPackageDetailViewnow treats the footer as a general package-actions area: show upgrade chrome only whenpackage.outdated, but always show uninstall chrome with a native confirmation dialog and copyable user-facingbrew uninstall ...command.
- Symptom:
vale docs/fails withlstat docs/../Gemfile: no such file or directorywhendocs/Gemfileis missing. - Cause: Vale 3 treats
RakefileandBrewfile(among others) as Ruby-format prose under[formats] rb = mdin.vale.ini. For those paths it expects a resolvableGemfilenext to the Ruby project layout; this repo haddocs/Gemfile.lockand Jekyll binstubs but nodocs/Gemfile, unlikeHomebrew/brewwheredocs/is a full Jekyll tree includingGemfile. - Fix: Commit
docs/Gemfile(matching upstream brew docs, consistent with the lockfile) anddocs/.ruby-versionso Vale and laterbundle execsteps in.github/workflows/docs.ymlboth succeed.
- Cache: In-memory
InstalledInventoryCacheactor storesInstalledInventorySnapshot(packages +PackageDependencyGraph) populated byBrewInstalledPackagesRepositoryafter successfulbrew info --installed --json=v2.BrewAppcreates one cache per app lifetime and injects it via SwiftUI environment (InstalledInventoryEnvironment.swift); feature roots constructBrewInstalledPackagesRepository/BrewInstalledDependentsRepositoryfrom that shared cache. Previews and tests construct isolated caches withInstalledInventoryCache()when needed. - TTL: Snapshots are stale after 3600 seconds;
load()may return cached packages when fresh;refresh()passesforceRefresh: trueto bypass TTL after mutating brew work. - Used by: Detail dependents come from reverse dependency edges among installed packages; per-selection
brew useswas removed.
- Visibility: New inventory types are module-internal; graph storage and JSON mapping helpers are
private. ProtocolsInstalledDependentsRepository/InstalledInventoryReadingstay internal as DI boundaries. - Follow-up (production, separate PR):
InstalledInventoryCache.packages()has no call sites (prefercachedPackages()).EmptyInstalledDependentsRepository/EmptyInstalledInventoryReadingare unused in production β used only frommakeInstalledDetailsViewModelin BrewTests; decide whether to delete, wire in previews, or keep for tests only. - Tests:
makeInstalledDetailsViewModelinInstalledDetailsViewModelTestsSupportsupplies empty repo defaults for non-inventory tests; production inits remain four explicit parameters.
- Removed dead installed-inventory API surface from app target:
InstalledInventoryCache.packages(),InstalledInventoryReading.installedPackages(for:),BrewInstalledPackagesRepository.installedPackages(for:), andBrewPackage.reference. - Moved test-only empty inventory/dependents stubs out of
Brew/RepositoriesintoBrewTests/TestSupport(EmptyInstalledInventoryReading,EmptyInstalledDependentsRepository) so production DI paths remain explicit. - Pruned unused test support helpers
localizedHomebrewCommandFailedMessage()andpackageInfoJSONResponse(...)fromInstalledPackagesRepositoryTestSupport.
- Installed uninstall presentation mapping now uses a feature-layer
UninstallPackageIteminitialized fromBrewPackage, instead of adding uninstall UI properties directly onBrewPackage. - Team convention clarified: domain model types stay presentation-agnostic; map to UI properties through feature ViewModels (top-level surfaces) or feature
*Itemtypes (subview/action presentation).
- Strengthened
CONVENTIONS.mdand.cursor/rules/swift-implementation.mdcwith an explicit MVVM guardrail: views must not compose multiple ViewModel state primitives inline to derive a single presentation decision. - Preferred pattern: expose one derived ViewModel property per UI concern (for example one spinner-driving busy flag), and have the view bind directly to it.
- Installed detail now uses feature-layer item mappings for co-changing presentation groups:
PackageDetailMetadataItem,UpgradePackageItem, andUninstallPackageItem, all exposed fromInstalledDetailsViewModelfor VM-driven subviews. - For this surface, independently changing async state streams (
isUpgrading,isUninstalling,isMutatingPackage) remain on the top-level ViewModel and are not folded into item types. - Detail-presentation extensions on
BrewPackagewere removed (BrewPackage+Presentation.swiftdeleted); presentation mapping lives in ViewModel/feature item types only.
- Installed detail ViewModel naming now aligns with the package-detail view family:
InstalledDetailsViewModelwas renamed toInstalledPackageDetailViewModeland moved toInstalledPackageDetailViewModel.swift. - Feature-local helper names should stay scoped but respect lint type-length limits (
SwiftLinttype_namemax 40): renamed detail command console helper toInstalledDetailMutationConsoleand mutation-parity test suite/file toInstalledDetailMutationParityTests. - Installed list row presentation value type renamed from
RowVersionPresentationtoInstalledListRowVersionPresentationto keep local naming explicit.
- Feature folders now use explicit subdirectories:
Features/<Feature>/ViewsandFeatures/<Feature>/ViewModels. Models/is now enforced as domain-only; non-domain types moved out:- Installed presentation/UI types moved to
Features/Installed/ViewModels. - Brew command JSON/operation helpers moved to
Services/BrewCommand(with command JSON underServices/BrewCommand/JSON). - Installed inventory snapshot moved to
Services/InstalledInventory.
- Installed presentation/UI types moved to
- Service infra is now grouped by boundary: brew command layer in
Services/BrewCommand, inventory infra inServices/InstalledInventory. - Added durable guidance in
CONVENTIONS.md,ARCHITECTURE.md, and.cursor/rules/folder-boundaries.mdcso future agents keep the same placement policy.
- Shared preview samples and lightweight preview fakes now live in a single source of truth:
Brew/PreviewSupport/AppPreviewSupport.swift. - Previews should consume centralized support types (
AppPreviewSupport, preview fakes) instead of defining one-off inline mock services/repositories per view. - Preview blocks are colocated at the bottom of their view files; standalone
+Previews.swiftfiles for those views were removed. - Enforcement guidance is documented in
CONVENTIONS.mdand.cursor/rules/previews-centralized.mdc(linked fromswift-implementation.mdc).
BrewPackagenow carriesdisplayNamefor UI labels while keepingnameas the canonical Homebrew identifier used for IDs and CLI commands.- Installed mapping from
brew info --json=v2now uses richer display fields with fallback:- formula:
full_name(fallbackname) - cask: first
nameentry (fallbacktoken)
- formula:
HomebrewPackageReferenceremains identity-first (.formula(name:)/.cask(token:)) and still uses canonical values forpackageID; dependency-only contexts may continue showing token/name when richer metadata is not present.
- User preference: place generated PR descriptions in
.ai/scratchpad.mdby default.
- Added project skill at
.cursor/skills/pr-description-to-scratchpad/SKILL.md. - Skill contract: build PR bodies from
main...HEAD, follow.github/PULL_REQUEST_TEMPLATE.md, and append to.ai/scratchpad.md.
- Added a dedicated Homebrew analytics network boundary:
BrewAPIClientprotocol +URLSessionBrewAPIClientinBrew/Services/BrewAPIClient.swift.- Endpoint-specific APIs for Discover:
fetchFormulaInstallOnRequestAnalytics(window:)fetchCaskInstallAnalytics(window:)
- Added resilient analytics decoding model
BrewAnalyticsJSON:- tolerant top-level decode with
formulae/casksbucket fallback - count parsing supports both numeric and comma-separated string forms
- normalized
BrewAnalyticsPackageCountoutput for repository mapping
- tolerant top-level decode with
- Added
DiscoverPackagesRepository+BrewDiscoverPackagesRepositoryreturning one combinedDiscoverTopPackagesSnapshot(topFormulae+topCasks) with limit/window parameters (defaults: top 10, 30d).
- Package-domain representations should expose
HomebrewPackageReferenceas their lookup identity (formulae map to.formula(name:), casks map to.cask(token:)). BrewPackagenow exposesreferenceagain as a computed property fromkind+name.- Discover models now carry typed references:
BrewAnalyticsPackageCount.referenceDiscoverTopPackage.reference
- Added
.cursor/rules/package-domain-reference.mdcand mirrored the rule inCONVENTIONS.mdto keep the requirement persistent for future domain models.
- Added
URLSessionProtocol(data(for:)) inBrew/Services/URLSessionProtocol.swiftwithURLSessionconformance. URLSessionBrewAPIClientnow supports dependency injection viainit(session: any URLSessionProtocol, ...), enabling deterministic unit tests with a mock session implementation instead of closure-only stubbing.
- Discover analytics decoding now fails fast for required non-optional fields rather than defaulting to empty/zero values.
BrewAnalyticsJSONrequires validcategory,total_items,total_count,start_date,end_date, and at least one analytics bucket container (formulaeorcasks).- Malformed per-entry
countvalues now throw during decode (no fallback0), and unresolved package references are treated as decode failures instead of being silently filtered out.
- Simplified
BrewAnalyticsJSONstrict decoder by removing fallback identity inference:- no fallback from bucket key
- no fallback from
categoryinferred kind
- Analytics rows now require explicit identity in payload (
formulaxorcask), which keeps failure behavior deterministic when server payloads are incomplete/ambiguous.
- Replaced
URLSessionProtocolabstraction tests with integration-style tests that use a realURLSessionconfigured withURLProtocolstubbing. URLSessionBrewAPIClientsession init now takes concreteURLSession.BrewAPIClientTestsuse host-scoped stub queues/recording inStubURLProtocolto keep tests deterministic under concurrent execution.
- Installed search still filters on canonical
BrewPackage.namerather thandisplayName. - User requested this remain unchanged for now and be revisited during discovery search work.
- Keep this as an explicit follow-up so label-search behavior can be aligned intentionally later.
- Catalogue transport decoding (
FormulaCatalogueJSON/CaskCatalogueJSON) now treatsdesc,homepage,versions.stable, andanalytics.install["30d"]as required fields. - Catalogue array decoding is now lossy per item: invalid entries are skipped, and decode failures are captured with item index + error string so callers can handle partial failures without dropping valid items.
- Split catalogue responsibilities into two boundaries:
CatalogueCacheactor owns only in-memory state, disk persistence, and ETag storage.BrewCatalogueRepositoryowns TTL, stale-while-revalidate policy, refresh orchestration, and in-flight task deduplication.
CatalogueCachecurrently preloads cache on async init:init(...) asyncloads formula/cask cache files immediately (missing/corrupt files are swallowed);- read/write methods operate on preloaded in-memory state (no separate warm-up method).
BrewCatalogueRepositorybehavior:- one refresh
Taskper catalogue kind is shared across concurrent callers; - stale cached data returns immediately while background refresh runs;
- cold start (no cache) blocks on refresh and surfaces errors;
304updates only last-refresh timestamp;- background refresh failures are swallowed.
- one refresh
CatalogueRepositorynow maps cached/network catalogue transport payloads to domainBrewPackagearrays before returning.- Durable rule:
Codabletransport types (*JSON, DTO/wire models) must stay behind service/repository boundaries and not appear in service/repository protocol return types. - Enforcement/docs:
- Added
CONVENTIONS.mdnote under Transport boundary. - Added
.cursor/rules/codable-boundary.mdc(always apply) and linked guidance in.cursor/rules/swift-implementation.mdc.
- Added
- Installed-only state moved out of shared package domain:
InstalledBrewPackagecomposes aBrewPackage(package) plusinstalledVersionsandoutdated.- Public fields shared with
BrewPackageare exposed as wrapper properties; identity (id,reference) derives frompackage. BrewPackageis slim/shared for catalogue/discover-prep fields only.
- Boundary rule:
- installed repositories/inventory/graph/view-model surfaces use
InstalledBrewPackage, - catalogue repository surfaces remain
[BrewPackage]and must not reintroduce installed-only fields.
- installed repositories/inventory/graph/view-model surfaces use
- Command/convenience identity behavior remains stable via
kind:nameIDs:BrewOperationID(package:)now takesInstalledBrewPackage,HomebrewPackageReferencegainedinit(installedPackage:)(delegates toinit(package:)),- package ID/reference semantics remain canonical and unchanged.
- Discover top-list domain rows now use
DiscoveryBrewPackage(package: BrewPackage+thirtyDayInstallCount) instead of reference/count-only payloads. DiscoverTopPackagesSnapshotnow carries[DiscoveryBrewPackage]for formula/cask sections; downstream list/detail mapping should read package metadata fromDiscoveryBrewPackage.packageand ranking fromDiscoveryBrewPackage.thirtyDayInstallCount.
BrewDiscoverPackagesRepositoryfetches analytics viaBrewAPIClient, then resolves each ranked analytics row throughCatalogueRepository.package(for:)(per-reference lookup, not whole-catalogue loads).CatalogueRepositorypublic API ispackage(for: HomebrewPackageReference) -> BrewPackage?; whole-catalogue fetch/map/cache refresh stays private insideBrewCatalogueRepository.- On
package(for:), if the kindβs catalogue is uncached or TTL-stale,BrewCatalogueRepositoryfetches the full catalogue once, updates cache, and serves lookups from an in-memory ID index (deduped in-flight refresh preserved). - Analytics rows with no catalogue match are dropped silently; the limit applies to matched rows only.
DiscoverViewModelno longer loads catalogues; it consumes enrichedDiscoveryBrewPackagerows from the discover repository.
- Selection chrome: Both
InstalledPackagesViewandDiscoverPackagesViewuse a plainList(notList(selection:)), row taps via.onTapGesture+viewModel.setSelection, and.listRowBackground(selected ? Color.brewBrandTint : Color.clear). AvoidList(selection:)β it applies system accent selection on top of the brand tint. - Column backgrounds: List column header, list body, and detail inherit window/split chrome; do not wrap Discover/Installed columns in
.background(Color.brewSurface)unless product asks for explicit surface fills everywhere. - Scroll: Both lists use
ScrollViewReader+scrollToSelectionon appear/selection/row-id changes (.brewFastanimation).
- Console UI is fed by a registry, not by
BrewCommandCenterdirectly.JobRegistry(@Observable @MainActor) projects center operation state for console views; it never mutates the center. Subscribes toallPhaseChanges()once at app launch viaBrewApp .task { jobRegistry.startObserving(commandCenter) }. Injection mirrorsBrewCommandCenterEnvironmentβ@Entry var jobRegistry: JobRegistry. CommandJobreusesBrewOperationID(do not mint a parallel UUID). Carries phase, output buffer (50k-line cap, FIFO eviction), and a derivedexitCode: Int32?.isTerminal == (exitCode != nil).- Exit code derivation from phase transitions (since
BrewOperationPhasedoesn't carry one):.running β .idleβ exit 0;.failed(.brewCommand(exitCode, _))β that exit code; other.failed(...)cases β-1sentinel. An.idle β .idle(initial replay or noop) does not back-fill an exit code. - Job materialization is lazy and ID-derived. Registry only creates a
CommandJobwhen it sees a.running(kind)phase for an unknown id; the user-facing command string andJobScopeare synthesized fromBrewOperationID.rawValue(parsed as"<kind>:<name>") plus the runningBrewOperationKind. This avoids extendingBrewMutatingCommandwith metadata for slice 1. JobScope:.package(name:)for per-package ops,.globalforbrew update/doctor/cleanup,.batch(names:)for multi-package upgrades. Used to filter jobs in detail panes vs show globally in the console.- Files live in
Brew/Features/Console/Models/(BrewCommandOutputLine,JobScope,CommandJob,JobRegistry,JobRegistryEnvironment). Future console UI will live alongside inBrew/Features/Console/Views/.
BrewCommandCenteraddsoutputChanges(for:)andallOutputChanges()mirroring the existing phase API shape exactly β per-id stream yields buffered-on-subscribe lines, all-output stream yields each new(id, line)with no replay. Both clean up viaAsyncStream.Continuation.onTermination.- Output sink plumbing uses a TaskLocal (
BrewCommandOutputContext.sink) rather than threading a closure throughBrewMutatingCommand/BrewCommandRunning.SerialBrewCommandCenter.submit(id:command:)sets the sink viawithValue(...)for the duration of the submit;BrewCommandServicereads it once at the top ofrun(...). Commands are untouched β they still callcontext.commandRunner.run(...). BrewCommandServicestreams via chunkedavailableData+ manual\nsplitting, notFileHandle.bytes.linesand notreadabilityHandler. Rationale: preserves byte-exactCommandOutput.standardOutput/standardError(the existing buffered path's contract β tests rely on it). Whensink == nilthe splitting is skipped (no overhead). Lines whose bytes are not valid UTF-8 are dropped from the stream (still kept in the verbatim byte return).- Ordering across actor boundary uses an internal
AsyncStreambuffer. The non-actor sink closure yields lines to a continuation; a per-submit drain Task pulls them in FIFO order onto the actor and broadcasts to per-id and all-output listeners. Drains untillineContinuation.finish()is called when the submit task settles. Across stdout/stderr the order is inherently non-deterministic (separate POSIX streams). NoopBrewCommandCenter/RecordingSerialBrewCommandCenterstub or forward the new methods. Output streams fromNoopBrewCommandCenterfinish immediately (no work to observe).- No 16ms batching yet β each line emits to listeners as it arrives. Sufficient for typical brew chatter; batching deferred to console-polish (Slice 6).
- Root layout:
MainWindowViewwrapsNavigationSplitViewin aVStack(spacing: 0) withDivider() + ConsolePanelat the bottom. The console spans the full window width (under sidebar + detail) β IDE convention, signals that it reflects all brew activity. @SceneStorage("consoleExpanded")stores the expand/collapse boolean per window. Survives quit-and-relaunch. Do not use@AppStorage(cross-window pollution).- Console layout tokens live in
BrewLayout:consoleCollapsedHeight(36),consoleMinExpandedHeight(120),consoleMaxExpandedHeight(600),consoleDefaultExpandedHeight(240). No new color literals at call sites β all colors flow throughBrewColors(brewTerminal,brewBrandPrimary,brewStatusSuccess,brewStatusError,brewTextSecondary,brewTextTertiary,brewCodeDefault,brewCodeError). - MVVM separation:
ConsoleStatusPresentation.from(registry:)projects the registry into a view-passive struct (DotState + Summary + isRunning); the view renders that snapshot. Mirrors the existing*BusyPresentationpattern in Discover/Installed. - Phase short labels live as
BrewOperationPhase.shortLabelextension (done/running/failed). Coarser than brew stdout markers (fetching/pouring/linking) because the center's phase enum doesn't expose those β sub-phase granularity would require stdout parsing. Single source of truth for status-bar + future inline-card text. - Status dot palette (design-system rule β BrewUI-owned components use amber for in-progress; terminal states use semantic colors): running =
brewBrandPrimary, succeeded =brewStatusSuccess, failed =brewStatusError, idle =brewTextTertiary. - Slice 3 ships the collapsed strip only. The expand toggle is wired but the expanded body comes in slice 4 β the chevron currently toggles state with no visible effect.
ConsolePanelis a state machine. Collapsed β justConsoleStatusBar. Expanded βVStackofConsoleResizeHandle+ConsoleToolbar+Divider+ConsoleBody. Animates.frame(height:)with.brewFast(0.15s easeOut) to keep theListabove from clipping (see ARCHITECTURE/CONSOLE_IMPLEMENTATION_PLAN Β§7).- Resize state:
@SceneStorage("consoleHeight") consoleHeight: Double = consoleDefaultExpandedHeight. Per-window, survives quit-and-relaunch. Drag is clamped betweenconsoleMinExpandedHeight(120) andconsoleMaxExpandedHeight(600). The handle's cursor usesNSCursor.resizeUpDown.push()/.pop()inonHoverso it doesn't leak when the view disappears mid-hover. - **
β\`` toggle usesFocusedBindingplumbing.**FocusedValues.consoleExpanded: Binding?is published byMainWindowViewvia.focusedSceneValue(.consoleExpanded, $consoleExpanded);ConsoleCommandsreads it with@FocusedBinding(.consoleExpanded)` so the menu command targets the active window's scene-storage. The menu button label flips between "Show Console" / "Hide Console" based on the current state; disabled when no focused window publishes the value. - Commands are added in two
.commandsblocks onWindowGroupβ the always-onConsoleCommandsand (DEBUG-only)DebugMenuCommands. Two adjacent.commandsmodifiers merge; this avoids#if DEBUGinside a@CommandsBuilder. - Toolbar = selected-job pill + actions + collapse chevron. Pill =
ConsoleStatusDot+ monospace command on abrewBrandTintrounded rect (sm radius). Save usesNSSavePaneldefaulting to~/Downloadswith filenamebrewui-<sanitized-command>-<yyyy-MM-dd-HHmmss>.log; Copy usesNSPasteboard.general. Both consumeCommandJob.formattedOutputForExport()which[stderr]-prefixes error lines so a downstream reader can disambiguate. - Clear calls
registry.clearCompleted()directly with no confirmation dialog.clearCompletedalready preserves in-flight jobs, so there's no destructive case to confirm β deviation from CONSOLE_IMPLEMENTATION_PLAN Β§3.4 which suggested a dialog when running jobs exist. Revisit if user feedback wants the call-out. ConsoleBodyis aListoverselectedJob.outputwith.listStyle(.plain) + .scrollContentBackground(.hidden) + .background(Color.brewTerminal). Auto-pin-to-bottom on.onChange(of: job.output.count). User scroll-lock when scrolled up is deferred to console polish. stderr lines usebrewCodeError; stdout usesbrewCodeDefault. Text uses.textSelection(.enabled)so individual lines can be copied. Empty state =ContentUnavailableView(macOS 14+).- No ANSI / color parsing in v1 β raw monospace text. Tracked for the polish slice.
2026-05-27 β Console split into BrewCommandJobsRepository + ConsoleViewModel (supersedes earlier registry note)
- Supersedes the 2026-05-27 "Console command-job projection layer" entry.
JobRegistryand\.jobRegistryno longer exist. The decision to call the layer a "Registry" pre-dated a full survey of the codebase's MVVM + Repository conventions; the conflation of translation/cache, screen-local state, and presentation projections inside one type drifted from how Discover/Installed are structured. - New layer split mirrors
BrewInstalledPackagesRepository+InstalledViewModel:BrewCommandJobsRepository(Brew/Repositories/,@Observable @MainActor, conforms toCommandJobsObserving) is the single source of truth for cached command-center operation state. Subscribes tocommandCenter.allPhaseChanges()and.allOutputChanges()ininitvia@ObservationIgnoredTasks (nostartObserving(_:)call fromBrewAppβ pattern matchesBrewInstalledPackagesRepository.completionObserverTask). Ownsjobs/orderedIDs; exposesremove(id:)andclearCompleted().handlePhase/handleOutputare internal seams kept around for unit tests.ConsoleViewModel(Brew/Features/Console/ViewModels/,@Observable @MainActor) owns the per-windowselectedID(screen state β does not belong on a Repository). Exposes derivedorderedJobs,selectedJob,activeJob,statusPresentation, andjobs(for packageName:). Intent methods (select,dismiss,clearCompleted) clean up selection before forwarding to the repository.
- Wiring:
\.commandJobsRepositoryenv key replaces\.jobRegistry.ConsolePanelRootreads the env value and constructs a@StateConsoleViewModelper-window (mirrorsInstalledColumnsRoot).ConsolePanel/ConsoleToolbar/ConsoleBody/ConsoleStatusBarconsume the VM directly (@Bindable/let) β no environment-key reads inside console views. - AppKit isolation:
NSSavePanel/NSPasteboardcalls live in a view-layer namespaceBrew/Features/Console/Views/ConsoleOutputExport.swiftrather than in views or the VM. Rule: ViewModels do not import AppKit; AppKit bridges sit at the view layer.CommandJob.formattedOutputForExport()and.suggestedExportFilename()remain on the domain model (pure data). - File placement:
CommandJob.swiftmoved fromConsole/Models/toConsole/ViewModels/β it's@Observable @MainActorwith presentation-shaped helpers, which CONVENTIONS.md says doesn't belong underModels/.Console/Models/now holds only the pure value typesBrewCommandOutputLineandJobScope. - Tests:
BrewCommandJobsRepositoryTestscovers cache mechanics (phase/output materialization,remove,clearCompleted).ConsoleViewModelTestscovers selection/projection/intent forwarding.ConsoleStatusPresentationTestsconstructs a(repository, viewModel)fixture and asserts againstviewModel.statusPresentation.
BrewOperationIDis now an enum (BrewCore/Operations/BrewOperationModels.swift):.package(HomebrewPackageID)and.maintenance(token:displayCommand:). Was a package-only struct. Maintenance ops (abrew doctorfix likebrew link/brew cleanup) aren't package-scoped, so they carry their owntokenfor identity plus the user-facingdisplayCommand. Backward-compatible: the existinginit(kind:name:)/init(package:)/init(packageID:)inits still build.package, so all install/upgrade/uninstall call sites were untouched.packageIDis now an optional accessor (nilfor maintenance).BrewOperationKind.doctorFixadded.CommandJob.materializebranches on the id: package ids synthesize the command from kind+name (existing path); maintenance ids use the id's storeddisplayCommandverbatim. This is why a doctor fix shows up correctly in the bottom console with no extra wiring.DoctorFixCommand(BrewCLI) runs an arbitrarybrewargv withoperationKind = .doctorFix; vended via the newBrewMutatingCommandFactory.doctorFixCommand(arguments:)port (Live/Stub/Unimplemented impls updated).DoctorRepositoryis an app-scoped@Observable @MainActorport (mirrorsInstalledInventoryObserving/BrewInstalledPackagesRepository), NOT a stateless one-shot β it holdsstate: LoadState<DoctorReport, any Error>+isRefreshingand exposesload(). Long-lived (injected once inBrewApp) so the report persists across leaving/returning to the Doctor tab.load()is stale-while-revalidate: an existing report stays on screen (isRefreshingflips on) while the re-check runs; only the first load shows.loading; a failed refresh keeps the prior report (logs). Runsbrew doctorread-only (via the command center); a non-zero exit means warnings were found, not a failure, so the exit code is ignored and both stdout+stderr are parsed. Concurrentload()s coalesce onto one in-flightTask.- Follow-up (still open): migrate install/upgrade/uninstall display strings to read from the command rather than re-synthesizing in
materialize, now that maintenance ops carrydisplayCommand.
DoctorIssueis modeled as an ordered list of typed blocks (Sources/BrewCore/Models/DoctorReport.swift):severity,blocks: [DoctorBlock](with.prose/.command/.data/.linkcontent), per-blockcaption+precededByBlankLine, andrawBodyas the verbatim fallback.- Block classification is by first member, not per-line allowlist (per the addendum). A colon-terminated un-indented line opens a pending block; its first indented member decides whether the whole block is
.command/.data/.link. The allowlist is consulted once per block (first-member command test) plus as a.prosefallback for stray commands under prose. - Run Fix is intentionally narrow.
DoctorBlock.isRunnableis true only for a single-step, non-adminbrewcommand (multi-step andsudoblocks are copy-only). CommandBlockView(Sources/BrewUIComponents/Views/CommandBlockView.swift) replacesPackageDetailCommandConsoleβ same single-command API plus acommands: [String]init that renders a numbered list with one "Copy all". Used by Installed (upgrade + uninstall), Discover (install), and Doctor (per fix sequence). The old name is gone.brew doctorruns throughBrewCommandCenterviaBrewOperationKind.doctorRead+ aDoctorReadCommandactor (Sources/BrewCLI/).BrewDoctorRepositorynow takescommandCenter: any BrewCommandCenterand submits a stable maintenance id (token "doctor",displayCommand "brew doctor") on everyload()β the bottom console picks up the pill + streams output from the existingBrewCommandJobsRepositoryprojection, and re-submits on the same id update the existingCommandJobinstead of spawning new pills.DoctorReadCommandis an actor because it owns mutablecapturedOutputmutated byrun(in:)and read by the repo after submit returns;nonisolated let operationKindsatisfies theBrewMutatingCommandrequirement. This loosens the center's "mutating only" framing β documented at the enum case. The serial queue is shared so doctor reads queue behind active mutations; acceptable.
DoctorSeverity.infodoes not exist. The enum iscaution / danger / unsupportedonly. Tier 1 is unreachable from anyWarning:block in realbrew doctoroutput: Homebrew'ssupport_tier_message(Ruby) starts withreturn if tier.to_s == "1", so a Tier 1 configuration produces no callout text at all. "Tier 1" means "fully supported, no editorial needed" β brew doesn't print anything about it. The parser's regex is intentionally narrowed to/This is a Tier ([23]) configuration:/to make this guarantee visible at the source.- Why this is a source-level guarantee, not a fixture-coverage gap: when the
.ai/plans/brew-doctor-console.txtfixture (which is the "all possible warnings" sample) produces no Tier 1 callouts, the first instinct is "fixture is incomplete." It isn't β there is no such callout anywhere in brew's source to capture. Don't re-add.infothinking the next fixture expansion will exercise it. If anyone wants a Tier 1-style "info" affordance in the UI, it has to come from a different source (e.g. anadd_info/ohaicapture frombrew config), not the doctor warning stream. .unsupportedis still real.support_tier_messagedoes emit "This is an Unsupported configuration:" for theunsupportedslug (prerelease / outdated-macOS paths incheck_for_unsupported_macosresolve there). The fixture happens not to include such a case; the unit testunsupported configuration trumps any tierexercises the parser branch.
- The problem. A GUI process launched from Finder/Dock/launchd inherits a stripped environment: missing
PATHentries andHOMEBREW_*vars thatbrew shellenvinstalls into the user's profile files (~/.zprofile). Runningbrew config/brew doctoragainst that stripped env produced output that didn't match what the user sees in Terminal, which destroys trust in the diagnostics. - The fix. All brew invocations now route through
LoginShellBrewCommandRunner(Sources/BrewCLI/), which rewritesrun(executableURL: brew, arguments: [...])into<login-shell> -l -i -c "<brew> <quoted args>". The login shell is resolved byLoginShellResolverfromgetpwuid(getuid())->pw_shell(Directory Services) with a/bin/zshfallback.$SHELLis intentionally not consulted β it can be stale or absent in a GUI launch. - Why
-lAND-i.-lsources.zprofile/.bash_profile(Homebrew's documented install writesbrew shellenvto.zprofile).-iadditionally sources interactive rc files (.zshrc/.bashrc) so users who put their brew setup in those files also get parity. The tradeoff is real and accepted: interactive rc files may print banners or expect a TTY (we run with/dev/nullstdin, so any TTY-dependent code in rc files will surface as warnings on stderr). If we ever see that as a meaningful noise source for users, the dial is-lonly. - Single spawning route. Quoting is handled by
LoginShellBrewCommandRunner.singleQuoted(POSIX'β¦'with'\''for embedded apostrophes), so package names and flags survive the shell parse intact. The three productionlive()factories (BrewCommandExecutionContext.live(),BrewConfigRepository.live(),BrewInstalledPackagesRepository.live()) all now constructLoginShellBrewCommandRunner(). Tests still useBrewCommandService()directly for raw-process plumbing tests β that's correct; the shell wrap is a live-wiring concern. - Out of scope (tracked separately).
brew.envfile parsing andBrewEnvFileLocatorassume brew sees the same world the user does, which they now do β but parser/locator changes are their own work.