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/AgentSkillsGetCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import ArgumentParser

struct AgentSkillsGetCommand: ParsableCommand, GlobalOptionsProviding {
#if DEBUG
typealias Deps = any TelemetryProvider
typealias Deps = any (TelemetryProvider & CommandOutputWriterProvider)
#else
typealias Deps = Dependencies
#endif
Expand All @@ -26,6 +26,6 @@ struct AgentSkillsGetCommand: ParsableCommand, GlobalOptionsProviding {
guard let skill = BundledAgentSkills.skill(named: name) else {
throw ValidationError("Unknown bundled Agent Skill '\(name)'.")
}
print(skill.content)
deps.commandOutputWriter.write(skill.content)
}
}
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 AgentSkillInstallationServiceProvider
typealias Deps = any (AgentSkillInstallationServiceProvider & CommandOutputWriterProvider)
#else
typealias Deps = Dependencies
#endif
Expand Down Expand Up @@ -43,6 +43,6 @@ struct AgentSkillsInstallCommand: ParsableCommand, GlobalOptionsProviding {
selection.selectedSkills(), root: selection.installationRoot(fileManager: deps.agentSkillFileManager()),
dryRun: selection.dryRun, force: force
)
print(output)
deps.commandOutputWriter.write(output)
}
}
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 AgentSkillInstallationServiceProvider
typealias Deps = any (AgentSkillInstallationServiceProvider & CommandOutputWriterProvider)
#else
typealias Deps = Dependencies
#endif
Expand Down Expand Up @@ -46,6 +46,6 @@ struct AgentSkillsUninstallCommand: ParsableCommand, GlobalOptionsProviding {
selection.selectedSkills(), root: selection.installationRoot(fileManager: deps.agentSkillFileManager()),
dryRun: selection.dryRun
)
print(output)
deps.commandOutputWriter.write(output)
}
}
12 changes: 12 additions & 0 deletions Sources/CLI/skills/AgentSkillFileSystem.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
import Foundation

extension FileManager {
func readSkillData(at url: URL) throws -> Data {
try Data(contentsOf: url)
}

func writeSkillData(_ data: Data, to url: URL) throws {
try data.write(to: url, options: .atomic)
}
}

#if DEBUG
protocol AgentSkillFileSystem {
var currentDirectoryPath: String { get }
Expand All @@ -12,6 +22,8 @@ import Foundation
func removeItem(at url: URL) throws
func contentsOfDirectory(atPath path: String) throws -> [String]
func attributesOfItem(atPath path: String) throws -> [FileAttributeKey: Any]
func readSkillData(at url: URL) throws -> Data
func writeSkillData(_ data: Data, to url: URL) throws
}

extension FileManager: AgentSkillFileSystem {}
Expand Down
6 changes: 3 additions & 3 deletions Sources/CLI/skills/AgentSkillInstallationService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -119,9 +119,9 @@ struct DefaultAgentSkillInstallationService {
if !dryRun {
try fileManager.createDirectory(
at: installation.directory, withIntermediateDirectories: true, attributes: nil)
try content.write(to: installation.file, options: .atomic)
try fileManager.writeSkillData(content, to: installation.file)
let receipt = Receipt(name: installation.skill.name, content: content)
try JSONEncoder().encode(receipt).write(to: installation.receiptFile, options: .atomic)
try fileManager.writeSkillData(JSONEncoder().encode(receipt), to: installation.receiptFile)
}
logger.info(dryRun ? "Would install skill" : "Installed skill", metadata: metadata)
return "\(dryRun ? "Would install" : "Installed"): \(installation.skill.name) at \(installation.file.path)"
Expand Down Expand Up @@ -208,7 +208,7 @@ struct DefaultAgentSkillInstallationService {
metadata: ["file": .string(url.lastPathComponent)])
throw ValidationError("Expected a regular file, not a symlink or directory, at '\(url.path)'.")
}
return try Data(contentsOf: url)
return try fileManager.readSkillData(at: url)
}

private func fileType(_ url: URL) throws -> FileAttributeType? {
Expand Down
14 changes: 14 additions & 0 deletions Tests/CLITests/cmd/agent/skills/AgentSkillsCommandTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,20 @@ struct AgentSkillsCommandTests {
#expect(deps.telemetry.commands == ["agent.skills.list"])
}

@Test("get writes bundled skill content through the injected output")
func writesSkillContent() throws {
// -- Arrange --
let command = try AgentSkillsGetCommand.parse(["apple-docs"])
let deps = SkillListDependencies()

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

// -- Assert --
#expect(deps.output.lines == [BundledAgentSkills.skill(named: "apple-docs")?.content])
#expect(deps.telemetry.commands == ["agent.skills.get"])
}

@Test("registers the nested list command")
func parsesListCommand() throws {
// -- Arrange --
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ struct AgentSkillsProjectInstallationTests {
#expect(
FileManager.default.fileExists(
atPath: home.appendingPathComponent(".agents/skills/apple-docs/SKILL.md").path))
#expect(
deps.output.lines == [
"Installed: apple-docs at \(home.appendingPathComponent(".agents/skills/apple-docs/SKILL.md").path)"
])
}

@Test("default uninstall uses the file manager's home directory")
Expand All @@ -43,6 +47,7 @@ struct AgentSkillsProjectInstallationTests {
#expect(
!FileManager.default.fileExists(
atPath: home.appendingPathComponent(".agents/skills/apple-docs/SKILL.md").path))
#expect(deps.output.lines.last == "Uninstalled: apple-docs")
}

@Test("project installation targets the Git root from a nested directory")
Expand Down Expand Up @@ -182,8 +187,10 @@ struct AgentSkillsProjectInstallationTests {
}
}

private struct TestAgentSkillDependencies: AgentSkillInstallationServiceProvider {
private struct TestAgentSkillDependencies: AgentSkillInstallationServiceProvider, CommandOutputWriterProvider {
let fileManager: FileManager
let output = RecordingCommandOutputWriter()
var commandOutputWriter: RecordingCommandOutputWriter { output }

func agentSkillFileManager() -> FileManager { fileManager }

Expand Down
87 changes: 87 additions & 0 deletions Tests/CLITests/skills/AgentSkillFileSystemTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import Foundation
import Logging
import Testing

@testable import CLI

@Suite("Agent skill file access")
struct AgentSkillFileSystemTests {
@Test("installation uses injected atomic file writes")
func usesInjectedWriter() throws {
// -- Arrange --
let root = temporaryRoot()
defer { try? FileManager.default.removeItem(at: root) }
let skill = try #require(BundledAgentSkills.skill(named: "apple-docs"))
let service = DefaultAgentSkillInstallationService(
logger: Logger(label: "test"), fileManager: FailingDataAccessFileSystem(denied: .write))
let file = root.appendingPathComponent("skills/apple-docs/SKILL.md")

// -- Act --
#expect(throws: DataAccessDenied.self) {
_ = try service.install([skill], root: root.path, dryRun: false, force: false)
}

// -- Assert --
#expect(!FileManager.default.fileExists(atPath: file.path))
}

@Test("uninstallation uses injected file reads before deleting")
func usesInjectedReader() throws {
// -- Arrange --
let root = temporaryRoot()
defer { try? FileManager.default.removeItem(at: root) }
let skill = try #require(BundledAgentSkills.skill(named: "apple-docs"))
let installed = DefaultAgentSkillInstallationService(logger: Logger(label: "test"))
_ = try installed.install([skill], root: root.path, dryRun: false, force: false)
let service = DefaultAgentSkillInstallationService(
logger: Logger(label: "test"), fileManager: FailingDataAccessFileSystem(denied: .read))
let file = root.appendingPathComponent("skills/apple-docs/SKILL.md")

// -- Act --
#expect(throws: DataAccessDenied.self) {
_ = try service.uninstall([skill], root: root.path, dryRun: false)
}

// -- Assert --
#expect(FileManager.default.fileExists(atPath: file.path))
}

private func temporaryRoot() -> URL {
FileManager.default.temporaryDirectory.resolvingSymlinksInPath()
.appendingPathComponent(UUID().uuidString)
}
}

private enum DataAccessDenied: Error {
case read, write
}

private struct FailingDataAccessFileSystem: AgentSkillFileSystem {
let denied: DataAccessDenied
private let fileManager = FileManager.default

var currentDirectoryPath: String { fileManager.currentDirectoryPath }
var homeDirectoryForCurrentUser: URL { fileManager.homeDirectoryForCurrentUser }
func fileExists(atPath path: String) -> Bool { fileManager.fileExists(atPath: path) }
func createDirectory(
at url: URL, withIntermediateDirectories createIntermediates: Bool, attributes: [FileAttributeKey: Any]?
) throws {
try fileManager.createDirectory(
at: url, withIntermediateDirectories: createIntermediates, attributes: attributes)
}
func removeItem(at url: URL) throws { try fileManager.removeItem(at: url) }
func contentsOfDirectory(atPath path: String) throws -> [String] {
try fileManager.contentsOfDirectory(atPath: path)
}
func attributesOfItem(atPath path: String) throws -> [FileAttributeKey: Any] {
try fileManager.attributesOfItem(atPath: path)
}
func readSkillData(at url: URL) throws -> Data {
if denied == .read { throw DataAccessDenied.read }
return try Data(contentsOf: url)
}
func writeSkillData(_ data: Data, to url: URL) throws {
if denied == .write { throw DataAccessDenied.write }
try data.write(to: url, options: .atomic)
}
}
3 changes: 2 additions & 1 deletion Tests/CLITests/telemetry/TelemetryTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,9 @@ struct TelemetryTests {
}
}

private struct CommandDeps: TelemetryProvider {
private struct CommandDeps: TelemetryProvider, CommandOutputWriterProvider {
let telemetry: CommandTelemetryRecorder
let commandOutputWriter = RecordingCommandOutputWriter()
}

private final class CommandTelemetryRecorder: Telemetry, @unchecked Sendable {
Expand Down
Loading