feat: switch Claude accounts instead of lending credentials
The previous helper moved the private account's OAuth token into the work profile and had to be given back afterwards. Only the credentials moved, so plugins and settings stayed behind, and a session that exited at the wrong moment left the swap half-done. An account is a whole profile directory here, and switching points CLAUDE_CONFIG_DIR at another one of them. Nothing is copied, so nothing has to be restored, and the plugins of an account come with it. A wrapper in ~/.local/bin makes the single `claude` command follow the switch, including for the hooks and subprocesses a shell alias would never reach. Each account also gets a Headroom proxy of its own -- its own port, its own workspace, its own service -- because one shared proxy would collect the work of both accounts in one cache. The router publishes them as <account>.headroom.localhost, gives the bare domain to whichever account is active, and keeps resolving the per-session names the enqs-jail plugin publishes. Status is a report rather than two lines: the parts of this setup fail quietly, and a proxy that is not running, a wrapper that is shadowed or two routers on one port are all invisible from a terminal.
This commit is contained in:
commit
cc8c764a67
17 files changed
+2985
No files matched your search
@@ -0,0 +1 @@
|
|||||||
|
/target
|
||||||
Generated
+331
@@ -0,0 +1,331 @@
|
|||||||
|
# This file is automatically @generated by Cargo.
|
||||||
|
# It is not intended for manual editing.
|
||||||
|
version = 3
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstream"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
|
||||||
|
dependencies = [
|
||||||
|
"anstyle",
|
||||||
|
"anstyle-parse",
|
||||||
|
"anstyle-query",
|
||||||
|
"anstyle-wincon",
|
||||||
|
"colorchoice",
|
||||||
|
"is_terminal_polyfill",
|
||||||
|
"utf8parse",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle"
|
||||||
|
version = "1.0.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-parse"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
|
||||||
|
dependencies = [
|
||||||
|
"utf8parse",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-query"
|
||||||
|
version = "1.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||||
|
dependencies = [
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-wincon"
|
||||||
|
version = "3.0.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||||
|
dependencies = [
|
||||||
|
"anstyle",
|
||||||
|
"once_cell_polyfill",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anyhow"
|
||||||
|
version = "1.0.104"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bitflags"
|
||||||
|
version = "2.13.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap"
|
||||||
|
version = "4.6.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946"
|
||||||
|
dependencies = [
|
||||||
|
"clap_builder",
|
||||||
|
"clap_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_builder"
|
||||||
|
version = "4.6.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d"
|
||||||
|
dependencies = [
|
||||||
|
"anstream",
|
||||||
|
"anstyle",
|
||||||
|
"clap_lex",
|
||||||
|
"strsim",
|
||||||
|
"terminal_size",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_derive"
|
||||||
|
version = "4.6.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0"
|
||||||
|
dependencies = [
|
||||||
|
"heck",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_lex"
|
||||||
|
version = "1.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "claude-account"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"clap",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colorchoice"
|
||||||
|
version = "1.0.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "equivalent"
|
||||||
|
version = "1.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "errno"
|
||||||
|
version = "0.3.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.17.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "heck"
|
||||||
|
version = "0.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "indexmap"
|
||||||
|
version = "2.14.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||||
|
dependencies = [
|
||||||
|
"equivalent",
|
||||||
|
"hashbrown",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "is_terminal_polyfill"
|
||||||
|
version = "1.70.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itoa"
|
||||||
|
version = "1.0.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libc"
|
||||||
|
version = "0.2.189"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "linux-raw-sys"
|
||||||
|
version = "0.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "memchr"
|
||||||
|
version = "2.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "once_cell_polyfill"
|
||||||
|
version = "1.70.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "proc-macro2"
|
||||||
|
version = "1.0.107"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quote"
|
||||||
|
version = "1.0.47"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustix"
|
||||||
|
version = "1.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"errno",
|
||||||
|
"libc",
|
||||||
|
"linux-raw-sys",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_core"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||||
|
dependencies = [
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_derive"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_json"
|
||||||
|
version = "1.0.151"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||||
|
dependencies = [
|
||||||
|
"indexmap",
|
||||||
|
"itoa",
|
||||||
|
"memchr",
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
|
"zmij",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "strsim"
|
||||||
|
version = "0.11.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "3.0.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "terminal_size"
|
||||||
|
version = "0.4.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874"
|
||||||
|
dependencies = [
|
||||||
|
"rustix",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-ident"
|
||||||
|
version = "1.0.26"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "utf8parse"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-link"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.61.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zmij"
|
||||||
|
version = "1.0.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
[package]
|
||||||
|
name = "claude-account"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
rust-version = "1.82"
|
||||||
|
description = "Switch the Claude Code account the `claude` command runs as, with a Headroom proxy per account."
|
||||||
|
license = "proprietary"
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "claude-account"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow = "1"
|
||||||
|
clap = { version = "4", features = ["derive", "wrap_help"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = { version = "1", features = ["preserve_order"] }
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
opt-level = "s"
|
||||||
|
lto = true
|
||||||
|
strip = true
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
# claude-account
|
||||||
|
|
||||||
|
One `claude` command, several accounts.
|
||||||
|
|
||||||
|
Each Claude account on this machine is a whole Claude Code profile: its own
|
||||||
|
login, settings, plugins, projects and memories. `claude-account switch` picks
|
||||||
|
which of them the `claude` command runs as, and every account gets a Headroom
|
||||||
|
proxy of its own, reachable under a name that does not change.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
claude-account setup # wrapper, a proxy per account, the router
|
||||||
|
claude-account switch work # `claude` runs as the work account from now on
|
||||||
|
claude-account status # everything, and everything that is wrong
|
||||||
|
```
|
||||||
|
|
||||||
|
## What it replaces
|
||||||
|
|
||||||
|
The previous helper lent the private account's OAuth token to the work profile
|
||||||
|
and had to be given back afterwards. Only the credentials moved, plugins and
|
||||||
|
settings stayed behind, and a session that exited at the wrong moment left the
|
||||||
|
swap half-done. Here nothing moves: an account is a directory, and switching
|
||||||
|
means pointing `CLAUDE_CONFIG_DIR` at another one. There is nothing to restore,
|
||||||
|
and the plugins of an account come with it.
|
||||||
|
|
||||||
|
## How the switch reaches `claude`
|
||||||
|
|
||||||
|
`~/.local/bin/claude` is a small wrapper that asks this tool which profile is
|
||||||
|
active, exports `CLAUDE_CONFIG_DIR` and hands over to the real `claude`. A
|
||||||
|
wrapper and not a shell alias, because an alias only exists in an interactive
|
||||||
|
shell: hooks, editors and subprocesses call `claude` without one and must land
|
||||||
|
in the same account as the terminal.
|
||||||
|
|
||||||
|
A `CLAUDE_CONFIG_DIR` that is already set wins over the wrapper, so a session
|
||||||
|
started deliberately in another profile -- a jail does this -- stays where it
|
||||||
|
was put. Sessions that are already open are never disturbed by a switch; each
|
||||||
|
keeps the profile it started with.
|
||||||
|
|
||||||
|
## A proxy per account
|
||||||
|
|
||||||
|
A Headroom proxy keeps what it sees: a cache, a retrieval store and logs, all
|
||||||
|
under one workspace directory. One proxy for two accounts would put the work of
|
||||||
|
both in one place, which is the thing keeping two accounts is meant to avoid.
|
||||||
|
So every account gets its own port, its own workspace and its own user service,
|
||||||
|
written from one systemd template:
|
||||||
|
|
||||||
|
| Address | What |
|
||||||
|
| --- | --- |
|
||||||
|
| `headroom.localhost` | the proxy of the account that is active right now |
|
||||||
|
| `<account>.headroom.localhost` | one account's proxy, active or not |
|
||||||
|
| `<jail>.headroom.localhost` | the proxy a jail started for one session |
|
||||||
|
|
||||||
|
The bare name follows the switch. That is the whole point of it: one address in
|
||||||
|
a bookmark, always showing the account being worked in.
|
||||||
|
|
||||||
|
Jails keep their own names. They are published as run files by the `enqs-jail`
|
||||||
|
plugin, and the router here reads the same directory in the same format, so a
|
||||||
|
jail is reachable whichever of the two routers is serving the domain -- but
|
||||||
|
only one of them can hold the port. `claude-account adopt` stops the plugin's
|
||||||
|
router and the hand-written proxy services, and takes their ports and
|
||||||
|
workspaces over unchanged.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
| Command | What |
|
||||||
|
| --- | --- |
|
||||||
|
| `status [-v]` | accounts, proxies, names, sessions, and a list of what to fix |
|
||||||
|
| `doctor` | the same, and a non-zero exit when something is wrong |
|
||||||
|
| `list`, `current`, `dir`, `env`, `url` | the small answers, for scripts |
|
||||||
|
| `switch <account>` | make an account the one `claude` runs as |
|
||||||
|
| `setup` | wrapper, proxies and router in one go |
|
||||||
|
| `adopt` | take over services that were written by hand |
|
||||||
|
| `shim install \| uninstall` | the `claude` wrapper |
|
||||||
|
| `headroom install \| start \| stop \| restart \| port \| logs` | one account's proxy |
|
||||||
|
| `router serve \| install \| uninstall \| port \| list` | the name router |
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
`~/.config/claude-account/config.json`. Everything in it has a default derived
|
||||||
|
from the machine, so the file is an override and not a requirement:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"active": "work",
|
||||||
|
"router_port": 80,
|
||||||
|
"accounts": {
|
||||||
|
"personal": { "dir": "~/.claude", "port": 8787, "workspace": "~/.headroom" },
|
||||||
|
"work": { "dir": "~/.claude-work", "port": 8788, "workspace": "~/.headroom-work" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
An account is discovered from `~/.claude` (called `personal`) and every
|
||||||
|
`~/.claude-<name>` that holds a login. Ports come from the profile's own
|
||||||
|
`settings.json` where it names one, and are handed out from 8790 upwards where
|
||||||
|
it does not.
|
||||||
|
|
||||||
|
The router wants port 80, because that is what makes the address
|
||||||
|
`headroom.localhost` rather than `headroom.localhost:8799`. A user service may
|
||||||
|
only take it once the machine allows unprivileged low ports:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo sysctl -w net.ipv4.ip_unprivileged_port_start=80 # and in /etc/sysctl.d
|
||||||
|
claude-account router port 8799 # or move the router
|
||||||
|
```
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
* Rust 1.82 to build.
|
||||||
|
* systemd as a user session, which keeps the proxies and the router running.
|
||||||
|
* `headroom` with the proxy extras: `pip install "headroom-ai[proxy]"`, or a
|
||||||
|
path in `CLAUDE_ACCOUNT_HEADROOM_BIN`.
|
||||||
Executable
+16
@@ -0,0 +1,16 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build the tool and put it in ~/.local/bin.
|
||||||
|
#
|
||||||
|
# bash install.sh build, link, and stop there
|
||||||
|
# bash install.sh --setup also write the wrapper, the proxies and the router
|
||||||
|
set -euo pipefail
|
||||||
|
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cargo build --release --manifest-path "$here/Cargo.toml"
|
||||||
|
mkdir -p "$HOME/.local/bin"
|
||||||
|
ln -sf "$here/target/release/claude-account" "$HOME/.local/bin/claude-account"
|
||||||
|
echo "Linked ~/.local/bin/claude-account -> $here/target/release/claude-account"
|
||||||
|
if [ "${1:-}" = "--setup" ]; then
|
||||||
|
"$HOME/.local/bin/claude-account" setup
|
||||||
|
else
|
||||||
|
echo "Run 'claude-account setup' to write the wrapper, the proxies and the router."
|
||||||
|
fi
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
max_width = 110
|
||||||
|
use_small_heuristics = "Max"
|
||||||
+320
@@ -0,0 +1,320 @@
|
|||||||
|
//! The accounts on this machine, and which one the `claude` command runs as.
|
||||||
|
//!
|
||||||
|
//! An account here is a whole Claude Code profile directory, not a set of
|
||||||
|
//! credentials: `~/.claude` and `~/.claude-work` each hold their own
|
||||||
|
//! `settings.json`, plugins, projects, memories and login. Switching means
|
||||||
|
//! pointing `CLAUDE_CONFIG_DIR` at another one of them, which is why nothing
|
||||||
|
//! in this file copies a credential anywhere. The older helper this replaces
|
||||||
|
//! moved the OAuth token between profiles and had to be undone afterwards; a
|
||||||
|
//! profile that is simply not selected needs no undoing and cannot be left
|
||||||
|
//! half-swapped.
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Context, Result};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use crate::config::{Config, Entry, FIRST_FREE_PORT};
|
||||||
|
use crate::paths::{data_dir, expand_user, home, shorten};
|
||||||
|
use crate::router;
|
||||||
|
|
||||||
|
/// What the profile directory Claude Code uses by default is called here.
|
||||||
|
///
|
||||||
|
/// `~/.claude` carries no suffix to name it after, and every other profile is
|
||||||
|
/// `~/.claude-<name>`. The label says nothing about the account inside it.
|
||||||
|
pub const DEFAULT_NAME: &str = "personal";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Account {
|
||||||
|
/// The name on the command line and in `<name>.headroom.localhost`.
|
||||||
|
pub name: String,
|
||||||
|
/// The directory `CLAUDE_CONFIG_DIR` is pointed at.
|
||||||
|
pub dir: PathBuf,
|
||||||
|
/// The port this account's own proxy listens on.
|
||||||
|
pub port: u16,
|
||||||
|
/// Where that proxy keeps its cache, its store and its logs.
|
||||||
|
pub workspace: PathBuf,
|
||||||
|
/// Whether the configuration file names this account, or discovery found
|
||||||
|
/// it. A discovered account works the same; it only has no overrides.
|
||||||
|
pub configured: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Account {
|
||||||
|
/// Who is logged in, as the profile itself remembers it.
|
||||||
|
pub fn email(&self) -> Option<String> {
|
||||||
|
let value = read_json(&self.dir.join(".claude.json"))?;
|
||||||
|
value.get("oauthAccount")?.get("emailAddress")?.as_str().map(str::to_string)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The subscription the stored token was issued for.
|
||||||
|
pub fn plan(&self) -> Option<String> {
|
||||||
|
let value = read_json(&self.dir.join(".credentials.json"))?;
|
||||||
|
value.get("claudeAiOauth")?.get("subscriptionType")?.as_str().map(str::to_string)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// When that token expires, in whole minutes from now. Negative means it
|
||||||
|
/// already has, which is worth saying: the session that finds out is the
|
||||||
|
/// one that has to log in again.
|
||||||
|
pub fn token_minutes_left(&self) -> Option<i64> {
|
||||||
|
let value = read_json(&self.dir.join(".credentials.json"))?;
|
||||||
|
let expires = value.get("claudeAiOauth")?.get("expiresAt")?.as_i64()?;
|
||||||
|
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).ok()?.as_millis() as i64;
|
||||||
|
Some((expires - now) / 60_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the profile holds a login at all.
|
||||||
|
pub fn has_credentials(&self) -> bool {
|
||||||
|
self.dir.join(".credentials.json").is_file()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The plugins installed into this profile, by name.
|
||||||
|
pub fn plugins(&self) -> Vec<String> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let Ok(markets) = std::fs::read_dir(self.dir.join("plugins/cache")) else {
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
for market in markets.flatten() {
|
||||||
|
let Ok(plugins) = std::fs::read_dir(market.path()) else { continue };
|
||||||
|
for plugin in plugins.flatten() {
|
||||||
|
out.push(plugin.file_name().to_string_lossy().to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.sort();
|
||||||
|
out.dedup();
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The base URL this profile's `settings.json` sends sessions to, if it
|
||||||
|
/// names one.
|
||||||
|
pub fn base_url(&self) -> Option<String> {
|
||||||
|
let value = read_json(&self.dir.join("settings.json"))?;
|
||||||
|
value.get("env")?.get("ANTHROPIC_BASE_URL")?.as_str().map(str::to_string)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The URL this account's sessions should be sent to: its own proxy.
|
||||||
|
pub fn own_url(&self) -> String {
|
||||||
|
format!("http://127.0.0.1:{}", self.port)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Point this profile's `settings.json` at its own proxy, and report
|
||||||
|
/// whether that changed anything.
|
||||||
|
///
|
||||||
|
/// Only the one key is touched. The file is the profile's own, edited by
|
||||||
|
/// hand and by Claude Code, so it is read, changed and written back with
|
||||||
|
/// its key order preserved rather than regenerated.
|
||||||
|
pub fn route_to_own_proxy(&self) -> Result<bool> {
|
||||||
|
let path = self.dir.join("settings.json");
|
||||||
|
let mut settings: serde_json::Value = match std::fs::read_to_string(&path) {
|
||||||
|
Ok(body) if !body.trim().is_empty() => {
|
||||||
|
serde_json::from_str(&body).with_context(|| format!("cannot read {}", shorten(&path)))?
|
||||||
|
}
|
||||||
|
_ => serde_json::json!({}),
|
||||||
|
};
|
||||||
|
let wanted = serde_json::Value::String(self.own_url());
|
||||||
|
let env = settings
|
||||||
|
.as_object_mut()
|
||||||
|
.ok_or_else(|| anyhow!("{} is not a JSON object", shorten(&path)))?
|
||||||
|
.entry("env")
|
||||||
|
.or_insert_with(|| serde_json::json!({}));
|
||||||
|
let env = env
|
||||||
|
.as_object_mut()
|
||||||
|
.ok_or_else(|| anyhow!("the env block of {} is not a JSON object", shorten(&path)))?;
|
||||||
|
if env.get("ANTHROPIC_BASE_URL") == Some(&wanted) {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
env.insert("ANTHROPIC_BASE_URL".to_string(), wanted);
|
||||||
|
let mut body = serde_json::to_string_pretty(&settings)?;
|
||||||
|
body.push('\n');
|
||||||
|
std::fs::write(&path, body).with_context(|| format!("cannot write {}", shorten(&path)))?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every account on this machine: the ones the configuration names and the
|
||||||
|
/// ones a `~/.claude*` directory betrays, in one list sorted by name.
|
||||||
|
pub fn all(config: &Config) -> Vec<Account> {
|
||||||
|
let mut found: Vec<Account> = Vec::new();
|
||||||
|
|
||||||
|
for (name, entry) in &config.accounts {
|
||||||
|
let dir = entry.dir.as_deref().map(expand_user).unwrap_or_else(|| dir_for(name));
|
||||||
|
found.push(build(name.clone(), dir, Some(entry), true));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(entries) = std::fs::read_dir(home()) {
|
||||||
|
let mut discovered: Vec<PathBuf> =
|
||||||
|
entries.flatten().map(|entry| entry.path()).filter(|path| is_profile(path)).collect();
|
||||||
|
discovered.sort();
|
||||||
|
for dir in discovered {
|
||||||
|
let Some(name) = name_of(&dir) else { continue };
|
||||||
|
if found.iter().any(|account| account.name == name || account.dir == dir) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
found.push(build(name, dir, None, false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A port nobody chose is chosen here, and two accounts must not land on
|
||||||
|
// the same one. Ports already spoken for are therefore taken out of the
|
||||||
|
// pool before the rest are handed out, in name order so that the same
|
||||||
|
// machine always gets the same answer.
|
||||||
|
found.sort_by(|left, right| left.name.cmp(&right.name));
|
||||||
|
let mut taken: Vec<u16> = found.iter().filter(|account| account.port != 0).map(|a| a.port).collect();
|
||||||
|
taken.push(config.router_port());
|
||||||
|
for account in found.iter_mut().filter(|account| account.port == 0) {
|
||||||
|
let mut port = FIRST_FREE_PORT;
|
||||||
|
while taken.contains(&port) {
|
||||||
|
port += 1;
|
||||||
|
}
|
||||||
|
taken.push(port);
|
||||||
|
account.port = port;
|
||||||
|
account.workspace = workspace_for(&account.name);
|
||||||
|
}
|
||||||
|
found
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One account by name.
|
||||||
|
pub fn find(config: &Config, name: &str) -> Result<Account> {
|
||||||
|
let wanted = router::label(name);
|
||||||
|
all(config)
|
||||||
|
.into_iter()
|
||||||
|
.find(|account| account.name == wanted)
|
||||||
|
.ok_or_else(|| anyhow!("there is no account called {name}. Run `claude-account list`."))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The account the `claude` command runs as.
|
||||||
|
///
|
||||||
|
/// The configuration decides, because that is what the shim reads. A
|
||||||
|
/// `CLAUDE_CONFIG_DIR` set in this shell wins over it, so that a session
|
||||||
|
/// started by hand in another profile does not report the wrong one.
|
||||||
|
pub fn active(config: &Config) -> Option<Account> {
|
||||||
|
if let Ok(explicit) = std::env::var("CLAUDE_CONFIG_DIR") {
|
||||||
|
if !explicit.is_empty() {
|
||||||
|
let dir = expand_user(&explicit);
|
||||||
|
if let Some(account) = all(config).into_iter().find(|account| account.dir == dir) {
|
||||||
|
return Some(account);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let name = config.active.clone().unwrap_or_else(|| DEFAULT_NAME.to_string());
|
||||||
|
all(config).into_iter().find(|account| account.name == name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Make an account the one the `claude` command runs as.
|
||||||
|
pub fn switch(config: &mut Config, name: &str) -> Result<Account> {
|
||||||
|
let account = find(config, name)?;
|
||||||
|
if !account.dir.is_dir() {
|
||||||
|
return Err(anyhow!("{} does not exist", shorten(&account.dir)));
|
||||||
|
}
|
||||||
|
config.active = Some(account.name.clone());
|
||||||
|
// What discovery worked out for this account is written down as it is
|
||||||
|
// switched to, so the shim and the router agree with this run even if a
|
||||||
|
// later one would derive something else.
|
||||||
|
let entry = config.entry_mut(&account.name);
|
||||||
|
entry.dir.get_or_insert_with(|| shorten(&account.dir));
|
||||||
|
entry.port.get_or_insert(account.port);
|
||||||
|
entry.workspace.get_or_insert_with(|| shorten(&account.workspace));
|
||||||
|
config.save()?;
|
||||||
|
Ok(account)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sessions of Claude Code that are running right now, by the profile they
|
||||||
|
/// were started in.
|
||||||
|
///
|
||||||
|
/// A switch does not disturb them -- each session keeps the directory it was
|
||||||
|
/// started with -- but a reader who has one open in another account wants to
|
||||||
|
/// know which one the next `claude` will be.
|
||||||
|
pub fn running_sessions() -> Vec<(u32, Option<PathBuf>)> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let Ok(entries) = std::fs::read_dir("/proc") else { return out };
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let Some(pid) = entry.file_name().to_string_lossy().parse::<u32>().ok() else { continue };
|
||||||
|
let Ok(command) = std::fs::read_to_string(entry.path().join("cmdline")) else { continue };
|
||||||
|
let mut parts = command.split('\0').filter(|part| !part.is_empty());
|
||||||
|
let Some(program) = parts.next() else { continue };
|
||||||
|
let program = Path::new(program).file_name().map(|name| name.to_string_lossy().to_string());
|
||||||
|
if program.as_deref() != Some("claude") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push((pid, profile_of_process(pid)));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The profile directory a running session was started with.
|
||||||
|
fn profile_of_process(pid: u32) -> Option<PathBuf> {
|
||||||
|
let environ = std::fs::read_to_string(format!("/proc/{pid}/environ")).ok()?;
|
||||||
|
environ
|
||||||
|
.split('\0')
|
||||||
|
.find_map(|pair| pair.strip_prefix("CLAUDE_CONFIG_DIR=").map(expand_user))
|
||||||
|
.or_else(|| Some(home().join(".claude")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build(name: String, dir: PathBuf, entry: Option<&Entry>, configured: bool) -> Account {
|
||||||
|
// A port of zero means nothing has chosen one yet; `all` hands those out
|
||||||
|
// once it knows every port that is already spoken for.
|
||||||
|
let port = entry.and_then(|entry| entry.port).or_else(|| settings_port(&dir)).unwrap_or(0);
|
||||||
|
let workspace = entry
|
||||||
|
.and_then(|entry| entry.workspace.as_deref())
|
||||||
|
.map(expand_user)
|
||||||
|
.unwrap_or_else(|| workspace_for(&name));
|
||||||
|
Account { name, dir, port, workspace, configured }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where an account's proxy keeps its state.
|
||||||
|
///
|
||||||
|
/// `~/.headroom` and `~/.headroom-<name>` are preferred when they exist: they
|
||||||
|
/// are where the hand-written services on this machine put it, and a tool that
|
||||||
|
/// took over with a fresh directory would look like it had lost the cache.
|
||||||
|
fn workspace_for(name: &str) -> PathBuf {
|
||||||
|
let legacy = match name {
|
||||||
|
DEFAULT_NAME => home().join(".headroom"),
|
||||||
|
other => home().join(format!(".headroom-{other}")),
|
||||||
|
};
|
||||||
|
if legacy.is_dir() {
|
||||||
|
return legacy;
|
||||||
|
}
|
||||||
|
data_dir().join(name).join("headroom")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The directory an account with that name would live in.
|
||||||
|
fn dir_for(name: &str) -> PathBuf {
|
||||||
|
match name {
|
||||||
|
DEFAULT_NAME => home().join(".claude"),
|
||||||
|
other => home().join(format!(".claude-{other}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a profile directory is called here, or nothing when it is not one.
|
||||||
|
pub fn name_of(dir: &Path) -> Option<String> {
|
||||||
|
let file = dir.file_name()?.to_string_lossy().to_string();
|
||||||
|
match file.as_str() {
|
||||||
|
".claude" => Some(DEFAULT_NAME.to_string()),
|
||||||
|
other => other.strip_prefix(".claude-").map(router::label),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a directory is a Claude Code profile rather than something else
|
||||||
|
/// that happens to start with `.claude`.
|
||||||
|
///
|
||||||
|
/// A login or the file that remembers one is the test. `~/.claude-mem` holds a
|
||||||
|
/// database and a `settings.json` of its own and is not a profile, so the
|
||||||
|
/// presence of settings alone would be the wrong question.
|
||||||
|
fn is_profile(dir: &Path) -> bool {
|
||||||
|
if !dir.is_dir() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let Some(file) = dir.file_name() else { return false };
|
||||||
|
let file = file.to_string_lossy();
|
||||||
|
if file != ".claude" && !file.starts_with(".claude-") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
dir.join(".credentials.json").is_file() || dir.join(".claude.json").is_file()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn settings_port(dir: &Path) -> Option<u16> {
|
||||||
|
let value = read_json(&dir.join("settings.json"))?;
|
||||||
|
let url = value.get("env")?.get("ANTHROPIC_BASE_URL")?.as_str()?;
|
||||||
|
router::loopback_port(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_json(path: &Path) -> Option<serde_json::Value> {
|
||||||
|
serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok()
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
//! The configuration file: which account is active, and what belongs to each.
|
||||||
|
//!
|
||||||
|
//! Everything in here has a default that is derived from the machine, so the
|
||||||
|
//! file is an override and not a requirement. It is written the first time a
|
||||||
|
//! command changes something -- a switch, an adopted port -- and never
|
||||||
|
//! silently rewritten otherwise, so what a reader put in it stays there.
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use crate::paths::{config_dir, shorten};
|
||||||
|
|
||||||
|
/// The port the name router listens on unless the file says otherwise.
|
||||||
|
///
|
||||||
|
/// Port 80 is the point of the exercise: it is what makes the address
|
||||||
|
/// `headroom.localhost` rather than `headroom.localhost:8799`, and a user
|
||||||
|
/// service can hold it once the machine allows unprivileged low ports. The
|
||||||
|
/// status report says so when it cannot.
|
||||||
|
pub const DEFAULT_ROUTER_PORT: u16 = 80;
|
||||||
|
|
||||||
|
/// The first port handed to an account that has no port anywhere yet.
|
||||||
|
///
|
||||||
|
/// Clear of 8787 and 8788, which the two accounts on the author's machine have
|
||||||
|
/// used since before this tool existed, and clear of the router.
|
||||||
|
pub const FIRST_FREE_PORT: u16 = 8790;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct Config {
|
||||||
|
/// The account the `claude` command runs as. `None` means nothing has been
|
||||||
|
/// switched yet, and the default profile applies.
|
||||||
|
pub active: Option<String>,
|
||||||
|
/// The port the router listens on.
|
||||||
|
pub router_port: Option<u16>,
|
||||||
|
/// What is known about an account beyond what discovery finds.
|
||||||
|
pub accounts: BTreeMap<String, Entry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct Entry {
|
||||||
|
/// The profile directory, written with a `~` where it is below the home
|
||||||
|
/// directory.
|
||||||
|
pub dir: Option<String>,
|
||||||
|
/// The port this account's proxy listens on.
|
||||||
|
pub port: Option<u16>,
|
||||||
|
/// Where that proxy keeps what it holds.
|
||||||
|
pub workspace: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
pub fn path() -> PathBuf {
|
||||||
|
config_dir().join("config.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The file, or the defaults when there is none.
|
||||||
|
pub fn load() -> Result<Config> {
|
||||||
|
let path = Self::path();
|
||||||
|
let Ok(body) = std::fs::read_to_string(&path) else {
|
||||||
|
return Ok(Config::default());
|
||||||
|
};
|
||||||
|
serde_json::from_str(&body).with_context(|| format!("cannot read {}", shorten(&path)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(&self) -> Result<PathBuf> {
|
||||||
|
let path = Self::path();
|
||||||
|
std::fs::create_dir_all(config_dir())
|
||||||
|
.with_context(|| format!("cannot create {}", shorten(&config_dir())))?;
|
||||||
|
let mut body = serde_json::to_string_pretty(self)?;
|
||||||
|
body.push('\n');
|
||||||
|
std::fs::write(&path, body).with_context(|| format!("cannot write {}", shorten(&path)))?;
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn router_port(&self) -> u16 {
|
||||||
|
self.router_port.unwrap_or(DEFAULT_ROUTER_PORT)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remember something about an account without disturbing the rest.
|
||||||
|
pub fn entry_mut(&mut self, name: &str) -> &mut Entry {
|
||||||
|
self.accounts.entry(name.to_string()).or_default()
|
||||||
|
}
|
||||||
|
}
|
||||||
+197
@@ -0,0 +1,197 @@
|
|||||||
|
//! One Headroom proxy per account.
|
||||||
|
//!
|
||||||
|
//! A proxy holds what a session sends in order to compress it, and keeps what
|
||||||
|
//! it holds: a cache, a retrieval store and logs, all under one workspace
|
||||||
|
//! directory. One proxy shared by two accounts would therefore mix the work of
|
||||||
|
//! both in one place, which is exactly what keeping two accounts is meant to
|
||||||
|
//! avoid. So every account gets a proxy of its own: its own port, its own
|
||||||
|
//! workspace, its own service. Jails go one step further and get a proxy per
|
||||||
|
//! session; that is the `enqs-jail` plugin's business, and this tool only
|
||||||
|
//! resolves the names those publish.
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Context, Result};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use crate::account::Account;
|
||||||
|
use crate::paths::{config_dir, expand_user, home, shorten, which};
|
||||||
|
use crate::router;
|
||||||
|
use crate::systemd;
|
||||||
|
|
||||||
|
/// The template every account's proxy service is an instance of.
|
||||||
|
pub const TEMPLATE: &str = "claude-headroom@.service";
|
||||||
|
|
||||||
|
/// The services the author's machine had before this tool existed, and which
|
||||||
|
/// it should take over rather than run beside.
|
||||||
|
pub const LEGACY_UNITS: [&str; 2] = ["headroom-private.service", "headroom-work.service"];
|
||||||
|
|
||||||
|
/// The unit that serves one account's proxy.
|
||||||
|
pub fn unit(account: &Account) -> String {
|
||||||
|
format!("claude-headroom@{}.service", account.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The file that tells one instance of the template its port and its
|
||||||
|
/// workspace. A template unit is one file for every account; what differs per
|
||||||
|
/// account has to come from somewhere, and this is that somewhere.
|
||||||
|
pub fn environment_file(name: &str) -> PathBuf {
|
||||||
|
config_dir().join(format!("headroom-{name}.env"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `headroom` that can serve a proxy.
|
||||||
|
///
|
||||||
|
/// The distribution package ships the command-line tool without the proxy
|
||||||
|
/// extras, so the one in the `PATH` is not necessarily the one that can be
|
||||||
|
/// started here. A virtual environment next to the other per-user
|
||||||
|
/// installations is preferred over it, and an explicit path over both.
|
||||||
|
pub fn binary() -> Option<PathBuf> {
|
||||||
|
if let Ok(explicit) = std::env::var("CLAUDE_ACCOUNT_HEADROOM_BIN") {
|
||||||
|
if !explicit.is_empty() {
|
||||||
|
return Some(expand_user(&explicit));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let venv = home().join(".local/share/headroom-venv/bin/headroom");
|
||||||
|
if venv.is_file() {
|
||||||
|
return Some(venv);
|
||||||
|
}
|
||||||
|
which("headroom")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn require_binary() -> Result<PathBuf> {
|
||||||
|
binary().ok_or_else(|| {
|
||||||
|
anyhow!(
|
||||||
|
"headroom is not installed, so no proxy can be started.\n \
|
||||||
|
Install it with the proxy extras, or name it in CLAUDE_ACCOUNT_HEADROOM_BIN:\n \
|
||||||
|
pip install \"headroom-ai[proxy]\""
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What one account's proxy is doing.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct State {
|
||||||
|
pub unit: String,
|
||||||
|
pub installed: bool,
|
||||||
|
pub enabled: bool,
|
||||||
|
pub active: bool,
|
||||||
|
pub pid: Option<u32>,
|
||||||
|
/// Whether something actually answers on the account's port. A service
|
||||||
|
/// that is active but does not listen is a service that is still starting
|
||||||
|
/// or one that is wedged, and the two look the same from `systemctl`.
|
||||||
|
pub listening: bool,
|
||||||
|
pub workspace: PathBuf,
|
||||||
|
pub workspace_size: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn state(account: &Account) -> State {
|
||||||
|
let unit = unit(account);
|
||||||
|
State {
|
||||||
|
installed: systemd::unit_path(TEMPLATE).exists() && environment_file(&account.name).exists(),
|
||||||
|
enabled: systemd::is_enabled(&unit),
|
||||||
|
active: systemd::is_active(&unit),
|
||||||
|
pid: systemd::main_pid(&unit),
|
||||||
|
listening: router::is_listening(account.port),
|
||||||
|
workspace: account.workspace.clone(),
|
||||||
|
workspace_size: directory_size(&account.workspace),
|
||||||
|
unit,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write the service for one account and start it.
|
||||||
|
///
|
||||||
|
/// Also points the profile's `settings.json` at the proxy, because a proxy
|
||||||
|
/// nothing is routed through is a running process and no more.
|
||||||
|
pub fn install(account: &Account) -> Result<Installed> {
|
||||||
|
let binary = require_binary()?;
|
||||||
|
std::fs::create_dir_all(&account.workspace)
|
||||||
|
.with_context(|| format!("cannot create {}", shorten(&account.workspace)))?;
|
||||||
|
|
||||||
|
let (_, template_changed) = systemd::write_unit(TEMPLATE, &template(&binary))?;
|
||||||
|
|
||||||
|
let environment = environment_file(&account.name);
|
||||||
|
std::fs::create_dir_all(config_dir())?;
|
||||||
|
let body = format!(
|
||||||
|
"# Written by claude-account for the {} account. Run `claude-account headroom install {}`\n\
|
||||||
|
# after changing the port or the workspace in config.json.\n\
|
||||||
|
HEADROOM_WORKSPACE_DIR={}\n\
|
||||||
|
HEADROOM_TELEMETRY=off\n\
|
||||||
|
CLAUDE_ACCOUNT_PORT={}\n",
|
||||||
|
account.name,
|
||||||
|
account.name,
|
||||||
|
account.workspace.display(),
|
||||||
|
account.port
|
||||||
|
);
|
||||||
|
std::fs::write(&environment, body).with_context(|| format!("cannot write {}", shorten(&environment)))?;
|
||||||
|
|
||||||
|
if template_changed {
|
||||||
|
systemd::reload()?;
|
||||||
|
}
|
||||||
|
let unit = unit(account);
|
||||||
|
systemd::enable_now(&unit)?;
|
||||||
|
systemd::restart(&unit)?;
|
||||||
|
let routed = account.route_to_own_proxy()?;
|
||||||
|
Ok(Installed { unit, environment, routed })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Installed {
|
||||||
|
pub unit: String,
|
||||||
|
pub environment: PathBuf,
|
||||||
|
/// Whether `settings.json` had to be pointed at the proxy.
|
||||||
|
pub routed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop one account's proxy and forget its service.
|
||||||
|
pub fn uninstall(account: &Account) -> Result<()> {
|
||||||
|
let unit = unit(account);
|
||||||
|
systemd::disable_now(&unit)?;
|
||||||
|
let _ = std::fs::remove_file(environment_file(&account.name));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The template. `%i` is the account name, which is what makes one file serve
|
||||||
|
/// every account.
|
||||||
|
fn template(binary: &std::path::Path) -> String {
|
||||||
|
format!(
|
||||||
|
"# Written by `claude-account headroom install`. Do not edit by hand.\n\
|
||||||
|
[Unit]\n\
|
||||||
|
Description=Headroom proxy for the %i Claude account\n\
|
||||||
|
After=network-online.target\n\
|
||||||
|
\n\
|
||||||
|
[Service]\n\
|
||||||
|
EnvironmentFile=%h/.config/claude-account/headroom-%i.env\n\
|
||||||
|
ExecStart={} proxy --host 127.0.0.1 --port ${{CLAUDE_ACCOUNT_PORT}} --mode cache --no-telemetry\n\
|
||||||
|
Restart=on-failure\n\
|
||||||
|
RestartSec=5\n\
|
||||||
|
\n\
|
||||||
|
[Install]\n\
|
||||||
|
WantedBy=default.target\n",
|
||||||
|
binary.display()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The hand-written proxy services still on this machine.
|
||||||
|
///
|
||||||
|
/// Reported rather than removed: they are somebody's setup, and two services
|
||||||
|
/// on one port is a failure that is easy to explain and hard to notice.
|
||||||
|
pub fn legacy_units() -> Vec<String> {
|
||||||
|
let mut out: Vec<String> =
|
||||||
|
LEGACY_UNITS.iter().filter(|unit| systemd::exists(unit)).map(|unit| unit.to_string()).collect();
|
||||||
|
for unit in systemd::units_matching("headroom*.service") {
|
||||||
|
if !out.contains(&unit) && !unit.starts_with("claude-headroom@") {
|
||||||
|
out.push(unit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How much one workspace holds, in the form `du` prints it.
|
||||||
|
fn directory_size(path: &std::path::Path) -> Option<String> {
|
||||||
|
if !path.is_dir() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let output = std::process::Command::new(which("du")?)
|
||||||
|
.args(["-sh", &path.display().to_string()])
|
||||||
|
.stderr(std::process::Stdio::null())
|
||||||
|
.output()
|
||||||
|
.ok()?;
|
||||||
|
let text = String::from_utf8_lossy(&output.stdout);
|
||||||
|
text.split_whitespace().next().map(str::to_string)
|
||||||
|
}
|
||||||
+533
@@ -0,0 +1,533 @@
|
|||||||
|
//! Switch the account Claude Code runs as, with a Headroom proxy per account.
|
||||||
|
//!
|
||||||
|
//! The whole idea in one line: `claude` is the only command, and it runs as
|
||||||
|
//! whichever account was switched to last.
|
||||||
|
|
||||||
|
mod account;
|
||||||
|
mod config;
|
||||||
|
mod headroom;
|
||||||
|
mod paths;
|
||||||
|
mod router;
|
||||||
|
mod service;
|
||||||
|
mod shim;
|
||||||
|
mod status;
|
||||||
|
mod systemd;
|
||||||
|
mod ui;
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use clap::{Parser, Subcommand};
|
||||||
|
|
||||||
|
use crate::account::Account;
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::paths::shorten;
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(
|
||||||
|
name = "claude-account",
|
||||||
|
version,
|
||||||
|
about = "Switch the Claude account `claude` runs as, with a Headroom proxy per account.",
|
||||||
|
disable_help_subcommand = true
|
||||||
|
)]
|
||||||
|
struct Cli {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: Option<Command>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum Command {
|
||||||
|
/// Show every account, every proxy, every name and everything that is wrong
|
||||||
|
Status {
|
||||||
|
/// Add the last lines each proxy logged
|
||||||
|
#[arg(short, long)]
|
||||||
|
verbose: bool,
|
||||||
|
},
|
||||||
|
/// List the accounts, one per line
|
||||||
|
List,
|
||||||
|
/// Make an account the one `claude` runs as
|
||||||
|
Switch {
|
||||||
|
/// The account, as `list` names it
|
||||||
|
account: String,
|
||||||
|
},
|
||||||
|
/// Print the name of the active account
|
||||||
|
Current,
|
||||||
|
/// Print the profile directory of an account, for `CLAUDE_CONFIG_DIR`
|
||||||
|
Dir {
|
||||||
|
/// The account; the active one by default
|
||||||
|
account: Option<String>,
|
||||||
|
},
|
||||||
|
/// Print the shell lines that put this shell into an account
|
||||||
|
Env {
|
||||||
|
/// The account; the active one by default
|
||||||
|
account: Option<String>,
|
||||||
|
},
|
||||||
|
/// Print the address a name resolves to
|
||||||
|
Url {
|
||||||
|
/// An account or a jail; the active account by default
|
||||||
|
name: Option<String>,
|
||||||
|
},
|
||||||
|
/// Install everything at once: the wrapper, a proxy per account, the router
|
||||||
|
Setup,
|
||||||
|
/// Take over the proxy services that were written by hand
|
||||||
|
Adopt,
|
||||||
|
/// Report every problem and exit non-zero when there is one
|
||||||
|
Doctor,
|
||||||
|
/// The `claude` wrapper in ~/.local/bin
|
||||||
|
Shim {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: ShimCommand,
|
||||||
|
},
|
||||||
|
/// The proxy of one account
|
||||||
|
Headroom {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: HeadroomCommand,
|
||||||
|
},
|
||||||
|
/// The router that resolves <name>.headroom.localhost
|
||||||
|
Router {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: RouterCommand,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum ShimCommand {
|
||||||
|
/// Write ~/.local/bin/claude
|
||||||
|
Install {
|
||||||
|
/// Overwrite a `claude` that this tool did not write
|
||||||
|
#[arg(long)]
|
||||||
|
force: bool,
|
||||||
|
},
|
||||||
|
/// Remove it again
|
||||||
|
Uninstall,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum HeadroomCommand {
|
||||||
|
/// Write the service for an account and start it
|
||||||
|
Install {
|
||||||
|
/// The account; every account when left out
|
||||||
|
account: Option<String>,
|
||||||
|
},
|
||||||
|
/// Start the proxy of an account
|
||||||
|
Start { account: Option<String> },
|
||||||
|
/// Stop it
|
||||||
|
Stop { account: Option<String> },
|
||||||
|
/// Stop and start it
|
||||||
|
Restart { account: Option<String> },
|
||||||
|
/// Stop it and remove its service
|
||||||
|
Uninstall { account: String },
|
||||||
|
/// Give an account another port, and write it everywhere it is named
|
||||||
|
Port { account: String, port: u16 },
|
||||||
|
/// Print what one proxy logged
|
||||||
|
Logs {
|
||||||
|
account: Option<String>,
|
||||||
|
/// How many lines
|
||||||
|
#[arg(short = 'n', long, default_value_t = 40)]
|
||||||
|
lines: usize,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum RouterCommand {
|
||||||
|
/// Serve the names until stopped
|
||||||
|
Serve {
|
||||||
|
#[arg(long)]
|
||||||
|
port: Option<u16>,
|
||||||
|
},
|
||||||
|
/// Install the router as a user service
|
||||||
|
Install,
|
||||||
|
/// Stop it and remove its service
|
||||||
|
Uninstall,
|
||||||
|
/// Move the router to another port
|
||||||
|
Port { port: u16 },
|
||||||
|
/// List the names that resolve
|
||||||
|
List,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
if let Err(error) = run() {
|
||||||
|
eprintln!("{} {error:#}", ui::red("claude-account:"));
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run() -> Result<()> {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
let mut config = Config::load()?;
|
||||||
|
match cli.command.unwrap_or(Command::Status { verbose: false }) {
|
||||||
|
Command::Status { verbose } => {
|
||||||
|
status::report(&config, verbose);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Command::Doctor => {
|
||||||
|
let problems = status::report(&config, false);
|
||||||
|
if problems.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
Command::List => {
|
||||||
|
let active = account::active(&config).map(|account| account.name);
|
||||||
|
for account in account::all(&config) {
|
||||||
|
let marker = if Some(&account.name) == active.as_ref() { "*" } else { " " };
|
||||||
|
println!(
|
||||||
|
"{marker} {:<10} {:<28} {}",
|
||||||
|
account.name,
|
||||||
|
shorten(&account.dir),
|
||||||
|
account.email().unwrap_or_else(|| "not logged in".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Command::Switch { account } => switch(&mut config, &account),
|
||||||
|
Command::Current => {
|
||||||
|
let account = require_active(&config)?;
|
||||||
|
println!("{}", account.name);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Command::Dir { account } => {
|
||||||
|
let account = one(&config, account.as_deref())?;
|
||||||
|
println!("{}", account.dir.display());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Command::Env { account } => {
|
||||||
|
let account = one(&config, account.as_deref())?;
|
||||||
|
println!("export CLAUDE_CONFIG_DIR={}", account.dir.display());
|
||||||
|
println!("export ENQS_CLAUDE_PROFILE={}", account.dir.display());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Command::Url { name } => {
|
||||||
|
let name = match name {
|
||||||
|
Some(name) => name,
|
||||||
|
None => require_active(&config)?.name,
|
||||||
|
};
|
||||||
|
println!("{}", router::url(&config, &name));
|
||||||
|
if router::resolve(&config, &name).is_none() {
|
||||||
|
eprintln!("{} nothing is published as {name} right now", ui::yellow("note:"));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Command::Setup => setup(&mut config),
|
||||||
|
Command::Adopt => adopt(&mut config),
|
||||||
|
Command::Shim { command } => shim_command(command),
|
||||||
|
Command::Headroom { command } => headroom_command(&mut config, command),
|
||||||
|
Command::Router { command } => router_command(&mut config, command),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn switch(config: &mut Config, name: &str) -> Result<()> {
|
||||||
|
let account = account::switch(config, name)?;
|
||||||
|
ui::done(format!("`claude` now runs as {} ({}).", ui::bold(&account.name), shorten(&account.dir)));
|
||||||
|
match account.email() {
|
||||||
|
Some(email) => ui::field("account", email),
|
||||||
|
None => ui::field("account", ui::yellow("not logged in — run `claude /login`")),
|
||||||
|
}
|
||||||
|
ui::field("proxy", format!("{} on port {}", router::bare_url(config), account.port));
|
||||||
|
let state = headroom::state(&account);
|
||||||
|
if !state.listening {
|
||||||
|
ui::field("proxy", ui::yellow("not running"));
|
||||||
|
ui::hint(format!("claude-account headroom start {}", account.name));
|
||||||
|
}
|
||||||
|
let shim = shim::state();
|
||||||
|
if !shim.installed || !shim.managed {
|
||||||
|
ui::field("claude", ui::yellow("the wrapper is not installed, so the switch changes nothing"));
|
||||||
|
ui::hint("claude-account shim install");
|
||||||
|
}
|
||||||
|
// A session that is open keeps the profile it started with, which is a
|
||||||
|
// feature and a surprise; say it rather than let it be noticed later.
|
||||||
|
let sessions = account::running_sessions();
|
||||||
|
if !sessions.is_empty() {
|
||||||
|
ui::field("note", format!("{} session(s) are open and stay in their account", sessions.len()));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn setup(config: &mut Config) -> Result<()> {
|
||||||
|
let (path, real) = shim::install(false)?;
|
||||||
|
ui::done(format!("Wrapper written to {} (hands over to {}).", shorten(&path), shorten(&real)));
|
||||||
|
for account in account::all(config) {
|
||||||
|
let installed = headroom::install(&account)?;
|
||||||
|
ui::done(format!("Proxy for {} on port {}.", account.name, account.port));
|
||||||
|
ui::field("unit", installed.unit);
|
||||||
|
if installed.routed {
|
||||||
|
ui::field("settings.json", "now points at that proxy");
|
||||||
|
}
|
||||||
|
remember(config, &account);
|
||||||
|
}
|
||||||
|
config.save()?;
|
||||||
|
let (path, unit) = service::install(config)?;
|
||||||
|
ui::done(format!("Router on port {}.", config.router_port()));
|
||||||
|
ui::field("unit", format!("{unit} ({})", shorten(&path)));
|
||||||
|
if let Some(account) = account::active(config) {
|
||||||
|
ui::field("bare name", format!("{} goes to {}", router::bare_url(config), account.name));
|
||||||
|
}
|
||||||
|
let legacy = headroom::legacy_units();
|
||||||
|
if !legacy.is_empty() {
|
||||||
|
ui::field("note", format!("{} still run beside these", legacy.join(", ")));
|
||||||
|
ui::hint("claude-account adopt");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Take the hand-written proxy services over: read what they run, write it
|
||||||
|
/// down as the account's port and workspace, stop them, and put this tool's
|
||||||
|
/// services in their place.
|
||||||
|
fn adopt(config: &mut Config) -> Result<()> {
|
||||||
|
let legacy = headroom::legacy_units();
|
||||||
|
if legacy.is_empty() && !systemd::is_active(service::RIVAL_UNIT) {
|
||||||
|
ui::done("Nothing to adopt: no other proxy service is installed.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
for unit in &legacy {
|
||||||
|
let body = std::fs::read_to_string(systemd::unit_path(unit)).unwrap_or_default();
|
||||||
|
let port = unit_port(&body);
|
||||||
|
let workspace = unit_workspace(&body);
|
||||||
|
// The account is the one already on that port; a unit on a port nobody
|
||||||
|
// claims is reported and left alone, because guessing which account it
|
||||||
|
// belongs to would move somebody's cache.
|
||||||
|
let account =
|
||||||
|
port.and_then(|port| account::all(config).into_iter().find(|account| account.port == port));
|
||||||
|
match (account, port) {
|
||||||
|
(Some(account), Some(port)) => {
|
||||||
|
systemd::disable_now(unit)?;
|
||||||
|
let entry = config.entry_mut(&account.name);
|
||||||
|
entry.dir.get_or_insert_with(|| shorten(&account.dir));
|
||||||
|
entry.port = Some(port);
|
||||||
|
if let Some(workspace) = workspace {
|
||||||
|
entry.workspace = Some(shorten(&workspace));
|
||||||
|
}
|
||||||
|
config.save()?;
|
||||||
|
let account = account::find(config, &account.name)?;
|
||||||
|
let installed = headroom::install(&account)?;
|
||||||
|
ui::done(format!("{unit} is now {}.", installed.unit));
|
||||||
|
ui::field("port", port);
|
||||||
|
ui::field("workspace", shorten(&account.workspace));
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
ui::field("left alone", format!("{unit} runs on a port no account claims; move it by hand"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if systemd::is_active(service::RIVAL_UNIT) {
|
||||||
|
systemd::disable_now(service::RIVAL_UNIT)?;
|
||||||
|
ui::done(format!("{} stopped; this tool serves the names now.", service::RIVAL_UNIT));
|
||||||
|
service::install(config)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shim_command(command: ShimCommand) -> Result<()> {
|
||||||
|
match command {
|
||||||
|
ShimCommand::Install { force } => {
|
||||||
|
let (path, real) = shim::install(force)?;
|
||||||
|
ui::done(format!("`claude` is now {}.", shorten(&path)));
|
||||||
|
ui::field("hands over to", shorten(&real));
|
||||||
|
let state = shim::state();
|
||||||
|
if state.first_in_path.as_ref() != Some(&path) {
|
||||||
|
ui::field(
|
||||||
|
"warning",
|
||||||
|
ui::yellow(format!(
|
||||||
|
"a shell still finds {} first; put ~/.local/bin earlier in the PATH",
|
||||||
|
state.first_in_path.map(|path| shorten(&path)).unwrap_or_default()
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
ShimCommand::Uninstall => match shim::uninstall()? {
|
||||||
|
Some(path) => {
|
||||||
|
ui::done(format!("Removed {}.", shorten(&path)));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
ui::done("There was no wrapper to remove.");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn headroom_command(config: &mut Config, command: HeadroomCommand) -> Result<()> {
|
||||||
|
match command {
|
||||||
|
HeadroomCommand::Install { account } => {
|
||||||
|
for account in some(config, account.as_deref())? {
|
||||||
|
let installed = headroom::install(&account)?;
|
||||||
|
ui::done(format!("Proxy for {} on port {}.", account.name, account.port));
|
||||||
|
ui::field("unit", installed.unit);
|
||||||
|
ui::field("environment", shorten(&installed.environment));
|
||||||
|
ui::field("workspace", shorten(&account.workspace));
|
||||||
|
ui::field("address", router::url(config, &account.name));
|
||||||
|
if installed.routed {
|
||||||
|
ui::field("settings.json", "now points at that proxy");
|
||||||
|
}
|
||||||
|
remember(config, &account);
|
||||||
|
}
|
||||||
|
config.save()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
HeadroomCommand::Start { account } => {
|
||||||
|
for account in some(config, account.as_deref())? {
|
||||||
|
systemd::start(&headroom::unit(&account))?;
|
||||||
|
ui::done(format!("Proxy for {} started.", account.name));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
HeadroomCommand::Stop { account } => {
|
||||||
|
for account in some(config, account.as_deref())? {
|
||||||
|
systemd::stop(&headroom::unit(&account))?;
|
||||||
|
ui::done(format!("Proxy for {} stopped.", account.name));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
HeadroomCommand::Restart { account } => {
|
||||||
|
for account in some(config, account.as_deref())? {
|
||||||
|
systemd::restart(&headroom::unit(&account))?;
|
||||||
|
ui::done(format!("Proxy for {} restarted.", account.name));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
HeadroomCommand::Uninstall { account } => {
|
||||||
|
let account = account::find(config, &account)?;
|
||||||
|
headroom::uninstall(&account)?;
|
||||||
|
ui::done(format!("Proxy service of {} removed.", account.name));
|
||||||
|
ui::field("workspace", format!("{} is left alone", shorten(&account.workspace)));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
HeadroomCommand::Port { account, port } => {
|
||||||
|
let account = account::find(config, &account)?;
|
||||||
|
if let Some(other) = account::all(config)
|
||||||
|
.into_iter()
|
||||||
|
.find(|other| other.port == port && other.name != account.name)
|
||||||
|
{
|
||||||
|
return Err(anyhow!("port {port} already belongs to {}", other.name));
|
||||||
|
}
|
||||||
|
config.entry_mut(&account.name).port = Some(port);
|
||||||
|
config.save()?;
|
||||||
|
let account = account::find(config, &account.name)?;
|
||||||
|
let installed = headroom::install(&account)?;
|
||||||
|
ui::done(format!("{} now runs its proxy on port {port}.", account.name));
|
||||||
|
ui::field("unit", installed.unit);
|
||||||
|
ui::field("address", router::url(config, &account.name));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
HeadroomCommand::Logs { account, lines } => {
|
||||||
|
for account in some(config, account.as_deref())? {
|
||||||
|
let unit = headroom::unit(&account);
|
||||||
|
println!("{}", ui::bold(format!("{} ({unit})", account.name)));
|
||||||
|
for line in systemd::log_tail(&unit, lines) {
|
||||||
|
println!(" {line}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn router_command(config: &mut Config, command: RouterCommand) -> Result<()> {
|
||||||
|
match command {
|
||||||
|
RouterCommand::Serve { port } => {
|
||||||
|
let port = port.unwrap_or_else(|| config.router_port());
|
||||||
|
eprintln!("router on port {port}: names end in .{}", router::DOMAIN);
|
||||||
|
router::serve(config.clone(), port)
|
||||||
|
}
|
||||||
|
RouterCommand::Install => {
|
||||||
|
let (path, unit) = service::install(config)?;
|
||||||
|
ui::done(format!("Router installed on port {}.", config.router_port()));
|
||||||
|
ui::field("unit", format!("{unit} ({})", shorten(&path)));
|
||||||
|
if service::privileged(config.router_port()) && !router::is_listening(config.router_port()) {
|
||||||
|
ui::field(
|
||||||
|
"warning",
|
||||||
|
ui::yellow(
|
||||||
|
"that port is privileged. Allow it with \
|
||||||
|
`sudo sysctl -w net.ipv4.ip_unprivileged_port_start=80`, or move the \
|
||||||
|
router with `claude-account router port 8799`.",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if systemd::is_active(service::RIVAL_UNIT) {
|
||||||
|
ui::field(
|
||||||
|
"warning",
|
||||||
|
ui::yellow(format!("{} serves the same domain and holds the port", service::RIVAL_UNIT)),
|
||||||
|
);
|
||||||
|
ui::hint("claude-account adopt");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
RouterCommand::Uninstall => match service::uninstall()? {
|
||||||
|
Some(path) => {
|
||||||
|
ui::done(format!("Router service removed ({}).", shorten(&path)));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
ui::done("No router service was installed.");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
RouterCommand::Port { port } => {
|
||||||
|
config.router_port = Some(port);
|
||||||
|
config.save()?;
|
||||||
|
ui::done(format!("The router listens on port {port} from now on."));
|
||||||
|
if systemd::exists(service::UNIT) {
|
||||||
|
service::install(config)?;
|
||||||
|
ui::field("service", "rewritten and restarted");
|
||||||
|
}
|
||||||
|
ui::field("addresses", router::bare_url(config));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
RouterCommand::List => {
|
||||||
|
for entry in router::names(config) {
|
||||||
|
let answers = if router::is_listening(entry.port) { "" } else { " (nothing answers)" };
|
||||||
|
println!(
|
||||||
|
"{:<40} {:>6} {}{answers}",
|
||||||
|
router::url(config, &entry.name),
|
||||||
|
entry.port,
|
||||||
|
entry.source
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write what discovery worked out into the configuration, so that the router
|
||||||
|
/// service and the wrapper see the same numbers as this run.
|
||||||
|
fn remember(config: &mut Config, account: &Account) {
|
||||||
|
let entry = config.entry_mut(&account.name);
|
||||||
|
entry.dir.get_or_insert_with(|| shorten(&account.dir));
|
||||||
|
entry.port = Some(account.port);
|
||||||
|
entry.workspace = Some(shorten(&account.workspace));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn require_active(config: &Config) -> Result<Account> {
|
||||||
|
account::active(config).ok_or_else(|| {
|
||||||
|
anyhow!("no account is active. Run `claude-account list`, then `claude-account switch <account>`.")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn one(config: &Config, name: Option<&str>) -> Result<Account> {
|
||||||
|
match name {
|
||||||
|
Some(name) => account::find(config, name),
|
||||||
|
None => require_active(config),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One account, or every account when none was named.
|
||||||
|
fn some(config: &Config, name: Option<&str>) -> Result<Vec<Account>> {
|
||||||
|
match name {
|
||||||
|
Some(name) => Ok(vec![account::find(config, name)?]),
|
||||||
|
None => Ok(account::all(config)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The port a hand-written unit starts its proxy on.
|
||||||
|
fn unit_port(body: &str) -> Option<u16> {
|
||||||
|
let index = body.find("--port")?;
|
||||||
|
body[index + "--port".len()..].split_whitespace().next()?.parse().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The workspace a hand-written unit gives its proxy.
|
||||||
|
fn unit_workspace(body: &str) -> Option<std::path::PathBuf> {
|
||||||
|
let line = body.lines().find(|line| line.contains("HEADROOM_WORKSPACE_DIR="))?;
|
||||||
|
let value = line.split("HEADROOM_WORKSPACE_DIR=").nth(1)?.trim();
|
||||||
|
// systemd writes `%h` for the home directory; this tool writes paths.
|
||||||
|
Some(paths::expand_user(&value.replace("%h", &paths::home().display().to_string())))
|
||||||
|
}
|
||||||
+107
@@ -0,0 +1,107 @@
|
|||||||
|
//! Directories and small path helpers.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
pub fn home() -> PathBuf {
|
||||||
|
std::env::var_os("HOME").map(PathBuf::from).unwrap_or_else(|| PathBuf::from("/"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `~/.config/claude-account`, or the XDG directory when one is set.
|
||||||
|
pub fn config_dir() -> PathBuf {
|
||||||
|
match std::env::var("XDG_CONFIG_HOME") {
|
||||||
|
Ok(value) if !value.is_empty() => PathBuf::from(value),
|
||||||
|
_ => home().join(".config"),
|
||||||
|
}
|
||||||
|
.join("claude-account")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where per-account state that survives a reboot lives.
|
||||||
|
pub fn data_dir() -> PathBuf {
|
||||||
|
match std::env::var("XDG_DATA_HOME") {
|
||||||
|
Ok(value) if !value.is_empty() => PathBuf::from(value),
|
||||||
|
_ => home().join(".local/share"),
|
||||||
|
}
|
||||||
|
.join("claude-account")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where a running process writes what it publishes about itself.
|
||||||
|
///
|
||||||
|
/// The same directory the `enqs-jail` plugin publishes its per-jail proxies
|
||||||
|
/// into, because the router in this tool resolves those names as well: a jail
|
||||||
|
/// keeps `<jail>.headroom.localhost` whoever serves the domain.
|
||||||
|
pub fn runtime_dir() -> PathBuf {
|
||||||
|
if let Ok(dir) = std::env::var("ENQS_HEADROOM_RUNTIME_DIR") {
|
||||||
|
if !dir.is_empty() {
|
||||||
|
return expand_user(&dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let base = match std::env::var("XDG_RUNTIME_DIR") {
|
||||||
|
Ok(value) if !value.is_empty() => PathBuf::from(value),
|
||||||
|
_ => PathBuf::from(format!("/tmp/enqs-{}", uid())),
|
||||||
|
};
|
||||||
|
base.join("enqs-headroom")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn systemd_dir() -> PathBuf {
|
||||||
|
home().join(".config/systemd/user")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn uid() -> u32 {
|
||||||
|
extern "C" {
|
||||||
|
fn getuid() -> u32;
|
||||||
|
}
|
||||||
|
unsafe { getuid() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A path with a leading `~` resolved.
|
||||||
|
pub fn expand_user(value: &str) -> PathBuf {
|
||||||
|
match value.strip_prefix("~/") {
|
||||||
|
Some(rest) => home().join(rest),
|
||||||
|
None if value == "~" => home(),
|
||||||
|
None => PathBuf::from(value),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A path written the short way, for output and for the configuration file.
|
||||||
|
pub fn shorten(path: &Path) -> String {
|
||||||
|
let text = path.display().to_string();
|
||||||
|
let home = home().display().to_string();
|
||||||
|
match text.strip_prefix(&home) {
|
||||||
|
Some("") => "~".to_string(),
|
||||||
|
Some(rest) => format!("~{rest}"),
|
||||||
|
None => text,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The first executable of that name in the `PATH`, skipping any path the
|
||||||
|
/// caller wants skipped -- which is how the `claude` shim finds the real
|
||||||
|
/// `claude` and not itself.
|
||||||
|
pub fn which_excluding(program: &str, skip: &[PathBuf]) -> Option<PathBuf> {
|
||||||
|
let path = std::env::var_os("PATH")?;
|
||||||
|
for dir in std::env::split_paths(&path) {
|
||||||
|
let candidate = dir.join(program);
|
||||||
|
if !candidate.is_file() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let real = candidate.canonicalize().unwrap_or_else(|_| candidate.clone());
|
||||||
|
if skip.iter().any(|skip| {
|
||||||
|
let skip = skip.canonicalize().unwrap_or_else(|_| skip.clone());
|
||||||
|
skip == real
|
||||||
|
}) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if is_executable(&candidate) {
|
||||||
|
return Some(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn which(program: &str) -> Option<PathBuf> {
|
||||||
|
which_excluding(program, &[])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_executable(path: &Path) -> bool {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
std::fs::metadata(path).map(|meta| meta.permissions().mode() & 0o111 != 0).unwrap_or(false)
|
||||||
|
}
|
||||||
+402
@@ -0,0 +1,402 @@
|
|||||||
|
//! The names under `headroom.localhost`, and the router that resolves them.
|
||||||
|
//!
|
||||||
|
//! Every Headroom proxy on this machine listens on a port of its own, and a
|
||||||
|
//! port is a poor address: it is not memorable, a jail's is different in every
|
||||||
|
//! session, and a status line cannot make it clickable. So each proxy gets a
|
||||||
|
//! name, and one router on a fixed port forwards that name to the port of the
|
||||||
|
//! moment:
|
||||||
|
//!
|
||||||
|
//! * `headroom.localhost` -- the proxy of the account that is active right
|
||||||
|
//! now. The shortest address follows the switch, which is the point: there
|
||||||
|
//! is one address to keep in a bookmark and it is always the dashboard of
|
||||||
|
//! the account being worked in.
|
||||||
|
//! * `<account>.headroom.localhost` -- that one account's proxy, active or
|
||||||
|
//! not, for looking at the other one without switching.
|
||||||
|
//! * `<jail>.headroom.localhost` -- the proxy a jail started for itself. Those
|
||||||
|
//! are published as run files by the `enqs-jail` plugin, and this router
|
||||||
|
//! reads the same directory in the same format, so a jail keeps its name
|
||||||
|
//! whichever of the two routers is serving the domain.
|
||||||
|
//!
|
||||||
|
//! `*.localhost` resolves to the loopback address without any configuration,
|
||||||
|
//! in every browser and on this machine, so none of these names costs a line
|
||||||
|
//! in `/etc/hosts`.
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::io::{ErrorKind, Read, Write};
|
||||||
|
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, TcpListener, TcpStream};
|
||||||
|
use std::path::Path;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use crate::account;
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::paths::runtime_dir;
|
||||||
|
|
||||||
|
/// The suffix every name carries.
|
||||||
|
pub const DOMAIN: &str = "headroom.localhost";
|
||||||
|
|
||||||
|
/// How long the router waits for a proxy to accept a connection. Everything
|
||||||
|
/// here is on the loopback interface, so a proxy that does not answer at once
|
||||||
|
/// is gone rather than slow.
|
||||||
|
const CONNECT_TIMEOUT: Duration = Duration::from_millis(500);
|
||||||
|
|
||||||
|
/// The largest request head the router reads before it gives up.
|
||||||
|
const MAX_HEAD: usize = 64 * 1024;
|
||||||
|
|
||||||
|
/// What a jail publishes about the proxy it started. The shape is the one the
|
||||||
|
/// `enqs-jail` plugin writes; do not change it without changing that plugin.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Published {
|
||||||
|
pub name: String,
|
||||||
|
pub port: u16,
|
||||||
|
pub pid: u32,
|
||||||
|
pub jail: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What one name stands for, for the listing and the diagnostics.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Name {
|
||||||
|
pub name: String,
|
||||||
|
pub port: u16,
|
||||||
|
pub kind: Kind,
|
||||||
|
/// The account directory or the jail directory the name belongs to.
|
||||||
|
pub source: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Kind {
|
||||||
|
Account,
|
||||||
|
Jail,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A name as a DNS label: lower case, letters, digits and hyphens.
|
||||||
|
///
|
||||||
|
/// A jail may be called anything, and a name that is not a label would simply
|
||||||
|
/// not resolve. Both this tool and the `enqs-jail` plugin run a name through
|
||||||
|
/// the same rules, so both arrive at the same label.
|
||||||
|
pub fn label(name: &str) -> String {
|
||||||
|
let out = sanitize(name);
|
||||||
|
if out.is_empty() {
|
||||||
|
"jail".to_string()
|
||||||
|
} else {
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sanitize(name: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(name.len());
|
||||||
|
for character in name.chars().flat_map(char::to_lowercase) {
|
||||||
|
match character {
|
||||||
|
'a'..='z' | '0'..='9' => out.push(character),
|
||||||
|
'ä' => out.push_str("ae"),
|
||||||
|
'ö' => out.push_str("oe"),
|
||||||
|
'ü' => out.push_str("ue"),
|
||||||
|
'ß' => out.push_str("ss"),
|
||||||
|
_ => {
|
||||||
|
if !out.ends_with('-') {
|
||||||
|
out.push('-');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.trim_matches('-').to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The port of a URL that points at this machine, and nothing else.
|
||||||
|
pub fn loopback_port(url: &str) -> Option<u16> {
|
||||||
|
let rest = url.split("://").nth(1).unwrap_or(url);
|
||||||
|
let authority = rest.split('/').next()?;
|
||||||
|
let (host, port) = authority.rsplit_once(':')?;
|
||||||
|
match host {
|
||||||
|
"127.0.0.1" | "localhost" | "[::1]" | "::1" => port.parse().ok(),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The URL a name is reachable at.
|
||||||
|
///
|
||||||
|
/// Port 80 is left out, because a browser and a terminal both assume it: that
|
||||||
|
/// is what a router on the privileged port buys.
|
||||||
|
pub fn url(config: &Config, name: &str) -> String {
|
||||||
|
let host = format!("{}.{DOMAIN}", label(name));
|
||||||
|
match config.router_port() {
|
||||||
|
80 => format!("http://{host}"),
|
||||||
|
port => format!("http://{host}:{port}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The URL the domain itself is reachable at: the active account's proxy.
|
||||||
|
pub fn bare_url(config: &Config) -> String {
|
||||||
|
match config.router_port() {
|
||||||
|
80 => format!("http://{DOMAIN}"),
|
||||||
|
port => format!("http://{DOMAIN}:{port}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every name that resolves right now.
|
||||||
|
///
|
||||||
|
/// Jails come first: a jail may be called `work` as well, and the proxy a
|
||||||
|
/// session started for itself is the one that session means.
|
||||||
|
pub fn names(config: &Config) -> Vec<Name> {
|
||||||
|
let mut out: Vec<Name> = published()
|
||||||
|
.into_iter()
|
||||||
|
.map(|entry| Name { name: entry.name, port: entry.port, kind: Kind::Jail, source: entry.jail })
|
||||||
|
.collect();
|
||||||
|
for account in account::all(config) {
|
||||||
|
if out.iter().any(|name| name.name == account.name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push(Name {
|
||||||
|
name: account.name,
|
||||||
|
port: account.port,
|
||||||
|
kind: Kind::Account,
|
||||||
|
source: crate::paths::shorten(&account.dir),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The port a name resolves to right now.
|
||||||
|
pub fn resolve(config: &Config, name: &str) -> Option<u16> {
|
||||||
|
let wanted = label(name);
|
||||||
|
names(config).into_iter().find(|entry| entry.name == wanted).map(|entry| entry.port)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The port the domain itself resolves to: the proxy of the active account.
|
||||||
|
pub fn resolve_bare(config: &Config) -> Option<u16> {
|
||||||
|
account::active(config).map(|account| account.port)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every jail proxy whose session is still running.
|
||||||
|
pub fn published() -> Vec<Published> {
|
||||||
|
let Ok(entries) = std::fs::read_dir(runtime_dir()) else { return Vec::new() };
|
||||||
|
let mut out: Vec<Published> = entries
|
||||||
|
.flatten()
|
||||||
|
.filter(|entry| entry.path().extension().is_some_and(|extension| extension == "json"))
|
||||||
|
.filter_map(|entry| std::fs::read_to_string(entry.path()).ok())
|
||||||
|
.filter_map(|body| serde_json::from_str::<Published>(&body).ok())
|
||||||
|
.filter(|entry| Path::new(&format!("/proc/{}", entry.pid)).exists())
|
||||||
|
.collect();
|
||||||
|
out.sort_by(|left, right| left.name.cmp(&right.name));
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether something is already listening on a port of this machine.
|
||||||
|
pub fn is_listening(port: u16) -> bool {
|
||||||
|
[IpAddr::V4(Ipv4Addr::LOCALHOST), IpAddr::V6(Ipv6Addr::LOCALHOST)]
|
||||||
|
.iter()
|
||||||
|
.any(|address| TcpStream::connect_timeout(&SocketAddr::new(*address, port), CONNECT_TIMEOUT).is_ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serve the router until it is stopped.
|
||||||
|
///
|
||||||
|
/// Both loopback addresses are bound: `*.localhost` resolves to `::1` on some
|
||||||
|
/// systems and to `127.0.0.1` on others, and which one a client picks is not
|
||||||
|
/// this program's decision.
|
||||||
|
pub fn serve(config: Config, port: u16) -> Result<()> {
|
||||||
|
let mut listeners = Vec::new();
|
||||||
|
for address in [IpAddr::V6(Ipv6Addr::LOCALHOST), IpAddr::V4(Ipv4Addr::LOCALHOST)] {
|
||||||
|
match TcpListener::bind(SocketAddr::new(address, port)) {
|
||||||
|
Ok(listener) => listeners.push(listener),
|
||||||
|
// A dual-stack socket on `::1` already answers for `127.0.0.1` on
|
||||||
|
// some systems, so the second bind failing is not a failure.
|
||||||
|
Err(error) if error.kind() == ErrorKind::AddrInUse && !listeners.is_empty() => {}
|
||||||
|
Err(error) if error.kind() == ErrorKind::PermissionDenied => {
|
||||||
|
return Err(error).with_context(|| {
|
||||||
|
format!(
|
||||||
|
"cannot listen on port {port}. A user service may only take a port below \
|
||||||
|
1024 once the machine allows it: set \
|
||||||
|
net.ipv4.ip_unprivileged_port_start=80, or choose another port with \
|
||||||
|
`claude-account router port <port>`"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
return Err(error).with_context(|| format!("cannot listen on {address} port {port}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut threads = Vec::new();
|
||||||
|
for listener in listeners {
|
||||||
|
// The configuration is read once per connection rather than held, so
|
||||||
|
// a switch takes effect for the bare name without a restart. It is one
|
||||||
|
// small file on a loopback request.
|
||||||
|
threads.push(std::thread::spawn(move || {
|
||||||
|
for stream in listener.incoming().flatten() {
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let _ = handle(stream);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
drop(config);
|
||||||
|
for thread in threads {
|
||||||
|
let _ = thread.join();
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One connection: read the head, find the proxy the name stands for, and hand
|
||||||
|
/// the two sockets to each other.
|
||||||
|
fn handle(mut client: TcpStream) -> Result<()> {
|
||||||
|
let config = Config::load().unwrap_or_default();
|
||||||
|
let Some(head) = read_head(&mut client)? else { return Ok(()) };
|
||||||
|
let Some(host) = header(&head, "host") else {
|
||||||
|
return answer(&mut client, 400, "No Host header, so there is no name to resolve.");
|
||||||
|
};
|
||||||
|
let name = host_label(&host);
|
||||||
|
let bare = name.is_empty() || name == "headroom";
|
||||||
|
if bare && target(&head).as_deref() == Some("/names") {
|
||||||
|
return answer_html(&mut client, &index(&config));
|
||||||
|
}
|
||||||
|
let port = if bare { resolve_bare(&config) } else { resolve(&config, &name) };
|
||||||
|
let Some(port) = port else {
|
||||||
|
let what = if bare { "the active account".to_string() } else { name.clone() };
|
||||||
|
return answer(
|
||||||
|
&mut client,
|
||||||
|
404,
|
||||||
|
&format!("Nothing is published as {what}. The list is at /names on this domain."),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
let address = SocketAddr::from((Ipv4Addr::LOCALHOST, port));
|
||||||
|
let Ok(mut upstream) = TcpStream::connect_timeout(&address, CONNECT_TIMEOUT) else {
|
||||||
|
return answer(
|
||||||
|
&mut client,
|
||||||
|
502,
|
||||||
|
&format!("Nothing answers on port {port}, where that name points. Its proxy is not running."),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
upstream.write_all(&head)?;
|
||||||
|
upstream.flush()?;
|
||||||
|
let mut back = upstream.try_clone()?;
|
||||||
|
let mut client_out = client.try_clone()?;
|
||||||
|
let up = std::thread::spawn(move || {
|
||||||
|
let _ = std::io::copy(&mut client, &mut upstream);
|
||||||
|
let _ = upstream.shutdown(std::net::Shutdown::Write);
|
||||||
|
});
|
||||||
|
let _ = std::io::copy(&mut back, &mut client_out);
|
||||||
|
let _ = client_out.shutdown(std::net::Shutdown::Write);
|
||||||
|
let _ = up.join();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bytes up to and including the blank line that ends the head.
|
||||||
|
fn read_head(stream: &mut TcpStream) -> Result<Option<Vec<u8>>> {
|
||||||
|
let mut head = Vec::new();
|
||||||
|
let mut byte = [0u8; 1];
|
||||||
|
while head.len() < MAX_HEAD {
|
||||||
|
match stream.read(&mut byte)? {
|
||||||
|
0 => return Ok(None),
|
||||||
|
_ => head.push(byte[0]),
|
||||||
|
}
|
||||||
|
if head.ends_with(b"\r\n\r\n") || head.ends_with(b"\n\n") {
|
||||||
|
return Ok(Some(head));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn header(head: &[u8], wanted: &str) -> Option<String> {
|
||||||
|
let text = String::from_utf8_lossy(head);
|
||||||
|
text.lines().skip(1).take_while(|line| !line.trim().is_empty()).find_map(|line| {
|
||||||
|
let (key, value) = line.split_once(':')?;
|
||||||
|
key.trim().eq_ignore_ascii_case(wanted).then(|| value.trim().to_string())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The path of the request, without its query.
|
||||||
|
fn target(head: &[u8]) -> Option<String> {
|
||||||
|
let text = String::from_utf8_lossy(head);
|
||||||
|
let line = text.lines().next()?;
|
||||||
|
let path = line.split_whitespace().nth(1)?;
|
||||||
|
Some(path.split('?').next().unwrap_or(path).to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The label in front of `.headroom.localhost` in a Host header.
|
||||||
|
fn host_label(host: &str) -> String {
|
||||||
|
let host = host.split(':').next().unwrap_or(host);
|
||||||
|
let host = host.strip_suffix('.').unwrap_or(host);
|
||||||
|
let front = host.strip_suffix(".localhost").unwrap_or(host);
|
||||||
|
let front = front.strip_suffix(".headroom").unwrap_or(front);
|
||||||
|
sanitize(front)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn answer(stream: &mut TcpStream, status: u16, message: &str) -> Result<()> {
|
||||||
|
let body = format!("{status}\n\n{message}\n");
|
||||||
|
let head = format!(
|
||||||
|
"HTTP/1.1 {status} \r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||||
|
body.len()
|
||||||
|
);
|
||||||
|
stream.write_all(head.as_bytes())?;
|
||||||
|
stream.write_all(body.as_bytes())?;
|
||||||
|
stream.flush()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn answer_html(stream: &mut TcpStream, body: &str) -> Result<()> {
|
||||||
|
let head = format!(
|
||||||
|
"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||||
|
body.len()
|
||||||
|
);
|
||||||
|
stream.write_all(head.as_bytes())?;
|
||||||
|
stream.write_all(body.as_bytes())?;
|
||||||
|
stream.flush()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The page at `/names`: what resolves, and what it belongs to.
|
||||||
|
fn index(config: &Config) -> String {
|
||||||
|
let active = account::active(config).map(|account| account.name).unwrap_or_default();
|
||||||
|
let mut rows = String::new();
|
||||||
|
for entry in names(config) {
|
||||||
|
let kind = match entry.kind {
|
||||||
|
Kind::Account if entry.name == active => "account, active",
|
||||||
|
Kind::Account => "account",
|
||||||
|
Kind::Jail => "jail",
|
||||||
|
};
|
||||||
|
let address = url(config, &entry.name);
|
||||||
|
rows.push_str(&format!(
|
||||||
|
"<tr><td><a href=\"{address}\">{address}</a></td><td>{kind}</td><td>{}</td><td>{}</td></tr>",
|
||||||
|
entry.port,
|
||||||
|
html(&entry.source)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
format!(
|
||||||
|
"<!doctype html><meta charset=utf-8><title>headroom.localhost</title>\
|
||||||
|
<style>body{{font:14px/1.5 system-ui,sans-serif;margin:3rem auto;max-width:60rem}}\
|
||||||
|
td,th{{padding:.25rem 1rem .25rem 0;text-align:left;vertical-align:top}}\
|
||||||
|
code{{background:#eee;padding:.1rem .3rem;border-radius:3px}}</style>\
|
||||||
|
<h1>headroom.localhost</h1>\
|
||||||
|
<p>The bare name goes to the proxy of the active account, <code>{active}</code>.</p>\
|
||||||
|
<table><tr><th>Address<th>What<th>Port<th>Where</tr>{rows}</table>",
|
||||||
|
active = html(&active),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn html(text: &str) -> String {
|
||||||
|
text.replace('&', "&").replace('<', "<").replace('>', ">")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_host_header_gives_the_label() {
|
||||||
|
assert_eq!(host_label("work.headroom.localhost"), "work");
|
||||||
|
assert_eq!(host_label("headroom.localhost:80"), "headroom");
|
||||||
|
assert_eq!(host_label("my_jail.headroom.localhost"), "my-jail");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_a_loopback_url_has_a_port_here() {
|
||||||
|
assert_eq!(loopback_port("http://127.0.0.1:8787"), Some(8787));
|
||||||
|
assert_eq!(loopback_port("https://api.anthropic.com"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_name_becomes_a_label() {
|
||||||
|
assert_eq!(label("Kunde Süd"), "kunde-sued");
|
||||||
|
assert_eq!(label("..."), "jail");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
//! The user service that keeps the router answering.
|
||||||
|
//!
|
||||||
|
//! The router holds nothing and forwards to whatever is published at the time,
|
||||||
|
//! so one per machine is enough and a restart costs nothing.
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::paths::home;
|
||||||
|
use crate::systemd;
|
||||||
|
|
||||||
|
pub const UNIT: &str = "claude-account-router.service";
|
||||||
|
|
||||||
|
/// The router of the `enqs-jail` plugin, which serves the same domain. Two
|
||||||
|
/// routers cannot both hold the port, so whichever starts first wins and the
|
||||||
|
/// other restarts for ever; the status report says so.
|
||||||
|
pub const RIVAL_UNIT: &str = "enqs-headroom.service";
|
||||||
|
|
||||||
|
pub fn install(config: &Config) -> Result<(PathBuf, String)> {
|
||||||
|
let port = config.router_port();
|
||||||
|
let (path, changed) = systemd::write_unit(UNIT, &text(port))?;
|
||||||
|
if changed {
|
||||||
|
systemd::reload()?;
|
||||||
|
}
|
||||||
|
systemd::enable_now(UNIT)?;
|
||||||
|
systemd::restart(UNIT)?;
|
||||||
|
Ok((path, UNIT.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn uninstall() -> Result<Option<PathBuf>> {
|
||||||
|
systemd::disable_now(UNIT)?;
|
||||||
|
systemd::remove_unit(UNIT)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a user service takes a port it cannot have without help.
|
||||||
|
pub fn privileged(port: u16) -> bool {
|
||||||
|
port < 1024
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn text(port: u16) -> String {
|
||||||
|
let binary = binary();
|
||||||
|
format!(
|
||||||
|
"# Written by `claude-account router install`. Do not edit by hand.\n\
|
||||||
|
[Unit]\n\
|
||||||
|
Description=Router for <name>.headroom.localhost\n\
|
||||||
|
After=network-online.target\n\
|
||||||
|
\n\
|
||||||
|
[Service]\n\
|
||||||
|
ExecStart={binary} router serve --port {port}\n\
|
||||||
|
Restart=always\n\
|
||||||
|
RestartSec=5\n\
|
||||||
|
\n\
|
||||||
|
[Install]\n\
|
||||||
|
WantedBy=default.target\n"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a unit should start: the copy in `~/.local/bin` where there is one, so
|
||||||
|
/// that a rebuilt binary is picked up without rewriting the unit.
|
||||||
|
fn binary() -> String {
|
||||||
|
let installed = home().join(".local/bin/claude-account");
|
||||||
|
if installed.is_file() {
|
||||||
|
return installed.display().to_string();
|
||||||
|
}
|
||||||
|
std::env::current_exe()
|
||||||
|
.map(|path| path.display().to_string())
|
||||||
|
.unwrap_or_else(|_| "claude-account".to_string())
|
||||||
|
}
|
||||||
+124
@@ -0,0 +1,124 @@
|
|||||||
|
//! The `claude` command itself.
|
||||||
|
//!
|
||||||
|
//! The point of this tool is that there is one command. No `claude-work`
|
||||||
|
//! beside `claude`, no alias to remember, nothing to think about before
|
||||||
|
//! typing: `claude` runs as whichever account was switched to last. That is
|
||||||
|
//! done with a small script in `~/.local/bin`, which sets `CLAUDE_CONFIG_DIR`
|
||||||
|
//! and then hands over to the real `claude`.
|
||||||
|
//!
|
||||||
|
//! A wrapper rather than an alias, because an alias only exists in an
|
||||||
|
//! interactive shell: a hook, an editor plugin, a service and a subprocess all
|
||||||
|
//! call `claude` without one, and all of them must land in the same account as
|
||||||
|
//! the terminal.
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Context, Result};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use crate::paths::{home, shorten, which_excluding};
|
||||||
|
|
||||||
|
/// The line that marks a script as this tool's, so an unrelated `claude` in
|
||||||
|
/// the path is never overwritten and never claimed.
|
||||||
|
const MARK: &str = "# Written by claude-account.";
|
||||||
|
|
||||||
|
pub fn path() -> PathBuf {
|
||||||
|
home().join(".local/bin/claude")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the wrapper looks like right now.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct State {
|
||||||
|
pub path: PathBuf,
|
||||||
|
pub installed: bool,
|
||||||
|
/// Whether the file is this tool's, rather than something else of that
|
||||||
|
/// name.
|
||||||
|
pub managed: bool,
|
||||||
|
/// The `claude` the wrapper hands over to.
|
||||||
|
pub real: Option<PathBuf>,
|
||||||
|
/// The `claude` a shell finds first. Anything other than the wrapper means
|
||||||
|
/// the wrapper is installed and bypassed.
|
||||||
|
pub first_in_path: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn state() -> State {
|
||||||
|
let path = path();
|
||||||
|
let body = std::fs::read_to_string(&path).unwrap_or_default();
|
||||||
|
let managed = body.contains(MARK);
|
||||||
|
let real = body
|
||||||
|
.lines()
|
||||||
|
.find_map(|line| line.strip_prefix("real="))
|
||||||
|
.map(|value| PathBuf::from(value.trim_matches('"')));
|
||||||
|
State { installed: path.exists(), managed, real, first_in_path: which_excluding("claude", &[]), path }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The real `claude`: the first one in the `PATH` that is not the wrapper.
|
||||||
|
pub fn real_claude() -> Result<PathBuf> {
|
||||||
|
which_excluding("claude", &[path()]).ok_or_else(|| {
|
||||||
|
anyhow!("no `claude` in the PATH besides the wrapper, so there is nothing to hand over to")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write the wrapper.
|
||||||
|
pub fn install(force: bool) -> Result<(PathBuf, PathBuf)> {
|
||||||
|
let path = path();
|
||||||
|
let state = state();
|
||||||
|
if state.installed && !state.managed && !force {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"{} exists and was not written by this tool. Look at it, then repeat with --force.",
|
||||||
|
shorten(&path)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let real = real_claude()?;
|
||||||
|
let this = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("claude-account"));
|
||||||
|
let body = script(&real, &this);
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
std::fs::write(&path, body).with_context(|| format!("cannot write {}", shorten(&path)))?;
|
||||||
|
make_executable(&path)?;
|
||||||
|
Ok((path, real))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn uninstall() -> Result<Option<PathBuf>> {
|
||||||
|
let state = state();
|
||||||
|
if !state.installed {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
if !state.managed {
|
||||||
|
return Err(anyhow!("{} was not written by this tool, so it is left alone.", shorten(&state.path)));
|
||||||
|
}
|
||||||
|
std::fs::remove_file(&state.path)?;
|
||||||
|
Ok(Some(state.path))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The wrapper.
|
||||||
|
///
|
||||||
|
/// `CLAUDE_CONFIG_DIR` is only set when nothing has set it already, so a
|
||||||
|
/// session started deliberately in another profile -- a jail does this -- is
|
||||||
|
/// not dragged back to the active account. If the lookup fails for any reason,
|
||||||
|
/// the wrapper still starts Claude Code: a broken switch must not cost the
|
||||||
|
/// ability to work.
|
||||||
|
fn script(real: &Path, tool: &Path) -> String {
|
||||||
|
format!(
|
||||||
|
"#!/usr/bin/env bash\n\
|
||||||
|
{MARK} Run `claude-account shim install` to rewrite it.\n\
|
||||||
|
# `claude` runs as the account `claude-account switch` selected last.\n\
|
||||||
|
set -uo pipefail\n\
|
||||||
|
real=\"{real}\"\n\
|
||||||
|
tool=\"{tool}\"\n\
|
||||||
|
if [ -z \"${{CLAUDE_CONFIG_DIR:-}}\" ] && [ -x \"$tool\" ]; then\n\
|
||||||
|
\tdir=$(\"$tool\" dir 2>/dev/null) || dir=\"\"\n\
|
||||||
|
\t[ -n \"$dir\" ] && [ -d \"$dir\" ] && export CLAUDE_CONFIG_DIR=\"$dir\"\n\
|
||||||
|
fi\n\
|
||||||
|
exec \"$real\" \"$@\"\n",
|
||||||
|
real = real.display(),
|
||||||
|
tool = tool.display(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_executable(path: &Path) -> Result<()> {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let mut permissions = std::fs::metadata(path)?.permissions();
|
||||||
|
permissions.set_mode(0o755);
|
||||||
|
std::fs::set_permissions(path, permissions)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
+427
@@ -0,0 +1,427 @@
|
|||||||
|
//! The status report: everything that has to line up, and what to do when it
|
||||||
|
//! does not.
|
||||||
|
//!
|
||||||
|
//! The setup this tool manages has parts that fail quietly. A proxy that is
|
||||||
|
//! not running still leaves a `settings.json` pointing at it, so every session
|
||||||
|
//! fails to connect. Two routers on one port leave whichever lost restarting
|
||||||
|
//! for ever. A wrapper that is installed but shadowed sends every session to
|
||||||
|
//! the wrong account. None of that is visible from a terminal, so the report
|
||||||
|
//! checks each of them and says the command that repairs it.
|
||||||
|
|
||||||
|
use crate::account::{self, Account};
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::headroom;
|
||||||
|
use crate::paths::shorten;
|
||||||
|
use crate::router::{self, Kind};
|
||||||
|
use crate::service;
|
||||||
|
use crate::shim;
|
||||||
|
use crate::systemd;
|
||||||
|
use crate::ui;
|
||||||
|
|
||||||
|
/// Something that is wrong, and the command that fixes it.
|
||||||
|
pub struct Problem {
|
||||||
|
pub what: String,
|
||||||
|
pub fix: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Problem {
|
||||||
|
fn new(what: impl Into<String>, fix: Option<&str>) -> Problem {
|
||||||
|
Problem { what: what.into(), fix: fix.map(str::to_string) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Print the report. Returns the problems, so `doctor` can exit on them.
|
||||||
|
pub fn report(config: &Config, verbose: bool) -> Vec<Problem> {
|
||||||
|
let mut problems = Vec::new();
|
||||||
|
let accounts = account::all(config);
|
||||||
|
let active = account::active(config);
|
||||||
|
|
||||||
|
account_section(config, &accounts, active.as_ref(), &mut problems);
|
||||||
|
command_section(active.as_ref(), &mut problems);
|
||||||
|
proxy_section(&accounts, active.as_ref(), &mut problems, verbose);
|
||||||
|
router_section(config, active.as_ref(), &mut problems);
|
||||||
|
jail_section(config, &mut problems);
|
||||||
|
session_section(&accounts);
|
||||||
|
problem_section(&problems);
|
||||||
|
problems
|
||||||
|
}
|
||||||
|
|
||||||
|
fn account_section(
|
||||||
|
config: &Config,
|
||||||
|
accounts: &[Account],
|
||||||
|
active: Option<&Account>,
|
||||||
|
problems: &mut Vec<Problem>,
|
||||||
|
) {
|
||||||
|
ui::heading("Accounts");
|
||||||
|
if accounts.is_empty() {
|
||||||
|
ui::bad("none", "no Claude Code profile was found below the home directory");
|
||||||
|
problems.push(Problem::new("there is no account to run as", Some("claude /login")));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for account in accounts {
|
||||||
|
let marker = if Some(account.name.as_str()) == active.map(|a| a.name.as_str()) {
|
||||||
|
ui::green("●")
|
||||||
|
} else {
|
||||||
|
ui::dim("○")
|
||||||
|
};
|
||||||
|
let who = account.email().unwrap_or_else(|| "not logged in".to_string());
|
||||||
|
let plan = account.plan().map(|plan| format!(" ({plan})")).unwrap_or_default();
|
||||||
|
println!(" {marker} {:<10} {}{}", ui::bold(&account.name), who, ui::dim(plan));
|
||||||
|
ui::field("profile", shorten(&account.dir));
|
||||||
|
ui::field("proxy", format!("{} on port {}", router::url(config, &account.name), account.port));
|
||||||
|
ui::field("plugins", plugins_line(account));
|
||||||
|
match account.token_minutes_left() {
|
||||||
|
Some(minutes) if minutes < 0 => {
|
||||||
|
ui::field("token", ui::red("expired"));
|
||||||
|
problems.push(Problem::new(
|
||||||
|
format!("the login of {} has expired", account.name),
|
||||||
|
Some(&format!("claude-account switch {} && claude /login", account.name)),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Some(minutes) if minutes < 60 => {
|
||||||
|
ui::field("token", ui::yellow(format!("{minutes} minutes left")))
|
||||||
|
}
|
||||||
|
Some(minutes) => ui::field("token", format!("{} hours left", minutes / 60)),
|
||||||
|
None if !account.has_credentials() => {
|
||||||
|
ui::field("token", ui::yellow("none"));
|
||||||
|
problems.push(Problem::new(
|
||||||
|
format!("{} holds no login", account.name),
|
||||||
|
Some(&format!("claude-account switch {} && claude /login", account.name)),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
None => ui::field("token", ui::dim("unknown")),
|
||||||
|
}
|
||||||
|
if !account.configured {
|
||||||
|
ui::field("state", ui::dim("found on this machine, not in config.json"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two accounts on one port, or in one workspace, is the failure this whole
|
||||||
|
// tool exists to prevent: it puts the work of both in one cache.
|
||||||
|
for (index, account) in accounts.iter().enumerate() {
|
||||||
|
for other in &accounts[index + 1..] {
|
||||||
|
if account.port == other.port {
|
||||||
|
problems.push(Problem::new(
|
||||||
|
format!("{} and {} both use port {}", account.name, other.name, account.port),
|
||||||
|
Some("claude-account headroom port <account> <port>"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if account.workspace == other.workspace {
|
||||||
|
problems.push(Problem::new(
|
||||||
|
format!(
|
||||||
|
"{} and {} share the proxy workspace {}, so each reads what the other kept",
|
||||||
|
account.name,
|
||||||
|
other.name,
|
||||||
|
shorten(&account.workspace)
|
||||||
|
),
|
||||||
|
Some("edit the workspace in ~/.config/claude-account/config.json"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn plugins_line(account: &Account) -> String {
|
||||||
|
let plugins = account.plugins();
|
||||||
|
match plugins.len() {
|
||||||
|
0 => ui::dim("none").to_string(),
|
||||||
|
count => format!("{count}: {}", plugins.join(", ")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `claude` command: which one a shell finds, and which account it starts.
|
||||||
|
fn command_section(active: Option<&Account>, problems: &mut Vec<Problem>) {
|
||||||
|
ui::heading("The claude command");
|
||||||
|
let state = shim::state();
|
||||||
|
let first = state.first_in_path.clone();
|
||||||
|
match (&first, state.installed, state.managed) {
|
||||||
|
(Some(path), true, true) if *path == state.path => {
|
||||||
|
ui::ok("wrapper", shorten(path));
|
||||||
|
}
|
||||||
|
(_, true, true) => {
|
||||||
|
ui::warn("wrapper", format!("{} is installed but shadowed", shorten(&state.path)));
|
||||||
|
let found = first.as_ref().map(|path| shorten(path)).unwrap_or_default();
|
||||||
|
problems.push(Problem::new(
|
||||||
|
format!("a shell finds {found} first, so the switch has no effect"),
|
||||||
|
Some("put ~/.local/bin first in the PATH"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
(_, true, false) => {
|
||||||
|
ui::warn("wrapper", format!("{} was written by something else", shorten(&state.path)));
|
||||||
|
problems.push(Problem::new(
|
||||||
|
format!("{} is not this tool's wrapper", shorten(&state.path)),
|
||||||
|
Some("claude-account shim install --force"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
ui::bad("wrapper", "not installed");
|
||||||
|
problems.push(Problem::new(
|
||||||
|
"`claude` does not follow the active account yet",
|
||||||
|
Some("claude-account shim install"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match (&state.real, shim::real_claude()) {
|
||||||
|
(Some(baked), Ok(found)) if *baked != found => {
|
||||||
|
ui::warn("hands over to", format!("{} (the PATH now has {})", shorten(baked), shorten(&found)));
|
||||||
|
problems.push(Problem::new(
|
||||||
|
"the wrapper points at an older claude than the PATH does",
|
||||||
|
Some("claude-account shim install"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
(Some(baked), _) if !baked.is_file() => {
|
||||||
|
ui::bad("hands over to", format!("{} is gone", shorten(baked)));
|
||||||
|
problems.push(Problem::new(
|
||||||
|
"the wrapper hands over to a file that no longer exists",
|
||||||
|
Some("claude-account shim install"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
(Some(baked), _) => ui::ok("hands over to", shorten(baked)),
|
||||||
|
(None, Ok(found)) => ui::field("claude", shorten(&found)),
|
||||||
|
(None, Err(_)) => {
|
||||||
|
ui::bad("claude", "not in the PATH");
|
||||||
|
problems.push(Problem::new("Claude Code is not installed on this machine", None));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match active {
|
||||||
|
Some(account) => ui::ok("runs as", format!("{} ({})", account.name, shorten(&account.dir))),
|
||||||
|
None => {
|
||||||
|
ui::bad("runs as", "nothing: the active account does not exist");
|
||||||
|
problems.push(Problem::new(
|
||||||
|
"the active account in config.json names no profile that exists",
|
||||||
|
Some("claude-account switch <account>"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Ok(explicit) = std::env::var("CLAUDE_CONFIG_DIR") {
|
||||||
|
if !explicit.is_empty() {
|
||||||
|
ui::field("this shell", format!("CLAUDE_CONFIG_DIR={explicit}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn proxy_section(accounts: &[Account], active: Option<&Account>, problems: &mut Vec<Problem>, verbose: bool) {
|
||||||
|
ui::heading("Headroom per account");
|
||||||
|
match headroom::binary() {
|
||||||
|
Some(path) => ui::ok("headroom", shorten(&path)),
|
||||||
|
None => {
|
||||||
|
ui::bad("headroom", "not installed");
|
||||||
|
problems.push(Problem::new(
|
||||||
|
"headroom is not installed, so no account can have a proxy",
|
||||||
|
Some("pip install \"headroom-ai[proxy]\""),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !systemd::available() {
|
||||||
|
ui::warn("systemd", "not available for this user; proxies cannot be kept running");
|
||||||
|
problems.push(Problem::new("there is no user systemd here to run the proxies", None));
|
||||||
|
}
|
||||||
|
|
||||||
|
for account in accounts {
|
||||||
|
let state = headroom::state(account);
|
||||||
|
let name = ui::bold(&account.name);
|
||||||
|
match (state.installed, state.active, state.listening) {
|
||||||
|
(true, true, true) => println!(" {} {name} on port {}", ui::green("✓"), account.port),
|
||||||
|
(true, true, false) => {
|
||||||
|
println!(
|
||||||
|
" {} {name} service runs but nothing answers on port {}",
|
||||||
|
ui::red("✗"),
|
||||||
|
account.port
|
||||||
|
);
|
||||||
|
problems.push(Problem::new(
|
||||||
|
format!("the proxy of {} does not answer on port {}", account.name, account.port),
|
||||||
|
Some(&format!("claude-account headroom restart {}", account.name)),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
(true, false, _) => {
|
||||||
|
println!(" {} {name} service is installed but stopped", ui::red("✗"));
|
||||||
|
problems.push(Problem::new(
|
||||||
|
format!("the proxy of {} is not running", account.name),
|
||||||
|
Some(&format!("claude-account headroom start {}", account.name)),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
(false, _, true) => {
|
||||||
|
println!(
|
||||||
|
" {} {name} answers on port {}, but not from a service of this tool",
|
||||||
|
ui::yellow("!"),
|
||||||
|
account.port
|
||||||
|
);
|
||||||
|
}
|
||||||
|
(false, _, false) => {
|
||||||
|
println!(" {} {name} has no proxy", ui::yellow("!"));
|
||||||
|
problems.push(Problem::new(
|
||||||
|
format!("{} has no proxy of its own", account.name),
|
||||||
|
Some(&format!("claude-account headroom install {}", account.name)),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ui::field(
|
||||||
|
"workspace",
|
||||||
|
match &state.workspace_size {
|
||||||
|
Some(size) => format!("{} ({size})", shorten(&state.workspace)),
|
||||||
|
None => format!("{} {}", shorten(&state.workspace), ui::dim("(empty)")),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if let Some(pid) = state.pid {
|
||||||
|
ui::field("process", format!("pid {pid}, unit {}", state.unit));
|
||||||
|
}
|
||||||
|
if state.installed && !state.enabled {
|
||||||
|
ui::field("at login", ui::yellow("not started: the service is installed but not enabled"));
|
||||||
|
problems.push(Problem::new(
|
||||||
|
format!("the proxy of {} does not come back after a reboot", account.name),
|
||||||
|
Some(&format!("claude-account headroom install {}", account.name)),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
match account.base_url() {
|
||||||
|
Some(url) if url == account.own_url() => ui::field("sessions", format!("go to {url}")),
|
||||||
|
Some(url) => {
|
||||||
|
ui::field("sessions", ui::yellow(format!("go to {url}, not to this account's proxy")));
|
||||||
|
problems.push(Problem::new(
|
||||||
|
format!("{} sends its sessions to {url}", account.name),
|
||||||
|
Some(&format!("claude-account headroom install {}", account.name)),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
ui::field("sessions", ui::yellow("go straight to the API"));
|
||||||
|
problems.push(Problem::new(
|
||||||
|
format!("{} does not use its proxy at all", account.name),
|
||||||
|
Some(&format!("claude-account headroom install {}", account.name)),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if verbose {
|
||||||
|
for line in systemd::log_tail(&state.unit, 3) {
|
||||||
|
ui::field("log", ui::dim(line));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let legacy = headroom::legacy_units();
|
||||||
|
if !legacy.is_empty() {
|
||||||
|
ui::warn("older units", legacy.join(", "));
|
||||||
|
problems.push(Problem::new(
|
||||||
|
format!("{} still run a proxy beside this tool's", legacy.join(" and ")),
|
||||||
|
Some("claude-account adopt"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let _ = active;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn router_section(config: &Config, active: Option<&Account>, problems: &mut Vec<Problem>) {
|
||||||
|
ui::heading("Names");
|
||||||
|
let port = config.router_port();
|
||||||
|
if router::is_listening(port) {
|
||||||
|
ui::ok("router", format!("answering on port {port}"));
|
||||||
|
} else {
|
||||||
|
ui::bad("router", format!("nothing answers on port {port}"));
|
||||||
|
problems.push(Problem::new(
|
||||||
|
"the .headroom.localhost names do not resolve",
|
||||||
|
Some("claude-account router install"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if systemd::exists(service::UNIT) {
|
||||||
|
let state = if systemd::is_active(service::UNIT) { "running" } else { "stopped" };
|
||||||
|
ui::field("service", format!("{} ({state})", service::UNIT));
|
||||||
|
} else {
|
||||||
|
ui::field("service", ui::dim("not installed"));
|
||||||
|
}
|
||||||
|
if service::privileged(port) && !router::is_listening(port) {
|
||||||
|
problems.push(Problem::new(
|
||||||
|
format!("port {port} is privileged, and a user service may not take it here"),
|
||||||
|
Some("sudo sysctl -w net.ipv4.ip_unprivileged_port_start=80"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// The plugin ships a router for the same domain. Both cannot hold the
|
||||||
|
// port, and the one that lost is the one restarting in a loop.
|
||||||
|
if systemd::is_active(service::RIVAL_UNIT) {
|
||||||
|
ui::warn("also running", service::RIVAL_UNIT);
|
||||||
|
problems.push(Problem::new(
|
||||||
|
format!("{} serves the same domain and fights over port {port}", service::RIVAL_UNIT),
|
||||||
|
Some("systemctl --user disable --now enqs-headroom.service"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
match active {
|
||||||
|
Some(account) => ui::field(
|
||||||
|
"bare name",
|
||||||
|
format!("{} goes to {} on port {}", router::bare_url(config), account.name, account.port),
|
||||||
|
),
|
||||||
|
None => ui::field("bare name", ui::yellow("goes nowhere: no account is active")),
|
||||||
|
}
|
||||||
|
for entry in router::names(config) {
|
||||||
|
let kind = match entry.kind {
|
||||||
|
Kind::Account => "account",
|
||||||
|
Kind::Jail => "jail",
|
||||||
|
};
|
||||||
|
let answers = router::is_listening(entry.port);
|
||||||
|
let mark = if answers { ui::green("✓") } else { ui::yellow("!") };
|
||||||
|
println!(
|
||||||
|
" {mark} {:<38} {}",
|
||||||
|
router::url(config, &entry.name),
|
||||||
|
ui::dim(format!("{kind}, port {} — {}", entry.port, entry.source))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn jail_section(config: &Config, problems: &mut Vec<Problem>) {
|
||||||
|
let jails = router::published();
|
||||||
|
if jails.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ui::heading("Jails with a proxy of their own");
|
||||||
|
for jail in jails {
|
||||||
|
let answers = router::is_listening(jail.port);
|
||||||
|
if answers {
|
||||||
|
ui::ok(&jail.name, format!("{} — {}", router::url(config, &jail.name), jail.jail));
|
||||||
|
} else {
|
||||||
|
ui::bad(&jail.name, format!("published on port {} but nothing answers", jail.port));
|
||||||
|
problems.push(Problem::new(
|
||||||
|
format!("the jail {} published a proxy that is gone", jail.name),
|
||||||
|
Some("end the session that left it, or remove its run file"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn session_section(accounts: &[Account]) {
|
||||||
|
let sessions = account::running_sessions();
|
||||||
|
if sessions.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ui::heading("Sessions running now");
|
||||||
|
for (pid, profile) in sessions {
|
||||||
|
let name = profile
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|dir| accounts.iter().find(|account| &account.dir == dir))
|
||||||
|
.map(|account| account.name.clone())
|
||||||
|
.or_else(|| profile.as_ref().map(|dir| shorten(dir)))
|
||||||
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
|
ui::field(format!("pid {pid}"), name);
|
||||||
|
}
|
||||||
|
println!(
|
||||||
|
" {}",
|
||||||
|
ui::dim("A switch does not disturb these: each session keeps the profile it started with.")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn problem_section(problems: &[Problem]) {
|
||||||
|
if problems.is_empty() {
|
||||||
|
println!("\n{} {}", ui::green("✓"), ui::bold("Nothing to fix."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ui::heading(format!("{} to fix", count(problems.len())));
|
||||||
|
for problem in problems {
|
||||||
|
println!(" {} {}", ui::red("✗"), problem.what);
|
||||||
|
if let Some(fix) = &problem.fix {
|
||||||
|
ui::hint(fix);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn count(number: usize) -> String {
|
||||||
|
match number {
|
||||||
|
1 => "1 thing".to_string(),
|
||||||
|
other => format!("{other} things"),
|
||||||
|
}
|
||||||
|
}
|
||||||
+152
@@ -0,0 +1,152 @@
|
|||||||
|
//! The user services this tool writes, and the few `systemctl` calls it makes.
|
||||||
|
//!
|
||||||
|
//! systemd is what keeps a proxy running between terminals and brings it back
|
||||||
|
//! after a reboot, which is the whole reason the proxies are services rather
|
||||||
|
//! than children of this process: the tool exits, the proxy must not.
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Context, Result};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
|
||||||
|
use crate::paths::{shorten, systemd_dir, which};
|
||||||
|
|
||||||
|
/// Whether this machine has a user systemd at all.
|
||||||
|
pub fn available() -> bool {
|
||||||
|
which("systemctl").is_some() && std::env::var_os("XDG_RUNTIME_DIR").is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn require() -> Result<()> {
|
||||||
|
if available() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err(anyhow!(
|
||||||
|
"systemd is not available for this user, so services cannot be managed here. \
|
||||||
|
Start the proxies by hand, or run `claude-account router serve` in a terminal."
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn unit_path(unit: &str) -> PathBuf {
|
||||||
|
systemd_dir().join(unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write a unit file, and report whether its contents changed.
|
||||||
|
pub fn write_unit(unit: &str, body: &str) -> Result<(PathBuf, bool)> {
|
||||||
|
let path = unit_path(unit);
|
||||||
|
std::fs::create_dir_all(systemd_dir())
|
||||||
|
.with_context(|| format!("cannot create {}", shorten(&systemd_dir())))?;
|
||||||
|
let current = std::fs::read_to_string(&path).unwrap_or_default();
|
||||||
|
if current == body {
|
||||||
|
return Ok((path, false));
|
||||||
|
}
|
||||||
|
std::fs::write(&path, body).with_context(|| format!("cannot write {}", shorten(&path)))?;
|
||||||
|
Ok((path, true))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove_unit(unit: &str) -> Result<Option<PathBuf>> {
|
||||||
|
let path = unit_path(unit);
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
std::fs::remove_file(&path).with_context(|| format!("cannot remove {}", shorten(&path)))?;
|
||||||
|
Ok(Some(path))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reload() -> Result<()> {
|
||||||
|
require()?;
|
||||||
|
run(&["daemon-reload"])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start a unit now and at every login.
|
||||||
|
pub fn enable_now(unit: &str) -> Result<()> {
|
||||||
|
require()?;
|
||||||
|
run(&["enable", "--now", unit])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop a unit now and at every login.
|
||||||
|
pub fn disable_now(unit: &str) -> Result<()> {
|
||||||
|
require()?;
|
||||||
|
// A unit whose file is already gone cannot be disabled, and a failure here
|
||||||
|
// would hide the removal that did work.
|
||||||
|
let _ = run(&["disable", "--now", unit]);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn restart(unit: &str) -> Result<()> {
|
||||||
|
require()?;
|
||||||
|
run(&["restart", unit])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stop(unit: &str) -> Result<()> {
|
||||||
|
require()?;
|
||||||
|
run(&["stop", unit])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn start(unit: &str) -> Result<()> {
|
||||||
|
require()?;
|
||||||
|
run(&["start", unit])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_active(unit: &str) -> bool {
|
||||||
|
query(&["is-active", unit]).as_deref() == Some("active")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_enabled(unit: &str) -> bool {
|
||||||
|
matches!(query(&["is-enabled", unit]).as_deref(), Some("enabled") | Some("enabled-runtime"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether systemd knows a unit, whoever wrote it.
|
||||||
|
pub fn exists(unit: &str) -> bool {
|
||||||
|
unit_path(unit).exists()
|
||||||
|
|| query(&["list-unit-files", "--no-legend", unit]).is_some_and(|out| !out.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The main process of a unit, or nothing when it is not running.
|
||||||
|
pub fn main_pid(unit: &str) -> Option<u32> {
|
||||||
|
let value = query(&["show", "-p", "MainPID", "--value", unit])?;
|
||||||
|
match value.parse::<u32>() {
|
||||||
|
Ok(0) | Err(_) => None,
|
||||||
|
Ok(pid) => Some(pid),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The last lines a unit logged, for the status report.
|
||||||
|
pub fn log_tail(unit: &str, lines: usize) -> Vec<String> {
|
||||||
|
let Some(journalctl) = which("journalctl") else { return Vec::new() };
|
||||||
|
let output = Command::new(journalctl)
|
||||||
|
.args(["--user", "-u", unit, "-n", &lines.to_string(), "--no-pager", "-o", "cat"])
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.output();
|
||||||
|
match output {
|
||||||
|
Ok(output) if output.status.success() => String::from_utf8_lossy(&output.stdout)
|
||||||
|
.lines()
|
||||||
|
.filter(|line| !line.trim().is_empty())
|
||||||
|
.map(str::to_string)
|
||||||
|
.collect(),
|
||||||
|
_ => Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every unit on this machine whose name matches a pattern, whoever wrote it.
|
||||||
|
pub fn units_matching(pattern: &str) -> Vec<String> {
|
||||||
|
let Some(output) = query(&["list-unit-files", "--no-legend", pattern]) else { return Vec::new() };
|
||||||
|
output.lines().filter_map(|line| line.split_whitespace().next().map(str::to_string)).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(arguments: &[&str]) -> Result<()> {
|
||||||
|
let status = Command::new("systemctl")
|
||||||
|
.arg("--user")
|
||||||
|
.args(arguments)
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.status()
|
||||||
|
.context("cannot run systemctl")?;
|
||||||
|
if status.success() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err(anyhow!("systemctl --user {} failed ({status})", arguments.join(" ")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn query(arguments: &[&str]) -> Option<String> {
|
||||||
|
let output =
|
||||||
|
Command::new("systemctl").arg("--user").args(arguments).stderr(Stdio::null()).output().ok()?;
|
||||||
|
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
//! Printing. One place, so every command reads the same way.
|
||||||
|
//!
|
||||||
|
//! Colour is written by hand rather than pulled in as a dependency: the output
|
||||||
|
//! of this tool is a handful of lines and a status report, and the only thing
|
||||||
|
//! a crate would add is the one check below.
|
||||||
|
|
||||||
|
use std::io::IsTerminal;
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
fn colour() -> bool {
|
||||||
|
static ON: OnceLock<bool> = OnceLock::new();
|
||||||
|
*ON.get_or_init(|| {
|
||||||
|
if std::env::var_os("NO_COLOR").is_some() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
std::io::stdout().is_terminal()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn paint(code: &str, text: &str) -> String {
|
||||||
|
if colour() {
|
||||||
|
format!("\x1b[{code}m{text}\x1b[0m")
|
||||||
|
} else {
|
||||||
|
text.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bold(text: impl AsRef<str>) -> String {
|
||||||
|
paint("1", text.as_ref())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn dim(text: impl AsRef<str>) -> String {
|
||||||
|
paint("2", text.as_ref())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn green(text: impl AsRef<str>) -> String {
|
||||||
|
paint("32", text.as_ref())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn yellow(text: impl AsRef<str>) -> String {
|
||||||
|
paint("33", text.as_ref())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn red(text: impl AsRef<str>) -> String {
|
||||||
|
paint("31", text.as_ref())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cyan(text: impl AsRef<str>) -> String {
|
||||||
|
paint("36", text.as_ref())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A heading above a block of the status report.
|
||||||
|
pub fn heading(text: impl AsRef<str>) {
|
||||||
|
println!("\n{}", bold(text.as_ref()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Something that is in order.
|
||||||
|
pub fn ok(label: impl AsRef<str>, value: impl std::fmt::Display) {
|
||||||
|
println!(" {} {:<12} {value}", green("✓"), label.as_ref());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Something that is not, and that the report explains further down.
|
||||||
|
pub fn bad(label: impl AsRef<str>, value: impl std::fmt::Display) {
|
||||||
|
println!(" {} {:<12} {value}", red("✗"), label.as_ref());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Something that works but is not what this tool would have set up.
|
||||||
|
pub fn warn(label: impl AsRef<str>, value: impl std::fmt::Display) {
|
||||||
|
println!(" {} {:<12} {value}", yellow("!"), label.as_ref());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A plain fact under a heading.
|
||||||
|
pub fn field(label: impl AsRef<str>, value: impl std::fmt::Display) {
|
||||||
|
println!(" {:<12} {value}", dim(label.as_ref()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The result of a command that changed something.
|
||||||
|
pub fn done(text: impl AsRef<str>) {
|
||||||
|
println!("{} {}", green("✓"), text.as_ref());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A command the reader may want to run next.
|
||||||
|
pub fn hint(command: impl AsRef<str>) {
|
||||||
|
println!(" {} {}", dim("run"), cyan(command.as_ref()));
|
||||||
|
}
|
||||||
Reference in new issue
Block a user