Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Sources/CLI/cmd/agent/skills/AgentSkillsInstallCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import ArgumentParser

struct AgentSkillsInstallCommand: ParsableCommand, GlobalOptionsProviding {
#if DEBUG
typealias Deps = any AgentSkillInstallerProvider
typealias Deps = any AgentSkillInstallationServiceProvider
#else
typealias Deps = Dependencies
#endif
Expand Down Expand Up @@ -39,7 +39,7 @@ struct AgentSkillsInstallCommand: ParsableCommand, GlobalOptionsProviding {
}

func run(deps: Deps) throws {
let output = try deps.agentSkillInstaller().install(
let output = try deps.agentSkillInstallationService().install(
selection.selectedSkills(), root: selection.installationRoot(fileManager: deps.agentSkillFileManager()),
dryRun: selection.dryRun, force: force
)
Expand Down
4 changes: 2 additions & 2 deletions Sources/CLI/cmd/agent/skills/AgentSkillsListCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import ArgumentParser

struct AgentSkillsListCommand: ParsableCommand, GlobalOptionsProviding {
#if DEBUG
typealias Deps = any TelemetryProvider
typealias Deps = any (TelemetryProvider & CommandOutputWriterProvider)
#else
typealias Deps = Dependencies
#endif
Expand All @@ -21,7 +21,7 @@ struct AgentSkillsListCommand: ParsableCommand, GlobalOptionsProviding {
func run(deps: Deps) {
deps.telemetry.startCommand(.agentSkillsList)
for skill in BundledAgentSkills.all {
print("\(skill.name)\t\(skill.shortDescription)")
deps.commandOutputWriter.write("\(skill.name)\t\(skill.shortDescription)")
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ struct AgentSkillsSelectionOptions: ParsableArguments {
}
}

func installationRoot(fileManager: FileManager) -> String {
func installationRoot(fileManager: AgentSkillFileSystem) -> String {
if project {
let currentDirectory = URL(fileURLWithPath: fileManager.currentDirectoryPath).standardizedFileURL
var directory = currentDirectory
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import ArgumentParser

struct AgentSkillsUninstallCommand: ParsableCommand, GlobalOptionsProviding {
#if DEBUG
typealias Deps = any AgentSkillInstallerProvider
typealias Deps = any AgentSkillInstallationServiceProvider
#else
typealias Deps = Dependencies
#endif
Expand Down Expand Up @@ -42,7 +42,7 @@ struct AgentSkillsUninstallCommand: ParsableCommand, GlobalOptionsProviding {
}

func run(deps: Deps) throws {
let output = try deps.agentSkillInstaller().uninstall(
let output = try deps.agentSkillInstallationService().uninstall(
selection.selectedSkills(), root: selection.installationRoot(fileManager: deps.agentSkillFileManager()),
dryRun: selection.dryRun
)
Expand Down
4 changes: 2 additions & 2 deletions Sources/CLI/cmd/cache/CacheCleanCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import ArgumentParser

struct CacheCleanCommand: ParsableCommand, GlobalOptionsProviding {
#if DEBUG
typealias Deps = any (TelemetryProvider & DocumentationCacheProvider)
typealias Deps = any (TelemetryProvider & DocumentationCacheProvider & CommandOutputWriterProvider)
#else
typealias Deps = Dependencies
#endif
Expand All @@ -24,6 +24,6 @@ struct CacheCleanCommand: ParsableCommand, GlobalOptionsProviding {
cache: deps.documentationCache
)
let result = runner.run()
print(result.output)
deps.commandOutputWriter.write(result.output)
}
}
4 changes: 2 additions & 2 deletions Sources/CLI/cmd/technologies/TechnologiesListCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ struct TechnologiesListCommand: AsyncParsableCommand, GlobalOptionsProviding {
#if DEBUG
typealias Deps = any (
TelemetryProvider & TerminalCapabilitiesProvider & TechnologyCatalogClientProvider
& TechnologyListRendererProvider
& TechnologyListRendererProvider & CommandOutputWriterProvider
)
#else
typealias Deps = Dependencies
Expand All @@ -31,6 +31,6 @@ struct TechnologiesListCommand: AsyncParsableCommand, GlobalOptionsProviding {
)
let result = try await runner.run(mode: deps.terminalCapabilities.mode(for: output))
deps.telemetry.record(.technologyCatalog(count: result.technologyCount), context: context)
print(result.output)
deps.commandOutputWriter.write(result.output)
}
}
4 changes: 2 additions & 2 deletions Sources/CLI/cmd/types/TypesListCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ struct TypesListCommand: AsyncParsableCommand, GlobalOptionsProviding {
#if DEBUG
typealias Deps = any (
TelemetryProvider & TerminalCapabilitiesProvider & DocumentationTypeCatalogClientProvider
& DocumentationTypeListRendererProvider
& DocumentationTypeListRendererProvider & CommandOutputWriterProvider
)
#else
typealias Deps = Dependencies
Expand Down Expand Up @@ -34,6 +34,6 @@ struct TypesListCommand: AsyncParsableCommand, GlobalOptionsProviding {
)
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)
deps.commandOutputWriter.write(result.output)
}
}
6 changes: 3 additions & 3 deletions Sources/CLI/cmd/types/TypesSearchCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ struct TypesSearchCommand: AsyncParsableCommand, GlobalOptionsProviding {
#if DEBUG
typealias Deps = any (
TelemetryProvider & TerminalCapabilitiesProvider & DocumentationTypeSearchClientProvider
& DocumentationTypeListRendererProvider
& DocumentationTypeListRendererProvider & CommandOutputWriterProvider
)
#else
typealias Deps = Dependencies
Expand Down Expand Up @@ -44,8 +44,8 @@ struct TypesSearchCommand: AsyncParsableCommand, GlobalOptionsProviding {
let warning =
"Warning: search results are incomplete. "
+ "\(result.unavailableCollectionCount) collections were unavailable.\n"
FileHandle.standardError.write(Data(warning.utf8))
deps.commandOutputWriter.writeWarning(warning)
}
print(result.output)
deps.commandOutputWriter.write(result.output)
}
}
4 changes: 2 additions & 2 deletions Sources/CLI/cmd/types/TypesViewCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ struct TypesViewCommand: AsyncParsableCommand, GlobalOptionsProviding {
#if DEBUG
typealias Deps = any (
TelemetryProvider & TerminalCapabilitiesProvider & AppleDocumentationClientProvider
& TypeDocumentationRendererProvider
& TypeDocumentationRendererProvider & CommandOutputWriterProvider
)
#else
typealias Deps = Dependencies
Expand Down Expand Up @@ -38,6 +38,6 @@ struct TypesViewCommand: AsyncParsableCommand, GlobalOptionsProviding {
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)
deps.commandOutputWriter.write(result.output)
}
}
29 changes: 29 additions & 0 deletions Sources/CLI/main/CommandOutputWriter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import Foundation

#if DEBUG
protocol CommandOutputWriting {
func write(_ line: String)
func writeWarning(_ warning: String)
}

extension DefaultCommandOutputWriter: CommandOutputWriting {}

protocol CommandOutputWriterProvider {
associatedtype OutputWriter: CommandOutputWriting
var commandOutputWriter: OutputWriter { get }
}

extension Dependencies: CommandOutputWriterProvider {}
#else
typealias CommandOutputWriting = DefaultCommandOutputWriter
#endif

struct DefaultCommandOutputWriter {
func write(_ line: String) {
print(line)
}

func writeWarning(_ warning: String) {
FileHandle.standardError.write(Data(warning.utf8))
}
}
8 changes: 6 additions & 2 deletions Sources/CLI/main/Dependencies.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ struct Dependencies: Sendable {
static let shared = Dependencies()

let terminalCapabilities = DefaultTerminalCapabilities()
let commandOutputWriter = DefaultCommandOutputWriter()
let terminalSetup = TerminalSetup()
let telemetry = DefaultTelemetry(
// Telemetry starts before SwiftLog is bootstrapped. Resolve its logger only when logging an event.
Expand Down Expand Up @@ -50,8 +51,11 @@ struct Dependencies: Sendable {
.default
}

func agentSkillInstaller() -> AgentSkillInstaller {
AgentSkillInstaller(logger: Logger(label: "com.techprimate.apple-docs.skills.installer"))
func agentSkillInstallationService() -> DefaultAgentSkillInstallationService {
DefaultAgentSkillInstallationService(
logger: Logger(label: "com.techprimate.apple-docs.skills.installer"),
fileManager: agentSkillFileManager()
)
}

func documentationRenderer(output: OutputOptions) -> DefaultTypeDocumentationRenderer {
Expand Down
20 changes: 20 additions & 0 deletions Sources/CLI/skills/AgentSkillFileSystem.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import Foundation

#if DEBUG
protocol AgentSkillFileSystem {
var currentDirectoryPath: String { get }
var homeDirectoryForCurrentUser: URL { get }
func fileExists(atPath path: String) -> Bool
func createDirectory(
at url: URL, withIntermediateDirectories createIntermediates: Bool,
attributes: [FileAttributeKey: Any]?
) throws
func removeItem(at url: URL) throws
func contentsOfDirectory(atPath path: String) throws -> [String]
func attributesOfItem(atPath path: String) throws -> [FileAttributeKey: Any]
}

extension FileManager: AgentSkillFileSystem {}
#else
typealias AgentSkillFileSystem = FileManager
#endif
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import Foundation
import Logging

/// Owns only SKILL.md and its installation receipt, never an entire skill directory.
struct AgentSkillInstaller {
struct DefaultAgentSkillInstallationService {
private struct Receipt: Codable {
let name: String
let content: Data
Expand All @@ -18,10 +18,11 @@ struct AgentSkillInstaller {
let receipt: Receipt?
}

private let fileManager = FileManager.default
private let fileManager: AgentSkillFileSystem
private let logger: Logger

init(logger: Logger) {
init(logger: Logger, fileManager: AgentSkillFileSystem = FileManager.default) {
self.fileManager = fileManager
self.logger = logger
logger.trace("Initialized agent skill installer")
}
Expand Down Expand Up @@ -116,7 +117,8 @@ struct AgentSkillInstaller {
)
}
if !dryRun {
try fileManager.createDirectory(at: installation.directory, withIntermediateDirectories: true)
try fileManager.createDirectory(
at: installation.directory, withIntermediateDirectories: true, attributes: nil)
try content.write(to: installation.file, options: .atomic)
let receipt = Receipt(name: installation.skill.name, content: content)
try JSONEncoder().encode(receipt).write(to: installation.receiptFile, options: .atomic)
Expand Down Expand Up @@ -221,18 +223,21 @@ struct AgentSkillInstaller {
}

#if DEBUG
protocol AgentSkillInstalling {
protocol AgentSkillInstallationService {
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 {}
extension DefaultAgentSkillInstallationService: AgentSkillInstallationService {}

protocol AgentSkillInstallerProvider {
associatedtype Installer: AgentSkillInstalling
func agentSkillInstaller() -> Installer
func agentSkillFileManager() -> FileManager
protocol AgentSkillInstallationServiceProvider {
associatedtype Service: AgentSkillInstallationService
associatedtype FileSystem: AgentSkillFileSystem
func agentSkillInstallationService() -> Service
func agentSkillFileManager() -> FileSystem
}

extension Dependencies: AgentSkillInstallerProvider {}
extension Dependencies: AgentSkillInstallationServiceProvider {}
#else
typealias AgentSkillInstallationService = DefaultAgentSkillInstallationService
#endif
22 changes: 22 additions & 0 deletions Tests/CLITests/cmd/CommandTestDoubles.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import Logging

@testable import CLI

final class RecordingCommandOutputWriter: CommandOutputWriting {
private(set) var lines: [String] = []
private(set) var warnings: [String] = []

func write(_ line: String) { lines.append(line) }
func writeWarning(_ warning: String) { warnings.append(warning) }
}

final class RecordingCommandTelemetry: Telemetry, @unchecked Sendable {
private(set) var commands: [String] = []
private(set) var metrics: [TelemetryMetric] = []

func start() {}
func startCommand(_ context: TelemetryCommandContext) { commands.append(context.command) }
func record(_ metric: TelemetryMetric, context: TelemetryCommandContext) { metrics.append(metric) }
func finishCommand(error: (any Error)?) {}
func makeLogHandler() -> (any LogHandler)? { nil }
}
39 changes: 34 additions & 5 deletions Tests/CLITests/cmd/agent/skills/AgentSkillsCommandTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,49 @@ import Testing

@Suite("Agent skills commands")
struct AgentSkillsCommandTests {
@Test("list writes the bundled skill names through the injected output")
func writesSkillList() throws {
// -- Arrange --
let command = try AgentSkillsListCommand.parse([])
let deps = SkillListDependencies()

// -- Act --
command.run(deps: deps)

// -- Assert --
#expect(deps.output.lines.count == BundledAgentSkills.all.count)
#expect(deps.output.lines.first?.hasPrefix("apple-docs\t") == true)
#expect(deps.telemetry.commands == ["agent.skills.list"])
}

@Test("registers the nested list command")
func parsesListCommand() throws {
let command = try CLI.parseAsRoot(["agent", "skills", "list"])
// -- Arrange --
let arguments = ["agent", "skills", "list"]

// -- Act --
let command = try CLI.parseAsRoot(arguments)

// -- Assert --
#expect(command is AgentSkillsListCommand)
}

@Test("registers the nested get command with a skill name")
func parsesGetCommand() throws {
let command = try CLI.parseAsRoot([
"agent", "skills", "get", "apple-docs",
])
let getCommand = try #require(command as? AgentSkillsGetCommand)
// -- Arrange --
let arguments = ["agent", "skills", "get", "apple-docs"]

// -- Act --
let command = try CLI.parseAsRoot(arguments)

// -- Assert --
let getCommand = try #require(command as? AgentSkillsGetCommand)
#expect(getCommand.name == "apple-docs")
}
}

private struct SkillListDependencies: TelemetryProvider, CommandOutputWriterProvider {
let telemetry = RecordingCommandTelemetry()
let output = RecordingCommandOutputWriter()
var commandOutputWriter: RecordingCommandOutputWriter { output }
}
Loading
Loading