Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
29 changes: 16 additions & 13 deletions Sources/xcsift/Install/Templates/CursorTemplates.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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}}'
Expand Down
10 changes: 10 additions & 0 deletions Sources/xcsift/Install/Templates/SharedTemplates.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
22 changes: 22 additions & 0 deletions Sources/xcsift/xcsift.docc/PluginInstallation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
227 changes: 227 additions & 0 deletions Tests/xcsiftTests/PluginFilesTests.swift
Original file line number Diff line number Diff line change
@@ -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)")
}
}
}
2 changes: 1 addition & 1 deletion plugins/claude-code/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
}
29 changes: 16 additions & 13 deletions plugins/claude-code/scripts/pre-xcsift.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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}}}'
Expand Down
12 changes: 11 additions & 1 deletion plugins/claude-code/skills/xcsift/SKILL.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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:
Expand Down
Loading