https://blog.ar-ms.me/thoughts/translation-cli/
[stamp]
[?][?]
The Unbearable Frustration of Figuring Out APIs
or: Writing a Translation Command Line Tool in Swift.
2026-01-14
Contents
* First Steps
+ Zig
+ MyCLI
* False Starts
* Async Woes
* Language Recognition
* Installing Models
* Final Code
* In Conclusion
In the ongoing effort for activities that fill the void made by
unemployment, I have recently started to learn Chinese. Got into a
Chinese language institute and everything. And because not every app
supports right-clicking some text and selecting the "Translate" menu
option, I found myself launching TextEdit just to do that. So, I
figured, maybe I can do a small command line tool where I'd write
something like:
translate Ni Hao
And the terminal would happily tell me it means "Hello". Should be
easy. A hungry ghost trapped in a jar can probably figure it out in
one shot.
First I figured I'd look for some translation API. Almost everything
I looked at seemed to want an API token and there is a rate limit
(not that I'd hit it), and maybe a credit card number. Then I
remembered that macOS has that Translate right click action, surely I
can just hook into that.
Note that this article, unlike what I do usually, is written after
the fact.
---------------------------------------------------------------------
First Steps
Zig
I have been using Zig for different projects this year. So I got the
Ghost to write the thing for me, and it even gave me the correct
flags to pass to the compiler! But then when I tried it, I realized
it used the Dictionary service instead of the Translation service.
When I asked it for that instead, it was like yeah you cannot call
Swift async functions from Zig, you need a Swift shim.
So let's do the thing in Swift instead. I have been meaning to learn
Swift anyway.
MyCLI
Thankfully, Swift was already set up on my machine with an LSP and a
formatter and the works. I had even followed the basic tutorial and
the file is on my machine already, A small edit and I can be on my
way.
For reference, this is the code at the end of the tutorial:
import ArgumentParser
import Figlet
@main
struct FigletTool: ParsableCommand {
@Option(help: "Specify the input")
public var input: String
public func run() throws {
Figlet.say(self.input)
}
}
After so long in Rust and Zig lands, the most jarring thing about
Swift so far is that import creates glob imports. This is not quite
apparent here, but it is there. Also, this is the accompanying
Package.swift, comments and all.
// swift-tools-version: 5.8
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
name: "MyCLI",
dependencies: [
.package(url: "https://github.com/apple/example-package-figlet", branch: "main"),
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.0.0"),
],
targets: [
// Targets are the basic building blocks of a package, defining a module or a test suite.
// Targets can depend on other targets in this package and products from dependencies.
.executableTarget(
name: "MyCLI",
dependencies: [
.product(name: "Figlet", package: "example-package-figlet"),
.product(name: "ArgumentParser", package: "swift-argument-parser"),
],
path: "Sources"),
]
)
This is run with swift run, but to pass arguments, one needs to do
the following:
swift run MyCLI --input Hello
I spent like 15 minutes trying different variations of this
# this does NOT work
swift run -- --input Hello
Anyway, I deleted the Figlet related lines and went from there.
---------------------------------------------------------------------
False Starts
I asked the Ghost in the Jar for how the API to use it looks like,
but every version it gave me was hallucinated in some fashion. So I
wrote import Translation at the top of the file, and figured maybe I
can figure it out as I go along. Here is the Apple API documentation.
A quick look tells you all the functions in here return a some View,
and the only function seemingly unrelated to SwiftUI is the init
function in TranslationSession, which, annoyingly, needs a known
source language.
So I do this
import ArgumentParser
import Translation
@main
struct Translate: ParsableCommand {
@Argument(help: "Specify the input")
public var input: String
public func run() async throws {
print(">\t\(input)")
let source = Locale.Language(identifier: "zh_CN")
let target = Locale.Language(identifier: "en_US")
let session = TranslationSession(installedSource: source, target: target)
let response = try await session.translate(input)
let result = response.targetText
print(">\t\(result)")
}
}
You can see the number of small changes to the original file here. I
changed the struct's name (because it shows up in the --help
message.) I changed Option to Argument so it does not need a flag. I
even turned run into an async function so I can await the
session.translate function call.
However, I start to hit a couple of snags. First is that
TranslationSession.init is restricted to macOS 26. Not wanting to do
a bunch of conditional compilation, (after all this tool is for my
own use), I will just add the platforms field in Package.swift. Oh
it needs to be before dependencies? Fine, whatever.
// swift-tools-version: 5.8
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
name: "MyCLI",
platforms: [
.macOS( /* one problem, I cannot find v26 in the dropdown */)
],
dependencies: [
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.0.0")
],
targets: [
// Targets are the basic building blocks of a package, defining a module or a test suite.
// Targets can depend on other targets in this package and products from dependencies.
.executableTarget(
name: "MyCLI",
dependencies: [
.product(name: "ArgumentParser", package: "swift-argument-parser")
],
path: "Sources",
)
]
)
I cannot find .v26 in the LSP's drop down. There is no .Tahoe either.
Even if I type it manually, I get an error. I am on Tahoe. I have the
latest version of Swift (I checked). why can't I select it from here?
You probably already know this, but apparently the first line in the
file, that comment, is actually significant. I edited that to say
swift-tools-version: 6.2, and now I can write .v26 in peace. I hate
syntactic comments.
Ok, the LSP is happy. The compiler seems happy. Let's get going.
---------------------------------------------------------------------
Async Woes
Running this with swift run -q MyCLI Ni Hao gives the following
message:
USAGE: translate
ARGUMENTS:
Specify the input
OPTIONS:
-h, --help Show help information.
Eh, I clearly passed in something. What gives? I tried changing
Argument back to Option, same result. Hm. I realized that the problem
maybe is that run is not supposed to be async. Fine, let's remove
async, but then how do I call session.translate ?
Looking things up, and communing with ghosts, apparently I can wrap
thing in Task. Sure.
Task {
let response = try await session.translate(input)
let result = response.targetText
print(">\t\(result)")
}
And this runs! I rerun the command I do swift run -q MyCLI Ni Hao and I
get ... nothing. Just the print message that prints input at the
start. I think hah .. I have fallen into the classic trap, I am
spawning a Task but I am not waiting for it. How do I wait for it?
The ghosts suggested a DispatchSemaphore. This is the full code:
import ArgumentParser
import Translation
@main
struct Translate: ParsableCommand {
@Argument(help: "Specify the input")
public var input: String
public func run() throws {
print(">\t\(input)")
let source = Locale.Language(identifier: "zh_CN")
let target = Locale.Language(identifier: "en_US")
let session = TranslationSession(installedSource: source, target: target)
let semaphore = DispatchSemaphore(value: 0)
Task {
let response = try await session.translate(input)
let result = response.targetText
print(">\t\(result)")
semaphore.signal()
}
semaphore.wait()
}
}
Ok .. for some reason, when I tried this right now, it actually
works. But when I tried it before, probably with a different
arrangement of stuff, it did not. It just froze and gave no feedback.
The power of hindsight, I guess.
Nonetheless, since it did not work, I figured I was doing something
wrong. So I searched for "how to call an async function from a sync
function in Swift", and came across What calls the first async
function? from Hacking With Swift, and the tl;dr of that article was
"not you, just make your main async". Very helpful, Paul. Apparently
I needed to make run async. I looked into the docs of
swift-argument-parser, I figured surely they have some guidance on
how to deal with async functions. Apparently the guidance was just to
replace the ParsableCommand protocol with AsyncParsableCommand. Ok,
now I felt like an idiot. This is the full code:
import ArgumentParser
import Translation
@main
struct Translate: AsyncParsableCommand {
@Argument(help: "Specify the input")
public var input: String
public func run() async throws {
print(">\t\(input)")
let source = Locale.Language(identifier: "zh_CN")
let target = Locale.Language(identifier: "en_US")
let session = TranslationSession(installedSource: source, target: target)
let response = try await session.translate(input)
let result = response.targetText
print(">\t\(result)")
}
}
Works like magic now. Except, at the time, it did not. It gave me the
following message:
> Ni Hao
Error: Unable to Translate
Also it leaves the small detail of autodetecting the language, which
is here hardcoded as zh_CN.^1
---------------------------------------------------------------------
Language Recognition
The error had zero feedback. Absolutely unhelpful. No idea what's
going on. I added a try await session.prepareTranslation() line I saw
in some online examples, but .. nothing. I looked a bit more around
for tutorials, I could not find anything. All tutorials just
regurgitate the same SwiftUI code examples, but I am not using
SwiftUI. Ekh.
Eventually, I came across the original WWDC video introducing the
Translation API, and decided to watch it. And it was actually very
helpful, despite it being SwiftUI focused.
It does not touch on how to use TranslationSession.init, instead it
mentions that a session is given to you to run in a SwiftUI closure.
It gives the rationale behind the API, but it also introduces the bit
I needed for autodetection, and that is NLLanguageRecognizer.
NaturalLanguage is an Apple framework for dealing with natural
language (How did you guess?). I pretty much copied the code from the
video into its own function (after import NaturalLanguage).
func identify_lang(_ sample: String) -> Locale.Language? {
let recognizer = NLLanguageRecognizer()
recognizer.processString(sample)
guard let language = recognizer.dominantLanguage else { return nil }
return Locale.Language(identifier: language.rawValue)
}
And this is my new func run()
public func run() async throws {
print(">\t\(input)")
// unwrapping here but I handle it more gracefully in the code
let source = identify_lang(input)!
let target = Locale.Language(identifier: "en_US")
let session = TranslationSession(installedSource: source, target: target)
try await session.prepareTranslation()
let response = try await session.translate(input)
let result = response.targetText
print(">\t\(result)")
}
The other thing the video pointed out, is that the API user should
check language availability. I saw that earlier, but did not
particularly care. I know Chinese and English are available. I have
been using them! Nonetheless, I copied that code as well just to be
thorough, to figure out what the API does.
let availability = LanguageAvailability()
let is_it = await availability.status(from: source, to: target)
switch is_it {
case .unsupported:
print("> language pairing from \(source.languageCode) to \(target.languageCode) unsupprted")
return
case .supported:
print("> language pairing from \(source.languageCode!) to \(target.languageCode!) not installed")
return
case .installed:
break
}
This gave me a compile error, as it happens, as apparently the enum
is marked the Swift equivelant of #[non_exhaustive]. Going over
StackOverflow and the Swift book, told me the answer is to add a
@unknown default: case. I could not quickly figure out how to merge
it with another case so I did not bother.
To my surprise, the pairing returned .supported, and not .installed.
In a SwiftUI app, the try await session.prepareTranslation() call
creates a pop up to ask the user to download the model.
But it is already installed! I am already using it via the right
click menu! Nonetheless, that must be the reason for the Unable to
Translate error.
---------------------------------------------------------------------
Installing Models
Communing again with Ghosts, they told me to install the models
through System Settings, General, Language, etc. I went where they
told me, to this dialogue, where I can install the models locally.
the dialogue
You can see that Mandarin is installed now, but the only language
installed at the time was English(US). No matter, I installed
Mandarin SImplified and Arabic, and added a note to the switch above
showing where to install the items. And then I tried again: swift run
-q MyCLI Ni Hao .
And .. same error. I guessed that it identified the language as
Mandarin Traditional for some reason, so I installed that as well,
and now it works, finally!!
% swift/MyCLI swift run -q MyCLI Ni Hao
> Ni Hao
> Hello
Why was that so hard? Why are the models here separate from the ones
in the right click menu? Too many questions.
---------------------------------------------------------------------
Final Code
This is the full final code, with some proper error names thrown in
for good measure.
import ArgumentParser
import NaturalLanguage
import Translation
@main
struct Translate: AsyncParsableCommand {
@Argument(help: "Specify the input")
public var input: String
public func run() async throws {
print(">\t\(input)")
let target = Locale.Language(identifier: "en_US")
guard let source = identify_lang(input) else {
print("> could not identify language")
throw CliError.Recognitionfailed
}
let availability = LanguageAvailability()
let is_it = await availability.status(from: source, to: target)
switch is_it {
case .unsupported:
print("> language pairing from \(source.languageCode) to \(target.languageCode) unsupprted")
throw CliError.PairingUnsupported
case .supported:
print("> language pairing from \(source.languageCode!) to \(target.languageCode!) not installed")
print("> Go to System Settings > General > Language & Region > Translation Languages and download the models.")
throw CliError.PairingNotInstalled
case .installed:
break
@unknown default:
print("Unknown status.")
}
let session = TranslationSession(installedSource: source, target: target)
try await session.prepareTranslation()
let response = try await session.translate(input)
let result = response.targetText
print(">\t\(result)")
}
}
func identify_lang(_ sample: String) -> Locale.Language? {
let recognizer = NLLanguageRecognizer()
recognizer.processString(sample)
guard let language = recognizer.dominantLanguage else { return nil }
return Locale.Language(identifier: language.rawValue)
}
enum CliError: Error {
case Recognitionfailed
case PairingUnsupported
case PairingNotInstalled
}
So I compiled it for release, put it in /usr/local/bin, and called it
a day. You can see an example of a failing call if you do swift run
-q MyCLI Bonjour, where it prompts to install the dictionary.
In Conclusion
To rub some salt on the wound, during the time I was trying to puzzle
this out, I realized that Spotlight (with the Cmd+Space key) can
already do this exactly how I envisioned it, with around the same
number of keystrokes, and it does not need this dance with installing
models and whatever.^2
spotlight
Also, Swift is frustrating. API of Swift libraries is frustrating.
Until later.
---------------------------------------------------------------------
1. By the way, there is no documentation anywhere I could find on
what the strings accepted here are. The only comment exists is "A
Unicode language identifier, like en-US, es-419, or zh-Hant-TW."
No list or anything. Am I supposed to guess? -
2. Also this supports Chinese input, while Ghostty's quick terminal
does not, for some reason. -
[?]