Agents: real ClawBrainHub integration (list + pull brains → trading cards)
Backend: - brainhub.rs: BrainHubClient trait + HttpBrainHubClient (public discovery API, optional Bearer token) + FakeBrainHubClient. Lists the user's brains (CLAWBRAINHUB_OWNER, default "omar") + redclawsystems reference brains, and pulls a .brain JSON, parsing identity/skills/tools into card fields (tagline, skills, tools, capabilities). 2 parser unit tests. - Routes GET /v1/brainhub/brains + /v1/brainhub/pull (rate-limited); wired into AppState + main (HttpBrainHubClient) + test harness (FakeBrainHubClient). Mobile: - src/api/brainhub.ts (listBrains/pullBrain), draftStore.pendingAgentBrain. - app/pick-brain.tsx: pick a brain → pull → hand to the agent-card editor. - Agents "+ New agent" now opens the brain picker; demo.tsx adopts the pulled brain (name, tagline, skill bars, tools, capabilities) for the flip side. Verified live against clawbrainhub.com (omar's brains + reference brains list; general-assistant pull yields real skills). Token stays server-side/env only. Gate: mobile 28, backend 123. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
d30deaf857
commit
9765a08a5a
@@ -0,0 +1,350 @@
|
|||||||
|
//! ClawBrainHub (clawbrainhub.com) integration: list agent "brains" and pull a
|
||||||
|
//! brain's identity/skills so the app can turn it into a trading-card-style
|
||||||
|
//! agent card. Reads use the public discovery API (no auth); an optional token
|
||||||
|
//! is sent as a Bearer for the user's private brains.
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
const DEFAULT_BASE: &str = "https://clawbrainhub.com/api/v1";
|
||||||
|
/// Reference brains the registry always ships — handy picks even when the user
|
||||||
|
/// hasn't published their own yet.
|
||||||
|
const REFERENCE_OWNER: &str = "redclawsystems";
|
||||||
|
|
||||||
|
/// A brain as shown in the picker list.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct BrainSummary {
|
||||||
|
pub owner: String,
|
||||||
|
pub name: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub version: String,
|
||||||
|
pub trust_score: Option<i64>,
|
||||||
|
pub badge: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A pulled brain normalized for a card (identity + skills/tools/capabilities).
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct AgentBrain {
|
||||||
|
pub owner: String,
|
||||||
|
pub name: String,
|
||||||
|
pub version: String,
|
||||||
|
pub tagline: String,
|
||||||
|
pub skills: Vec<String>,
|
||||||
|
pub tools: Vec<String>,
|
||||||
|
pub capabilities: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum BrainHubError {
|
||||||
|
Http(String),
|
||||||
|
Parse(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for BrainHubError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
BrainHubError::Http(e) => write!(f, "clawbrainhub request failed: {e}"),
|
||||||
|
BrainHubError::Parse(e) => write!(f, "clawbrainhub parse failed: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait BrainHubClient: Send + Sync {
|
||||||
|
/// The user's brains plus the registry's reference brains.
|
||||||
|
async fn list_brains(&self) -> Result<Vec<BrainSummary>, BrainHubError>;
|
||||||
|
/// Pull a brain and normalize it into card fields.
|
||||||
|
async fn pull_brain(
|
||||||
|
&self,
|
||||||
|
owner: &str,
|
||||||
|
name: &str,
|
||||||
|
version: &str,
|
||||||
|
) -> Result<AgentBrain, BrainHubError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct HttpBrainHubClient {
|
||||||
|
http: reqwest::Client,
|
||||||
|
base: String,
|
||||||
|
/// The user's ClawBrainHub account (its brains are listed first).
|
||||||
|
owner: String,
|
||||||
|
token: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HttpBrainHubClient {
|
||||||
|
pub fn new(owner: String, token: Option<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
http: reqwest::Client::new(),
|
||||||
|
base: DEFAULT_BASE.to_string(),
|
||||||
|
owner,
|
||||||
|
token,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_json(&self, url: &str) -> Result<serde_json::Value, BrainHubError> {
|
||||||
|
let mut req = self.http.get(url);
|
||||||
|
if let Some(t) = &self.token {
|
||||||
|
req = req.bearer_auth(t);
|
||||||
|
}
|
||||||
|
let resp = req
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| BrainHubError::Http(e.to_string()))?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(BrainHubError::Http(format!("status {}", resp.status())));
|
||||||
|
}
|
||||||
|
resp.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| BrainHubError::Parse(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_owner(&self, owner: &str) -> Vec<BrainSummary> {
|
||||||
|
let url = format!("{}/brains/{}", self.base, owner);
|
||||||
|
match self.get_json(&url).await {
|
||||||
|
Ok(json) => parse_summaries(&json),
|
||||||
|
Err(_) => Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl BrainHubClient for HttpBrainHubClient {
|
||||||
|
async fn list_brains(&self) -> Result<Vec<BrainSummary>, BrainHubError> {
|
||||||
|
let mut out = self.list_owner(&self.owner).await;
|
||||||
|
for b in self.list_owner(REFERENCE_OWNER).await {
|
||||||
|
if !out.iter().any(|x| x.owner == b.owner && x.name == b.name) {
|
||||||
|
out.push(b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn pull_brain(
|
||||||
|
&self,
|
||||||
|
owner: &str,
|
||||||
|
name: &str,
|
||||||
|
version: &str,
|
||||||
|
) -> Result<AgentBrain, BrainHubError> {
|
||||||
|
let url = format!("{}/brains/{}/{}/{}/pull", self.base, owner, name, version);
|
||||||
|
let json = self.get_json(&url).await?;
|
||||||
|
Ok(parse_brain(owner, name, version, &json))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a `/brains/{owner}` list response into summaries (latest version each).
|
||||||
|
fn parse_summaries(json: &serde_json::Value) -> Vec<BrainSummary> {
|
||||||
|
let arr = match json.as_array() {
|
||||||
|
Some(a) => a,
|
||||||
|
None => return Vec::new(),
|
||||||
|
};
|
||||||
|
arr.iter()
|
||||||
|
.filter_map(|b| {
|
||||||
|
let owner = b.get("owner")?.as_str()?.to_string();
|
||||||
|
let name = b.get("name")?.as_str()?.to_string();
|
||||||
|
// First non-yanked version (the API lists newest first).
|
||||||
|
let ver = b.get("versions").and_then(|v| v.as_array()).and_then(|vs| {
|
||||||
|
vs.iter()
|
||||||
|
.find(|v| !v.get("yanked").and_then(|y| y.as_bool()).unwrap_or(false))
|
||||||
|
});
|
||||||
|
let version = ver
|
||||||
|
.and_then(|v| v.get("version"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("latest")
|
||||||
|
.to_string();
|
||||||
|
Some(BrainSummary {
|
||||||
|
owner,
|
||||||
|
name,
|
||||||
|
description: b
|
||||||
|
.get("description")
|
||||||
|
.and_then(|d| d.as_str())
|
||||||
|
.map(str::to_string),
|
||||||
|
version,
|
||||||
|
trust_score: ver
|
||||||
|
.and_then(|v| v.get("trust_score"))
|
||||||
|
.and_then(|t| t.as_i64()),
|
||||||
|
badge: ver
|
||||||
|
.and_then(|v| v.get("badge"))
|
||||||
|
.and_then(|x| x.as_str())
|
||||||
|
.map(str::to_string),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalize a pulled `.brain` JSON into card fields.
|
||||||
|
fn parse_brain(owner: &str, name: &str, version: &str, json: &serde_json::Value) -> AgentBrain {
|
||||||
|
let meta_name = json
|
||||||
|
.pointer("/meta/brain_name")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or(name);
|
||||||
|
let agent_md = json
|
||||||
|
.pointer("/identity/agent_md")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
let soul_md = json
|
||||||
|
.pointer("/identity/soul_md")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
let skills_md = json
|
||||||
|
.pointer("/skills/skills_md")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
|
||||||
|
let mut skills = md_headings(skills_md);
|
||||||
|
skills.truncate(8);
|
||||||
|
|
||||||
|
let mut tools: Vec<String> = json
|
||||||
|
.pointer("/skills/tool_defs")
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
.map(|a| {
|
||||||
|
a.iter()
|
||||||
|
.filter_map(|t| t.get("name").and_then(|n| n.as_str()).map(str::to_string))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
tools.truncate(8);
|
||||||
|
|
||||||
|
let mut capabilities = md_bullets(agent_md);
|
||||||
|
if capabilities.is_empty() {
|
||||||
|
capabilities = md_headings(agent_md)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|h| h != "Purpose")
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
capabilities.truncate(6);
|
||||||
|
|
||||||
|
let tagline = first_paragraph(soul_md)
|
||||||
|
.or_else(|| first_paragraph(agent_md))
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
AgentBrain {
|
||||||
|
owner: owner.to_string(),
|
||||||
|
name: meta_name.to_string(),
|
||||||
|
version: version.to_string(),
|
||||||
|
tagline,
|
||||||
|
skills,
|
||||||
|
tools,
|
||||||
|
capabilities,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `## Heading` lines, prettified (snake_case → Title Case).
|
||||||
|
fn md_headings(md: &str) -> Vec<String> {
|
||||||
|
md.lines()
|
||||||
|
.filter_map(|l| l.trim().strip_prefix("## "))
|
||||||
|
.map(|h| {
|
||||||
|
h.trim()
|
||||||
|
.split(['_', '-', ' '])
|
||||||
|
.filter(|w| !w.is_empty())
|
||||||
|
.map(|w| {
|
||||||
|
let mut c = w.chars();
|
||||||
|
match c.next() {
|
||||||
|
Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
|
||||||
|
None => String::new(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ")
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `- ` / `* ` bullet lines, stripped.
|
||||||
|
fn md_bullets(md: &str) -> Vec<String> {
|
||||||
|
md.lines()
|
||||||
|
.filter_map(|l| {
|
||||||
|
let t = l.trim();
|
||||||
|
t.strip_prefix("- ")
|
||||||
|
.or_else(|| t.strip_prefix("* "))
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
})
|
||||||
|
.filter(|s| !s.is_empty() && s.len() < 80)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// First non-empty, non-heading, non-bullet line.
|
||||||
|
fn first_paragraph(md: &str) -> Option<String> {
|
||||||
|
md.lines()
|
||||||
|
.map(str::trim)
|
||||||
|
.find(|l| {
|
||||||
|
!l.is_empty() && !l.starts_with('#') && !l.starts_with('-') && !l.starts_with('*')
|
||||||
|
})
|
||||||
|
.map(|s| {
|
||||||
|
if s.chars().count() <= 150 {
|
||||||
|
return s.to_string();
|
||||||
|
}
|
||||||
|
// Truncate at a word boundary so the tagline doesn't end mid-word.
|
||||||
|
let head: String = s.chars().take(150).collect();
|
||||||
|
let cut = head.rsplit_once(' ').map(|(a, _)| a).unwrap_or(&head);
|
||||||
|
format!("{}…", cut.trim_end_matches([',', '.', ';', ':', ' ']))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deterministic double for tests.
|
||||||
|
pub struct FakeBrainHubClient;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl BrainHubClient for FakeBrainHubClient {
|
||||||
|
async fn list_brains(&self) -> Result<Vec<BrainSummary>, BrainHubError> {
|
||||||
|
Ok(vec![BrainSummary {
|
||||||
|
owner: "redclawsystems".into(),
|
||||||
|
name: "general-assistant".into(),
|
||||||
|
description: Some("General-purpose assistant".into()),
|
||||||
|
version: "1.0.0".into(),
|
||||||
|
trust_score: Some(90),
|
||||||
|
badge: Some("Verified".into()),
|
||||||
|
}])
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn pull_brain(
|
||||||
|
&self,
|
||||||
|
owner: &str,
|
||||||
|
name: &str,
|
||||||
|
version: &str,
|
||||||
|
) -> Result<AgentBrain, BrainHubError> {
|
||||||
|
Ok(AgentBrain {
|
||||||
|
owner: owner.to_string(),
|
||||||
|
name: name.to_string(),
|
||||||
|
version: version.to_string(),
|
||||||
|
tagline: "A general-purpose assistant.".into(),
|
||||||
|
skills: vec!["Web Search".into(), "Code".into()],
|
||||||
|
tools: vec![],
|
||||||
|
capabilities: vec!["Writing".into(), "Analysis".into()],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_skills_and_tagline_from_brain_json() {
|
||||||
|
let json = serde_json::json!({
|
||||||
|
"meta": { "brain_name": "general-assistant" },
|
||||||
|
"identity": {
|
||||||
|
"soul_md": "# Soul\n\nI am a helpful general-purpose assistant.",
|
||||||
|
"agent_md": "# Agent\n\n## Purpose\n\n- Writing\n- Planning"
|
||||||
|
},
|
||||||
|
"skills": { "skills_md": "# Skills\n\n## web_search\nSearch the web.\n## code_review\nReview code." }
|
||||||
|
});
|
||||||
|
let b = parse_brain("redclawsystems", "general-assistant", "1.0.0", &json);
|
||||||
|
assert_eq!(b.name, "general-assistant");
|
||||||
|
assert_eq!(b.skills, vec!["Web Search", "Code Review"]);
|
||||||
|
assert_eq!(b.capabilities, vec!["Writing", "Planning"]);
|
||||||
|
assert!(b.tagline.contains("general-purpose assistant"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_list_summaries() {
|
||||||
|
let json = serde_json::json!([
|
||||||
|
{ "owner": "o", "name": "n", "description": null,
|
||||||
|
"versions": [{ "version": "1.0.0", "trust_score": 85, "badge": "Verified", "yanked": false }] }
|
||||||
|
]);
|
||||||
|
let s = parse_summaries(&json);
|
||||||
|
assert_eq!(s.len(), 1);
|
||||||
|
assert_eq!(s[0].version, "1.0.0");
|
||||||
|
assert_eq!(s[0].trust_score, Some(85));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
//! ClawBrainHub handlers: list agent brains and pull one (normalized for a
|
||||||
|
//! trading-card-style agent card). Reads proxy the public registry; lightly
|
||||||
|
//! rate-limited per IP to avoid hammering the upstream.
|
||||||
|
|
||||||
|
use axum::extract::{Query, State};
|
||||||
|
use axum::http::HeaderMap;
|
||||||
|
use axum::Json;
|
||||||
|
use cardclaws_types::AppError;
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::brainhub::{AgentBrain, BrainSummary};
|
||||||
|
use crate::error::ApiResult;
|
||||||
|
use crate::handlers::analytics::client_ip;
|
||||||
|
use crate::middleware::rate_limit;
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
fn rl_key(op: &str, headers: &HeaderMap) -> String {
|
||||||
|
let ip = client_ip(headers).unwrap_or_else(|| "unknown".into());
|
||||||
|
format!("brainhub:{op}:{ip}")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> ApiResult<Json<Vec<BrainSummary>>> {
|
||||||
|
rate_limit::check(state.cache.as_ref(), &rl_key("list", &headers), 60, 3600).await?;
|
||||||
|
let brains = state
|
||||||
|
.brainhub
|
||||||
|
.list_brains()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(e.to_string()))?;
|
||||||
|
Ok(Json(brains))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct PullQuery {
|
||||||
|
pub owner: String,
|
||||||
|
pub name: String,
|
||||||
|
pub version: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn pull(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Query(q): Query<PullQuery>,
|
||||||
|
) -> ApiResult<Json<AgentBrain>> {
|
||||||
|
rate_limit::check(state.cache.as_ref(), &rl_key("pull", &headers), 60, 3600).await?;
|
||||||
|
let brain = state
|
||||||
|
.brainhub
|
||||||
|
.pull_brain(&q.owner, &q.name, &q.version)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(e.to_string()))?;
|
||||||
|
Ok(Json(brain))
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ pub mod ai;
|
|||||||
pub mod analytics;
|
pub mod analytics;
|
||||||
pub mod assets;
|
pub mod assets;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
|
pub mod brainhub;
|
||||||
pub mod cards;
|
pub mod cards;
|
||||||
pub mod demo;
|
pub mod demo;
|
||||||
pub mod health;
|
pub mod health;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
pub mod ai;
|
pub mod ai;
|
||||||
pub mod assets;
|
pub mod assets;
|
||||||
|
pub mod brainhub;
|
||||||
pub mod cache;
|
pub mod cache;
|
||||||
pub mod email;
|
pub mod email;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use cardclaws_api::ai::{AiClient, DisabledAiClient, GeminiClient};
|
use cardclaws_api::ai::{AiClient, DisabledAiClient, GeminiClient};
|
||||||
use cardclaws_api::assets::R2Store;
|
use cardclaws_api::assets::R2Store;
|
||||||
|
use cardclaws_api::brainhub::HttpBrainHubClient;
|
||||||
use cardclaws_api::cache::RedisCache;
|
use cardclaws_api::cache::RedisCache;
|
||||||
use cardclaws_api::email::ResendEmailSender;
|
use cardclaws_api::email::ResendEmailSender;
|
||||||
use cardclaws_api::geo::{GeoResolver, NullGeoResolver};
|
use cardclaws_api::geo::{GeoResolver, NullGeoResolver};
|
||||||
@@ -59,6 +60,16 @@ async fn main() -> Result<(), BoxError> {
|
|||||||
None => Arc::new(DisabledAiClient),
|
None => Arc::new(DisabledAiClient),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ClawBrainHub: list/pull agent brains. Reads are public; CLAWBRAINHUB_TOKEN
|
||||||
|
// (optional) authorizes the user's private brains. CLAWBRAINHUB_OWNER is the
|
||||||
|
// account whose brains are listed first (defaults to "omar").
|
||||||
|
let brainhub = Arc::new(HttpBrainHubClient::new(
|
||||||
|
std::env::var("CLAWBRAINHUB_OWNER").unwrap_or_else(|_| "omar".into()),
|
||||||
|
std::env::var("CLAWBRAINHUB_TOKEN")
|
||||||
|
.ok()
|
||||||
|
.filter(|t| !t.trim().is_empty()),
|
||||||
|
));
|
||||||
|
|
||||||
// Brand glyphs bundled into every pass. Solid-fill placeholders for now;
|
// Brand glyphs bundled into every pass. Solid-fill placeholders for now;
|
||||||
// replaced by real CardClaws artwork when design assets land.
|
// replaced by real CardClaws artwork when design assets land.
|
||||||
let brand = BrandAssets {
|
let brand = BrandAssets {
|
||||||
@@ -73,6 +84,7 @@ async fn main() -> Result<(), BoxError> {
|
|||||||
assets: Arc::new(assets),
|
assets: Arc::new(assets),
|
||||||
geo,
|
geo,
|
||||||
ai,
|
ai,
|
||||||
|
brainhub,
|
||||||
jwt: JwtKeys::new(&config.jwt_secret),
|
jwt: JwtKeys::new(&config.jwt_secret),
|
||||||
apple: Arc::new(HttpJwkProvider::new()),
|
apple: Arc::new(HttpJwkProvider::new()),
|
||||||
apple_audience,
|
apple_audience,
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ use axum::routing::{get, post};
|
|||||||
use axum::Router;
|
use axum::Router;
|
||||||
|
|
||||||
use crate::handlers::{
|
use crate::handlers::{
|
||||||
account, ai, analytics, assets, auth, cards, demo, health, profile, share, teams, wallet,
|
account, ai, analytics, assets, auth, brainhub, cards, demo, health, profile, share, teams,
|
||||||
webhooks,
|
wallet, webhooks,
|
||||||
};
|
};
|
||||||
use crate::middleware::cors;
|
use crate::middleware::cors;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
@@ -78,6 +78,8 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
.route("/ai/image", post(ai::image))
|
.route("/ai/image", post(ai::image))
|
||||||
.route("/ai/video", post(ai::start_video))
|
.route("/ai/video", post(ai::start_video))
|
||||||
.route("/ai/video/status", post(ai::video_status))
|
.route("/ai/video/status", post(ai::video_status))
|
||||||
|
.route("/brainhub/brains", get(brainhub::list))
|
||||||
|
.route("/brainhub/pull", get(brainhub::pull))
|
||||||
.route("/account/export", get(account::export_data))
|
.route("/account/export", get(account::export_data))
|
||||||
.route("/account", axum::routing::delete(account::delete_account))
|
.route("/account", axum::routing::delete(account::delete_account))
|
||||||
.route("/assets/upload", post(assets::presign_upload))
|
.route("/assets/upload", post(assets::presign_upload))
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ use cardclaws_wallet::google::jwt_signer::GoogleWalletSigner;
|
|||||||
|
|
||||||
use crate::ai::AiClient;
|
use crate::ai::AiClient;
|
||||||
use crate::assets::ObjectStore;
|
use crate::assets::ObjectStore;
|
||||||
|
use crate::brainhub::BrainHubClient;
|
||||||
use crate::cache::Cache;
|
use crate::cache::Cache;
|
||||||
use crate::email::EmailSender;
|
use crate::email::EmailSender;
|
||||||
use crate::geo::GeoResolver;
|
use crate::geo::GeoResolver;
|
||||||
@@ -26,6 +27,7 @@ pub struct AppState {
|
|||||||
pub assets: Arc<dyn ObjectStore>,
|
pub assets: Arc<dyn ObjectStore>,
|
||||||
pub geo: Arc<dyn GeoResolver>,
|
pub geo: Arc<dyn GeoResolver>,
|
||||||
pub ai: Arc<dyn AiClient>,
|
pub ai: Arc<dyn AiClient>,
|
||||||
|
pub brainhub: Arc<dyn BrainHubClient>,
|
||||||
pub jwt: JwtKeys,
|
pub jwt: JwtKeys,
|
||||||
pub apple: Arc<dyn JwkProvider>,
|
pub apple: Arc<dyn JwkProvider>,
|
||||||
/// Apple Services ID / bundle id the identity token must be addressed to.
|
/// Apple Services ID / bundle id the identity token must be addressed to.
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ use tower::ServiceExt;
|
|||||||
|
|
||||||
use cardclaws_api::ai::FakeAiClient;
|
use cardclaws_api::ai::FakeAiClient;
|
||||||
use cardclaws_api::assets::InMemoryStore;
|
use cardclaws_api::assets::InMemoryStore;
|
||||||
|
use cardclaws_api::brainhub::FakeBrainHubClient;
|
||||||
use cardclaws_api::cache::InMemoryCache;
|
use cardclaws_api::cache::InMemoryCache;
|
||||||
use cardclaws_api::email::CapturingEmailSender;
|
use cardclaws_api::email::CapturingEmailSender;
|
||||||
use cardclaws_api::geo::{GeoLocation, GeoResolver};
|
use cardclaws_api::geo::{GeoLocation, GeoResolver};
|
||||||
@@ -86,6 +87,7 @@ pub async fn try_setup() -> Option<TestApp> {
|
|||||||
assets: assets.clone(),
|
assets: assets.clone(),
|
||||||
geo: Arc::new(FakeGeo),
|
geo: Arc::new(FakeGeo),
|
||||||
ai: Arc::new(FakeAiClient),
|
ai: Arc::new(FakeAiClient),
|
||||||
|
brainhub: Arc::new(FakeBrainHubClient),
|
||||||
jwt: JwtKeys::new("test-jwt-secret"),
|
jwt: JwtKeys::new("test-jwt-secret"),
|
||||||
apple: Arc::new(NoopApple),
|
apple: Arc::new(NoopApple),
|
||||||
apple_audience: "com.cardclaws.test".into(),
|
apple_audience: "com.cardclaws.test".into(),
|
||||||
|
|||||||
@@ -107,6 +107,15 @@ function buildDemoCard(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** "general-assistant" → "General Assistant". */
|
||||||
|
function prettifyBrainName(name: string): string {
|
||||||
|
return name
|
||||||
|
.split(/[-_\s]+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
export default function CardEditorScreen() {
|
export default function CardEditorScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { cardId, kind: kindParam } = useLocalSearchParams<{ cardId?: string; kind?: string }>();
|
const { cardId, kind: kindParam } = useLocalSearchParams<{ cardId?: string; kind?: string }>();
|
||||||
@@ -173,6 +182,21 @@ export default function CardEditorScreen() {
|
|||||||
setShowing(false);
|
setShowing(false);
|
||||||
draft.setPendingVideoUri(null);
|
draft.setPendingVideoUri(null);
|
||||||
}
|
}
|
||||||
|
// A brain pulled from ClawBrainHub → prefill the agent card's flip side.
|
||||||
|
if (draft.pendingAgentBrain) {
|
||||||
|
const b = draft.pendingAgentBrain;
|
||||||
|
setCardKind("agent");
|
||||||
|
setName(prettifyBrainName(b.name));
|
||||||
|
setTitle(`v${b.version} · @${b.owner}`);
|
||||||
|
setAgentMeta({
|
||||||
|
tagline: b.tagline,
|
||||||
|
skills: b.skills.map((s) => ({ name: s, level: 4 })),
|
||||||
|
tools: b.tools,
|
||||||
|
capabilities: b.capabilities,
|
||||||
|
});
|
||||||
|
setShowing(false);
|
||||||
|
draft.setPendingAgentBrain(null);
|
||||||
|
}
|
||||||
}, []),
|
}, []),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
// Pick an agent brain from ClawBrainHub (your account + reference brains), pull
|
||||||
|
// it, and hand it to the agent-card editor to turn into a trading card.
|
||||||
|
|
||||||
|
import { Stack, useRouter } from "expo-router";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { ActivityIndicator, FlatList, Pressable, StyleSheet, Text, View } from "react-native";
|
||||||
|
import { BrainSummary, listBrains, pullBrain } from "../src/api/brainhub";
|
||||||
|
import { useDraftStore } from "../src/stores/draftStore";
|
||||||
|
|
||||||
|
export default function PickBrain() {
|
||||||
|
const router = useRouter();
|
||||||
|
const setPendingAgentBrain = useDraftStore((s) => s.setPendingAgentBrain);
|
||||||
|
const [brains, setBrains] = useState<BrainSummary[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [pulling, setPulling] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
listBrains()
|
||||||
|
.then((b) => alive && setBrains(b))
|
||||||
|
.catch(() => alive && setError("Couldn't reach ClawBrainHub. Check your connection."))
|
||||||
|
.finally(() => alive && setLoading(false));
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const choose = async (b: BrainSummary) => {
|
||||||
|
const key = `${b.owner}/${b.name}`;
|
||||||
|
setPulling(key);
|
||||||
|
try {
|
||||||
|
const brain = await pullBrain(b.owner, b.name, b.version);
|
||||||
|
setPendingAgentBrain(brain);
|
||||||
|
router.replace({ pathname: "/demo", params: { kind: "agent" } });
|
||||||
|
} catch {
|
||||||
|
setError("Couldn't pull that brain. Try another.");
|
||||||
|
setPulling(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.root}>
|
||||||
|
<Stack.Screen options={{ title: "Pick an agent brain", headerTitleAlign: "center" }} />
|
||||||
|
{loading ? (
|
||||||
|
<View style={styles.center}>
|
||||||
|
<ActivityIndicator color="#ff3b30" />
|
||||||
|
<Text style={styles.dim}>Loading brains…</Text>
|
||||||
|
</View>
|
||||||
|
) : error ? (
|
||||||
|
<View style={styles.center}>
|
||||||
|
<Text style={styles.error}>{error}</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
data={brains}
|
||||||
|
keyExtractor={(b) => `${b.owner}/${b.name}`}
|
||||||
|
contentContainerStyle={styles.list}
|
||||||
|
ListHeaderComponent={
|
||||||
|
<Text style={styles.hint}>Pull a brain from ClawBrainHub to make an agent card.</Text>
|
||||||
|
}
|
||||||
|
renderItem={({ item }) => {
|
||||||
|
const key = `${item.owner}/${item.name}`;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
style={styles.row}
|
||||||
|
disabled={!!pulling}
|
||||||
|
onPress={() => choose(item)}
|
||||||
|
>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={styles.name} numberOfLines={1}>
|
||||||
|
{item.name}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.owner} numberOfLines={1}>
|
||||||
|
@{item.owner} · v{item.version}
|
||||||
|
{item.badge ? ` · ${item.badge}` : ""}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{pulling === key ? (
|
||||||
|
<ActivityIndicator color="#ff3b30" />
|
||||||
|
) : (
|
||||||
|
<Text style={styles.pull}>Pull →</Text>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: { flex: 1, backgroundColor: "#0a0a0c" },
|
||||||
|
center: { flex: 1, alignItems: "center", justifyContent: "center", gap: 12, padding: 32 },
|
||||||
|
dim: { color: "#9a9aa0" },
|
||||||
|
error: { color: "#ff453a", textAlign: "center", fontSize: 15 },
|
||||||
|
list: { padding: 16, gap: 10 },
|
||||||
|
hint: { color: "#9a9aa0", fontSize: 14, marginBottom: 8 },
|
||||||
|
row: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 12,
|
||||||
|
backgroundColor: "#15151a",
|
||||||
|
borderRadius: 14,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 16,
|
||||||
|
},
|
||||||
|
name: { color: "#f5f5f7", fontSize: 16, fontWeight: "700" },
|
||||||
|
owner: { color: "#9a9aa0", fontSize: 13, marginTop: 2 },
|
||||||
|
pull: { color: "#ff3b30", fontWeight: "700", fontSize: 14 },
|
||||||
|
});
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
// ClawBrainHub API (via our backend proxy): list agent brains and pull one,
|
||||||
|
// normalized into card fields (skills/tools/capabilities).
|
||||||
|
|
||||||
|
import { api } from "./client";
|
||||||
|
|
||||||
|
export interface BrainSummary {
|
||||||
|
owner: string;
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
version: string;
|
||||||
|
trustScore?: number | null;
|
||||||
|
badge?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentBrain {
|
||||||
|
owner: string;
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
tagline: string;
|
||||||
|
skills: string[];
|
||||||
|
tools: string[];
|
||||||
|
capabilities: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listBrains(): Promise<BrainSummary[]> {
|
||||||
|
const res = await api.get<BrainSummary[]>("/v1/brainhub/brains", { timeout: 30000 });
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pullBrain(
|
||||||
|
owner: string,
|
||||||
|
name: string,
|
||||||
|
version: string,
|
||||||
|
): Promise<AgentBrain> {
|
||||||
|
const q = `owner=${encodeURIComponent(owner)}&name=${encodeURIComponent(name)}&version=${encodeURIComponent(version)}`;
|
||||||
|
const res = await api.get<AgentBrain>(`/v1/brainhub/pull?${q}`, { timeout: 30000 });
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
@@ -34,7 +34,11 @@ export function CardCollection({
|
|||||||
kind,
|
kind,
|
||||||
);
|
);
|
||||||
const columns = columnsFor(cards.length);
|
const columns = columnsFor(cards.length);
|
||||||
const openNew = () => router.push({ pathname: "/demo", params: { kind } });
|
// Agents start by picking a brain from ClawBrainHub; others go straight to the creator.
|
||||||
|
const openNew = () =>
|
||||||
|
kind === "agent"
|
||||||
|
? router.push("/pick-brain")
|
||||||
|
: router.push({ pathname: "/demo", params: { kind } });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.root}>
|
<View style={styles.root}>
|
||||||
|
|||||||
@@ -2,12 +2,16 @@
|
|||||||
// the card editor picks it up when it regains focus.
|
// the card editor picks it up when it regains focus.
|
||||||
|
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
|
import type { AgentBrain } from "../api/brainhub";
|
||||||
|
|
||||||
interface DraftState {
|
interface DraftState {
|
||||||
pendingImageUri: string | null;
|
pendingImageUri: string | null;
|
||||||
setPendingImageUri: (uri: string | null) => void;
|
setPendingImageUri: (uri: string | null) => void;
|
||||||
pendingVideoUri: string | null;
|
pendingVideoUri: string | null;
|
||||||
setPendingVideoUri: (uri: string | null) => void;
|
setPendingVideoUri: (uri: string | null) => void;
|
||||||
|
/** Brain pulled from ClawBrainHub, awaiting the agent-card editor. */
|
||||||
|
pendingAgentBrain: AgentBrain | null;
|
||||||
|
setPendingAgentBrain: (b: AgentBrain | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useDraftStore = create<DraftState>((set) => ({
|
export const useDraftStore = create<DraftState>((set) => ({
|
||||||
@@ -15,4 +19,6 @@ export const useDraftStore = create<DraftState>((set) => ({
|
|||||||
setPendingImageUri: (uri) => set({ pendingImageUri: uri }),
|
setPendingImageUri: (uri) => set({ pendingImageUri: uri }),
|
||||||
pendingVideoUri: null,
|
pendingVideoUri: null,
|
||||||
setPendingVideoUri: (uri) => set({ pendingVideoUri: uri }),
|
setPendingVideoUri: (uri) => set({ pendingVideoUri: uri }),
|
||||||
|
pendingAgentBrain: null,
|
||||||
|
setPendingAgentBrain: (b) => set({ pendingAgentBrain: b }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user