Two unrelated scripts that share a name because they share a theme: getting a Linux box to behave like a Chromebook.
stage— bring a fresh ChromeOS Linux (Crostini) container up from nothing, when the things you want to install live in a private Cloud Storage bucket.flex— turn a stock Debian desktop into a ChromeOS Flex lookalike: shelf, web apps, keybindings, wallpaper. No bucket, no gcloud, nothing private — this one is standalone.
That is a chicken-and-egg problem: reading the bucket needs gcloud, and a
bare container has no gcloud. This script is the one piece that has to be
fetchable without credentials, so it lives here instead of in the bucket.
bash <(curl -sSL https://raw.githubusercontent.com/xbill9/chromeos-boot/main/stage)No git required — curl is enough, so there is nothing to apt-get install
first.
Process substitution rather than a pipe is deliberate. curl ... | bash hands
the script to bash on stdin, which is the same stdin gcloud auth login
needs to read your answers from; the login then fails or silently eats the rest
of the script. bash <(curl ...) passes it as a file descriptor instead and
leaves stdin attached to your terminal.
If your shell has no process substitution, download and run in two steps:
curl -sSL https://raw.githubusercontent.com/xbill9/chromeos-boot/main/stage -o /tmp/stage
bash /tmp/stagestage leaves you with a container that can read the bucket. Four commands
take it the rest of the way:
bash <(curl -sSL https://raw.githubusercontent.com/xbill9/chromeos-boot/main/stage)
exec bash -l
bootstrap
bootstrap codestage— gcloud, login, then~/binand the dotfiles out of the bucket, and finally the phone tools.exec bash -l— load-bearing, and easy to skip.bootstrapis a shell function defined in the.bashrcthatstagehas just fetched, so it does not exist until a new login shell reads it.bootstrap— apt packages, node, python, rust, go, docker, aws and the agent CLIs. Every stage is idempotent, so re-running is how you repair one that failed;bootstrap <stage>runs a single one andbootstrap -llists them. The python stage compiles CPython and is slow.bootstrap code— clones the repos. Kept out of the default set because it takes a while.
Two things then need the steps above to have finished:
- Log out and back in. The docker stage adds you to the
dockergroup, which a session that is already running will not pick up. - Re-run
nnn. A couple of the scripts in~/binare symlinks into a cloned repo rather than copies from the bucket, so they cannot be linked untilbootstrap codehas cloned it.nnnwarns and skips them until then.
- Installs the Google Cloud CLI from the tarball into
$HOME— no sudo, no apt, no keyring setup. - Logs you in, opening a browser tab. Crostini hands the URL to the ChromeOS browser you are already signed into.
- Copies
nnnout of the bucket into~/binand runs it, which fetches everything else. - Replaces the tarball with the apt-managed
google-cloud-cliin/usr/bin, then deletes~/google-cloud-sdk. - Installs the phone tools —
adb,fastbootand the libimobiledevice utilities — and puts you in theplugdevgroup. - Installs the doc tools —
pandoc,python3-pil,fonts-liberation,fonts-dejavu-coreandgit— pluszip.
It is idempotent: existing gcloud and an active login are detected and
skipped, so re-run it to repair a half-finished container.
The tarball is the only kind of gcloud a bare container can install: no sudo,
no keyring, no apt repo to add — and adding one needs gnupg, which may not be
there yet either. It exists to read the bucket once, and is deliberately never
added to PATH.
It is not the copy you want to keep. Step 4 adds Google's apt repo and installs
google-cloud-cli into /usr/bin, which is what everything downstream expects
and what gets updated along with the rest of the machine, and then removes
~/google-cloud-sdk. This used to be left to the bootstrap function in the
fetched .bashrc; stage now does it, so bootstrap's gcloud stage finds
the CLI already in place and does nothing.
Step 4 is non-fatal. Without sudo, or with apt unreachable, it warns, keeps
the tarball and leaves the job to bootstrap gcloud.
Credentials live in ~/.config/gcloud, a separate directory shared by both
copies, so you log in exactly once and removing the tarball does not log you
out.
Step 5 installs adb and fastboot for Android, and usbmuxd,
libimobiledevice-utils and ifuse for iPhones, plus usbutils for lsusb.
All of it from Debian rather than from Google's or libimobiledevice's own
builds: the distro packages lag a release or two but they are the ones apt
keeps current, and both protocols tolerate an older client talking to a newer
phone.
android-sdk-platform-tools-common is in the list for its udev rules alone.
Without them adb devices reports no permissions for everything it sees.
The rules hand each device to uaccess and to the plugdev group; uaccess
does nothing in Crostini, which has no logind seat, so plugdev is what
actually grants access — which is why the step adds you to it. Like the
docker group, that needs a log out and back in.
Like step 4, this one is non-fatal: without sudo, or with apt unreachable,
it warns and carries on, and re-running stage installs the tools later.
Then there is the part no script can do. A phone plugged into the Chromebook
belongs to ChromeOS, not to the container, until you share it across from
ChromeOS Settings, under the Linux development environment, Manage USB
devices — and it has to be re-shared after every unplug. Once it is
shared, adb devices should list an Android handset and ideviceinfo should
answer for an iPhone; lsusb is the thing to check when neither does.
Step 6 installs pandoc, python3-pil, fonts-liberation,
fonts-dejavu-core and git, which are what the publishing kit shells out
to: pandoc renders article markdown to HTML, Pillow draws the cover image and
rasterises every table, the cover and table generators hardcode paths into the
Liberation faces, and diagrams are drawn in DejaVu Sans Mono — the only face
with full box-drawing coverage. DejaVu is usually on the machine already, but
only as one branch of fontconfig-config's dependency, so it is named
explicitly. git is there because the kit's checks are git checks: a cover
that is not committed is a broken image on dev.to, so check-article,
preflight and publish-devto all fail without it. zip is the exception:
nothing in the kit needs it, and it is there only so an archive can be packed
without a trip back to apt.
Debian's pandoc rather than the upstream release or the pypandoc-binary
wheel. The wheel works, but it installs a second pandoc under ~/.local that
drifts from apt on its own schedule, and two pandocs on one machine is how you
get output that changes without an edit.
python3-pil rather than pip install Pillow for a related reason: the kit is
run with whatever python3 is on PATH, which in a fresh container is
/usr/bin/python3. A --user wheel installed against a pyenv interpreter is
invisible to it, and the failure is an import error at the point of use rather
than at install time.
Like steps 4 and 5, this is non-fatal — no sudo or no network warns and moves
on, and re-running stage installs them later.
Defaults to the stage bucket; pass another as the first argument, or set
BUCKET. The name is not sensitive - the bucket is private and IAM gates every
object in it, so knowing the name gets you nothing without an authorised
account. Step 2 is what establishes that.
Turn a stock Debian 13 (trixie) / GNOME 48 desktop into a ChromeOS Flex lookalike: a bottom shelf, the Google web apps as first-class icons, ChromeOS keybindings, a pruned app grid, and a matching pair of wallpapers. Written from — and matches — the customisation actually run on this Yoga Pro 9 16IRP8 install.
Unlike stage, this one is self-contained: no bucket, no gcloud, nothing
private. Everything it touches lives under $HOME except the repos and
pkgs stages (apt, and non-fatal without sudo) and boot-splash.sh, a
script it installs but never runs, since that one edits GRUB.
Install GNOME with gdm3 — the Debian installer's default desktop task
gives you that; take it and change nothing. flex never touches the display
manager, so this is not about anything it configures, it is about the session
it lands you in, and the script only works in one: it dies without
gsettings, warns unless XDG_CURRENT_DESKTOP is GNOME, and installs a
gnome-shell extension for the shelf, reading gnome-shell --version to pick
the build.
lightdm is the wrong choice here. Debian's greeter only lists
/usr/share/xsessions, so GNOME starts on Xorg rather than the Wayland
session everything below was measured on, and it doesn't take the handoff
from Plymouth cleanly, which is most of the point of boot-splash.sh. If a
machine somehow ends up with both, sudo dpkg-reconfigure gdm3 chooses.
bash flex # everything, dark mode
bash flex --light # everything, light mode
bash flex -l # list the stages
bash flex shelf webapps # just those two
bash flex revert # undo itEvery stage is idempotent, so re-running is how you repair one that failed,
and naming a stage is how you repair it without redoing the rest. icons
(a ~60MB icon theme) and wallpaper (~9s per render) are the slow ones.
Wayland cannot restart gnome-shell in place, so the shelf, the GTK theme
and the app grid only take effect after a full log out and back in; the
wallpaper and the keybindings are live immediately.
-
repos— turns on thecontribandnon-freeapt components, which is what puts the Microsoft fonts,unrarand the proprietary drivers within apt's reach. It adds a separate file,/etc/apt/sources.list.d/chromeos-flex-nonfree.sources, naming the Debian archives the machine already uses with only the extra components, rather than rewriting the distro's own sources — which are deb822 on a fresh trixie and one-line on one upgraded from bookworm, and awkward to undo either way. The archives are read back fromapt-get indextargetsand filtered onOrigin: Debian, so third-party repos such as Chrome's and Docker's are left alone.non-free-firmwareis not touched; the installer has enabled it since Debian 12. This is the one stagebash flex revertneedssudofor. -
pkgs— Roboto, the croscore and Noto font sets, gnome-tweaks, unzip (the shelf's extension download is a zip), zip,gh,pandoc,python3-pil,fonts-liberation,fonts-dejavu-coreandgit, and Chrome if no Chromium-family browser is already installed. Noto CJK is 91MB and left out;FONTS_CJK=1adds it. The pandoc and Pillow pair are the publishing kit's dependencies, listed here for the same reasonstagelists them: the kit runs under the systempython3, where a--userwheel is not visible. The two fonts are named because the kit loads them by hardcoded path, andgitbecause its checks shell out to it. -
theme— adw-gtk3, light and dark, from the upstream release tarball (trixie has no package for it), so GTK3 apps match the libadwaita GTK4 ones. Pinned tov5.7, upstream's GNOME 48 / libadwaita 1.7 release — which is what trixie ships. Do not raise the pin to keep up: fromv6.4the GTK4 half needs GTK 4.20 for a CSS@mediaquery, trixie has GTK 4.18, and the mismatch is silent — the stage still reports success while every@mediablock is discarded at parse time. The stage records the installed version in~/.local/share/themes/.adw-gtk3-versionand re-extracts when the pin moves, so correcting a bad pin is a re-run rather than a manualrm -rf. -
icons— Papirus and Papirus-Dark, user-level. -
shelf— installs dash-to-panel in place of the packaged dash-to-dock: only dash-to-panel merges the taskbar and system tray into one bar, which is what the ChromeOS shelf is. Bottom, 36px, 75% opacity, Google-Blue running-app dots, Alt+1-9 launches the nth pinned app. App icons are centred on the monitor with the launcher hard left and the clock and tray hard right, as on ChromeOS. The per-monitor layout is resolved through Mutter'sDisplayConfigat run time rather than a hardcoded panel ID, so it isn't tied to one laptop's monitor — but a monitor this stage has never seen falls back to dash-to-panel's own left-stacked default, so plug the second screen in first and re-runbash flex shelf. -
webapps— the eleven Google apps (Gemini, Gmail, Chat, Calendar, Drive, Docs, Sheets, Keep, Photos, Maps, YouTube) as windowless launchers with their own shelf icons, icons fetched from gstatic with a favicon-service fallback. All are pinned to the shelf except Keep.Two forms. If the site has been installed as a PWA the launcher uses
--app-id=<id>, which is what ChromeOS itself does: the window carries the manifest's identity and Chrome names itcrx_<id>, a name that survives Google reorganising the site's URLs. Otherwise it falls back to--app=<url>, a plain app-shortcut window with no manifest and no scope. The stage detects installed apps by looking for the launcher Chrome generates for them, so the upgrade path is: install from Chrome's ⋮ menu → Cast, Save and Share → Install page as app, then re-runbash flex webapps. Where both exist, Chrome's own launcher is hidden withNoDisplay=trueand stripped of itsStartupWMClassso it neither doubles up in the app grid nor competes for the window;flex revertrestores both lines.--classdoes nothing under Wayland. Chrome names an--app=window after its URL —chrome-<host>__<path>-Default, every character outside[A-Za-z0-9.-]replaced by an underscore — and ignores the flag entirely. AStartupWMClassthat doesn't match means GNOME never sees the app as running, so every click on the shelf icon opens another window, which is exactly what the hand-written--class=chromeos-<id>launchers used to do. The rule inwmclass_for_url()was measured rather than guessed, and reproduces all ten ids exactly:WAYLAND_DEBUG=1 google-chrome-stable --user-data-dir=$(mktemp -d) \ --app=https://mail.google.com/mail/u/0/ 2>&1 | grep set_app_idThat is the only way to read the id on this box:
xpropcannot see Wayland windows,org.gnome.Shell.Evalis off without unsafe-mode, andorg.gnome.Shell.Introspect.GetWindowsreturnsAccessDeniedto callers that are not allowlisted. -
appgrid— hides the apps ChromeOS doesn't have (LibreOffice, xterm, Disk Utility, and 30-odd others) by shadowing each system.desktopwith a copy carryingNoDisplay=true— a copy, not a stub, so MIME associations and "Open with" still work. Nothing is uninstalled. GNOME Text Editor is renamed to "Text". -
fonts— what Chrome puts on a page, as opposed tolook, which sets the desktop's own font. Chrome asks fontconfig whatsans-serif,serifandmonospacemean; stock Debian answers DejaVu for all three, where ChromeOS answers Arimo, Tinos and Cousine.monospaceis the one that visibly breaks — DejaVu Sans Mono is much wider and heavier than Cousine, so code blocks, diffs and devtools all read wrong. The stage writes~/.config/fontconfig/conf.d/50-chromeos-flex.confpointing the generics at the ChromeOS families, and pins grayscale antialiasing with slight hinting.system-uineeds no rule: Chrome takes that from the GTK font, solookalready makes it Roboto. SetFONT_SANS=Robototo have plainsans-serifmatch the shelf rather than match ChromeOS — it is narrower than Arimo and will reflow some pages. Chrome reads fontconfig once at startup, so this one needs a Chrome restart, not a reload. -
look— Roboto as the UI/document/titlebar font, text scaled to 1.2×, blue accent, no hot corners, time-only clock, one workspace, shelf favourites. The text scale is what makes Chrome readable: Chrome turns GNOME'stext-scaling-factorintoXft.dpiand uses it as its device scale factor, so it is the only setting that enlarges the browser UI — tabs, omnibox, menus — rather than just page text. SetTEXT_SCALE=1.0to leave the size alone, or higher on a denser panel. -
keys— Caps Lock becomes Super (the ChromeOS Launcher key); Alt for window minimise/maximise/tile; Alt+Tab cycles windows (not app groups, which move to Super+Tab); Super+[ / Super+] switch workspaces; Ctrl+F5 overview; Ctrl+Shift+F5 screenshot; Ctrl+Shift+Q sign out; Ctrl+Alt+T terminal. -
helpers— installsset-mode.sh,gen_wallpaper.pyandboot-splash.shunder~/.local/share/chromeos-flex/. -
claude— Claude Code's own preferences, so a rebuilt machine opens on a prompt rather than on the onboarding wizard and a fortnight of first-run notices: theme following--light/--dark, auto permission mode, spinner tips and the feedback survey off, and the one-shot dialogs (onboarding, Claude in Chrome, release notes) already answered. The two config files are merged, key by key, never copied:~/.claude/settings.jsonis preferences, but~/.claude.jsonis machine state and also holds the OAuth account, the machine id and the path of every project ever opened.~/.claude/.credentials.jsonis not touched at all — the new box does oneclaude login. The packaged global~/.claude/CLAUDE.mdis installed only when there isn't one there already. Claude Code rewrites~/.claude.jsonwholesale when it exits, so the stage says so if a session is running: run it again once that session has quit. -
wallpaper— renders both wallpaper variants (pure-Python PNG encoder, no Pillow needed) and applies the requested mode.
~/.local/share/chromeos-flex/set-mode.sh light # flip the whole look
~/.local/share/chromeos-flex/set-mode.sh # report the current modeTwo things it can't do for you, both a couple of clicks in Settings: mounting Drive in the Files app (Settings → Online Accounts → Google), and Chrome reopening its tabs on launch (Chrome Settings → On startup). The latter has a managed-policy route, deliberately not taken here — it stamps "Managed by your organization" onto the Chrome menu permanently in exchange for skipping one five-second click.
boot-splash.sh, also under ~/.local/share/chromeos-flex/, is installed
but never run automatically: it edits /etc/default/grub to drop the GRUB
menu and enable the Plymouth splash that ships already installed. Needs
root; read it before running it.
bash flex revert resets every setting this script touched back to the
GNOME defaults (not to whatever they were before — this doesn't snapshot
prior values) and removes the launchers, icons, wallpapers and helper
scripts it created, plus the contrib/non-free sources file — the one
part that asks for a password. Custom keyboard shortcuts are the exception
to the reset: only the Ctrl+Alt+T entry keys added is taken out, matched
by name and command, so shortcuts you added yourself keep working. Claude
Code is the same shape: the keys claude wrote come out of both files by
name and nothing else in them is disturbed, and ~/.claude/CLAUDE.md goes
only if it is still byte-for-byte the packaged copy.
adw-gtk3, Papirus, dash-to-panel, Chrome's apt repo and any apt packages
are left in place, since removing them isn't really an "undo" either.
Anything already installed from contrib or non-free stays installed,
and stops getting updates, so uninstall it first if you care.