feat(shuma-shell): matilda embebido — server admin desde la ventana

matilda deja de ser sólo un ejecutable aparte: el shell lo incorpora
como herramienta. Meta-comando `:matilda plan|script|apply
<inventario.json>` — reconcilia contra el estado real de la máquina
(matilda-discover) y vuelca el resultado al feed del shell:

- `plan`/`script` → una tarjeta sintética con el plan o el script.
- `apply` → ejecuta el script de verdad; su salida fluye en una
  tarjeta como cualquier comando (streaming, captura acotada, kill).

El panel [RUN] gana una sección [tools] con «⚙ matilda» que precarga
el comando. Reusa todo lo del shell —feed, ejecución, sesión— sin
panel nuevo ni peso extra: la herramienta es no invasiva.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
sergio
2026-05-20 20:36:55 +00:00
parent 7dc533dbbf
commit fdf820edbb
3 changed files with 153 additions and 1 deletions
+6
View File
@@ -18,6 +18,12 @@ shuma-session = { path = "../../modules/shuma/shuma-session" }
shuma-exec = { path = "../../modules/shuma/shuma-exec" }
shuma-infer = { path = "../../modules/shuma/shuma-infer" }
shuma-sysmon = { path = "../../modules/shuma/shuma-sysmon" }
# Herramienta matilda, embebida en la ventana del shell.
matilda-core = { path = "../../modules/matilda/matilda-core" }
matilda-plan = { path = "../../modules/matilda/matilda-plan" }
matilda-apply = { path = "../../modules/matilda/matilda-apply" }
matilda-discover = { path = "../../modules/matilda/matilda-discover" }
nahual-theme = { path = "../../modules/nahual/libs/theme" }
nahual-launcher = { path = "../../modules/nahual/libs/launcher" }
gpui = { workspace = true }
serde_json = { workspace = true }
+142 -1
View File
@@ -600,6 +600,11 @@ impl Shell {
.set_spill(matches!(arg.trim(), "on" | "si" | "" | "1" | "true"));
return;
}
if let Some(args) = line.strip_prefix(":matilda ") {
// Herramienta matilda embebida — administración de servidores.
self.matilda_command(args);
return;
}
// Los comandos anteriores que el usuario no fijó se autocolapsan
// al aparecer uno nuevo abajo — orden de terminal tradicional.
@@ -641,13 +646,19 @@ impl Shell {
/// Arma la `CommandSpec` de una línea: decide directo vs shell y
/// aplica la política de captura de la sesión.
fn build_spec(&self, line: &str, stdin: Option<String>, run_id: RunId) -> CommandSpec {
self.build_spec_exec(plan_exec(line), stdin, run_id)
}
/// `build_spec` con el modo de ejecución ya decidido (lo usa la
/// herramienta matilda, que ejecuta un script de shell completo).
fn build_spec_exec(&self, exec: Exec, stdin: Option<String>, run_id: RunId) -> CommandSpec {
let policy = self.session.capture();
let spill_path = (policy.spill && policy.limit_bytes > 0).then(|| {
std::env::temp_dir()
.join(format!("shuma-spill-{}-{run_id}.log", std::process::id()))
});
CommandSpec {
exec: plan_exec(line),
exec,
cwd: self.session.cwd().to_string(),
capture_limit: policy.limit_bytes,
spill_path,
@@ -655,6 +666,116 @@ impl Shell {
}
}
/// Registra un comando "sintético" ya terminado — su salida la
/// produce el shell mismo, no un proceso (la usa `:matilda plan`).
fn synthetic_run(&mut self, label: &str, output: Vec<String>, ok: bool) {
for ui in self.run_ui.values_mut() {
if !ui.user_touched {
ui.collapsed = true;
}
}
let now = unix_now();
let id = self.session.begin_run(label, now);
self.run_ui.insert(id, RunUi::default());
for line in output {
self.session.append_output(id, Stream::Stdout, line);
}
self.session.finish_run(id, if ok { 0 } else { 1 }, now);
self.scroll.scroll_to_bottom();
}
/// Ejecuta `exec_line` mostrando `label` en la tarjeta — el comando
/// real puede diferir de lo que se ve (matilda corre un script).
fn spawn_labeled(&mut self, label: String, exec_line: String) {
for ui in self.run_ui.values_mut() {
if !ui.user_touched {
ui.collapsed = true;
}
}
let now = unix_now();
let id = self.session.begin_run(&label, now);
self.run_ui.insert(id, RunUi::default());
let exec = Exec::Shell { line: exec_line, program: "bash".into() };
let spec = self.build_spec_exec(exec, None, id);
self.active.push((id, exec_run(&spec)));
self.scroll.scroll_to_bottom();
}
/// Carga un inventario JSON, resolviendo la ruta contra el cwd.
fn load_inventory(&self, file: &str) -> Result<matilda_core::Inventory, String> {
let path = if file.starts_with('/') {
file.to_string()
} else {
format!("{}/{}", self.session.cwd(), file)
};
let text = std::fs::read_to_string(&path)
.map_err(|e| format!("no se pudo leer {path}: {e}"))?;
serde_json::from_str(&text).map_err(|e| format!("JSON inválido: {e}"))
}
/// La herramienta matilda, embebida: `:matilda plan|script|apply
/// <inventario.json>`. Reconcilia contra el estado real de la
/// máquina y vuelca el resultado al feed del shell.
fn matilda_command(&mut self, args: &str) {
let label = format!(":matilda {args}");
let parts: Vec<&str> = args.split_whitespace().collect();
let (sub, file) = match parts.as_slice() {
[s, f] => (*s, *f),
_ => {
self.synthetic_run(
&label,
vec!["uso: :matilda plan|script|apply <inventario.json>".into()],
false,
);
return;
}
};
let desired = match self.load_inventory(file) {
Ok(d) => d,
Err(e) => {
self.synthetic_run(&label, vec![e], false);
return;
}
};
// Reconcilia contra el estado observado de esta máquina.
let current =
matilda_discover::observed_inventory(&matilda_discover::discover_local(), &desired);
let p = matilda_plan::plan(&current, &desired);
match sub {
"plan" => {
let lines: Vec<String> = if p.is_empty() {
vec!["sin cambios: el servidor ya está al día".into()]
} else {
p.actions
.iter()
.enumerate()
.map(|(i, a)| format!("{:>2}. {}", i + 1, a.describe()))
.collect()
};
self.synthetic_run(&label, lines, true);
}
"script" => {
let script = matilda_apply::steps_to_script(&matilda_apply::plan_to_steps(
&p, &desired,
));
self.synthetic_run(&label, script.lines().map(String::from).collect(), true);
}
"apply" => {
let steps = matilda_apply::plan_to_steps(&p, &desired);
if steps.is_empty() {
self.synthetic_run(&label, vec!["sin cambios: nada que aplicar".into()], true);
} else {
// El script se ejecuta de verdad — fluye al feed.
self.spawn_labeled(label, matilda_apply::steps_to_script(&steps));
}
}
other => {
self.synthetic_run(&label, vec![format!("subcomando desconocido: {other}")], false)
}
}
}
/// Reprocesa la salida capturada del comando `source`: ejecuta `line`
/// alimentándole esa salida por stdin, sin volver a correr el
/// original. Así un resultado se filtra con distintas herramientas.
@@ -1290,6 +1411,26 @@ impl Render for Shell {
.text_color(dim)
.child("clic ejecuta · guarda lo último"),
)
.child(div().h(px(1.)).bg(theme.border))
.child(div().text_color(dim).text_size(px(12.)).child("[tools]"))
.child(
div()
.id("tool-matilda")
.px(px(8.))
.py(px(6.))
.bg(node_bg)
.rounded(px(4.))
.text_color(text)
.text_size(px(13.))
.cursor_pointer()
.hover(|s| s.bg(theme.bg_row_hover))
.child("⚙ matilda")
.on_click(cx.listener(|shell, _, _, cx| {
// Precarga el comando para que el usuario nombre el inventario.
shell.line.set_text(":matilda plan ");
cx.notify();
})),
)
};
// --- Lienzo central: comandos ejecutados + su salida ---