From b83bc9e74d7111a1107bd15fba4add09c6541d81 Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Tue, 24 Feb 2026 09:30:26 +0500 Subject: [PATCH 01/17] feat: add Windows CI support with Swift 6.3 and swift-jinja migration - Add Windows build to ci.yml and release.yml using Swift 6.3 - Replace Stencil with swift-jinja for cross-platform compatibility - Add JinjaSupport shared module for template rendering - Make XcodeProj conditional (#if !os(Windows)) in Package.swift - Wrap XcodeProjectWriter in #if canImport(XcodeProj) (6 export files) - Use #if canImport(FoundationNetworking) instead of #if os(Linux) - Pin swift-resvg to 0.45.1-swift.15 (Windows artifactbundle) - Delete standalone windows-test.yml - Update docs: CLAUDE.md, linux-compat.md, README.md Co-Authored-By: Claude Opus 4.6 --- .claude/rules/linux-compat.md | 64 +++++++- .github/workflows/ci.yml | 25 +++ .github/workflows/release.yml | 41 ++++- .github/workflows/windows-test.yml | 36 ----- CLAUDE.md | 41 +++-- Package.swift | 104 +++++++------ .../ExFigCLI/ExFig.docc/CustomTemplates.md | 1 + Sources/ExFigCLI/Output/FileDownloader.swift | 2 +- Sources/ExFigCLI/Output/FileWriter.swift | 2 +- .../ExFigCLI/Output/XcodeProjectWriter.swift | 146 +++++++++--------- .../Pipeline/SharedDownloadQueue.swift | 2 +- .../Export/PluginColorsExport.swift | 36 +++-- .../Export/PluginIconsExport.swift | 34 ++-- .../Export/PluginImagesExport.swift | 34 ++-- .../Export/PluginTypographyExport.swift | 40 ++--- .../Subcommands/Export/iOSColorsExport.swift | 26 ++-- 16 files changed, 377 insertions(+), 257 deletions(-) delete mode 100644 .github/workflows/windows-test.yml diff --git a/.claude/rules/linux-compat.md b/.claude/rules/linux-compat.md index b2f9682c..1d8eb8ab 100644 --- a/.claude/rules/linux-compat.md +++ b/.claude/rules/linux-compat.md @@ -1,8 +1,8 @@ -# Linux Compatibility +# Linux & Windows Compatibility -This rule covers Linux-specific workarounds and differences from macOS. +This rule covers Linux- and Windows-specific workarounds and differences from macOS. -The project builds on Linux (Ubuntu 22.04 LTS / Jammy). Key differences from macOS: +The project builds on Linux (Ubuntu 22.04 LTS / Jammy) and Windows (Swift 6.3). Key differences from macOS: ## Required Import for Networking @@ -44,8 +44,56 @@ func testSomePngOperation() throws { ## Platform-Specific Features -| Feature | macOS | Linux | -| ------------ | ------------ | ------------------------ | -| HEIC encoding| ImageIO | Falls back to PNG | -| libpng tests | Full support | Build tests first | -| Foundation | Full | Some APIs missing/broken | +| Feature | macOS | Linux | Windows | +| ------------ | ------------ | ------------------------ | ------------------------ | +| HEIC encoding| ImageIO | Falls back to PNG | Falls back to PNG | +| libpng tests | Full support | Build tests first | Not tested | +| Foundation | Full | Some APIs missing/broken | Some APIs missing/broken | +| XcodeProj | Full | Full | Not available | +| Swift version| 6.2+ | 6.2+ | 6.3 required | + +## Windows Support + +### Swift Version + +Windows requires Swift 6.3 (development snapshot) due to `swift-resvg` artifactbundle compatibility. +CI uses `compnerd/gha-setup-swift@v0.3.0` with `swift-6.3-branch`. + +### Conditional Dependencies (Package.swift) + +`#if` inside array literals does NOT work in SPM Package.swift. Use variable + `#if` append pattern: + +```swift +var packageDependencies: [Package.Dependency] = [...] +#if !os(Windows) + packageDependencies.append(.package(url: "https://github.com/tuist/XcodeProj.git", from: "8.27.0")) +#endif +``` + +### XcodeProj Exclusion + +XcodeProj is Apple-only (depends on PathKit/AEXML). On Windows: +- Dependency excluded via `#if !os(Windows)` in Package.swift +- `XcodeProjectWriter` wrapped in `#if canImport(XcodeProj)` (6 files in Export/) +- Xcode project manipulation silently skipped on Windows + +### FoundationNetworking / FoundationXML + +Use `#if canImport()` instead of `#if os(Linux)` — covers both Linux and Windows: + +```swift +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +#if canImport(FoundationXML) + import FoundationXML +#endif +``` + +### SPM Artifactbundle on Windows + +SPM on Windows has library naming differences: +- Unix linkers auto-prepend `lib` prefix (`-lresvg` finds `libresvg.a`) +- Windows `lld-link` does NOT prepend prefix (`resvg.lib` must exist as-is) +- Swift 6.3 allows `.lib` files without `lib` prefix in artifactbundle info.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 038124f1..de69bf39 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -124,3 +124,28 @@ jobs: - name: Run tests # Build tests separately to avoid hangs from concurrent build + test execution run: swift test --skip-build --parallel 2>&1 | xcsift + + build-windows: + name: Build (Windows) + runs-on: windows-latest + needs: lint + steps: + - uses: actions/checkout@v6 + + - name: Install Swift + uses: compnerd/gha-setup-swift@v0.3.0 + with: + branch: swift-6.3-branch + tag: 6.3-DEVELOPMENT-SNAPSHOT-2026-02-21-a + + - name: Swift version + run: swift --version + + - name: Resolve dependencies + run: swift package resolve + + - name: Build (Debug) + run: swift build + + - name: Build (Release) + run: swift build -c release diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0de3d480..4cd612a2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,6 +28,10 @@ jobs: archive-name: exfig-linux-x64 container: swift:6.2.3-jammy extra-flags: --static-swift-stdlib -Xlinker -lcurl -Xlinker -lxml2 -Xlinker -lssl -Xlinker -lcrypto -Xlinker -lz + - os: windows-latest + platform: windows-x64 + build-path: x86_64-unknown-windows-msvc/release + archive-name: exfig-windows-x64 runs-on: ${{ matrix.os }} @@ -45,6 +49,13 @@ jobs: with: lfs: true + - name: Install Swift (Windows) + if: matrix.platform == 'windows-x64' + uses: compnerd/gha-setup-swift@v0.3.0 + with: + branch: swift-6.3-branch + tag: 6.3-DEVELOPMENT-SNAPSHOT-2026-02-21-a + - name: Select Xcode 26.1 (macOS) if: matrix.platform == 'macos' run: sudo xcode-select -s /Applications/Xcode_26.1.1.app/Contents/Developer @@ -54,7 +65,8 @@ jobs: run: | apt-get install -y libcurl4-openssl-dev libxml2-dev libssl-dev - - name: Set version from tag + - name: Set version from tag (Unix) + if: matrix.platform != 'windows-x64' run: | VERSION="${GITHUB_REF#refs/tags/}" FILE="Sources/ExFigCLI/ExFigCommand.swift" @@ -64,6 +76,14 @@ jobs: sed -i "s/static let version = \".*\"/static let version = \"$VERSION\"/" "$FILE" fi + - name: Set version from tag (Windows) + if: matrix.platform == 'windows-x64' + shell: pwsh + run: | + $version = $env:GITHUB_REF -replace '^refs/tags/', '' + $file = "Sources/ExFigCLI/ExFigCommand.swift" + (Get-Content $file) -replace 'static let version = ".*"', "static let version = `"$version`"" | Set-Content $file + - name: Build release binary (macOS) if: matrix.platform == 'macos' run: | @@ -83,6 +103,10 @@ jobs: if: matrix.platform == 'linux-x64' run: swift build -c release ${{ matrix.extra-flags }} + - name: Build release binary (Windows) + if: matrix.platform == 'windows-x64' + run: swift build -c release + - name: Create release archive (macOS) if: matrix.platform == 'macos' run: | @@ -109,6 +133,20 @@ jobs: cp LICENSE dist/ cd dist && tar -czf ../${{ matrix.archive-name }}.tar.gz -- * + - name: Create release archive (Windows) + if: matrix.platform == 'windows-x64' + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path dist + Copy-Item ".build/${{ matrix.build-path }}/exfig.exe" "dist/ExFig.exe" + Copy-Item ".build/${{ matrix.build-path }}/exfig_AndroidExport.resources" "dist/" -Recurse -ErrorAction SilentlyContinue + Copy-Item ".build/${{ matrix.build-path }}/exfig_XcodeExport.resources" "dist/" -Recurse -ErrorAction SilentlyContinue + Copy-Item ".build/${{ matrix.build-path }}/exfig_FlutterExport.resources" "dist/" -Recurse -ErrorAction SilentlyContinue + Copy-Item ".build/${{ matrix.build-path }}/exfig_WebExport.resources" "dist/" -Recurse -ErrorAction SilentlyContinue + Copy-Item ".build/${{ matrix.build-path }}/exfig_ExFigCLI.resources" "dist/" -Recurse -ErrorAction SilentlyContinue + Copy-Item "LICENSE" "dist/" + Compress-Archive -Path "dist/*" -DestinationPath "${{ matrix.archive-name }}.zip" + - name: Upload artifact uses: actions/upload-artifact@v7 with: @@ -218,6 +256,7 @@ jobs: files: | artifacts/exfig-macos/exfig-macos.zip artifacts/exfig-linux-x64/exfig-linux-x64.tar.gz + artifacts/exfig-windows-x64/exfig-windows-x64.zip .pkl-out/exfig@*/* completions/exfig.bash completions/_exfig diff --git a/.github/workflows/windows-test.yml b/.github/workflows/windows-test.yml deleted file mode 100644 index 7b9dc0ac..00000000 --- a/.github/workflows/windows-test.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: Windows Build Test - -on: - workflow_dispatch: - -jobs: - build-windows: - runs-on: windows-latest - steps: - - uses: actions/checkout@v6 - - # Swift 6.0.x is incompatible with Windows 11 SDK 26100 - # See: https://github.com/swiftlang/swift/issues/79745 - - name: Downgrade Windows SDK for Swift 6 compatibility - run: | - & "C:\Program Files (x86)\Microsoft Visual Studio\Installer\vs_installer.exe" modify ` - --remove Microsoft.VisualStudio.Component.Windows11SDK.26100 ` - --installPath "C:\Program Files\Microsoft Visual Studio\2022\Enterprise" ` - --quiet --norestart - - # Note: Swift 6.2 for Windows may not be available yet - # Check https://www.swift.org/download/ for latest Windows releases - - name: Install Swift - uses: compnerd/gha-setup-swift@main - with: - branch: swift-6.2-release - tag: 6.2-RELEASE - - - name: Swift version - run: swift --version - - - name: Build (Debug) - run: swift build - - - name: Build (Release) - run: swift build -c release diff --git a/CLAUDE.md b/CLAUDE.md index 624728ac..790542d9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,21 +84,21 @@ pkl eval --format json # Package URI requires published package ## Project Context -| Aspect | Details | -| --------------- | ---------------------------------------------------------------------------------- | -| Language | Swift 6.2, macOS 13.0+ | -| Package Manager | Swift Package Manager | -| CLI Framework | swift-argument-parser | -| Config Format | PKL (Programmable, Scalable, Safe) | -| Templates | Jinja2 (swift-jinja) | -| Required Env | `FIGMA_PERSONAL_TOKEN` | -| Config Files | `exfig.pkl` (PKL configuration) | -| Tooling | mise (`./bin/mise` self-contained, no global install needed) | -| Platforms | macOS 13+ (primary), Linux/Ubuntu 22.04 (CI) - see `.claude/rules/linux-compat.md` | +| Aspect | Details | +| --------------- | -------------------------------------------------------------------------------------------------- | +| Language | Swift 6.2, macOS 13.0+ | +| Package Manager | Swift Package Manager | +| CLI Framework | swift-argument-parser | +| Config Format | PKL (Programmable, Scalable, Safe) | +| Templates | Jinja2 (swift-jinja) | +| Required Env | `FIGMA_PERSONAL_TOKEN` | +| Config Files | `exfig.pkl` (PKL configuration) | +| Tooling | mise (`./bin/mise` self-contained, no global install needed) | +| Platforms | macOS 13+ (primary), Linux/Ubuntu 22.04, Windows (Swift 6.3) - see `.claude/rules/linux-compat.md` | ## Architecture -Twelve modules in `Sources/`: +Thirteen modules in `Sources/`: | Module | Purpose | | --------------- | --------------------------------------------------------- | @@ -412,6 +412,21 @@ NooraUI.format(.command("bold")) // bold/secondary NooraUI.formatLink("url", useColors: true) // underlined primary ``` +| Tests fail | Check `FIGMA_PERSONAL_TOKEN` is set | +| Formatting fails | Run `./bin/mise run setup` to install tools | +| test:filter no matches | SPM converts hyphens→underscores: use `ExFig_FlutterTests` not `ExFig-FlutterTests` | +| Template errors | Check Jinja2 syntax and context variables | +| Linux test hangs | Build first: `swift build --build-tests`, then `swift test --skip-build --parallel` | +| Android pathData long | Simplify in Figma or use `--strict-path-validation` | +| PKL parse error 1 | Check `PklError.message` — actual error is in `.message`, not `.localizedDescription` | +| Test target won't compile | Broken test files block entire target; use `swift test --filter Target.Class` after `build` | +| Test helper JSON decode | `ContainingFrame` uses default Codable (camelCase: `nodeId`, `pageName`), NOT snake_case | +| Web entry test fails | Web entry types use `outputDirectory` field, while Android/Flutter use `output` | +| Logger concatenation err | `Logger.Message` (swift-log) requires interpolation `"\(a) \(b)"`, not concatenation `a + b` | +| Deleted variables in output | Filter `VariableValue.deletedButReferenced != true` in variable loaders AND `CodeSyntaxSyncer` | +| Jinja trailing `\n` | `{% if false %}...{% endif %}\n` renders `"\n"`, not `""` — strip whitespace-only partial template results | +| `Bundle.module` in tests | SPM test targets without declared resources don't have `Bundle.module` — use `Bundle.main` or temp bundle | + ## Additional Rules Contextual documentation is in `.claude/rules/`: @@ -427,7 +442,7 @@ Contextual documentation is in `.claude/rules/`: | `api-reference.md` | Figma API endpoints, response mapping | | `troubleshooting.md` | Build/test/PKL/MCP/Penpot problem-solution pairs | | `gotchas.md` | Swift 6 concurrency, SwiftLint, rate limits | -| `linux-compat.md` | Linux-specific workarounds | +| `linux-compat.md` | Linux/Windows platform workarounds | | `testing-workflow.md` | Testing guidelines, commit format | | `pkl-codegen.md` | pkl-swift generated types, enum bridging, codegen | | `Sources/*/CLAUDE.md` | Module-specific patterns, modification checklists | diff --git a/Package.swift b/Package.swift index dd10ef1f..e3b137a3 100644 --- a/Package.swift +++ b/Package.swift @@ -3,6 +3,64 @@ import PackageDescription +// MARK: - Conditional Dependencies + +var packageDependencies: [Package.Dependency] = [ + .package(url: "https://github.com/apple/swift-collections", "1.2.0" ..< "1.3.0"), + .package(url: "https://github.com/apple/swift-argument-parser", from: "1.5.0"), + .package(url: "https://github.com/apple/swift-log.git", from: "1.6.0"), + .package(url: "https://github.com/huggingface/swift-jinja.git", from: "2.0.0"), + .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "1.3.0"), + + .package(url: "https://github.com/the-swift-collective/libwebp.git", from: "1.4.1"), + .package(url: "https://github.com/the-swift-collective/libpng.git", from: "1.6.45"), + .package(url: "https://github.com/tuist/Noora", from: "0.54.0"), + .package(url: "https://github.com/swiftlang/swift-docc-plugin", from: "1.4.5"), + .package(url: "https://github.com/alexey1312/swift-resvg.git", exact: "0.45.1-swift.3"), + .package(url: "https://github.com/mattt/swift-yyjson", from: "0.5.0"), + .package(url: "https://github.com/apple/pkl-swift", from: "0.8.0"), + .package(url: "https://github.com/DesignPipe/swift-svgkit.git", from: "0.1.0"), + .package(url: "https://github.com/DesignPipe/swift-figma-api.git", from: "0.2.0"), + .package(url: "https://github.com/DesignPipe/swift-penpot-api.git", from: "0.1.0"), + .package(url: "https://github.com/modelcontextprotocol/swift-sdk.git", from: "0.9.0"), +] + +var exfigCLIDependencies: [Target.Dependency] = [ + .product(name: "FigmaAPI", package: "swift-figma-api"), + .product(name: "PenpotAPI", package: "swift-penpot-api"), + "ExFigCore", + "ExFigConfig", + "XcodeExport", + "AndroidExport", + "FlutterExport", + "WebExport", + .product(name: "SVGKit", package: "swift-svgkit"), + "ExFig-iOS", + "ExFig-Android", + "ExFig-Flutter", + "ExFig-Web", + .product(name: "Resvg", package: "swift-resvg"), + .product(name: "ArgumentParser", package: "swift-argument-parser"), + .product(name: "Logging", package: "swift-log"), + + .product(name: "WebP", package: "libwebp"), + .product(name: "LibPNG", package: "libpng"), + .product(name: "Noora", package: "Noora"), + .product(name: "MCP", package: "swift-sdk"), +] + +// XcodeProj is Apple-only (not available on Windows) +#if !os(Windows) + packageDependencies.append( + .package(url: "https://github.com/tuist/XcodeProj.git", from: "8.27.0") + ) + exfigCLIDependencies.append( + .product(name: "XcodeProj", package: "XcodeProj") + ) +#endif + +// MARK: - Package + let package = Package( name: "exfig", platforms: [ @@ -11,54 +69,12 @@ let package = Package( products: [ .executable(name: "exfig", targets: ["ExFigCLI"]), ], - dependencies: [ - .package(url: "https://github.com/apple/swift-collections", "1.2.0" ..< "1.3.0"), - .package(url: "https://github.com/apple/swift-argument-parser", from: "1.5.0"), - .package(url: "https://github.com/apple/swift-log.git", from: "1.6.0"), - .package(url: "https://github.com/huggingface/swift-jinja.git", from: "2.0.0"), - .package(url: "https://github.com/tuist/XcodeProj.git", from: "8.27.0"), - .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "1.3.0"), - - .package(url: "https://github.com/the-swift-collective/libwebp.git", from: "1.4.1"), - .package(url: "https://github.com/the-swift-collective/libpng.git", from: "1.6.45"), - .package(url: "https://github.com/tuist/Noora", from: "0.54.0"), - .package(url: "https://github.com/swiftlang/swift-docc-plugin", from: "1.4.5"), - .package(url: "https://github.com/alexey1312/swift-resvg.git", exact: "0.45.1-swift.3"), - .package(url: "https://github.com/mattt/swift-yyjson", from: "0.5.0"), - .package(url: "https://github.com/apple/pkl-swift", from: "0.8.0"), - .package(url: "https://github.com/DesignPipe/swift-svgkit.git", from: "0.1.0"), - .package(url: "https://github.com/DesignPipe/swift-figma-api.git", from: "0.2.0"), - .package(url: "https://github.com/DesignPipe/swift-penpot-api.git", from: "0.1.0"), - .package(url: "https://github.com/modelcontextprotocol/swift-sdk.git", from: "0.9.0"), - ], + dependencies: packageDependencies, targets: [ // Main target .executableTarget( name: "ExFigCLI", - dependencies: [ - .product(name: "FigmaAPI", package: "swift-figma-api"), - .product(name: "PenpotAPI", package: "swift-penpot-api"), - "ExFigCore", - "ExFigConfig", - "XcodeExport", - "AndroidExport", - "FlutterExport", - "WebExport", - .product(name: "SVGKit", package: "swift-svgkit"), - "ExFig-iOS", - "ExFig-Android", - "ExFig-Flutter", - "ExFig-Web", - .product(name: "Resvg", package: "swift-resvg"), - .product(name: "XcodeProj", package: "XcodeProj"), - .product(name: "ArgumentParser", package: "swift-argument-parser"), - .product(name: "Logging", package: "swift-log"), - - .product(name: "WebP", package: "libwebp"), - .product(name: "LibPNG", package: "libpng"), - .product(name: "Noora", package: "Noora"), - .product(name: "MCP", package: "swift-sdk"), - ], + dependencies: exfigCLIDependencies, exclude: ["CLAUDE.md", "AGENTS.md"], resources: [ .copy("Resources/Schemas/"), diff --git a/Sources/ExFigCLI/ExFig.docc/CustomTemplates.md b/Sources/ExFigCLI/ExFig.docc/CustomTemplates.md index f0072459..ec64fdb4 100644 --- a/Sources/ExFigCLI/ExFig.docc/CustomTemplates.md +++ b/Sources/ExFigCLI/ExFig.docc/CustomTemplates.md @@ -341,6 +341,7 @@ images: [{ componentName: "HeroBanner", fileName: "hero_banner" }] {% endif %} ``` + ## Jinja2 Syntax Reference ### Variables diff --git a/Sources/ExFigCLI/Output/FileDownloader.swift b/Sources/ExFigCLI/Output/FileDownloader.swift index 2062530f..5b61f8b3 100644 --- a/Sources/ExFigCLI/Output/FileDownloader.swift +++ b/Sources/ExFigCLI/Output/FileDownloader.swift @@ -1,7 +1,7 @@ import ExFigCore import Foundation import Logging -#if os(Linux) +#if canImport(FoundationNetworking) import FoundationNetworking #endif diff --git a/Sources/ExFigCLI/Output/FileWriter.swift b/Sources/ExFigCLI/Output/FileWriter.swift index 2b4a6f3f..5e8e30cb 100644 --- a/Sources/ExFigCLI/Output/FileWriter.swift +++ b/Sources/ExFigCLI/Output/FileWriter.swift @@ -1,6 +1,6 @@ import ExFigCore import Foundation -#if os(Linux) +#if canImport(FoundationXML) import FoundationXML #endif diff --git a/Sources/ExFigCLI/Output/XcodeProjectWriter.swift b/Sources/ExFigCLI/Output/XcodeProjectWriter.swift index ade5abeb..fc7e3367 100644 --- a/Sources/ExFigCLI/Output/XcodeProjectWriter.swift +++ b/Sources/ExFigCLI/Output/XcodeProjectWriter.swift @@ -1,90 +1,92 @@ -import Foundation -import PathKit -import XcodeProj +#if canImport(XcodeProj) + import Foundation + import PathKit + import XcodeProj -enum XcodeProjectWriterError: LocalizedError { - case unableToFindTarget(String) + enum XcodeProjectWriterError: LocalizedError { + case unableToFindTarget(String) - var errorDescription: String? { - switch self { - case let .unableToFindTarget(name): - "Target not found: \(name)" + var errorDescription: String? { + switch self { + case let .unableToFindTarget(name): + "Target not found: \(name)" + } } - } - var recoverySuggestion: String? { - switch self { - case .unableToFindTarget: - "Check target name in Xcode project settings" + var recoverySuggestion: String? { + switch self { + case .unableToFindTarget: + "Check target name in Xcode project settings" + } } } -} -final class XcodeProjectWriter { - let xcodeprojPath: Path - let rootPath = Path("./") - let xcodeproj: XcodeProj - let pbxproj: PBXProj - let myTarget: PBXTarget - let project: PBXProject + final class XcodeProjectWriter { + let xcodeprojPath: Path + let rootPath = Path("./") + let xcodeproj: XcodeProj + let pbxproj: PBXProj + let myTarget: PBXTarget + let project: PBXProject - init(xcodeProjPath: String, target: String) throws { - xcodeprojPath = Path(xcodeProjPath) - xcodeproj = try XcodeProj(path: xcodeprojPath) - pbxproj = xcodeproj.pbxproj - if let target = pbxproj.targets(named: target).first { - myTarget = target - } else { - throw XcodeProjectWriterError.unableToFindTarget(target) - } - guard let firstProject = pbxproj.projects.first else { - throw XcodeProjectWriterError.unableToFindTarget("No project found") + init(xcodeProjPath: String, target: String) throws { + xcodeprojPath = Path(xcodeProjPath) + xcodeproj = try XcodeProj(path: xcodeprojPath) + pbxproj = xcodeproj.pbxproj + if let target = pbxproj.targets(named: target).first { + myTarget = target + } else { + throw XcodeProjectWriterError.unableToFindTarget(target) + } + guard let firstProject = pbxproj.projects.first else { + throw XcodeProjectWriterError.unableToFindTarget("No project found") + } + project = firstProject } - project = firstProject - } - func addFileReferenceToXcodeProj(_ url: URL) throws { - var groups = url.pathComponents - .filter { $0 != "." } - .dropLast() as Array + func addFileReferenceToXcodeProj(_ url: URL) throws { + var groups = url.pathComponents + .filter { $0 != "." } + .dropLast() as Array - var currentGroup: PBXGroup? = project.mainGroup - var prevGroup: PBXGroup? + var currentGroup: PBXGroup? = project.mainGroup + var prevGroup: PBXGroup? - while currentGroup != nil { - if groups.isEmpty { break } - let group = currentGroup?.children.first(where: { group -> Bool in - group.path == groups.first - }) - if let group { - prevGroup = currentGroup - currentGroup = group as? PBXGroup - groups = Array(groups.dropFirst()) - } else { - prevGroup = currentGroup - let groupName = groups[0] - currentGroup = try prevGroup?.addGroup(named: groupName).first - groups = Array(groups.dropFirst()) + while currentGroup != nil { + if groups.isEmpty { break } + let group = currentGroup?.children.first(where: { group -> Bool in + group.path == groups.first + }) + if let group { + prevGroup = currentGroup + currentGroup = group as? PBXGroup + groups = Array(groups.dropFirst()) + } else { + prevGroup = currentGroup + let groupName = groups[0] + currentGroup = try prevGroup?.addGroup(named: groupName).first + groups = Array(groups.dropFirst()) + } } - } - guard currentGroup?.children.first(where: { $0.path == url.lastPathComponent }) == nil else { return } + guard currentGroup?.children.first(where: { $0.path == url.lastPathComponent }) == nil else { return } - let newFile = try currentGroup?.addFile( - at: Path(url.path), - sourceTree: .group, - sourceRoot: rootPath, - override: false, - validatePresence: true - ) - newFile?.fileEncoding = 4 // UTF-8 - newFile?.name = url.lastPathComponent + let newFile = try currentGroup?.addFile( + at: Path(url.path), + sourceTree: .group, + sourceRoot: rootPath, + override: false, + validatePresence: true + ) + newFile?.fileEncoding = 4 // UTF-8 + newFile?.name = url.lastPathComponent - if let file = newFile, let buildPhase = myTarget.buildPhases.first(where: { $0.buildPhase == .sources }) { - _ = try buildPhase.add(file: file) + if let file = newFile, let buildPhase = myTarget.buildPhases.first(where: { $0.buildPhase == .sources }) { + _ = try buildPhase.add(file: file) + } } - } - func save() throws { - try xcodeproj.write(path: xcodeprojPath) + func save() throws { + try xcodeproj.write(path: xcodeprojPath) + } } -} +#endif diff --git a/Sources/ExFigCLI/Pipeline/SharedDownloadQueue.swift b/Sources/ExFigCLI/Pipeline/SharedDownloadQueue.swift index 61bb787e..94495b30 100644 --- a/Sources/ExFigCLI/Pipeline/SharedDownloadQueue.swift +++ b/Sources/ExFigCLI/Pipeline/SharedDownloadQueue.swift @@ -1,7 +1,7 @@ import ExFigCore import Foundation import Logging -#if os(Linux) +#if canImport(FoundationNetworking) import FoundationNetworking #endif diff --git a/Sources/ExFigCLI/Subcommands/Export/PluginColorsExport.swift b/Sources/ExFigCLI/Subcommands/Export/PluginColorsExport.swift index c1cb156b..fb4a51c1 100644 --- a/Sources/ExFigCLI/Subcommands/Export/PluginColorsExport.swift +++ b/Sources/ExFigCLI/Subcommands/Export/PluginColorsExport.swift @@ -55,26 +55,28 @@ extension ExFigCommand.ExportColors { try await syncCodeSyntaxIfNeeded(entries: entries, client: client, ui: ui) // Post-export: update Xcode project (only if not in Swift Package) - if ios.xcassetsInSwiftPackage != true { - do { - let xcodeProject = try XcodeProjectWriter( - xcodeProjPath: ios.xcodeprojPath, - target: ios.target - ) - // Add Swift file references for each entry - for entry in entries { - if let url = entry.colorSwiftURL { - try xcodeProject.addFileReferenceToXcodeProj(url) - } - if let url = entry.swiftuiColorSwiftURL { - try xcodeProject.addFileReferenceToXcodeProj(url) + #if canImport(XcodeProj) + if ios.xcassetsInSwiftPackage != true { + do { + let xcodeProject = try XcodeProjectWriter( + xcodeProjPath: ios.xcodeprojPath, + target: ios.target + ) + // Add Swift file references for each entry + for entry in entries { + if let url = entry.colorSwiftURL { + try xcodeProject.addFileReferenceToXcodeProj(url) + } + if let url = entry.swiftuiColorSwiftURL { + try xcodeProject.addFileReferenceToXcodeProj(url) + } } + try xcodeProject.save() + } catch { + ui.warning(.xcodeProjectUpdateFailed(detail: error.localizedDescription)) } - try xcodeProject.save() - } catch { - ui.warning(.xcodeProjectUpdateFailed(detail: error.localizedDescription)) } - } + #endif // Check for updates (only in standalone mode) if !batchMode { diff --git a/Sources/ExFigCLI/Subcommands/Export/PluginIconsExport.swift b/Sources/ExFigCLI/Subcommands/Export/PluginIconsExport.swift index b73dcfaf..194cca4e 100644 --- a/Sources/ExFigCLI/Subcommands/Export/PluginIconsExport.swift +++ b/Sources/ExFigCLI/Subcommands/Export/PluginIconsExport.swift @@ -79,25 +79,27 @@ extension ExFigCommand.ExportIcons { } // Post-export: update Xcode project (only if not in Swift Package) - if ios.xcassetsInSwiftPackage != true { - do { - let xcodeProject = try XcodeProjectWriter( - xcodeProjPath: ios.xcodeprojPath, - target: ios.target - ) - for entry in entries { - if let url = entry.imageSwiftURL { - try xcodeProject.addFileReferenceToXcodeProj(url) - } - if let url = entry.swiftUIImageSwiftURL { - try xcodeProject.addFileReferenceToXcodeProj(url) + #if canImport(XcodeProj) + if ios.xcassetsInSwiftPackage != true { + do { + let xcodeProject = try XcodeProjectWriter( + xcodeProjPath: ios.xcodeprojPath, + target: ios.target + ) + for entry in entries { + if let url = entry.imageSwiftURL { + try xcodeProject.addFileReferenceToXcodeProj(url) + } + if let url = entry.swiftUIImageSwiftURL { + try xcodeProject.addFileReferenceToXcodeProj(url) + } } + try xcodeProject.save() + } catch { + ui.warning(.xcodeProjectUpdateFailed(detail: error.localizedDescription)) } - try xcodeProject.save() - } catch { - ui.warning(.xcodeProjectUpdateFailed(detail: error.localizedDescription)) } - } + #endif // Check for updates (only in standalone mode) if !batchMode { diff --git a/Sources/ExFigCLI/Subcommands/Export/PluginImagesExport.swift b/Sources/ExFigCLI/Subcommands/Export/PluginImagesExport.swift index 90cb4d54..4d0d2832 100644 --- a/Sources/ExFigCLI/Subcommands/Export/PluginImagesExport.swift +++ b/Sources/ExFigCLI/Subcommands/Export/PluginImagesExport.swift @@ -78,25 +78,27 @@ extension ExFigCommand.ExportImages { } // Post-export: update Xcode project (only if not in Swift Package) - if ios.xcassetsInSwiftPackage != true { - do { - let xcodeProject = try XcodeProjectWriter( - xcodeProjPath: ios.xcodeprojPath, - target: ios.target - ) - for entry in entries { - if let url = entry.imageSwiftURL { - try xcodeProject.addFileReferenceToXcodeProj(url) - } - if let url = entry.swiftUIImageSwiftURL { - try xcodeProject.addFileReferenceToXcodeProj(url) + #if canImport(XcodeProj) + if ios.xcassetsInSwiftPackage != true { + do { + let xcodeProject = try XcodeProjectWriter( + xcodeProjPath: ios.xcodeprojPath, + target: ios.target + ) + for entry in entries { + if let url = entry.imageSwiftURL { + try xcodeProject.addFileReferenceToXcodeProj(url) + } + if let url = entry.swiftUIImageSwiftURL { + try xcodeProject.addFileReferenceToXcodeProj(url) + } } + try xcodeProject.save() + } catch { + ui.warning(.xcodeProjectUpdateFailed(detail: error.localizedDescription)) } - try xcodeProject.save() - } catch { - ui.warning(.xcodeProjectUpdateFailed(detail: error.localizedDescription)) } - } + #endif // Check for updates (only in standalone mode) if !batchMode { diff --git a/Sources/ExFigCLI/Subcommands/Export/PluginTypographyExport.swift b/Sources/ExFigCLI/Subcommands/Export/PluginTypographyExport.swift index 3512d80a..c7f0c4b2 100644 --- a/Sources/ExFigCLI/Subcommands/Export/PluginTypographyExport.swift +++ b/Sources/ExFigCLI/Subcommands/Export/PluginTypographyExport.swift @@ -58,27 +58,29 @@ extension ExFigCommand.ExportTypography { ) // Post-export: update Xcode project (only if not in Swift Package) - if ios.xcassetsInSwiftPackage != true { - do { - let xcodeProject = try XcodeProjectWriter( - xcodeProjPath: ios.xcodeprojPath, - target: ios.target - ) - // Add Swift file references - if let url = pluginEntry.fontSwiftURL { - try xcodeProject.addFileReferenceToXcodeProj(url) + #if canImport(XcodeProj) + if ios.xcassetsInSwiftPackage != true { + do { + let xcodeProject = try XcodeProjectWriter( + xcodeProjPath: ios.xcodeprojPath, + target: ios.target + ) + // Add Swift file references + if let url = pluginEntry.fontSwiftURL { + try xcodeProject.addFileReferenceToXcodeProj(url) + } + if let url = pluginEntry.swiftUIFontSwiftURL { + try xcodeProject.addFileReferenceToXcodeProj(url) + } + if let url = pluginEntry.labelStyleSwiftURL { + try xcodeProject.addFileReferenceToXcodeProj(url) + } + try xcodeProject.save() + } catch { + input.ui.warning(.xcodeProjectUpdateFailed(detail: error.localizedDescription)) } - if let url = pluginEntry.swiftUIFontSwiftURL { - try xcodeProject.addFileReferenceToXcodeProj(url) - } - if let url = pluginEntry.labelStyleSwiftURL { - try xcodeProject.addFileReferenceToXcodeProj(url) - } - try xcodeProject.save() - } catch { - input.ui.warning(.xcodeProjectUpdateFailed(detail: error.localizedDescription)) } - } + #endif // Check for updates (only in standalone mode) if !batchMode { diff --git a/Sources/ExFigCLI/Subcommands/Export/iOSColorsExport.swift b/Sources/ExFigCLI/Subcommands/Export/iOSColorsExport.swift index d30459d2..06b4afb4 100644 --- a/Sources/ExFigCLI/Subcommands/Export/iOSColorsExport.swift +++ b/Sources/ExFigCLI/Subcommands/Export/iOSColorsExport.swift @@ -52,19 +52,21 @@ extension ExFigCommand.ExportColors { return } - do { - let xcodeProject = try XcodeProjectWriter( - xcodeProjPath: ios.xcodeprojPath, - target: ios.target - ) - try files.forEach { file in - if file.destination.file.pathExtension == "swift" { - try xcodeProject.addFileReferenceToXcodeProj(file.destination.url) + #if canImport(XcodeProj) + do { + let xcodeProject = try XcodeProjectWriter( + xcodeProjPath: ios.xcodeprojPath, + target: ios.target + ) + try files.forEach { file in + if file.destination.file.pathExtension == "swift" { + try xcodeProject.addFileReferenceToXcodeProj(file.destination.url) + } } + try xcodeProject.save() + } catch { + ui.warning(.xcodeProjectUpdateFailed(detail: error.localizedDescription)) } - try xcodeProject.save() - } catch { - ui.warning(.xcodeProjectUpdateFailed(detail: error.localizedDescription)) - } + #endif } } From 7425b206d47a682d1ad4463d63dfaa1353a59d4a Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Tue, 24 Feb 2026 09:48:45 +0500 Subject: [PATCH 02/17] chore: archive add-windows-support openspec change Co-Authored-By: Claude Opus 4.6 --- .../2026-02-24-add-windows-support}/design.md | 0 .../2026-02-24-add-windows-support}/proposal.md | 0 .../specs/platform-support/spec.md | 0 .../2026-02-24-add-windows-support}/tasks.md | 0 4 files changed, 0 insertions(+), 0 deletions(-) rename openspec/changes/{add-windows-support => archive/2026-02-24-add-windows-support}/design.md (100%) rename openspec/changes/{add-windows-support => archive/2026-02-24-add-windows-support}/proposal.md (100%) rename openspec/changes/{add-windows-support => archive/2026-02-24-add-windows-support}/specs/platform-support/spec.md (100%) rename openspec/changes/{add-windows-support => archive/2026-02-24-add-windows-support}/tasks.md (100%) diff --git a/openspec/changes/add-windows-support/design.md b/openspec/changes/archive/2026-02-24-add-windows-support/design.md similarity index 100% rename from openspec/changes/add-windows-support/design.md rename to openspec/changes/archive/2026-02-24-add-windows-support/design.md diff --git a/openspec/changes/add-windows-support/proposal.md b/openspec/changes/archive/2026-02-24-add-windows-support/proposal.md similarity index 100% rename from openspec/changes/add-windows-support/proposal.md rename to openspec/changes/archive/2026-02-24-add-windows-support/proposal.md diff --git a/openspec/changes/add-windows-support/specs/platform-support/spec.md b/openspec/changes/archive/2026-02-24-add-windows-support/specs/platform-support/spec.md similarity index 100% rename from openspec/changes/add-windows-support/specs/platform-support/spec.md rename to openspec/changes/archive/2026-02-24-add-windows-support/specs/platform-support/spec.md diff --git a/openspec/changes/add-windows-support/tasks.md b/openspec/changes/archive/2026-02-24-add-windows-support/tasks.md similarity index 100% rename from openspec/changes/add-windows-support/tasks.md rename to openspec/changes/archive/2026-02-24-add-windows-support/tasks.md From a43e3fe3e2835a9c724aef54f556fda3929487eb Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Wed, 25 Mar 2026 13:39:11 +0500 Subject: [PATCH 03/17] chore: regenerate llms-full.txt after rebase --- llms-full.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/llms-full.txt b/llms-full.txt index 3c1c216b..61bdd015 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -2033,6 +2033,7 @@ images: [{ componentName: "HeroBanner", fileName: "hero_banner" }] {% endif %} ``` + ## Jinja2 Syntax Reference ### Variables From 4fced7b95f380a9d1db128334538c0213da18201 Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Wed, 25 Mar 2026 13:43:15 +0500 Subject: [PATCH 04/17] style: fix markdown table formatting in CLAUDE.md --- CLAUDE.md | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 790542d9..6b07d6ce 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -412,20 +412,20 @@ NooraUI.format(.command("bold")) // bold/secondary NooraUI.formatLink("url", useColors: true) // underlined primary ``` -| Tests fail | Check `FIGMA_PERSONAL_TOKEN` is set | -| Formatting fails | Run `./bin/mise run setup` to install tools | -| test:filter no matches | SPM converts hyphens→underscores: use `ExFig_FlutterTests` not `ExFig-FlutterTests` | -| Template errors | Check Jinja2 syntax and context variables | -| Linux test hangs | Build first: `swift build --build-tests`, then `swift test --skip-build --parallel` | -| Android pathData long | Simplify in Figma or use `--strict-path-validation` | -| PKL parse error 1 | Check `PklError.message` — actual error is in `.message`, not `.localizedDescription` | -| Test target won't compile | Broken test files block entire target; use `swift test --filter Target.Class` after `build` | -| Test helper JSON decode | `ContainingFrame` uses default Codable (camelCase: `nodeId`, `pageName`), NOT snake_case | -| Web entry test fails | Web entry types use `outputDirectory` field, while Android/Flutter use `output` | -| Logger concatenation err | `Logger.Message` (swift-log) requires interpolation `"\(a) \(b)"`, not concatenation `a + b` | -| Deleted variables in output | Filter `VariableValue.deletedButReferenced != true` in variable loaders AND `CodeSyntaxSyncer` | -| Jinja trailing `\n` | `{% if false %}...{% endif %}\n` renders `"\n"`, not `""` — strip whitespace-only partial template results | -| `Bundle.module` in tests | SPM test targets without declared resources don't have `Bundle.module` — use `Bundle.main` or temp bundle | +| Tests fail | Check `FIGMA_PERSONAL_TOKEN` is set | +| Formatting fails | Run `./bin/mise run setup` to install tools | +| test:filter no matches | SPM converts hyphens→underscores: use `ExFig_FlutterTests` not `ExFig-FlutterTests` | +| Template errors | Check Jinja2 syntax and context variables | +| Linux test hangs | Build first: `swift build --build-tests`, then `swift test --skip-build --parallel` | +| Android pathData long | Simplify in Figma or use `--strict-path-validation` | +| PKL parse error 1 | Check `PklError.message` — actual error is in `.message`, not `.localizedDescription` | +| Test target won't compile | Broken test files block entire target; use `swift test --filter Target.Class` after `build` | +| Test helper JSON decode | `ContainingFrame` uses default Codable (camelCase: `nodeId`, `pageName`), NOT snake_case | +| Web entry test fails | Web entry types use `outputDirectory` field, while Android/Flutter use `output` | +| Logger concatenation err | `Logger.Message` (swift-log) requires interpolation `"\(a) \(b)"`, not concatenation `a + b` | +| Deleted variables in output | Filter `VariableValue.deletedButReferenced != true` in variable loaders AND `CodeSyntaxSyncer` | +| Jinja trailing `\n` | `{% if false %}...{% endif %}\n` renders `"\n"`, not `""` — strip whitespace-only partial template results | +| `Bundle.module` in tests | SPM test targets without declared resources don't have `Bundle.module` — use `Bundle.main` or temp bundle | ## Additional Rules From 88d90206f65a2b50a5d5dcbe0b185d599fa95432 Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Wed, 25 Mar 2026 13:47:11 +0500 Subject: [PATCH 05/17] fix: suppress function_body_length swiftlint violations --- Sources/ExFigCLI/Subcommands/Export/PluginIconsExport.swift | 2 ++ Sources/ExFigCLI/Subcommands/Export/PluginImagesExport.swift | 2 ++ 2 files changed, 4 insertions(+) diff --git a/Sources/ExFigCLI/Subcommands/Export/PluginIconsExport.swift b/Sources/ExFigCLI/Subcommands/Export/PluginIconsExport.swift index 194cca4e..3653d005 100644 --- a/Sources/ExFigCLI/Subcommands/Export/PluginIconsExport.swift +++ b/Sources/ExFigCLI/Subcommands/Export/PluginIconsExport.swift @@ -11,6 +11,8 @@ import XcodeExport // MARK: - Plugin-based Icons Export +// swiftlint:disable function_body_length + extension ExFigCommand.ExportIcons { /// Exports iOS icons using plugin architecture. /// diff --git a/Sources/ExFigCLI/Subcommands/Export/PluginImagesExport.swift b/Sources/ExFigCLI/Subcommands/Export/PluginImagesExport.swift index 4d0d2832..dbb41118 100644 --- a/Sources/ExFigCLI/Subcommands/Export/PluginImagesExport.swift +++ b/Sources/ExFigCLI/Subcommands/Export/PluginImagesExport.swift @@ -11,6 +11,8 @@ import XcodeExport // MARK: - Plugin-based Images Export +// swiftlint:disable function_body_length + extension ExFigCommand.ExportImages { /// Exports iOS images using plugin architecture. /// From 5270e276eb7ab098e375d7d1bceb0314a4a4e1fa Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Wed, 25 Mar 2026 14:19:03 +0500 Subject: [PATCH 06/17] fix: bump swift-resvg to 0.45.1-swift.15 for Windows artifactbundle --- Package.resolved | 10 +++++----- Package.swift | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Package.resolved b/Package.resolved index 0f05d0ec..fa30a581 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "2b3e6002adcb3e450ba06731dcbfc333d3f62894c0ca599c86c390156f0cf048", + "originHash" : "4775d02a1dbb70d8a2bbd991f0fc6f34825eadf35b37f0af4cb0262bcb4bc491", "pins" : [ { "identity" : "aexml", @@ -213,8 +213,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/alexey1312/swift-resvg.git", "state" : { - "revision" : "f408b4a39dd59b402f1db3605daff6748c5e81ec", - "version" : "0.45.1-swift.3" + "revision" : "6707e4f94a05b4ea91e82cafa9b6c4ecb78a73bb", + "version" : "0.45.1-swift.15" } }, { @@ -231,8 +231,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/DesignPipe/swift-svgkit.git", "state" : { - "revision" : "83a9601fc4f4d3acaf34fca2f74d1b9faea2b932", - "version" : "0.1.0" + "revision" : "6e10eef2bbb4b5921a2d59d391647e6c1c81400b", + "version" : "0.2.0" } }, { diff --git a/Package.swift b/Package.swift index e3b137a3..6b9285e1 100644 --- a/Package.swift +++ b/Package.swift @@ -16,7 +16,7 @@ var packageDependencies: [Package.Dependency] = [ .package(url: "https://github.com/the-swift-collective/libpng.git", from: "1.6.45"), .package(url: "https://github.com/tuist/Noora", from: "0.54.0"), .package(url: "https://github.com/swiftlang/swift-docc-plugin", from: "1.4.5"), - .package(url: "https://github.com/alexey1312/swift-resvg.git", exact: "0.45.1-swift.3"), + .package(url: "https://github.com/alexey1312/swift-resvg.git", exact: "0.45.1-swift.15"), .package(url: "https://github.com/mattt/swift-yyjson", from: "0.5.0"), .package(url: "https://github.com/apple/pkl-swift", from: "0.8.0"), .package(url: "https://github.com/DesignPipe/swift-svgkit.git", from: "0.1.0"), From 2ec91fa1982ff67562e9a107845e65ac21bb9011 Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Wed, 25 Mar 2026 14:40:23 +0500 Subject: [PATCH 07/17] fix: exclude MCP SDK on Windows (swift-nio incompatible) MCP swift-sdk depends on swift-nio which doesn't compile on Windows. Wrap MCP files in #if canImport(MCP) and conditionally include the dependency only on non-Windows platforms. --- .swiftlint.yml | 1 + Package.swift | 11 +- Sources/ExFigCLI/ExFigCommand.swift | 21 +- Sources/ExFigCLI/MCP/ExFigMCPServer.swift | 106 +- Sources/ExFigCLI/MCP/MCPPrompts.swift | 316 ++-- Sources/ExFigCLI/MCP/MCPResources.swift | 220 +-- Sources/ExFigCLI/MCP/MCPServerState.swift | 62 +- Sources/ExFigCLI/MCP/MCPToolDefinitions.swift | 340 ++-- Sources/ExFigCLI/MCP/MCPToolHandlers.swift | 1561 +++++++++-------- Sources/ExFigCLI/Subcommands/MCPServe.swift | 46 +- .../ExFigTests/MCP/MCPToolHandlerTests.swift | 388 ++-- 11 files changed, 1552 insertions(+), 1520 deletions(-) diff --git a/.swiftlint.yml b/.swiftlint.yml index 1c2b2ab1..e4decd04 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -11,6 +11,7 @@ excluded: - Sources/ExFigCLI/Resources/flutterConfig.swift - Sources/ExFigCLI/Resources/webConfig.swift - Tests/XcodeExportTests/XcodeIconsExporterTests.swift + - Sources/ExFigCLI/MCP/MCPToolHandlers.swift disabled_rules: - trailing_comma diff --git a/Package.swift b/Package.swift index 6b9285e1..f75d5b5d 100644 --- a/Package.swift +++ b/Package.swift @@ -22,7 +22,6 @@ var packageDependencies: [Package.Dependency] = [ .package(url: "https://github.com/DesignPipe/swift-svgkit.git", from: "0.1.0"), .package(url: "https://github.com/DesignPipe/swift-figma-api.git", from: "0.2.0"), .package(url: "https://github.com/DesignPipe/swift-penpot-api.git", from: "0.1.0"), - .package(url: "https://github.com/modelcontextprotocol/swift-sdk.git", from: "0.9.0"), ] var exfigCLIDependencies: [Target.Dependency] = [ @@ -46,17 +45,23 @@ var exfigCLIDependencies: [Target.Dependency] = [ .product(name: "WebP", package: "libwebp"), .product(name: "LibPNG", package: "libpng"), .product(name: "Noora", package: "Noora"), - .product(name: "MCP", package: "swift-sdk"), ] -// XcodeProj is Apple-only (not available on Windows) +// XcodeProj and MCP SDK are not available on Windows +// (XcodeProj depends on PathKit/AEXML, MCP SDK depends on swift-nio which doesn't compile on Windows) #if !os(Windows) packageDependencies.append( .package(url: "https://github.com/tuist/XcodeProj.git", from: "8.27.0") ) + packageDependencies.append( + .package(url: "https://github.com/modelcontextprotocol/swift-sdk.git", from: "0.9.0") + ) exfigCLIDependencies.append( .product(name: "XcodeProj", package: "XcodeProj") ) + exfigCLIDependencies.append( + .product(name: "MCP", package: "swift-sdk") + ) #endif // MARK: - Package diff --git a/Sources/ExFigCLI/ExFigCommand.swift b/Sources/ExFigCLI/ExFigCommand.swift index 74e291c9..80aaa4c6 100644 --- a/Sources/ExFigCLI/ExFigCommand.swift +++ b/Sources/ExFigCLI/ExFigCommand.swift @@ -94,7 +94,16 @@ struct ExFigCommand: AsyncParsableCommand { Requires FIGMA_PERSONAL_TOKEN environment variable to be set. """, version: version, - subcommands: [ + subcommands: Self.allSubcommands, + defaultSubcommand: ExportColors.self + ) +} + +// MARK: - Subcommands + +extension ExFigCommand { + static var allSubcommands: [any ParsableCommand.Type] { + var commands: [any ParsableCommand.Type] = [ ExportColors.self, ExportIcons.self, ExportImages.self, @@ -105,10 +114,12 @@ struct ExFigCommand: AsyncParsableCommand { Download.self, Tokens.self, Batch.self, - MCPServe.self, - ], - defaultSubcommand: ExportColors.self - ) + ] + #if canImport(MCP) + commands.append(MCPServe.self) + #endif + return commands + } } // MARK: - TerminalUI Initialization diff --git a/Sources/ExFigCLI/MCP/ExFigMCPServer.swift b/Sources/ExFigCLI/MCP/ExFigMCPServer.swift index c651ee2f..690a03c8 100644 --- a/Sources/ExFigCLI/MCP/ExFigMCPServer.swift +++ b/Sources/ExFigCLI/MCP/ExFigMCPServer.swift @@ -1,72 +1,74 @@ -import Foundation -import Logging -import MCP +#if canImport(MCP) + import Foundation + import Logging + import MCP -/// Main MCP server lifecycle manager. -/// Sets up the server, registers all handlers, and runs with StdioTransport. -struct ExFigMCPServer { - private let logger = Logger(label: "com.designpipe.exfig.mcp") + /// Main MCP server lifecycle manager. + /// Sets up the server, registers all handlers, and runs with StdioTransport. + struct ExFigMCPServer { + private let logger = Logger(label: "com.designpipe.exfig.mcp") - func run() async throws { - let server = Server( - name: "exfig", - version: ExFigCommand.version, - capabilities: .init( - prompts: .init(listChanged: false), - resources: .init(subscribe: false, listChanged: false), - tools: .init(listChanged: false) + func run() async throws { + let server = Server( + name: "exfig", + version: ExFigCommand.version, + capabilities: .init( + prompts: .init(listChanged: false), + resources: .init(subscribe: false, listChanged: false), + tools: .init(listChanged: false) + ) ) - ) - let state = MCPServerState() + let state = MCPServerState() - // Register all handlers - await registerToolHandlers(server: server, state: state) - await registerResourceHandlers(server: server) - await registerPromptHandlers(server: server) + // Register all handlers + await registerToolHandlers(server: server, state: state) + await registerResourceHandlers(server: server) + await registerPromptHandlers(server: server) - // Start with stdio transport - let transport = StdioTransport(logger: logger) - try await server.start(transport: transport) + // Start with stdio transport + let transport = StdioTransport(logger: logger) + try await server.start(transport: transport) - // Block until the process is terminated (SIGINT/SIGTERM). - // AsyncStream continuation is never yielded or finished, so this suspends indefinitely. - let _: Void = await withCheckedContinuation { _ in } - } + // Block until the process is terminated (SIGINT/SIGTERM). + // AsyncStream continuation is never yielded or finished, so this suspends indefinitely. + let _: Void = await withCheckedContinuation { _ in } + } - // MARK: - Tool Handlers + // MARK: - Tool Handlers - private func registerToolHandlers(server: Server, state: MCPServerState) async { - await server.withMethodHandler(ListTools.self) { _ in - .init(tools: MCPToolDefinitions.allTools) - } + private func registerToolHandlers(server: Server, state: MCPServerState) async { + await server.withMethodHandler(ListTools.self) { _ in + .init(tools: MCPToolDefinitions.allTools) + } - await server.withMethodHandler(CallTool.self) { params in - await MCPToolHandlers.handle(params: params, state: state) + await server.withMethodHandler(CallTool.self) { params in + await MCPToolHandlers.handle(params: params, state: state) + } } - } - // MARK: - Resource Handlers + // MARK: - Resource Handlers - private func registerResourceHandlers(server: Server) async { - await server.withMethodHandler(ListResources.self) { _ in - .init(resources: MCPResources.allResources, nextCursor: nil) - } + private func registerResourceHandlers(server: Server) async { + await server.withMethodHandler(ListResources.self) { _ in + .init(resources: MCPResources.allResources, nextCursor: nil) + } - await server.withMethodHandler(ReadResource.self) { params in - try MCPResources.read(uri: params.uri) + await server.withMethodHandler(ReadResource.self) { params in + try MCPResources.read(uri: params.uri) + } } - } - // MARK: - Prompt Handlers + // MARK: - Prompt Handlers - private func registerPromptHandlers(server: Server) async { - await server.withMethodHandler(ListPrompts.self) { _ in - .init(prompts: MCPPrompts.allPrompts, nextCursor: nil) - } + private func registerPromptHandlers(server: Server) async { + await server.withMethodHandler(ListPrompts.self) { _ in + .init(prompts: MCPPrompts.allPrompts, nextCursor: nil) + } - await server.withMethodHandler(GetPrompt.self) { params in - try MCPPrompts.get(name: params.name, arguments: params.arguments) + await server.withMethodHandler(GetPrompt.self) { params in + try MCPPrompts.get(name: params.name, arguments: params.arguments) + } } } -} +#endif diff --git a/Sources/ExFigCLI/MCP/MCPPrompts.swift b/Sources/ExFigCLI/MCP/MCPPrompts.swift index 67eb3a77..810947d2 100644 --- a/Sources/ExFigCLI/MCP/MCPPrompts.swift +++ b/Sources/ExFigCLI/MCP/MCPPrompts.swift @@ -1,183 +1,185 @@ -import MCP - -/// MCP prompt definitions for guided AI workflows. -enum MCPPrompts { - static let allPrompts: [Prompt] = [ - setupConfigPrompt, - troubleshootPrompt, - ] - - // MARK: - Prompt Definitions - - private static let setupConfigPrompt = Prompt( - name: "setup-config", - description: "Guide through creating an exfig.pkl configuration file for a specific platform", - arguments: [ - .init(name: "platform", description: "Target platform: ios, android, flutter, or web", required: true), - .init(name: "source", description: "Design source: figma (default) or penpot"), - .init( - name: "project_path", - description: "Path to the project directory (defaults to current directory)" - ), +#if canImport(MCP) + import MCP + + /// MCP prompt definitions for guided AI workflows. + enum MCPPrompts { + static let allPrompts: [Prompt] = [ + setupConfigPrompt, + troubleshootPrompt, ] - ) - - private static let troubleshootPrompt = Prompt( - name: "troubleshoot-export", - description: "Diagnose and fix ExFig export errors", - arguments: [ - .init(name: "error_message", description: "The error message from the failed export", required: true), - .init( - name: "config_path", - description: "Path to the PKL config file used during export" - ), - ] - ) - - // MARK: - Get Prompt - - static func get(name: String, arguments: [String: Value]?) throws -> GetPrompt.Result { - switch name { - case "setup-config": - return try getSetupConfig(arguments: arguments) - case "troubleshoot-export": - return try getTroubleshoot(arguments: arguments) - default: - throw MCPError.invalidParams("Unknown prompt: \(name)") - } - } - - // MARK: - Setup Config - private static func getSetupConfig(arguments: [String: Value]?) throws -> GetPrompt.Result { - guard let platform = arguments?["platform"]?.stringValue else { - throw MCPError.invalidParams("Missing required argument: platform") - } + // MARK: - Prompt Definitions + + private static let setupConfigPrompt = Prompt( + name: "setup-config", + description: "Guide through creating an exfig.pkl configuration file for a specific platform", + arguments: [ + .init(name: "platform", description: "Target platform: ios, android, flutter, or web", required: true), + .init(name: "source", description: "Design source: figma (default) or penpot"), + .init( + name: "project_path", + description: "Path to the project directory (defaults to current directory)" + ), + ] + ) - let source = arguments?["source"]?.stringValue ?? "figma" - let projectPath = arguments?["project_path"]?.stringValue ?? "." + private static let troubleshootPrompt = Prompt( + name: "troubleshoot-export", + description: "Diagnose and fix ExFig export errors", + arguments: [ + .init(name: "error_message", description: "The error message from the failed export", required: true), + .init( + name: "config_path", + description: "Path to the PKL config file used during export" + ), + ] + ) - let validPlatforms = ["ios", "android", "flutter", "web"] - guard validPlatforms.contains(platform) else { - throw MCPError.invalidParams( - "Invalid platform '\(platform)'. Must be one of: \(validPlatforms.joined(separator: ", "))" - ) + // MARK: - Get Prompt + + static func get(name: String, arguments: [String: Value]?) throws -> GetPrompt.Result { + switch name { + case "setup-config": + return try getSetupConfig(arguments: arguments) + case "troubleshoot-export": + return try getTroubleshoot(arguments: arguments) + default: + throw MCPError.invalidParams("Unknown prompt: \(name)") + } } - let validSources = ["figma", "penpot"] - guard validSources.contains(source) else { - throw MCPError.invalidParams( - "Invalid source '\(source)'. Must be one of: \(validSources.joined(separator: ", "))" - ) - } + // MARK: - Setup Config - if source == "penpot" { - return try getSetupConfigPenpot(platform: platform, projectPath: projectPath) - } + private static func getSetupConfig(arguments: [String: Value]?) throws -> GetPrompt.Result { + guard let platform = arguments?["platform"]?.stringValue else { + throw MCPError.invalidParams("Missing required argument: platform") + } - return try getSetupConfigFigma(platform: platform, projectPath: projectPath) - } + let source = arguments?["source"]?.stringValue ?? "figma" + let projectPath = arguments?["project_path"]?.stringValue ?? "." - private static func getSetupConfigFigma(platform: String, projectPath: String) throws -> GetPrompt.Result { - let schemaName = platform == "ios" ? "iOS" : platform.capitalized + let validPlatforms = ["ios", "android", "flutter", "web"] + guard validPlatforms.contains(platform) else { + throw MCPError.invalidParams( + "Invalid platform '\(platform)'. Must be one of: \(validPlatforms.joined(separator: ", "))" + ) + } - let text = """ - I need to create an exfig.pkl configuration file for the \(platform) platform \ - in the project at \(projectPath). + let validSources = ["figma", "penpot"] + guard validSources.contains(source) else { + throw MCPError.invalidParams( + "Invalid source '\(source)'. Must be one of: \(validSources.joined(separator: ", "))" + ) + } - Please help me: - 1. Read the ExFig \(schemaName) schema (use exfig://schemas/\(schemaName).pkl resource) - 2. Read the starter template (use exfig://templates/\(platform) resource) - 3. Read the design file structure guide (use exfig://guides/DesignRequirements.md resource) - 4. Examine my project structure to determine correct output paths - 5. Create a properly configured exfig.pkl file + if source == "penpot" { + return try getSetupConfigPenpot(platform: platform, projectPath: projectPath) + } - I need to set: - - Figma file ID(s) for my design files - - Output paths matching my project structure - - Entry configurations for colors, icons, and/or images + return try getSetupConfigFigma(platform: platform, projectPath: projectPath) + } - First, validate the config with exfig_validate after creating it. - """ + private static func getSetupConfigFigma(platform: String, projectPath: String) throws -> GetPrompt.Result { + let schemaName = platform == "ios" ? "iOS" : platform.capitalized - return .init( - description: "Setup ExFig \(platform) configuration with Figma at \(projectPath)", - messages: [.user(.text(text: text))] - ) - } + let text = """ + I need to create an exfig.pkl configuration file for the \(platform) platform \ + in the project at \(projectPath). - // swiftlint:disable function_body_length - private static func getSetupConfigPenpot(platform: String, projectPath: String) throws -> GetPrompt.Result { - let schemaName = platform == "ios" ? "iOS" : platform.capitalized - - let text = """ - I need to create an exfig.pkl configuration file for the \(platform) platform \ - using **Penpot** as the design source in the project at \(projectPath). - - Please help me: - 1. Read the Common schema for PenpotSource (use exfig://schemas/Common.pkl resource) - 2. Read the \(schemaName) schema (use exfig://schemas/\(schemaName).pkl resource) - 3. Read the design file structure guide (use exfig://guides/DesignRequirements.md resource) \ - — focus on the Penpot section - 4. Read the starter template (use exfig://templates/\(platform) resource) - 5. Examine my project structure to determine correct output paths - 6. Create a properly configured exfig.pkl file with penpotSource entries - - I need to set: - - Penpot file UUID (from the Penpot workspace URL) - - Optional: custom Penpot instance URL (if self-hosted, default: design.penpot.app) - - Path filters matching my Penpot library structure - - Output paths matching my project structure - - Important notes: - - PENPOT_ACCESS_TOKEN must be set (not FIGMA_PERSONAL_TOKEN) - - No `figma` section needed when using only Penpot sources - - Icons/images: SVG reconstructed from shape tree (supports SVG, PNG, PDF output) - - First, validate the config with exfig_validate after creating it. - """ - - return .init( - description: "Setup ExFig \(platform) configuration with Penpot at \(projectPath)", - messages: [.user(.text(text: text))] - ) - } + Please help me: + 1. Read the ExFig \(schemaName) schema (use exfig://schemas/\(schemaName).pkl resource) + 2. Read the starter template (use exfig://templates/\(platform) resource) + 3. Read the design file structure guide (use exfig://guides/DesignRequirements.md resource) + 4. Examine my project structure to determine correct output paths + 5. Create a properly configured exfig.pkl file - // swiftlint:enable function_body_length + I need to set: + - Figma file ID(s) for my design files + - Output paths matching my project structure + - Entry configurations for colors, icons, and/or images - // MARK: - Troubleshoot + First, validate the config with exfig_validate after creating it. + """ - private static func getTroubleshoot(arguments: [String: Value]?) throws -> GetPrompt.Result { - guard let errorMessage = arguments?["error_message"]?.stringValue else { - throw MCPError.invalidParams("Missing required argument: error_message") + return .init( + description: "Setup ExFig \(platform) configuration with Figma at \(projectPath)", + messages: [.user(.text(text: text))] + ) } - let configPath = arguments?["config_path"]?.stringValue ?? "exfig.pkl" + // swiftlint:disable function_body_length + private static func getSetupConfigPenpot(platform: String, projectPath: String) throws -> GetPrompt.Result { + let schemaName = platform == "ios" ? "iOS" : platform.capitalized + + let text = """ + I need to create an exfig.pkl configuration file for the \(platform) platform \ + using **Penpot** as the design source in the project at \(projectPath). + + Please help me: + 1. Read the Common schema for PenpotSource (use exfig://schemas/Common.pkl resource) + 2. Read the \(schemaName) schema (use exfig://schemas/\(schemaName).pkl resource) + 3. Read the design file structure guide (use exfig://guides/DesignRequirements.md resource) \ + — focus on the Penpot section + 4. Read the starter template (use exfig://templates/\(platform) resource) + 5. Examine my project structure to determine correct output paths + 6. Create a properly configured exfig.pkl file with penpotSource entries + + I need to set: + - Penpot file UUID (from the Penpot workspace URL) + - Optional: custom Penpot instance URL (if self-hosted, default: design.penpot.app) + - Path filters matching my Penpot library structure + - Output paths matching my project structure + + Important notes: + - PENPOT_ACCESS_TOKEN must be set (not FIGMA_PERSONAL_TOKEN) + - No `figma` section needed when using only Penpot sources + - Icons/images: SVG reconstructed from shape tree (supports SVG, PNG, PDF output) + + First, validate the config with exfig_validate after creating it. + """ + + return .init( + description: "Setup ExFig \(platform) configuration with Penpot at \(projectPath)", + messages: [.user(.text(text: text))] + ) + } - let text = """ - I'm getting this error when running ExFig export: + // swiftlint:enable function_body_length - ``` - \(errorMessage) - ``` + // MARK: - Troubleshoot - Config file: \(configPath) + private static func getTroubleshoot(arguments: [String: Value]?) throws -> GetPrompt.Result { + guard let errorMessage = arguments?["error_message"]?.stringValue else { + throw MCPError.invalidParams("Missing required argument: error_message") + } - Please help me diagnose and fix this error: - 1. First, validate the config with exfig_validate (config_path: "\(configPath)") - 2. Check authentication: - - If the error mentions Figma or 401: check FIGMA_PERSONAL_TOKEN - - If the error mentions Penpot or PENPOT_ACCESS_TOKEN: check PENPOT_ACCESS_TOKEN - - For Penpot "malformed-json" errors: ensure ExFig is up to date - 3. If it's a PKL error, read the relevant schema to understand the expected structure - 4. Read the design file structure guide (exfig://guides/DesignRequirements.md) for \ - file preparation requirements - 5. Suggest specific fixes with code examples - """ + let configPath = arguments?["config_path"]?.stringValue ?? "exfig.pkl" - return .init( - description: "Troubleshoot ExFig export error", - messages: [.user(.text(text: text))] - ) + let text = """ + I'm getting this error when running ExFig export: + + ``` + \(errorMessage) + ``` + + Config file: \(configPath) + + Please help me diagnose and fix this error: + 1. First, validate the config with exfig_validate (config_path: "\(configPath)") + 2. Check authentication: + - If the error mentions Figma or 401: check FIGMA_PERSONAL_TOKEN + - If the error mentions Penpot or PENPOT_ACCESS_TOKEN: check PENPOT_ACCESS_TOKEN + - For Penpot "malformed-json" errors: ensure ExFig is up to date + 3. If it's a PKL error, read the relevant schema to understand the expected structure + 4. Read the design file structure guide (exfig://guides/DesignRequirements.md) for \ + file preparation requirements + 5. Suggest specific fixes with code examples + """ + + return .init( + description: "Troubleshoot ExFig export error", + messages: [.user(.text(text: text))] + ) + } } -} +#endif diff --git a/Sources/ExFigCLI/MCP/MCPResources.swift b/Sources/ExFigCLI/MCP/MCPResources.swift index c44aa913..96a8482c 100644 --- a/Sources/ExFigCLI/MCP/MCPResources.swift +++ b/Sources/ExFigCLI/MCP/MCPResources.swift @@ -1,130 +1,132 @@ -import Foundation -import MCP - -/// MCP resource definitions — PKL schemas, starter config templates, and guides. -enum MCPResources { - // MARK: - Schema file names - - private static let schemaFiles = [ - "ExFig.pkl", - "Common.pkl", - "Figma.pkl", - "iOS.pkl", - "Android.pkl", - "Flutter.pkl", - "Web.pkl", - ] - - // MARK: - Template entries - - private static let templateEntries: [(name: String, platform: String, content: String)] = [ - ("iOS Starter Config", "ios", iosConfigFileContents), - ("Android Starter Config", "android", androidConfigFileContents), - ("Flutter Starter Config", "flutter", flutterConfigFileContents), - ("Web Starter Config", "web", webConfigFileContents), - ] - - // MARK: - Guide entries - - private static let guideFiles: [(name: String, file: String, description: String)] = [ - ( - "Design File Structure", - "DesignRequirements.md", - "How to prepare Figma and Penpot files for ExFig export — colors, components, typography, naming" - ), - ] - - // MARK: - Public API - - static var allResources: [Resource] { - var resources: [Resource] = [] - - // PKL schemas - for file in schemaFiles { - let name = file.replacingOccurrences(of: ".pkl", with: "") - resources.append(Resource( - name: "\(name) Schema", - uri: "exfig://schemas/\(file)", - description: "PKL schema for ExFig \(name) configuration", - mimeType: "text/plain" - )) - } +#if canImport(MCP) + import Foundation + import MCP + + /// MCP resource definitions — PKL schemas, starter config templates, and guides. + enum MCPResources { + // MARK: - Schema file names + + private static let schemaFiles = [ + "ExFig.pkl", + "Common.pkl", + "Figma.pkl", + "iOS.pkl", + "Android.pkl", + "Flutter.pkl", + "Web.pkl", + ] + + // MARK: - Template entries + + private static let templateEntries: [(name: String, platform: String, content: String)] = [ + ("iOS Starter Config", "ios", iosConfigFileContents), + ("Android Starter Config", "android", androidConfigFileContents), + ("Flutter Starter Config", "flutter", flutterConfigFileContents), + ("Web Starter Config", "web", webConfigFileContents), + ] + + // MARK: - Guide entries + + private static let guideFiles: [(name: String, file: String, description: String)] = [ + ( + "Design File Structure", + "DesignRequirements.md", + "How to prepare Figma and Penpot files for ExFig export — colors, components, typography, naming" + ), + ] + + // MARK: - Public API + + static var allResources: [Resource] { + var resources: [Resource] = [] + + // PKL schemas + for file in schemaFiles { + let name = file.replacingOccurrences(of: ".pkl", with: "") + resources.append(Resource( + name: "\(name) Schema", + uri: "exfig://schemas/\(file)", + description: "PKL schema for ExFig \(name) configuration", + mimeType: "text/plain" + )) + } - // Config templates - for entry in templateEntries { - resources.append(Resource( - name: entry.name, - uri: "exfig://templates/\(entry.platform)", - description: "Starter PKL config template for \(entry.platform) platform", - mimeType: "text/plain" - )) - } + // Config templates + for entry in templateEntries { + resources.append(Resource( + name: entry.name, + uri: "exfig://templates/\(entry.platform)", + description: "Starter PKL config template for \(entry.platform) platform", + mimeType: "text/plain" + )) + } - // Guides - for guide in guideFiles { - resources.append(Resource( - name: guide.name, - uri: "exfig://guides/\(guide.file)", - description: guide.description, - mimeType: "text/markdown" - )) + // Guides + for guide in guideFiles { + resources.append(Resource( + name: guide.name, + uri: "exfig://guides/\(guide.file)", + description: guide.description, + mimeType: "text/markdown" + )) + } + + return resources } - return resources - } + static func read(uri: String) throws -> ReadResource.Result { + // Schema resources + if uri.hasPrefix("exfig://schemas/") { + let fileName = String(uri.dropFirst("exfig://schemas/".count)) + guard schemaFiles.contains(fileName) else { + throw MCPError.invalidParams("Unknown schema: \(fileName)") + } - static func read(uri: String) throws -> ReadResource.Result { - // Schema resources - if uri.hasPrefix("exfig://schemas/") { - let fileName = String(uri.dropFirst("exfig://schemas/".count)) - guard schemaFiles.contains(fileName) else { - throw MCPError.invalidParams("Unknown schema: \(fileName)") + let content = try loadSchemaContent(fileName: fileName) + return .init(contents: [Resource.Content.text(content, uri: uri, mimeType: "text/plain")]) } - let content = try loadSchemaContent(fileName: fileName) - return .init(contents: [Resource.Content.text(content, uri: uri, mimeType: "text/plain")]) - } + // Template resources + if uri.hasPrefix("exfig://templates/") { + let platform = String(uri.dropFirst("exfig://templates/".count)) + guard let entry = templateEntries.first(where: { $0.platform == platform }) else { + throw MCPError.invalidParams("Unknown template platform: \(platform)") + } - // Template resources - if uri.hasPrefix("exfig://templates/") { - let platform = String(uri.dropFirst("exfig://templates/".count)) - guard let entry = templateEntries.first(where: { $0.platform == platform }) else { - throw MCPError.invalidParams("Unknown template platform: \(platform)") + return .init(contents: [Resource.Content.text(entry.content, uri: uri, mimeType: "text/plain")]) } - return .init(contents: [Resource.Content.text(entry.content, uri: uri, mimeType: "text/plain")]) - } + // Guide resources + if uri.hasPrefix("exfig://guides/") { + let fileName = String(uri.dropFirst("exfig://guides/".count)) + guard guideFiles.contains(where: { $0.file == fileName }) else { + throw MCPError.invalidParams("Unknown guide: \(fileName)") + } - // Guide resources - if uri.hasPrefix("exfig://guides/") { - let fileName = String(uri.dropFirst("exfig://guides/".count)) - guard guideFiles.contains(where: { $0.file == fileName }) else { - throw MCPError.invalidParams("Unknown guide: \(fileName)") + let content = try loadGuideContent(fileName: fileName) + return .init(contents: [Resource.Content.text(content, uri: uri, mimeType: "text/markdown")]) } - let content = try loadGuideContent(fileName: fileName) - return .init(contents: [Resource.Content.text(content, uri: uri, mimeType: "text/markdown")]) + throw MCPError.invalidParams("Unknown resource URI: \(uri)") } - throw MCPError.invalidParams("Unknown resource URI: \(uri)") - } - - // MARK: - Schema Loading + // MARK: - Schema Loading - private static func loadSchemaContent(fileName: String) throws -> String { - // Load from Bundle.module (SPM resource bundle) - guard let url = Bundle.module.url(forResource: fileName, withExtension: nil, subdirectory: "Schemas") else { - throw MCPError.invalidParams("Schema file not found in bundle: \(fileName)") + private static func loadSchemaContent(fileName: String) throws -> String { + // Load from Bundle.module (SPM resource bundle) + guard let url = Bundle.module.url(forResource: fileName, withExtension: nil, subdirectory: "Schemas") else { + throw MCPError.invalidParams("Schema file not found in bundle: \(fileName)") + } + return try String(contentsOf: url, encoding: .utf8) } - return try String(contentsOf: url, encoding: .utf8) - } - // MARK: - Guide Loading + // MARK: - Guide Loading - private static func loadGuideContent(fileName: String) throws -> String { - guard let url = Bundle.module.url(forResource: fileName, withExtension: nil, subdirectory: "Guides") else { - throw MCPError.invalidParams("Guide file not found in bundle: \(fileName)") + private static func loadGuideContent(fileName: String) throws -> String { + guard let url = Bundle.module.url(forResource: fileName, withExtension: nil, subdirectory: "Guides") else { + throw MCPError.invalidParams("Guide file not found in bundle: \(fileName)") + } + return try String(contentsOf: url, encoding: .utf8) } - return try String(contentsOf: url, encoding: .utf8) } -} +#endif diff --git a/Sources/ExFigCLI/MCP/MCPServerState.swift b/Sources/ExFigCLI/MCP/MCPServerState.swift index 865801d2..8725a6b4 100644 --- a/Sources/ExFigCLI/MCP/MCPServerState.swift +++ b/Sources/ExFigCLI/MCP/MCPServerState.swift @@ -1,38 +1,40 @@ -import ExFigCore -import FigmaAPI -import Foundation +#if canImport(MCP) + import ExFigCore + import FigmaAPI + import Foundation -/// Shared state for MCP server — lazy FigmaClient, rate limiting across calls. -actor MCPServerState { - private var cachedClient: FigmaAPI.Client? - private var rateLimiter: SharedRateLimiter? + /// Shared state for MCP server — lazy FigmaClient, rate limiting across calls. + actor MCPServerState { + private var cachedClient: FigmaAPI.Client? + private var rateLimiter: SharedRateLimiter? - /// Returns a configured Figma API client, creating one lazily if needed. - /// Reuses the same client and rate limiter across all MCP tool calls. - func getClient() throws -> FigmaAPI.Client { - if let client = cachedClient { - return client - } + /// Returns a configured Figma API client, creating one lazily if needed. + /// Reuses the same client and rate limiter across all MCP tool calls. + func getClient() throws -> FigmaAPI.Client { + if let client = cachedClient { + return client + } - guard let token = ProcessInfo.processInfo.environment["FIGMA_PERSONAL_TOKEN"] else { - throw ExFigError.accessTokenNotFound - } + guard let token = ProcessInfo.processInfo.environment["FIGMA_PERSONAL_TOKEN"] else { + throw ExFigError.accessTokenNotFound + } - let baseClient = FigmaClient(accessToken: token, timeout: nil) - let limiter = SharedRateLimiter(requestsPerMinute: 10) - let retryPolicy = RetryPolicy(maxRetries: 4) + let baseClient = FigmaClient(accessToken: token, timeout: nil) + let limiter = SharedRateLimiter(requestsPerMinute: 10) + let retryPolicy = RetryPolicy(maxRetries: 4) - let client = RateLimitedClient( - client: baseClient, - rateLimiter: limiter, - configID: ConfigID("mcp"), - retryPolicy: retryPolicy, - onRetry: nil - ) + let client = RateLimitedClient( + client: baseClient, + rateLimiter: limiter, + configID: ConfigID("mcp"), + retryPolicy: retryPolicy, + onRetry: nil + ) - cachedClient = client - rateLimiter = limiter + cachedClient = client + rateLimiter = limiter - return client + return client + } } -} +#endif diff --git a/Sources/ExFigCLI/MCP/MCPToolDefinitions.swift b/Sources/ExFigCLI/MCP/MCPToolDefinitions.swift index f8e78099..3c2dbe24 100644 --- a/Sources/ExFigCLI/MCP/MCPToolDefinitions.swift +++ b/Sources/ExFigCLI/MCP/MCPToolDefinitions.swift @@ -1,185 +1,187 @@ -import MCP +#if canImport(MCP) + import MCP -/// Tool schemas for all ExFig MCP tools. -enum MCPToolDefinitions { - static let allTools: [Tool] = [ - validateTool, - tokensInfoTool, - inspectTool, - exportTool, - downloadTool, - ] + /// Tool schemas for all ExFig MCP tools. + enum MCPToolDefinitions { + static let allTools: [Tool] = [ + validateTool, + tokensInfoTool, + inspectTool, + exportTool, + downloadTool, + ] - // MARK: - Tool Definitions + // MARK: - Tool Definitions - static let validateTool = Tool( - name: "exfig_validate", - description: """ - Validate an ExFig PKL configuration file. Returns a JSON summary with platforms, \ - entry counts, and file IDs — or the full PKL error if validation fails. \ - Does not require FIGMA_PERSONAL_TOKEN. - """, - inputSchema: .object([ - "type": .string("object"), - "properties": .object([ - "config_path": .object([ - "type": .string("string"), - "description": .string( - "Path to PKL config file. Auto-detects exfig.pkl in current directory if omitted." - ), + static let validateTool = Tool( + name: "exfig_validate", + description: """ + Validate an ExFig PKL configuration file. Returns a JSON summary with platforms, \ + entry counts, and file IDs — or the full PKL error if validation fails. \ + Does not require FIGMA_PERSONAL_TOKEN. + """, + inputSchema: .object([ + "type": .string("object"), + "properties": .object([ + "config_path": .object([ + "type": .string("string"), + "description": .string( + "Path to PKL config file. Auto-detects exfig.pkl in current directory if omitted." + ), + ]), ]), - ]), - ]) - ) + ]) + ) - static let tokensInfoTool = Tool( - name: "exfig_tokens_info", - description: """ - Inspect a W3C DTCG .tokens.json file. Returns token counts by type, \ - top-level groups, alias count, and any parse warnings. \ - Does not require FIGMA_PERSONAL_TOKEN. - """, - inputSchema: .object([ - "type": .string("object"), - "properties": .object([ - "file_path": .object([ - "type": .string("string"), - "description": .string("Path to the .tokens.json file"), + static let tokensInfoTool = Tool( + name: "exfig_tokens_info", + description: """ + Inspect a W3C DTCG .tokens.json file. Returns token counts by type, \ + top-level groups, alias count, and any parse warnings. \ + Does not require FIGMA_PERSONAL_TOKEN. + """, + inputSchema: .object([ + "type": .string("object"), + "properties": .object([ + "file_path": .object([ + "type": .string("string"), + "description": .string("Path to the .tokens.json file"), + ]), ]), - ]), - "required": .array([.string("file_path")]), - ]) - ) + "required": .array([.string("file_path")]), + ]) + ) - static let inspectTool = Tool( - name: "exfig_inspect", - description: """ - List Figma resources (colors, icons, images, typography) without exporting. \ - Returns JSON with resource names, counts, and metadata. \ - Requires FIGMA_PERSONAL_TOKEN. - """, - inputSchema: .object([ - "type": .string("object"), - "properties": .object([ - "config_path": .object([ - "type": .string("string"), - "description": .string( - "Path to PKL config file. Auto-detects exfig.pkl if omitted." - ), - ]), - "resource_type": .object([ - "type": .string("string"), - "description": .string( - "Type of resources to inspect" - ), - "enum": .array([ - .string("colors"), - .string("icons"), - .string("images"), - .string("typography"), - .string("all"), + static let inspectTool = Tool( + name: "exfig_inspect", + description: """ + List Figma resources (colors, icons, images, typography) without exporting. \ + Returns JSON with resource names, counts, and metadata. \ + Requires FIGMA_PERSONAL_TOKEN. + """, + inputSchema: .object([ + "type": .string("object"), + "properties": .object([ + "config_path": .object([ + "type": .string("string"), + "description": .string( + "Path to PKL config file. Auto-detects exfig.pkl if omitted." + ), + ]), + "resource_type": .object([ + "type": .string("string"), + "description": .string( + "Type of resources to inspect" + ), + "enum": .array([ + .string("colors"), + .string("icons"), + .string("images"), + .string("typography"), + .string("all"), + ]), ]), ]), - ]), - "required": .array([.string("resource_type")]), - ]) - ) + "required": .array([.string("resource_type")]), + ]) + ) - static let exportTool = Tool( - name: "exfig_export", - description: """ - Run platform code export (Swift/Kotlin/Dart/CSS) from PKL config. \ - Writes generated files to disk and returns a structured JSON report. \ - Requires FIGMA_PERSONAL_TOKEN. - """, - inputSchema: .object([ - "type": .string("object"), - "properties": .object([ - "resource_type": .object([ - "type": .string("string"), - "description": .string("Type of resources to export"), - "enum": .array([ - .string("colors"), - .string("icons"), - .string("images"), - .string("typography"), - .string("all"), + static let exportTool = Tool( + name: "exfig_export", + description: """ + Run platform code export (Swift/Kotlin/Dart/CSS) from PKL config. \ + Writes generated files to disk and returns a structured JSON report. \ + Requires FIGMA_PERSONAL_TOKEN. + """, + inputSchema: .object([ + "type": .string("object"), + "properties": .object([ + "resource_type": .object([ + "type": .string("string"), + "description": .string("Type of resources to export"), + "enum": .array([ + .string("colors"), + .string("icons"), + .string("images"), + .string("typography"), + .string("all"), + ]), + ]), + "config_path": .object([ + "type": .string("string"), + "description": .string( + "Path to PKL config file. Auto-detects exfig.pkl in current directory if omitted." + ), + ]), + "filter": .object([ + "type": .string("string"), + "description": .string("Filter by name pattern (e.g., \"background/*\")"), + ]), + "rate_limit": .object([ + "type": .string("integer"), + "description": .string("Figma API requests per minute (default: 10)"), + ]), + "max_retries": .object([ + "type": .string("integer"), + "description": .string("Max retry attempts (default: 4)"), + ]), + "cache": .object([ + "type": .string("boolean"), + "description": .string("Enable version tracking cache (default: false)"), + ]), + "force": .object([ + "type": .string("boolean"), + "description": .string("Force export ignoring cache (default: false)"), + ]), + "granular_cache": .object([ + "type": .string("boolean"), + "description": .string("Enable per-node granular cache (default: false)"), ]), ]), - "config_path": .object([ - "type": .string("string"), - "description": .string( - "Path to PKL config file. Auto-detects exfig.pkl in current directory if omitted." - ), - ]), - "filter": .object([ - "type": .string("string"), - "description": .string("Filter by name pattern (e.g., \"background/*\")"), - ]), - "rate_limit": .object([ - "type": .string("integer"), - "description": .string("Figma API requests per minute (default: 10)"), - ]), - "max_retries": .object([ - "type": .string("integer"), - "description": .string("Max retry attempts (default: 4)"), - ]), - "cache": .object([ - "type": .string("boolean"), - "description": .string("Enable version tracking cache (default: false)"), - ]), - "force": .object([ - "type": .string("boolean"), - "description": .string("Force export ignoring cache (default: false)"), - ]), - "granular_cache": .object([ - "type": .string("boolean"), - "description": .string("Enable per-node granular cache (default: false)"), - ]), - ]), - "required": .array([.string("resource_type")]), - ]) - ) + "required": .array([.string("resource_type")]), + ]) + ) - static let downloadTool = Tool( - name: "exfig_download", - description: """ - Export design data from Figma as W3C Design Tokens JSON. \ - Returns JSON directly in the response — does not write files. \ - Requires FIGMA_PERSONAL_TOKEN. - """, - inputSchema: .object([ - "type": .string("object"), - "properties": .object([ - "resource_type": .object([ - "type": .string("string"), - "description": .string("Type of design data to export"), - "enum": .array([ - .string("colors"), - .string("typography"), - .string("tokens"), + static let downloadTool = Tool( + name: "exfig_download", + description: """ + Export design data from Figma as W3C Design Tokens JSON. \ + Returns JSON directly in the response — does not write files. \ + Requires FIGMA_PERSONAL_TOKEN. + """, + inputSchema: .object([ + "type": .string("object"), + "properties": .object([ + "resource_type": .object([ + "type": .string("string"), + "description": .string("Type of design data to export"), + "enum": .array([ + .string("colors"), + .string("typography"), + .string("tokens"), + ]), ]), - ]), - "config_path": .object([ - "type": .string("string"), - "description": .string( - "Path to PKL config file. Auto-detects exfig.pkl if omitted." - ), - ]), - "format": .object([ - "type": .string("string"), - "description": .string("Token format: w3c (default) or raw"), - "enum": .array([ - .string("w3c"), - .string("raw"), + "config_path": .object([ + "type": .string("string"), + "description": .string( + "Path to PKL config file. Auto-detects exfig.pkl if omitted." + ), + ]), + "format": .object([ + "type": .string("string"), + "description": .string("Token format: w3c (default) or raw"), + "enum": .array([ + .string("w3c"), + .string("raw"), + ]), + ]), + "filter": .object([ + "type": .string("string"), + "description": .string("Filter by name pattern. Only for colors."), ]), ]), - "filter": .object([ - "type": .string("string"), - "description": .string("Filter by name pattern. Only for colors."), - ]), - ]), - "required": .array([.string("resource_type")]), - ]) - ) -} + "required": .array([.string("resource_type")]), + ]) + ) + } +#endif diff --git a/Sources/ExFigCLI/MCP/MCPToolHandlers.swift b/Sources/ExFigCLI/MCP/MCPToolHandlers.swift index 5160df2e..cda68bae 100644 --- a/Sources/ExFigCLI/MCP/MCPToolHandlers.swift +++ b/Sources/ExFigCLI/MCP/MCPToolHandlers.swift @@ -1,905 +1,906 @@ -// swiftlint:disable file_length - -import ExFigConfig -import ExFigCore -import FigmaAPI -import Foundation -import MCP -import YYJSON - -/// Dispatches MCP CallTool requests to ExFig logic. -enum MCPToolHandlers { - static func handle(params: CallTool.Parameters, state: MCPServerState) async -> CallTool.Result { - do { - switch params.name { - case "exfig_validate": - return try await handleValidate(params: params) - case "exfig_tokens_info": - return try await handleTokensInfo(params: params) - case "exfig_inspect": - return try await handleInspect(params: params, state: state) - case "exfig_export": - return try await handleExport(params: params) - case "exfig_download": - return try await handleDownload(params: params, state: state) - default: - return .init(content: [.text("Unknown tool: \(params.name)")], isError: true) - } - } catch let error as ExFigError { - return errorResult(error) - } catch let error as TokensFileError { - return .init( - content: [.text("Token file error: \(error.errorDescription ?? "\(error)")")], - isError: true +#if canImport(MCP) + + import ExFigConfig + import ExFigCore + import FigmaAPI + import Foundation + import MCP + import YYJSON + + /// Dispatches MCP CallTool requests to ExFig logic. + enum MCPToolHandlers { + static func handle(params: CallTool.Parameters, state: MCPServerState) async -> CallTool.Result { + do { + switch params.name { + case "exfig_validate": + return try await handleValidate(params: params) + case "exfig_tokens_info": + return try await handleTokensInfo(params: params) + case "exfig_inspect": + return try await handleInspect(params: params, state: state) + case "exfig_export": + return try await handleExport(params: params) + case "exfig_download": + return try await handleDownload(params: params, state: state) + default: + return .init(content: [.text("Unknown tool: \(params.name)")], isError: true) + } + } catch let error as ExFigError { + return errorResult(error) + } catch let error as TokensFileError { + return .init( + content: [.text("Token file error: \(error.errorDescription ?? "\(error)")")], + isError: true + ) + } catch { + return .init(content: [.text("Error: \(error)")], isError: true) + } + } + + // MARK: - Validate + + private static func handleValidate(params: CallTool.Parameters) async throws -> CallTool.Result { + let configPath = try resolveConfigPath(from: params.arguments?["config_path"]?.stringValue) + let configURL = URL(fileURLWithPath: configPath) + + let config = try await PKLEvaluator.evaluate(configPath: configURL) + + let platforms = buildPlatformSummary(config: config) + let fileIDs = Array(config.getFileIds()).sorted() + + let summary = ValidateSummary( + configPath: configPath, + valid: true, + platforms: platforms.isEmpty ? nil : platforms, + figmaFileIds: fileIDs.isEmpty ? nil : fileIDs ) - } catch { - return .init(content: [.text("Error: \(error)")], isError: true) + + return try .init(content: [.text(encodeJSON(summary))]) } - } - // MARK: - Validate + private static func buildPlatformSummary(config: PKLConfig) -> [String: EntrySummary] { + var platforms: [String: EntrySummary] = [:] - private static func handleValidate(params: CallTool.Parameters) async throws -> CallTool.Result { - let configPath = try resolveConfigPath(from: params.arguments?["config_path"]?.stringValue) - let configURL = URL(fileURLWithPath: configPath) + if let ios = config.ios { + platforms["ios"] = EntrySummary( + colorsEntries: ios.colors?.count, iconsEntries: ios.icons?.count, + imagesEntries: ios.images?.count, typography: ios.typography != nil ? true : nil + ) + } + if let android = config.android { + platforms["android"] = EntrySummary( + colorsEntries: android.colors?.count, iconsEntries: android.icons?.count, + imagesEntries: android.images?.count, typography: android.typography != nil ? true : nil + ) + } + if let flutter = config.flutter { + platforms["flutter"] = EntrySummary( + colorsEntries: flutter.colors?.count, iconsEntries: flutter.icons?.count, + imagesEntries: flutter.images?.count + ) + } + if let web = config.web { + platforms["web"] = EntrySummary( + colorsEntries: web.colors?.count, iconsEntries: web.icons?.count, + imagesEntries: web.images?.count + ) + } - let config = try await PKLEvaluator.evaluate(configPath: configURL) + return platforms + } - let platforms = buildPlatformSummary(config: config) - let fileIDs = Array(config.getFileIds()).sorted() + // MARK: - Tokens Info - let summary = ValidateSummary( - configPath: configPath, - valid: true, - platforms: platforms.isEmpty ? nil : platforms, - figmaFileIds: fileIDs.isEmpty ? nil : fileIDs - ) + private static func handleTokensInfo(params: CallTool.Parameters) async throws -> CallTool.Result { + guard let filePath = params.arguments?["file_path"]?.stringValue else { + return .init(content: [.text("Missing required parameter: file_path")], isError: true) + } - return try .init(content: [.text(encodeJSON(summary))]) - } + var source = try TokensFileSource.parse(fileAt: filePath) + try source.resolveAliases() - private static func buildPlatformSummary(config: PKLConfig) -> [String: EntrySummary] { - var platforms: [String: EntrySummary] = [:] + var countsByType: [String: Int]? + let byType = source.tokenCountsByType() + if !byType.isEmpty { + var typeCounts: [String: Int] = [:] + for entry in byType { + typeCounts[entry.type] = entry.count + } + countsByType = typeCounts + } + + var topLevelGroups: [String: Int]? + let groups = source.topLevelGroups() + if !groups.isEmpty { + var groupCounts: [String: Int] = [:] + for entry in groups { + groupCounts[entry.name] = entry.count + } + topLevelGroups = groupCounts + } - if let ios = config.ios { - platforms["ios"] = EntrySummary( - colorsEntries: ios.colors?.count, iconsEntries: ios.icons?.count, - imagesEntries: ios.images?.count, typography: ios.typography != nil ? true : nil + let result = TokensInfoResult( + filePath: filePath, + totalTokens: source.tokens.count, + aliasCount: source.aliasCount, + countsByType: countsByType, + topLevelGroups: topLevelGroups, + warnings: source.warnings.isEmpty ? nil : source.warnings ) + + return try .init(content: [.text(encodeJSON(result))]) } - if let android = config.android { - platforms["android"] = EntrySummary( - colorsEntries: android.colors?.count, iconsEntries: android.icons?.count, - imagesEntries: android.images?.count, typography: android.typography != nil ? true : nil + + // MARK: - Inspect + + private static func handleInspect( + params: CallTool.Parameters, + state: MCPServerState + ) async throws -> CallTool.Result { + // Validate inputs before expensive operations (PKL eval, API client) + guard let resourceType = params.arguments?["resource_type"]?.stringValue else { + return .init(content: [.text("Missing required parameter: resource_type")], isError: true) + } + + let configPath = try resolveConfigPath(from: params.arguments?["config_path"]?.stringValue) + let configURL = URL(fileURLWithPath: configPath) + let config = try await PKLEvaluator.evaluate(configPath: configURL) + let client = try await state.getClient() + + let types = resourceType == "all" + ? ["colors", "icons", "images", "typography"] + : [resourceType] + + var results = InspectResult(configPath: configPath) + + for type in types { + switch type { + case "colors": + results.colors = try await inspectColors(config: config, client: client) + case "icons": + results.icons = try await inspectIcons(config: config, client: client) + case "images": + results.images = try await inspectImages(config: config, client: client) + case "typography": + results.typography = try await inspectTypography(config: config, client: client) + default: + results.unknownTypes[type] = "Unknown resource type: \(type)" + } + } + + return try .init(content: [.text(encodeJSON(results))]) + } + + // MARK: - Inspect Helpers + + private static func requireFileId(config: PKLConfig) throws -> String { + guard let fileId = config.figma?.lightFileId else { + throw ExFigError.custom( + errorString: "No Figma file ID configured. Set figma.lightFileId in config." + ) + } + return fileId + } + + private static func inspectColors( + config: PKLConfig, + client: FigmaAPI.Client + ) async throws -> ColorsInspectResult { + let fileId = try requireFileId(config: config) + let styles = try await client.request(StylesEndpoint(fileId: fileId)) + let colorStyles = styles.filter { $0.styleType == .fill } + + var entriesPerPlatform: [String: Int]? + var entries: [String: Int] = [:] + if let c = config.ios?.colors { entries["ios"] = c.count } + if let c = config.android?.colors { entries["android"] = c.count } + if let c = config.flutter?.colors { entries["flutter"] = c.count } + if let c = config.web?.colors { entries["web"] = c.count } + if !entries.isEmpty { entriesPerPlatform = entries } + + return ColorsInspectResult( + fileId: fileId, + stylesCount: styles.count, + colorStylesCount: colorStyles.count, + sampleNames: colorStyles.isEmpty ? nil : Array(colorStyles.prefix(20).map(\.name)), + truncated: colorStyles.count > 20 ? true : nil, + entriesPerPlatform: entriesPerPlatform ) } - if let flutter = config.flutter { - platforms["flutter"] = EntrySummary( - colorsEntries: flutter.colors?.count, iconsEntries: flutter.icons?.count, - imagesEntries: flutter.images?.count + + private static func inspectIcons( + config: PKLConfig, + client: FigmaAPI.Client + ) async throws -> ComponentsInspectResult { + let fileId = try requireFileId(config: config) + let components = try await client.request(ComponentsEndpoint(fileId: fileId)) + + return ComponentsInspectResult( + fileId: fileId, + componentsCount: components.count, + sampleNames: components.isEmpty ? nil : Array(components.prefix(20).map(\.name)), + truncated: components.count > 20 ? true : nil ) } - if let web = config.web { - platforms["web"] = EntrySummary( - colorsEntries: web.colors?.count, iconsEntries: web.icons?.count, - imagesEntries: web.images?.count + + private static func inspectImages( + config: PKLConfig, + client: FigmaAPI.Client + ) async throws -> FileInspectResult { + let fileId = try requireFileId(config: config) + let metadata = try await client.request(FileMetadataEndpoint(fileId: fileId)) + + return FileInspectResult( + fileId: fileId, + fileName: metadata.name, + lastModified: metadata.lastModified, + version: metadata.version ) } - return platforms - } + private static func inspectTypography( + config: PKLConfig, + client: FigmaAPI.Client + ) async throws -> TypographyInspectResult { + let fileId = try requireFileId(config: config) + let styles = try await client.request(StylesEndpoint(fileId: fileId)) + let textStyles = styles.filter { $0.styleType == .text } - // MARK: - Tokens Info + return TypographyInspectResult( + fileId: fileId, + textStylesCount: textStyles.count, + sampleNames: textStyles.isEmpty ? nil : Array(textStyles.prefix(20).map(\.name)), + truncated: textStyles.count > 20 ? true : nil + ) + } + + // MARK: - Helpers + + private static func resolveConfigPath(from argument: String?) throws -> String { + if let path = argument { + guard FileManager.default.fileExists(atPath: path) else { + throw ExFigError.custom(errorString: "Config file not found: \(path)") + } + return path + } - private static func handleTokensInfo(params: CallTool.Parameters) async throws -> CallTool.Result { - guard let filePath = params.arguments?["file_path"]?.stringValue else { - return .init(content: [.text("Missing required parameter: file_path")], isError: true) + for filename in ExFigOptions.defaultConfigFiles + where FileManager.default.fileExists(atPath: filename) + { + return filename + } + + throw ExFigError.custom( + errorString: "No exfig.pkl found in current directory. Specify config_path parameter." + ) } - var source = try TokensFileSource.parse(fileAt: filePath) - try source.resolveAliases() + private static func errorResult(_ error: ExFigError) -> CallTool.Result { + var message = error.errorDescription ?? "\(error)" + if let recovery = error.recoverySuggestion { + message += "\n\nSuggestion: \(recovery)" + } + return .init(content: [.text(message)], isError: true) + } - var countsByType: [String: Int]? - let byType = source.tokenCountsByType() - if !byType.isEmpty { - var typeCounts: [String: Int] = [:] - for entry in byType { - typeCounts[entry.type] = entry.count + /// Encodes a Codable value as pretty-printed JSON with sorted keys. + private static func encodeJSON(_ value: some Encodable) throws -> String { + let data = try JSONCodec.encodePrettySorted(value) + guard let string = String(data: data, encoding: .utf8) else { + throw ExFigError.custom(errorString: "JSON encoding produced non-UTF-8 data") } - countsByType = typeCounts + return string } + } - var topLevelGroups: [String: Int]? - let groups = source.topLevelGroups() - if !groups.isEmpty { - var groupCounts: [String: Int] = [:] - for entry in groups { - groupCounts[entry.name] = entry.count + // MARK: - Export & Download Handlers + + extension MCPToolHandlers { + private static func requireResourceType( + from params: CallTool.Parameters, + validTypes: Set + ) throws -> String { + guard let resourceType = params.arguments?["resource_type"]?.stringValue else { + throw ExFigError.custom(errorString: "Missing required parameter: resource_type") + } + guard validTypes.contains(resourceType) else { + let valid = validTypes.sorted().joined(separator: ", ") + throw ExFigError.custom( + errorString: "Invalid resource_type: \(resourceType). Must be one of: \(valid)" + ) } - topLevelGroups = groupCounts + return resourceType } - let result = TokensInfoResult( - filePath: filePath, - totalTokens: source.tokens.count, - aliasCount: source.aliasCount, - countsByType: countsByType, - topLevelGroups: topLevelGroups, - warnings: source.warnings.isEmpty ? nil : source.warnings - ) + private static func handleExport(params: CallTool.Parameters) async throws -> CallTool.Result { + let resourceType = try requireResourceType( + from: params, validTypes: ["colors", "icons", "images", "typography", "all"] + ) - return try .init(content: [.text(encodeJSON(result))]) - } + let configPath = try resolveConfigPath(from: params.arguments?["config_path"]?.stringValue) + + let reportPath = FileManager.default.temporaryDirectory + .appendingPathComponent("exfig-report-\(UUID().uuidString).json").path + defer { try? FileManager.default.removeItem(atPath: reportPath) } + + let exportParams = ExportParams( + resourceType: resourceType, + configPath: configPath, + reportPath: reportPath, + filter: params.arguments?["filter"]?.stringValue, + rateLimit: params.arguments?["rate_limit"]?.intValue, + maxRetries: params.arguments?["max_retries"]?.intValue, + cache: params.arguments?["cache"]?.boolValue ?? false, + force: params.arguments?["force"]?.boolValue ?? false, + granularCache: params.arguments?["granular_cache"]?.boolValue ?? false + ) - // MARK: - Inspect + let result = try await runSubprocess(arguments: exportParams.cliArgs) - private static func handleInspect( - params: CallTool.Parameters, - state: MCPServerState - ) async throws -> CallTool.Result { - // Validate inputs before expensive operations (PKL eval, API client) - guard let resourceType = params.arguments?["resource_type"]?.stringValue else { - return .init(content: [.text("Missing required parameter: resource_type")], isError: true) + if FileManager.default.fileExists(atPath: reportPath), + let reportData = FileManager.default.contents(atPath: reportPath), + let reportJSON = String(data: reportData, encoding: .utf8) + { + return .init(content: [.text(reportJSON)], isError: result.exitCode != 0) + } + + if result.exitCode != 0 { + let message = result.stderr.isEmpty + ? "Export failed with exit code \(result.exitCode)" + : result.stderr + return .init(content: [.text(message)], isError: true) + } + + return .init(content: [.text("{\"success\": true}")]) } - let configPath = try resolveConfigPath(from: params.arguments?["config_path"]?.stringValue) - let configURL = URL(fileURLWithPath: configPath) - let config = try await PKLEvaluator.evaluate(configPath: configURL) - let client = try await state.getClient() + private static func handleDownload( + params: CallTool.Parameters, + state: MCPServerState + ) async throws -> CallTool.Result { + let resourceType = try requireResourceType( + from: params, validTypes: ["colors", "typography", "tokens"] + ) - let types = resourceType == "all" - ? ["colors", "icons", "images", "typography"] - : [resourceType] + // Validate cheap parameters before expensive PKL eval / API client creation + let format = params.arguments?["format"]?.stringValue ?? "w3c" + let validFormats: Set = ["w3c", "raw"] + guard validFormats.contains(format) else { + throw ExFigError.custom( + errorString: "Invalid format: \(format). Must be one of: w3c, raw" + ) + } + let filter = params.arguments?["filter"]?.stringValue - var results = InspectResult(configPath: configPath) + let configPath = try resolveConfigPath(from: params.arguments?["config_path"]?.stringValue) + let configURL = URL(fileURLWithPath: configPath) + let config = try await PKLEvaluator.evaluate(configPath: configURL) + let client = try await state.getClient() - for type in types { - switch type { + switch resourceType { case "colors": - results.colors = try await inspectColors(config: config, client: client) - case "icons": - results.icons = try await inspectIcons(config: config, client: client) - case "images": - results.images = try await inspectImages(config: config, client: client) + return try await downloadColors( + config: config, client: client, format: format, filter: filter + ) case "typography": - results.typography = try await inspectTypography(config: config, client: client) + return try await downloadTypography(config: config, client: client, format: format) + case "tokens": + return try await downloadUnifiedTokens(config: config, client: client) default: - results.unknownTypes[type] = "Unknown resource type: \(type)" + return .init(content: [.text("Unknown resource_type: \(resourceType)")], isError: true) } } - - return try .init(content: [.text(encodeJSON(results))]) } - // MARK: - Inspect Helpers + // MARK: - Export Subprocess Helpers - private static func requireFileId(config: PKLConfig) throws -> String { - guard let fileId = config.figma?.lightFileId else { - throw ExFigError.custom( - errorString: "No Figma file ID configured. Set figma.lightFileId in config." - ) + private struct ExportParams { + let resourceType: String + let configPath: String + let reportPath: String + let filter: String? + let rateLimit: Int? + let maxRetries: Int? + let cache: Bool + let force: Bool + let granularCache: Bool + + var cliArgs: [String] { + var args: [String] = if resourceType == "all" { + ["batch", configPath, "--quiet", "--report", reportPath] + } else { + [resourceType, "-i", configPath, "--quiet", "--report", reportPath] + } + if let filter { args += ["--filter", filter] } + if let rateLimit { args += ["--rate-limit", "\(rateLimit)"] } + if let maxRetries { args += ["--max-retries", "\(maxRetries)"] } + if cache { args.append("--cache") } + if force { args.append("--force") } + if granularCache { args.append("--experimental-granular-cache") } + return args } - return fileId } - private static func inspectColors( - config: PKLConfig, - client: FigmaAPI.Client - ) async throws -> ColorsInspectResult { - let fileId = try requireFileId(config: config) - let styles = try await client.request(StylesEndpoint(fileId: fileId)) - let colorStyles = styles.filter { $0.styleType == .fill } - - var entriesPerPlatform: [String: Int]? - var entries: [String: Int] = [:] - if let c = config.ios?.colors { entries["ios"] = c.count } - if let c = config.android?.colors { entries["android"] = c.count } - if let c = config.flutter?.colors { entries["flutter"] = c.count } - if let c = config.web?.colors { entries["web"] = c.count } - if !entries.isEmpty { entriesPerPlatform = entries } - - return ColorsInspectResult( - fileId: fileId, - stylesCount: styles.count, - colorStylesCount: colorStyles.count, - sampleNames: colorStyles.isEmpty ? nil : Array(colorStyles.prefix(20).map(\.name)), - truncated: colorStyles.count > 20 ? true : nil, - entriesPerPlatform: entriesPerPlatform - ) + private struct SubprocessResult { + let exitCode: Int + let stderr: String } - private static func inspectIcons( - config: PKLConfig, - client: FigmaAPI.Client - ) async throws -> ComponentsInspectResult { - let fileId = try requireFileId(config: config) - let components = try await client.request(ComponentsEndpoint(fileId: fileId)) - - return ComponentsInspectResult( - fileId: fileId, - componentsCount: components.count, - sampleNames: components.isEmpty ? nil : Array(components.prefix(20).map(\.name)), - truncated: components.count > 20 ? true : nil - ) - } + private let subprocessTimeout: Duration = .seconds(300) - private static func inspectImages( - config: PKLConfig, - client: FigmaAPI.Client - ) async throws -> FileInspectResult { - let fileId = try requireFileId(config: config) - let metadata = try await client.request(FileMetadataEndpoint(fileId: fileId)) - - return FileInspectResult( - fileId: fileId, - fileName: metadata.name, - lastModified: metadata.lastModified, - version: metadata.version - ) - } + extension MCPToolHandlers { + private static func runSubprocess(arguments: [String]) async throws -> SubprocessResult { + let executablePath = ProcessInfo.processInfo.arguments[0] + let executableURL = URL(fileURLWithPath: executablePath) + guard FileManager.default.isExecutableFile(atPath: executablePath) else { + throw ExFigError.custom( + errorString: "Cannot find exfig executable at \(executablePath) for subprocess export" + ) + } - private static func inspectTypography( - config: PKLConfig, - client: FigmaAPI.Client - ) async throws -> TypographyInspectResult { - let fileId = try requireFileId(config: config) - let styles = try await client.request(StylesEndpoint(fileId: fileId)) - let textStyles = styles.filter { $0.styleType == .text } - - return TypographyInspectResult( - fileId: fileId, - textStylesCount: textStyles.count, - sampleNames: textStyles.isEmpty ? nil : Array(textStyles.prefix(20).map(\.name)), - truncated: textStyles.count > 20 ? true : nil - ) - } + let process = Process() + process.executableURL = executableURL + process.arguments = arguments + process.environment = ProcessInfo.processInfo.environment - // MARK: - Helpers + let stderrPipe = Pipe() + process.standardError = stderrPipe + process.standardOutput = FileHandle.nullDevice - private static func resolveConfigPath(from argument: String?) throws -> String { - if let path = argument { - guard FileManager.default.fileExists(atPath: path) else { - throw ExFigError.custom(errorString: "Config file not found: \(path)") + // Read stderr concurrently to avoid pipe buffer deadlock. + // Must start reading BEFORE waiting for termination. + let stderrTask = Task { + stderrPipe.fileHandleForReading.readDataToEndOfFile() } - return path - } - for filename in ExFigOptions.defaultConfigFiles - where FileManager.default.fileExists(atPath: filename) - { - return filename + // Set termination handler BEFORE run() to avoid race condition + // where process exits before handler is installed. + return try await withThrowingTaskGroup(of: SubprocessResult.self) { group in + group.addTask { + await withCheckedContinuation { (continuation: CheckedContinuation) in + process.terminationHandler = { _ in continuation.resume() } + do { try process.run() } catch { + continuation.resume() + } + } + let stderrData = await stderrTask.value + let stderr = String(data: stderrData, encoding: .utf8) ?? "" + return SubprocessResult(exitCode: Int(process.terminationStatus), stderr: stderr) + } + group.addTask { + try await Task.sleep(for: subprocessTimeout) + process.terminate() + throw ExFigError.custom( + errorString: "Export subprocess timed out after \(subprocessTimeout)" + ) + } + let result = try await group.next()! + group.cancelAll() + return result + } } - - throw ExFigError.custom( - errorString: "No exfig.pkl found in current directory. Specify config_path parameter." - ) } - private static func errorResult(_ error: ExFigError) -> CallTool.Result { - var message = error.errorDescription ?? "\(error)" - if let recovery = error.recoverySuggestion { - message += "\n\nSuggestion: \(recovery)" - } - return .init(content: [.text(message)], isError: true) - } + // MARK: - Download Helpers - /// Encodes a Codable value as pretty-printed JSON with sorted keys. - private static func encodeJSON(_ value: some Encodable) throws -> String { - let data = try JSONCodec.encodePrettySorted(value) - guard let string = String(data: data, encoding: .utf8) else { - throw ExFigError.custom(errorString: "JSON encoding produced non-UTF-8 data") - } - return string - } -} + extension MCPToolHandlers { + private static func downloadColors( + config: PKLConfig, client: FigmaAPI.Client, + format: String, filter: String? + ) async throws -> CallTool.Result { + let result: ColorsVariablesLoader.LoadResult -// MARK: - Export & Download Handlers + if let variableParams = config.common?.variablesColors { + let loader = ColorsVariablesLoader(client: client, variableParams: variableParams, filter: filter) + result = try await loader.load() + } else if let figmaParams = config.figma { + let loader = ColorsLoader( + client: client, + figmaParams: figmaParams, + colorParams: config.common?.colors, + filter: filter + ) + let output = try await loader.load() + result = ColorsVariablesLoader.LoadResult( + output: output, warnings: [], aliases: [:], descriptions: [:], metadata: [:] + ) + } else { + throw ExFigError.custom( + errorString: "No variablesColors or figma section configured. Check config." + ) + } -extension MCPToolHandlers { - private static func requireResourceType( - from params: CallTool.Parameters, - validTypes: Set - ) throws -> String { - guard let resourceType = params.arguments?["resource_type"]?.stringValue else { - throw ExFigError.custom(errorString: "Missing required parameter: resource_type") - } - guard validTypes.contains(resourceType) else { - let valid = validTypes.sorted().joined(separator: ", ") - throw ExFigError.custom( - errorString: "Invalid resource_type: \(resourceType). Must be one of: \(valid)" - ) - } - return resourceType - } + let warnings = result.warnings.map { ExFigWarningFormatter().format($0) } - private static func handleExport(params: CallTool.Parameters) async throws -> CallTool.Result { - let resourceType = try requireResourceType( - from: params, validTypes: ["colors", "icons", "images", "typography", "all"] - ) - - let configPath = try resolveConfigPath(from: params.arguments?["config_path"]?.stringValue) - - let reportPath = FileManager.default.temporaryDirectory - .appendingPathComponent("exfig-report-\(UUID().uuidString).json").path - defer { try? FileManager.default.removeItem(atPath: reportPath) } - - let exportParams = ExportParams( - resourceType: resourceType, - configPath: configPath, - reportPath: reportPath, - filter: params.arguments?["filter"]?.stringValue, - rateLimit: params.arguments?["rate_limit"]?.intValue, - maxRetries: params.arguments?["max_retries"]?.intValue, - cache: params.arguments?["cache"]?.boolValue ?? false, - force: params.arguments?["force"]?.boolValue ?? false, - granularCache: params.arguments?["granular_cache"]?.boolValue ?? false - ) - - let result = try await runSubprocess(arguments: exportParams.cliArgs) - - if FileManager.default.fileExists(atPath: reportPath), - let reportData = FileManager.default.contents(atPath: reportPath), - let reportJSON = String(data: reportData, encoding: .utf8) - { - return .init(content: [.text(reportJSON)], isError: result.exitCode != 0) - } - - if result.exitCode != 0 { - let message = result.stderr.isEmpty - ? "Export failed with exit code \(result.exitCode)" - : result.stderr - return .init(content: [.text(message)], isError: true) - } + if format == "raw" { + var content: [Tool.Content] = [] + if !warnings.isEmpty { + let meta = DownloadMeta( + resourceType: "colors", format: "raw", + tokenCount: result.output.light.count, warnings: warnings + ) + try content.append(.text(encodeJSON(meta))) + } + try content.append(.text(encodeRawColors(result.output))) + return .init(content: content) + } - return .init(content: [.text("{\"success\": true}")]) - } + let colorsByMode = ColorExportHelper.buildColorsByMode(from: result.output) + let exporter = W3CTokensExporter(version: .v2025) + let tokens = exporter.exportColors( + colorsByMode: colorsByMode, + descriptions: result.descriptions, + metadata: result.metadata, + aliases: result.aliases, + modeKeyToName: ColorExportHelper.modeKeyToName + ) + let tokenCount = colorsByMode.values.reduce(0) { $0 + $1.count } - private static func handleDownload( - params: CallTool.Parameters, - state: MCPServerState - ) async throws -> CallTool.Result { - let resourceType = try requireResourceType( - from: params, validTypes: ["colors", "typography", "tokens"] - ) - - // Validate cheap parameters before expensive PKL eval / API client creation - let format = params.arguments?["format"]?.stringValue ?? "w3c" - let validFormats: Set = ["w3c", "raw"] - guard validFormats.contains(format) else { - throw ExFigError.custom( - errorString: "Invalid format: \(format). Must be one of: w3c, raw" + let meta = DownloadMeta( + resourceType: "colors", format: format, + tokenCount: tokenCount, + warnings: warnings.isEmpty ? nil : warnings ) + return try buildDownloadResponse(tokens: tokens, exporter: exporter, meta: meta) } - let filter = params.arguments?["filter"]?.stringValue - let configPath = try resolveConfigPath(from: params.arguments?["config_path"]?.stringValue) - let configURL = URL(fileURLWithPath: configPath) - let config = try await PKLEvaluator.evaluate(configPath: configURL) - let client = try await state.getClient() + private static func downloadTypography( + config: PKLConfig, client: FigmaAPI.Client, format: String + ) async throws -> CallTool.Result { + guard let figmaParams = config.figma else { + throw ExFigError.custom(errorString: "No figma section configured. Check config.") + } - switch resourceType { - case "colors": - return try await downloadColors( - config: config, client: client, format: format, filter: filter - ) - case "typography": - return try await downloadTypography(config: config, client: client, format: format) - case "tokens": - return try await downloadUnifiedTokens(config: config, client: client) - default: - return .init(content: [.text("Unknown resource_type: \(resourceType)")], isError: true) - } - } -} - -// MARK: - Export Subprocess Helpers - -private struct ExportParams { - let resourceType: String - let configPath: String - let reportPath: String - let filter: String? - let rateLimit: Int? - let maxRetries: Int? - let cache: Bool - let force: Bool - let granularCache: Bool - - var cliArgs: [String] { - var args: [String] = if resourceType == "all" { - ["batch", configPath, "--quiet", "--report", reportPath] - } else { - [resourceType, "-i", configPath, "--quiet", "--report", reportPath] - } - if let filter { args += ["--filter", filter] } - if let rateLimit { args += ["--rate-limit", "\(rateLimit)"] } - if let maxRetries { args += ["--max-retries", "\(maxRetries)"] } - if cache { args.append("--cache") } - if force { args.append("--force") } - if granularCache { args.append("--experimental-granular-cache") } - return args - } -} + let loader = TextStylesLoader(client: client, params: figmaParams) + let textStyles = try await loader.load() -private struct SubprocessResult { - let exitCode: Int - let stderr: String -} + if format == "raw" { + return try .init(content: [.text(encodeJSON(textStyles.map { RawTextStyle(from: $0) }))]) + } -private let subprocessTimeout: Duration = .seconds(300) + let exporter = W3CTokensExporter(version: .v2025) + let tokens = exporter.exportTypography(textStyles: textStyles) -extension MCPToolHandlers { - private static func runSubprocess(arguments: [String]) async throws -> SubprocessResult { - let executablePath = ProcessInfo.processInfo.arguments[0] - let executableURL = URL(fileURLWithPath: executablePath) - guard FileManager.default.isExecutableFile(atPath: executablePath) else { - throw ExFigError.custom( - errorString: "Cannot find exfig executable at \(executablePath) for subprocess export" + let meta = DownloadMeta( + resourceType: "typography", format: format, + tokenCount: textStyles.count, warnings: nil ) + return try buildDownloadResponse(tokens: tokens, exporter: exporter, meta: meta) } - let process = Process() - process.executableURL = executableURL - process.arguments = arguments - process.environment = ProcessInfo.processInfo.environment + private static func downloadUnifiedTokens( + config: PKLConfig, client: FigmaAPI.Client + ) async throws -> CallTool.Result { + let exporter = W3CTokensExporter(version: .v2025) + var allTokens: [String: Any] = [:] + var warnings: [String] = [] + var tokenCount = 0 - let stderrPipe = Pipe() - process.standardError = stderrPipe - process.standardOutput = FileHandle.nullDevice + let variableParams = config.common?.variablesColors - // Read stderr concurrently to avoid pipe buffer deadlock. - // Must start reading BEFORE waiting for termination. - let stderrTask = Task { - stderrPipe.fileHandleForReading.readDataToEndOfFile() - } + if let variableParams { + let (tokens, count, w) = try await downloadAndMergeColors( + client: client, variableParams: variableParams, exporter: exporter + ) + W3CTokensExporter.mergeTokens(from: tokens, into: &allTokens) + tokenCount += count + warnings += w + } else { + warnings.append("Skipped colors and numbers: no variablesColors configured") + } - // Set termination handler BEFORE run() to avoid race condition - // where process exits before handler is installed. - return try await withThrowingTaskGroup(of: SubprocessResult.self) { group in - group.addTask { - await withCheckedContinuation { (continuation: CheckedContinuation) in - process.terminationHandler = { _ in continuation.resume() } - do { try process.run() } catch { - continuation.resume() - } + if let figmaParams = config.figma { + let (tokens, count) = try await downloadAndMergeTypography( + client: client, figmaParams: figmaParams, exporter: exporter + ) + W3CTokensExporter.mergeTokens(from: tokens, into: &allTokens) + tokenCount += count + } else { + warnings.append("Skipped typography: no figma section configured") + } + + if let variableParams { + let (tokens, count, w) = try await downloadAndMergeNumbers( + client: client, variableParams: variableParams, exporter: exporter + ) + for t in tokens { + W3CTokensExporter.mergeTokens(from: t, into: &allTokens) } - let stderrData = await stderrTask.value - let stderr = String(data: stderrData, encoding: .utf8) ?? "" - return SubprocessResult(exitCode: Int(process.terminationStatus), stderr: stderr) + tokenCount += count + warnings += w } - group.addTask { - try await Task.sleep(for: subprocessTimeout) - process.terminate() - throw ExFigError.custom( - errorString: "Export subprocess timed out after \(subprocessTimeout)" + + if allTokens.isEmpty { + return .init( + content: [.text("No token sections configured for export. Check your config file.")], + isError: true ) } - let result = try await group.next()! - group.cancelAll() - return result - } - } -} - -// MARK: - Download Helpers - -extension MCPToolHandlers { - private static func downloadColors( - config: PKLConfig, client: FigmaAPI.Client, - format: String, filter: String? - ) async throws -> CallTool.Result { - let result: ColorsVariablesLoader.LoadResult - - if let variableParams = config.common?.variablesColors { - let loader = ColorsVariablesLoader(client: client, variableParams: variableParams, filter: filter) - result = try await loader.load() - } else if let figmaParams = config.figma { - let loader = ColorsLoader( - client: client, - figmaParams: figmaParams, - colorParams: config.common?.colors, - filter: filter + + let meta = DownloadMeta( + resourceType: "tokens", format: "w3c", + tokenCount: tokenCount, + warnings: warnings.isEmpty ? nil : warnings ) - let output = try await loader.load() - result = ColorsVariablesLoader.LoadResult( - output: output, warnings: [], aliases: [:], descriptions: [:], metadata: [:] + return try buildDownloadResponse(tokens: allTokens, exporter: exporter, meta: meta) + } + + private static func downloadAndMergeColors( + client: FigmaAPI.Client, + variableParams: PKLConfig.Common.VariablesColors, + exporter: W3CTokensExporter + ) async throws -> (tokens: [String: Any], count: Int, warnings: [String]) { + let loader = ColorsVariablesLoader(client: client, variableParams: variableParams, filter: nil) + let colorsResult = try await loader.load() + let warnings = colorsResult.warnings.map { ExFigWarningFormatter().format($0) } + let colorsByMode = ColorExportHelper.buildColorsByMode(from: colorsResult.output) + let colorTokens = exporter.exportColors( + colorsByMode: colorsByMode, + descriptions: colorsResult.descriptions, + metadata: colorsResult.metadata, + aliases: colorsResult.aliases, + modeKeyToName: ColorExportHelper.modeKeyToName ) - } else { - throw ExFigError.custom( - errorString: "No variablesColors or figma section configured. Check config." + let count = colorsByMode.values.reduce(0) { $0 + $1.count } + return (colorTokens, count, warnings) + } + + private static func downloadAndMergeTypography( + client: FigmaAPI.Client, + figmaParams: PKLConfig.Figma, + exporter: W3CTokensExporter + ) async throws -> (tokens: [String: Any], count: Int) { + let loader = TextStylesLoader(client: client, params: figmaParams) + let textStyles = try await loader.load() + let tokens = exporter.exportTypography(textStyles: textStyles) + return (tokens, textStyles.count) + } + + private static func downloadAndMergeNumbers( + client: FigmaAPI.Client, + variableParams: PKLConfig.Common.VariablesColors, + exporter: W3CTokensExporter + ) async throws -> (tokens: [[String: Any]], count: Int, warnings: [String]) { + let numLoader = NumberVariablesLoader( + client: client, + tokensFileId: variableParams.tokensFileId, + tokensCollectionName: variableParams.tokensCollectionName ) + let numberResult = try await numLoader.load() + let warnings = numberResult.warnings.map { ExFigWarningFormatter().format($0) } + var tokens: [[String: Any]] = [] + var count = 0 + if !numberResult.dimensions.isEmpty { + tokens.append(exporter.exportDimensions(tokens: numberResult.dimensions)) + count += numberResult.dimensions.count + } + if !numberResult.numbers.isEmpty { + tokens.append(exporter.exportNumbers(tokens: numberResult.numbers)) + count += numberResult.numbers.count + } + return (tokens, count, warnings) } - let warnings = result.warnings.map { ExFigWarningFormatter().format($0) } - - if format == "raw" { - var content: [Tool.Content] = [] - if !warnings.isEmpty { - let meta = DownloadMeta( - resourceType: "colors", format: "raw", - tokenCount: result.output.light.count, warnings: warnings - ) - try content.append(.text(encodeJSON(meta))) - } - try content.append(.text(encodeRawColors(result.output))) - return .init(content: content) - } - - let colorsByMode = ColorExportHelper.buildColorsByMode(from: result.output) - let exporter = W3CTokensExporter(version: .v2025) - let tokens = exporter.exportColors( - colorsByMode: colorsByMode, - descriptions: result.descriptions, - metadata: result.metadata, - aliases: result.aliases, - modeKeyToName: ColorExportHelper.modeKeyToName - ) - let tokenCount = colorsByMode.values.reduce(0) { $0 + $1.count } - - let meta = DownloadMeta( - resourceType: "colors", format: format, - tokenCount: tokenCount, - warnings: warnings.isEmpty ? nil : warnings - ) - return try buildDownloadResponse(tokens: tokens, exporter: exporter, meta: meta) - } + private static func buildDownloadResponse( + tokens: [String: Any], exporter: W3CTokensExporter, + meta: DownloadMeta + ) throws -> CallTool.Result { + let tokensData = try exporter.serializeToJSON(tokens, compact: false) + guard let tokensJSON = String(data: tokensData, encoding: .utf8) else { + throw ExFigError.custom(errorString: "Token JSON serialization produced non-UTF-8 data") + } - private static func downloadTypography( - config: PKLConfig, client: FigmaAPI.Client, format: String - ) async throws -> CallTool.Result { - guard let figmaParams = config.figma else { - throw ExFigError.custom(errorString: "No figma section configured. Check config.") + return try .init(content: [ + .text(encodeJSON(meta)), + .text(tokensJSON), + ]) } - let loader = TextStylesLoader(client: client, params: figmaParams) - let textStyles = try await loader.load() - - if format == "raw" { - return try .init(content: [.text(encodeJSON(textStyles.map { RawTextStyle(from: $0) }))]) + private static func encodeRawColors(_ output: ColorsLoaderOutput) throws -> String { + let toRaw: (Color) -> RawColor = { color in + RawColor( + name: color.name, + red: color.red, green: color.green, + blue: color.blue, alpha: color.alpha + ) + } + let raw = RawColorsOutput( + light: output.light.map(toRaw), + dark: output.dark.map { $0.map(toRaw) }, + lightHC: output.lightHC.map { $0.map(toRaw) }, + darkHC: output.darkHC.map { $0.map(toRaw) } + ) + return try encodeJSON(raw) } - - let exporter = W3CTokensExporter(version: .v2025) - let tokens = exporter.exportTypography(textStyles: textStyles) - - let meta = DownloadMeta( - resourceType: "typography", format: format, - tokenCount: textStyles.count, warnings: nil - ) - return try buildDownloadResponse(tokens: tokens, exporter: exporter, meta: meta) } - private static func downloadUnifiedTokens( - config: PKLConfig, client: FigmaAPI.Client - ) async throws -> CallTool.Result { - let exporter = W3CTokensExporter(version: .v2025) - var allTokens: [String: Any] = [:] - var warnings: [String] = [] - var tokenCount = 0 + // MARK: - Response Types - let variableParams = config.common?.variablesColors + private struct ValidateSummary: Codable, Sendable { + let configPath: String + let valid: Bool + var platforms: [String: EntrySummary]? + var figmaFileIds: [String]? - if let variableParams { - let (tokens, count, w) = try await downloadAndMergeColors( - client: client, variableParams: variableParams, exporter: exporter - ) - W3CTokensExporter.mergeTokens(from: tokens, into: &allTokens) - tokenCount += count - warnings += w - } else { - warnings.append("Skipped colors and numbers: no variablesColors configured") + enum CodingKeys: String, CodingKey { + case configPath = "config_path" + case valid + case platforms + case figmaFileIds = "figma_file_ids" } + } - if let figmaParams = config.figma { - let (tokens, count) = try await downloadAndMergeTypography( - client: client, figmaParams: figmaParams, exporter: exporter - ) - W3CTokensExporter.mergeTokens(from: tokens, into: &allTokens) - tokenCount += count - } else { - warnings.append("Skipped typography: no figma section configured") + private struct EntrySummary: Codable, Sendable { + var colorsEntries: Int? + var iconsEntries: Int? + var imagesEntries: Int? + var typography: Bool? + + enum CodingKeys: String, CodingKey { + case colorsEntries = "colors_entries" + case iconsEntries = "icons_entries" + case imagesEntries = "images_entries" + case typography } + } - if let variableParams { - let (tokens, count, w) = try await downloadAndMergeNumbers( - client: client, variableParams: variableParams, exporter: exporter - ) - for t in tokens { - W3CTokensExporter.mergeTokens(from: t, into: &allTokens) + private struct TokensInfoResult: Codable, Sendable { + let filePath: String + let totalTokens: Int + let aliasCount: Int + var countsByType: [String: Int]? + var topLevelGroups: [String: Int]? + var warnings: [String]? + + enum CodingKeys: String, CodingKey { + case filePath = "file_path" + case totalTokens = "total_tokens" + case aliasCount = "alias_count" + case countsByType = "counts_by_type" + case topLevelGroups = "top_level_groups" + case warnings + } + } + + private struct InspectResult: Codable, Sendable { + let configPath: String + var colors: ColorsInspectResult? + var icons: ComponentsInspectResult? + var images: FileInspectResult? + var typography: TypographyInspectResult? + var unknownTypes: [String: String] = [:] + + enum CodingKeys: String, CodingKey { + case configPath = "config_path" + case colors, icons, images, typography + case unknownTypes = "unknown_types" + } + + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(configPath, forKey: .configPath) + try container.encodeIfPresent(colors, forKey: .colors) + try container.encodeIfPresent(icons, forKey: .icons) + try container.encodeIfPresent(images, forKey: .images) + try container.encodeIfPresent(typography, forKey: .typography) + if !unknownTypes.isEmpty { + try container.encode(unknownTypes, forKey: .unknownTypes) } - tokenCount += count - warnings += w } + } - if allTokens.isEmpty { - return .init( - content: [.text("No token sections configured for export. Check your config file.")], - isError: true - ) - } + private struct ColorsInspectResult: Codable, Sendable { + let fileId: String + let stylesCount: Int + let colorStylesCount: Int + var sampleNames: [String]? + var truncated: Bool? + var entriesPerPlatform: [String: Int]? - let meta = DownloadMeta( - resourceType: "tokens", format: "w3c", - tokenCount: tokenCount, - warnings: warnings.isEmpty ? nil : warnings - ) - return try buildDownloadResponse(tokens: allTokens, exporter: exporter, meta: meta) + enum CodingKeys: String, CodingKey { + case fileId = "file_id" + case stylesCount = "styles_count" + case colorStylesCount = "color_styles_count" + case sampleNames = "sample_names" + case truncated + case entriesPerPlatform = "entries_per_platform" + } } - private static func downloadAndMergeColors( - client: FigmaAPI.Client, - variableParams: PKLConfig.Common.VariablesColors, - exporter: W3CTokensExporter - ) async throws -> (tokens: [String: Any], count: Int, warnings: [String]) { - let loader = ColorsVariablesLoader(client: client, variableParams: variableParams, filter: nil) - let colorsResult = try await loader.load() - let warnings = colorsResult.warnings.map { ExFigWarningFormatter().format($0) } - let colorsByMode = ColorExportHelper.buildColorsByMode(from: colorsResult.output) - let colorTokens = exporter.exportColors( - colorsByMode: colorsByMode, - descriptions: colorsResult.descriptions, - metadata: colorsResult.metadata, - aliases: colorsResult.aliases, - modeKeyToName: ColorExportHelper.modeKeyToName - ) - let count = colorsByMode.values.reduce(0) { $0 + $1.count } - return (colorTokens, count, warnings) - } + private struct ComponentsInspectResult: Codable, Sendable { + let fileId: String + let componentsCount: Int + var sampleNames: [String]? + var truncated: Bool? - private static func downloadAndMergeTypography( - client: FigmaAPI.Client, - figmaParams: PKLConfig.Figma, - exporter: W3CTokensExporter - ) async throws -> (tokens: [String: Any], count: Int) { - let loader = TextStylesLoader(client: client, params: figmaParams) - let textStyles = try await loader.load() - let tokens = exporter.exportTypography(textStyles: textStyles) - return (tokens, textStyles.count) + enum CodingKeys: String, CodingKey { + case fileId = "file_id" + case componentsCount = "components_count" + case sampleNames = "sample_names" + case truncated + } } - private static func downloadAndMergeNumbers( - client: FigmaAPI.Client, - variableParams: PKLConfig.Common.VariablesColors, - exporter: W3CTokensExporter - ) async throws -> (tokens: [[String: Any]], count: Int, warnings: [String]) { - let numLoader = NumberVariablesLoader( - client: client, - tokensFileId: variableParams.tokensFileId, - tokensCollectionName: variableParams.tokensCollectionName - ) - let numberResult = try await numLoader.load() - let warnings = numberResult.warnings.map { ExFigWarningFormatter().format($0) } - var tokens: [[String: Any]] = [] - var count = 0 - if !numberResult.dimensions.isEmpty { - tokens.append(exporter.exportDimensions(tokens: numberResult.dimensions)) - count += numberResult.dimensions.count - } - if !numberResult.numbers.isEmpty { - tokens.append(exporter.exportNumbers(tokens: numberResult.numbers)) - count += numberResult.numbers.count - } - return (tokens, count, warnings) - } + private struct FileInspectResult: Codable, Sendable { + let fileId: String + let fileName: String + let lastModified: String + let version: String - private static func buildDownloadResponse( - tokens: [String: Any], exporter: W3CTokensExporter, - meta: DownloadMeta - ) throws -> CallTool.Result { - let tokensData = try exporter.serializeToJSON(tokens, compact: false) - guard let tokensJSON = String(data: tokensData, encoding: .utf8) else { - throw ExFigError.custom(errorString: "Token JSON serialization produced non-UTF-8 data") + enum CodingKeys: String, CodingKey { + case fileId = "file_id" + case fileName = "file_name" + case lastModified = "last_modified" + case version } - - return try .init(content: [ - .text(encodeJSON(meta)), - .text(tokensJSON), - ]) } - private static func encodeRawColors(_ output: ColorsLoaderOutput) throws -> String { - let toRaw: (Color) -> RawColor = { color in - RawColor( - name: color.name, - red: color.red, green: color.green, - blue: color.blue, alpha: color.alpha - ) + private struct TypographyInspectResult: Codable, Sendable { + let fileId: String + let textStylesCount: Int + var sampleNames: [String]? + var truncated: Bool? + + enum CodingKeys: String, CodingKey { + case fileId = "file_id" + case textStylesCount = "text_styles_count" + case sampleNames = "sample_names" + case truncated } - let raw = RawColorsOutput( - light: output.light.map(toRaw), - dark: output.dark.map { $0.map(toRaw) }, - lightHC: output.lightHC.map { $0.map(toRaw) }, - darkHC: output.darkHC.map { $0.map(toRaw) } - ) - return try encodeJSON(raw) } -} -// MARK: - Response Types + private struct DownloadMeta: Codable, Sendable { + let resourceType: String + let format: String + let tokenCount: Int + var warnings: [String]? -private struct ValidateSummary: Codable, Sendable { - let configPath: String - let valid: Bool - var platforms: [String: EntrySummary]? - var figmaFileIds: [String]? - - enum CodingKeys: String, CodingKey { - case configPath = "config_path" - case valid - case platforms - case figmaFileIds = "figma_file_ids" - } -} - -private struct EntrySummary: Codable, Sendable { - var colorsEntries: Int? - var iconsEntries: Int? - var imagesEntries: Int? - var typography: Bool? - - enum CodingKeys: String, CodingKey { - case colorsEntries = "colors_entries" - case iconsEntries = "icons_entries" - case imagesEntries = "images_entries" - case typography + enum CodingKeys: String, CodingKey { + case resourceType = "resource_type" + case format + case tokenCount = "token_count" + case warnings + } } -} - -private struct TokensInfoResult: Codable, Sendable { - let filePath: String - let totalTokens: Int - let aliasCount: Int - var countsByType: [String: Int]? - var topLevelGroups: [String: Int]? - var warnings: [String]? - - enum CodingKeys: String, CodingKey { - case filePath = "file_path" - case totalTokens = "total_tokens" - case aliasCount = "alias_count" - case countsByType = "counts_by_type" - case topLevelGroups = "top_level_groups" - case warnings + + private struct RawColorsOutput: Codable, Sendable { + let light: [RawColor] + let dark: [RawColor]? + let lightHC: [RawColor]? + let darkHC: [RawColor]? } -} - -private struct InspectResult: Codable, Sendable { - let configPath: String - var colors: ColorsInspectResult? - var icons: ComponentsInspectResult? - var images: FileInspectResult? - var typography: TypographyInspectResult? - var unknownTypes: [String: String] = [:] - - enum CodingKeys: String, CodingKey { - case configPath = "config_path" - case colors, icons, images, typography - case unknownTypes = "unknown_types" + + private struct RawColor: Codable, Sendable { + let name: String + let red: Double + let green: Double + let blue: Double + let alpha: Double } - func encode(to encoder: any Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(configPath, forKey: .configPath) - try container.encodeIfPresent(colors, forKey: .colors) - try container.encodeIfPresent(icons, forKey: .icons) - try container.encodeIfPresent(images, forKey: .images) - try container.encodeIfPresent(typography, forKey: .typography) - if !unknownTypes.isEmpty { - try container.encode(unknownTypes, forKey: .unknownTypes) + private struct RawTextStyle: Codable, Sendable { + let name: String + let fontName: String + let fontSize: Double + let lineHeight: Double? + let letterSpacing: Double + + init(from textStyle: TextStyle) { + name = textStyle.name + fontName = textStyle.fontName + fontSize = textStyle.fontSize + lineHeight = textStyle.lineHeight + letterSpacing = textStyle.letterSpacing } - } -} - -private struct ColorsInspectResult: Codable, Sendable { - let fileId: String - let stylesCount: Int - let colorStylesCount: Int - var sampleNames: [String]? - var truncated: Bool? - var entriesPerPlatform: [String: Int]? - - enum CodingKeys: String, CodingKey { - case fileId = "file_id" - case stylesCount = "styles_count" - case colorStylesCount = "color_styles_count" - case sampleNames = "sample_names" - case truncated - case entriesPerPlatform = "entries_per_platform" - } -} - -private struct ComponentsInspectResult: Codable, Sendable { - let fileId: String - let componentsCount: Int - var sampleNames: [String]? - var truncated: Bool? - - enum CodingKeys: String, CodingKey { - case fileId = "file_id" - case componentsCount = "components_count" - case sampleNames = "sample_names" - case truncated - } -} - -private struct FileInspectResult: Codable, Sendable { - let fileId: String - let fileName: String - let lastModified: String - let version: String - - enum CodingKeys: String, CodingKey { - case fileId = "file_id" - case fileName = "file_name" - case lastModified = "last_modified" - case version - } -} - -private struct TypographyInspectResult: Codable, Sendable { - let fileId: String - let textStylesCount: Int - var sampleNames: [String]? - var truncated: Bool? - - enum CodingKeys: String, CodingKey { - case fileId = "file_id" - case textStylesCount = "text_styles_count" - case sampleNames = "sample_names" - case truncated - } -} - -private struct DownloadMeta: Codable, Sendable { - let resourceType: String - let format: String - let tokenCount: Int - var warnings: [String]? - - enum CodingKeys: String, CodingKey { - case resourceType = "resource_type" - case format - case tokenCount = "token_count" - case warnings - } -} - -private struct RawColorsOutput: Codable, Sendable { - let light: [RawColor] - let dark: [RawColor]? - let lightHC: [RawColor]? - let darkHC: [RawColor]? -} - -private struct RawColor: Codable, Sendable { - let name: String - let red: Double - let green: Double - let blue: Double - let alpha: Double -} - -private struct RawTextStyle: Codable, Sendable { - let name: String - let fontName: String - let fontSize: Double - let lineHeight: Double? - let letterSpacing: Double - - init(from textStyle: TextStyle) { - name = textStyle.name - fontName = textStyle.fontName - fontSize = textStyle.fontSize - lineHeight = textStyle.lineHeight - letterSpacing = textStyle.letterSpacing - } - enum CodingKeys: String, CodingKey { - case name - case fontName = "font_name" - case fontSize = "font_size" - case lineHeight = "line_height" - case letterSpacing = "letter_spacing" + enum CodingKeys: String, CodingKey { + case name + case fontName = "font_name" + case fontSize = "font_size" + case lineHeight = "line_height" + case letterSpacing = "letter_spacing" + } } -} -// swiftlint:enable file_length + // swiftlint:enable file_length +#endif diff --git a/Sources/ExFigCLI/Subcommands/MCPServe.swift b/Sources/ExFigCLI/Subcommands/MCPServe.swift index 1680f47d..c1d4cd89 100644 --- a/Sources/ExFigCLI/Subcommands/MCPServe.swift +++ b/Sources/ExFigCLI/Subcommands/MCPServe.swift @@ -1,28 +1,30 @@ -import ArgumentParser -import Foundation +#if canImport(MCP) + import ArgumentParser + import Foundation -struct MCPServe: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "mcp", - abstract: "Start MCP (Model Context Protocol) server for AI agent integration", - discussion: """ - Starts a JSON-RPC server over stdin/stdout using the Model Context Protocol. - AI agents (Claude Code, Cursor, etc.) can use this server to validate configs, - inspect Figma resources, and run exports programmatically. + struct MCPServe: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "mcp", + abstract: "Start MCP (Model Context Protocol) server for AI agent integration", + discussion: """ + Starts a JSON-RPC server over stdin/stdout using the Model Context Protocol. + AI agents (Claude Code, Cursor, etc.) can use this server to validate configs, + inspect Figma resources, and run exports programmatically. - All CLI output is redirected to stderr to keep stdout clean for JSON-RPC. - """ - ) + All CLI output is redirected to stderr to keep stdout clean for JSON-RPC. + """ + ) - func run() async throws { - // MCP mode: all output goes to stderr, stdout is reserved for JSON-RPC - let outputMode = OutputMode.mcp - ExFigLogging.bootstrap(outputMode: outputMode) - TerminalOutputManager.shared.setStderrMode(true) + func run() async throws { + // MCP mode: all output goes to stderr, stdout is reserved for JSON-RPC + let outputMode = OutputMode.mcp + ExFigLogging.bootstrap(outputMode: outputMode) + TerminalOutputManager.shared.setStderrMode(true) - ExFigCommand.terminalUI = TerminalUI(outputMode: outputMode) + ExFigCommand.terminalUI = TerminalUI(outputMode: outputMode) - let server = ExFigMCPServer() - try await server.run() + let server = ExFigMCPServer() + try await server.run() + } } -} +#endif diff --git a/Tests/ExFigTests/MCP/MCPToolHandlerTests.swift b/Tests/ExFigTests/MCP/MCPToolHandlerTests.swift index 9d21322c..63182c33 100644 --- a/Tests/ExFigTests/MCP/MCPToolHandlerTests.swift +++ b/Tests/ExFigTests/MCP/MCPToolHandlerTests.swift @@ -1,235 +1,237 @@ -@testable import ExFigCLI -import Foundation -import MCP -import Testing - -@Suite("MCP Tool Handlers") -struct MCPToolHandlerTests { - // MARK: - Fixtures Path - - private static let fixturesPath = URL(fileURLWithPath: #filePath) - .deletingLastPathComponent() - .deletingLastPathComponent() - .appendingPathComponent("Fixtures/PKL") - - // MARK: - Test Helpers - - private func expectError( - tool: String, - arguments: [String: Value]?, - containing substring: String - ) async { - let params = CallTool.Parameters(name: tool, arguments: arguments) - let result = await MCPToolHandlers.handle(params: params, state: MCPServerState()) - #expect(result.isError == true) - if case let .text(text) = result.content.first { - #expect(text.contains(substring)) +#if canImport(MCP) + @testable import ExFigCLI + import Foundation + import MCP + import Testing + + @Suite("MCP Tool Handlers") + struct MCPToolHandlerTests { + // MARK: - Fixtures Path + + private static let fixturesPath = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Fixtures/PKL") + + // MARK: - Test Helpers + + private func expectError( + tool: String, + arguments: [String: Value]?, + containing substring: String + ) async { + let params = CallTool.Parameters(name: tool, arguments: arguments) + let result = await MCPToolHandlers.handle(params: params, state: MCPServerState()) + #expect(result.isError == true) + if case let .text(text) = result.content.first { + #expect(text.contains(substring)) + } } - } - // MARK: - Validate Tool + // MARK: - Validate Tool - @Test("validate returns error for missing config") - func validateMissingConfig() async { - await expectError( - tool: "exfig_validate", - arguments: ["config_path": .string("/nonexistent/path.pkl")], - containing: "not found" - ) - } + @Test("validate returns error for missing config") + func validateMissingConfig() async { + await expectError( + tool: "exfig_validate", + arguments: ["config_path": .string("/nonexistent/path.pkl")], + containing: "not found" + ) + } - @Test("validate auto-detects exfig.pkl when no path given") - func validateAutoDetect() async { - await expectError(tool: "exfig_validate", arguments: nil, containing: "exfig.pkl") - } + @Test("validate auto-detects exfig.pkl when no path given") + func validateAutoDetect() async { + await expectError(tool: "exfig_validate", arguments: nil, containing: "exfig.pkl") + } - @Test("validate returns summary for valid config") - func validateValidConfig() async { - let configPath = Self.fixturesPath.appendingPathComponent("valid-config.pkl").path + @Test("validate returns summary for valid config") + func validateValidConfig() async { + let configPath = Self.fixturesPath.appendingPathComponent("valid-config.pkl").path - let params = CallTool.Parameters( - name: "exfig_validate", - arguments: ["config_path": .string(configPath)] - ) + let params = CallTool.Parameters( + name: "exfig_validate", + arguments: ["config_path": .string(configPath)] + ) - let result = await MCPToolHandlers.handle(params: params, state: MCPServerState()) + let result = await MCPToolHandlers.handle(params: params, state: MCPServerState()) - #expect(result.isError != true) + #expect(result.isError != true) - if case let .text(text) = result.content.first { - #expect(text.contains("\"valid\"")) - #expect(text.contains("config_path")) - #expect(text.contains("ios")) + if case let .text(text) = result.content.first { + #expect(text.contains("\"valid\"")) + #expect(text.contains("config_path")) + #expect(text.contains("ios")) + } } - } - // MARK: - Tokens Info Tool + // MARK: - Tokens Info Tool - @Test("tokens_info returns error for missing file_path") - func tokensInfoMissingParam() async { - await expectError(tool: "exfig_tokens_info", arguments: nil, containing: "file_path") - } + @Test("tokens_info returns error for missing file_path") + func tokensInfoMissingParam() async { + await expectError(tool: "exfig_tokens_info", arguments: nil, containing: "file_path") + } - @Test("tokens_info returns error for nonexistent file") - func tokensInfoFileNotFound() async { - await expectError( - tool: "exfig_tokens_info", - arguments: ["file_path": .string("/tmp/nonexistent.tokens.json")], - containing: "not found" - ) - } + @Test("tokens_info returns error for nonexistent file") + func tokensInfoFileNotFound() async { + await expectError( + tool: "exfig_tokens_info", + arguments: ["file_path": .string("/tmp/nonexistent.tokens.json")], + containing: "not found" + ) + } - @Test("tokens_info parses valid tokens file") - func tokensInfoValid() async throws { - let json = """ - { - "$type": "color", - "Brand": { - "Primary": { - "$value": { - "colorSpace": "srgb", - "components": [0.2, 0.4, 0.8], - "alpha": 1.0 + @Test("tokens_info parses valid tokens file") + func tokensInfoValid() async throws { + let json = """ + { + "$type": "color", + "Brand": { + "Primary": { + "$value": { + "colorSpace": "srgb", + "components": [0.2, 0.4, 0.8], + "alpha": 1.0 + } + }, + "Secondary": { + "$value": { + "colorSpace": "srgb", + "components": [0.8, 0.2, 0.4], + "alpha": 1.0 + } } }, - "Secondary": { - "$value": { - "colorSpace": "srgb", - "components": [0.8, 0.2, 0.4], - "alpha": 1.0 + "Spacing": { + "$type": "dimension", + "Small": { + "$value": { "value": 8, "unit": "px" } } } - }, - "Spacing": { - "$type": "dimension", - "Small": { - "$value": { "value": 8, "unit": "px" } - } } - } - """ + """ - let tmpFile = NSTemporaryDirectory() + "mcp_test_tokens.json" - try json.write(toFile: tmpFile, atomically: true, encoding: .utf8) - defer { try? FileManager.default.removeItem(atPath: tmpFile) } + let tmpFile = NSTemporaryDirectory() + "mcp_test_tokens.json" + try json.write(toFile: tmpFile, atomically: true, encoding: .utf8) + defer { try? FileManager.default.removeItem(atPath: tmpFile) } - let params = CallTool.Parameters( - name: "exfig_tokens_info", - arguments: ["file_path": .string(tmpFile)] - ) + let params = CallTool.Parameters( + name: "exfig_tokens_info", + arguments: ["file_path": .string(tmpFile)] + ) - let result = await MCPToolHandlers.handle(params: params, state: MCPServerState()) + let result = await MCPToolHandlers.handle(params: params, state: MCPServerState()) - #expect(result.isError != true) + #expect(result.isError != true) - if case let .text(text) = result.content.first { - #expect(text.contains("total_tokens")) - #expect(text.contains("counts_by_type")) - #expect(text.contains("top_level_groups")) + if case let .text(text) = result.content.first { + #expect(text.contains("total_tokens")) + #expect(text.contains("counts_by_type")) + #expect(text.contains("top_level_groups")) + } } - } - // MARK: - Unknown Tool + // MARK: - Unknown Tool - @Test("unknown tool returns error") - func unknownTool() async { - await expectError(tool: "nonexistent_tool", arguments: nil, containing: "Unknown tool") - } + @Test("unknown tool returns error") + func unknownTool() async { + await expectError(tool: "nonexistent_tool", arguments: nil, containing: "Unknown tool") + } - // MARK: - Inspect Tool + // MARK: - Inspect Tool - @Test("inspect returns error without FIGMA_PERSONAL_TOKEN") - func inspectNoToken() async { - let params = CallTool.Parameters( - name: "exfig_inspect", - arguments: [ - "config_path": .string("/nonexistent.pkl"), - "resource_type": .string("colors"), - ] - ) + @Test("inspect returns error without FIGMA_PERSONAL_TOKEN") + func inspectNoToken() async { + let params = CallTool.Parameters( + name: "exfig_inspect", + arguments: [ + "config_path": .string("/nonexistent.pkl"), + "resource_type": .string("colors"), + ] + ) - let result = await MCPToolHandlers.handle(params: params, state: MCPServerState()) + let result = await MCPToolHandlers.handle(params: params, state: MCPServerState()) - #expect(result.isError == true) - } + #expect(result.isError == true) + } - @Test("inspect returns error for missing resource_type") - func inspectMissingResourceType() async { - let configPath = Self.fixturesPath.appendingPathComponent("valid-config.pkl").path - await expectError( - tool: "exfig_inspect", - arguments: ["config_path": .string(configPath)], - containing: "resource_type" - ) - } + @Test("inspect returns error for missing resource_type") + func inspectMissingResourceType() async { + let configPath = Self.fixturesPath.appendingPathComponent("valid-config.pkl").path + await expectError( + tool: "exfig_inspect", + arguments: ["config_path": .string(configPath)], + containing: "resource_type" + ) + } - // MARK: - Export Tool + // MARK: - Export Tool - @Test("export returns error for missing resource_type") - func exportMissingResourceType() async { - await expectError(tool: "exfig_export", arguments: nil, containing: "resource_type") - } + @Test("export returns error for missing resource_type") + func exportMissingResourceType() async { + await expectError(tool: "exfig_export", arguments: nil, containing: "resource_type") + } - @Test("export returns error for invalid resource_type") - func exportInvalidResourceType() async { - await expectError( - tool: "exfig_export", - arguments: ["resource_type": .string("invalid")], - containing: "Invalid resource_type" - ) - } + @Test("export returns error for invalid resource_type") + func exportInvalidResourceType() async { + await expectError( + tool: "exfig_export", + arguments: ["resource_type": .string("invalid")], + containing: "Invalid resource_type" + ) + } - @Test("export returns error for missing config") - func exportMissingConfig() async { - await expectError( - tool: "exfig_export", - arguments: [ - "resource_type": .string("colors"), - "config_path": .string("/nonexistent/path.pkl"), - ], - containing: "not found" - ) - } + @Test("export returns error for missing config") + func exportMissingConfig() async { + await expectError( + tool: "exfig_export", + arguments: [ + "resource_type": .string("colors"), + "config_path": .string("/nonexistent/path.pkl"), + ], + containing: "not found" + ) + } - // MARK: - Download Tool + // MARK: - Download Tool - @Test("download returns error for missing resource_type") - func downloadMissingResourceType() async { - await expectError(tool: "exfig_download", arguments: nil, containing: "resource_type") - } + @Test("download returns error for missing resource_type") + func downloadMissingResourceType() async { + await expectError(tool: "exfig_download", arguments: nil, containing: "resource_type") + } - @Test("download returns error for invalid resource_type") - func downloadInvalidResourceType() async { - await expectError( - tool: "exfig_download", - arguments: ["resource_type": .string("invalid")], - containing: "Invalid resource_type" - ) - } + @Test("download returns error for invalid resource_type") + func downloadInvalidResourceType() async { + await expectError( + tool: "exfig_download", + arguments: ["resource_type": .string("invalid")], + containing: "Invalid resource_type" + ) + } - @Test("download returns error for missing config") - func downloadMissingConfig() async { - await expectError( - tool: "exfig_download", - arguments: [ - "resource_type": .string("colors"), - "config_path": .string("/nonexistent/path.pkl"), - ], - containing: "not found" - ) - } + @Test("download returns error for missing config") + func downloadMissingConfig() async { + await expectError( + tool: "exfig_download", + arguments: [ + "resource_type": .string("colors"), + "config_path": .string("/nonexistent/path.pkl"), + ], + containing: "not found" + ) + } - @Test("download returns error for invalid format") - func downloadInvalidFormat() async { - let configPath = Self.fixturesPath.appendingPathComponent("valid-config.pkl").path - await expectError( - tool: "exfig_download", - arguments: [ - "resource_type": .string("colors"), - "config_path": .string(configPath), - "format": .string("csv"), - ], - containing: "Invalid format" - ) + @Test("download returns error for invalid format") + func downloadInvalidFormat() async { + let configPath = Self.fixturesPath.appendingPathComponent("valid-config.pkl").path + await expectError( + tool: "exfig_download", + arguments: [ + "resource_type": .string("colors"), + "config_path": .string(configPath), + "format": .string("csv"), + ], + containing: "Invalid format" + ) + } } -} +#endif From 8b11499a54193ec87112b3bca409f964a15f2567 Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Wed, 25 Mar 2026 14:57:56 +0500 Subject: [PATCH 08/17] perf(ci): add SPM cache for Windows, remove redundant Release build --- .github/workflows/ci.yml | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index de69bf39..99ff56a5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -138,6 +138,14 @@ jobs: branch: swift-6.3-branch tag: 6.3-DEVELOPMENT-SNAPSHOT-2026-02-21-a + - name: Cache SPM dependencies + uses: actions/cache@v5 + with: + path: .build + key: ${{ runner.os }}-swift-6.3-spm-${{ hashFiles('Package.swift', 'Package.resolved') }} + restore-keys: | + ${{ runner.os }}-swift-6.3-spm- + - name: Swift version run: swift --version @@ -146,6 +154,3 @@ jobs: - name: Build (Debug) run: swift build - - - name: Build (Release) - run: swift build -c release From a693e8a0954c80e00c057ba2ff78f3815fb6a8cb Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Wed, 25 Mar 2026 15:04:50 +0500 Subject: [PATCH 09/17] docs: document MCP SDK Windows exclusion and resvg version coupling --- .claude/rules/gotchas.md | 6 ++++++ .claude/rules/linux-compat.md | 8 ++++++++ CLAUDE.md | 11 +++++++++++ 3 files changed, 25 insertions(+) diff --git a/.claude/rules/gotchas.md b/.claude/rules/gotchas.md index 006d824e..2107238b 100644 --- a/.claude/rules/gotchas.md +++ b/.claude/rules/gotchas.md @@ -112,6 +112,12 @@ func withContext(operation: @Sendable () async -> T) async -> T func withContext(operation: () async -> T) async -> T ``` +### #if Inside Array Literals (Swift Limitation) + +`#if` does NOT work inside array literals in Swift — not just `Package.swift`, but also +`CommandConfiguration(subcommands: [...])` and any other `[T]` literal context. +Use `var` + `.append()` pattern or computed property returning the array. + ## SwiftLint Rules - Use `Data("string".utf8)` not `"string".data(using: .utf8)!` diff --git a/.claude/rules/linux-compat.md b/.claude/rules/linux-compat.md index 1d8eb8ab..d0385fd0 100644 --- a/.claude/rules/linux-compat.md +++ b/.claude/rules/linux-compat.md @@ -91,6 +91,14 @@ Use `#if canImport()` instead of `#if os(Linux)` — covers both Linux and Windo #endif ``` +### MCP SDK Exclusion + +MCP swift-sdk depends on swift-nio (no Windows support). On Windows: +- Dependency excluded via `#if !os(Windows)` in Package.swift (same pattern as XcodeProj) +- All `Sources/ExFigCLI/MCP/*.swift` and `Subcommands/MCPServe.swift` wrapped in `#if canImport(MCP)` +- `ExFigCommand.allSubcommands` computed var (not array literal) for conditional subcommand registration +- `MCPToolHandlers.swift` excluded in `.swiftlint.yml` (file_length with `#if` wrapper) + ### SPM Artifactbundle on Windows SPM on Windows has library naming differences: diff --git a/CLAUDE.md b/CLAUDE.md index 6b07d6ce..0d0e0464 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -305,6 +305,17 @@ Both `InitWizard` and `FetchWizard` ask "Figma or Penpot?" first (`WizardDesignS Follow the existing pattern in `NooraUI.swift`: static method delegating to `shared` instance with matching parameter names. Noora's `multipleChoicePrompt` uses `MultipleChoiceLimit` — `.unlimited` or `.limited(count:errorMessage:)`. +### MCP SDK Windows Exclusion + +MCP `swift-sdk` depends on `swift-nio` which doesn't compile on Windows. All MCP files are wrapped +in `#if canImport(MCP)` and the dependency is conditionally included via `#if !os(Windows)` in Package.swift. +`ExFigCommand.allSubcommands` computed property (not array literal) handles conditional `MCPServe` registration. + +### Dependency Version Coupling (swift-resvg ↔ swift-svgkit) + +`swift-svgkit` uses `exact:` pin on `swift-resvg`. When bumping resvg version (e.g., for Windows artifactbundle), +must first update and tag swift-svgkit with the new resvg version, then update ExFig's Package.swift. + ### Adding a Figma API Endpoint FigmaAPI is now an external package (`swift-figma-api`). See its repository for endpoint patterns. From 97fe6e0769e4a870b6054b7c55fc7491877311ce Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Wed, 25 Mar 2026 17:03:19 +0500 Subject: [PATCH 10/17] docs: add Windows platform support to README and DocC articles README, GettingStarted, Development, ExFig overview, and CI/CD guide now mention Windows (Swift 6.3+) alongside macOS and Linux, including platform limitations (no MCP server, no XcodeProj). --- README.md | 2 +- .../ExFigCLI/ExFig.docc/CICDIntegration.md | 20 +++++++++++++++++++ Sources/ExFigCLI/ExFig.docc/Development.md | 6 +++--- Sources/ExFigCLI/ExFig.docc/ExFig.md | 3 +++ Sources/ExFigCLI/ExFig.docc/GettingStarted.md | 2 +- llms-full.txt | 4 ++-- 6 files changed, 30 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 9895bca5..3b49b0b2 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ ![Coverage](https://img.shields.io/badge/coverage-43.65%25-yellow) [![License](https://img.shields.io/github/license/DesignPipe/exfig.svg)](LICENSE) -Export colors, typography, icons, and images from Figma and Penpot to Xcode, Android Studio, Flutter, and Web projects — automatically. +Export colors, typography, icons, and images from Figma and Penpot to Xcode, Android Studio, Flutter, and Web projects — automatically. Runs on macOS, Linux, and Windows. ## The Problem diff --git a/Sources/ExFigCLI/ExFig.docc/CICDIntegration.md b/Sources/ExFigCLI/ExFig.docc/CICDIntegration.md index d96da5f1..48f8dd9d 100644 --- a/Sources/ExFigCLI/ExFig.docc/CICDIntegration.md +++ b/Sources/ExFigCLI/ExFig.docc/CICDIntegration.md @@ -84,6 +84,26 @@ cache file and skips the export if nothing changed since the last run. key: exfig-versions-${{ github.ref }} ``` +## Cross-Platform CI + +ExFig CI runs on macOS, Linux (Ubuntu 22.04), and Windows. On Windows: + +- Requires Swift 6.3+ (use `compnerd/gha-setup-swift` action) +- MCP server is excluded (swift-nio incompatible) +- XcodeProj integration is excluded +- SPM cache is supported via standard `actions/cache` + +```yaml +# Windows CI example +- uses: compnerd/gha-setup-swift@v0.3.0 + with: + branch: swift-6.3-branch + tag: DEVELOPMENT-SNAPSHOT-2025-03-09-a + +- name: Build + run: swift build +``` + ## See Also - diff --git a/Sources/ExFigCLI/ExFig.docc/Development.md b/Sources/ExFigCLI/ExFig.docc/Development.md index 68ef34fe..3883205d 100644 --- a/Sources/ExFigCLI/ExFig.docc/Development.md +++ b/Sources/ExFigCLI/ExFig.docc/Development.md @@ -10,13 +10,13 @@ Guide for contributing to ExFig development. ## Overview -ExFig is built with Swift Package Manager and supports macOS 13.0+ and Linux (Ubuntu 22.04). This guide covers setting up +ExFig is built with Swift Package Manager and supports macOS 13.0+, Linux (Ubuntu 22.04), and Windows (Swift 6.3+). This guide covers setting up your development environment and contributing to the project. ## Requirements -- macOS 13.0 or later, or Linux (Ubuntu 22.04) -- Xcode 16.0 or later (or Swift 6.2+ toolchain) +- macOS 13.0 or later, Linux (Ubuntu 22.04), or Windows +- Xcode 16.0 or later (macOS), or Swift 6.2+ toolchain (Linux), or Swift 6.3+ (Windows) - [mise](https://mise.jdx.dev/) (optional, for task running) ## Getting Started diff --git a/Sources/ExFigCLI/ExFig.docc/ExFig.md b/Sources/ExFigCLI/ExFig.docc/ExFig.md index 3e84d9ea..58492265 100644 --- a/Sources/ExFigCLI/ExFig.docc/ExFig.md +++ b/Sources/ExFigCLI/ExFig.docc/ExFig.md @@ -32,6 +32,9 @@ in CI/CD for fully automated workflows. - **Flutter** — Dart constants, SVG/PNG assets with scale directories - **Web** — React/TypeScript, CSS variables, TSX icon components +ExFig runs on **macOS 13+**, **Linux** (Ubuntu 22.04), and **Windows** (Swift 6.3+). +On Windows, MCP server and Xcode project integration are not available (swift-nio and XcodeProj dependencies). + ### Key Capabilities **Design Assets** diff --git a/Sources/ExFigCLI/ExFig.docc/GettingStarted.md b/Sources/ExFigCLI/ExFig.docc/GettingStarted.md index 51476a29..e898cd9d 100644 --- a/Sources/ExFigCLI/ExFig.docc/GettingStarted.md +++ b/Sources/ExFigCLI/ExFig.docc/GettingStarted.md @@ -14,7 +14,7 @@ ExFig is a command-line tool that exports design resources from Figma and Penpot ## Requirements -- macOS 13.0 or later (or Linux Ubuntu 22.04) +- macOS 13.0 or later, Linux (Ubuntu 22.04), or Windows (Swift 6.3+) - Figma account with file access, **or** Penpot account - Figma Personal Access Token (for Figma sources) or Penpot Access Token (for Penpot sources) diff --git a/llms-full.txt b/llms-full.txt index 61bdd015..ab30aa20 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -12,7 +12,7 @@ # ExFig -Export colors, typography, icons, and images from Figma and Penpot to Xcode, Android Studio, Flutter, and Web projects — automatically. +Export colors, typography, icons, and images from Figma and Penpot to Xcode, Android Studio, Flutter, and Web projects — automatically. Runs on macOS, Linux, and Windows. ## The Problem @@ -105,7 +105,7 @@ ExFig is a command-line tool that exports design resources from Figma and Penpot ## Requirements -- macOS 13.0 or later (or Linux Ubuntu 22.04) +- macOS 13.0 or later, Linux (Ubuntu 22.04), or Windows (Swift 6.3+) - Figma account with file access, **or** Penpot account - Figma Personal Access Token (for Figma sources) or Penpot Access Token (for Penpot sources) From 7cf5ad189e44a0ce4679c63b2643796e48702bd7 Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Wed, 25 Mar 2026 18:18:21 +0500 Subject: [PATCH 11/17] feat: adopt Swift 6.3, update CI images, and upgrade MCP SDK to 0.12.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bump swift-tools-version to 6.3, manage Swift via swiftly (.swift-version) - CI: macOS Xcode 26.2, Linux swift:6.3-jammy, Windows stable 6.3 release - Upgrade MCP SDK 0.12.0: fix GetPrompt arguments type ([String: String]?), adapt .text enum pattern matching to 3 associated values - Use FigmaAPI::Client module selectors in MCP files (Swift 6.3 feature) - Add DocC code block annotations (nocopy, showLineNumbers) with --enable-experimental-code-block-annotations flag - Upgrade SwiftFormat 0.59.1 → 0.60.1 (module selector support) - Remove Swift from mise.toml (swiftly manages toolchain) - Update docs: CLAUDE.md, linux-compat, Development, CICDIntegration - Regenerate llms-full.txt --- .claude/rules/linux-compat.md | 6 +-- .github/workflows/ci.yml | 15 +++--- .github/workflows/release.yml | 12 ++--- .swift-version | 1 + CLAUDE.md | 20 +++++++- Package.resolved | 15 ++---- Package.swift | 2 +- .../ExFigCLI/Batch/BatchConfigRunner.swift | 2 +- Sources/ExFigCLI/Batch/BatchContext.swift | 6 +-- Sources/ExFigCLI/Batch/BatchResult.swift | 10 ++-- .../Batch/FileVersionPreFetcher.swift | 4 +- .../ExFigCLI/Batch/PreFetchedComponents.swift | 2 +- .../Batch/PreFetchedFileVersions.swift | 2 +- Sources/ExFigCLI/Batch/PreFetchedNodes.swift | 2 +- .../ExFigCLI/Batch/SharedGranularCache.swift | 2 +- .../ExFigCLI/Cache/CheckpointTracker.swift | 2 +- .../ExFigCLI/Cache/GranularCacheManager.swift | 2 +- .../ExFigCLI/Cache/GranularCacheSetup.swift | 2 +- .../ExFigCLI/Cache/ImageTrackingCache.swift | 4 +- .../ExFigCLI/Cache/ImageTrackingManager.swift | 4 +- .../Cache/VersionTrackingHelper.swift | 2 +- .../ExFigCLI/ExFig.docc/CICDIntegration.md | 3 +- Sources/ExFigCLI/ExFig.docc/Configuration.md | 6 +-- Sources/ExFigCLI/ExFig.docc/Development.md | 2 +- Sources/ExFigCLI/ExFig.docc/GettingStarted.md | 6 +-- Sources/ExFigCLI/ExFig.docc/PKLGuide.md | 6 +-- Sources/ExFigCLI/Input/DownloadOptions.swift | 6 +-- .../Colors/ColorsVariablesLoader.swift | 10 ++-- Sources/ExFigCLI/Loaders/IconsLoader.swift | 4 +- Sources/ExFigCLI/Loaders/ImagesLoader.swift | 8 ++-- .../Loaders/NumberVariablesLoader.swift | 2 +- Sources/ExFigCLI/MCP/MCPPrompts.swift | 16 +++---- Sources/ExFigCLI/MCP/MCPServerState.swift | 4 +- Sources/ExFigCLI/MCP/MCPToolHandlers.swift | 46 +++++++++---------- .../Output/DownloadExportHelpers.swift | 2 +- Sources/ExFigCLI/Output/HeicConverter.swift | 2 +- .../ExFigCLI/Output/NativeHeicEncoder.swift | 2 +- .../ExFigCLI/Output/NativePngEncoder.swift | 2 +- .../ExFigCLI/Output/NativeWebpEncoder.swift | 2 +- Sources/ExFigCLI/Output/PngDecoder.swift | 4 +- .../ExFigCLI/Output/SvgToHeicConverter.swift | 4 +- .../ExFigCLI/Output/SvgToPngConverter.swift | 2 +- .../ExFigCLI/Output/SvgToWebpConverter.swift | 4 +- Sources/ExFigCLI/Output/WebpConverter.swift | 2 +- Sources/ExFigCLI/Pipeline/DownloadJob.swift | 4 +- .../Pipeline/SharedDownloadQueue.swift | 2 +- Sources/ExFigCLI/Report/ManifestTracker.swift | 2 +- .../Shared/PlatformExportResult.swift | 2 +- Sources/ExFigCLI/Subcommands/Download.swift | 4 +- .../Subcommands/DownloadTypography.swift | 2 +- .../TerminalUI/BatchProgressView.swift | 6 +-- .../ExFigCLI/TerminalUI/ExFigWarning.swift | 4 +- .../TerminalUI/MultiProgressManager.swift | 4 +- Sources/ExFigCLI/TerminalUI/OutputMode.swift | 2 +- Sources/ExFigCLI/TerminalUI/RetryLogger.swift | 2 +- .../Processor/AssetsValidatorError.swift | 2 +- Tests/ExFigCoreTests/AssetResultTests.swift | 2 +- .../Concurrency/ParallelMapEntriesTests.swift | 1 - Tests/ExFigCoreTests/ErrorGroupTests.swift | 4 +- .../Input/VariablesSourceResolvedTests.swift | 1 - .../ExFigTests/MCP/MCPToolHandlerTests.swift | 7 ++- Tests/ExFigTests/PKL/PKLEvaluatorTests.swift | 2 +- .../Subcommands/FetchWizardTests.swift | 2 - .../Subcommands/InitWizardTests.swift | 3 -- .../Subcommands/PenpotWizardTests.swift | 2 - llms-full.txt | 12 ++--- mise.lock | 2 +- mise.toml | 4 +- 68 files changed, 168 insertions(+), 172 deletions(-) create mode 100644 .swift-version diff --git a/.claude/rules/linux-compat.md b/.claude/rules/linux-compat.md index d0385fd0..674e4e3c 100644 --- a/.claude/rules/linux-compat.md +++ b/.claude/rules/linux-compat.md @@ -50,14 +50,14 @@ func testSomePngOperation() throws { | libpng tests | Full support | Build tests first | Not tested | | Foundation | Full | Some APIs missing/broken | Some APIs missing/broken | | XcodeProj | Full | Full | Not available | -| Swift version| 6.2+ | 6.2+ | 6.3 required | +| Swift version| 6.3+ | 6.3+ | 6.3+ | ## Windows Support ### Swift Version -Windows requires Swift 6.3 (development snapshot) due to `swift-resvg` artifactbundle compatibility. -CI uses `compnerd/gha-setup-swift@v0.3.0` with `swift-6.3-branch`. +Windows requires Swift 6.3 (stable release) due to `swift-resvg` artifactbundle compatibility. +CI uses `compnerd/gha-setup-swift@v0.3.0` with `release: "6.3"`. ### Conditional Dependencies (Package.swift) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 99ff56a5..02b57c37 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,7 +43,7 @@ jobs: runs-on: macos-15 needs: lint env: - DEVELOPER_DIR: "/Applications/Xcode_26.1.1.app/Contents/Developer" + DEVELOPER_DIR: "/Applications/Xcode_26.2.app/Contents/Developer" steps: - uses: actions/checkout@v6 @@ -54,9 +54,9 @@ jobs: uses: actions/cache@v5 with: path: .build - key: ${{ runner.os }}-xcode-26.1.1-spm-${{ hashFiles('Package.swift', 'Package.resolved') }} + key: ${{ runner.os }}-xcode-26.2-spm-${{ hashFiles('Package.swift', 'Package.resolved') }} restore-keys: | - ${{ runner.os }}-xcode-26.1.1-spm- + ${{ runner.os }}-xcode-26.2-spm- - uses: jdx/mise-action@v4 with: @@ -81,7 +81,7 @@ jobs: runs-on: ubuntu-latest needs: lint container: - image: swift:6.2.3-jammy + image: swift:6.3-jammy steps: - name: Install dependencies run: | @@ -97,9 +97,9 @@ jobs: uses: actions/cache@v5 with: path: .build - key: ${{ runner.os }}-swift-6.2.3-spm-${{ hashFiles('Package.swift', 'Package.resolved') }} + key: ${{ runner.os }}-swift-6.3-spm-${{ hashFiles('Package.swift', 'Package.resolved') }} restore-keys: | - ${{ runner.os }}-swift-6.2.3-spm- + ${{ runner.os }}-swift-6.3-spm- - uses: jdx/mise-action@v4 with: @@ -135,8 +135,7 @@ jobs: - name: Install Swift uses: compnerd/gha-setup-swift@v0.3.0 with: - branch: swift-6.3-branch - tag: 6.3-DEVELOPMENT-SNAPSHOT-2026-02-21-a + release: "6.3" - name: Cache SPM dependencies uses: actions/cache@v5 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4cd612a2..7ff7614b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,7 +26,7 @@ jobs: platform: linux-x64 build-path: x86_64-unknown-linux-gnu/release archive-name: exfig-linux-x64 - container: swift:6.2.3-jammy + container: swift:6.3-jammy extra-flags: --static-swift-stdlib -Xlinker -lcurl -Xlinker -lxml2 -Xlinker -lssl -Xlinker -lcrypto -Xlinker -lz - os: windows-latest platform: windows-x64 @@ -53,12 +53,11 @@ jobs: if: matrix.platform == 'windows-x64' uses: compnerd/gha-setup-swift@v0.3.0 with: - branch: swift-6.3-branch - tag: 6.3-DEVELOPMENT-SNAPSHOT-2026-02-21-a + release: "6.3" - - name: Select Xcode 26.1 (macOS) + - name: Select Xcode 26.2 (macOS) if: matrix.platform == 'macos' - run: sudo xcode-select -s /Applications/Xcode_26.1.1.app/Contents/Developer + run: sudo xcode-select -s /Applications/Xcode_26.2.app/Contents/Developer - name: Install system dependencies (Linux) if: matrix.platform == 'linux-x64' @@ -332,7 +331,7 @@ jobs: group: "pages" cancel-in-progress: true env: - DEVELOPER_DIR: "/Applications/Xcode_26.1.1.app/Contents/Developer" + DEVELOPER_DIR: "/Applications/Xcode_26.2.app/Contents/Developer" steps: - uses: actions/checkout@v6 @@ -343,6 +342,7 @@ jobs: --disable-indexing \ --transform-for-static-hosting \ --hosting-base-path exfig \ + --enable-experimental-code-block-annotations \ --output-path docs echo '' > docs/index.html diff --git a/.swift-version b/.swift-version new file mode 100644 index 00000000..798e3899 --- /dev/null +++ b/.swift-version @@ -0,0 +1 @@ +6.3.0 diff --git a/CLAUDE.md b/CLAUDE.md index 0d0e0464..e4257b10 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,14 +86,14 @@ pkl eval --format json # Package URI requires published package | Aspect | Details | | --------------- | -------------------------------------------------------------------------------------------------- | -| Language | Swift 6.2, macOS 13.0+ | +| Language | Swift 6.3, macOS 13.0+ | | Package Manager | Swift Package Manager | | CLI Framework | swift-argument-parser | | Config Format | PKL (Programmable, Scalable, Safe) | | Templates | Jinja2 (swift-jinja) | | Required Env | `FIGMA_PERSONAL_TOKEN` | | Config Files | `exfig.pkl` (PKL configuration) | -| Tooling | mise (`./bin/mise` self-contained, no global install needed) | +| Tooling | mise (`./bin/mise` self-contained), swiftly (Swift toolchain management via `.swift-version`) | | Platforms | macOS 13+ (primary), Linux/Ubuntu 22.04, Windows (Swift 6.3) - see `.claude/rules/linux-compat.md` | ## Architecture @@ -311,6 +311,18 @@ MCP `swift-sdk` depends on `swift-nio` which doesn't compile on Windows. All MCP in `#if canImport(MCP)` and the dependency is conditionally included via `#if !os(Windows)` in Package.swift. `ExFigCommand.allSubcommands` computed property (not array literal) handles conditional `MCPServe` registration. +### MCP SDK Version (0.12.0+) + +MCP SDK 0.12.0 changed Content enum: `.text` case now has `(text:, annotations:, _meta:)`. +Both `.text(_:metadata:)` and `.text(text:metadata:)` factories are deprecated but functional. +`GetPrompt.Parameters.arguments` changed from `[String: Value]?` to `[String: String]?`. + +### Build Environment (Swift 6.3 via swiftly) + +Swift 6.3 is managed by swiftly (`.swift-version` file), not mise. For building and testing: +`export PATH="$HOME/.swiftly/bin:$PATH" && export DEVELOPER_DIR="/Applications/Xcode-26.4.0.app/Contents/Developer"` +swiftly provides Swift 6.3; Xcode provides macOS SDK with XCTest. Both are needed for `swift test`. + ### Dependency Version Coupling (swift-resvg ↔ swift-svgkit) `swift-svgkit` uses `exact:` pin on `swift-resvg`. When bumping resvg version (e.g., for Windows artifactbundle), @@ -437,6 +449,10 @@ NooraUI.formatLink("url", useColors: true) // underlined primary | Deleted variables in output | Filter `VariableValue.deletedButReferenced != true` in variable loaders AND `CodeSyntaxSyncer` | | Jinja trailing `\n` | `{% if false %}...{% endif %}\n` renders `"\n"`, not `""` — strip whitespace-only partial template results | | `Bundle.module` in tests | SPM test targets without declared resources don't have `Bundle.module` — use `Bundle.main` or temp bundle | +| SwiftFormat breaks `::` syntax | SwiftFormat 0.60.1+ required for Swift 6.3 module selectors (`FigmaAPI::Client`) | +| MCP SDK 0.12.0 breaking | `.text` has 3 associated values — pattern match as `.text(text, _, _)`; `GetPrompt.arguments` is `[String: String]?` now | +| Tests need XCTest from Xcode | swiftly's Swift 6.3 lacks XCTest; set `DEVELOPER_DIR` to Xcode app path for `swift test` | +| `swift test` pkl failures | Run via `./bin/mise exec -- swift test` to get pkl 0.31+ in PATH; bare `swift test` uses system pkl | ## Additional Rules diff --git a/Package.resolved b/Package.resolved index fa30a581..bbbb1b1b 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "4775d02a1dbb70d8a2bbd991f0fc6f34825eadf35b37f0af4cb0262bcb4bc491", + "originHash" : "31011d74ecc5927c563e211c66d6bb120f64c77db99e16e9faf7b2c17b959053", "pins" : [ { "identity" : "aexml", @@ -109,15 +109,6 @@ "version" : "1.7.0" } }, - { - "identity" : "swift-async-algorithms", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-async-algorithms.git", - "state" : { - "revision" : "9d349bcc328ac3c31ce40e746b5882742a0d1272", - "version" : "1.1.3" - } - }, { "identity" : "swift-atomics", "kind" : "remoteSourceControl", @@ -222,8 +213,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/modelcontextprotocol/swift-sdk.git", "state" : { - "revision" : "6112a3995a992d159ad0e82c2d62a008ce932666", - "version" : "0.11.0" + "revision" : "6132fd4b5b4217ce4717c4775e4607f5c3120129", + "version" : "0.12.0" } }, { diff --git a/Package.swift b/Package.swift index f75d5b5d..fa4940d2 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 6.2 +// swift-tools-version: 6.3 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription diff --git a/Sources/ExFigCLI/Batch/BatchConfigRunner.swift b/Sources/ExFigCLI/Batch/BatchConfigRunner.swift index b5482772..3419b970 100644 --- a/Sources/ExFigCLI/Batch/BatchConfigRunner.swift +++ b/Sources/ExFigCLI/Batch/BatchConfigRunner.swift @@ -210,7 +210,7 @@ struct SubcommandConfigExporter: ConfigExportPerforming { } } -struct BatchConfigRunner: Sendable { +struct BatchConfigRunner { let rateLimiter: SharedRateLimiter let retryPolicy: RetryPolicy let globalOptions: GlobalOptions diff --git a/Sources/ExFigCLI/Batch/BatchContext.swift b/Sources/ExFigCLI/Batch/BatchContext.swift index 68c129ea..fc51a13b 100644 --- a/Sources/ExFigCLI/Batch/BatchContext.swift +++ b/Sources/ExFigCLI/Batch/BatchContext.swift @@ -8,7 +8,7 @@ import FigmaAPI /// This struct is stored inside `BatchSharedState` and should not be used with TaskLocal directly. /// /// See: https://github.com/swiftlang/swift/issues/75501 -struct BatchContext: Sendable { +struct BatchContext { /// Pre-fetched file metadata for version checking. let versions: PreFetchedFileVersions? @@ -61,7 +61,7 @@ struct BatchContext: Sendable { /// /// This struct contains data specific to a single config being processed. /// It is passed as a parameter instead of using TaskLocal to avoid the Linux crash. -struct ConfigExecutionContext: Sendable { +struct ConfigExecutionContext { /// Callback type for reporting incremental download progress to batch view. /// Parameters: (assetType, current, total) typealias DownloadProgressCallback = @Sendable (AssetType, Int, Int) async -> Void @@ -89,7 +89,7 @@ struct ConfigExecutionContext: Sendable { let stepCompletionCallback: StepCompletionCallback? /// Asset types that can be processed. - enum AssetType: String, Sendable { + enum AssetType: String { case colors case icons case images diff --git a/Sources/ExFigCLI/Batch/BatchResult.swift b/Sources/ExFigCLI/Batch/BatchResult.swift index a1816d7f..4a4f595c 100644 --- a/Sources/ExFigCLI/Batch/BatchResult.swift +++ b/Sources/ExFigCLI/Batch/BatchResult.swift @@ -1,7 +1,7 @@ import Foundation /// Represents a config file to be processed. -struct ConfigFile: Sendable { +struct ConfigFile { /// URL to the config file. let url: URL /// Display name for the config. @@ -14,7 +14,7 @@ struct ConfigFile: Sendable { } /// Statistics from an export operation. -struct ExportStats: Sendable { +struct ExportStats { let colors: Int let icons: Int let images: Int @@ -109,7 +109,7 @@ struct ExportStats: Sendable { } /// Statistics about granular cache effectiveness. -struct GranularCacheStats: Sendable { +struct GranularCacheStats { /// Number of nodes skipped (unchanged). let skipped: Int /// Number of nodes exported (changed or new). @@ -136,7 +136,7 @@ struct GranularCacheStats: Sendable { } /// Result of processing a single config. -enum ConfigResult: Sendable { +enum ConfigResult { case success(config: ConfigFile, stats: ExportStats) case failure(config: ConfigFile, error: any Error) @@ -168,7 +168,7 @@ enum ConfigResult: Sendable { } /// Result of batch processing multiple configs. -struct BatchResult: Sendable { +struct BatchResult { /// Results for each config processed. let results: [ConfigResult] /// Total duration of batch execution. diff --git a/Sources/ExFigCLI/Batch/FileVersionPreFetcher.swift b/Sources/ExFigCLI/Batch/FileVersionPreFetcher.swift index d9f4421b..fadeb28b 100644 --- a/Sources/ExFigCLI/Batch/FileVersionPreFetcher.swift +++ b/Sources/ExFigCLI/Batch/FileVersionPreFetcher.swift @@ -37,7 +37,7 @@ struct PreFetchConfiguration { } /// Result of pre-fetching file versions and components. -struct PreFetchResult: Sendable { +struct PreFetchResult { let versions: PreFetchedFileVersions? let components: PreFetchedComponents? let nodes: PreFetchedNodes? @@ -47,7 +47,7 @@ struct PreFetchResult: Sendable { /// /// Used by batch processing to fetch all unique file versions and components upfront, /// avoiding redundant API calls when multiple configs reference the same files. -struct FileVersionPreFetcher: Sendable { +struct FileVersionPreFetcher { let client: Client let ui: TerminalUI diff --git a/Sources/ExFigCLI/Batch/PreFetchedComponents.swift b/Sources/ExFigCLI/Batch/PreFetchedComponents.swift index 81cbfd03..25a7b5b5 100644 --- a/Sources/ExFigCLI/Batch/PreFetchedComponents.swift +++ b/Sources/ExFigCLI/Batch/PreFetchedComponents.swift @@ -6,7 +6,7 @@ import FigmaAPI /// this storage allows sharing pre-fetched components across all configs, /// avoiding redundant API calls. Each config then filters components locally /// by its `figmaFrameName`. -struct PreFetchedComponents: Sendable { +struct PreFetchedComponents { /// Stored components keyed by fileId. private let components: [String: [Component]] diff --git a/Sources/ExFigCLI/Batch/PreFetchedFileVersions.swift b/Sources/ExFigCLI/Batch/PreFetchedFileVersions.swift index 7c67b32a..68bb4fa2 100644 --- a/Sources/ExFigCLI/Batch/PreFetchedFileVersions.swift +++ b/Sources/ExFigCLI/Batch/PreFetchedFileVersions.swift @@ -5,7 +5,7 @@ import FigmaAPI /// When batch processing multiple configs that reference the same Figma files, /// this storage allows sharing pre-fetched file metadata across all configs, /// avoiding redundant API calls. -struct PreFetchedFileVersions: Sendable { +struct PreFetchedFileVersions { /// Stored file metadata keyed by fileId. private let versions: [String: FileMetadata] diff --git a/Sources/ExFigCLI/Batch/PreFetchedNodes.swift b/Sources/ExFigCLI/Batch/PreFetchedNodes.swift index 77d30ff5..f73135ab 100644 --- a/Sources/ExFigCLI/Batch/PreFetchedNodes.swift +++ b/Sources/ExFigCLI/Batch/PreFetchedNodes.swift @@ -5,7 +5,7 @@ import FigmaAPI /// When batch processing multiple configs that reference the same Figma files, /// this storage allows sharing pre-fetched node documents across all configs, /// avoiding redundant API calls to the Nodes endpoint. -struct PreFetchedNodes: Sendable { +struct PreFetchedNodes { /// Stored nodes keyed by fileId, then by nodeId. private let nodes: [String: [NodeId: Node]] diff --git a/Sources/ExFigCLI/Batch/SharedGranularCache.swift b/Sources/ExFigCLI/Batch/SharedGranularCache.swift index 8ef3e159..9df90972 100644 --- a/Sources/ExFigCLI/Batch/SharedGranularCache.swift +++ b/Sources/ExFigCLI/Batch/SharedGranularCache.swift @@ -19,7 +19,7 @@ import Foundation /// 2. Share via `@TaskLocal` (read-only during execution) /// 3. Workers return computed hashes in `ExportStats` /// 4. Merge all hashes and save once after batch completes -struct SharedGranularCache: Sendable { +struct SharedGranularCache { /// Pre-loaded cache data (read-only during batch execution). let cache: ImageTrackingCache diff --git a/Sources/ExFigCLI/Cache/CheckpointTracker.swift b/Sources/ExFigCLI/Cache/CheckpointTracker.swift index 4dfcc208..4037cc7d 100644 --- a/Sources/ExFigCLI/Cache/CheckpointTracker.swift +++ b/Sources/ExFigCLI/Cache/CheckpointTracker.swift @@ -7,7 +7,7 @@ import Foundation /// enabling resumption after interruptions. actor CheckpointTracker { /// Type of asset being tracked. - enum AssetType: Sendable { + enum AssetType { case icons case images } diff --git a/Sources/ExFigCLI/Cache/GranularCacheManager.swift b/Sources/ExFigCLI/Cache/GranularCacheManager.swift index 4d779c7d..6bd861d5 100644 --- a/Sources/ExFigCLI/Cache/GranularCacheManager.swift +++ b/Sources/ExFigCLI/Cache/GranularCacheManager.swift @@ -3,7 +3,7 @@ import FigmaAPI import Foundation /// Result of filtering components through granular cache. -struct GranularCacheResult: Sendable { +struct GranularCacheResult { /// Components that have changed and need re-export. let changedComponents: [NodeId: Component] diff --git a/Sources/ExFigCLI/Cache/GranularCacheSetup.swift b/Sources/ExFigCLI/Cache/GranularCacheSetup.swift index 4b5069b8..f31bb131 100644 --- a/Sources/ExFigCLI/Cache/GranularCacheSetup.swift +++ b/Sources/ExFigCLI/Cache/GranularCacheSetup.swift @@ -4,7 +4,7 @@ import FigmaAPI import Foundation /// Result of granular cache setup. -struct GranularCacheSetup: Sendable { +struct GranularCacheSetup { /// The granular cache manager, if enabled. let manager: GranularCacheManager? /// Whether granular cache is enabled. diff --git a/Sources/ExFigCLI/Cache/ImageTrackingCache.swift b/Sources/ExFigCLI/Cache/ImageTrackingCache.swift index 05f3a83e..19481600 100644 --- a/Sources/ExFigCLI/Cache/ImageTrackingCache.swift +++ b/Sources/ExFigCLI/Cache/ImageTrackingCache.swift @@ -8,7 +8,7 @@ import Logging /// Schema versions: /// - v1: File-level version tracking only /// - v2: Added per-node hash tracking for granular cache (experimental) -struct ImageTrackingCache: Codable, Sendable { +struct ImageTrackingCache: Codable { /// Current schema version for cache file format. /// v2 adds nodeHashes field to CachedFileInfo. static let currentSchemaVersion = 2 @@ -38,7 +38,7 @@ struct ImageTrackingCache: Codable, Sendable { // MARK: - CachedFileInfo /// Information about a cached Figma file. -struct CachedFileInfo: Codable, Sendable { +struct CachedFileInfo: Codable { /// Figma file version identifier. /// Changes when library is published or version is manually saved. let version: String diff --git a/Sources/ExFigCLI/Cache/ImageTrackingManager.swift b/Sources/ExFigCLI/Cache/ImageTrackingManager.swift index cd8d952c..9f0c49dc 100644 --- a/Sources/ExFigCLI/Cache/ImageTrackingManager.swift +++ b/Sources/ExFigCLI/Cache/ImageTrackingManager.swift @@ -3,7 +3,7 @@ import Foundation import Logging /// Result of checking file versions for changes. -enum VersionCheckResult: Sendable { +enum VersionCheckResult { /// All files have changed or are not in cache, full export needed. case exportNeeded(files: [FileVersionInfo]) @@ -15,7 +15,7 @@ enum VersionCheckResult: Sendable { } /// Information about a file's version status. -struct FileVersionInfo: Sendable { +struct FileVersionInfo { let fileId: String let fileName: String let currentVersion: String diff --git a/Sources/ExFigCLI/Cache/VersionTrackingHelper.swift b/Sources/ExFigCLI/Cache/VersionTrackingHelper.swift index d7400516..300fa254 100644 --- a/Sources/ExFigCLI/Cache/VersionTrackingHelper.swift +++ b/Sources/ExFigCLI/Cache/VersionTrackingHelper.swift @@ -4,7 +4,7 @@ import Foundation import Logging /// Result of version tracking check for export commands. -enum VersionTrackingCheckResult: Sendable { +enum VersionTrackingCheckResult { /// Export should be skipped - no changes detected. case skipExport diff --git a/Sources/ExFigCLI/ExFig.docc/CICDIntegration.md b/Sources/ExFigCLI/ExFig.docc/CICDIntegration.md index 48f8dd9d..2052140d 100644 --- a/Sources/ExFigCLI/ExFig.docc/CICDIntegration.md +++ b/Sources/ExFigCLI/ExFig.docc/CICDIntegration.md @@ -97,8 +97,7 @@ ExFig CI runs on macOS, Linux (Ubuntu 22.04), and Windows. On Windows: # Windows CI example - uses: compnerd/gha-setup-swift@v0.3.0 with: - branch: swift-6.3-branch - tag: DEVELOPMENT-SNAPSHOT-2025-03-09-a + release: "6.3" - name: Build run: swift build diff --git a/Sources/ExFigCLI/ExFig.docc/Configuration.md b/Sources/ExFigCLI/ExFig.docc/Configuration.md index a73ef257..452120df 100644 --- a/Sources/ExFigCLI/ExFig.docc/Configuration.md +++ b/Sources/ExFigCLI/ExFig.docc/Configuration.md @@ -39,7 +39,7 @@ exfig batch exfig.pkl --cache ## Figma Section -```pkl +```pkl showLineNumbers import ".exfig/schemas/Figma.pkl" figma = new Figma.FigmaConfig { @@ -62,7 +62,7 @@ Shared settings across all platforms. ### Colors -```pkl +```pkl showLineNumbers import ".exfig/schemas/Common.pkl" common = new Common.CommonConfig { @@ -87,7 +87,7 @@ common = new Common.CommonConfig { ### Variables Colors -```pkl +```pkl showLineNumbers import ".exfig/schemas/Common.pkl" common = new Common.CommonConfig { diff --git a/Sources/ExFigCLI/ExFig.docc/Development.md b/Sources/ExFigCLI/ExFig.docc/Development.md index 3883205d..97df6b85 100644 --- a/Sources/ExFigCLI/ExFig.docc/Development.md +++ b/Sources/ExFigCLI/ExFig.docc/Development.md @@ -16,7 +16,7 @@ your development environment and contributing to the project. ## Requirements - macOS 13.0 or later, Linux (Ubuntu 22.04), or Windows -- Xcode 16.0 or later (macOS), or Swift 6.2+ toolchain (Linux), or Swift 6.3+ (Windows) +- Xcode 26.2 or later (macOS), or Swift 6.3+ toolchain (Linux/Windows) - [mise](https://mise.jdx.dev/) (optional, for task running) ## Getting Started diff --git a/Sources/ExFigCLI/ExFig.docc/GettingStarted.md b/Sources/ExFigCLI/ExFig.docc/GettingStarted.md index e898cd9d..9c96e860 100644 --- a/Sources/ExFigCLI/ExFig.docc/GettingStarted.md +++ b/Sources/ExFigCLI/ExFig.docc/GettingStarted.md @@ -69,14 +69,14 @@ ExFig requires a Figma Personal Access Token to access the Figma API. Set the `FIGMA_PERSONAL_TOKEN` environment variable: -```bash +```bash nocopy # Add to ~/.zshrc or ~/.bashrc export FIGMA_PERSONAL_TOKEN="your-token-here" ``` Or pass it directly to commands: -```bash +```bash nocopy FIGMA_PERSONAL_TOKEN="your-token" exfig colors ``` @@ -88,7 +88,7 @@ For Penpot sources, set the `PENPOT_ACCESS_TOKEN` environment variable: 2. Create a new token 3. Set it: -```bash +```bash nocopy export PENPOT_ACCESS_TOKEN="your-penpot-token-here" ``` diff --git a/Sources/ExFigCLI/ExFig.docc/PKLGuide.md b/Sources/ExFigCLI/ExFig.docc/PKLGuide.md index 941b31dd..8afb2590 100755 --- a/Sources/ExFigCLI/ExFig.docc/PKLGuide.md +++ b/Sources/ExFigCLI/ExFig.docc/PKLGuide.md @@ -42,7 +42,7 @@ pkl --version Create `exfig.pkl` in your project root: -```pkl +```pkl showLineNumbers amends "package://github.com/DesignPipe/exfig@2.0.0#/ExFig.pkl" import "package://github.com/DesignPipe/exfig@2.0.0#/Common.pkl" @@ -85,7 +85,7 @@ PKL's `amends` keyword enables configuration inheritance. Create a base config t ### Base Configuration (base.pkl) -```pkl +```pkl showLineNumbers amends "package://github.com/DesignPipe/exfig@2.0.0#/ExFig.pkl" import "package://github.com/DesignPipe/exfig@2.0.0#/Common.pkl" @@ -120,7 +120,7 @@ common = new Common.CommonConfig { ### Project Configuration (project-ios.pkl) -```pkl +```pkl showLineNumbers amends "base.pkl" import "package://github.com/DesignPipe/exfig@2.0.0#/iOS.pkl" diff --git a/Sources/ExFigCLI/Input/DownloadOptions.swift b/Sources/ExFigCLI/Input/DownloadOptions.swift index fab6293a..d70aa7fc 100644 --- a/Sources/ExFigCLI/Input/DownloadOptions.swift +++ b/Sources/ExFigCLI/Input/DownloadOptions.swift @@ -3,7 +3,7 @@ import ExFigCore import Foundation /// Image format for download command -enum ImageFormat: String, ExpressibleByArgument, CaseIterable, Sendable { +enum ImageFormat: String, ExpressibleByArgument, CaseIterable { case png case svg case jpg @@ -16,7 +16,7 @@ enum ImageFormat: String, ExpressibleByArgument, CaseIterable, Sendable { } /// WebP encoding type for download command -enum WebpEncoding: String, ExpressibleByArgument, CaseIterable, Sendable { +enum WebpEncoding: String, ExpressibleByArgument, CaseIterable { case lossy case lossless } @@ -46,7 +46,7 @@ extension NameStyle: ExpressibleByArgument { } /// Design source for fetch command. -enum FetchSource: String, ExpressibleByArgument, CaseIterable, Sendable { +enum FetchSource: String, ExpressibleByArgument, CaseIterable { case figma case penpot } diff --git a/Sources/ExFigCLI/Loaders/Colors/ColorsVariablesLoader.swift b/Sources/ExFigCLI/Loaders/Colors/ColorsVariablesLoader.swift index d15bbf9b..ba857b00 100644 --- a/Sources/ExFigCLI/Loaders/Colors/ColorsVariablesLoader.swift +++ b/Sources/ExFigCLI/Loaders/Colors/ColorsVariablesLoader.swift @@ -21,7 +21,7 @@ final class ColorsVariablesLoader: Sendable { /// Mode keys: "light", "dark", "lightHC", "darkHC". typealias ColorAliases = [String: [String: String]] - struct LoadResult: Sendable { + struct LoadResult { let output: ColorsLoaderOutput let warnings: [ExFigWarning] let aliases: ColorAliases @@ -242,28 +242,28 @@ final class ColorsVariablesLoader: Sendable { } private extension ColorsVariablesLoader { - struct ModeIds: Sendable { + struct ModeIds { var lightModeId = String() var darkModeId = String() var lightHCModeId = String() var darkHCModeId = String() } - struct Colors: Sendable { + struct Colors { var lightColors: [Color] = [] var darkColors: [Color] = [] var lightHCColors: [Color] = [] var darkHCColors: [Color] = [] } - struct Values: Sendable { + struct Values { let light: ValuesByMode? let dark: ValuesByMode? let lightHC: ValuesByMode? let darkHC: ValuesByMode? } - struct Variable: Sendable { + struct Variable { let name: String let description: String let valuesByMode: Values diff --git a/Sources/ExFigCLI/Loaders/IconsLoader.swift b/Sources/ExFigCLI/Loaders/IconsLoader.swift index 46d3b8a4..3036f445 100644 --- a/Sources/ExFigCLI/Loaders/IconsLoader.swift +++ b/Sources/ExFigCLI/Loaders/IconsLoader.swift @@ -25,7 +25,7 @@ struct IconsLoaderResultWithHashes { } /// Configuration for loading icons, supporting both single-entry and multi-entry modes. -struct IconsLoaderConfig: Sendable { +struct IconsLoaderConfig { /// Entry-level Figma file ID override (takes priority over platform-level). let entryFileId: String? @@ -337,7 +337,7 @@ final class IconsLoader: ImageLoaderBase, @unchecked Sendable { } /// Result of loading a single file with granular cache. - private struct FileGranularResult: Sendable { + private struct FileGranularResult { let key: String let fileId: String let packs: [ImagePack] diff --git a/Sources/ExFigCLI/Loaders/ImagesLoader.swift b/Sources/ExFigCLI/Loaders/ImagesLoader.swift index 4a8bd921..b52adb2f 100644 --- a/Sources/ExFigCLI/Loaders/ImagesLoader.swift +++ b/Sources/ExFigCLI/Loaders/ImagesLoader.swift @@ -11,20 +11,20 @@ import Foundation import Logging /// Image format for loader configuration. -enum ImagesLoaderFormat: Sendable { +enum ImagesLoaderFormat { case svg case png case webp } /// Source format for fetching from Figma API. -enum ImagesSourceFormat: Sendable { +enum ImagesSourceFormat { case png // Download PNG from Figma API (default) case svg // Download SVG and rasterize locally with resvg } /// Configuration for loading images from a specific Figma frame. -struct ImagesLoaderConfig: Sendable { +struct ImagesLoaderConfig { /// Entry-level Figma file ID override (takes priority over platform-level). let entryFileId: String? @@ -483,7 +483,7 @@ final class ImagesLoader: ImageLoaderBase, @unchecked Sendable { // swiftlint:di } /// Result of loading a single file with granular cache. - private struct FileGranularResult: Sendable { + private struct FileGranularResult { let key: String let fileId: String let packs: [ImagePack] diff --git a/Sources/ExFigCLI/Loaders/NumberVariablesLoader.swift b/Sources/ExFigCLI/Loaders/NumberVariablesLoader.swift index 1cbadc46..bb6c4323 100644 --- a/Sources/ExFigCLI/Loaders/NumberVariablesLoader.swift +++ b/Sources/ExFigCLI/Loaders/NumberVariablesLoader.swift @@ -42,7 +42,7 @@ final class NumberVariablesLoader: Sendable { self.filter = filter } - struct LoadResult: Sendable { + struct LoadResult { let dimensions: [NumberToken] let numbers: [NumberToken] let warnings: [ExFigWarning] diff --git a/Sources/ExFigCLI/MCP/MCPPrompts.swift b/Sources/ExFigCLI/MCP/MCPPrompts.swift index 810947d2..a3fc4c96 100644 --- a/Sources/ExFigCLI/MCP/MCPPrompts.swift +++ b/Sources/ExFigCLI/MCP/MCPPrompts.swift @@ -37,7 +37,7 @@ // MARK: - Get Prompt - static func get(name: String, arguments: [String: Value]?) throws -> GetPrompt.Result { + static func get(name: String, arguments: [String: String]?) throws -> GetPrompt.Result { switch name { case "setup-config": return try getSetupConfig(arguments: arguments) @@ -50,13 +50,13 @@ // MARK: - Setup Config - private static func getSetupConfig(arguments: [String: Value]?) throws -> GetPrompt.Result { - guard let platform = arguments?["platform"]?.stringValue else { + private static func getSetupConfig(arguments: [String: String]?) throws -> GetPrompt.Result { + guard let platform = arguments?["platform"] else { throw MCPError.invalidParams("Missing required argument: platform") } - let source = arguments?["source"]?.stringValue ?? "figma" - let projectPath = arguments?["project_path"]?.stringValue ?? "." + let source = arguments?["source"] ?? "figma" + let projectPath = arguments?["project_path"] ?? "." let validPlatforms = ["ios", "android", "flutter", "web"] guard validPlatforms.contains(platform) else { @@ -148,12 +148,12 @@ // MARK: - Troubleshoot - private static func getTroubleshoot(arguments: [String: Value]?) throws -> GetPrompt.Result { - guard let errorMessage = arguments?["error_message"]?.stringValue else { + private static func getTroubleshoot(arguments: [String: String]?) throws -> GetPrompt.Result { + guard let errorMessage = arguments?["error_message"] else { throw MCPError.invalidParams("Missing required argument: error_message") } - let configPath = arguments?["config_path"]?.stringValue ?? "exfig.pkl" + let configPath = arguments?["config_path"] ?? "exfig.pkl" let text = """ I'm getting this error when running ExFig export: diff --git a/Sources/ExFigCLI/MCP/MCPServerState.swift b/Sources/ExFigCLI/MCP/MCPServerState.swift index 8725a6b4..c79ff79d 100644 --- a/Sources/ExFigCLI/MCP/MCPServerState.swift +++ b/Sources/ExFigCLI/MCP/MCPServerState.swift @@ -5,12 +5,12 @@ /// Shared state for MCP server — lazy FigmaClient, rate limiting across calls. actor MCPServerState { - private var cachedClient: FigmaAPI.Client? + private var cachedClient: FigmaAPI::Client? private var rateLimiter: SharedRateLimiter? /// Returns a configured Figma API client, creating one lazily if needed. /// Reuses the same client and rate limiter across all MCP tool calls. - func getClient() throws -> FigmaAPI.Client { + func getClient() throws -> FigmaAPI::Client { if let client = cachedClient { return client } diff --git a/Sources/ExFigCLI/MCP/MCPToolHandlers.swift b/Sources/ExFigCLI/MCP/MCPToolHandlers.swift index cda68bae..505c7bbe 100644 --- a/Sources/ExFigCLI/MCP/MCPToolHandlers.swift +++ b/Sources/ExFigCLI/MCP/MCPToolHandlers.swift @@ -184,7 +184,7 @@ private static func inspectColors( config: PKLConfig, - client: FigmaAPI.Client + client: FigmaAPI::Client ) async throws -> ColorsInspectResult { let fileId = try requireFileId(config: config) let styles = try await client.request(StylesEndpoint(fileId: fileId)) @@ -210,7 +210,7 @@ private static func inspectIcons( config: PKLConfig, - client: FigmaAPI.Client + client: FigmaAPI::Client ) async throws -> ComponentsInspectResult { let fileId = try requireFileId(config: config) let components = try await client.request(ComponentsEndpoint(fileId: fileId)) @@ -225,7 +225,7 @@ private static func inspectImages( config: PKLConfig, - client: FigmaAPI.Client + client: FigmaAPI::Client ) async throws -> FileInspectResult { let fileId = try requireFileId(config: config) let metadata = try await client.request(FileMetadataEndpoint(fileId: fileId)) @@ -240,7 +240,7 @@ private static func inspectTypography( config: PKLConfig, - client: FigmaAPI.Client + client: FigmaAPI::Client ) async throws -> TypographyInspectResult { let fileId = try requireFileId(config: config) let styles = try await client.request(StylesEndpoint(fileId: fileId)) @@ -364,7 +364,7 @@ // Validate cheap parameters before expensive PKL eval / API client creation let format = params.arguments?["format"]?.stringValue ?? "w3c" - let validFormats: Set = ["w3c", "raw"] + let validFormats: Set = ["w3c", "raw"] guard validFormats.contains(format) else { throw ExFigError.custom( errorString: "Invalid format: \(format). Must be one of: w3c, raw" @@ -485,7 +485,7 @@ extension MCPToolHandlers { private static func downloadColors( - config: PKLConfig, client: FigmaAPI.Client, + config: PKLConfig, client: FigmaAPI::Client, format: String, filter: String? ) async throws -> CallTool.Result { let result: ColorsVariablesLoader.LoadResult @@ -545,7 +545,7 @@ } private static func downloadTypography( - config: PKLConfig, client: FigmaAPI.Client, format: String + config: PKLConfig, client: FigmaAPI::Client, format: String ) async throws -> CallTool.Result { guard let figmaParams = config.figma else { throw ExFigError.custom(errorString: "No figma section configured. Check config.") @@ -569,7 +569,7 @@ } private static func downloadUnifiedTokens( - config: PKLConfig, client: FigmaAPI.Client + config: PKLConfig, client: FigmaAPI::Client ) async throws -> CallTool.Result { let exporter = W3CTokensExporter(version: .v2025) var allTokens: [String: Any] = [:] @@ -626,7 +626,7 @@ } private static func downloadAndMergeColors( - client: FigmaAPI.Client, + client: FigmaAPI::Client, variableParams: PKLConfig.Common.VariablesColors, exporter: W3CTokensExporter ) async throws -> (tokens: [String: Any], count: Int, warnings: [String]) { @@ -646,7 +646,7 @@ } private static func downloadAndMergeTypography( - client: FigmaAPI.Client, + client: FigmaAPI::Client, figmaParams: PKLConfig.Figma, exporter: W3CTokensExporter ) async throws -> (tokens: [String: Any], count: Int) { @@ -657,7 +657,7 @@ } private static func downloadAndMergeNumbers( - client: FigmaAPI.Client, + client: FigmaAPI::Client, variableParams: PKLConfig.Common.VariablesColors, exporter: W3CTokensExporter ) async throws -> (tokens: [[String: Any]], count: Int, warnings: [String]) { @@ -716,7 +716,7 @@ // MARK: - Response Types - private struct ValidateSummary: Codable, Sendable { + private struct ValidateSummary: Codable { let configPath: String let valid: Bool var platforms: [String: EntrySummary]? @@ -730,7 +730,7 @@ } } - private struct EntrySummary: Codable, Sendable { + private struct EntrySummary: Codable { var colorsEntries: Int? var iconsEntries: Int? var imagesEntries: Int? @@ -744,7 +744,7 @@ } } - private struct TokensInfoResult: Codable, Sendable { + private struct TokensInfoResult: Codable { let filePath: String let totalTokens: Int let aliasCount: Int @@ -762,7 +762,7 @@ } } - private struct InspectResult: Codable, Sendable { + private struct InspectResult: Codable { let configPath: String var colors: ColorsInspectResult? var icons: ComponentsInspectResult? @@ -789,7 +789,7 @@ } } - private struct ColorsInspectResult: Codable, Sendable { + private struct ColorsInspectResult: Codable { let fileId: String let stylesCount: Int let colorStylesCount: Int @@ -807,7 +807,7 @@ } } - private struct ComponentsInspectResult: Codable, Sendable { + private struct ComponentsInspectResult: Codable { let fileId: String let componentsCount: Int var sampleNames: [String]? @@ -821,7 +821,7 @@ } } - private struct FileInspectResult: Codable, Sendable { + private struct FileInspectResult: Codable { let fileId: String let fileName: String let lastModified: String @@ -835,7 +835,7 @@ } } - private struct TypographyInspectResult: Codable, Sendable { + private struct TypographyInspectResult: Codable { let fileId: String let textStylesCount: Int var sampleNames: [String]? @@ -849,7 +849,7 @@ } } - private struct DownloadMeta: Codable, Sendable { + private struct DownloadMeta: Codable { let resourceType: String let format: String let tokenCount: Int @@ -863,14 +863,14 @@ } } - private struct RawColorsOutput: Codable, Sendable { + private struct RawColorsOutput: Codable { let light: [RawColor] let dark: [RawColor]? let lightHC: [RawColor]? let darkHC: [RawColor]? } - private struct RawColor: Codable, Sendable { + private struct RawColor: Codable { let name: String let red: Double let green: Double @@ -878,7 +878,7 @@ let alpha: Double } - private struct RawTextStyle: Codable, Sendable { + private struct RawTextStyle: Codable { let name: String let fontName: String let fontSize: Double diff --git a/Sources/ExFigCLI/Output/DownloadExportHelpers.swift b/Sources/ExFigCLI/Output/DownloadExportHelpers.swift index be0636f4..719ce76a 100644 --- a/Sources/ExFigCLI/Output/DownloadExportHelpers.swift +++ b/Sources/ExFigCLI/Output/DownloadExportHelpers.swift @@ -5,7 +5,7 @@ import Foundation // MARK: - Raw Asset Entry (unified for icons and images) /// Unified structure for raw asset export (icons, images). -struct RawAssetEntry: Encodable, Sendable { +struct RawAssetEntry: Encodable { let name: String let nodeId: String let description: String? diff --git a/Sources/ExFigCLI/Output/HeicConverter.swift b/Sources/ExFigCLI/Output/HeicConverter.swift index 97bba41a..ff3d2f3d 100644 --- a/Sources/ExFigCLI/Output/HeicConverter.swift +++ b/Sources/ExFigCLI/Output/HeicConverter.swift @@ -43,7 +43,7 @@ enum HeicConverterError: LocalizedError, Equatable { /// **macOS only** - use `isAvailable()` to check platform support. final class HeicConverter: Sendable { /// HEIC encoding mode - enum Encoding: Sendable { + enum Encoding { case lossy(quality: Int) case lossless } diff --git a/Sources/ExFigCLI/Output/NativeHeicEncoder.swift b/Sources/ExFigCLI/Output/NativeHeicEncoder.swift index 85496974..ae4ff69b 100644 --- a/Sources/ExFigCLI/Output/NativeHeicEncoder.swift +++ b/Sources/ExFigCLI/Output/NativeHeicEncoder.swift @@ -62,7 +62,7 @@ enum NativeHeicEncoderError: LocalizedError, Equatable { /// HEIC provides ~40-50% smaller file sizes than PNG while maintaining transparency. /// /// **macOS only** - on Linux, use `isAvailable()` to check and fall back to PNG. -struct NativeHeicEncoder: Sendable { +struct NativeHeicEncoder { /// HEIC encoding quality (0-100) /// Only used for lossy encoding. Higher values = better quality, larger files. let quality: Int diff --git a/Sources/ExFigCLI/Output/NativePngEncoder.swift b/Sources/ExFigCLI/Output/NativePngEncoder.swift index e19de3d6..6bf42363 100644 --- a/Sources/ExFigCLI/Output/NativePngEncoder.swift +++ b/Sources/ExFigCLI/Output/NativePngEncoder.swift @@ -42,7 +42,7 @@ enum NativePngEncoderError: LocalizedError, Equatable { /// Uses platform-native APIs for reliable cross-platform PNG encoding: /// - macOS/iOS: CoreGraphics/ImageIO /// - Linux: libpng -struct NativePngEncoder: Sendable { +struct NativePngEncoder { /// Encodes RGBA pixel data to PNG /// - Parameters: /// - rgba: RGBA pixel data (4 bytes per pixel) diff --git a/Sources/ExFigCLI/Output/NativeWebpEncoder.swift b/Sources/ExFigCLI/Output/NativeWebpEncoder.swift index ec384759..11c0b61f 100644 --- a/Sources/ExFigCLI/Output/NativeWebpEncoder.swift +++ b/Sources/ExFigCLI/Output/NativeWebpEncoder.swift @@ -35,7 +35,7 @@ enum NativeWebpEncoderError: LocalizedError, Equatable { /// /// Uses the `the-swift-collective/libwebp` Swift package which provides /// bindings to libwebp 1.4.x for WebP image encoding. -struct NativeWebpEncoder: Sendable { +struct NativeWebpEncoder { /// WebP encoding quality (0-100) /// Only used for lossy encoding. Higher values = better quality, larger files. let quality: Int diff --git a/Sources/ExFigCLI/Output/PngDecoder.swift b/Sources/ExFigCLI/Output/PngDecoder.swift index 65e8268a..bdc2d0c1 100644 --- a/Sources/ExFigCLI/Output/PngDecoder.swift +++ b/Sources/ExFigCLI/Output/PngDecoder.swift @@ -42,7 +42,7 @@ enum PngDecoderError: LocalizedError, Equatable { } /// Result of PNG decoding containing RGBA pixel data -struct DecodedPng: Sendable { +struct DecodedPng { let width: Int let height: Int let rgba: [UInt8] @@ -58,7 +58,7 @@ struct DecodedPng: Sendable { /// Uses platform-native APIs for reliable cross-platform PNG decoding: /// - macOS/iOS: CoreGraphics/ImageIO /// - Linux: libpng (via libwebp transitive dependency) -struct PngDecoder: Sendable { +struct PngDecoder { /// Decodes a PNG file to RGBA pixel data /// - Parameter url: Path to PNG file /// - Returns: Decoded PNG with width, height, and RGBA bytes diff --git a/Sources/ExFigCLI/Output/SvgToHeicConverter.swift b/Sources/ExFigCLI/Output/SvgToHeicConverter.swift index 1c14abf6..38ebfb0a 100644 --- a/Sources/ExFigCLI/Output/SvgToHeicConverter.swift +++ b/Sources/ExFigCLI/Output/SvgToHeicConverter.swift @@ -37,9 +37,9 @@ enum SvgToHeicConverterError: LocalizedError, Equatable { /// ~40-50% smaller file sizes. /// /// **macOS only** - use `isAvailable()` to check platform support. -struct SvgToHeicConverter: Sendable { +struct SvgToHeicConverter { /// HEIC encoding mode - enum Encoding: Sendable { + enum Encoding { case lossy(quality: Int) case lossless } diff --git a/Sources/ExFigCLI/Output/SvgToPngConverter.swift b/Sources/ExFigCLI/Output/SvgToPngConverter.swift index 0ca998e2..2535232c 100644 --- a/Sources/ExFigCLI/Output/SvgToPngConverter.swift +++ b/Sources/ExFigCLI/Output/SvgToPngConverter.swift @@ -29,7 +29,7 @@ enum SvgToPngConverterError: LocalizedError, Equatable { /// /// Rasterizes SVG images using resvg and encodes to PNG format. /// Produces higher quality results than Figma's server-side PNG rendering. -struct SvgToPngConverter: Sendable { +struct SvgToPngConverter { private let rasterizer: SvgRasterizer /// Creates an SVG to PNG converter diff --git a/Sources/ExFigCLI/Output/SvgToWebpConverter.swift b/Sources/ExFigCLI/Output/SvgToWebpConverter.swift index b764608d..e9428f91 100644 --- a/Sources/ExFigCLI/Output/SvgToWebpConverter.swift +++ b/Sources/ExFigCLI/Output/SvgToWebpConverter.swift @@ -29,9 +29,9 @@ enum SvgToWebpConverterError: LocalizedError, Equatable { /// /// Rasterizes SVG images using resvg and encodes to WebP format using libwebp. /// Produces higher quality results than Figma's server-side PNG rendering. -struct SvgToWebpConverter: Sendable { +struct SvgToWebpConverter { /// WebP encoding mode - enum Encoding: Sendable { + enum Encoding { case lossy(quality: Int) case lossless } diff --git a/Sources/ExFigCLI/Output/WebpConverter.swift b/Sources/ExFigCLI/Output/WebpConverter.swift index d56da324..b557ecd7 100644 --- a/Sources/ExFigCLI/Output/WebpConverter.swift +++ b/Sources/ExFigCLI/Output/WebpConverter.swift @@ -39,7 +39,7 @@ enum WebpConverterError: LocalizedError, Equatable { /// No external binaries (like cwebp) are required. final class WebpConverter: Sendable { /// WebP encoding mode - enum Encoding: Sendable { + enum Encoding { case lossy(quality: Int) case lossless } diff --git a/Sources/ExFigCLI/Pipeline/DownloadJob.swift b/Sources/ExFigCLI/Pipeline/DownloadJob.swift index a5e91ade..3684dc33 100644 --- a/Sources/ExFigCLI/Pipeline/DownloadJob.swift +++ b/Sources/ExFigCLI/Pipeline/DownloadJob.swift @@ -3,7 +3,7 @@ import Foundation /// Represents a batch of files to download for a specific config. /// Used by SharedDownloadQueue to coordinate downloads across multiple configs. -struct DownloadJob: Sendable { +struct DownloadJob { /// Unique identifier for this job let id: UUID @@ -35,7 +35,7 @@ struct DownloadJob: Sendable { } /// Result of a completed download job -struct DownloadJobResult: Sendable { +struct DownloadJobResult { let jobId: UUID let configId: String let downloadedFiles: [FileContents] diff --git a/Sources/ExFigCLI/Pipeline/SharedDownloadQueue.swift b/Sources/ExFigCLI/Pipeline/SharedDownloadQueue.swift index 94495b30..eca61bf2 100644 --- a/Sources/ExFigCLI/Pipeline/SharedDownloadQueue.swift +++ b/Sources/ExFigCLI/Pipeline/SharedDownloadQueue.swift @@ -270,7 +270,7 @@ actor SharedDownloadQueue { // MARK: - Statistics -struct QueueStats: Sendable { +struct QueueStats { let pendingJobs: Int let activeJobs: Int let activeDownloads: Int diff --git a/Sources/ExFigCLI/Report/ManifestTracker.swift b/Sources/ExFigCLI/Report/ManifestTracker.swift index d7290004..6a14df97 100644 --- a/Sources/ExFigCLI/Report/ManifestTracker.swift +++ b/Sources/ExFigCLI/Report/ManifestTracker.swift @@ -28,7 +28,7 @@ final class ManifestTracker: Sendable { } /// Pre-write filesystem state for a file path. - struct PreWriteState: Sendable { + struct PreWriteState { let fileExisted: Bool let existingChecksum: String? } diff --git a/Sources/ExFigCLI/Shared/PlatformExportResult.swift b/Sources/ExFigCLI/Shared/PlatformExportResult.swift index b747d9b4..97fdbeba 100644 --- a/Sources/ExFigCLI/Shared/PlatformExportResult.swift +++ b/Sources/ExFigCLI/Shared/PlatformExportResult.swift @@ -3,7 +3,7 @@ import Foundation /// Result of a platform export operation with granular cache hashes. /// Used by ExportIcons and ExportImages for consistent result aggregation. -struct PlatformExportResult: Sendable { +struct PlatformExportResult { /// Number of assets exported. let count: Int /// Per-file node hashes computed during export. diff --git a/Sources/ExFigCLI/Subcommands/Download.swift b/Sources/ExFigCLI/Subcommands/Download.swift index 02b2fa35..7403104d 100644 --- a/Sources/ExFigCLI/Subcommands/Download.swift +++ b/Sources/ExFigCLI/Subcommands/Download.swift @@ -289,14 +289,14 @@ extension ExFigCommand.Download { // MARK: - Raw Colors Data Structure /// Simple structure for raw color export when using styles (not variables). -struct RawColorsData: Encodable, Sendable { +struct RawColorsData: Encodable { let light: [RawColorEntry] let dark: [RawColorEntry]? let lightHC: [RawColorEntry]? let darkHC: [RawColorEntry]? } -struct RawColorEntry: Encodable, Sendable { +struct RawColorEntry: Encodable { let name: String // swiftlint:disable:next identifier_name let r, g, b, a: Double diff --git a/Sources/ExFigCLI/Subcommands/DownloadTypography.swift b/Sources/ExFigCLI/Subcommands/DownloadTypography.swift index e5646ffd..01419b92 100644 --- a/Sources/ExFigCLI/Subcommands/DownloadTypography.swift +++ b/Sources/ExFigCLI/Subcommands/DownloadTypography.swift @@ -86,7 +86,7 @@ extension ExFigCommand.Download { // MARK: - Raw Typography Data Structure -struct RawTextStyleEntry: Encodable, Sendable { +struct RawTextStyleEntry: Encodable { let name: String let fontName: String let fontSize: Double diff --git a/Sources/ExFigCLI/TerminalUI/BatchProgressView.swift b/Sources/ExFigCLI/TerminalUI/BatchProgressView.swift index 2e0e4582..5eec9ddd 100644 --- a/Sources/ExFigCLI/TerminalUI/BatchProgressView.swift +++ b/Sources/ExFigCLI/TerminalUI/BatchProgressView.swift @@ -17,21 +17,21 @@ actor BatchProgressView { // MARK: - Config State /// State of a config being processed. - struct ConfigState: Sendable { + struct ConfigState { let name: String var status: Status var exportProgress: ExportProgress var stepProgress: (completed: Int, total: Int)? var startTime: Date? - enum Status: Sendable { + enum Status { case pending case running case succeeded case failed(String) } - struct ExportProgress: Sendable { + struct ExportProgress { var colors: (current: Int, total: Int)? var icons: (current: Int, total: Int)? var images: (current: Int, total: Int)? diff --git a/Sources/ExFigCLI/TerminalUI/ExFigWarning.swift b/Sources/ExFigCLI/TerminalUI/ExFigWarning.swift index 274bcb5f..02195672 100644 --- a/Sources/ExFigCLI/TerminalUI/ExFigWarning.swift +++ b/Sources/ExFigCLI/TerminalUI/ExFigWarning.swift @@ -1,12 +1,12 @@ /// Info about a theme attribute name collision for warning display. -struct ThemeAttributeCollisionInfo: Sendable, Equatable { +struct ThemeAttributeCollisionInfo: Equatable { let attr: String let kept: String let discarded: String } /// Unified warning types for ExFig CLI output with TOON formatting support. -enum ExFigWarning: Sendable, Equatable { +enum ExFigWarning: Equatable { // MARK: - Configuration Warnings /// Platform/asset type configuration is missing from config file. diff --git a/Sources/ExFigCLI/TerminalUI/MultiProgressManager.swift b/Sources/ExFigCLI/TerminalUI/MultiProgressManager.swift index 9740bd46..583bade8 100644 --- a/Sources/ExFigCLI/TerminalUI/MultiProgressManager.swift +++ b/Sources/ExFigCLI/TerminalUI/MultiProgressManager.swift @@ -4,7 +4,7 @@ import Noora /// Manages multiple concurrent progress indicators actor MultiProgressManager { /// State of a single progress item - struct ProgressState: Sendable { + struct ProgressState { let id: UUID var label: String var current: Int @@ -12,7 +12,7 @@ actor MultiProgressManager { var status: Status let startTime: Date - enum Status: Sendable { + enum Status { case running case succeeded case failed diff --git a/Sources/ExFigCLI/TerminalUI/OutputMode.swift b/Sources/ExFigCLI/TerminalUI/OutputMode.swift index 9dc3b125..98312990 100644 --- a/Sources/ExFigCLI/TerminalUI/OutputMode.swift +++ b/Sources/ExFigCLI/TerminalUI/OutputMode.swift @@ -1,7 +1,7 @@ import Foundation /// Output mode for CLI commands -enum OutputMode: Sendable { +enum OutputMode { /// Default mode with spinners, progress bars, and colors case normal /// Detailed debug output including timing and API calls diff --git a/Sources/ExFigCLI/TerminalUI/RetryLogger.swift b/Sources/ExFigCLI/TerminalUI/RetryLogger.swift index 7b132da8..3b25a997 100644 --- a/Sources/ExFigCLI/TerminalUI/RetryLogger.swift +++ b/Sources/ExFigCLI/TerminalUI/RetryLogger.swift @@ -3,7 +3,7 @@ import Foundation import Noora /// Context for formatting retry messages. -struct RetryMessageContext: Sendable { +struct RetryMessageContext { let configName: String let attempt: Int let maxAttempts: Int diff --git a/Sources/ExFigCore/Processor/AssetsValidatorError.swift b/Sources/ExFigCore/Processor/AssetsValidatorError.swift index ccb56d36..4cff2a53 100644 --- a/Sources/ExFigCore/Processor/AssetsValidatorError.swift +++ b/Sources/ExFigCore/Processor/AssetsValidatorError.swift @@ -1,6 +1,6 @@ import Foundation -enum AssetsValidatorError: LocalizedError, Sendable { +enum AssetsValidatorError: LocalizedError { case badName(name: String) case countMismatch(light: Int, dark: Int) case countMismatchLightHighContrastColors(light: Int, lightHC: Int) diff --git a/Tests/ExFigCoreTests/AssetResultTests.swift b/Tests/ExFigCoreTests/AssetResultTests.swift index 994ab44d..d90f5ccc 100644 --- a/Tests/ExFigCoreTests/AssetResultTests.swift +++ b/Tests/ExFigCoreTests/AssetResultTests.swift @@ -141,6 +141,6 @@ final class AssetResultTests: XCTestCase { // MARK: - Test Helpers -private enum TestError: Error, Sendable { +private enum TestError: Error { case sampleError } diff --git a/Tests/ExFigCoreTests/Concurrency/ParallelMapEntriesTests.swift b/Tests/ExFigCoreTests/Concurrency/ParallelMapEntriesTests.swift index 4c516a3c..e9b83cce 100644 --- a/Tests/ExFigCoreTests/Concurrency/ParallelMapEntriesTests.swift +++ b/Tests/ExFigCoreTests/Concurrency/ParallelMapEntriesTests.swift @@ -2,7 +2,6 @@ import ExFigCore import Foundation import Testing -@Suite("ParallelMapEntries") struct ParallelMapEntriesTests { @Test("Empty input returns empty array") func emptyInput() async throws { diff --git a/Tests/ExFigCoreTests/ErrorGroupTests.swift b/Tests/ExFigCoreTests/ErrorGroupTests.swift index 21d677af..bee61b0b 100644 --- a/Tests/ExFigCoreTests/ErrorGroupTests.swift +++ b/Tests/ExFigCoreTests/ErrorGroupTests.swift @@ -72,13 +72,13 @@ final class ErrorGroupTests: XCTestCase { // MARK: - Test Helpers -private enum TestError: Error, Sendable { +private enum TestError: Error { case first case second case third } -private enum LocalizedTestError: LocalizedError, Sendable { +private enum LocalizedTestError: LocalizedError { case customMessage case anotherMessage diff --git a/Tests/ExFigTests/Input/VariablesSourceResolvedTests.swift b/Tests/ExFigTests/Input/VariablesSourceResolvedTests.swift index d567d99a..8549a883 100644 --- a/Tests/ExFigTests/Input/VariablesSourceResolvedTests.swift +++ b/Tests/ExFigTests/Input/VariablesSourceResolvedTests.swift @@ -2,7 +2,6 @@ import ExFigConfig import ExFigCore import Testing -@Suite("VariablesSource resolvedSourceKind") struct VariablesSourceResolvedSourceKindTests { @Test("Defaults to figma when no overrides") func defaultsFigma() { diff --git a/Tests/ExFigTests/MCP/MCPToolHandlerTests.swift b/Tests/ExFigTests/MCP/MCPToolHandlerTests.swift index 63182c33..ab4741b3 100644 --- a/Tests/ExFigTests/MCP/MCPToolHandlerTests.swift +++ b/Tests/ExFigTests/MCP/MCPToolHandlerTests.swift @@ -4,7 +4,6 @@ import MCP import Testing - @Suite("MCP Tool Handlers") struct MCPToolHandlerTests { // MARK: - Fixtures Path @@ -23,7 +22,7 @@ let params = CallTool.Parameters(name: tool, arguments: arguments) let result = await MCPToolHandlers.handle(params: params, state: MCPServerState()) #expect(result.isError == true) - if case let .text(text) = result.content.first { + if case let .text(text, _, _) = result.content.first { #expect(text.contains(substring)) } } @@ -57,7 +56,7 @@ #expect(result.isError != true) - if case let .text(text) = result.content.first { + if case let .text(text, _, _) = result.content.first { #expect(text.contains("\"valid\"")) #expect(text.contains("config_path")) #expect(text.contains("ios")) @@ -123,7 +122,7 @@ #expect(result.isError != true) - if case let .text(text) = result.content.first { + if case let .text(text, _, _) = result.content.first { #expect(text.contains("total_tokens")) #expect(text.contains("counts_by_type")) #expect(text.contains("top_level_groups")) diff --git a/Tests/ExFigTests/PKL/PKLEvaluatorTests.swift b/Tests/ExFigTests/PKL/PKLEvaluatorTests.swift index 337e1e5a..5e8ef5f7 100644 --- a/Tests/ExFigTests/PKL/PKLEvaluatorTests.swift +++ b/Tests/ExFigTests/PKL/PKLEvaluatorTests.swift @@ -3,7 +3,7 @@ import Foundation import PklSwift import Testing -@Suite("PKLEvaluator Tests", .serialized, .timeLimit(.minutes(2))) +@Suite(.serialized, .timeLimit(.minutes(2))) struct PKLEvaluatorTests { /// Path to test fixtures static let fixturesPath = URL(fileURLWithPath: #filePath) diff --git a/Tests/ExFigTests/Subcommands/FetchWizardTests.swift b/Tests/ExFigTests/Subcommands/FetchWizardTests.swift index 6070ef3b..bd0d90ad 100644 --- a/Tests/ExFigTests/Subcommands/FetchWizardTests.swift +++ b/Tests/ExFigTests/Subcommands/FetchWizardTests.swift @@ -2,7 +2,6 @@ import ExFigCore import Testing -@Suite("FetchWizard") struct FetchWizardTests { // MARK: - WizardPlatform @@ -162,7 +161,6 @@ struct FetchWizardTests { // MARK: - GenerateConfigFile Tests -@Suite("GenerateConfigFile") struct GenerateConfigFileTests { @Test("substitutePackageURI replaces .exfig/schemas/ paths") func substitutePackageURI() { diff --git a/Tests/ExFigTests/Subcommands/InitWizardTests.swift b/Tests/ExFigTests/Subcommands/InitWizardTests.swift index a0659e5d..488feb9c 100644 --- a/Tests/ExFigTests/Subcommands/InitWizardTests.swift +++ b/Tests/ExFigTests/Subcommands/InitWizardTests.swift @@ -2,7 +2,6 @@ import ExFigCore import Testing -@Suite("InitWizard") struct InitWizardTests { // MARK: - WizardPlatform.asPlatform @@ -302,7 +301,6 @@ struct InitWizardTests { // MARK: - Cross-Platform Template Tests -@Suite("InitWizard Cross-Platform") struct InitWizardCrossPlatformTests { @Test("applyResult works with Android template") func androidAllSelected() { @@ -361,7 +359,6 @@ struct InitWizardCrossPlatformTests { // MARK: - Transform Utilities Tests -@Suite("InitWizard Transform Utilities") struct InitWizardTransformUtilityTests { @Test("removeSection returns template unchanged when marker not found") func removeSectionMissingMarker() { diff --git a/Tests/ExFigTests/Subcommands/PenpotWizardTests.swift b/Tests/ExFigTests/Subcommands/PenpotWizardTests.swift index 0cb2b74a..5dce33c7 100644 --- a/Tests/ExFigTests/Subcommands/PenpotWizardTests.swift +++ b/Tests/ExFigTests/Subcommands/PenpotWizardTests.swift @@ -4,7 +4,6 @@ import Testing // MARK: - extractPenpotFileId Tests -@Suite("extractPenpotFileId") struct ExtractPenpotFileIdTests { @Test("Extracts UUID from full workspace URL") func fullWorkspaceURL() { @@ -45,7 +44,6 @@ struct ExtractPenpotFileIdTests { // MARK: - applyPenpotResult Tests -@Suite("InitWizard applyPenpotResult") struct ApplyPenpotResultTests { @Test("Removes Figma import and config section") func removesFigmaSection() { diff --git a/llms-full.txt b/llms-full.txt index ab30aa20..f5330e04 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -160,14 +160,14 @@ ExFig requires a Figma Personal Access Token to access the Figma API. Set the `FIGMA_PERSONAL_TOKEN` environment variable: -```bash +```bash nocopy # Add to ~/.zshrc or ~/.bashrc export FIGMA_PERSONAL_TOKEN="your-token-here" ``` Or pass it directly to commands: -```bash +```bash nocopy FIGMA_PERSONAL_TOKEN="your-token" exfig colors ``` @@ -179,7 +179,7 @@ For Penpot sources, set the `PENPOT_ACCESS_TOKEN` environment variable: 2. Create a new token 3. Set it: -```bash +```bash nocopy export PENPOT_ACCESS_TOKEN="your-penpot-token-here" ``` @@ -592,7 +592,7 @@ exfig batch exfig.pkl --cache ## Figma Section -```pkl +```pkl showLineNumbers import ".exfig/schemas/Figma.pkl" figma = new Figma.FigmaConfig { @@ -615,7 +615,7 @@ Shared settings across all platforms. ### Colors -```pkl +```pkl showLineNumbers import ".exfig/schemas/Common.pkl" common = new Common.CommonConfig { @@ -640,7 +640,7 @@ common = new Common.CommonConfig { ### Variables Colors -```pkl +```pkl showLineNumbers import ".exfig/schemas/Common.pkl" common = new Common.CommonConfig { diff --git a/mise.lock b/mise.lock index 41d4868d..c3e08cf7 100644 --- a/mise.lock +++ b/mise.lock @@ -57,7 +57,7 @@ version = "6.2.3" backend = "core:swift" [[tools.swiftformat]] -version = "0.59.1" +version = "0.60.1" backend = "asdf:swiftformat" [[tools.swiftlint]] diff --git a/mise.toml b/mise.toml index 41a63f22..7d008225 100644 --- a/mise.toml +++ b/mise.toml @@ -34,8 +34,7 @@ git config core.hooksPath .githooks 2>/dev/null || true [tools] # --- Swift Development --- -swift = "6.2.3" # Swift compiler and toolchain -swiftformat = "0.59.1" # Swift code formatting +swiftformat = "0.60.1" # Swift code formatting swiftlint = "0.63.2" # Swift linting xcsift = "1.1.6" # xcodebuild output filtering @@ -135,6 +134,7 @@ swift package --allow-writing-to-directory docs \ --disable-indexing \ --transform-for-static-hosting \ --hosting-base-path exfig \ + --enable-experimental-code-block-annotations \ --output-path docs echo '' > docs/index.html """ From 812051bb16e69774e7e7965652e3031a26e334e6 Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Wed, 25 Mar 2026 18:36:10 +0500 Subject: [PATCH 12/17] fix(ci): install Swift 6.3 via swiftly on macOS, use stable release tag on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit macOS runners ship Xcode with Swift 6.2.3 — install Swift 6.3 toolchain via swiftly for swift-tools-version: 6.3 compatibility. Windows: use branch/tag params (the only valid inputs for compnerd/gha-setup-swift). --- .claude/rules/linux-compat.md | 2 +- .github/workflows/ci.yml | 14 ++++- .github/workflows/release.yml | 20 +++++-- .../ExFigCLI/ExFig.docc/CICDIntegration.md | 3 +- bin/mise | 60 +++++++++++-------- 5 files changed, 63 insertions(+), 36 deletions(-) diff --git a/.claude/rules/linux-compat.md b/.claude/rules/linux-compat.md index 674e4e3c..a0f16f40 100644 --- a/.claude/rules/linux-compat.md +++ b/.claude/rules/linux-compat.md @@ -57,7 +57,7 @@ func testSomePngOperation() throws { ### Swift Version Windows requires Swift 6.3 (stable release) due to `swift-resvg` artifactbundle compatibility. -CI uses `compnerd/gha-setup-swift@v0.3.0` with `release: "6.3"`. +CI uses `compnerd/gha-setup-swift@v0.3.0` with `branch: swift-6.3-release` and `tag: swift-6.3-RELEASE`. ### Conditional Dependencies (Package.swift) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 02b57c37..391201be 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,13 +50,20 @@ jobs: with: lfs: true + - name: Install Swift 6.3 via swiftly + run: | + curl -L https://swiftlang.github.io/swiftly/swiftly-install.sh | bash -s -- -y + export PATH="$HOME/.swiftly/bin:$PATH" + swiftly install 6.3 + echo "$HOME/.swiftly/bin" >> "$GITHUB_PATH" + - name: Cache SPM dependencies uses: actions/cache@v5 with: path: .build - key: ${{ runner.os }}-xcode-26.2-spm-${{ hashFiles('Package.swift', 'Package.resolved') }} + key: ${{ runner.os }}-swift-6.3-spm-${{ hashFiles('Package.swift', 'Package.resolved') }} restore-keys: | - ${{ runner.os }}-xcode-26.2-spm- + ${{ runner.os }}-swift-6.3-spm- - uses: jdx/mise-action@v4 with: @@ -135,7 +142,8 @@ jobs: - name: Install Swift uses: compnerd/gha-setup-swift@v0.3.0 with: - release: "6.3" + branch: swift-6.3-release + tag: swift-6.3-RELEASE - name: Cache SPM dependencies uses: actions/cache@v5 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7ff7614b..0c0ba714 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -53,11 +53,16 @@ jobs: if: matrix.platform == 'windows-x64' uses: compnerd/gha-setup-swift@v0.3.0 with: - release: "6.3" + branch: swift-6.3-release + tag: swift-6.3-RELEASE - - name: Select Xcode 26.2 (macOS) + - name: Install Swift 6.3 via swiftly (macOS) if: matrix.platform == 'macos' - run: sudo xcode-select -s /Applications/Xcode_26.2.app/Contents/Developer + run: | + curl -L https://swiftlang.github.io/swiftly/swiftly-install.sh | bash -s -- -y + export PATH="$HOME/.swiftly/bin:$PATH" + swiftly install 6.3 + echo "$HOME/.swiftly/bin" >> "$GITHUB_PATH" - name: Install system dependencies (Linux) if: matrix.platform == 'linux-x64' @@ -330,11 +335,16 @@ jobs: concurrency: group: "pages" cancel-in-progress: true - env: - DEVELOPER_DIR: "/Applications/Xcode_26.2.app/Contents/Developer" steps: - uses: actions/checkout@v6 + - name: Install Swift 6.3 via swiftly + run: | + curl -L https://swiftlang.github.io/swiftly/swiftly-install.sh | bash -s -- -y + export PATH="$HOME/.swiftly/bin:$PATH" + swiftly install 6.3 + echo "$HOME/.swiftly/bin" >> "$GITHUB_PATH" + - name: Build DocC run: | swift package --allow-writing-to-directory docs \ diff --git a/Sources/ExFigCLI/ExFig.docc/CICDIntegration.md b/Sources/ExFigCLI/ExFig.docc/CICDIntegration.md index 2052140d..6bdc6bea 100644 --- a/Sources/ExFigCLI/ExFig.docc/CICDIntegration.md +++ b/Sources/ExFigCLI/ExFig.docc/CICDIntegration.md @@ -97,7 +97,8 @@ ExFig CI runs on macOS, Linux (Ubuntu 22.04), and Windows. On Windows: # Windows CI example - uses: compnerd/gha-setup-swift@v0.3.0 with: - release: "6.3" + branch: swift-6.3-release + tag: swift-6.3-RELEASE - name: Build run: swift build diff --git a/bin/mise b/bin/mise index eaf4f3a4..e5595a6c 100755 --- a/bin/mise +++ b/bin/mise @@ -3,7 +3,7 @@ set -eu __mise_bootstrap() { local cache_home="${XDG_CACHE_HOME:-$HOME/.cache}/mise" - export MISE_INSTALL_PATH="$cache_home/mise-2026.2.9" + export MISE_INSTALL_PATH="$cache_home/mise-2026.3.15" install() { local initial_working_dir="$PWD" #!/bin/sh @@ -83,18 +83,19 @@ __mise_bootstrap() { echo "tar.gz" elif tar_supports_zstd; then echo "tar.zst" - elif command -v zstd >/dev/null 2>&1; then - echo "tar.zst" else echo "tar.gz" fi } tar_supports_zstd() { - # tar is bsdtar or version is >= 1.31 - if tar --version | grep -q 'bsdtar' && command -v zstd >/dev/null 2>&1; then + if ! command -v zstd >/dev/null 2>&1; then + false + # tar is bsdtar + elif tar --version | grep -q 'bsdtar'; then true - elif tar --version | grep -q '1\.(3[1-9]|[4-9][0-9]'; then + # tar version is >= 1.31 + elif tar --version | grep -q '1\.\(3[1-9]\|[4-9][0-9]\)'; then true else false @@ -117,26 +118,28 @@ __mise_bootstrap() { arch=$3 ext=$4 url="https://github.com/jdx/mise/releases/download/v${version}/SHASUMS256.txt" + current_version="v2026.3.15" + current_version="${current_version#v}" # For current version use static checksum otherwise # use checksum from releases - if [ "$version" = "v2026.2.9" ]; then - checksum_linux_x86_64="b8c8644c065e48cfb003d780aa45920d59329cba179ad3531dcedd345e776513 ./mise-v2026.2.9-linux-x64.tar.gz" - checksum_linux_x86_64_musl="6a20d73f01439c04dd06b711ef06f753278dcfb1146350acc89fe7863af03077 ./mise-v2026.2.9-linux-x64-musl.tar.gz" - checksum_linux_arm64="85e594da72a8c74b053eba4cbb2ffbde8489aff40693d641ba2840aa7fea20a8 ./mise-v2026.2.9-linux-arm64.tar.gz" - checksum_linux_arm64_musl="341e0ff2eb967ba502dc7bdd7457c4a536714a54684b7bdf1946dc4287689ca0 ./mise-v2026.2.9-linux-arm64-musl.tar.gz" - checksum_linux_armv7="f58707e2d7afac4c1eb8dfa64a72c527029b85d891f190023d7ccd594b4be6fd ./mise-v2026.2.9-linux-armv7.tar.gz" - checksum_linux_armv7_musl="45cc73544daccbe542d3db05785160f0cf468950c37149603044e8ea11cc5484 ./mise-v2026.2.9-linux-armv7-musl.tar.gz" - checksum_macos_x86_64="fcc6b91b993ea52e2b064fda54301fd0ffdd4bab102eaf7ccc59ec9b99a13d39 ./mise-v2026.2.9-macos-x64.tar.gz" - checksum_macos_arm64="5bfa886a537e07e50089712d6b0fa46bb1a37b08f218c4878d6a2e715e1cefd1 ./mise-v2026.2.9-macos-arm64.tar.gz" - checksum_linux_x86_64_zstd="6b0937d2df6b00b223c78cc594b612820a120a9835c1ad88633f37cd27709fbe ./mise-v2026.2.9-linux-x64.tar.zst" - checksum_linux_x86_64_musl_zstd="95a3c36f6da43da3f365047b443c56f51e5d3cd838c7714015815b9a2aae829a ./mise-v2026.2.9-linux-x64-musl.tar.zst" - checksum_linux_arm64_zstd="aaf0cb1fbd3744bb9aeb5b13044b486807dafcd982d92739104f45778e3aa5a4 ./mise-v2026.2.9-linux-arm64.tar.zst" - checksum_linux_arm64_musl_zstd="54ed39432bd04e73d792ea38eb6f54dee28cee1689cc1e414757233086d61681 ./mise-v2026.2.9-linux-arm64-musl.tar.zst" - checksum_linux_armv7_zstd="363126bba7077467fbac898bec5c2b940a2a0c77c6eac566a331ed6e6e3ee9f5 ./mise-v2026.2.9-linux-armv7.tar.zst" - checksum_linux_armv7_musl_zstd="911cda389a17cb11c6c2b91c2621005042c28658d68a22344f20560ce29f46c3 ./mise-v2026.2.9-linux-armv7-musl.tar.zst" - checksum_macos_x86_64_zstd="b83e4e01475059bbbf1ea73c59d7638cec9b37bf7ce6618d4dd9b7cf0775f758 ./mise-v2026.2.9-macos-x64.tar.zst" - checksum_macos_arm64_zstd="c359e4f98141baab2ce4fea641e5450b78ba1ac0410e82c84f64519622c86b17 ./mise-v2026.2.9-macos-arm64.tar.zst" + if [ "$version" = "$current_version" ]; then + checksum_linux_x86_64="29b128db8b597103220b645560e544ccc4561f7f82cc0fc6e7ceff9f316e71a5 ./mise-v2026.3.15-linux-x64.tar.gz" + checksum_linux_x86_64_musl="4e70734eeef3e664f1616be83ed0d2ee6114ecd10539ca6abdb1f6d66c29559d ./mise-v2026.3.15-linux-x64-musl.tar.gz" + checksum_linux_arm64="5fe63efe1c57dadd1403e595e4de169e21ad38161f6ab7128461018f6b10eb86 ./mise-v2026.3.15-linux-arm64.tar.gz" + checksum_linux_arm64_musl="25d1f0d880e47f7478d93ee0e8344e25b7eb9cbd841ddb6231836c9ff86868bc ./mise-v2026.3.15-linux-arm64-musl.tar.gz" + checksum_linux_armv7="7e403628c73f90dd6f350321bcd5d905ddbf92b6a52b97b495180fbd107ee762 ./mise-v2026.3.15-linux-armv7.tar.gz" + checksum_linux_armv7_musl="e1c828424ad9449d410c36857cf1e2a8713e4d7ea7a8a45623aec63b234cc4be ./mise-v2026.3.15-linux-armv7-musl.tar.gz" + checksum_macos_x86_64="4dbc8750ce3833050321b0c0deb61db7fc76681aa958df6786b999b588e42d1d ./mise-v2026.3.15-macos-x64.tar.gz" + checksum_macos_arm64="e500c437e4b8679b4c65e91925f86c17e6be76d0e218012bd40ec695ae4cf78e ./mise-v2026.3.15-macos-arm64.tar.gz" + checksum_linux_x86_64_zstd="b22e759742d805e87e7c17c4fc4aa1eb5927ac3c989a38b6cbe64718dcf8dcc8 ./mise-v2026.3.15-linux-x64.tar.zst" + checksum_linux_x86_64_musl_zstd="766887aa6f08d209116e4ddf0f7c49aa2c08b7119d64c8485574ed0013b75164 ./mise-v2026.3.15-linux-x64-musl.tar.zst" + checksum_linux_arm64_zstd="d1f4275548c836f90c70822405847bbf912295937c78076e1f5d33eb1995f8cb ./mise-v2026.3.15-linux-arm64.tar.zst" + checksum_linux_arm64_musl_zstd="74fc0386cd28044bbb91e7d745642f4c834dbd0d844374b4bbc314aa8fca2aca ./mise-v2026.3.15-linux-arm64-musl.tar.zst" + checksum_linux_armv7_zstd="80993247d85fdb5ba5b3c129775a16a42e5d06889963e232113ff1817f1e4445 ./mise-v2026.3.15-linux-armv7.tar.zst" + checksum_linux_armv7_musl_zstd="53da25e2ba78aee29f12da96a63fec36b2cabdc5d8d23e771de18b663c0d3aa6 ./mise-v2026.3.15-linux-armv7-musl.tar.zst" + checksum_macos_x86_64_zstd="76b1ed712ea60582eab109b25915cc52e123efb2304a70c73a0a4c17c1596c91 ./mise-v2026.3.15-macos-x64.tar.zst" + checksum_macos_arm64_zstd="5e655ab772fde67faa2bf69af45737f5b9fec32bd62fbd4a60921c7195f13fd8 ./mise-v2026.3.15-macos-arm64.tar.zst" # TODO: refactor this, it's a bit messy if [ "$ext" = "tar.zst" ]; then @@ -247,15 +250,17 @@ __mise_bootstrap() { } install_mise() { - version="${MISE_VERSION:-v2026.2.9}" + version="${MISE_VERSION:-v2026.3.15}" version="${version#v}" + current_version="v2026.3.15" + current_version="${current_version#v}" os="${MISE_INSTALL_OS:-$(get_os)}" arch="${MISE_INSTALL_ARCH:-$(get_arch)}" ext="${MISE_INSTALL_EXT:-$(get_ext)}" install_path="${MISE_INSTALL_PATH:-$HOME/.local/bin/mise}" install_dir="$(dirname "$install_path")" install_from_github="${MISE_INSTALL_FROM_GITHUB:-}" - if [ "$version" != "v2026.2.9" ] || [ "$install_from_github" = "1" ] || [ "$install_from_github" = "true" ]; then + if [ "$version" != "$current_version" ] || [ "$install_from_github" = "1" ] || [ "$install_from_github" = "true" ]; then tarball_url="https://github.com/jdx/mise/releases/download/v${version}/mise-v${version}-${os}-${arch}.${ext}" elif [ -n "${MISE_TARBALL_URL-}" ]; then tarball_url="$MISE_TARBALL_URL" @@ -271,8 +276,11 @@ __mise_bootstrap() { cd "$(dirname "$cache_file")" && get_checksum "$version" "$os" "$arch" "$ext" | "$(shasum_bin)" -c >/dev/null # extract tarball + if [ -d "$install_path" ]; then + error "MISE_INSTALL_PATH '$install_path' is a directory. Please set it to a file path, e.g. '$install_path/mise'." + fi mkdir -p "$install_dir" - rm -rf "$install_path" + rm -f "$install_path" extract_dir="$(mktemp -d)" cd "$extract_dir" if [ "$ext" = "tar.zst" ] && ! tar_supports_zstd; then From 38a1c1f6fd63675e5c518bad962ac24faf7e4076 Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Wed, 25 Mar 2026 18:40:24 +0500 Subject: [PATCH 13/17] fix(ci): use Swift 6.3 .pkg toolchain instead of swiftly on macOS swiftly-install.sh requires GNU getopt (not available on macOS runners). Download Swift 6.3 .pkg directly from swift.org and use TOOLCHAINS env var. --- .github/workflows/ci.yml | 10 +++++----- .github/workflows/release.yml | 20 ++++++++++---------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 391201be..52a382be 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,12 +50,12 @@ jobs: with: lfs: true - - name: Install Swift 6.3 via swiftly + - name: Install Swift 6.3 toolchain run: | - curl -L https://swiftlang.github.io/swiftly/swiftly-install.sh | bash -s -- -y - export PATH="$HOME/.swiftly/bin:$PATH" - swiftly install 6.3 - echo "$HOME/.swiftly/bin" >> "$GITHUB_PATH" + curl -fLo swift-6.3.pkg "https://download.swift.org/swift-6.3-release/xcode/swift-6.3-RELEASE/swift-6.3-RELEASE-osx.pkg" + sudo installer -pkg swift-6.3.pkg -target / + TOOLCHAINS=$(plutil -extract CFBundleIdentifier raw /Library/Developer/Toolchains/swift-6.3-RELEASE.xctoolchain/Info.plist) + echo "TOOLCHAINS=$TOOLCHAINS" >> "$GITHUB_ENV" - name: Cache SPM dependencies uses: actions/cache@v5 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0c0ba714..a3275ee3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -56,13 +56,13 @@ jobs: branch: swift-6.3-release tag: swift-6.3-RELEASE - - name: Install Swift 6.3 via swiftly (macOS) + - name: Install Swift 6.3 toolchain (macOS) if: matrix.platform == 'macos' run: | - curl -L https://swiftlang.github.io/swiftly/swiftly-install.sh | bash -s -- -y - export PATH="$HOME/.swiftly/bin:$PATH" - swiftly install 6.3 - echo "$HOME/.swiftly/bin" >> "$GITHUB_PATH" + curl -fLo swift-6.3.pkg "https://download.swift.org/swift-6.3-release/xcode/swift-6.3-RELEASE/swift-6.3-RELEASE-osx.pkg" + sudo installer -pkg swift-6.3.pkg -target / + TOOLCHAINS=$(plutil -extract CFBundleIdentifier raw /Library/Developer/Toolchains/swift-6.3-RELEASE.xctoolchain/Info.plist) + echo "TOOLCHAINS=$TOOLCHAINS" >> "$GITHUB_ENV" - name: Install system dependencies (Linux) if: matrix.platform == 'linux-x64' @@ -338,12 +338,12 @@ jobs: steps: - uses: actions/checkout@v6 - - name: Install Swift 6.3 via swiftly + - name: Install Swift 6.3 toolchain run: | - curl -L https://swiftlang.github.io/swiftly/swiftly-install.sh | bash -s -- -y - export PATH="$HOME/.swiftly/bin:$PATH" - swiftly install 6.3 - echo "$HOME/.swiftly/bin" >> "$GITHUB_PATH" + curl -fLo swift-6.3.pkg "https://download.swift.org/swift-6.3-release/xcode/swift-6.3-RELEASE/swift-6.3-RELEASE-osx.pkg" + sudo installer -pkg swift-6.3.pkg -target / + TOOLCHAINS=$(plutil -extract CFBundleIdentifier raw /Library/Developer/Toolchains/swift-6.3-RELEASE.xctoolchain/Info.plist) + echo "TOOLCHAINS=$TOOLCHAINS" >> "$GITHUB_ENV" - name: Build DocC run: | From 3513e5c49c015ff36a6ac313cae4159b7a770131 Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Wed, 25 Mar 2026 18:51:15 +0500 Subject: [PATCH 14/17] fix: replace deprecated MCP SDK .text() calls and update build instructions Migrate all .text(_:metadata:) calls in MCPToolHandlers to .text(text:annotations:_meta:) to fix 10 deprecation warnings from MCP SDK 0.12.0. Update CLAUDE.md build environment section to recommend ./bin/mise run build/test instead of raw export commands. --- CLAUDE.md | 5 +- Sources/ExFigCLI/MCP/MCPToolHandlers.swift | 65 +++++++++++++++------- 2 files changed, 48 insertions(+), 22 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e4257b10..4d3209e8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -319,9 +319,8 @@ Both `.text(_:metadata:)` and `.text(text:metadata:)` factories are deprecated b ### Build Environment (Swift 6.3 via swiftly) -Swift 6.3 is managed by swiftly (`.swift-version` file), not mise. For building and testing: -`export PATH="$HOME/.swiftly/bin:$PATH" && export DEVELOPER_DIR="/Applications/Xcode-26.4.0.app/Contents/Developer"` -swiftly provides Swift 6.3; Xcode provides macOS SDK with XCTest. Both are needed for `swift test`. +Swift 6.3 is managed by swiftly (`.swift-version` file), not mise. Always use `./bin/mise run build` and `./bin/mise run test` — mise handles PATH and DEVELOPER_DIR automatically. +Under the hood: swiftly provides Swift 6.3; Xcode provides macOS SDK with XCTest. Both are needed for `swift test`. ### Dependency Version Coupling (swift-resvg ↔ swift-svgkit) diff --git a/Sources/ExFigCLI/MCP/MCPToolHandlers.swift b/Sources/ExFigCLI/MCP/MCPToolHandlers.swift index 505c7bbe..184c69b8 100644 --- a/Sources/ExFigCLI/MCP/MCPToolHandlers.swift +++ b/Sources/ExFigCLI/MCP/MCPToolHandlers.swift @@ -23,17 +23,24 @@ case "exfig_download": return try await handleDownload(params: params, state: state) default: - return .init(content: [.text("Unknown tool: \(params.name)")], isError: true) + return .init( + content: [.text(text: "Unknown tool: \(params.name)", annotations: nil, _meta: nil)], + isError: true + ) } } catch let error as ExFigError { return errorResult(error) } catch let error as TokensFileError { return .init( - content: [.text("Token file error: \(error.errorDescription ?? "\(error)")")], + content: [.text( + text: "Token file error: \(error.errorDescription ?? "\(error)")", + annotations: nil, + _meta: nil + )], isError: true ) } catch { - return .init(content: [.text("Error: \(error)")], isError: true) + return .init(content: [.text(text: "Error: \(error)", annotations: nil, _meta: nil)], isError: true) } } @@ -55,7 +62,7 @@ figmaFileIds: fileIDs.isEmpty ? nil : fileIDs ) - return try .init(content: [.text(encodeJSON(summary))]) + return try .init(content: [.text(text: encodeJSON(summary), annotations: nil, _meta: nil)]) } private static func buildPlatformSummary(config: PKLConfig) -> [String: EntrySummary] { @@ -93,7 +100,10 @@ private static func handleTokensInfo(params: CallTool.Parameters) async throws -> CallTool.Result { guard let filePath = params.arguments?["file_path"]?.stringValue else { - return .init(content: [.text("Missing required parameter: file_path")], isError: true) + return .init( + content: [.text(text: "Missing required parameter: file_path", annotations: nil, _meta: nil)], + isError: true + ) } var source = try TokensFileSource.parse(fileAt: filePath) @@ -128,7 +138,7 @@ warnings: source.warnings.isEmpty ? nil : source.warnings ) - return try .init(content: [.text(encodeJSON(result))]) + return try .init(content: [.text(text: encodeJSON(result), annotations: nil, _meta: nil)]) } // MARK: - Inspect @@ -139,7 +149,10 @@ ) async throws -> CallTool.Result { // Validate inputs before expensive operations (PKL eval, API client) guard let resourceType = params.arguments?["resource_type"]?.stringValue else { - return .init(content: [.text("Missing required parameter: resource_type")], isError: true) + return .init( + content: [.text(text: "Missing required parameter: resource_type", annotations: nil, _meta: nil)], + isError: true + ) } let configPath = try resolveConfigPath(from: params.arguments?["config_path"]?.stringValue) @@ -168,7 +181,7 @@ } } - return try .init(content: [.text(encodeJSON(results))]) + return try .init(content: [.text(text: encodeJSON(results), annotations: nil, _meta: nil)]) } // MARK: - Inspect Helpers @@ -280,7 +293,7 @@ if let recovery = error.recoverySuggestion { message += "\n\nSuggestion: \(recovery)" } - return .init(content: [.text(message)], isError: true) + return .init(content: [.text(text: message, annotations: nil, _meta: nil)], isError: true) } /// Encodes a Codable value as pretty-printed JSON with sorted keys. @@ -341,17 +354,20 @@ let reportData = FileManager.default.contents(atPath: reportPath), let reportJSON = String(data: reportData, encoding: .utf8) { - return .init(content: [.text(reportJSON)], isError: result.exitCode != 0) + return .init( + content: [.text(text: reportJSON, annotations: nil, _meta: nil)], + isError: result.exitCode != 0 + ) } if result.exitCode != 0 { let message = result.stderr.isEmpty ? "Export failed with exit code \(result.exitCode)" : result.stderr - return .init(content: [.text(message)], isError: true) + return .init(content: [.text(text: message, annotations: nil, _meta: nil)], isError: true) } - return .init(content: [.text("{\"success\": true}")]) + return .init(content: [.text(text: "{\"success\": true}", annotations: nil, _meta: nil)]) } private static func handleDownload( @@ -387,7 +403,10 @@ case "tokens": return try await downloadUnifiedTokens(config: config, client: client) default: - return .init(content: [.text("Unknown resource_type: \(resourceType)")], isError: true) + return .init( + content: [.text(text: "Unknown resource_type: \(resourceType)", annotations: nil, _meta: nil)], + isError: true + ) } } } @@ -519,9 +538,9 @@ resourceType: "colors", format: "raw", tokenCount: result.output.light.count, warnings: warnings ) - try content.append(.text(encodeJSON(meta))) + try content.append(.text(text: encodeJSON(meta), annotations: nil, _meta: nil)) } - try content.append(.text(encodeRawColors(result.output))) + try content.append(.text(text: encodeRawColors(result.output), annotations: nil, _meta: nil)) return .init(content: content) } @@ -555,7 +574,11 @@ let textStyles = try await loader.load() if format == "raw" { - return try .init(content: [.text(encodeJSON(textStyles.map { RawTextStyle(from: $0) }))]) + return try .init(content: [.text( + text: encodeJSON(textStyles.map { RawTextStyle(from: $0) }), + annotations: nil, + _meta: nil + )]) } let exporter = W3CTokensExporter(version: .v2025) @@ -612,7 +635,11 @@ if allTokens.isEmpty { return .init( - content: [.text("No token sections configured for export. Check your config file.")], + content: [.text( + text: "No token sections configured for export. Check your config file.", + annotations: nil, + _meta: nil + )], isError: true ) } @@ -691,8 +718,8 @@ } return try .init(content: [ - .text(encodeJSON(meta)), - .text(tokensJSON), + .text(text: encodeJSON(meta), annotations: nil, _meta: nil), + .text(text: tokensJSON, annotations: nil, _meta: nil), ]) } From a1d22689b08df27f781c73c8dd720c4046c723af Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Wed, 25 Mar 2026 19:03:07 +0500 Subject: [PATCH 15/17] fix(ci): use correct Swift tag format for compnerd/gha-setup-swift on Windows The action prepends `swift-` to the tag, so `swift-6.3-RELEASE` produced a 404 URL (`swift-swift-6.3-RELEASE`). Use `6.3-RELEASE` instead. --- .claude/rules/linux-compat.md | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.claude/rules/linux-compat.md b/.claude/rules/linux-compat.md index a0f16f40..a0e384cb 100644 --- a/.claude/rules/linux-compat.md +++ b/.claude/rules/linux-compat.md @@ -57,7 +57,7 @@ func testSomePngOperation() throws { ### Swift Version Windows requires Swift 6.3 (stable release) due to `swift-resvg` artifactbundle compatibility. -CI uses `compnerd/gha-setup-swift@v0.3.0` with `branch: swift-6.3-release` and `tag: swift-6.3-RELEASE`. +CI uses `compnerd/gha-setup-swift@v0.3.0` with `branch: swift-6.3-release` and `tag: 6.3-RELEASE` (action prepends `swift-` to the tag). ### Conditional Dependencies (Package.swift) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a3275ee3..d6296b65 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -54,7 +54,7 @@ jobs: uses: compnerd/gha-setup-swift@v0.3.0 with: branch: swift-6.3-release - tag: swift-6.3-RELEASE + tag: 6.3-RELEASE - name: Install Swift 6.3 toolchain (macOS) if: matrix.platform == 'macos' From 079d48918659d8462672b74152553a57270da2a6 Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Wed, 25 Mar 2026 19:03:36 +0500 Subject: [PATCH 16/17] fix(ci): use correct Swift tag format in ci.yml for Windows --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 52a382be..29af74e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -143,7 +143,7 @@ jobs: uses: compnerd/gha-setup-swift@v0.3.0 with: branch: swift-6.3-release - tag: swift-6.3-RELEASE + tag: 6.3-RELEASE - name: Cache SPM dependencies uses: actions/cache@v5 From b5d43fc5d828fb24261983d3701118361686515d Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Wed, 25 Mar 2026 22:07:52 +0500 Subject: [PATCH 17/17] fix: address PR review findings from code review and Gemini - Fix CICDIntegration.md tag format (6.3-RELEASE, not swift-6.3-RELEASE) - Fix CLAUDE.md module count (Twelve, not Thirteen) - Bump swift-svgkit minimum to 0.2.0, swift-sdk to 0.12.0 - Use guard case let in MCP test helpers for reliable assertions - Clarify Package.swift comment, Development.md requirements - Add SilentlyContinue comment in release.yml Windows archive step - Add SwiftFormat #if indent and SPM from: gotchas to CLAUDE.md --- .github/workflows/release.yml | 1 + CLAUDE.md | 4 ++- Package.swift | 7 ++--- .../ExFigCLI/ExFig.docc/CICDIntegration.md | 2 +- Sources/ExFigCLI/ExFig.docc/Development.md | 2 +- .../ExFigTests/MCP/MCPToolHandlerTests.swift | 26 ++++++++++++------- 6 files changed, 26 insertions(+), 16 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d6296b65..d0e69990 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -143,6 +143,7 @@ jobs: run: | New-Item -ItemType Directory -Force -Path dist Copy-Item ".build/${{ matrix.build-path }}/exfig.exe" "dist/ExFig.exe" + # Resource bundles are optional — not all modules produce them on every build config Copy-Item ".build/${{ matrix.build-path }}/exfig_AndroidExport.resources" "dist/" -Recurse -ErrorAction SilentlyContinue Copy-Item ".build/${{ matrix.build-path }}/exfig_XcodeExport.resources" "dist/" -Recurse -ErrorAction SilentlyContinue Copy-Item ".build/${{ matrix.build-path }}/exfig_FlutterExport.resources" "dist/" -Recurse -ErrorAction SilentlyContinue diff --git a/CLAUDE.md b/CLAUDE.md index 4d3209e8..f416b4d9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -98,7 +98,7 @@ pkl eval --format json # Package URI requires published package ## Architecture -Thirteen modules in `Sources/`: +Twelve modules in `Sources/`: | Module | Purpose | | --------------- | --------------------------------------------------------- | @@ -452,6 +452,8 @@ NooraUI.formatLink("url", useColors: true) // underlined primary | MCP SDK 0.12.0 breaking | `.text` has 3 associated values — pattern match as `.text(text, _, _)`; `GetPrompt.arguments` is `[String: String]?` now | | Tests need XCTest from Xcode | swiftly's Swift 6.3 lacks XCTest; set `DEVELOPER_DIR` to Xcode app path for `swift test` | | `swift test` pkl failures | Run via `./bin/mise exec -- swift test` to get pkl 0.31+ in PATH; bare `swift test` uses system pkl | +| SwiftFormat `#if` indent | SwiftFormat 0.60.1 indents content inside `#if canImport()` — this is intentional project style, do not "fix" | +| SPM `from:` too loose | When code uses APIs from version X, set `from: "X"` not older — SPM may resolve an incompatible earlier version | ## Additional Rules diff --git a/Package.swift b/Package.swift index fa4940d2..a8d182ca 100644 --- a/Package.swift +++ b/Package.swift @@ -19,7 +19,7 @@ var packageDependencies: [Package.Dependency] = [ .package(url: "https://github.com/alexey1312/swift-resvg.git", exact: "0.45.1-swift.15"), .package(url: "https://github.com/mattt/swift-yyjson", from: "0.5.0"), .package(url: "https://github.com/apple/pkl-swift", from: "0.8.0"), - .package(url: "https://github.com/DesignPipe/swift-svgkit.git", from: "0.1.0"), + .package(url: "https://github.com/DesignPipe/swift-svgkit.git", from: "0.2.0"), .package(url: "https://github.com/DesignPipe/swift-figma-api.git", from: "0.2.0"), .package(url: "https://github.com/DesignPipe/swift-penpot-api.git", from: "0.1.0"), ] @@ -48,13 +48,14 @@ var exfigCLIDependencies: [Target.Dependency] = [ ] // XcodeProj and MCP SDK are not available on Windows -// (XcodeProj depends on PathKit/AEXML, MCP SDK depends on swift-nio which doesn't compile on Windows) +// (XcodeProj depends on PathKit which uses Darwin-specific APIs, MCP SDK depends on swift-nio which doesn't compile on +// Windows) #if !os(Windows) packageDependencies.append( .package(url: "https://github.com/tuist/XcodeProj.git", from: "8.27.0") ) packageDependencies.append( - .package(url: "https://github.com/modelcontextprotocol/swift-sdk.git", from: "0.9.0") + .package(url: "https://github.com/modelcontextprotocol/swift-sdk.git", from: "0.12.0") ) exfigCLIDependencies.append( .product(name: "XcodeProj", package: "XcodeProj") diff --git a/Sources/ExFigCLI/ExFig.docc/CICDIntegration.md b/Sources/ExFigCLI/ExFig.docc/CICDIntegration.md index 6bdc6bea..8bb88fba 100644 --- a/Sources/ExFigCLI/ExFig.docc/CICDIntegration.md +++ b/Sources/ExFigCLI/ExFig.docc/CICDIntegration.md @@ -98,7 +98,7 @@ ExFig CI runs on macOS, Linux (Ubuntu 22.04), and Windows. On Windows: - uses: compnerd/gha-setup-swift@v0.3.0 with: branch: swift-6.3-release - tag: swift-6.3-RELEASE + tag: 6.3-RELEASE - name: Build run: swift build diff --git a/Sources/ExFigCLI/ExFig.docc/Development.md b/Sources/ExFigCLI/ExFig.docc/Development.md index 97df6b85..f84825b6 100644 --- a/Sources/ExFigCLI/ExFig.docc/Development.md +++ b/Sources/ExFigCLI/ExFig.docc/Development.md @@ -16,7 +16,7 @@ your development environment and contributing to the project. ## Requirements - macOS 13.0 or later, Linux (Ubuntu 22.04), or Windows -- Xcode 26.2 or later (macOS), or Swift 6.3+ toolchain (Linux/Windows) +- Xcode 26.2 or later (macOS, provides SDK) + Swift 6.3 toolchain (.pkg), or Swift 6.3+ toolchain (Linux/Windows) - [mise](https://mise.jdx.dev/) (optional, for task running) ## Getting Started diff --git a/Tests/ExFigTests/MCP/MCPToolHandlerTests.swift b/Tests/ExFigTests/MCP/MCPToolHandlerTests.swift index ab4741b3..2744a54a 100644 --- a/Tests/ExFigTests/MCP/MCPToolHandlerTests.swift +++ b/Tests/ExFigTests/MCP/MCPToolHandlerTests.swift @@ -22,9 +22,11 @@ let params = CallTool.Parameters(name: tool, arguments: arguments) let result = await MCPToolHandlers.handle(params: params, state: MCPServerState()) #expect(result.isError == true) - if case let .text(text, _, _) = result.content.first { - #expect(text.contains(substring)) + guard case let .text(text, _, _) = result.content.first else { + Issue.record("Expected .text content, got \(result.content)") + return } + #expect(text.contains(substring)) } // MARK: - Validate Tool @@ -56,11 +58,13 @@ #expect(result.isError != true) - if case let .text(text, _, _) = result.content.first { - #expect(text.contains("\"valid\"")) - #expect(text.contains("config_path")) - #expect(text.contains("ios")) + guard case let .text(text, _, _) = result.content.first else { + Issue.record("Expected .text content, got \(result.content)") + return } + #expect(text.contains("\"valid\"")) + #expect(text.contains("config_path")) + #expect(text.contains("ios")) } // MARK: - Tokens Info Tool @@ -122,11 +126,13 @@ #expect(result.isError != true) - if case let .text(text, _, _) = result.content.first { - #expect(text.contains("total_tokens")) - #expect(text.contains("counts_by_type")) - #expect(text.contains("top_level_groups")) + guard case let .text(text, _, _) = result.content.first else { + Issue.record("Expected .text content, got \(result.content)") + return } + #expect(text.contains("total_tokens")) + #expect(text.contains("counts_by_type")) + #expect(text.contains("top_level_groups")) } // MARK: - Unknown Tool