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
35 changes: 20 additions & 15 deletions Sources/LucaCLI/Commands/InstallCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,16 @@ struct InstallCommand: AsyncParsableCommand {
enum InstallCommandError: Error, LocalizedError {
case cannotConstructUrl(String)
case invalidCombinationOfArguments(Arguments)
case globalLucafileMissing(String)
case globalSpecMissing(String)

var errorDescription: String? {
switch self {
case .cannotConstructUrl(let value):
return "Cannot construct URL from String '\(value)'."
case .invalidCombinationOfArguments(let arguments):
return "Invalid combination of arguments. Please rely on the documentation to see examples of invocations (e.g. use --help).\nGot the following parameters:\n\(String(describing: arguments))."
case .globalLucafileMissing(let path):
return "No global Lucafile found at \(path). Create one to get started."
case .globalSpecMissing(let path):
return "No global spec file found in \(path). Create a Lucafile, Toolfile, or Skillfile (optionally with .yml extension) to get started."
}
}
}
Expand Down Expand Up @@ -179,8 +179,9 @@ struct InstallCommand: AsyncParsableCommand {
@Flag(help: ArgumentHelp(
"Install skills globally, caching to ~/.luca/skills/.",
discussion: """
Reads from the global Lucafile (~/.config/luca/Lucafile) when no --spec is given,
caches skills to ~/.luca/skills/, and symlinks into each agent's global skills path.
Reads the first global spec file found in ~/.config/luca/ when no --spec is given.
Checks Lucafile, Toolfile, and Skillfile (plain and .yml) in priority order.
Caches skills to ~/.luca/skills/ and symlinks into each agent's global skills path.
Cannot be combined with --only-tools.
Example:
luca install --global
Expand Down Expand Up @@ -279,18 +280,10 @@ struct InstallCommand: AsyncParsableCommand {
throw ValidationError("--global cannot be combined with --only-tools. Global installation is skills-only.")
}

// When --global is set and no explicit --spec was provided, default to ~/.config/luca/Lucafile
// When --global is set and no explicit --spec was provided, search ~/.config/luca/ for the first recognised spec file
let resolvedSpec: String?
if global && spec == nil {
let globalLucafileURL = fileManager.homeDirectoryForCurrentUser
.appending(components: ".config", "luca", "Lucafile")
// Ensure parent directory exists
let globalConfigDir = globalLucafileURL.deletingLastPathComponent()
try fileManager.createDirectory(at: globalConfigDir, withIntermediateDirectories: true)
guard fileManager.fileExists(atPath: globalLucafileURL.path) else {
throw InstallCommandError.globalLucafileMissing(globalLucafileURL.path)
}
resolvedSpec = globalLucafileURL.path
resolvedSpec = try globalSpecPath(fileManager: fileManager).path
} else {
resolvedSpec = spec
}
Expand Down Expand Up @@ -452,6 +445,18 @@ struct InstallCommand: AsyncParsableCommand {
}
}

/// Searches `~/.config/luca/` for the first recognised spec file, returning its URL.
///
/// Delegates to ``GlobalSpecFinder`` and re-throws as ``InstallCommandError/globalSpecMissing(_:)``.
private func globalSpecPath(fileManager: FileManaging) throws -> URL {
let finder = GlobalSpecFinder(fileManager: fileManager)
do {
return try finder.findGlobalSpec()
} catch GlobalSpecFinder.GlobalSpecFinderError.noSpecFound(let path) {
throw InstallCommandError.globalSpecMissing(path)
}
}

private func toolUrl(for urlString: String?) throws -> URL? {
if let urlString {
guard let toolUrl = URL(string: urlString) else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ public protocol FileManaging:
FileTypeDetectorFileManaging,
GitHookInstallerFileManaging,
GitIgnoreFileManaging,
GlobalSpecFinderFileManaging,
InstalledSkillsListerFileManaging,
InstalledToolsFileManaging,
PermissionManagerFileManaging,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// GlobalSpecFinderFileManaging.swift

import Foundation

/// File system interface for ``GlobalSpecFinder``.
public protocol GlobalSpecFinderFileManaging {
var homeDirectoryForCurrentUser: URL { get }
func fileExists(atPath path: String) -> Bool
func createDirectory(at url: URL, withIntermediateDirectories createIntermediates: Bool) throws
}
51 changes: 51 additions & 0 deletions Sources/LucaCore/Core/GlobalSpecFinder/GlobalSpecFinder.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// GlobalSpecFinder.swift

import Foundation

/// Searches `~/.config/luca/` for the first recognised spec file.
///
/// Checks each name in ``Constants/specFiles`` (plain then `.yml`) in priority order.
public struct GlobalSpecFinder: GlobalSpecFinding {

/// Errors thrown by ``GlobalSpecFinder``.
public enum GlobalSpecFinderError: Error, LocalizedError, Equatable {
/// No recognised spec file was found in the global config directory.
case noSpecFound(String)

public var errorDescription: String? {
switch self {
case .noSpecFound(let path):
return "No global spec file found in \(path). Create a Lucafile, Toolfile, or Skillfile (optionally with .yml extension) to get started."
}
}
}

private let fileManager: GlobalSpecFinderFileManaging

public init(fileManager: GlobalSpecFinderFileManaging) {
self.fileManager = fileManager
}

/// Finds the first recognised global spec file in `~/.config/luca/`.
///
/// - Returns: The URL of the first matching file.
/// - Throws: ``GlobalSpecFinderError/noSpecFound(_:)`` if no spec file is found.
public func findGlobalSpec() throws -> URL {
let globalConfigDir = fileManager.homeDirectoryForCurrentUser
.appending(components: ".config", "luca")
try fileManager.createDirectory(at: globalConfigDir, withIntermediateDirectories: true)

for name in Constants.specFiles {
let plain = globalConfigDir.appending(component: name)
if fileManager.fileExists(atPath: plain.path) {
return plain
}
let yml = globalConfigDir.appending(component: "\(name).\(Constants.ymlExtension)")
if fileManager.fileExists(atPath: yml.path) {
return yml
}
}

throw GlobalSpecFinderError.noSpecFound(globalConfigDir.path)
}
}
12 changes: 12 additions & 0 deletions Sources/LucaCore/Core/GlobalSpecFinder/GlobalSpecFinding.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// GlobalSpecFinding.swift

import Foundation

/// Finds a global spec file in the Luca config directory.
protocol GlobalSpecFinding {
/// Finds the first recognised global spec file in `~/.config/luca/`.
///
/// - Returns: The URL of the first found spec file.
/// - Throws: ``GlobalSpecFinder/GlobalSpecFinderError/noSpecFound(_:)`` when no spec file exists.
func findGlobalSpec() throws -> URL
}
101 changes: 101 additions & 0 deletions Tests/Core/GlobalSpecFinderTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
// GlobalSpecFinderTests.swift

import Foundation
import Testing
@testable import LucaCore

struct GlobalSpecFinderTests {

// MARK: - Helpers

private func globalConfigDir(fileManager: FileManagerWrapperMock) -> URL {
fileManager.homeDirectoryForCurrentUser.appending(components: ".config", "luca")
}

private func createFile(named name: String, in directory: URL, fileManager: FileManagerWrapperMock) throws {
try fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
_ = fileManager.fileManager.createFile(atPath: directory.appending(component: name).path, contents: nil)
}

// MARK: - Tests

@Test
func findGlobalSpec_lucafileExists_returnsIt() throws {
let fileManager = FileManagerWrapperMock()
let dir = globalConfigDir(fileManager: fileManager)
try createFile(named: Constants.specFile, in: dir, fileManager: fileManager)
let finder = GlobalSpecFinder(fileManager: fileManager)

let result = try finder.findGlobalSpec()

#expect(result.lastPathComponent == Constants.specFile)
}

@Test
func findGlobalSpec_toolfileExists_returnsIt() throws {
let fileManager = FileManagerWrapperMock()
let dir = globalConfigDir(fileManager: fileManager)
try createFile(named: Constants.toolFile, in: dir, fileManager: fileManager)
let finder = GlobalSpecFinder(fileManager: fileManager)

let result = try finder.findGlobalSpec()

#expect(result.lastPathComponent == Constants.toolFile)
}

@Test
func findGlobalSpec_skillfileExists_returnsIt() throws {
let fileManager = FileManagerWrapperMock()
let dir = globalConfigDir(fileManager: fileManager)
try createFile(named: Constants.skillFile, in: dir, fileManager: fileManager)
let finder = GlobalSpecFinder(fileManager: fileManager)

let result = try finder.findGlobalSpec()

#expect(result.lastPathComponent == Constants.skillFile)
}

@Test
func findGlobalSpec_ymlVariant_returnsIt() throws {
let fileManager = FileManagerWrapperMock()
let dir = globalConfigDir(fileManager: fileManager)
let ymlName = "\(Constants.specFile).\(Constants.ymlExtension)"
try createFile(named: ymlName, in: dir, fileManager: fileManager)
let finder = GlobalSpecFinder(fileManager: fileManager)

let result = try finder.findGlobalSpec()

#expect(result.lastPathComponent == ymlName)
}

@Test
func findGlobalSpec_lucafileAndToolfile_prefersLucafile() throws {
let fileManager = FileManagerWrapperMock()
let dir = globalConfigDir(fileManager: fileManager)
try createFile(named: Constants.specFile, in: dir, fileManager: fileManager)
try createFile(named: Constants.toolFile, in: dir, fileManager: fileManager)
let finder = GlobalSpecFinder(fileManager: fileManager)

let result = try finder.findGlobalSpec()

#expect(result.lastPathComponent == Constants.specFile)
}

@Test
func findGlobalSpec_noFiles_throwsNoSpecFound() throws {
let fileManager = FileManagerWrapperMock()
let finder = GlobalSpecFinder(fileManager: fileManager)

#expect(throws: GlobalSpecFinder.GlobalSpecFinderError.self) {
try finder.findGlobalSpec()
}
}

@Test
func noSpecFound_errorDescription_containsPath() throws {
let path = "/home/user/.config/luca"
let error = GlobalSpecFinder.GlobalSpecFinderError.noSpecFound(path)

#expect(error.errorDescription?.contains(path) == true)
}
}