use anyhow::Result;
use serde_json::json;
use std::process::Command;
const ROK_RULES: &str = r#"You are implementing features for the rok Rust web framework.
rok is built on Axum + SQLx targeting Laravel/AdonisJS-grade developer experience.
## Coding Rules
- No extra abstractions beyond what the task requires
- No comments unless the WHY is non-obvious
- Use anyhow::Result for all fallible functions
- Follow existing file structure: crates/<crate>/src/
- cargo check -p <crate> must pass after each file
## CLI Cheatsheet (JSON-native)
rok make:controller <Name> [--json] → controller stub
rok make:model <Name> [--json] → model + migration
rok make:scaffold <template> [--json] → feature scaffold
rok make:feature --json-file spec.json → full feature from spec
rok plan:next [--json] → next incomplete phase
rok plan:list [--json] → all phases + status
rok plan:status --phase N [--json] → single phase status
rok db:migrate [--json] → run migrations
rok db:status [--json] → migration status
## Commit Format
feat(phase-N): <description>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
"#;
pub fn rules(json: bool) -> Result<()> {
if json {
let obj = json!({ "status": "ok", "rules": ROK_RULES });
println!("{}", serde_json::to_string_pretty(&obj)?);
} else {
println!("{ROK_RULES}");
}
Ok(())
}
pub fn context(phase: Option<u32>, json: bool) -> Result<()> {
let parts: Vec<(String, String)> = if let Some(n) = phase {
let prefix = format!("feat/phase-{n}-*");
let dir = glob::glob(&prefix)
.ok()
.and_then(|mut g| g.next())
.and_then(|r| r.ok())
.map(|p| p.display().to_string())
.unwrap_or_else(|| format!("feat/phase-{n}"));
vec![
(
format!("{dir}/feature.md"),
std::fs::read_to_string(format!("{dir}/feature.md")).unwrap_or_default(),
),
(
format!("{dir}/PRD.json"),
std::fs::read_to_string(format!("{dir}/PRD.json")).unwrap_or_default(),
),
(
format!("{dir}/prompt.md"),
std::fs::read_to_string(format!("{dir}/prompt.md")).unwrap_or_default(),
),
(
format!("{dir}/progress.txt"),
std::fs::read_to_string(format!("{dir}/progress.txt")).unwrap_or_default(),
),
]
} else {
vec![(
"router.md".into(),
std::fs::read_to_string("router.md").unwrap_or_default(),
)]
};
if json {
let files: Vec<serde_json::Value> = parts
.iter()
.map(|(path, content)| json!({ "path": path, "content": content }))
.collect();
println!(
"{}",
serde_json::to_string_pretty(&json!({ "status": "ok", "files": files }))?
);
} else {
for (path, content) in &parts {
println!("=== {path} ===\n{content}\n");
}
}
Ok(())
}
pub fn feedback(progress: &str, prd_item: Option<usize>, json: bool) -> Result<()> {
let progress_path = "progress.txt";
if std::path::Path::new(progress_path).exists() {
let mut existing = std::fs::read_to_string(progress_path)?;
existing.push('\n');
existing.push_str(progress);
std::fs::write(progress_path, &existing)?;
}
if json {
println!(
"{}",
serde_json::to_string_pretty(&json!({
"status": "ok",
"appended": progress,
"prd_item_updated": prd_item,
}))?
);
} else {
println!("Feedback recorded.");
if let Some(item) = prd_item {
println!("PRD item {} marked as updated.", item);
}
}
Ok(())
}
fn detect_agent(name: &str) -> Option<String> {
let cmd = if cfg!(target_os = "windows") {
"where"
} else {
"which"
};
let out = Command::new(cmd).arg(name).output().ok()?;
if out.status.success() {
let path = String::from_utf8_lossy(&out.stdout).trim().to_string();
if !path.is_empty() {
Some(path)
} else {
None
}
} else {
None
}
}
pub fn launch_claude(phase: Option<u32>, extra_prompt: Option<&str>) -> Result<()> {
let agent = detect_agent("claude").ok_or_else(|| {
anyhow::anyhow!("claude not found in PATH — install from https://claude.ai/download")
})?;
let mut system = ROK_RULES.to_string();
if let Some(n) = phase {
let ctx = load_phase_context(n);
system.push_str("\n\n## Phase Context\n");
system.push_str(&ctx);
}
if let Some(extra) = extra_prompt {
system.push_str("\n\n## Additional Instructions\n");
system.push_str(extra);
}
let status = Command::new(&agent).args(["-p", &system]).status()?;
if !status.success() {
anyhow::bail!("claude exited with status {status}");
}
Ok(())
}
pub fn launch_opencode(phase: Option<u32>, extra_prompt: Option<&str>) -> Result<()> {
let agent =
detect_agent("opencode").ok_or_else(|| anyhow::anyhow!("opencode not found in PATH"))?;
let mut system = ROK_RULES.to_string();
if let Some(n) = phase {
system.push_str(&format!(
"\n\nImplement Phase {n}. See feat/phase-{n}-*/prompt.md"
));
}
if let Some(extra) = extra_prompt {
system.push_str("\n\n");
system.push_str(extra);
}
Command::new(&agent).arg(&system).status()?;
Ok(())
}
fn load_phase_context(n: u32) -> String {
let prefix = format!("feat/phase-{n}-");
let dir = std::fs::read_dir("feat")
.ok()
.and_then(|mut entries| {
entries.find_map(|e| {
let e = e.ok()?;
let name = e.file_name().to_string_lossy().to_string();
if name.starts_with(&prefix) {
Some(e.path().display().to_string())
} else {
None
}
})
})
.unwrap_or_else(|| format!("feat/phase-{n}"));
let mut ctx = String::new();
for file in &["feature.md", "PRD.json", "prompt.md", "progress.txt"] {
let path = format!("{dir}/{file}");
if let Ok(content) = std::fs::read_to_string(&path) {
ctx.push_str(&format!("### {file}\n{content}\n\n"));
}
}
ctx
}