cellstate/commands/
pack.rs

1//! `cellstate pack` — Pack compilation and AAIF convergence output commands.
2//!
3//! Compiles a CELLSTATE pack from disk and emits convergence outputs
4//! (AGENTS.md, llms.txt, A2A Agent Card, SKILL.md, A2UI schema).
5
6use crate::ui;
7use console::style;
8use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10
11// ============================================================================
12// Command definitions
13// ============================================================================
14
15#[derive(Debug, clap::Args)]
16pub struct PackArgs {
17    #[command(subcommand)]
18    pub command: PackCommand,
19}
20
21#[derive(Debug, clap::Subcommand)]
22pub enum PackCommand {
23    /// Compile a pack and emit one or more AAIF convergence outputs.
24    Emit(EmitArgs),
25    /// Validate a pack without emitting any output. Exits 0 on success, 1 on error.
26    Validate(ValidateArgs),
27}
28
29#[derive(Debug, clap::Args)]
30pub struct ValidateArgs {
31    /// Pack directory (must contain cstate.toml). Defaults to current directory.
32    #[arg(long, default_value = ".")]
33    pub dir: PathBuf,
34}
35
36#[derive(Debug, clap::Args)]
37pub struct EmitArgs {
38    /// Format to emit.
39    #[arg(value_enum)]
40    pub format: EmitFormat,
41
42    /// Pack directory (must contain cstate.toml). Defaults to current directory.
43    #[arg(long, default_value = ".")]
44    pub dir: PathBuf,
45
46    /// Base URL for generated links (e.g. https://api.example.com).
47    #[arg(long)]
48    pub base_url: Option<String>,
49
50    /// Write output to a file instead of stdout.
51    #[arg(long, short)]
52    pub output: Option<PathBuf>,
53}
54
55#[derive(Debug, Clone, clap::ValueEnum)]
56pub enum EmitFormat {
57    /// AGENTS.md — agent guidance for coding assistants (60k+ repos)
58    AgentsMd,
59    /// llms.txt — LLM-readable discovery document (844k+ sites)
60    LlmsTxt,
61    /// A2A Agent Card — Google Agent-to-Agent protocol
62    A2a,
63    /// Agent Skills — SKILL.md open standard (17+ agents)
64    Skills,
65    /// A2UI Schema — Google A2UI v0.8 compatible component graph
66    A2uiSchema,
67    /// All formats — emits every convergence output
68    All,
69}
70
71// ============================================================================
72// Entry point
73// ============================================================================
74
75pub fn run(args: &PackArgs) -> anyhow::Result<()> {
76    match &args.command {
77        PackCommand::Emit(emit_args) => run_emit(emit_args),
78        PackCommand::Validate(validate_args) => run_validate(validate_args),
79    }
80}
81
82fn run_validate(args: &ValidateArgs) -> anyhow::Result<()> {
83    let dir = if args.dir == Path::new(".") {
84        std::env::current_dir()?
85    } else {
86        args.dir.clone()
87    };
88
89    let manifest_path = dir.join("cstate.toml");
90    if !manifest_path.exists() {
91        anyhow::bail!(
92            "No cstate.toml found in {}. Is this a CELLSTATE pack directory?",
93            dir.display()
94        );
95    }
96
97    let manifest = std::fs::read_to_string(&manifest_path)?;
98    let markdowns = discover_markdowns(&dir)?;
99    let contracts = discover_contracts(&dir)?;
100
101    let input = cellstate_pipeline::pack::PackInput {
102        root: dir.clone(),
103        manifest,
104        markdowns,
105        contracts,
106    };
107
108    match cellstate_pipeline::pack::compose_pack(input) {
109        Ok(_) => {
110            ui::ok(&format!("Pack at {} is valid", dir.display()));
111            Ok(())
112        }
113        Err(e) => {
114            eprintln!("{} {}", style("error:").red().bold(), e);
115            std::process::exit(1);
116        }
117    }
118}
119
120fn run_emit(args: &EmitArgs) -> anyhow::Result<()> {
121    let dir = if args.dir == Path::new(".") {
122        std::env::current_dir()?
123    } else {
124        args.dir.clone()
125    };
126
127    // Read cstate.toml
128    let manifest_path = dir.join("cstate.toml");
129    if !manifest_path.exists() {
130        anyhow::bail!(
131            "No cstate.toml found in {}. Is this a CELLSTATE pack directory?",
132            dir.display()
133        );
134    }
135
136    ui::info(&format!("Compiling pack from {}", dir.display()));
137
138    let manifest = std::fs::read_to_string(&manifest_path)?;
139    let markdowns = discover_markdowns(&dir)?;
140    let contracts = discover_contracts(&dir)?;
141
142    let input = cellstate_pipeline::pack::PackInput {
143        root: dir.clone(),
144        manifest,
145        markdowns,
146        contracts,
147    };
148
149    let output = cellstate_pipeline::pack::compose_pack(input)
150        .map_err(|e| anyhow::anyhow!("Pack compilation failed: {}", e))?;
151
152    let base_url = args.base_url.as_deref();
153
154    match args.format {
155        EmitFormat::AgentsMd => {
156            let md = cellstate_pipeline::pack::agents_md::generate_agents_md(&output.compiled);
157            write_output(args, &md, "agents.md")?;
158        }
159        EmitFormat::LlmsTxt => {
160            let txt =
161                cellstate_pipeline::pack::llms_txt::generate_llms_txt(&output.compiled, base_url);
162            write_output(args, &txt, "llms.txt")?;
163        }
164        EmitFormat::A2a => {
165            let card =
166                cellstate_pipeline::pack::a2a::generate_agent_card(&output.compiled, base_url);
167            let json = serde_json::to_string_pretty(&card)?;
168            write_output(args, &json, "agent.json")?;
169        }
170        EmitFormat::Skills => {
171            let pack = cellstate_pipeline::pack::skills::generate_skill_pack(&output.compiled);
172            let json = serde_json::to_string_pretty(&pack)?;
173            write_output(args, &json, "skills.json")?;
174        }
175        EmitFormat::A2uiSchema => {
176            let schema =
177                cellstate_pipeline::pack::a2ui_compat::generate_a2ui_schema(&output.compiled);
178            let json = serde_json::to_string_pretty(&schema)?;
179            write_output(args, &json, "a2ui-schema.json")?;
180        }
181        EmitFormat::All => {
182            emit_all(&output.compiled, args, base_url)?;
183        }
184    }
185
186    Ok(())
187}
188
189fn emit_all(
190    compiled: &cellstate_pipeline::compiler::CompiledConfig,
191    args: &EmitArgs,
192    base_url: Option<&str>,
193) -> anyhow::Result<()> {
194    let outputs: Vec<(&str, String)> = vec![
195        (
196            "agents.md",
197            cellstate_pipeline::pack::agents_md::generate_agents_md(compiled),
198        ),
199        (
200            "llms.txt",
201            cellstate_pipeline::pack::llms_txt::generate_llms_txt(compiled, base_url),
202        ),
203        (
204            "agent.json",
205            serde_json::to_string_pretty(&cellstate_pipeline::pack::a2a::generate_agent_card(
206                compiled, base_url,
207            ))?,
208        ),
209        (
210            "skills.json",
211            serde_json::to_string_pretty(&cellstate_pipeline::pack::skills::generate_skill_pack(
212                compiled,
213            ))?,
214        ),
215        (
216            "a2ui-schema.json",
217            serde_json::to_string_pretty(
218                &cellstate_pipeline::pack::a2ui_compat::generate_a2ui_schema(compiled),
219            )?,
220        ),
221    ];
222
223    if let Some(ref out_dir) = args.output {
224        // When --output is a directory, write all files there
225        std::fs::create_dir_all(out_dir)?;
226        for (filename, content) in &outputs {
227            let path = out_dir.join(filename);
228            std::fs::write(&path, content)?;
229            ui::ok(&format!("Wrote {}", path.display()));
230        }
231    } else {
232        // Print all to stdout with separators
233        for (filename, content) in &outputs {
234            println!("{} {}", style("───").dim(), style(filename).cyan().bold());
235            println!("{}", content);
236            println!();
237        }
238    }
239
240    ui::ok("All convergence outputs emitted");
241    Ok(())
242}
243
244fn write_output(args: &EmitArgs, content: &str, default_name: &str) -> anyhow::Result<()> {
245    if let Some(ref path) = args.output {
246        std::fs::write(path, content)?;
247        ui::ok(&format!("Wrote {}", path.display()));
248    } else {
249        print!("{}", content);
250    }
251    let _ = default_name; // used by `all` variant
252    Ok(())
253}
254
255// ============================================================================
256// File discovery
257// ============================================================================
258
259/// Walk the pack directory for markdown files (agents/*.md, prompts, etc.)
260fn discover_markdowns(
261    dir: &PathBuf,
262) -> anyhow::Result<Vec<cellstate_pipeline::pack::PackMarkdownFile>> {
263    let mut files = Vec::new();
264
265    for entry in walkdir(dir)? {
266        let path = entry;
267        if path.extension().map(|e| e == "md").unwrap_or(false) {
268            // Skip hidden dirs and node_modules
269            let rel = path.strip_prefix(dir).unwrap_or(&path);
270            if rel
271                .components()
272                .any(|c| c.as_os_str().to_string_lossy().starts_with('.'))
273            {
274                continue;
275            }
276            let content = std::fs::read_to_string(&path)?;
277            files.push(cellstate_pipeline::pack::PackMarkdownFile {
278                path: path.clone(),
279                content,
280            });
281        }
282    }
283
284    Ok(files)
285}
286
287/// Walk the pack directory for JSON contract schema files.
288fn discover_contracts(dir: &PathBuf) -> anyhow::Result<HashMap<String, String>> {
289    let mut contracts = HashMap::new();
290    let contracts_dir = dir.join("contracts");
291    if contracts_dir.exists() {
292        for entry in walkdir(&contracts_dir)? {
293            if entry.extension().map(|e| e == "json").unwrap_or(false) {
294                let rel = entry
295                    .strip_prefix(dir)
296                    .unwrap_or(&entry)
297                    .to_string_lossy()
298                    .to_string();
299                let content = std::fs::read_to_string(&entry)?;
300                contracts.insert(rel, content);
301            }
302        }
303    }
304    Ok(contracts)
305}
306
307/// Simple recursive directory walk (avoids pulling in the `walkdir` crate).
308fn walkdir(dir: &PathBuf) -> anyhow::Result<Vec<PathBuf>> {
309    let mut results = Vec::new();
310    if !dir.is_dir() {
311        return Ok(results);
312    }
313    for entry in std::fs::read_dir(dir)? {
314        let entry = entry?;
315        let path = entry.path();
316        if path.is_dir() {
317            results.extend(walkdir(&path)?);
318        } else {
319            results.push(path);
320        }
321    }
322    Ok(results)
323}