diff --git a/Sources/CLI/cmd/agent/AgentCommand.swift b/Sources/CLI/cmd/agent/AgentCommand.swift index cc872af..739cc2b 100644 --- a/Sources/CLI/cmd/agent/AgentCommand.swift +++ b/Sources/CLI/cmd/agent/AgentCommand.swift @@ -1,6 +1,8 @@ import ArgumentParser -struct AgentCommand: ParsableCommand { +struct AgentCommand: ParsableCommand, GlobalOptionsProviding { + @OptionGroup var global: GlobalOptions + static let configuration = CommandConfiguration( commandName: "agent", abstract: "Agent mode utilities.", diff --git a/Sources/CLI/cmd/agent/skills/AgentSkillsCommand.swift b/Sources/CLI/cmd/agent/skills/AgentSkillsCommand.swift index 2c32474..ad093df 100644 --- a/Sources/CLI/cmd/agent/skills/AgentSkillsCommand.swift +++ b/Sources/CLI/cmd/agent/skills/AgentSkillsCommand.swift @@ -1,6 +1,8 @@ import ArgumentParser -struct AgentSkillsCommand: ParsableCommand { +struct AgentSkillsCommand: ParsableCommand, GlobalOptionsProviding { + @OptionGroup var global: GlobalOptions + static let configuration = CommandConfiguration( commandName: "skills", abstract: "Access portable Agent Skills bundled with apple-docs.", diff --git a/Sources/CLI/cmd/agent/skills/AgentSkillsGetCommand.swift b/Sources/CLI/cmd/agent/skills/AgentSkillsGetCommand.swift index 0f42069..d302620 100644 --- a/Sources/CLI/cmd/agent/skills/AgentSkillsGetCommand.swift +++ b/Sources/CLI/cmd/agent/skills/AgentSkillsGetCommand.swift @@ -5,7 +5,9 @@ import Logging @preconcurrency import SentrySwift #endif -struct AgentSkillsGetCommand: ParsableCommand { +struct AgentSkillsGetCommand: ParsableCommand, GlobalOptionsProviding { + @OptionGroup var global: GlobalOptions + private static let logger = Logger( label: "com.techprimate.apple-docs.agent-skills-get" ) diff --git a/Sources/CLI/cmd/agent/skills/AgentSkillsInstallCommand.swift b/Sources/CLI/cmd/agent/skills/AgentSkillsInstallCommand.swift index b412da8..fedd9bd 100644 --- a/Sources/CLI/cmd/agent/skills/AgentSkillsInstallCommand.swift +++ b/Sources/CLI/cmd/agent/skills/AgentSkillsInstallCommand.swift @@ -1,6 +1,8 @@ import ArgumentParser -struct AgentSkillsInstallCommand: ParsableCommand { +struct AgentSkillsInstallCommand: ParsableCommand, GlobalOptionsProviding { + @OptionGroup var global: GlobalOptions + static let configuration = CommandConfiguration( commandName: "install", abstract: "Install bundled Agent Skills into a .agents directory.", diff --git a/Sources/CLI/cmd/agent/skills/AgentSkillsListCommand.swift b/Sources/CLI/cmd/agent/skills/AgentSkillsListCommand.swift index 6156aad..25b68ac 100644 --- a/Sources/CLI/cmd/agent/skills/AgentSkillsListCommand.swift +++ b/Sources/CLI/cmd/agent/skills/AgentSkillsListCommand.swift @@ -5,7 +5,9 @@ import Logging @preconcurrency import SentrySwift #endif -struct AgentSkillsListCommand: ParsableCommand { +struct AgentSkillsListCommand: ParsableCommand, GlobalOptionsProviding { + @OptionGroup var global: GlobalOptions + private static let logger = Logger( label: "com.techprimate.apple-docs.agent-skills-list" ) diff --git a/Sources/CLI/cmd/agent/skills/AgentSkillsUninstallCommand.swift b/Sources/CLI/cmd/agent/skills/AgentSkillsUninstallCommand.swift index f8922d5..6f3683b 100644 --- a/Sources/CLI/cmd/agent/skills/AgentSkillsUninstallCommand.swift +++ b/Sources/CLI/cmd/agent/skills/AgentSkillsUninstallCommand.swift @@ -1,6 +1,8 @@ import ArgumentParser -struct AgentSkillsUninstallCommand: ParsableCommand { +struct AgentSkillsUninstallCommand: ParsableCommand, GlobalOptionsProviding { + @OptionGroup var global: GlobalOptions + static let configuration = CommandConfiguration( commandName: "uninstall", abstract: "Remove skills managed by apple-docs from a .agents directory.", diff --git a/Sources/CLI/cmd/cache/CacheCleanCommand.swift b/Sources/CLI/cmd/cache/CacheCleanCommand.swift index 35ef7d1..267d6af 100644 --- a/Sources/CLI/cmd/cache/CacheCleanCommand.swift +++ b/Sources/CLI/cmd/cache/CacheCleanCommand.swift @@ -5,7 +5,9 @@ import Logging @preconcurrency import SentrySwift #endif -struct CacheCleanCommand: ParsableCommand { +struct CacheCleanCommand: ParsableCommand, GlobalOptionsProviding { + @OptionGroup var global: GlobalOptions + private static let logger = Logger( label: "com.techprimate.apple-docs.cache-clean" ) diff --git a/Sources/CLI/cmd/cache/CacheCommand.swift b/Sources/CLI/cmd/cache/CacheCommand.swift index 31e77c0..158f037 100644 --- a/Sources/CLI/cmd/cache/CacheCommand.swift +++ b/Sources/CLI/cmd/cache/CacheCommand.swift @@ -1,6 +1,8 @@ import ArgumentParser -struct CacheCommand: ParsableCommand { +struct CacheCommand: ParsableCommand, GlobalOptionsProviding { + @OptionGroup var global: GlobalOptions + static let configuration = CommandConfiguration( commandName: "cache", abstract: "Manage cached Apple documentation.", diff --git a/Sources/CLI/cmd/technologies/TechnologiesCommand.swift b/Sources/CLI/cmd/technologies/TechnologiesCommand.swift index 55730d7..3a9cff0 100644 --- a/Sources/CLI/cmd/technologies/TechnologiesCommand.swift +++ b/Sources/CLI/cmd/technologies/TechnologiesCommand.swift @@ -1,6 +1,8 @@ import ArgumentParser -struct TechnologiesCommand: AsyncParsableCommand { +struct TechnologiesCommand: AsyncParsableCommand, GlobalOptionsProviding { + @OptionGroup var global: GlobalOptions + static let configuration = CommandConfiguration( commandName: "technologies", abstract: "Work with Apple documentation technologies.", diff --git a/Sources/CLI/cmd/technologies/TechnologiesListCommand.swift b/Sources/CLI/cmd/technologies/TechnologiesListCommand.swift index 737f7d3..1f8f2f7 100644 --- a/Sources/CLI/cmd/technologies/TechnologiesListCommand.swift +++ b/Sources/CLI/cmd/technologies/TechnologiesListCommand.swift @@ -5,7 +5,9 @@ import Logging @preconcurrency import SentrySwift #endif -struct TechnologiesListCommand: AsyncParsableCommand { +struct TechnologiesListCommand: AsyncParsableCommand, GlobalOptionsProviding { + @OptionGroup var global: GlobalOptions + private static let logger = Logger( label: "com.techprimate.apple-docs.technologies-list" ) diff --git a/Sources/CLI/cmd/types/TypesCommand.swift b/Sources/CLI/cmd/types/TypesCommand.swift index 423be34..504be6c 100644 --- a/Sources/CLI/cmd/types/TypesCommand.swift +++ b/Sources/CLI/cmd/types/TypesCommand.swift @@ -1,6 +1,8 @@ import ArgumentParser -struct TypesCommand: AsyncParsableCommand { +struct TypesCommand: AsyncParsableCommand, GlobalOptionsProviding { + @OptionGroup var global: GlobalOptions + static let configuration = CommandConfiguration( commandName: "types", abstract: "Work with Apple documentation types.", diff --git a/Sources/CLI/cmd/types/TypesListCommand.swift b/Sources/CLI/cmd/types/TypesListCommand.swift index 089eb12..4132942 100644 --- a/Sources/CLI/cmd/types/TypesListCommand.swift +++ b/Sources/CLI/cmd/types/TypesListCommand.swift @@ -5,7 +5,9 @@ import Logging @preconcurrency import SentrySwift #endif -struct TypesListCommand: AsyncParsableCommand { +struct TypesListCommand: AsyncParsableCommand, GlobalOptionsProviding { + @OptionGroup var global: GlobalOptions + private static let logger = Logger( label: "com.techprimate.apple-docs.types-list" ) diff --git a/Sources/CLI/cmd/types/TypesSearchCommand.swift b/Sources/CLI/cmd/types/TypesSearchCommand.swift index 1100652..28cbe68 100644 --- a/Sources/CLI/cmd/types/TypesSearchCommand.swift +++ b/Sources/CLI/cmd/types/TypesSearchCommand.swift @@ -5,7 +5,9 @@ import Logging @preconcurrency import SentrySwift #endif -struct TypesSearchCommand: AsyncParsableCommand { +struct TypesSearchCommand: AsyncParsableCommand, GlobalOptionsProviding { + @OptionGroup var global: GlobalOptions + private static let logger = Logger( label: "com.techprimate.apple-docs.types-search" ) diff --git a/Sources/CLI/cmd/types/TypesViewCommand.swift b/Sources/CLI/cmd/types/TypesViewCommand.swift index 427abfc..df7eae0 100644 --- a/Sources/CLI/cmd/types/TypesViewCommand.swift +++ b/Sources/CLI/cmd/types/TypesViewCommand.swift @@ -5,7 +5,9 @@ import Logging @preconcurrency import SentrySwift #endif -struct TypesViewCommand: AsyncParsableCommand { +struct TypesViewCommand: AsyncParsableCommand, GlobalOptionsProviding { + @OptionGroup var global: GlobalOptions + private static let logger = Logger( label: "com.techprimate.apple-docs.types-view" ) diff --git a/Sources/CLI/main/AppleDocs.swift b/Sources/CLI/main/AppleDocs.swift index 63ace2a..c3f28ce 100644 --- a/Sources/CLI/main/AppleDocs.swift +++ b/Sources/CLI/main/AppleDocs.swift @@ -4,7 +4,6 @@ import Logging #if canImport(SentrySwift) @preconcurrency import SentrySwift - import SentrySwiftLog #endif @main @@ -14,21 +13,36 @@ enum AppleDocs { @MainActor static func main() async { let telemetryEnabled = configureTelemetry() + var loggingConfigured = false do { var command = try await CLI.asyncParseAsRoot() + LoggingConfiguration.bootstrap( + verbose: verboseLoggingEnabled(for: command), + telemetryEnabled: telemetryEnabled + ) + loggingConfigured = true + Self.logger.debug("CLI command parsed") if var asyncCommand = command as? any AsyncParsableCommand { try await asyncCommand.run() } else { try command.run() } + Self.logger.debug("CLI command finished") finishTelemetry(enabled: telemetryEnabled) } catch { + if !loggingConfigured { + LoggingConfiguration.bootstrap(verbose: false, telemetryEnabled: telemetryEnabled) + } captureTelemetry(error, enabled: telemetryEnabled) CLI.exit(withError: error) } } + private static func verboseLoggingEnabled(for command: any ParsableCommand) -> Bool { + (command as? any GlobalOptionsProviding)?.global.verbose ?? false + } + private static func configureTelemetry() -> Bool { #if canImport(SentrySwift) let enabled = SentryConfiguration.isEnabled( @@ -38,19 +52,9 @@ enum AppleDocs { SentrySDK.start { options in SentryConfiguration.configure(options) } - LoggingSystem.bootstrap { _ in - SentryLogHandler(logLevel: .info) - } - } else { - LoggingSystem.bootstrap { _ in - SwiftLogNoOpLogHandler() - } } return enabled #else - LoggingSystem.bootstrap { _ in - SwiftLogNoOpLogHandler() - } return false #endif } diff --git a/Sources/CLI/main/CLI.swift b/Sources/CLI/main/CLI.swift index 7271d4e..51e20ca 100644 --- a/Sources/CLI/main/CLI.swift +++ b/Sources/CLI/main/CLI.swift @@ -1,6 +1,8 @@ import ArgumentParser -struct CLI: AsyncParsableCommand { +struct CLI: AsyncParsableCommand, GlobalOptionsProviding { + @OptionGroup var global: GlobalOptions + static let configuration = CommandConfiguration( commandName: "apple-docs", abstract: "Access Apple developer documentation from the command line.", diff --git a/Sources/CLI/main/GlobalOptions.swift b/Sources/CLI/main/GlobalOptions.swift new file mode 100644 index 0000000..accb61d --- /dev/null +++ b/Sources/CLI/main/GlobalOptions.swift @@ -0,0 +1,10 @@ +import ArgumentParser + +struct GlobalOptions: ParsableArguments { + @Flag(help: "Show debug and higher-level logs on stderr.") + var verbose = false +} + +protocol GlobalOptionsProviding { + var global: GlobalOptions { get } +} diff --git a/Sources/CLI/main/LoggingConfiguration.swift b/Sources/CLI/main/LoggingConfiguration.swift new file mode 100644 index 0000000..f9957d0 --- /dev/null +++ b/Sources/CLI/main/LoggingConfiguration.swift @@ -0,0 +1,40 @@ +import Logging + +#if canImport(SentrySwift) + import SentrySwiftLog +#endif + +enum LoggingConfiguration { + static func bootstrap(verbose: Bool, telemetryEnabled: Bool) { + LoggingSystem.bootstrap { label in + var telemetry: (any LogHandler)? + #if canImport(SentrySwift) + if telemetryEnabled { + telemetry = SentryLogHandler(logLevel: .info) + } + #endif + return handler( + console: StreamLogHandler.standardError(label: label), + telemetry: telemetry, + verbose: verbose + ) + } + } + + static func handler( + console: any LogHandler, telemetry: (any LogHandler)?, verbose: Bool + ) -> any LogHandler { + var handlers: [any LogHandler] = [] + if verbose { + var console = console + console.logLevel = .debug + handlers.append(console) + } + if var telemetry { + telemetry.logLevel = .info + handlers.append(telemetry) + } + guard !handlers.isEmpty else { return SwiftLogNoOpLogHandler() } + return MultiplexLogHandler(handlers) + } +} diff --git a/Tests/CLITests/client/ClientLogRecorder.swift b/Tests/CLITests/client/ClientLogRecorder.swift index 03416f3..340fe17 100644 --- a/Tests/CLITests/client/ClientLogRecorder.swift +++ b/Tests/CLITests/client/ClientLogRecorder.swift @@ -11,10 +11,14 @@ final class ClientLogRecorder: Sendable { func logger() -> Logger { Logger(label: "test.client") { _ in - RecordingHandler(recorder: self) + self.handler() } } + func handler() -> any LogHandler { + RecordingHandler(recorder: self) + } + private func append(_ event: LogEvent) { storage.withLock { $0.append(event) } } diff --git a/Tests/CLITests/main/CLITests.swift b/Tests/CLITests/main/CLITests.swift index 0e040d8..8fab04d 100644 --- a/Tests/CLITests/main/CLITests.swift +++ b/Tests/CLITests/main/CLITests.swift @@ -4,6 +4,78 @@ import Testing @Suite("CLI help") struct CLITests { + @Test( + "accepts verbose at every command depth", + arguments: [ + ["--verbose", "agent", "skills", "list"], + ["agent", "--verbose", "skills", "list"], + ["agent", "skills", "--verbose", "list"], + ["agent", "skills", "list", "--verbose"], + ]) + func acceptsVerboseAtEveryDepth(arguments: [String]) throws { + // -- Arrange -- + let expectedType = AgentSkillsListCommand.self + + // -- Act -- + let command = try CLI.parseAsRoot(arguments) + + // -- Assert -- + #expect(type(of: command) == expectedType) + let options = try #require(command as? any GlobalOptionsProviding) + #expect(options.global.verbose) + } + + @Test( + "accepts verbose on each executable command", + arguments: [ + ["types", "view", "String", "--technology", "Swift", "--json"], + ["types", "list", "--technology", "Swift", "--agent"], + ["types", "search", "String", "--technology", "Swift"], + ["technologies", "list", "--json"], + ["cache", "clean"], + ["agent", "skills", "get", "apple-docs"], + ["agent", "skills", "list"], + ["agent", "skills", "install", "apple-docs", "--dry-run"], + ["agent", "skills", "uninstall", "apple-docs", "--dry-run"], + ]) + func acceptsVerboseOnEachCommand(arguments: [String]) throws { + // -- Arrange -- + let arguments = arguments + ["--verbose"] + + // -- Act -- + let command = try CLI.parseAsRoot(arguments) + + // -- Assert -- + #expect(type(of: command).configuration.subcommands.isEmpty) + let options = try #require(command as? any GlobalOptionsProviding) + #expect(options.global.verbose) + } + + @Test("does not treat positional values after the terminator as flags") + func respectsArgumentTerminator() throws { + // -- Arrange -- + let arguments = ["agent", "skills", "get", "--", "--verbose"] + + // -- Act -- + let command = try #require(CLI.parseAsRoot(arguments) as? AgentSkillsGetCommand) + + // -- Assert -- + #expect(command.name == "--verbose") + #expect(!command.global.verbose) + } + + @Test("leaves verbose disabled by default") + func defaultsToQuietLogging() throws { + // -- Arrange -- + let arguments = ["agent", "skills", "list"] + + // -- Act -- + let command = try #require(CLI.parseAsRoot(arguments) as? AgentSkillsListCommand) + + // -- Assert -- + #expect(!command.global.verbose) + } + @Test("directs agents to bundled skills") func includesAgentSkillsHint() { #expect( diff --git a/Tests/CLITests/main/LoggingConfigurationTests.swift b/Tests/CLITests/main/LoggingConfigurationTests.swift new file mode 100644 index 0000000..e3034fd --- /dev/null +++ b/Tests/CLITests/main/LoggingConfigurationTests.swift @@ -0,0 +1,49 @@ +import Logging +import Testing + +@testable import CLI + +@Suite("Global logging configuration") +struct LoggingConfigurationTests { + @available(macOS 15, *) + @Test("verbose controls console logs independently of telemetry", arguments: [false, true]) + func routesTelemetryIndependently(verbose: Bool) { + // -- Arrange -- + let console = ClientLogRecorder() + let telemetry = ClientLogRecorder() + let logger = Logger(label: "test") { _ in + LoggingConfiguration.handler(console: console.handler(), telemetry: telemetry.handler(), verbose: verbose) + } + + // -- Act -- + for level in [Logger.Level.trace, .debug, .info, .notice, .warning, .error, .critical] { + logger.log(level: level, "Test event", metadata: ["request": "test"]) + } + + // -- Assert -- + let expectedConsole: [Logger.Level] = verbose ? [.debug, .info, .notice, .warning, .error, .critical] : [] + #expect(console.events.map(\.level) == expectedConsole) + #expect(telemetry.events.map(\.level) == [.info, .notice, .warning, .error, .critical]) + #expect(telemetry.events.allSatisfy { $0.metadata?["request"]?.description == "test" }) + } + + @available(macOS 15, *) + @Test("verbose works with telemetry disabled and stays quiet by default", arguments: [false, true]) + func routesWithoutTelemetry(verbose: Bool) { + // -- Arrange -- + let console = ClientLogRecorder() + let logger = Logger(label: "test") { _ in + LoggingConfiguration.handler(console: console.handler(), telemetry: nil, verbose: verbose) + } + + // -- Act -- + for level in [Logger.Level.trace, .debug, .info, .notice, .warning, .error, .critical] { + logger.log(level: level, "Test event", metadata: ["request": "test"]) + } + + // -- Assert -- + let expected: [Logger.Level] = verbose ? [.debug, .info, .notice, .warning, .error, .critical] : [] + #expect(console.events.map(\.level) == expected) + #expect(console.events.allSatisfy { $0.metadata?["request"]?.description == "test" }) + } +}