I built onyx to learn, not to compete
Same module surface in Go and Rust. paths, files, shell, clipboard, notify, keyring. Two crates that exist because I wrote them.
This week I open-sourced two crates. One Go module, one Rust crate. Same name, same surface: osinfo, paths, files, shell, clipboard, notify, keyring. The whole thing is called onyx. Code at github.com/akira-io/onyx (Go) and github.com/akira-io/onyx-rs (Rust).
I did not build it to compete with anything.
Onyx is a study project I let escape into MIT. The point was the shape of cross-platform desktop glue, not the market. dirs, arboard, notify-rust, keyring-rs. They all exist. They all work. Reading other people’s libraries teaches you how they think. Writing your own teaches you what you believe.
The problem
Every desktop application I have written rewrites the same OS shims:
- Find the user config directory.
- Open a file with the default app.
- Reveal it in the file manager.
- Resolve a CLI binary on PATH with platform-specific fallbacks.
- Read and write the clipboard.
- Show a notification.
- Store a secret in the system keyring.
switch runtime.GOOS {
case "darwin":
return filepath.Join(home, "Library", "Application Support", app), nil
case "linux":
if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
return filepath.Join(xdg, app), nil
}
return filepath.Join(home, ".config", app), nil
case "windows":
if v := os.Getenv("APPDATA"); v != "" {
return filepath.Join(v, app), nil
}
return filepath.Join(home, "AppData", "Roaming", app), nil
}
That block lives in every desktop app I have shipped. The cost is not the lines of code. The cost is the cognitive tax of confirming, again, which OS Linux means and which path Windows wants this week.
The rule
Onyx replaces that block with one line.
// github.com/akira-io/onyx/paths
config, err := paths.For("Hyperion").Config()
One function. One verb. One return shape. The Resolver follows the same discipline.
// github.com/akira-io/onyx/shell
claude, err := shell.NewResolver().
Lookup("claude").
Lookup("/opt/homebrew/bin/claude").
Resolve()
Lookup accepts either a name resolved via PATH or an absolute path checked as a file. The package figures out which because the difference is mechanical: if the string has a separator, it is a path. The caller does not pick.
I do not think this is the best API. I think it is the API I will reach for, which is a different and more useful claim.
Why the Rust mirror
onyx-rs is the same drill in a second language. Same module names, same conventions, idiomatic in each. paths.For("Hyperion").Config() on the Go side becomes paths::for_app("Hyperion").config() on the Rust side. shell.NewResolver() becomes shell::Resolver::new(). The Go module returns (string, error). The Rust crate returns Result<PathBuf, ShellError>.
The point of the Rust port is not portability for users. It is portability for me. Writing the same shapes in Rust forced me to look at every default I had not noticed in the Go version: error variants, builder ergonomics, path types, what to derive, what to expose, what to keep crate-private.
The obvious counter
“There are libraries for this.”
Yes. dirs, arboard, notify-rust, keyring-rs. They have more contributors, more downloads, more battle-test than onyx will ever have. If you want a dependency you can lean on, use them.
Onyx is for the same reason people rewrite their resume in plain text every two years. Not because the format matters. Because the act of typing it forces you to remember what you do.
Closer
I open-sourced it because shipping under your name is the only way you keep the code honest. Public README. Public conventions. Public mistakes. The crate that lives in a private vendor folder forever is not a study project. It is a draft.
Onyx is a draft I committed to ship.