sergio/takana
> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…
⎇ dbbc10e8546e1cd36b479338722e2f20382dd6ff
crates/hammer-cli/src/main.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
Fase 6 — lenguaje de consulta del sistema (SDD 08 §6)
1//! `hammer` — el punto de entrada de todo flujo manual. Ver `docs/`.
2//!
3//! Los subcomandos están mapeados a las fases del roadmap (`docs/10-roadmap.md`). Los que aún
4//! no están implementados devuelven un error claro indicando su fase, para que el esqueleto
5//! sea navegable desde el día uno.
6
7use std::path::PathBuf;
8
9use clap::{Parser, Subcommand};
10
11const DEFAULT_STORE: &str = "/store";
12
13#[derive(Parser)]
14#[command(
15 name = "hammer",
16 version,
17 about = "Laboratorio funcional en el sótano, terminal mutable arriba.",
18 long_about = "hammer: distro AI-nativa. Ver docs/ para el diseño completo."
19)]
20struct Cli {
21 /// Ruta del content-addressed store.
22 #[arg(long, default_value = DEFAULT_STORE, global = true)]
23 store: String,
24
25 #[command(subcommand)]
26 cmd: Cmd,
27}
28
29#[derive(Subcommand)]
30enum Cmd {
31 /// [Fase 0] Compila una receta y la sella en el store.
32 Build {
33 /// Ruta a la receta TOML.
34 recipe: String,
35 },
36 /// [Fase 1] Proyecta un artefacto del store al FHS (real o de overlay).
37 Hydrate {
38 /// Hash del artefacto (acepta prefijos cortos; con o sin `b3:`).
39 hash: String,
40 #[arg(long, default_value = "/")]
41 into: String,
42 /// Modo de enlazado: `static` (hardlink directo) | `dynamic` (requiere patchelf).
43 #[arg(long, default_value = "static")]
44 link: String,
45 },
46 /// [Fase 2] Monta un overlay de experimentación sobre los directorios del sistema.
47 /// Sin targets → cubre el FHS clásico (/usr/bin, /bin, /usr/lib, /lib, /etc, …).
48 Try {
49 /// Directorios a poner bajo overlay. Si vacío, usa la lista por defecto.
50 targets: Vec<PathBuf>,
51 /// Raíz donde viven los manifiestos y los upper/work. Default `/var/lib/hammer/overlays`.
52 #[arg(long)]
53 state_root: Option<PathBuf>,
54 },
55 /// [Fase 2+3] Fusiona un overlay al FHS real y desmonta. Por defecto registra cada
56 /// archivo promocionado en el diario; usa `--no-journal` para saltarse el registro.
57 Commit {
58 id: String,
59 #[arg(long)]
60 state_root: Option<PathBuf>,
61 /// Directorio del diario donde anotar la promoción.
62 #[arg(long, default_value = "/var/lib/hammer/journal")]
63 journal: PathBuf,
64 /// Saltarse el registro en el diario (útil para experimentos efímeros).
65 #[arg(long)]
66 no_journal: bool,
67 },
68 /// [Fase 2] Desmonta un overlay y borra su upper. El sistema vuelve al estado base.
69 Discard {
70 id: String,
71 #[arg(long)]
72 state_root: Option<PathBuf>,
73 },
74 /// [Fase 2] Lista los overlays activos según los manifiestos persistidos.
75 Status {
76 #[arg(long)]
77 state_root: Option<PathBuf>,
78 },
79 /// [Fase 3] Ver/seguir el diario de mutaciones.
80 Journal {
81 /// Directorio del diario (mismo que `hammerd --journal`).
82 #[arg(long, default_value = "/var/lib/hammer/journal")]
83 dir: PathBuf,
84 /// Muestra los últimos N eventos. Default: 20.
85 #[arg(long, short, default_value = "20")]
86 tail: usize,
87 /// Sigue el archivo: imprime nuevos eventos según aparecen.
88 #[arg(long, short)]
89 follow: bool,
90 /// Formato. `pretty` (humano, default) o `json` (raw passthrough).
91 #[arg(long, default_value = "pretty")]
92 format: String,
93 },
94 /// [Fase 4] Aplica un manifiesto .swm. Por defecto abre un overlay sobre el FHS y aplica
95 /// las mutaciones ahí; con `--prefix DIR` opera directamente en `DIR` sin overlay
96 /// (útil para tests offline o staging en otro filesystem).
97 Apply {
98 file: String,
99 /// Si está, re-rootea todas las rutas absolutas del .swm bajo este prefix y NO abre
100 /// overlay. `--prefix /mnt/foo` ⇒ `/etc/network.conf` → `/mnt/foo/etc/network.conf`.
101 #[arg(long)]
102 prefix: Option<PathBuf>,
103 /// Si está, salta source_patch silenciosamente. Útil para probar el flujo de
104 /// config_edit/file_drop sin lab disponible.
105 #[arg(long)]
106 skip_source_patch: bool,
107 /// Tras aplicar, intenta verificar pin contra base local de `--base-ref`.
108 #[arg(long)]
109 base_ref: Option<PathBuf>,
110 /// Raíz del overlay (sólo en modo overlay).
111 #[arg(long)]
112 state_root: Option<PathBuf>,
113 },
114 /// [Fase 4] Verifica un .swm: schema, base (si se da `--base-ref`), y opcionalmente el
115 /// hash de cada `file_drop` inline.
116 SwmVerify {
117 file: String,
118 #[arg(long)]
119 base_ref: Option<PathBuf>,
120 },
121 /// [Fase 4] Exporta el diario actual como manifiesto .swm a stdout.
122 /// Mutaciones trazables ⇒ file_drop con content_b64 y hash. Mutaciones opacas ⇒
123 /// mismas pero con `note` indicando el origen (warning legible para el receptor).
124 Export {
125 /// Archivo JSON con la base local; se inserta como `base` en el .swm. Si no se da,
126 /// se emite una base con `distro_version: "unknown"` y se imprime un warning.
127 #[arg(long)]
128 base_ref: Option<PathBuf>,
129 /// Directorio del diario.
130 #[arg(long, default_value = "/var/lib/hammer/journal")]
131 journal: PathBuf,
132 /// Sólo eventos a partir de este timestamp (RFC 3339). Si no se da, exporta todo.
133 #[arg(long)]
134 since: Option<String>,
135 },
136 /// [Fase 6] Ejecuta el bucle agéntico: traduce una intención NL a un .swm vía catálogo
137 /// mock y la aplica a un overlay (o prefix) sin promover al FHS. Imprime el Proposal.
138 Ai {
139 /// La intención en lenguaje natural (string exacta, indexada en el catálogo).
140 intent: String,
141 /// Catálogo YAML de intent → swm. Cuando exista un traductor LLM real, este flag
142 /// será opcional y se documentará el path por defecto.
143 #[arg(long)]
144 catalog: PathBuf,
145 /// Re-rootea las mutaciones bajo este prefix en vez de abrir overlay. Útil para
146 /// dev/CI sin root ni overlayfs.
147 #[arg(long)]
148 prefix: Option<PathBuf>,
149 /// Base local (JSON). Si está, el orquestador verifica `verify_base` contra ella.
150 #[arg(long)]
151 base_ref: Option<PathBuf>,
152 /// Socket de hammerd. Si está + el .swm trae source_patch, el orquestador llama
153 /// Compile por el bus. Si no, source_patch se omite con warning.
154 #[arg(long)]
155 bus: Option<PathBuf>,
156 /// Raíz de overlays para `try`. Default coincide con `hammer try`.
157 #[arg(long)]
158 state_root: Option<PathBuf>,
159 },
160 /// [Fase 5] Envía un comando al FIFO de control humano del init.
161 /// Por defecto: /run/init.control. Equivalente a `echo "<line>" > /run/init.control`,
162 /// pero con un mensaje de error claro si el FIFO no existe.
163 Ctl {
164 /// La línea a enviar (p. ej. "start web", "restart network").
165 line: String,
166 #[arg(long, default_value = "/run/init.control")]
167 fifo: PathBuf,
168 },
169 /// [Fase 6] Evalúa una expresión del mini-lenguaje (SDD 08 §6) contra el sistema local.
170 /// Forma: `kind:value`. Kinds soportados: `bin`, `file`, `pin`, `service`, `depends`.
171 /// Ejemplos:
172 /// hammer query bin:grep
173 /// hammer query file:/etc/hosts
174 /// hammer query depends:/usr/bin/curl
175 /// hammer query pin:musl --base-ref base.json
176 Query {
177 /// La expresión a evaluar.
178 expr: String,
179 /// Base local (JSON) para resolver `pin:<name>`. Opcional.
180 #[arg(long)]
181 base_ref: Option<PathBuf>,
182 /// Re-rootea las rutas absolutas bajo este prefix (útil para probar contra un overlay
183 /// o un prefix de test sin tocar el FHS real).
184 #[arg(long)]
185 fs_root: Option<PathBuf>,
186 },
187}
188
189fn print_event(ev: &hammer_journal::MutationEvent, format: &str) {
190 match format {
191 "json" => {
192 // Re-serializa. No imprimimos la línea original (no la tenemos a mano), pero el
193 // formato es estable porque MutationEvent es la fuente de verdad del schema.
194 match serde_json::to_string(ev) {
195 Ok(s) => println!("{s}"),
196 Err(e) => eprintln!("journal: error serializando: {e}"),
197 }
198 }
199 _ => {
200 let actor = match &ev.by.source {
201 hammer_journal::Source::HammerHydrate { artifact } => format!("hydrate {artifact}"),
202 hammer_journal::Source::HammerCommit { overlay, artifact } => match artifact {
203 Some(a) => format!("commit {overlay} ({a})"),
204 None => format!("commit {overlay}"),
205 },
206 hammer_journal::Source::External => {
207 let pid = ev.by.pid.map(|p| format!("pid={p}")).unwrap_or_default();
208 let uid = ev.by.uid.map(|u| format!("uid={u}")).unwrap_or_default();
209 format!("external {pid} {uid}").trim().to_string()
210 }
211 };
212 println!(
213 "{ts} {op:<7} {path} ← {actor}",
214 ts = ev.ts,
215 op = ev.op.as_str(),
216 path = ev.path.display(),
217 );
218 }
219 }
220}
221
222fn main() -> anyhow::Result<()> {
223 tracing_subscriber::fmt()
224 .with_writer(std::io::stderr)
225 .with_env_filter(
226 tracing_subscriber::EnvFilter::try_from_default_env()
227 .unwrap_or_else(|_| "info".into()),
228 )
229 .init();
230
231 let cli = Cli::parse();
232
233 // Sólo las cmds que de verdad tocan artefactos abren (y posiblemente crean) el store.
234 // Las cmds de overlay/diario/apply pueden correr en sistemas sin store local todavía.
235 match cli.cmd {
236 Cmd::Build { recipe } => {
237 let store = hammer_core::Store::open(&cli.store)?;
238 let recipe = hammer_core::Recipe::load_from_path(&recipe)?;
239 let cfg = hammer_build::BuildConfig::from_env_or_defaults(store.root());
240 let hash = hammer_build::build(&recipe, &cfg, &store)?;
241 println!("{hash}");
242 }
243 Cmd::Hydrate { hash, into, link } => {
244 let store = hammer_core::Store::open(&cli.store)?;
245 let mode = match link.as_str() {
246 "static" => hammer_core::LinkMode::Static,
247 "dynamic" => hammer_core::LinkMode::Dynamic,
248 other => anyhow::bail!("--link debe ser 'static' o 'dynamic', no '{other}'"),
249 };
250 let artifact_dir = store.find_by_hash(&hash)?;
251 let report = hammer_build::run_hydrate(
252 &artifact_dir,
253 std::path::Path::new(&into),
254 mode,
255 )?;
256 println!(
257 "hydrated {} archivo(s) ({} dir(s), {} symlink(s)) → {}",
258 report.files.len(),
259 report.dirs_created,
260 report.symlinks,
261 into
262 );
263 for f in &report.files {
264 println!(" {}", f.dst.display());
265 }
266 }
267 Cmd::Try { targets, state_root } => {
268 let root = state_root
269 .unwrap_or_else(|| PathBuf::from(hammer_overlay::DEFAULT_STATE_ROOT));
270 std::fs::create_dir_all(&root)?;
271 let id = hammer_overlay::try_overlay(&targets, &root)?;
272 println!("{id}");
273 }
274 Cmd::Commit { id, state_root, journal, no_journal } => {
275 let root = state_root
276 .unwrap_or_else(|| PathBuf::from(hammer_overlay::DEFAULT_STATE_ROOT));
277 let id = hammer_overlay::OverlayId(id);
278 let report = if no_journal {
279 hammer_overlay::commit(&id, &root)?
280 } else {
281 let j = hammer_journal::Journal::open(&journal)?;
282 hammer_overlay::commit_with_journal(&id, &root, &j)?
283 };
284 println!(
285 "commit OK — {} archivo(s) promocionados, {} eliminado(s){}",
286 report.copied.len(),
287 report.removed.len(),
288 if no_journal { "" } else { " (anotado en el diario)" }
289 );
290 }
291 Cmd::Discard { id, state_root } => {
292 let root = state_root
293 .unwrap_or_else(|| PathBuf::from(hammer_overlay::DEFAULT_STATE_ROOT));
294 hammer_overlay::discard(&hammer_overlay::OverlayId(id), &root)?;
295 println!("discard OK");
296 }
297 Cmd::Status { state_root } => {
298 let root = state_root
299 .unwrap_or_else(|| PathBuf::from(hammer_overlay::DEFAULT_STATE_ROOT));
300 let overlays = hammer_overlay::status(&root)?;
301 if overlays.is_empty() {
302 println!("(sin overlays activos)");
303 } else {
304 for o in overlays {
305 println!(
306 "{} created={} mounts={}",
307 o.id,
308 o.created_at,
309 o.mounts.len()
310 );
311 for m in &o.mounts {
312 println!(" └─ {} upper={}", m.target.display(), m.upper.display());
313 }
314 }
315 }
316 }
317 Cmd::Journal { dir, tail, follow, format } => {
318 let j = hammer_journal::Journal::open(&dir)?;
319 let initial = j.tail(tail)?;
320 for ev in &initial {
321 print_event(ev, &format);
322 }
323 if follow {
324 use std::io::Write;
325 let _ = std::io::stdout().flush();
326 let mut cursor = j.end_offset()?;
327 loop {
328 std::thread::sleep(std::time::Duration::from_millis(500));
329 let (news, new_cursor) = j.read_from_offset(cursor)?;
330 cursor = new_cursor;
331 for ev in &news {
332 print_event(ev, &format);
333 }
334 let _ = std::io::stdout().flush();
335 }
336 }
337 }
338 Cmd::Apply { file, prefix, skip_source_patch, base_ref, state_root } => {
339 run_apply(
340 &cli.store,
341 &file,
342 prefix.as_deref(),
343 skip_source_patch,
344 base_ref.as_deref(),
345 state_root.as_deref(),
346 )?;
347 }
348 Cmd::SwmVerify { file, base_ref } => {
349 run_swm_verify(&file, base_ref.as_deref())?;
350 }
351 Cmd::Export { base_ref, journal, since } => {
352 run_export(base_ref.as_deref(), &journal, since.as_deref())?;
353 }
354 Cmd::Ai { intent, catalog, prefix, base_ref, bus, state_root } => {
355 run_ai(
356 &intent,
357 &catalog,
358 prefix.as_deref(),
359 base_ref.as_deref(),
360 bus.as_deref(),
361 state_root.as_deref(),
362 &cli.store,
363 )?;
364 }
365 Cmd::Ctl { line, fifo } => {
366 run_ctl(&line, &fifo)?;
367 }
368 Cmd::Query { expr, base_ref, fs_root } => {
369 run_query(&expr, base_ref.as_deref(), fs_root.as_deref())?;
370 }
371 }
372 Ok(())
373}
374
375/// Lee y deserializa un `.swm` desde disco; falla con mensaje útil si no parsea.
376fn load_swm(file: &str) -> anyhow::Result<hammer_core::Swm> {
377 let yaml = std::fs::read_to_string(file)
378 .map_err(|e| anyhow::anyhow!("no pude leer {file}: {e}"))?;
379 hammer_core::Swm::from_yaml(&yaml)
380 .map_err(|e| anyhow::anyhow!("no pude parsear {file}: {e}"))
381}
382
383/// Lee una `BaseRef` local desde JSON; usada por `apply` y `swm verify` para verificar la
384/// compatibilidad de pins. Si la ruta no existe, devuelve `Ok(None)` para que el caller
385/// pueda decidir entre fallar o seguir con un warning.
386fn load_local_base(path: Option<&std::path::Path>) -> anyhow::Result<Option<hammer_core::BaseRef>> {
387 let Some(p) = path else { return Ok(None) };
388 if !p.is_file() {
389 return Err(anyhow::anyhow!("base-ref {} no existe", p.display()));
390 }
391 let bytes = std::fs::read(p)?;
392 let base: hammer_core::BaseRef =
393 serde_json::from_slice(&bytes).map_err(|e| anyhow::anyhow!("base-ref inválido: {e}"))?;
394 Ok(Some(base))
395}
396
397fn print_base_compat(c: &hammer_core::BaseCompat) {
398 use hammer_core::BaseCompat::*;
399 match c {
400 Ok => eprintln!("verify_base: OK"),
401 DistroMismatch { local, swm } => eprintln!(
402 "verify_base: distro_version no coincide — local={local} swm={swm}"
403 ),
404 PinMismatch(diffs) => {
405 eprintln!("verify_base: {} pin(s) divergente(s):", diffs.len());
406 for d in diffs {
407 let local = d.local.as_deref().unwrap_or("(ausente)");
408 eprintln!(" {}: local={local} swm={}", d.name, d.swm);
409 }
410 }
411 }
412}
413
414fn run_swm_verify(file: &str, base_ref: Option<&std::path::Path>) -> anyhow::Result<()> {
415 let swm = load_swm(file)?;
416 swm.verify_schema()
417 .map_err(|e| anyhow::anyhow!("schema inválido: {e}"))?;
418 println!("schema: OK ({} mutación(es))", swm.mutations.len());
419 // Verificación de hash de los file_drop inline (no toca disco).
420 for (i, m) in swm.mutations.iter().enumerate() {
421 if let hammer_core::Mutation::FileDrop { content_b64: Some(b64), content_hash, path, .. } = m {
422 hammer_core::apply::verify_content_hash(b64, content_hash).map_err(|e| {
423 anyhow::anyhow!("file_drop #{} en {path}: {e}", i + 1)
424 })?;
425 }
426 }
427 println!("file_drop inline: OK");
428 if let Some(local) = load_local_base(base_ref)? {
429 print_base_compat(&swm.verify_base(&local));
430 } else {
431 eprintln!("verify_base: omitido (sin --base-ref)");
432 }
433 Ok(())
434}
435
436/// Aplica un `.swm`. Dos modos:
437/// - **prefix** (`--prefix DIR`): re-rootea cada path al DIR; no abre overlay. Para tests y
438/// staging.
439/// - **overlay** (default): abre un overlay sobre los targets por defecto y aplica las
440/// mutaciones sobre las rutas reales (que el kernel redirige al upper del overlay).
441fn run_apply(
442 store_path: &str,
443 file: &str,
444 prefix: Option<&std::path::Path>,
445 skip_source_patch: bool,
446 base_ref: Option<&std::path::Path>,
447 state_root: Option<&std::path::Path>,
448) -> anyhow::Result<()> {
449 let swm = load_swm(file)?;
450 swm.verify_schema()?;
451
452 if let Some(local) = load_local_base(base_ref)? {
453 match swm.verify_base(&local) {
454 hammer_core::BaseCompat::Ok => {}
455 other => {
456 print_base_compat(&other);
457 anyhow::bail!("base local no compatible con la base del .swm");
458 }
459 }
460 }
461
462 let mut overlay_id: Option<hammer_overlay::OverlayId> = None;
463 if prefix.is_none() {
464 let root = state_root
465 .map(|p| p.to_path_buf())
466 .unwrap_or_else(|| PathBuf::from(hammer_overlay::DEFAULT_STATE_ROOT));
467 std::fs::create_dir_all(&root)?;
468 let id = hammer_overlay::try_overlay(&[], &root)?;
469 eprintln!("overlay {id} abierto");
470 overlay_id = Some(id);
471 }
472
473 let mut counts = (0usize, 0usize, 0usize, 0usize); // src_patch, cfg, init, drop
474 for (i, m) in swm.mutations.iter().enumerate() {
475 match m {
476 hammer_core::Mutation::SourcePatch { target_bin, .. } => {
477 counts.0 += 1;
478 if skip_source_patch {
479 eprintln!(" [skip] source_patch #{} → {target_bin}", i + 1);
480 continue;
481 }
482 let store = hammer_core::Store::open(store_path)?;
483 let cfg = hammer_build::BuildConfig::from_env_or_defaults(store.root());
484 let hash = hammer_build::build_source_patch(m, &cfg, &store, None)?;
485 let artifact = store.find_by_hash(hash.as_str())?;
486 let into = hammer_core::apply::rebase_path("/", prefix);
487 let report = hammer_build::run_hydrate(
488 &artifact,
489 &into,
490 hammer_core::LinkMode::Static,
491 )?;
492 eprintln!(
493 " source_patch #{}: {} → hidratados {} archivo(s) en {}",
494 i + 1,
495 hash,
496 report.files.len(),
497 into.display()
498 );
499 // Sanity: el target_bin declarado debe existir tras la hidratación.
500 let abs = hammer_core::apply::rebase_path(target_bin, prefix);
501 if !abs.exists() {
502 anyhow::bail!(
503 "source_patch declara target_bin={target_bin} pero no quedó en {}",
504 abs.display()
505 );
506 }
507 }
508 hammer_core::Mutation::ConfigEdit { file: f, inline_diff } => {
509 counts.1 += 1;
510 let target = hammer_core::apply::rebase_path(f, prefix);
511 hammer_core::apply::apply_config_edit(&target, inline_diff)?;
512 eprintln!(" config_edit #{} → {}", i + 1, target.display());
513 }
514 hammer_core::Mutation::InitRule { action, service, .. } => {
515 counts.2 += 1;
516 eprintln!(
517 " [pendiente fase 5] init_rule #{}: {action} {service} — ignorando",
518 i + 1
519 );
520 }
521 hammer_core::Mutation::FileDrop { path, content_hash, content_b64, content_url } => {
522 counts.3 += 1;
523 let target = hammer_core::apply::rebase_path(path, prefix);
524 match (content_b64, content_url) {
525 (Some(b64), None) => {
526 hammer_core::apply::apply_file_drop(&target, b64, content_hash)?;
527 eprintln!(" file_drop #{} → {}", i + 1, target.display());
528 }
529 (None, Some(url)) => {
530 anyhow::bail!(
531 "file_drop #{} usa content_url={url}: fetch remoto pendiente",
532 i + 1
533 );
534 }
535 _ => unreachable!("verify_schema lo descarta"),
536 }
537 }
538 }
539 }
540 eprintln!(
541 "apply OK — source_patch={} config_edit={} init_rule={} file_drop={}",
542 counts.0, counts.1, counts.2, counts.3
543 );
544 if let Some(id) = &overlay_id {
545 println!("{id}");
546 eprintln!("overlay listo: usa `hammer commit {id}` para promocionar al FHS real.");
547 }
548 Ok(())
549}
550
551/// Exporta el diario actual como `.swm`. Estrategia honesta para Fase 4:
552///
553/// - Cada evento se traduce a un `file_drop` con `content_b64` (lo que hay en disco *ahora*)
554/// y `content_hash`. Esto garantiza reproducción byte-a-byte sin depender de tener el
555/// recipe original. La pérdida es que perdemos provenance — el receptor obtiene el binario
556/// pero no la receta que lo produjo. Para `source_patch` real necesitaremos un mapa
557/// artefacto→receta que la Fase 4 no construye todavía.
558/// - Mutaciones opacas (`Source::External`) llevan un `note` indicando "origen externo"
559/// para que el receptor sepa que no hay garantía de provenance.
560/// - Mutaciones con `op = Delete` se omiten: un `.swm` describe el estado final por
561/// adición/edición, no por borrado. Es un compromiso consciente (un futuro `FileRemove`
562/// resolvería esto).
563fn run_export(
564 base_ref_path: Option<&std::path::Path>,
565 journal_dir: &std::path::Path,
566 since: Option<&str>,
567) -> anyhow::Result<()> {
568 use base64::{engine::general_purpose::STANDARD, Engine as _};
569 use std::collections::BTreeMap;
570
571 let j = hammer_journal::Journal::open(journal_dir)?;
572 let events = j.read_all()?;
573 let filtered: Vec<_> = match since {
574 Some(t) => events.into_iter().filter(|e| e.ts.as_str() > t).collect(),
575 None => events,
576 };
577
578 let base = match load_local_base(base_ref_path)? {
579 Some(local) => hammer_core::Base {
580 distro_version: local.distro_version,
581 pins: local.pins,
582 },
583 None => {
584 eprintln!("warning: sin --base-ref; emitiendo base con distro_version='unknown'");
585 hammer_core::Base {
586 distro_version: "unknown".into(),
587 pins: BTreeMap::new(),
588 }
589 }
590 };
591
592 // Dedup por path: dentro de un export queremos un único estado final por archivo.
593 // Procesamos en orden y nos quedamos con la última ocurrencia.
594 let mut last_by_path: std::collections::BTreeMap<PathBuf, &hammer_journal::MutationEvent> =
595 std::collections::BTreeMap::new();
596 for ev in &filtered {
597 if matches!(ev.op, hammer_journal::MutationOp::Delete) {
598 last_by_path.remove(&ev.path); // borrado anula cualquier estado previo
599 continue;
600 }
601 last_by_path.insert(ev.path.clone(), ev);
602 }
603
604 let mut mutations = Vec::new();
605 let mut warnings = 0usize;
606 for (path, ev) in &last_by_path {
607 let bytes = match std::fs::read(path) {
608 Ok(b) => b,
609 Err(e) => {
610 warnings += 1;
611 eprintln!(
612 "warning: no pude leer {} para exportar (ev ts={}): {e}",
613 path.display(),
614 ev.ts
615 );
616 continue;
617 }
618 };
619 let h = hammer_core::ArtifactHash::of_inputs(&[bytes.as_slice()]);
620 let b64 = STANDARD.encode(&bytes);
621 let note = match &ev.by.source {
622 hammer_journal::Source::External => Some(format!(
623 "origen externo (pid={:?}, uid={:?}) — sin provenance",
624 ev.by.pid, ev.by.uid
625 )),
626 hammer_journal::Source::HammerHydrate { artifact } => {
627 Some(format!("hydrate {artifact}"))
628 }
629 hammer_journal::Source::HammerCommit { overlay, artifact } => match artifact {
630 Some(a) => Some(format!("commit {overlay} ({a})")),
631 None => Some(format!("commit {overlay}")),
632 },
633 };
634 // El note vive en el journal, no en el schema del file_drop — lo omitimos en el
635 // YAML emitido por ahora; está en stderr (warnings) si era external.
636 let _ = note;
637 mutations.push(hammer_core::Mutation::FileDrop {
638 path: path.display().to_string(),
639 content_hash: h.as_str().to_string(),
640 content_b64: Some(b64),
641 content_url: None,
642 });
643 }
644
645 let swm = hammer_core::Swm {
646 swm_version: 1,
647 base,
648 mutations,
649 signature: None,
650 };
651 let yaml = swm.to_yaml()?;
652 print!("{yaml}");
653 eprintln!(
654 "export: {} mutación(es), {} warning(s)",
655 swm.mutations.len(),
656 warnings
657 );
658 Ok(())
659}
660
661/// Ejecuta el bucle agéntico de Fase 6 vía `hammer-agent`. Acepta una intención NL +
662/// catálogo YAML que mapea intentos exactos a `.swm`s pre-armados. La salida es un
663/// `Proposal` legible en stdout + el siguiente paso recomendado al humano.
664fn run_ai(
665 intent: &str,
666 catalog: &std::path::Path,
667 prefix: Option<&std::path::Path>,
668 base_ref: Option<&std::path::Path>,
669 bus: Option<&std::path::Path>,
670 state_root: Option<&std::path::Path>,
671 store_root: &str,
672) -> anyhow::Result<()> {
673 use hammer_agent::{
674 orchestrator::{ApplyTarget, CompileMode},
675 IntentCatalog, MockTranslator, Orchestrator,
676 };
677
678 let cat = IntentCatalog::load_from_path(catalog)
679 .map_err(|e| anyhow::anyhow!("catálogo {}: {e}", catalog.display()))?;
680 let base_dir = catalog.parent().unwrap_or_else(|| std::path::Path::new("."));
681 let translator = MockTranslator::from_catalog(&cat, base_dir)
682 .map_err(|e| anyhow::anyhow!("catalog: {e}"))?;
683
684 // BaseRef: si se da, leerlo; si no, fabricar uno permisivo desde la base del catálogo
685 // (la usaremos sólo para verify_base, que tolera pins extra en local).
686 let base = match load_local_base(base_ref)? {
687 Some(b) => b,
688 None => {
689 // Tomamos la base del swm del intent (si existe) como referencia local.
690 let ctx_swm = cat
691 .intents
692 .iter()
693 .find(|e| e.intent == intent)
694 .ok_or_else(|| anyhow::anyhow!("intent '{intent}' no está en el catálogo"))?
695 .swm_inline
696 .clone()
697 .or_else(|| {
698 cat.intents
699 .iter()
700 .find(|e| e.intent == intent)
701 .and_then(|e| e.swm_path.as_ref())
702 .and_then(|p| std::fs::read_to_string(base_dir.join(p)).ok())
703 .and_then(|t| hammer_core::Swm::from_yaml(&t).ok())
704 });
705 match ctx_swm {
706 Some(s) => hammer_core::BaseRef::from(&s.base),
707 None => anyhow::bail!("no pude inferir BaseRef sin --base-ref"),
708 }
709 }
710 };
711
712 let apply = match prefix {
713 Some(p) => ApplyTarget::Prefix { prefix: p.to_path_buf() },
714 None => ApplyTarget::Overlay {
715 state_root: state_root
716 .map(|p| p.to_path_buf())
717 .unwrap_or_else(|| PathBuf::from(hammer_overlay::DEFAULT_STATE_ROOT)),
718 },
719 };
720
721 let compile = match bus {
722 Some(s) => CompileMode::ViaBus {
723 sock: s.to_path_buf(),
724 store: PathBuf::from(store_root),
725 timeout: std::time::Duration::from_secs(600),
726 },
727 None => CompileMode::Skip,
728 };
729
730 let orch = Orchestrator::new(translator, base, apply, compile);
731 let proposal = orch
732 .run(intent)
733 .map_err(|e| anyhow::anyhow!("orchestrator: {e}"))?;
734
735 println!("--- proposal ---");
736 println!("intent: {}", proposal.intent);
737 println!(
738 "applied: source_patch={} config_edit={} init_rule={} file_drop={}",
739 proposal.applied.source_patch,
740 proposal.applied.config_edit,
741 proposal.applied.init_rule,
742 proposal.applied.file_drop,
743 );
744 if proposal.skipped_source_patches > 0 {
745 println!(
746 "skipped_source_patches: {} (sin --bus)",
747 proposal.skipped_source_patches
748 );
749 }
750 println!("checks:");
751 for c in &proposal.checks {
752 println!(" [{:?}] {}", c.level, c.msg);
753 }
754 match (&proposal.overlay_id, &proposal.prefix) {
755 (Some(id), _) => {
756 println!("\noverlay {id} listo.");
757 println!(" Revisa el cambio en caliente y luego:");
758 println!(" hammer commit {id} # promueve + diario");
759 println!(" hammer discard {id} # descarta");
760 }
761 (None, Some(p)) => {
762 println!("\nprefix {} actualizado (modo test, sin overlay).", p.display());
763 }
764 _ => {}
765 }
766 Ok(())
767}
768
769/// Evalúa una expresión del mini-lenguaje localmente e imprime el JSON resultante.
770/// Espejo en proceso del path remoto vía bus (`AgentClient::query_expr`).
771fn run_query(
772 expr: &str,
773 base_ref: Option<&std::path::Path>,
774 fs_root: Option<&std::path::Path>,
775) -> anyhow::Result<()> {
776 let mut ctx = hammer_core::query::EvalContext::new();
777 if let Some(b) = load_local_base(base_ref)? {
778 ctx = ctx.with_base(b);
779 }
780 if let Some(r) = fs_root {
781 ctx = ctx.with_fs_root(r.to_path_buf());
782 }
783 let value = hammer_core::query::eval_str(expr, &ctx);
784 let json = serde_json::to_string_pretty(&value)?;
785 println!("{json}");
786 Ok(())
787}
788
789/// Envía una línea al FIFO de control humano. No abrimos un proceso intermedio (sería
790/// `sh -c "echo ... > FIFO"` con riesgo de quoting); usamos un `OpenOptions::write` y un
791/// `write_all`. El FIFO bloquea si no hay reader — eso es la garantía de "ack" implícito.
792fn run_ctl(line: &str, fifo: &std::path::Path) -> anyhow::Result<()> {
793 use std::io::Write;
794 use std::os::unix::fs::FileTypeExt;
795 let meta = std::fs::symlink_metadata(fifo).map_err(|e| {
796 anyhow::anyhow!(
797 "no encuentro el FIFO {} ({e}); ¿está hammerd arrancado?",
798 fifo.display()
799 )
800 })?;
801 if !meta.file_type().is_fifo() {
802 anyhow::bail!("{} existe pero no es un FIFO", fifo.display());
803 }
804 let mut f = std::fs::OpenOptions::new().write(true).open(fifo)?;
805 f.write_all(line.as_bytes())?;
806 if !line.ends_with('\n') {
807 f.write_all(b"\n")?;
808 }
809 Ok(())
810}