Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ qualification-candidate = []
[dependencies]
anyhow = "1.0.102"
clap = { version = "4.6.1", features = ["derive"] }
dirs = "6.0.0"
futures = "0.3.32"
globset = "0.4.18"
httpdate = "1.0.3"
Expand All @@ -35,6 +36,7 @@ serde_json = { version = "1.0.150", features = ["raw_value"] }
sha2 = "0.11.0"
similar = "3.1.1"
tempfile = "3.27.0"
time = { version = "0.3.54", features = ["parsing", "formatting"] }
tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros", "process", "time"] }
toml = "1.1.2"
yaml_serde = "0.10.4"
Expand Down
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,12 @@ Release binaries cover Linux x86_64 and ARM64 with glibc or musl, plus macOS on

## Review before pushing

Authenticate once for hosted inference against your organization's entitlement, or bring your own model key:

```sh
export MODEL_API_KEY=... # OpenRouter is the default endpoint
export REVIEW_MODEL=provider/qualified-model
postil login # zero-config: stores a credential for hosted inference
# or: export MODEL_API_KEY=... # OpenRouter is the default endpoint
# export REVIEW_MODEL=provider/qualified-model
postil doctor # validate the endpoint and repository
postil review --staged # review the staged change
postil review --base origin/main # review the branch
Expand Down
12 changes: 11 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ Place organization-specific merge rules in `.postil/guardrails.md`. Place additi
| Variable | Purpose |
| --- | --- |
| `POSTIL_API_KEY`, `OPENROUTER_API_KEY`, `MODEL_API_KEY`, `LLM_API_KEY` | Model provider credential, checked in that order |
| `POSTIL_LOGIN_SERVER` | Postil web app used by `postil login`/`postil logout`; defaults to `https://postil.dev` |
| `POSTIL_API_BASE` | Model endpoint selected by the operator |
| `POSTIL_API_FORMAT` | `openai-compatible` or `anthropic` |
| `POSTIL_ENDPOINT_AUTH_HEADER`, `POSTIL_ENDPOINT_AUTH_VALUE` | Additional private-gateway authentication |
Expand All @@ -64,6 +65,15 @@ Place organization-specific merge rules in `.postil/guardrails.md`. Place additi

Forge credentials and base URLs are listed in [Code forges](forges.md). Provider-specific behavior is in [Model providers](model-providers.md).

## Login

```sh
postil login
postil logout
```

`postil login` authenticates against postil.dev over a device-authorization flow (open the printed URL, enter the code) and stores a credential at `${XDG_CONFIG_HOME:-~/.config}/postil/credentials.json`, mode `0600` in a `0700` directory. That credential is a fallback: it is used only when none of `POSTIL_API_KEY`, `OPENROUTER_API_KEY`, `MODEL_API_KEY`, or `LLM_API_KEY` is set. When it is used, its `apiBase` and model select the request unless `POSTIL_API_BASE`/`REVIEW_MODEL` are set, which still win. `postil logout` revokes the credential server-side and removes the local file even if that call fails. An expired credential produces one instruction to run `postil login` again rather than a provider authentication error.

## Inspect and initialize

```sh
Expand All @@ -72,4 +82,4 @@ postil config
postil doctor
```

`postil config` prints the resolved non-secret configuration and its sources. `postil doctor` validates endpoint reachability, credential acceptance, and repository setup without printing credential values.
`postil config` prints the resolved non-secret configuration and its sources. `postil doctor` validates endpoint reachability, credential acceptance, and repository setup without printing credential values, and reports whether a login credential is present and when it expires.
105 changes: 105 additions & 0 deletions src/api_key.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,16 @@
//! Inference API-key resolution shared by CLI runtime checks.
//!
//! [`resolve_from_process_env`] resolves only the four explicit env vars, in
//! priority order. [`resolve_effective`] adds one more source below all
//! four: a stored `postil login` credential, used only when none of the env
//! vars is set. See `config.rs`'s module doc for the full precedence
//! statement.

use std::path::Path;

use anyhow::Result;

use crate::credentials::{self, Credentials};

pub(crate) const API_KEY_ENV_VARS: [&str; 4] = [
"POSTIL_API_KEY",
Expand All @@ -21,6 +33,37 @@ pub(crate) fn resolve_with(mut lookup: impl FnMut(&str) -> Option<String>) -> Op
.find_map(|name| lookup(name).filter(|value| !value.trim().is_empty()))
}

/// Bearer key resolved for inference, falling back to a stored `postil
/// login` credential when none of the four explicit env vars is set.
/// `Ok(None)` means neither source has a key; callers turn that into their
/// own "set a key or run `postil login`" message. An expired stored
/// credential is reported here as an error, so the caller surfaces one
/// actionable instruction instead of a confusing upstream auth failure once
/// the request reaches the provider.
pub(crate) fn resolve_effective(credentials_path: &Path) -> Result<Option<String>> {
resolve_effective_with(
|name| std::env::var(name).ok(),
|| credentials::read(credentials_path),
)
}

pub(crate) fn resolve_effective_with(
env_lookup: impl FnMut(&str) -> Option<String>,
credential_lookup: impl FnOnce() -> Result<Option<Credentials>>,
) -> Result<Option<String>> {
if let Some(key) = resolve_with(env_lookup) {
return Ok(Some(key));
}
let Some(credentials) = credential_lookup()? else {
return Ok(None);
};
anyhow::ensure!(
!credentials.is_expired(),
"the stored postil login credential expired; run `postil login` again"
);
Ok(Some(credentials.token))
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -63,4 +106,66 @@ mod tests {
Some("llm-key".to_string())
);
}

fn stored_credential(expires_at: &str) -> Credentials {
Credentials {
version: credentials::CREDENTIALS_VERSION,
token: "pcli_stored-token-not-a-real-secret".to_string(),
expires_at: expires_at.to_string(),
api_base: "https://postil.dev/api/inference/v1".to_string(),
org: "runatlas-is".to_string(),
model: "z-ai/glm-5.2".to_string(),
}
}

fn resolve_effective(
pairs: &[(&str, &str)],
stored: Option<Credentials>,
) -> Result<Option<String>> {
let values: HashMap<&str, &str> = pairs.iter().copied().collect();
resolve_effective_with(
|name| values.get(name).map(|value| (*value).to_string()),
|| Ok(stored),
)
}

#[test]
fn each_explicit_env_var_wins_over_a_stored_credential() {
for name in API_KEY_ENV_VARS {
let resolved = resolve_effective(
&[(name, "explicit-key")],
Some(stored_credential("2999-01-01T00:00:00.000Z")),
)
.unwrap();
assert_eq!(
resolved,
Some("explicit-key".to_string()),
"{name} did not take priority over a stored credential"
);
}
}

#[test]
fn stored_credential_is_used_when_no_env_var_is_set() {
let resolved =
resolve_effective(&[], Some(stored_credential("2999-01-01T00:00:00.000Z"))).unwrap();
assert_eq!(
resolved,
Some("pcli_stored-token-not-a-real-secret".to_string())
);
}

#[test]
fn absence_of_both_env_var_and_credential_resolves_to_none() {
assert_eq!(resolve_effective(&[], None).unwrap(), None);
}

#[test]
fn expired_stored_credential_yields_one_actionable_relogin_error() {
let error = resolve_effective(&[], Some(stored_credential("2020-01-01T00:00:00.000Z")))
.expect_err("expired credential must error, not silently resolve");
let message = error.to_string();
assert!(message.contains("postil login"));
assert!(!message.contains("pcli_stored-token-not-a-real-secret"));
}
}
31 changes: 31 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,15 @@ pub enum Command {
#[command(subcommand)]
action: HookAction,
},
/// Authenticate against postil.dev for zero-config hosted inference.
Login {
/// Organization to select during approval. The browser approval page
/// is authoritative for membership; this only pre-fills a hint.
#[arg(long)]
org: Option<String>,
},
/// Remove the stored login credential and revoke it server-side.
Logout,
}

#[derive(Subcommand)]
Expand Down Expand Up @@ -346,4 +355,26 @@ mod tests {
.is_err()
);
}

#[test]
fn login_accepts_an_optional_org_hint() {
let parsed = Cli::try_parse_from(["postil", "login"]).unwrap();
let Command::Login { org } = parsed.command else {
panic!("expected login command");
};
assert_eq!(org, None);

let parsed = Cli::try_parse_from(["postil", "login", "--org", "runatlas-is"]).unwrap();
let Command::Login { org } = parsed.command else {
panic!("expected login command");
};
assert_eq!(org.as_deref(), Some("runatlas-is"));
}

#[test]
fn logout_takes_no_arguments() {
let parsed = Cli::try_parse_from(["postil", "logout"]).unwrap();
assert!(matches!(parsed.command, Command::Logout));
assert!(Cli::try_parse_from(["postil", "logout", "--org", "runatlas-is"]).is_err());
}
}
Loading
Loading