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
416 changes: 414 additions & 2 deletions apps/springtale-cli/src/cli.rs

Large diffs are not rendered by default.

27 changes: 27 additions & 0 deletions apps/springtale-cli/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,33 @@ impl Client {
Ok(resp)
}

/// POST `path` and hand back the undecoded response — the shape the
/// SSE-over-POST routes need (`/workspaces/onboard` streams progress
/// frames rather than answering with one JSON body).
pub async fn post_stream<B: Serialize>(
&self,
path: &str,
body: &B,
) -> Result<reqwest::Response> {
// SECURITY: expose needed to set the bearer header.
let resp = self
.http
.post(self.url(path))
.bearer_auth(self.token.expose_secret())
.json(body)
.send()
.await
.context(UNREACHABLE)?;
if !resp.status().is_success() {
bail!(
"{}: {}",
resp.status(),
resp.text().await.unwrap_or_default()
);
}
Ok(resp)
}

/// Start a request against `path` with the bearer header already
/// applied, for callers that need the raw `reqwest` response rather
/// than a decoded JSON body. The MCP stdio bridge uses it: it needs
Expand Down
31 changes: 31 additions & 0 deletions apps/springtale-cli/src/commands/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,37 @@ use crate::output;
pub async fn run(action: AgentAction, json_out: bool) -> Result<()> {
let client = Client::from_config()?;
match action {
AgentAction::States => {
let body: Value = client.get("/agents/states").await?;
output::emit(json_out, &body, |v| {
let rows = output::array(v, "agents")
.iter()
.map(|a| {
vec![
output::cell(a, "name"),
output::cell(a, "activity"),
output::cell(a, "autonomy"),
output::cell(a, "connector_name"),
]
})
.collect();
output::rows_table(&["NAME", "ACTIVITY", "AUTONOMY", "CONNECTOR"], rows)
})?;
}
AgentAction::StepAutonomy { name, direction } => {
let body: Value = client
.post(
&format!("/agents/{name}/autonomy/step"),
&json!({ "direction": direction }),
)
.await?;
output::emit(json_out, &body, |v| {
format!(
"Agent '{name}' autonomy is now: {}",
output::cell(v, "level")
)
})?;
}
AgentAction::SetAutonomy { name, level } => {
// The daemon resolves the rule name or id to an autonomy
// target — the CLI does not need the rule set to do it.
Expand Down
40 changes: 40 additions & 0 deletions apps/springtale-cli/src/commands/auth.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
//! `springtale auth` — the API tokens the daemon has issued.
//!
//! `springtale login` mints one and writes it to the token file; this
//! family is how you see the rest and revoke one you no longer trust.

use anyhow::Result;
use serde_json::Value;

use crate::cli::AuthAction;
use crate::client::Client;
use crate::output;

/// Handle auth subcommands.
pub async fn run(action: AuthAction, json_out: bool) -> Result<()> {
let client = Client::from_config()?;
match action {
AuthAction::Tokens => {
let body: Value = client.get("/auth/tokens").await?;
output::emit(json_out, &body, |v| {
let rows = output::array(v, "tokens")
.iter()
.map(|t| {
vec![
output::cell(t, "id"),
output::cell(t, "name"),
output::cell(t, "created_at"),
output::cell(t, "last_used_at"),
]
})
.collect();
output::rows_table(&["ID", "NAME", "CREATED", "LAST USED"], rows)
})?;
}
AuthAction::Revoke { id } => {
let body: Value = client.delete(&format!("/auth/tokens/{id}")).await?;
output::emit_status(json_out, &body, |_| format!("Revoked token {id}."))?;
}
}
Ok(())
}
24 changes: 24 additions & 0 deletions apps/springtale-cli/src/commands/bot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,30 @@ pub async fn panic_unpair(opts: &PassphraseOpts, json_out: bool) -> Result<()> {
})
}

/// `springtale bot status` — what the runtime is doing right now.
pub async fn status(json_out: bool) -> Result<()> {
read(json_out, "/bot/status").await
}

/// `springtale bot formations` — the formations the bot is running.
pub async fn formations(json_out: bool) -> Result<()> {
read(json_out, "/bot/formations").await
}

/// `springtale bot memory` — the session memory the bot is holding.
pub async fn memory(json_out: bool) -> Result<()> {
read(json_out, "/bot/memory").await
}

/// GET one read-only bot view and print it.
async fn read(json_out: bool, path: &str) -> Result<()> {
let client = Client::from_config()?;
let body: serde_json::Value = client.get(path).await?;
output::emit(json_out, &body, |v| {
serde_json::to_string_pretty(v).unwrap_or_default()
})
}

/// `springtale bot settings …` — plan 6.3. Goes through the daemon so the
/// change reaches the live runtime (a direct store write would only be
/// picked up on the next restart, which is the thing this replaced).
Expand Down
18 changes: 17 additions & 1 deletion apps/springtale-cli/src/commands/canvas.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,24 @@ use crate::client::Client;
use crate::output;

/// Print the canvas snapshot, or follow live updates.
pub async fn run(stream: bool, json_out: bool) -> Result<()> {
pub async fn run(stream: bool, connections: bool, json_out: bool) -> Result<()> {
let client = Client::from_config()?;
if connections {
let body: Value = client.get("/canvas/connections").await?;
return output::emit(json_out, &body, |v| {
let rows = output::array(v, "connections")
.iter()
.map(|c| {
vec![
output::cell(c, "a"),
output::cell(c, "b"),
output::array(c, "pipes").len().to_string(),
]
})
.collect();
output::rows_table(&["FROM", "TO", "PIPES"], rows)
});
}
if !stream {
let body: Value = client.get("/canvas").await?;
return output::emit(json_out, &body, |v| {
Expand Down
31 changes: 31 additions & 0 deletions apps/springtale-cli/src/commands/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ use springtale_runtime::operations::config::{AI_COLONY_KEY, AiTarget};

use crate::cli::{AiConfigAction, ConfigAction};
use crate::client::Client;
use crate::commands::json_input;
use crate::output;

/// Handle `config` subcommands.
Expand All @@ -23,6 +24,30 @@ pub async fn run(action: ConfigAction, json_out: bool) -> Result<()> {
serde_json::to_string_pretty(v).unwrap_or_default()
})
}
ConfigAction::Connector { name, file } => {
let body: Value = client
.post(
&format!("/config/connector/{name}"),
&json_input::load(&file)?,
)
.await?;
output::emit_status(json_out, &body, |_| {
format!("Connector config saved for '{name}'.")
})
}
ConfigAction::Heartbeat { file } => {
let body: Value = match file {
Some(file) => {
client
.put("/config/heartbeat", &json_input::load(&file)?)
.await?
}
None => client.get("/config/heartbeat").await?,
};
output::emit(json_out, &body, |v| {
serde_json::to_string_pretty(v).unwrap_or_default()
})
}
ConfigAction::Ai { action } => run_ai(action, &client, json_out).await,
}
}
Expand Down Expand Up @@ -57,6 +82,12 @@ async fn run_ai(action: AiConfigAction, client: &Client, json_out: bool) -> Resu
serde_json::to_string_pretty(v).unwrap_or_default()
})
}
AiConfigAction::Put { file } => {
// The whole adapter document, as-is. `set` is the flag-built
// sibling; this one is for a config you already have on disk.
let body: Value = client.post("/config/ai", &json_input::load(&file)?).await?;
output::emit_status(json_out, &body, |_| "AI config applied.".to_owned())
}
AiConfigAction::Set {
scope,
id,
Expand Down
Loading
Loading