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
7 changes: 7 additions & 0 deletions Documentation/Reference/CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ Canonical requirement flags follow SwiftPM-style spelling:
skill add owner/repo --branch main
skill add owner/repo --revision abc123
skill add owner/repo --exact 1.2.3
skill add owner/repo --exact 1.2.3-beta.4
skill add owner/repo --from 1.2.0
skill add owner/repo --up-to-next-minor-from 1.2.0
skill add owner/repo --from 1.2.0 --to 2.0.0
Expand All @@ -66,6 +67,8 @@ skill add larksuite/cli@revision:abc123@path:skills/lark-base

Rules:

- exact versions retain prerelease and build identifiers; an optional `v` tag
prefix is accepted, and ranges use SemVer precedence
- known requirement labels are `branch`, `revision`, `exact`, `from`,
`minor`, and `ref`
- known selector labels are `path` and `skill`
Expand Down Expand Up @@ -155,6 +158,10 @@ managed installation still exists on disk. Status values include `installed`,
directories and includes unmanaged skills that are not tracked by resolved
state.

`skill remove` accepts managed names from resolved state even when their
installation files or local source are missing. It removes the selected
installation records as well as any remaining installed files.

Diagnose install state without mutating files:

```bash
Expand Down
5 changes: 2 additions & 3 deletions Sources/CLI/RemoveCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,17 +35,16 @@ struct Remove: ParsableCommand {
}
let agents = try parseAgents(agentValues, all: agentValues.isEmpty)
let environment = RuntimeEnvironment()
let installed = try RuntimeService.listInstalled(
let available = try RuntimeService.removableSkillNames(
scope: scope.installScope, agents: agents, environment: environment)
if installed.isEmpty {
if available.isEmpty {
print("No skills found to remove")
return
}
guard all || !names.isEmpty else {
throw ValidationError("pass one or more skills, --skill, or --all")
}
if !all {
let available = Set(installed.map { PathSafety.sanitizeName($0.name) })
let missing = names.filter { !available.contains(PathSafety.sanitizeName($0)) }
if !missing.isEmpty {
print("No matching skills found")
Expand Down
6 changes: 5 additions & 1 deletion Sources/Core/ProcessRunner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,11 @@ public enum Git {
return (requirement.value, nil)
case "exact":
let version = try parsedVersion(requirement.value, label: "exact")
guard let match = tags(in: checkout).first(where: { $0.version == version }) else {
guard
let match = tags(in: checkout).first(where: {
$0.version.description == version.description
})
else {
throw CoreError.notFound("tag for exact version \(requirement.value)")
}
return (match.tag, match.version.description)
Expand Down
12 changes: 10 additions & 2 deletions Sources/Core/RuntimeService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -501,8 +501,8 @@ public enum RuntimeService {
skillNames: [String], scope: InstallScope, agents: [AgentID], environment: RuntimeEnvironment
) throws -> [String] {
if !skillNames.isEmpty {
let installed = try listInstalled(scope: scope, agents: agents, environment: environment)
let available = Set(installed.map { PathSafety.sanitizeName($0.name) })
let available = try removableSkillNames(
scope: scope, agents: agents, environment: environment)
let missing = skillNames.filter { !available.contains(PathSafety.sanitizeName($0)) }
if let first = missing.first {
throw CoreError.notFound("skill '\(first)'")
Expand All @@ -512,6 +512,14 @@ public enum RuntimeService {
skillNames: skillNames, agents: agents, scope: scope, environment: environment)
}

public static func removableSkillNames(
scope: InstallScope, agents: [AgentID], environment: RuntimeEnvironment
) throws -> Set<String> {
let onDisk = try listInstalled(scope: scope, agents: agents, environment: environment)
let managed = try listManaged(scope: scope, agents: agents, environment: environment)
return Set((onDisk + managed).map { PathSafety.sanitizeName($0.name) })
}

public static func installProjectResolved(environment: RuntimeEnvironment) throws
-> [InstallResult]
{
Expand Down
70 changes: 66 additions & 4 deletions Sources/Core/SourceRequirement.swift
Original file line number Diff line number Diff line change
Expand Up @@ -62,33 +62,66 @@ public struct SemanticVersion: Comparable, Equatable, Sendable, CustomStringConv
public var major: Int
public var minor: Int
public var patch: Int
public var prerelease: [String]
public var buildMetadata: [String]

public init(major: Int, minor: Int, patch: Int) {
self.major = major
self.minor = minor
self.patch = patch
self.prerelease = []
self.buildMetadata = []
}

public init?(_ raw: String) {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
let body =
trimmed.hasPrefix("v") || trimmed.hasPrefix("V") ? String(trimmed.dropFirst()) : trimmed
let core = body.split(separator: "-", maxSplits: 1, omittingEmptySubsequences: false)[0]
let parts = core.split(separator: ".").map(String.init)
let metadataParts = body.split(
separator: "+", maxSplits: 1, omittingEmptySubsequences: false)
let versionParts = metadataParts[0].split(
separator: "-", maxSplits: 1, omittingEmptySubsequences: false)
let parts = versionParts[0].split(separator: ".", omittingEmptySubsequences: false)
guard parts.count == 3,
parts.allSatisfy({ Self.isNumeric(String($0)) && ($0.count == 1 || !$0.hasPrefix("0")) }),
let major = Int(parts[0]),
let minor = Int(parts[1]),
let patch = Int(parts[2])
else {
return nil
}
let prerelease =
versionParts.count == 2
? versionParts[1].split(separator: ".", omittingEmptySubsequences: false).map(String.init)
: []
let buildMetadata =
metadataParts.count == 2
? metadataParts[1].split(separator: ".", omittingEmptySubsequences: false).map(String.init)
: []
guard
prerelease.allSatisfy({
Self.isIdentifier($0) && (!Self.isNumeric($0) || $0.count == 1 || !$0.hasPrefix("0"))
}), buildMetadata.allSatisfy(Self.isIdentifier)
else {
return nil
}
self.major = major
self.minor = minor
self.patch = patch
self.prerelease = prerelease
self.buildMetadata = buildMetadata
}

public var description: String {
"\(major).\(minor).\(patch)"
let suffix = prerelease.isEmpty ? "" : "-" + prerelease.joined(separator: ".")
let metadata = buildMetadata.isEmpty ? "" : "+" + buildMetadata.joined(separator: ".")
return "\(major).\(minor).\(patch)" + suffix + metadata
}

// Build metadata does not affect SemVer precedence.
public static func == (lhs: SemanticVersion, rhs: SemanticVersion) -> Bool {
lhs.major == rhs.major && lhs.minor == rhs.minor && lhs.patch == rhs.patch
&& lhs.prerelease == rhs.prerelease
}

public static func < (lhs: SemanticVersion, rhs: SemanticVersion) -> Bool {
Expand All @@ -98,6 +131,35 @@ public struct SemanticVersion: Comparable, Equatable, Sendable, CustomStringConv
if lhs.minor != rhs.minor {
return lhs.minor < rhs.minor
}
return lhs.patch < rhs.patch
if lhs.patch != rhs.patch {
return lhs.patch < rhs.patch
}
if lhs.prerelease.isEmpty || rhs.prerelease.isEmpty {
return !lhs.prerelease.isEmpty && rhs.prerelease.isEmpty
}
for (left, right) in zip(lhs.prerelease, rhs.prerelease) {
if left == right { continue }
let leftNumeric = isNumeric(left)
let rightNumeric = isNumeric(right)
if leftNumeric && rightNumeric && left.count != right.count {
return left.count < right.count
}
if leftNumeric != rightNumeric {
return leftNumeric
}
return left < right
}
return lhs.prerelease.count < rhs.prerelease.count
}

private static func isNumeric(_ value: String) -> Bool {
!value.isEmpty && value.utf8.allSatisfy { (48...57).contains($0) }
}

private static func isIdentifier(_ value: String) -> Bool {
!value.isEmpty
&& value.utf8.allSatisfy {
$0 == 45 || (48...57).contains($0) || (65...90).contains($0) || (97...122).contains($0)
}
}
}
36 changes: 36 additions & 0 deletions Tests/CoreTests/CLISurfaceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,42 @@ import Testing

@Suite("CLI Surface")
struct CLISurfaceTests {
@Test(arguments: [false, true])
func removesMissingManagedSkillsByNameOrAll(_ all: Bool) throws {
let project = try cliTemporaryDirectory()
let home = try cliTemporaryDirectory()
let source = try cliTemporaryDirectory()
let skillDir = source.appendingPathComponent("skills/gone")
try FileManager.default.createDirectory(at: skillDir, withIntermediateDirectories: true)
try """
---
name: gone
description: Removable skill
---
""".write(to: skillDir.appendingPathComponent("SKILL.md"), atomically: true, encoding: .utf8)
let add = try runSkills(
["add", source.path, "--mode", "edit", "--agent", "codex"],
currentDirectory: project, environment: ["HOME": home.path])
#expect(add.exitCode == 0)
try FileManager.default.removeItem(at: source)
try FileManager.default.removeItem(at: project.appendingPathComponent(".agents/skills/gone"))

let before = try runSkills(
["doctor", "--agent", "codex", "--json"],
currentDirectory: project, environment: ["HOME": home.path])
#expect(before.stdout.contains("source-missing"))
let remove = try runSkills(
["remove", all ? "--all" : "gone", "--agent", "codex"],
currentDirectory: project, environment: ["HOME": home.path])
#expect(remove.exitCode == 0)
#expect(!remove.stdout.contains("No skills found"))
#expect(!remove.stdout.contains("No matching skills"))
let after = try runSkills(
["doctor", "--agent", "codex", "--json"],
currentDirectory: project, environment: ["HOME": home.path])
#expect(!after.stdout.contains("source-missing"))
}

@Test func exposesCurrentCommandFamilies() throws {
let help = try runSkills(["--help"])
#expect(help.exitCode == 0)
Expand Down
40 changes: 40 additions & 0 deletions Tests/CoreTests/CommandBehaviorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,46 @@ struct RuntimeServiceWorkflowTests {
_ = skillDir
}

@Test(arguments: [InstallMode.copy, .edit])
func removesManagedRecordsWithMissingInstallationsOrSources(_ mode: InstallMode) throws {
let project = try commandBehaviorTemporaryDirectory()
let home = try commandBehaviorTemporaryDirectory()
let sourceRoot = try commandBehaviorTemporaryDirectory()
_ = try writeCommandBehaviorSkill(root: sourceRoot, path: "skills/gone", name: "gone")
let environment = RuntimeEnvironment(
projectDirectory: project, homeDirectory: home, environment: [:])
_ = try RuntimeService.add(
AddOptions(
source: sourceRoot.path,
agents: [.codex],
skillNames: ["gone"],
scope: .project,
mode: mode,
environment: environment))
try FileManager.default.removeItem(at: sourceRoot)
try FileManager.default.removeItem(at: project.appendingPathComponent(".agents/skills/gone"))

#expect(
try RuntimeService.listInstalled(scope: .project, agents: [.codex], environment: environment)
.isEmpty)
let report = try RuntimeService.doctor(
scope: .project, agents: [.codex], environment: environment)
#expect(!report.ok)
if mode == .edit {
#expect(report.checks.contains { $0.message.contains("source-missing") })
}
#expect(
try RuntimeService.removableSkillNames(
scope: .project, agents: [.codex], environment: environment
).contains("gone"))

_ = try RuntimeService.remove(
skillNames: ["gone"], scope: .project, agents: [.codex], environment: environment)
#expect(try InstallLockStore.load(scope: .project, environment: environment).pins.isEmpty)
#expect(
try RuntimeService.doctor(scope: .project, agents: [.codex], environment: environment).ok)
}

@Test func installsWatchedSkillFromReviewedBaselineAndKeepsDryRunReadOnly() throws {
let project = try commandBehaviorTemporaryDirectory()
let home = try commandBehaviorTemporaryDirectory()
Expand Down
65 changes: 65 additions & 0 deletions Tests/CoreTests/InstallWorkflowTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,71 @@ extension InstallerTests {

@Suite("Source Resolution")
struct SourceResolutionTests {
@Test func installsExactPrereleaseInsteadOfAnEarlierTagWithoutTheSkill() throws {
let project = try temporaryDirectory()
let home = try temporaryDirectory()
let repo = project.appendingPathComponent("repo")
try FileManager.default.createDirectory(at: repo, withIntermediateDirectories: true)
try "Skills repository".write(
to: repo.appendingPathComponent("README.md"), atomically: true, encoding: .utf8)
try initGitRepo(repo)
try ProcessRunner.run(
"/usr/bin/env", arguments: ["git", "-C", repo.path, "tag", "v0.1.0-alpha.1"])

let skillDir = repo.appendingPathComponent("skills/reminder-creator")
try FileManager.default.createDirectory(at: skillDir, withIntermediateDirectories: true)
try """
---
name: reminder-creator
description: Prerelease skill
---
""".write(to: skillDir.appendingPathComponent("SKILL.md"), atomically: true, encoding: .utf8)
try ProcessRunner.run("/usr/bin/env", arguments: ["git", "-C", repo.path, "add", "."])
try ProcessRunner.run(
"/usr/bin/env", arguments: ["git", "-C", repo.path, "commit", "-m", "add prerelease skill"])
let prereleaseRevision = try gitHead(repo)
try ProcessRunner.run(
"/usr/bin/env", arguments: ["git", "-C", repo.path, "tag", "v0.1.0-alpha.4"])

try "Stable release".write(
to: repo.appendingPathComponent("README.md"), atomically: true, encoding: .utf8)
try ProcessRunner.run("/usr/bin/env", arguments: ["git", "-C", repo.path, "add", "."])
try ProcessRunner.run(
"/usr/bin/env", arguments: ["git", "-C", repo.path, "commit", "-m", "stable release"])
let stableRevision = try gitHead(repo)
try ProcessRunner.run("/usr/bin/env", arguments: ["git", "-C", repo.path, "tag", "v0.1.0"])
try ProcessRunner.run(
"/usr/bin/env", arguments: ["git", "-C", repo.path, "tag", "v0.1.0+build.1"])

let environment = RuntimeEnvironment(
projectDirectory: project, homeDirectory: home, environment: [:])
let outcome = try RuntimeService.add(
AddOptions(
source: repo.path, agents: [.codex], skillNames: ["reminder-creator"],
scope: .global, mode: .copy, path: "skills",
sourceRequirement: .exact("0.1.0-alpha.4"), environment: environment))
#expect(outcome.installed.map(\.skill) == ["reminder-creator"])
#expect(outcome.source.revision == prereleaseRevision)
#expect(outcome.source.resolvedVersion == "0.1.0-alpha.4")
let lock = try InstallLockStore.load(scope: .global, environment: environment)
#expect(lock.pins.first?.state.version == "0.1.0-alpha.4")
#expect(lock.pins.first?.state.revision == prereleaseRevision)

let stable = try SourceResolver.resolve(
repo.path, environment: environment, requirement: .exact("0.1.0"))
#expect(stable.requestedRef == "v0.1.0")
#expect(stable.revision == stableRevision)
let metadata = try SourceResolver.resolve(
repo.path, environment: environment, requirement: .exact("0.1.0+build.1"))
#expect(metadata.requestedRef == "v0.1.0+build.1")
#expect(metadata.resolvedVersion == "0.1.0+build.1")
let prereleaseRange = try SourceResolver.resolve(
repo.path, environment: environment,
requirement: .range(from: "0.1.0-alpha.1", to: "0.1.0"))
#expect(prereleaseRange.revision == prereleaseRevision)
#expect(prereleaseRange.resolvedVersion == "0.1.0-alpha.4")
}

@Test func resolvesSemanticVersionRequirementsFromTags() throws {
let project = try temporaryDirectory()
let home = try temporaryDirectory()
Expand Down
40 changes: 40 additions & 0 deletions Tests/CoreTests/SemanticVersionTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import Testing

@testable import Core

@Suite("Semantic Versions")
struct SemanticVersionTests {
@Test func preservesPrereleaseAndBuildIdentifiers() throws {
let version = try #require(SemanticVersion(" v0.1.0-alpha.4+build.01 "))
#expect(version.description == "0.1.0-alpha.4+build.01")
#expect(version.prerelease == ["alpha", "4"])
#expect(version.buildMetadata == ["build", "01"])
#expect(version != SemanticVersion("0.1.0-alpha.1"))
#expect(version != SemanticVersion("0.1.0"))
#expect(version == SemanticVersion("0.1.0-alpha.4+other"))
}

@Test func ordersPrereleasesBySemanticPrecedence() throws {
let ordered = try [
"1.0.0-alpha", "1.0.0-alpha.1", "1.0.0-alpha.beta", "1.0.0-beta",
"1.0.0-beta.2", "1.0.0-beta.10", "1.0.0-rc.1", "1.0.0",
].map { try #require(SemanticVersion($0)) }
for (lower, upper) in zip(ordered, ordered.dropFirst()) {
#expect(lower < upper)
}
#expect(ordered.reversed().sorted() == ordered)
#expect(
try #require(SemanticVersion("1.0.0-999999999999999999999999"))
< #require(SemanticVersion("1.0.0-1000000000000000000000000")))
#expect(SemanticVersion("1.0.0+first") == SemanticVersion("1.0.0+second"))
}

@Test(arguments: [
"", "1.2", "1..2.3", "-1.2.3", "01.2.3", "1.2.03", "1.2.3-",
"1.2.3-alpha..1", "1.2.3-alpha.01", "1.2.3-alpha_1", "1.2.3-α",
"1.2.3+", "1.2.3+build..1", "1.2.3+build+other",
])
func rejectsMalformedVersions(_ raw: String) {
#expect(SemanticVersion(raw) == nil)
}
}
Loading