use anyhow::Result;
#[cfg(feature = "documents")]
use asobi::embed::EmbeddingProvider;
use asobi::paths::AsobiPaths;
use clap::{Parser, Subcommand};
#[cfg(feature = "documents")]
use std::sync::Arc;
use tracing::{info, warn};
use tracing_subscriber::EnvFilter;
#[derive(Parser)]
#[command(name = "asobi")]
#[command(version)]
#[command(about = "Asobi: Knowledge Graph & Memory CLI", long_about = None)]
struct Cli {
#[command(subcommand)]
command: Commands,
#[arg(long, global = true)]
json: bool,
}
#[derive(Subcommand)]
enum Commands {
#[cfg(feature = "documents")]
Ingest {
path: String,
},
#[cfg(feature = "documents")]
Query {
query: String,
#[arg(long, default_value_t = 5)]
limit: usize,
#[arg(long)]
json: bool,
},
New {
#[arg(num_args = 2.., value_names = ["NAME", "TYPE"])]
pairs: Vec<String>,
#[arg(long = "obs", value_name = "OBSERVATION")]
observations: Vec<String>,
},
Link {
#[arg(num_args = 3.., value_names = ["FROM", "TO", "TYPE"])]
triples: Vec<String>,
},
Obs {
name: String,
#[arg(num_args = 1..)]
contents: Vec<String>,
},
Truth {
name: String,
key: String,
value: String,
},
RmTruth { name: String, key: String },
Rm { names: Vec<String> },
RmObs {
name: String,
content: String,
#[arg(long)]
id: bool,
},
UpdateObs {
name: String,
old_content: String,
new_content: String,
#[arg(long)]
id: bool,
},
Unlink {
from: String,
to: String,
relation_type: String,
},
Graph,
Search {
query: Option<String>,
#[arg(long, default_value_t = asobi::db::DEFAULT_SEARCH_LIMIT)]
limit: usize,
#[arg(long = "where", value_name = "KEY=VALUE")]
filters: Vec<String>,
},
Show {
names: Vec<String>,
#[arg(long, value_name = "RELATION_TYPE")]
expand: Vec<String>,
#[arg(long)]
with_ids: bool,
},
#[cfg(feature = "documents")]
Compact {
#[arg(long, default_value = "90")]
older_than: u32,
},
Init {
#[arg(long)]
local: bool,
},
Stats {
#[arg(long)]
per_entity: bool,
},
Export {
#[arg(short, long)]
output: Option<String>,
},
Import {
file: String,
},
Reset {
#[arg(long)]
force: bool,
},
Backup {
#[arg(short, long)]
output: Option<String>,
#[arg(long, default_value_t = 3)]
keep: usize,
},
Restore {
file: String,
#[arg(long)]
force: bool,
},
Skills {
#[command(subcommand)]
subcommand: Option<SkillsCommands>,
},
}
#[derive(Subcommand, Debug)]
enum SkillsCommands {
Install {
source: String,
#[arg(long)]
all: bool,
#[arg(long, num_args = 1..)]
select: Option<Vec<String>>,
},
Update {
source: Option<String>,
},
Remove {
target: String,
},
Show {
name: String,
},
}
#[cfg(feature = "documents")]
fn needs_vector(cmd: &Commands) -> bool {
matches!(
cmd,
Commands::Ingest { .. } | Commands::Query { .. } | Commands::Compact { .. }
)
}
#[cfg(not(feature = "documents"))]
fn needs_vector(_: &Commands) -> bool {
false
}
pub const ENV_FASTEMBED_CACHE_DIR: &str = "ASOBI_FASTEMBED_CACHE_DIR";
pub const ENV_TOPICS_DIR: &str = "ASOBI_TOPICS_DIR";
#[cfg(feature = "documents")]
async fn init_vector(
conn: libsql::Connection,
paths: &AsobiPaths,
) -> Result<(
asobi::vector::VectorStore,
Arc<asobi::embed::FastEmbedProvider>,
)> {
let store = asobi::vector::VectorStore::new(conn);
let cache_dir = std::env::var(ENV_FASTEMBED_CACHE_DIR)
.map(std::path::PathBuf::from)
.unwrap_or_else(|_| paths.data_dir.join("fastembed_cache"));
let embedder: Arc<asobi::embed::FastEmbedProvider> =
Arc::new(asobi::embed::FastEmbedProvider::new(cache_dir)?);
if store.dim() != embedder.dim() {
anyhow::bail!(
"Vector store dimension mismatch: store={}, embedder={}",
store.dim(),
embedder.dim()
);
}
Ok((store, embedder))
}
fn init_tracing() {
tracing_subscriber::fmt()
.with_writer(std::io::stderr)
.with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
)
.with_target(false)
.compact()
.init();
}
fn ensure_git_available() -> Result<()> {
match std::process::Command::new("git").arg("--version").output() {
Ok(output) if output.status.success() => Ok(()),
Ok(_) => anyhow::bail!("`git --version` failed; ensure git is installed and on PATH"),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => anyhow::bail!(
"`git` not found on PATH — install git to install or update skills from a remote repository"
),
Err(e) => anyhow::bail!("failed to invoke git: {e}"),
}
}
fn validate_git_url(git_url: &str) -> Result<()> {
if git_url.starts_with('-') {
anyhow::bail!("Invalid git URL: URLs must not start with '-'");
}
let has_allowed_scheme = ["https://", "ssh://", "git://", "file://"]
.iter()
.any(|scheme| git_url.starts_with(scheme));
let is_scp_style = git_url.starts_with("git@") && git_url.contains(':');
if !has_allowed_scheme && !is_scp_style {
anyhow::bail!(
"Unsupported git URL '{}'; use https://, ssh://, git://, file://, or git@host:path",
git_url
);
}
Ok(())
}
fn get_or_update_cached_repo(
git_url: &str,
caches_dir: &std::path::Path,
) -> Result<(std::path::PathBuf, String)> {
ensure_git_available()?;
validate_git_url(git_url)?;
let slug = asobi::skills::derive_source_slug(git_url);
let repo_cache_dir = caches_dir.join(&slug);
std::fs::create_dir_all(caches_dir)?;
if repo_cache_dir.exists() {
info!("Updating cached repository in {:?}...", repo_cache_dir);
let fetch_status = std::process::Command::new("git")
.arg("fetch")
.arg("--depth")
.arg("1")
.current_dir(&repo_cache_dir)
.status();
let mut success = false;
if let Ok(status) = fetch_status
&& status.success()
{
let reset_status = std::process::Command::new("git")
.arg("reset")
.arg("--hard")
.arg("origin/HEAD")
.current_dir(&repo_cache_dir)
.status();
if let Ok(status) = reset_status
&& status.success()
{
success = true;
}
}
if !success {
info!(
"Failed to update existing cache, re-cloning to {:?}...",
repo_cache_dir
);
let _ = std::fs::remove_dir_all(&repo_cache_dir);
let clone_status = std::process::Command::new("git")
.arg("clone")
.arg("--depth")
.arg("1")
.arg("--")
.arg(git_url)
.arg(&repo_cache_dir)
.status()?;
if !clone_status.success() {
anyhow::bail!("Failed to clone repository from {}", git_url);
}
}
} else {
info!("Cloning {} to {:?}...", git_url, repo_cache_dir);
let clone_status = std::process::Command::new("git")
.arg("clone")
.arg("--depth")
.arg("1")
.arg("--")
.arg(git_url)
.arg(&repo_cache_dir)
.status()?;
if !clone_status.success() {
anyhow::bail!("Failed to clone repository from {}", git_url);
}
}
let output = std::process::Command::new("git")
.arg("rev-parse")
.arg("HEAD")
.current_dir(&repo_cache_dir)
.output()?;
let version = if output.status.success() {
String::from_utf8_lossy(&output.stdout).trim().to_string()
} else {
"unknown".to_string()
};
Ok((repo_cache_dir, version))
}
#[tokio::main]
async fn main() {
init_tracing();
let cli = Cli::parse();
let json = cli.json;
if let Err(e) = run_cli(cli).await {
if json {
let error_json = serde_json::json!({
"status": "failed",
"error": e.to_string()
});
println!("{}", serde_json::to_string_pretty(&error_json).unwrap());
} else {
eprintln!("Error: {:?}", e);
}
std::process::exit(1);
}
}
async fn run_cli(cli: Cli) -> Result<()> {
if let Commands::Init { local } = cli.command {
let cwd = std::env::current_dir()?;
let target = if local {
asobi::init::InitTarget::Local
} else {
asobi::init::InitTarget::Xdg
};
let report = asobi::init::init_workspace(target, &cwd)?;
print_init_report(&report);
return Ok(());
}
let paths = AsobiPaths::resolve();
let (db, conn) = asobi::db::init_db().await?;
if needs_vector(&cli.command) {
#[cfg(feature = "documents")]
{
let (store, embedder) = init_vector(conn, &paths).await?;
match cli.command {
Commands::Ingest { path } => {
let p = std::path::Path::new(&path);
if p.is_dir() {
info!("Ingesting directory: {:?}...", p);
let count =
asobi::ingest::ingest_dir(p, store.conn(), &store, embedder.as_ref())
.await?;
info!("Done. Ingested {} files.", count);
} else {
info!("Ingesting file: {:?}...", p);
asobi::ingest::ingest_file(p, store.conn(), &store, embedder.as_ref())
.await?;
info!("Done.");
}
}
Commands::Query { query, limit, json } => {
info!("Searching: {}...", query);
let results = asobi::recall::recall(
&query,
store.conn(),
&store,
embedder.as_ref(),
limit,
)
.await?;
if json {
println!("{}", serde_json::to_string_pretty(&results)?);
} else if results.is_empty() {
info!("No results found.");
} else {
for r in results {
println!(
"{:<20} | (score: {:.2}) | {}",
r.title, r.score, r.file_path
);
}
}
}
Commands::Compact { older_than } => {
let topics_root = std::env::var(ENV_TOPICS_DIR)
.unwrap_or_else(|_| paths.topics_dir.to_str().unwrap().to_string());
let pruned = asobi::compact::prune_old_sessions(&topics_root, older_than)?;
info!("Pruned {} old session files.", pruned);
let clusters =
asobi::compact::find_duplicate_clusters(&store, store.conn(), 0.85).await?;
info!("Found {} near-duplicate topic clusters.", clusters.len());
for (i, cluster) in clusters.iter().enumerate() {
info!(" Cluster {}: {}", i + 1, cluster.join(", "));
}
info!("Syncing Graph to Markdown...");
let synced = asobi::compact::sync_graph_to_markdown(
store.conn(),
&store,
embedder.as_ref(),
)
.await?;
info!("Done. Synced {} entities to Markdown.", synced);
}
_ => unreachable!(),
}
}
return Ok(());
}
let json = cli.json;
match cli.command {
Commands::New {
pairs,
observations,
} => {
if pairs.is_empty() || pairs.len() % 2 != 0 {
anyhow::bail!(
"new expects one or more `NAME TYPE` pairs (got {} arguments)",
pairs.len()
);
}
let entities: Vec<asobi::model::EntityInput> = pairs
.chunks_exact(2)
.map(|c| asobi::model::EntityInput {
name: c[0].clone(),
entity_type: c[1].clone(),
observations: observations.clone(),
})
.collect();
let names: Vec<String> = entities.iter().map(|e| e.name.clone()).collect();
asobi::db::create_entities(&conn, entities).await?;
info!("{} entit{} created.", names.len(), plural(names.len()));
if json {
emit_nodes(&conn, names).await?;
}
}
Commands::Link { triples } => {
if triples.is_empty() || triples.len() % 3 != 0 {
anyhow::bail!(
"link expects one or more `FROM TO TYPE` triples (got {} arguments)",
triples.len()
);
}
let relations: Vec<asobi::model::RelationInput> = triples
.chunks_exact(3)
.map(|c| asobi::model::RelationInput {
from: c[0].clone(),
to: c[1].clone(),
relation_type: c[2].clone(),
})
.collect();
let involved: Vec<String> = relations
.iter()
.flat_map(|r| [r.from.clone(), r.to.clone()])
.collect();
let count = relations.len();
asobi::db::create_relations(&conn, relations).await?;
info!("{} relation{} created.", count, suffix(count));
if json {
emit_nodes(&conn, involved).await?;
}
}
Commands::Obs { name, contents } => {
let paths = asobi::paths::AsobiPaths::resolve();
let limit = std::env::var(asobi::constant::ENV_OBSERVATION_LIMIT)
.ok()
.and_then(|v| v.parse::<usize>().ok())
.unwrap_or(
paths
.observation_limit
.unwrap_or(asobi::constant::DEFAULT_OBSERVATION_LIMIT),
);
asobi::db::add_observations(
&conn,
vec![asobi::model::ObservationInput {
entity_name: name.clone(),
contents,
}],
limit,
)
.await?;
info!("Observation added.");
if json {
emit_nodes(&conn, vec![name]).await?;
}
}
Commands::Truth { name, key, value } => {
asobi::db::truth_upsert(&conn, &name, &key, &value).await?;
info!("Truth added.");
if json {
emit_nodes(&conn, vec![name]).await?;
}
}
Commands::RmTruth { name, key } => {
asobi::db::truth_delete(&conn, &name, &key).await?;
info!("Truth deleted.");
if json {
emit_nodes(&conn, vec![name]).await?;
}
}
Commands::Rm { names } => {
let deleted = names.clone();
asobi::db::delete_entities(&conn, names).await?;
info!("Entities deleted.");
if json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({ "deleted": deleted }))?
);
}
}
Commands::RmObs { name, content, id } => {
if id {
let parsed_id = content.parse::<i64>().map_err(|_| {
anyhow::anyhow!(
"Invalid observation ID: '{}'. Expected an integer.",
content
)
})?;
asobi::db::delete_observation_by_id(&conn, &name, parsed_id).await?;
} else {
asobi::db::delete_observations(
&conn,
vec![asobi::model::ObservationDeletion {
entity_name: name.clone(),
observations: vec![content],
}],
)
.await?;
}
info!("Observations deleted.");
if json {
emit_nodes(&conn, vec![name]).await?;
}
}
Commands::UpdateObs {
name,
old_content,
new_content,
id,
} => {
if id {
let parsed_id = old_content.parse::<i64>().map_err(|_| {
anyhow::anyhow!(
"Invalid observation ID: '{}'. Expected an integer.",
old_content
)
})?;
asobi::db::update_observation_by_id(&conn, &name, parsed_id, &new_content).await?;
} else {
asobi::db::update_observation(&conn, &name, &old_content, &new_content).await?;
}
info!("Observation updated.");
if json {
emit_nodes(&conn, vec![name]).await?;
}
}
Commands::Unlink {
from,
to,
relation_type,
} => {
asobi::db::delete_relations(
&conn,
vec![asobi::model::RelationInput {
from: from.clone(),
to: to.clone(),
relation_type,
}],
)
.await?;
info!("Relations deleted.");
if json {
emit_nodes(&conn, vec![from, to]).await?;
}
}
Commands::Graph => {
let graph = asobi::db::read_graph(&conn).await?;
println!("{}", serde_json::to_string_pretty(&graph)?);
}
Commands::Search {
query,
limit,
filters,
} => {
let mut parsed_filters = Vec::new();
for f in &filters {
if let Some((k, v)) = f.split_once('=') {
parsed_filters.push((k.trim().to_string(), v.trim().to_string()));
} else {
anyhow::bail!("Invalid filter format: '{}'. Expected KEY=VALUE.", f);
}
}
let query_str = query.unwrap_or_default();
let graph =
asobi::db::search_nodes_with_limit(&conn, &query_str, limit, &parsed_filters)
.await?;
println!("{}", serde_json::to_string_pretty(&graph)?);
}
Commands::Show {
names,
expand,
with_ids,
} => {
let graph = asobi::db::open_nodes_detailed(&conn, names, with_ids, &expand).await?;
println!("{}", serde_json::to_string_pretty(&graph)?);
}
Commands::Stats { per_entity } => {
let paths = asobi::paths::AsobiPaths::resolve();
let db_path = std::env::var(asobi::constant::ENV_DATABASE_URL)
.unwrap_or_else(|_| paths.db_path().to_str().unwrap().to_string());
let mut rows = conn.query("PRAGMA journal_mode", ()).await?;
let journal_mode = if let Some(row) = rows.next().await? {
row.get::<String>(0)?
} else {
"unknown".to_string()
};
let (entities, relations, observations) = asobi::db::stats(&conn).await?;
if json {
let mut stats_json = serde_json::json!({
"entities": entities,
"relations": relations,
"observations": observations,
"databasePath": db_path,
"journalMode": journal_mode
});
if per_entity {
let paths = asobi::paths::AsobiPaths::resolve();
let limit = std::env::var(asobi::constant::ENV_OBSERVATION_LIMIT)
.ok()
.and_then(|v| v.parse::<usize>().ok())
.unwrap_or(
paths
.observation_limit
.unwrap_or(asobi::constant::DEFAULT_OBSERVATION_LIMIT),
);
let list = asobi::db::stats_per_entity(&conn).await?;
let detailed: Vec<serde_json::Value> = list
.iter()
.map(|(name, count)| {
let pct = if limit > 0 {
(*count as f64 / limit as f64) * 100.0
} else {
0.0
};
serde_json::json!({
"name": name,
"observationCount": count,
"limit": limit,
"percentage": pct,
"critical": limit > 0 && *count >= (limit * 80 / 100)
})
})
.collect();
stats_json["entitiesDetailed"] = serde_json::json!(detailed);
}
println!("{}", serde_json::to_string_pretty(&stats_json)?);
} else {
println!("Database Path: {}", db_path);
println!("Journal Mode: {}", journal_mode);
println!("Knowledge Graph Statistics:");
println!(" Entities: {}", entities);
println!(" Relations: {}", relations);
println!(" Observations: {}", observations);
if per_entity {
let paths = asobi::paths::AsobiPaths::resolve();
let limit = std::env::var(asobi::constant::ENV_OBSERVATION_LIMIT)
.ok()
.and_then(|v| v.parse::<usize>().ok())
.unwrap_or(
paths
.observation_limit
.unwrap_or(asobi::constant::DEFAULT_OBSERVATION_LIMIT),
);
let list = asobi::db::stats_per_entity(&conn).await?;
if !list.is_empty() {
println!("\nEntities by Observation Count:");
for (name, count) in &list {
let pct = if limit > 0 {
(*count as f64 / limit as f64) * 100.0
} else {
0.0
};
if limit > 0 && *count >= (limit * 80 / 100) {
println!(
" {:_<40} {} / {} (CRITICAL: {:.1}%)",
name, count, limit, pct
);
} else {
println!(" {:_<40} {}", name, count);
}
}
}
}
}
}
Commands::Export { output } => {
let graph = asobi::db::read_graph_eager(&conn).await?;
let json = serde_json::to_string_pretty(&graph)?;
if let Some(path) = output {
std::fs::write(&path, json)?;
asobi::backup::restrict_permissions(std::path::Path::new(&path), 0o600)?;
info!("Graph exported to {}", path);
} else {
println!("{}", json);
}
}
Commands::Import { file } => {
let content = std::fs::read_to_string(&file)?;
let graph: asobi::model::Graph = serde_json::from_str(&content)?;
let had_entities = !graph.entities.is_empty();
let had_relations = !graph.relations.is_empty();
import_graph(&conn, graph).await?;
if had_entities {
info!("Imported entities, observations, and truths.");
}
if had_relations {
info!("Imported relations.");
}
info!("Import complete.");
}
Commands::Reset { force } => {
if !force {
use std::io::Write;
print!("Are you sure you want to completely clear the knowledge graph? [y/N]: ");
std::io::stdout().flush()?;
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
if input.trim().to_lowercase() != "y" {
info!("Reset aborted.");
return Ok(());
}
}
asobi::db::reset(&conn).await?;
info!("Knowledge graph reset successfully.");
}
Commands::Backup { output, keep } => {
let dest =
asobi::backup::backup(&conn, output.map(std::path::PathBuf::from), keep).await?;
info!("Backup written to {}", dest.display());
}
Commands::Restore { file, force } => {
asobi::backup::restore(db, conn, std::path::Path::new(&file), force).await?;
}
Commands::Skills { subcommand } => {
use std::io::IsTerminal;
match subcommand {
None => {
let skills = asobi::db::list_skills(&conn).await?;
if skills.is_empty() {
println!("No skills installed.");
} else {
let mut grouped: std::collections::BTreeMap<
String,
Vec<asobi::db::SkillRow>,
> = std::collections::BTreeMap::new();
for s in skills {
grouped.entry(s.source.clone()).or_default().push(s);
}
println!("Installed Skills:");
for (source, list) in grouped {
println!("Source: {}", source);
for s in list {
println!(" {} · {} · {}", s.entity_name, s.description, s.version);
}
}
}
}
Some(SkillsCommands::Install {
source,
all,
select,
}) => {
let mut git_url = source.clone();
let is_git = if source.contains("://") || source.contains("git@") {
true
} else if source.contains("github.com/") || source.contains("gitlab.com/") {
git_url = format!("https://{}", source);
true
} else {
!std::path::Path::new(&source).is_dir() && source.ends_with(".git")
};
let (target_path, version) = if is_git {
let (cache_path, ver) =
get_or_update_cached_repo(&git_url, &paths.caches_dir())?;
(cache_path, ver)
} else {
let local_path = std::path::Path::new(&source);
if !local_path.exists() {
anyhow::bail!("Local path {} does not exist", source);
}
(local_path.to_path_buf(), "local".to_string())
};
let mode = if all {
asobi::skills::SelectionMode::All
} else if let Some(sel) = select {
asobi::skills::SelectionMode::Select(sel)
} else {
asobi::skills::SelectionMode::Interactive
};
let is_tty = std::io::stdin().is_terminal();
#[cfg(feature = "documents")]
let (store, embedder) = init_vector(conn.clone(), &paths).await?;
#[cfg(feature = "documents")]
let vector_ctx = Some((&store, embedder.as_ref()));
let prune = matches!(mode, asobi::skills::SelectionMode::All);
asobi::skills::install_skills_from_dir(
&conn,
&target_path,
&git_url,
&version,
mode,
is_tty,
prune,
#[cfg(feature = "documents")]
vector_ctx,
)
.await?;
info!("Skills installed successfully.");
}
Some(SkillsCommands::Update { source }) => {
#[cfg(feature = "documents")]
let (store, embedder) = init_vector(conn.clone(), &paths).await?;
#[cfg(feature = "documents")]
let vector_ctx = Some((&store, embedder.as_ref()));
let skills = asobi::db::list_skills(&conn).await?;
let mut unique_sources = std::collections::HashSet::new();
for s in skills {
if let Some(ref filter_src) = source {
let slug = asobi::skills::derive_source_slug(&s.source);
if &s.source == filter_src || &slug == filter_src {
unique_sources.insert(s.source.clone());
}
} else {
unique_sources.insert(s.source.clone());
}
}
if unique_sources.is_empty() {
if let Some(src_val) = source {
anyhow::bail!(
"No installed skills found matching source/slug {:?}",
src_val
);
} else {
info!("No skills currently installed.");
return Ok(());
}
}
for src in unique_sources {
info!("Updating skills from {}...", src);
let mut git_url = src.clone();
let is_git = if src.contains("://") || src.contains("git@") {
true
} else if src.contains("github.com/") || src.contains("gitlab.com/") {
git_url = format!("https://{}", src);
true
} else {
!std::path::Path::new(&src).is_dir() && src.ends_with(".git")
};
let (target_path, version) = if is_git {
let (cache_path, ver) =
get_or_update_cached_repo(&git_url, &paths.caches_dir())?;
(cache_path, ver)
} else {
let local_path = std::path::Path::new(&src);
if !local_path.exists() {
warn!("Local path {} does not exist, skipping update", src);
continue;
}
(local_path.to_path_buf(), "local".to_string())
};
asobi::skills::install_skills_from_dir(
&conn,
&target_path,
&git_url,
&version,
asobi::skills::SelectionMode::All,
false,
true,
#[cfg(feature = "documents")]
vector_ctx,
)
.await?;
info!("Successfully updated skills from {}.", src);
}
}
Some(SkillsCommands::Remove { target }) => {
let skills = asobi::db::list_skills(&conn).await?;
let mut entities_to_delete = Vec::new();
for s in skills {
let slug = asobi::skills::derive_source_slug(&s.source);
if s.entity_name == target || s.source == target || slug == target {
entities_to_delete.push(s.entity_name.clone());
}
}
if !entities_to_delete.is_empty() {
info!("Deleting {} skill entities...", entities_to_delete.len());
asobi::db::delete_entities(&conn, entities_to_delete).await?;
info!("Skills removed successfully.");
} else if target.starts_with("skill:") {
info!("Deleting skill entity {}...", target);
asobi::db::delete_entities(&conn, vec![target.clone()]).await?;
info!("Skills removed successfully.");
} else {
anyhow::bail!("No installed skills found matching target {:?}", target);
}
}
Some(SkillsCommands::Show { name }) => {
let mut entity_name = name.clone();
if !entity_name.starts_with("skill:") {
let skills = asobi::db::list_skills(&conn).await?;
let matches: Vec<_> = skills
.iter()
.filter(|s| {
s.entity_name == name
|| s.entity_name.ends_with(&format!(":{}", name))
})
.collect();
if matches.len() == 1 {
entity_name = matches[0].entity_name.clone();
} else if matches.len() > 1 {
anyhow::bail!(
"Ambiguous skill name '{}'. Matches: {}",
name,
matches
.iter()
.map(|s| &s.entity_name)
.cloned()
.collect::<Vec<_>>()
.join(", ")
);
} else {
entity_name = format!("skill:{}", name);
}
}
match asobi::db::skill_body(&conn, &entity_name).await? {
Some(body) => {
print!("{}", body);
}
None => {
anyhow::bail!("Skill '{}' not found", name);
}
}
}
}
}
_ => unreachable!(),
}
Ok(())
}
async fn emit_nodes(conn: &libsql::Connection, names: Vec<String>) -> Result<()> {
let graph = asobi::db::open_nodes(conn, names).await?;
println!("{}", serde_json::to_string_pretty(&graph)?);
Ok(())
}
async fn import_graph(conn: &libsql::Connection, graph: asobi::model::Graph) -> Result<()> {
let mut entities = Vec::with_capacity(graph.entities.len());
let mut truths = Vec::new();
for entity in graph.entities {
let name = entity.name;
truths.extend(
entity
.truths
.into_iter()
.map(|(key, value)| (name.clone(), key, value)),
);
entities.push(asobi::model::EntityInput {
name,
entity_type: entity.entity_type,
observations: entity.observations,
});
}
if !entities.is_empty() {
asobi::db::create_entities(conn, entities).await?;
for (name, key, value) in truths {
asobi::db::truth_upsert(conn, &name, &key, &value).await?;
}
}
if !graph.relations.is_empty() {
asobi::db::create_relations(conn, graph.relations).await?;
}
Ok(())
}
fn suffix(n: usize) -> &'static str {
if n == 1 { "" } else { "s" }
}
fn plural(n: usize) -> &'static str {
if n == 1 { "y" } else { "ies" }
}
fn print_init_report(report: &asobi::init::InitReport) {
let label = match report.target {
asobi::init::InitTarget::Xdg => "Initialised Asobi workspace (XDG)",
asobi::init::InitTarget::Local => "Initialised Asobi workspace (project-local)",
};
println!("{}", label);
for dir in &report.created_dirs {
println!(" created {}", dir.display());
}
for dir in &report.skipped_dirs {
println!(" exists {}", dir.display());
}
if let Some(path) = &report.wrote_config {
println!(" wrote {}", path.display());
} else if let Some(path) = &report.config_existed {
println!(" exists {}", path.display());
}
}
#[cfg(test)]
mod tests {
use super::{import_graph, validate_git_url};
use tempfile::tempdir;
#[test]
fn git_url_validator_rejects_option_and_command_urls() {
assert!(validate_git_url("-upload-pack=x").is_err());
assert!(validate_git_url("ext::sh -c id").is_err());
}
#[test]
fn git_url_validator_accepts_supported_urls() {
for url in [
"https://example.com/repo.git",
"ssh://example.com/repo.git",
"git://example.com/repo.git",
"file:///tmp/repo",
"git@example.com:repo.git",
] {
assert!(validate_git_url(url).is_ok(), "expected valid URL: {url}");
}
}
#[tokio::test]
async fn import_graph_round_trips_truths() {
let dir = tempdir().unwrap();
unsafe {
std::env::set_var(
asobi::constant::ENV_DATABASE_URL,
dir.path().join("test.db").to_str().unwrap(),
);
}
let (_db, conn) = asobi::db::init_db().await.unwrap();
asobi::db::create_entities(
&conn,
vec![asobi::model::EntityInput {
name: "project".to_string(),
entity_type: "task".to_string(),
observations: vec!["ship it".to_string()],
}],
)
.await
.unwrap();
asobi::db::truth_upsert(&conn, "project", "status", "READY")
.await
.unwrap();
let exported = asobi::db::read_graph_eager(&conn).await.unwrap();
asobi::db::reset(&conn).await.unwrap();
import_graph(&conn, exported).await.unwrap();
let imported = asobi::db::read_graph_eager(&conn).await.unwrap();
assert_eq!(
imported.entities[0].truths.get("status"),
Some(&"READY".to_string())
);
}
}