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
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,12 +120,34 @@ anyr api keys list --json
anyr api keys create ci-bot # secret on stdout only: KEY=$(anyr api keys create x)
anyr api GET /credits # raw request, like `gh api`
anyr api PATCH /keys/<hash> disabled=true limit:=25
anyr api POST /apps/explain/submissions --data @body.json # body from a file
cat body.json | anyr api POST /apps/explain/submissions --data - -H 'X-Trace: 1'
```

Resources: `me credits keys logs presets aliases models providers dashboard hubs connections`.
Run `anyr api --help` for verbs. Piped output is TSV; pass `--json` for JSON.
Errors exit 2 (usage), 3 (404) or 4 (401/403).

### Publish to an app: `anyr publish`

Apps publish their own JSON Schema; the CLI knows no app by name. `publish` checks
your document against that schema locally (problems listed by field, exit 2, nothing
uploaded), then sends it and prints the URL.

```bash
anyr apps schema explain > schema.json # what an agent must produce
<agent produces explain json> | anyr publish explain -
anyr publish explain fig.json --dry-run # server checks it; nothing uploaded
anyr publish explain fig.json --visibility public --yes
anyr apps ls explain # what you published
anyr apps visibility explain <id> private
anyr apps rm explain <id> --yes
```

Visibility is `unlisted` by default (link works, not listed), `private` (only you) or
`public` (listed after review). Re-running the same document is safe: it carries a
stable `Idempotency-Key`, so the server returns the original instead of a copy.

### Bring your own provider keys: `anyr byok`

```bash
Expand Down
147 changes: 127 additions & 20 deletions src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,31 @@
//! stdout on raw calls) prints the server JSON unchanged.

use std::collections::BTreeMap;
use std::io::IsTerminal;
use std::io::{IsTerminal, Read};

use serde_json::{Map, Value};

use crate::config::Profile;
use crate::http::{http_delete, http_get, http_patch, http_post, join_api};
use crate::http::{http_delete, http_get, http_patch, http_post, http_send, join_api};
use crate::key::{load_config_if_present, no_key_error, resolve_api_key, resolve_base_url};
use crate::parse::{get_string_flag, ParsedArgs};
use crate::term;

pub const FLAGS: &[&str] = &[
"profile", "base-url", "config", "key", "json", "yes", "limit", "model", "status", "type",
"name", "data", "method",
"profile",
"base-url",
"config",
"key",
"json",
"yes",
"limit",
"model",
"status",
"type",
"name",
"data",
"method",
"idempotency-key",
];

/// (resource, description). Order is help order.
Expand Down Expand Up @@ -141,13 +153,17 @@ fn run_inner(parsed: &ParsedArgs, env: &BTreeMap<String, String>) -> Result<i32,
.to_ascii_uppercase();
(m, &args[..])
};
let body = body_from(parsed, &rest[1..])?;
let (mut headers, words) = split_headers(&rest[1..])?;
if let Some(k) = get_string_flag(&parsed.flags, "idempotency-key") {
headers.push(("Idempotency-Key".into(), k));
}
let body = body_from(parsed, &words)?;
let method = if method == "GET" && body.is_some() {
"POST".into()
} else {
method
};
return raw(&ctx, &method, rest[0], body.as_deref());
return raw(&ctx, &method, rest[0], body.as_deref(), &headers);
}

let resource = match args[0] {
Expand Down Expand Up @@ -394,8 +410,8 @@ fn presets(ctx: &Ctx, parsed: &ParsedArgs, verb: &str, rest: &[&str]) -> Result<
body.insert("slug".into(), Value::String(slug.into()));
let name = get_string_flag(&parsed.flags, "name").unwrap_or_else(|| slug.into());
body.entry("name").or_insert(Value::String(name));
if let Some(data) = get_string_flag(&parsed.flags, "data") {
body.insert("config".into(), parse_json(&data)?);
if let Some(data) = data_flag(parsed)? {
body.insert("config".into(), data);
}
if !body.contains_key("config") {
return Err("presets create needs --data '<config json>', e.g. --data '{\"model\":\"anyrouter/auto\"}'".into());
Expand All @@ -415,8 +431,8 @@ fn presets(ctx: &Ctx, parsed: &ParsedArgs, verb: &str, rest: &[&str]) -> Result<
"update" => {
let slug = slug()?;
let mut body = fields(&rest[1..])?;
if let Some(data) = get_string_flag(&parsed.flags, "data") {
body.insert("config".into(), parse_json(&data)?);
if let Some(data) = data_flag(parsed)? {
body.insert("config".into(), data);
}
if let Some(name) = get_string_flag(&parsed.flags, "name") {
body.insert("name".into(), Value::String(name));
Expand Down Expand Up @@ -509,13 +525,19 @@ fn aliases(
Ok(0)
}

fn raw(ctx: &Ctx, method: &str, path: &str, body: Option<&str>) -> Result<i32, String> {
fn raw(
ctx: &Ctx,
method: &str,
path: &str,
body: Option<&str>,
headers: &[(String, String)],
) -> Result<i32, String> {
let path = if path.starts_with("/v1/") || path == "/v1" {
path.to_string()
} else {
format!("/v1{path}")
};
let (status, text) = request(ctx, method, &path, body, ctx.key.as_deref())?;
let (status, text) = request_with(ctx, method, &path, body, ctx.key.as_deref(), headers)?;
let pretty = serde_json::from_str::<Value>(&text)
.ok()
.filter(|_| std::io::stdout().is_terminal())
Expand All @@ -532,14 +554,33 @@ fn raw(ctx: &Ctx, method: &str, path: &str, body: Option<&str>) -> Result<i32, S
}
}

fn request(
pub(crate) fn request(
ctx: &Ctx,
method: &str,
path: &str,
body: Option<&str>,
key: Option<&str>,
) -> Result<(u16, String), String> {
request_with(ctx, method, path, body, key, &[])
}

pub(crate) fn request_with(
ctx: &Ctx,
method: &str,
path: &str,
body: Option<&str>,
key: Option<&str>,
headers: &[(String, String)],
) -> Result<(u16, String), String> {
let url = join_api(&ctx.base, path);
if !headers.is_empty() {
return match method {
"GET" | "POST" | "PUT" | "PATCH" | "DELETE" => {
http_send(method, &url, key, headers, body)
}
other => Err(format!("unsupported method {other}")),
};
}
match method {
"GET" => http_get(&url, key),
"POST" | "PUT" => http_post(&url, key, body),
Expand Down Expand Up @@ -629,18 +670,19 @@ fn error_summary(status: u16, body: &str) -> String {
}
}

fn exit_for(status: u16) -> i32 {
pub(crate) fn exit_for(status: u16) -> i32 {
match status {
401 | 403 => 4,
404 => 3,
_ => 1,
}
}

/// `--data '<json>'` wins; otherwise `k=v` words become a JSON object.
/// `--data '<json>'`, `--data @file` or `--data -` (stdin) wins; otherwise
/// `k=v` words become a JSON object.
fn body_from(parsed: &ParsedArgs, words: &[&str]) -> Result<Option<String>, String> {
if let Some(data) = get_string_flag(&parsed.flags, "data") {
return Ok(Some(parse_json(&data)?.to_string()));
if let Some(data) = data_flag(parsed)? {
return Ok(Some(data.to_string()));
}
let map = fields(words)?;
Ok((!map.is_empty()).then(|| Value::Object(map).to_string()))
Expand Down Expand Up @@ -672,6 +714,71 @@ fn fields(words: &[&str]) -> Result<Map<String, Value>, String> {
Ok(map)
}

/// The `--data` value as JSON, reading `@file` or `-` (stdin) when asked.
fn data_flag(parsed: &ParsedArgs) -> Result<Option<Value>, String> {
let Some(spec) = get_string_flag(&parsed.flags, "data") else {
return Ok(None);
};
let text = if spec == "-" {
read_stdin()?
} else if let Some(path) = spec.strip_prefix('@') {
read_file_capped(path)?
} else {
spec
};
parse_json(&text).map(Some)
}

/// Largest document read from a file or stdin (the server caps bodies at 256 KiB).
pub(crate) const MAX_INPUT_BYTES: u64 = 256 * 1024;

fn read_capped(r: impl std::io::Read, what: &str) -> Result<String, String> {
let mut buf = String::new();
r.take(MAX_INPUT_BYTES + 1)
.read_to_string(&mut buf)
.map_err(|e| format!("cannot read {what}: {e}"))?;
if buf.len() as u64 > MAX_INPUT_BYTES {
return Err(format!(
"{what} is larger than {} KiB, the server limit",
MAX_INPUT_BYTES / 1024
));
}
Ok(buf)
}

pub(crate) fn read_stdin() -> Result<String, String> {
read_capped(std::io::stdin(), "stdin")
}

pub(crate) fn read_file_capped(path: &str) -> Result<String, String> {
let f = std::fs::File::open(path).map_err(|e| format!("cannot read {path}: {e}"))?;
read_capped(f, path)
}

/// Pull `-H key:value` pairs (repeatable) out of the positional words.
type Headers = Vec<(String, String)>;

fn split_headers<'a>(words: &[&'a str]) -> Result<(Headers, Vec<&'a str>), String> {
let mut headers = Vec::new();
let mut rest = Vec::new();
let mut it = words.iter();
while let Some(&w) = it.next() {
if w == "-H" {
let raw = it
.next()
.ok_or_else(|| format!("{USAGE}-H needs a header, e.g. -H 'X-Trace: 1'"))?;
let (k, v) = raw
.split_once(':')
.filter(|(k, _)| !k.trim().is_empty())
.ok_or_else(|| format!("{USAGE}header must look like key:value, got \"{raw}\""))?;
headers.push((k.trim().to_string(), v.trim().to_string()));
} else {
rest.push(w);
}
}
Ok((headers, rest))
}

fn parse_json(s: &str) -> Result<Value, String> {
serde_json::from_str(s).map_err(|e| format!("invalid JSON ({e}): {s}"))
}
Expand Down Expand Up @@ -699,7 +806,7 @@ fn encode(s: &str) -> String {
.collect()
}

fn confirm(parsed: &ParsedArgs, question: &str) -> Result<(), String> {
pub(crate) fn confirm(parsed: &ParsedArgs, question: &str) -> Result<(), String> {
if parsed.flag_true("yes") {
return Ok(());
}
Expand Down Expand Up @@ -844,14 +951,14 @@ fn show(ctx: &Ctx, value: Value, _hide: &[&str]) -> Result<i32, String> {
pub fn help() -> String {
let bin = crate::help::invoked_bin();
let mut out = format!(
"{bin} api — the AnyRouter dashboard from your terminal\n\nExamples\n $ {bin} api me\n $ {bin} api credits\n $ {bin} api keys create ci-bot\n $ {bin} api keys list --json\n $ {bin} api logs list --limit 20\n $ {bin} api presets create fast --data '{{\"model\":\"anyrouter/auto\"}}'\n $ {bin} api aliases set opus anthropic/claude-opus-4.5\n\nRaw requests (like `gh api`)\n $ {bin} api /credits\n $ {bin} api PATCH /keys/<hash> disabled=true limit:=25\n\nResources\n"
"{bin} api — the AnyRouter dashboard from your terminal\n\nExamples\n $ {bin} api me\n $ {bin} api credits\n $ {bin} api keys create ci-bot\n $ {bin} api keys list --json\n $ {bin} api logs list --limit 20\n $ {bin} api presets create fast --data '{{\"model\":\"anyrouter/auto\"}}'\n $ {bin} api aliases set opus anthropic/claude-opus-4.5\n\nRaw requests (like `gh api`)\n $ {bin} api /credits\n $ {bin} api PATCH /keys/<hash> disabled=true limit:=25\n $ {bin} api POST /apps/explain/submissions --data @body.json\n $ cat body.json | {bin} api POST /apps/explain/submissions --data - -H X-Trace:1\n\nResources\n"
);
for (name, desc) in RESOURCES {
let verbs: Vec<&str> = verbs(name).iter().map(|(v, _)| *v).collect();
out.push_str(&format!(" {name:<12}{desc} [{}]\n", verbs.join(" ")));
}
out.push_str(
"\nFlags\n --json Print server JSON (default when piped for get)\n --yes Skip confirmation on revoke/delete\n --limit <n> Page size for list verbs\n --data <json> JSON body for raw / presets\n --profile <name> Use another account\n\nExit codes: 0 ok, 1 error, 2 usage, 3 not found, 4 auth/permission.\n",
"\nFlags\n --json Print server JSON (default when piped for get)\n --yes Skip confirmation on revoke/delete\n --limit <n> Page size for list verbs\n --data <json> JSON body: inline, @file, or - for stdin (raw / presets)\n -H key:value Extra request header on raw calls (repeatable)\n --idempotency-key <k> Send an Idempotency-Key header on raw calls\n --profile <name> Use another account\n\nExit codes: 0 ok, 1 error, 2 usage, 3 not found, 4 auth/permission.\n",
);
out
}
Expand Down
15 changes: 14 additions & 1 deletion src/cmd/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,8 @@ pub(crate) fn cmd_kind(command: &str) -> Option<CmdKind> {
"setup" | "login" | "auth" | "menu" | "models" | "config" | "keys" | "whoami"
| "status" | "logout" | "account" | "usage" | "claude" | "codex" | "grok" | "opencode"
| "pool" | "pi" | "upgrade" | "onboard" | "impl" | "plan" | "fix" | "deploy" | "cp"
| "relay" | "decision" | "commands" | "api" | "completion" | "byok" => CmdKind::Implemented,
| "relay" | "decision" | "commands" | "api" | "completion" | "byok" | "publish"
| "apps" => CmdKind::Implemented,
"cursor" | "cline" | "windsurf" => CmdKind::HelpOnly,
"chat" | "task" | "delegate" | "audit" | "logs" | "transactions" | "skills" | "prompt" => {
CmdKind::Stub
Expand Down Expand Up @@ -256,6 +257,14 @@ pub(crate) const COMMANDS: &[(&str, &str)] = &[
"api",
"Dashboard from the terminal: keys, credits, presets…",
),
(
"publish",
"Publish a JSON document to an app: <app> [file|-]",
),
(
"apps",
"Manage published documents: schema, ls, rm, visibility",
),
("decision", "Structured decisions (Decisions API)"),
("models", "List catalog and set the default"),
("model", "Alias of models"),
Expand Down Expand Up @@ -447,6 +456,8 @@ pub(crate) fn allowed_flags(command: &str) -> Option<&'static [&'static str]> {
"prompt" => &["base-url", "json"],
"menu" => &["dump-tui", "config", "profile", "key", "base-url"],
"api" => crate::api::FLAGS,
"publish" => crate::publish::PUBLISH_FLAGS,
"apps" => crate::publish::APPS_FLAGS,
"completion" => &[],
"onboard" | "impl" | "plan" | "fix" | "deploy" | "cp" => &["json", "copy"],
"upgrade" => &[
Expand Down Expand Up @@ -756,6 +767,8 @@ mod tests {
"pi",
"pool",
"api",
"publish",
"apps",
"decision",
"models",
"usage",
Expand Down
2 changes: 2 additions & 0 deletions src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,8 @@ fn dispatch(
"cursor" | "cline" | "windsurf" => stub(command),
"upgrade" | "update" => crate::upgrade::run(parsed, env),
"api" => crate::api::run(parsed, env),
"publish" => crate::publish::run_publish(parsed, env),
"apps" => crate::publish::run_apps(parsed, env),
"completion" => run_completion(parsed),
"onboard" | "impl" | "plan" | "fix" | "deploy" | "cp" => {
crate::onboard::run(command, parsed)
Expand Down
1 change: 1 addition & 0 deletions src/completion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ pub fn subcommands(command: &str) -> &'static [(&'static str, &'static str)] {
),
],
"api" => crate::api::RESOURCES,
"apps" => crate::publish::APPS_VERBS,
_ => &[],
}
}
Expand Down
2 changes: 2 additions & 0 deletions src/help.rs
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,8 @@ pub fn command_help(command: &str) -> Option<String> {
),
"commands" => commands_help(),
"api" => crate::api::help(),
"publish" => crate::publish::publish_help(),
"apps" => crate::publish::apps_help(),
"completion" => fill(&bin, COMPLETION),
"prompt" => fill(
&bin,
Expand Down
Loading
Loading