diff --git a/Sources/LucaCLI/Commands/InstallCommand.swift b/Sources/LucaCLI/Commands/InstallCommand.swift index 2d1f228..a76f541 100644 --- a/Sources/LucaCLI/Commands/InstallCommand.swift +++ b/Sources/LucaCLI/Commands/InstallCommand.swift @@ -261,13 +261,16 @@ struct InstallCommand: AsyncParsableCommand { var agents: [String] = [] @Option(name: .customLong("ref"), help: ArgumentHelp( - "Git tag or commit SHA to pin the skill repository to.", + "Git tag, commit SHA, or 'latest' to pin the skill repository to.", discussion: """ - Pins skill installation to a specific git tag or commit SHA1. - When omitted, the default branch HEAD is used. + Pins skill installation to a specific git tag or commit SHA1, or pass 'latest' to always + resolve to the current default-branch HEAD commit. Unlike a pinned tag/SHA, 'latest' + re-checks the remote repository on every install, so the resolved commit — and therefore + what gets installed — can change between runs as the upstream repository advances. Examples: luca install vercel-labs/agent-skills --ref v1.2.0 luca install owner/repo --ref abc1234 + luca install owner/repo --ref latest """, valueName: "ref" )) diff --git a/Sources/LucaCLI/LucaCLI.docc/Lucafile.md b/Sources/LucaCLI/LucaCLI.docc/Lucafile.md index baa4d9e..1403795 100644 --- a/Sources/LucaCLI/LucaCLI.docc/Lucafile.md +++ b/Sources/LucaCLI/LucaCLI.docc/Lucafile.md @@ -161,6 +161,34 @@ The optional `skills:` key installs agentic skills from Git repositories. |-------|----------|-------------| | `name` | Yes | Name of the skill to install; omit to install all | | `repository` | Yes | `owner/repo` shorthand, full HTTPS/GIT URL, or a `repos` alias key | +| `version` | Yes, unless inherited from a `repos` alias | Git tag, commit SHA, or `latest` | + +### Versioning + +The `version` field accepts: + +- a git tag (e.g. `v1.2.0`) +- a commit SHA (e.g. `abc1234`) +- the literal value `latest`, which always resolves to the repository's current default-branch HEAD commit + +```yaml +skills: + - name: skill-creator + repository: vercel-labs/agent-skills + version: latest +``` + +When `version: latest` is used, Luca runs `git ls-remote` against the repository on every install to +determine the current HEAD commit SHA. That resolved SHA — never the literal string `latest` — is +what's used for the on-disk cache path (`~/.luca/skills/{name}/{sha}/`) and the `git checkout`. This +has two consequences: + +- Every install with `version: latest` requires a network round-trip to check the remote, even if + the resolved commit turns out to be one already cached locally (in which case the download itself + is still skipped). +- Two installs at different times can resolve to different commits — and therefore install different + content — if the upstream repository gained new commits in between. Pin to a tag or SHA instead if + you need fully reproducible installs across your team. ### Repository Aliases diff --git a/Sources/LucaFoundation/Core/SubprocessRunner/SubprocessOutputRunner.swift b/Sources/LucaFoundation/Core/SubprocessRunner/SubprocessOutputRunner.swift new file mode 100644 index 0000000..fb5b9fc --- /dev/null +++ b/Sources/LucaFoundation/Core/SubprocessRunner/SubprocessOutputRunner.swift @@ -0,0 +1,50 @@ +// SubprocessOutputRunner.swift + +import Foundation + +/// Runs an external process using `Foundation.Process`, capturing standard output. +/// +/// Standard input is closed (`/dev/null`) so a subprocess that unexpectedly waits for +/// input returns immediately instead of hanging. Standard error is discarded. +public struct SubprocessOutputRunner: SubprocessOutputRunning { + + public init() {} + + public func run(executableURL: URL, arguments: [String], environment: [String: String]) async throws -> (exitCode: Int32, output: String) { + try await withCheckedThrowingContinuation { continuation in + DispatchQueue.global(qos: .utility).async { + let process = Process() + process.executableURL = executableURL + process.arguments = arguments + process.standardInput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + + if !environment.isEmpty { + var env = ProcessInfo.processInfo.environment + env.merge(environment) { _, new in new } + process.environment = env + } + + let stdoutPipe = Pipe() + process.standardOutput = stdoutPipe + + do { + try process.run() + } catch { + continuation.resume(throwing: error) + return + } + + // `readDataToEndOfFile()` drains the pipe continuously as the child writes to it, + // so — unlike a single read after the process exits — it can never deadlock on a + // full kernel pipe buffer. It returns once the write end closes, which happens + // when the child exits, so `waitUntilExit()` below only reaps the exit status. + let data = stdoutPipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + + let output = String(data: data, encoding: .utf8) ?? "" + continuation.resume(returning: (process.terminationStatus, output)) + } + } + } +} diff --git a/Sources/LucaFoundation/Core/SubprocessRunner/SubprocessOutputRunning.swift b/Sources/LucaFoundation/Core/SubprocessRunner/SubprocessOutputRunning.swift new file mode 100644 index 0000000..c0e2990 --- /dev/null +++ b/Sources/LucaFoundation/Core/SubprocessRunner/SubprocessOutputRunning.swift @@ -0,0 +1,19 @@ +// SubprocessOutputRunning.swift + +import Foundation + +/// Runs an external process and returns both its exit code and captured standard output. +/// +/// Unlike ``SubprocessRunning``, which inherits stdout/stderr for interactive commands +/// (e.g. `git clone`, `unzip`), this protocol is for commands whose output must be +/// parsed programmatically (e.g. `git ls-remote`). +public protocol SubprocessOutputRunning: Sendable { + /// Runs the executable at the given URL and captures its standard output. + /// + /// - Parameters: + /// - executableURL: The URL of the executable to run. + /// - arguments: The command-line arguments to pass. + /// - environment: Additional environment variables merged on top of the inherited environment. + /// - Returns: The process termination status and the full contents of standard output. + func run(executableURL: URL, arguments: [String], environment: [String: String]) async throws -> (exitCode: Int32, output: String) +} diff --git a/Sources/LucaFoundation/Models/RepoEntry.swift b/Sources/LucaFoundation/Models/RepoEntry.swift index 092eee0..d32f04e 100644 --- a/Sources/LucaFoundation/Models/RepoEntry.swift +++ b/Sources/LucaFoundation/Models/RepoEntry.swift @@ -22,7 +22,8 @@ struct RepoEntry: Decodable { /// The repository URL or `owner/repo` shorthand. let url: String /// An optional default git ref applied to all skills that reference this entry - /// and do not specify their own `version:` field. + /// and do not specify their own `version:` field. May be `"latest"` to always resolve + /// to the repository's current default-branch HEAD. let version: String? init(url: String, version: String? = nil) { diff --git a/Sources/LucaFoundation/Models/Skill.swift b/Sources/LucaFoundation/Models/Skill.swift index c7ff173..48dfdf6 100644 --- a/Sources/LucaFoundation/Models/Skill.swift +++ b/Sources/LucaFoundation/Models/Skill.swift @@ -29,9 +29,14 @@ public struct Skill: Codable { public let name: String? /// The repository reference — either `owner/repo` (GitHub shorthand) or a full HTTPS/GIT URL. public let repository: String - /// A git ref pinning the skill. Accepts a tag (e.g. `v1.2.0`) or a commit SHA (e.g. `abc1234`). + /// A git ref pinning the skill. Accepts a tag (e.g. `v1.2.0`), a commit SHA (e.g. `abc1234`), + /// or ``latestVersionKeyword`` (`"latest"`) to always resolve to the current default-branch HEAD. public let version: String + /// The sentinel value that requests resolution to the repository's current default-branch + /// HEAD commit, instead of pinning to a fixed tag or SHA. + public static let latestVersionKeyword = "latest" + public init(name: String?, repository: String, version: String) { self.name = name self.repository = repository diff --git a/Sources/LucaFoundation/Models/Spec.swift b/Sources/LucaFoundation/Models/Spec.swift index 1a13245..c25bc43 100644 --- a/Sources/LucaFoundation/Models/Spec.swift +++ b/Sources/LucaFoundation/Models/Spec.swift @@ -38,6 +38,9 @@ public typealias Agent = String /// - name: swift-concurrency /// repository: https://github.com/AvdLee/Swift-Concurrency-Agent-Skill.git /// version: v2.0.0 # required when repo has no default version +/// - name: skill-creator +/// repository: vercel +/// version: latest # always resolves to the repo's current default-branch HEAD /// ``` /// /// ## Topics diff --git a/Sources/ManagerCore/Core/GitRepositorySkillFetcher/GitRepositorySkillFetcher.swift b/Sources/ManagerCore/Core/GitRepositorySkillFetcher/GitRepositorySkillFetcher.swift index 241f7e8..83d9705 100644 --- a/Sources/ManagerCore/Core/GitRepositorySkillFetcher/GitRepositorySkillFetcher.swift +++ b/Sources/ManagerCore/Core/GitRepositorySkillFetcher/GitRepositorySkillFetcher.swift @@ -117,7 +117,7 @@ actor GitRepositorySkillFetcher: SkillRepositoryFetching { let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString) - let gitURL = cloneURL(for: repository) + let gitURL = GitRepositoryURLNormalizer.cloneURL(for: repository) if let ref, isCommitSHA(ref) { let cloneExitCode = try await subprocessRunner.run( @@ -162,19 +162,6 @@ actor GitRepositorySkillFetcher: SkillRepositoryFetching { ref.count >= 7 && ref.count <= 40 && ref.allSatisfy(\.isHexDigit) } - /// Returns a git-clonable URL for the given repository reference. - /// - /// SSH and HTTPS/HTTP URLs are passed through unchanged; `owner/repo` shorthand is - /// expanded to `https://github.com/owner/repo`. - private func cloneURL(for repository: String) -> String { - if repository.hasPrefix("git@") - || repository.hasPrefix("https://") - || repository.hasPrefix("http://") { - return repository - } - return "https://github.com/\(repository)" - } - /// Walks the cloned directory and returns paths of all skill-related files. /// /// A file is included when it is either a `SKILL.md` or lives inside a directory that diff --git a/Sources/ManagerCore/Core/GitRepositorySkillFetcher/GitRepositoryURLNormalizer.swift b/Sources/ManagerCore/Core/GitRepositorySkillFetcher/GitRepositoryURLNormalizer.swift new file mode 100644 index 0000000..2c51a93 --- /dev/null +++ b/Sources/ManagerCore/Core/GitRepositorySkillFetcher/GitRepositoryURLNormalizer.swift @@ -0,0 +1,19 @@ +// GitRepositoryURLNormalizer.swift + +import Foundation + +/// Normalizes a repository reference into a URL string usable by `git` subcommands. +enum GitRepositoryURLNormalizer { + /// Returns a git-clonable URL for the given repository reference. + /// + /// SSH and HTTPS/HTTP URLs are passed through unchanged; `owner/repo` shorthand is + /// expanded to `https://github.com/owner/repo`. + static func cloneURL(for repository: String) -> String { + if repository.hasPrefix("git@") + || repository.hasPrefix("https://") + || repository.hasPrefix("http://") { + return repository + } + return "https://github.com/\(repository)" + } +} diff --git a/Sources/ManagerCore/Core/SkillLatestVersionResolver/SkillLatestVersionResolver.swift b/Sources/ManagerCore/Core/SkillLatestVersionResolver/SkillLatestVersionResolver.swift new file mode 100644 index 0000000..b3cb969 --- /dev/null +++ b/Sources/ManagerCore/Core/SkillLatestVersionResolver/SkillLatestVersionResolver.swift @@ -0,0 +1,76 @@ +// SkillLatestVersionResolver.swift + +import Foundation +import LucaFoundation + +/// Resolves `Skill.latestVersionKeyword` ("latest") to a concrete commit SHA via `git ls-remote`. +/// +/// The resolved SHA is a normal 40-character commit SHA from the caller's perspective — it flows +/// into ``SkillSet/version`` exactly like any hand-typed SHA in a Lucafile, so every downstream +/// consumer (cache path, symlink target, `git checkout`) requires no special-casing for `latest`. +struct SkillLatestVersionResolver: SkillLatestVersionResolving { + + // MARK: - Error + + enum SkillLatestVersionResolverError: Error, LocalizedError, Equatable { + /// The git executable was not found at the expected path. + case gitNotFound + /// `git ls-remote` exited with a non-zero status. + case lsRemoteFailed(repository: String, exitCode: Int32) + /// `git ls-remote` succeeded but its output did not contain a parseable commit SHA for HEAD. + case headRefNotFound(repository: String) + + var errorDescription: String? { + switch self { + case .gitNotFound: + return "Could not find git at /usr/bin/git. Please install git (e.g. Xcode Command Line Tools on macOS)." + case .lsRemoteFailed(let repo, let exitCode): + return "Failed to resolve the latest commit for '\(repo)' (git ls-remote exited with code \(exitCode)). Ensure the repository URL is correct and reachable." + case .headRefNotFound(let repo): + return "Could not determine the default branch HEAD commit for '\(repo)'." + } + } + } + + // MARK: - Properties + + private static let gitExecutableURL = URL(fileURLWithPath: "/usr/bin/git") + + private let outputRunner: SubprocessOutputRunning + + // MARK: - Init + + init(outputRunner: SubprocessOutputRunning = SubprocessOutputRunner()) { + self.outputRunner = outputRunner + } + + // MARK: - SkillLatestVersionResolving + + func resolveLatestVersion(repository: String) async throws -> String { + guard FileManager.default.fileExists(atPath: Self.gitExecutableURL.path) else { + throw SkillLatestVersionResolverError.gitNotFound + } + + let url = GitRepositoryURLNormalizer.cloneURL(for: repository) + let (exitCode, output) = try await outputRunner.run( + executableURL: Self.gitExecutableURL, + arguments: ["ls-remote", "--quiet", url, "HEAD"], + environment: ["GIT_TERMINAL_PROMPT": "0"] + ) + + guard exitCode == 0 else { + throw SkillLatestVersionResolverError.lsRemoteFailed(repository: repository, exitCode: exitCode) + } + + guard + let firstLine = output.split(separator: "\n").first, + let sha = firstLine.split(separator: "\t").first.map(String.init), + sha.count == 40, + sha.allSatisfy({ $0.isHexDigit }) + else { + throw SkillLatestVersionResolverError.headRefNotFound(repository: repository) + } + + return sha + } +} diff --git a/Sources/ManagerCore/Core/SkillLatestVersionResolver/SkillLatestVersionResolving.swift b/Sources/ManagerCore/Core/SkillLatestVersionResolver/SkillLatestVersionResolving.swift new file mode 100644 index 0000000..3b687b5 --- /dev/null +++ b/Sources/ManagerCore/Core/SkillLatestVersionResolver/SkillLatestVersionResolving.swift @@ -0,0 +1,10 @@ +// SkillLatestVersionResolving.swift + +import Foundation + +/// Resolves the `latest` version sentinel for a skill repository to a concrete commit SHA. +protocol SkillLatestVersionResolving: Sendable { + /// Returns the current commit SHA of `repository`'s default branch HEAD. + /// - Parameter repository: The repository reference — an SSH URL, an HTTPS URL, or an `owner/repo` GitHub shorthand. + func resolveLatestVersion(repository: String) async throws -> String +} diff --git a/Sources/ManagerCore/Core/SkillsInfoFactory/SkillsInfoFactory.swift b/Sources/ManagerCore/Core/SkillsInfoFactory/SkillsInfoFactory.swift index 1ff8f0c..350bbf4 100644 --- a/Sources/ManagerCore/Core/SkillsInfoFactory/SkillsInfoFactory.swift +++ b/Sources/ManagerCore/Core/SkillsInfoFactory/SkillsInfoFactory.swift @@ -27,9 +27,11 @@ struct SkillsInfoFactory { } private let specLoader: SpecLoading + private let skillVersionResolver: SkillLatestVersionResolving - init(specLoader: SpecLoading) { + init(specLoader: SpecLoading, skillVersionResolver: SkillLatestVersionResolving = SkillLatestVersionResolver()) { self.specLoader = specLoader + self.skillVersionResolver = skillVersionResolver } // MARK: - Internal @@ -40,7 +42,7 @@ struct SkillsInfoFactory { switch installationType { case .spec(let specPath): let spec = try specLoader.loadSpec(at: specPath) - let skills = spec.skills ?? [] + let skills = try await resolveLatestVersions(in: spec.skills ?? []) // Phase 1: separate groups that want all skills (nil name) from those with named skills. // Skills are grouped by (repository, version) so the same URL at different SHAs @@ -75,8 +77,38 @@ struct SkillsInfoFactory { guard let ref else { throw SkillsInfoFactoryError.missingVersion(repository: repository) } - let skillSet = SkillSet(repository: repository, skills: skillNames, version: ref) + let resolvedRef = ref == Skill.latestVersionKeyword + ? try await skillVersionResolver.resolveLatestVersion(repository: repository) + : ref + let skillSet = SkillSet(repository: repository, skills: skillNames, version: resolvedRef) return SkillsInfo(agents: agents, skillSets: [skillSet]) } } + + // MARK: - Private + + /// Replaces every `Skill.latestVersionKeyword` version with a concrete commit SHA. + /// + /// Resolves at most once per distinct repository — multiple skill entries requesting + /// `latest` for the same repository must land on the same resolved SHA so they merge + /// into a single ``SkillSet`` in the grouping phase. + private func resolveLatestVersions(in skills: [Skill]) async throws -> [Skill] { + var resolvedByRepository: [String: String] = [:] + var result: [Skill] = [] + for skill in skills { + guard skill.version == Skill.latestVersionKeyword else { + result.append(skill) + continue + } + let resolved: String + if let cached = resolvedByRepository[skill.repository] { + resolved = cached + } else { + resolved = try await skillVersionResolver.resolveLatestVersion(repository: skill.repository) + resolvedByRepository[skill.repository] = resolved + } + result.append(Skill(name: skill.name, repository: skill.repository, version: resolved)) + } + return result + } } diff --git a/Sources/ManagerCore/Models/SkillSet.swift b/Sources/ManagerCore/Models/SkillSet.swift index 9f1af67..697d096 100644 --- a/Sources/ManagerCore/Models/SkillSet.swift +++ b/Sources/ManagerCore/Models/SkillSet.swift @@ -7,7 +7,9 @@ struct SkillSet: Codable { let repository: String /// The skill names to install. An empty array means install all skills in the repository. let skills: [String] - /// A git ref pinning the repository to a specific tag or commit SHA. + /// A git ref pinning the repository to a specific tag or commit SHA. Never the literal + /// `"latest"` — by the time a ``SkillSet`` exists, `SkillsInfoFactory` has already resolved + /// any `latest` sentinel to a concrete SHA. let version: String init(repository: String, skills: [String], version: String) { diff --git a/Tests/Core/GitRepositoryURLNormalizerTests.swift b/Tests/Core/GitRepositoryURLNormalizerTests.swift new file mode 100644 index 0000000..5b85bf7 --- /dev/null +++ b/Tests/Core/GitRepositoryURLNormalizerTests.swift @@ -0,0 +1,27 @@ +// GitRepositoryURLNormalizerTests.swift + +import Testing +@testable import ManagerCore + +struct GitRepositoryURLNormalizerTests { + + @Test + func test_cloneURL_shorthand_expandsToGitHubHTTPSURL() { + #expect(GitRepositoryURLNormalizer.cloneURL(for: "owner/repo") == "https://github.com/owner/repo") + } + + @Test + func test_cloneURL_httpsURL_passesThroughUnchanged() { + #expect(GitRepositoryURLNormalizer.cloneURL(for: "https://github.com/owner/repo.git") == "https://github.com/owner/repo.git") + } + + @Test + func test_cloneURL_httpURL_passesThroughUnchanged() { + #expect(GitRepositoryURLNormalizer.cloneURL(for: "http://internal-git/owner/repo") == "http://internal-git/owner/repo") + } + + @Test + func test_cloneURL_sshURL_passesThroughUnchanged() { + #expect(GitRepositoryURLNormalizer.cloneURL(for: "git@github.com:owner/repo.git") == "git@github.com:owner/repo.git") + } +} diff --git a/Tests/Core/SkillLatestVersionResolverTests.swift b/Tests/Core/SkillLatestVersionResolverTests.swift new file mode 100644 index 0000000..fb032ca --- /dev/null +++ b/Tests/Core/SkillLatestVersionResolverTests.swift @@ -0,0 +1,88 @@ +// SkillLatestVersionResolverTests.swift + +import Foundation +import Testing +@testable import ManagerCore + +struct SkillLatestVersionResolverTests { + + @Test + func test_resolveLatestVersion_parsesShaFromLsRemoteOutput() async throws { + let runner = SubprocessOutputRunnerMock() + runner.results = [(0, "947ad5941cddb8bd7d998129a642f43f0deb5c5f\tHEAD\n")] + let sut = SkillLatestVersionResolver(outputRunner: runner) + + let sha = try await sut.resolveLatestVersion(repository: "owner/repo") + + #expect(sha == "947ad5941cddb8bd7d998129a642f43f0deb5c5f") + } + + @Test + func test_resolveLatestVersion_expandsShorthandRepositoryToGitHubURL() async throws { + let runner = SubprocessOutputRunnerMock() + runner.results = [(0, "947ad5941cddb8bd7d998129a642f43f0deb5c5f\tHEAD\n")] + let sut = SkillLatestVersionResolver(outputRunner: runner) + + _ = try await sut.resolveLatestVersion(repository: "owner/repo") + + let args = try #require(runner.recordedArguments.first) + #expect(args == ["ls-remote", "--quiet", "https://github.com/owner/repo", "HEAD"]) + } + + @Test + func test_resolveLatestVersion_passesThroughExplicitURLsUnchanged() async throws { + let runner = SubprocessOutputRunnerMock() + runner.results = [(0, "947ad5941cddb8bd7d998129a642f43f0deb5c5f\tHEAD\n")] + let sut = SkillLatestVersionResolver(outputRunner: runner) + + _ = try await sut.resolveLatestVersion(repository: "git@github.com:owner/repo.git") + + let args = try #require(runner.recordedArguments.first) + #expect(args == ["ls-remote", "--quiet", "git@github.com:owner/repo.git", "HEAD"]) + } + + @Test + func test_resolveLatestVersion_disablesGitTerminalPrompt() async throws { + let runner = SubprocessOutputRunnerMock() + runner.results = [(0, "947ad5941cddb8bd7d998129a642f43f0deb5c5f\tHEAD\n")] + let sut = SkillLatestVersionResolver(outputRunner: runner) + + _ = try await sut.resolveLatestVersion(repository: "owner/repo") + + let env = try #require(runner.recordedEnvironments.first) + #expect(env["GIT_TERMINAL_PROMPT"] == "0") + } + + @Test + func test_resolveLatestVersion_nonZeroExit_throwsLsRemoteFailed() async throws { + let runner = SubprocessOutputRunnerMock() + runner.results = [(128, "")] + let sut = SkillLatestVersionResolver(outputRunner: runner) + + await #expect(throws: SkillLatestVersionResolver.SkillLatestVersionResolverError.lsRemoteFailed(repository: "owner/repo", exitCode: 128)) { + try await sut.resolveLatestVersion(repository: "owner/repo") + } + } + + @Test + func test_resolveLatestVersion_emptyOutput_throwsHeadRefNotFound() async throws { + let runner = SubprocessOutputRunnerMock() + runner.results = [(0, "")] + let sut = SkillLatestVersionResolver(outputRunner: runner) + + await #expect(throws: SkillLatestVersionResolver.SkillLatestVersionResolverError.headRefNotFound(repository: "owner/repo")) { + try await sut.resolveLatestVersion(repository: "owner/repo") + } + } + + @Test + func test_resolveLatestVersion_malformedShaInOutput_throwsHeadRefNotFound() async throws { + let runner = SubprocessOutputRunnerMock() + runner.results = [(0, "not-a-sha\tHEAD\n")] + let sut = SkillLatestVersionResolver(outputRunner: runner) + + await #expect(throws: SkillLatestVersionResolver.SkillLatestVersionResolverError.headRefNotFound(repository: "owner/repo")) { + try await sut.resolveLatestVersion(repository: "owner/repo") + } + } +} diff --git a/Tests/Core/SkillsInfoFactoryTests.swift b/Tests/Core/SkillsInfoFactoryTests.swift index be89ebf..4b4dab8 100644 --- a/Tests/Core/SkillsInfoFactoryTests.swift +++ b/Tests/Core/SkillsInfoFactoryTests.swift @@ -226,6 +226,91 @@ struct SkillsInfoFactoryTests { } } + // MARK: - "latest" version resolution + + @Test + func test_skillsInfoForInstallationType_versionLatest_resolvesToConcreteSha() async throws { + let spec = Spec(tools: nil, skills: [ + Skill(name: "frontend-design", repository: "vercel-labs/agent-skills", version: Skill.latestVersionKeyword) + ], agents: nil) + let resolver = SkillLatestVersionResolverMock() + resolver.resultsByRepository = ["vercel-labs/agent-skills": .success("947ad5941cddb8bd7d998129a642f43f0deb5c5f")] + let sut = SkillsInfoFactory(specLoader: SpecLoaderMock(spec: spec), skillVersionResolver: resolver) + + let info = try await sut.skillsInfoForInstallationType(.spec(specPath: URL(fileURLWithPath: "/Lucafile"))) + + let skillSet = try #require(info.skillSets.first) + #expect(skillSet.version == "947ad5941cddb8bd7d998129a642f43f0deb5c5f") + #expect(resolver.recordedRepositories == ["vercel-labs/agent-skills"]) + } + + @Test + func test_skillsInfoForInstallationType_multipleSkillsSameRepoVersionLatest_resolvesOnceAndMergesIntoOneSkillSet() async throws { + let spec = Spec(tools: nil, skills: [ + Skill(name: "skill-a", repository: "vercel-labs/agent-skills", version: Skill.latestVersionKeyword), + Skill(name: "skill-b", repository: "vercel-labs/agent-skills", version: Skill.latestVersionKeyword) + ], agents: nil) + let resolver = SkillLatestVersionResolverMock() + resolver.resultsByRepository = ["vercel-labs/agent-skills": .success("947ad5941cddb8bd7d998129a642f43f0deb5c5f")] + let sut = SkillsInfoFactory(specLoader: SpecLoaderMock(spec: spec), skillVersionResolver: resolver) + + let info = try await sut.skillsInfoForInstallationType(.spec(specPath: URL(fileURLWithPath: "/Lucafile"))) + + #expect(info.skillSets.count == 1) + let skillSet = try #require(info.skillSets.first) + #expect(skillSet.version == "947ad5941cddb8bd7d998129a642f43f0deb5c5f") + #expect(Set(skillSet.skills) == Set(["skill-a", "skill-b"])) + #expect(resolver.recordedRepositories.count == 1, "the same repository must only be resolved once per call") + } + + @Test + func test_skillsInfoForInstallationType_pinnedAndLatestSameRepo_produceSeparateSkillSets() async throws { + let spec = Spec(tools: nil, skills: [ + Skill(name: "skill-a", repository: "vercel-labs/agent-skills", version: "v1.0.0"), + Skill(name: "skill-b", repository: "vercel-labs/agent-skills", version: Skill.latestVersionKeyword) + ], agents: nil) + let resolver = SkillLatestVersionResolverMock() + resolver.resultsByRepository = ["vercel-labs/agent-skills": .success("947ad5941cddb8bd7d998129a642f43f0deb5c5f")] + let sut = SkillsInfoFactory(specLoader: SpecLoaderMock(spec: spec), skillVersionResolver: resolver) + + let info = try await sut.skillsInfoForInstallationType(.spec(specPath: URL(fileURLWithPath: "/Lucafile"))) + + #expect(info.skillSets.count == 2) + let pinnedSet = try #require(info.skillSets.first(where: { $0.version == "v1.0.0" })) + #expect(pinnedSet.skills == ["skill-a"]) + let latestSet = try #require(info.skillSets.first(where: { $0.version == "947ad5941cddb8bd7d998129a642f43f0deb5c5f" })) + #expect(latestSet.skills == ["skill-b"]) + } + + @Test + func test_skillsInfoForInstallationType_resolverThrows_propagatesError() async throws { + struct ResolverError: Error, Equatable {} + let spec = Spec(tools: nil, skills: [ + Skill(name: "frontend-design", repository: "vercel-labs/agent-skills", version: Skill.latestVersionKeyword) + ], agents: nil) + let resolver = SkillLatestVersionResolverMock() + resolver.resultsByRepository = ["vercel-labs/agent-skills": .failure(ResolverError())] + let sut = SkillsInfoFactory(specLoader: SpecLoaderMock(spec: spec), skillVersionResolver: resolver) + + await #expect(throws: ResolverError.self) { + try await sut.skillsInfoForInstallationType(.spec(specPath: URL(fileURLWithPath: "/Lucafile"))) + } + } + + @Test + func test_skillsInfoForInstallationType_individual_refLatest_resolvesToConcreteSha() async throws { + let resolver = SkillLatestVersionResolverMock() + resolver.resultsByRepository = ["owner/repo": .success("947ad5941cddb8bd7d998129a642f43f0deb5c5f")] + let sut = SkillsInfoFactory(specLoader: SpecLoaderMock(spec: Spec(tools: nil, skills: nil, agents: nil)), skillVersionResolver: resolver) + + let info = try await sut.skillsInfoForInstallationType( + .individual(repository: "owner/repo", skillNames: [], agents: nil, ref: Skill.latestVersionKeyword) + ) + + let skillSet = try #require(info.skillSets.first) + #expect(skillSet.version == "947ad5941cddb8bd7d998129a642f43f0deb5c5f") + } + // MARK: - Empty skills @Test diff --git a/Tests/Core/SubprocessOutputRunnerTests.swift b/Tests/Core/SubprocessOutputRunnerTests.swift new file mode 100644 index 0000000..1a1388d --- /dev/null +++ b/Tests/Core/SubprocessOutputRunnerTests.swift @@ -0,0 +1,47 @@ +// SubprocessOutputRunnerTests.swift + +import Foundation +import Testing +@testable import LucaFoundation + +struct SubprocessOutputRunnerTests { + + private let sh = URL(fileURLWithPath: "/bin/sh") + private let runner = SubprocessOutputRunner() + + @Test + func test_run_capturesStdout() async throws { + let result = try await runner.run(executableURL: sh, arguments: ["-c", "echo hello"], environment: [:]) + #expect(result.exitCode == 0) + #expect(result.output.trimmingCharacters(in: .whitespacesAndNewlines) == "hello") + } + + @Test + func test_run_multilineStdout_capturesAllLines() async throws { + let result = try await runner.run(executableURL: sh, arguments: ["-c", "printf 'line1\\nline2\\n'"], environment: [:]) + #expect(result.output == "line1\nline2\n") + } + + @Test + func test_run_nonZeroExit_returnsExitCodeWithoutThrowing() async throws { + let result = try await runner.run(executableURL: sh, arguments: ["-c", "echo partial; exit 7"], environment: [:]) + #expect(result.exitCode == 7) + #expect(result.output.trimmingCharacters(in: .whitespacesAndNewlines) == "partial") + } + + @Test + func test_run_environmentVariablesAreMergedIntoProcess() async throws { + let result = try await runner.run( + executableURL: sh, + arguments: ["-c", "echo \"$MY_TEST_VAR\""], + environment: ["MY_TEST_VAR": "hello"] + ) + #expect(result.output.trimmingCharacters(in: .whitespacesAndNewlines) == "hello") + } + + @Test + func test_run_stdinIsClosed_catExitsImmediately() async throws { + let result = try await runner.run(executableURL: sh, arguments: ["-c", "cat"], environment: [:]) + #expect(result.exitCode == 0) + } +} diff --git a/Tests/Mocks/SkillLatestVersionResolverMock.swift b/Tests/Mocks/SkillLatestVersionResolverMock.swift new file mode 100644 index 0000000..42e405d --- /dev/null +++ b/Tests/Mocks/SkillLatestVersionResolverMock.swift @@ -0,0 +1,20 @@ +// SkillLatestVersionResolverMock.swift + +import Foundation +@testable import ManagerCore + +final class SkillLatestVersionResolverMock: SkillLatestVersionResolving, @unchecked Sendable { + + /// SHA (or error) to return, keyed by repository. + var resultsByRepository: [String: Result] = [:] + /// Repositories the resolver was actually asked to resolve, in call order. + var recordedRepositories: [String] = [] + + func resolveLatestVersion(repository: String) async throws -> String { + recordedRepositories.append(repository) + guard let result = resultsByRepository[repository] else { + fatalError("SkillLatestVersionResolverMock has no stubbed result for repository '\(repository)'") + } + return try result.get() + } +} diff --git a/Tests/Mocks/SubprocessOutputRunnerMock.swift b/Tests/Mocks/SubprocessOutputRunnerMock.swift new file mode 100644 index 0000000..1b40839 --- /dev/null +++ b/Tests/Mocks/SubprocessOutputRunnerMock.swift @@ -0,0 +1,24 @@ +// SubprocessOutputRunnerMock.swift + +import Foundation +@testable import LucaFoundation + +final class SubprocessOutputRunnerMock: SubprocessOutputRunning, @unchecked Sendable { + + /// Results returned in order per call. If exhausted, the last value is reused. + var results: [(exitCode: Int32, output: String)] = [(0, "")] + var recordedExecutableURLs: [URL] = [] + var recordedArguments: [[String]] = [] + var recordedEnvironments: [[String: String]] = [] + + private var callCount = 0 + + func run(executableURL: URL, arguments: [String], environment: [String: String]) async throws -> (exitCode: Int32, output: String) { + recordedExecutableURLs.append(executableURL) + recordedArguments.append(arguments) + recordedEnvironments.append(environment) + let result = results.indices.contains(callCount) ? results[callCount] : results[results.count - 1] + callCount += 1 + return result + } +}