diff --git a/Sources/CLI/cmd/agent/skills/AgentSkillsInstallCommand.swift b/Sources/CLI/cmd/agent/skills/AgentSkillsInstallCommand.swift index 8bed026..081ea75 100644 --- a/Sources/CLI/cmd/agent/skills/AgentSkillsInstallCommand.swift +++ b/Sources/CLI/cmd/agent/skills/AgentSkillsInstallCommand.swift @@ -13,13 +13,14 @@ struct AgentSkillsInstallCommand: ParsableCommand, GlobalOptionsProviding { commandName: "install", abstract: "Install bundled Agent Skills into a .agents directory.", discussion: """ - Installs into ~/.agents/skills by default. Use --dir .agents for a project-local installation. + Installs into ~/.agents/skills by default. Use --project for a project-local installation. + This selects the Git root, or the current directory outside Git. Use --dir for another root. Existing unmanaged files and symlinks are never overwritten, even with --force. Examples: apple-docs agent skills install apple-docs apple-docs agent skills install --all --dry-run - apple-docs agent skills install --all --dir .agents + apple-docs agent skills install --all --project apple-docs agent skills install apple-docs --force """ ) @@ -39,7 +40,8 @@ struct AgentSkillsInstallCommand: ParsableCommand, GlobalOptionsProviding { func run(deps: Deps) throws { let output = try deps.agentSkillInstaller().install( - selection.selectedSkills(), root: selection.dir, dryRun: selection.dryRun, force: force + selection.selectedSkills(), root: selection.installationRoot(fileManager: deps.agentSkillFileManager()), + dryRun: selection.dryRun, force: force ) print(output) } diff --git a/Sources/CLI/cmd/agent/skills/AgentSkillsSelectionOptions.swift b/Sources/CLI/cmd/agent/skills/AgentSkillsSelectionOptions.swift index 736893c..fdca608 100644 --- a/Sources/CLI/cmd/agent/skills/AgentSkillsSelectionOptions.swift +++ b/Sources/CLI/cmd/agent/skills/AgentSkillsSelectionOptions.swift @@ -1,4 +1,5 @@ import ArgumentParser +import Foundation struct AgentSkillsSelectionOptions: ParsableArguments { @Argument(help: "One or more bundled skill names. Use 'agent skills list' to see available names.") @@ -8,12 +9,20 @@ struct AgentSkillsSelectionOptions: ParsableArguments { var all = false @Option(help: "Root of the .agents installation. Skills are stored under DIR/skills.") - var dir = "~/.agents" + var dir: String? + + @Flag( + help: "Use the Git root's .agents directory (current directory outside Git). Conflicts with --dir." + ) + var project = false @Flag(help: "Preview changes without writing or removing files.") var dryRun = false func selectedSkills() throws -> [BundledAgentSkill] { + if project, dir != nil { + throw ValidationError("Use either --project or --dir, not both.") + } guard all != !skills.isEmpty else { throw ValidationError("Specify one or more skill names, or --all, but not both.") } @@ -26,4 +35,20 @@ struct AgentSkillsSelectionOptions: ParsableArguments { return seen.insert(name).inserted ? skill : nil } } + + func installationRoot(fileManager: FileManager) -> String { + if project { + let currentDirectory = URL(fileURLWithPath: fileManager.currentDirectoryPath).standardizedFileURL + var directory = currentDirectory + while !fileManager.fileExists(atPath: directory.appendingPathComponent(".git").path) { + if directory.path == "/" { break } + directory = directory.deletingLastPathComponent() + } + let root = + fileManager.fileExists(atPath: directory.appendingPathComponent(".git").path) + ? directory : currentDirectory + return root.appendingPathComponent(".agents").path + } + return dir ?? fileManager.homeDirectoryForCurrentUser.appendingPathComponent(".agents").path + } } diff --git a/Sources/CLI/cmd/agent/skills/AgentSkillsUninstallCommand.swift b/Sources/CLI/cmd/agent/skills/AgentSkillsUninstallCommand.swift index 6a6fa2a..19cf3ff 100644 --- a/Sources/CLI/cmd/agent/skills/AgentSkillsUninstallCommand.swift +++ b/Sources/CLI/cmd/agent/skills/AgentSkillsUninstallCommand.swift @@ -14,13 +14,14 @@ struct AgentSkillsUninstallCommand: ParsableCommand, GlobalOptionsProviding { abstract: "Remove skills managed by apple-docs from a .agents directory.", discussion: """ Removes only unchanged managed files. Local edits, unrelated files, and other skills are preserved. - Bulk removal requires --yes unless --dry-run is set. No interactive prompt is used. + Use --project for the Git root (or current directory outside Git). Bulk removal requires --yes + unless --dry-run is set. No interactive prompt is used. Examples: apple-docs agent skills uninstall apple-docs apple-docs agent skills uninstall --all --dry-run apple-docs agent skills uninstall --all --yes - apple-docs agent skills uninstall --all --yes --dir .agents + apple-docs agent skills uninstall --all --yes --project """ ) @@ -42,7 +43,8 @@ struct AgentSkillsUninstallCommand: ParsableCommand, GlobalOptionsProviding { func run(deps: Deps) throws { let output = try deps.agentSkillInstaller().uninstall( - selection.selectedSkills(), root: selection.dir, dryRun: selection.dryRun + selection.selectedSkills(), root: selection.installationRoot(fileManager: deps.agentSkillFileManager()), + dryRun: selection.dryRun ) print(output) } diff --git a/Sources/CLI/main/Dependencies.swift b/Sources/CLI/main/Dependencies.swift index e1cb103..9f4f1b3 100644 --- a/Sources/CLI/main/Dependencies.swift +++ b/Sources/CLI/main/Dependencies.swift @@ -45,6 +45,10 @@ struct Dependencies: Sendable { httpDataTransport.configuration.urlCache } + func agentSkillFileManager() -> FileManager { + .default + } + func agentSkillInstaller() -> AgentSkillInstaller { AgentSkillInstaller(logger: Logger(label: "com.techprimate.apple-docs.skills.installer")) } diff --git a/Sources/CLI/skills/AgentSkillInstaller.swift b/Sources/CLI/skills/AgentSkillInstaller.swift index f1f16a6..c1eb9aa 100644 --- a/Sources/CLI/skills/AgentSkillInstaller.swift +++ b/Sources/CLI/skills/AgentSkillInstaller.swift @@ -231,6 +231,7 @@ struct AgentSkillInstaller { protocol AgentSkillInstallerProvider { associatedtype Installer: AgentSkillInstalling func agentSkillInstaller() -> Installer + func agentSkillFileManager() -> FileManager } extension Dependencies: AgentSkillInstallerProvider {} diff --git a/Tests/CLITests/cmd/agent/skills/AgentSkillsProjectInstallationTests.swift b/Tests/CLITests/cmd/agent/skills/AgentSkillsProjectInstallationTests.swift new file mode 100644 index 0000000..02c4f5c --- /dev/null +++ b/Tests/CLITests/cmd/agent/skills/AgentSkillsProjectInstallationTests.swift @@ -0,0 +1,185 @@ +import Foundation +import Testing + +@testable import CLI + +@Suite("Agent skills installation scope") +struct AgentSkillsProjectInstallationTests { + @Test("default installation uses the file manager's home directory") + func installsInHomeDirectory() throws { + // -- Arrange -- + let home = temporaryRoot() + defer { try? FileManager.default.removeItem(at: home) } + let deps = TestAgentSkillDependencies(fileManager: TestHomeFileManager(home: home)) + let command = try #require( + CLI.parseAsRoot(["agent", "skills", "install", "apple-docs"]) as? AgentSkillsInstallCommand) + + // -- Act -- + try command.run(deps: deps) + + // -- Assert -- + #expect( + FileManager.default.fileExists( + atPath: home.appendingPathComponent(".agents/skills/apple-docs/SKILL.md").path)) + } + + @Test("default uninstall uses the file manager's home directory") + func uninstallsFromHomeDirectory() throws { + // -- Arrange -- + let home = temporaryRoot() + defer { try? FileManager.default.removeItem(at: home) } + let deps = TestAgentSkillDependencies(fileManager: TestHomeFileManager(home: home)) + let install = try #require( + CLI.parseAsRoot(["agent", "skills", "install", "apple-docs"]) as? AgentSkillsInstallCommand) + try install.run(deps: deps) + let uninstall = try #require( + CLI.parseAsRoot(["agent", "skills", "uninstall", "apple-docs"]) as? AgentSkillsUninstallCommand) + + // -- Act -- + try uninstall.run(deps: deps) + + // -- Assert -- + #expect( + !FileManager.default.fileExists( + atPath: home.appendingPathComponent(".agents/skills/apple-docs/SKILL.md").path)) + } + + @Test("project installation targets the Git root from a nested directory") + func installsInGitRoot() throws { + // -- Arrange -- + let root = temporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + let nested = root.appendingPathComponent("src/nested") + try FileManager.default.createDirectory(at: nested, withIntermediateDirectories: true) + try FileManager.default.createDirectory( + at: root.appendingPathComponent(".git"), withIntermediateDirectories: true) + let deps = TestAgentSkillDependencies(fileManager: TestHomeFileManager(home: root, currentDirectory: nested)) + let command = try #require( + CLI.parseAsRoot(["agent", "skills", "install", "apple-docs", "--project"]) + as? AgentSkillsInstallCommand) + + // -- Act -- + try command.run(deps: deps) + + // -- Assert -- + #expect( + FileManager.default.fileExists( + atPath: root.appendingPathComponent(".agents/skills/apple-docs/SKILL.md").path)) + #expect(!FileManager.default.fileExists(atPath: nested.appendingPathComponent(".agents").path)) + } + + @Test("project installation uses the current directory outside Git") + func installsOutsideGit() throws { + // -- Arrange -- + let root = temporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let deps = TestAgentSkillDependencies(fileManager: TestHomeFileManager(home: root, currentDirectory: root)) + let command = try #require( + CLI.parseAsRoot(["agent", "skills", "install", "apple-docs", "--project"]) + as? AgentSkillsInstallCommand) + + // -- Act -- + try command.run(deps: deps) + + // -- Assert -- + #expect( + FileManager.default.fileExists( + atPath: root.appendingPathComponent(".agents/skills/apple-docs/SKILL.md").path)) + } + + @Test("project uninstall removes bundled skills from the Git root") + func uninstallsFromGitRoot() throws { + // -- Arrange -- + let root = temporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + let nested = root.appendingPathComponent("src/nested") + try FileManager.default.createDirectory(at: nested, withIntermediateDirectories: true) + try FileManager.default.createDirectory( + at: root.appendingPathComponent(".git"), withIntermediateDirectories: true) + let deps = TestAgentSkillDependencies(fileManager: TestHomeFileManager(home: root, currentDirectory: nested)) + let install = try #require( + CLI.parseAsRoot(["agent", "skills", "install", "--all", "--project"]) + as? AgentSkillsInstallCommand) + try install.run(deps: deps) + let unrelated = root.appendingPathComponent(".agents/skills/custom.txt") + try Data("keep".utf8).write(to: unrelated) + + // -- Act -- + let uninstall = try #require( + CLI.parseAsRoot(["agent", "skills", "uninstall", "--all", "--yes", "--project"]) + as? AgentSkillsUninstallCommand) + try uninstall.run(deps: deps) + + // -- Assert -- + for skill in BundledAgentSkills.all { + #expect( + !FileManager.default.fileExists( + atPath: root.appendingPathComponent(".agents/skills/\(skill.name)/SKILL.md").path)) + } + #expect(try String(contentsOf: unrelated, encoding: .utf8) == "keep") + } + + @Test("project and dir cannot be combined") + func rejectsAmbiguousInstallationRoot() throws { + // -- Arrange -- + let root = temporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + + // -- Act -- + #expect(throws: (any Error).self) { + var command = try CLI.parseAsRoot([ + "agent", "skills", "install", "apple-docs", "--project", "--dir", root.path, + ]) + try command.run() + } + + // -- Assert -- + #expect(!FileManager.default.fileExists(atPath: root.path)) + } + + @Test("project uninstall rejects an explicit dir") + func rejectsAmbiguousUninstallRoot() throws { + // -- Arrange -- + let root = temporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + let arguments = [ + "agent", "skills", "uninstall", "apple-docs", "--project", "--dir", root.path, + ] + + // -- Act -- + #expect(throws: (any Error).self) { + _ = try CLI.parseAsRoot(arguments) + } + + // -- Assert -- + #expect(!FileManager.default.fileExists(atPath: root.path)) + } + + private func temporaryRoot() -> URL { + FileManager.default.temporaryDirectory.resolvingSymlinksInPath() + .appendingPathComponent(UUID().uuidString) + } +} + +private struct TestAgentSkillDependencies: AgentSkillInstallerProvider { + let fileManager: FileManager + + func agentSkillFileManager() -> FileManager { fileManager } + + func agentSkillInstaller() -> AgentSkillInstaller { Dependencies.shared.agentSkillInstaller() } +} + +private final class TestHomeFileManager: FileManager { + let home: URL + let directory: URL + + init(home: URL, currentDirectory: URL? = nil) { + self.home = home + self.directory = currentDirectory ?? home + super.init() + } + + override var homeDirectoryForCurrentUser: URL { home } + override var currentDirectoryPath: String { directory.path } +}