From c9618c78aa84e5afcf9e428a4b3dc712794f5d24 Mon Sep 17 00:00:00 2001 From: Philip Niedertscheider Date: Tue, 29 Sep 2026 00:26:37 +0200 Subject: [PATCH 1/3] ref(cli): Inject instance-based command dependencies Replace static dependency access with a shared instance and per-command provider protocols so tests can supply independent clients, renderers, cache, and telemetry. Add terminal capability and output-mode scaffolding for future interactive commands. --- Sources/CLI/cache/DocumentationCache.swift | 7 + .../CLI/client/AppleDocumentationClient.swift | 7 + .../DocumentationTypeCatalogClient.swift | 7 + .../DocumentationTypeSearchClient.swift | 7 + .../CLI/client/TechnologyCatalogClient.swift | 7 + .../agent/skills/AgentSkillsGetCommand.swift | 12 +- .../skills/AgentSkillsInstallCommand.swift | 12 +- .../agent/skills/AgentSkillsListCommand.swift | 12 +- .../skills/AgentSkillsUninstallCommand.swift | 12 +- Sources/CLI/cmd/cache/CacheCleanCommand.swift | 19 ++- .../TechnologiesListCommand.swift | 26 ++-- .../TechnologiesListCommandRunner.swift | 2 +- Sources/CLI/cmd/types/TypesListCommand.swift | 26 ++-- .../cmd/types/TypesListCommandRunner.swift | 2 +- .../CLI/cmd/types/TypesSearchCommand.swift | 26 ++-- .../cmd/types/TypesSearchCommandRunner.swift | 2 +- Sources/CLI/cmd/types/TypesViewCommand.swift | 26 ++-- .../cmd/types/TypesViewCommandRunner.swift | 2 +- Sources/CLI/main/AppleDocs.swift | 2 +- Sources/CLI/main/Dependencies.swift | 70 +++++----- Sources/CLI/main/OutputOptions.swift | 15 +++ Sources/CLI/main/TerminalCapabilities.swift | 31 +++++ .../DocumentationTypeListRenderer.swift | 7 + .../CLI/renderer/TechnologyListRenderer.swift | 7 + .../renderer/TypeDocumentationRenderer.swift | 7 + Sources/CLI/skills/AgentSkillInstaller.swift | 16 +++ Sources/CLI/telemetry/Telemetry.swift | 14 +- Sources/CLI/telemetry/TelemetryMetric.swift | 6 + .../TechnologiesListCommandRunnerTests.swift | 2 +- .../types/TypesListCommandRunnerTests.swift | 2 +- .../cmd/types/TypesListCommandTests.swift | 65 ++++++++++ .../types/TypesSearchCommandRunnerTests.swift | 6 +- .../types/TypesViewCommandRunnerTests.swift | 3 +- Tests/CLITests/main/DependenciesTests.swift | 22 +++- Tests/CLITests/main/OutputOptionsTests.swift | 121 +++++++++++++++++- Tests/CLITests/telemetry/TelemetryTests.swift | 6 +- 36 files changed, 508 insertions(+), 108 deletions(-) create mode 100644 Sources/CLI/main/TerminalCapabilities.swift create mode 100644 Sources/CLI/telemetry/TelemetryMetric.swift create mode 100644 Tests/CLITests/cmd/types/TypesListCommandTests.swift diff --git a/Sources/CLI/cache/DocumentationCache.swift b/Sources/CLI/cache/DocumentationCache.swift index f9f37b1..7ee3e3f 100644 --- a/Sources/CLI/cache/DocumentationCache.swift +++ b/Sources/CLI/cache/DocumentationCache.swift @@ -12,6 +12,13 @@ import Foundation } extension URLCache: DocumentationCache {} + + protocol DocumentationCacheProvider { + associatedtype Cache: DocumentationCache + var documentationCache: Cache? { get } + } + + extension Dependencies: DocumentationCacheProvider {} #else typealias DocumentationCache = URLCache #endif diff --git a/Sources/CLI/client/AppleDocumentationClient.swift b/Sources/CLI/client/AppleDocumentationClient.swift index ec1a6e3..de6ef04 100644 --- a/Sources/CLI/client/AppleDocumentationClient.swift +++ b/Sources/CLI/client/AppleDocumentationClient.swift @@ -10,6 +10,13 @@ import Logging func fetchType(named name: String, technology: String) async throws -> TypeDocumentationDocument } extension DefaultAppleDocumentationClient: AppleDocumentationClient {} + + protocol AppleDocumentationClientProvider { + associatedtype Client: AppleDocumentationClient + var documentationClient: Client { get } + } + + extension Dependencies: AppleDocumentationClientProvider {} #else typealias AppleDocumentationClient = DefaultAppleDocumentationClient #endif diff --git a/Sources/CLI/client/DocumentationTypeCatalogClient.swift b/Sources/CLI/client/DocumentationTypeCatalogClient.swift index 121afb9..fe9f70e 100644 --- a/Sources/CLI/client/DocumentationTypeCatalogClient.swift +++ b/Sources/CLI/client/DocumentationTypeCatalogClient.swift @@ -10,6 +10,13 @@ import Foundation } extension DefaultAppleDocumentationClient: DocumentationTypeCatalogClient {} + + protocol DocumentationTypeCatalogClientProvider { + associatedtype Client: DocumentationTypeCatalogClient + var documentationClient: Client { get } + } + + extension Dependencies: DocumentationTypeCatalogClientProvider {} #else typealias DocumentationTypeCatalogClient = DefaultAppleDocumentationClient #endif diff --git a/Sources/CLI/client/DocumentationTypeSearchClient.swift b/Sources/CLI/client/DocumentationTypeSearchClient.swift index 4adcf98..e4cfe82 100644 --- a/Sources/CLI/client/DocumentationTypeSearchClient.swift +++ b/Sources/CLI/client/DocumentationTypeSearchClient.swift @@ -15,6 +15,13 @@ struct DocumentationSearchResult: Equatable, Sendable { } extension DefaultAppleDocumentationClient: DocumentationTypeSearchClient {} + + protocol DocumentationTypeSearchClientProvider { + associatedtype Client: DocumentationTypeSearchClient + var documentationClient: Client { get } + } + + extension Dependencies: DocumentationTypeSearchClientProvider {} #else typealias DocumentationTypeSearchClient = DefaultAppleDocumentationClient #endif diff --git a/Sources/CLI/client/TechnologyCatalogClient.swift b/Sources/CLI/client/TechnologyCatalogClient.swift index 4c6d65a..00b4983 100644 --- a/Sources/CLI/client/TechnologyCatalogClient.swift +++ b/Sources/CLI/client/TechnologyCatalogClient.swift @@ -10,6 +10,13 @@ import Foundation } extension DefaultAppleDocumentationClient: TechnologyCatalogClient {} + + protocol TechnologyCatalogClientProvider { + associatedtype Client: TechnologyCatalogClient + var documentationClient: Client { get } + } + + extension Dependencies: TechnologyCatalogClientProvider {} #else typealias TechnologyCatalogClient = DefaultAppleDocumentationClient #endif diff --git a/Sources/CLI/cmd/agent/skills/AgentSkillsGetCommand.swift b/Sources/CLI/cmd/agent/skills/AgentSkillsGetCommand.swift index f87fa8a..d20fa86 100644 --- a/Sources/CLI/cmd/agent/skills/AgentSkillsGetCommand.swift +++ b/Sources/CLI/cmd/agent/skills/AgentSkillsGetCommand.swift @@ -1,6 +1,12 @@ import ArgumentParser struct AgentSkillsGetCommand: ParsableCommand, GlobalOptionsProviding { + #if DEBUG + typealias Deps = any TelemetryProvider + #else + typealias Deps = Dependencies + #endif + @OptionGroup var global: GlobalOptions static let configuration = CommandConfiguration( @@ -12,11 +18,11 @@ struct AgentSkillsGetCommand: ParsableCommand, GlobalOptionsProviding { var name: String mutating func run() throws { - try run(telemetry: Dependencies.telemetry) + try run(deps: Dependencies.shared) } - func run(telemetry: Telemetry) throws { - telemetry.startCommand(.agentSkillsGet) + func run(deps: Deps) throws { + deps.telemetry.startCommand(.agentSkillsGet) guard let skill = BundledAgentSkills.skill(named: name) else { throw ValidationError("Unknown bundled Agent Skill '\(name)'.") } diff --git a/Sources/CLI/cmd/agent/skills/AgentSkillsInstallCommand.swift b/Sources/CLI/cmd/agent/skills/AgentSkillsInstallCommand.swift index fedd9bd..8bed026 100644 --- a/Sources/CLI/cmd/agent/skills/AgentSkillsInstallCommand.swift +++ b/Sources/CLI/cmd/agent/skills/AgentSkillsInstallCommand.swift @@ -1,6 +1,12 @@ import ArgumentParser struct AgentSkillsInstallCommand: ParsableCommand, GlobalOptionsProviding { + #if DEBUG + typealias Deps = any AgentSkillInstallerProvider + #else + typealias Deps = Dependencies + #endif + @OptionGroup var global: GlobalOptions static let configuration = CommandConfiguration( @@ -28,7 +34,11 @@ struct AgentSkillsInstallCommand: ParsableCommand, GlobalOptionsProviding { } mutating func run() throws { - let output = try Dependencies.agentSkillInstaller().install( + try run(deps: Dependencies.shared) + } + + func run(deps: Deps) throws { + let output = try deps.agentSkillInstaller().install( selection.selectedSkills(), root: selection.dir, dryRun: selection.dryRun, force: force ) print(output) diff --git a/Sources/CLI/cmd/agent/skills/AgentSkillsListCommand.swift b/Sources/CLI/cmd/agent/skills/AgentSkillsListCommand.swift index 2a2350b..23ab634 100644 --- a/Sources/CLI/cmd/agent/skills/AgentSkillsListCommand.swift +++ b/Sources/CLI/cmd/agent/skills/AgentSkillsListCommand.swift @@ -1,6 +1,12 @@ import ArgumentParser struct AgentSkillsListCommand: ParsableCommand, GlobalOptionsProviding { + #if DEBUG + typealias Deps = any TelemetryProvider + #else + typealias Deps = Dependencies + #endif + @OptionGroup var global: GlobalOptions static let configuration = CommandConfiguration( @@ -9,11 +15,11 @@ struct AgentSkillsListCommand: ParsableCommand, GlobalOptionsProviding { ) mutating func run() throws { - run(telemetry: Dependencies.telemetry) + run(deps: Dependencies.shared) } - func run(telemetry: Telemetry) { - telemetry.startCommand(.agentSkillsList) + func run(deps: Deps) { + deps.telemetry.startCommand(.agentSkillsList) for skill in BundledAgentSkills.all { print("\(skill.name)\t\(skill.shortDescription)") } diff --git a/Sources/CLI/cmd/agent/skills/AgentSkillsUninstallCommand.swift b/Sources/CLI/cmd/agent/skills/AgentSkillsUninstallCommand.swift index 6f3683b..6a6fa2a 100644 --- a/Sources/CLI/cmd/agent/skills/AgentSkillsUninstallCommand.swift +++ b/Sources/CLI/cmd/agent/skills/AgentSkillsUninstallCommand.swift @@ -1,6 +1,12 @@ import ArgumentParser struct AgentSkillsUninstallCommand: ParsableCommand, GlobalOptionsProviding { + #if DEBUG + typealias Deps = any AgentSkillInstallerProvider + #else + typealias Deps = Dependencies + #endif + @OptionGroup var global: GlobalOptions static let configuration = CommandConfiguration( @@ -31,7 +37,11 @@ struct AgentSkillsUninstallCommand: ParsableCommand, GlobalOptionsProviding { } mutating func run() throws { - let output = try Dependencies.agentSkillInstaller().uninstall( + try run(deps: Dependencies.shared) + } + + func run(deps: Deps) throws { + let output = try deps.agentSkillInstaller().uninstall( selection.selectedSkills(), root: selection.dir, dryRun: selection.dryRun ) print(output) diff --git a/Sources/CLI/cmd/cache/CacheCleanCommand.swift b/Sources/CLI/cmd/cache/CacheCleanCommand.swift index 0719017..cd406cb 100644 --- a/Sources/CLI/cmd/cache/CacheCleanCommand.swift +++ b/Sources/CLI/cmd/cache/CacheCleanCommand.swift @@ -1,6 +1,12 @@ import ArgumentParser struct CacheCleanCommand: ParsableCommand, GlobalOptionsProviding { + #if DEBUG + typealias Deps = any (TelemetryProvider & DocumentationCacheProvider) + #else + typealias Deps = Dependencies + #endif + @OptionGroup var global: GlobalOptions static let configuration = CommandConfiguration( @@ -9,14 +15,15 @@ struct CacheCleanCommand: ParsableCommand, GlobalOptionsProviding { ) mutating func run() throws { - run(telemetry: Dependencies.telemetry) + run(deps: Dependencies.shared) } - func run(telemetry: Telemetry) { - telemetry.startCommand(.cacheClean) - let result = CacheCleanCommandRunner( - cache: Dependencies.documentationCache - ).run() + func run(deps: Deps) { + deps.telemetry.startCommand(.cacheClean) + let runner = CacheCleanCommandRunner( + cache: deps.documentationCache + ) + let result = runner.run() print(result.output) } } diff --git a/Sources/CLI/cmd/technologies/TechnologiesListCommand.swift b/Sources/CLI/cmd/technologies/TechnologiesListCommand.swift index df0c1e9..66014e7 100644 --- a/Sources/CLI/cmd/technologies/TechnologiesListCommand.swift +++ b/Sources/CLI/cmd/technologies/TechnologiesListCommand.swift @@ -1,6 +1,15 @@ import ArgumentParser struct TechnologiesListCommand: AsyncParsableCommand, GlobalOptionsProviding { + #if DEBUG + typealias Deps = any ( + TelemetryProvider & TerminalCapabilitiesProvider & TechnologyCatalogClientProvider + & TechnologyListRendererProvider + ) + #else + typealias Deps = Dependencies + #endif + @OptionGroup var global: GlobalOptions @OptionGroup var output: OutputOptions @@ -10,17 +19,18 @@ struct TechnologiesListCommand: AsyncParsableCommand, GlobalOptionsProviding { ) mutating func run() async throws { - try await run(telemetry: Dependencies.telemetry) + try await run(deps: Dependencies.shared) } - func run(telemetry: Telemetry) async throws { + func run(deps: Deps) async throws { let context = TelemetryCommandContext.technologiesList(json: output.json) - telemetry.startCommand(context) - let result = try await TechnologiesListCommandRunner( - client: Dependencies.documentationClient, - renderer: Dependencies.technologyListRenderer(output: output) - ).run() - telemetry.record(.technologyCatalog(count: result.technologyCount), context: context) + deps.telemetry.startCommand(context) + let runner = TechnologiesListCommandRunner( + client: deps.documentationClient, + renderer: deps.technologyListRenderer(output: output) + ) + let result = try await runner.run(mode: deps.terminalCapabilities.mode(for: output)) + deps.telemetry.record(.technologyCatalog(count: result.technologyCount), context: context) print(result.output) } } diff --git a/Sources/CLI/cmd/technologies/TechnologiesListCommandRunner.swift b/Sources/CLI/cmd/technologies/TechnologiesListCommandRunner.swift index 44b10bb..01af697 100644 --- a/Sources/CLI/cmd/technologies/TechnologiesListCommandRunner.swift +++ b/Sources/CLI/cmd/technologies/TechnologiesListCommandRunner.swift @@ -17,7 +17,7 @@ struct TechnologiesListCommandRunner: Sendable { self.renderer = renderer } - func run() async throws -> Result { + func run(mode: OutputMode) async throws -> Result { let technologies = try await client.fetchTechnologies().sorted { $0.name.compare($1.name, options: .caseInsensitive) == .orderedAscending } diff --git a/Sources/CLI/cmd/types/TypesListCommand.swift b/Sources/CLI/cmd/types/TypesListCommand.swift index 8cf3b5a..ec17f4a 100644 --- a/Sources/CLI/cmd/types/TypesListCommand.swift +++ b/Sources/CLI/cmd/types/TypesListCommand.swift @@ -1,6 +1,15 @@ import ArgumentParser struct TypesListCommand: AsyncParsableCommand, GlobalOptionsProviding { + #if DEBUG + typealias Deps = any ( + TelemetryProvider & TerminalCapabilitiesProvider & DocumentationTypeCatalogClientProvider + & DocumentationTypeListRendererProvider + ) + #else + typealias Deps = Dependencies + #endif + @OptionGroup var global: GlobalOptions @OptionGroup var output: OutputOptions @@ -13,17 +22,18 @@ struct TypesListCommand: AsyncParsableCommand, GlobalOptionsProviding { var technology: String mutating func run() async throws { - try await run(telemetry: Dependencies.telemetry) + try await run(deps: Dependencies.shared) } - func run(telemetry: Telemetry) async throws { + func run(deps: Deps) async throws { let context = TelemetryCommandContext.typesList(technology: technology, json: output.json) - telemetry.startCommand(context) - let result = try await TypesListCommandRunner( - client: Dependencies.documentationClient, - renderer: Dependencies.documentationTypeListRenderer(output: output, technology: technology) - ).run(technology: technology) - telemetry.record(.typeCatalog(count: result.typeCount), context: context) + deps.telemetry.startCommand(context) + let runner = TypesListCommandRunner( + client: deps.documentationClient, + renderer: deps.documentationTypeListRenderer(output: output, technology: technology) + ) + let result = try await runner.run(technology: technology, mode: deps.terminalCapabilities.mode(for: output)) + deps.telemetry.record(.typeCatalog(count: result.typeCount), context: context) print(result.output) } } diff --git a/Sources/CLI/cmd/types/TypesListCommandRunner.swift b/Sources/CLI/cmd/types/TypesListCommandRunner.swift index 8a4fbde..8f4ba8f 100644 --- a/Sources/CLI/cmd/types/TypesListCommandRunner.swift +++ b/Sources/CLI/cmd/types/TypesListCommandRunner.swift @@ -15,7 +15,7 @@ struct TypesListCommandRunner: Sendable { self.renderer = renderer } - func run(technology: String) async throws -> Result { + func run(technology: String, mode: OutputMode) async throws -> Result { let types = try await client.fetchTypes(technology: technology) return Result( output: try renderer.render(types), diff --git a/Sources/CLI/cmd/types/TypesSearchCommand.swift b/Sources/CLI/cmd/types/TypesSearchCommand.swift index c9a9de1..403176c 100644 --- a/Sources/CLI/cmd/types/TypesSearchCommand.swift +++ b/Sources/CLI/cmd/types/TypesSearchCommand.swift @@ -2,6 +2,15 @@ import ArgumentParser import Foundation struct TypesSearchCommand: AsyncParsableCommand, GlobalOptionsProviding { + #if DEBUG + typealias Deps = any ( + TelemetryProvider & TerminalCapabilitiesProvider & DocumentationTypeSearchClientProvider + & DocumentationTypeListRendererProvider + ) + #else + typealias Deps = Dependencies + #endif + @OptionGroup var global: GlobalOptions @OptionGroup var output: OutputOptions @@ -17,18 +26,19 @@ struct TypesSearchCommand: AsyncParsableCommand, GlobalOptionsProviding { var technology: String mutating func run() async throws { - try await run(telemetry: Dependencies.telemetry) + try await run(deps: Dependencies.shared) } - func run(telemetry: Telemetry) async throws { + func run(deps: Deps) async throws { // Search text can be user-authored, so it is deliberately excluded from telemetry context. let context = TelemetryCommandContext.typesSearch(technology: technology, json: output.json) - telemetry.startCommand(context) - let result = try await TypesSearchCommandRunner( - client: Dependencies.documentationClient, - renderer: Dependencies.documentationTypeListRenderer(output: output, technology: technology) - ).run(query: query, technology: technology) - telemetry.record(.typeSearch(matches: result.matchCount), context: context) + deps.telemetry.startCommand(context) + let runner = TypesSearchCommandRunner( + client: deps.documentationClient, + renderer: deps.documentationTypeListRenderer(output: output, technology: technology) + ) + let result = try await runner.run(query: query, technology: technology, mode: deps.terminalCapabilities.mode(for: output)) + deps.telemetry.record(.typeSearch(matches: result.matchCount), context: context) if result.unavailableCollectionCount > 0 { let warning = "Warning: search results are incomplete. " diff --git a/Sources/CLI/cmd/types/TypesSearchCommandRunner.swift b/Sources/CLI/cmd/types/TypesSearchCommandRunner.swift index 799c861..e47013b 100644 --- a/Sources/CLI/cmd/types/TypesSearchCommandRunner.swift +++ b/Sources/CLI/cmd/types/TypesSearchCommandRunner.swift @@ -16,7 +16,7 @@ struct TypesSearchCommandRunner: Sendable { self.renderer = renderer } - func run(query: String, technology: String) async throws -> Result { + func run(query: String, technology: String, mode: OutputMode) async throws -> Result { let result = try await client.searchTypes(query: query, technology: technology) return Result( output: try renderer.render(result.types), diff --git a/Sources/CLI/cmd/types/TypesViewCommand.swift b/Sources/CLI/cmd/types/TypesViewCommand.swift index 1cd49e1..404ee8d 100644 --- a/Sources/CLI/cmd/types/TypesViewCommand.swift +++ b/Sources/CLI/cmd/types/TypesViewCommand.swift @@ -1,6 +1,15 @@ import ArgumentParser struct TypesViewCommand: AsyncParsableCommand, GlobalOptionsProviding { + #if DEBUG + typealias Deps = any ( + TelemetryProvider & TerminalCapabilitiesProvider & AppleDocumentationClientProvider + & TypeDocumentationRendererProvider + ) + #else + typealias Deps = Dependencies + #endif + @OptionGroup var global: GlobalOptions @OptionGroup var output: OutputOptions @@ -16,17 +25,18 @@ struct TypesViewCommand: AsyncParsableCommand, GlobalOptionsProviding { var technology: String mutating func run() async throws { - try await run(telemetry: Dependencies.telemetry) + try await run(deps: Dependencies.shared) } - func run(telemetry: Telemetry) async throws { + func run(deps: Deps) async throws { let context = TelemetryCommandContext.typesView(name: name, technology: technology, json: output.json) - telemetry.startCommand(context) - let result = try await TypesViewCommandRunner( - client: Dependencies.documentationClient, - renderer: Dependencies.documentationRenderer(output: output) - ).run(name: name, technology: technology) - telemetry.record(.typeView(responseBytes: result.responseByteCount), context: context) + deps.telemetry.startCommand(context) + let runner = TypesViewCommandRunner( + client: deps.documentationClient, + renderer: deps.documentationRenderer(output: output) + ) + let result = try await runner.run(name: name, technology: technology, mode: deps.terminalCapabilities.mode(for: output)) + deps.telemetry.record(.typeView(responseBytes: result.responseByteCount), context: context) print(result.output) } } diff --git a/Sources/CLI/cmd/types/TypesViewCommandRunner.swift b/Sources/CLI/cmd/types/TypesViewCommandRunner.swift index ceffe68..c5c76f5 100644 --- a/Sources/CLI/cmd/types/TypesViewCommandRunner.swift +++ b/Sources/CLI/cmd/types/TypesViewCommandRunner.swift @@ -15,7 +15,7 @@ struct TypesViewCommandRunner: Sendable { self.renderer = renderer } - func run(name: String, technology: String) async throws -> Result { + func run(name: String, technology: String, mode: OutputMode) async throws -> Result { let document = try await client.fetchType( named: name, technology: technology diff --git a/Sources/CLI/main/AppleDocs.swift b/Sources/CLI/main/AppleDocs.swift index 74edd8f..99008ca 100644 --- a/Sources/CLI/main/AppleDocs.swift +++ b/Sources/CLI/main/AppleDocs.swift @@ -7,7 +7,7 @@ enum AppleDocs { @MainActor static func main() async { - let telemetry = Dependencies.telemetry + let telemetry = Dependencies.shared.telemetry telemetry.start() var loggingConfigured = false diff --git a/Sources/CLI/main/Dependencies.swift b/Sources/CLI/main/Dependencies.swift index a689cc7..e1cb103 100644 --- a/Sources/CLI/main/Dependencies.swift +++ b/Sources/CLI/main/Dependencies.swift @@ -5,74 +5,62 @@ import Logging import FoundationNetworking #endif -enum Dependencies { - static let telemetry = DefaultTelemetry( +struct Dependencies: Sendable { + static let shared = Dependencies() + + let terminalCapabilities = DefaultTerminalCapabilities() + let telemetry = DefaultTelemetry( // Telemetry starts before SwiftLog is bootstrapped. Resolve its logger only when logging an event. logger: { Logger(label: "com.techprimate.apple-docs.telemetry") }, environment: ProcessInfo.processInfo.environment ) + let httpCache: URLCache? + let httpDataTransport: URLSession + let documentationClient: DefaultAppleDocumentationClient - static let httpCache: URLCache? = { - guard - let cachesDirectory = FileManager.default.urls( - for: .cachesDirectory, - in: .userDomainMask - ).first - else { - return nil + init() { + if let cachesDirectory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first { + let cacheDirectory = cachesDirectory.appendingPathComponent("com.techprimate.apple-docs", isDirectory: true) + #if canImport(FoundationNetworking) + httpCache = URLCache( + memoryCapacity: 16_000_000, diskCapacity: 1_000_000_000, diskPath: cacheDirectory.path) + #else + httpCache = URLCache( + memoryCapacity: 16_000_000, diskCapacity: 1_000_000_000, directory: cacheDirectory) + #endif + } else { + httpCache = nil } - let cacheDirectory = cachesDirectory.appendingPathComponent( - "com.techprimate.apple-docs", - isDirectory: true - ) - #if canImport(FoundationNetworking) - return URLCache( - memoryCapacity: 16_000_000, - diskCapacity: 1_000_000_000, - diskPath: cacheDirectory.path - ) - #else - return URLCache( - memoryCapacity: 16_000_000, - diskCapacity: 1_000_000_000, - directory: cacheDirectory - ) - #endif - }() - static let httpDataTransport: URLSession = { let configuration = URLSessionConfiguration.default if let httpCache { configuration.urlCache = httpCache } - return URLSession(configuration: configuration) - }() + httpDataTransport = URLSession(configuration: configuration) + documentationClient = DefaultAppleDocumentationClient( + logger: Logger(label: "com.techprimate.apple-docs.client"), dependencies: httpDataTransport) + } - static var documentationCache: URLCache? { + var documentationCache: URLCache? { httpDataTransport.configuration.urlCache } - static let documentationClient = DefaultAppleDocumentationClient( - logger: Logger(label: "com.techprimate.apple-docs.client"), - dependencies: httpDataTransport - ) - - static func agentSkillInstaller() -> AgentSkillInstaller { + func agentSkillInstaller() -> AgentSkillInstaller { AgentSkillInstaller(logger: Logger(label: "com.techprimate.apple-docs.skills.installer")) } - static func documentationRenderer(output: OutputOptions) -> DefaultTypeDocumentationRenderer { + func documentationRenderer(output: OutputOptions) -> DefaultTypeDocumentationRenderer { DefaultTypeDocumentationRenderer(output: output.format, audience: output.audience) } - static func documentationTypeListRenderer( + func documentationTypeListRenderer( output: OutputOptions, technology: String ) -> DefaultDocumentationTypeListRenderer { DefaultDocumentationTypeListRenderer( output: output.json ? .json : .table, audience: output.audience, technology: technology) } - static func technologyListRenderer(output: OutputOptions) -> DefaultTechnologyListRenderer { + func technologyListRenderer(output: OutputOptions) -> DefaultTechnologyListRenderer { DefaultTechnologyListRenderer(output: output.json ? .json : .table, audience: output.audience) } } diff --git a/Sources/CLI/main/OutputOptions.swift b/Sources/CLI/main/OutputOptions.swift index 718f3b5..516d331 100644 --- a/Sources/CLI/main/OutputOptions.swift +++ b/Sources/CLI/main/OutputOptions.swift @@ -8,7 +8,15 @@ enum OutputFormat: Equatable, Sendable { case text, json } +enum OutputMode: Equatable, Sendable { + case interactive + case oneShot(audience: OutputAudience, format: OutputFormat) +} + struct OutputOptions: ParsableArguments { + @Flag(help: "Print documentation once without opening the terminal browser.") + var nonInteractive = false + @Flag(help: "Print normalized semantic JSON. Combine with --agent to include follow-up commands.") var json = false @@ -17,4 +25,11 @@ struct OutputOptions: ParsableArguments { var audience: OutputAudience { agent ? .agent : .human } var format: OutputFormat { json ? .json : .text } + + func mode(stdinIsTTY: Bool, stdoutIsTTY: Bool) -> OutputMode { + if json || agent || nonInteractive || !stdinIsTTY || !stdoutIsTTY { + return .oneShot(audience: audience, format: format) + } + return .interactive + } } diff --git a/Sources/CLI/main/TerminalCapabilities.swift b/Sources/CLI/main/TerminalCapabilities.swift new file mode 100644 index 0000000..1776180 --- /dev/null +++ b/Sources/CLI/main/TerminalCapabilities.swift @@ -0,0 +1,31 @@ +#if canImport(Darwin) + import Darwin +#elseif canImport(Glibc) + import Glibc +#endif + +protocol TerminalCapabilities { + var stdinIsTTY: Bool { get } + var stdoutIsTTY: Bool { get } + func mode(for output: OutputOptions) -> OutputMode +} + +extension TerminalCapabilities { + var stdinIsTTY: Bool { isatty(STDIN_FILENO) == 1 } + var stdoutIsTTY: Bool { isatty(STDOUT_FILENO) == 1 } + + func mode(for output: OutputOptions) -> OutputMode { + output.mode(stdinIsTTY: stdinIsTTY, stdoutIsTTY: stdoutIsTTY) + } +} + +struct DefaultTerminalCapabilities: TerminalCapabilities {} + +#if DEBUG + protocol TerminalCapabilitiesProvider { + associatedtype Capabilities: TerminalCapabilities + var terminalCapabilities: Capabilities { get } + } + + extension Dependencies: TerminalCapabilitiesProvider {} +#endif diff --git a/Sources/CLI/renderer/DocumentationTypeListRenderer.swift b/Sources/CLI/renderer/DocumentationTypeListRenderer.swift index 1899c1b..506f7b4 100644 --- a/Sources/CLI/renderer/DocumentationTypeListRenderer.swift +++ b/Sources/CLI/renderer/DocumentationTypeListRenderer.swift @@ -4,6 +4,13 @@ } extension DefaultDocumentationTypeListRenderer: DocumentationTypeListRenderer {} + + protocol DocumentationTypeListRendererProvider { + associatedtype Renderer: DocumentationTypeListRenderer + func documentationTypeListRenderer(output: OutputOptions, technology: String) -> Renderer + } + + extension Dependencies: DocumentationTypeListRendererProvider {} #else typealias DocumentationTypeListRenderer = DefaultDocumentationTypeListRenderer #endif diff --git a/Sources/CLI/renderer/TechnologyListRenderer.swift b/Sources/CLI/renderer/TechnologyListRenderer.swift index 071a961..97ff2ce 100644 --- a/Sources/CLI/renderer/TechnologyListRenderer.swift +++ b/Sources/CLI/renderer/TechnologyListRenderer.swift @@ -4,6 +4,13 @@ } extension DefaultTechnologyListRenderer: TechnologyListRenderer {} + + protocol TechnologyListRendererProvider { + associatedtype TechnologyRenderer: TechnologyListRenderer + func technologyListRenderer(output: OutputOptions) -> TechnologyRenderer + } + + extension Dependencies: TechnologyListRendererProvider {} #else typealias TechnologyListRenderer = DefaultTechnologyListRenderer #endif diff --git a/Sources/CLI/renderer/TypeDocumentationRenderer.swift b/Sources/CLI/renderer/TypeDocumentationRenderer.swift index 7015210..57948c7 100644 --- a/Sources/CLI/renderer/TypeDocumentationRenderer.swift +++ b/Sources/CLI/renderer/TypeDocumentationRenderer.swift @@ -4,6 +4,13 @@ } extension DefaultTypeDocumentationRenderer: TypeDocumentationRenderer {} + + protocol TypeDocumentationRendererProvider { + associatedtype TypeRenderer: TypeDocumentationRenderer + func documentationRenderer(output: OutputOptions) -> TypeRenderer + } + + extension Dependencies: TypeDocumentationRendererProvider {} #else typealias TypeDocumentationRenderer = DefaultTypeDocumentationRenderer #endif diff --git a/Sources/CLI/skills/AgentSkillInstaller.swift b/Sources/CLI/skills/AgentSkillInstaller.swift index 5d965e5..f1f16a6 100644 --- a/Sources/CLI/skills/AgentSkillInstaller.swift +++ b/Sources/CLI/skills/AgentSkillInstaller.swift @@ -219,3 +219,19 @@ struct AgentSkillInstaller { } } } + +#if DEBUG + protocol AgentSkillInstalling { + func install(_ skills: [BundledAgentSkill], root: String, dryRun: Bool, force: Bool) throws -> String + func uninstall(_ skills: [BundledAgentSkill], root: String, dryRun: Bool) throws -> String + } + + extension AgentSkillInstaller: AgentSkillInstalling {} + + protocol AgentSkillInstallerProvider { + associatedtype Installer: AgentSkillInstalling + func agentSkillInstaller() -> Installer + } + + extension Dependencies: AgentSkillInstallerProvider {} +#endif diff --git a/Sources/CLI/telemetry/Telemetry.swift b/Sources/CLI/telemetry/Telemetry.swift index 3aca647..4fb3a5b 100644 --- a/Sources/CLI/telemetry/Telemetry.swift +++ b/Sources/CLI/telemetry/Telemetry.swift @@ -13,6 +13,13 @@ import Logging #if canImport(SentrySwift) extension SentryTelemetry: Telemetry {} #endif + + protocol TelemetryProvider { + associatedtype TelemetryType: Telemetry + var telemetry: TelemetryType { get } + } + + extension Dependencies: TelemetryProvider {} #else typealias Telemetry = DefaultTelemetry #endif @@ -22,10 +29,3 @@ import Logging #else typealias DefaultTelemetry = NoOpTelemetry #endif - -enum TelemetryMetric: Sendable { - case technologyCatalog(count: Int) - case typeCatalog(count: Int) - case typeSearch(matches: Int) - case typeView(responseBytes: Int) -} diff --git a/Sources/CLI/telemetry/TelemetryMetric.swift b/Sources/CLI/telemetry/TelemetryMetric.swift new file mode 100644 index 0000000..3c4ab36 --- /dev/null +++ b/Sources/CLI/telemetry/TelemetryMetric.swift @@ -0,0 +1,6 @@ +enum TelemetryMetric: Sendable { + case technologyCatalog(count: Int) + case typeCatalog(count: Int) + case typeSearch(matches: Int) + case typeView(responseBytes: Int) +} diff --git a/Tests/CLITests/cmd/technologies/TechnologiesListCommandRunnerTests.swift b/Tests/CLITests/cmd/technologies/TechnologiesListCommandRunnerTests.swift index 10c4cc9..18620c2 100644 --- a/Tests/CLITests/cmd/technologies/TechnologiesListCommandRunnerTests.swift +++ b/Tests/CLITests/cmd/technologies/TechnologiesListCommandRunnerTests.swift @@ -21,7 +21,7 @@ struct TechnologiesListCommandRunnerTests { ) // -- Act -- - let result = try await runner.run() + let result = try await runner.run(mode: .oneShot(audience: .human, format: .json)) // -- Assert -- let technologies = try JSONDecoder().decode( diff --git a/Tests/CLITests/cmd/types/TypesListCommandRunnerTests.swift b/Tests/CLITests/cmd/types/TypesListCommandRunnerTests.swift index eb52245..ca5bc3a 100644 --- a/Tests/CLITests/cmd/types/TypesListCommandRunnerTests.swift +++ b/Tests/CLITests/cmd/types/TypesListCommandRunnerTests.swift @@ -21,7 +21,7 @@ struct TypesListCommandRunnerTests { ) // -- Act -- - let result = try await runner.run(technology: "SwiftData") + let result = try await runner.run(technology: "SwiftData", mode: .oneShot(audience: .human, format: .text)) // -- Assert -- #expect(result.output == "rendered types") diff --git a/Tests/CLITests/cmd/types/TypesListCommandTests.swift b/Tests/CLITests/cmd/types/TypesListCommandTests.swift new file mode 100644 index 0000000..81626fa --- /dev/null +++ b/Tests/CLITests/cmd/types/TypesListCommandTests.swift @@ -0,0 +1,65 @@ +import Logging +import Testing + +@testable import CLI + +@Suite("Types list command dependencies") +struct TypesListCommandTests { + @Test("runs with a complete injected dependency provider") + func usesInjectedDependencies() async throws { + // -- Arrange -- + let command = try TypesListCommand.parse(["--technology", "SwiftData", "--json"]) + let deps = TestDeps() + let telemetry = deps.telemetry + + // -- Act -- + try await command.run(deps: deps) + + // -- Assert -- + #expect(telemetry.commands == ["types.list"]) + #expect(telemetry.typeCounts == [1]) + } +} + +private typealias TestProviders = TelemetryProvider & TerminalCapabilitiesProvider + & DocumentationTypeCatalogClientProvider & DocumentationTypeListRendererProvider + +private struct TestDeps: TestProviders { + let telemetry = TypesListTelemetryRecorder() + let terminalCapabilities = TypesListTestTerminal() + let documentationClient = TypesListTestClient() + + typealias Renderer = DefaultDocumentationTypeListRenderer + + func documentationTypeListRenderer(output: OutputOptions, technology: String) -> Renderer { + DefaultDocumentationTypeListRenderer(output: .json, technology: technology) + } +} + +private struct TypesListTestTerminal: TerminalCapabilities { + var stdinIsTTY: Bool { false } +} + +private struct TypesListTestClient: DocumentationTypeCatalogClient { + func fetchTypes(technology: String) async throws -> [DocumentationType] { + #expect(technology == "SwiftData") + return [ + DocumentationType( + name: "Model", kind: "macro", path: "model", + url: "https://developer.apple.com/documentation/swiftdata/model") + ] + } +} + +private final class TypesListTelemetryRecorder: Telemetry, @unchecked Sendable { + private(set) var commands: [String] = [] + private(set) var typeCounts: [Int] = [] + + func start() {} + func startCommand(_ context: TelemetryCommandContext) { commands.append(context.command) } + func record(_ metric: TelemetryMetric, context: TelemetryCommandContext) { + if case .typeCatalog(let count) = metric { typeCounts.append(count) } + } + func finishCommand(error: (any Error)?) {} + func makeLogHandler() -> (any LogHandler)? { nil } +} diff --git a/Tests/CLITests/cmd/types/TypesSearchCommandRunnerTests.swift b/Tests/CLITests/cmd/types/TypesSearchCommandRunnerTests.swift index f7625fa..488f085 100644 --- a/Tests/CLITests/cmd/types/TypesSearchCommandRunnerTests.swift +++ b/Tests/CLITests/cmd/types/TypesSearchCommandRunnerTests.swift @@ -21,7 +21,8 @@ struct TypesSearchCommandRunnerTests { ) // -- Act -- - let result = try await runner.run(query: "Button", technology: "SwiftUI") + let result = try await runner.run( + query: "Button", technology: "SwiftUI", mode: .oneShot(audience: .human, format: .text)) // -- Assert -- #expect(result.output == "rendered matches") @@ -37,7 +38,8 @@ struct TypesSearchCommandRunnerTests { renderer: DefaultDocumentationTypeListRenderer(output: .json)) // -- Act -- - let result = try await runner.run(query: "Button", technology: "SwiftUI") + let result = try await runner.run( + query: "Button", technology: "SwiftUI", mode: .oneShot(audience: .human, format: .json)) // -- Assert -- #expect(result.output == "[\n\n]") diff --git a/Tests/CLITests/cmd/types/TypesViewCommandRunnerTests.swift b/Tests/CLITests/cmd/types/TypesViewCommandRunnerTests.swift index 5c8f5e8..dd809e4 100644 --- a/Tests/CLITests/cmd/types/TypesViewCommandRunnerTests.swift +++ b/Tests/CLITests/cmd/types/TypesViewCommandRunnerTests.swift @@ -40,7 +40,8 @@ struct TypesViewCommandRunnerTests { // -- Act -- let result = try await runner.run( name: "MXHangDiagnostic", - technology: "MetricKit" + technology: "MetricKit", + mode: .oneShot(audience: .human, format: .text) ) // -- Assert -- diff --git a/Tests/CLITests/main/DependenciesTests.swift b/Tests/CLITests/main/DependenciesTests.swift index 71ad6e1..68ab2d1 100644 --- a/Tests/CLITests/main/DependenciesTests.swift +++ b/Tests/CLITests/main/DependenciesTests.swift @@ -9,14 +9,32 @@ import Testing @Suite("Dependencies") struct DependenciesTests { + @Test("separate dependency instances own separate transports and caches") + func isolatesInstances() throws { + // -- Arrange -- + let first = Dependencies() + let second = Dependencies() + + // -- Act -- + let firstCache = try #require(first.documentationCache) + let secondCache = try #require(second.documentationCache) + + // -- Assert -- + #expect(first.httpDataTransport !== second.httpDataTransport) + #expect(firstCache !== secondCache) + #expect(firstCache === first.httpDataTransport.configuration.urlCache) + #expect(secondCache === second.httpDataTransport.configuration.urlCache) + } + @Test("uses a dedicated large documentation cache") func usesDedicatedDocumentationCache() throws { // -- Arrange -- - let session = Dependencies.httpDataTransport + let dependencies = Dependencies.shared + let session = dependencies.httpDataTransport // -- Act -- let cache = try #require(session.configuration.urlCache) - let documentationCache = try #require(Dependencies.documentationCache) + let documentationCache = try #require(dependencies.documentationCache) // -- Assert -- #expect(session !== URLSession.shared) diff --git a/Tests/CLITests/main/OutputOptionsTests.swift b/Tests/CLITests/main/OutputOptionsTests.swift index 76708f6..8d5687d 100644 --- a/Tests/CLITests/main/OutputOptionsTests.swift +++ b/Tests/CLITests/main/OutputOptionsTests.swift @@ -2,7 +2,7 @@ import Testing @testable import CLI -@Suite("Noninteractive output options") +@Suite("Documentation output options") struct OutputOptionsTests { @Test( "selects human text by default and separates audience from format", @@ -25,4 +25,123 @@ struct OutputOptionsTests { #expect(command.output.format == format) } + @Test( + "parses non-interactive for every documentation command", + arguments: [ + ["technologies", "list"], + ["types", "list", "--technology", "Swift"], + ["types", "search", "String", "--technology", "Swift"], + ["types", "view", "String", "--technology", "Swift"], + ]) + func parsesNonInteractive(arguments: [String]) throws { + // -- Arrange -- + let arguments = arguments + ["--non-interactive"] + + // -- Act -- + let command = try CLI.parseAsRoot(arguments) + + // -- Assert -- + switch command { + case let command as TechnologiesListCommand: #expect(command.output.nonInteractive) + case let command as TypesListCommand: #expect(command.output.nonInteractive) + case let command as TypesSearchCommand: #expect(command.output.nonInteractive) + case let command as TypesViewCommand: #expect(command.output.nonInteractive) + default: Issue.record("Unexpected documentation command") + } + } + + @Test("a partial terminal override is used through the protocol") + func partialTerminalOverride() throws { + // -- Arrange -- + let terminal: any TerminalCapabilities = OutputNotTTY() + let options = try OutputOptions.parse([]) + + // -- Act -- + let mode = terminal.mode(for: options) + + // -- Assert -- + #expect(!terminal.stdoutIsTTY) + #expect(mode == .oneShot(audience: .human, format: .text)) + } + + private struct OutputNotTTY: TerminalCapabilities { + var stdoutIsTTY: Bool { false } + } + + private struct StubTerminalCapabilities: TerminalCapabilities { + let stdinIsTTY: Bool + let stdoutIsTTY: Bool + } + + struct TerminalCase: Sendable { + let input: Bool + let output: Bool + let expected: OutputMode + } + + struct FlagCase: Sendable { + let flags: [String] + let expected: OutputMode + } + + @Test( + "only two terminal descriptors select interactive output", + arguments: [ + TerminalCase(input: true, output: true, expected: .interactive), + TerminalCase(input: false, output: true, expected: .oneShot(audience: .human, format: .text)), + TerminalCase(input: true, output: false, expected: .oneShot(audience: .human, format: .text)), + TerminalCase(input: false, output: false, expected: .oneShot(audience: .human, format: .text)), + ]) + func selectsDefaultMode(test: TerminalCase) throws { + // -- Arrange -- + let options = try OutputOptions.parse([]) + let terminal = StubTerminalCapabilities(stdinIsTTY: test.input, stdoutIsTTY: test.output) + + // -- Act -- + let mode = terminal.mode(for: options) + + // -- Assert -- + #expect(mode == test.expected) + } + + @Test( + "explicit flags are independent, order-independent, and never interactive", + arguments: [ + FlagCase(flags: ["--non-interactive"], expected: .oneShot(audience: .human, format: .text)), + FlagCase(flags: ["--json"], expected: .oneShot(audience: .human, format: .json)), + FlagCase(flags: ["--agent"], expected: .oneShot(audience: .agent, format: .text)), + FlagCase(flags: ["--agent", "--json"], expected: .oneShot(audience: .agent, format: .json)), + FlagCase(flags: ["--json", "--agent"], expected: .oneShot(audience: .agent, format: .json)), + FlagCase(flags: ["--non-interactive", "--agent"], expected: .oneShot(audience: .agent, format: .text)), + FlagCase(flags: ["--agent", "--non-interactive"], expected: .oneShot(audience: .agent, format: .text)), + FlagCase(flags: ["--json", "--non-interactive"], expected: .oneShot(audience: .human, format: .json)), + FlagCase(flags: ["--non-interactive", "--json"], expected: .oneShot(audience: .human, format: .json)), + FlagCase( + flags: ["--agent", "--json", "--non-interactive"], expected: .oneShot(audience: .agent, format: .json)), + FlagCase( + flags: ["--agent", "--non-interactive", "--json"], expected: .oneShot(audience: .agent, format: .json)), + FlagCase( + flags: ["--json", "--agent", "--non-interactive"], expected: .oneShot(audience: .agent, format: .json)), + FlagCase( + flags: ["--json", "--non-interactive", "--agent"], expected: .oneShot(audience: .agent, format: .json)), + FlagCase( + flags: ["--non-interactive", "--agent", "--json"], expected: .oneShot(audience: .agent, format: .json)), + FlagCase( + flags: ["--non-interactive", "--json", "--agent"], expected: .oneShot(audience: .agent, format: .json)), + ]) + func selectsExplicitMode(test: FlagCase) throws { + // -- Arrange -- + let options = try OutputOptions.parse(test.flags) + + // -- Act -- + let modes = [ + StubTerminalCapabilities(stdinIsTTY: true, stdoutIsTTY: true).mode(for: options), + StubTerminalCapabilities(stdinIsTTY: false, stdoutIsTTY: true).mode(for: options), + StubTerminalCapabilities(stdinIsTTY: true, stdoutIsTTY: false).mode(for: options), + StubTerminalCapabilities(stdinIsTTY: false, stdoutIsTTY: false).mode(for: options), + ] + + // -- Assert -- + #expect(modes.allSatisfy { $0 == test.expected }) + } } diff --git a/Tests/CLITests/telemetry/TelemetryTests.swift b/Tests/CLITests/telemetry/TelemetryTests.swift index 570bac8..248c18d 100644 --- a/Tests/CLITests/telemetry/TelemetryTests.swift +++ b/Tests/CLITests/telemetry/TelemetryTests.swift @@ -66,7 +66,7 @@ struct TelemetryTests { // -- Act -- #expect(throws: ValidationError.self) { - try command.run(telemetry: telemetry) + try command.run(deps: CommandDeps(telemetry: telemetry)) } // -- Assert -- @@ -76,6 +76,10 @@ struct TelemetryTests { } } +private struct CommandDeps: TelemetryProvider { + let telemetry: CommandTelemetryRecorder +} + private final class CommandTelemetryRecorder: Telemetry, @unchecked Sendable { // Accessed only by the synchronous command under test. private(set) var contexts: [TelemetryCommandContext] = [] From 4d9bdade32520e3d087e875a783df3e282f2a847 Mon Sep 17 00:00:00 2001 From: Philip Niedertscheider Date: Tue, 29 Sep 2026 00:30:32 +0200 Subject: [PATCH 2/3] style(cli): Wrap documentation runner calls --- Sources/CLI/cmd/types/TypesSearchCommand.swift | 3 ++- Sources/CLI/cmd/types/TypesViewCommand.swift | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Sources/CLI/cmd/types/TypesSearchCommand.swift b/Sources/CLI/cmd/types/TypesSearchCommand.swift index 403176c..4d9c82c 100644 --- a/Sources/CLI/cmd/types/TypesSearchCommand.swift +++ b/Sources/CLI/cmd/types/TypesSearchCommand.swift @@ -37,7 +37,8 @@ struct TypesSearchCommand: AsyncParsableCommand, GlobalOptionsProviding { client: deps.documentationClient, renderer: deps.documentationTypeListRenderer(output: output, technology: technology) ) - let result = try await runner.run(query: query, technology: technology, mode: deps.terminalCapabilities.mode(for: output)) + let result = try await runner.run( + query: query, technology: technology, mode: deps.terminalCapabilities.mode(for: output)) deps.telemetry.record(.typeSearch(matches: result.matchCount), context: context) if result.unavailableCollectionCount > 0 { let warning = diff --git a/Sources/CLI/cmd/types/TypesViewCommand.swift b/Sources/CLI/cmd/types/TypesViewCommand.swift index 404ee8d..2bc073f 100644 --- a/Sources/CLI/cmd/types/TypesViewCommand.swift +++ b/Sources/CLI/cmd/types/TypesViewCommand.swift @@ -35,7 +35,8 @@ struct TypesViewCommand: AsyncParsableCommand, GlobalOptionsProviding { client: deps.documentationClient, renderer: deps.documentationRenderer(output: output) ) - let result = try await runner.run(name: name, technology: technology, mode: deps.terminalCapabilities.mode(for: output)) + let result = try await runner.run( + name: name, technology: technology, mode: deps.terminalCapabilities.mode(for: output)) deps.telemetry.record(.typeView(responseBytes: result.responseByteCount), context: context) print(result.output) } From d77b4631ef47eef5b2abc58ed1bbefc773e6bb80 Mon Sep 17 00:00:00 2001 From: Philip Niedertscheider Date: Tue, 29 Sep 2026 00:37:05 +0200 Subject: [PATCH 3/3] fix(cli): Import Musl for static Linux terminal checks --- Sources/CLI/main/TerminalCapabilities.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Sources/CLI/main/TerminalCapabilities.swift b/Sources/CLI/main/TerminalCapabilities.swift index 1776180..90ed0fe 100644 --- a/Sources/CLI/main/TerminalCapabilities.swift +++ b/Sources/CLI/main/TerminalCapabilities.swift @@ -2,6 +2,8 @@ import Darwin #elseif canImport(Glibc) import Glibc +#elseif canImport(Musl) + import Musl #endif protocol TerminalCapabilities {