use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::time::Duration;
use kodact_api::MockTurn;
use kodact_test_host::{command, kodact_bin, MockApi};
use serde_json::json;
fn repo_file(rel: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../..").join(rel)
}
fn code() -> Vec<String> {
let doc = std::fs::read_to_string(repo_file("docs/CHECKLIST.md")).unwrap();
let mut out = vec![];
let mut prose = String::new();
let mut fenced = false;
for line in doc.lines() {
if line.trim_start().starts_with("```") {
fenced = !fenced;
continue;
}
if fenced {
out.push(line.trim().to_string());
} else {
prose.push_str(line.trim());
prose.push(' ');
}
}
out.extend(prose.split('`').skip(1).step_by(2).map(str::to_string));
out
}
const OPERATORS: &[&str] = &["|", "||", "&&", ";", ">", ">>", "2>", "<", "{", "}"];
fn kodact_commands() -> Vec<Vec<String>> {
let mut out = vec![];
for c in code() {
let Some(words) = shlex::split(&c) else { continue };
for seg in words.split(|w| OPERATORS.contains(&w.as_str())) {
let seg: Vec<&String> = seg.iter().skip_while(|w| w.contains('=') && !w.starts_with('-')).collect();
if seg.first().map(|w| w.as_str()) == Some("kodact") {
out.push(seg[1..].iter().map(|s| s.to_string()).collect());
}
}
}
out
}
#[tokio::test]
async fn every_forge_command_in_the_checklist_parses() {
let cmds = kodact_commands();
assert!(cmds.len() >= 20, "found only {} commands: {cmds:?}", cmds.len());
let home = tempfile::tempdir().unwrap();
for args in cmds {
let mut all: Vec<&str> = args.iter().map(String::as_str).collect();
all.push("--help");
let mut c = command(&kodact_bin(), home.path(), home.path(), "http://127.0.0.1:9", &all);
c.stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::piped());
let out = tokio::time::timeout(Duration::from_secs(30), c.output()).await.unwrap().unwrap();
assert!(
out.status.success(),
"`kodact {}` doesn't parse:\n{}",
args.join(" "),
String::from_utf8_lossy(&out.stderr)
);
}
}
#[test]
fn every_slash_command_in_the_checklist_exists() {
let skills: Vec<String> = kodact_agents::skills::bundled_skills(&mut vec![]).into_iter().map(|s| s.name).collect();
let mut seen = vec![];
for c in code() {
for word in c.split(|ch: char| ch.is_whitespace() || ch == '"' || ch == '\'') {
let Some(name) = word.strip_prefix('/') else { continue };
if name.is_empty() || name.contains(['/', '.']) || !name.starts_with(|c: char| c.is_ascii_lowercase()) {
continue;
}
let name = name.trim_end_matches([',', ')', ':']);
assert!(
kodact_core::commands::lookup(name).is_some() || skills.iter().any(|s| s == name),
"/{name} in docs/CHECKLIST.md is neither a built-in nor a bundled skill"
);
seen.push(name.to_string());
}
}
for want in ["goal", "usage", "btw", "diff", "context", "hooks", "agents", "keybindings", "code-review"] {
assert!(seen.iter().any(|s| s == want), "the checklist covers /{want}");
}
}
fn setup() -> (tempfile::TempDir, PathBuf, PathBuf) {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().canonicalize().unwrap();
let (repo, home) = (root.join("kodact-check"), root.join("home"));
std::fs::create_dir_all(&home).unwrap();
let out = std::process::Command::new("sh").arg(repo_file("scripts/check-setup.sh")).arg(&repo).output().unwrap();
assert!(out.status.success(), "check-setup.sh: {}", String::from_utf8_lossy(&out.stderr));
let again = std::process::Command::new("sh").arg(repo_file("scripts/check-setup.sh")).arg(&repo).output().unwrap();
assert_eq!(again.status.code(), Some(1));
let forced = std::process::Command::new("sh")
.arg(repo_file("scripts/check-setup.sh"))
.arg(&repo)
.arg("--force")
.output()
.unwrap();
assert!(forced.status.success());
(dir, repo, home)
}
async fn kodact(repo: &Path, home: &Path, api: &str, args: &[&str]) -> (i32, String, String) {
let mut c = command(&kodact_bin(), repo, home, api, args);
c.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
let out = tokio::time::timeout(Duration::from_secs(60), c.output()).await.unwrap().unwrap();
(
out.status.code().unwrap_or(-1),
String::from_utf8_lossy(&out.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),
)
}
#[tokio::test]
async fn print_mode_checks_say_what_forge_prints() {
let (_dir, repo, home) = setup();
for f in ["calc.py", "test_calc.py", "notes.md", "secret.txt", ".kodact/settings.json", "other-repo/main.py"] {
assert!(repo.join(f).exists(), "the setup makes {f}");
}
assert!(std::fs::read_to_string(repo.join("calc.py")).unwrap().contains("a - b"), "the bug is in the working tree");
let api = MockApi::start(vec![MockTurn::text("Monday.")]).await;
let (code, out, err) = kodact(&repo, &home, &api.url, &["-p", "what does @notes.md say about the CLI?"]).await;
assert_eq!((code, out.trim()), (0, "Monday."), "{err}");
assert!(api.requests()[0]["messages"].to_string().contains("the CLI on Monday"));
let api = MockApi::start(vec![MockTurn::text("I can't read it.")]).await;
let (code, _, err) =
kodact(&repo, &home, &api.url, &["-p", "what does @secret.txt say?", "--disallowedTools", "Read(secret.txt)"])
.await;
assert_eq!(code, 0, "{err}");
let sent = api.requests()[0]["messages"].to_string();
assert!(!sent.contains("4417"), "{sent}");
assert!(sent.contains("secret.txt was not attached: blocked by a permission rule; use Read"), "{sent}");
let (code, out, _) = kodact(&repo, &home, &api.url, &["-p", "/status"]).await;
assert_eq!(code, 0);
assert!(out.contains(&format!("Directory: {}", repo.display())), "{out}");
assert!(out.contains("Permissions: default mode"), "{out}");
let log = home.join("kodact-hook.log");
let add = format!("/hooks add PreToolUse Bash echo hook-ran >> {}", log.display());
let (code, out, err) = kodact(&repo, &home, &api.url, &["-p", &add]).await;
assert_eq!(code, 0, "{err}");
assert!(out.contains("Added a PreToolUse hook for Bash"), "{out}");
assert!(std::fs::read_to_string(repo.join(".kodact/settings.local.json")).unwrap().contains("hook-ran"));
let api = MockApi::start(vec![MockTurn::tool("Bash", json!({"command": "ls"})), MockTurn::text("Listed.")]).await;
let (code, _, err) =
kodact(&repo, &home, &api.url, &["-p", "--permission-mode", "acceptEdits", "run ls with Bash"]).await;
assert_eq!(code, 0, "{err}");
assert!(std::fs::read_to_string(&log).unwrap_or_default().contains("hook-ran"), "the hook ran");
let (_, out, _) = kodact(&repo, &home, &api.url, &["-p", "/hooks"]).await;
assert!(out.contains("1. [Bash] echo hook-ran") && out.contains("local)"), "{out}");
let (code, out, _) = kodact(&repo, &home, &api.url, &["-p", "/hooks remove PreToolUse 1"]).await;
assert_eq!(code, 0, "{out}");
assert!(!std::fs::read_to_string(repo.join(".kodact/settings.local.json")).unwrap().contains("hook-ran"));
let (code, out, err) = kodact(
&repo,
&home,
&api.url,
&["-p", "/agents create test-writer --description 'Writes unit tests' --tools Read,Write,Bash --model sonnet"],
)
.await;
assert_eq!(code, 0, "{out}{err}");
let def = std::fs::read_to_string(repo.join(".kodact/agents/test-writer.md")).unwrap();
assert!(
def.contains("name: test-writer") && def.contains("tools: Read, Write, Bash") && def.contains("model: sonnet")
);
}