Skip to content
9 changes: 6 additions & 3 deletions Sources/LucaCLI/Commands/InstallCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
))
Expand Down
28 changes: 28 additions & 0 deletions Sources/LucaCLI/LucaCLI.docc/Lucafile.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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))
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
3 changes: 2 additions & 1 deletion Sources/LucaFoundation/Models/RepoEntry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
7 changes: 6 additions & 1 deletion Sources/LucaFoundation/Models/Skill.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions Sources/LucaFoundation/Models/Spec.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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)"
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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
}
38 changes: 35 additions & 3 deletions Sources/ManagerCore/Core/SkillsInfoFactory/SkillsInfoFactory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
}
}
4 changes: 3 additions & 1 deletion Sources/ManagerCore/Models/SkillSet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
27 changes: 27 additions & 0 deletions Tests/Core/GitRepositoryURLNormalizerTests.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading