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
8 changes: 5 additions & 3 deletions Sources/CLI/cmd/agent/skills/AgentSkillsInstallCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
"""
)
Expand All @@ -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)
}
Expand Down
27 changes: 26 additions & 1 deletion Sources/CLI/cmd/agent/skills/AgentSkillsSelectionOptions.swift
Original file line number Diff line number Diff line change
@@ -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.")
Expand All @@ -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.")
}
Expand All @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
"""
)

Expand All @@ -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)
}
Expand Down
4 changes: 4 additions & 0 deletions Sources/CLI/main/Dependencies.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
}
Expand Down
1 change: 1 addition & 0 deletions Sources/CLI/skills/AgentSkillInstaller.swift
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@ struct AgentSkillInstaller {
protocol AgentSkillInstallerProvider {
associatedtype Installer: AgentSkillInstalling
func agentSkillInstaller() -> Installer
func agentSkillFileManager() -> FileManager
}

extension Dependencies: AgentSkillInstallerProvider {}
Expand Down
Original file line number Diff line number Diff line change
@@ -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 }
}
Loading