mod commands;
mod output;
use clap::{Parser, Subcommand};
use ed25519_dalek::SigningKey;
use noether_core::capability::Capability;
use noether_core::effects::EffectKind;
use noether_core::stdlib::load_stdlib;
use noether_engine::checker::{CapabilityPolicy, EffectPolicy};
use noether_engine::index::{IndexConfig, SemanticIndex};
use noether_engine::providers;
use noether_engine::registry_client::RemoteStageStore;
use noether_engine::trace::JsonFileTraceStore;
use noether_store::{JsonFileStore, StageStore};
#[derive(Parser)]
#[command(name = "noether", about = "Agent-native verified composition platform")]
struct Cli {
#[arg(long, global = true, env = "NOETHER_REGISTRY")]
registry: Option<String>,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Introspect,
Version,
Stage {
#[command(subcommand)]
command: StageCommands,
},
Store {
#[command(subcommand)]
command: StoreCommands,
},
Run {
graph: String,
#[arg(long)]
dry_run: bool,
#[arg(long)]
input: Option<String>,
#[arg(long)]
allow_capabilities: Option<String>,
#[arg(long)]
allow_effects: Option<String>,
#[arg(long)]
budget_cents: Option<u64>,
},
Trace {
composition_id: String,
},
Build {
graph: String,
#[arg(short, long, default_value = "./noether-app")]
output: String,
#[arg(long)]
name: Option<String>,
#[arg(long)]
description: Option<String>,
#[arg(long, default_value = "native")]
target: String,
#[arg(long)]
serve: Option<String>,
},
Serve {
graph: String,
#[arg(long, short, default_value = ":8080")]
port: String,
},
Compose {
problem: String,
#[arg(long)]
model: Option<String>,
#[arg(long)]
dry_run: bool,
#[arg(long)]
input: Option<String>,
#[arg(long)]
force: bool,
#[arg(long)]
allow_capabilities: Option<String>,
#[arg(long)]
budget_cents: Option<u64>,
#[arg(long, short)]
verbose: bool,
},
}
#[derive(Subcommand)]
enum StageCommands {
Search {
query: String,
#[arg(long, value_name = "TAG")]
tag: Option<String>,
},
Add {
spec: String,
#[arg(long)]
draft: bool,
},
Sync {
directory: String,
#[arg(long)]
draft: bool,
},
List {
#[arg(long, value_name = "TAG")]
tag: Option<String>,
#[arg(long, value_name = "WHO")]
signed_by: Option<String>,
#[arg(long, value_name = "STATE")]
lifecycle: Option<String>,
#[arg(long)]
full_ids: bool,
},
Get {
hash: String,
},
Activate {
hash: String,
},
Test {
hash: Option<String>,
},
}
#[derive(Subcommand)]
enum StoreCommands {
Stats,
Retro {
#[arg(long)]
dry_run: bool,
#[arg(long)]
apply: bool,
#[arg(long, default_value_t = 0.92)]
threshold: f32,
},
Dedup {
#[arg(long, default_value_t = 0.90)]
threshold: f32,
#[arg(long)]
apply: bool,
},
MigrateEffects {
#[arg(long)]
dry_run: bool,
},
Health,
}
fn read_stdin_input() -> Option<serde_json::Value> {
use std::io::{IsTerminal, Read};
let stdin = std::io::stdin();
if stdin.is_terminal() {
return None;
}
let mut buf = String::new();
if stdin.lock().read_to_string(&mut buf).is_err() {
return None;
}
let trimmed = buf.trim();
if trimmed.is_empty() {
return None;
}
Some(
serde_json::from_str(trimmed).unwrap_or_else(|_| serde_json::Value::String(trimmed.into())),
)
}
fn noether_dir() -> std::path::PathBuf {
if let Ok(dir) = std::env::var("NOETHER_HOME") {
std::path::PathBuf::from(dir)
} else {
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
std::path::PathBuf::from(home).join(".noether")
}
}
fn build_store(registry: Option<&str>) -> Box<dyn StageStore> {
if let Some(url) = registry {
let api_key = std::env::var("NOETHER_API_KEY").ok();
match RemoteStageStore::connect(url, api_key.as_deref()) {
Ok(remote) => {
eprintln!(
"Connected to remote registry at {} ({} stages cached)",
remote.base_url(),
remote.list(None).len()
);
return Box::new(remote);
}
Err(e) => {
eprintln!("Warning: could not connect to registry at {url}: {e}");
eprintln!("Falling back to local store.");
}
}
}
Box::new(init_local_store())
}
fn init_local_store() -> JsonFileStore {
let store_path = noether_dir().join("store.json");
let mut store = JsonFileStore::open(&store_path).unwrap_or_else(|e| {
eprintln!(
"Warning: failed to open store at {}: {e}",
store_path.display()
);
eprintln!("Using empty store.");
JsonFileStore::open("/dev/null").unwrap()
});
let stdlib_ids: std::collections::HashSet<String> =
load_stdlib().iter().map(|s| s.id.0.clone()).collect();
for stage in load_stdlib() {
let already_tombstoned = store
.get(&stage.id)
.ok()
.flatten()
.map(|s| matches!(s.lifecycle, noether_core::stage::StageLifecycle::Tombstone))
.unwrap_or(false);
if !already_tombstoned {
let _ = store.upsert(stage);
}
}
let unsigned_non_stdlib: Vec<noether_core::stage::StageId> = store
.list(None)
.iter()
.filter(|s| s.ed25519_signature.is_none() && !stdlib_ids.contains(&s.id.0))
.map(|s| s.id.clone())
.collect();
if !unsigned_non_stdlib.is_empty() {
eprintln!(
"init_store: removing {} unsigned legacy synthesized stage(s) from store",
unsigned_non_stdlib.len()
);
for id in &unsigned_non_stdlib {
let _ = store.remove(id);
}
}
store
}
fn init_trace_store() -> JsonFileTraceStore {
let trace_path = noether_dir().join("traces.json");
JsonFileTraceStore::open(&trace_path).unwrap_or_else(|e| {
eprintln!("Warning: failed to open trace store: {e}");
JsonFileTraceStore::open("/tmp/noether-traces.json").unwrap()
})
}
fn load_or_create_author_key(dir: &std::path::Path) -> SigningKey {
use rand::rngs::OsRng;
let key_path = dir.join("author-key.hex");
if key_path.exists() {
let hex = std::fs::read_to_string(&key_path).unwrap_or_default();
let bytes = hex::decode(hex.trim()).unwrap_or_default();
if bytes.len() == 32 {
let arr: [u8; 32] = bytes.try_into().expect("checked length");
return SigningKey::from_bytes(&arr);
}
eprintln!(
"Warning: author key at {} is corrupt — regenerating.",
key_path.display()
);
}
let key = SigningKey::generate(&mut OsRng);
let hex = hex::encode(key.to_bytes());
if let Err(e) = std::fs::create_dir_all(dir).and_then(|_| std::fs::write(&key_path, &hex)) {
eprintln!(
"Warning: could not save author key to {}: {e}",
key_path.display()
);
} else {
eprintln!(
"Generated new author signing key → {}\n\
Public key: {}\n\
Back this file up — stages you sign are tied to it.",
key_path.display(),
hex::encode(key.verifying_key().to_bytes()),
);
}
key
}
fn build_index(store: &dyn StageStore, loud: bool) -> SemanticIndex {
let verbose = loud || std::env::var("NOETHER_VERBOSE").is_ok();
let (provider, name) = providers::build_embedding_provider();
if verbose && name != "mock" {
eprintln!("Embedding provider: {name}");
}
if name == "mock" {
SemanticIndex::build(store, provider, IndexConfig::default()).unwrap()
} else {
let cache_path = noether_dir().join("embeddings.json");
let cached =
noether_engine::index::cache::CachedEmbeddingProvider::new(provider, cache_path);
SemanticIndex::build_cached(store, cached, IndexConfig::default()).unwrap_or_else(|e| {
if verbose {
eprintln!("Warning: embedding provider unavailable ({e}), using mock index.");
}
let mock = noether_engine::index::embedding::MockEmbeddingProvider::new(128);
SemanticIndex::build(store, Box::new(mock), IndexConfig::default()).unwrap()
})
}
}
fn parse_capability_policy(raw: Option<&str>) -> CapabilityPolicy {
match raw {
None => CapabilityPolicy::allow_all(),
Some(s) => {
let caps = s.split(',').filter_map(|token| match token.trim() {
"network" => Some(Capability::Network),
"fs-read" => Some(Capability::FsRead),
"fs-write" => Some(Capability::FsWrite),
"gpu" => Some(Capability::Gpu),
"llm" => Some(Capability::Llm),
other => {
eprintln!("Warning: unknown capability '{other}', ignoring");
None
}
});
CapabilityPolicy::restrict(caps)
}
}
}
fn parse_effect_policy(raw: Option<&str>) -> EffectPolicy {
match raw {
None => EffectPolicy::allow_all(),
Some(s) => {
let kinds = s.split(',').filter_map(|token| match token.trim() {
"pure" => Some(EffectKind::Pure),
"fallible" => Some(EffectKind::Fallible),
"llm" => Some(EffectKind::Llm),
"network" => Some(EffectKind::Network),
"non-deterministic" | "nondeterministic" => Some(EffectKind::NonDeterministic),
"cost" => Some(EffectKind::Cost),
"process" => Some(EffectKind::Process),
"unknown" => Some(EffectKind::Unknown),
other => {
eprintln!("Warning: unknown effect kind '{other}', ignoring");
None
}
});
EffectPolicy::restrict(kinds)
}
}
}
fn main() {
let cli = Cli::parse();
let registry = cli.registry.as_deref();
match cli.command {
Commands::Introspect => {
let tree = output::build_command_tree();
let json = serde_json::to_value(&tree).unwrap();
println!("{}", output::acli_ok(json));
}
Commands::Version => {
println!(
"{}",
output::acli_ok(serde_json::json!({
"version": env!("CARGO_PKG_VERSION")
}))
);
}
Commands::Stage { command } => {
let mut store = build_store(registry);
match command {
StageCommands::Search { query, tag } => {
let index = build_index(store.as_ref(), true);
commands::stage::cmd_search(store.as_ref(), &index, &query, tag.as_deref());
}
StageCommands::Add { spec, draft } => {
let author_key = load_or_create_author_key(&noether_dir());
let index = build_index(store.as_ref(), false);
commands::stage::cmd_add(store.as_mut(), &spec, &author_key, &index, !draft);
}
StageCommands::Sync { directory, draft } => {
let author_key = load_or_create_author_key(&noether_dir());
let index = build_index(store.as_ref(), false);
commands::stage::cmd_sync(
store.as_mut(),
&directory,
&author_key,
&index,
!draft,
);
}
StageCommands::List {
tag,
signed_by,
lifecycle,
full_ids,
} => commands::stage::cmd_list(
store.as_ref(),
commands::stage::ListOptions {
tag: tag.as_deref(),
signed_by: signed_by.as_deref(),
lifecycle: lifecycle.as_deref(),
full_ids,
},
),
StageCommands::Get { hash } => commands::stage::cmd_get(store.as_ref(), &hash),
StageCommands::Activate { hash } => {
commands::stage::cmd_activate(store.as_mut(), &hash)
}
StageCommands::Test { hash } => {
let executor = commands::executor_builder::build_executor(store.as_ref());
commands::stage::cmd_test(store.as_ref(), &executor, hash.as_deref());
}
}
}
Commands::Store { command } => {
let mut store = build_store(registry);
match command {
StoreCommands::Stats => {
let index = build_index(store.as_ref(), false);
commands::store::cmd_stats(store.as_ref(), &index);
}
StoreCommands::Retro {
dry_run,
apply,
threshold,
} => {
let index = build_index(store.as_ref(), false);
commands::store::cmd_retro(store.as_mut(), &index, dry_run, apply, threshold);
}
StoreCommands::MigrateEffects { dry_run } => {
let (llm, _) = providers::build_llm_provider();
commands::store::cmd_migrate_effects(store.as_mut(), llm.as_ref(), dry_run);
}
StoreCommands::Health => {
commands::store::cmd_health(store.as_ref());
}
StoreCommands::Dedup { threshold, apply } => {
let index = build_index(store.as_ref(), false);
commands::store::cmd_dedup(store.as_mut(), &index, threshold, apply);
}
}
}
Commands::Run {
graph,
dry_run,
input,
allow_capabilities,
allow_effects,
budget_cents,
} => {
let store = build_store(registry);
let mut trace_store = init_trace_store();
let input_value = match input.as_deref() {
Some(s) => serde_json::from_str(s).unwrap_or(serde_json::Value::String(s.into())),
None => read_stdin_input().unwrap_or(serde_json::Value::Null),
};
let policy = parse_capability_policy(allow_capabilities.as_deref());
let effect_policy = parse_effect_policy(allow_effects.as_deref());
commands::run::cmd_run(
store.as_ref(),
&mut trace_store,
&graph,
dry_run,
&input_value,
commands::run::RunPolicies {
capabilities: &policy,
effects: &effect_policy,
budget_cents,
},
);
}
Commands::Trace { composition_id } => {
let trace_store = init_trace_store();
commands::trace::cmd_trace(&trace_store, &composition_id);
}
Commands::Build {
graph,
output,
name,
description,
target,
serve,
} => {
let store = build_store(registry);
commands::build::cmd_build(
store.as_ref(),
commands::build::BuildOptions {
graph_path: &graph,
output_path: &output,
app_name: name.as_deref(),
description: description.as_deref(),
target: &target,
serve_addr: serve.as_deref(),
},
);
}
Commands::Serve { graph, port } => {
let store = build_store(registry);
let executor = commands::executor_builder::build_executor(store.as_ref());
commands::serve::cmd_serve(store.as_ref(), &executor, &graph, &port);
}
Commands::Compose {
problem,
model,
dry_run,
input,
force,
allow_capabilities,
budget_cents,
verbose,
} => {
if verbose {
std::env::set_var("NOETHER_VERBOSE", "1");
}
let mut store = build_store(registry);
let mut index = build_index(store.as_ref(), true);
let (llm, llm_name) = providers::build_llm_provider();
if llm_name != "mock" {
eprintln!("LLM provider: {llm_name}");
}
let resolved_model = model
.or_else(|| std::env::var("VERTEX_AI_MODEL").ok())
.unwrap_or_else(|| noether_engine::llm::LlmConfig::default().model);
let input_value = match input.as_deref() {
Some(s) => serde_json::from_str(s).unwrap_or(serde_json::Value::String(s.into())),
None => read_stdin_input().unwrap_or(serde_json::Value::Null),
};
let cache_path = noether_dir().join("compositions.json");
let policy = parse_capability_policy(allow_capabilities.as_deref());
commands::compose::cmd_compose(
store.as_mut(),
&mut index,
llm.as_ref(),
&problem,
commands::compose::ComposeOptions {
model: &resolved_model,
dry_run,
input: &input_value,
force,
cache_path: &cache_path,
policy: &policy,
budget_cents,
},
);
}
}
}