Package Exports
This package does not declare an exports field, so the exports above have been automatically detected and optimized by JSPM instead. If any package subpath is missing, it is recommended to post an issue to the original package (@blackasteroid/riu) to support the "exports" field. If that is not possible, create a JSPM override to customize the exports field for this package.
Readme
rust-icon-uploader (riu)
Automate uploading icon-only Rust workshop skins from the command line.
riu generates the right manifest.txt, validates your icon PNG, and pushes the workshop item to Steam — interactively (Ink TUI) or headless via JSON (agent-friendly). Every command supports --dry-run against a mock Steam client so you can exercise the whole pipeline without libsteam_api.dylib.
Why this exists
Most Rust items aren't on Facepunch's official skinnable list (UAV, Patrol Signal, etc.), so you can't make traditional texture skins for them. But there's a workaround: you can upload a workshop item with an empty Textures block and Rust will use the workshop preview image as the in-game inventory icon.
Doing this by hand is tedious. There's a Windows-only Unity tool called Rust Custom Icons that automates the upload, but if you're on macOS or want to script it from CI / an agent, you're stuck. riu is the cross-platform replacement — Node.js, no Bun, no Zig, no Windows.
Install
npm install -g @blackasteroid/riuThat's it — riu is now on your PATH. Requires Node.js 20+.
To upgrade later:
riu upgraderiu checks npm for a newer version once a day in the background and prints a one-line notice to stderr when one is available (skipped automatically in --json mode so agents don't see noise). You can also run riu upgrade --check to query manually.
From source (development)
git clone git@github.com:BlackAsteroid/rust-icon-uploader.git
cd rust-icon-uploader
npm install
npm run build
npm link # makes the `riu` command available globally from this checkoutPrerequisites for real uploads
--dry-run works on any machine. For real uploads you need:
- Steam client running, logged into the account that owns Rust (AppID 252490).
libsteam_api.dylib—riu initandriu install-sdkwill auto-detect and copy this from your local Rust install (it ships asRustClient.app/Contents/PlugIns/libsteam_api.bundle, a flat universal Mach-O). You don't need to download anything from Valve.
If you don't have Rust installed locally, you can also:
- Install Spacewar (free, AppID 480) which ships the SDK reference binaries
- Pass
--from <path>toriu install-sdkwith a manually-acquired dylib - Register as a Steamworks partner (free) and download the SDK from https://partner.steamgames.com
Run riu doctor after setup to verify everything is wired up.
Quick start
# 1. one-time setup — auto-detects libsteam_api.dylib from your Rust install
riu init # interactive: only asks for SteamID
# or headless:
riu init --json '{"authorId":"76561198xxxxxxxxx"}' --force
# 2. verify the environment
riu doctor
# 3. generate a skin folder from one of the example icons
riu new
# 4. upload it (use --dry-run first to confirm without touching Steam)
riu upload ./skins/my-uav-skin --dry-run
riu upload ./skins/my-uav-skin
# 5. see what you've uploaded
riu listCommand reference
All commands accept --json [payload] for headless / agent use and --dry-run to use the mock Steam client.
riu init
One-time setup wizard. Asks for SDK path, SteamID64, default tags, etc., and writes them to ~/Library/Application Support/rust-icon-uploader-nodejs/config.json (macOS).
# interactive
riu init
# headless (for agents / CI)
riu init --json '{
"steamSdkPath": "/Users/me/steamworks_sdk",
"authorId": "76561198194158447",
"defaultTags": ["Version3", "Skin"]
}' --force
# show current config
riu init --show
riu init --show --jsonFlags:
--showprint current config--forceoverwrite existing config--dry-runvalidate without writing--json [payload]headless mode (payload required for write, omit for--show)
riu install-sdk
Auto-locates libsteam_api.dylib (or .bundle) from a local Steam game install and copies it to ~/.riu/sdk/libsteam_api.dylib. Updates the existing config to point at the managed location.
riu install-sdk # auto-locate and install
riu install-sdk --dry-run --json # show what would be installed
riu install-sdk --from /path/to/libsteam_api.dylib # explicit source
riu install-sdk --force # reinstall over existingThis command is run automatically by riu init if no steamSdkPath is provided. Useful as a standalone after installing Rust on a new machine.
riu upgrade
Upgrade riu to the latest published version on npm.
riu upgrade # full upgrade
riu upgrade --check # check only, don't install
riu upgrade --check --json # structured output for scripting
riu upgrade --dry-run # show the npm install command without running itBackground update checks run automatically on every command (cached 1 day) and print a stderr notice if a newer version is available. Skipped automatically when --json / --version / --help is used so output stays clean for agents.
riu doctor
Diagnostic check: verifies config exists, dylib is present, Steam is running, SteamID format is valid, AppID is set. Exits non-zero if any check fails.
riu doctor
riu doctor --jsonriu new
Generates a complete skin folder (manifest.txt + icon.png).
# interactive Ink wizard
riu new
# headless
riu new --json '{
"itemType": "Grenade",
"title": "UAV Custom Icon",
"description": "BusinessCore UAV signal",
"tags": ["Version3", "Skin"],
"iconPath": "/path/to/uav.png",
"outputDir": "./skins/uav"
}'
# validate without writing
riu new --json '{...}' --dry-runIcon validation (sharp): PNG, square, dimensions in {256, 512, 1024, 2048}, ≤1MB. RGBA preferred.
Known item types: Grenade (UAV / Patrol Signal), Rock, Pistol, Rifle, Shotgun, SMG, Bow, Sword, Spear, Knife, Axe, Hammer, Tool, Hat, Shirt, Pants, Boots, Gloves, Container, SleepingBag, Furniture. Pass "allowCustomItemType": true in the payload to use a string outside this list.
riu upload [skin-dir]
Uploads a single skin folder to the workshop.
# positional arg
riu upload ./skins/uav
# with overrides
riu upload ./skins/uav --title "Custom UAV v2" --tags "Version3,Skin,UAV"
# headless JSON
riu upload --json '{
"skinDir": "./skins/uav",
"title": "UAV Custom Icon",
"description": "...",
"tags": ["Version3","Skin"]
}'
# dry run with mock client (no Steam contact)
riu upload ./skins/uav --dry-runInteractive mode shows a staged Ink progress UI (validating → creating → configuring → uploading → submitting → done). JSON mode skips the UI and emits a single result document.
riu upload-all <parent-dir>
Bulk-uploads every skin folder under a parent directory in sequence.
riu upload-all ./skins
riu upload-all ./skins --dry-run --json
riu upload-all ./skins --filter Grenade --limit 5Resume support: on success, the returned PublishedFileId is written back into manifest.txt. On re-run, any folder whose manifest already has PublishedFileId is skipped without contacting Steam — so partial-failure recovery is automatic.
JSON mode: emits an NDJSON event stream so agents can consume progress live:
{"event":"start","total":3,"dryRun":true}
{"event":"item","index":1,"title":"uav","status":"uploading"}
{"event":"item","index":1,"title":"uav","status":"success","publishedFileId":"9000000001","url":"..."}
{"event":"item","index":2,"title":"patrol","status":"skipped","reason":"already published"}
{"event":"summary","total":3,"success":2,"skipped":1,"failed":0}Flags:
--filter <itemType>only upload skins matching the category--limit <n>cap number of uploads (testing)--continue-on-errorkeep going if one fails (default: true)--dry-runmock client--jsonNDJSON output
riu list
Show skins uploaded via this tool (read from local cache).
riu list
riu list --json
riu list --filter Grenade
riu list --include-dry-runBy default, dry-run uploads are filtered out so the list reflects real workshop items only.
riu update <published-file-id>
Re-upload the icon for an existing workshop item.
# uses cached skinDir if available
riu update 3248306153 --note "icon refresh"
# or specify the folder explicitly
riu update 3248306153 --skin-dir ./skins/uav --note "tweaked colors"
# headless
riu update --json '{
"publishedFileId": "3248306153",
"skinDir": "./skins/uav",
"note": "icon refresh"
}'Agent / scripting integration
--json makes every command machine-consumable. The pattern most agents will want is:
riu init --force --json '{"steamSdkPath":"...","authorId":"..."}'
riu new --json '{"itemType":"Grenade","title":"...","iconPath":"...","outputDir":"./skin-x"}'
riu upload ./skin-x --jsonOr all-at-once from a folder of pre-generated skins:
riu upload-all ./generated-skins --json | while read line; do
echo "$line" | jq -r '. | "\(.event): \(.title // "")"'
doneExit codes:
0success1runtime error (missing config, Steam init failed, network)2validation error (bad input, malformed JSON, schema failure)
Error shape in JSON mode:
{"status":"error","error":{"message":"...","field":"steamSdkPath"}}Mock / dry-run mode
--dry-run swaps the real Steam client for a MockSteamClient that:
- Does not load
libsteam_api.dylib(works on any machine) - Does not require Steam to be running
- Returns synthetic
9_xxx_xxx_xxxPublishedFileIds - Writes cache entries with
dryRun: truesoriu listfilters them by default
This means scripts/e2e-dry-run.sh works on any machine, in CI, anywhere — no Steam SDK required. Useful for:
- CI smoke tests
- Agent tool development
- Validating manifests before a real run
- Demos
How the icon-only skin trick works
A normal Rust workshop skin replaces an item's textures (diffuse, normal, etc.) by setting non-empty entries in the manifest's Textures dictionary. Rust's workshop client downloads those textures, applies them to the item's material at runtime, and the item looks different in-world.
For items NOT on Facepunch's skinnable list, that texture-replacement path doesn't work — Rust has no skinnable material for them. But the workshop client still downloads the preview image of every workshop item the player is subscribed to and uses it as the inventory icon. So if you upload a workshop item with:
{
"Version": 3,
"ItemType": "Grenade",
"Groups": [{
"Textures": {}, ← empty, no texture overrides
"Floats": {...},
"Colors": {...}
}]
}…Rust will recognize the workshop item, see no textures to apply, and just use the preview image as the in-game icon for items of that ItemType. That's the entire trick.
Project layout
src/
├── cli.tsx # commander entrypoint
├── commands/ # one file per CLI command
│ ├── init.tsx
│ ├── doctor.tsx
│ ├── new.tsx
│ ├── upload.tsx
│ ├── list.tsx
│ ├── update.tsx
│ └── upload-all.tsx
├── ui/ # Ink components
│ ├── InitWizard.tsx
│ ├── NewSkinWizard.tsx
│ ├── UploadProgress.tsx
│ └── BulkUploadProgress.tsx
├── steam/ # Steam UGC layer (mockable)
│ ├── types.ts
│ ├── client.ts # real, wraps steamworks-ffi-node
│ └── mock.ts # fake, used by --dry-run
├── manifest/ # pure functions, unit tested
│ ├── itemTypes.ts
│ ├── builder.ts
│ ├── writer.ts
│ └── __tests__/
├── config/ # conf-backed user config + Zod schema
├── cache/ # uploads.json local cache
└── utils/ # iconValidator, slugifyDevelopment
npm run dev -- new # run via tsx without building
npm run build # tsc → dist/
npm run typecheck # tsc --noEmit
npm test # node:test unit tests
bash scripts/e2e-dry-run.sh # full dry-run smoke testTest config gets stored in a temp dir via RIU_CONFIG_DIR so it never touches your real config.
Troubleshooting
Steamworks SDK not ready: no Steam library found
The path you gave riu init doesn't contain libsteam_api.dylib. Run riu doctor for a clear report.
Steam init failed
Steam isn't running, or the account isn't logged in, or it doesn't own AppID 252490 (Rust). The steam_appid.txt file is automatically managed in cwd — leftover copies are deleted on riu shutdown.
createItem returned null
Steam refused to create the workshop item. Common cause: the account hasn't accepted the workshop legal agreement. Visit any workshop submission page in a browser to accept it.
icon validation failed: must be square
Resize your PNG to 256/512/1024/2048 px. Sharp can do it: npx sharp-cli resize 512 512 < bad.png > good.png.
License
MIT