1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
use anyhow::Result;
use crossterm::style::{Color, Stylize};
use std::io::{self, Write};
use crate::{
config::{get_config, Theme},
env::Env,
eval::eval_program,
parser, // must expose `pub fn parse_program(&str) -> anyhow::Result<Vec<crate::ast::Stmt>>`
value::Value,
};
/// Parse a hex color string to crossterm Color
fn parse_hex_color(hex: &str) -> Color {
let hex = hex.trim_start_matches('#');
if hex.len() == 6 {
if let (Ok(r), Ok(g), Ok(b)) = (
u8::from_str_radix(&hex[0..2], 16),
u8::from_str_radix(&hex[2..4], 16),
u8::from_str_radix(&hex[4..6], 16),
) {
return Color::Rgb { r, g, b };
}
}
Color::White // Fallback
}
/// Get the current theme colors
fn get_theme_colors() -> crate::config::CustomColors {
let config = get_config();
if config.colors.theme == "custom" {
config.colors.custom.clone()
} else {
Theme::from_str(&config.colors.theme).colors()
}
}
/// Load persisted history, oldest first.
///
/// A missing or unreadable history file is not an error — a fresh shell simply
/// starts with no suggestions.
fn load_history() -> Vec<String> {
let config = get_config();
if !config.history.enabled {
return Vec::new();
}
let path = crate::config::ShellConfig::history_file();
std::fs::read_to_string(path)
.map(|s| {
s.lines()
.filter(|l| !l.trim().is_empty())
.map(|l| l.to_string())
.collect()
})
.unwrap_or_default()
}
/// Append one command to the history file, honoring the ignore rules.
fn append_history(command: &str, previous: Option<&String>) {
let config = get_config();
if !config.history.enabled {
return;
}
if config.history.ignore_space && command.starts_with(' ') {
return;
}
if config.history.ignore_duplicates && previous.is_some_and(|p| p == command) {
return;
}
let path = crate::config::ShellConfig::history_file();
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
{
use std::io::Write as _;
let _ = writeln!(f, "{command}");
}
}
/// Interactive REPL. Ctrl-D exits or type 'exit'/'quit'.
pub fn run(env: &mut Env) -> Result<()> {
use crate::line_editor::{read_line, EditAction, LineState};
use crate::prompt::{render_line, render_transient, PromptContext};
let mut stdout = io::stdout();
let config = get_config();
// Show banner if enabled
if config.shell.show_banner {
if config.colors.enabled {
writeln!(
stdout,
"{}",
"Æther REPL — type 'exit', 'quit', or Ctrl-D to exit".dark_grey()
)?;
} else {
writeln!(
stdout,
"Æther REPL — type 'exit', 'quit', or Ctrl-D to exit"
)?;
}
stdout.flush()?;
}
let mut history = load_history();
// Exit status and duration of the previous command feed the prompt's
// status/duration segments.
let mut last_status = 0i32;
let mut last_duration_ms = 0u64;
loop {
// Re-read per line, so a `config_reload()` typed here changes the prompt.
let config = get_config();
let ctx = PromptContext::current(&config.prompt).with_result(last_status, last_duration_ms);
let rendered = render_line(&config.prompt, &ctx);
let mut state = LineState::new(&config.prompt, history.clone());
let action = read_line(&rendered, &mut state, config.colors.enabled)?;
let code = match action {
EditAction::Submit(line) => line,
EditAction::Cancelled => {
last_status = 130; // 128 + SIGINT, as a shell reports it
continue;
}
EditAction::Eof => {
writeln!(stdout)?;
break;
}
// read_line only returns terminal actions; Continue/ClearScreen are
// handled inside its event loop.
_ => continue,
};
let code = code.trim();
if code.is_empty() {
continue;
}
if code == "exit" || code == "quit" {
break;
}
// Replace the full prompt with a compact one now that the command has
// been submitted, so scrollback stays readable (oh-my-posh's
// "transient prompt").
if config.prompt.transient && config.colors.enabled {
write!(
stdout,
"\x1b[1A\r\x1b[2K{}{}\n",
render_transient(&config.prompt, &ctx),
code
)?;
stdout.flush()?;
}
append_history(code, history.last());
history.push(code.to_string());
let started = std::time::Instant::now();
let result = eval_line(env, code);
last_duration_ms = started.elapsed().as_millis() as u64;
crate::record::note(
crate::record::Surface::Repl,
code,
&result,
started.elapsed(),
);
match result {
Ok(v) => {
last_status = 0;
if let Some(out) = render_for_repl(&v) {
writeln!(stdout, "{out}")?;
}
}
Err(e) => {
last_status = 1;
if config.colors.enabled {
writeln!(stdout, "{} {e}", "error:".red().bold())?;
} else {
writeln!(stdout, "error: {e}")?;
}
}
}
}
Ok(())
}
/// The most bytes a one-shot `-c` prints, from `IRONSHELL_MAX_OUTPUT_BYTES`.
/// Unset means no cap. A value that is not a number is ignored with a warning
/// on stderr, so a typo does not silently lift the cap.
fn max_output_bytes() -> Option<usize> {
let raw = std::env::var("IRONSHELL_MAX_OUTPUT_BYTES").ok()?;
match raw.trim().parse::<usize>() {
Ok(max) => Some(max),
Err(_) => {
eprintln!(
"ish: IRONSHELL_MAX_OUTPUT_BYTES={raw:?} is not a byte count; no cap applied"
);
None
}
}
}
/// Print the result of a one-shot `-c`, cut at [`max_output_bytes`].
///
/// A cut lands on the last UTF-8 boundary at or before the cap, so the output
/// is still valid text. The bytes left out are counted on stderr. The exit
/// status is decided before this runs, so a cut never changes it.
fn print_result(out: &str) {
let Some(max) = max_output_bytes() else {
println!("{out}");
return;
};
if out.len() <= max {
println!("{out}");
return;
}
let mut keep = max;
while !out.is_char_boundary(keep) {
keep -= 1;
}
println!("{}", &out[..keep]);
eprintln!(
"ish: output cut at IRONSHELL_MAX_OUTPUT_BYTES={max}: {} bytes omitted",
out.len() - keep
);
}
/// One-liner (e.g. `ish -c 'code'`)
pub fn run_one(env: &mut Env, code: &str) -> Result<i32> {
run_one_as(env, code, crate::record::Surface::C)
}
/// [`run_one`] for a given surface, which only the recorder reads.
pub fn run_one_as(env: &mut Env, code: &str, surface: crate::record::Surface) -> Result<i32> {
let started = std::time::Instant::now();
let result = eval_line(env, code);
crate::record::note(surface, code, &result, started.elapsed());
match result {
Ok(v) => {
let budget = std::env::var("IRONSHELL_TOKEN_BUDGET")
.ok()
.and_then(|s| s.parse::<usize>().ok())
.filter(|m| *m > 0);
// Deterministic mode (`--deterministic` / IRONSHELL_DETERMINISTIC) takes
// precedence over every other renderer: canonical, byte-stable JSON for
// snapshot tests / caching / diffs. The whole value is emitted (budget
// is intentionally not applied — reproducibility wants the full result).
let deterministic = std::env::var("IRONSHELL_DETERMINISTIC")
.map(|s| s == "1" || s.eq_ignore_ascii_case("true"))
.unwrap_or(false);
if deterministic {
// Byte-stable output must not become the way around redaction:
// in agent mode the canonical JSON is scrubbed as the agent
// renderer's output is.
let v = if crate::safety::current_mode() == crate::safety::Mode::Agent
&& crate::safety::redaction_enabled()
{
crate::builtins::redact_value(&v)
} else {
v
};
if let Some(out) = crate::builtins::render_canonical(&v) {
print_result(&out);
}
return Ok(0);
}
// Agent mode renders results as compact, deterministic AECON by
// default (keys once, the structural levers, no ANSI) — the token
// savings happen automatically instead of requiring an explicit
// `| aecon`. The human REPL keeps its colorized pretty-printer.
if crate::safety::current_mode() == crate::safety::Mode::Agent {
if let Some(out) = crate::builtins::render_agent(&v, budget) {
print_result(&out);
}
return Ok(0);
}
// Human path: apply an output token budget when IRONSHELL_TOKEN_BUDGET is set
// (e.g. via `--budget`), then pretty-print.
let v = match budget {
Some(max) => crate::builtins::budget_value(&v, max, 0),
None => v,
};
if let Some(out) = render_for_repl(&v) {
print_result(&out);
}
Ok(0)
}
Err(e) => {
print_eval_error(&e, colors_enabled_stderr());
Ok(exit_status_for(&e))
}
}
}
/// The exit status a failure leaves behind.
///
/// Every failure used to exit 1, which scored 0 of 10 on exit-status
/// granularity against bash's 5 of 10 (`benches/agentic/errors.mjs`). The
/// taxonomy already existed; it just never reached the process boundary.
/// See [`crate::safety::ErrorCode::exit_code`] for the mapping and why two of
/// them borrow bash's numbers.
pub fn exit_status_for(e: &anyhow::Error) -> i32 {
if let Some(se) = e.downcast_ref::<crate::safety::SafetyError>() {
return se.code.exit_code();
}
// A parse failure is a `ParseError` rather than a `SafetyError`, because the
// parser builds for wasm and `safety` does not. bash exits 2 on a syntax
// error; so do we.
if e.downcast_ref::<crate::parser::ParseError>().is_some() {
return 2;
}
1
}
/// Render an uncaught evaluation error.
///
/// A [`crate::safety::SafetyError`]'s `Display` is JSON (so agents can branch on
/// it). For a **human** at the REPL that's noise, so we unpack it into legible
/// prose — `error[CODE]: message`, a `hint:` line, and (for an approvable action)
/// the exact re-run incantation. **Agent mode keeps the raw JSON** so the
/// structured `code`/`hint`/`approval` survive for programmatic self-correction.
/// Non-safety errors print as plain prose in both modes.
fn print_eval_error(e: &anyhow::Error, color: bool) {
use crate::safety::{current_mode, Mode, SafetyError};
if current_mode() == Mode::Agent {
// Structured form is what an agent reads — emit it verbatim.
eprintln!("{e}");
return;
}
// A parse failure is a `ParseError` rather than a `SafetyError`, because
// the parser is built for wasm too and `safety` is not. It carries the same
// code/message/hint contract, so unpack it the same way.
if let Some(pe) = e.downcast_ref::<crate::parser::ParseError>() {
let code = crate::parser::ParseError::CODE;
if color {
eprintln!(
"{}{}{} {}",
"error[".red().bold(),
code.red().bold(),
"]:".red().bold(),
pe.message
);
eprintln!(" {} {}", "hint:".yellow().bold(), pe.hint);
} else {
eprintln!("error[{code}]: {}", pe.message);
eprintln!(" hint: {}", pe.hint);
}
return;
}
if let Some(se) = e.downcast_ref::<SafetyError>() {
let code = se.code.as_str();
if color {
eprintln!(
"{}{}{} {}",
"error[".red().bold(),
code.red().bold(),
"]:".red().bold(),
se.message
);
if !se.hint.is_empty() {
eprintln!(" {} {}", "hint:".yellow().bold(), se.hint);
}
if let Some(a) = &se.approval {
eprintln!(
" {} re-run with IRONSHELL_APPROVE={} (or call approve(\"{}\"))",
"approve:".cyan().bold(),
a.token,
a.token
);
}
} else {
eprintln!("error[{code}]: {}", se.message);
if !se.hint.is_empty() {
eprintln!(" hint: {}", se.hint);
}
if let Some(a) = &se.approval {
eprintln!(
" approve: re-run with IRONSHELL_APPROVE={} (or call approve(\"{}\"))",
a.token, a.token
);
}
}
return;
}
if color {
eprintln!("{} {e}", "error:".red().bold());
} else {
eprintln!("error: {e}");
}
}
pub fn eval_line(env: &mut Env, code: &str) -> Result<Value> {
let stmts = parser::parse_program(code)?;
eval_program(&stmts, env)
}
/// Should rendered values carry ANSI colour?
///
/// The config alone is not enough to answer this: until 10.0.1 it *was* the
/// whole answer, so `ish -c '1 + 2' > out` wrote `\x1b[38;2;180;142;173m3\x1b[39m`
/// into the file. Anything consuming the shell's output — a pipe, a
/// `$(...)` capture, the Homebrew formula's own `assert_equal "3"` — got
/// escape codes it did not ask for.
///
/// The order below is the one users expect from other tools:
/// * `NO_COLOR` set to anything non-empty wins (no-color.org),
/// * then an explicit force, for people piping into `less -R`,
/// * then: colour only when stdout is actually a terminal,
/// * and only then does the config get a say.
use crate::config::{colors_enabled, colors_enabled_stderr};
/// REPL rendering:
/// - Null => print nothing
/// - Str => print raw (no quotes), so ANSI works
/// - else => compact colorized pretty-print (or plain if colors disabled)
fn render_for_repl(v: &Value) -> Option<String> {
match v {
Value::Null => None,
Value::Str(s) => Some(s.clone()),
_ => {
if colors_enabled() {
Some(pp_colored(v))
} else {
Some(pp(v))
}
}
}
}
/// Apply a color from the theme to a string
fn colorize(s: &str, hex_color: &str) -> String {
// Checked here as well as in `render_for_repl`, because `pp_colored` is
// reachable from other callers and a helper that emits escapes regardless
// of destination is the bug this pair of checks exists to prevent.
if colors_enabled() && get_config().colors.true_color {
format!("{}", s.with(parse_hex_color(hex_color)))
} else {
// Fallback to basic colors when true_color is disabled
s.to_string()
}
}
/// Colorized pretty-print using theme colors from config
fn pp_colored(v: &Value) -> String {
let colors = get_theme_colors();
match v {
Value::Null => colorize("null", &colors.dim),
Value::Bool(b) => colorize(&b.to_string(), &colors.boolean),
Value::Int(n) => colorize(&n.to_string(), &colors.number),
Value::Float(x) => colorize(&x.to_string(), &colors.number),
Value::Str(s) => colorize(&format!("\"{}\"", s), &colors.string),
Value::Uri(u) => colorize(u, &colors.uri),
Value::Array(items) => {
let mut s = String::new();
s.push_str(&colorize("[", &colors.punctuation));
for (i, it) in items.iter().enumerate() {
if i > 0 {
s.push_str(", ");
}
s.push_str(&pp_item_colored(it));
}
s.push_str(&colorize("]", &colors.punctuation));
s
}
Value::Record(map) => {
let mut s = String::new();
s.push_str(&colorize("{", &colors.punctuation));
let mut first = true;
for (k, v) in map {
if !first {
s.push_str(", ");
}
first = false;
s.push_str(&colorize(k, &colors.key));
s.push_str(": ");
s.push_str(&pp_item_colored(v));
}
s.push_str(&colorize("}", &colors.punctuation));
s
}
Value::Table(t) => colorize(&format!("<Table rows={}>", t.rows.len()), &colors.dim),
Value::Lambda(_) => colorize("<lambda>", &colors.dim),
Value::AsyncLambda(_) => colorize("<async lambda>", &colors.dim),
Value::Future(_) => colorize("<future>", &colors.dim),
Value::Error(msg) => colorize(&format!("Error: {}", msg), &colors.error),
Value::Builtin(b) => colorize(&format!("<builtin:{}>", b.name), &colors.dim),
}
}
fn pp_item_colored(v: &Value) -> String {
let colors = get_theme_colors();
match v {
Value::Null => colorize("null", &colors.dim),
Value::Bool(b) => colorize(&b.to_string(), &colors.boolean),
Value::Int(n) => colorize(&n.to_string(), &colors.number),
Value::Float(x) => colorize(&x.to_string(), &colors.number),
Value::Str(s) => colorize(&format!("\"{}\"", s), &colors.string),
Value::Uri(u) => colorize(u, &colors.uri),
Value::Array(a) => colorize(&format!("[…{}]", a.len()), &colors.punctuation),
// See `pp_item`: a record one level deep shows its fields, or an array
// of records renders as nothing at all.
Value::Record(map) => {
let mut s = colorize("{", &colors.punctuation);
for (i, (k, val)) in map.iter().enumerate() {
if i > 0 {
s.push_str(&colorize(", ", &colors.punctuation));
}
s.push_str(&colorize(k, &colors.key));
s.push_str(&colorize(": ", &colors.punctuation));
s.push_str(&pp_leaf_colored(val));
}
s.push_str(&colorize("}", &colors.punctuation));
s
}
Value::Table(t) => colorize(&format!("<Table rows={}>", t.rows.len()), &colors.dim),
Value::Lambda(_) => colorize("<lambda>", &colors.dim),
Value::AsyncLambda(_) => colorize("<async lambda>", &colors.dim),
Value::Future(_) => colorize("<future>", &colors.dim),
Value::Error(msg) => colorize(&format!("Error: {}", msg), &colors.error),
Value::Builtin(b) => colorize(&format!("<builtin:{}>", b.name), &colors.dim),
}
}
/// Colorized [`pp_leaf`].
fn pp_leaf_colored(v: &Value) -> String {
let colors = get_theme_colors();
match v {
Value::Array(a) => colorize(&format!("[len={}]", a.len()), &colors.punctuation),
Value::Record(m) => colorize(&format!("{{fields={}}}", m.len()), &colors.dim),
other => pp_item_colored(other),
}
}
// Non-colored versions for when colors are disabled
fn pp(v: &Value) -> String {
match v {
Value::Null => "null".into(),
Value::Bool(b) => b.to_string(),
Value::Int(n) => n.to_string(),
Value::Float(x) => x.to_string(),
Value::Str(s) => s.clone(),
Value::Uri(u) => u.clone(),
Value::Array(items) => {
let mut s = String::new();
s.push('[');
for (i, it) in items.iter().enumerate() {
if i > 0 {
s.push_str(", ");
}
s.push_str(&pp_item(it));
}
s.push(']');
s
}
Value::Record(map) => {
let mut s = String::new();
s.push('{');
let mut first = true;
for (k, v) in map {
if !first {
s.push_str(", ");
}
first = false;
s.push_str(k);
s.push_str(": ");
s.push_str(&pp_item(v));
}
s.push('}');
s
}
Value::Table(t) => format!("<Table rows={}>", t.rows.len()),
Value::Lambda(_) => "<lambda>".into(),
Value::AsyncLambda(_) => "<async lambda>".into(),
Value::Future(_) => "<future>".into(),
Value::Builtin(b) => format!("<builtin:{}>", b.name),
Value::Error(msg) => format!("Error: {}", msg),
}
}
#[allow(dead_code)]
fn pp_item(v: &Value) -> String {
match v {
Value::Null => "null".into(),
Value::Bool(b) => b.to_string(),
Value::Int(n) => n.to_string(),
Value::Float(x) => x.to_string(),
Value::Str(s) => s.clone(),
Value::Uri(u) => u.clone(),
Value::Array(a) => format!("[len={}]", a.len()),
// A record nested one level deep renders its fields.
//
// This used to be `{…}`, and because an array of records is the single
// most common thing a shell produces, that meant the flagship example
// printed nothing:
//
// ls("src") | pick("name", "size")
// [{…}, {…}, {…}, … 45 of them]
//
// Not a token budget — `[{a: 1}, {a: 2}]` did it too. The renderer
// simply stopped one level too early. Volume is already handled by
// `budget_value` on the way in, so eliding here bought nothing and cost
// the answer. Arrays keep their `[len=N]` summary, which is deliberate
// and documented (docs/book/src/ai/tools.md), and anything nested
// *inside* this record still elides, so the depth stays bounded at two.
Value::Record(map) => {
let mut s = String::from("{");
for (i, (k, val)) in map.iter().enumerate() {
if i > 0 {
s.push_str(", ");
}
s.push_str(k);
s.push_str(": ");
s.push_str(&pp_leaf(val));
}
s.push('}');
s
}
Value::Table(t) => format!("<Table rows={}>", t.rows.len()),
Value::Lambda(_) => "<lambda>".into(),
Value::AsyncLambda(_) => "<async lambda>".into(),
Value::Future(_) => "<future>".into(),
Value::Builtin(b) => format!("<builtin:{}>", b.name),
Value::Error(msg) => format!("Error: {}", msg),
}
}
/// Depth two: scalars in full, collections as a summary.
///
/// This is where the recursion stops, so a deeply nested value cannot make one
/// line of REPL output unbounded.
fn pp_leaf(v: &Value) -> String {
match v {
Value::Array(a) => format!("[len={}]", a.len()),
Value::Record(m) => format!("{{fields={}}}", m.len()),
other => pp_item(other),
}
}