From 4ed2ad22ba76a802b697c3f819fbf264fa09d5ba Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Tue, 18 Aug 2026 11:03:36 +0500 Subject: [PATCH] fix: let informational xcodebuild commands bypass xcsift The pre-tool hooks rewrote every command that starts with `xcodebuild`, so `xcodebuild -version`, `-list -json`, `-showBuildSettings -json` and `-showTestPlans -json` went through the build parser. The parser found no build markers and reported `status: incomplete` with an empty summary, which discarded the answer. `xcodebuild -showsdks` and `swift build --show-bin-path` failed the same way. The hook is the correct place for the fix: the hook reads the command, but xcsift reads only the output. Changes to the hook condition, applied to all three copies: - Informational commands pass through untouched. - The command matcher accepts a leading shell separator, so `cd App && xcodebuild build` is now piped through xcsift. - The rewrite groups the command as `{ ... ; } 2>&1 | xcsift -f toon`. The previous code appended `2>&1 |` to the string, which sent only the last part of `xcodebuild build; swift test` to xcsift. - Commands with their own pipe or file redirection pass through, because `xcodebuild build > log.txt` left xcsift with empty input. - `xcodebuild-foo build` no longer matches. Also synchronize the plugin version across marketplace.json, plugin.json and SKILL.md, which drifted apart in #60, and raise it to 1.0.4 so installed plugins load the new script. `PluginFilesTests` runs one command table against all three hook copies and compares the three version manifests, so a partial edit fails the tests. Closes #80 --- .claude-plugin/marketplace.json | 2 +- .../Install/Templates/CursorTemplates.swift | 29 ++- .../Install/Templates/SharedTemplates.swift | 10 + .../xcsift/xcsift.docc/PluginInstallation.md | 22 ++ Tests/xcsiftTests/PluginFilesTests.swift | 227 ++++++++++++++++++ .../claude-code/.claude-plugin/plugin.json | 2 +- plugins/claude-code/scripts/pre-xcsift.sh | 29 ++- plugins/claude-code/skills/xcsift/SKILL.md | 12 +- plugins/codex/SKILL.md | 10 + plugins/cursor/hooks/pre-xcsift.sh | 29 ++- 10 files changed, 330 insertions(+), 42 deletions(-) create mode 100644 Tests/xcsiftTests/PluginFilesTests.swift diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 6ce48ab..2ad790d 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -8,7 +8,7 @@ "plugins": [ { "name": "xcsift", - "version": "1.0.2", + "version": "1.0.4", "source": "./plugins/claude-code", "description": "Pipe xcodebuild/swift build output through xcsift for structured TOON format" } diff --git a/Sources/xcsift/Install/Templates/CursorTemplates.swift b/Sources/xcsift/Install/Templates/CursorTemplates.swift index 44639e7..5db4833 100644 --- a/Sources/xcsift/Install/Templates/CursorTemplates.swift +++ b/Sources/xcsift/Install/Templates/CursorTemplates.swift @@ -58,19 +58,22 @@ enum CursorTemplates { exit 0 fi - # Patterns that should be piped through xcsift - # Match: xcodebuild, swift build, swift test (with any arguments) - # But NOT: already piped through xcsift - if echo "$COMMAND" | grep -qE '^\\s*(xcodebuild|swift\\s+(build|test))\\b' && \\ - ! echo "$COMMAND" | grep -q 'xcsift'; then - - # Add 2>&1 if not present (to capture stderr) - if ! echo "$COMMAND" | grep -q '2>&1'; then - COMMAND="$COMMAND 2>&1" - fi - - # Pipe through xcsift with TOON format - MODIFIED_COMMAND="$COMMAND | xcsift -f toon" + # Build commands. The leading separator also matches `cd App && xcodebuild build`. + BUILD_RE='(^|[;&|][[:space:]]*)[[:space:]]*(xcodebuild|swift[[:space:]]+(build|test))([[:space:]]|$)' + + # Informational commands print an answer, not a build log. xcsift discards that answer. + QUERY_RE='(^|[[:space:]])--?(version|usage|help|h|list|showsdks|showdestinations|showTestPlans|showBuildSettings|showBuildSettingsForIndex|find-executable|find-library|checkFirstLaunchStatus|create-xcframework|show-bin-path|list-tests)([[:space:]]|$)' + + # Remove the redirections this hook adds, to find the redirections the user wrote. + BARE=$(printf '%s' "$COMMAND" | sed 's/2>&1//g; s/>&2//g') + + if echo "$COMMAND" | grep -qE "$BUILD_RE" \\ + && ! echo "$COMMAND" | grep -qE "$QUERY_RE" \\ + && ! echo "$COMMAND" | grep -q 'xcsift' \\ + && ! echo "$BARE" | grep -qE '[|>]'; then + + # Group the command, so each part of a `;` or `&&` chain goes to xcsift. + MODIFIED_COMMAND="{ $COMMAND ; } 2>&1 | xcsift -f toon" # Return modified command jq -n --arg cmd "$MODIFIED_COMMAND" '{"permission":"allow","updated_input":{"command":$cmd}}' diff --git a/Sources/xcsift/Install/Templates/SharedTemplates.swift b/Sources/xcsift/Install/Templates/SharedTemplates.swift index 9e594c9..f4b6c0c 100644 --- a/Sources/xcsift/Install/Templates/SharedTemplates.swift +++ b/Sources/xcsift/Install/Templates/SharedTemplates.swift @@ -21,6 +21,16 @@ enum SharedTemplates { - `swift build` / `swift test` - Any command that produces Xcode/SPM build output + ## When Not to Use + + Informational commands print an answer, not a build log. xcsift discards that answer, so run them + directly: + + - `xcodebuild -version` / `-usage` / `-help` + - `xcodebuild -list` / `-showsdks` / `-showdestinations` / `-showTestPlans` / `-showBuildSettings` + - `xcodebuild -find-executable` / `-find-library` / `-create-xcframework` + - `swift build --show-bin-path`, `swift test --list-tests`, and any `--help` or `--version` command + ## Usage Pattern Always redirect stderr and use TOON format: diff --git a/Sources/xcsift/xcsift.docc/PluginInstallation.md b/Sources/xcsift/xcsift.docc/PluginInstallation.md index 4e7da50..5edfcc1 100644 --- a/Sources/xcsift/xcsift.docc/PluginInstallation.md +++ b/Sources/xcsift/xcsift.docc/PluginInstallation.md @@ -147,6 +147,28 @@ cat ~/.cursor/hooks.json xcodebuild build 2>&1 # Should show xcsift-formatted output ``` +## Which Commands the Hooks Rewrite + +The Claude Code and Cursor hooks rewrite a command only when it runs a build or a test: + +```bash +xcodebuild build # becomes { xcodebuild build ; } 2>&1 | xcsift -f toon +swift test --filter Foo # becomes { swift test --filter Foo ; } 2>&1 | xcsift -f toon +cd App && xcodebuild build +``` + +The hooks leave every other command unchanged, and the terminal shows its raw output: + +- Informational commands, such as `xcodebuild -version`, `-list`, `-showsdks`, `-showdestinations`, + `-showTestPlans`, `-showBuildSettings`, `-find-executable`, `-create-xcframework`, + `swift build --show-bin-path`, and any `--help` or `--version` command. These commands print an + answer, not a build log, and xcsift discards that answer. +- Commands with their own pipe or file redirection, such as `xcodebuild build > log.txt` or + `xcodebuild build | tee log.txt`. Your redirection has priority. +- Commands that already call xcsift. + +Codex has no hook support. Its skill tells the model to apply the same rule by hand. + ## Troubleshooting ### Claude Code: "Command not found: claude" diff --git a/Tests/xcsiftTests/PluginFilesTests.swift b/Tests/xcsiftTests/PluginFilesTests.swift new file mode 100644 index 0000000..091cdc8 --- /dev/null +++ b/Tests/xcsiftTests/PluginFilesTests.swift @@ -0,0 +1,227 @@ +import Foundation +import XCTest + +@testable import xcsift + +/// Guards the plugin files that xcsift ships to Claude Code, Cursor, and Codex. +/// +/// Each fact below lives in more than one file, and the copies must agree: the hook decision logic +/// sits in the Cursor template plus two scripts in `plugins/`, and the plugin version sits in three +/// manifests. These tests read every copy, so a partial edit fails instead of shipping. +final class PluginFilesTests: XCTestCase { + + private struct Hook { + let name: String + let path: String + /// The JSON key that the hook uses when it rewrites the command. + let rewriteKey: String + } + + private static let repoRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + + private static let rewrittenCommands = [ + "xcodebuild build", + "xcodebuild test -scheme App", + "swift build", + "swift test --filter FooTests", + "cd App && xcodebuild build", + ] + + private static let passedThroughCommands = [ + "xcodebuild -version", + "xcodebuild -list -json", + "xcodebuild -showBuildSettings -json", + "xcodebuild -project app.xcodeproj -scheme app -showTestPlans -json", + "xcodebuild -showsdks", + "swift build --show-bin-path", + "swift test --help", + "swift package resolve", + "xcodebuild build 2>&1 | xcsift -f toon", + "xcodebuild build > log.txt", + "xcodebuild-foo build", + "git commit -m \"fix xcodebuild build flags\"", + ] + + private var sandbox: URL! + private var searchPath: String! + + override func setUpWithError() throws { + try super.setUpWithError() + + sandbox = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("xcsift-hook-tests-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: sandbox, withIntermediateDirectories: true) + + // The hooks pass every command through when xcsift is absent from PATH. + let stub = sandbox.appendingPathComponent("xcsift") + try "#!/bin/sh\nexit 0\n".write(to: stub, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: stub.path) + + let inherited = ProcessInfo.processInfo.environment["PATH"] ?? "/usr/bin:/bin" + searchPath = "\(sandbox.path):\(inherited)" + } + + override func tearDownWithError() throws { + if let sandbox { + try? FileManager.default.removeItem(at: sandbox) + } + try super.tearDownWithError() + } + + // MARK: - Tests + + func testBuildCommandsAreRewritten() throws { + for hook in try hooks() { + for command in Self.rewrittenCommands { + let rewritten = try rewrittenCommand(from: hook, command: command) + XCTAssertEqual( + rewritten, + "{ \(command) ; } 2>&1 | xcsift -f toon", + "\(hook.name) rewrote '\(command)' incorrectly" + ) + } + } + } + + func testInformationalAndRedirectedCommandsPassThrough() throws { + for hook in try hooks() { + for command in Self.passedThroughCommands { + let rewritten = try rewrittenCommand(from: hook, command: command) + XCTAssertNil( + rewritten, + "\(hook.name) must not rewrite '\(command)'" + ) + } + } + } + + func testCursorTemplateMatchesCheckedInScript() throws { + let checkedIn = try String( + contentsOf: Self.repoRoot.appendingPathComponent("plugins/cursor/hooks/pre-xcsift.sh"), + encoding: .utf8 + ) + + XCTAssertEqual( + CursorTemplates.hookScript, + checkedIn.trimmingCharacters(in: .newlines), + "CursorTemplates.hookScript and plugins/cursor/hooks/pre-xcsift.sh must stay equal" + ) + } + + func testPluginVersionsAgree() throws { + let manifest = try json(at: "plugins/claude-code/.claude-plugin/plugin.json") + let marketplace = try json(at: ".claude-plugin/marketplace.json") + let plugins = try XCTUnwrap(marketplace["plugins"] as? [[String: Any]]) + + let skill = try String( + contentsOf: Self.repoRoot + .appendingPathComponent("plugins/claude-code/skills/xcsift/SKILL.md"), + encoding: .utf8 + ) + let skillVersion = skill.split(separator: "\n") + .first { $0.hasPrefix("version:") }? + .dropFirst("version:".count) + .trimmingCharacters(in: .whitespaces) + + let expected = try XCTUnwrap(manifest["version"] as? String) + XCTAssertEqual( + plugins.first?["version"] as? String, + expected, + "marketplace.json must carry the plugin.json version" + ) + XCTAssertEqual( + skillVersion, + expected, + "The Claude Code SKILL.md must carry the plugin.json version" + ) + } + + private func json(at relativePath: String) throws -> [String: Any] { + let data = try Data(contentsOf: Self.repoRoot.appendingPathComponent(relativePath)) + return try XCTUnwrap(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + } + + // MARK: - Helpers + + private func hooks() throws -> [Hook] { + guard Self.isOnPath("jq") else { + throw XCTSkip("The hook scripts require jq") + } + + let template = sandbox.appendingPathComponent("cursor-template.sh") + try CursorTemplates.hookScript.write(to: template, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: template.path) + + return [ + Hook( + name: "CursorTemplates.hookScript", + path: template.path, + rewriteKey: "updated_input" + ), + Hook( + name: "plugins/cursor/hooks/pre-xcsift.sh", + path: Self.repoRoot.appendingPathComponent("plugins/cursor/hooks/pre-xcsift.sh") + .path, + rewriteKey: "updated_input" + ), + Hook( + name: "plugins/claude-code/scripts/pre-xcsift.sh", + path: Self.repoRoot + .appendingPathComponent("plugins/claude-code/scripts/pre-xcsift.sh").path, + rewriteKey: "updatedInput" + ), + ] + } + + /// Returns the command that the hook substitutes, or nil when the hook passes the command on. + private func rewrittenCommand(from hook: Hook, command: String) throws -> String? { + let payload = try JSONSerialization.data(withJSONObject: ["tool_input": ["command": command]]) + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/bash") + process.arguments = [hook.path] + process.environment = ["PATH": searchPath] + + let input = Pipe() + let output = Pipe() + process.standardInput = input + process.standardOutput = output + process.standardError = Pipe() + + try process.run() + input.fileHandleForWriting.write(payload) + input.fileHandleForWriting.closeFile() + let data = output.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + + guard let root = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + XCTFail("\(hook.name) produced no JSON for '\(command)'") + return nil + } + return Self.command(in: root, under: hook.rewriteKey) + } + + private static func command(in object: [String: Any], under key: String) -> String? { + if let rewrite = object[key] as? [String: Any] { + return rewrite["command"] as? String + } + for value in object.values { + if let child = value as? [String: Any], + let found = command(in: child, under: key) + { + return found + } + } + return nil + } + + private static func isOnPath(_ tool: String) -> Bool { + let path = ProcessInfo.processInfo.environment["PATH"] ?? "" + return path.split(separator: ":").contains { directory in + FileManager.default.isExecutableFile(atPath: "\(directory)/\(tool)") + } + } +} diff --git a/plugins/claude-code/.claude-plugin/plugin.json b/plugins/claude-code/.claude-plugin/plugin.json index 206b3db..6efcc89 100644 --- a/plugins/claude-code/.claude-plugin/plugin.json +++ b/plugins/claude-code/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "xcsift", - "version": "1.0.3", + "version": "1.0.4", "description": "Automatically pipe xcodebuild and swift build output through xcsift for structured TOON format optimized for LLM consumption", "skills": "./skills" } diff --git a/plugins/claude-code/scripts/pre-xcsift.sh b/plugins/claude-code/scripts/pre-xcsift.sh index 7d460ee..bf7d123 100755 --- a/plugins/claude-code/scripts/pre-xcsift.sh +++ b/plugins/claude-code/scripts/pre-xcsift.sh @@ -23,19 +23,22 @@ if ! command -v xcsift &> /dev/null; then exit 0 fi -# Patterns that should be piped through xcsift -# Match: xcodebuild, swift build, swift test (with any arguments) -# But NOT: already piped through xcsift -if echo "$COMMAND" | grep -qE '^\s*(xcodebuild|swift\s+(build|test))\b' && \ - ! echo "$COMMAND" | grep -q 'xcsift'; then - - # Add 2>&1 if not present (to capture stderr) - if ! echo "$COMMAND" | grep -q '2>&1'; then - COMMAND="$COMMAND 2>&1" - fi - - # Pipe through xcsift with TOON format - MODIFIED_COMMAND="$COMMAND | xcsift -f toon" +# Build commands. The leading separator also matches `cd App && xcodebuild build`. +BUILD_RE='(^|[;&|][[:space:]]*)[[:space:]]*(xcodebuild|swift[[:space:]]+(build|test))([[:space:]]|$)' + +# Informational commands print an answer, not a build log. xcsift discards that answer. +QUERY_RE='(^|[[:space:]])--?(version|usage|help|h|list|showsdks|showdestinations|showTestPlans|showBuildSettings|showBuildSettingsForIndex|find-executable|find-library|checkFirstLaunchStatus|create-xcframework|show-bin-path|list-tests)([[:space:]]|$)' + +# Remove the redirections this hook adds, to find the redirections the user wrote. +BARE=$(printf '%s' "$COMMAND" | sed 's/2>&1//g; s/>&2//g') + +if echo "$COMMAND" | grep -qE "$BUILD_RE" \ + && ! echo "$COMMAND" | grep -qE "$QUERY_RE" \ + && ! echo "$COMMAND" | grep -q 'xcsift' \ + && ! echo "$BARE" | grep -qE '[|>]'; then + + # Group the command, so each part of a `;` or `&&` chain goes to xcsift. + MODIFIED_COMMAND="{ $COMMAND ; } 2>&1 | xcsift -f toon" # Return modified command with rewritten input jq -n --arg cmd "$MODIFIED_COMMAND" '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow","updatedInput":{"command":$cmd}}}' diff --git a/plugins/claude-code/skills/xcsift/SKILL.md b/plugins/claude-code/skills/xcsift/SKILL.md index dfb81c7..8bf674a 100644 --- a/plugins/claude-code/skills/xcsift/SKILL.md +++ b/plugins/claude-code/skills/xcsift/SKILL.md @@ -1,7 +1,7 @@ --- name: formatting-build-output description: Use /usr/local/bin/xcsift for all Swift and Xcode build commands. Pipe xcodebuild build, xcodebuild test, swift build, swift test through xcsift to get structured TOON output with compile errors, warnings, test failures, code coverage, slow tests, and build timing. Essential for parsing Xcode build output, diagnosing compile errors, analyzing test results, and measuring code coverage in any Swift or Xcode project. -version: 1.0.3 +version: 1.0.4 --- # xcsift - Xcode Build Output Formatter @@ -15,6 +15,16 @@ xcsift parses and formats xcodebuild/SPM output into token-efficient TOON format - `swift build` / `swift test` - Any command that produces Xcode/SPM build output +## When Not to Use + +Informational commands print an answer, not a build log. xcsift discards that answer, so run them +directly: + +- `xcodebuild -version` / `-usage` / `-help` +- `xcodebuild -list` / `-showsdks` / `-showdestinations` / `-showTestPlans` / `-showBuildSettings` +- `xcodebuild -find-executable` / `-find-library` / `-create-xcframework` +- `swift build --show-bin-path`, `swift test --list-tests`, and any `--help` or `--version` command + ## Usage Pattern Always redirect stderr and use TOON format: diff --git a/plugins/codex/SKILL.md b/plugins/codex/SKILL.md index 8bfde11..09fcfbe 100644 --- a/plugins/codex/SKILL.md +++ b/plugins/codex/SKILL.md @@ -14,6 +14,16 @@ xcsift parses and formats xcodebuild/SPM output into token-efficient TOON format - `swift build` / `swift test` - Any command that produces Xcode/SPM build output +## When Not to Use + +Informational commands print an answer, not a build log. xcsift discards that answer, so run them +directly: + +- `xcodebuild -version` / `-usage` / `-help` +- `xcodebuild -list` / `-showsdks` / `-showdestinations` / `-showTestPlans` / `-showBuildSettings` +- `xcodebuild -find-executable` / `-find-library` / `-create-xcframework` +- `swift build --show-bin-path`, `swift test --list-tests`, and any `--help` or `--version` command + ## Usage Pattern Always redirect stderr and use TOON format: diff --git a/plugins/cursor/hooks/pre-xcsift.sh b/plugins/cursor/hooks/pre-xcsift.sh index 400807b..8a5883a 100755 --- a/plugins/cursor/hooks/pre-xcsift.sh +++ b/plugins/cursor/hooks/pre-xcsift.sh @@ -23,19 +23,22 @@ if ! command -v xcsift &> /dev/null; then exit 0 fi -# Patterns that should be piped through xcsift -# Match: xcodebuild, swift build, swift test (with any arguments) -# But NOT: already piped through xcsift -if echo "$COMMAND" | grep -qE '^\s*(xcodebuild|swift\s+(build|test))\b' && \ - ! echo "$COMMAND" | grep -q 'xcsift'; then - - # Add 2>&1 if not present (to capture stderr) - if ! echo "$COMMAND" | grep -q '2>&1'; then - COMMAND="$COMMAND 2>&1" - fi - - # Pipe through xcsift with TOON format - MODIFIED_COMMAND="$COMMAND | xcsift -f toon" +# Build commands. The leading separator also matches `cd App && xcodebuild build`. +BUILD_RE='(^|[;&|][[:space:]]*)[[:space:]]*(xcodebuild|swift[[:space:]]+(build|test))([[:space:]]|$)' + +# Informational commands print an answer, not a build log. xcsift discards that answer. +QUERY_RE='(^|[[:space:]])--?(version|usage|help|h|list|showsdks|showdestinations|showTestPlans|showBuildSettings|showBuildSettingsForIndex|find-executable|find-library|checkFirstLaunchStatus|create-xcframework|show-bin-path|list-tests)([[:space:]]|$)' + +# Remove the redirections this hook adds, to find the redirections the user wrote. +BARE=$(printf '%s' "$COMMAND" | sed 's/2>&1//g; s/>&2//g') + +if echo "$COMMAND" | grep -qE "$BUILD_RE" \ + && ! echo "$COMMAND" | grep -qE "$QUERY_RE" \ + && ! echo "$COMMAND" | grep -q 'xcsift' \ + && ! echo "$BARE" | grep -qE '[|>]'; then + + # Group the command, so each part of a `;` or `&&` chain goes to xcsift. + MODIFIED_COMMAND="{ $COMMAND ; } 2>&1 | xcsift -f toon" # Return modified command jq -n --arg cmd "$MODIFIED_COMMAND" '{"permission":"allow","updated_input":{"command":$cmd}}'