use anyhow::{anyhow, bail, Context, Result};
use clap::ValueEnum;
use std::fs;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
mod brief;
mod deck;
pub const MCP_GUIDANCE: &str = r#"## You may have MCP tools available — use them when the topic warrants it
If the agent runtime exposes MCP servers (Google Calendar, Gmail, Slack,
Linear, Granola, HubSpot, Attention, etc.), they are fair game when the
topic is clearly about the *user's* world: a meeting on their calendar, a
deal in their CRM, a ticket they own, an incident in their channels, a
person they work with. Use them to gather real, current context before
writing — a brief about "my 1:1 with Sam tomorrow" should pull the calendar
event, recent shared meetings, and any open tickets between them.
Do NOT use MCP for public/external topics ("the history of TLS", "what is
RAG", "a deck about coffee"). Those are general-knowledge subjects — pulling
the user's private data into them is a privacy leak, not helpful research.
When in doubt, lean toward NOT calling MCP tools.
When you do call MCP tools, keep it tasteful: a few targeted queries, not a
fishing expedition. The output is a finished artifact, not a research log —
don't cite tool calls or surface raw IDs."#;
#[derive(Copy, Clone, Debug, ValueEnum)]
pub enum Agent {
Claude,
Gemini,
Codex,
Opencode,
}
impl Agent {
fn label(&self) -> &'static str {
match self {
Agent::Claude => "Claude Code",
Agent::Gemini => "Gemini CLI",
Agent::Codex => "Codex",
Agent::Opencode => "OpenCode",
}
}
fn bin(&self) -> &'static str {
match self {
Agent::Claude => "claude",
Agent::Gemini => "gemini",
Agent::Codex => "codex",
Agent::Opencode => "opencode",
}
}
fn command(&self, prompt: &str) -> Command {
let mut c = Command::new(self.bin());
match self {
Agent::Claude | Agent::Gemini => {
c.arg("-p").arg(prompt);
}
Agent::Codex => {
c.arg("exec").arg(prompt);
}
Agent::Opencode => {
c.arg("run").arg(prompt);
}
}
c
}
const AUTO_DETECT_ORDER: &'static [Agent] =
&[Agent::Claude, Agent::Gemini, Agent::Codex, Agent::Opencode];
}
pub struct Wish {
pub name: &'static str,
pub description: &'static str,
pub default_out: &'static str,
pub questions_md: &'static str,
pub readme_md: &'static str,
pub references: &'static [(&'static str, &'static str)],
pub agent_prompt: &'static str,
pub yolo_prompt: fn(Option<&str>) -> String,
pub stdout_markdown: &'static str,
}
fn all() -> &'static [&'static Wish] {
const ALL: &[&Wish] = &[&deck::DECK, &brief::BRIEF];
ALL
}
fn find(name: &str) -> Option<&'static Wish> {
all().iter().copied().find(|w| w.name == name)
}
pub fn run(
name: &str,
out: Option<PathBuf>,
agent: Option<Agent>,
stdout_mode: bool,
dry_run: bool,
yolo: Option<String>,
) -> Result<()> {
if name == "list" {
return list();
}
let wish =
find(name).ok_or_else(|| anyhow!("unknown wish '{}'. Try `kazam wish list`", name))?;
if stdout_mode {
print!("{}", wish.stdout_markdown);
return Ok(());
}
ensure_site_config()?;
if let Some(topic) = yolo {
let topic = topic.trim();
let topic = if topic.is_empty() { None } else { Some(topic) };
return grant_yolo(wish, topic, out, agent, dry_run);
}
let workspace = PathBuf::from(format!("wish-{}", wish.name));
if !workspace.exists() {
scaffold(wish, &workspace)?;
return Ok(());
}
grant(wish, &workspace, out, agent, dry_run)
}
fn ensure_site_config() -> Result<()> {
let cfg = Path::new("kazam.yaml");
if cfg.exists() {
return Ok(());
}
let name = std::env::current_dir()
.ok()
.as_deref()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str())
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.unwrap_or_else(|| "Site".to_string());
fs::write(cfg, format!("name: {}\ntheme: dark\n", name)).context("writing kazam.yaml")?;
println!(" (wrote a minimal kazam.yaml — site config for `kazam dev` / `kazam build`)");
Ok(())
}
fn scaffold(wish: &Wish, workspace: &Path) -> Result<()> {
fs::create_dir_all(workspace)
.with_context(|| format!("creating workspace {}", workspace.display()))?;
fs::write(workspace.join("questions.md"), wish.questions_md)?;
fs::write(workspace.join("README.md"), wish.readme_md)?;
if !wish.references.is_empty() {
let ref_dir = workspace.join("reference");
fs::create_dir_all(&ref_dir).with_context(|| format!("creating {}", ref_dir.display()))?;
for (name, contents) in wish.references {
fs::write(ref_dir.join(name), contents)
.with_context(|| format!("writing reference/{}", name))?;
}
}
println!();
println!(" ✨ Making your {} wish — {}", wish.name, wish.description);
println!();
println!(" Created workspace: {}/", workspace.display());
println!(" questions.md structured prompts — fill in what you know");
println!(" README.md what to drop in this folder");
if !wish.references.is_empty() {
println!(" reference/ kazam schema + worked example (read-only to the agent)");
}
println!();
println!(" Next:");
println!(
" 1. Fill in {}/questions.md with whatever you want to",
workspace.display()
);
println!(" answer up front (blanks are fine — the agent will infer).");
println!(" 2. Drop any real context (docs, notes, transcripts, last");
println!(" quarter's deck) into {}/.", workspace.display());
println!(" 3. Run `kazam wish {}` again to grant it.", wish.name);
println!();
Ok(())
}
fn grant(
wish: &Wish,
workspace: &Path,
out: Option<PathBuf>,
agent: Option<Agent>,
dry_run: bool,
) -> Result<()> {
if dry_run {
print!("{}", wish.agent_prompt);
return Ok(());
}
let out_path = out.unwrap_or_else(|| PathBuf::from(wish.default_out));
if out_path.exists() {
bail!(
"'{}' already exists — pass `--out <path>` to write elsewhere",
out_path.display()
);
}
let agent = agent.or_else(detect_agent).ok_or_else(no_agent_error)?;
println!();
println!(
" ✨ Granting your {} wish via {}",
wish.name,
agent.label()
);
println!();
println!(
" Running {} in {} (can take 30-90s)…",
agent.bin(),
workspace.display()
);
println!();
let yaml = run_agent(agent, wish.agent_prompt, workspace)?;
fs::write(&out_path, &yaml).with_context(|| format!("writing {}", out_path.display()))?;
let page_url = out_path
.file_stem()
.and_then(|s| s.to_str())
.map(|stem| format!("http://localhost:3000/{}.html", stem))
.unwrap_or_else(|| "http://localhost:3000/".to_string());
println!();
println!(" ✓ Wrote {}", out_path.display());
println!();
println!(" Next:");
println!(" kazam dev .");
println!(" → open {}", page_url);
println!();
Ok(())
}
fn grant_yolo(
wish: &Wish,
topic: Option<&str>,
out: Option<PathBuf>,
agent: Option<Agent>,
dry_run: bool,
) -> Result<()> {
let prompt = (wish.yolo_prompt)(topic);
if dry_run {
print!("{}", prompt);
return Ok(());
}
let out_path = out.unwrap_or_else(|| PathBuf::from(wish.default_out));
if out_path.exists() {
bail!(
"'{}' already exists — pass `--out <path>` to write elsewhere",
out_path.display()
);
}
let agent = agent.or_else(detect_agent).ok_or_else(no_agent_error)?;
let cwd = std::env::current_dir().context("reading current directory")?;
println!();
match topic {
Some(t) => println!(
" 🎲 Granting a YOLO {} wish via {} — topic: {}",
wish.name,
agent.label(),
t
),
None => println!(
" 🎲 Granting a YOLO {} wish via {} — topic: surprise me",
wish.name,
agent.label()
),
}
println!();
println!(" Running {} (can take 30-90s)…", agent.bin());
println!();
let yaml = run_agent(agent, &prompt, &cwd)?;
fs::write(&out_path, &yaml).with_context(|| format!("writing {}", out_path.display()))?;
let page_url = out_path
.file_stem()
.and_then(|s| s.to_str())
.map(|stem| format!("http://localhost:3000/{}.html", stem))
.unwrap_or_else(|| "http://localhost:3000/".to_string());
println!();
println!(" ✓ Wrote {}", out_path.display());
println!();
println!(" Next:");
println!(" kazam dev .");
println!(" → open {}", page_url);
println!();
Ok(())
}
fn detect_agent() -> Option<Agent> {
Agent::AUTO_DETECT_ORDER
.iter()
.copied()
.find(|a| cmd_on_path(a.bin()))
}
fn no_agent_error() -> anyhow::Error {
anyhow!(
"no agent CLI found on $PATH.
kazam wish shells out to your agent to write the YAML. Install any one of:
Claude Code https://docs.claude.com/en/docs/claude-code/install
Gemini CLI https://github.com/google-gemini/gemini-cli
Codex https://developers.openai.com/codex/cli
OpenCode https://opencode.ai
Then re-run this command. Or for a no-CLI-agent path, pass --dry-run
to print the prompt and paste it into ChatGPT, Claude.ai, Gemini web,
or any other LLM context window — save what comes back as deck.yaml."
)
}
fn cmd_on_path(name: &str) -> bool {
let path = match std::env::var_os("PATH") {
Some(p) => p,
None => return false,
};
for dir in std::env::split_paths(&path) {
if dir.join(name).is_file() {
return true;
}
}
false
}
fn run_agent(agent: Agent, prompt: &str, cwd: &Path) -> Result<String> {
let mut child = agent
.command(prompt)
.current_dir(cwd)
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.spawn()
.with_context(|| {
format!(
"could not start `{}` — is it installed and on your $PATH?",
agent.bin()
)
})?;
let mut stdout = child.stdout.take().expect("captured stdout");
let mut raw = String::new();
stdout.read_to_string(&mut raw)?;
let status = child.wait()?;
if !status.success() {
bail!("{} exited with {}", agent.label(), status);
}
Ok(strip_code_fence(&raw))
}
fn strip_code_fence(s: &str) -> String {
let trimmed = s.trim();
if let Some(rest) = trimmed.strip_prefix("```") {
let after_lang = rest.find('\n').map(|i| &rest[i + 1..]).unwrap_or(rest);
if let Some(body) = after_lang.rsplit_once("```") {
return body.0.trim_end().to_string() + "\n";
}
}
if !trimmed.ends_with('\n') {
format!("{}\n", trimmed)
} else {
trimmed.to_string()
}
}
fn list() -> Result<()> {
println!();
println!(" Available wishes:");
println!();
for w in all() {
println!(" kazam wish {:<10} {}", w.name, w.description);
}
println!();
println!(" Flags:");
println!(" --agent <claude|gemini|codex|opencode> Force a specific agent");
println!(" --yolo [topic] Skip workspace, let the agent invent everything");
println!(" --dry-run Print the prompt instead of running the agent");
println!(" --stdout Print the portable wish markdown");
println!(" --out <path> Where to write the populated YAML");
println!();
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strip_code_fence_removes_yaml_wrapper() {
let raw = "```yaml\ntitle: hi\nshell: deck\n```\n";
let s = strip_code_fence(raw);
assert!(s.starts_with("title: hi"));
assert!(!s.contains("```"));
}
#[test]
fn strip_code_fence_passes_through_plain_yaml() {
let raw = "title: hi\nshell: deck\n";
let s = strip_code_fence(raw);
assert!(s.starts_with("title: hi"));
}
#[test]
fn all_wishes_have_unique_names() {
let mut names: Vec<&str> = all().iter().map(|w| w.name).collect();
names.sort();
let before = names.len();
names.dedup();
assert_eq!(before, names.len(), "duplicate wish names");
}
}