Colony CLI v1: init, whoami, channels, read, post, create-channel
Working commands against live apes.unslope.com: - colony init --api-url X --user Y - colony whoami [--json] - colony channels [--json] - colony read <channel> [--since N] [--json] - colony post <channel> "msg" [--type X] [--json] [--quiet] - colony create-channel <name> [--json] All with --json support, proper exit codes, channel name resolution. Reuses colony-types for no split brain. Added Serialize to request types. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
906
Cargo.lock
generated
906
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
18
crates/colony-cli/Cargo.toml
Normal file
18
crates/colony-cli/Cargo.toml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
[package]
|
||||||
|
name = "colony-cli"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "colony"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
colony-types = { path = "../colony-types" }
|
||||||
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
reqwest = { version = "0.12", features = ["json", "rustls-tls"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
toml = "0.8"
|
||||||
|
uuid = { version = "1", features = ["v4", "serde"] }
|
||||||
93
crates/colony-cli/src/client.rs
Normal file
93
crates/colony-cli/src/client.rs
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
use crate::config::Config;
|
||||||
|
use colony_types::*;
|
||||||
|
use reqwest::Client;
|
||||||
|
use std::process;
|
||||||
|
|
||||||
|
pub struct ColonyClient {
|
||||||
|
http: Client,
|
||||||
|
pub config: Config,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ColonyClient {
|
||||||
|
pub fn new(config: Config) -> Self {
|
||||||
|
let http = Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(30))
|
||||||
|
.build()
|
||||||
|
.expect("failed to create HTTP client");
|
||||||
|
Self { http, config }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn url(&self, path: &str) -> String {
|
||||||
|
format!("{}{}", self.config.api_url, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_error(&self, res: reqwest::Response) -> ! {
|
||||||
|
let status = res.status().as_u16();
|
||||||
|
let body = res.text().await.unwrap_or_default();
|
||||||
|
let exit_code = match status {
|
||||||
|
401 => 2,
|
||||||
|
404 => 3,
|
||||||
|
_ => 1,
|
||||||
|
};
|
||||||
|
eprintln!("error {}: {}", status, body);
|
||||||
|
process::exit(exit_code);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_me(&self) -> User {
|
||||||
|
let res = self.http
|
||||||
|
.get(self.url(&format!("/api/me?{}", self.config.user_query())))
|
||||||
|
.send().await.unwrap_or_else(|e| { eprintln!("colony unreachable: {e}"); process::exit(1); });
|
||||||
|
if !res.status().is_success() { self.handle_error(res).await; }
|
||||||
|
res.json().await.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_channels(&self) -> Vec<Channel> {
|
||||||
|
let res = self.http
|
||||||
|
.get(self.url("/api/channels"))
|
||||||
|
.send().await.unwrap_or_else(|e| { eprintln!("colony unreachable: {e}"); process::exit(1); });
|
||||||
|
if !res.status().is_success() { self.handle_error(res).await; }
|
||||||
|
res.json().await.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_messages(&self, channel_id: &str, after_seq: Option<i64>) -> Vec<Message> {
|
||||||
|
let mut url = format!("/api/channels/{}/messages", channel_id);
|
||||||
|
if let Some(seq) = after_seq {
|
||||||
|
url.push_str(&format!("?after_seq={}", seq));
|
||||||
|
}
|
||||||
|
let res = self.http
|
||||||
|
.get(self.url(&url))
|
||||||
|
.send().await.unwrap_or_else(|e| { eprintln!("colony unreachable: {e}"); process::exit(1); });
|
||||||
|
if !res.status().is_success() { self.handle_error(res).await; }
|
||||||
|
res.json().await.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn post_message(&self, channel_id: &str, body: &PostMessage) -> Message {
|
||||||
|
let res = self.http
|
||||||
|
.post(self.url(&format!("/api/channels/{}/messages?{}", channel_id, self.config.user_query())))
|
||||||
|
.json(body)
|
||||||
|
.send().await.unwrap_or_else(|e| { eprintln!("colony unreachable: {e}"); process::exit(1); });
|
||||||
|
if !res.status().is_success() { self.handle_error(res).await; }
|
||||||
|
res.json().await.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_channel(&self, body: &CreateChannel) -> Channel {
|
||||||
|
let res = self.http
|
||||||
|
.post(self.url(&format!("/api/channels?{}", self.config.user_query())))
|
||||||
|
.json(body)
|
||||||
|
.send().await.unwrap_or_else(|e| { eprintln!("colony unreachable: {e}"); process::exit(1); });
|
||||||
|
if !res.status().is_success() { self.handle_error(res).await; }
|
||||||
|
res.json().await.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve channel name to ID. Fetches channel list and finds by name.
|
||||||
|
pub async fn resolve_channel(&self, name: &str) -> String {
|
||||||
|
let channels = self.get_channels().await;
|
||||||
|
for ch in &channels {
|
||||||
|
if ch.name == name {
|
||||||
|
return ch.id.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
eprintln!("channel not found: #{}", name);
|
||||||
|
process::exit(3);
|
||||||
|
}
|
||||||
|
}
|
||||||
41
crates/colony-cli/src/config.rs
Normal file
41
crates/colony-cli/src/config.rs
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
pub struct Config {
|
||||||
|
pub api_url: String,
|
||||||
|
pub user: String,
|
||||||
|
pub token: Option<String>,
|
||||||
|
pub password: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
pub fn load() -> Result<Self, String> {
|
||||||
|
// Search order: $COLONY_CONFIG, ./.colony.toml, ~/.colony.toml
|
||||||
|
let paths = [
|
||||||
|
std::env::var("COLONY_CONFIG").ok().map(PathBuf::from),
|
||||||
|
Some(PathBuf::from(".colony.toml")),
|
||||||
|
dirs_next().map(|h| h.join(".colony.toml")),
|
||||||
|
];
|
||||||
|
|
||||||
|
for path in paths.into_iter().flatten() {
|
||||||
|
if path.exists() {
|
||||||
|
let content = std::fs::read_to_string(&path)
|
||||||
|
.map_err(|e| format!("failed to read {}: {}", path.display(), e))?;
|
||||||
|
let config: Config = toml::from_str(&content)
|
||||||
|
.map_err(|e| format!("invalid config {}: {}", path.display(), e))?;
|
||||||
|
return Ok(config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err("no .colony.toml found. run: colony init --api-url URL --user NAME".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn user_query(&self) -> String {
|
||||||
|
format!("user={}", self.user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dirs_next() -> Option<PathBuf> {
|
||||||
|
std::env::var("HOME").ok().map(PathBuf::from)
|
||||||
|
}
|
||||||
177
crates/colony-cli/src/main.rs
Normal file
177
crates/colony-cli/src/main.rs
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
mod client;
|
||||||
|
mod config;
|
||||||
|
|
||||||
|
use clap::{Parser, Subcommand};
|
||||||
|
use client::ColonyClient;
|
||||||
|
use colony_types::*;
|
||||||
|
use config::Config;
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(name = "colony", about = "Ape Colony CLI — chat from the terminal")]
|
||||||
|
struct Cli {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: Commands,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum Commands {
|
||||||
|
/// Show current identity
|
||||||
|
Whoami {
|
||||||
|
#[arg(long)]
|
||||||
|
json: bool,
|
||||||
|
},
|
||||||
|
/// List channels
|
||||||
|
Channels {
|
||||||
|
#[arg(long)]
|
||||||
|
json: bool,
|
||||||
|
},
|
||||||
|
/// Read messages from a channel
|
||||||
|
Read {
|
||||||
|
channel: String,
|
||||||
|
#[arg(long)]
|
||||||
|
since: Option<i64>,
|
||||||
|
#[arg(long)]
|
||||||
|
json: bool,
|
||||||
|
},
|
||||||
|
/// Post a message to a channel
|
||||||
|
Post {
|
||||||
|
channel: String,
|
||||||
|
message: String,
|
||||||
|
#[arg(long, default_value = "text")]
|
||||||
|
r#type: String,
|
||||||
|
#[arg(long)]
|
||||||
|
reply_to: Option<String>,
|
||||||
|
#[arg(long)]
|
||||||
|
metadata: Option<String>,
|
||||||
|
#[arg(long)]
|
||||||
|
json: bool,
|
||||||
|
#[arg(long)]
|
||||||
|
quiet: bool,
|
||||||
|
},
|
||||||
|
/// Create a new channel
|
||||||
|
CreateChannel {
|
||||||
|
name: String,
|
||||||
|
#[arg(long, default_value = "")]
|
||||||
|
description: String,
|
||||||
|
#[arg(long)]
|
||||||
|
json: bool,
|
||||||
|
},
|
||||||
|
/// Initialize .colony.toml
|
||||||
|
Init {
|
||||||
|
#[arg(long)]
|
||||||
|
api_url: String,
|
||||||
|
#[arg(long)]
|
||||||
|
user: String,
|
||||||
|
#[arg(long)]
|
||||||
|
token: Option<String>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
|
||||||
|
match cli.command {
|
||||||
|
Commands::Init { api_url, user, token } => {
|
||||||
|
let config = Config {
|
||||||
|
api_url,
|
||||||
|
user,
|
||||||
|
token,
|
||||||
|
password: None,
|
||||||
|
};
|
||||||
|
let toml_str = toml::to_string_pretty(&config).unwrap();
|
||||||
|
std::fs::write(".colony.toml", &toml_str).unwrap();
|
||||||
|
eprintln!("wrote .colony.toml");
|
||||||
|
}
|
||||||
|
|
||||||
|
_ => {
|
||||||
|
let config = match Config::load() {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("{}", e);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let client = ColonyClient::new(config);
|
||||||
|
run_command(cli.command, &client).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_command(cmd: Commands, client: &ColonyClient) {
|
||||||
|
match cmd {
|
||||||
|
Commands::Whoami { json } => {
|
||||||
|
let me = client.get_me().await;
|
||||||
|
if json {
|
||||||
|
println!("{}", serde_json::to_string(&me).unwrap());
|
||||||
|
} else {
|
||||||
|
println!("{} ({}) — {}", me.username, match me.role {
|
||||||
|
UserRole::Ape => "ape",
|
||||||
|
UserRole::Agent => "agent",
|
||||||
|
}, client.config.api_url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Commands::Channels { json } => {
|
||||||
|
let channels = client.get_channels().await;
|
||||||
|
if json {
|
||||||
|
println!("{}", serde_json::to_string(&channels).unwrap());
|
||||||
|
} else {
|
||||||
|
for ch in &channels {
|
||||||
|
println!("#{:<16} {}", ch.name, ch.description);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Commands::Read { channel, since, json } => {
|
||||||
|
let channel_id = client.resolve_channel(&channel).await;
|
||||||
|
let messages = client.get_messages(&channel_id, since).await;
|
||||||
|
if json {
|
||||||
|
println!("{}", serde_json::to_string(&messages).unwrap());
|
||||||
|
} else {
|
||||||
|
for msg in &messages {
|
||||||
|
println!("[{}] {}: {}", msg.seq, msg.user.username, msg.content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Commands::Post { channel, message, r#type, reply_to, metadata, json, quiet } => {
|
||||||
|
let channel_id = client.resolve_channel(&channel).await;
|
||||||
|
let msg_type = match r#type.as_str() {
|
||||||
|
"code" => MessageType::Code,
|
||||||
|
"result" => MessageType::Result,
|
||||||
|
"error" => MessageType::Error,
|
||||||
|
"plan" => MessageType::Plan,
|
||||||
|
_ => MessageType::Text,
|
||||||
|
};
|
||||||
|
let meta_value = metadata.and_then(|m| serde_json::from_str(&m).ok());
|
||||||
|
let reply = reply_to.and_then(|r| uuid::Uuid::parse_str(&r).ok());
|
||||||
|
|
||||||
|
let body = PostMessage {
|
||||||
|
content: message,
|
||||||
|
r#type: msg_type,
|
||||||
|
metadata: meta_value,
|
||||||
|
reply_to: reply,
|
||||||
|
};
|
||||||
|
|
||||||
|
let msg = client.post_message(&channel_id, &body).await;
|
||||||
|
if json {
|
||||||
|
println!("{}", serde_json::to_string(&msg).unwrap());
|
||||||
|
} else if !quiet {
|
||||||
|
println!("posted message #{} to #{}", msg.seq, channel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Commands::CreateChannel { name, description, json } => {
|
||||||
|
let body = CreateChannel { name: name.clone(), description };
|
||||||
|
let ch = client.create_channel(&body).await;
|
||||||
|
if json {
|
||||||
|
println!("{}", serde_json::to_string(&ch).unwrap());
|
||||||
|
} else {
|
||||||
|
println!("created #{}", name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Commands::Init { .. } => unreachable!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -67,14 +67,14 @@ pub enum MessageType {
|
|||||||
|
|
||||||
// ── Request types ──
|
// ── Request types ──
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, TS)]
|
#[derive(Debug, Serialize, Deserialize, TS)]
|
||||||
#[ts(export)]
|
#[ts(export)]
|
||||||
pub struct CreateChannel {
|
pub struct CreateChannel {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, TS)]
|
#[derive(Debug, Serialize, Deserialize, TS)]
|
||||||
#[ts(export)]
|
#[ts(export)]
|
||||||
pub struct PostMessage {
|
pub struct PostMessage {
|
||||||
pub content: String,
|
pub content: String,
|
||||||
|
|||||||
Reference in New Issue
Block a user