tawasuyu

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…

TOML 1572Shell 203Rust 109Markdown 993728 Sutichaykuna

⎇ dbbc10e8546e1cd36b479338722e2f20382dd6ff crates/hammer-cli/src/main.rs Rust · 810 siq'ikuna · 31.8 KB Ñawpa willay Pi imata llamkarqan ch'uya

Fase 6 — lenguaje de consulta del sistema (SDD 08 §6) dbbc10e85 · Sergio ·

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}