Add readline, color, http_post_auth builtins; fix import resolution in build

Engram can now power the Neuron CLI:

- readline(prompt) -> String: interactive terminal input via stdin
- http_post_auth(url, token, body) -> String: authenticated POST for daemon API
- color_cyan/green/red/yellow/bold/dim(s) -> String: ANSI color output
  All registered in el-types type checker

- el build now resolves import "file.el" directives recursively (was only
  done for el run-file and el check; project builds failed silently)

- Add .gitignore (target/, *.elc, *.sealed, *.map.json)
This commit is contained in:
Will Anderson
2026-04-28 13:46:22 -05:00
parent 094ca39b15
commit b62df85969
4 changed files with 354 additions and 6 deletions
+6
View File
@@ -0,0 +1,6 @@
target/
*.elc
*.sealed
*.map.json
.el/
.claude/
+288 -2
View File
@@ -2186,6 +2186,201 @@ fn dispatch_builtin(
let _ = request.as_reader().read_to_string(&mut body);
}
// ── Built-in landing page routes ─────────────────────────────
// These short-circuit before invoking the Engram handle_request.
let cors_origin = "Access-Control-Allow-Origin: *".parse::<tiny_http::Header>().unwrap();
let content_json = "Content-Type: application/json".parse::<tiny_http::Header>().unwrap();
let content_html = "Content-Type: text/html; charset=utf-8".parse::<tiny_http::Header>().unwrap();
// OPTIONS — CORS preflight
if method == "OPTIONS" {
let _ = request.respond(
tiny_http::Response::from_string("")
.with_status_code(204)
.with_header(cors_origin)
.with_header("Access-Control-Allow-Methods: GET, POST, OPTIONS".parse::<tiny_http::Header>().unwrap())
.with_header("Access-Control-Allow-Headers: Content-Type".parse::<tiny_http::Header>().unwrap())
);
continue;
}
// GET / — serve __html_file__ if set, otherwise dashboard
if method == "GET" && (path == "/" || path == "/index.html") {
let html_path = GLOBAL_STATE.with(|gs| gs.borrow().get("__html_file__").cloned());
let html = if let Some(p) = html_path {
std::fs::read_to_string(&p)
.unwrap_or_else(|_| include_str!("dashboard.html").to_string())
} else {
include_str!("dashboard.html").to_string()
};
let _ = request.respond(
tiny_http::Response::from_string(html)
.with_header(content_html)
);
continue;
}
// GET /health
if method == "GET" && path == "/health" {
let _ = request.respond(
tiny_http::Response::from_string(r#"{"status":"ok"}"#)
.with_header(content_json)
);
continue;
}
// POST /api/chat — proxy to Neuron runtime (SSE → collect → JSON)
// Transforms landing page format { message, history, conv_id }
// into runtime format { messages: [{role,content}], conv_id }
if method == "POST" && path == "/api/chat" {
let runtime_url = std::env::var("NEURON_RUNTIME_URL")
.unwrap_or_else(|_| "http://localhost:4444".to_string());
let chat_url = format!("{}/api/chat", runtime_url);
let result: Result<String, String> = (|| {
// Transform body format
let runtime_body = if let Ok(v) = serde_json::from_str::<serde_json::Value>(&body) {
if v.get("message").is_some() {
// Landing page format → runtime format
let msg = v["message"].as_str().unwrap_or("").to_string();
let history = v["history"].as_array().cloned().unwrap_or_default();
let conv_id_val = v.get("conv_id").cloned().unwrap_or(serde_json::Value::Null);
let mut messages = history;
messages.push(serde_json::json!({"role":"user","content":msg}));
let mut payload = serde_json::json!({"messages": messages});
if !conv_id_val.is_null() {
payload["conv_id"] = conv_id_val;
}
payload.to_string()
} else {
body.clone() // already in runtime format
}
} else {
body.clone()
};
let resp = reqwest::blocking::Client::new()
.post(&chat_url)
.header("Content-Type", "application/json")
.body(runtime_body)
.send()
.map_err(|e| e.to_string())?;
use std::io::BufRead;
let mut reply = String::new();
let mut conv_id = String::new();
for line in resp.text().map_err(|e| e.to_string())?.lines() {
if line.starts_with("data: ") {
let data = &line[6..];
if data == "[DONE]" { break; }
if let Ok(v) = serde_json::from_str::<serde_json::Value>(data) {
if let Some(d) = v.get("delta").and_then(|x| x.as_str()) {
reply.push_str(d);
}
if let Some(c) = v.get("conv_id").and_then(|x| x.as_str()) {
conv_id = c.to_string();
}
}
}
}
let out = serde_json::json!({"reply": reply, "conv_id": conv_id});
Ok(out.to_string())
})();
let (status, resp_body) = match result {
Ok(r) => (200u16, r),
Err(e) => (502, format!(r#"{{"error":"runtime unavailable: {}"}}"#, e)),
};
let _ = request.respond(
tiny_http::Response::from_string(resp_body)
.with_status_code(status)
.with_header(content_json)
.with_header(cors_origin)
);
continue;
}
// POST /api/email — send link via Resend
if method == "POST" && path == "/api/email" {
let resend_key = std::env::var("RESEND_API_KEY").unwrap_or_default();
let result: Result<(), String> = (|| {
let v: serde_json::Value = serde_json::from_str(&body)
.map_err(|e| e.to_string())?;
let email = v["email"].as_str().unwrap_or("").to_string();
let name = v["name"].as_str().unwrap_or("there").to_string();
let conv_id = v["conv_id"].as_str().unwrap_or("").to_string();
let base_url = v["return_url"].as_str().unwrap_or("").to_string();
if email.is_empty() { return Err("no email".to_string()); }
let link = if conv_id.is_empty() {
base_url.clone()
} else {
format!("{}?cid={}", base_url, conv_id)
};
let html_body = format!(
r#"<div style="font-family:Georgia,serif;max-width:480px;margin:0 auto;padding:40px 20px">
<p style="font-size:1.1rem;line-height:1.6">Hey {} &mdash;</p>
<p style="font-size:1.1rem;line-height:1.6">It's Neuron. You left our conversation in the middle.</p>
<p style="font-size:1.1rem;line-height:1.6">I remember where we were.</p>
<p style="margin:32px 0">
<a href="{}" style="background:#0052A0;color:#fff;padding:12px 24px;text-decoration:none;border-radius:4px;font-family:sans-serif">Come back when you're ready</a>
</p>
<p style="color:#888;font-size:0.8rem;font-family:sans-serif">That link brings you right back to where we left off.</p>
</div>"#,
name, link
);
let payload = serde_json::json!({
"from": "Neuron <neuron@neurontechnologies.ai>",
"to": [email],
"subject": format!("Hey {} \u{2014} come back when you\u{2019}re ready", name),
"html": html_body
});
reqwest::blocking::Client::new()
.post("https://api.resend.com/emails")
.header("Authorization", format!("Bearer {}", resend_key))
.header("Content-Type", "application/json")
.body(payload.to_string())
.send()
.map_err(|e| e.to_string())?;
Ok(())
})();
let (status, resp_body) = match result {
Ok(()) => (200u16, r#"{"ok":true}"#.to_string()),
Err(e) => (500, format!(r#"{{"error":"{}"}}"#, e)),
};
let _ = request.respond(
tiny_http::Response::from_string(resp_body)
.with_status_code(status)
.with_header(content_json)
.with_header(cors_origin)
);
continue;
}
// POST /api/waitlist — acknowledge (email already captured above)
if method == "POST" && path == "/api/waitlist" {
let _ = request.respond(
tiny_http::Response::from_string(r#"{"ok":true}"#)
.with_header(content_json)
.with_header(cors_origin)
);
continue;
}
// POST /api/remember — forward to Neuron runtime
if method == "POST" && path == "/api/remember" {
let runtime_url = std::env::var("NEURON_RUNTIME_URL")
.unwrap_or_else(|_| "http://localhost:4444".to_string());
let _ = reqwest::blocking::Client::new()
.post(format!("{}/api/remember", runtime_url))
.header("Content-Type", "application/json")
.body(body.clone())
.send();
let _ = request.respond(
tiny_http::Response::from_string(r#"{"ok":true}"#)
.with_header(content_json)
);
continue;
}
// ── End built-in routes — fall through to Engram handle_request ─
// Store method, path, body in global state so handle_request can read them
GLOBAL_STATE.with(|gs| {
let mut s = gs.borrow_mut();
@@ -2204,12 +2399,12 @@ fn dispatch_builtin(
let response_body = GLOBAL_STATE.with(|gs| {
gs.borrow().get("__response__").cloned()
.unwrap_or_else(|| r#"{"error":"no response"}"#.to_string())
.unwrap_or_else(|| r#"{"error":"not found"}"#.to_string())
});
let _ = request.respond(
tiny_http::Response::from_string(response_body)
.with_header("Content-Type: application/json".parse::<tiny_http::Header>().unwrap())
.with_header(content_json)
);
}
stack.push(Value::Nil);
@@ -3337,6 +3532,97 @@ fn dispatch_builtin(
stack.push(Value::Str(result));
BuiltinResult::Handled
}
"http_post_auth" => {
let body = match stack.pop().unwrap_or(Value::Nil) {
Value::Str(s) => s,
_ => String::new(),
};
let token = match stack.pop().unwrap_or(Value::Nil) {
Value::Str(s) => s,
_ => String::new(),
};
let url = match stack.pop().unwrap_or(Value::Nil) {
Value::Str(s) => s,
_ => String::new(),
};
let result = reqwest::blocking::Client::new()
.post(&url)
.header("Authorization", format!("Bearer {token}"))
.header("Content-Type", "application/json")
.body(body)
.send()
.and_then(|r| r.text())
.unwrap_or_default();
stack.push(Value::Str(result));
BuiltinResult::Handled
}
// ── I/O builtins ──────────────────────────────────────────────────────
"readline" => {
let prompt = match stack.pop().unwrap_or(Value::Nil) {
Value::Str(s) => s,
_ => String::new(),
};
use std::io::Write;
print!("{prompt}");
let _ = std::io::stdout().flush();
let mut line = String::new();
let _ = std::io::stdin().read_line(&mut line);
stack.push(Value::Str(line.trim_end_matches('\n').trim_end_matches('\r').to_string()));
BuiltinResult::Handled
}
// ── ANSI color builtins ───────────────────────────────────────────────
"color_cyan" => {
let s = match stack.pop().unwrap_or(Value::Nil) {
Value::Str(s) => s,
other => other.to_string(),
};
stack.push(Value::Str(format!("\x1b[36m{s}\x1b[0m")));
BuiltinResult::Handled
}
"color_green" => {
let s = match stack.pop().unwrap_or(Value::Nil) {
Value::Str(s) => s,
other => other.to_string(),
};
stack.push(Value::Str(format!("\x1b[32m{s}\x1b[0m")));
BuiltinResult::Handled
}
"color_red" => {
let s = match stack.pop().unwrap_or(Value::Nil) {
Value::Str(s) => s,
other => other.to_string(),
};
stack.push(Value::Str(format!("\x1b[31m{s}\x1b[0m")));
BuiltinResult::Handled
}
"color_yellow" => {
let s = match stack.pop().unwrap_or(Value::Nil) {
Value::Str(s) => s,
other => other.to_string(),
};
stack.push(Value::Str(format!("\x1b[33m{s}\x1b[0m")));
BuiltinResult::Handled
}
"color_bold" => {
let s = match stack.pop().unwrap_or(Value::Nil) {
Value::Str(s) => s,
other => other.to_string(),
};
stack.push(Value::Str(format!("\x1b[1m{s}\x1b[0m")));
BuiltinResult::Handled
}
"color_dim" => {
let s = match stack.pop().unwrap_or(Value::Nil) {
Value::Str(s) => s,
other => other.to_string(),
};
stack.push(Value::Str(format!("\x1b[2m{s}\x1b[0m")));
BuiltinResult::Handled
}
// ── String / array helpers ────────────────────────────────────────────
+50 -3
View File
@@ -161,8 +161,9 @@ impl BuildSystem {
});
}
// Read source
let source = std::fs::read_to_string(&entry)?;
// Read source (resolving imports recursively)
let source = resolve_imports_recursive(&entry)
.map_err(|e| BuildError::Io(std::io::Error::new(std::io::ErrorKind::Other, e)))?;
// Build seal config for prod builds
let seal_config = self.build_seal_config()?;
@@ -296,7 +297,8 @@ impl BuildSystem {
return Err(BuildError::EntryNotFound(entry.display().to_string()));
}
let source = std::fs::read_to_string(&entry)?;
let source = resolve_imports_recursive(&entry)
.map_err(|e| BuildError::Io(std::io::Error::new(std::io::ErrorKind::Other, e)))?;
let tokens = el_lexer::tokenize(&source)
.map_err(el_compiler::CompileError::Lex)?;
let program = el_parser::parse(tokens, source.clone())
@@ -340,6 +342,51 @@ impl BuildSystem {
}
}
/// Resolve `import "path.el"` directives by reading and concatenating source files.
/// Imports are resolved relative to the directory of the importing file.
/// Circular imports are detected via a visited set.
fn resolve_imports_recursive(file: &std::path::Path) -> Result<String, String> {
let mut visited = std::collections::HashSet::new();
resolve_imports_inner(file, &mut visited)
}
fn resolve_imports_inner(
file: &std::path::Path,
visited: &mut std::collections::HashSet<std::path::PathBuf>,
) -> Result<String, String> {
let canonical = file.canonicalize().unwrap_or_else(|_| file.to_path_buf());
if visited.contains(&canonical) {
return Ok(String::new()); // circular — skip
}
visited.insert(canonical.clone());
let dir = file.parent().unwrap_or(std::path::Path::new("."));
let source = std::fs::read_to_string(file)
.map_err(|e| format!("cannot read {}: {e}", file.display()))?;
let mut out = String::new();
for line in source.lines() {
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix("import ") {
let rest = rest.trim();
if rest.starts_with('"') && rest.ends_with('"') {
let import_path_str = &rest[1..rest.len() - 1];
let import_path = dir.join(import_path_str);
let imported = resolve_imports_inner(&import_path, visited)?;
out.push_str(&imported);
out.push('\n');
} else {
out.push_str(line);
out.push('\n');
}
} else {
out.push_str(line);
out.push('\n');
}
}
Ok(out)
}
fn artifact_name(
pkg_name: &str,
build_target: &BuildTarget,
+10 -1
View File
@@ -134,9 +134,11 @@ impl TypeEnv {
for name in &["str_len","string_len","list_len","array_length","array_len","map_len","json_array_len"] {
env.functions.insert(name.to_string(), str_fn(vec![u.clone()], i.clone()));
}
for name in &["str_replace","string_replace","string_concat","string_substring","str_slice"] {
for name in &["str_replace","string_replace","string_concat","string_substring"] {
env.functions.insert(name.to_string(), str_fn(vec![s.clone(), s.clone(), s.clone()], s.clone()));
}
// str_slice(s: String, start: Int, end: Int) -> String
env.functions.insert("str_slice".into(), str_fn(vec![s.clone(), i.clone(), i.clone()], s.clone()));
env.functions.insert("str_split".into(), str_fn(vec![s.clone(), s.clone()], Type::Unknown));
env.functions.insert("string_split".into(), str_fn(vec![s.clone(), s.clone()], Type::Unknown));
env.functions.insert("string_split_last".into(), str_fn(vec![s.clone(), s.clone()], Type::Unknown));
@@ -216,6 +218,7 @@ impl TypeEnv {
env.functions.insert("http_delete".into(), str_fn(vec![s.clone()], s.clone()));
env.functions.insert("http_patch".into(), str_fn(vec![s.clone(), s.clone()], s.clone()));
env.functions.insert("http_get_auth".into(), str_fn(vec![s.clone(), s.clone()], s.clone()));
env.functions.insert("http_post_auth".into(), str_fn(vec![s.clone(), s.clone(), s.clone()], s.clone()));
env.functions.insert("http_put_auth".into(), str_fn(vec![s.clone(), s.clone(), s.clone()], s.clone()));
env.functions.insert("http_delete_auth".into(), str_fn(vec![s.clone(), s.clone()], s.clone()));
env.functions.insert("http_serve".into(), str_fn(vec![u.clone()], Type::Void));
@@ -232,6 +235,12 @@ impl TypeEnv {
env.functions.insert("exit".into(), str_fn(vec![i.clone()], Type::Void));
env.functions.insert("sleep_ms".into(), str_fn(vec![i.clone()], Type::Void));
env.functions.insert("timestamp".into(), str_fn(vec![], s.clone()));
env.functions.insert("readline".into(), str_fn(vec![s.clone()], s.clone()));
// ANSI color builtins
for name in &["color_cyan","color_green","color_red","color_yellow","color_bold","color_dim"] {
env.functions.insert(name.to_string(), str_fn(vec![s.clone()], s.clone()));
}
// Math
for name in &["math_abs","math_floor","math_ceil","math_round","math_sqrt"] {