#![cfg(not(target_arch = "wasm32"))]
#![allow(clippy::collapsible_match)]
#![allow(clippy::collapsible_if)]
mod adapters;
mod builtins;
mod core;
mod lsp;
mod providers;
mod registry;
mod runtime;
mod services;
mod templates;
mod testing;
mod ui;
use anyhow::{anyhow, Context, Result};
use clap::{Parser, Subcommand};
use serde_json::Value;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tracing::{debug, info, warn};
use core::executor::WorkflowExecutor;
use core::parser::GraphParser;
use core::prompt_parser::PromptParser;
use core::renderer::JwlRenderer;
use core::resolver;
use core::skill_parser;
use core::type_checker::TypeChecker;
use core::validator::{ProjectContext, WorkflowValidator};
use services::config::JuglansConfig;
use services::github;
use services::local_runtime::LocalRuntime;
use services::prompt_loader::PromptRegistry;
use services::web_server;
#[derive(Parser)]
#[command(name = "juglans", author = "Juglans Team", version = env!("CARGO_PKG_VERSION"))]
struct Cli {
file: Option<PathBuf>,
#[command(subcommand)]
command: Option<Commands>,
#[arg(short, long)]
input: Option<String>,
#[arg(long)]
input_file: Option<PathBuf>,
#[arg(short, long)]
verbose: bool,
#[arg(long)]
dry_run: bool,
#[arg(short, long)]
output: Option<PathBuf>,
#[arg(long, default_value = "text")]
output_format: String,
#[arg(long)]
chat_id: Option<String>,
#[arg(long)]
info: bool,
}
#[derive(Subcommand)]
enum Commands {
Init { name: String },
Install,
Check {
path: Option<PathBuf>,
#[arg(long)]
all: bool,
#[arg(long, default_value = "text")]
format: String,
},
Web {
#[arg(short, long)]
port: Option<u16>,
#[arg(long)]
host: Option<String>,
},
Whoami {
#[arg(long, short = 'v')]
verbose: bool,
},
Serve {
#[arg(short, long)]
port: Option<u16>,
#[arg(long)]
host: Option<String>,
#[arg(long)]
entry: Option<PathBuf>,
},
Bot {
platform: String,
#[arg(long)]
agent: Option<String>,
#[arg(long)]
port: Option<u16>,
},
Skills {
#[command(subcommand)]
action: SkillsAction,
},
Pack {
path: Option<PathBuf>,
#[arg(short, long)]
output: Option<PathBuf>,
},
Publish {
path: Option<PathBuf>,
},
Add {
package: String,
},
Remove {
package: String,
},
Chat {
#[arg(short, long)]
agent: Option<PathBuf>,
},
Deploy {
#[arg(long)]
tag: Option<String>,
#[arg(short, long)]
port: Option<u16>,
#[arg(long)]
build_only: bool,
#[arg(long)]
push: bool,
#[arg(long)]
stop: bool,
#[arg(long)]
status: bool,
#[arg(long = "env", short = 'e')]
env_vars: Vec<String>,
path: Option<PathBuf>,
},
Cron {
file: PathBuf,
#[arg(long, short = 's')]
schedule: Option<String>,
},
Lsp,
Test {
path: Option<PathBuf>,
#[arg(long)]
filter: Option<String>,
#[arg(long, default_value = "text")]
format: String,
},
Doctest {
path: Option<PathBuf>,
#[arg(long, default_value = "text")]
format: String,
},
}
#[derive(Subcommand)]
enum SkillsAction {
Add {
repo: String,
#[arg(long = "skill")]
skills: Vec<String>,
#[arg(long)]
all: bool,
#[arg(long)]
list: bool,
#[arg(short, long)]
output: Option<PathBuf>,
},
List,
Remove {
name: String,
},
}
fn resolve_input_data(cli: &Cli) -> Result<Option<String>> {
if let Some(input_file_path) = &cli.input_file {
let content = fs::read_to_string(input_file_path)
.with_context(|| format!("Failed to read input file: {:?}", input_file_path))?;
Ok(Some(content))
} else {
Ok(cli.input.clone())
}
}
fn resolve_import_patterns_verbose(
base_dir_ref: &Path,
raw_patterns: &[String],
at_base: Option<&Path>,
) -> Vec<String> {
let expanded = resolver::expand_at_prefixes(raw_patterns, at_base);
let mut resolved_output_list = Vec::new();
for pattern_str in &expanded {
if Path::new(pattern_str).is_absolute() {
resolved_output_list.push(pattern_str.clone());
} else {
let combined_path_obj = base_dir_ref.join(pattern_str);
resolved_output_list.push(combined_path_obj.to_string_lossy().to_string());
}
}
resolved_output_list
}
fn has_local_llm_provider(config: &JuglansConfig) -> bool {
if config.ai.has_providers() {
return true;
}
[
"OPENAI_API_KEY",
"ANTHROPIC_API_KEY",
"DEEPSEEK_API_KEY",
"GEMINI_API_KEY",
"QWEN_API_KEY",
"ARK_API_KEY",
"XAI_API_KEY",
]
.iter()
.any(|k| std::env::var(k).map(|v| !v.is_empty()).unwrap_or(false))
}
async fn build_executor(
config: &JuglansConfig,
prompt_registry: PromptRegistry,
workflow: Option<&Arc<core::graph::WorkflowGraph>>,
) -> Result<Arc<WorkflowExecutor>> {
if has_local_llm_provider(config) {
tracing::info!("Using local LLM provider (direct API)");
}
let runtime: Arc<LocalRuntime> = Arc::new(LocalRuntime::new_with_config(&config.ai));
let mut executor =
WorkflowExecutor::new_with_debug(Arc::new(prompt_registry), runtime, config.debug.clone())
.await;
executor.apply_limits(&config.limits);
if let Some(wf) = workflow {
executor.load_tools(wf).await;
if let Err(e) = executor.init_python_runtime(wf, config.limits.python_workers) {
warn!("Failed to initialize Python runtime: {}", e);
}
}
let shared = Arc::new(executor);
shared.get_registry().set_executor(Arc::downgrade(&shared));
Ok(shared)
}
async fn handle_file_logic(cli: &Cli) -> Result<()> {
let source_file_path = cli
.file
.as_ref()
.ok_or_else(|| anyhow!("Input missing: Please provide a valid file path."))?;
let file_ext_name = source_file_path
.extension()
.and_then(|s| s.to_str())
.unwrap_or("");
let absolute_target_path = fs::canonicalize(source_file_path)
.with_context(|| format!("Physical file error: Cannot resolve {:?}", source_file_path))?;
let project_root_path = find_project_root(&absolute_target_path)?;
env::set_current_dir(&project_root_path)?;
let file_parent_context = absolute_target_path.parent().unwrap_or(Path::new("."));
let mut relative_base_offset =
pathdiff::diff_paths(file_parent_context, &project_root_path).unwrap_or(PathBuf::from("."));
let source_raw_text = fs::read_to_string(&absolute_target_path)?;
match file_ext_name {
"jg" | "jgflow" => {
info!("🚀 Starting Workflow Graph Logic: {:?}", source_file_path);
let local_config = JuglansConfig::load()?;
let at_base: Option<PathBuf> = local_config
.paths
.base
.as_ref()
.map(|b| project_root_path.join(b));
let mut workflow_definition_obj = if file_ext_name == "jgflow" {
let manifest = GraphParser::parse_manifest(&source_raw_text)?;
if manifest.source.is_empty() {
anyhow::bail!(".jgflow manifest requires a 'source' field pointing to a .jg file.\nExample: source: \"./main.jg\"");
}
let source_path = file_parent_context.join(&manifest.source);
let source_content = fs::read_to_string(&source_path)
.with_context(|| format!("Failed to read source: {}", manifest.source))?;
let mut wf = GraphParser::parse(&source_content)?;
manifest.apply_to(&mut wf);
let source_parent = source_path.parent().unwrap_or(Path::new("."));
relative_base_offset = pathdiff::diff_paths(source_parent, &project_root_path)
.unwrap_or(PathBuf::from("."));
wf
} else {
GraphParser::parse(&source_raw_text)?
};
let mut import_stack = vec![absolute_target_path.clone()];
resolver::resolve_lib_imports(
&mut workflow_definition_obj,
&relative_base_offset,
&mut import_stack,
at_base.as_deref(),
)?;
import_stack = vec![absolute_target_path.clone()];
resolver::resolve_flow_imports(
&mut workflow_definition_obj,
&relative_base_offset,
&mut import_stack,
at_base.as_deref(),
)?;
core::macro_expand::expand_decorators(&mut workflow_definition_obj)?;
let validation = WorkflowValidator::validate(&workflow_definition_obj);
if !validation.is_valid {
eprint!(
"{}",
validation.format_report(&source_file_path.display().to_string())
);
anyhow::bail!("Validation failed. Run `juglans check` for details.");
}
if validation.warning_count() > 0 {
eprint!(
"{}",
validation.format_report(&source_file_path.display().to_string())
);
}
let mut prompt_registry_inst = PromptRegistry::new();
let resolved_p_patterns = resolve_import_patterns_verbose(
&relative_base_offset,
&workflow_definition_obj.prompt_patterns,
at_base.as_deref(),
);
let resolved_t_patterns = resolve_import_patterns_verbose(
&relative_base_offset,
&workflow_definition_obj.tool_patterns,
at_base.as_deref(),
);
workflow_definition_obj.tool_patterns = resolved_t_patterns;
let workflow_definition_obj = Arc::new(workflow_definition_obj);
if !resolved_p_patterns.is_empty() {
prompt_registry_inst.load_from_paths(&resolved_p_patterns)?;
}
let shared_executor_engine = build_executor(
&local_config,
prompt_registry_inst,
Some(&workflow_definition_obj),
)
.await?;
let input_value: Option<serde_json::Value> =
resolve_input_data(cli)?.and_then(|s| serde_json::from_str(&s).ok());
let context = shared_executor_engine
.run_with_input(workflow_definition_obj, &local_config, input_value)
.await?;
match cli.output_format.as_str() {
"json" => {
let output = context
.resolve_path("output")
.ok()
.flatten()
.unwrap_or(serde_json::Value::Null);
println!("{}", serde_json::to_string(&output)?);
}
"sse" => {
let output = context
.resolve_path("output")
.ok()
.flatten()
.unwrap_or(serde_json::Value::Null);
println!(
"data: {}\n",
serde_json::to_string(&serde_json::json!({
"event": "done",
"output": output
}))?
);
}
_ => {} }
}
"jgx" | "jgprompt" => {
println!("🔍 Executing Local Render: {:?}", source_file_path);
let prompt_resource_item = PromptParser::parse(&source_raw_text)?;
let mut rendering_variables_ctx = prompt_resource_item.inputs.clone();
if let Some(ext_input_json) = resolve_input_data(cli)? {
let parsed_input_data: Value = serde_json::from_str(&ext_input_json)?;
if let Some(data_obj) = parsed_input_data.as_object() {
for (k, v) in data_obj {
rendering_variables_ctx[k] = v.clone();
}
}
}
let renderer_instance = JwlRenderer::new();
let final_text_output =
renderer_instance.render(&prompt_resource_item.ast, &rendering_variables_ctx)?;
println!(
"\n--- Rendered Content ---\n{}\n-----------------------",
final_text_output
);
}
_ => return Err(anyhow!("Unsupported JWL file type: .{}", file_ext_name)),
}
Ok(())
}
fn find_project_root(start_search_path: &Path) -> Result<PathBuf> {
let mut current_ptr = start_search_path.to_path_buf();
if current_ptr.is_file() {
current_ptr.pop();
}
let fallback = current_ptr.clone();
loop {
if current_ptr.join("juglans.toml").exists() {
return Ok(current_ptr);
}
if !current_ptr.pop() {
return Ok(fallback);
}
}
}
fn handle_init(new_project_name: &str) -> Result<()> {
let root_path_obj = Path::new(new_project_name);
if root_path_obj.exists() {
return Err(anyhow!("Directory exists: '{}'", new_project_name));
}
fs::create_dir_all(root_path_obj)?;
fs::write(root_path_obj.join("juglans.toml"), templates::TPL_TOML)?;
templates::PROJECT_TEMPLATE_DIR.extract(root_path_obj)?;
let docs_path = root_path_obj.join("docs");
fs::create_dir_all(&docs_path)?;
templates::DOCS_DIR.extract(&docs_path)?;
println!("✅ Initialized: {:?}", root_path_obj);
Ok(())
}
async fn handle_install() -> Result<()> {
let project_dir = env::current_dir()?;
let manifest_path = project_dir.join("jgpackage.toml");
if manifest_path.exists() {
let config = JuglansConfig::load()?;
let registry_url = config
.registry
.as_ref()
.map(|r| r.url.as_str())
.unwrap_or("https://jgr.juglans.ai");
let installer = registry::installer::PackageInstaller::with_defaults(registry_url)?;
let installed = installer.install_all(&project_dir).await?;
if installed.is_empty() {
println!("No package dependencies to install.");
} else {
for pkg in &installed {
println!(" Installed {}@{}", pkg.name, pkg.version);
}
println!("Installed {} package(s).", installed.len());
}
}
Ok(())
}
async fn handle_whoami(verbose: bool) -> Result<()> {
let config = JuglansConfig::load()?;
let config_path = if Path::new("juglans.toml").exists() {
"./juglans.toml"
} else {
"~/.config/juglans/juglans.toml or system default"
};
println!("\n📋 Account Information\n");
println!("\x1b[1m💻 Local Configuration\x1b[0m");
println!("User ID: {}", config.account.id);
println!("Name: {}", config.account.name);
if let Some(role) = &config.account.role {
println!("Role: {}", role);
}
println!();
if let Some(workspace) = &config.workspace {
println!("Workspace: {} ({})", workspace.id, workspace.name);
if let Some(members) = &workspace.members {
println!("Members: {} user(s)", members.len());
}
if verbose {
if !workspace.workflows.is_empty() || !workspace.prompts.is_empty() {
println!("\nResource Paths:");
println!(" Workflows: {}", workspace.workflows.join(", "));
println!(" Prompts: {}", workspace.prompts.join(", "));
if !workspace.tools.is_empty() {
println!(" Tools: {}", workspace.tools.join(", "));
}
}
if !workspace.exclude.is_empty() {
println!("\nExclude: {}", workspace.exclude.join(", "));
}
}
println!();
}
if verbose {
println!(
"Web Server: {}:{}",
config.server.host, config.server.port
);
println!();
}
println!("Config: {}", config_path);
println!();
Ok(())
}
fn handle_check(path: Option<&Path>, show_all: bool, output_format: &str) -> Result<()> {
use glob::glob;
let check_path = path
.map(|p| p.to_path_buf())
.unwrap_or_else(|| env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
let patterns: Vec<String> = if check_path.is_file() {
vec![check_path.to_string_lossy().to_string()]
} else {
vec![
check_path.join("**/*.jg").to_string_lossy().to_string(),
check_path.join("**/*.jgflow").to_string_lossy().to_string(),
check_path.join("**/*.jgx").to_string_lossy().to_string(),
check_path
.join("**/*.jgprompt")
.to_string_lossy()
.to_string(),
]
};
let mut total_files = 0;
let mut valid_count = 0;
let mut error_count = 0;
let mut warning_count = 0;
let mut results: Vec<serde_json::Value> = Vec::new();
let mut workflow_count = 0;
let mut prompt_count = 0;
println!(
" \x1b[1;32mChecking\x1b[0m juglans files in {:?}\n",
check_path
);
let mut all_paths: Vec<PathBuf> = Vec::new();
for pattern in &patterns {
if let Ok(paths) = glob(pattern) {
all_paths.extend(paths.flatten());
}
}
if all_paths.is_empty() {
println!(" \x1b[33mNo .jg/.jgflow or .jgx files found\x1b[0m");
return Ok(());
}
let mut project_ctx = ProjectContext {
base_dir: check_path.clone(),
..Default::default()
};
for entry in &all_paths {
if let Ok(content) = fs::read_to_string(entry) {
let ext = entry.extension().and_then(|s| s.to_str()).unwrap_or("");
match ext {
"jgx" | "jgprompt" => {
if let Ok(prompt) = PromptParser::parse(&content) {
project_ctx.prompt_slugs.insert(prompt.slug);
}
}
"jg" | "jgflow" => {
if let Ok(canonical) = entry.canonicalize() {
project_ctx.flow_paths.insert(canonical);
} else {
project_ctx.flow_paths.insert(entry.clone());
}
}
_ => {}
}
}
}
for entry in all_paths {
total_files += 1;
let file_name = entry
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown");
let ext = entry.extension().and_then(|s| s.to_str()).unwrap_or("");
let relative_path = entry
.strip_prefix(&check_path)
.unwrap_or(&entry)
.display()
.to_string();
let relative_path = if relative_path.is_empty() {
entry
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string()
} else {
relative_path
};
match fs::read_to_string(&entry) {
Ok(content) => match ext {
"jg" | "jgflow" => {
workflow_count += 1;
if ext == "jgflow" {
match GraphParser::parse_manifest(&content) {
Ok(manifest) => {
if manifest.source.is_empty() {
error_count += 1;
println!(" \x1b[1;31merror\x1b[0m[manifest]: {} — missing 'source' field", relative_path);
continue;
}
let source_path = entry
.parent()
.unwrap_or(Path::new("."))
.join(&manifest.source);
if !source_path.exists() {
error_count += 1;
println!(" \x1b[1;31merror\x1b[0m[manifest]: {} — source file not found: {}", relative_path, manifest.source);
continue;
}
valid_count += 1;
if show_all {
println!(
" \x1b[32mok\x1b[0m[manifest]: {} → {}",
relative_path, manifest.source
);
}
}
Err(e) => {
error_count += 1;
println!(
" \x1b[1;31merror\x1b[0m[parse]: {} — {}",
relative_path, e
);
}
}
continue;
}
let parse_result = GraphParser::parse(&content);
match parse_result {
Ok(graph) => {
project_ctx.file_dir =
entry.parent().unwrap_or(Path::new(".")).to_path_buf();
let mut graph = graph;
if let Err(e) = resolver::resolve_lib_imports(
&mut graph,
&project_ctx.file_dir.clone(),
&mut vec![],
None,
) {
debug!("Lib import resolution warning: {}", e);
}
let _ = resolver::resolve_flow_imports(
&mut graph,
&project_ctx.file_dir.clone(),
&mut vec![],
None,
);
let validation =
WorkflowValidator::validate_with_project(&graph, &project_ctx);
let type_result = TypeChecker::new().check(&graph);
let slug = if graph.slug.is_empty() {
file_name.to_string()
} else {
graph.slug.clone()
};
let type_warn_count = type_result.warnings.len();
let type_err_count = type_result.errors.len();
if output_format == "json" {
let mut all_warnings: Vec<serde_json::Value> = validation.warnings.iter().map(|w| {
serde_json::json!({"code": w.code, "message": w.message})
}).collect();
for tw in &type_result.warnings {
all_warnings.push(serde_json::json!({
"code": "TYPE_WARN",
"message": format!("class '{}' field '{}': {}", tw.class_name, tw.field_or_method, tw.message)
}));
}
let mut all_errors: Vec<serde_json::Value> = validation.errors.iter().map(|e| {
serde_json::json!({"code": e.code, "message": e.message})
}).collect();
for te in &type_result.errors {
all_errors.push(serde_json::json!({
"code": "TYPE_ERR",
"message": format!("class '{}' {}: {}", te.class_name, te.field_or_method, te.message)
}));
}
results.push(serde_json::json!({
"file": relative_path,
"type": "workflow",
"slug": slug,
"valid": validation.is_valid && type_err_count == 0,
"errors": all_errors,
"warnings": all_warnings,
}));
}
if validation.is_valid && type_err_count == 0 {
valid_count += 1;
let total_warns = validation.warning_count() + type_warn_count;
if total_warns > 0 {
warning_count += total_warns;
if show_all {
println!(" \x1b[33mwarning\x1b[0m[workflow]: {} ({} warning(s))", relative_path, total_warns);
for warn in &validation.warnings {
println!(
" \x1b[33m-->\x1b[0m [{}] {}",
warn.code, warn.message
);
}
for tw in &type_result.warnings {
println!(
" \x1b[33m-->\x1b[0m [TYPE] class '{}' field '{}': {}",
tw.class_name, tw.field_or_method, tw.message
);
}
}
}
} else {
error_count += 1;
let total_warns = validation.warning_count() + type_warn_count;
let total_errs = validation.error_count() + type_err_count;
warning_count += total_warns;
println!(" \x1b[1;31merror\x1b[0m[workflow]: {} ({} error(s), {} warning(s))",
relative_path, total_errs, total_warns);
for err in &validation.errors {
println!(
" \x1b[31m-->\x1b[0m [{}] {}",
err.code, err.message
);
}
for te in &type_result.errors {
println!(
" \x1b[31m-->\x1b[0m [TYPE] class '{}' {}: {}",
te.class_name, te.field_or_method, te.message
);
}
if show_all {
for warn in &validation.warnings {
println!(
" \x1b[33m-->\x1b[0m [{}] {}",
warn.code, warn.message
);
}
for tw in &type_result.warnings {
println!(
" \x1b[33m-->\x1b[0m [TYPE] class '{}' field '{}': {}",
tw.class_name, tw.field_or_method, tw.message
);
}
}
}
}
Err(e) => {
if let Ok(lib_graph) = GraphParser::parse_lib(&content) {
valid_count += 1;
let slug = if lib_graph.slug.is_empty() {
file_name.to_string()
} else {
lib_graph.slug.clone()
};
let func_count = lib_graph.functions.len();
if show_all {
println!(
" \x1b[32mok\x1b[0m[library]: {} — {} ({} function(s))",
relative_path, slug, func_count
);
}
if output_format == "json" {
results.push(serde_json::json!({
"file": relative_path,
"type": "library",
"slug": slug,
"valid": true,
"functions": func_count,
}));
}
continue;
}
error_count += 1;
let full_err = e.to_string();
println!(
" \x1b[1;31merror\x1b[0m[workflow]: {} (parse failed)",
relative_path
);
for line in full_err.lines() {
println!(" \x1b[31m-->\x1b[0m {}", line);
}
if output_format == "json" {
results.push(serde_json::json!({
"file": relative_path,
"type": "workflow",
"slug": file_name,
"valid": false,
"errors": [{"code": "PARSE", "message": full_err}],
"warnings": [],
}));
}
}
}
}
"jgx" | "jgprompt" => {
prompt_count += 1;
match PromptParser::parse(&content) {
Ok(prompt) => {
valid_count += 1;
if output_format == "json" {
results.push(serde_json::json!({
"file": relative_path,
"type": "prompt",
"slug": prompt.slug,
"valid": true,
"errors": [],
"warnings": [],
}));
}
}
Err(e) => {
error_count += 1;
let full_err = e.to_string();
println!(
" \x1b[1;31merror\x1b[0m[prompt]: {} (parse failed)",
relative_path
);
for line in full_err.lines() {
println!(" \x1b[31m-->\x1b[0m {}", line);
}
if output_format == "json" {
results.push(serde_json::json!({
"file": relative_path,
"type": "prompt",
"slug": file_name,
"valid": false,
"errors": [{"code": "PARSE", "message": full_err}],
"warnings": [],
}));
}
}
}
}
_ => {}
},
Err(e) => {
error_count += 1;
println!(
" \x1b[1;31merror\x1b[0m: {} (read failed: {})",
relative_path, e
);
}
}
}
println!();
if output_format == "json" {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"total": total_files,
"valid": valid_count,
"errors": error_count,
"warnings": warning_count,
"by_type": {
"workflows": workflow_count,
"prompts": prompt_count,
},
"results": results,
}))?
);
} else {
if error_count > 0 {
println!(
"\x1b[1;31merror\x1b[0m: could not validate {} file(s) due to {} previous error(s)",
error_count, error_count
);
}
if warning_count > 0 && error_count == 0 {
println!(
"\x1b[1;33mwarning\x1b[0m: {} warning(s) generated",
warning_count
);
}
let mut parts = Vec::new();
if workflow_count > 0 {
parts.push(format!("{} workflow(s)", workflow_count));
}
if prompt_count > 0 {
parts.push(format!("{} prompt(s)", prompt_count));
}
let summary = parts.join(", ");
if error_count == 0 && warning_count == 0 {
println!(
" \x1b[1;32mFinished\x1b[0m checking {} - all valid",
summary
);
} else if error_count == 0 {
println!(
" \x1b[1;32mFinished\x1b[0m checking {} - {} valid with warnings",
summary, valid_count
);
}
}
if error_count > 0 {
std::process::exit(1);
}
Ok(())
}
async fn handle_bot(
platform: &str,
agent_override: Option<String>,
port: Option<u16>,
) -> Result<()> {
let config = JuglansConfig::load()?;
let current_dir = env::current_dir()?;
let project_root = find_project_root(¤t_dir)?;
match platform {
"telegram" => {
let agent_slug = agent_override
.or_else(|| {
config
.bot
.as_ref()
.and_then(|b| b.telegram.as_ref())
.map(|t| t.agent.clone())
})
.unwrap_or_else(|| "default".to_string());
adapters::telegram::start(config, project_root, agent_slug).await?;
}
"feishu" | "lark" => {
let agent_slug = agent_override
.or_else(|| {
config
.bot
.as_ref()
.and_then(|b| b.feishu.as_ref())
.map(|f| f.agent.clone())
})
.unwrap_or_else(|| "default".to_string());
let feishu_port = port.unwrap_or_else(|| {
config
.bot
.as_ref()
.and_then(|b| b.feishu.as_ref())
.map(|f| f.port)
.unwrap_or(9000)
});
adapters::feishu::start(config, project_root, agent_slug, feishu_port).await?;
}
"wechat" | "weixin" => {
adapters::wechat::start(config, project_root, agent_override).await?;
}
_ => {
return Err(anyhow!(
"Unknown platform '{}'. Supported: telegram, feishu, wechat",
platform
));
}
}
Ok(())
}
async fn handle_serve(
host: Option<String>,
port: Option<u16>,
entry: Option<PathBuf>,
) -> Result<()> {
let config = JuglansConfig::load()?;
let current_dir = env::current_dir()?;
let project_root = find_project_root(¤t_dir)?;
let entry_file = entry.unwrap_or_else(|| project_root.join("main.jg"));
if !entry_file.exists() {
return Err(anyhow!(
"Entry workflow not found: {:?}\nCreate a main.jg in your project root, or specify --entry <file>",
entry_file
));
}
let entry_slug = entry_file
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("main")
.to_string();
let final_host = host.unwrap_or_else(|| config.server.host.clone());
let final_port = port.unwrap_or(config.server.port);
let mut active_platforms: Vec<String> = vec![];
if let Some(ref bot) = config.bot {
if bot.telegram.is_some() && config.server.endpoint_url.is_none() {
let tg_config = config.clone();
let tg_root = project_root.clone();
let tg_slug = entry_slug.clone();
active_platforms.push("telegram (polling)".into());
tokio::spawn(async move {
if let Err(e) = adapters::telegram::start(tg_config, tg_root, tg_slug).await {
tracing::error!("Telegram bot error: {}", e);
}
});
}
if bot.wechat.is_some() {
let wx_config = config.clone();
let wx_root = project_root.clone();
let wx_slug = Some(entry_slug.clone());
active_platforms.push("wechat (polling)".into());
tokio::spawn(async move {
if let Err(e) = adapters::wechat::start(wx_config, wx_root, wx_slug).await {
tracing::error!("WeChat bot error: {}", e);
}
});
}
}
println!("──────────────────────────────────────────────────");
println!(" Juglans Serve");
println!(" Listening on: http://{}:{}", final_host, final_port);
println!(" Project: {}", project_root.display());
println!(" Entry: {}", entry_file.display());
println!(" Endpoints:");
println!(" POST /api/chat");
if !active_platforms.is_empty() {
println!(" Platforms: {}", active_platforms.join(", "));
}
println!("──────────────────────────────────────────────────");
web_server::start_web_server(final_host, final_port, project_root).await?;
Ok(())
}
async fn handle_skills(action: &SkillsAction) -> Result<()> {
match action {
SkillsAction::Add {
repo,
skills,
all,
list,
output,
} => {
if *list {
println!("Fetching skill list from {}...", repo);
let names = github::list_remote_skills(repo).await?;
if names.is_empty() {
println!("No skills found in {}", repo);
} else {
println!("Available skills in {}:", repo);
for name in &names {
println!(" - {}", name);
}
println!("\nUse: juglans skills add {} --skill <name>", repo);
}
return Ok(());
}
let skill_names = if *all {
println!("Fetching all skills from {}...", repo);
github::list_remote_skills(repo).await?
} else if skills.is_empty() {
return Err(anyhow!(
"No skill specified. Use --skill <name>, --all, or --list.\n\
Example: juglans skills add {} --skill pdf",
repo
));
} else {
skills.clone()
};
let prompts_dir = output.clone().unwrap_or_else(|| PathBuf::from("./prompts"));
fs::create_dir_all(&prompts_dir)?;
let temp_dir = env::temp_dir().join(format!("juglans-skills-{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir)?;
let fetched = github::fetch_skills(repo, &skill_names, &temp_dir).await?;
let mut success_count = 0;
for skill_entry in &fetched {
match skill_parser::load_skill_dir(&skill_entry.local_dir) {
Ok(skill) => {
let jgx_content = skill_parser::skill_to_jgx(&skill);
let output_path = prompts_dir.join(format!("{}.jgx", skill.name));
fs::write(&output_path, &jgx_content)?;
println!(" ✓ Saved: {}", output_path.display());
success_count += 1;
}
Err(e) => {
eprintln!(" ✗ Failed to parse skill '{}': {}", skill_entry.name, e);
}
}
}
let _ = fs::remove_dir_all(&temp_dir);
println!(
"\nDone! {} skill(s) saved to {}/",
success_count,
prompts_dir.display()
);
println!(
"Prompts in {} are loaded automatically by workflows.",
prompts_dir.display()
);
}
SkillsAction::List => {
let prompts_dir = PathBuf::from("./prompts");
if !prompts_dir.is_dir() {
println!("No prompts/ directory found. No skills installed.");
return Ok(());
}
let mut found = false;
if let Ok(entries) = fs::read_dir(&prompts_dir) {
for entry in entries.flatten() {
let path = entry.path();
if matches!(
path.extension().and_then(|e| e.to_str()),
Some("jgx" | "jgprompt")
) {
let name = path.file_stem().unwrap().to_string_lossy();
println!(" - {}", name);
found = true;
}
}
}
if !found {
println!("No .jgx files found in prompts/");
}
}
SkillsAction::Remove { name } => {
let path = PathBuf::from(format!("./prompts/{}.jgx", name));
if path.exists() {
fs::remove_file(&path)?;
println!("Removed: {}", path.display());
} else {
println!("Skill '{}' not found at {}", name, path.display());
}
}
}
Ok(())
}
fn handle_pack(path: Option<&Path>, output: Option<&Path>) -> Result<()> {
let dir = path
.map(PathBuf::from)
.unwrap_or_else(|| env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
let manifest_path = dir.join("jgpackage.toml");
if !manifest_path.exists() {
return Err(anyhow!("jgpackage.toml not found in {}", dir.display()));
}
let manifest = registry::package::PackageManifest::load(&manifest_path)?;
println!(
"Packing {} v{} ...",
manifest.package.name, manifest.package.version
);
let archive = registry::package::pack(&dir, output)?;
println!(" Created {}", archive.display());
Ok(())
}
async fn handle_publish(path: Option<&Path>) -> Result<()> {
let dir = path
.map(PathBuf::from)
.unwrap_or_else(|| env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
let manifest_path = dir.join("jgpackage.toml");
if !manifest_path.exists() {
return Err(anyhow!("jgpackage.toml not found in {}", dir.display()));
}
let manifest = registry::package::PackageManifest::load(&manifest_path)?;
println!(
"Publishing {} v{} ...",
manifest.package.name, manifest.package.version
);
let archive = registry::package::pack(&dir, None)?;
let config = JuglansConfig::load()?;
let registry_url = config
.registry
.as_ref()
.map(|r| r.url.as_str())
.unwrap_or("https://jgr.juglans.ai");
let api_key = std::env::var("JUGLANS_REGISTRY_API_KEY")
.or_else(|_| std::env::var("REGISTRY_API_KEY"))
.map_err(|_| {
anyhow!(
"JUGLANS_REGISTRY_API_KEY (or REGISTRY_API_KEY) env var is required for publishing"
)
})?;
let api_key = api_key.as_str();
let url = format!(
"{}/api/v1/packages/{}/{}",
registry_url.trim_end_matches('/'),
manifest.package.name,
manifest.package.version
);
let file_bytes = fs::read(&archive)?;
let part = reqwest::multipart::Part::bytes(file_bytes)
.file_name(archive.file_name().unwrap().to_string_lossy().to_string())
.mime_str("application/gzip")?;
let metadata = serde_json::json!({
"name": manifest.package.name,
"version": manifest.package.version,
"slug": manifest.slug(),
"description": manifest.package.description,
"author": manifest.package.author,
"license": manifest.package.license,
"entry": manifest.package.entry,
"dependencies": manifest.dependencies,
});
let meta_part =
reqwest::multipart::Part::text(metadata.to_string()).mime_str("application/json")?;
let form = reqwest::multipart::Form::new()
.part("metadata", meta_part)
.part("package", part);
let client = reqwest::Client::new();
let resp = client
.put(&url)
.header("Authorization", format!("Bearer {}", api_key))
.multipart(form)
.send()
.await?;
if resp.status().is_success() {
println!(
" Published {}-{} to {}",
manifest.package.name, manifest.package.version, registry_url
);
} else {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(anyhow!("Publish failed ({}): {}", status, body));
}
let _ = fs::remove_file(&archive);
Ok(())
}
async fn handle_add(package: &str) -> Result<()> {
let config = JuglansConfig::load()?;
let registry_url = config
.registry
.as_ref()
.map(|r| r.url.as_str())
.unwrap_or("https://jgr.juglans.ai");
let installer = registry::installer::PackageInstaller::with_defaults(registry_url)?;
let project_dir = env::current_dir()?;
let installed = installer.install_from_import(package, &project_dir).await?;
let manifest_path = project_dir.join("jgpackage.toml");
if manifest_path.exists() {
let content = fs::read_to_string(&manifest_path)?;
let mut doc: toml::Table = toml::from_str(&content)?;
let deps = doc
.entry("dependencies")
.or_insert_with(|| toml::Value::Table(toml::Table::new()));
if let toml::Value::Table(deps_table) = deps {
let constraint = format!("^{}", installed.version);
deps_table.insert(installed.name.clone(), toml::Value::String(constraint));
}
fs::write(&manifest_path, toml::to_string_pretty(&doc)?)?;
}
println!(
"Added {}@{} → jg_modules/{}",
installed.name, installed.version, installed.name
);
Ok(())
}
fn handle_remove(package: &str) -> Result<()> {
let project_dir = env::current_dir()?;
let manifest_path = project_dir.join("jgpackage.toml");
if manifest_path.exists() {
let content = fs::read_to_string(&manifest_path)?;
let mut doc: toml::Table = toml::from_str(&content)?;
if let Some(toml::Value::Table(deps)) = doc.get_mut("dependencies") {
deps.remove(package);
}
fs::write(&manifest_path, toml::to_string_pretty(&doc)?)?;
}
let config = JuglansConfig::load().ok();
let registry_url = config
.as_ref()
.and_then(|c| c.registry.as_ref())
.map(|r| r.url.as_str())
.unwrap_or("https://jgr.juglans.ai");
let installer = registry::installer::PackageInstaller::with_defaults(registry_url)?;
installer.unlink(package, &project_dir)?;
let mut lock = registry::lock::LockFile::load(&project_dir).unwrap_or_default();
lock.remove(package);
let _ = lock.save(&project_dir);
println!("Removed {}", package);
Ok(())
}
async fn handle_tui(agent: Option<&Path>) -> Result<()> {
let mut app = ui::tui::app::App::new();
if let Some(path) = agent {
app.pending_agent_load = Some(path.to_path_buf());
}
ui::tui::run(app).await
}
async fn handle_cron(file: &PathBuf, schedule_override: Option<&str>) -> Result<()> {
use cron::Schedule;
use std::str::FromStr;
let absolute_path =
fs::canonicalize(file).with_context(|| format!("Cannot resolve {:?}", file))?;
let source_raw = fs::read_to_string(&absolute_path)?;
let ext = absolute_path
.extension()
.and_then(|s| s.to_str())
.unwrap_or("");
let manifest_schedule = if ext == "jgflow" {
GraphParser::parse_manifest(&source_raw)?.schedule.clone()
} else {
None
};
let cron_expr = schedule_override
.map(|s| s.to_string())
.or(manifest_schedule)
.ok_or_else(|| {
anyhow!(
"No schedule found. Add `schedule: \"0 * * * *\"` to your .jgflow \
or use --schedule \"0 * * * *\""
)
})?;
let full_expr = if cron_expr.split_whitespace().count() == 5 {
format!("0 {}", cron_expr)
} else {
cron_expr.clone()
};
let schedule = Schedule::from_str(&full_expr)
.map_err(|e| anyhow!("Invalid cron expression '{}': {}", cron_expr, e))?;
println!("⏰ Cron scheduler started for: {}", absolute_path.display());
println!(" Schedule: {}", cron_expr);
let upcoming: Vec<_> = schedule.upcoming(chrono::Utc).take(3).collect();
for (i, t) in upcoming.iter().enumerate() {
println!(" Next {}: {}", i + 1, t.format("%Y-%m-%d %H:%M:%S UTC"));
}
println!(" Press Ctrl+C to stop.\n");
loop {
let now = chrono::Utc::now();
let next = schedule
.upcoming(chrono::Utc)
.next()
.ok_or_else(|| anyhow!("No upcoming schedule"))?;
let wait = (next - now)
.to_std()
.unwrap_or(std::time::Duration::from_secs(1));
println!(
"⏳ Waiting until {} ({:.0}s)...",
next.format("%H:%M:%S UTC"),
wait.as_secs_f64()
);
tokio::time::sleep(wait).await;
println!(
"🚀 Executing workflow at {}...",
chrono::Utc::now().format("%H:%M:%S UTC")
);
let cli = Cli {
file: Some(absolute_path.clone()),
command: None,
input: None,
input_file: None,
dry_run: false,
output: None,
output_format: "text".to_string(),
chat_id: None,
verbose: false,
info: false,
};
match handle_file_logic(&cli).await {
Ok(_) => println!("✅ Execution completed.\n"),
Err(e) => println!("❌ Execution failed: {}\n", e),
}
}
}
async fn handle_test(path: Option<&Path>, filter: Option<&str>, format: &str) -> Result<()> {
use testing::{reporter, TestRunner};
let search_path = path.unwrap_or_else(|| Path::new("./tests"));
let test_files = TestRunner::discover_test_files(search_path)?;
if test_files.is_empty() {
println!("\n No test files found in {}\n", search_path.display());
println!(" Test files are .jg files containing [test_*] nodes.");
println!(" Default search path: ./tests/\n");
return Ok(());
}
let config = JuglansConfig::load()?;
let runtime: Arc<LocalRuntime> = Arc::new(LocalRuntime::new_with_config(&config.ai));
let prompt_registry = Arc::new(PromptRegistry::new());
let runner = TestRunner::new(runtime, prompt_registry);
let mut all_results = Vec::new();
for file in &test_files {
match runner.run_file_filtered(file, filter).await {
Ok(file_result) => {
all_results.push(file_result);
}
Err(e) => {
eprintln!(" Error running {}: {}", file.display(), e);
}
}
}
match format {
"json" => reporter::print_json(&all_results),
"junit" => reporter::print_junit(&all_results),
_ => reporter::print_text(&all_results),
}
let total_failed: usize = all_results.iter().map(|f| f.failed_count()).sum();
if total_failed > 0 {
std::process::exit(1);
}
Ok(())
}
#[tokio::main]
async fn main() -> Result<()> {
let application_cli = Cli::parse();
let is_tui = matches!(application_cli.command, Some(Commands::Chat { .. }));
let default_filter = if is_tui {
"off"
} else {
"juglans=info,tower_http=info"
};
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(default_filter));
tracing_subscriber::fmt().with_env_filter(filter).init();
if let Some(sub_command_enum) = &application_cli.command {
match sub_command_enum {
Commands::Init { name } => handle_init(name)?,
Commands::Install => handle_install().await?,
Commands::Check { path, all, format } => {
handle_check(path.as_deref(), *all, format)?;
}
Commands::Web { port, host } => {
let current_dir = env::current_dir()?;
let root = find_project_root(¤t_dir)?;
let config = JuglansConfig::load().ok();
let final_host = host
.clone()
.or_else(|| config.as_ref().map(|c| c.server.host.clone()))
.unwrap_or_else(|| "127.0.0.1".to_string());
let final_port = port
.or_else(|| config.as_ref().map(|c| c.server.port))
.unwrap_or(8080);
web_server::start_web_server(final_host, final_port, root).await?;
}
Commands::Whoami { verbose } => {
handle_whoami(*verbose).await?;
}
Commands::Serve { port, host, entry } => {
handle_serve(host.clone(), *port, entry.clone()).await?;
}
Commands::Bot {
platform,
agent,
port,
} => {
handle_bot(platform, agent.clone(), *port).await?;
}
Commands::Skills { action } => {
handle_skills(action).await?;
}
Commands::Pack { path, output } => {
handle_pack(path.as_deref(), output.as_deref())?;
}
Commands::Publish { path } => {
handle_publish(path.as_deref()).await?;
}
Commands::Add { package } => {
handle_add(package).await?;
}
Commands::Remove { package } => {
handle_remove(package)?;
}
Commands::Chat { agent } => {
handle_tui(agent.as_deref()).await?;
}
Commands::Deploy {
tag,
port,
build_only,
push,
stop,
status,
env_vars,
path,
} => {
services::deploy::handle_deploy(services::deploy::DeployConfig {
tag: tag.clone(),
port: *port,
build_only: *build_only,
push: *push,
stop: *stop,
status: *status,
env_vars: env_vars.clone(),
path: path.clone(),
})?;
}
Commands::Cron { file, schedule } => {
handle_cron(file, schedule.as_deref()).await?;
}
Commands::Lsp => {
lsp::run_server().await?;
}
Commands::Test {
path,
filter,
format,
} => {
handle_test(path.as_deref(), filter.as_deref(), format).await?;
}
Commands::Doctest { path, format } => {
let target = path.as_deref().unwrap_or(Path::new("./docs"));
juglans::doctest::run_doctest(target, format)?;
}
}
} else if application_cli.file.is_some() {
handle_file_logic(&application_cli).await?;
} else {
println!("JWL Language Runtime (Multipurpose CLI)\nUse --help for command list.");
}
Ok(())
}