Shark

June 24, 2026 · View on GitHub

Build

Shark is a Swift command line tool for type-safe resources and a complete localization workflow:

  • shark generate (the default) — type-safe enums for your images, colors, storyboards, fonts, data assets, and localizations, for UIKit, AppKit, and SwiftUI.
  • shark lint — a CI gate that catches missing translations, dead keys, and format-specifier mismatches across all locales.
  • shark translate — fills localization gaps with a local Claude Code or Codex install, or the Claude API for CI, machine-validated and routed through human review.

Because Shark reads your .xcodeproj to find these assets, the setup is extremely simple: one binary, one build phase line, no config file.

Why Shark?

Xcode has been nibbling at this space for years — asset symbols arrived with Xcode 15, type-safe string symbols with String Catalogs after that. SwiftGen and R.swift have generated resource accessors for even longer. So why Shark, in 2026?

Versus Xcode's generated symbols:

  • Generated symbols cover images, colors, and strings — the latter only if you've migrated to String Catalogs. Shark additionally covers fonts, storyboards, and data assets, speaks both .xcstrings and classic .strings (which is what long-lived production apps actually contain), and generates the same API across UIKit, AppKit, and SwiftUI.
  • Shark namespaces by your asset-catalog folder structure (Provides Namespace) and by localization key separators: L.login.error.message("…") instead of a flat symbol soup. With --target, --exclude, and --name it handles white-label/multi-target setups that symbol generation has no answer for.
  • Most importantly: codegen was never the whole problem — keeping localizations complete is. Xcode will not tell your CI that 21 keys are missing in Japanese. shark lint will, and shark translate will fix it, with format-specifier preservation checked by machine and the result parked as needs_review for a human.

Versus SwiftGen / R.swift:

  • Shark reads the .xcodeproj directly and is target-aware — no swiftgen.yml, no templates, no resource-bundle plumbing. The generated file is plain Swift with zero runtime dependency.
  • Both are codegen-only tools. Neither lints localization completeness, neither translates, and neither validates that %1$@ survived a translator.

Versus project generators (XcodeGen, Tuist): different problem — they generate projects, not resource accessors or localization workflows. Shark happily works on projects they generate.

The three subcommands share one project model and one format-specifier parser: what generate turns into a function signature is exactly what lint checks and what translate refuses to let a model break.

A bit of history: WWDC 2023 sherlocked our colors and images, WWDC 2025 our localization accessors. We took it as a compliment — and built the workflow parts Apple didn't.

The generated code

Here's what a generated Shark.swift file for UIKit looks like and how it is used in a codebase:

// Shark.swift
// Generated by Shark https://github.com/kaandedeoglu/Shark

import UIKit

// swiftlint:disable all
public enum Shark {
    private static let bundle: Bundle = {
        class Custom {}
        return Bundle(for: Custom.self)
    }()

    public enum I {
        public enum Button {
            public static var profile: UIImage { return UIImage(named:"profile", in: bundle, compatibleWith: nil)! }
            public static var cancel: UIImage { return UIImage(named:"cancel", in: bundle, compatibleWith: nil)! }
            public static var user_avatar: UIImage { return UIImage(named:"user_avatar", in: bundle, compatibleWith: nil)! }
        }
    }

    public enum C {
        public static var blue1: UIColor { return UIColor(named: "blue1", in: bundle, compatibleWith: nil)! }
        public static var blue2: UIColor { return UIColor(named: "blue2", in: bundle, compatibleWith: nil)! }
        public static var gray1: UIColor { return UIColor(named: "gray1", in: bundle, compatibleWith: nil)! }
        public static var gray2: UIColor { return UIColor(named: "gray2", in: bundle, compatibleWith: nil)! }
        public static var green1: UIColor { return UIColor(named: "green1", in: bundle, compatibleWith: nil)! }
        public static var green2: UIColor { return UIColor(named: "green2", in: bundle, compatibleWith: nil)! }
    }

    public enum F {
        public static func gothamBold(ofSize size: CGFloat) -> UIFont { return UIFont(name: "Gotham-Bold", size: size)! }
        public static func gothamMedium(ofSize size: CGFloat) -> UIFont { return UIFont(name: "Gotham-Medium", size: size)! }
        public static func gothamRegular(ofSize size: CGFloat) -> UIFont { return UIFont(name: "Gotham-Regular", size: size)! }
    }

    public enum L {
        public enum button {
            /// Login
            public static var login: String { return NSLocalizedString("button.login", bundle: bundle, comment: "") }

            /// Logout
            public static var logout: String { return NSLocalizedString("button.logout", bundle: bundle, comment: "") }
        }

        public enum login {
            /// Please log in to continue
            public static var title: String { return NSLocalizedString("login.title", bundle: bundle, comment: "") }

            /// Skip login and continue
            public static var skip: String { return NSLocalizedString("login.skip", bundle: bundle, comment: "") }

            public enum error {
                /// Login failed
                public static var title: String { return NSLocalizedString("login.error.title", bundle: bundle, comment: "") }

                /// Operation failed with error: %@
                public static func message(_ value1: String) -> String {
                    return String(format: NSLocalizedString("login.error.message", bundle: bundle, comment: ""), value1)
                }
            }
        }
    }
}

// At the call site
imageView.image = Shark.I.Button.profile
label.font = Shark.F.gothamBold(ofSize: 16.0)
label.text = Shark.L.login.title
view.backgroundColor = Shark.C.green1

// You can also make it prettier with typealiases
typealias I = Shark.I
typealias C = Shark.C
typealias F = Shark.F
typealias L = Shark.L

imageView.image = I.Button.profile
label.font = F.gothamBold(ofSize: 16.0)
label.text = L.login.error.message("I disobeyed my masters")
view.backgroundColor = C.green1

There are a few things to notice:

First, have a look at this Xcode screenshot from the inspector pane of an asset catalogue's folder entry:

If you place your image and color assets in folders, Shark will create namespaced enums ­– provided you have configured the respective Xcode setting Provides Namespace. If you have deeply nested folders, Shark will respect every one's individual namespace setting.

Localizations are always namespaced with separators. Currently Shark uses the dot symbol . as the separator. As you can see localization keys are recursively namespaced until we get to the last component.

Installation

Homebrew

brew install kaandedeoglu/formulae/shark

Mint

mint install kaandedeoglu/formulae/shark

Manually

Clone the project, then do:

> swift build -c release
> cp ./build/release/Shark /usr/local/bin

You can then verify the installation by doing

> shark --help

Compatibility Notes

Shark 1.8.6 handles Xcode projects whose local Swift package dependencies emit manifest warnings during swift package dump-package. Older Shark builds may fail these projects with a generic "data couldn't be read because it isn't in the correct format" parse error.

If you use Xcode's discovered dependency file setting in a Run Script phase, keep the dependencyFile project value quoted when it contains path separators, for example:

dependencyFile = "/tmp/deps-MyApp.d";

Setup (Easy)

  • Add a new Run Script phase to your target's build phases. This build phase should ideally run before the Compile Sources phase. The script body should look like the following:

    unset SDKROOT
    if [ -x "$(command -v shark)" ]; then
    shark $PROJECT_FILE_PATH $PROJECT_DIR/$PROJECT_NAME/
    fi
    

    the if/fi block makes sure that Shark runs only if it's installed on the current machine.

  • Build your project. You should now see a file named Shark.swift in your project folder.

  • Add this file to your target. Voila! Shark.swift will be updated every time you build the project.

  • Alternatively you can do the following:

    # Write to a specific file called MyAssets.swift
    shark $PROJECT_FILE_PATH $PROJECT_DIR/$PROJECT_NAME/MyAssets.swift
    
    # Write to a specific file in a different folder
    shark $PROJECT_FILE_PATH $PROJECT_DIR/$PROJECT_NAME/Utility/MyAssets.swift
    

    With this setup, Shark will generate the resource files on every code run. This can add several seconds to the build time which you may not like.

Setup (Advanced)

If you want to run Shark only when it really needs to, then make the following adjustments:

  1. Edit the command line to have Shark create a dependency file. This file will contain the paths of all the assets in your project.
shark $PROJECT_FILE_PATH $PROJECT_DIR/$PROJECT_NAME --deps /tmp/deps-$PROJECT_NAME.d
  1. Add the output file (e.g. ${SRCROOT}/Shark.swift) as a dependency to the Run Script phase.

  2. Check the [x] Based on dependency analyses in the build phase.

  3. Check the [x] Use discovered dependency file: and enter the path to the dependency file.

At the end of all that, your run Script phase should look similar to this:

Shark build phase in Xcode

Options & Flags

Shark also accepts the following command line options to configure behavior

--name

By default, the top level enum everything else lives under is called - you guessed it - Shark. You can change this by using the --name flag.

 shark $PROJECT_FILE_PATH $PROJECT_DIR/$PROJECT_NAME --name Assets

--locale

By default, Shark will try to find English localizations to generate the localizations enum. If there are no English .strings file in your project, or you'd like Shark to take another localization as base, you can specify the language code with the --locale flag.

# Use Spanish localizations for generation
shark $PROJECT_FILE_PATH $PROJECT_DIR/$PROJECT_NAME --locale es

--target

In case your Xcode project has multiple application targets, you should specify which one Shark should look at by using the --target flag.

shark $PROJECT_FILE_PATH $PROJECT_DIR/$PROJECT_NAME --target MyAppTarget

--visibility

By default, Shark will create all properties with the visibilty of public. Submit this option to change this to, e.g., internal.

--framework

By default, Shark creates code for UIKit. Specify --framework appkit to create code for AppKit, and --framework swiftui for SwiftUI.

--separator

Shark will split localization keys using the separator character value, and create nested enums until we hit the last element. For example, the lines login.button.positive = "Log in!"; and login.button.negative = "Go back..."; will create the following structure inside the top level localizations enum L:

public enum login {
    public enum button {
        public static var positive: String { return NSLocalizedString("login.button.positive") }
        public static var negative: String { return NSLocalizedString("login.button.negative") }
    }
}

By default, the separator is ., only single character inputs are accepted for this option.

--top-level-scope

Declares the I, C, F, L enums in the top level scope instead of nesting it in a top level Shark enum.

--deps

Add the path to a Makefile-style dependency file. This gives Xcode a hint when to skip calling build phase because nothing has changed.

When Xcode persists this path into project.pbxproj, paths such as /tmp/deps-MyApp.d should remain quoted for parser compatibility.

--exclude

By default, Shark will process all resource files it knows about. If you don't want that, you can specify exceptions. Note that Shark uses infix matching to identify files to exclude.

--help

Prints the overview, example usage and available flags to the console.

Localization workflow: lint & translate

Beyond code generation (shark generate, the default subcommand), Shark 2.0 helps with the localization workflow itself.

shark lint

Checks all localization tables of a target across all locales:

shark lint MyApp.xcodeproj
RuleSeverity
missing-key — key exists in the source locale but is missing or empty in anotherfails the run
placeholder-mismatch — format specifiers differ between source and translation (catches String(format:) crashes; positional reordering like %2$@ … %1$@ is not a mismatch, and prose percent signs like "25% and" are filtered)fails the run
empty-source-value — key has an empty value in the source locale itself (a dead key, reported once at the root instead of per locale)fails the run
orphaned-key — key exists only outside the source localefails only with --strict

Exit code 1 on findings makes it a CI gate. --format github emits workflow annotations, --format json is for tooling. Plural keys are reported but not checked yet.

shark translate

Finds keys that are missing in the target locale(s) and translates them with a local agent by default:

shark translate MyApp.xcodeproj --to de,fr --glossary Glossary.md --context AppContext.md

# CI / direct API
ANTHROPIC_API_KEY=sk-ant-... shark translate MyApp.xcodeproj --to de,fr --backend api --yes

# Codex CLI
shark translate MyApp.xcodeproj --to de,fr --backend codex --yes

How it stays safe:

  • Only missing or empty entries are candidates — existing translations (including ones in review) are never overwritten.
  • Every returned value is machine-validated: format specifiers must survive translation exactly (normalized by position). Rejected values get one retry with the rejection reason; persistent failures are reported, never written.
  • Results land as needs_review in .xcstrings, so Xcode's String Catalog editor surfaces them for human review. For .strings files, new keys are appended under a review comment.
  • A confirmation prompt shows a token/cost estimate before anything is sent (--yes skips it, --dry-run only lists).

Backends (--backend claude-code|api|codex|auto):

  • claude-code (default) — pipes through a locally installed Claude Code binary with structured-output validation and bills against your existing Claude subscription. No API key needed.
  • api — direct Messages API via ANTHROPIC_API_KEY: structured output, prompt caching across batches, parallel requests. The right choice for CI.
  • codex — pipes through a locally installed Codex CLI binary (codex exec) and writes the final response through Codex's structured-output schema support.
  • autoapi if a key is set, otherwise claude-code, otherwise codex.

--glossary takes a Markdown file with project terminology, --context a short app description — both are passed to the model (and cached across batches on the api backend). --model selects the model; Claude backends default to claude-opus-4-8, while codex uses your Codex CLI default unless set.

Smoke tests

Shark keeps committed synthetic .xcodeproj fixtures under Examples/ and runs them in CI:

Scripts/smoke-fixtures.sh

The fixture smoke covers:

  • Format90Example — objectVersion 90 and the Xcode 16.3 shell-script array form, plus a clean generate/lint path.
  • XcodeGenSmoke — a temporary project generated by XcodeGen during the smoke run. This currently covers objectVersion 77 on XcodeGen 2.45.x and guards against parser regressions in generator-produced projects.
  • LocalizationWorkflowExample — expected localization findings across .strings and .xcstrings, skipped plural catalog entries, and translate --dry-run gap discovery without calling a model backend.

Real-world projects are useful before release tags, but they are intentionally opt-in because they may be private, target-dependent, or machine-specific:

SHARK_REAL_WORLD_ROOT="$HOME/Documents/late" Scripts/smoke-real-world.sh

When a real-world smoke exposes a bug, reduce it to the smallest reproducible synthetic fixture and commit that fixture with the regression test.

License

The MIT License (MIT)

Copyright (c) Kaan Dedeoglu, Dr. Michael 'Mickey' Lauer, and contributors.

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.