diff --git a/Sources/CLI/cmd/agent/skills/AgentSkillsInstallCommand.swift b/Sources/CLI/cmd/agent/skills/AgentSkillsInstallCommand.swift index 578d924..b412da8 100644 --- a/Sources/CLI/cmd/agent/skills/AgentSkillsInstallCommand.swift +++ b/Sources/CLI/cmd/agent/skills/AgentSkillsInstallCommand.swift @@ -26,7 +26,7 @@ struct AgentSkillsInstallCommand: ParsableCommand { } mutating func run() throws { - let output = try AgentSkillInstaller().install( + let output = try Dependencies.agentSkillInstaller().install( selection.selectedSkills(), root: selection.dir, dryRun: selection.dryRun, force: force ) print(output) diff --git a/Sources/CLI/cmd/agent/skills/AgentSkillsUninstallCommand.swift b/Sources/CLI/cmd/agent/skills/AgentSkillsUninstallCommand.swift index fb7213e..f8922d5 100644 --- a/Sources/CLI/cmd/agent/skills/AgentSkillsUninstallCommand.swift +++ b/Sources/CLI/cmd/agent/skills/AgentSkillsUninstallCommand.swift @@ -29,7 +29,7 @@ struct AgentSkillsUninstallCommand: ParsableCommand { } mutating func run() throws { - let output = try AgentSkillInstaller().uninstall( + let output = try Dependencies.agentSkillInstaller().uninstall( selection.selectedSkills(), root: selection.dir, dryRun: selection.dryRun ) print(output) diff --git a/Sources/CLI/main/Dependencies.swift b/Sources/CLI/main/Dependencies.swift index ebdb12a..4dff129 100644 --- a/Sources/CLI/main/Dependencies.swift +++ b/Sources/CLI/main/Dependencies.swift @@ -51,6 +51,10 @@ enum Dependencies { dependencies: httpDataTransport ) + static func agentSkillInstaller() -> AgentSkillInstaller { + AgentSkillInstaller(logger: Logger(label: "com.techprimate.apple-docs.skills.installer")) + } + static func documentationRenderer( json: Bool ) -> DefaultTypeDocumentationRenderer { diff --git a/Sources/CLI/skills/AgentSkillInstaller.swift b/Sources/CLI/skills/AgentSkillInstaller.swift index 8d4a362..5d965e5 100644 --- a/Sources/CLI/skills/AgentSkillInstaller.swift +++ b/Sources/CLI/skills/AgentSkillInstaller.swift @@ -1,5 +1,6 @@ import ArgumentParser import Foundation +import Logging /// Owns only SKILL.md and its installation receipt, never an entire skill directory. struct AgentSkillInstaller { @@ -18,77 +19,143 @@ struct AgentSkillInstaller { } private let fileManager = FileManager.default + private let logger: Logger + + init(logger: Logger) { + self.logger = logger + logger.trace("Initialized agent skill installer") + } func install( _ skills: [BundledAgentSkill], root: String, dryRun: Bool, force: Bool ) throws -> String { - let installations = try inspect(skills, root: root) - for installation in installations { - if let content = installation.content { - guard installation.receipt != nil else { - throw ValidationError("Refusing to overwrite unmanaged skill '\(installation.skill.name)'.") - } - if content != Data(installation.skill.content.utf8), !force { - throw ValidationError( - "Skill '\(installation.skill.name)' differs. Use --force to replace the managed file." - ) + logger.debug( + "Installing agent skills", + metadata: [ + "count": .stringConvertible(skills.count), "dry_run": .stringConvertible(dryRun), + "force": .stringConvertible(force), + ]) + do { + let installations = try inspect(skills, root: root) + for installation in installations { + if let content = installation.content { + guard installation.receipt != nil else { + logger.warning( + "Refusing to overwrite unmanaged skill", + metadata: ["skill": .string(installation.skill.name)]) + throw ValidationError("Refusing to overwrite unmanaged skill '\(installation.skill.name)'.") + } + if content != Data(installation.skill.content.utf8), !force { + logger.warning( + "Managed skill differs, force required", + metadata: ["skill": .string(installation.skill.name)]) + throw ValidationError( + "Skill '\(installation.skill.name)' differs. Use --force to replace the managed file." + ) + } } } - } - - return try installations.map { installation in - let content = Data(installation.skill.content.utf8) - if installation.content == content, installation.receipt?.content == content { - return "Unchanged: \(installation.skill.name)" - } - if !dryRun { - try fileManager.createDirectory(at: installation.directory, withIntermediateDirectories: true) - 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) + logger.debug( + "Skill installation preflight completed", metadata: ["count": .stringConvertible(installations.count)]) + return try installations.map { try install($0, dryRun: dryRun, force: force) }.joined(separator: "\n") + } catch { + if !(error is ValidationError) { + logger.error( + "Skill installation failed", metadata: ["error_type": .string(String(reflecting: type(of: error)))]) } - return "\(dryRun ? "Would install" : "Installed"): \(installation.skill.name) at \(installation.file.path)" - }.joined(separator: "\n") + throw error + } } func uninstall(_ skills: [BundledAgentSkill], root: String, dryRun: Bool) throws -> String { - let installations = try inspect(skills, root: root) - for installation in installations { - if let content = installation.content { - guard let receipt = installation.receipt else { - throw ValidationError("Refusing to remove unmanaged skill '\(installation.skill.name)'.") - } - guard content == receipt.content else { - throw ValidationError( - "Skill '\(installation.skill.name)' was edited. Back up and restore it before uninstalling." - ) + logger.debug( + "Uninstalling agent skills", + metadata: [ + "count": .stringConvertible(skills.count), "dry_run": .stringConvertible(dryRun), + ]) + do { + let installations = try inspect(skills, root: root) + for installation in installations { + if let content = installation.content { + guard let receipt = installation.receipt else { + logger.warning( + "Refusing to remove unmanaged skill", metadata: ["skill": .string(installation.skill.name)]) + throw ValidationError("Refusing to remove unmanaged skill '\(installation.skill.name)'.") + } + guard content == receipt.content else { + logger.warning( + "Refusing to remove edited skill", metadata: ["skill": .string(installation.skill.name)]) + throw ValidationError( + "Skill '\(installation.skill.name)' was edited. Back up and restore it before uninstalling." + ) + } } } + logger.debug( + "Skill removal preflight completed", metadata: ["count": .stringConvertible(installations.count)]) + return try installations.map { try uninstall($0, dryRun: dryRun) }.joined(separator: "\n") + } catch { + if !(error is ValidationError) { + logger.error( + "Skill removal failed", metadata: ["error_type": .string(String(reflecting: type(of: error)))]) + } + throw error } + } - return try installations.map { installation in - guard installation.receipt != nil else { - return "Not installed: \(installation.skill.name)" + private func install(_ installation: Installation, dryRun: Bool, force: Bool) throws -> String { + let metadata: Logger.Metadata = ["skill": .string(installation.skill.name)] + let content = Data(installation.skill.content.utf8) + if installation.content == content, installation.receipt?.content == content { + logger.debug("Skill installation unchanged", metadata: metadata) + return "Unchanged: \(installation.skill.name)" + } + if force, let previousContent = installation.content, previousContent != content { + logger.notice( + dryRun ? "Would replace modified managed skill" : "Replacing modified managed skill", metadata: metadata + ) + } + if !dryRun { + try fileManager.createDirectory(at: installation.directory, withIntermediateDirectories: true) + 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) + } + logger.info(dryRun ? "Would install skill" : "Installed skill", metadata: metadata) + return "\(dryRun ? "Would install" : "Installed"): \(installation.skill.name) at \(installation.file.path)" + } + + private func uninstall(_ installation: Installation, dryRun: Bool) throws -> String { + let metadata: Logger.Metadata = ["skill": .string(installation.skill.name)] + guard installation.receipt != nil else { + logger.debug("Skill not installed", metadata: metadata) + return "Not installed: \(installation.skill.name)" + } + if !dryRun { + if installation.content != nil { + try fileManager.removeItem(at: installation.file) } - if !dryRun { - if installation.content != nil { - try fileManager.removeItem(at: installation.file) - } - try fileManager.removeItem(at: installation.receiptFile) - if try fileManager.contentsOfDirectory(atPath: installation.directory.path).isEmpty { - try fileManager.removeItem(at: installation.directory) - } + try fileManager.removeItem(at: installation.receiptFile) + if try fileManager.contentsOfDirectory(atPath: installation.directory.path).isEmpty { + try fileManager.removeItem(at: installation.directory) + logger.trace("Removed empty skill directory", metadata: metadata) + } else { + logger.debug("Preserved unrelated skill directory contents", metadata: metadata) } - return "\(dryRun ? "Would uninstall" : "Uninstalled"): \(installation.skill.name)" - }.joined(separator: "\n") + } + logger.info(dryRun ? "Would uninstall skill" : "Uninstalled skill", metadata: metadata) + return "\(dryRun ? "Would uninstall" : "Uninstalled"): \(installation.skill.name)" } private func inspect(_ skills: [BundledAgentSkill], root: String) throws -> [Installation] { + logger.trace("Inspecting skill installations", metadata: ["count": .stringConvertible(skills.count)]) guard !root.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + logger.warning("Empty skill installation root") throw ValidationError("The installation directory must not be empty.") } let rootURL = URL(fileURLWithPath: (root as NSString).expandingTildeInPath).standardizedFileURL return try skills.map { skill in + logger.debug("Inspecting skill installation", metadata: ["skill": .string(skill.name)]) let directory = rootURL.appendingPathComponent("skills").appendingPathComponent(skill.name) let file = directory.appendingPathComponent("SKILL.md") let receiptFile = directory.appendingPathComponent(".apple-docs-managed.json") @@ -100,10 +167,13 @@ struct AgentSkillInstaller { guard let decoded = try? JSONDecoder().decode(Receipt.self, from: receiptData), decoded.name == skill.name else { + logger.warning("Invalid skill installation receipt", metadata: ["skill": .string(skill.name)]) throw ValidationError("Invalid apple-docs installation receipt at '\(receiptFile.path)'.") } + logger.trace("Validated skill installation receipt", metadata: ["skill": .string(skill.name)]) receipt = decoded } else { + logger.trace("No skill installation receipt", metadata: ["skill": .string(skill.name)]) receipt = nil } return Installation( @@ -114,9 +184,11 @@ struct AgentSkillInstaller { } private func validateDirectory(_ directory: URL, root: URL) throws { + logger.trace("Validating skill directory ancestors") var ancestor = directory while true { if let type = try fileType(ancestor), type != .typeDirectory { + logger.warning("Refusing symlink or non-directory in skill installation path") throw ValidationError("Expected a directory, not a symlink or other file, at '\(ancestor.path)'.") } // Ancestors above the user-selected root may be OS aliases such as /var on macOS. @@ -126,17 +198,23 @@ struct AgentSkillInstaller { } private func readRegularFile(_ url: URL) throws -> Data? { + logger.trace("Reading skill installation file", metadata: ["file": .string(url.lastPathComponent)]) guard let type = try fileType(url) else { return nil } guard type == .typeRegular else { + logger.warning( + "Refusing symlink or non-regular skill installation file", + metadata: ["file": .string(url.lastPathComponent)]) throw ValidationError("Expected a regular file, not a symlink or directory, at '\(url.path)'.") } return try Data(contentsOf: url) } private func fileType(_ url: URL) throws -> FileAttributeType? { + logger.trace("Inspecting filesystem entry type") do { return try fileManager.attributesOfItem(atPath: url.path)[.type] as? FileAttributeType } catch let error as CocoaError where error.code == .fileReadNoSuchFile { + logger.trace("Filesystem entry absent") return nil } } diff --git a/Tests/CLITests/skills/AgentSkillInstallerTests.swift b/Tests/CLITests/skills/AgentSkillInstallerTests.swift new file mode 100644 index 0000000..3e29ff9 --- /dev/null +++ b/Tests/CLITests/skills/AgentSkillInstallerTests.swift @@ -0,0 +1,194 @@ +import ArgumentParser +import Foundation +import Logging +import Testing + +@testable import CLI + +@Suite("Agent skill installer logging") +struct AgentSkillInstallerTests { + @available(macOS 15, *) + @Test("logs installation, unchanged files, removal, and absent installations") + func logsLifecycle() throws { + // -- Arrange -- + let recorder = ClientLogRecorder() + let installer = AgentSkillInstaller(logger: recorder.logger()) + let root = temporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + let skill = try #require(BundledAgentSkills.skill(named: "apple-docs")) + + // -- Act -- + _ = try installer.install([skill], root: root.path, dryRun: false, force: false) + let installed = try String(contentsOf: skillFile(root), encoding: .utf8) + _ = try installer.install([skill], root: root.path, dryRun: false, force: false) + _ = try installer.uninstall([skill], root: root.path, dryRun: false) + _ = try installer.uninstall([skill], root: root.path, dryRun: false) + + // -- Assert -- + #expect(installed == skill.content) + #expect(!FileManager.default.fileExists(atPath: skillFile(root).path)) + let events = recorder.events + let installedEvent = try #require(events.first { $0.message.description == "Installed skill" }) + #expect(installedEvent.level == .info) + #expect(installedEvent.metadata?["skill"]?.description == "apple-docs") + #expect(events.contains { $0.level == .debug && $0.message.description == "Skill installation unchanged" }) + #expect(events.contains { $0.level == .info && $0.message.description == "Uninstalled skill" }) + #expect(events.contains { $0.level == .debug && $0.message.description == "Skill not installed" }) + #expect(events.contains { $0.level == .trace }) + #expect(!events.contains { $0.level >= .warning }) + for event in events { + let log = "\(event.message) \(event.metadata ?? [:])" + #expect(!log.contains(root.path)) + #expect(!log.contains(skill.content)) + } + } + + @available(macOS 15, *) + @Test("distinguishes dry runs from actual filesystem changes") + func logsDryRuns() throws { + // -- Arrange -- + let recorder = ClientLogRecorder() + let installer = AgentSkillInstaller(logger: recorder.logger()) + let root = temporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + let skill = try #require(BundledAgentSkills.skill(named: "apple-docs")) + + // -- Act -- + _ = try installer.install([skill], root: root.path, dryRun: true, force: false) + let rootExistsAfterPreview = FileManager.default.fileExists(atPath: root.path) + let previewEvents = recorder.events + _ = try installer.install([skill], root: root.path, dryRun: false, force: false) + let beforeUninstallPreview = recorder.events.count + _ = try installer.uninstall([skill], root: root.path, dryRun: true) + + // -- Assert -- + #expect(!rootExistsAfterPreview) + #expect(previewEvents.contains { $0.level == .info && $0.message.description == "Would install skill" }) + #expect(!previewEvents.contains { $0.message.description == "Installed skill" }) + #expect(FileManager.default.fileExists(atPath: skillFile(root).path)) + let removalEvents = recorder.events.dropFirst(beforeUninstallPreview) + #expect(removalEvents.contains { $0.level == .info && $0.message.description == "Would uninstall skill" }) + #expect(!removalEvents.contains { $0.message.description == "Uninstalled skill" }) + } + + @available(macOS 15, *) + @Test("warns about unmanaged files without overwriting or leaking their content", arguments: [false, true]) + func logsUnmanagedConflict(uninstall: Bool) throws { + // -- Arrange -- + let recorder = ClientLogRecorder() + let installer = AgentSkillInstaller(logger: recorder.logger()) + let root = temporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + let skill = try #require(BundledAgentSkills.skill(named: "apple-docs")) + try write("private user skill", to: skillFile(root)) + + // -- Act -- + #expect(throws: ValidationError.self) { + if uninstall { + _ = try installer.uninstall([skill], root: root.path, dryRun: false) + } else { + _ = try installer.install([skill], root: root.path, dryRun: false, force: true) + } + } + + // -- Assert -- + #expect(try String(contentsOf: skillFile(root), encoding: .utf8) == "private user skill") + #expect(recorder.events.contains { $0.level == .warning }) + #expect(!recorder.events.contains { $0.level >= .error || $0.level == .info }) + for event in recorder.events { + let log = "\(event.message) \(event.metadata ?? [:])" + #expect(!log.contains("private user skill")) + #expect(!log.contains(root.path)) + } + } + + @available(macOS 15, *) + @Test("logs forced replacement of modified managed files") + func logsForcedReplacement() throws { + // -- Arrange -- + let recorder = ClientLogRecorder() + let installer = AgentSkillInstaller(logger: recorder.logger()) + let root = temporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + let skill = try #require(BundledAgentSkills.skill(named: "apple-docs")) + _ = try installer.install([skill], root: root.path, dryRun: false, force: false) + try write("local edits", to: skillFile(root)) + let previousEventCount = recorder.events.count + + // -- Act -- + _ = try installer.install([skill], root: root.path, dryRun: false, force: true) + + // -- Assert -- + #expect(try String(contentsOf: skillFile(root), encoding: .utf8) == skill.content) + let events = recorder.events.dropFirst(previousEventCount) + #expect(events.contains { $0.level == .notice && $0.message.description == "Replacing modified managed skill" }) + #expect(events.contains { $0.level == .info && $0.message.description == "Installed skill" }) + } + + @available(macOS 15, *) + @Test("warns about invalid receipts before changing installed files") + func logsInvalidReceipt() throws { + // -- Arrange -- + let recorder = ClientLogRecorder() + let installer = AgentSkillInstaller(logger: recorder.logger()) + let root = temporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + let skill = try #require(BundledAgentSkills.skill(named: "apple-docs")) + _ = try installer.install([skill], root: root.path, dryRun: false, force: false) + try write( + "private invalid receipt", to: root.appendingPathComponent("skills/apple-docs/.apple-docs-managed.json")) + let previousEventCount = recorder.events.count + + // -- Act -- + #expect(throws: ValidationError.self) { + try installer.uninstall([skill], root: root.path, dryRun: false) + } + + // -- Assert -- + #expect(try String(contentsOf: skillFile(root), encoding: .utf8) == skill.content) + let events = recorder.events.dropFirst(previousEventCount) + #expect( + events.contains { $0.level == .warning && $0.message.description == "Invalid skill installation receipt" }) + #expect(!events.contains { $0.level >= .error || $0.level == .info }) + } + + @available(macOS 15, *) + @Test("logs filesystem failures as errors without exposing the installation path", arguments: [false, true]) + func logsFilesystemFailure(uninstall: Bool) throws { + // -- Arrange -- + let recorder = ClientLogRecorder() + let installer = AgentSkillInstaller(logger: recorder.logger()) + let root = temporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let invalidRoot = root.appendingPathComponent(String(repeating: "x", count: 300)) + let skill = try #require(BundledAgentSkills.skill(named: "apple-docs")) + + // -- Act -- + #expect(throws: (any Error).self) { + if uninstall { + _ = try installer.uninstall([skill], root: invalidRoot.path, dryRun: false) + } else { + _ = try installer.install([skill], root: invalidRoot.path, dryRun: false, force: false) + } + } + + // -- Assert -- + #expect(recorder.events.contains { $0.level == .error }) + #expect(!recorder.events.contains { $0.level == .info }) + #expect(!recorder.events.contains { "\($0.message) \($0.metadata ?? [:])".contains(root.path) }) + } + + private func temporaryRoot() -> URL { + FileManager.default.temporaryDirectory.resolvingSymlinksInPath().appendingPathComponent(UUID().uuidString) + } + + private func skillFile(_ root: URL) -> URL { + root.appendingPathComponent("skills/apple-docs/SKILL.md") + } + + private func write(_ content: String, to url: URL) throws { + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data(content.utf8).write(to: url) + } +}