Compare commits

...
Author SHA1 Message Date
lawrencecchen c718f0f1cb Add standalone CodeRouter CLI for Codex subscriptions 2026-08-03 23:08:39 -07:00
21 changed files with 3116 additions and 0 deletions
+40
View File
@@ -0,0 +1,40 @@
name: coderouter CLI
on:
pull_request:
paths:
- "coderouter/**"
- ".github/workflows/coderouter-*.yml"
push:
branches: [main]
paths:
- "coderouter/**"
- ".github/workflows/coderouter-*.yml"
permissions: {}
jobs:
test:
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: coderouter
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy,rustfmt
- uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.2
with:
workspaces: coderouter
- run: cargo fmt --check
- run: cargo clippy --all-targets -- -D warnings
- run: cargo test --all-targets
- run: node scripts/check-version.mjs
- run: npm pack --dry-run
working-directory: coderouter/npm
@@ -0,0 +1,52 @@
name: coderouter publish npm
on:
workflow_dispatch:
inputs:
version:
description: Stable X.Y.Z release version
required: true
type: string
permissions: {}
jobs:
publish:
runs-on: ubuntu-latest
environment: npm-coderouter
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
fetch-depth: 0
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "22.14.0"
registry-url: https://registry.npmjs.org
- name: Validate release tag
env:
VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]
[[ "$GITHUB_REF" == "refs/tags/coderouter-v$VERSION" ]]
[[ "$(jq -r .version coderouter/npm/package.json)" == "$VERSION" ]]
git fetch origin main
git merge-base --is-ancestor "$GITHUB_SHA" origin/main
- name: Download verified release packages
uses: robinraju/release-downloader@daf26c55d821e836577a15f77d86ddc078948b05 # v1.12
with:
tag: coderouter-v${{ inputs.version }}
fileName: "coderouter-npm-*.tgz"
out-file-path: dist
- run: npm install -g [email protected]
- name: Publish platform packages, then launcher
run: |
set -euo pipefail
for package in dist/coderouter-npm-cli-*.tgz; do
npm publish --provenance "$package"
done
npm publish --provenance "dist/coderouter-npm-launcher.tgz"
@@ -0,0 +1,45 @@
name: coderouter publish pypi
on:
workflow_dispatch:
inputs:
version:
description: Stable X.Y.Z release version
required: true
type: string
permissions: {}
jobs:
publish:
runs-on: ubuntu-latest
environment: pypi-coderouter
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
fetch-depth: 0
- name: Validate protected release tag
env:
VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]
[[ "$GITHUB_REF" == "refs/tags/coderouter-v$VERSION" ]]
[[ "$(node coderouter/scripts/check-version.mjs)" == "$VERSION" ]]
git fetch origin main
git merge-base --is-ancestor "$GITHUB_SHA" origin/main
- name: Download wheels from the signed GitHub release
uses: robinraju/release-downloader@daf26c55d821e836577a15f77d86ddc078948b05 # v1.12
with:
tag: coderouter-v${{ inputs.version }}
fileName: "coderouter-*.whl"
out-file-path: dist
- name: Publish through PyPI Trusted Publishing
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1
with:
packages-dir: dist
attestations: true
+141
View File
@@ -0,0 +1,141 @@
name: coderouter release
on:
push:
tags:
- "coderouter-v*"
permissions: {}
jobs:
validate:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
fetch-depth: 0
- id: version
run: |
set -euo pipefail
version="${GITHUB_REF_NAME#coderouter-v}"
[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]
actual="$(node coderouter/scripts/check-version.mjs)"
[[ "$actual" == "$version" ]] || {
echo "tag version $version does not match package version $actual" >&2
exit 1
}
git fetch origin main
git merge-base --is-ancestor "$GITHUB_SHA" origin/main
echo "version=$version" >> "$GITHUB_OUTPUT"
build:
needs: validate
strategy:
fail-fast: false
matrix:
include:
- runner: macos-14-xlarge
rust_target: aarch64-apple-darwin
npm_target: darwin-arm64
executable: coderouter
- runner: macos-15-intel
rust_target: x86_64-apple-darwin
npm_target: darwin-x64
executable: coderouter
- runner: ubuntu-latest
rust_target: x86_64-unknown-linux-gnu
npm_target: linux-x64
executable: coderouter
- runner: windows-latest
rust_target: x86_64-pc-windows-msvc
npm_target: win32-x64
executable: coderouter.exe
runs-on: ${{ matrix.runner }}
permissions:
contents: read
defaults:
run:
working-directory: coderouter
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.rust_target }}
- uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.2
with:
workspaces: coderouter
key: ${{ matrix.rust_target }}
- run: cargo test --release --target ${{ matrix.rust_target }}
- run: cargo build --release --target ${{ matrix.rust_target }} --bin coderouter
- name: Package npm platform binary
shell: bash
env:
VERSION: ${{ needs.validate.outputs.version }}
NPM_TARGET: ${{ matrix.npm_target }}
RUST_TARGET: ${{ matrix.rust_target }}
EXECUTABLE: ${{ matrix.executable }}
run: |
set -euo pipefail
mkdir -p dist/npm
node scripts/package-npm.mjs \
"$VERSION" "$NPM_TARGET" \
"target/$RUST_TARGET/release/$EXECUTABLE" dist/npm
npm pack "dist/npm/cli-$NPM_TARGET" --pack-destination dist
package="$(find dist -maxdepth 1 -name 'coderouter-cli-*.tgz' -print -quit)"
mv "$package" "dist/coderouter-npm-cli-$NPM_TARGET.tgz"
- name: Build PyPI wheel
uses: PyO3/maturin-action@86b9d133d34bc1b40018696f782949dac11bd380 # v1.49.4
with:
command: build
target: ${{ matrix.rust_target }}
args: --release --out coderouter/dist --manifest-path coderouter/Cargo.toml
manylinux: auto
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: coderouter-${{ matrix.npm_target }}
path: |
coderouter/dist/coderouter-npm-cli-${{ matrix.npm_target }}.tgz
coderouter/dist/*.whl
if-no-files-found: error
release:
needs: [validate, build]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
pattern: coderouter-*
path: dist
merge-multiple: true
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "22.14.0"
- name: Package npm launcher
env:
VERSION: ${{ needs.validate.outputs.version }}
run: |
set -euo pipefail
[[ "$(node coderouter/scripts/check-version.mjs)" == "$VERSION" ]]
npm pack coderouter/npm --pack-destination dist
mv "dist/coderouter-$VERSION.tgz" dist/coderouter-npm-launcher.tgz
sha256sum dist/* > dist/SHA256SUMS
- name: Create immutable GitHub release
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release create "$GITHUB_REF_NAME" dist/* \
--verify-tag \
--title "CodeRouter ${{ needs.validate.outputs.version }}" \
--generate-notes
+5
View File
@@ -0,0 +1,5 @@
/target/
/dist/
/dist-test/
*.tgz
+1851
View File
File diff suppressed because it is too large Load Diff
+38
View File
@@ -0,0 +1,38 @@
[package]
name = "coderouter"
version = "0.1.0"
edition = "2024"
rust-version = "1.85"
license = "MIT"
description = "Run Codex across your CodeRouter subscription pool"
repository = "https://github.com/manaflow-ai/cmux"
publish = false
[lib]
name = "coderouter"
path = "src/lib.rs"
[[bin]]
name = "coderouter"
path = "src/main.rs"
[[bin]]
name = "cr"
path = "src/bin/cr.rs"
[dependencies]
crossterm = "0.29"
dirs = "6"
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls-native-roots"] }
sha2 = "0.10"
tempfile = "3"
thiserror = "2"
[dev-dependencies]
assert_cmd = "2"
predicates = "3"
[package.metadata.maturin]
bindings = "bin"
strip = true
+40
View File
@@ -0,0 +1,40 @@
# CodeRouter CLI
CodeRouter gives Codex one command for a shared pool of Codex subscriptions.
```bash
cr add
cr codex
cr naked
```
`coderouter` and `cr` are equivalent executable names. With no command, `cr`
behaves like `cr codex` and forwards every argument to Codex.
## Commands
```text
cr [codex arguments...] Codex through CodeRouter
cr codex [arguments...] Codex through CodeRouter
cr naked [arguments...] ordinary Codex, with CodeRouter bypassed
cr direct [arguments...] alias for naked
cr add interactive Codex subscription setup
cr accounts list available subscriptions
cr usage show quota state
cr doctor diagnose login, vault, and local routing
cr login / cr logout manage this machine's Stack Auth session
```
The interactive add flow either opens a fresh official Codex OAuth login in an
isolated `CODEX_HOME`, or shows the local import plan and asks for confirmation.
The
normal `~/.codex/auth.json` is not modified by the new-login flow.
CodeRouter currently supports Codex subscriptions only.
## Routing engine
The CLI uses the open-source Subrouter routing engine. It first honors
`CODEROUTER_SUBROUTER_BIN`, then an existing `subrouter` on `PATH`, then installs
the pinned release into the user's application-data directory after verifying
the release SHA-256 manifest.
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env node
const { spawnSync } = require("node:child_process");
const path = require("node:path");
const platform = process.platform;
const arch = process.arch;
const supported = new Set([
"darwin-arm64",
"darwin-x64",
"linux-x64",
"win32-x64",
]);
const target = `${platform}-${arch}`;
if (!supported.has(target)) {
console.error(`CodeRouter does not provide a binary for ${target}.`);
process.exit(1);
}
const packageName = `@coderouter/cli-${target}`;
let packageJson;
try {
packageJson = require.resolve(`${packageName}/package.json`);
} catch {
console.error(
`CodeRouter's platform package ${packageName} is missing. ` +
"Reinstall coderouter and ensure optional dependencies are enabled.",
);
process.exit(1);
}
const executable = path.join(
path.dirname(packageJson),
"bin",
platform === "win32" ? "coderouter.exe" : "coderouter",
);
const result = spawnSync(executable, process.argv.slice(2), { stdio: "inherit" });
if (result.error) {
console.error(result.error.message);
process.exit(1);
}
process.exit(result.status === null ? 1 : result.status);
+27
View File
@@ -0,0 +1,27 @@
{
"name": "coderouter",
"version": "0.1.0",
"description": "Run Codex across your CodeRouter subscription pool",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/manaflow-ai/cmux.git",
"directory": "coderouter"
},
"bin": {
"coderouter": "bin/coderouter.js",
"cr": "bin/coderouter.js"
},
"files": [
"bin"
],
"optionalDependencies": {
"@coderouter/cli-darwin-arm64": "0.1.0",
"@coderouter/cli-darwin-x64": "0.1.0",
"@coderouter/cli-linux-x64": "0.1.0",
"@coderouter/cli-win32-x64": "0.1.0"
},
"engines": {
"node": ">=18"
}
}
+16
View File
@@ -0,0 +1,16 @@
[build-system]
requires = ["maturin>=1.9,<2"]
build-backend = "maturin"
[project]
name = "coderouter"
version = "0.1.0"
description = "Run Codex across your CodeRouter subscription pool"
readme = "README.md"
requires-python = ">=3.9"
license = { text = "MIT" }
[tool.maturin]
bindings = "bin"
strip = true
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env node
import fs from "node:fs";
const cargo = fs.readFileSync(new URL("../Cargo.toml", import.meta.url), "utf8");
const pyproject = fs.readFileSync(new URL("../pyproject.toml", import.meta.url), "utf8");
const npm = JSON.parse(
fs.readFileSync(new URL("../npm/package.json", import.meta.url), "utf8"),
);
const cargoVersion = cargo.match(/^version = "([^"]+)"$/m)?.[1];
const pythonVersion = pyproject.match(/^version = "([^"]+)"$/m)?.[1];
const versions = new Set([cargoVersion, pythonVersion, npm.version]);
if (versions.size !== 1 || versions.has(undefined)) {
console.error(
`CodeRouter versions differ: cargo=${cargoVersion} python=${pythonVersion} npm=${npm.version}`,
);
process.exit(1);
}
for (const [name, version] of Object.entries(npm.optionalDependencies ?? {})) {
if (version !== npm.version) {
console.error(`${name} is ${version}, expected ${npm.version}`);
process.exit(1);
}
}
console.log(npm.version);
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
const [version, target, binary, outputRoot] = process.argv.slice(2);
if (!version || !target || !binary || !outputRoot) {
console.error("usage: package-npm.mjs <version> <npm-target> <binary> <output-root>");
process.exit(2);
}
const packageName = `@coderouter/cli-${target}`;
const directory = path.join(outputRoot, `cli-${target}`);
fs.rmSync(directory, { recursive: true, force: true });
fs.mkdirSync(path.join(directory, "bin"), { recursive: true });
const executableName = target.startsWith("win32-") ? "coderouter.exe" : "coderouter";
fs.copyFileSync(binary, path.join(directory, "bin", executableName));
if (!target.startsWith("win32-")) {
fs.chmodSync(path.join(directory, "bin", executableName), 0o755);
}
fs.writeFileSync(
path.join(directory, "package.json"),
`${JSON.stringify(
{
name: packageName,
version,
license: "MIT",
os: [target.split("-")[0]],
cpu: [target.split("-")[1]],
files: ["bin"],
},
null,
2,
)}\n`,
);
+199
View File
@@ -0,0 +1,199 @@
use std::fs;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use sha2::{Digest, Sha256};
use crate::cli::Error;
use crate::process;
const SUBROUTER_VERSION: &str = "0.1.56";
const RELEASE_BASE: &str = "https://github.com/manaflow-ai/subrouter/releases/download";
pub fn resolve() -> Result<PathBuf, Error> {
if let Some(explicit) = std::env::var_os("CODEROUTER_SUBROUTER_BIN") {
let path = PathBuf::from(explicit);
if path.is_file() {
return Ok(path);
}
return Err(Error::Backend(format!(
"CODEROUTER_SUBROUTER_BIN does not point to a file: {}",
path.display()
)));
}
if let Some(path) = process::find_on_path("subrouter") {
return Ok(path);
}
let managed = managed_binary_path()?;
if managed.is_file() {
return Ok(managed);
}
install_managed(&managed)?;
Ok(managed)
}
fn install_managed(destination: &Path) -> Result<(), Error> {
let asset = release_asset()?;
let asset_url = format!("{RELEASE_BASE}/v{SUBROUTER_VERSION}/{asset}");
let expected = release_checksum(&asset).ok_or_else(|| {
Error::Backend(format!(
"CodeRouter has no pinned checksum for Subrouter asset {asset}"
))
})?;
eprintln!("CodeRouter needs its routing engine; installing Subrouter v{SUBROUTER_VERSION}");
let client = reqwest::blocking::Client::builder()
.user_agent(format!("coderouter/{}", env!("CARGO_PKG_VERSION")))
.build()
.map_err(|error| Error::Backend(error.to_string()))?;
let mut response = client
.get(asset_url)
.send()
.and_then(reqwest::blocking::Response::error_for_status)
.map_err(|error| Error::Backend(format!("could not download Subrouter: {error}")))?;
let parent = destination
.parent()
.ok_or_else(|| Error::Backend("invalid managed binary path".into()))?;
fs::create_dir_all(parent)?;
let mut temporary = tempfile::NamedTempFile::new_in(parent)?;
let mut hasher = Sha256::new();
let mut buffer = [0_u8; 64 * 1024];
loop {
let count = response.read(&mut buffer).map_err(|error| {
Error::Backend(format!("could not read Subrouter download: {error}"))
})?;
if count == 0 {
break;
}
hasher.update(&buffer[..count]);
temporary.write_all(&buffer[..count])?;
}
let actual = format!("{:x}", hasher.finalize());
if actual != expected {
return Err(Error::Backend(format!(
"Subrouter checksum mismatch: expected {expected}, received {actual}"
)));
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
temporary
.as_file()
.set_permissions(fs::Permissions::from_mode(0o755))?;
}
temporary
.persist(destination)
.map_err(|error| Error::Io(error.error))?;
eprintln!("Installed {}", destination.display());
Ok(())
}
fn managed_binary_path() -> Result<PathBuf, Error> {
let data = dirs::data_local_dir()
.or_else(dirs::home_dir)
.ok_or_else(|| Error::Backend("could not determine a user data directory".into()))?;
Ok(data.join("coderouter").join("bin").join(if cfg!(windows) {
"subrouter.exe"
} else {
"subrouter"
}))
}
fn release_asset() -> Result<String, Error> {
let os = match std::env::consts::OS {
"macos" => "darwin",
"linux" => "linux",
"windows" => "windows",
other => {
return Err(Error::Backend(format!(
"automatic installation is not supported on {other}; install subrouter manually"
)));
}
};
let arch = match std::env::consts::ARCH {
"x86_64" => "amd64",
"aarch64" => "arm64",
other => {
return Err(Error::Backend(format!(
"automatic installation is not supported on {other}; install subrouter manually"
)));
}
};
let suffix = if os == "windows" { ".exe" } else { "" };
Ok(format!("subrouter_{SUBROUTER_VERSION}_{os}_{arch}{suffix}"))
}
fn release_checksum(asset: &str) -> Option<&'static str> {
match asset {
"subrouter_0.1.56_darwin_amd64" => {
Some("84e7572b013d3b638bac4353027b075dc9e31a17db550202d721769f0ddcdb42")
}
"subrouter_0.1.56_darwin_arm64" => {
Some("d104bb03476cbcb59fbb207bddaaeef8af7bc5c25ed7c508ffd0506540a41ec4")
}
"subrouter_0.1.56_linux_amd64" => {
Some("19c644dc251b38afd4a0abedb6d4f533f91a3b1f5630821bc431ea807c1a04dc")
}
"subrouter_0.1.56_windows_amd64.exe" => {
Some("b214122e1b4b5311c78722fb05220846336f2126732f2c043507ad22c3033b72")
}
_ => None,
}
}
pub fn ensure_hosted_login(binary: &Path) -> Result<i32, Error> {
let status = process::output(binary, &["team", "current"])?;
if status.status.success() {
return Ok(0);
}
process::run_attached(binary, &[process::os("login")], &[])
}
pub fn ensure_ready(binary: &Path) -> Result<i32, Error> {
let login = ensure_hosted_login(binary)?;
if login != 0 {
return Ok(login);
}
let storage = process::output(binary, &["storage"])?;
let hosted = storage.status.success()
&& String::from_utf8_lossy(&storage.stdout)
.to_ascii_lowercase()
.contains("hosted");
if !hosted {
let selected = process::run_attached(
binary,
&[process::os("storage"), process::os("hosted")],
&[],
)?;
if selected != 0 {
return Ok(selected);
}
}
let daemon = process::output(binary, &["daemon", "status"])?;
if daemon.status.success() {
return Ok(0);
}
eprintln!("CodeRouter needs one-time local proxy setup.");
process::run_attached(binary, &[process::os("setup")], &[])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn release_assets_have_pinned_checksums() {
let asset = release_asset().expect("supported test platform");
assert!(release_checksum(&asset).is_some());
}
#[test]
fn rejects_missing_release_checksum() {
assert_eq!(release_checksum("unknown"), None);
}
}
+3
View File
@@ -0,0 +1,3 @@
fn main() {
std::process::exit(coderouter::run(std::env::args_os()));
}
+220
View File
@@ -0,0 +1,220 @@
use std::ffi::OsString;
use std::io;
use std::path::PathBuf;
use thiserror::Error;
use crate::backend;
use crate::process;
use crate::tui::{self, AddChoice};
const HELP: &str = "\
CodeRouter — run Codex across your subscription pool
Usage:
cr [codex arguments...] Run Codex through CodeRouter
cr codex [arguments...] Run Codex through CodeRouter
cr naked [arguments...] Run the real Codex without CodeRouter
cr direct [arguments...] Alias for `cr naked`
cr add Add a Codex subscription interactively
cr add login Sign in to a new Codex subscription
cr add import Import local Codex credentials
cr login | logout Manage this machine's CodeRouter login
cr accounts List shared Codex subscriptions
cr usage Show subscription usage
cr doctor Diagnose CodeRouter
The long command name `coderouter` supports the same interface.
";
#[derive(Debug, Error)]
pub enum Error {
#[error("{0}")]
Usage(String),
#[error("{0}")]
Backend(String),
#[error("could not start {executable}: {source}")]
Spawn {
executable: PathBuf,
source: io::Error,
},
#[error(transparent)]
Io(#[from] io::Error),
}
pub fn run(args: impl IntoIterator<Item = OsString>) -> Result<i32, Error> {
let mut args = args.into_iter();
let _program = args.next();
let remaining: Vec<OsString> = args.collect();
let command = remaining.first().and_then(|value| value.to_str());
match command {
Some("-h" | "--help" | "help") => {
print!("{HELP}");
Ok(0)
}
Some("-V" | "--version" | "version") => {
println!("coderouter {}", env!("CARGO_PKG_VERSION"));
Ok(0)
}
Some("naked" | "direct") => run_naked(&remaining[1..]),
Some("add") => run_add(&remaining[1..]),
Some("login") => run_backend(&["login"], &remaining[1..]),
Some("logout") => run_backend(&["logout"], &remaining[1..]),
Some("accounts" | "account") => run_backend(&["account", "list"], &remaining[1..]),
Some("usage") => run_backend(&["status"], &remaining[1..]),
Some("doctor") => run_backend(&["doctor"], &remaining[1..]),
Some("codex") => run_routed_codex(&remaining[1..]),
Some(value) if value.starts_with('-') => run_routed_codex(&remaining),
Some(_) | None => run_routed_codex(&remaining),
}
}
fn run_routed_codex(args: &[OsString]) -> Result<i32, Error> {
let backend = backend::resolve()?;
let setup = backend::ensure_ready(&backend)?;
if setup != 0 {
return Ok(setup);
}
let mut backend_args = vec![process::os("codex")];
backend_args.extend_from_slice(args);
process::run_attached_with_env(
&backend,
&backend_args,
&[],
&[("SUBROUTER_CODEX_SERVER", "local")],
)
}
fn run_naked(args: &[OsString]) -> Result<i32, Error> {
let codex = resolve_real_codex()?;
process::run_attached(
&codex,
args,
&[
"CODEROUTER_API_URL",
"CODEROUTER_SUBROUTER_BIN",
"CR_ACCOUNT",
"CR_POLICY",
"SUBROUTER_CODEX_ACCOUNT_ID",
"SUBROUTER_CODEX_BASE_URL",
"SUBROUTER_CODEX_SERVER",
"SUBROUTER_CODEX_USER_EMAIL",
],
)
}
fn run_add(args: &[OsString]) -> Result<i32, Error> {
let choice = match args.first().and_then(|arg| arg.to_str()) {
None => tui::choose_add_action()?,
Some("login" | "new") if args.len() == 1 => AddChoice::NewLogin,
Some("import") if args.len() == 1 => AddChoice::ImportLocal,
Some("cancel") if args.len() == 1 => AddChoice::Cancel,
_ => {
return Err(Error::Usage("usage: cr add [login|import]".into()));
}
};
if choice == AddChoice::Cancel {
return Ok(0);
}
let backend = backend::resolve()?;
let login = backend::ensure_hosted_login(&backend)?;
if login != 0 {
return Ok(login);
}
let storage = process::run_attached(
&backend,
&[process::os("storage"), process::os("hosted")],
&[],
)?;
if storage != 0 {
return Ok(storage);
}
let code = match choice {
AddChoice::NewLogin => process::run_attached(
&backend,
&[
process::os("account"),
process::os("add"),
process::os("codex"),
],
&[],
)?,
AddChoice::ImportLocal => process::run_attached(
&backend,
&[
process::os("account"),
process::os("import"),
process::os("--all"),
],
&[],
)?,
AddChoice::Cancel => 0,
};
if code != 0 {
return Ok(code);
}
backend::ensure_ready(&backend)
}
fn run_backend(prefix: &[&str], rest: &[OsString]) -> Result<i32, Error> {
let backend = backend::resolve()?;
let mut args: Vec<OsString> = prefix.iter().map(process::os).collect();
args.extend_from_slice(rest);
process::run_attached(&backend, &args, &[])
}
fn resolve_real_codex() -> Result<PathBuf, Error> {
let codex = process::find_on_path("codex").ok_or_else(|| {
Error::Usage(
"Codex is not installed or is not on PATH; install Codex before running `cr naked`"
.into(),
)
})?;
if let Ok(current) = std::env::current_exe() {
if process::is_same_executable(&codex, &current) {
return Err(Error::Usage(
"`codex` resolves back to CodeRouter; put the real Codex executable on PATH".into(),
));
}
}
Ok(codex)
}
#[cfg(test)]
mod tests {
use super::*;
fn args(values: &[&str]) -> Vec<OsString> {
values.iter().map(OsString::from).collect()
}
#[test]
fn help_is_a_management_command() {
assert_eq!(run(args(&["cr", "--help"])).unwrap(), 0);
}
#[test]
fn add_rejects_unknown_mode_without_starting_backend() {
let error = run(args(&["cr", "add", "wat"])).unwrap_err();
assert!(error.to_string().contains("usage: cr add"));
}
#[test]
fn direct_and_naked_are_reserved() {
assert!(matches!(
args(&["cr", "direct"])
.get(1)
.and_then(|value| value.to_str()),
Some("direct")
));
assert!(matches!(
args(&["cr", "naked"])
.get(1)
.and_then(|value| value.to_str()),
Some("naked")
));
}
}
+16
View File
@@ -0,0 +1,16 @@
mod backend;
mod cli;
mod process;
mod tui;
use std::ffi::OsString;
pub fn run(args: impl IntoIterator<Item = OsString>) -> i32 {
match cli::run(args) {
Ok(code) => code,
Err(error) => {
eprintln!("cr: {error}");
1
}
}
}
+3
View File
@@ -0,0 +1,3 @@
fn main() {
std::process::exit(coderouter::run(std::env::args_os()));
}
+103
View File
@@ -0,0 +1,103 @@
use std::ffi::{OsStr, OsString};
use std::path::{Path, PathBuf};
use std::process::{Command, ExitStatus, Stdio};
use crate::cli::Error;
pub fn find_on_path(name: &str) -> Option<PathBuf> {
let path = std::env::var_os("PATH")?;
std::env::split_paths(&path)
.map(|directory| directory.join(executable_name(name)))
.find(|candidate| is_executable_file(candidate))
}
pub fn run_attached(
executable: &Path,
args: &[OsString],
removed_env: &[&str],
) -> Result<i32, Error> {
run_attached_with_env(executable, args, removed_env, &[])
}
pub fn run_attached_with_env(
executable: &Path,
args: &[OsString],
removed_env: &[&str],
added_env: &[(&str, &str)],
) -> Result<i32, Error> {
let mut command = Command::new(executable);
command
.args(args)
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit());
for key in removed_env {
command.env_remove(key);
}
for (key, value) in added_env {
command.env(key, value);
}
let status = command.status().map_err(|source| Error::Spawn {
executable: executable.to_path_buf(),
source,
})?;
Ok(exit_code(status))
}
pub fn output(executable: &Path, args: &[&str]) -> Result<std::process::Output, Error> {
Command::new(executable)
.args(args)
.stdin(Stdio::null())
.output()
.map_err(|source| Error::Spawn {
executable: executable.to_path_buf(),
source,
})
}
pub fn is_same_executable(left: &Path, right: &Path) -> bool {
let left = std::fs::canonicalize(left).unwrap_or_else(|_| left.to_path_buf());
let right = std::fs::canonicalize(right).unwrap_or_else(|_| right.to_path_buf());
left == right
}
fn exit_code(status: ExitStatus) -> i32 {
status.code().unwrap_or(1)
}
fn executable_name(name: &str) -> OsString {
#[cfg(windows)]
{
if name.ends_with(".exe") {
OsString::from(name)
} else {
OsString::from(format!("{name}.exe"))
}
}
#[cfg(not(windows))]
{
OsString::from(name)
}
}
fn is_executable_file(path: &Path) -> bool {
let Ok(metadata) = path.metadata() else {
return false;
};
if !metadata.is_file() {
return false;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
metadata.permissions().mode() & 0o111 != 0
}
#[cfg(not(unix))]
{
true
}
}
pub fn os(value: impl AsRef<OsStr>) -> OsString {
value.as_ref().to_os_string()
}
+129
View File
@@ -0,0 +1,129 @@
use std::io::{self, IsTerminal, Write};
use crossterm::{
cursor,
event::{self, Event, KeyCode, KeyEventKind},
execute,
style::{Attribute, Color, Print, ResetColor, SetAttribute, SetForegroundColor},
terminal::{self, ClearType, EnterAlternateScreen, LeaveAlternateScreen},
};
use crate::cli::Error;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum AddChoice {
NewLogin,
ImportLocal,
Cancel,
}
const ITEMS: &[(AddChoice, &str, &str)] = &[
(
AddChoice::NewLogin,
"Sign in to a new Codex subscription",
"Opens the official Codex OAuth flow in an isolated profile.",
),
(
AddChoice::ImportLocal,
"Import local Codex credentials",
"Reviews and uploads Codex accounts already present on this machine.",
),
(AddChoice::Cancel, "Cancel", "Make no changes."),
];
pub fn choose_add_action() -> Result<AddChoice, Error> {
if !io::stdin().is_terminal() || !io::stdout().is_terminal() {
return Err(Error::Usage(
"`cr add` needs an interactive terminal; use `cr add login` or `cr add import`".into(),
));
}
let mut screen = Screen::enter()?;
let mut selected = 0_usize;
loop {
screen.draw(selected)?;
let Event::Key(key) = event::read()? else {
continue;
};
if key.kind != KeyEventKind::Press {
continue;
}
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
selected = selected.checked_sub(1).unwrap_or(ITEMS.len() - 1);
}
KeyCode::Down | KeyCode::Char('j') => {
selected = (selected + 1) % ITEMS.len();
}
KeyCode::Char('1') => return Ok(AddChoice::NewLogin),
KeyCode::Char('2') => return Ok(AddChoice::ImportLocal),
KeyCode::Enter => return Ok(ITEMS[selected].0),
KeyCode::Esc | KeyCode::Char('q') => return Ok(AddChoice::Cancel),
_ => {}
}
}
}
struct Screen {
stdout: io::Stdout,
}
impl Screen {
fn enter() -> Result<Self, Error> {
terminal::enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, cursor::Hide)?;
Ok(Self { stdout })
}
fn draw(&mut self, selected: usize) -> Result<(), Error> {
execute!(
self.stdout,
cursor::MoveTo(0, 0),
terminal::Clear(ClearType::All),
SetAttribute(Attribute::Bold),
Print("Add a Codex subscription\n\n"),
SetAttribute(Attribute::Reset),
SetForegroundColor(Color::DarkGrey),
Print("Credentials are uploaded to your selected CodeRouter team vault.\n"),
Print("Your normal ~/.codex/auth.json is never modified.\n\n"),
ResetColor
)?;
for (index, (_, title, description)) in ITEMS.iter().enumerate() {
if index == selected {
execute!(
self.stdout,
SetForegroundColor(Color::Cyan),
SetAttribute(Attribute::Bold),
Print(format!(" {}. {title}\n", index + 1)),
SetAttribute(Attribute::Reset),
SetForegroundColor(Color::DarkGrey),
Print(format!(" {description}\n\n")),
ResetColor
)?;
} else {
execute!(
self.stdout,
Print(format!(" {}. {title}\n", index + 1)),
SetForegroundColor(Color::DarkGrey),
Print(format!(" {description}\n\n")),
ResetColor
)?;
}
}
execute!(
self.stdout,
SetForegroundColor(Color::DarkGrey),
Print("↑/↓ move enter select esc cancel"),
ResetColor
)?;
self.stdout.flush()?;
Ok(())
}
}
impl Drop for Screen {
fn drop(&mut self) {
let _ = execute!(self.stdout, LeaveAlternateScreen, cursor::Show);
let _ = terminal::disable_raw_mode();
}
}
+83
View File
@@ -0,0 +1,83 @@
use std::fs;
use assert_cmd::Command;
use predicates::prelude::*;
use tempfile::TempDir;
#[test]
fn both_binary_names_show_the_same_help() {
Command::cargo_bin("cr")
.unwrap()
.arg("--help")
.assert()
.success()
.stdout(predicate::str::contains("cr naked"));
Command::cargo_bin("coderouter")
.unwrap()
.arg("--help")
.assert()
.success()
.stdout(predicate::str::contains("cr naked"));
}
#[cfg(unix)]
#[test]
fn naked_executes_codex_without_coderouter_routing_environment() {
use std::os::unix::fs::PermissionsExt;
let root = TempDir::new().unwrap();
let codex = root.path().join("codex");
fs::write(
&codex,
"#!/bin/sh\nprintf '%s\\n' \"$*\"\nprintf 'route=%s\\n' \"${SUBROUTER_CODEX_SERVER-unset}\"\nexit 23\n",
)
.unwrap();
fs::set_permissions(&codex, fs::Permissions::from_mode(0o755)).unwrap();
let path = format!(
"{}:{}",
root.path().display(),
std::env::var("PATH").unwrap_or_default()
);
Command::cargo_bin("cr")
.unwrap()
.args(["naked", "exec", "hello"])
.env("PATH", path)
.env("SUBROUTER_CODEX_SERVER", "cmux")
.assert()
.code(23)
.stdout(
predicate::str::contains("exec hello").and(predicate::str::contains("route=unset")),
);
}
#[cfg(unix)]
#[test]
fn codex_command_delegates_to_the_routing_engine() {
use std::os::unix::fs::PermissionsExt;
let root = TempDir::new().unwrap();
let backend = root.path().join("subrouter");
fs::write(
&backend,
"#!/bin/sh\n\
if [ \"$1 $2\" = 'team current' ]; then exit 0; fi\n\
if [ \"$1\" = storage ]; then printf 'hosted\\n'; exit 0; fi\n\
if [ \"$1 $2\" = 'daemon status' ]; then exit 0; fi\n\
printf '%s\\n' \"$*\"\n\
printf 'server=%s\\n' \"${SUBROUTER_CODEX_SERVER-unset}\"\n",
)
.unwrap();
fs::set_permissions(&backend, fs::Permissions::from_mode(0o755)).unwrap();
Command::cargo_bin("cr")
.unwrap()
.args(["codex", "exec", "hello"])
.env("CODEROUTER_SUBROUTER_BIN", &backend)
.assert()
.success()
.stdout(
predicate::str::contains("codex exec hello")
.and(predicate::str::contains("server=local")),
);
}