mod diff;
mod python;
mod schema;
mod ts;
use std::path::{Path, PathBuf};
use std::time::Duration;
use clap::{Args, Subcommand, ValueEnum};
use serde::{Deserialize, Serialize};
use crate::commands::aggregator::RemoteAttachArgs;
use crate::context::{require_remote_attach, resolve_profile, CliContext};
use crate::error::{generic, invalid_args, CliError};
use crate::prelude::{emit_value, OutputFormat};
use net_sdk::tool::ToolDescriptor;
const SNAPSHOT_FORMAT_VERSION: u32 = 1;
#[derive(Subcommand, Debug)]
pub enum TypegenCommand {
Generate(GenerateArgs),
Snapshot(SnapshotArgs),
Diff(DiffArgs),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
pub enum Language {
Ts,
Python,
}
#[derive(Args, Debug)]
pub struct GenerateArgs {
#[arg(long, value_enum)]
pub language: Language,
#[arg(long = "tag", num_args = 1.., value_name = "TAG")]
pub tags: Vec<String>,
#[arg(long = "tool", num_args = 1.., value_name = "TOOL_ID")]
pub tools: Vec<String>,
#[arg(long, value_name = "PATH")]
pub from_snapshot: Option<PathBuf>,
#[arg(long, default_value = "./generated")]
pub out: PathBuf,
#[arg(long)]
pub identity: Option<PathBuf>,
#[arg(long, default_value_t = crate::prelude::DEFAULT_SUPERVISOR_NODE)]
pub node: u64,
#[command(flatten)]
pub attach: RemoteAttachArgs,
}
#[derive(Args, Debug)]
pub struct SnapshotArgs {
#[arg(long = "tag", num_args = 1.., value_name = "TAG")]
pub tags: Vec<String>,
#[arg(long = "tool", num_args = 1.., value_name = "TOOL_ID")]
pub tools: Vec<String>,
#[arg(long, value_name = "PATH")]
pub out: PathBuf,
#[arg(long)]
pub identity: Option<PathBuf>,
#[arg(long, default_value_t = crate::prelude::DEFAULT_SUPERVISOR_NODE)]
pub node: u64,
#[command(flatten)]
pub attach: RemoteAttachArgs,
}
#[derive(Args, Debug)]
pub struct DiffArgs {
#[arg(long, value_name = "PATH")]
pub from: PathBuf,
#[arg(long, value_name = "PATH")]
pub to: PathBuf,
#[arg(long)]
pub exit_code: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TypegenSnapshot {
pub format_version: u32,
pub captured_at: String,
pub source_query: SnapshotQuery,
pub descriptors: Vec<ToolDescriptor>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SnapshotQuery {
pub tags: Vec<String>,
pub tools: Vec<String>,
}
pub struct GeneratedFile {
pub rel_path: String,
pub contents: String,
}
pub struct GenMeta {
pub source_label: String,
pub captured_at: String,
pub format_version: u32,
}
pub async fn run(
cmd: TypegenCommand,
output: Option<OutputFormat>,
config_path: Option<&std::path::Path>,
profile_name: &str,
) -> Result<(), CliError> {
match cmd {
TypegenCommand::Generate(args) => {
run_generate(args, output, config_path, profile_name).await
}
TypegenCommand::Snapshot(args) => {
run_snapshot(args, output, config_path, profile_name).await
}
TypegenCommand::Diff(args) => run_diff(args, output).await,
}
}
async fn run_generate(
args: GenerateArgs,
output: Option<OutputFormat>,
config_path: Option<&std::path::Path>,
profile_name: &str,
) -> Result<(), CliError> {
let (descriptors, meta) = match &args.from_snapshot {
Some(path) => load_snapshot_source(path, &args.tags, &args.tools)?,
None => {
fetch_live_source(
&args.tags,
&args.tools,
&args.attach,
args.identity.as_deref(),
args.node,
config_path,
profile_name,
)
.await?
}
};
let mut skipped: Vec<String> = Vec::new();
let usable: Vec<ToolDescriptor> = descriptors
.into_iter()
.filter(|d| {
if d.input_schema.is_none() {
eprintln!(
"warning: tool `{}` has no inline input schema (size > fold budget); \
binding skipped. Re-run after `tool.metadata.fetch` ships.",
d.tool_id
);
skipped.push(d.tool_id.clone());
false
} else {
true
}
})
.collect();
if usable.is_empty() {
eprintln!(
"warning: no tools to generate (after filters and schema skips); \
only index + metadata files will be written."
);
}
let collisions = basename_collisions(&usable);
if !collisions.is_empty() {
let detail = collisions
.iter()
.map(|(base, ids)| {
format!(
"`{base}` ← {}",
ids.iter()
.map(|id| format!("`{id}`"))
.collect::<Vec<_>>()
.join(", ")
)
})
.collect::<Vec<_>>()
.join("; ");
return Err(invalid_args(format!(
"tool ids collide after sanitization and would overwrite each other's generated \
files: {detail}. Rename a tool id, or narrow the set with --tag / --tool."
)));
}
let files = match args.language {
Language::Ts => ts::generate(&usable, &meta, &mut skipped)?,
Language::Python => python::generate(&usable, &meta, &mut skipped)?,
};
let written = write_generated(&args.out, &files).await?;
let view = GenerateView {
language: format!("{:?}", args.language).to_lowercase(),
tool_count: usable.len() as u64,
files_written: written,
skipped,
out: args.out.display().to_string(),
};
emit_value(OutputFormat::resolve_oneshot(output), &view)
.map_err(|e| generic(format!("write typegen generate result: {e}")))?;
Ok(())
}
async fn run_snapshot(
args: SnapshotArgs,
output: Option<OutputFormat>,
config_path: Option<&std::path::Path>,
profile_name: &str,
) -> Result<(), CliError> {
let (descriptors, _meta) = fetch_live_source(
&args.tags,
&args.tools,
&args.attach,
args.identity.as_deref(),
args.node,
config_path,
profile_name,
)
.await?;
let schema_bytes: u64 = descriptors
.iter()
.map(|d| {
d.input_schema.as_ref().map(|s| s.len()).unwrap_or(0) as u64
+ d.output_schema.as_ref().map(|s| s.len()).unwrap_or(0) as u64
})
.sum();
let snapshot = TypegenSnapshot {
format_version: SNAPSHOT_FORMAT_VERSION,
captured_at: now_rfc3339(),
source_query: SnapshotQuery {
tags: args.tags.clone(),
tools: args.tools.clone(),
},
descriptors,
};
let json = serde_json::to_string_pretty(&snapshot)
.map_err(|e| generic(format!("serialize snapshot: {e}")))?;
if let Some(parent) = args.out.parent() {
if !parent.as_os_str().is_empty() {
tokio::fs::create_dir_all(parent)
.await
.map_err(|e| generic(format!("create snapshot dir {}: {e}", parent.display())))?;
}
}
tokio::fs::write(&args.out, json.as_bytes())
.await
.map_err(|e| generic(format!("write snapshot {}: {e}", args.out.display())))?;
let view = SnapshotView {
tool_count: snapshot.descriptors.len() as u64,
schema_bytes,
out: args.out.display().to_string(),
};
emit_value(OutputFormat::resolve_oneshot(output), &view)
.map_err(|e| generic(format!("write typegen snapshot result: {e}")))?;
Ok(())
}
async fn run_diff(args: DiffArgs, output: Option<OutputFormat>) -> Result<(), CliError> {
let from = read_snapshot(&args.from)?;
let to = read_snapshot(&args.to)?;
let report = diff::diff(&from, &to);
match OutputFormat::resolve_oneshot(output) {
OutputFormat::Table | OutputFormat::Text => {
print!("{}", diff::render_text(&report));
}
format => {
emit_value(format, &report).map_err(|e| generic(format!("write typegen diff: {e}")))?;
}
}
if args.exit_code && report.breaking_count > 0 {
return Err(CliError::new(
crate::error::ExitCodeKind::TypegenBreakingChanges,
format!(
"{} BREAKING schema change(s) detected",
report.breaking_count
),
));
}
Ok(())
}
fn load_snapshot_source(
path: &Path,
tags: &[String],
tools: &[String],
) -> Result<(Vec<ToolDescriptor>, GenMeta), CliError> {
let snapshot = read_snapshot(path)?;
let meta = GenMeta {
source_label: format!("snapshot {}", path.display()),
captured_at: snapshot.captured_at.clone(),
format_version: snapshot.format_version,
};
let descriptors = filter_descriptors(snapshot.descriptors, tags, tools);
Ok((descriptors, meta))
}
fn read_snapshot(path: &Path) -> Result<TypegenSnapshot, CliError> {
let bytes = std::fs::read(path)
.map_err(|e| generic(format!("read snapshot {}: {e}", path.display())))?;
let snapshot: TypegenSnapshot = serde_json::from_slice(&bytes).map_err(|e| {
invalid_args(format!(
"snapshot {} is not a valid TypegenSnapshot: {e}",
path.display()
))
})?;
if snapshot.format_version != SNAPSHOT_FORMAT_VERSION {
return Err(invalid_args(format!(
"snapshot {} has format_version {} (this build understands {})",
path.display(),
snapshot.format_version,
SNAPSHOT_FORMAT_VERSION
)));
}
Ok(snapshot)
}
async fn fetch_live_source(
tags: &[String],
tools: &[String],
attach: &RemoteAttachArgs,
identity: Option<&Path>,
node: u64,
config_path: Option<&std::path::Path>,
profile_name: &str,
) -> Result<(Vec<ToolDescriptor>, GenMeta), CliError> {
let profile = resolve_profile(config_path, profile_name).await?;
let remote = require_remote_attach(&profile, attach, || {
invalid_args(
"net typegen live discovery needs a mesh target: pass --node-addr <IP:PORT> \
--node-pubkey <HEX> --node-id <N> --psk-hex <HEX> (each defaultable in the \
profile), or use --from-snapshot for offline generation.",
)
})?;
let ctx = CliContext::build_with_remote(&profile, identity, node, false, remote).await?;
let mesh = ctx.require_mesh()?;
let raw = discover_with_timeout(mesh, Duration::from_secs(5)).await;
if raw.is_empty() {
eprintln!(
"warning: live discovery found no tools within 5s — the capability fold may still \
be populating, or this node advertises none; proceeding with an empty set."
);
}
let descriptors = filter_descriptors(raw, tags, tools);
let meta = GenMeta {
source_label: "live discovery".to_string(),
captured_at: now_rfc3339(),
format_version: SNAPSHOT_FORMAT_VERSION,
};
Ok((descriptors, meta))
}
async fn discover_with_timeout(mesh: &net_sdk::Mesh, budget: Duration) -> Vec<ToolDescriptor> {
let started = std::time::Instant::now();
loop {
let tools = mesh.list_tools(None);
if !tools.is_empty() || started.elapsed() >= budget {
return tools;
}
tokio::time::sleep(Duration::from_millis(200)).await;
}
}
fn filter_descriptors(
descriptors: Vec<ToolDescriptor>,
tags: &[String],
tools: &[String],
) -> Vec<ToolDescriptor> {
let by_tag: Vec<ToolDescriptor> = if tags.is_empty() {
descriptors
} else {
descriptors
.into_iter()
.filter(|d| d.tags.iter().any(|t| tags.contains(t)))
.collect()
};
filter_by_tools(by_tag, tools)
}
fn basename_collisions(descriptors: &[ToolDescriptor]) -> Vec<(String, Vec<String>)> {
let mut groups: std::collections::BTreeMap<String, Vec<String>> =
std::collections::BTreeMap::new();
for d in descriptors {
groups
.entry(module_basename(&d.tool_id))
.or_default()
.push(d.tool_id.clone());
}
groups
.into_iter()
.filter(|(_, ids)| ids.len() > 1)
.collect()
}
fn filter_by_tools(descriptors: Vec<ToolDescriptor>, tools: &[String]) -> Vec<ToolDescriptor> {
if tools.is_empty() {
descriptors
} else {
descriptors
.into_iter()
.filter(|d| tools.contains(&d.tool_id))
.collect()
}
}
async fn write_generated(out: &Path, files: &[GeneratedFile]) -> Result<u64, CliError> {
for file in files {
let dest = out.join(rel_to_os_path(&file.rel_path));
if let Some(parent) = dest.parent() {
tokio::fs::create_dir_all(parent)
.await
.map_err(|e| generic(format!("create dir {}: {e}", parent.display())))?;
}
tokio::fs::write(&dest, file.contents.as_bytes())
.await
.map_err(|e| generic(format!("write {}: {e}", dest.display())))?;
}
Ok(files.len() as u64)
}
fn rel_to_os_path(rel: &str) -> PathBuf {
rel.split('/').collect()
}
pub(crate) fn pascal_case(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut new_word = true;
for ch in s.chars() {
if ch.is_ascii_alphanumeric() {
if new_word {
out.extend(ch.to_uppercase());
} else {
out.push(ch);
}
new_word = false;
} else {
new_word = true;
}
}
if out.chars().next().is_some_and(|c| c.is_ascii_digit()) {
out.insert(0, '_');
}
if out.is_empty() {
out.push('_');
}
out
}
pub(crate) fn module_basename(tool_id: &str) -> String {
let mut out: String = tool_id
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '_' {
c
} else {
'_'
}
})
.collect();
if out.chars().next().is_some_and(|c| c.is_ascii_digit()) {
out.insert(0, '_');
}
if out.is_empty() {
out.push('_');
}
out
}
fn now_rfc3339() -> String {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let days = (secs / 86_400) as i64;
let rem = secs % 86_400;
let (h, mi, s) = (rem / 3600, (rem % 3600) / 60, rem % 60);
let (y, m, d) = civil_from_days(days);
format!("{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z")
}
fn civil_from_days(z: i64) -> (i64, u32, u32) {
let z = z + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
(if m <= 2 { y + 1 } else { y }, m, d)
}
#[derive(Serialize)]
struct GenerateView {
language: String,
tool_count: u64,
files_written: u64,
#[serde(skip_serializing_if = "Vec::is_empty")]
skipped: Vec<String>,
out: String,
}
#[derive(Serialize)]
struct SnapshotView {
tool_count: u64,
schema_bytes: u64,
out: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pascal_case_handles_separators_and_digits() {
assert_eq!(pascal_case("acme/web_search"), "AcmeWebSearch");
assert_eq!(pascal_case("vendor.tool-name"), "VendorToolName");
assert_eq!(pascal_case("3d_render"), "_3dRender");
assert_eq!(pascal_case("already"), "Already");
}
#[test]
fn module_basename_sanitizes() {
assert_eq!(module_basename("acme/web_search"), "acme_web_search");
assert_eq!(module_basename("a.b/c"), "a_b_c");
assert_eq!(module_basename("9lives"), "_9lives");
}
#[test]
fn civil_from_days_known_dates() {
assert_eq!(civil_from_days(0), (1970, 1, 1));
assert_eq!(civil_from_days(18_993), (2022, 1, 1));
}
fn desc(tool_id: &str) -> ToolDescriptor {
ToolDescriptor {
tool_id: tool_id.into(),
name: tool_id.into(),
version: "1.0.0".into(),
description: None,
input_schema: None,
output_schema: None,
requires: vec![],
estimated_time_ms: 0,
stateless: true,
streaming: false,
tags: vec![],
node_count: 1,
}
}
#[test]
fn basename_collisions_detects_only_clashing_ids() {
let descriptors = vec![
desc("acme/web-search"),
desc("acme/web_search"),
desc("acme/maps"),
];
let collisions = basename_collisions(&descriptors);
assert_eq!(collisions.len(), 1, "{collisions:?}");
assert_eq!(collisions[0].0, "acme_web_search");
assert_eq!(
collisions[0].1,
vec!["acme/web-search".to_string(), "acme/web_search".to_string()]
);
}
#[test]
fn snapshot_round_trips() {
let snap = TypegenSnapshot {
format_version: SNAPSHOT_FORMAT_VERSION,
captured_at: "2026-06-04T10:00:00Z".into(),
source_query: SnapshotQuery {
tags: vec!["search".into()],
tools: vec![],
},
descriptors: vec![],
};
let json = serde_json::to_string(&snap).expect("ser");
let back: TypegenSnapshot = serde_json::from_str(&json).expect("de");
assert_eq!(back.format_version, SNAPSHOT_FORMAT_VERSION);
assert_eq!(back.source_query.tags, vec!["search".to_string()]);
}
}