diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 647500ca..bb79ec19 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,7 +51,7 @@ jobs: lfs: true - name: Cache SPM dependencies - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: .build key: ${{ runner.os }}-xcode-26.1.1-spm-${{ hashFiles('Package.swift', 'Package.resolved') }} @@ -102,7 +102,7 @@ jobs: lfs: true - name: Cache SPM dependencies - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: .build key: ${{ runner.os }}-swift-6.2.3-spm-${{ hashFiles('Package.swift', 'Package.resolved') }} diff --git a/CLAUDE.md b/CLAUDE.md index bb89b5b5..80a7262e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -120,6 +120,7 @@ Fourteen modules in `Sources/`: **MCP data flow:** `exfig mcp` → StdioTransport (JSON-RPC on stdin/stdout) → tool handlers → PKLEvaluator / TokensFileSource / FigmaAPI **MCP stdout safety:** `OutputMode.mcp` + `TerminalOutputManager.setStderrMode(true)` — all CLI output goes to stderr +**Claude Code plugins:** [exfig-plugins](https://github.com/DesignPipe/exfig-plugins) marketplace — MCP integration, setup wizard, export commands, config review, troubleshooting **Batch mode:** Single `@TaskLocal` via `BatchSharedState` actor — see `ExFigCLI/CLAUDE.md`. @@ -366,37 +367,41 @@ NooraUI.formatLink("url", useColors: true) // underlined primary ## Troubleshooting -| Problem | Solution | -| --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| codegen:pkl gen.pkl error | gen.pkl `read?` bug: needs `--generator-settings` + `--project-dir` flags (see mise.toml) | -| xcsift "signal code 5" | False positive when piping `swift test` through xcsift; run `swift test` directly to verify | -| PKL tests need Pkl 0.31+ | Schemas use `isNotEmpty`; run tests via `./bin/mise exec -- swift test` to get correct Pkl in PATH | -| PKL FrameSource change | Update ALL entry init calls in tests (EnumBridgingTests, IconsLoaderConfigTests) | -| Build fails | `swift package clean && swift build` | -| 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` | -| mise "sources up-to-date" | mise caches tasks with `sources`/`outputs` — run script directly via `bash` when debugging | -| 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 | -| SwiftLint trailing closure | When function takes 2+ closures, use explicit label for last closure (`export: { ... }`) not trailing syntax | -| CLI flag default vs absent | swift-argument-parser can't distinguish explicit `--flag default_value` from omitted. Use `Optional` + computed `effectiveX` property for flags that wizard may override | -| MCP `Client` ambiguous | `FigmaAPI.Client` vs `MCP.Client` — always use `FigmaAPI.Client` in MCP/ files | -| MCP `FigmaConfig` fields | No `colorsFileId` — use `config.getFileIds()` or `figma.lightFileId`/`darkFileId` | -| `distantFuture` on clock | `ContinuousClock.Instant` has no `distantFuture`; use `withCheckedContinuation { _ in }` for infinite suspend | -| MCP stderr duplication | `TerminalOutputManager.setStderrMode(true)` handles all output routing — don't duplicate in `ExFigLogHandler` | -| PKL template word search | Template comments on `lightFileId` contain cross-refs (`variablesColors`, `typography`); test section removal by matching full markers (`colors = new Common.Colors {`) not bare words | -| CI llms-full.txt stale | `llms-full.txt` is generated from README + DocC articles; after editing `Usage.md`, `ExFig.md`, or `README.md`, run `./bin/mise run generate:llms` and commit the result | -| Release build .pcm warnings | Stale `ModuleCache` — clean with: `rm -r .build/*/release/ModuleCache` then rebuild | +| Problem | Solution | +| ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| codegen:pkl gen.pkl error | gen.pkl `read?` bug: needs `--generator-settings` + `--project-dir` flags (see mise.toml) | +| xcsift "signal code 5" | False positive when piping `swift test` through xcsift; run `swift test` directly to verify | +| PKL tests need Pkl 0.31+ | Schemas use `isNotEmpty`; run tests via `./bin/mise exec -- swift test` to get correct Pkl in PATH | +| PKL FrameSource change | Update ALL entry init calls in tests (EnumBridgingTests, IconsLoaderConfigTests) | +| Build fails | `swift package clean && swift build` | +| 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` | +| mise "sources up-to-date" | mise caches tasks with `sources`/`outputs` — run script directly via `bash` when debugging | +| 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 | +| SwiftLint trailing closure | When function takes 2+ closures, use explicit label for last closure (`export: { ... }`) not trailing syntax | +| CLI flag default vs absent | swift-argument-parser can't distinguish explicit `--flag default_value` from omitted. Use `Optional` + computed `effectiveX` property for flags that wizard may override | +| MCP `Client` ambiguous | `FigmaAPI.Client` vs `MCP.Client` — always use `FigmaAPI.Client` in MCP/ files | +| MCP `FigmaConfig` fields | No `colorsFileId` — use `config.getFileIds()` or `figma.lightFileId`/`darkFileId` | +| `distantFuture` on clock | `ContinuousClock.Instant` has no `distantFuture`; use `withCheckedContinuation { _ in }` for infinite suspend | +| MCP stderr duplication | `TerminalOutputManager.setStderrMode(true)` handles all output routing — don't duplicate in `ExFigLogHandler` | +| MCP `Process` race condition | Set `terminationHandler` BEFORE `process.run()` — process may exit before handler is installed, hanging the continuation forever | +| MCP pipe deadlock | Read stderr via concurrent `Task` BEFORE waiting for termination — pipe buffer (~64KB) can fill and block the subprocess | +| MCP `encodeJSON` errors | Use `throws` not `try?` — silently returning `"\(value)"` (Swift debug dump) breaks JSON consumers; top-level `do/catch` in `handle()` catches automatically | +| `VariablesColors` vs `Colors` | `ColorsVariablesLoader` takes `Common.VariablesColors?`, not `Common.Colors?` — different PKL types | +| PKL template word search | Template comments on `lightFileId` contain cross-refs (`variablesColors`, `typography`); test section removal by matching full markers (`colors = new Common.Colors {`) not bare words | +| CI llms-full.txt stale | `llms-full.txt` is generated from README + DocC articles; after editing `Usage.md`, `ExFig.md`, or `README.md`, run `./bin/mise run generate:llms` and commit the result | +| Release build .pcm warnings | Stale `ModuleCache` — clean with: `rm -r .build/*/release/ModuleCache` then rebuild | ## Additional Rules diff --git a/README.md b/README.md index 1f88bdf2..71b95382 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,16 @@ See the [Getting Started guide](https://DesignPipe.github.io/exfig/documentation cache: true ``` +## Claude Code Plugins + +Use ExFig directly from Claude Code with the [exfig-plugins](https://github.com/DesignPipe/exfig-plugins) marketplace: + +```bash +claude /plugin marketplace add https://github.com/DesignPipe/exfig-plugins +``` + +Includes MCP integration, setup wizard, config review, troubleshooting, and `/export-*` slash commands. + ## Documentation Full documentation — platform guides, configuration reference, batch processing, design tokens, custom templates, and MCP server — is available at **[DesignPipe.github.io/exfig](https://DesignPipe.github.io/exfig/documentation/exfig)**. diff --git a/Sources/ExFigCLI/CLAUDE.md b/Sources/ExFigCLI/CLAUDE.md index 24a22b0f..d265c399 100644 --- a/Sources/ExFigCLI/CLAUDE.md +++ b/Sources/ExFigCLI/CLAUDE.md @@ -188,6 +188,19 @@ reserved for MCP JSON-RPC protocol. **Tool handler order:** Validate input parameters BEFORE expensive operations (PKL eval, API client creation). +### Adding an MCP Tool Handler + +1. Add tool definition in `MCP/MCPToolDefinitions.swift` (JSON Schema via `.object([...])`) +2. Add case in `MCPToolHandlers.handle()` dispatch switch +3. Implement handler in an `extension MCPToolHandlers` (keeps `type_body_length` under 300) +4. `ExFigWarning` → string via `ExFigWarningFormatter().format(warning)` (no `.formattedMessage` property) +5. `Color.hex` is in AndroidExport, not accessible from ExFigCLI — use RGBA components +6. `ColorsVariablesLoader` takes `PKLConfig.Common.VariablesColors?`, not `.Colors?` +7. MCP `CallTool.Parameters.arguments` type is `[String: Value]?` (not `JSONValue`); accessors: `.stringValue`, `.intValue`, `.boolValue` +8. Export tools: `exfig_export` runs subprocess (self-invoke), reads JSON report from temp file; `exfig_download` returns tokens inline via loaders +9. `runSubprocess` pattern: set `terminationHandler` BEFORE `process.run()` (race condition); read stderr pipe concurrently via `Task` (deadlock at 64KB buffer); use `withThrowingTaskGroup` race for timeout +10. Validate cheap params (format, resource_type) BEFORE expensive operations (PKL eval, `state.getClient()`) — keeps tests fast and error messages clear + ## Modification Patterns ### Adding a New Subcommand diff --git a/Sources/ExFigCLI/ExFig.docc/ExFig.md b/Sources/ExFigCLI/ExFig.docc/ExFig.md index 88dba8a1..b4836b46 100644 --- a/Sources/ExFigCLI/ExFig.docc/ExFig.md +++ b/Sources/ExFigCLI/ExFig.docc/ExFig.md @@ -45,8 +45,9 @@ file version tracking, and experimental per-node granular cache. **Developer Experience** CI/CD ready (quiet mode, exit codes, JSON reports), GitHub Action for automated exports, -MCP server for AI assistant integration, customizable Jinja2 code templates, -and rich progress indicators with ETA. +MCP server for AI assistant integration, +[Claude Code plugins](https://github.com/DesignPipe/exfig-plugins) for setup wizards and slash commands, +customizable Jinja2 code templates, and rich progress indicators with ETA. **Code Generation** Type-safe Swift/Kotlin/Dart/TypeScript extensions, pre-configured UILabel subclasses, diff --git a/Sources/ExFigCLI/ExFig.docc/MCPServer.md b/Sources/ExFigCLI/ExFig.docc/MCPServer.md index 6670a8e3..ffff1d75 100644 --- a/Sources/ExFigCLI/ExFig.docc/MCPServer.md +++ b/Sources/ExFigCLI/ExFig.docc/MCPServer.md @@ -40,11 +40,13 @@ Add to your `.mcp.json` (Claude Code, Cursor, Codex): ## Available Tools -| Tool | Description | Requires Token | -| ------------------- | -------------------------------- | -------------- | -| `exfig_validate` | Validate a PKL config file | No | -| `exfig_tokens_info` | Inspect a local `.tokens.json` | No | -| `exfig_inspect` | List resources in a Figma file | Yes | +| Tool | Description | Requires Token | +| ------------------- | ---------------------------------------------------- | -------------- | +| `exfig_validate` | Validate a PKL config file | No | +| `exfig_tokens_info` | Inspect a local `.tokens.json` | No | +| `exfig_inspect` | List resources in a Figma file | Yes | +| `exfig_export` | Run code export (writes files, returns JSON report) | Yes | +| `exfig_download` | Export W3C Design Tokens JSON (inline, no file I/O) | Yes | ## Resources @@ -62,6 +64,27 @@ AI assistants can read these to understand config structure and generate valid c | `setup-config` | Guide through creating an `exfig.pkl` config | | `troubleshoot-export` | Diagnose and fix export errors | +## Claude Code Plugins + +For a turnkey Claude Code experience, install the +[exfig-plugins](https://github.com/DesignPipe/exfig-plugins) marketplace: + +```bash +claude /plugin marketplace add https://github.com/DesignPipe/exfig-plugins +``` + +The marketplace includes: + +| Plugin | What it does | +| ------ | ------------ | +| **exfig-mcp** | Pre-configured `.mcp.json` + usage skill | +| **exfig-setup** | Interactive wizard: install → token → config → first export → CI | +| **exfig-export** | `/export-colors`, `/export-icons`, `/export-images`, `/export-all` commands | +| **exfig-config-review** | Reviews `exfig.pkl` for issues and optimizations | +| **exfig-troubleshooting** | Error catalog with diagnostic steps | +| **exfig-migration** | Migration guide between major versions | +| **exfig-rules** | Naming and structure conventions | + ## See Also - diff --git a/Sources/ExFigCLI/MCP/MCPToolDefinitions.swift b/Sources/ExFigCLI/MCP/MCPToolDefinitions.swift index f36aa46a..f8e78099 100644 --- a/Sources/ExFigCLI/MCP/MCPToolDefinitions.swift +++ b/Sources/ExFigCLI/MCP/MCPToolDefinitions.swift @@ -6,6 +6,8 @@ enum MCPToolDefinitions { validateTool, tokensInfoTool, inspectTool, + exportTool, + downloadTool, ] // MARK: - Tool Definitions @@ -82,4 +84,102 @@ enum MCPToolDefinitions { "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"), + ]), + ]), + "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")]), + ]) + ) + + 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"), + ]), + ]), + "filter": .object([ + "type": .string("string"), + "description": .string("Filter by name pattern. Only for colors."), + ]), + ]), + "required": .array([.string("resource_type")]), + ]) + ) } diff --git a/Sources/ExFigCLI/MCP/MCPToolHandlers.swift b/Sources/ExFigCLI/MCP/MCPToolHandlers.swift index ab8ec4d3..5160df2e 100644 --- a/Sources/ExFigCLI/MCP/MCPToolHandlers.swift +++ b/Sources/ExFigCLI/MCP/MCPToolHandlers.swift @@ -18,6 +18,10 @@ enum MCPToolHandlers { 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) } @@ -29,7 +33,7 @@ enum MCPToolHandlers { isError: true ) } catch { - return .init(content: [.text("Error: \(error.localizedDescription)")], isError: true) + return .init(content: [.text("Error: \(error)")], isError: true) } } @@ -51,7 +55,7 @@ enum MCPToolHandlers { figmaFileIds: fileIDs.isEmpty ? nil : fileIDs ) - return .init(content: [.text(encodeJSON(summary))]) + return try .init(content: [.text(encodeJSON(summary))]) } private static func buildPlatformSummary(config: PKLConfig) -> [String: EntrySummary] { @@ -124,7 +128,7 @@ enum MCPToolHandlers { warnings: source.warnings.isEmpty ? nil : source.warnings ) - return .init(content: [.text(encodeJSON(result))]) + return try .init(content: [.text(encodeJSON(result))]) } // MARK: - Inspect @@ -164,7 +168,7 @@ enum MCPToolHandlers { } } - return .init(content: [.text(encodeJSON(results))]) + return try .init(content: [.text(encodeJSON(results))]) } // MARK: - Inspect Helpers @@ -280,11 +284,433 @@ enum MCPToolHandlers { } /// Encodes a Codable value as pretty-printed JSON with sorted keys. - private static func encodeJSON(_ value: some Encodable) -> String { - guard let data = try? JSONCodec.encodePrettySorted(value) else { - return "\(value)" + 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(data: data, encoding: .utf8) ?? "\(value)" + return string + } +} + +// 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)" + ) + } + return resourceType + } + + 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) + } + + return .init(content: [.text("{\"success\": true}")]) + } + + 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 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() + + 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 + } +} + +private struct SubprocessResult { + let exitCode: Int + let stderr: String +} + +private let subprocessTimeout: Duration = .seconds(300) + +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 process = Process() + process.executableURL = executableURL + process.arguments = arguments + process.environment = ProcessInfo.processInfo.environment + + let stderrPipe = Pipe() + process.standardError = stderrPipe + process.standardOutput = FileHandle.nullDevice + + // Read stderr concurrently to avoid pipe buffer deadlock. + // Must start reading BEFORE waiting for termination. + let stderrTask = Task { + stderrPipe.fileHandleForReading.readDataToEndOfFile() + } + + // 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 + } + } +} + +// 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 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." + ) + } + + 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 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.") + } + + 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) }))]) + } + + 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 + + let variableParams = config.common?.variablesColors + + 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") + } + + 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) + } + tokenCount += count + warnings += w + } + + if allTokens.isEmpty { + return .init( + content: [.text("No token sections configured for export. Check your config file.")], + isError: true + ) + } + + let meta = DownloadMeta( + resourceType: "tokens", format: "w3c", + tokenCount: tokenCount, + warnings: warnings.isEmpty ? nil : warnings + ) + 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 + ) + 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) + } + + 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") + } + + 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 + ) + } + 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) } } @@ -423,4 +849,57 @@ private struct TypographyInspectResult: Codable, Sendable { } } +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" + } +} + // swiftlint:enable file_length diff --git a/Tests/ExFigTests/MCP/MCPToolHandlerTests.swift b/Tests/ExFigTests/MCP/MCPToolHandlerTests.swift index 16ded889..9d21322c 100644 --- a/Tests/ExFigTests/MCP/MCPToolHandlerTests.swift +++ b/Tests/ExFigTests/MCP/MCPToolHandlerTests.swift @@ -12,37 +12,35 @@ struct MCPToolHandlerTests { .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 @Test("validate returns error for missing config") func validateMissingConfig() async { - let params = CallTool.Parameters( - name: "exfig_validate", - arguments: ["config_path": .string("/nonexistent/path.pkl")] + await expectError( + tool: "exfig_validate", + arguments: ["config_path": .string("/nonexistent/path.pkl")], + containing: "not found" ) - - let result = await MCPToolHandlers.handle(params: params, state: MCPServerState()) - - #expect(result.isError == true) - if case let .text(text) = result.content.first { - #expect(text.contains("not found")) - } } @Test("validate auto-detects exfig.pkl when no path given") func validateAutoDetect() async { - let params = CallTool.Parameters( - name: "exfig_validate", - arguments: nil - ) - - // No exfig.pkl in working directory → error with helpful message - let result = await MCPToolHandlers.handle(params: params, state: MCPServerState()) - - #expect(result.isError == true) - if case let .text(text) = result.content.first { - #expect(text.contains("exfig.pkl")) - } + await expectError(tool: "exfig_validate", arguments: nil, containing: "exfig.pkl") } @Test("validate returns summary for valid config") @@ -69,32 +67,16 @@ struct MCPToolHandlerTests { @Test("tokens_info returns error for missing file_path") func tokensInfoMissingParam() async { - let params = CallTool.Parameters( - name: "exfig_tokens_info", - arguments: nil - ) - - let result = await MCPToolHandlers.handle(params: params, state: MCPServerState()) - - #expect(result.isError == true) - if case let .text(text) = result.content.first { - #expect(text.contains("file_path")) - } + await expectError(tool: "exfig_tokens_info", arguments: nil, containing: "file_path") } @Test("tokens_info returns error for nonexistent file") func tokensInfoFileNotFound() async { - let params = CallTool.Parameters( - name: "exfig_tokens_info", - arguments: ["file_path": .string("/tmp/nonexistent.tokens.json")] + await expectError( + tool: "exfig_tokens_info", + arguments: ["file_path": .string("/tmp/nonexistent.tokens.json")], + containing: "not found" ) - - let result = await MCPToolHandlers.handle(params: params, state: MCPServerState()) - - #expect(result.isError == true) - if case let .text(text) = result.content.first { - #expect(text.contains("not found")) - } } @Test("tokens_info parses valid tokens file") @@ -151,17 +133,7 @@ struct MCPToolHandlerTests { @Test("unknown tool returns error") func unknownTool() async { - let params = CallTool.Parameters( - name: "nonexistent_tool", - arguments: nil - ) - - let result = await MCPToolHandlers.handle(params: params, state: MCPServerState()) - - #expect(result.isError == true) - if case let .text(text) = result.content.first { - #expect(text.contains("Unknown tool")) - } + await expectError(tool: "nonexistent_tool", arguments: nil, containing: "Unknown tool") } // MARK: - Inspect Tool @@ -184,17 +156,80 @@ struct MCPToolHandlerTests { @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" + ) + } - let params = CallTool.Parameters( - name: "exfig_inspect", - arguments: ["config_path": .string(configPath)] + // 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 invalid resource_type") + func exportInvalidResourceType() async { + await expectError( + tool: "exfig_export", + arguments: ["resource_type": .string("invalid")], + containing: "Invalid resource_type" ) + } - let result = await MCPToolHandlers.handle(params: params, state: MCPServerState()) + @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" + ) + } - #expect(result.isError == true) - if case let .text(text) = result.content.first { - #expect(text.contains("resource_type")) - } + // 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 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 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" + ) } } diff --git a/llms-full.txt b/llms-full.txt index 64188aa5..c244254f 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -63,6 +63,16 @@ See the [Getting Started guide](https://DesignPipe.github.io/exfig/documentation cache: true ``` +## Claude Code Plugins + +Use ExFig directly from Claude Code with the [exfig-plugins](https://github.com/DesignPipe/exfig-plugins) marketplace: + +```bash +claude /plugin marketplace add https://github.com/DesignPipe/exfig-plugins +``` + +Includes MCP integration, setup wizard, config review, troubleshooting, and `/export-*` slash commands. + ## Documentation Full documentation — platform guides, configuration reference, batch processing, design tokens, custom templates, and MCP server — is available at **[DesignPipe.github.io/exfig](https://DesignPipe.github.io/exfig/documentation/exfig)**.